✨ 更换web界面
46
.air.toml
Normal file
@ -0,0 +1,46 @@
|
||||
root = "."
|
||||
testdata_dir = "testdata"
|
||||
tmp_dir = "tmp"
|
||||
|
||||
[build]
|
||||
args_bin = []
|
||||
bin = "./tmp/main"
|
||||
cmd = "go build -o ./tmp/main ."
|
||||
delay = 1000
|
||||
exclude_dir = ["assets", "tmp", "vendor", "testdata", "web"]
|
||||
exclude_file = []
|
||||
exclude_regex = ["_test.go"]
|
||||
exclude_unchanged = false
|
||||
follow_symlink = false
|
||||
full_bin = ""
|
||||
include_dir = []
|
||||
include_ext = ["go", "tpl", "tmpl", "html"]
|
||||
include_file = []
|
||||
kill_delay = "0s"
|
||||
log = "build-errors.log"
|
||||
poll = false
|
||||
poll_interval = 0
|
||||
post_cmd = []
|
||||
pre_cmd = []
|
||||
rerun = false
|
||||
rerun_delay = 500
|
||||
send_interrupt = false
|
||||
stop_on_error = false
|
||||
|
||||
[color]
|
||||
app = ""
|
||||
build = "yellow"
|
||||
main = "magenta"
|
||||
runner = "green"
|
||||
watcher = "cyan"
|
||||
|
||||
[log]
|
||||
main_only = false
|
||||
time = false
|
||||
|
||||
[misc]
|
||||
clean_on_exit = false
|
||||
|
||||
[screen]
|
||||
clear_on_rebuild = false
|
||||
keep_scroll = true
|
@ -121,7 +121,7 @@ var (
|
||||
GlobalApiRateLimitNum = GetOrDefault("GLOBAL_API_RATE_LIMIT", 180)
|
||||
GlobalApiRateLimitDuration int64 = 3 * 60
|
||||
|
||||
GlobalWebRateLimitNum = GetOrDefault("GLOBAL_WEB_RATE_LIMIT", 60)
|
||||
GlobalWebRateLimitNum = GetOrDefault("GLOBAL_WEB_RATE_LIMIT", 100)
|
||||
GlobalWebRateLimitDuration int64 = 3 * 60
|
||||
|
||||
UploadRateLimitNum = 10
|
||||
|
@ -7,6 +7,7 @@ import (
|
||||
"one-api/common"
|
||||
"one-api/model"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
@ -248,6 +249,30 @@ func GetUser(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
func GetUserDashboard(c *gin.Context) {
|
||||
id := c.GetInt("id")
|
||||
// 获取7天前 00:00:00 和 今天23:59:59 的秒时间戳
|
||||
now := time.Now()
|
||||
toDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
endOfDay := toDay.Add(time.Hour * 24).Add(-time.Second).Unix()
|
||||
startOfDay := toDay.AddDate(0, 0, -7).Unix()
|
||||
|
||||
dashboards, err := model.SearchLogsByDayAndModel(id, int(startOfDay), int(endOfDay))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无法获取统计信息.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": dashboards,
|
||||
})
|
||||
}
|
||||
|
||||
func GenerateAccessToken(c *gin.Context) {
|
||||
id := c.GetInt("id")
|
||||
user, err := model.GetUserById(id, true)
|
||||
|
29
model/log.go
@ -23,6 +23,15 @@ type Log struct {
|
||||
ChannelId int `json:"channel" gorm:"index"`
|
||||
}
|
||||
|
||||
type LogStatistic struct {
|
||||
Day string `gorm:"column:day"`
|
||||
ModelName string `gorm:"column:model_name"`
|
||||
RequestCount int `gorm:"column:request_count"`
|
||||
Quota int `gorm:"column:quota"`
|
||||
PromptTokens int `gorm:"column:prompt_tokens"`
|
||||
CompletionTokens int `gorm:"column:completion_tokens"`
|
||||
}
|
||||
|
||||
const (
|
||||
LogTypeUnknown = iota
|
||||
LogTypeTopup
|
||||
@ -184,6 +193,26 @@ func DeleteOldLog(targetTimestamp int64) (int64, error) {
|
||||
return result.RowsAffected, result.Error
|
||||
}
|
||||
|
||||
func SearchLogsByDayAndModel(user_id, start, end int) (LogStatistics []*LogStatistic, err error) {
|
||||
err = DB.Raw(`
|
||||
SELECT TO_CHAR(date_trunc('day', to_timestamp(created_at)), 'YYYY-MM-DD') as day,
|
||||
model_name, count(1) as request_count,
|
||||
sum(quota) as quota,
|
||||
sum(prompt_tokens) as prompt_tokens,
|
||||
sum(completion_tokens) as completion_tokens
|
||||
FROM logs
|
||||
WHERE type=2
|
||||
AND user_id= ?
|
||||
AND created_at BETWEEN ? AND ?
|
||||
GROUP BY day, model_name
|
||||
ORDER BY day, model_name
|
||||
`, user_id, start, end).Scan(&LogStatistics).Error
|
||||
|
||||
fmt.Println(user_id, start, end)
|
||||
|
||||
return LogStatistics, err
|
||||
}
|
||||
|
||||
func assembleSumSelectStr(selectStr string) string {
|
||||
sumSelectStr := "%s(sum(%s),0)"
|
||||
nullfunc := "ifnull"
|
||||
|
@ -3,8 +3,9 @@ package model
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"gorm.io/gorm"
|
||||
"one-api/common"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Redemption struct {
|
||||
@ -27,7 +28,7 @@ func GetAllRedemptions(startIdx int, num int) ([]*Redemption, error) {
|
||||
}
|
||||
|
||||
func SearchRedemptions(keyword string) (redemptions []*Redemption, err error) {
|
||||
err = DB.Where("id = ? or name LIKE ?", keyword, keyword+"%").Find(&redemptions).Error
|
||||
err = DB.Where("id = ? or name LIKE ?", common.String2Int(keyword), keyword+"%").Find(&redemptions).Error
|
||||
return redemptions, err
|
||||
}
|
||||
|
||||
|
@ -3,9 +3,10 @@ package model
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"gorm.io/gorm"
|
||||
"one-api/common"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// User if you add sensitive fields, don't forget to clean them in setupLogin function.
|
||||
@ -42,7 +43,8 @@ func GetAllUsers(startIdx int, num int) (users []*User, err error) {
|
||||
}
|
||||
|
||||
func SearchUsers(keyword string) (users []*User, err error) {
|
||||
err = DB.Omit("password").Where("id = ? or username LIKE ? or email LIKE ? or display_name LIKE ?", keyword, keyword+"%", keyword+"%", keyword+"%").Find(&users).Error
|
||||
err = DB.Omit("password").Where("id = ? or username LIKE ? or email LIKE ? or display_name LIKE ?", common.String2Int(keyword), keyword+"%", keyword+"%", keyword+"%").Find(&users).Error
|
||||
|
||||
return users, err
|
||||
}
|
||||
|
||||
|
@ -35,6 +35,7 @@ func SetApiRouter(router *gin.Engine) {
|
||||
selfRoute := userRoute.Group("/")
|
||||
selfRoute.Use(middleware.UserAuth())
|
||||
{
|
||||
selfRoute.GET("/dashboard", controller.GetUserDashboard)
|
||||
selfRoute.GET("/self", controller.GetSelf)
|
||||
selfRoute.PUT("/self", controller.UpdateSelf)
|
||||
selfRoute.DELETE("/self", controller.DeleteSelf)
|
||||
|
89
web/.eslintrc
Normal file
@ -0,0 +1,89 @@
|
||||
{
|
||||
"root": true,
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2021": true
|
||||
},
|
||||
"extends": [
|
||||
"prettier",
|
||||
"plugin:react/jsx-runtime",
|
||||
"plugin:jsx-a11y/recommended",
|
||||
"plugin:react-hooks/recommended",
|
||||
"eslint:recommended",
|
||||
"plugin:react/recommended"
|
||||
],
|
||||
"settings": {
|
||||
"react": {
|
||||
"createClass": "createReactClass", // Regex for Component Factory to use,
|
||||
// default to "createReactClass"
|
||||
"pragma": "React", // Pragma to use, default to "React"
|
||||
"fragment": "Fragment", // Fragment to use (may be a property of <pragma>), default to "Fragment"
|
||||
"version": "detect", // React version. "detect" automatically picks the version you have installed.
|
||||
// You can also use `16.0`, `16.3`, etc, if you want to override the detected value.
|
||||
// It will default to "latest" and warn if missing, and to "detect" in the future
|
||||
"flowVersion": "0.53" // Flow version
|
||||
},
|
||||
"import/resolver": {
|
||||
"node": {
|
||||
"moduleDirectory": ["node_modules", "src/"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"parser": "@babel/eslint-parser",
|
||||
"parserOptions": {
|
||||
"ecmaFeatures": {
|
||||
"experimentalObjectRestSpread": true,
|
||||
"impliedStrict": true,
|
||||
"jsx": true
|
||||
},
|
||||
"ecmaVersion": 12
|
||||
},
|
||||
"plugins": ["prettier", "react", "react-hooks"],
|
||||
"rules": {
|
||||
"react/jsx-uses-react": "error",
|
||||
"react/jsx-uses-vars": "error",
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"no-undef": "off",
|
||||
"react/display-name": "off",
|
||||
"react/jsx-filename-extension": "off",
|
||||
"no-param-reassign": "off",
|
||||
"react/prop-types": 1,
|
||||
"react/require-default-props": "off",
|
||||
"react/no-array-index-key": "off",
|
||||
"react/jsx-props-no-spreading": "off",
|
||||
"react/forbid-prop-types": "off",
|
||||
"import/order": "off",
|
||||
"import/no-cycle": "off",
|
||||
"no-console": "off",
|
||||
"jsx-a11y/anchor-is-valid": "off",
|
||||
"prefer-destructuring": "off",
|
||||
"no-shadow": "off",
|
||||
"import/no-named-as-default": "off",
|
||||
"import/no-extraneous-dependencies": "off",
|
||||
"jsx-a11y/no-autofocus": "off",
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"patterns": ["@mui/*/*/*", "!@mui/material/test-utils/*"]
|
||||
}
|
||||
],
|
||||
"no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
"ignoreRestSiblings": false
|
||||
}
|
||||
],
|
||||
"prettier/prettier": [
|
||||
"warn",
|
||||
{
|
||||
"bracketSpacing": true,
|
||||
"printWidth": 140,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"endOfLine": "auto"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
8
web/.prettierrc
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"bracketSpacing": true,
|
||||
"printWidth": 140,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"tabWidth": 2,
|
||||
"useTabs": false
|
||||
}
|
@ -1,21 +1,14 @@
|
||||
# React Template
|
||||
# One API 前端界面
|
||||
|
||||
## Basic Usages
|
||||
这个项目是 One API 的前端界面,它基于 [Berry Free React Admin Template](https://github.com/codedthemes/berry-free-react-admin-template) 进行开发。
|
||||
|
||||
```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`.
|
||||
- [Berry Free React Admin Template](https://github.com/codedthemes/berry-free-react-admin-template)
|
||||
- [minimal-ui-kit](minimal-ui-kit)
|
||||
|
||||
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
|
||||
本项目中使用的代码遵循 MIT 许可证。
|
||||
|
9
web/jsconfig.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"module": "commonjs",
|
||||
"baseUrl": "src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
@ -1,20 +1,42 @@
|
||||
{
|
||||
"name": "react-template",
|
||||
"version": "0.1.0",
|
||||
"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-dropzone": "^14.2.3",
|
||||
"react-router-dom": "^6.3.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"react-toastify": "^9.0.8",
|
||||
"react-turnstile": "^1.0.5",
|
||||
"semantic-ui-css": "^2.5.0",
|
||||
"semantic-ui-react": "^2.1.3"
|
||||
"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",
|
||||
@ -24,15 +46,18 @@
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
"react-app"
|
||||
]
|
||||
},
|
||||
"babel": {
|
||||
"presets": [
|
||||
"@babel/preset-react"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
"defaults",
|
||||
"not IE 11"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
@ -41,11 +66,19 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"prettier": "^2.7.1"
|
||||
},
|
||||
"prettier": {
|
||||
"singleQuote": true,
|
||||
"jsxSingleQuote": true
|
||||
},
|
||||
"proxy": "http://localhost:3000"
|
||||
"@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"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 40 KiB |
@ -1,18 +1,26 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>One API</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<!-- Meta Tags-->
|
||||
<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="theme-color" content="#2296f3" />
|
||||
<meta
|
||||
name="description"
|
||||
content="OpenAI 接口聚合管理,支持多种渠道包括 Azure,可用于二次分发管理 key,仅单可执行文件,已打包好 Docker 镜像,一键部署,开箱即用"
|
||||
/>
|
||||
<title>One API</title>
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Poppins:wght@400;500;600;700&family=Roboto:wght@400;500;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
Before Width: | Height: | Size: 7.9 KiB |
@ -1,3 +0,0 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
318
web/src/App.js
@ -1,293 +1,43 @@
|
||||
import React, { lazy, Suspense, useContext, useEffect } from 'react';
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
import Loading from './components/Loading';
|
||||
import User from './pages/User';
|
||||
import { PrivateRoute } from './components/PrivateRoute';
|
||||
import RegisterForm from './components/RegisterForm';
|
||||
import LoginForm from './components/LoginForm';
|
||||
import NotFound from './pages/NotFound';
|
||||
import Setting from './pages/Setting';
|
||||
import EditUser from './pages/User/EditUser';
|
||||
import AddUser from './pages/User/AddUser';
|
||||
import { API, getLogo, getSystemName, showError, showNotice } from './helpers';
|
||||
import PasswordResetForm from './components/PasswordResetForm';
|
||||
import GitHubOAuth from './components/GitHubOAuth';
|
||||
import PasswordResetConfirm from './components/PasswordResetConfirm';
|
||||
import { UserContext } from './context/User';
|
||||
import { StatusContext } from './context/Status';
|
||||
import Channel from './pages/Channel';
|
||||
import Token from './pages/Token';
|
||||
import EditToken from './pages/Token/EditToken';
|
||||
import EditChannel from './pages/Channel/EditChannel';
|
||||
import Redemption from './pages/Redemption';
|
||||
import EditRedemption from './pages/Redemption/EditRedemption';
|
||||
import TopUp from './pages/TopUp';
|
||||
import Log from './pages/Log';
|
||||
import Chat from './pages/Chat';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
const Home = lazy(() => import('./pages/Home'));
|
||||
const About = lazy(() => import('./pages/About'));
|
||||
import { ThemeProvider } from '@mui/material/styles';
|
||||
import { CssBaseline, StyledEngineProvider } from '@mui/material';
|
||||
|
||||
function App() {
|
||||
const [userState, userDispatch] = useContext(UserContext);
|
||||
const [statusState, statusDispatch] = useContext(StatusContext);
|
||||
// routing
|
||||
import Routes from 'routes';
|
||||
|
||||
const loadUser = () => {
|
||||
let user = localStorage.getItem('user');
|
||||
if (user) {
|
||||
let data = JSON.parse(user);
|
||||
userDispatch({ type: 'login', payload: data });
|
||||
}
|
||||
};
|
||||
const loadStatus = async () => {
|
||||
const res = await API.get('/api/status');
|
||||
const { success, data } = res.data;
|
||||
if (success) {
|
||||
localStorage.setItem('status', JSON.stringify(data));
|
||||
statusDispatch({ type: 'set', payload: data });
|
||||
localStorage.setItem('system_name', data.system_name);
|
||||
localStorage.setItem('logo', data.logo);
|
||||
localStorage.setItem('footer_html', data.footer_html);
|
||||
localStorage.setItem('quota_per_unit', data.quota_per_unit);
|
||||
localStorage.setItem('display_in_currency', data.display_in_currency);
|
||||
if (data.chat_link) {
|
||||
localStorage.setItem('chat_link', data.chat_link);
|
||||
} else {
|
||||
localStorage.removeItem('chat_link');
|
||||
}
|
||||
if (
|
||||
data.version !== process.env.REACT_APP_VERSION &&
|
||||
data.version !== 'v0.0.0' &&
|
||||
process.env.REACT_APP_VERSION !== ''
|
||||
) {
|
||||
showNotice(
|
||||
`新版本可用:${data.version},请使用快捷键 Shift + F5 刷新页面`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
showError('无法正常连接至服务器!');
|
||||
}
|
||||
};
|
||||
// defaultTheme
|
||||
import themes from 'themes';
|
||||
|
||||
useEffect(() => {
|
||||
loadUser();
|
||||
loadStatus().then();
|
||||
let systemName = getSystemName();
|
||||
if (systemName) {
|
||||
document.title = systemName;
|
||||
}
|
||||
let logo = getLogo();
|
||||
if (logo) {
|
||||
let linkElement = document.querySelector("link[rel~='icon']");
|
||||
if (linkElement) {
|
||||
linkElement.href = logo;
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
// 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 customization = useSelector((state) => state.customization);
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route
|
||||
path='/'
|
||||
element={
|
||||
<Suspense fallback={<Loading></Loading>}>
|
||||
<Home />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/channel'
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<Channel />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/channel/edit/:id'
|
||||
element={
|
||||
<Suspense fallback={<Loading></Loading>}>
|
||||
<EditChannel />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/channel/add'
|
||||
element={
|
||||
<Suspense fallback={<Loading></Loading>}>
|
||||
<EditChannel />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/token'
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<Token />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/token/edit/:id'
|
||||
element={
|
||||
<Suspense fallback={<Loading></Loading>}>
|
||||
<EditToken />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/token/add'
|
||||
element={
|
||||
<Suspense fallback={<Loading></Loading>}>
|
||||
<EditToken />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/redemption'
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<Redemption />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/redemption/edit/:id'
|
||||
element={
|
||||
<Suspense fallback={<Loading></Loading>}>
|
||||
<EditRedemption />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/redemption/add'
|
||||
element={
|
||||
<Suspense fallback={<Loading></Loading>}>
|
||||
<EditRedemption />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/user'
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<User />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/user/edit/:id'
|
||||
element={
|
||||
<Suspense fallback={<Loading></Loading>}>
|
||||
<EditUser />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/user/edit'
|
||||
element={
|
||||
<Suspense fallback={<Loading></Loading>}>
|
||||
<EditUser />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/user/add'
|
||||
element={
|
||||
<Suspense fallback={<Loading></Loading>}>
|
||||
<AddUser />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/user/reset'
|
||||
element={
|
||||
<Suspense fallback={<Loading></Loading>}>
|
||||
<PasswordResetConfirm />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/login'
|
||||
element={
|
||||
<Suspense fallback={<Loading></Loading>}>
|
||||
<LoginForm />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/register'
|
||||
element={
|
||||
<Suspense fallback={<Loading></Loading>}>
|
||||
<RegisterForm />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/reset'
|
||||
element={
|
||||
<Suspense fallback={<Loading></Loading>}>
|
||||
<PasswordResetForm />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/oauth/github'
|
||||
element={
|
||||
<Suspense fallback={<Loading></Loading>}>
|
||||
<GitHubOAuth />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/setting'
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<Suspense fallback={<Loading></Loading>}>
|
||||
<Setting />
|
||||
</Suspense>
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/topup'
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<Suspense fallback={<Loading></Loading>}>
|
||||
<TopUp />
|
||||
</Suspense>
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/log'
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<Log />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/about'
|
||||
element={
|
||||
<Suspense fallback={<Loading></Loading>}>
|
||||
<About />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/chat'
|
||||
element={
|
||||
<Suspense fallback={<Loading></Loading>}>
|
||||
<Chat />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route path='*' element={
|
||||
<NotFound />
|
||||
} />
|
||||
</Routes>
|
||||
<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;
|
||||
|
40
web/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/src/assets/images/auth/auth-blue-card.svg
Normal file
After Width: | Height: | Size: 16 KiB |
39
web/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/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/src/assets/images/auth/auth-purple-card.svg
Normal file
After Width: | Height: | Size: 22 KiB |
1
web/src/assets/images/auth/auth-signup-blue-card.svg
Normal file
After Width: | Height: | Size: 272 KiB |
40
web/src/assets/images/auth/auth-signup-white-card.svg
Normal file
After Width: | Height: | Size: 15 KiB |
5
web/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/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 |
1
web/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/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/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/src/assets/images/invite/cover.jpg
Normal file
After Width: | Height: | Size: 86 KiB |
BIN
web/src/assets/images/invite/cwok_casual_19.webp
Normal file
After Width: | Height: | Size: 174 KiB |
15
web/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/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/src/assets/images/users/user-round.svg
Normal file
After Width: | Height: | Size: 20 KiB |
157
web/src/assets/scss/_themes-vars.module.scss
Normal file
@ -0,0 +1,157 @@
|
||||
// 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;
|
||||
|
||||
// ==============================|| DARK THEME VARIANTS ||============================== //
|
||||
|
||||
// paper & background
|
||||
$darkBackground: #1a223f; // level 3
|
||||
$darkPaper: #111936; // level 4
|
||||
|
||||
// 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;
|
||||
}
|
128
web/src/assets/scss/style.scss
Normal file
@ -0,0 +1,128 @@
|
||||
// color variants
|
||||
@import 'themes-vars.module.scss';
|
||||
|
||||
// third-party
|
||||
@import '~react-perfect-scrollbar/dist/css/styles.css';
|
||||
|
||||
// ==============================|| LIGHT BOX ||============================== //
|
||||
.fullscreen .react-images__blanket {
|
||||
z-index: 1200;
|
||||
}
|
||||
|
||||
// ==============================|| APEXCHART ||============================== //
|
||||
|
||||
.apexcharts-legend-series .apexcharts-legend-marker {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
// ==============================|| PERFECT SCROLLBAR ||============================== //
|
||||
|
||||
.scrollbar-container {
|
||||
.ps__rail-y {
|
||||
&:hover > .ps__thumb-y,
|
||||
&:focus > .ps__thumb-y,
|
||||
&.ps--clicking .ps__thumb-y {
|
||||
background-color: $grey500;
|
||||
width: 5px;
|
||||
}
|
||||
}
|
||||
.ps__thumb-y {
|
||||
background-color: $grey500;
|
||||
border-radius: 6px;
|
||||
width: 5px;
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.scrollbar-container.ps,
|
||||
.scrollbar-container > .ps {
|
||||
&.ps--active-y > .ps__rail-y {
|
||||
width: 5px;
|
||||
background-color: transparent !important;
|
||||
z-index: 999;
|
||||
&:hover,
|
||||
&.ps--clicking {
|
||||
width: 5px;
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
&.ps--scrolling-y > .ps__rail-y,
|
||||
&.ps--scrolling-x > .ps__rail-x {
|
||||
opacity: 0.4;
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
// ==============================|| ANIMATION KEYFRAMES ||============================== //
|
||||
|
||||
@keyframes wings {
|
||||
50% {
|
||||
transform: translateY(-40px);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%,
|
||||
20%,
|
||||
53%,
|
||||
to {
|
||||
animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1);
|
||||
transform: translateZ(0);
|
||||
}
|
||||
40%,
|
||||
43% {
|
||||
animation-timing-function: cubic-bezier(0.755, 0.05, 0.855, 0.06);
|
||||
transform: translate3d(0, -5px, 0);
|
||||
}
|
||||
70% {
|
||||
animation-timing-function: cubic-bezier(0.755, 0.05, 0.855, 0.06);
|
||||
transform: translate3d(0, -7px, 0);
|
||||
}
|
||||
80% {
|
||||
transition-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1);
|
||||
transform: translateZ(0);
|
||||
}
|
||||
90% {
|
||||
transform: translate3d(0, -2px, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideY {
|
||||
0%,
|
||||
50%,
|
||||
100% {
|
||||
transform: translateY(0px);
|
||||
}
|
||||
25% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
75% {
|
||||
transform: translateY(10px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideX {
|
||||
0%,
|
||||
50%,
|
||||
100% {
|
||||
transform: translateX(0px);
|
||||
}
|
||||
25% {
|
||||
transform: translateX(-10px);
|
||||
}
|
||||
75% {
|
||||
transform: translateX(10px);
|
||||
}
|
||||
}
|
@ -1,564 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, Form, Input, Label, Message, Pagination, Popup, Table } from 'semantic-ui-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { API, setPromptShown, shouldShowPrompt, showError, showInfo, showSuccess, timestamp2string } from '../helpers';
|
||||
|
||||
import { CHANNEL_OPTIONS, ITEMS_PER_PAGE } from '../constants';
|
||||
import { renderGroup, renderNumber } from '../helpers/render';
|
||||
|
||||
function renderTimestamp(timestamp) {
|
||||
return (
|
||||
<>
|
||||
{timestamp2string(timestamp)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
let type2label = undefined;
|
||||
|
||||
function renderType(type) {
|
||||
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 <Label basic color={type2label[type]?.color}>{type2label[type]?.text}</Label>;
|
||||
}
|
||||
|
||||
function renderBalance(type, balance) {
|
||||
switch (type) {
|
||||
case 1: // OpenAI
|
||||
return <span>${balance.toFixed(2)}</span>;
|
||||
case 4: // CloseAI
|
||||
return <span>¥{balance.toFixed(2)}</span>;
|
||||
case 8: // 自定义
|
||||
return <span>${balance.toFixed(2)}</span>;
|
||||
case 5: // OpenAI-SB
|
||||
return <span>¥{(balance / 10000).toFixed(2)}</span>;
|
||||
case 10: // AI Proxy
|
||||
return <span>{renderNumber(balance)}</span>;
|
||||
case 12: // API2GPT
|
||||
return <span>¥{balance.toFixed(2)}</span>;
|
||||
case 13: // AIGC2D
|
||||
return <span>{renderNumber(balance)}</span>;
|
||||
default:
|
||||
return <span>不支持</span>;
|
||||
}
|
||||
}
|
||||
|
||||
const ChannelsTable = () => {
|
||||
const [channels, setChannels] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activePage, setActivePage] = useState(1);
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [updatingBalance, setUpdatingBalance] = useState(false);
|
||||
const [showPrompt, setShowPrompt] = useState(shouldShowPrompt("channel-test"));
|
||||
|
||||
const loadChannels = async (startIdx) => {
|
||||
const res = await API.get(`/api/channel/?p=${startIdx}`);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
if (startIdx === 0) {
|
||||
setChannels(data);
|
||||
} else {
|
||||
let newChannels = [...channels];
|
||||
newChannels.splice(startIdx * ITEMS_PER_PAGE, data.length, ...data);
|
||||
setChannels(newChannels);
|
||||
}
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const onPaginationChange = (e, { activePage }) => {
|
||||
(async () => {
|
||||
if (activePage === Math.ceil(channels.length / ITEMS_PER_PAGE) + 1) {
|
||||
// In this case we have to load more data and then append them.
|
||||
await loadChannels(activePage - 1);
|
||||
}
|
||||
setActivePage(activePage);
|
||||
})();
|
||||
};
|
||||
|
||||
const refresh = async () => {
|
||||
setLoading(true);
|
||||
await loadChannels(activePage - 1);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadChannels(0)
|
||||
.then()
|
||||
.catch((reason) => {
|
||||
showError(reason);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const manageChannel = async (id, action, idx, value) => {
|
||||
let data = { id };
|
||||
let res;
|
||||
switch (action) {
|
||||
case 'delete':
|
||||
res = await API.delete(`/api/channel/${id}/`);
|
||||
break;
|
||||
case 'enable':
|
||||
data.status = 1;
|
||||
res = await API.put('/api/channel/', data);
|
||||
break;
|
||||
case 'disable':
|
||||
data.status = 2;
|
||||
res = await API.put('/api/channel/', data);
|
||||
break;
|
||||
case 'priority':
|
||||
if (value === '') {
|
||||
return;
|
||||
}
|
||||
data.priority = parseInt(value);
|
||||
res = await API.put('/api/channel/', data);
|
||||
break;
|
||||
case 'weight':
|
||||
if (value === '') {
|
||||
return;
|
||||
}
|
||||
data.weight = parseInt(value);
|
||||
if (data.weight < 0) {
|
||||
data.weight = 0;
|
||||
}
|
||||
res = await API.put('/api/channel/', data);
|
||||
break;
|
||||
}
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess('操作成功完成!');
|
||||
let channel = res.data.data;
|
||||
let newChannels = [...channels];
|
||||
let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx;
|
||||
if (action === 'delete') {
|
||||
newChannels[realIdx].deleted = true;
|
||||
} else {
|
||||
newChannels[realIdx].status = channel.status;
|
||||
}
|
||||
setChannels(newChannels);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
};
|
||||
|
||||
const renderStatus = (status) => {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return <Label basic color='green'>已启用</Label>;
|
||||
case 2:
|
||||
return (
|
||||
<Popup
|
||||
trigger={<Label basic color='red'>
|
||||
已禁用
|
||||
</Label>}
|
||||
content='本渠道被手动禁用'
|
||||
basic
|
||||
/>
|
||||
);
|
||||
case 3:
|
||||
return (
|
||||
<Popup
|
||||
trigger={<Label basic color='yellow'>
|
||||
已禁用
|
||||
</Label>}
|
||||
content='本渠道被程序自动禁用'
|
||||
basic
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Label basic color='grey'>
|
||||
未知状态
|
||||
</Label>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const renderResponseTime = (responseTime) => {
|
||||
let time = responseTime / 1000;
|
||||
time = time.toFixed(2) + ' 秒';
|
||||
if (responseTime === 0) {
|
||||
return <Label basic color='grey'>未测试</Label>;
|
||||
} else if (responseTime <= 1000) {
|
||||
return <Label basic color='green'>{time}</Label>;
|
||||
} else if (responseTime <= 3000) {
|
||||
return <Label basic color='olive'>{time}</Label>;
|
||||
} else if (responseTime <= 5000) {
|
||||
return <Label basic color='yellow'>{time}</Label>;
|
||||
} else {
|
||||
return <Label basic color='red'>{time}</Label>;
|
||||
}
|
||||
};
|
||||
|
||||
const searchChannels = async () => {
|
||||
if (searchKeyword === '') {
|
||||
// if keyword is blank, load files instead.
|
||||
await loadChannels(0);
|
||||
setActivePage(1);
|
||||
return;
|
||||
}
|
||||
setSearching(true);
|
||||
const res = await API.get(`/api/channel/search?keyword=${searchKeyword}`);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
setChannels(data);
|
||||
setActivePage(1);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
setSearching(false);
|
||||
};
|
||||
|
||||
const testChannel = async (id, name, idx) => {
|
||||
const res = await API.get(`/api/channel/test/${id}/`);
|
||||
const { success, message, time } = res.data;
|
||||
if (success) {
|
||||
let newChannels = [...channels];
|
||||
let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx;
|
||||
newChannels[realIdx].response_time = time * 1000;
|
||||
newChannels[realIdx].test_time = Date.now() / 1000;
|
||||
setChannels(newChannels);
|
||||
showInfo(`通道 ${name} 测试成功,耗时 ${time.toFixed(2)} 秒。`);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
};
|
||||
|
||||
const testAllChannels = async () => {
|
||||
const res = await API.get(`/api/channel/test`);
|
||||
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 (id, name, idx) => {
|
||||
const res = await API.get(`/api/channel/update_balance/${id}/`);
|
||||
const { success, message, balance } = res.data;
|
||||
if (success) {
|
||||
let newChannels = [...channels];
|
||||
let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx;
|
||||
newChannels[realIdx].balance = balance;
|
||||
newChannels[realIdx].balance_updated_time = Date.now() / 1000;
|
||||
setChannels(newChannels);
|
||||
showInfo(`通道 ${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 handleKeywordChange = async (e, { value }) => {
|
||||
setSearchKeyword(value.trim());
|
||||
};
|
||||
|
||||
const sortChannel = (key) => {
|
||||
if (channels.length === 0) return;
|
||||
setLoading(true);
|
||||
let sortedChannels = [...channels];
|
||||
sortedChannels.sort((a, b) => {
|
||||
if (!isNaN(a[key])) {
|
||||
// If the value is numeric, subtract to sort
|
||||
return a[key] - b[key];
|
||||
} else {
|
||||
// If the value is not numeric, sort as strings
|
||||
return ('' + a[key]).localeCompare(b[key]);
|
||||
}
|
||||
});
|
||||
if (sortedChannels[0].id === channels[0].id) {
|
||||
sortedChannels.reverse();
|
||||
}
|
||||
setChannels(sortedChannels);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form onSubmit={searchChannels}>
|
||||
<Form.Input
|
||||
icon='search'
|
||||
fluid
|
||||
iconPosition='left'
|
||||
placeholder='搜索渠道的 ID,名称和密钥 ...'
|
||||
value={searchKeyword}
|
||||
loading={searching}
|
||||
onChange={handleKeywordChange}
|
||||
/>
|
||||
</Form>
|
||||
{
|
||||
showPrompt && (
|
||||
<Message onDismiss={() => {
|
||||
setShowPrompt(false);
|
||||
setPromptShown("channel-test");
|
||||
}}>
|
||||
当前版本测试是通过按照 OpenAI API 格式使用 gpt-3.5-turbo
|
||||
模型进行非流式请求实现的,因此测试报错并不一定代表通道不可用,该功能后续会修复。
|
||||
|
||||
另外,OpenAI 渠道已经不再支持通过 key 获取余额,因此余额显示为 0。对于支持的渠道类型,请点击余额进行刷新。
|
||||
</Message>
|
||||
)
|
||||
}
|
||||
<Table basic compact size='small'>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
sortChannel('id');
|
||||
}}
|
||||
>
|
||||
ID
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
sortChannel('name');
|
||||
}}
|
||||
>
|
||||
名称
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
sortChannel('group');
|
||||
}}
|
||||
>
|
||||
分组
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
sortChannel('type');
|
||||
}}
|
||||
>
|
||||
类型
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
sortChannel('status');
|
||||
}}
|
||||
>
|
||||
状态
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
sortChannel('response_time');
|
||||
}}
|
||||
>
|
||||
响应时间
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
sortChannel('balance');
|
||||
}}
|
||||
>
|
||||
余额
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
sortChannel('priority');
|
||||
}}
|
||||
>
|
||||
优先级
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell>操作</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
|
||||
<Table.Body>
|
||||
{channels
|
||||
.slice(
|
||||
(activePage - 1) * ITEMS_PER_PAGE,
|
||||
activePage * ITEMS_PER_PAGE
|
||||
)
|
||||
.map((channel, idx) => {
|
||||
if (channel.deleted) return <></>;
|
||||
return (
|
||||
<Table.Row key={channel.id}>
|
||||
<Table.Cell>{channel.id}</Table.Cell>
|
||||
<Table.Cell>{channel.name ? channel.name : '无'}</Table.Cell>
|
||||
<Table.Cell>{renderGroup(channel.group)}</Table.Cell>
|
||||
<Table.Cell>{renderType(channel.type)}</Table.Cell>
|
||||
<Table.Cell>{renderStatus(channel.status)}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Popup
|
||||
content={channel.test_time ? renderTimestamp(channel.test_time) : '未测试'}
|
||||
key={channel.id}
|
||||
trigger={renderResponseTime(channel.response_time)}
|
||||
basic
|
||||
/>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Popup
|
||||
trigger={<span onClick={() => {
|
||||
updateChannelBalance(channel.id, channel.name, idx);
|
||||
}} style={{ cursor: 'pointer' }}>
|
||||
{renderBalance(channel.type, channel.balance)}
|
||||
</span>}
|
||||
content='点击更新'
|
||||
basic
|
||||
/>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Popup
|
||||
trigger={<Input type='number' defaultValue={channel.priority} onBlur={(event) => {
|
||||
manageChannel(
|
||||
channel.id,
|
||||
'priority',
|
||||
idx,
|
||||
event.target.value
|
||||
);
|
||||
}}>
|
||||
<input style={{ maxWidth: '60px' }} />
|
||||
</Input>}
|
||||
content='渠道选择优先级,越高越优先'
|
||||
basic
|
||||
/>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<div>
|
||||
<Button
|
||||
size={'small'}
|
||||
positive
|
||||
onClick={() => {
|
||||
testChannel(channel.id, channel.name, idx);
|
||||
}}
|
||||
>
|
||||
测试
|
||||
</Button>
|
||||
{/*<Button*/}
|
||||
{/* size={'small'}*/}
|
||||
{/* positive*/}
|
||||
{/* loading={updatingBalance}*/}
|
||||
{/* onClick={() => {*/}
|
||||
{/* updateChannelBalance(channel.id, channel.name, idx);*/}
|
||||
{/* }}*/}
|
||||
{/*>*/}
|
||||
{/* 更新余额*/}
|
||||
{/*</Button>*/}
|
||||
<Popup
|
||||
trigger={
|
||||
<Button size='small' negative>
|
||||
删除
|
||||
</Button>
|
||||
}
|
||||
on='click'
|
||||
flowing
|
||||
hoverable
|
||||
>
|
||||
<Button
|
||||
negative
|
||||
onClick={() => {
|
||||
manageChannel(channel.id, 'delete', idx);
|
||||
}}
|
||||
>
|
||||
删除渠道 {channel.name}
|
||||
</Button>
|
||||
</Popup>
|
||||
<Button
|
||||
size={'small'}
|
||||
onClick={() => {
|
||||
manageChannel(
|
||||
channel.id,
|
||||
channel.status === 1 ? 'disable' : 'enable',
|
||||
idx
|
||||
);
|
||||
}}
|
||||
>
|
||||
{channel.status === 1 ? '禁用' : '启用'}
|
||||
</Button>
|
||||
<Button
|
||||
size={'small'}
|
||||
as={Link}
|
||||
to={'/channel/edit/' + channel.id}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
);
|
||||
})}
|
||||
</Table.Body>
|
||||
|
||||
<Table.Footer>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell colSpan='9'>
|
||||
<Button size='small' as={Link} to='/channel/add' loading={loading}>
|
||||
添加新的渠道
|
||||
</Button>
|
||||
<Button size='small' loading={loading} onClick={testAllChannels}>
|
||||
测试所有已启用通道
|
||||
</Button>
|
||||
<Button size='small' onClick={updateAllChannelsBalance}
|
||||
loading={loading || updatingBalance}>更新所有已启用通道余额</Button>
|
||||
<Popup
|
||||
trigger={
|
||||
<Button size='small' loading={loading}>
|
||||
删除禁用渠道
|
||||
</Button>
|
||||
}
|
||||
on='click'
|
||||
flowing
|
||||
hoverable
|
||||
>
|
||||
<Button size='small' loading={loading} negative onClick={deleteAllDisabledChannels}>
|
||||
确认删除
|
||||
</Button>
|
||||
</Popup>
|
||||
<Pagination
|
||||
floated='right'
|
||||
activePage={activePage}
|
||||
onPageChange={onPaginationChange}
|
||||
size='small'
|
||||
siblingRange={1}
|
||||
totalPages={
|
||||
Math.ceil(channels.length / ITEMS_PER_PAGE) +
|
||||
(channels.length % ITEMS_PER_PAGE === 0 ? 1 : 0)
|
||||
}
|
||||
/>
|
||||
<Button size='small' onClick={refresh} loading={loading}>刷新</Button>
|
||||
</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Footer>
|
||||
</Table>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelsTable;
|
@ -1,61 +0,0 @@
|
||||
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>{' '}
|
||||
构建,源代码遵循{' '}
|
||||
<a href='https://opensource.org/licenses/mit-license.php'>
|
||||
MIT 协议
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</Container>
|
||||
</Segment>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
@ -1,58 +0,0 @@
|
||||
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;
|
@ -1,223 +0,0 @@
|
||||
import React, { useContext, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { UserContext } from '../context/User';
|
||||
|
||||
import { Button, Container, Dropdown, Icon, Menu, Segment } from 'semantic-ui-react';
|
||||
import { API, getLogo, getSystemName, isAdmin, isMobile, showSuccess } from '../helpers';
|
||||
import '../index.css';
|
||||
|
||||
// Header Buttons
|
||||
let headerButtons = [
|
||||
{
|
||||
name: '首页',
|
||||
to: '/',
|
||||
icon: 'home'
|
||||
},
|
||||
{
|
||||
name: '渠道',
|
||||
to: '/channel',
|
||||
icon: 'sitemap',
|
||||
admin: true
|
||||
},
|
||||
{
|
||||
name: '令牌',
|
||||
to: '/token',
|
||||
icon: 'key'
|
||||
},
|
||||
{
|
||||
name: '兑换',
|
||||
to: '/redemption',
|
||||
icon: 'dollar sign',
|
||||
admin: true
|
||||
},
|
||||
{
|
||||
name: '充值',
|
||||
to: '/topup',
|
||||
icon: 'cart'
|
||||
},
|
||||
{
|
||||
name: '用户',
|
||||
to: '/user',
|
||||
icon: 'user',
|
||||
admin: true
|
||||
},
|
||||
{
|
||||
name: '日志',
|
||||
to: '/log',
|
||||
icon: 'book'
|
||||
},
|
||||
{
|
||||
name: '设置',
|
||||
to: '/setting',
|
||||
icon: 'setting'
|
||||
},
|
||||
{
|
||||
name: '关于',
|
||||
to: '/about',
|
||||
icon: 'info circle'
|
||||
}
|
||||
];
|
||||
|
||||
if (localStorage.getItem('chat_link')) {
|
||||
headerButtons.splice(1, 0, {
|
||||
name: '聊天',
|
||||
to: '/chat',
|
||||
icon: 'comments'
|
||||
});
|
||||
}
|
||||
|
||||
const Header = () => {
|
||||
const [userState, userDispatch] = useContext(UserContext);
|
||||
let navigate = useNavigate();
|
||||
|
||||
const [showSidebar, setShowSidebar] = useState(false);
|
||||
const systemName = getSystemName();
|
||||
const logo = getLogo();
|
||||
|
||||
async function logout() {
|
||||
setShowSidebar(false);
|
||||
await API.get('/api/user/logout');
|
||||
showSuccess('注销成功!');
|
||||
userDispatch({ type: 'logout' });
|
||||
localStorage.removeItem('user');
|
||||
navigate('/login');
|
||||
}
|
||||
|
||||
const toggleSidebar = () => {
|
||||
setShowSidebar(!showSidebar);
|
||||
};
|
||||
|
||||
const renderButtons = (isMobile) => {
|
||||
return headerButtons.map((button) => {
|
||||
if (button.admin && !isAdmin()) return <></>;
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Menu.Item
|
||||
onClick={() => {
|
||||
navigate(button.to);
|
||||
setShowSidebar(false);
|
||||
}}
|
||||
>
|
||||
{button.name}
|
||||
</Menu.Item>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Menu.Item key={button.name} as={Link} to={button.to}>
|
||||
<Icon name={button.icon} />
|
||||
{button.name}
|
||||
</Menu.Item>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
if (isMobile()) {
|
||||
return (
|
||||
<>
|
||||
<Menu
|
||||
borderless
|
||||
size='large'
|
||||
style={
|
||||
showSidebar
|
||||
? {
|
||||
borderBottom: 'none',
|
||||
marginBottom: '0',
|
||||
borderTop: 'none',
|
||||
height: '51px'
|
||||
}
|
||||
: { borderTop: 'none', height: '52px' }
|
||||
}
|
||||
>
|
||||
<Container>
|
||||
<Menu.Item as={Link} to='/'>
|
||||
<img
|
||||
src={logo}
|
||||
alt='logo'
|
||||
style={{ marginRight: '0.75em' }}
|
||||
/>
|
||||
<div style={{ fontSize: '20px' }}>
|
||||
<b>{systemName}</b>
|
||||
</div>
|
||||
</Menu.Item>
|
||||
<Menu.Menu position='right'>
|
||||
<Menu.Item onClick={toggleSidebar}>
|
||||
<Icon name={showSidebar ? 'close' : 'sidebar'} />
|
||||
</Menu.Item>
|
||||
</Menu.Menu>
|
||||
</Container>
|
||||
</Menu>
|
||||
{showSidebar ? (
|
||||
<Segment style={{ marginTop: 0, borderTop: '0' }}>
|
||||
<Menu secondary vertical style={{ width: '100%', margin: 0 }}>
|
||||
{renderButtons(true)}
|
||||
<Menu.Item>
|
||||
{userState.user ? (
|
||||
<Button onClick={logout}>注销</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowSidebar(false);
|
||||
navigate('/login');
|
||||
}}
|
||||
>
|
||||
登录
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowSidebar(false);
|
||||
navigate('/register');
|
||||
}}
|
||||
>
|
||||
注册
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
</Segment>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu borderless style={{ borderTop: 'none' }}>
|
||||
<Container>
|
||||
<Menu.Item as={Link} to='/' className={'hide-on-mobile'}>
|
||||
<img src={logo} alt='logo' style={{ marginRight: '0.75em' }} />
|
||||
<div style={{ fontSize: '20px' }}>
|
||||
<b>{systemName}</b>
|
||||
</div>
|
||||
</Menu.Item>
|
||||
{renderButtons(false)}
|
||||
<Menu.Menu position='right'>
|
||||
{userState.user ? (
|
||||
<Dropdown
|
||||
text={userState.user.username}
|
||||
pointing
|
||||
className='link item'
|
||||
>
|
||||
<Dropdown.Menu>
|
||||
<Dropdown.Item onClick={logout}>注销</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
) : (
|
||||
<Menu.Item
|
||||
name='登录'
|
||||
as={Link}
|
||||
to='/login'
|
||||
className='btn btn-link'
|
||||
/>
|
||||
)}
|
||||
</Menu.Menu>
|
||||
</Container>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
@ -1,14 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Segment, Dimmer, Loader } from 'semantic-ui-react';
|
||||
|
||||
const Loading = ({ prompt: name = 'page' }) => {
|
||||
return (
|
||||
<Segment style={{ height: 100 }}>
|
||||
<Dimmer active inverted>
|
||||
<Loader indeterminate>加载{name}中...</Loader>
|
||||
</Dimmer>
|
||||
</Segment>
|
||||
);
|
||||
};
|
||||
|
||||
export default Loading;
|
@ -1,193 +0,0 @@
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import { Button, Divider, Form, Grid, Header, Image, Message, Modal, Segment } from 'semantic-ui-react';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { UserContext } from '../context/User';
|
||||
import { API, getLogo, showError, showSuccess, showWarning } from '../helpers';
|
||||
import { onGitHubOAuthClicked } from './utils';
|
||||
|
||||
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);
|
||||
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);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const [showWeChatLoginModal, setShowWeChatLoginModal] = useState(false);
|
||||
|
||||
const onWeChatLoginClicked = () => {
|
||||
setShowWeChatLoginModal(true);
|
||||
};
|
||||
|
||||
const onSubmitWeChatVerificationCode = async () => {
|
||||
const res = await API.get(
|
||||
`/api/oauth/wechat?code=${inputs.wechat_verification_code}`
|
||||
);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
userDispatch({ type: 'login', payload: data });
|
||||
localStorage.setItem('user', JSON.stringify(data));
|
||||
navigate('/');
|
||||
showSuccess('登录成功!');
|
||||
setShowWeChatLoginModal(false);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
};
|
||||
|
||||
function handleChange(e) {
|
||||
const { name, value } = e.target;
|
||||
setInputs((inputs) => ({ ...inputs, [name]: value }));
|
||||
}
|
||||
|
||||
async function handleSubmit(e) {
|
||||
setSubmitted(true);
|
||||
if (username && password) {
|
||||
const res = await API.post(`/api/user/login`, {
|
||||
username,
|
||||
password
|
||||
});
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
userDispatch({ type: 'login', payload: data });
|
||||
localStorage.setItem('user', JSON.stringify(data));
|
||||
if (username === 'root' && password === '123456') {
|
||||
navigate('/user/edit');
|
||||
showSuccess('登录成功!');
|
||||
showWarning('请立刻修改默认密码!');
|
||||
} else {
|
||||
navigate('/token');
|
||||
showSuccess('登录成功!');
|
||||
}
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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='用户名'
|
||||
name='username'
|
||||
value={username}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<Form.Input
|
||||
fluid
|
||||
icon='lock'
|
||||
iconPosition='left'
|
||||
placeholder='密码'
|
||||
name='password'
|
||||
type='password'
|
||||
value={password}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<Button color='green' fluid size='large' onClick={handleSubmit}>
|
||||
登录
|
||||
</Button>
|
||||
</Segment>
|
||||
</Form>
|
||||
<Message>
|
||||
忘记密码?
|
||||
<Link to='/reset' className='btn btn-link'>
|
||||
点击重置
|
||||
</Link>
|
||||
; 没有账户?
|
||||
<Link to='/register' className='btn btn-link'>
|
||||
点击注册
|
||||
</Link>
|
||||
</Message>
|
||||
{status.github_oauth || status.wechat_login ? (
|
||||
<>
|
||||
<Divider horizontal>Or</Divider>
|
||||
{status.github_oauth ? (
|
||||
<Button
|
||||
circular
|
||||
color='black'
|
||||
icon='github'
|
||||
onClick={() => onGitHubOAuthClicked(status.github_client_id)}
|
||||
/>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
{status.wechat_login ? (
|
||||
<Button
|
||||
circular
|
||||
color='green'
|
||||
icon='wechat'
|
||||
onClick={onWeChatLoginClicked}
|
||||
/>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
<Modal
|
||||
onClose={() => setShowWeChatLoginModal(false)}
|
||||
onOpen={() => setShowWeChatLoginModal(true)}
|
||||
open={showWeChatLoginModal}
|
||||
size={'mini'}
|
||||
>
|
||||
<Modal.Content>
|
||||
<Modal.Description>
|
||||
<Image src={status.wechat_qrcode} fluid />
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<p>
|
||||
微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)
|
||||
</p>
|
||||
</div>
|
||||
<Form size='large'>
|
||||
<Form.Input
|
||||
fluid
|
||||
placeholder='验证码'
|
||||
name='wechat_verification_code'
|
||||
value={inputs.wechat_verification_code}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<Button
|
||||
color=''
|
||||
fluid
|
||||
size='large'
|
||||
onClick={onSubmitWeChatVerificationCode}
|
||||
>
|
||||
登录
|
||||
</Button>
|
||||
</Form>
|
||||
</Modal.Description>
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
</Grid.Column>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginForm;
|
@ -1,403 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, Form, Header, Label, Pagination, Segment, Select, Table } from 'semantic-ui-react';
|
||||
import { API, isAdmin, showError, timestamp2string } from '../helpers';
|
||||
|
||||
import { ITEMS_PER_PAGE } from '../constants';
|
||||
import { renderQuota } from '../helpers/render';
|
||||
|
||||
function renderTimestamp(timestamp) {
|
||||
return (
|
||||
<>
|
||||
{timestamp2string(timestamp)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const MODE_OPTIONS = [
|
||||
{ key: 'all', text: '全部用户', value: 'all' },
|
||||
{ key: 'self', text: '当前用户', value: 'self' }
|
||||
];
|
||||
|
||||
const LOG_OPTIONS = [
|
||||
{ key: '0', text: '全部', value: 0 },
|
||||
{ key: '1', text: '充值', value: 1 },
|
||||
{ key: '2', text: '消费', value: 2 },
|
||||
{ key: '3', text: '管理', value: 3 },
|
||||
{ key: '4', text: '系统', value: 4 }
|
||||
];
|
||||
|
||||
function renderType(type) {
|
||||
switch (type) {
|
||||
case 1:
|
||||
return <Label basic color='green'> 充值 </Label>;
|
||||
case 2:
|
||||
return <Label basic color='olive'> 消费 </Label>;
|
||||
case 3:
|
||||
return <Label basic color='orange'> 管理 </Label>;
|
||||
case 4:
|
||||
return <Label basic color='purple'> 系统 </Label>;
|
||||
default:
|
||||
return <Label basic color='black'> 未知 </Label>;
|
||||
}
|
||||
}
|
||||
|
||||
const LogsTable = () => {
|
||||
const [logs, setLogs] = useState([]);
|
||||
const [showStat, setShowStat] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activePage, setActivePage] = useState(1);
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [logType, setLogType] = useState(0);
|
||||
const isAdminUser = isAdmin();
|
||||
let now = new Date();
|
||||
const [inputs, setInputs] = useState({
|
||||
username: '',
|
||||
token_name: '',
|
||||
model_name: '',
|
||||
start_timestamp: timestamp2string(0),
|
||||
end_timestamp: timestamp2string(now.getTime() / 1000 + 3600),
|
||||
channel: ''
|
||||
});
|
||||
const { username, token_name, model_name, start_timestamp, end_timestamp, channel } = inputs;
|
||||
|
||||
const [stat, setStat] = useState({
|
||||
quota: 0,
|
||||
token: 0
|
||||
});
|
||||
|
||||
const handleInputChange = (e, { name, value }) => {
|
||||
setInputs((inputs) => ({ ...inputs, [name]: value }));
|
||||
};
|
||||
|
||||
const getLogSelfStat = async () => {
|
||||
let localStartTimestamp = Date.parse(start_timestamp) / 1000;
|
||||
let localEndTimestamp = Date.parse(end_timestamp) / 1000;
|
||||
let res = await API.get(`/api/log/self/stat?type=${logType}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
setStat(data);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
};
|
||||
|
||||
const getLogStat = async () => {
|
||||
let localStartTimestamp = Date.parse(start_timestamp) / 1000;
|
||||
let localEndTimestamp = Date.parse(end_timestamp) / 1000;
|
||||
let res = await API.get(`/api/log/stat?type=${logType}&username=${username}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&channel=${channel}`);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
setStat(data);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEyeClick = async () => {
|
||||
if (!showStat) {
|
||||
if (isAdminUser) {
|
||||
await getLogStat();
|
||||
} else {
|
||||
await getLogSelfStat();
|
||||
}
|
||||
}
|
||||
setShowStat(!showStat);
|
||||
};
|
||||
|
||||
const loadLogs = async (startIdx) => {
|
||||
let url = '';
|
||||
let localStartTimestamp = Date.parse(start_timestamp) / 1000;
|
||||
let localEndTimestamp = Date.parse(end_timestamp) / 1000;
|
||||
if (isAdminUser) {
|
||||
url = `/api/log/?p=${startIdx}&type=${logType}&username=${username}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&channel=${channel}`;
|
||||
} else {
|
||||
url = `/api/log/self/?p=${startIdx}&type=${logType}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
|
||||
}
|
||||
const res = await API.get(url);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
if (startIdx === 0) {
|
||||
setLogs(data);
|
||||
} else {
|
||||
let newLogs = [...logs];
|
||||
newLogs.splice(startIdx * ITEMS_PER_PAGE, data.length, ...data);
|
||||
setLogs(newLogs);
|
||||
}
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const onPaginationChange = (e, { activePage }) => {
|
||||
(async () => {
|
||||
if (activePage === Math.ceil(logs.length / ITEMS_PER_PAGE) + 1) {
|
||||
// In this case we have to load more data and then append them.
|
||||
await loadLogs(activePage - 1);
|
||||
}
|
||||
setActivePage(activePage);
|
||||
})();
|
||||
};
|
||||
|
||||
const refresh = async () => {
|
||||
setLoading(true);
|
||||
setActivePage(1);
|
||||
await loadLogs(0);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refresh().then();
|
||||
}, [logType]);
|
||||
|
||||
const searchLogs = async () => {
|
||||
if (searchKeyword === '') {
|
||||
// if keyword is blank, load files instead.
|
||||
await loadLogs(0);
|
||||
setActivePage(1);
|
||||
return;
|
||||
}
|
||||
setSearching(true);
|
||||
const res = await API.get(`/api/log/self/search?keyword=${searchKeyword}`);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
setLogs(data);
|
||||
setActivePage(1);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
setSearching(false);
|
||||
};
|
||||
|
||||
const handleKeywordChange = async (e, { value }) => {
|
||||
setSearchKeyword(value.trim());
|
||||
};
|
||||
|
||||
const sortLog = (key) => {
|
||||
if (logs.length === 0) return;
|
||||
setLoading(true);
|
||||
let sortedLogs = [...logs];
|
||||
if (typeof sortedLogs[0][key] === 'string') {
|
||||
sortedLogs.sort((a, b) => {
|
||||
return ('' + a[key]).localeCompare(b[key]);
|
||||
});
|
||||
} else {
|
||||
sortedLogs.sort((a, b) => {
|
||||
if (a[key] === b[key]) return 0;
|
||||
if (a[key] > b[key]) return -1;
|
||||
if (a[key] < b[key]) return 1;
|
||||
});
|
||||
}
|
||||
if (sortedLogs[0].id === logs[0].id) {
|
||||
sortedLogs.reverse();
|
||||
}
|
||||
setLogs(sortedLogs);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Segment>
|
||||
<Header as='h3'>
|
||||
使用明细(总消耗额度:
|
||||
{showStat && renderQuota(stat.quota)}
|
||||
{!showStat && <span onClick={handleEyeClick} style={{ cursor: 'pointer', color: 'gray' }}>点击查看</span>}
|
||||
)
|
||||
</Header>
|
||||
<Form>
|
||||
<Form.Group>
|
||||
<Form.Input fluid label={'令牌名称'} width={3} value={token_name}
|
||||
placeholder={'可选值'} name='token_name' onChange={handleInputChange} />
|
||||
<Form.Input fluid label='模型名称' width={3} value={model_name} placeholder='可选值'
|
||||
name='model_name'
|
||||
onChange={handleInputChange} />
|
||||
<Form.Input fluid label='起始时间' width={4} value={start_timestamp} type='datetime-local'
|
||||
name='start_timestamp'
|
||||
onChange={handleInputChange} />
|
||||
<Form.Input fluid label='结束时间' width={4} value={end_timestamp} type='datetime-local'
|
||||
name='end_timestamp'
|
||||
onChange={handleInputChange} />
|
||||
<Form.Button fluid label='操作' width={2} onClick={refresh}>查询</Form.Button>
|
||||
</Form.Group>
|
||||
{
|
||||
isAdminUser && <>
|
||||
<Form.Group>
|
||||
<Form.Input fluid label={'渠道 ID'} width={3} value={channel}
|
||||
placeholder='可选值' name='channel'
|
||||
onChange={handleInputChange} />
|
||||
<Form.Input fluid label={'用户名称'} width={3} value={username}
|
||||
placeholder={'可选值'} name='username'
|
||||
onChange={handleInputChange} />
|
||||
|
||||
</Form.Group>
|
||||
</>
|
||||
}
|
||||
</Form>
|
||||
<Table basic compact size='small'>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
sortLog('created_time');
|
||||
}}
|
||||
width={3}
|
||||
>
|
||||
时间
|
||||
</Table.HeaderCell>
|
||||
{
|
||||
isAdminUser && <Table.HeaderCell
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
sortLog('channel');
|
||||
}}
|
||||
width={1}
|
||||
>
|
||||
渠道
|
||||
</Table.HeaderCell>
|
||||
}
|
||||
{
|
||||
isAdminUser && <Table.HeaderCell
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
sortLog('username');
|
||||
}}
|
||||
width={1}
|
||||
>
|
||||
用户
|
||||
</Table.HeaderCell>
|
||||
}
|
||||
<Table.HeaderCell
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
sortLog('token_name');
|
||||
}}
|
||||
width={1}
|
||||
>
|
||||
令牌
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
sortLog('type');
|
||||
}}
|
||||
width={1}
|
||||
>
|
||||
类型
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
sortLog('model_name');
|
||||
}}
|
||||
width={2}
|
||||
>
|
||||
模型
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
sortLog('prompt_tokens');
|
||||
}}
|
||||
width={1}
|
||||
>
|
||||
提示
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
sortLog('completion_tokens');
|
||||
}}
|
||||
width={1}
|
||||
>
|
||||
补全
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
sortLog('quota');
|
||||
}}
|
||||
width={1}
|
||||
>
|
||||
额度
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
sortLog('content');
|
||||
}}
|
||||
width={isAdminUser ? 4 : 6}
|
||||
>
|
||||
详情
|
||||
</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
|
||||
<Table.Body>
|
||||
{logs
|
||||
.slice(
|
||||
(activePage - 1) * ITEMS_PER_PAGE,
|
||||
activePage * ITEMS_PER_PAGE
|
||||
)
|
||||
.map((log, idx) => {
|
||||
if (log.deleted) return <></>;
|
||||
return (
|
||||
<Table.Row key={log.id}>
|
||||
<Table.Cell>{renderTimestamp(log.created_at)}</Table.Cell>
|
||||
{
|
||||
isAdminUser && (
|
||||
<Table.Cell>{log.channel ? <Label basic>{log.channel}</Label> : ''}</Table.Cell>
|
||||
)
|
||||
}
|
||||
{
|
||||
isAdminUser && (
|
||||
<Table.Cell>{log.username ? <Label>{log.username}</Label> : ''}</Table.Cell>
|
||||
)
|
||||
}
|
||||
<Table.Cell>{log.token_name ? <Label basic>{log.token_name}</Label> : ''}</Table.Cell>
|
||||
<Table.Cell>{renderType(log.type)}</Table.Cell>
|
||||
<Table.Cell>{log.model_name ? <Label basic>{log.model_name}</Label> : ''}</Table.Cell>
|
||||
<Table.Cell>{log.prompt_tokens ? log.prompt_tokens : ''}</Table.Cell>
|
||||
<Table.Cell>{log.completion_tokens ? log.completion_tokens : ''}</Table.Cell>
|
||||
<Table.Cell>{log.quota ? renderQuota(log.quota, 6) : ''}</Table.Cell>
|
||||
<Table.Cell>{log.content}</Table.Cell>
|
||||
</Table.Row>
|
||||
);
|
||||
})}
|
||||
</Table.Body>
|
||||
|
||||
<Table.Footer>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell colSpan={'10'}>
|
||||
<Select
|
||||
placeholder='选择明细分类'
|
||||
options={LOG_OPTIONS}
|
||||
style={{ marginRight: '8px' }}
|
||||
name='logType'
|
||||
value={logType}
|
||||
onChange={(e, { name, value }) => {
|
||||
setLogType(value);
|
||||
}}
|
||||
/>
|
||||
<Button size='small' onClick={refresh} loading={loading}>刷新</Button>
|
||||
<Pagination
|
||||
floated='right'
|
||||
activePage={activePage}
|
||||
onPageChange={onPaginationChange}
|
||||
size='small'
|
||||
siblingRange={1}
|
||||
totalPages={
|
||||
Math.ceil(logs.length / ITEMS_PER_PAGE) +
|
||||
(logs.length % ITEMS_PER_PAGE === 0 ? 1 : 0)
|
||||
}
|
||||
/>
|
||||
</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Footer>
|
||||
</Table>
|
||||
</Segment>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default LogsTable;
|
@ -1,360 +0,0 @@
|
||||
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: '',
|
||||
GroupRatio: '',
|
||||
TopUpLink: '',
|
||||
ChatLink: '',
|
||||
QuotaPerUnit: 0,
|
||||
AutomaticDisableChannelEnabled: '',
|
||||
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.value = JSON.stringify(JSON.parse(item.value), null, 2);
|
||||
}
|
||||
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);
|
||||
}
|
||||
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.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='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;
|
@ -1,207 +0,0 @@
|
||||
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';
|
||||
|
||||
const OtherSetting = () => {
|
||||
let [inputs, setInputs] = useState({
|
||||
Footer: '',
|
||||
Notice: '',
|
||||
About: '',
|
||||
SystemName: '',
|
||||
Logo: '',
|
||||
HomePageContent: ''
|
||||
});
|
||||
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 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='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;
|
@ -1,113 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, Form, Grid, Header, Image, Segment } from 'semantic-ui-react';
|
||||
import { API, copy, showError, showInfo, showNotice, showSuccess } 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;
|
@ -1,102 +0,0 @@
|
||||
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;
|
@ -1,376 +0,0 @@
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import { Button, Divider, Form, Header, Image, Message, Modal } from 'semantic-ui-react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { API, copy, showError, showInfo, showNotice, showSuccess } from '../helpers';
|
||||
import Turnstile from 'react-turnstile';
|
||||
import { UserContext } from '../context/User';
|
||||
import { onGitHubOAuthClicked } from './utils';
|
||||
|
||||
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: ''
|
||||
});
|
||||
const [status, setStatus] = useState({});
|
||||
const [showWeChatBindModal, setShowWeChatBindModal] = useState(false);
|
||||
const [showEmailBindModal, setShowEmailBindModal] = useState(false);
|
||||
const [showAccountDeleteModal, setShowAccountDeleteModal] = useState(false);
|
||||
const [turnstileEnabled, setTurnstileEnabled] = useState(false);
|
||||
const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
|
||||
const [turnstileToken, setTurnstileToken] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [disableButton, setDisableButton] = useState(false);
|
||||
const [countdown, setCountdown] = useState(30);
|
||||
const [affLink, setAffLink] = useState("");
|
||||
const [systemToken, setSystemToken] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let status = localStorage.getItem('status');
|
||||
if (status) {
|
||||
status = JSON.parse(status);
|
||||
setStatus(status);
|
||||
if (status.turnstile_check) {
|
||||
setTurnstileEnabled(true);
|
||||
setTurnstileSiteKey(status.turnstile_site_key);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let countdownInterval = null;
|
||||
if (disableButton && countdown > 0) {
|
||||
countdownInterval = setInterval(() => {
|
||||
setCountdown(countdown - 1);
|
||||
}, 1000);
|
||||
} else if (countdown === 0) {
|
||||
setDisableButton(false);
|
||||
setCountdown(30);
|
||||
}
|
||||
return () => clearInterval(countdownInterval); // Clean up on unmount
|
||||
}, [disableButton, countdown]);
|
||||
|
||||
const handleInputChange = (e, { name, value }) => {
|
||||
setInputs((inputs) => ({ ...inputs, [name]: value }));
|
||||
};
|
||||
|
||||
const generateAccessToken = async () => {
|
||||
const res = await API.get('/api/user/token');
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
setSystemToken(data);
|
||||
setAffLink("");
|
||||
await copy(data);
|
||||
showSuccess(`令牌已重置并已复制到剪贴板`);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
};
|
||||
|
||||
const getAffLink = async () => {
|
||||
const res = await API.get('/api/user/aff');
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
let link = `${window.location.origin}/register?aff=${data}`;
|
||||
setAffLink(link);
|
||||
setSystemToken("");
|
||||
await copy(link);
|
||||
showSuccess(`邀请链接已复制到剪切板`);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAffLinkClick = async (e) => {
|
||||
e.target.select();
|
||||
await copy(e.target.value);
|
||||
showSuccess(`邀请链接已复制到剪切板`);
|
||||
};
|
||||
|
||||
const handleSystemTokenClick = async (e) => {
|
||||
e.target.select();
|
||||
await copy(e.target.value);
|
||||
showSuccess(`系统令牌已复制到剪切板`);
|
||||
};
|
||||
|
||||
const deleteAccount = async () => {
|
||||
if (inputs.self_account_deletion_confirmation !== userState.user.username) {
|
||||
showError('请输入你的账户名以确认删除!');
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await API.delete('/api/user/self');
|
||||
const { success, message } = res.data;
|
||||
|
||||
if (success) {
|
||||
showSuccess('账户已删除!');
|
||||
await API.get('/api/user/logout');
|
||||
userDispatch({ type: 'logout' });
|
||||
localStorage.removeItem('user');
|
||||
navigate('/login');
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
};
|
||||
|
||||
const bindWeChat = async () => {
|
||||
if (inputs.wechat_verification_code === '') return;
|
||||
const res = await API.get(
|
||||
`/api/oauth/wechat/bind?code=${inputs.wechat_verification_code}`
|
||||
);
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess('微信账户绑定成功!');
|
||||
setShowWeChatBindModal(false);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
};
|
||||
|
||||
const sendVerificationCode = async () => {
|
||||
setDisableButton(true);
|
||||
if (inputs.email === '') return;
|
||||
if (turnstileEnabled && turnstileToken === '') {
|
||||
showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
const res = await API.get(
|
||||
`/api/verification?email=${inputs.email}&turnstile=${turnstileToken}`
|
||||
);
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess('验证码发送成功,请检查邮箱!');
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const bindEmail = async () => {
|
||||
if (inputs.email_verification_code === '') return;
|
||||
setLoading(true);
|
||||
const res = await API.get(
|
||||
`/api/oauth/email/bind?email=${inputs.email}&code=${inputs.email_verification_code}`
|
||||
);
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess('邮箱账户绑定成功!');
|
||||
setShowEmailBindModal(false);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ lineHeight: '40px' }}>
|
||||
<Header as='h3'>通用设置</Header>
|
||||
<Message>
|
||||
注意,此处生成的令牌用于系统管理,而非用于请求 OpenAI 相关的服务,请知悉。
|
||||
</Message>
|
||||
<Button as={Link} to={`/user/edit/`}>
|
||||
更新个人信息
|
||||
</Button>
|
||||
<Button onClick={generateAccessToken}>生成系统访问令牌</Button>
|
||||
<Button onClick={getAffLink}>复制邀请链接</Button>
|
||||
<Button onClick={() => {
|
||||
setShowAccountDeleteModal(true);
|
||||
}}>删除个人账户</Button>
|
||||
|
||||
{systemToken && (
|
||||
<Form.Input
|
||||
fluid
|
||||
readOnly
|
||||
value={systemToken}
|
||||
onClick={handleSystemTokenClick}
|
||||
style={{ marginTop: '10px' }}
|
||||
/>
|
||||
)}
|
||||
{affLink && (
|
||||
<Form.Input
|
||||
fluid
|
||||
readOnly
|
||||
value={affLink}
|
||||
onClick={handleAffLinkClick}
|
||||
style={{ marginTop: '10px' }}
|
||||
/>
|
||||
)}
|
||||
<Divider />
|
||||
<Header as='h3'>账号绑定</Header>
|
||||
{
|
||||
status.wechat_login && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowWeChatBindModal(true);
|
||||
}}
|
||||
>
|
||||
绑定微信账号
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
<Modal
|
||||
onClose={() => setShowWeChatBindModal(false)}
|
||||
onOpen={() => setShowWeChatBindModal(true)}
|
||||
open={showWeChatBindModal}
|
||||
size={'mini'}
|
||||
>
|
||||
<Modal.Content>
|
||||
<Modal.Description>
|
||||
<Image src={status.wechat_qrcode} fluid />
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<p>
|
||||
微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)
|
||||
</p>
|
||||
</div>
|
||||
<Form size='large'>
|
||||
<Form.Input
|
||||
fluid
|
||||
placeholder='验证码'
|
||||
name='wechat_verification_code'
|
||||
value={inputs.wechat_verification_code}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
<Button color='' fluid size='large' onClick={bindWeChat}>
|
||||
绑定
|
||||
</Button>
|
||||
</Form>
|
||||
</Modal.Description>
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
{
|
||||
status.github_oauth && (
|
||||
<Button onClick={()=>{onGitHubOAuthClicked(status.github_client_id)}}>绑定 GitHub 账号</Button>
|
||||
)
|
||||
}
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowEmailBindModal(true);
|
||||
}}
|
||||
>
|
||||
绑定邮箱地址
|
||||
</Button>
|
||||
<Modal
|
||||
onClose={() => setShowEmailBindModal(false)}
|
||||
onOpen={() => setShowEmailBindModal(true)}
|
||||
open={showEmailBindModal}
|
||||
size={'tiny'}
|
||||
style={{ maxWidth: '450px' }}
|
||||
>
|
||||
<Modal.Header>绑定邮箱地址</Modal.Header>
|
||||
<Modal.Content>
|
||||
<Modal.Description>
|
||||
<Form size='large'>
|
||||
<Form.Input
|
||||
fluid
|
||||
placeholder='输入邮箱地址'
|
||||
onChange={handleInputChange}
|
||||
name='email'
|
||||
type='email'
|
||||
action={
|
||||
<Button onClick={sendVerificationCode} disabled={disableButton || loading}>
|
||||
{disableButton ? `重新发送(${countdown})` : '获取验证码'}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Form.Input
|
||||
fluid
|
||||
placeholder='验证码'
|
||||
name='email_verification_code'
|
||||
value={inputs.email_verification_code}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
{turnstileEnabled ? (
|
||||
<Turnstile
|
||||
sitekey={turnstileSiteKey}
|
||||
onVerify={(token) => {
|
||||
setTurnstileToken(token);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: '1rem' }}>
|
||||
<Button
|
||||
color=''
|
||||
fluid
|
||||
size='large'
|
||||
onClick={bindEmail}
|
||||
loading={loading}
|
||||
>
|
||||
确认绑定
|
||||
</Button>
|
||||
<div style={{ width: '1rem' }}></div>
|
||||
<Button
|
||||
fluid
|
||||
size='large'
|
||||
onClick={() => setShowEmailBindModal(false)}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal.Description>
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
<Modal
|
||||
onClose={() => setShowAccountDeleteModal(false)}
|
||||
onOpen={() => setShowAccountDeleteModal(true)}
|
||||
open={showAccountDeleteModal}
|
||||
size={'tiny'}
|
||||
style={{ maxWidth: '450px' }}
|
||||
>
|
||||
<Modal.Header>危险操作</Modal.Header>
|
||||
<Modal.Content>
|
||||
<Message>您正在删除自己的帐户,将清空所有数据且不可恢复</Message>
|
||||
<Modal.Description>
|
||||
<Form size='large'>
|
||||
<Form.Input
|
||||
fluid
|
||||
placeholder={`输入你的账户名 ${userState?.user?.username} 以确认删除`}
|
||||
name='self_account_deletion_confirmation'
|
||||
value={inputs.self_account_deletion_confirmation}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
{turnstileEnabled ? (
|
||||
<Turnstile
|
||||
sitekey={turnstileSiteKey}
|
||||
onVerify={(token) => {
|
||||
setTurnstileToken(token);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: '1rem' }}>
|
||||
<Button
|
||||
color='red'
|
||||
fluid
|
||||
size='large'
|
||||
onClick={deleteAccount}
|
||||
loading={loading}
|
||||
>
|
||||
确认删除
|
||||
</Button>
|
||||
<div style={{ width: '1rem' }}></div>
|
||||
<Button
|
||||
fluid
|
||||
size='large'
|
||||
onClick={() => setShowAccountDeleteModal(false)}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal.Description>
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PersonalSetting;
|
@ -1,13 +0,0 @@
|
||||
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 };
|
@ -1,320 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, Form, Label, Popup, Pagination, Table } from 'semantic-ui-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { API, copy, showError, showInfo, showSuccess, showWarning, timestamp2string } from '../helpers';
|
||||
|
||||
import { ITEMS_PER_PAGE } from '../constants';
|
||||
import { renderQuota } from '../helpers/render';
|
||||
|
||||
function renderTimestamp(timestamp) {
|
||||
return (
|
||||
<>
|
||||
{timestamp2string(timestamp)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function renderStatus(status) {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return <Label basic color='green'>未使用</Label>;
|
||||
case 2:
|
||||
return <Label basic color='red'> 已禁用 </Label>;
|
||||
case 3:
|
||||
return <Label basic color='grey'> 已使用 </Label>;
|
||||
default:
|
||||
return <Label basic color='black'> 未知状态 </Label>;
|
||||
}
|
||||
}
|
||||
|
||||
const RedemptionsTable = () => {
|
||||
const [redemptions, setRedemptions] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activePage, setActivePage] = useState(1);
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const [searching, setSearching] = useState(false);
|
||||
|
||||
const loadRedemptions = async (startIdx) => {
|
||||
const res = await API.get(`/api/redemption/?p=${startIdx}`);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
if (startIdx === 0) {
|
||||
setRedemptions(data);
|
||||
} else {
|
||||
let newRedemptions = redemptions;
|
||||
newRedemptions.push(...data);
|
||||
setRedemptions(newRedemptions);
|
||||
}
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const onPaginationChange = (e, { activePage }) => {
|
||||
(async () => {
|
||||
if (activePage === Math.ceil(redemptions.length / ITEMS_PER_PAGE) + 1) {
|
||||
// In this case we have to load more data and then append them.
|
||||
await loadRedemptions(activePage - 1);
|
||||
}
|
||||
setActivePage(activePage);
|
||||
})();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadRedemptions(0)
|
||||
.then()
|
||||
.catch((reason) => {
|
||||
showError(reason);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const manageRedemption = async (id, action, idx) => {
|
||||
let data = { id };
|
||||
let res;
|
||||
switch (action) {
|
||||
case 'delete':
|
||||
res = await API.delete(`/api/redemption/${id}/`);
|
||||
break;
|
||||
case 'enable':
|
||||
data.status = 1;
|
||||
res = await API.put('/api/redemption/?status_only=true', data);
|
||||
break;
|
||||
case 'disable':
|
||||
data.status = 2;
|
||||
res = await API.put('/api/redemption/?status_only=true', data);
|
||||
break;
|
||||
}
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess('操作成功完成!');
|
||||
let redemption = res.data.data;
|
||||
let newRedemptions = [...redemptions];
|
||||
let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx;
|
||||
if (action === 'delete') {
|
||||
newRedemptions[realIdx].deleted = true;
|
||||
} else {
|
||||
newRedemptions[realIdx].status = redemption.status;
|
||||
}
|
||||
setRedemptions(newRedemptions);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
};
|
||||
|
||||
const searchRedemptions = async () => {
|
||||
if (searchKeyword === '') {
|
||||
// if keyword is blank, load files instead.
|
||||
await loadRedemptions(0);
|
||||
setActivePage(1);
|
||||
return;
|
||||
}
|
||||
setSearching(true);
|
||||
const res = await API.get(`/api/redemption/search?keyword=${searchKeyword}`);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
setRedemptions(data);
|
||||
setActivePage(1);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
setSearching(false);
|
||||
};
|
||||
|
||||
const handleKeywordChange = async (e, { value }) => {
|
||||
setSearchKeyword(value.trim());
|
||||
};
|
||||
|
||||
const sortRedemption = (key) => {
|
||||
if (redemptions.length === 0) return;
|
||||
setLoading(true);
|
||||
let sortedRedemptions = [...redemptions];
|
||||
sortedRedemptions.sort((a, b) => {
|
||||
if (!isNaN(a[key])) {
|
||||
// If the value is numeric, subtract to sort
|
||||
return a[key] - b[key];
|
||||
} else {
|
||||
// If the value is not numeric, sort as strings
|
||||
return ('' + a[key]).localeCompare(b[key]);
|
||||
}
|
||||
});
|
||||
if (sortedRedemptions[0].id === redemptions[0].id) {
|
||||
sortedRedemptions.reverse();
|
||||
}
|
||||
setRedemptions(sortedRedemptions);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form onSubmit={searchRedemptions}>
|
||||
<Form.Input
|
||||
icon='search'
|
||||
fluid
|
||||
iconPosition='left'
|
||||
placeholder='搜索兑换码的 ID 和名称 ...'
|
||||
value={searchKeyword}
|
||||
loading={searching}
|
||||
onChange={handleKeywordChange}
|
||||
/>
|
||||
</Form>
|
||||
|
||||
<Table basic compact size='small'>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
sortRedemption('id');
|
||||
}}
|
||||
>
|
||||
ID
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
sortRedemption('name');
|
||||
}}
|
||||
>
|
||||
名称
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
sortRedemption('status');
|
||||
}}
|
||||
>
|
||||
状态
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
sortRedemption('quota');
|
||||
}}
|
||||
>
|
||||
额度
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
sortRedemption('created_time');
|
||||
}}
|
||||
>
|
||||
创建时间
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
sortRedemption('redeemed_time');
|
||||
}}
|
||||
>
|
||||
兑换时间
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell>操作</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
|
||||
<Table.Body>
|
||||
{redemptions
|
||||
.slice(
|
||||
(activePage - 1) * ITEMS_PER_PAGE,
|
||||
activePage * ITEMS_PER_PAGE
|
||||
)
|
||||
.map((redemption, idx) => {
|
||||
if (redemption.deleted) return <></>;
|
||||
return (
|
||||
<Table.Row key={redemption.id}>
|
||||
<Table.Cell>{redemption.id}</Table.Cell>
|
||||
<Table.Cell>{redemption.name ? redemption.name : '无'}</Table.Cell>
|
||||
<Table.Cell>{renderStatus(redemption.status)}</Table.Cell>
|
||||
<Table.Cell>{renderQuota(redemption.quota)}</Table.Cell>
|
||||
<Table.Cell>{renderTimestamp(redemption.created_time)}</Table.Cell>
|
||||
<Table.Cell>{redemption.redeemed_time ? renderTimestamp(redemption.redeemed_time) : "尚未兑换"} </Table.Cell>
|
||||
<Table.Cell>
|
||||
<div>
|
||||
<Button
|
||||
size={'small'}
|
||||
positive
|
||||
onClick={async () => {
|
||||
if (await copy(redemption.key)) {
|
||||
showSuccess('已复制到剪贴板!');
|
||||
} else {
|
||||
showWarning('无法复制到剪贴板,请手动复制,已将兑换码填入搜索框。')
|
||||
setSearchKeyword(redemption.key);
|
||||
}
|
||||
}}
|
||||
>
|
||||
复制
|
||||
</Button>
|
||||
<Popup
|
||||
trigger={
|
||||
<Button size='small' negative>
|
||||
删除
|
||||
</Button>
|
||||
}
|
||||
on='click'
|
||||
flowing
|
||||
hoverable
|
||||
>
|
||||
<Button
|
||||
negative
|
||||
onClick={() => {
|
||||
manageRedemption(redemption.id, 'delete', idx);
|
||||
}}
|
||||
>
|
||||
确认删除
|
||||
</Button>
|
||||
</Popup>
|
||||
<Button
|
||||
size={'small'}
|
||||
disabled={redemption.status === 3} // used
|
||||
onClick={() => {
|
||||
manageRedemption(
|
||||
redemption.id,
|
||||
redemption.status === 1 ? 'disable' : 'enable',
|
||||
idx
|
||||
);
|
||||
}}
|
||||
>
|
||||
{redemption.status === 1 ? '禁用' : '启用'}
|
||||
</Button>
|
||||
<Button
|
||||
size={'small'}
|
||||
as={Link}
|
||||
to={'/redemption/edit/' + redemption.id}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
);
|
||||
})}
|
||||
</Table.Body>
|
||||
|
||||
<Table.Footer>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell colSpan='8'>
|
||||
<Button size='small' as={Link} to='/redemption/add' loading={loading}>
|
||||
添加新的兑换码
|
||||
</Button>
|
||||
<Pagination
|
||||
floated='right'
|
||||
activePage={activePage}
|
||||
onPageChange={onPaginationChange}
|
||||
size='small'
|
||||
siblingRange={1}
|
||||
totalPages={
|
||||
Math.ceil(redemptions.length / ITEMS_PER_PAGE) +
|
||||
(redemptions.length % ITEMS_PER_PAGE === 0 ? 1 : 0)
|
||||
}
|
||||
/>
|
||||
</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Footer>
|
||||
</Table>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default RedemptionsTable;
|
@ -1,194 +0,0 @@
|
||||
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;
|
@ -1,537 +0,0 @@
|
||||
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: '',
|
||||
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 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'>
|
||||
配置 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;
|
@ -1,449 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, Dropdown, Form, Label, Pagination, Popup, Table } from 'semantic-ui-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { API, copy, showError, showSuccess, showWarning, timestamp2string } from '../helpers';
|
||||
|
||||
import { ITEMS_PER_PAGE } from '../constants';
|
||||
import { renderQuota } from '../helpers/render';
|
||||
|
||||
const COPY_OPTIONS = [
|
||||
{ key: 'next', text: 'ChatGPT Next Web', value: 'next' },
|
||||
{ key: 'ama', text: 'AMA 问天', value: 'ama' },
|
||||
{ key: 'opencat', text: 'OpenCat', value: 'opencat' },
|
||||
];
|
||||
|
||||
const OPEN_LINK_OPTIONS = [
|
||||
{ key: 'ama', text: 'AMA 问天', value: 'ama' },
|
||||
{ key: 'opencat', text: 'OpenCat', value: 'opencat' },
|
||||
];
|
||||
|
||||
function renderTimestamp(timestamp) {
|
||||
return (
|
||||
<>
|
||||
{timestamp2string(timestamp)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function renderStatus(status) {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return <Label basic color='green'>已启用</Label>;
|
||||
case 2:
|
||||
return <Label basic color='red'> 已禁用 </Label>;
|
||||
case 3:
|
||||
return <Label basic color='yellow'> 已过期 </Label>;
|
||||
case 4:
|
||||
return <Label basic color='grey'> 已耗尽 </Label>;
|
||||
default:
|
||||
return <Label basic color='black'> 未知状态 </Label>;
|
||||
}
|
||||
}
|
||||
|
||||
const TokensTable = () => {
|
||||
const [tokens, setTokens] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activePage, setActivePage] = useState(1);
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [showTopUpModal, setShowTopUpModal] = useState(false);
|
||||
const [targetTokenIdx, setTargetTokenIdx] = useState(0);
|
||||
|
||||
const loadTokens = async (startIdx) => {
|
||||
const res = await API.get(`/api/token/?p=${startIdx}`);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
if (startIdx === 0) {
|
||||
setTokens(data);
|
||||
} else {
|
||||
let newTokens = [...tokens];
|
||||
newTokens.splice(startIdx * ITEMS_PER_PAGE, data.length, ...data);
|
||||
setTokens(newTokens);
|
||||
}
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const onPaginationChange = (e, { activePage }) => {
|
||||
(async () => {
|
||||
if (activePage === Math.ceil(tokens.length / ITEMS_PER_PAGE) + 1) {
|
||||
// In this case we have to load more data and then append them.
|
||||
await loadTokens(activePage - 1);
|
||||
}
|
||||
setActivePage(activePage);
|
||||
})();
|
||||
};
|
||||
|
||||
const refresh = async () => {
|
||||
setLoading(true);
|
||||
await loadTokens(activePage - 1);
|
||||
};
|
||||
|
||||
const onCopy = async (type, key) => {
|
||||
let status = localStorage.getItem('status');
|
||||
let serverAddress = '';
|
||||
if (status) {
|
||||
status = JSON.parse(status);
|
||||
serverAddress = status.server_address;
|
||||
}
|
||||
if (serverAddress === '') {
|
||||
serverAddress = window.location.origin;
|
||||
}
|
||||
let encodedServerAddress = encodeURIComponent(serverAddress);
|
||||
const nextLink = localStorage.getItem('chat_link');
|
||||
let nextUrl;
|
||||
|
||||
if (nextLink) {
|
||||
nextUrl = nextLink + `/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`;
|
||||
} else {
|
||||
nextUrl = `https://chat.oneapi.pro/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`;
|
||||
}
|
||||
|
||||
let url;
|
||||
switch (type) {
|
||||
case 'ama':
|
||||
url = `ama://set-api-key?server=${encodedServerAddress}&key=sk-${key}`;
|
||||
break;
|
||||
case 'opencat':
|
||||
url = `opencat://team/join?domain=${encodedServerAddress}&token=sk-${key}`;
|
||||
break;
|
||||
case 'next':
|
||||
url = nextUrl;
|
||||
break;
|
||||
default:
|
||||
url = `sk-${key}`;
|
||||
}
|
||||
if (await copy(url)) {
|
||||
showSuccess('已复制到剪贴板!');
|
||||
} else {
|
||||
showWarning('无法复制到剪贴板,请手动复制,已将令牌填入搜索框。');
|
||||
setSearchKeyword(url);
|
||||
}
|
||||
};
|
||||
|
||||
const onOpenLink = async (type, key) => {
|
||||
let status = localStorage.getItem('status');
|
||||
let serverAddress = '';
|
||||
if (status) {
|
||||
status = JSON.parse(status);
|
||||
serverAddress = status.server_address;
|
||||
}
|
||||
if (serverAddress === '') {
|
||||
serverAddress = window.location.origin;
|
||||
}
|
||||
let encodedServerAddress = encodeURIComponent(serverAddress);
|
||||
const chatLink = localStorage.getItem('chat_link');
|
||||
let defaultUrl;
|
||||
|
||||
if (chatLink) {
|
||||
defaultUrl = chatLink + `/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`;
|
||||
} else {
|
||||
defaultUrl = `https://chat.oneapi.pro/#/?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;
|
||||
|
||||
default:
|
||||
url = defaultUrl;
|
||||
}
|
||||
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadTokens(0)
|
||||
.then()
|
||||
.catch((reason) => {
|
||||
showError(reason);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const manageToken = async (id, action, idx) => {
|
||||
let data = { id };
|
||||
let res;
|
||||
switch (action) {
|
||||
case 'delete':
|
||||
res = await API.delete(`/api/token/${id}/`);
|
||||
break;
|
||||
case 'enable':
|
||||
data.status = 1;
|
||||
res = await API.put('/api/token/?status_only=true', data);
|
||||
break;
|
||||
case 'disable':
|
||||
data.status = 2;
|
||||
res = await API.put('/api/token/?status_only=true', data);
|
||||
break;
|
||||
}
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess('操作成功完成!');
|
||||
let token = res.data.data;
|
||||
let newTokens = [...tokens];
|
||||
let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx;
|
||||
if (action === 'delete') {
|
||||
newTokens[realIdx].deleted = true;
|
||||
} else {
|
||||
newTokens[realIdx].status = token.status;
|
||||
}
|
||||
setTokens(newTokens);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
};
|
||||
|
||||
const searchTokens = async () => {
|
||||
if (searchKeyword === '') {
|
||||
// if keyword is blank, load files instead.
|
||||
await loadTokens(0);
|
||||
setActivePage(1);
|
||||
return;
|
||||
}
|
||||
setSearching(true);
|
||||
const res = await API.get(`/api/token/search?keyword=${searchKeyword}`);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
setTokens(data);
|
||||
setActivePage(1);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
setSearching(false);
|
||||
};
|
||||
|
||||
const handleKeywordChange = async (e, { value }) => {
|
||||
setSearchKeyword(value.trim());
|
||||
};
|
||||
|
||||
const sortToken = (key) => {
|
||||
if (tokens.length === 0) return;
|
||||
setLoading(true);
|
||||
let sortedTokens = [...tokens];
|
||||
sortedTokens.sort((a, b) => {
|
||||
if (!isNaN(a[key])) {
|
||||
// If the value is numeric, subtract to sort
|
||||
return a[key] - b[key];
|
||||
} else {
|
||||
// If the value is not numeric, sort as strings
|
||||
return ('' + a[key]).localeCompare(b[key]);
|
||||
}
|
||||
});
|
||||
if (sortedTokens[0].id === tokens[0].id) {
|
||||
sortedTokens.reverse();
|
||||
}
|
||||
setTokens(sortedTokens);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form onSubmit={searchTokens}>
|
||||
<Form.Input
|
||||
icon='search'
|
||||
fluid
|
||||
iconPosition='left'
|
||||
placeholder='搜索令牌的名称 ...'
|
||||
value={searchKeyword}
|
||||
loading={searching}
|
||||
onChange={handleKeywordChange}
|
||||
/>
|
||||
</Form>
|
||||
|
||||
<Table basic compact size='small'>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
sortToken('name');
|
||||
}}
|
||||
>
|
||||
名称
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
sortToken('status');
|
||||
}}
|
||||
>
|
||||
状态
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
sortToken('used_quota');
|
||||
}}
|
||||
>
|
||||
已用额度
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
sortToken('remain_quota');
|
||||
}}
|
||||
>
|
||||
剩余额度
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
sortToken('created_time');
|
||||
}}
|
||||
>
|
||||
创建时间
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
sortToken('expired_time');
|
||||
}}
|
||||
>
|
||||
过期时间
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell>操作</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
|
||||
<Table.Body>
|
||||
{tokens
|
||||
.slice(
|
||||
(activePage - 1) * ITEMS_PER_PAGE,
|
||||
activePage * ITEMS_PER_PAGE
|
||||
)
|
||||
.map((token, idx) => {
|
||||
if (token.deleted) return <></>;
|
||||
return (
|
||||
<Table.Row key={token.id}>
|
||||
<Table.Cell>{token.name ? token.name : '无'}</Table.Cell>
|
||||
<Table.Cell>{renderStatus(token.status)}</Table.Cell>
|
||||
<Table.Cell>{renderQuota(token.used_quota)}</Table.Cell>
|
||||
<Table.Cell>{token.unlimited_quota ? '无限制' : renderQuota(token.remain_quota, 2)}</Table.Cell>
|
||||
<Table.Cell>{renderTimestamp(token.created_time)}</Table.Cell>
|
||||
<Table.Cell>{token.expired_time === -1 ? '永不过期' : renderTimestamp(token.expired_time)}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<div>
|
||||
<Button.Group color='green' size={'small'}>
|
||||
<Button
|
||||
size={'small'}
|
||||
positive
|
||||
onClick={async () => {
|
||||
await onCopy('', token.key);
|
||||
}}
|
||||
>
|
||||
复制
|
||||
</Button>
|
||||
<Dropdown
|
||||
className='button icon'
|
||||
floating
|
||||
options={COPY_OPTIONS.map(option => ({
|
||||
...option,
|
||||
onClick: async () => {
|
||||
await onCopy(option.value, token.key);
|
||||
}
|
||||
}))}
|
||||
trigger={<></>}
|
||||
/>
|
||||
</Button.Group>
|
||||
{' '}
|
||||
<Button.Group color='blue' size={'small'}>
|
||||
<Button
|
||||
size={'small'}
|
||||
positive
|
||||
onClick={() => {
|
||||
onOpenLink('', token.key);
|
||||
}}>
|
||||
聊天
|
||||
</Button>
|
||||
<Dropdown
|
||||
className="button icon"
|
||||
floating
|
||||
options={OPEN_LINK_OPTIONS.map(option => ({
|
||||
...option,
|
||||
onClick: async () => {
|
||||
await onOpenLink(option.value, token.key);
|
||||
}
|
||||
}))}
|
||||
trigger={<></>}
|
||||
/>
|
||||
</Button.Group>
|
||||
{' '}
|
||||
<Popup
|
||||
trigger={
|
||||
<Button size='small' negative>
|
||||
删除
|
||||
</Button>
|
||||
}
|
||||
on='click'
|
||||
flowing
|
||||
hoverable
|
||||
>
|
||||
<Button
|
||||
negative
|
||||
onClick={() => {
|
||||
manageToken(token.id, 'delete', idx);
|
||||
}}
|
||||
>
|
||||
删除令牌 {token.name}
|
||||
</Button>
|
||||
</Popup>
|
||||
<Button
|
||||
size={'small'}
|
||||
onClick={() => {
|
||||
manageToken(
|
||||
token.id,
|
||||
token.status === 1 ? 'disable' : 'enable',
|
||||
idx
|
||||
);
|
||||
}}
|
||||
>
|
||||
{token.status === 1 ? '禁用' : '启用'}
|
||||
</Button>
|
||||
<Button
|
||||
size={'small'}
|
||||
as={Link}
|
||||
to={'/token/edit/' + token.id}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
);
|
||||
})}
|
||||
</Table.Body>
|
||||
|
||||
<Table.Footer>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell colSpan='7'>
|
||||
<Button size='small' as={Link} to='/token/add' loading={loading}>
|
||||
添加新的令牌
|
||||
</Button>
|
||||
<Button size='small' onClick={refresh} loading={loading}>刷新</Button>
|
||||
<Pagination
|
||||
floated='right'
|
||||
activePage={activePage}
|
||||
onPageChange={onPaginationChange}
|
||||
size='small'
|
||||
siblingRange={1}
|
||||
totalPages={
|
||||
Math.ceil(tokens.length / ITEMS_PER_PAGE) +
|
||||
(tokens.length % ITEMS_PER_PAGE === 0 ? 1 : 0)
|
||||
}
|
||||
/>
|
||||
</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Footer>
|
||||
</Table>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TokensTable;
|
@ -1,344 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, Form, Label, Pagination, Popup, Table } from 'semantic-ui-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { API, showError, showSuccess } from '../helpers';
|
||||
|
||||
import { ITEMS_PER_PAGE } from '../constants';
|
||||
import { renderGroup, renderNumber, renderQuota, renderText } from '../helpers/render';
|
||||
|
||||
function renderRole(role) {
|
||||
switch (role) {
|
||||
case 1:
|
||||
return <Label>普通用户</Label>;
|
||||
case 10:
|
||||
return <Label color='yellow'>管理员</Label>;
|
||||
case 100:
|
||||
return <Label color='orange'>超级管理员</Label>;
|
||||
default:
|
||||
return <Label color='red'>未知身份</Label>;
|
||||
}
|
||||
}
|
||||
|
||||
const UsersTable = () => {
|
||||
const [users, setUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activePage, setActivePage] = useState(1);
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const [searching, setSearching] = useState(false);
|
||||
|
||||
const loadUsers = async (startIdx) => {
|
||||
const res = await API.get(`/api/user/?p=${startIdx}`);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
if (startIdx === 0) {
|
||||
setUsers(data);
|
||||
} else {
|
||||
let newUsers = users;
|
||||
newUsers.push(...data);
|
||||
setUsers(newUsers);
|
||||
}
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const onPaginationChange = (e, { activePage }) => {
|
||||
(async () => {
|
||||
if (activePage === Math.ceil(users.length / ITEMS_PER_PAGE) + 1) {
|
||||
// In this case we have to load more data and then append them.
|
||||
await loadUsers(activePage - 1);
|
||||
}
|
||||
setActivePage(activePage);
|
||||
})();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers(0)
|
||||
.then()
|
||||
.catch((reason) => {
|
||||
showError(reason);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const manageUser = (username, action, idx) => {
|
||||
(async () => {
|
||||
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];
|
||||
let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx;
|
||||
if (action === 'delete') {
|
||||
newUsers[realIdx].deleted = true;
|
||||
} else {
|
||||
newUsers[realIdx].status = user.status;
|
||||
newUsers[realIdx].role = user.role;
|
||||
}
|
||||
setUsers(newUsers);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
const renderStatus = (status) => {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return <Label basic>已激活</Label>;
|
||||
case 2:
|
||||
return (
|
||||
<Label basic color='red'>
|
||||
已封禁
|
||||
</Label>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Label basic color='grey'>
|
||||
未知状态
|
||||
</Label>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const searchUsers = async () => {
|
||||
if (searchKeyword === '') {
|
||||
// if keyword is blank, load files instead.
|
||||
await loadUsers(0);
|
||||
setActivePage(1);
|
||||
return;
|
||||
}
|
||||
setSearching(true);
|
||||
const res = await API.get(`/api/user/search?keyword=${searchKeyword}`);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
setUsers(data);
|
||||
setActivePage(1);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
setSearching(false);
|
||||
};
|
||||
|
||||
const handleKeywordChange = async (e, { value }) => {
|
||||
setSearchKeyword(value.trim());
|
||||
};
|
||||
|
||||
const sortUser = (key) => {
|
||||
if (users.length === 0) return;
|
||||
setLoading(true);
|
||||
let sortedUsers = [...users];
|
||||
sortedUsers.sort((a, b) => {
|
||||
if (!isNaN(a[key])) {
|
||||
// If the value is numeric, subtract to sort
|
||||
return a[key] - b[key];
|
||||
} else {
|
||||
// If the value is not numeric, sort as strings
|
||||
return ('' + a[key]).localeCompare(b[key]);
|
||||
}
|
||||
});
|
||||
if (sortedUsers[0].id === users[0].id) {
|
||||
sortedUsers.reverse();
|
||||
}
|
||||
setUsers(sortedUsers);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form onSubmit={searchUsers}>
|
||||
<Form.Input
|
||||
icon='search'
|
||||
fluid
|
||||
iconPosition='left'
|
||||
placeholder='搜索用户的 ID,用户名,显示名称,以及邮箱地址 ...'
|
||||
value={searchKeyword}
|
||||
loading={searching}
|
||||
onChange={handleKeywordChange}
|
||||
/>
|
||||
</Form>
|
||||
|
||||
<Table basic compact size='small'>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
sortUser('id');
|
||||
}}
|
||||
>
|
||||
ID
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
sortUser('username');
|
||||
}}
|
||||
>
|
||||
用户名
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
sortUser('group');
|
||||
}}
|
||||
>
|
||||
分组
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
sortUser('quota');
|
||||
}}
|
||||
>
|
||||
统计信息
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
sortUser('role');
|
||||
}}
|
||||
>
|
||||
用户角色
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
sortUser('status');
|
||||
}}
|
||||
>
|
||||
状态
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell>操作</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
|
||||
<Table.Body>
|
||||
{users
|
||||
.slice(
|
||||
(activePage - 1) * ITEMS_PER_PAGE,
|
||||
activePage * ITEMS_PER_PAGE
|
||||
)
|
||||
.map((user, idx) => {
|
||||
if (user.deleted) return <></>;
|
||||
return (
|
||||
<Table.Row key={user.id}>
|
||||
<Table.Cell>{user.id}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Popup
|
||||
content={user.email ? user.email : '未绑定邮箱地址'}
|
||||
key={user.username}
|
||||
header={user.display_name ? user.display_name : user.username}
|
||||
trigger={<span>{renderText(user.username, 15)}</span>}
|
||||
hoverable
|
||||
/>
|
||||
</Table.Cell>
|
||||
<Table.Cell>{renderGroup(user.group)}</Table.Cell>
|
||||
{/*<Table.Cell>*/}
|
||||
{/* {user.email ? <Popup hoverable content={user.email} trigger={<span>{renderText(user.email, 24)}</span>} /> : '无'}*/}
|
||||
{/*</Table.Cell>*/}
|
||||
<Table.Cell>
|
||||
<Popup content='剩余额度' trigger={<Label basic>{renderQuota(user.quota)}</Label>} />
|
||||
<Popup content='已用额度' trigger={<Label basic>{renderQuota(user.used_quota)}</Label>} />
|
||||
<Popup content='请求次数' trigger={<Label basic>{renderNumber(user.request_count)}</Label>} />
|
||||
</Table.Cell>
|
||||
<Table.Cell>{renderRole(user.role)}</Table.Cell>
|
||||
<Table.Cell>{renderStatus(user.status)}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<div>
|
||||
<Button
|
||||
size={'small'}
|
||||
positive
|
||||
onClick={() => {
|
||||
manageUser(user.username, 'promote', idx);
|
||||
}}
|
||||
disabled={user.role === 100}
|
||||
>
|
||||
提升
|
||||
</Button>
|
||||
<Button
|
||||
size={'small'}
|
||||
color={'yellow'}
|
||||
onClick={() => {
|
||||
manageUser(user.username, 'demote', idx);
|
||||
}}
|
||||
disabled={user.role === 100}
|
||||
>
|
||||
降级
|
||||
</Button>
|
||||
<Popup
|
||||
trigger={
|
||||
<Button size='small' negative disabled={user.role === 100}>
|
||||
删除
|
||||
</Button>
|
||||
}
|
||||
on='click'
|
||||
flowing
|
||||
hoverable
|
||||
>
|
||||
<Button
|
||||
negative
|
||||
onClick={() => {
|
||||
manageUser(user.username, 'delete', idx);
|
||||
}}
|
||||
>
|
||||
删除用户 {user.username}
|
||||
</Button>
|
||||
</Popup>
|
||||
<Button
|
||||
size={'small'}
|
||||
onClick={() => {
|
||||
manageUser(
|
||||
user.username,
|
||||
user.status === 1 ? 'disable' : 'enable',
|
||||
idx
|
||||
);
|
||||
}}
|
||||
disabled={user.role === 100}
|
||||
>
|
||||
{user.status === 1 ? '禁用' : '启用'}
|
||||
</Button>
|
||||
<Button
|
||||
size={'small'}
|
||||
as={Link}
|
||||
to={'/user/edit/' + user.id}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
);
|
||||
})}
|
||||
</Table.Body>
|
||||
|
||||
<Table.Footer>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell colSpan='7'>
|
||||
<Button size='small' as={Link} to='/user/add' loading={loading}>
|
||||
添加新的用户
|
||||
</Button>
|
||||
<Pagination
|
||||
floated='right'
|
||||
activePage={activePage}
|
||||
onPageChange={onPaginationChange}
|
||||
size='small'
|
||||
siblingRange={1}
|
||||
totalPages={
|
||||
Math.ceil(users.length / ITEMS_PER_PAGE) +
|
||||
(users.length % ITEMS_PER_PAGE === 0 ? 1 : 0)
|
||||
}
|
||||
/>
|
||||
</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Footer>
|
||||
</Table>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default UsersTable;
|
@ -1,20 +0,0 @@
|
||||
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`
|
||||
);
|
||||
}
|
29
web/src/config.js
Normal file
@ -0,0 +1,29 @@
|
||||
const config = {
|
||||
// basename: only at build time to set, and Don't add '/' at end off BASENAME for breadcrumbs, also Don't put only '/' use blank('') instead,
|
||||
// like '/berry-material-react/react/default'
|
||||
basename: '/',
|
||||
defaultPath: '/panel/dashboard',
|
||||
fontFamily: `'Roboto', sans-serif, Helvetica, Arial, sans-serif`,
|
||||
borderRadius: 12,
|
||||
siteInfo: {
|
||||
chat_link: '',
|
||||
display_in_currency: true,
|
||||
email_verification: false,
|
||||
footer_html: '',
|
||||
github_client_id: '',
|
||||
github_oauth: false,
|
||||
logo: '',
|
||||
quota_per_unit: 500000,
|
||||
server_address: '',
|
||||
start_time: 0,
|
||||
system_name: 'One API',
|
||||
top_up_link: '',
|
||||
turnstile_check: false,
|
||||
turnstile_site_key: '',
|
||||
version: '',
|
||||
wechat_login: false,
|
||||
wechat_qrcode: ''
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
146
web/src/constants/ChannelConstants.js
Normal file
@ -0,0 +1,146 @@
|
||||
export const CHANNEL_OPTIONS = {
|
||||
1: {
|
||||
key: 1,
|
||||
text: 'OpenAI',
|
||||
value: 1,
|
||||
color: 'primary'
|
||||
},
|
||||
14: {
|
||||
key: 14,
|
||||
text: 'Anthropic Claude',
|
||||
value: 14,
|
||||
color: 'info'
|
||||
},
|
||||
3: {
|
||||
key: 3,
|
||||
text: 'Azure OpenAI',
|
||||
value: 3,
|
||||
color: 'orange'
|
||||
},
|
||||
11: {
|
||||
key: 11,
|
||||
text: 'Google PaLM2',
|
||||
value: 11,
|
||||
color: 'orange'
|
||||
},
|
||||
15: {
|
||||
key: 15,
|
||||
text: '百度文心千帆',
|
||||
value: 15,
|
||||
color: 'default'
|
||||
},
|
||||
17: {
|
||||
key: 17,
|
||||
text: '阿里通义千问',
|
||||
value: 17,
|
||||
color: 'default'
|
||||
},
|
||||
18: {
|
||||
key: 18,
|
||||
text: '讯飞星火认知',
|
||||
value: 18,
|
||||
color: 'default'
|
||||
},
|
||||
16: {
|
||||
key: 16,
|
||||
text: '智谱 ChatGLM',
|
||||
value: 16,
|
||||
color: 'default'
|
||||
},
|
||||
19: {
|
||||
key: 19,
|
||||
text: '360 智脑',
|
||||
value: 19,
|
||||
color: 'default'
|
||||
},
|
||||
23: {
|
||||
key: 23,
|
||||
text: '腾讯混元',
|
||||
value: 23,
|
||||
color: 'default'
|
||||
},
|
||||
24: {
|
||||
key: 24,
|
||||
text: 'Azure Speech',
|
||||
value: 24,
|
||||
color: 'orange'
|
||||
},
|
||||
8: {
|
||||
key: 8,
|
||||
text: '自定义渠道',
|
||||
value: 8,
|
||||
color: 'primary'
|
||||
},
|
||||
22: {
|
||||
key: 22,
|
||||
text: '知识库:FastGPT',
|
||||
value: 22,
|
||||
color: 'default'
|
||||
},
|
||||
21: {
|
||||
key: 21,
|
||||
text: '知识库:AI Proxy',
|
||||
value: 21,
|
||||
color: 'purple'
|
||||
},
|
||||
20: {
|
||||
key: 20,
|
||||
text: '代理:OpenRouter',
|
||||
value: 20,
|
||||
color: 'primary'
|
||||
},
|
||||
2: {
|
||||
key: 2,
|
||||
text: '代理:API2D',
|
||||
value: 2,
|
||||
color: 'primary'
|
||||
},
|
||||
5: {
|
||||
key: 5,
|
||||
text: '代理:OpenAI-SB',
|
||||
value: 5,
|
||||
color: 'primary'
|
||||
},
|
||||
7: {
|
||||
key: 7,
|
||||
text: '代理:OhMyGPT',
|
||||
value: 7,
|
||||
color: 'primary'
|
||||
},
|
||||
10: {
|
||||
key: 10,
|
||||
text: '代理:AI Proxy',
|
||||
value: 10,
|
||||
color: 'primary'
|
||||
},
|
||||
4: {
|
||||
key: 4,
|
||||
text: '代理:CloseAI',
|
||||
value: 4,
|
||||
color: 'primary'
|
||||
},
|
||||
6: {
|
||||
key: 6,
|
||||
text: '代理:OpenAI Max',
|
||||
value: 6,
|
||||
color: 'primary'
|
||||
},
|
||||
9: {
|
||||
key: 9,
|
||||
text: '代理:AI.LS',
|
||||
value: 9,
|
||||
color: 'primary'
|
||||
},
|
||||
12: {
|
||||
key: 12,
|
||||
text: '代理:API2GPT',
|
||||
value: 12,
|
||||
color: 'primary'
|
||||
},
|
||||
13: {
|
||||
key: 13,
|
||||
text: '代理:AIGC2D',
|
||||
value: 13,
|
||||
color: 'primary'
|
||||
}
|
||||
};
|
27
web/src/constants/SnackbarConstants.js
Normal file
@ -0,0 +1,27 @@
|
||||
export const snackbarConstants = {
|
||||
Common: {
|
||||
ERROR: {
|
||||
variant: 'error',
|
||||
autoHideDuration: 5000
|
||||
},
|
||||
WARNING: {
|
||||
variant: 'warning',
|
||||
autoHideDuration: 10000
|
||||
},
|
||||
SUCCESS: {
|
||||
variant: 'success',
|
||||
autoHideDuration: 1500
|
||||
},
|
||||
INFO: {
|
||||
variant: 'info',
|
||||
autoHideDuration: 3000
|
||||
},
|
||||
NOTICE: {
|
||||
variant: 'info',
|
||||
autoHideDuration: 20000
|
||||
}
|
||||
},
|
||||
Mobile: {
|
||||
anchorOrigin: { vertical: 'bottom', horizontal: 'center' }
|
||||
}
|
||||
};
|
@ -1,26 +0,0 @@
|
||||
export const CHANNEL_OPTIONS = [
|
||||
{ key: 1, text: 'OpenAI', value: 1, color: 'green' },
|
||||
{ key: 14, text: 'Anthropic Claude', value: 14, color: 'black' },
|
||||
{ key: 3, text: 'Azure OpenAI', value: 3, color: 'olive' },
|
||||
{ key: 11, text: 'Google PaLM2', value: 11, color: 'orange' },
|
||||
{ key: 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: 23, text: '腾讯混元', value: 23, color: 'teal' },
|
||||
{ key: 24, text: 'Azure Speech', value: 24, color: 'olive' },
|
||||
{ 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' }
|
||||
];
|
@ -1,4 +1,3 @@
|
||||
export * from './toast.constants';
|
||||
export * from './user.constants';
|
||||
export * from './common.constant';
|
||||
export * from './channel.constants';
|
||||
export * from './SnackbarConstants';
|
||||
export * from './CommonConstants';
|
||||
export * from './ChannelConstants';
|
||||
|
@ -1,7 +0,0 @@
|
||||
export const toastConstants = {
|
||||
SUCCESS_TIMEOUT: 1500,
|
||||
INFO_TIMEOUT: 3000,
|
||||
ERROR_TIMEOUT: 5000,
|
||||
WARNING_TIMEOUT: 10000,
|
||||
NOTICE_TIMEOUT: 20000
|
||||
};
|
@ -1,19 +0,0 @@
|
||||
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'
|
||||
};
|
@ -1,19 +0,0 @@
|
||||
// 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>
|
||||
);
|
||||
};
|
@ -1,20 +0,0 @@
|
||||
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,
|
||||
};
|
@ -1,19 +0,0 @@
|
||||
// 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>
|
||||
)
|
||||
}
|
63
web/src/contexts/StatusContext.js
Normal file
@ -0,0 +1,63 @@
|
||||
import { useEffect, useCallback, createContext } from 'react';
|
||||
import { API } from 'utils/api';
|
||||
import { showNotice } from 'utils/common';
|
||||
import { SET_SITE_INFO } from 'store/actions';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
export const LoadStatusContext = createContext();
|
||||
|
||||
// eslint-disable-next-line
|
||||
const StatusProvider = ({ children }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const loadStatus = useCallback(async () => {
|
||||
const res = await API.get('/api/status');
|
||||
const { success, data } = res.data;
|
||||
let system_name = '';
|
||||
if (success) {
|
||||
if (!data.chat_link) {
|
||||
delete data.chat_link;
|
||||
}
|
||||
localStorage.setItem('siteInfo', JSON.stringify(data));
|
||||
localStorage.setItem('quota_per_unit', data.quota_per_unit);
|
||||
localStorage.setItem('display_in_currency', data.display_in_currency);
|
||||
dispatch({ type: SET_SITE_INFO, payload: data });
|
||||
if (
|
||||
data.version !== process.env.REACT_APP_VERSION &&
|
||||
data.version !== 'v0.0.0' &&
|
||||
data.version !== '' &&
|
||||
process.env.REACT_APP_VERSION !== ''
|
||||
) {
|
||||
showNotice(`新版本可用:${data.version},请使用快捷键 Shift + F5 刷新页面`);
|
||||
}
|
||||
if (data.system_name) {
|
||||
system_name = data.system_name;
|
||||
}
|
||||
} else {
|
||||
const backupSiteInfo = localStorage.getItem('siteInfo');
|
||||
if (backupSiteInfo) {
|
||||
const data = JSON.parse(backupSiteInfo);
|
||||
if (data.system_name) {
|
||||
system_name = data.system_name;
|
||||
}
|
||||
dispatch({
|
||||
type: SET_SITE_INFO,
|
||||
payload: data
|
||||
});
|
||||
}
|
||||
showError('无法正常连接至服务器!');
|
||||
}
|
||||
|
||||
if (system_name) {
|
||||
document.title = system_name;
|
||||
}
|
||||
}, [dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
loadStatus().then();
|
||||
}, [loadStatus]);
|
||||
|
||||
return <LoadStatusContext.Provider value={loadStatus}> {children} </LoadStatusContext.Provider>;
|
||||
};
|
||||
|
||||
export default StatusProvider;
|
29
web/src/contexts/UserContext.js
Normal file
@ -0,0 +1,29 @@
|
||||
// contexts/User/index.jsx
|
||||
import React, { useEffect, useCallback, createContext, useState } from 'react';
|
||||
import { LOGIN } from 'store/actions';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
export const UserContext = createContext();
|
||||
|
||||
// eslint-disable-next-line
|
||||
const UserProvider = ({ children }) => {
|
||||
const dispatch = useDispatch();
|
||||
const [isUserLoaded, setIsUserLoaded] = useState(false);
|
||||
|
||||
const loadUser = useCallback(() => {
|
||||
let user = localStorage.getItem('user');
|
||||
if (user) {
|
||||
let data = JSON.parse(user);
|
||||
dispatch({ type: LOGIN, payload: data });
|
||||
}
|
||||
setIsUserLoaded(true);
|
||||
}, [dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
loadUser();
|
||||
}, [loadUser]);
|
||||
|
||||
return <UserContext.Provider value={{ loadUser, isUserLoaded }}> {children} </UserContext.Provider>;
|
||||
};
|
||||
|
||||
export default UserProvider;
|
@ -1,13 +0,0 @@
|
||||
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);
|
||||
}
|
||||
);
|
@ -1,10 +0,0 @@
|
||||
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 {};
|
||||
}
|
||||
}
|
@ -1,3 +0,0 @@
|
||||
import { createBrowserHistory } from 'history';
|
||||
|
||||
export const history = createBrowserHistory();
|
@ -1,4 +0,0 @@
|
||||
export * from './history';
|
||||
export * from './auth-header';
|
||||
export * from './utils';
|
||||
export * from './api';
|
@ -1,58 +0,0 @@
|
||||
import { Label } from 'semantic-ui-react';
|
||||
|
||||
export function renderText(text, limit) {
|
||||
if (text.length > limit) {
|
||||
return text.slice(0, limit - 3) + '...';
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
export function renderGroup(group) {
|
||||
if (group === '') {
|
||||
return <Label>default</Label>;
|
||||
}
|
||||
let groups = group.split(',');
|
||||
groups.sort();
|
||||
return <>
|
||||
{groups.map((group) => {
|
||||
if (group === 'vip' || group === 'pro') {
|
||||
return <Label color='yellow'>{group}</Label>;
|
||||
} else if (group === 'svip' || group === 'premium') {
|
||||
return <Label color='red'>{group}</Label>;
|
||||
}
|
||||
return <Label>{group}</Label>;
|
||||
})}
|
||||
</>;
|
||||
}
|
||||
|
||||
export function renderNumber(num) {
|
||||
if (num >= 1000000000) {
|
||||
return (num / 1000000000).toFixed(1) + 'B';
|
||||
} else if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(1) + 'M';
|
||||
} else if (num >= 10000) {
|
||||
return (num / 1000).toFixed(1) + 'k';
|
||||
} else {
|
||||
return num;
|
||||
}
|
||||
}
|
||||
|
||||
export function renderQuota(quota, 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 '';
|
||||
}
|
@ -1,199 +0,0 @@
|
||||
import { toast } from 'react-toastify';
|
||||
import { toastConstants } from '../constants';
|
||||
import React from 'react';
|
||||
|
||||
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('错误:请求次数过多,请稍后再试!', showErrorOptions);
|
||||
break;
|
||||
case 500:
|
||||
toast.error('错误:服务器内部错误,请联系管理员!', showErrorOptions);
|
||||
break;
|
||||
case 405:
|
||||
toast.info('本站仅作演示之用,无服务端!');
|
||||
break;
|
||||
default:
|
||||
toast.error('错误:' + error.message, showErrorOptions);
|
||||
}
|
||||
return;
|
||||
}
|
||||
toast.error('错误:' + error.message, showErrorOptions);
|
||||
} else {
|
||||
toast.error('错误:' + error, showErrorOptions);
|
||||
}
|
||||
}
|
||||
|
||||
export function showWarning(message) {
|
||||
toast.warn(message, showWarningOptions);
|
||||
}
|
||||
|
||||
export function showSuccess(message) {
|
||||
toast.success(message, showSuccessOptions);
|
||||
}
|
||||
|
||||
export function showInfo(message) {
|
||||
toast.info(message, showInfoOptions);
|
||||
}
|
||||
|
||||
export function showNotice(message, isHTML = false) {
|
||||
if (isHTML) {
|
||||
toast(<HTMLToastContent htmlContent={message} />, showNoticeOptions);
|
||||
} else {
|
||||
toast.info(message, showNoticeOptions);
|
||||
}
|
||||
}
|
||||
|
||||
export function openPage(url) {
|
||||
window.open(url);
|
||||
}
|
||||
|
||||
export function removeTrailingSlash(url) {
|
||||
if (url.endsWith('/')) {
|
||||
return url.slice(0, -1);
|
||||
} else {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
export function timestamp2string(timestamp) {
|
||||
let date = new Date(timestamp * 1000);
|
||||
let year = date.getFullYear().toString();
|
||||
let month = (date.getMonth() + 1).toString();
|
||||
let day = date.getDate().toString();
|
||||
let hour = date.getHours().toString();
|
||||
let minute = date.getMinutes().toString();
|
||||
let second = date.getSeconds().toString();
|
||||
if (month.length === 1) {
|
||||
month = '0' + month;
|
||||
}
|
||||
if (day.length === 1) {
|
||||
day = '0' + day;
|
||||
}
|
||||
if (hour.length === 1) {
|
||||
hour = '0' + hour;
|
||||
}
|
||||
if (minute.length === 1) {
|
||||
minute = '0' + minute;
|
||||
}
|
||||
if (second.length === 1) {
|
||||
second = '0' + second;
|
||||
}
|
||||
return (
|
||||
year +
|
||||
'-' +
|
||||
month +
|
||||
'-' +
|
||||
day +
|
||||
' ' +
|
||||
hour +
|
||||
':' +
|
||||
minute +
|
||||
':' +
|
||||
second
|
||||
);
|
||||
}
|
||||
|
||||
export function downloadTextAsFile(text, filename) {
|
||||
let blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
|
||||
let url = URL.createObjectURL(blob);
|
||||
let a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
}
|
||||
|
||||
export const verifyJSON = (str) => {
|
||||
try {
|
||||
JSON.parse(str);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
export function shouldShowPrompt(id) {
|
||||
let prompt = localStorage.getItem(`prompt-${id}`);
|
||||
return !prompt;
|
||||
|
||||
}
|
||||
|
||||
export function setPromptShown(id) {
|
||||
localStorage.setItem(`prompt-${id}`, 'true');
|
||||
}
|
13
web/src/hooks/useAuth.js
Normal file
@ -0,0 +1,13 @@
|
||||
import { isAdmin } from 'utils/common';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
const navigate = useNavigate();
|
||||
|
||||
const useAuth = () => {
|
||||
const userIsAdmin = isAdmin();
|
||||
|
||||
if (!userIsAdmin) {
|
||||
navigate('/panel/404');
|
||||
}
|
||||
};
|
||||
|
||||
export default useAuth;
|
78
web/src/hooks/useLogin.js
Normal file
@ -0,0 +1,78 @@
|
||||
import { API } from 'utils/api';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { LOGIN } from 'store/actions';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { showSuccess } from 'utils/common';
|
||||
|
||||
const useLogin = () => {
|
||||
const dispatch = useDispatch();
|
||||
const navigate = useNavigate();
|
||||
const login = async (username, password) => {
|
||||
try {
|
||||
const res = await API.post(`/api/user/login`, {
|
||||
username,
|
||||
password
|
||||
});
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
localStorage.setItem('user', JSON.stringify(data));
|
||||
dispatch({ type: LOGIN, payload: data });
|
||||
navigate('/panel');
|
||||
}
|
||||
return { success, message };
|
||||
} catch (err) {
|
||||
// 请求失败,设置错误信息
|
||||
return { success: false, message: '' };
|
||||
}
|
||||
};
|
||||
|
||||
const githubLogin = async (code, state) => {
|
||||
try {
|
||||
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('/panel');
|
||||
} else {
|
||||
dispatch({ type: LOGIN, payload: data });
|
||||
localStorage.setItem('user', JSON.stringify(data));
|
||||
showSuccess('登录成功!');
|
||||
navigate('/panel');
|
||||
}
|
||||
}
|
||||
return { success, message };
|
||||
} catch (err) {
|
||||
// 请求失败,设置错误信息
|
||||
return { success: false, message: '' };
|
||||
}
|
||||
};
|
||||
|
||||
const wechatLogin = async (code) => {
|
||||
try {
|
||||
const res = await API.get(`/api/oauth/wechat?code=${code}`);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
dispatch({ type: LOGIN, payload: data });
|
||||
localStorage.setItem('user', JSON.stringify(data));
|
||||
showSuccess('登录成功!');
|
||||
navigate('/panel');
|
||||
}
|
||||
return { success, message };
|
||||
} catch (err) {
|
||||
// 请求失败,设置错误信息
|
||||
return { success: false, message: '' };
|
||||
}
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
await API.get('/api/user/logout');
|
||||
localStorage.removeItem('user');
|
||||
dispatch({ type: LOGIN, payload: null });
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
return { login, logout, githubLogin, wechatLogin };
|
||||
};
|
||||
|
||||
export default useLogin;
|
39
web/src/hooks/useRegister.js
Normal file
@ -0,0 +1,39 @@
|
||||
import { API } from 'utils/api';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { showSuccess } from 'utils/common';
|
||||
|
||||
const useRegister = () => {
|
||||
const navigate = useNavigate();
|
||||
const register = async (input, turnstile) => {
|
||||
try {
|
||||
const res = await API.post(`/api/user/register?turnstile=${turnstile}`, input);
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess('注册成功!');
|
||||
navigate('/login');
|
||||
}
|
||||
return { success, message };
|
||||
} catch (err) {
|
||||
// 请求失败,设置错误信息
|
||||
return { success: false, message: '' };
|
||||
}
|
||||
};
|
||||
|
||||
const sendVerificationCode = async (email, turnstile) => {
|
||||
try {
|
||||
const res = await API.get(`/api/verification?email=${email}&turnstile=${turnstile}`);
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess('验证码发送成功,请检查你的邮箱!');
|
||||
}
|
||||
return { success, message };
|
||||
} catch (err) {
|
||||
// 请求失败,设置错误信息
|
||||
return { success: false, message: '' };
|
||||
}
|
||||
};
|
||||
|
||||
return { register, sendVerificationCode };
|
||||
};
|
||||
|
||||
export default useRegister;
|
18
web/src/hooks/useScriptRef.js
Normal file
@ -0,0 +1,18 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
// ==============================|| ELEMENT REFERENCE HOOKS ||============================== //
|
||||
|
||||
const useScriptRef = () => {
|
||||
const scripted = useRef(true);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
scripted.current = true;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return scripted;
|
||||
};
|
||||
|
||||
export default useScriptRef;
|
@ -1,35 +0,0 @@
|
||||
body {
|
||||
margin: 0;
|
||||
padding-top: 55px;
|
||||
overflow-y: scroll;
|
||||
font-family: Lato, 'Helvetica Neue', Arial, Helvetica, "Microsoft YaHei", sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
body::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.small-icon .icon {
|
||||
font-size: 1em !important;
|
||||
}
|
||||
|
||||
.custom-footer {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 600px) {
|
||||
.hide-on-mobile {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
@ -1,31 +1,31 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { Container } from 'semantic-ui-react';
|
||||
import App from './App';
|
||||
import Header from './components/Header';
|
||||
import Footer from './components/Footer';
|
||||
import 'semantic-ui-css/semantic.min.css';
|
||||
import './index.css';
|
||||
import { UserProvider } from './context/User';
|
||||
import { ToastContainer } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import { StatusProvider } from './context/Status';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
// third party
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
// project imports
|
||||
import * as serviceWorker from 'serviceWorker';
|
||||
import App from 'App';
|
||||
import { store } from 'store';
|
||||
|
||||
// style + assets
|
||||
import 'assets/scss/style.scss';
|
||||
import config from './config';
|
||||
|
||||
// ==============================|| REACT DOM RENDER ||============================== //
|
||||
|
||||
const container = document.getElementById('root');
|
||||
const root = createRoot(container); // createRoot(container!) if you use TypeScript
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<StatusProvider>
|
||||
<UserProvider>
|
||||
<BrowserRouter>
|
||||
<Header />
|
||||
<Container className={'main-content'}>
|
||||
<App />
|
||||
</Container>
|
||||
<ToastContainer />
|
||||
<Footer />
|
||||
</BrowserRouter>
|
||||
</UserProvider>
|
||||
</StatusProvider>
|
||||
</React.StrictMode>
|
||||
<Provider store={store}>
|
||||
<BrowserRouter basename={config.basename}>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</Provider>
|
||||
);
|
||||
|
||||
// If you want your app to work offline and load faster, you can change
|
||||
// unregister() to register() below. Note this comes with some pitfalls.
|
||||
// Learn more about service workers: https://bit.ly/CRA-PWA
|
||||
serviceWorker.register();
|
||||
|
173
web/src/layout/MainLayout/Header/ProfileSection/index.js
Normal file
@ -0,0 +1,173 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
// material-ui
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import {
|
||||
Avatar,
|
||||
Chip,
|
||||
ClickAwayListener,
|
||||
List,
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Paper,
|
||||
Popper,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
|
||||
// project imports
|
||||
import MainCard from 'ui-component/cards/MainCard';
|
||||
import Transitions from 'ui-component/extended/Transitions';
|
||||
import User1 from 'assets/images/users/user-round.svg';
|
||||
import useLogin from 'hooks/useLogin';
|
||||
|
||||
// assets
|
||||
import { IconLogout, IconSettings, IconUserScan } from '@tabler/icons-react';
|
||||
|
||||
// ==============================|| PROFILE MENU ||============================== //
|
||||
|
||||
const ProfileSection = () => {
|
||||
const theme = useTheme();
|
||||
const navigate = useNavigate();
|
||||
const customization = useSelector((state) => state.customization);
|
||||
const { logout } = useLogin();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
/**
|
||||
* anchorRef is used on different componets and specifying one type leads to other components throwing an error
|
||||
* */
|
||||
const anchorRef = useRef(null);
|
||||
const handleLogout = async () => {
|
||||
logout();
|
||||
};
|
||||
|
||||
const handleClose = (event) => {
|
||||
if (anchorRef.current && anchorRef.current.contains(event.target)) {
|
||||
return;
|
||||
}
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleToggle = () => {
|
||||
setOpen((prevOpen) => !prevOpen);
|
||||
};
|
||||
|
||||
const prevOpen = useRef(open);
|
||||
useEffect(() => {
|
||||
if (prevOpen.current === true && open === false) {
|
||||
anchorRef.current.focus();
|
||||
}
|
||||
|
||||
prevOpen.current = open;
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Chip
|
||||
sx={{
|
||||
height: '48px',
|
||||
alignItems: 'center',
|
||||
borderRadius: '27px',
|
||||
transition: 'all .2s ease-in-out',
|
||||
borderColor: theme.palette.primary.light,
|
||||
backgroundColor: theme.palette.primary.light,
|
||||
'&[aria-controls="menu-list-grow"], &:hover': {
|
||||
borderColor: theme.palette.primary.main,
|
||||
background: `${theme.palette.primary.main}!important`,
|
||||
color: theme.palette.primary.light,
|
||||
'& svg': {
|
||||
stroke: theme.palette.primary.light
|
||||
}
|
||||
},
|
||||
'& .MuiChip-label': {
|
||||
lineHeight: 0
|
||||
}
|
||||
}}
|
||||
icon={
|
||||
<Avatar
|
||||
src={User1}
|
||||
sx={{
|
||||
...theme.typography.mediumAvatar,
|
||||
margin: '8px 0 8px 8px !important',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
ref={anchorRef}
|
||||
aria-controls={open ? 'menu-list-grow' : undefined}
|
||||
aria-haspopup="true"
|
||||
color="inherit"
|
||||
/>
|
||||
}
|
||||
label={<IconSettings stroke={1.5} size="1.5rem" color={theme.palette.primary.main} />}
|
||||
variant="outlined"
|
||||
ref={anchorRef}
|
||||
aria-controls={open ? 'menu-list-grow' : undefined}
|
||||
aria-haspopup="true"
|
||||
onClick={handleToggle}
|
||||
color="primary"
|
||||
/>
|
||||
<Popper
|
||||
placement="bottom-end"
|
||||
open={open}
|
||||
anchorEl={anchorRef.current}
|
||||
role={undefined}
|
||||
transition
|
||||
disablePortal
|
||||
popperOptions={{
|
||||
modifiers: [
|
||||
{
|
||||
name: 'offset',
|
||||
options: {
|
||||
offset: [0, 14]
|
||||
}
|
||||
}
|
||||
]
|
||||
}}
|
||||
>
|
||||
{({ TransitionProps }) => (
|
||||
<Transitions in={open} {...TransitionProps}>
|
||||
<Paper>
|
||||
<ClickAwayListener onClickAway={handleClose}>
|
||||
<MainCard border={false} elevation={16} content={false} boxShadow shadow={theme.shadows[16]}>
|
||||
<List
|
||||
component="nav"
|
||||
sx={{
|
||||
width: '100%',
|
||||
maxWidth: 350,
|
||||
minWidth: 150,
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
borderRadius: '10px',
|
||||
[theme.breakpoints.down('md')]: {
|
||||
minWidth: '100%'
|
||||
},
|
||||
'& .MuiListItemButton-root': {
|
||||
mt: 0.5
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ListItemButton sx={{ borderRadius: `${customization.borderRadius}px` }} onClick={() => navigate('/panel/profile')}>
|
||||
<ListItemIcon>
|
||||
<IconUserScan stroke={1.5} size="1.3rem" />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={<Typography variant="body2">设置</Typography>} />
|
||||
</ListItemButton>
|
||||
|
||||
<ListItemButton sx={{ borderRadius: `${customization.borderRadius}px` }} onClick={handleLogout}>
|
||||
<ListItemIcon>
|
||||
<IconLogout stroke={1.5} size="1.3rem" />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={<Typography variant="body2">Logout</Typography>} />
|
||||
</ListItemButton>
|
||||
</List>
|
||||
</MainCard>
|
||||
</ClickAwayListener>
|
||||
</Paper>
|
||||
</Transitions>
|
||||
)}
|
||||
</Popper>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileSection;
|
68
web/src/layout/MainLayout/Header/index.js
Normal file
@ -0,0 +1,68 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
// material-ui
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import { Avatar, Box, ButtonBase } from '@mui/material';
|
||||
|
||||
// project imports
|
||||
import LogoSection from '../LogoSection';
|
||||
import ProfileSection from './ProfileSection';
|
||||
|
||||
// assets
|
||||
import { IconMenu2 } from '@tabler/icons-react';
|
||||
|
||||
// ==============================|| MAIN NAVBAR / HEADER ||============================== //
|
||||
|
||||
const Header = ({ handleLeftDrawerToggle }) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* logo & toggler button */}
|
||||
<Box
|
||||
sx={{
|
||||
width: 228,
|
||||
display: 'flex',
|
||||
[theme.breakpoints.down('md')]: {
|
||||
width: 'auto'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Box component="span" sx={{ display: { xs: 'none', md: 'block' }, flexGrow: 1 }}>
|
||||
<LogoSection />
|
||||
</Box>
|
||||
<ButtonBase sx={{ borderRadius: '12px', overflow: 'hidden' }}>
|
||||
<Avatar
|
||||
variant="rounded"
|
||||
sx={{
|
||||
...theme.typography.commonAvatar,
|
||||
...theme.typography.mediumAvatar,
|
||||
transition: 'all .2s ease-in-out',
|
||||
background: theme.palette.secondary.light,
|
||||
color: theme.palette.secondary.dark,
|
||||
'&:hover': {
|
||||
background: theme.palette.secondary.dark,
|
||||
color: theme.palette.secondary.light
|
||||
}
|
||||
}}
|
||||
onClick={handleLeftDrawerToggle}
|
||||
color="inherit"
|
||||
>
|
||||
<IconMenu2 stroke={1.5} size="1.3rem" />
|
||||
</Avatar>
|
||||
</ButtonBase>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
|
||||
<ProfileSection />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Header.propTypes = {
|
||||
handleLeftDrawerToggle: PropTypes.func
|
||||
};
|
||||
|
||||
export default Header;
|
24
web/src/layout/MainLayout/LogoSection/index.js
Normal file
@ -0,0 +1,24 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
// material-ui
|
||||
import { ButtonBase } from '@mui/material';
|
||||
|
||||
// project imports
|
||||
import config from 'config';
|
||||
import Logo from 'ui-component/Logo';
|
||||
import { MENU_OPEN } from 'store/actions';
|
||||
|
||||
// ==============================|| MAIN LOGO ||============================== //
|
||||
|
||||
const LogoSection = () => {
|
||||
const defaultId = useSelector((state) => state.customization.defaultId);
|
||||
const dispatch = useDispatch();
|
||||
return (
|
||||
<ButtonBase disableRipple onClick={() => dispatch({ type: MENU_OPEN, id: defaultId })} component={Link} to={config.basename}>
|
||||
<Logo />
|
||||
</ButtonBase>
|
||||
);
|
||||
};
|
||||
|
||||
export default LogoSection;
|
130
web/src/layout/MainLayout/Sidebar/MenuCard/index.js
Normal file
@ -0,0 +1,130 @@
|
||||
// import PropTypes from 'prop-types';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
// material-ui
|
||||
import { styled, useTheme } from '@mui/material/styles';
|
||||
import {
|
||||
Avatar,
|
||||
Card,
|
||||
CardContent,
|
||||
// Grid,
|
||||
// LinearProgress,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemAvatar,
|
||||
ListItemText,
|
||||
Typography
|
||||
// linearProgressClasses
|
||||
} from '@mui/material';
|
||||
import User1 from 'assets/images/users/user-round.svg';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
// assets
|
||||
// import TableChartOutlinedIcon from '@mui/icons-material/TableChartOutlined';
|
||||
|
||||
// styles
|
||||
// const BorderLinearProgress = styled(LinearProgress)(({ theme }) => ({
|
||||
// height: 10,
|
||||
// borderRadius: 30,
|
||||
// [`&.${linearProgressClasses.colorPrimary}`]: {
|
||||
// backgroundColor: '#fff'
|
||||
// },
|
||||
// [`& .${linearProgressClasses.bar}`]: {
|
||||
// borderRadius: 5,
|
||||
// backgroundColor: theme.palette.primary.main
|
||||
// }
|
||||
// }));
|
||||
|
||||
const CardStyle = styled(Card)(({ theme }) => ({
|
||||
background: theme.palette.primary.light,
|
||||
marginBottom: '22px',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
'&:after': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
width: '157px',
|
||||
height: '157px',
|
||||
background: theme.palette.primary[200],
|
||||
borderRadius: '50%',
|
||||
top: '-105px',
|
||||
right: '-96px'
|
||||
}
|
||||
}));
|
||||
|
||||
// ==============================|| PROGRESS BAR WITH LABEL ||============================== //
|
||||
|
||||
// function LinearProgressWithLabel({ value, ...others }) {
|
||||
// const theme = useTheme();
|
||||
|
||||
// return (
|
||||
// <Grid container direction="column" spacing={1} sx={{ mt: 1.5 }}>
|
||||
// <Grid item>
|
||||
// <Grid container justifyContent="space-between">
|
||||
// <Grid item>
|
||||
// <Typography variant="h6" sx={{ color: theme.palette.primary[800] }}>
|
||||
// Progress
|
||||
// </Typography>
|
||||
// </Grid>
|
||||
// <Grid item>
|
||||
// <Typography variant="h6" color="inherit">{`${Math.round(value)}%`}</Typography>
|
||||
// </Grid>
|
||||
// </Grid>
|
||||
// </Grid>
|
||||
// <Grid item>
|
||||
// <BorderLinearProgress variant="determinate" value={value} {...others} />
|
||||
// </Grid>
|
||||
// </Grid>
|
||||
// );
|
||||
// }
|
||||
|
||||
// LinearProgressWithLabel.propTypes = {
|
||||
// value: PropTypes.number
|
||||
// };
|
||||
|
||||
// ==============================|| SIDEBAR MENU Card ||============================== //
|
||||
|
||||
const MenuCard = () => {
|
||||
const theme = useTheme();
|
||||
const account = useSelector((state) => state.account);
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<CardStyle>
|
||||
<CardContent sx={{ p: 2 }}>
|
||||
<List sx={{ p: 0, m: 0 }}>
|
||||
<ListItem alignItems="flex-start" disableGutters sx={{ p: 0 }}>
|
||||
<ListItemAvatar sx={{ mt: 0 }}>
|
||||
<Avatar
|
||||
variant="rounded"
|
||||
src={User1}
|
||||
sx={{
|
||||
...theme.typography.commonAvatar,
|
||||
...theme.typography.largeAvatar,
|
||||
color: theme.palette.primary.main,
|
||||
border: 'none',
|
||||
borderColor: theme.palette.primary.main,
|
||||
background: '#fff',
|
||||
marginRight: '12px'
|
||||
}}
|
||||
onClick={() => navigate('/panel/profile')}
|
||||
></Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
sx={{ mt: 0 }}
|
||||
primary={
|
||||
<Typography variant="subtitle1" sx={{ color: theme.palette.primary[800] }}>
|
||||
{account.user?.username}
|
||||
</Typography>
|
||||
}
|
||||
secondary={<Typography variant="caption"> 欢迎回来 </Typography>}
|
||||
/>
|
||||
</ListItem>
|
||||
</List>
|
||||
{/* <LinearProgressWithLabel value={80} /> */}
|
||||
</CardContent>
|
||||
</CardStyle>
|
||||
);
|
||||
};
|
||||
|
||||
export default MenuCard;
|
158
web/src/layout/MainLayout/Sidebar/MenuList/NavCollapse/index.js
Normal file
@ -0,0 +1,158 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useLocation, useNavigate } from 'react-router';
|
||||
|
||||
// material-ui
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import { Collapse, List, ListItemButton, ListItemIcon, ListItemText, Typography } from '@mui/material';
|
||||
|
||||
// project imports
|
||||
import NavItem from '../NavItem';
|
||||
|
||||
// assets
|
||||
import FiberManualRecordIcon from '@mui/icons-material/FiberManualRecord';
|
||||
import { IconChevronDown, IconChevronUp } from '@tabler/icons-react';
|
||||
|
||||
// ==============================|| SIDEBAR MENU LIST COLLAPSE ITEMS ||============================== //
|
||||
|
||||
const NavCollapse = ({ menu, level }) => {
|
||||
const theme = useTheme();
|
||||
const customization = useSelector((state) => state.customization);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selected, setSelected] = useState(null);
|
||||
|
||||
const handleClick = () => {
|
||||
setOpen(!open);
|
||||
setSelected(!selected ? menu.id : null);
|
||||
if (menu?.id !== 'authentication') {
|
||||
navigate(menu.children[0]?.url);
|
||||
}
|
||||
};
|
||||
|
||||
const { pathname } = useLocation();
|
||||
const checkOpenForParent = (child, id) => {
|
||||
child.forEach((item) => {
|
||||
if (item.url === pathname) {
|
||||
setOpen(true);
|
||||
setSelected(id);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// menu collapse for sub-levels
|
||||
useEffect(() => {
|
||||
setOpen(false);
|
||||
setSelected(null);
|
||||
if (menu.children) {
|
||||
menu.children.forEach((item) => {
|
||||
if (item.children?.length) {
|
||||
checkOpenForParent(item.children, menu.id);
|
||||
}
|
||||
if (item.url === pathname) {
|
||||
setSelected(menu.id);
|
||||
setOpen(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pathname, menu.children]);
|
||||
|
||||
// menu collapse & item
|
||||
const menus = menu.children?.map((item) => {
|
||||
switch (item.type) {
|
||||
case 'collapse':
|
||||
return <NavCollapse key={item.id} menu={item} level={level + 1} />;
|
||||
case 'item':
|
||||
return <NavItem key={item.id} item={item} level={level + 1} />;
|
||||
default:
|
||||
return (
|
||||
<Typography key={item.id} variant="h6" color="error" align="center">
|
||||
Menu Items Error
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const Icon = menu.icon;
|
||||
const menuIcon = menu.icon ? (
|
||||
<Icon strokeWidth={1.5} size="1.3rem" style={{ marginTop: 'auto', marginBottom: 'auto' }} />
|
||||
) : (
|
||||
<FiberManualRecordIcon
|
||||
sx={{
|
||||
width: selected === menu.id ? 8 : 6,
|
||||
height: selected === menu.id ? 8 : 6
|
||||
}}
|
||||
fontSize={level > 0 ? 'inherit' : 'medium'}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ListItemButton
|
||||
sx={{
|
||||
borderRadius: `${customization.borderRadius}px`,
|
||||
mb: 0.5,
|
||||
alignItems: 'flex-start',
|
||||
backgroundColor: level > 1 ? 'transparent !important' : 'inherit',
|
||||
py: level > 1 ? 1 : 1.25,
|
||||
pl: `${level * 24}px`
|
||||
}}
|
||||
selected={selected === menu.id}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<ListItemIcon sx={{ my: 'auto', minWidth: !menu.icon ? 18 : 36 }}>{menuIcon}</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Typography variant={selected === menu.id ? 'h5' : 'body1'} color="inherit" sx={{ my: 'auto' }}>
|
||||
{menu.title}
|
||||
</Typography>
|
||||
}
|
||||
secondary={
|
||||
menu.caption && (
|
||||
<Typography variant="caption" sx={{ ...theme.typography.subMenuCaption }} display="block" gutterBottom>
|
||||
{menu.caption}
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
/>
|
||||
{open ? (
|
||||
<IconChevronUp stroke={1.5} size="1rem" style={{ marginTop: 'auto', marginBottom: 'auto' }} />
|
||||
) : (
|
||||
<IconChevronDown stroke={1.5} size="1rem" style={{ marginTop: 'auto', marginBottom: 'auto' }} />
|
||||
)}
|
||||
</ListItemButton>
|
||||
<Collapse in={open} timeout="auto" unmountOnExit>
|
||||
<List
|
||||
component="div"
|
||||
disablePadding
|
||||
sx={{
|
||||
position: 'relative',
|
||||
'&:after': {
|
||||
content: "''",
|
||||
position: 'absolute',
|
||||
left: '32px',
|
||||
top: 0,
|
||||
height: '100%',
|
||||
width: '1px',
|
||||
opacity: 1,
|
||||
background: theme.palette.primary.light
|
||||
}
|
||||
}}
|
||||
>
|
||||
{menus}
|
||||
</List>
|
||||
</Collapse>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
NavCollapse.propTypes = {
|
||||
menu: PropTypes.object,
|
||||
level: PropTypes.number
|
||||
};
|
||||
|
||||
export default NavCollapse;
|
61
web/src/layout/MainLayout/Sidebar/MenuList/NavGroup/index.js
Normal file
@ -0,0 +1,61 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
// material-ui
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import { Divider, List, Typography } from '@mui/material';
|
||||
|
||||
// project imports
|
||||
import NavItem from '../NavItem';
|
||||
import NavCollapse from '../NavCollapse';
|
||||
|
||||
// ==============================|| SIDEBAR MENU LIST GROUP ||============================== //
|
||||
|
||||
const NavGroup = ({ item }) => {
|
||||
const theme = useTheme();
|
||||
|
||||
// menu list collapse & items
|
||||
const items = item.children?.map((menu) => {
|
||||
switch (menu.type) {
|
||||
case 'collapse':
|
||||
return <NavCollapse key={menu.id} menu={menu} level={1} />;
|
||||
case 'item':
|
||||
return <NavItem key={menu.id} item={menu} level={1} />;
|
||||
default:
|
||||
return (
|
||||
<Typography key={menu.id} variant="h6" color="error" align="center">
|
||||
Menu Items Error
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<List
|
||||
subheader={
|
||||
item.title && (
|
||||
<Typography variant="caption" sx={{ ...theme.typography.menuCaption }} display="block" gutterBottom>
|
||||
{item.title}
|
||||
{item.caption && (
|
||||
<Typography variant="caption" sx={{ ...theme.typography.subMenuCaption }} display="block" gutterBottom>
|
||||
{item.caption}
|
||||
</Typography>
|
||||
)}
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
>
|
||||
{items}
|
||||
</List>
|
||||
|
||||
{/* group divider */}
|
||||
<Divider sx={{ mt: 0.25, mb: 1.25 }} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
NavGroup.propTypes = {
|
||||
item: PropTypes.object
|
||||
};
|
||||
|
||||
export default NavGroup;
|
115
web/src/layout/MainLayout/Sidebar/MenuList/NavItem/index.js
Normal file
@ -0,0 +1,115 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { forwardRef, useEffect } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
// material-ui
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import { Avatar, Chip, ListItemButton, ListItemIcon, ListItemText, Typography, useMediaQuery } from '@mui/material';
|
||||
|
||||
// project imports
|
||||
import { MENU_OPEN, SET_MENU } from 'store/actions';
|
||||
|
||||
// assets
|
||||
import FiberManualRecordIcon from '@mui/icons-material/FiberManualRecord';
|
||||
|
||||
// ==============================|| SIDEBAR MENU LIST ITEMS ||============================== //
|
||||
|
||||
const NavItem = ({ item, level }) => {
|
||||
const theme = useTheme();
|
||||
const dispatch = useDispatch();
|
||||
const { pathname } = useLocation();
|
||||
const customization = useSelector((state) => state.customization);
|
||||
const matchesSM = useMediaQuery(theme.breakpoints.down('lg'));
|
||||
|
||||
const Icon = item.icon;
|
||||
const itemIcon = item?.icon ? (
|
||||
<Icon stroke={1.5} size="1.3rem" />
|
||||
) : (
|
||||
<FiberManualRecordIcon
|
||||
sx={{
|
||||
width: customization.isOpen.findIndex((id) => id === item?.id) > -1 ? 8 : 6,
|
||||
height: customization.isOpen.findIndex((id) => id === item?.id) > -1 ? 8 : 6
|
||||
}}
|
||||
fontSize={level > 0 ? 'inherit' : 'medium'}
|
||||
/>
|
||||
);
|
||||
|
||||
let itemTarget = '_self';
|
||||
if (item.target) {
|
||||
itemTarget = '_blank';
|
||||
}
|
||||
|
||||
let listItemProps = {
|
||||
component: forwardRef((props, ref) => <Link ref={ref} {...props} to={item.url} target={itemTarget} />)
|
||||
};
|
||||
if (item?.external) {
|
||||
listItemProps = { component: 'a', href: item.url, target: itemTarget };
|
||||
}
|
||||
|
||||
const itemHandler = (id) => {
|
||||
dispatch({ type: MENU_OPEN, id });
|
||||
if (matchesSM) dispatch({ type: SET_MENU, opened: false });
|
||||
};
|
||||
|
||||
// active menu item on page load
|
||||
useEffect(() => {
|
||||
const currentIndex = document.location.pathname
|
||||
.toString()
|
||||
.split('/')
|
||||
.findIndex((id) => id === item.id);
|
||||
if (currentIndex > -1) {
|
||||
dispatch({ type: MENU_OPEN, id: item.id });
|
||||
}
|
||||
// eslint-disable-next-line
|
||||
}, [pathname]);
|
||||
|
||||
return (
|
||||
<ListItemButton
|
||||
{...listItemProps}
|
||||
disabled={item.disabled}
|
||||
sx={{
|
||||
borderRadius: `${customization.borderRadius}px`,
|
||||
mb: 0.5,
|
||||
alignItems: 'flex-start',
|
||||
backgroundColor: level > 1 ? 'transparent !important' : 'inherit',
|
||||
py: level > 1 ? 1 : 1.25,
|
||||
pl: `${level * 24}px`
|
||||
}}
|
||||
selected={customization.isOpen.findIndex((id) => id === item.id) > -1}
|
||||
onClick={() => itemHandler(item.id)}
|
||||
>
|
||||
<ListItemIcon sx={{ my: 'auto', minWidth: !item?.icon ? 18 : 36 }}>{itemIcon}</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Typography variant={customization.isOpen.findIndex((id) => id === item.id) > -1 ? 'h5' : 'body1'} color="inherit">
|
||||
{item.title}
|
||||
</Typography>
|
||||
}
|
||||
secondary={
|
||||
item.caption && (
|
||||
<Typography variant="caption" sx={{ ...theme.typography.subMenuCaption }} display="block" gutterBottom>
|
||||
{item.caption}
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
/>
|
||||
{item.chip && (
|
||||
<Chip
|
||||
color={item.chip.color}
|
||||
variant={item.chip.variant}
|
||||
size={item.chip.size}
|
||||
label={item.chip.label}
|
||||
avatar={item.chip.avatar && <Avatar>{item.chip.avatar}</Avatar>}
|
||||
/>
|
||||
)}
|
||||
</ListItemButton>
|
||||
);
|
||||
};
|
||||
|
||||
NavItem.propTypes = {
|
||||
item: PropTypes.object,
|
||||
level: PropTypes.number
|
||||
};
|
||||
|
||||
export default NavItem;
|
36
web/src/layout/MainLayout/Sidebar/MenuList/index.js
Normal file
@ -0,0 +1,36 @@
|
||||
// material-ui
|
||||
import { Typography } from '@mui/material';
|
||||
|
||||
// project imports
|
||||
import NavGroup from './NavGroup';
|
||||
import menuItem from 'menu-items';
|
||||
import { isAdmin } from 'utils/common';
|
||||
|
||||
// ==============================|| SIDEBAR MENU LIST ||============================== //
|
||||
const MenuList = () => {
|
||||
const userIsAdmin = isAdmin();
|
||||
|
||||
return (
|
||||
<>
|
||||
{menuItem.items.map((item) => {
|
||||
if (item.type !== 'group') {
|
||||
return (
|
||||
<Typography key={item.id} variant="h6" color="error" align="center">
|
||||
Menu Items Error
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
const filteredChildren = item.children.filter((child) => !child.isAdmin || userIsAdmin);
|
||||
|
||||
if (filteredChildren.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <NavGroup key={item.id} item={{ ...item, children: filteredChildren }} />;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MenuList;
|
94
web/src/layout/MainLayout/Sidebar/index.js
Normal file
@ -0,0 +1,94 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
// material-ui
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import { Box, Chip, Drawer, Stack, useMediaQuery } from '@mui/material';
|
||||
|
||||
// third-party
|
||||
import PerfectScrollbar from 'react-perfect-scrollbar';
|
||||
import { BrowserView, MobileView } from 'react-device-detect';
|
||||
|
||||
// project imports
|
||||
import MenuList from './MenuList';
|
||||
import LogoSection from '../LogoSection';
|
||||
import MenuCard from './MenuCard';
|
||||
import { drawerWidth } from 'store/constant';
|
||||
|
||||
// ==============================|| SIDEBAR DRAWER ||============================== //
|
||||
|
||||
const Sidebar = ({ drawerOpen, drawerToggle, window }) => {
|
||||
const theme = useTheme();
|
||||
const matchUpMd = useMediaQuery(theme.breakpoints.up('md'));
|
||||
|
||||
const drawer = (
|
||||
<>
|
||||
<Box sx={{ display: { xs: 'block', md: 'none' } }}>
|
||||
<Box sx={{ display: 'flex', p: 2, mx: 'auto' }}>
|
||||
<LogoSection />
|
||||
</Box>
|
||||
</Box>
|
||||
<BrowserView>
|
||||
<PerfectScrollbar
|
||||
component="div"
|
||||
style={{
|
||||
height: !matchUpMd ? 'calc(100vh - 56px)' : 'calc(100vh - 88px)',
|
||||
paddingLeft: '16px',
|
||||
paddingRight: '16px'
|
||||
}}
|
||||
>
|
||||
<MenuList />
|
||||
<MenuCard />
|
||||
<Stack direction="row" justifyContent="center" sx={{ mb: 2 }}>
|
||||
<Chip label={process.env.REACT_APP_VERSION} disabled chipcolor="secondary" size="small" sx={{ cursor: 'pointer' }} />
|
||||
</Stack>
|
||||
</PerfectScrollbar>
|
||||
</BrowserView>
|
||||
<MobileView>
|
||||
<Box sx={{ px: 2 }}>
|
||||
<MenuList />
|
||||
<MenuCard />
|
||||
<Stack direction="row" justifyContent="center" sx={{ mb: 2 }}>
|
||||
<Chip label={process.env.REACT_APP_VERSION} disabled chipcolor="secondary" size="small" sx={{ cursor: 'pointer' }} />
|
||||
</Stack>
|
||||
</Box>
|
||||
</MobileView>
|
||||
</>
|
||||
);
|
||||
|
||||
const container = window !== undefined ? () => window.document.body : undefined;
|
||||
|
||||
return (
|
||||
<Box component="nav" sx={{ flexShrink: { md: 0 }, width: matchUpMd ? drawerWidth : 'auto' }} aria-label="mailbox folders">
|
||||
<Drawer
|
||||
container={container}
|
||||
variant={matchUpMd ? 'persistent' : 'temporary'}
|
||||
anchor="left"
|
||||
open={drawerOpen}
|
||||
onClose={drawerToggle}
|
||||
sx={{
|
||||
'& .MuiDrawer-paper': {
|
||||
width: drawerWidth,
|
||||
background: theme.palette.background.default,
|
||||
color: theme.palette.text.primary,
|
||||
borderRight: 'none',
|
||||
[theme.breakpoints.up('md')]: {
|
||||
top: '88px'
|
||||
}
|
||||
}
|
||||
}}
|
||||
ModalProps={{ keepMounted: true }}
|
||||
color="inherit"
|
||||
>
|
||||
{drawer}
|
||||
</Drawer>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
Sidebar.propTypes = {
|
||||
drawerOpen: PropTypes.bool,
|
||||
drawerToggle: PropTypes.func,
|
||||
window: PropTypes.object
|
||||
};
|
||||
|
||||
export default Sidebar;
|
103
web/src/layout/MainLayout/index.js
Normal file
@ -0,0 +1,103 @@
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import AuthGuard from 'utils/route-guard/AuthGuard';
|
||||
|
||||
// material-ui
|
||||
import { styled, useTheme } from '@mui/material/styles';
|
||||
import { AppBar, Box, CssBaseline, Toolbar, useMediaQuery } from '@mui/material';
|
||||
import AdminContainer from 'ui-component/AdminContainer';
|
||||
|
||||
// project imports
|
||||
import Breadcrumbs from 'ui-component/extended/Breadcrumbs';
|
||||
import Header from './Header';
|
||||
import Sidebar from './Sidebar';
|
||||
import navigation from 'menu-items';
|
||||
import { drawerWidth } from 'store/constant';
|
||||
import { SET_MENU } from 'store/actions';
|
||||
|
||||
// assets
|
||||
import { IconChevronRight } from '@tabler/icons-react';
|
||||
|
||||
// styles
|
||||
const Main = styled('main', { shouldForwardProp: (prop) => prop !== 'open' })(({ theme, open }) => ({
|
||||
...theme.typography.mainContent,
|
||||
borderBottomLeftRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
transition: theme.transitions.create(
|
||||
'margin',
|
||||
open
|
||||
? {
|
||||
easing: theme.transitions.easing.easeOut,
|
||||
duration: theme.transitions.duration.enteringScreen
|
||||
}
|
||||
: {
|
||||
easing: theme.transitions.easing.sharp,
|
||||
duration: theme.transitions.duration.leavingScreen
|
||||
}
|
||||
),
|
||||
[theme.breakpoints.up('md')]: {
|
||||
marginLeft: open ? 0 : -(drawerWidth - 20),
|
||||
width: `calc(100% - ${drawerWidth}px)`
|
||||
},
|
||||
[theme.breakpoints.down('md')]: {
|
||||
marginLeft: '20px',
|
||||
width: `calc(100% - ${drawerWidth}px)`,
|
||||
padding: '16px'
|
||||
},
|
||||
[theme.breakpoints.down('sm')]: {
|
||||
marginLeft: '10px',
|
||||
width: `calc(100% - ${drawerWidth}px)`,
|
||||
padding: '16px',
|
||||
marginRight: '10px'
|
||||
}
|
||||
}));
|
||||
|
||||
// ==============================|| MAIN LAYOUT ||============================== //
|
||||
|
||||
const MainLayout = () => {
|
||||
const theme = useTheme();
|
||||
const matchDownMd = useMediaQuery(theme.breakpoints.down('md'));
|
||||
// Handle left drawer
|
||||
const leftDrawerOpened = useSelector((state) => state.customization.opened);
|
||||
const dispatch = useDispatch();
|
||||
const handleLeftDrawerToggle = () => {
|
||||
dispatch({ type: SET_MENU, opened: !leftDrawerOpened });
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex' }}>
|
||||
<CssBaseline />
|
||||
{/* header */}
|
||||
<AppBar
|
||||
enableColorOnDark
|
||||
position="fixed"
|
||||
color="inherit"
|
||||
elevation={0}
|
||||
sx={{
|
||||
bgcolor: theme.palette.background.default,
|
||||
transition: leftDrawerOpened ? theme.transitions.create('width') : 'none'
|
||||
}}
|
||||
>
|
||||
<Toolbar>
|
||||
<Header handleLeftDrawerToggle={handleLeftDrawerToggle} />
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
{/* drawer */}
|
||||
<Sidebar drawerOpen={!matchDownMd ? leftDrawerOpened : !leftDrawerOpened} drawerToggle={handleLeftDrawerToggle} />
|
||||
|
||||
{/* main content */}
|
||||
<Main theme={theme} open={leftDrawerOpened}>
|
||||
{/* breadcrumb */}
|
||||
<Breadcrumbs separator={IconChevronRight} navigation={navigation} icon title rightAlign />
|
||||
<AuthGuard>
|
||||
<AdminContainer>
|
||||
<Outlet />
|
||||
</AdminContainer>
|
||||
</AuthGuard>
|
||||
</Main>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default MainLayout;
|
55
web/src/layout/MinimalLayout/Header/index.js
Normal file
@ -0,0 +1,55 @@
|
||||
// material-ui
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import { Box, Button, Stack } from '@mui/material';
|
||||
import LogoSection from 'layout/MainLayout/LogoSection';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
// ==============================|| MAIN NAVBAR / HEADER ||============================== //
|
||||
|
||||
const Header = () => {
|
||||
const theme = useTheme();
|
||||
const { pathname } = useLocation();
|
||||
const account = useSelector((state) => state.account);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
sx={{
|
||||
width: 228,
|
||||
display: 'flex',
|
||||
[theme.breakpoints.down('md')]: {
|
||||
width: 'auto'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Box component="span" sx={{ flexGrow: 1 }}>
|
||||
<LogoSection />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
<Stack spacing={2} direction="row">
|
||||
<Button component={Link} variant="text" to="/" color={pathname === '/' ? 'primary' : 'inherit'}>
|
||||
首页
|
||||
</Button>
|
||||
<Button component={Link} variant="text" to="/about" color={pathname === '/about' ? 'primary' : 'inherit'}>
|
||||
关于
|
||||
</Button>
|
||||
{account.user ? (
|
||||
<Button component={Link} variant="contained" to="/panel" color="primary">
|
||||
控制台
|
||||
</Button>
|
||||
) : (
|
||||
<Button component={Link} variant="contained" to="/login" color="primary">
|
||||
登入
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
39
web/src/layout/MinimalLayout/index.js
Normal file
@ -0,0 +1,39 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import { AppBar, Box, CssBaseline, Toolbar } from '@mui/material';
|
||||
import Header from './Header';
|
||||
import Footer from 'ui-component/Footer';
|
||||
|
||||
// ==============================|| MINIMAL LAYOUT ||============================== //
|
||||
|
||||
const MinimalLayout = () => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
|
||||
<CssBaseline />
|
||||
<AppBar
|
||||
enableColorOnDark
|
||||
position="fixed"
|
||||
color="inherit"
|
||||
elevation={0}
|
||||
sx={{
|
||||
bgcolor: theme.palette.background.default,
|
||||
flex: 'none'
|
||||
}}
|
||||
>
|
||||
<Toolbar>
|
||||
<Header />
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
<Box sx={{ flex: '1 1 auto', overflow: 'auto' }} paddingTop={'64px'}>
|
||||
<Outlet />
|
||||
</Box>
|
||||
<Box sx={{ flex: 'none' }}>
|
||||
<Footer />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default MinimalLayout;
|
39
web/src/layout/NavMotion.js
Normal file
@ -0,0 +1,39 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
// ==============================|| ANIMATION FOR CONTENT ||============================== //
|
||||
|
||||
const NavMotion = ({ children }) => {
|
||||
const motionVariants = {
|
||||
initial: {
|
||||
opacity: 0,
|
||||
scale: 0.99
|
||||
},
|
||||
in: {
|
||||
opacity: 1,
|
||||
scale: 1
|
||||
},
|
||||
out: {
|
||||
opacity: 0,
|
||||
scale: 1.01
|
||||
}
|
||||
};
|
||||
|
||||
const motionTransition = {
|
||||
type: 'tween',
|
||||
ease: 'anticipate',
|
||||
duration: 0.4
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div initial="initial" animate="in" exit="out" variants={motionVariants} transition={motionTransition}>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
NavMotion.propTypes = {
|
||||
children: PropTypes.node
|
||||
};
|
||||
|
||||
export default NavMotion;
|
26
web/src/layout/NavigationScroll.js
Normal file
@ -0,0 +1,26 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
// ==============================|| NAVIGATION SCROLL TO TOP ||============================== //
|
||||
|
||||
const NavigationScroll = ({ children }) => {
|
||||
const location = useLocation();
|
||||
const { pathname } = location;
|
||||
|
||||
useEffect(() => {
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
left: 0,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}, [pathname]);
|
||||
|
||||
return children || null;
|
||||
};
|
||||
|
||||
NavigationScroll.propTypes = {
|
||||
children: PropTypes.node
|
||||
};
|
||||
|
||||
export default NavigationScroll;
|
18
web/src/menu-items/index.js
Normal file
@ -0,0 +1,18 @@
|
||||
import panel from './panel';
|
||||
|
||||
// ==============================|| MENU ITEMS ||============================== //
|
||||
|
||||
const menuItems = {
|
||||
items: [panel],
|
||||
urlMap: {}
|
||||
};
|
||||
|
||||
// Initialize urlMap
|
||||
menuItems.urlMap = menuItems.items.reduce((map, item) => {
|
||||
item.children.forEach((child) => {
|
||||
map[child.url] = child;
|
||||
});
|
||||
return map;
|
||||
}, {});
|
||||
|
||||
export default menuItems;
|
104
web/src/menu-items/panel.js
Normal file
@ -0,0 +1,104 @@
|
||||
// assets
|
||||
import {
|
||||
IconDashboard,
|
||||
IconSitemap,
|
||||
IconArticle,
|
||||
IconCoin,
|
||||
IconAdjustments,
|
||||
IconKey,
|
||||
IconGardenCart,
|
||||
IconUser,
|
||||
IconUserScan
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
// constant
|
||||
const icons = { IconDashboard, IconSitemap, IconArticle, IconCoin, IconAdjustments, IconKey, IconGardenCart, IconUser, IconUserScan };
|
||||
|
||||
// ==============================|| DASHBOARD MENU ITEMS ||============================== //
|
||||
|
||||
const panel = {
|
||||
id: 'panel',
|
||||
type: 'group',
|
||||
children: [
|
||||
{
|
||||
id: 'dashboard',
|
||||
title: 'Dashboard',
|
||||
type: 'item',
|
||||
url: '/panel/dashboard',
|
||||
icon: icons.IconDashboard,
|
||||
breadcrumbs: false,
|
||||
isAdmin: false
|
||||
},
|
||||
{
|
||||
id: 'channel',
|
||||
title: '渠道',
|
||||
type: 'item',
|
||||
url: '/panel/channel',
|
||||
icon: icons.IconSitemap,
|
||||
breadcrumbs: false,
|
||||
isAdmin: true
|
||||
},
|
||||
{
|
||||
id: 'token',
|
||||
title: 'Token',
|
||||
type: 'item',
|
||||
url: '/panel/token',
|
||||
icon: icons.IconKey,
|
||||
breadcrumbs: false
|
||||
},
|
||||
{
|
||||
id: 'log',
|
||||
title: '日志',
|
||||
type: 'item',
|
||||
url: '/panel/log',
|
||||
icon: icons.IconArticle,
|
||||
breadcrumbs: false
|
||||
},
|
||||
{
|
||||
id: 'redemption',
|
||||
title: '兑换',
|
||||
type: 'item',
|
||||
url: '/panel/redemption',
|
||||
icon: icons.IconCoin,
|
||||
breadcrumbs: false,
|
||||
isAdmin: true
|
||||
},
|
||||
{
|
||||
id: 'topup',
|
||||
title: '充值',
|
||||
type: 'item',
|
||||
url: '/panel/topup',
|
||||
icon: icons.IconGardenCart,
|
||||
breadcrumbs: false
|
||||
},
|
||||
{
|
||||
id: 'user',
|
||||
title: '用户',
|
||||
type: 'item',
|
||||
url: '/panel/user',
|
||||
icon: icons.IconUser,
|
||||
breadcrumbs: false,
|
||||
isAdmin: true
|
||||
},
|
||||
{
|
||||
id: 'profile',
|
||||
title: '个人设置',
|
||||
type: 'item',
|
||||
url: '/panel/profile',
|
||||
icon: icons.IconUserScan,
|
||||
breadcrumbs: false,
|
||||
isAdmin: true
|
||||
},
|
||||
{
|
||||
id: 'setting',
|
||||
title: '设置',
|
||||
type: 'item',
|
||||
url: '/panel/setting',
|
||||
icon: icons.IconAdjustments,
|
||||
breadcrumbs: false,
|
||||
isAdmin: true
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export default panel;
|
@ -1,58 +0,0 @@
|
||||
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;
|
@ -1,469 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, Form, Header, Input, Message, Segment } from 'semantic-ui-react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { API, showError, showInfo, showSuccess, verifyJSON } from '../../helpers';
|
||||
import { CHANNEL_OPTIONS } from '../../constants';
|
||||
|
||||
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 = () => {
|
||||
const params = useParams();
|
||||
const navigate = useNavigate();
|
||||
const channelId = params.id;
|
||||
const isEdit = channelId !== undefined;
|
||||
const [loading, setLoading] = useState(isEdit);
|
||||
const handleCancel = () => {
|
||||
navigate('/channel');
|
||||
};
|
||||
|
||||
const originInputs = {
|
||||
name: '',
|
||||
type: 1,
|
||||
key: '',
|
||||
base_url: '',
|
||||
other: '',
|
||||
model_mapping: '',
|
||||
models: [],
|
||||
groups: ['default']
|
||||
};
|
||||
const [batch, setBatch] = useState(false);
|
||||
const [inputs, setInputs] = useState(originInputs);
|
||||
const [originModelOptions, setOriginModelOptions] = useState([]);
|
||||
const [modelOptions, setModelOptions] = useState([]);
|
||||
const [groupOptions, setGroupOptions] = useState([]);
|
||||
const [basicModels, setBasicModels] = useState([]);
|
||||
const [fullModels, setFullModels] = useState([]);
|
||||
const [customModel, setCustomModel] = useState('');
|
||||
const handleInputChange = (e, { name, value }) => {
|
||||
setInputs((inputs) => ({ ...inputs, [name]: value }));
|
||||
if (name === 'type' && inputs.models.length === 0) {
|
||||
let localModels = [];
|
||||
switch (value) {
|
||||
case 14:
|
||||
localModels = ['claude-instant-1', 'claude-2'];
|
||||
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', 'text-embedding-v1'];
|
||||
break;
|
||||
case 16:
|
||||
localModels = ['chatglm_turbo', 'chatglm_pro', 'chatglm_std', 'chatglm_lite'];
|
||||
break;
|
||||
case 18:
|
||||
localModels = ['SparkDesk'];
|
||||
break;
|
||||
case 19:
|
||||
localModels = ['360GPT_S2_V9', 'embedding-bert-512-v1', 'embedding_s1_v1', 'semantic_similarity_s1_v1'];
|
||||
break;
|
||||
case 23:
|
||||
localModels = ['hunyuan'];
|
||||
break;
|
||||
}
|
||||
setInputs((inputs) => ({ ...inputs, models: localModels }));
|
||||
}
|
||||
};
|
||||
|
||||
const loadChannel = async () => {
|
||||
let res = await API.get(`/api/channel/${channelId}`);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
if (data.models === '') {
|
||||
data.models = [];
|
||||
} else {
|
||||
data.models = data.models.split(',');
|
||||
}
|
||||
if (data.group === '') {
|
||||
data.groups = [];
|
||||
} else {
|
||||
data.groups = data.group.split(',');
|
||||
}
|
||||
if (data.model_mapping !== '') {
|
||||
data.model_mapping = JSON.stringify(JSON.parse(data.model_mapping), null, 2);
|
||||
}
|
||||
setInputs(data);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const fetchModels = async () => {
|
||||
try {
|
||||
let res = await API.get(`/api/channel/models`);
|
||||
let localModelOptions = res.data.data.map((model) => ({
|
||||
key: model.id,
|
||||
text: model.id,
|
||||
value: model.id
|
||||
}));
|
||||
setOriginModelOptions(localModelOptions);
|
||||
setFullModels(res.data.data.map((model) => model.id));
|
||||
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) => ({
|
||||
key: group,
|
||||
text: group,
|
||||
value: group
|
||||
})));
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let localModelOptions = [...originModelOptions];
|
||||
inputs.models.forEach((model) => {
|
||||
if (!localModelOptions.find((option) => option.key === model)) {
|
||||
localModelOptions.push({
|
||||
key: model,
|
||||
text: model,
|
||||
value: model
|
||||
});
|
||||
}
|
||||
});
|
||||
setModelOptions(localModelOptions);
|
||||
}, [originModelOptions, inputs.models]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit) {
|
||||
loadChannel().then();
|
||||
}
|
||||
fetchModels().then();
|
||||
fetchGroups().then();
|
||||
}, []);
|
||||
|
||||
const submit = async () => {
|
||||
if (!isEdit && (inputs.name === '' || inputs.key === '')) {
|
||||
showInfo('请填写渠道名称和渠道密钥!');
|
||||
return;
|
||||
}
|
||||
if (inputs.models.length === 0) {
|
||||
showInfo('请至少选择一个模型!');
|
||||
return;
|
||||
}
|
||||
if (inputs.model_mapping !== '' && !verifyJSON(inputs.model_mapping)) {
|
||||
showInfo('模型映射必须是合法的 JSON 格式!');
|
||||
return;
|
||||
}
|
||||
let localInputs = inputs;
|
||||
if (localInputs.base_url && localInputs.base_url.endsWith('/')) {
|
||||
localInputs.base_url = localInputs.base_url.slice(0, localInputs.base_url.length - 1);
|
||||
}
|
||||
if (localInputs.type === 3 && localInputs.other === '') {
|
||||
localInputs.other = '2023-06-01-preview';
|
||||
}
|
||||
if (localInputs.type === 18 && localInputs.other === '') {
|
||||
localInputs.other = 'v2.1';
|
||||
}
|
||||
let res;
|
||||
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);
|
||||
}
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
};
|
||||
|
||||
const addCustomModel = () => {
|
||||
if (customModel.trim() === '') return;
|
||||
if (inputs.models.includes(customModel)) return;
|
||||
let localModels = [...inputs.models];
|
||||
localModels.push(customModel);
|
||||
let localModelOptions = [];
|
||||
localModelOptions.push({
|
||||
key: customModel,
|
||||
text: customModel,
|
||||
value: customModel
|
||||
});
|
||||
setModelOptions(modelOptions => {
|
||||
return [...modelOptions, ...localModelOptions];
|
||||
});
|
||||
setCustomModel('');
|
||||
handleInputChange(null, { name: 'models', value: localModels });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Segment loading={loading}>
|
||||
<Header as='h3'>{isEdit ? '更新渠道信息' : '创建新的渠道'}</Header>
|
||||
<Form autoComplete='new-password'>
|
||||
<Form.Field>
|
||||
<Form.Select
|
||||
label='类型'
|
||||
name='type'
|
||||
required
|
||||
options={CHANNEL_OPTIONS}
|
||||
value={inputs.type}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</Form.Field>
|
||||
{
|
||||
inputs.type === 3 && (
|
||||
<>
|
||||
<Message>
|
||||
注意,<strong>模型部署名称必须和模型名称保持一致</strong>,因为 One API 会把请求体中的 model
|
||||
参数替换为你的部署名称(模型名称中的点会被剔除),<a target='_blank'
|
||||
href='https://github.com/songquanpeng/one-api/issues/133?notification_referrer_id=NT_kwDOAmJSYrM2NjIwMzI3NDgyOjM5OTk4MDUw#issuecomment-1571602271'>图片演示</a>。
|
||||
</Message>
|
||||
<Form.Field>
|
||||
<Form.Input
|
||||
label='AZURE_OPENAI_ENDPOINT'
|
||||
name='base_url'
|
||||
placeholder={'请输入 AZURE_OPENAI_ENDPOINT,例如:https://docs-test-001.openai.azure.com'}
|
||||
onChange={handleInputChange}
|
||||
value={inputs.base_url}
|
||||
autoComplete='new-password'
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<Form.Input
|
||||
label='默认 API 版本'
|
||||
name='other'
|
||||
placeholder={'请输入默认 API 版本,例如:2023-06-01-preview,该配置可以被实际的请求查询参数所覆盖'}
|
||||
onChange={handleInputChange}
|
||||
value={inputs.other}
|
||||
autoComplete='new-password'
|
||||
/>
|
||||
</Form.Field>
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
inputs.type === 8 && (
|
||||
<Form.Field>
|
||||
<Form.Input
|
||||
label='Base URL'
|
||||
name='base_url'
|
||||
placeholder={'请输入自定义渠道的 Base URL,例如:https://openai.justsong.cn'}
|
||||
onChange={handleInputChange}
|
||||
value={inputs.base_url}
|
||||
autoComplete='new-password'
|
||||
/>
|
||||
</Form.Field>
|
||||
)
|
||||
}
|
||||
<Form.Field>
|
||||
<Form.Input
|
||||
label='名称'
|
||||
required
|
||||
name='name'
|
||||
placeholder={'请为渠道命名'}
|
||||
onChange={handleInputChange}
|
||||
value={inputs.name}
|
||||
autoComplete='new-password'
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<Form.Dropdown
|
||||
label='分组'
|
||||
placeholder={'请选择可以使用该渠道的分组'}
|
||||
name='groups'
|
||||
required
|
||||
fluid
|
||||
multiple
|
||||
selection
|
||||
allowAdditions
|
||||
additionLabel={'请在系统设置页面编辑分组倍率以添加新的分组:'}
|
||||
onChange={handleInputChange}
|
||||
value={inputs.groups}
|
||||
autoComplete='new-password'
|
||||
options={groupOptions}
|
||||
/>
|
||||
</Form.Field>
|
||||
{
|
||||
inputs.type === 18 && (
|
||||
<Form.Field>
|
||||
<Form.Input
|
||||
label='模型版本'
|
||||
name='other'
|
||||
placeholder={'请输入星火大模型版本,注意是接口地址中的版本号,例如:v2.1'}
|
||||
onChange={handleInputChange}
|
||||
value={inputs.other}
|
||||
autoComplete='new-password'
|
||||
/>
|
||||
</Form.Field>
|
||||
)
|
||||
}
|
||||
{
|
||||
inputs.type === 21 && (
|
||||
<Form.Field>
|
||||
<Form.Input
|
||||
label='知识库 ID'
|
||||
name='other'
|
||||
placeholder={'请输入知识库 ID,例如:123456'}
|
||||
onChange={handleInputChange}
|
||||
value={inputs.other}
|
||||
autoComplete='new-password'
|
||||
/>
|
||||
</Form.Field>
|
||||
)
|
||||
}
|
||||
<Form.Field>
|
||||
<Form.Dropdown
|
||||
label='模型'
|
||||
placeholder={'请选择该渠道所支持的模型'}
|
||||
name='models'
|
||||
required
|
||||
fluid
|
||||
multiple
|
||||
selection
|
||||
onChange={handleInputChange}
|
||||
value={inputs.models}
|
||||
autoComplete='new-password'
|
||||
options={modelOptions}
|
||||
/>
|
||||
</Form.Field>
|
||||
<div style={{ lineHeight: '40px', marginBottom: '12px' }}>
|
||||
<Button type={'button'} onClick={() => {
|
||||
handleInputChange(null, { name: 'models', value: basicModels });
|
||||
}}>填入基础模型</Button>
|
||||
<Button type={'button'} onClick={() => {
|
||||
handleInputChange(null, { name: 'models', value: fullModels });
|
||||
}}>填入所有模型</Button>
|
||||
<Button type={'button'} onClick={() => {
|
||||
handleInputChange(null, { name: 'models', value: [] });
|
||||
}}>清除所有模型</Button>
|
||||
<Input
|
||||
action={
|
||||
<Button type={'button'} onClick={addCustomModel}>填入</Button>
|
||||
}
|
||||
placeholder='输入自定义模型名称'
|
||||
value={customModel}
|
||||
onChange={(e, { value }) => {
|
||||
setCustomModel(value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
addCustomModel();
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Form.Field>
|
||||
<Form.TextArea
|
||||
label='模型重定向'
|
||||
placeholder={`此项可选,用于修改请求体中的模型名称,为一个 JSON 字符串,键为请求中模型名称,值为要替换的模型名称,例如:\n${JSON.stringify(MODEL_MAPPING_EXAMPLE, null, 2)}`}
|
||||
name='model_mapping'
|
||||
onChange={handleInputChange}
|
||||
value={inputs.model_mapping}
|
||||
style={{ minHeight: 150, fontFamily: 'JetBrains Mono, Consolas' }}
|
||||
autoComplete='new-password'
|
||||
/>
|
||||
</Form.Field>
|
||||
{
|
||||
batch ? <Form.Field>
|
||||
<Form.TextArea
|
||||
label='密钥'
|
||||
name='key'
|
||||
required
|
||||
placeholder={'请输入密钥,一行一个'}
|
||||
onChange={handleInputChange}
|
||||
value={inputs.key}
|
||||
style={{ minHeight: 150, fontFamily: 'JetBrains Mono, Consolas' }}
|
||||
autoComplete='new-password'
|
||||
/>
|
||||
</Form.Field> : <Form.Field>
|
||||
<Form.Input
|
||||
label='密钥'
|
||||
name='key'
|
||||
required
|
||||
placeholder={type2secretPrompt(inputs.type)}
|
||||
onChange={handleInputChange}
|
||||
value={inputs.key}
|
||||
autoComplete='new-password'
|
||||
/>
|
||||
</Form.Field>
|
||||
}
|
||||
{
|
||||
!isEdit && (
|
||||
<Form.Checkbox
|
||||
checked={batch}
|
||||
label='批量创建'
|
||||
name='batch'
|
||||
onChange={() => setBatch(!batch)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
inputs.type !== 3 && inputs.type !== 8 && inputs.type !== 22 && (
|
||||
<Form.Field>
|
||||
<Form.Input
|
||||
label='代理'
|
||||
name='base_url'
|
||||
placeholder={'此项可选,用于通过代理站来进行 API 调用,请输入代理站地址,格式为:https://domain.com'}
|
||||
onChange={handleInputChange}
|
||||
value={inputs.base_url}
|
||||
autoComplete='new-password'
|
||||
/>
|
||||
</Form.Field>
|
||||
)
|
||||
}
|
||||
{
|
||||
inputs.type === 22 && (
|
||||
<Form.Field>
|
||||
<Form.Input
|
||||
label='私有部署地址'
|
||||
name='base_url'
|
||||
placeholder={'请输入私有部署地址,格式为:https://fastgpt.run/api/openapi'}
|
||||
onChange={handleInputChange}
|
||||
value={inputs.base_url}
|
||||
autoComplete='new-password'
|
||||
/>
|
||||
</Form.Field>
|
||||
)
|
||||
}
|
||||
<Button onClick={handleCancel}>取消</Button>
|
||||
<Button type={isEdit ? 'button' : 'submit'} positive onClick={submit}>提交</Button>
|
||||
</Form>
|
||||
</Segment>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditChannel;
|
@ -1,14 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Header, Segment } from 'semantic-ui-react';
|
||||
import ChannelsTable from '../../components/ChannelsTable';
|
||||
|
||||
const File = () => (
|
||||
<>
|
||||
<Segment>
|
||||
<Header as='h3'>管理渠道</Header>
|
||||
<ChannelsTable />
|
||||
</Segment>
|
||||
</>
|
||||
);
|
||||
|
||||
export default File;
|
@ -1,15 +0,0 @@
|
||||
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;
|