first commit: one-api base code + SAAS plan document
47
web/README.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# One API 的前端界面
|
||||
|
||||
> 每个文件夹代表一个主题,欢迎提交你的主题
|
||||
|
||||
> [!WARNING]
|
||||
> 不是每一个主题都及时同步了所有功能,由于精力有限,优先更新默认主题,其他主题欢迎 & 期待 PR
|
||||
|
||||
## 提交新的主题
|
||||
|
||||
> 欢迎在页面底部保留你和 One API 的版权信息以及指向链接
|
||||
|
||||
1. 在 `web` 文件夹下新建一个文件夹,文件夹名为主题名。
|
||||
2. 把你的主题文件放到这个文件夹下。
|
||||
3. 修改你的 `package.json` 文件,把 `build` 命令改为:`"build": "react-scripts build && mv -f build ../build/default"`,其中 `default` 为你的主题名。
|
||||
4. 修改 `common/config/config.go` 中的 `ValidThemes`,把你的主题名称注册进去。
|
||||
5. 修改 `web/THEMES` 文件,这里也需要同步修改。
|
||||
|
||||
## 主题列表
|
||||
|
||||
### 主题:default
|
||||
|
||||
默认主题,由 [JustSong](https://github.com/songquanpeng) 开发。
|
||||
|
||||
预览:
|
||||
|||
|
||||
|:---:|:---:|
|
||||
|
||||
### 主题:berry
|
||||
|
||||
由 [MartialBE](https://github.com/MartialBE) 开发。
|
||||
|
||||
预览:
|
||||
|||
|
||||
|:---:|:---:|
|
||||
|||
|
||||
|||
|
||||
|||
|
||||
|
||||
### 主题:air
|
||||
由 [Calon](https://github.com/Calcium-Ion) 开发。
|
||||
|||
|
||||
|:---:|:---:|
|
||||
|
||||
|
||||
#### 开发说明
|
||||
|
||||
请查看 [web/berry/README.md](https://github.com/songquanpeng/one-api/tree/main/web/berry/README.md)
|
||||
3
web/THEMES
Normal file
@@ -0,0 +1,3 @@
|
||||
default
|
||||
berry
|
||||
air
|
||||
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
@@ -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
@@ -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
|
After Width: | Height: | Size: 4.2 KiB |
18
web/air/public/index.html
Normal file
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="logo.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
<meta
|
||||
name="description"
|
||||
content="OpenAI 接口聚合管理,支持多种渠道包括 Azure,可用于二次分发管理 key,仅单可执行文件,已打包好 Docker 镜像,一键部署,开箱即用"
|
||||
/>
|
||||
<title>One API</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
BIN
web/air/public/logo.png
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
403
web/air/src/components/LogsTable.js
Normal file
@@ -0,0 +1,403 @@
|
||||
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>;
|
||||
case 5:
|
||||
return <Tag color="violet" 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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/available_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>
|
||||
<p>可用模型(可点击复制)</p>
|
||||
<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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
636
web/air/src/components/TokensTable.js
Normal file
@@ -0,0 +1,636 @@
|
||||
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' },
|
||||
{ key: 'lobechat', text: 'LobeChat', value: 'lobechat' },
|
||||
];
|
||||
|
||||
const OPEN_LINK_OPTIONS = [
|
||||
{ key: 'ama', text: 'ChatGPT Web & Midjourney', value: 'ama' },
|
||||
{ key: 'opencat', text: 'OpenCat', value: 'opencat' },
|
||||
{ key: 'lobechat', text: 'LobeChat', value: 'lobechat' }
|
||||
];
|
||||
|
||||
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' },
|
||||
{
|
||||
node: 'item', key: 'lobechat', name: 'LobeChat', onClick: () => {
|
||||
onOpenLink('lobechat');
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
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);
|
||||
}
|
||||
},
|
||||
{
|
||||
node: 'item', key: 'lobechat', name: 'LobeChat', onClick: () => {
|
||||
onOpenLink('lobechat');
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
>
|
||||
<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 [orderBy, setOrderBy] = useState('');
|
||||
const [dropdownVisible, setDropdownVisible] = useState(false);
|
||||
|
||||
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}&order=${orderBy}`);
|
||||
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, orderBy);
|
||||
}
|
||||
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://app.nextchat.dev/#/?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;
|
||||
case 'lobechat':
|
||||
url = chatLink + `/?settings={"keyVaults":{"openai":{"apiKey":"sk-${key}","baseURL":"${serverAddress}/v1"}}}`;
|
||||
break;
|
||||
default:
|
||||
if (!chatLink) {
|
||||
showError('管理员未设置聊天链接');
|
||||
return;
|
||||
}
|
||||
url = defaultUrl;
|
||||
}
|
||||
|
||||
window.open(url, '_blank');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadTokens(0, orderBy)
|
||||
.then()
|
||||
.catch((reason) => {
|
||||
showError(reason);
|
||||
});
|
||||
}, [pageSize, orderBy]);
|
||||
|
||||
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);
|
||||
setOrderBy('');
|
||||
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 {};
|
||||
}
|
||||
};
|
||||
|
||||
const handleOrderByChange = (e, { value }) => {
|
||||
setOrderBy(value);
|
||||
setActivePage(1);
|
||||
setDropdownVisible(false);
|
||||
};
|
||||
|
||||
const renderSelectedOption = (orderBy) => {
|
||||
switch (orderBy) {
|
||||
case 'remain_quota':
|
||||
return '按剩余额度排序';
|
||||
case 'used_quota':
|
||||
return '按已用额度排序';
|
||||
default:
|
||||
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>
|
||||
<Dropdown
|
||||
trigger="click"
|
||||
position="bottomLeft"
|
||||
visible={dropdownVisible}
|
||||
onVisibleChange={(visible) => setDropdownVisible(visible)}
|
||||
render={
|
||||
<Dropdown.Menu>
|
||||
<Dropdown.Item onClick={() => handleOrderByChange('', { value: '' })}>默认排序</Dropdown.Item>
|
||||
<Dropdown.Item onClick={() => handleOrderByChange('', { value: 'remain_quota' })}>按剩余额度排序</Dropdown.Item>
|
||||
<Dropdown.Item onClick={() => handleOrderByChange('', { value: 'used_quota' })}>按已用额度排序</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
}
|
||||
>
|
||||
<Button style={{ marginLeft: '10px' }}>{renderSelectedOption(orderBy)}</Button>
|
||||
</Dropdown>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TokensTable;
|
||||
376
web/air/src/components/UsersTable.js
Normal file
@@ -0,0 +1,376 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { API, showError, showSuccess } from '../helpers';
|
||||
import { Button, Form, Popconfirm, Space, Table, Tag, Tooltip, Dropdown } 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 [orderBy, setOrderBy] = useState('');
|
||||
const [dropdownVisible, setDropdownVisible] = useState(false);
|
||||
|
||||
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}&order=${orderBy}`);
|
||||
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, orderBy);
|
||||
}
|
||||
setActivePage(activePage);
|
||||
})();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers(0, orderBy)
|
||||
.then()
|
||||
.catch((reason) => {
|
||||
showError(reason);
|
||||
});
|
||||
}, [orderBy]);
|
||||
|
||||
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);
|
||||
setOrderBy('');
|
||||
return;
|
||||
}
|
||||
setSearching(true);
|
||||
const res = await API.get(`/api/user/search?keyword=${searchKeyword}`);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
setUsers(data);
|
||||
setActivePage(1);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
setSearching(false);
|
||||
};
|
||||
|
||||
const handleKeywordChange = async (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();
|
||||
}
|
||||
};
|
||||
|
||||
const handleOrderByChange = (e, { value }) => {
|
||||
setOrderBy(value);
|
||||
setActivePage(1);
|
||||
setDropdownVisible(false);
|
||||
};
|
||||
|
||||
const renderSelectedOption = (orderBy) => {
|
||||
switch (orderBy) {
|
||||
case 'quota':
|
||||
return '按剩余额度排序';
|
||||
case 'used_quota':
|
||||
return '按已用额度排序';
|
||||
case 'request_count':
|
||||
return '按请求次数排序';
|
||||
default:
|
||||
return '默认排序';
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
<Dropdown
|
||||
trigger="click"
|
||||
position="bottomLeft"
|
||||
visible={dropdownVisible}
|
||||
onVisibleChange={(visible) => setDropdownVisible(visible)}
|
||||
render={
|
||||
<Dropdown.Menu>
|
||||
<Dropdown.Item onClick={() => handleOrderByChange('', { value: '' })}>默认排序</Dropdown.Item>
|
||||
<Dropdown.Item onClick={() => handleOrderByChange('', { value: 'quota' })}>按剩余额度排序</Dropdown.Item>
|
||||
<Dropdown.Item onClick={() => handleOrderByChange('', { value: 'used_quota' })}>按已用额度排序</Dropdown.Item>
|
||||
<Dropdown.Item onClick={() => handleOrderByChange('', { value: 'request_count' })}>按请求次数排序</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
}
|
||||
>
|
||||
<Button style={{ marginLeft: '10px' }}>{renderSelectedOption(orderBy)}</Button>
|
||||
</Dropdown>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default UsersTable;
|
||||
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
@@ -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`
|
||||
);
|
||||
}
|
||||
52
web/air/src/constants/channel.constants.js
Normal file
@@ -0,0 +1,52 @@
|
||||
export const CHANNEL_OPTIONS = [
|
||||
{ key: 1, text: 'OpenAI', value: 1, color: 'green' },
|
||||
{ key: 14, text: 'Anthropic Claude', value: 14, color: 'black' },
|
||||
{ key: 33, text: 'AWS', value: 33, 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: 41, text: 'Novita', value: 41, color: 'purple' },
|
||||
{key: 40, text: '字节火山引擎', value: 40, color: 'blue'},
|
||||
{ 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: 32, text: '阶跃星辰', value: 32, color: 'blue' },
|
||||
{ key: 34, text: 'Coze', value: 34, color: 'blue' },
|
||||
{ key: 35, text: 'Cohere', value: 35, color: 'blue' },
|
||||
{ key: 36, text: 'DeepSeek', value: 36, color: 'black' },
|
||||
{ key: 37, text: 'Cloudflare', value: 37, color: 'orange' },
|
||||
{ key: 38, text: 'DeepL', value: 38, color: 'black' },
|
||||
{ key: 39, text: 'together.ai', value: 39, color: 'blue' },
|
||||
{ key: 42, text: 'VertexAI', value: 42, color: 'blue' },
|
||||
{ key: 43, text: 'Proxy', value: 43, color: 'blue' },
|
||||
{ key: 44, text: 'SiliconFlow', value: 44, color: 'blue' },
|
||||
{ key: 45, text: 'xAI', value: 45, color: 'blue' },
|
||||
{ key: 46, text: 'Replicate', value: 46, color: 'blue' },
|
||||
{ key: 8, text: '自定义渠道', value: 8, color: 'pink' },
|
||||
{ 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,3 @@
|
||||
import { createBrowserHistory } from 'history';
|
||||
|
||||
export const history = createBrowserHistory();
|
||||
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
@@ -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
@@ -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 'One API';
|
||||
return system_name;
|
||||
}
|
||||
|
||||
export function getLogo() {
|
||||
let logo = localStorage.getItem('logo');
|
||||
if (!logo) return '/logo.png';
|
||||
return logo
|
||||
}
|
||||
|
||||
export function getFooterHTML() {
|
||||
return localStorage.getItem('footer_html');
|
||||
}
|
||||
|
||||
export async function copy(text) {
|
||||
let okay = true;
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch (e) {
|
||||
okay = false;
|
||||
console.error(e);
|
||||
}
|
||||
return okay;
|
||||
}
|
||||
|
||||
export function isMobile() {
|
||||
return window.innerWidth <= 600;
|
||||
}
|
||||
|
||||
let showErrorOptions = { autoClose: toastConstants.ERROR_TIMEOUT };
|
||||
let showWarningOptions = { autoClose: toastConstants.WARNING_TIMEOUT };
|
||||
let showSuccessOptions = { autoClose: toastConstants.SUCCESS_TIMEOUT };
|
||||
let showInfoOptions = { autoClose: toastConstants.INFO_TIMEOUT };
|
||||
let showNoticeOptions = { autoClose: false };
|
||||
|
||||
if (isMobile()) {
|
||||
showErrorOptions.position = 'top-center';
|
||||
// showErrorOptions.transition = 'flip';
|
||||
|
||||
showSuccessOptions.position = 'top-center';
|
||||
// showSuccessOptions.transition = 'flip';
|
||||
|
||||
showInfoOptions.position = 'top-center';
|
||||
// showInfoOptions.transition = 'flip';
|
||||
|
||||
showNoticeOptions.position = 'top-center';
|
||||
// showNoticeOptions.transition = 'flip';
|
||||
}
|
||||
|
||||
export function showError(error) {
|
||||
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');
|
||||
}
|
||||
116
web/air/src/index.css
Normal file
@@ -0,0 +1,116 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* 隐藏浏览器默认的滚动条 */
|
||||
body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 自定义滚动条样式 */
|
||||
body::-webkit-scrollbar {
|
||||
width: 0; /* 隐藏滚动条的宽度 */
|
||||
}
|
||||
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
@@ -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;
|
||||
642
web/air/src/pages/Channel/EditChannel.js
Normal file
@@ -0,0 +1,642 @@
|
||||
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: '',
|
||||
system_prompt: '',
|
||||
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", "claude-3-5-haiku-20241022", "claude-3-5-sonnet-20240620", "claude-3-5-sonnet-20241022"];
|
||||
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.1-128K', 'SparkDesk-v3.5', 'SparkDesk-v3.5-32K', 'SparkDesk-v4.0'];
|
||||
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 = '2024-03-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 版本,例如:2024-03-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'
|
||||
/>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<Typography.Text strong>系统提示词:</Typography.Text>
|
||||
</div>
|
||||
<TextArea
|
||||
placeholder={`此项可选,用于强制设置给定的系统提示词,请配合自定义模型 & 模型重定向使用,首先创建一个唯一的自定义模型名称并在上面填入,之后将该自定义模型重定向映射到该渠道一个原生支持的模型`}
|
||||
name='system_prompt'
|
||||
onChange={value => {
|
||||
handleInputChange('system_prompt', value)
|
||||
}}
|
||||
autosize
|
||||
value={inputs.system_prompt}
|
||||
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"github": {
|
||||
"silent": true
|
||||
}
|
||||
}
|
||||
26
web/berry/.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
|
||||
8
web/berry/.prettierrc
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"bracketSpacing": true,
|
||||
"printWidth": 140,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"tabWidth": 2,
|
||||
"useTabs": false
|
||||
}
|
||||
61
web/berry/README.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# One API 前端界面
|
||||
|
||||
这个项目是 One API 的前端界面,它基于 [Berry Free React Admin Template](https://github.com/codedthemes/berry-free-react-admin-template) 进行开发。
|
||||
|
||||
## 使用的开源项目
|
||||
|
||||
使用了以下开源项目作为我们项目的一部分:
|
||||
|
||||
- [Berry Free React Admin Template](https://github.com/codedthemes/berry-free-react-admin-template)
|
||||
- [minimal-ui-kit](minimal-ui-kit)
|
||||
|
||||
## 开发说明
|
||||
|
||||
当添加新的渠道时,需要修改以下地方:
|
||||
|
||||
1. `web/berry/src/constants/ChannelConstants.js`
|
||||
|
||||
在该文件中的 `CHANNEL_OPTIONS` 添加新的渠道
|
||||
|
||||
```js
|
||||
export const CHANNEL_OPTIONS = {
|
||||
//key 为渠道ID
|
||||
1: {
|
||||
key: 1, // 渠道ID
|
||||
text: "OpenAI", // 渠道名称
|
||||
value: 1, // 渠道ID
|
||||
color: "primary", // 渠道列表显示的颜色
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
2. `web/berry/src/views/Channel/type/Config.js`
|
||||
|
||||
在该文件中的`typeConfig`添加新的渠道配置, 如果无需配置,可以不添加
|
||||
|
||||
```js
|
||||
const typeConfig = {
|
||||
// key 为渠道ID
|
||||
3: {
|
||||
inputLabel: {
|
||||
// 输入框名称 配置
|
||||
// 对应的字段名称
|
||||
base_url: "AZURE_OPENAI_ENDPOINT",
|
||||
other: "默认 API 版本",
|
||||
},
|
||||
prompt: {
|
||||
// 输入框提示 配置
|
||||
// 对应的字段名称
|
||||
base_url: "请填写AZURE_OPENAI_ENDPOINT",
|
||||
|
||||
// 注意:通过判断 `other` 是否有值来判断是否需要显示 `other` 输入框, 默认是没有值的
|
||||
other: "请输入默认API版本,例如:2024-03-01-preview",
|
||||
},
|
||||
modelGroup: "openai", // 模型组名称,这个值是给 填入渠道支持模型 按钮使用的。 填入渠道支持模型 按钮会根据这个值来获取模型组,如果填写默认是 openai
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目中使用的代码遵循 MIT 许可证。
|
||||
9
web/berry/jsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"module": "commonjs",
|
||||
"baseUrl": "src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
84
web/berry/package.json
Normal file
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"name": "one_api_web",
|
||||
"version": "1.0.0",
|
||||
"proxy": "http://127.0.0.1:3000",
|
||||
"private": true,
|
||||
"homepage": "",
|
||||
"dependencies": {
|
||||
"@emotion/cache": "^11.9.3",
|
||||
"@emotion/react": "^11.9.3",
|
||||
"@emotion/styled": "^11.9.3",
|
||||
"@mui/icons-material": "^5.8.4",
|
||||
"@mui/lab": "^5.0.0-alpha.88",
|
||||
"@mui/material": "^5.8.6",
|
||||
"@mui/system": "^5.8.6",
|
||||
"@mui/utils": "^5.8.6",
|
||||
"@mui/x-date-pickers": "^6.18.5",
|
||||
"@tabler/icons-react": "^2.44.0",
|
||||
"apexcharts": "3.35.3",
|
||||
"axios": "^0.27.2",
|
||||
"dayjs": "^1.11.10",
|
||||
"formik": "^2.2.9",
|
||||
"framer-motion": "^6.3.16",
|
||||
"history": "^5.3.0",
|
||||
"marked": "^4.1.1",
|
||||
"material-ui-popup-state": "^4.0.1",
|
||||
"notistack": "^3.0.1",
|
||||
"prop-types": "^15.8.1",
|
||||
"react": "^18.2.0",
|
||||
"react-apexcharts": "1.4.0",
|
||||
"react-device-detect": "^2.2.2",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-perfect-scrollbar": "^1.5.8",
|
||||
"react-redux": "^8.0.2",
|
||||
"react-router": "6.3.0",
|
||||
"react-router-dom": "6.3.0",
|
||||
"react-scripts": "^5.0.1",
|
||||
"react-turnstile": "^1.1.2",
|
||||
"redux": "^4.2.0",
|
||||
"yup": "^0.32.11"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build && mv -f build ../build/berry",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app"
|
||||
]
|
||||
},
|
||||
"babel": {
|
||||
"presets": [
|
||||
"@babel/preset-react"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
"defaults",
|
||||
"not IE 11"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.21.4",
|
||||
"@babel/eslint-parser": "^7.21.3",
|
||||
"eslint": "^8.38.0",
|
||||
"eslint-config-prettier": "^8.8.0",
|
||||
"eslint-config-react-app": "^7.0.1",
|
||||
"eslint-plugin-flowtype": "^8.0.3",
|
||||
"eslint-plugin-import": "^2.27.5",
|
||||
"eslint-plugin-jsx-a11y": "^6.7.1",
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"eslint-plugin-react": "^7.32.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"immutable": "^4.3.0",
|
||||
"prettier": "^2.8.7",
|
||||
"sass": "^1.53.0"
|
||||
}
|
||||
}
|
||||
BIN
web/berry/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
21
web/berry/public/index.html
Normal file
@@ -0,0 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<title>One API</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<!-- Meta Tags-->
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#2296f3" />
|
||||
<meta
|
||||
name="description"
|
||||
content="OpenAI 接口聚合管理,支持多种渠道包括 Azure,可用于二次分发管理 key,仅单可执行文件,已打包好 Docker 镜像,一键部署,开箱即用"
|
||||
/>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
52
web/berry/src/App.js
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
|
||||
import { ThemeProvider } from '@mui/material/styles';
|
||||
import { CssBaseline, StyledEngineProvider } from '@mui/material';
|
||||
import { SET_THEME } from 'store/actions';
|
||||
// routing
|
||||
import Routes from 'routes';
|
||||
|
||||
// defaultTheme
|
||||
import themes from 'themes';
|
||||
|
||||
// project imports
|
||||
import NavigationScroll from 'layout/NavigationScroll';
|
||||
|
||||
// auth
|
||||
import UserProvider from 'contexts/UserContext';
|
||||
import StatusProvider from 'contexts/StatusContext';
|
||||
import { SnackbarProvider } from 'notistack';
|
||||
|
||||
// ==============================|| APP ||============================== //
|
||||
|
||||
const App = () => {
|
||||
const dispatch = useDispatch();
|
||||
const customization = useSelector((state) => state.customization);
|
||||
|
||||
useEffect(() => {
|
||||
const storedTheme = localStorage.getItem('theme');
|
||||
if (storedTheme) {
|
||||
dispatch({ type: SET_THEME, theme: storedTheme });
|
||||
}
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<StyledEngineProvider injectFirst>
|
||||
<ThemeProvider theme={themes(customization)}>
|
||||
<CssBaseline />
|
||||
<NavigationScroll>
|
||||
<SnackbarProvider autoHideDuration={5000} maxSnack={3} anchorOrigin={{ vertical: 'top', horizontal: 'right' }}>
|
||||
<UserProvider>
|
||||
<StatusProvider>
|
||||
<Routes />
|
||||
</StatusProvider>
|
||||
</UserProvider>
|
||||
</SnackbarProvider>
|
||||
</NavigationScroll>
|
||||
</ThemeProvider>
|
||||
</StyledEngineProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
BIN
web/berry/src/assets/fonts/roboto-500.woff2
Normal file
BIN
web/berry/src/assets/fonts/roboto-700.woff2
Normal file
BIN
web/berry/src/assets/fonts/roboto-regular.woff2
Normal file
40
web/berry/src/assets/images/404.svg
Normal file
@@ -0,0 +1,40 @@
|
||||
<svg width="480" height="360" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M0 198.781c0 41.457 14.945 79.235 39.539 107.785 28.214 32.765 69.128 53.365 114.734 53.434a148.458 148.458 0 0056.495-11.036c9.051-3.699 19.182-3.274 27.948 1.107a75.774 75.774 0 0033.957 8.011c5.023 0 9.942-.495 14.7-1.434 13.581-2.67 25.94-8.99 36.089-17.94 6.379-5.627 14.548-8.456 22.898-8.446h.142c27.589 0 53.215-8.732 74.492-23.696 19.021-13.36 34.554-31.696 44.904-53.225C474.92 234.581 480 213.388 480 190.958c0-76.931-59.774-139.305-133.498-139.305-7.516 0-14.88.663-22.063 1.899C305.418 21.42 271.355 0 232.498 0a103.647 103.647 0 00-45.879 10.661c-13.24 6.487-25.011 15.705-34.641 26.939-32.697.544-62.93 11.69-87.675 30.291C25.351 97.155 0 144.882 0 198.781z"
|
||||
fill="url(#prefix__paint0_linear)" opacity=".2" />
|
||||
<g filter="url(#prefix__filter0_d)">
|
||||
<circle opacity=".15" cx="182.109" cy="97.623" r="44.623" fill="#FFC107" />
|
||||
<circle cx="182.109" cy="97.623" r="23.406" fill="url(#prefix__paint1_linear)" />
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M244.878 306.611c34.56 0 62.575-28.016 62.575-62.575 0-34.56-28.015-62.576-62.575-62.576-34.559 0-62.575 28.016-62.575 62.576 0 34.559 28.016 62.575 62.575 62.575zm0-23.186c21.754 0 39.389-17.635 39.389-39.389 0-21.755-17.635-39.39-39.389-39.39s-39.389 17.635-39.389 39.39c0 21.754 17.635 39.389 39.389 39.389z"
|
||||
fill="#061B64" />
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M174.965 264.592c0-4.133-1.492-5.625-5.637-5.625h-11.373v-66.611c0-4.476-1.492-5.637-5.638-5.637h-9.172a9.866 9.866 0 00-7.948 3.974l-55.03 68.274a11.006 11.006 0 00-1.957 6.787v5.968c0 4.145 1.492 5.637 5.625 5.637h54.676v21.707c0 4.133 1.492 5.625 5.625 5.625h8.12c4.146 0 5.638-1.492 5.638-5.625v-21.707h11.434c4.414 0 5.637-1.492 5.637-5.637v-7.13zm-72.42-5.625l35.966-44.415v44.415h-35.966zM411.607 264.592c0-4.133-1.492-5.625-5.638-5.625h-11.422v-66.611c0-4.476-1.492-5.637-5.637-5.637h-9.111a9.87 9.87 0 00-7.949 3.974l-55.03 68.274a11.011 11.011 0 00-1.981 6.787v5.968c0 4.145 1.492 5.637 5.626 5.637h54.687v21.707c0 4.133 1.492 5.625 5.626 5.625h8.12c4.145 0 5.637-1.492 5.637-5.625v-21.707h11.434c4.476 0 5.638-1.492 5.638-5.637v-7.13zm-72.42-5.625l35.965-44.415v44.415h-35.965z"
|
||||
fill="#2065D1" />
|
||||
<path opacity=".24"
|
||||
d="M425.621 117.222a8.267 8.267 0 00-9.599-8.157 11.129 11.129 0 00-9.784-5.87h-.403a13.23 13.23 0 00-20.365-14.078 13.23 13.23 0 00-5.316 14.078h-.403a11.153 11.153 0 100 22.293h38.68v-.073a8.279 8.279 0 007.19-8.193zM104.258 199.045a7.093 7.093 0 00-7.093-7.092c-.381.007-.761.039-1.138.097a9.552 9.552 0 00-8.425-5.026h-.343a11.348 11.348 0 10-22.012 0h-.342a9.564 9.564 0 100 19.114h33.177v-.061a7.107 7.107 0 006.176-7.032z"
|
||||
fill="#2065D1" />
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="prefix__paint0_linear" x1="328.81" y1="424.032" x2="505.393" y2="26.048"
|
||||
gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#2065D1" />
|
||||
<stop offset="1" stop-color="#2065D1" stop-opacity=".01" />
|
||||
</linearGradient>
|
||||
<linearGradient id="prefix__paint1_linear" x1="135.297" y1="97.623" x2="182.109" y2="144.436"
|
||||
gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FFE16A" />
|
||||
<stop offset="1" stop-color="#B78103" />
|
||||
</linearGradient>
|
||||
<filter id="prefix__filter0_d" x="51" y="49" width="394.621" height="277.611" filterUnits="userSpaceOnUse"
|
||||
color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix" />
|
||||
<feColorMatrix in="SourceAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" />
|
||||
<feOffset dx="8" dy="8" />
|
||||
<feGaussianBlur stdDeviation="6" />
|
||||
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.12 0" />
|
||||
<feBlend in2="BackgroundImageFix" result="effect1_dropShadow" />
|
||||
<feBlend in="SourceGraphic" in2="effect1_dropShadow" result="shape" />
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.9 KiB |
65
web/berry/src/assets/images/auth/auth-blue-card.svg
Normal file
|
After Width: | Height: | Size: 16 KiB |
39
web/berry/src/assets/images/auth/auth-pattern-dark.svg
Normal file
@@ -0,0 +1,39 @@
|
||||
<svg width="670" height="903" viewBox="0 0 670 903" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<mask id="mask0" mask-type="alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="670" height="903">
|
||||
<g opacity="0.2">
|
||||
<path d="M0 0H670V903H0V0Z" fill="white"/>
|
||||
</g>
|
||||
</mask>
|
||||
<g mask="url(#mask0)">
|
||||
<path d="M2030.91 374.849L426.331 1300.78" stroke="#8492C4"/>
|
||||
<path d="M426.409 -527.071L2030.72 399.311" stroke="#8492C4"/>
|
||||
<path d="M1919.22 310.39L314.731 1236.47" stroke="#8492C4"/>
|
||||
<path d="M314.731 -462.612L1919.22 463.467" stroke="#8492C4"/>
|
||||
<path d="M1807.54 245.932L203.055 1172.01" stroke="#8492C4"/>
|
||||
<path d="M203.052 -398.154L1807.54 527.925" stroke="#8492C4"/>
|
||||
<path d="M1695.87 181.473L91.3788 1107.55" stroke="#8492C4"/>
|
||||
<path d="M91.3744 -333.695L1695.86 592.384" stroke="#8492C4"/>
|
||||
<path d="M1584.19 117.014L-20.3012 1043.09" stroke="#8492C4"/>
|
||||
<path d="M-20.3044 -269.237L1584.19 656.843" stroke="#8492C4"/>
|
||||
<path d="M1472.51 52.5562L-131.98 978.636" stroke="#8492C4"/>
|
||||
<path d="M-131.983 -204.778L1472.51 721.301" stroke="#8492C4"/>
|
||||
<path d="M1360.83 -11.9023L-243.658 914.177" stroke="#8492C4"/>
|
||||
<path d="M-243.662 -140.319L1360.83 785.76" stroke="#8492C4"/>
|
||||
<path d="M1249.15 -76.3613L-355.336 849.718" stroke="#8492C4"/>
|
||||
<path d="M-355.341 -75.8608L1249.15 850.219" stroke="#8492C4"/>
|
||||
<path d="M1137.48 -140.819L-467.014 785.26" stroke="#8492C4"/>
|
||||
<path d="M-467.017 -11.4023L1137.47 914.677" stroke="#8492C4"/>
|
||||
<path d="M1025.8 -205.278L-578.692 720.801" stroke="#8492C4"/>
|
||||
<path d="M-578.693 53.0562L1025.8 979.136" stroke="#8492C4"/>
|
||||
<path d="M914.119 -269.736L-690.371 656.343" stroke="#8492C4"/>
|
||||
<path d="M-690.379 117.515L914.111 1043.59" stroke="#8492C4"/>
|
||||
<path d="M802.441 -334.195L-802.052 591.887" stroke="#8492C4"/>
|
||||
<path d="M-802.055 181.974L802.435 1108.05" stroke="#8492C4"/>
|
||||
<path d="M690.762 -398.654L-913.728 527.426" stroke="#8492C4"/>
|
||||
<path d="M-913.731 246.432L690.759 1172.51" stroke="#8492C4"/>
|
||||
<path d="M579.084 -463.112L-1025.41 462.967" stroke="#8492C4"/>
|
||||
<path d="M-1025.41 310.891L579.083 1236.97" stroke="#8492C4"/>
|
||||
<path d="M467.406 -527.571L-1136.91 398.811" stroke="#8492C4"/>
|
||||
<path d="M-1137.09 375.35L467.397 1301.43" stroke="#8492C4"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
39
web/berry/src/assets/images/auth/auth-pattern.svg
Normal file
@@ -0,0 +1,39 @@
|
||||
<svg width="670" height="903" viewBox="0 0 670 903" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<mask id="mask0" mask-type="alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="670" height="903">
|
||||
<g opacity="0.2">
|
||||
<path d="M0 0H670V903H0V0Z" fill="white"/>
|
||||
</g>
|
||||
</mask>
|
||||
<g mask="url(#mask0)">
|
||||
<path d="M2030.91 374.849L426.331 1300.78" stroke="rgba(0,0,0,0.30)"/>
|
||||
<path d="M426.409 -527.071L2030.72 399.311" stroke="rgba(0,0,0,0.30)"/>
|
||||
<path d="M1919.22 310.39L314.731 1236.47" stroke="rgba(0,0,0,0.30)"/>
|
||||
<path d="M314.731 -462.612L1919.22 463.467" stroke="rgba(0,0,0,0.30)"/>
|
||||
<path d="M1807.54 245.932L203.055 1172.01" stroke="rgba(0,0,0,0.30)"/>
|
||||
<path d="M203.052 -398.154L1807.54 527.925" stroke="rgba(0,0,0,0.30)"/>
|
||||
<path d="M1695.87 181.473L91.3788 1107.55" stroke="rgba(0,0,0,0.30)"/>
|
||||
<path d="M91.3744 -333.695L1695.86 592.384" stroke="rgba(0,0,0,0.30)"/>
|
||||
<path d="M1584.19 117.014L-20.3012 1043.09" stroke="rgba(0,0,0,0.30)"/>
|
||||
<path d="M-20.3044 -269.237L1584.19 656.843" stroke="rgba(0,0,0,0.30)"/>
|
||||
<path d="M1472.51 52.5562L-131.98 978.636" stroke="rgba(0,0,0,0.30)"/>
|
||||
<path d="M-131.983 -204.778L1472.51 721.301" stroke="rgba(0,0,0,0.30)"/>
|
||||
<path d="M1360.83 -11.9023L-243.658 914.177" stroke="rgba(0,0,0,0.30)"/>
|
||||
<path d="M-243.662 -140.319L1360.83 785.76" stroke="rgba(0,0,0,0.30)"/>
|
||||
<path d="M1249.15 -76.3613L-355.336 849.718" stroke="rgba(0,0,0,0.30)"/>
|
||||
<path d="M-355.341 -75.8608L1249.15 850.219" stroke="rgba(0,0,0,0.30)"/>
|
||||
<path d="M1137.48 -140.819L-467.014 785.26" stroke="rgba(0,0,0,0.30)"/>
|
||||
<path d="M-467.017 -11.4023L1137.47 914.677" stroke="rgba(0,0,0,0.30)"/>
|
||||
<path d="M1025.8 -205.278L-578.692 720.801" stroke="rgba(0,0,0,0.30)"/>
|
||||
<path d="M-578.693 53.0562L1025.8 979.136" stroke="rgba(0,0,0,0.30)"/>
|
||||
<path d="M914.119 -269.736L-690.371 656.343" stroke="rgba(0,0,0,0.30)"/>
|
||||
<path d="M-690.379 117.515L914.111 1043.59" stroke="rgba(0,0,0,0.30)"/>
|
||||
<path d="M802.441 -334.195L-802.052 591.887" stroke="rgba(0,0,0,0.30)"/>
|
||||
<path d="M-802.055 181.974L802.435 1108.05" stroke="rgba(0,0,0,0.30)"/>
|
||||
<path d="M690.762 -398.654L-913.728 527.426" stroke="rgba(0,0,0,0.30)"/>
|
||||
<path d="M-913.731 246.432L690.759 1172.51" stroke="rgba(0,0,0,0.30)"/>
|
||||
<path d="M579.084 -463.112L-1025.41 462.967" stroke="rgba(0,0,0,0.30)"/>
|
||||
<path d="M-1025.41 310.891L579.083 1236.97" stroke="rgba(0,0,0,0.30)"/>
|
||||
<path d="M467.406 -527.571L-1136.91 398.811" stroke="rgba(0,0,0,0.30)"/>
|
||||
<path d="M-1137.09 375.35L467.397 1301.43" stroke="rgba(0,0,0,0.30)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
69
web/berry/src/assets/images/auth/auth-purple-card.svg
Normal file
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 272 KiB |
40
web/berry/src/assets/images/auth/auth-signup-white-card.svg
Normal file
|
After Width: | Height: | Size: 15 KiB |
5
web/berry/src/assets/images/icons/earning.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19 9H9C7.89543 9 7 9.89543 7 11V17C7 18.1046 7.89543 19 9 19H19C20.1046 19 21 18.1046 21 17V11C21 9.89543 20.1046 9 19 9Z" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M14 16C15.1046 16 16 15.1046 16 14C16 12.8954 15.1046 12 14 12C12.8954 12 12 12.8954 12 14C12 15.1046 12.8954 16 14 16Z" fill="#90CAF9"/>
|
||||
<path d="M17 9V7C17 6.46957 16.7893 5.96086 16.4142 5.58579C16.0391 5.21071 15.5304 5 15 5H5C4.46957 5 3.96086 5.21071 3.58579 5.58579C3.21071 5.96086 3 6.46957 3 7V13C3 13.5304 3.21071 14.0391 3.58579 14.4142C3.96086 14.7893 4.46957 15 5 15H7" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 794 B |
1
web/berry/src/assets/images/icons/github.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg t="1702350903010" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4215" width="32" height="32"><path d="M512 85.333333C276.266667 85.333333 85.333333 276.266667 85.333333 512a426.410667 426.410667 0 0 0 291.754667 404.821333c21.333333 3.712 29.312-9.088 29.312-20.309333 0-10.112-0.554667-43.690667-0.554667-79.445333-107.178667 19.754667-134.912-26.112-143.445333-50.133334-4.821333-12.288-25.6-50.133333-43.733333-60.288-14.933333-7.978667-36.266667-27.733333-0.554667-28.245333 33.621333-0.554667 57.6 30.933333 65.621333 43.733333 38.4 64.512 99.754667 46.378667 124.245334 35.2 3.754667-27.733333 14.933333-46.378667 27.221333-57.045333-94.933333-10.666667-194.133333-47.488-194.133333-210.688 0-46.421333 16.512-84.778667 43.733333-114.688-4.266667-10.666667-19.2-54.4 4.266667-113.066667 0 0 35.712-11.178667 117.333333 43.776a395.946667 395.946667 0 0 1 106.666667-14.421333c36.266667 0 72.533333 4.778667 106.666666 14.378667 81.578667-55.466667 117.333333-43.690667 117.333334-43.690667 23.466667 58.666667 8.533333 102.4 4.266666 113.066667 27.178667 29.866667 43.733333 67.712 43.733334 114.645333 0 163.754667-99.712 200.021333-194.645334 210.688 15.445333 13.312 28.8 38.912 28.8 78.933333 0 57.045333-0.554667 102.912-0.554666 117.333334 0 11.178667 8.021333 24.490667 29.354666 20.224A427.349333 427.349333 0 0 0 938.666667 512c0-235.733333-190.933333-426.666667-426.666667-426.666667z" fill="#000000" p-id="4216"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
5
web/berry/src/assets/images/icons/lark.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg t="1723134993089" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="7682"
|
||||
width="200" height="200">
|
||||
<path d="M138.67 472.593v267.659l1.085 0.825c30.488 23.11 68.369 45.41 109.072 62.904l1.473 0.63c57.452 24.487 117.23 38.204 176.468 38.39 49.026 0.15 94.773-6.199 138.432-20.266 23.88-7.694 47.048-17.68 69.546-30.137-16.42-1.696-32.855-4.635-49.166-8.6-68.949-16.77-138.975-52.578-203.999-96.138-92.077-61.684-178.453-141.863-242.91-215.267zM597.31 182H350.745l25.776 22.626 6.472 5.7 7.9 6.985 6.658 5.915 4.791 4.275 6.144 5.509 5.17 4.665 4.297 3.899 4.876 4.451 4.077 3.748 3.337 3.085 4.594 4.276 3.238 3.034 3.21 3.026 1.917 1.814 1.633 1.553a1573.085 1573.085 0 0 1 16.368 15.806c61.384 60.116 109.769 118.745 146.283 181.615 34.096-38.136 67.506-67.95 100.368-90.075-0.08-0.224-0.158-0.452-0.233-0.681-9.674-29.434-24.133-63.778-41.697-96.235-20.272-37.46-43.21-69.797-68.284-94.664l-0.331-0.327z m149.927 506.476a42.573 42.573 0 0 1 13.201-4.727c12.04-16.686 22.616-34.537 32.12-53.8 5.835-11.824 11.243-24.101 16.582-37.512l1.185-2.999c0.593-1.509 1.184-3.032 1.776-4.572l1.184-3.099a817.09 817.09 0 0 0 2.963-7.94l0.902-2.466 1.54-4.256 1.24-3.478 0.944-2.668 1.282-3.658 1.32-3.797 1.364-3.963 1.784-5.225 10.079-29.815 1.237-3.615 1.619-4.688 1.193-3.419 1.176-3.34 1.16-3.261 0.766-2.133 1.137-3.139 0.75-2.052 0.747-2.022 1.11-2.977 0.735-1.948 0.731-1.92 0.728-1.893 1.086-2.789 1.08-2.731 0.717-1.79 0.716-1.766 0.715-1.743 0.714-1.721 1.07-2.54 0.713-1.668 0.714-1.649 0.357-0.816 0.715-1.62 0.358-0.803 0.717-1.593 0.72-1.576 0.72-1.56 0.725-1.545 0.727-1.53 0.73-1.515 0.734-1.503 0.739-1.49 0.743-1.478 0.373-0.735 0.75-1.461 0.756-1.452 0.761-1.443 0.768-1.434 0.774-1.426 0.78-1.42 0.789-1.413 0.796-1.407 0.803-1.402 0.813-1.399c0.272-0.465 0.545-0.93 0.82-1.395 6.898-11.644 14.45-22.205 22.804-31.716l0.381-0.432-0.363-0.1a167.736 167.736 0 0 0-17.333-3.808l-1.17-0.187c-19.22-3.015-38.771-2.474-59.435 2.33C766 404.728 707.642 444.655 643.04 520.58c-50.09 58.868-112.372 100.194-178.36 119.035 48.387 29.017 96.32 50.621 141.075 61.507 55.428 13.48 102.617 9.838 140.868-12.298z m40.248 75.25a229.325 229.325 0 0 1-21.023 10.522 420.333 420.333 0 0 1-20.405 18.512c-50.095 42.57-103.865 72.408-161.229 90.891-50.432 16.25-102.815 23.52-158.258 23.355-68.336-0.22-136.434-15.846-201.363-43.52-52.844-22.523-101.532-52.32-138.874-83.06a32.016 32.016 0 0 1-11.662-24.212l-0.003-364.272a663.709 663.709 0 0 1-3.155-4.546l-0.395-0.584c-12.855-19.356-7.842-45.51 11.382-58.72 19.417-13.345 45.98-8.428 59.33 10.98 49.099 71.385 141.145 170.175 245.108 248.023 60.719-0.825 122.443-27.243 174.57-74.197-34.118-63.594-81.951-122.977-145.085-184.807a1497.032 1497.032 0 0 0-16.553-15.971l-3.41-3.23-3.07-2.888-3.727-3.484-3.783-3.514-3.854-3.556-3.938-3.61-5.403-4.921-4.184-3.786-5.043-4.54-4.477-4.008-6.214-5.538-7.36-6.527-9.592-8.463-8.388-7.371-15.995-14.032-10.596-9.324-7.673-6.781-6.151-5.465-4.92-4.397-4.536-4.081-3.615-3.28-3.395-3.104-2.68-2.472-2.058-1.913-2.48-2.324-1.919-1.814-1.871-1.783-2.735-2.632-2.231-2.172-1.92-1.888a702.481 702.481 0 0 1-6.747-6.754c-19.741-20.021-5.898-53.785 21.938-54.458l0.848-0.01h341.332a32 32 0 0 1 21.426 8.232c34.675 31.26 64.87 72.453 90.762 120.299 17.457 32.258 32.066 66.007 42.695 96.357 13.332-5.523 26.586-9.793 39.771-12.858 28.861-6.71 56.686-7.48 83.844-3.22 21.406 3.358 40.992 9.48 62.014 18.392l1.835 0.783 1.842 0.796 1.861 0.816 1.894 0.839 1.94 0.868 1.996 0.903 11.45 5.265c23.934 10.99 25.088 44.57 1.966 57.177-18.753 10.224-33.09 24.764-45.475 45.42l-0.82 1.383-0.65 1.111-0.643 1.119-0.64 1.126-0.634 1.136-0.631 1.144-0.627 1.155-0.623 1.165-0.62 1.176-0.618 1.188-0.615 1.2-0.612 1.214-0.61 1.227-0.609 1.24-0.607 1.257-0.606 1.272-0.605 1.287-0.907 1.963-0.604 1.33-0.605 1.35-0.605 1.368-0.606 1.387-0.606 1.407-0.608 1.428-0.61 1.45-0.919 2.216-0.923 2.267-0.62 1.542-0.622 1.565-0.626 1.591-0.63 1.617-0.633 1.643-0.638 1.67-1.29 3.423-0.981 2.642-0.994 2.71-0.67 1.845-1.017 2.826-1.38 3.88-1.054 2.999-1.072 3.076-1.09 3.155-1.11 3.237-10.062 29.76-1.935 5.673-1.84 5.35-1.42 4.084-1.386 3.945-1.022 2.885-1.35 3.772-0.672 1.862-1.01 2.775c-8.668 23.731-17.234 44.304-27.005 64.106-11.498 23.305-24.457 44.997-39.33 65.303 0.773 15.454-6.924 30.784-21.242 39.07z"
|
||||
p-id="7683" fill="#2c2c2c"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.3 KiB |
7
web/berry/src/assets/images/icons/oidc.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg t="1723135116886" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"
|
||||
p-id="10969" width="200" height="200">
|
||||
<path d="M512 960C265 960 64 759 64 512S265 64 512 64s448 201 448 448-201 448-448 448z m0-882.6c-239.7 0-434.6 195-434.6 434.6s195 434.6 434.6 434.6 434.6-195 434.6-434.6S751.7 77.4 512 77.4z"
|
||||
p-id="10970" fill="#2c2c2c" stroke="#2c2c2c" stroke-width="60"></path>
|
||||
<path d="M197.7 512c0-78.3 31.6-98.8 87.2-98.8 56.2 0 87.2 20.5 87.2 98.8s-31 98.8-87.2 98.8c-55.7 0-87.2-20.5-87.2-98.8z m130.4 0c0-46.8-7.8-64.5-43.2-64.5-35.2 0-42.9 17.7-42.9 64.5 0 47.1 7.8 63.7 42.9 63.7 35.4 0 43.2-16.6 43.2-63.7zM409.7 415.9h42.1V608h-42.1V415.9zM653.9 512c0 74.2-37.1 96.1-93.6 96.1h-65.9V415.9h65.9c56.5 0 93.6 16.1 93.6 96.1z m-43.5 0c0-49.3-17.7-60.6-52.3-60.6h-21.6v120.7h21.6c35.4 0 52.3-13.3 52.3-60.1zM686.5 512c0-74.2 36.3-98.8 92.7-98.8 18.3 0 33.2 2.2 44.8 6.4v36.3c-11.9-4.2-26-6.6-42.1-6.6-34.6 0-49.8 15.5-49.8 62.6 0 50.1 15.2 62.6 49.3 62.6 15.8 0 30.2-2.2 44.8-7.5v36c-11.3 4.7-28.5 8-46.8 8-56.1-0.2-92.9-18.7-92.9-99z"
|
||||
p-id="10971" fill="#2c2c2c" stroke="#2c2c2c" stroke-width="20"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
1
web/berry/src/assets/images/icons/shape-avatar.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg height="62" viewBox="0 0 144 62" width="144" xmlns="http://www.w3.org/2000/svg"><path d="m111.34 23.88c-10.62-10.46-18.5-23.88-38.74-23.88h-1.2c-20.24 0-28.12 13.42-38.74 23.88-7.72 9.64-19.44 11.74-32.66 12.12v26h144v-26c-13.22-.38-24.94-2.48-32.66-12.12z" fill="#fff" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 302 B |
6
web/berry/src/assets/images/icons/social-google.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.06129 13.2253L4.31871 15.9975L1.60458 16.0549C0.793457 14.5504 0.333374 12.8292 0.333374 11C0.333374 9.23119 0.763541 7.56319 1.52604 6.09448H1.52662L3.94296 6.53748L5.00146 8.93932C4.77992 9.58519 4.65917 10.2785 4.65917 11C4.65925 11.783 4.80108 12.5332 5.06129 13.2253Z" fill="#FBBB00"/>
|
||||
<path d="M21.4804 9.00732C21.6029 9.65257 21.6668 10.3189 21.6668 11C21.6668 11.7637 21.5865 12.5086 21.4335 13.2271C20.9143 15.6722 19.5575 17.8073 17.678 19.3182L17.6774 19.3177L14.6339 19.1624L14.2031 16.4734C15.4503 15.742 16.425 14.5974 16.9384 13.2271H11.2346V9.00732H17.0216H21.4804Z" fill="#518EF8"/>
|
||||
<path d="M17.6772 19.3176L17.6777 19.3182C15.8498 20.7875 13.5277 21.6666 11 21.6666C6.93783 21.6666 3.40612 19.3962 1.60449 16.0549L5.0612 13.2253C5.96199 15.6294 8.28112 17.3408 11 17.3408C12.1686 17.3408 13.2634 17.0249 14.2029 16.4734L17.6772 19.3176Z" fill="#28B446"/>
|
||||
<path d="M17.8085 2.78892L14.353 5.61792C13.3807 5.01017 12.2313 4.65908 11 4.65908C8.21963 4.65908 5.85713 6.44896 5.00146 8.93925L1.52658 6.09442H1.526C3.30125 2.67171 6.8775 0.333252 11 0.333252C13.5881 0.333252 15.9612 1.25517 17.8085 2.78892Z" fill="#F14336"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
1
web/berry/src/assets/images/icons/wechat.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg t="1702350975929" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2474" width="32" height="32"><path d="M512 1024C229.23264 1024 0 794.76736 0 512S229.23264 0 512 0s512 229.23264 512 512-229.23264 512-512 512z m212.0704-290.0992l41.5744 24.064c9.216 5.3248 14.5408 1.26976 11.83744-9.03168l-9.0112-34.4064a9.76896 9.76896 0 0 1 3.76832-10.4448C813.50656 674.75456 839.68 631.0912 839.68 582.32832c0-88.28928-85.79072-159.86688-191.61088-159.86688s-191.61088 71.5776-191.61088 159.86688c0 88.2688 85.79072 159.86688 191.61088 159.86688 22.9376 0 44.91264-3.35872 65.26976-9.5232a13.55776 13.55776 0 0 1 10.73152 1.2288z m-366.63296-116.08064a271.85152 271.85152 0 0 0 89.98912 10.89536 146.61632 146.61632 0 0 1-7.7824-47.16544c0-96.31744 94.33088-174.3872 210.71872-174.3872 4.07552 0 8.11008 0.08192 12.12416 0.28672C645.2224 315.84256 549.96992 245.76 435.03616 245.76 307.87584 245.76 204.8 331.55072 204.8 437.37088c0 58.1632 31.15008 110.2848 80.32256 145.42848 4.62848 3.31776 6.7584 9.13408 5.28384 14.62272l-10.83392 40.38656c-3.2768 12.1856 2.99008 16.95744 13.88544 10.58816l49.29536-28.79488a18.59584 18.59584 0 0 1 14.68416-1.78176z m353.73056-60.74368a26.0096 26.0096 0 1 1 0-52.0192 26.0096 26.0096 0 0 1 0 52.0192z m-126.976 0a26.0096 26.0096 0 1 1 0-52.0192 26.0096 26.0096 0 0 1 0 52.0192z m-72.66304-150.69184a30.59712 30.59712 0 1 1 0-61.19424 30.59712 30.59712 0 0 1 0 61.19424z m-153.74336 0a30.59712 30.59712 0 1 1 0-61.19424 30.59712 30.59712 0 0 1 0 61.19424z" fill="#2BD418" p-id="2475"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
BIN
web/berry/src/assets/images/invite/cover.jpg
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
web/berry/src/assets/images/invite/cwok_casual_19.webp
Normal file
|
After Width: | Height: | Size: 174 KiB |
15
web/berry/src/assets/images/logo-2.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<svg viewBox="0 0 590 265" xmlns="http://www.w3.org/2000/svg" version="1.1">
|
||||
|
||||
<g>
|
||||
<title>Layer 1</title>
|
||||
<ellipse transform="rotate(103.6 287.6 32.87)" id="svg_1" ry="28.14" rx="28.47" cy="32.87" cx="287.60001" fill="#0d161f"/>
|
||||
<path id="svg_2" d="m232.92,128.89c3.78,27.29 -1.81,55.44 -17.71,78.09a2.62,2.62 0 0 0 -0.06,2.92c1.24,1.92 2.96,5.05 5.56,4.94q5.25,-0.22 10.79,0.11a1.26,1.26 0 0 1 1.19,1.27l-0.4,42.53a1.31,1.31 0 0 1 -1.31,1.3q-16.77,-0.09 -36.53,0.01q-2.25,0.02 -3.71,-1.56q-16.02,-17.28 -31.98,-35.32c-5.13,-5.8 -10.18,-11.16 -14.86,-17.59a1.35,1.34 -31.1 0 1 0.5,-2q12.88,-6.32 22.13,-17.12q18.18,-21.23 15.08,-48.84q-2.66,-23.7 -22.4,-40.46q-23.43,-19.9 -54.88,-13.86c-4.1,0.79 -7.83,2.5 -11.72,4.12q-11.86,4.94 -20.59,14.64c-14.25,15.81 -20.07,36.4 -15.05,57.16q4.99,20.63 22.86,35.71c10.45,8.81 23.7,13.12 37.26,14.18q1.47,0.11 3.6,2.65c11.68,13.89 24.48,27.72 35.94,41.96a0.43,0.43 0 0 1 -0.21,0.68q-22.51,7.27 -47.37,5.37q-19.4,-1.47 -39.74,-11.22q-18.27,-8.75 -30.59,-21.28q-18.66,-18.98 -28.02,-43.57q-10.8,-28.4 -4.93,-58.67c1.59,-8.17 4.03,-17 7.42,-24.61q5.08,-11.38 11.61,-20.64q25.41,-36.03 68.45,-46.13q32.42,-7.61 64.23,3.92q25.31,9.17 43.2,27.31c16.85,17.09 28.91,40.01 32.24,64z" fill="#0d161f"/>
|
||||
<path id="svg_3" d="m499.47,180.61c6.45,13.53 16.44,21.75 31.96,22q11.94,0.19 22.17,-5.36q2.21,-1.2 3.93,0.69q12.56,13.78 24.89,28.47q1.21,1.44 1.44,3.13a0.95,0.95 0 0 1 -0.36,0.89c-1.62,1.23 -3.33,2.71 -5.03,3.69q-29.37,17.01 -62.47,11.31c-20.61,-3.55 -39.05,-15.24 -51.47,-32.51q-6.4,-8.89 -9.91,-17.08c-2.62,-6.12 -4.73,-13.3 -5.41,-20.08q-3.96,-39.88 22.94,-67.74c9.48,-9.81 21.15,-16.67 34.39,-19.49c16.54,-3.53 34.64,-1.83 48.77,7.1q13.92,8.79 21.13,20.4q11.07,17.84 10.48,38.92c-0.02,0.94 -0.21,1.81 -0.85,2.54q-7.73,8.77 -18.71,20.16c-1.28,1.32 -2.61,2.26 -4.51,2.23q-24.45,-0.37 -51.64,-0.41q-5.03,0 -10.84,-0.22a0.96,0.95 -11.7 0 0 -0.9,1.36zm1.12,-37.17q-0.55,1.19 -0.63,2.34q-0.08,1.01 0.94,1.03q19.01,0.25 36.98,0.01q0.5,0 0.94,-0.22q0.57,-0.28 0.44,-0.9q-2.34,-11.6 -14.11,-15.25q-3.59,-1.11 -6.44,-0.57q-13.07,2.5 -18.12,13.56z" fill="#0d161f"/>
|
||||
<path id="svg_4" d="m312.3,100.22a0.5,0.49 -22.1 0 0 0.84,0.35q2.76,-2.64 5.82,-4.31q8.45,-4.62 16.71,-6.57c15.81,-3.72 33.58,-3.2 48.2,3.95q24.49,11.98 35.05,35.76c4.66,10.5 5.44,22.96 5.5,35.35q0.21,49.99 -0.12,88q-0.03,3.06 -0.08,6.16a1.32,1.32 0 0 1 -1.33,1.3q-20.22,-0.18 -40.18,-0.23q-3.64,-0.01 -8.13,-0.44a1.06,1.05 -87.3 0 1 -0.95,-1.05q0.02,-45.49 -0.22,-92.99c-0.03,-6.25 -1.21,-13.88 -5.05,-18.95q-5.33,-7.03 -12.32,-10.18c-10.99,-4.93 -24.52,-1.84 -33.13,6.37q-10.01,9.53 -10.07,23.76q-0.11,25.46 -0.1,48.98c0,3.52 -0.06,8.31 -1.1,11.68c-4.37,14.04 -17.31,19.5 -31.04,16.77c-8.22,-1.64 -15.07,-7.75 -17.62,-15.62q-1.45,-4.49 -1.42,-10.2q0.3,-64.69 0.1,-129.86a0.47,0.47 0 0 1 0.47,-0.47l48.46,-0.35a1.56,1.55 89.4 0 1 1.56,1.54l0.15,11.25z" fill="#0d161f"/>
|
||||
<path id="svg_5" d="m265.63,344.43a2.02,2.01 76.7 0 0 -1.85,-1.15l-17.03,0.24a2.25,2.22 9.3 0 0 -2.06,1.46l-2.86,7.84a2.47,2.46 -79.1 0 1 -2.38,1.62l-6.23,-0.19q-1.19,-0.04 -0.88,-1.19q1.38,-5.23 2.81,-8.7c3.41,-8.3 6.48,-16.83 10.12,-25.35q2.96,-6.93 5.21,-14.24c0.46,-1.52 1.69,-2.64 3.37,-2.63c2.02,0 4.68,-0.78 5.7,1.58q7.68,17.74 18.16,44.75q0.96,2.46 1.48,5a0.67,0.66 84.3 0 1 -0.65,0.8l-6.05,-0.02q-2.16,-0.01 -3.1,-1.96l-3.76,-7.86zm-16.73,-10.31a0.34,0.34 0 0 0 0.32,0.47l12.85,-0.36a0.34,0.34 0 0 0 0.3,-0.48l-6.84,-14.7a0.34,0.34 0 0 0 -0.62,0.02l-6.01,15.05z" fill="#0d161f"/>
|
||||
<rect id="svg_6" rx="2.17" height="52.28" width="9.84" y="302.19" x="345.67" fill="#0d161f"/>
|
||||
<path id="svg_7" d="m303.07,338.46l-0.15,14.42q-0.01,1.55 -1.56,1.52l-5.84,-0.12q-1.79,-0.04 -1.81,-1.83c-0.24,-15.33 -0.25,-30.89 -0.27,-47.22q-0.01,-2.99 2.55,-3.06q12.47,-0.33 20.15,0.8q8.61,1.25 12.86,9.17c2.95,5.49 2.53,13.5 -1.5,18.65c-5.57,7.14 -14.88,6.62 -23.24,6.51a1.17,1.17 0 0 0 -1.19,1.16zm-0.15,-24.81l0.16,12.72a1.72,1.72 0 0 0 1.74,1.7l6.07,-0.08a10.01,7.98 -0.7 0 0 9.91,-8.1l0,-0.2a10.01,7.98 -0.7 0 0 -10.11,-7.86l-6.07,0.08a1.72,1.72 0 0 0 -1.7,1.74z" fill="#0d161f"/>
|
||||
<rect id="svg_8" rx="3.58" height="7.26" width="79.2" y="322.99" x="107" fill="#0d161f"/>
|
||||
<rect id="svg_9" rx="3.81" height="7.72" width="79.1" y="322.78" x="417.27" fill="#0d161f"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
13
web/berry/src/assets/images/logo-white.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<svg viewBox="0 0 590 360" xmlns="http://www.w3.org/2000/svg" version="1.1">
|
||||
<g>
|
||||
<ellipse transform="rotate(103.6 287.6 32.87)" id="svg_1" ry="28.14" rx="28.47" cy="32.87" cx="287.60001" fill="#fff"/>
|
||||
<path id="svg_2" d="m232.92,128.89c3.78,27.29 -1.81,55.44 -17.71,78.09a2.62,2.62 0 0 0 -0.06,2.92c1.24,1.92 2.96,5.05 5.56,4.94q5.25,-0.22 10.79,0.11a1.26,1.26 0 0 1 1.19,1.27l-0.4,42.53a1.31,1.31 0 0 1 -1.31,1.3q-16.77,-0.09 -36.53,0.01q-2.25,0.02 -3.71,-1.56q-16.02,-17.28 -31.98,-35.32c-5.13,-5.8 -10.18,-11.16 -14.86,-17.59a1.35,1.34 -31.1 0 1 0.5,-2q12.88,-6.32 22.13,-17.12q18.18,-21.23 15.08,-48.84q-2.66,-23.7 -22.4,-40.46q-23.43,-19.9 -54.88,-13.86c-4.1,0.79 -7.83,2.5 -11.72,4.12q-11.86,4.94 -20.59,14.64c-14.25,15.81 -20.07,36.4 -15.05,57.16q4.99,20.63 22.86,35.71c10.45,8.81 23.7,13.12 37.26,14.18q1.47,0.11 3.6,2.65c11.68,13.89 24.48,27.72 35.94,41.96a0.43,0.43 0 0 1 -0.21,0.68q-22.51,7.27 -47.37,5.37q-19.4,-1.47 -39.74,-11.22q-18.27,-8.75 -30.59,-21.28q-18.66,-18.98 -28.02,-43.57q-10.8,-28.4 -4.93,-58.67c1.59,-8.17 4.03,-17 7.42,-24.61q5.08,-11.38 11.61,-20.64q25.41,-36.03 68.45,-46.13q32.42,-7.61 64.23,3.92q25.31,9.17 43.2,27.31c16.85,17.09 28.91,40.01 32.24,64z" fill="#fff"/>
|
||||
<path id="svg_3" d="m499.47,180.61c6.45,13.53 16.44,21.75 31.96,22q11.94,0.19 22.17,-5.36q2.21,-1.2 3.93,0.69q12.56,13.78 24.89,28.47q1.21,1.44 1.44,3.13a0.95,0.95 0 0 1 -0.36,0.89c-1.62,1.23 -3.33,2.71 -5.03,3.69q-29.37,17.01 -62.47,11.31c-20.61,-3.55 -39.05,-15.24 -51.47,-32.51q-6.4,-8.89 -9.91,-17.08c-2.62,-6.12 -4.73,-13.3 -5.41,-20.08q-3.96,-39.88 22.94,-67.74c9.48,-9.81 21.15,-16.67 34.39,-19.49c16.54,-3.53 34.64,-1.83 48.77,7.1q13.92,8.79 21.13,20.4q11.07,17.84 10.48,38.92c-0.02,0.94 -0.21,1.81 -0.85,2.54q-7.73,8.77 -18.71,20.16c-1.28,1.32 -2.61,2.26 -4.51,2.23q-24.45,-0.37 -51.64,-0.41q-5.03,0 -10.84,-0.22a0.96,0.95 -11.7 0 0 -0.9,1.36zm1.12,-37.17q-0.55,1.19 -0.63,2.34q-0.08,1.01 0.94,1.03q19.01,0.25 36.98,0.01q0.5,0 0.94,-0.22q0.57,-0.28 0.44,-0.9q-2.34,-11.6 -14.11,-15.25q-3.59,-1.11 -6.44,-0.57q-13.07,2.5 -18.12,13.56z" fill="#fff"/>
|
||||
<path id="svg_4" d="m312.3,100.22a0.5,0.49 -22.1 0 0 0.84,0.35q2.76,-2.64 5.82,-4.31q8.45,-4.62 16.71,-6.57c15.81,-3.72 33.58,-3.2 48.2,3.95q24.49,11.98 35.05,35.76c4.66,10.5 5.44,22.96 5.5,35.35q0.21,49.99 -0.12,88q-0.03,3.06 -0.08,6.16a1.32,1.32 0 0 1 -1.33,1.3q-20.22,-0.18 -40.18,-0.23q-3.64,-0.01 -8.13,-0.44a1.06,1.05 -87.3 0 1 -0.95,-1.05q0.02,-45.49 -0.22,-92.99c-0.03,-6.25 -1.21,-13.88 -5.05,-18.95q-5.33,-7.03 -12.32,-10.18c-10.99,-4.93 -24.52,-1.84 -33.13,6.37q-10.01,9.53 -10.07,23.76q-0.11,25.46 -0.1,48.98c0,3.52 -0.06,8.31 -1.1,11.68c-4.37,14.04 -17.31,19.5 -31.04,16.77c-8.22,-1.64 -15.07,-7.75 -17.62,-15.62q-1.45,-4.49 -1.42,-10.2q0.3,-64.69 0.1,-129.86a0.47,0.47 0 0 1 0.47,-0.47l48.46,-0.35a1.56,1.55 89.4 0 1 1.56,1.54l0.15,11.25z" fill="#fff"/>
|
||||
<path id="svg_5" d="m265.63,344.43a2.02,2.01 76.7 0 0 -1.85,-1.15l-17.03,0.24a2.25,2.22 9.3 0 0 -2.06,1.46l-2.86,7.84a2.47,2.46 -79.1 0 1 -2.38,1.62l-6.23,-0.19q-1.19,-0.04 -0.88,-1.19q1.38,-5.23 2.81,-8.7c3.41,-8.3 6.48,-16.83 10.12,-25.35q2.96,-6.93 5.21,-14.24c0.46,-1.52 1.69,-2.64 3.37,-2.63c2.02,0 4.68,-0.78 5.7,1.58q7.68,17.74 18.16,44.75q0.96,2.46 1.48,5a0.67,0.66 84.3 0 1 -0.65,0.8l-6.05,-0.02q-2.16,-0.01 -3.1,-1.96l-3.76,-7.86zm-16.73,-10.31a0.34,0.34 0 0 0 0.32,0.47l12.85,-0.36a0.34,0.34 0 0 0 0.3,-0.48l-6.84,-14.7a0.34,0.34 0 0 0 -0.62,0.02l-6.01,15.05z" fill="#fff"/>
|
||||
<rect id="svg_6" rx="2.17" height="52.28" width="9.84" y="302.19" x="345.67" fill="#fff"/>
|
||||
<path id="svg_7" d="m303.07,338.46l-0.15,14.42q-0.01,1.55 -1.56,1.52l-5.84,-0.12q-1.79,-0.04 -1.81,-1.83c-0.24,-15.33 -0.25,-30.89 -0.27,-47.22q-0.01,-2.99 2.55,-3.06q12.47,-0.33 20.15,0.8q8.61,1.25 12.86,9.17c2.95,5.49 2.53,13.5 -1.5,18.65c-5.57,7.14 -14.88,6.62 -23.24,6.51a1.17,1.17 0 0 0 -1.19,1.16zm-0.15,-24.81l0.16,12.72a1.72,1.72 0 0 0 1.74,1.7l6.07,-0.08a10.01,7.98 -0.7 0 0 9.91,-8.1l0,-0.2a10.01,7.98 -0.7 0 0 -10.11,-7.86l-6.07,0.08a1.72,1.72 0 0 0 -1.7,1.74z" fill="#fff"/>
|
||||
<rect id="svg_8" rx="3.58" height="7.26" width="79.2" y="322.99" x="107" fill="#fff"/>
|
||||
<rect id="svg_9" rx="3.81" height="7.72" width="79.1" y="322.78" x="417.27" fill="#fff"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.1 KiB |
13
web/berry/src/assets/images/logo.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<svg viewBox="0 0 590 360" xmlns="http://www.w3.org/2000/svg" version="1.1">
|
||||
<g>
|
||||
<ellipse transform="rotate(103.6 287.6 32.87)" id="svg_1" ry="28.14" rx="28.47" cy="32.87" cx="287.60001" fill="#0d161f"/>
|
||||
<path id="svg_2" d="m232.92,128.89c3.78,27.29 -1.81,55.44 -17.71,78.09a2.62,2.62 0 0 0 -0.06,2.92c1.24,1.92 2.96,5.05 5.56,4.94q5.25,-0.22 10.79,0.11a1.26,1.26 0 0 1 1.19,1.27l-0.4,42.53a1.31,1.31 0 0 1 -1.31,1.3q-16.77,-0.09 -36.53,0.01q-2.25,0.02 -3.71,-1.56q-16.02,-17.28 -31.98,-35.32c-5.13,-5.8 -10.18,-11.16 -14.86,-17.59a1.35,1.34 -31.1 0 1 0.5,-2q12.88,-6.32 22.13,-17.12q18.18,-21.23 15.08,-48.84q-2.66,-23.7 -22.4,-40.46q-23.43,-19.9 -54.88,-13.86c-4.1,0.79 -7.83,2.5 -11.72,4.12q-11.86,4.94 -20.59,14.64c-14.25,15.81 -20.07,36.4 -15.05,57.16q4.99,20.63 22.86,35.71c10.45,8.81 23.7,13.12 37.26,14.18q1.47,0.11 3.6,2.65c11.68,13.89 24.48,27.72 35.94,41.96a0.43,0.43 0 0 1 -0.21,0.68q-22.51,7.27 -47.37,5.37q-19.4,-1.47 -39.74,-11.22q-18.27,-8.75 -30.59,-21.28q-18.66,-18.98 -28.02,-43.57q-10.8,-28.4 -4.93,-58.67c1.59,-8.17 4.03,-17 7.42,-24.61q5.08,-11.38 11.61,-20.64q25.41,-36.03 68.45,-46.13q32.42,-7.61 64.23,3.92q25.31,9.17 43.2,27.31c16.85,17.09 28.91,40.01 32.24,64z" fill="#0d161f"/>
|
||||
<path id="svg_3" d="m499.47,180.61c6.45,13.53 16.44,21.75 31.96,22q11.94,0.19 22.17,-5.36q2.21,-1.2 3.93,0.69q12.56,13.78 24.89,28.47q1.21,1.44 1.44,3.13a0.95,0.95 0 0 1 -0.36,0.89c-1.62,1.23 -3.33,2.71 -5.03,3.69q-29.37,17.01 -62.47,11.31c-20.61,-3.55 -39.05,-15.24 -51.47,-32.51q-6.4,-8.89 -9.91,-17.08c-2.62,-6.12 -4.73,-13.3 -5.41,-20.08q-3.96,-39.88 22.94,-67.74c9.48,-9.81 21.15,-16.67 34.39,-19.49c16.54,-3.53 34.64,-1.83 48.77,7.1q13.92,8.79 21.13,20.4q11.07,17.84 10.48,38.92c-0.02,0.94 -0.21,1.81 -0.85,2.54q-7.73,8.77 -18.71,20.16c-1.28,1.32 -2.61,2.26 -4.51,2.23q-24.45,-0.37 -51.64,-0.41q-5.03,0 -10.84,-0.22a0.96,0.95 -11.7 0 0 -0.9,1.36zm1.12,-37.17q-0.55,1.19 -0.63,2.34q-0.08,1.01 0.94,1.03q19.01,0.25 36.98,0.01q0.5,0 0.94,-0.22q0.57,-0.28 0.44,-0.9q-2.34,-11.6 -14.11,-15.25q-3.59,-1.11 -6.44,-0.57q-13.07,2.5 -18.12,13.56z" fill="#0d161f"/>
|
||||
<path id="svg_4" d="m312.3,100.22a0.5,0.49 -22.1 0 0 0.84,0.35q2.76,-2.64 5.82,-4.31q8.45,-4.62 16.71,-6.57c15.81,-3.72 33.58,-3.2 48.2,3.95q24.49,11.98 35.05,35.76c4.66,10.5 5.44,22.96 5.5,35.35q0.21,49.99 -0.12,88q-0.03,3.06 -0.08,6.16a1.32,1.32 0 0 1 -1.33,1.3q-20.22,-0.18 -40.18,-0.23q-3.64,-0.01 -8.13,-0.44a1.06,1.05 -87.3 0 1 -0.95,-1.05q0.02,-45.49 -0.22,-92.99c-0.03,-6.25 -1.21,-13.88 -5.05,-18.95q-5.33,-7.03 -12.32,-10.18c-10.99,-4.93 -24.52,-1.84 -33.13,6.37q-10.01,9.53 -10.07,23.76q-0.11,25.46 -0.1,48.98c0,3.52 -0.06,8.31 -1.1,11.68c-4.37,14.04 -17.31,19.5 -31.04,16.77c-8.22,-1.64 -15.07,-7.75 -17.62,-15.62q-1.45,-4.49 -1.42,-10.2q0.3,-64.69 0.1,-129.86a0.47,0.47 0 0 1 0.47,-0.47l48.46,-0.35a1.56,1.55 89.4 0 1 1.56,1.54l0.15,11.25z" fill="#0d161f"/>
|
||||
<path id="svg_5" d="m265.63,344.43a2.02,2.01 76.7 0 0 -1.85,-1.15l-17.03,0.24a2.25,2.22 9.3 0 0 -2.06,1.46l-2.86,7.84a2.47,2.46 -79.1 0 1 -2.38,1.62l-6.23,-0.19q-1.19,-0.04 -0.88,-1.19q1.38,-5.23 2.81,-8.7c3.41,-8.3 6.48,-16.83 10.12,-25.35q2.96,-6.93 5.21,-14.24c0.46,-1.52 1.69,-2.64 3.37,-2.63c2.02,0 4.68,-0.78 5.7,1.58q7.68,17.74 18.16,44.75q0.96,2.46 1.48,5a0.67,0.66 84.3 0 1 -0.65,0.8l-6.05,-0.02q-2.16,-0.01 -3.1,-1.96l-3.76,-7.86zm-16.73,-10.31a0.34,0.34 0 0 0 0.32,0.47l12.85,-0.36a0.34,0.34 0 0 0 0.3,-0.48l-6.84,-14.7a0.34,0.34 0 0 0 -0.62,0.02l-6.01,15.05z" fill="#0d161f"/>
|
||||
<rect id="svg_6" rx="2.17" height="52.28" width="9.84" y="302.19" x="345.67" fill="#0d161f"/>
|
||||
<path id="svg_7" d="m303.07,338.46l-0.15,14.42q-0.01,1.55 -1.56,1.52l-5.84,-0.12q-1.79,-0.04 -1.81,-1.83c-0.24,-15.33 -0.25,-30.89 -0.27,-47.22q-0.01,-2.99 2.55,-3.06q12.47,-0.33 20.15,0.8q8.61,1.25 12.86,9.17c2.95,5.49 2.53,13.5 -1.5,18.65c-5.57,7.14 -14.88,6.62 -23.24,6.51a1.17,1.17 0 0 0 -1.19,1.16zm-0.15,-24.81l0.16,12.72a1.72,1.72 0 0 0 1.74,1.7l6.07,-0.08a10.01,7.98 -0.7 0 0 9.91,-8.1l0,-0.2a10.01,7.98 -0.7 0 0 -10.11,-7.86l-6.07,0.08a1.72,1.72 0 0 0 -1.7,1.74z" fill="#0d161f"/>
|
||||
<rect id="svg_8" rx="3.58" height="7.26" width="79.2" y="322.99" x="107" fill="#0d161f"/>
|
||||
<rect id="svg_9" rx="3.81" height="7.72" width="79.1" y="322.78" x="417.27" fill="#0d161f"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.1 KiB |
1
web/berry/src/assets/images/users/user-round.svg
Normal file
|
After Width: | Height: | Size: 20 KiB |
167
web/berry/src/assets/scss/_themes-vars.module.scss
Normal file
@@ -0,0 +1,167 @@
|
||||
// paper & background
|
||||
$paper: #ffffff;
|
||||
|
||||
// primary
|
||||
$primaryLight: #eef2f6;
|
||||
$primaryMain: #2196f3;
|
||||
$primaryDark: #1e88e5;
|
||||
$primary200: #90caf9;
|
||||
$primary800: #1565c0;
|
||||
|
||||
// secondary
|
||||
$secondaryLight: #ede7f6;
|
||||
$secondaryMain: #673ab7;
|
||||
$secondaryDark: #5e35b1;
|
||||
$secondary200: #b39ddb;
|
||||
$secondary800: #4527a0;
|
||||
|
||||
// success Colors
|
||||
$successLight: #b9f6ca;
|
||||
$success200: #69f0ae;
|
||||
$successMain: #00e676;
|
||||
$successDark: #00c853;
|
||||
|
||||
// error
|
||||
$errorLight: #ef9a9a;
|
||||
$errorMain: #f44336;
|
||||
$errorDark: #c62828;
|
||||
|
||||
// orange
|
||||
$orangeLight: #fbe9e7;
|
||||
$orangeMain: #ffab91;
|
||||
$orangeDark: #d84315;
|
||||
|
||||
// warning
|
||||
$warningLight: #fff8e1;
|
||||
$warningMain: #ffe57f;
|
||||
$warningDark: #ffc107;
|
||||
|
||||
// grey
|
||||
$grey50: #f8fafc;
|
||||
$grey100: #eef2f6;
|
||||
$grey200: #e3e8ef;
|
||||
$grey300: #cdd5df;
|
||||
$grey500: #697586;
|
||||
$grey600: #4b5565;
|
||||
$grey700: #364152;
|
||||
$grey900: #121926;
|
||||
|
||||
$tableBackground: #f4f6f8;
|
||||
$tableBorderBottom: #f1f3f4;
|
||||
|
||||
// ==============================|| DARK THEME VARIANTS ||============================== //
|
||||
|
||||
// paper & background
|
||||
$darkBackground: #1a223f; // level 3
|
||||
$darkPaper: #111936; // level 4
|
||||
$darkDivider: rgba(227, 232, 239, 0.2);
|
||||
$darkSelectedBack : rgba(124, 77, 255, 0.15);
|
||||
|
||||
// dark 800 & 900
|
||||
$darkLevel1: #29314f; // level 1
|
||||
$darkLevel2: #212946; // level 2
|
||||
|
||||
// primary dark
|
||||
$darkPrimaryLight: #eef2f6;
|
||||
$darkPrimaryMain: #2196f3;
|
||||
$darkPrimaryDark: #1e88e5;
|
||||
$darkPrimary200: #90caf9;
|
||||
$darkPrimary800: #1565c0;
|
||||
|
||||
// secondary dark
|
||||
$darkSecondaryLight: #d1c4e9;
|
||||
$darkSecondaryMain: #7c4dff;
|
||||
$darkSecondaryDark: #651fff;
|
||||
$darkSecondary200: #b39ddb;
|
||||
$darkSecondary800: #6200ea;
|
||||
|
||||
// text variants
|
||||
$darkTextTitle: #d7dcec;
|
||||
$darkTextPrimary: #bdc8f0;
|
||||
$darkTextSecondary: #8492c4;
|
||||
|
||||
// ==============================|| JAVASCRIPT ||============================== //
|
||||
|
||||
:export {
|
||||
// paper & background
|
||||
paper: $paper;
|
||||
|
||||
// primary
|
||||
primaryLight: $primaryLight;
|
||||
primary200: $primary200;
|
||||
primaryMain: $primaryMain;
|
||||
primaryDark: $primaryDark;
|
||||
primary800: $primary800;
|
||||
|
||||
// secondary
|
||||
secondaryLight: $secondaryLight;
|
||||
secondary200: $secondary200;
|
||||
secondaryMain: $secondaryMain;
|
||||
secondaryDark: $secondaryDark;
|
||||
secondary800: $secondary800;
|
||||
|
||||
// success
|
||||
successLight: $successLight;
|
||||
success200: $success200;
|
||||
successMain: $successMain;
|
||||
successDark: $successDark;
|
||||
|
||||
// error
|
||||
errorLight: $errorLight;
|
||||
errorMain: $errorMain;
|
||||
errorDark: $errorDark;
|
||||
|
||||
// orange
|
||||
orangeLight: $orangeLight;
|
||||
orangeMain: $orangeMain;
|
||||
orangeDark: $orangeDark;
|
||||
|
||||
// warning
|
||||
warningLight: $warningLight;
|
||||
warningMain: $warningMain;
|
||||
warningDark: $warningDark;
|
||||
|
||||
// grey
|
||||
grey50: $grey50;
|
||||
grey100: $grey100;
|
||||
grey200: $grey200;
|
||||
grey300: $grey300;
|
||||
grey500: $grey500;
|
||||
grey600: $grey600;
|
||||
grey700: $grey700;
|
||||
grey900: $grey900;
|
||||
|
||||
// ==============================|| DARK THEME VARIANTS ||============================== //
|
||||
|
||||
// paper & background
|
||||
darkPaper: $darkPaper;
|
||||
darkBackground: $darkBackground;
|
||||
|
||||
// dark 800 & 900
|
||||
darkLevel1: $darkLevel1;
|
||||
darkLevel2: $darkLevel2;
|
||||
|
||||
// text variants
|
||||
darkTextTitle: $darkTextTitle;
|
||||
darkTextPrimary: $darkTextPrimary;
|
||||
darkTextSecondary: $darkTextSecondary;
|
||||
|
||||
// primary dark
|
||||
darkPrimaryLight: $darkPrimaryLight;
|
||||
darkPrimaryMain: $darkPrimaryMain;
|
||||
darkPrimaryDark: $darkPrimaryDark;
|
||||
darkPrimary200: $darkPrimary200;
|
||||
darkPrimary800: $darkPrimary800;
|
||||
|
||||
// secondary dark
|
||||
darkSecondaryLight: $darkSecondaryLight;
|
||||
darkSecondaryMain: $darkSecondaryMain;
|
||||
darkSecondaryDark: $darkSecondaryDark;
|
||||
darkSecondary200: $darkSecondary200;
|
||||
darkSecondary800: $darkSecondary800;
|
||||
|
||||
darkDivider: $darkDivider;
|
||||
darkSelectedBack: $darkSelectedBack;
|
||||
tableBackground: $tableBackground;
|
||||
tableBorderBottom: $tableBorderBottom;
|
||||
}
|
||||