mirror of
https://github.com/songquanpeng/one-api.git
synced 2025-11-13 03:43:44 +08:00
feat: add new theme berry (#860)
* feat: add theme berry * docs: add development notes * fix: fix blank page * chore: update implementation * fix: fix package.json * chore: update ui copy --------- Co-authored-by: JustSong <songquanpeng@foxmail.com>
This commit is contained in:
69
web/berry/src/views/About/index.js
Normal file
69
web/berry/src/views/About/index.js
Normal file
@@ -0,0 +1,69 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { API } from 'utils/api';
|
||||
import { showError } from 'utils/common';
|
||||
import { marked } from 'marked';
|
||||
import { Box, Container, Typography } from '@mui/material';
|
||||
import MainCard from 'ui-component/cards/MainCard';
|
||||
|
||||
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 === '' ? (
|
||||
<>
|
||||
<Box>
|
||||
<Container sx={{ paddingTop: '40px' }}>
|
||||
<MainCard title="关于">
|
||||
<Typography variant="body2">
|
||||
可在设置页面设置关于内容,支持 HTML & Markdown <br />
|
||||
项目仓库地址:
|
||||
<a href="https://github.com/songquanpeng/one-api">https://github.com/songquanpeng/one-api</a>
|
||||
</Typography>
|
||||
</MainCard>
|
||||
</Container>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Box>
|
||||
{about.startsWith('https://') ? (
|
||||
<iframe title="about" src={about} style={{ width: '100%', height: '100vh', border: 'none' }} />
|
||||
) : (
|
||||
<>
|
||||
<Container>
|
||||
<div style={{ fontSize: 'larger' }} dangerouslySetInnerHTML={{ __html: about }}></div>
|
||||
</Container>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default About;
|
||||
68
web/berry/src/views/Authentication/Auth/ForgetPassword.js
Normal file
68
web/berry/src/views/Authentication/Auth/ForgetPassword.js
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
// material-ui
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import { Divider, Grid, Stack, Typography, useMediaQuery } from '@mui/material';
|
||||
|
||||
// project imports
|
||||
import AuthWrapper from '../AuthWrapper';
|
||||
import AuthCardWrapper from '../AuthCardWrapper';
|
||||
import ForgetPasswordForm from '../AuthForms/ForgetPasswordForm';
|
||||
import Logo from 'ui-component/Logo';
|
||||
|
||||
// assets
|
||||
|
||||
// ================================|| AUTH3 - LOGIN ||================================ //
|
||||
|
||||
const ForgetPassword = () => {
|
||||
const theme = useTheme();
|
||||
const matchDownSM = useMediaQuery(theme.breakpoints.down('md'));
|
||||
|
||||
return (
|
||||
<AuthWrapper>
|
||||
<Grid container direction="column" justifyContent="flex-end">
|
||||
<Grid item xs={12}>
|
||||
<Grid container justifyContent="center" alignItems="center" sx={{ minHeight: 'calc(100vh - 136px)' }}>
|
||||
<Grid item sx={{ m: { xs: 1, sm: 3 }, mb: 0 }}>
|
||||
<AuthCardWrapper>
|
||||
<Grid container spacing={2} alignItems="center" justifyContent="center">
|
||||
<Grid item sx={{ mb: 3 }}>
|
||||
<Link to="#">
|
||||
<Logo />
|
||||
</Link>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Grid container direction={matchDownSM ? 'column-reverse' : 'row'} alignItems="center" justifyContent="center">
|
||||
<Grid item>
|
||||
<Stack alignItems="center" justifyContent="center" spacing={1}>
|
||||
<Typography color={theme.palette.primary.main} gutterBottom variant={matchDownSM ? 'h3' : 'h2'}>
|
||||
密码重置
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<ForgetPasswordForm />
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Divider />
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Grid item container direction="column" alignItems="center" xs={12}>
|
||||
<Typography component={Link} to="/login" variant="subtitle1" sx={{ textDecoration: 'none' }}>
|
||||
登录
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</AuthCardWrapper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</AuthWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ForgetPassword;
|
||||
94
web/berry/src/views/Authentication/Auth/GitHubOAuth.js
Normal file
94
web/berry/src/views/Authentication/Auth/GitHubOAuth.js
Normal file
@@ -0,0 +1,94 @@
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { showError } from 'utils/common';
|
||||
import useLogin from 'hooks/useLogin';
|
||||
|
||||
// material-ui
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import { Grid, Stack, Typography, useMediaQuery, CircularProgress } from '@mui/material';
|
||||
|
||||
// project imports
|
||||
import AuthWrapper from '../AuthWrapper';
|
||||
import AuthCardWrapper from '../AuthCardWrapper';
|
||||
import Logo from 'ui-component/Logo';
|
||||
|
||||
// assets
|
||||
|
||||
// ================================|| AUTH3 - LOGIN ||================================ //
|
||||
|
||||
const GitHubOAuth = () => {
|
||||
const theme = useTheme();
|
||||
const matchDownSM = useMediaQuery(theme.breakpoints.down('md'));
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const [prompt, setPrompt] = useState('处理中...');
|
||||
const { githubLogin } = useLogin();
|
||||
|
||||
let navigate = useNavigate();
|
||||
|
||||
const sendCode = async (code, state, count) => {
|
||||
const { success, message } = await githubLogin(code, state);
|
||||
if (!success) {
|
||||
if (message) {
|
||||
showError(message);
|
||||
}
|
||||
if (count === 0) {
|
||||
setPrompt(`操作失败,重定向至登录界面中...`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
count++;
|
||||
setPrompt(`出现错误,第 ${count} 次重试中...`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
await sendCode(code, state, count);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let code = searchParams.get('code');
|
||||
let state = searchParams.get('state');
|
||||
sendCode(code, state, 0).then();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthWrapper>
|
||||
<Grid container direction="column" justifyContent="flex-end">
|
||||
<Grid item xs={12}>
|
||||
<Grid container justifyContent="center" alignItems="center" sx={{ minHeight: 'calc(100vh - 136px)' }}>
|
||||
<Grid item sx={{ m: { xs: 1, sm: 3 }, mb: 0 }}>
|
||||
<AuthCardWrapper>
|
||||
<Grid container spacing={2} alignItems="center" justifyContent="center">
|
||||
<Grid item sx={{ mb: 3 }}>
|
||||
<Link to="#">
|
||||
<Logo />
|
||||
</Link>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Grid container direction={matchDownSM ? 'column-reverse' : 'row'} alignItems="center" justifyContent="center">
|
||||
<Grid item>
|
||||
<Stack alignItems="center" justifyContent="center" spacing={1}>
|
||||
<Typography color={theme.palette.primary.main} gutterBottom variant={matchDownSM ? 'h3' : 'h2'}>
|
||||
GitHub 登录
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Grid item xs={12} container direction="column" justifyContent="center" alignItems="center" style={{ height: '200px' }}>
|
||||
<CircularProgress />
|
||||
<Typography variant="h3" paddingTop={'20px'}>
|
||||
{prompt}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</AuthCardWrapper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</AuthWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default GitHubOAuth;
|
||||
66
web/berry/src/views/Authentication/Auth/Login.js
Normal file
66
web/berry/src/views/Authentication/Auth/Login.js
Normal file
@@ -0,0 +1,66 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
// material-ui
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import { Divider, Grid, Stack, Typography, useMediaQuery } from '@mui/material';
|
||||
|
||||
// project imports
|
||||
import AuthWrapper from '../AuthWrapper';
|
||||
import AuthCardWrapper from '../AuthCardWrapper';
|
||||
import AuthLogin from '../AuthForms/AuthLogin';
|
||||
import Logo from 'ui-component/Logo';
|
||||
|
||||
// ================================|| AUTH3 - LOGIN ||================================ //
|
||||
|
||||
const Login = () => {
|
||||
const theme = useTheme();
|
||||
const matchDownSM = useMediaQuery(theme.breakpoints.down('md'));
|
||||
|
||||
return (
|
||||
<AuthWrapper>
|
||||
<Grid container direction="column" justifyContent="flex-end">
|
||||
<Grid item xs={12}>
|
||||
<Grid container justifyContent="center" alignItems="center" sx={{ minHeight: 'calc(100vh - 136px)' }}>
|
||||
<Grid item sx={{ m: { xs: 1, sm: 3 }, mb: 0 }}>
|
||||
<AuthCardWrapper>
|
||||
<Grid container spacing={2} alignItems="center" justifyContent="center">
|
||||
<Grid item sx={{ mb: 3 }}>
|
||||
<Link to="#">
|
||||
<Logo />
|
||||
</Link>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Grid container direction={matchDownSM ? 'column-reverse' : 'row'} alignItems="center" justifyContent="center">
|
||||
<Grid item>
|
||||
<Stack alignItems="center" justifyContent="center" spacing={1}>
|
||||
<Typography color={theme.palette.primary.main} gutterBottom variant={matchDownSM ? 'h3' : 'h2'}>
|
||||
登录
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<AuthLogin />
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Divider />
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Grid item container direction="column" alignItems="center" xs={12}>
|
||||
<Typography component={Link} to="/register" variant="subtitle1" sx={{ textDecoration: 'none' }}>
|
||||
注册
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</AuthCardWrapper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</AuthWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
71
web/berry/src/views/Authentication/Auth/Register.js
Normal file
71
web/berry/src/views/Authentication/Auth/Register.js
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
// material-ui
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import { Divider, Grid, Stack, Typography, useMediaQuery } from '@mui/material';
|
||||
|
||||
// project imports
|
||||
import AuthWrapper from '../AuthWrapper';
|
||||
import AuthCardWrapper from '../AuthCardWrapper';
|
||||
import Logo from 'ui-component/Logo';
|
||||
import AuthRegister from '../AuthForms/AuthRegister';
|
||||
|
||||
// assets
|
||||
|
||||
// ===============================|| AUTH3 - REGISTER ||=============================== //
|
||||
|
||||
const Register = () => {
|
||||
const theme = useTheme();
|
||||
const matchDownSM = useMediaQuery(theme.breakpoints.down('md'));
|
||||
|
||||
return (
|
||||
<AuthWrapper>
|
||||
<Grid container direction="column" justifyContent="flex-end">
|
||||
<Grid item xs={12}>
|
||||
<Grid container justifyContent="center" alignItems="center" sx={{ minHeight: 'calc(100vh - 136px)' }}>
|
||||
<Grid item sx={{ m: { xs: 1, sm: 3 }, mb: 0 }}>
|
||||
<AuthCardWrapper>
|
||||
<Grid container spacing={2} alignItems="center" justifyContent="center">
|
||||
<Grid item sx={{ mb: 3 }}>
|
||||
<Link to="#">
|
||||
<Logo />
|
||||
</Link>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Grid container direction={matchDownSM ? 'column-reverse' : 'row'} alignItems="center" justifyContent="center">
|
||||
<Grid item>
|
||||
<Stack alignItems="center" justifyContent="center" spacing={1}>
|
||||
<Typography color={theme.palette.primary.main} gutterBottom variant={matchDownSM ? 'h3' : 'h2'}>
|
||||
注册
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<AuthRegister />
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Divider />
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Grid item container direction="column" alignItems="center" xs={12}>
|
||||
<Typography component={Link} to="/login" variant="subtitle1" sx={{ textDecoration: 'none' }}>
|
||||
已经有帐号了?点击登录
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</AuthCardWrapper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
{/* <Grid item xs={12} sx={{ m: 3, mt: 1 }}>
|
||||
<AuthFooter />
|
||||
</Grid> */}
|
||||
</Grid>
|
||||
</AuthWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default Register;
|
||||
66
web/berry/src/views/Authentication/Auth/ResetPassword.js
Normal file
66
web/berry/src/views/Authentication/Auth/ResetPassword.js
Normal file
@@ -0,0 +1,66 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
// material-ui
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import { Divider, Grid, Stack, Typography, useMediaQuery } from '@mui/material';
|
||||
|
||||
// project imports
|
||||
import AuthWrapper from '../AuthWrapper';
|
||||
import AuthCardWrapper from '../AuthCardWrapper';
|
||||
import ResetPasswordForm from '../AuthForms/ResetPasswordForm';
|
||||
import Logo from 'ui-component/Logo';
|
||||
|
||||
// ================================|| AUTH3 - LOGIN ||================================ //
|
||||
|
||||
const ResetPassword = () => {
|
||||
const theme = useTheme();
|
||||
const matchDownSM = useMediaQuery(theme.breakpoints.down('md'));
|
||||
|
||||
return (
|
||||
<AuthWrapper>
|
||||
<Grid container direction="column" justifyContent="flex-end">
|
||||
<Grid item xs={12}>
|
||||
<Grid container justifyContent="center" alignItems="center" sx={{ minHeight: 'calc(100vh - 136px)' }}>
|
||||
<Grid item sx={{ m: { xs: 1, sm: 3 }, mb: 0 }}>
|
||||
<AuthCardWrapper>
|
||||
<Grid container spacing={2} alignItems="center" justifyContent="center">
|
||||
<Grid item sx={{ mb: 3 }}>
|
||||
<Link to="#">
|
||||
<Logo />
|
||||
</Link>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Grid container direction={matchDownSM ? 'column-reverse' : 'row'} alignItems="center" justifyContent="center">
|
||||
<Grid item>
|
||||
<Stack alignItems="center" justifyContent="center" spacing={1}>
|
||||
<Typography color={theme.palette.primary.main} gutterBottom variant={matchDownSM ? 'h3' : 'h2'}>
|
||||
密码重置确认
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<ResetPasswordForm />
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Divider />
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Grid item container direction="column" alignItems="center" xs={12}>
|
||||
<Typography component={Link} to="/login" variant="subtitle1" sx={{ textDecoration: 'none' }}>
|
||||
登录
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</AuthCardWrapper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</AuthWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResetPassword;
|
||||
32
web/berry/src/views/Authentication/AuthCardWrapper.js
Normal file
32
web/berry/src/views/Authentication/AuthCardWrapper.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
// material-ui
|
||||
import { Box } from '@mui/material';
|
||||
|
||||
// project import
|
||||
import MainCard from 'ui-component/cards/MainCard';
|
||||
|
||||
// ==============================|| AUTHENTICATION CARD WRAPPER ||============================== //
|
||||
|
||||
const AuthCardWrapper = ({ children, ...other }) => (
|
||||
<MainCard
|
||||
sx={{
|
||||
maxWidth: { xs: 400, lg: 475 },
|
||||
margin: { xs: 2.5, md: 3 },
|
||||
'& > *': {
|
||||
flexGrow: 1,
|
||||
flexBasis: '50%'
|
||||
}
|
||||
}}
|
||||
content={false}
|
||||
{...other}
|
||||
>
|
||||
<Box sx={{ p: { xs: 2, sm: 3, xl: 5 } }}>{children}</Box>
|
||||
</MainCard>
|
||||
);
|
||||
|
||||
AuthCardWrapper.propTypes = {
|
||||
children: PropTypes.node
|
||||
};
|
||||
|
||||
export default AuthCardWrapper;
|
||||
268
web/berry/src/views/Authentication/AuthForms/AuthLogin.js
Normal file
268
web/berry/src/views/Authentication/AuthForms/AuthLogin.js
Normal file
@@ -0,0 +1,268 @@
|
||||
import { useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
// material-ui
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Divider,
|
||||
FormControl,
|
||||
FormHelperText,
|
||||
Grid,
|
||||
IconButton,
|
||||
InputAdornment,
|
||||
InputLabel,
|
||||
OutlinedInput,
|
||||
Stack,
|
||||
Typography,
|
||||
useMediaQuery
|
||||
} from '@mui/material';
|
||||
|
||||
// third party
|
||||
import * as Yup from 'yup';
|
||||
import { Formik } from 'formik';
|
||||
|
||||
// project imports
|
||||
import useLogin from 'hooks/useLogin';
|
||||
import AnimateButton from 'ui-component/extended/AnimateButton';
|
||||
import WechatModal from 'views/Authentication/AuthForms/WechatModal';
|
||||
|
||||
// assets
|
||||
import Visibility from '@mui/icons-material/Visibility';
|
||||
import VisibilityOff from '@mui/icons-material/VisibilityOff';
|
||||
|
||||
import Github from 'assets/images/icons/github.svg';
|
||||
import Wechat from 'assets/images/icons/wechat.svg';
|
||||
import { onGitHubOAuthClicked } from 'utils/common';
|
||||
|
||||
// ============================|| FIREBASE - LOGIN ||============================ //
|
||||
|
||||
const LoginForm = ({ ...others }) => {
|
||||
const theme = useTheme();
|
||||
const { login, wechatLogin } = useLogin();
|
||||
const [openWechat, setOpenWechat] = useState(false);
|
||||
const matchDownSM = useMediaQuery(theme.breakpoints.down('md'));
|
||||
const customization = useSelector((state) => state.customization);
|
||||
const siteInfo = useSelector((state) => state.siteInfo);
|
||||
// const [checked, setChecked] = useState(true);
|
||||
|
||||
let tripartiteLogin = false;
|
||||
if (siteInfo.github_oauth || siteInfo.wechat_login) {
|
||||
tripartiteLogin = true;
|
||||
}
|
||||
|
||||
const handleWechatOpen = () => {
|
||||
setOpenWechat(true);
|
||||
};
|
||||
|
||||
const handleWechatClose = () => {
|
||||
setOpenWechat(false);
|
||||
};
|
||||
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const handleClickShowPassword = () => {
|
||||
setShowPassword(!showPassword);
|
||||
};
|
||||
|
||||
const handleMouseDownPassword = (event) => {
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{tripartiteLogin && (
|
||||
<Grid container direction="column" justifyContent="center" spacing={2}>
|
||||
{siteInfo.github_oauth && (
|
||||
<Grid item xs={12}>
|
||||
<AnimateButton>
|
||||
<Button
|
||||
disableElevation
|
||||
fullWidth
|
||||
onClick={() => onGitHubOAuthClicked(siteInfo.github_client_id)}
|
||||
size="large"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
color: 'grey.700',
|
||||
backgroundColor: theme.palette.grey[50],
|
||||
borderColor: theme.palette.grey[100]
|
||||
}}
|
||||
>
|
||||
<Box sx={{ mr: { xs: 1, sm: 2, width: 20 }, display: 'flex', alignItems: 'center' }}>
|
||||
<img src={Github} alt="github" width={25} height={25} style={{ marginRight: matchDownSM ? 8 : 16 }} />
|
||||
</Box>
|
||||
使用 Github 登录
|
||||
</Button>
|
||||
</AnimateButton>
|
||||
</Grid>
|
||||
)}
|
||||
{siteInfo.wechat_login && (
|
||||
<Grid item xs={12}>
|
||||
<AnimateButton>
|
||||
<Button
|
||||
disableElevation
|
||||
fullWidth
|
||||
onClick={handleWechatOpen}
|
||||
size="large"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
color: 'grey.700',
|
||||
backgroundColor: theme.palette.grey[50],
|
||||
borderColor: theme.palette.grey[100]
|
||||
}}
|
||||
>
|
||||
<Box sx={{ mr: { xs: 1, sm: 2, width: 20 }, display: 'flex', alignItems: 'center' }}>
|
||||
<img src={Wechat} alt="Wechat" width={25} height={25} style={{ marginRight: matchDownSM ? 8 : 16 }} />
|
||||
</Box>
|
||||
使用 Wechat 登录
|
||||
</Button>
|
||||
</AnimateButton>
|
||||
<WechatModal open={openWechat} handleClose={handleWechatClose} wechatLogin={wechatLogin} qrCode={siteInfo.wechat_qrcode} />
|
||||
</Grid>
|
||||
)}
|
||||
<Grid item xs={12}>
|
||||
<Box
|
||||
sx={{
|
||||
alignItems: 'center',
|
||||
display: 'flex'
|
||||
}}
|
||||
>
|
||||
<Divider sx={{ flexGrow: 1 }} orientation="horizontal" />
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
sx={{
|
||||
cursor: 'unset',
|
||||
m: 2,
|
||||
py: 0.5,
|
||||
px: 7,
|
||||
borderColor: `${theme.palette.grey[100]} !important`,
|
||||
color: `${theme.palette.grey[900]}!important`,
|
||||
fontWeight: 500,
|
||||
borderRadius: `${customization.borderRadius}px`
|
||||
}}
|
||||
disableRipple
|
||||
disabled
|
||||
>
|
||||
OR
|
||||
</Button>
|
||||
|
||||
<Divider sx={{ flexGrow: 1 }} orientation="horizontal" />
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
<Formik
|
||||
initialValues={{
|
||||
username: '',
|
||||
password: '',
|
||||
submit: null
|
||||
}}
|
||||
validationSchema={Yup.object().shape({
|
||||
username: Yup.string().max(255).required('Username is required'),
|
||||
password: Yup.string().max(255).required('Password is required')
|
||||
})}
|
||||
onSubmit={async (values, { setErrors, setStatus, setSubmitting }) => {
|
||||
const { success, message } = await login(values.username, values.password);
|
||||
if (success) {
|
||||
setStatus({ success: true });
|
||||
} else {
|
||||
setStatus({ success: false });
|
||||
if (message) {
|
||||
setErrors({ submit: message });
|
||||
}
|
||||
}
|
||||
setSubmitting(false);
|
||||
}}
|
||||
>
|
||||
{({ errors, handleBlur, handleChange, handleSubmit, isSubmitting, touched, values }) => (
|
||||
<form noValidate onSubmit={handleSubmit} {...others}>
|
||||
<FormControl fullWidth error={Boolean(touched.username && errors.username)} sx={{ ...theme.typography.customInput }}>
|
||||
<InputLabel htmlFor="outlined-adornment-username-login">用户名</InputLabel>
|
||||
<OutlinedInput
|
||||
id="outlined-adornment-username-login"
|
||||
type="text"
|
||||
value={values.username}
|
||||
name="username"
|
||||
onBlur={handleBlur}
|
||||
onChange={handleChange}
|
||||
label="用户名"
|
||||
inputProps={{ autoComplete: 'username' }}
|
||||
/>
|
||||
{touched.username && errors.username && (
|
||||
<FormHelperText error id="standard-weight-helper-text-username-login">
|
||||
{errors.username}
|
||||
</FormHelperText>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
<FormControl fullWidth error={Boolean(touched.password && errors.password)} sx={{ ...theme.typography.customInput }}>
|
||||
<InputLabel htmlFor="outlined-adornment-password-login">密码</InputLabel>
|
||||
<OutlinedInput
|
||||
id="outlined-adornment-password-login"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={values.password}
|
||||
name="password"
|
||||
onBlur={handleBlur}
|
||||
onChange={handleChange}
|
||||
endAdornment={
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
aria-label="toggle password visibility"
|
||||
onClick={handleClickShowPassword}
|
||||
onMouseDown={handleMouseDownPassword}
|
||||
edge="end"
|
||||
size="large"
|
||||
>
|
||||
{showPassword ? <Visibility /> : <VisibilityOff />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
}
|
||||
label="Password"
|
||||
/>
|
||||
{touched.password && errors.password && (
|
||||
<FormHelperText error id="standard-weight-helper-text-password-login">
|
||||
{errors.password}
|
||||
</FormHelperText>
|
||||
)}
|
||||
</FormControl>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" spacing={1}>
|
||||
{/* <FormControlLabel
|
||||
control={
|
||||
<Checkbox checked={checked} onChange={(event) => setChecked(event.target.checked)} name="checked" color="primary" />
|
||||
}
|
||||
label="记住我"
|
||||
/> */}
|
||||
<Typography
|
||||
component={Link}
|
||||
to="/reset"
|
||||
variant="subtitle1"
|
||||
color="primary"
|
||||
sx={{ textDecoration: 'none', cursor: 'pointer' }}
|
||||
>
|
||||
忘记密码?
|
||||
</Typography>
|
||||
</Stack>
|
||||
{errors.submit && (
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<FormHelperText error>{errors.submit}</FormHelperText>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<AnimateButton>
|
||||
<Button disableElevation disabled={isSubmitting} fullWidth size="large" type="submit" variant="contained" color="primary">
|
||||
登录
|
||||
</Button>
|
||||
</AnimateButton>
|
||||
</Box>
|
||||
</form>
|
||||
)}
|
||||
</Formik>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginForm;
|
||||
310
web/berry/src/views/Authentication/AuthForms/AuthRegister.js
Normal file
310
web/berry/src/views/Authentication/AuthForms/AuthRegister.js
Normal file
@@ -0,0 +1,310 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import useRegister from 'hooks/useRegister';
|
||||
import Turnstile from 'react-turnstile';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
// import { useSelector } from 'react-redux';
|
||||
|
||||
// material-ui
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
FormControl,
|
||||
FormHelperText,
|
||||
Grid,
|
||||
IconButton,
|
||||
InputAdornment,
|
||||
InputLabel,
|
||||
OutlinedInput,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
|
||||
// third party
|
||||
import * as Yup from 'yup';
|
||||
import { Formik } from 'formik';
|
||||
|
||||
// project imports
|
||||
import AnimateButton from 'ui-component/extended/AnimateButton';
|
||||
import { strengthColor, strengthIndicator } from 'utils/password-strength';
|
||||
|
||||
// assets
|
||||
import Visibility from '@mui/icons-material/Visibility';
|
||||
import VisibilityOff from '@mui/icons-material/VisibilityOff';
|
||||
import { showError, showInfo } from 'utils/common';
|
||||
|
||||
// ===========================|| FIREBASE - REGISTER ||=========================== //
|
||||
|
||||
const RegisterForm = ({ ...others }) => {
|
||||
const theme = useTheme();
|
||||
const { register, sendVerificationCode } = useRegister();
|
||||
const siteInfo = useSelector((state) => state.siteInfo);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const [showEmailVerification, setShowEmailVerification] = useState(false);
|
||||
const [turnstileEnabled, setTurnstileEnabled] = useState(false);
|
||||
const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
|
||||
const [turnstileToken, setTurnstileToken] = useState('');
|
||||
|
||||
const [strength, setStrength] = useState(0);
|
||||
const [level, setLevel] = useState();
|
||||
|
||||
const handleClickShowPassword = () => {
|
||||
setShowPassword(!showPassword);
|
||||
};
|
||||
|
||||
const handleMouseDownPassword = (event) => {
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
const changePassword = (value) => {
|
||||
const temp = strengthIndicator(value);
|
||||
setStrength(temp);
|
||||
setLevel(strengthColor(temp));
|
||||
};
|
||||
|
||||
const handleSendCode = async (email) => {
|
||||
if (email === '') {
|
||||
showError('请输入邮箱');
|
||||
return;
|
||||
}
|
||||
if (turnstileEnabled && turnstileToken === '') {
|
||||
showError('请稍后几秒重试,Turnstile 正在检查用户环境!');
|
||||
return;
|
||||
}
|
||||
|
||||
const { success, message } = await sendVerificationCode(email, turnstileToken);
|
||||
if (!success) {
|
||||
showError(message);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let affCode = searchParams.get('aff');
|
||||
if (affCode) {
|
||||
localStorage.setItem('aff', affCode);
|
||||
}
|
||||
|
||||
setShowEmailVerification(siteInfo.email_verification);
|
||||
if (siteInfo.turnstile_check) {
|
||||
setTurnstileEnabled(true);
|
||||
setTurnstileSiteKey(siteInfo.turnstile_site_key);
|
||||
}
|
||||
}, [siteInfo]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Formik
|
||||
initialValues={{
|
||||
username: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
email: showEmailVerification ? '' : undefined,
|
||||
verification_code: showEmailVerification ? '' : undefined,
|
||||
submit: null
|
||||
}}
|
||||
validationSchema={Yup.object().shape({
|
||||
username: Yup.string().max(255).required('用户名是必填项'),
|
||||
password: Yup.string().max(255).required('密码是必填项'),
|
||||
confirmPassword: Yup.string()
|
||||
.required('确认密码是必填项')
|
||||
.oneOf([Yup.ref('password'), null], '两次输入的密码不一致'),
|
||||
email: showEmailVerification ? Yup.string().email('必须是有效的Email地址').max(255).required('Email是必填项') : Yup.mixed(),
|
||||
verification_code: showEmailVerification ? Yup.string().max(255).required('验证码是必填项') : Yup.mixed()
|
||||
})}
|
||||
onSubmit={async (values, { setErrors, setStatus, setSubmitting }) => {
|
||||
if (turnstileEnabled && turnstileToken === '') {
|
||||
showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const { success, message } = await register(values, turnstileToken);
|
||||
if (success) {
|
||||
setStatus({ success: true });
|
||||
} else {
|
||||
setStatus({ success: false });
|
||||
if (message) {
|
||||
setErrors({ submit: message });
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ errors, handleBlur, handleChange, handleSubmit, isSubmitting, touched, values }) => (
|
||||
<form noValidate onSubmit={handleSubmit} {...others}>
|
||||
<FormControl fullWidth error={Boolean(touched.username && errors.username)} sx={{ ...theme.typography.customInput }}>
|
||||
<InputLabel htmlFor="outlined-adornment-username-register">用户名</InputLabel>
|
||||
<OutlinedInput
|
||||
id="outlined-adornment-username-register"
|
||||
type="text"
|
||||
value={values.username}
|
||||
name="username"
|
||||
onBlur={handleBlur}
|
||||
onChange={handleChange}
|
||||
inputProps={{ autoComplete: 'username' }}
|
||||
/>
|
||||
{touched.username && errors.username && (
|
||||
<FormHelperText error id="standard-weight-helper-text--register">
|
||||
{errors.username}
|
||||
</FormHelperText>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
<FormControl fullWidth error={Boolean(touched.password && errors.password)} sx={{ ...theme.typography.customInput }}>
|
||||
<InputLabel htmlFor="outlined-adornment-password-register">密码</InputLabel>
|
||||
<OutlinedInput
|
||||
id="outlined-adornment-password-register"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={values.password}
|
||||
name="password"
|
||||
label="Password"
|
||||
onBlur={handleBlur}
|
||||
onChange={(e) => {
|
||||
handleChange(e);
|
||||
changePassword(e.target.value);
|
||||
}}
|
||||
endAdornment={
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
aria-label="toggle password visibility"
|
||||
onClick={handleClickShowPassword}
|
||||
onMouseDown={handleMouseDownPassword}
|
||||
edge="end"
|
||||
size="large"
|
||||
color={'primary'}
|
||||
>
|
||||
{showPassword ? <Visibility /> : <VisibilityOff />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
}
|
||||
inputProps={{}}
|
||||
/>
|
||||
{touched.password && errors.password && (
|
||||
<FormHelperText error id="standard-weight-helper-text-password-register">
|
||||
{errors.password}
|
||||
</FormHelperText>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl
|
||||
fullWidth
|
||||
error={Boolean(touched.confirmPassword && errors.confirmPassword)}
|
||||
sx={{ ...theme.typography.customInput }}
|
||||
>
|
||||
<InputLabel htmlFor="outlined-adornment-confirm-password-register">确认密码</InputLabel>
|
||||
<OutlinedInput
|
||||
id="outlined-adornment-confirm-password-register"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={values.confirmPassword}
|
||||
name="confirmPassword"
|
||||
label="Confirm Password"
|
||||
onBlur={handleBlur}
|
||||
onChange={handleChange}
|
||||
inputProps={{}}
|
||||
/>
|
||||
{touched.confirmPassword && errors.confirmPassword && (
|
||||
<FormHelperText error id="standard-weight-helper-text-confirm-password-register">
|
||||
{errors.confirmPassword}
|
||||
</FormHelperText>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
{strength !== 0 && (
|
||||
<FormControl fullWidth>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Grid container spacing={2} alignItems="center">
|
||||
<Grid item>
|
||||
<Box style={{ backgroundColor: level?.color }} sx={{ width: 85, height: 8, borderRadius: '7px' }} />
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<Typography variant="subtitle1" fontSize="0.75rem">
|
||||
{level?.label}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</FormControl>
|
||||
)}
|
||||
|
||||
{showEmailVerification && (
|
||||
<>
|
||||
<FormControl fullWidth error={Boolean(touched.email && errors.email)} sx={{ ...theme.typography.customInput }}>
|
||||
<InputLabel htmlFor="outlined-adornment-email-register">Email</InputLabel>
|
||||
<OutlinedInput
|
||||
id="outlined-adornment-email-register"
|
||||
type="text"
|
||||
value={values.email}
|
||||
name="email"
|
||||
onBlur={handleBlur}
|
||||
onChange={handleChange}
|
||||
endAdornment={
|
||||
<InputAdornment position="end">
|
||||
<Button variant="contained" color="primary" onClick={() => handleSendCode(values.email)}>
|
||||
发送验证码
|
||||
</Button>
|
||||
</InputAdornment>
|
||||
}
|
||||
inputProps={{}}
|
||||
/>
|
||||
{touched.email && errors.email && (
|
||||
<FormHelperText error id="standard-weight-helper-text--register">
|
||||
{errors.email}
|
||||
</FormHelperText>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl
|
||||
fullWidth
|
||||
error={Boolean(touched.verification_code && errors.verification_code)}
|
||||
sx={{ ...theme.typography.customInput }}
|
||||
>
|
||||
<InputLabel htmlFor="outlined-adornment-verification_code-register">验证码</InputLabel>
|
||||
<OutlinedInput
|
||||
id="outlined-adornment-verification_code-register"
|
||||
type="text"
|
||||
value={values.verification_code}
|
||||
name="verification_code"
|
||||
onBlur={handleBlur}
|
||||
onChange={handleChange}
|
||||
inputProps={{}}
|
||||
/>
|
||||
{touched.verification_code && errors.verification_code && (
|
||||
<FormHelperText error id="standard-weight-helper-text--register">
|
||||
{errors.verification_code}
|
||||
</FormHelperText>
|
||||
)}
|
||||
</FormControl>
|
||||
</>
|
||||
)}
|
||||
|
||||
{errors.submit && (
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<FormHelperText error>{errors.submit}</FormHelperText>
|
||||
</Box>
|
||||
)}
|
||||
{turnstileEnabled ? (
|
||||
<Turnstile
|
||||
sitekey={turnstileSiteKey}
|
||||
onVerify={(token) => {
|
||||
setTurnstileToken(token);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<AnimateButton>
|
||||
<Button disableElevation disabled={isSubmitting} fullWidth size="large" type="submit" variant="contained" color="primary">
|
||||
Sign up
|
||||
</Button>
|
||||
</AnimateButton>
|
||||
</Box>
|
||||
</form>
|
||||
)}
|
||||
</Formik>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default RegisterForm;
|
||||
@@ -0,0 +1,174 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import Turnstile from "react-turnstile";
|
||||
import { API } from "utils/api";
|
||||
|
||||
// material-ui
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
FormControl,
|
||||
FormHelperText,
|
||||
InputLabel,
|
||||
OutlinedInput,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
|
||||
// third party
|
||||
import * as Yup from "yup";
|
||||
import { Formik } from "formik";
|
||||
|
||||
// project imports
|
||||
import AnimateButton from "ui-component/extended/AnimateButton";
|
||||
|
||||
// assets
|
||||
import { showError, showInfo, showSuccess } from "utils/common";
|
||||
|
||||
// ===========================|| FIREBASE - REGISTER ||=========================== //
|
||||
|
||||
const ForgetPasswordForm = ({ ...others }) => {
|
||||
const theme = useTheme();
|
||||
const siteInfo = useSelector((state) => state.siteInfo);
|
||||
|
||||
const [sendEmail, setSendEmail] = useState(false);
|
||||
const [turnstileEnabled, setTurnstileEnabled] = useState(false);
|
||||
const [turnstileSiteKey, setTurnstileSiteKey] = useState("");
|
||||
const [turnstileToken, setTurnstileToken] = useState("");
|
||||
const [disableButton, setDisableButton] = useState(false);
|
||||
const [countdown, setCountdown] = useState(30);
|
||||
|
||||
const submit = async (values, { setSubmitting }) => {
|
||||
setDisableButton(true);
|
||||
setSubmitting(true);
|
||||
if (turnstileEnabled && turnstileToken === "") {
|
||||
showInfo("请稍后几秒重试,Turnstile 正在检查用户环境!");
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
const res = await API.get(
|
||||
`/api/reset_password?email=${values.email}&turnstile=${turnstileToken}`
|
||||
);
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess("重置邮件发送成功,请检查邮箱!");
|
||||
setSendEmail(true);
|
||||
} else {
|
||||
showError(message);
|
||||
setDisableButton(false);
|
||||
setCountdown(30);
|
||||
}
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
if (siteInfo.turnstile_check) {
|
||||
setTurnstileEnabled(true);
|
||||
setTurnstileSiteKey(siteInfo.turnstile_site_key);
|
||||
}
|
||||
}, [siteInfo]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{sendEmail ? (
|
||||
<Typography variant="h3" padding={"20px"}>
|
||||
重置邮件发送成功,请检查邮箱!
|
||||
</Typography>
|
||||
) : (
|
||||
<Formik
|
||||
initialValues={{
|
||||
email: "",
|
||||
}}
|
||||
validationSchema={Yup.object().shape({
|
||||
email: Yup.string()
|
||||
.email("必须是有效的Email地址")
|
||||
.max(255)
|
||||
.required("Email是必填项"),
|
||||
})}
|
||||
onSubmit={submit}
|
||||
>
|
||||
{({
|
||||
errors,
|
||||
handleBlur,
|
||||
handleChange,
|
||||
handleSubmit,
|
||||
isSubmitting,
|
||||
touched,
|
||||
values,
|
||||
}) => (
|
||||
<form noValidate onSubmit={handleSubmit} {...others}>
|
||||
<FormControl
|
||||
fullWidth
|
||||
error={Boolean(touched.email && errors.email)}
|
||||
sx={{ ...theme.typography.customInput }}
|
||||
>
|
||||
<InputLabel htmlFor="outlined-adornment-email-register">
|
||||
Email
|
||||
</InputLabel>
|
||||
<OutlinedInput
|
||||
id="outlined-adornment-email-register"
|
||||
type="text"
|
||||
value={values.email}
|
||||
name="email"
|
||||
onBlur={handleBlur}
|
||||
onChange={handleChange}
|
||||
inputProps={{}}
|
||||
/>
|
||||
{touched.email && errors.email && (
|
||||
<FormHelperText
|
||||
error
|
||||
id="standard-weight-helper-text--register"
|
||||
>
|
||||
{errors.email}
|
||||
</FormHelperText>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
{turnstileEnabled ? (
|
||||
<Turnstile
|
||||
sitekey={turnstileSiteKey}
|
||||
onVerify={(token) => {
|
||||
setTurnstileToken(token);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<AnimateButton>
|
||||
<Button
|
||||
disableElevation
|
||||
disabled={isSubmitting || disableButton}
|
||||
fullWidth
|
||||
size="large"
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
>
|
||||
{disableButton ? `重试 (${countdown})` : "提交"}
|
||||
</Button>
|
||||
</AnimateButton>
|
||||
</Box>
|
||||
</form>
|
||||
)}
|
||||
</Formik>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ForgetPasswordForm;
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
|
||||
// material-ui
|
||||
import { Button, Stack, Typography, Alert } from "@mui/material";
|
||||
|
||||
// assets
|
||||
import { showError, showInfo } from "utils/common";
|
||||
import { API } from "utils/api";
|
||||
|
||||
// ===========================|| FIREBASE - REGISTER ||=========================== //
|
||||
|
||||
const ResetPasswordForm = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const [inputs, setInputs] = useState({
|
||||
email: "",
|
||||
token: "",
|
||||
});
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
|
||||
const submit = async () => {
|
||||
const res = await API.post(`/api/user/reset`, inputs);
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
let password = res.data.data;
|
||||
setNewPassword(password);
|
||||
navigator.clipboard.writeText(password);
|
||||
showInfo(`新密码已复制到剪贴板:${password}`);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let email = searchParams.get("email");
|
||||
let token = searchParams.get("token");
|
||||
setInputs({
|
||||
token,
|
||||
email,
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Stack
|
||||
spacing={3}
|
||||
padding={"24px"}
|
||||
justifyContent={"center"}
|
||||
alignItems={"center"}
|
||||
>
|
||||
{!inputs.email || !inputs.token ? (
|
||||
<Typography variant="h3" sx={{ textDecoration: "none" }}>
|
||||
无效的链接
|
||||
</Typography>
|
||||
) : newPassword ? (
|
||||
<Alert severity="error">
|
||||
你的新密码是: <b>{newPassword}</b> <br />
|
||||
请登录后及时修改密码
|
||||
</Alert>
|
||||
) : (
|
||||
<Button
|
||||
fullWidth
|
||||
onClick={submit}
|
||||
size="large"
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
>
|
||||
点击重置密码
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResetPasswordForm;
|
||||
70
web/berry/src/views/Authentication/AuthForms/WechatModal.js
Normal file
70
web/berry/src/views/Authentication/AuthForms/WechatModal.js
Normal file
@@ -0,0 +1,70 @@
|
||||
// WechatModal.js
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { Dialog, DialogTitle, DialogContent, TextField, Button, Typography, Grid } from '@mui/material';
|
||||
import { Formik, Form, Field } from 'formik';
|
||||
import { showError } from 'utils/common';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
const validationSchema = Yup.object().shape({
|
||||
code: Yup.string().required('验证码不能为空')
|
||||
});
|
||||
|
||||
const WechatModal = ({ open, handleClose, wechatLogin, qrCode }) => {
|
||||
const handleSubmit = (values) => {
|
||||
const { success, message } = wechatLogin(values.code);
|
||||
if (success) {
|
||||
handleClose();
|
||||
} else {
|
||||
showError(message || '未知错误');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose}>
|
||||
<DialogTitle>微信验证码登录</DialogTitle>
|
||||
<DialogContent>
|
||||
<Grid container direction="column" alignItems="center">
|
||||
<img src={qrCode} alt="二维码" style={{ maxWidth: '300px', maxHeight: '300px', width: 'auto', height: 'auto' }} />
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
style={{ marginTop: '10px', textAlign: 'center', wordWrap: 'break-word', maxWidth: '300px' }}
|
||||
>
|
||||
请使用微信扫描二维码关注公众号,输入「验证码」获取验证码(三分钟内有效)
|
||||
</Typography>
|
||||
<Formik initialValues={{ code: '' }} validationSchema={validationSchema} onSubmit={handleSubmit}>
|
||||
{({ errors, touched }) => (
|
||||
<Form style={{ width: '100%' }}>
|
||||
<Grid item xs={12}>
|
||||
<Field
|
||||
as={TextField}
|
||||
name="code"
|
||||
label="验证码"
|
||||
error={touched.code && Boolean(errors.code)}
|
||||
helperText={touched.code && errors.code}
|
||||
fullWidth
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Button type="submit" fullWidth>
|
||||
提交
|
||||
</Button>
|
||||
</Grid>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</Grid>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default WechatModal;
|
||||
|
||||
WechatModal.propTypes = {
|
||||
open: PropTypes.bool,
|
||||
handleClose: PropTypes.func,
|
||||
wechatLogin: PropTypes.func,
|
||||
qrCode: PropTypes.string
|
||||
};
|
||||
28
web/berry/src/views/Authentication/AuthWrapper.js
Normal file
28
web/berry/src/views/Authentication/AuthWrapper.js
Normal file
@@ -0,0 +1,28 @@
|
||||
// material-ui
|
||||
import { styled } from '@mui/material/styles';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useEffect, useContext } from 'react';
|
||||
import { UserContext } from 'contexts/UserContext';
|
||||
|
||||
// ==============================|| AUTHENTICATION 1 WRAPPER ||============================== //
|
||||
|
||||
const AuthStyle = styled('div')(({ theme }) => ({
|
||||
backgroundColor: theme.palette.primary.light
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line
|
||||
const AuthWrapper = ({ children }) => {
|
||||
const account = useSelector((state) => state.account);
|
||||
const { isUserLoaded } = useContext(UserContext);
|
||||
const navigate = useNavigate();
|
||||
useEffect(() => {
|
||||
if (isUserLoaded && account.user) {
|
||||
navigate('/panel');
|
||||
}
|
||||
}, [account, navigate, isUserLoaded]);
|
||||
|
||||
return <AuthStyle> {children} </AuthStyle>;
|
||||
};
|
||||
|
||||
export default AuthWrapper;
|
||||
718
web/berry/src/views/Channel/component/EditModal.js
Normal file
718
web/berry/src/views/Channel/component/EditModal.js
Normal file
@@ -0,0 +1,718 @@
|
||||
import PropTypes from "prop-types";
|
||||
import { useState, useEffect } from "react";
|
||||
import { CHANNEL_OPTIONS } from "constants/ChannelConstants";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { API } from "utils/api";
|
||||
import { showError, showSuccess } from "utils/common";
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
Button,
|
||||
Divider,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
OutlinedInput,
|
||||
ButtonGroup,
|
||||
Container,
|
||||
Autocomplete,
|
||||
FormHelperText,
|
||||
} from "@mui/material";
|
||||
|
||||
import { Formik } from "formik";
|
||||
import * as Yup from "yup";
|
||||
import { defaultConfig, typeConfig } from "../type/Config"; //typeConfig
|
||||
import { createFilterOptions } from "@mui/material/Autocomplete";
|
||||
|
||||
const filter = createFilterOptions();
|
||||
const validationSchema = Yup.object().shape({
|
||||
is_edit: Yup.boolean(),
|
||||
name: Yup.string().required("名称 不能为空"),
|
||||
type: Yup.number().required("渠道 不能为空"),
|
||||
key: Yup.string().when("is_edit", {
|
||||
is: false,
|
||||
then: Yup.string().required("密钥 不能为空"),
|
||||
}),
|
||||
other: Yup.string(),
|
||||
proxy: Yup.string(),
|
||||
test_model: Yup.string(),
|
||||
models: Yup.array().min(1, "模型 不能为空"),
|
||||
groups: Yup.array().min(1, "用户组 不能为空"),
|
||||
base_url: Yup.string().when("type", {
|
||||
is: (value) => [3, 24, 8].includes(value),
|
||||
then: Yup.string().required("渠道API地址 不能为空"), // base_url 是必需的
|
||||
otherwise: Yup.string(), // 在其他情况下,base_url 可以是任意字符串
|
||||
}),
|
||||
model_mapping: Yup.string().test(
|
||||
"is-json",
|
||||
"必须是有效的JSON字符串",
|
||||
function (value) {
|
||||
try {
|
||||
if (value === "" || value === null || value === undefined) {
|
||||
return true;
|
||||
}
|
||||
const parsedValue = JSON.parse(value);
|
||||
if (typeof parsedValue === "object") {
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
),
|
||||
});
|
||||
|
||||
const EditModal = ({ open, channelId, onCancel, onOk }) => {
|
||||
const theme = useTheme();
|
||||
// const [loading, setLoading] = useState(false);
|
||||
const [initialInput, setInitialInput] = useState(defaultConfig.input);
|
||||
const [inputLabel, setInputLabel] = useState(defaultConfig.inputLabel); //
|
||||
const [inputPrompt, setInputPrompt] = useState(defaultConfig.prompt);
|
||||
const [groupOptions, setGroupOptions] = useState([]);
|
||||
const [modelOptions, setModelOptions] = useState([]);
|
||||
|
||||
const initChannel = (typeValue) => {
|
||||
if (typeConfig[typeValue]?.inputLabel) {
|
||||
setInputLabel({
|
||||
...defaultConfig.inputLabel,
|
||||
...typeConfig[typeValue].inputLabel,
|
||||
});
|
||||
} else {
|
||||
setInputLabel(defaultConfig.inputLabel);
|
||||
}
|
||||
|
||||
if (typeConfig[typeValue]?.prompt) {
|
||||
setInputPrompt({
|
||||
...defaultConfig.prompt,
|
||||
...typeConfig[typeValue].prompt,
|
||||
});
|
||||
} else {
|
||||
setInputPrompt(defaultConfig.prompt);
|
||||
}
|
||||
|
||||
return typeConfig[typeValue]?.input;
|
||||
};
|
||||
const handleTypeChange = (setFieldValue, typeValue, values) => {
|
||||
const newInput = initChannel(typeValue);
|
||||
|
||||
if (newInput) {
|
||||
Object.keys(newInput).forEach((key) => {
|
||||
if (
|
||||
(!Array.isArray(values[key]) &&
|
||||
values[key] !== null &&
|
||||
values[key] !== undefined &&
|
||||
values[key] !== "") ||
|
||||
(Array.isArray(values[key]) && values[key].length > 0)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "models") {
|
||||
setFieldValue(key, initialModel(newInput[key]));
|
||||
return;
|
||||
}
|
||||
setFieldValue(key, newInput[key]);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const basicModels = (channelType) => {
|
||||
let modelGroup =
|
||||
typeConfig[channelType]?.modelGroup || defaultConfig.modelGroup;
|
||||
// 循环 modelOptions,找到 modelGroup 对应的模型
|
||||
let modelList = [];
|
||||
modelOptions.forEach((model) => {
|
||||
if (model.group === modelGroup) {
|
||||
modelList.push(model);
|
||||
}
|
||||
});
|
||||
return modelList;
|
||||
};
|
||||
|
||||
const fetchGroups = async () => {
|
||||
try {
|
||||
let res = await API.get(`/api/group/`);
|
||||
setGroupOptions(res.data.data);
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchModels = async () => {
|
||||
try {
|
||||
let res = await API.get(`/api/channel/models`);
|
||||
setModelOptions(
|
||||
res.data.data.map((model) => {
|
||||
return {
|
||||
id: model.id,
|
||||
group: model.owned_by,
|
||||
};
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async (values, { setErrors, setStatus, setSubmitting }) => {
|
||||
setSubmitting(true);
|
||||
if (values.base_url && values.base_url.endsWith("/")) {
|
||||
values.base_url = values.base_url.slice(0, values.base_url.length - 1);
|
||||
}
|
||||
if (values.type === 3 && values.other === "") {
|
||||
values.other = "2023-09-01-preview";
|
||||
}
|
||||
if (values.type === 18 && values.other === "") {
|
||||
values.other = "v2.1";
|
||||
}
|
||||
let res;
|
||||
const modelsStr = values.models.map((model) => model.id).join(",");
|
||||
values.group = values.groups.join(",");
|
||||
if (channelId) {
|
||||
res = await API.put(`/api/channel/`, {
|
||||
...values,
|
||||
id: parseInt(channelId),
|
||||
models: modelsStr,
|
||||
});
|
||||
} else {
|
||||
res = await API.post(`/api/channel/`, { ...values, models: modelsStr });
|
||||
}
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
if (channelId) {
|
||||
showSuccess("渠道更新成功!");
|
||||
} else {
|
||||
showSuccess("渠道创建成功!");
|
||||
}
|
||||
setSubmitting(false);
|
||||
setStatus({ success: true });
|
||||
onOk(true);
|
||||
} else {
|
||||
setStatus({ success: false });
|
||||
showError(message);
|
||||
setErrors({ submit: message });
|
||||
}
|
||||
};
|
||||
|
||||
function initialModel(channelModel) {
|
||||
if (!channelModel) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 如果 channelModel 是一个字符串
|
||||
if (typeof channelModel === "string") {
|
||||
channelModel = channelModel.split(",");
|
||||
}
|
||||
let modelList = channelModel.map((model) => {
|
||||
const modelOption = modelOptions.find((option) => option.id === model);
|
||||
if (modelOption) {
|
||||
return modelOption;
|
||||
}
|
||||
return { id: model, group: "自定义:点击或回车输入" };
|
||||
});
|
||||
return modelList;
|
||||
}
|
||||
|
||||
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 = initialModel(data.models);
|
||||
}
|
||||
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
|
||||
);
|
||||
}
|
||||
data.is_edit = true;
|
||||
initChannel(data.type);
|
||||
setInitialInput(data);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchGroups().then();
|
||||
fetchModels().then();
|
||||
if (channelId) {
|
||||
loadChannel().then();
|
||||
} else {
|
||||
initChannel(1);
|
||||
setInitialInput({ ...defaultConfig.input, is_edit: false });
|
||||
}
|
||||
}, [channelId]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onCancel} fullWidth maxWidth={"md"}>
|
||||
<DialogTitle
|
||||
sx={{
|
||||
margin: "0px",
|
||||
fontWeight: 700,
|
||||
lineHeight: "1.55556",
|
||||
padding: "24px",
|
||||
fontSize: "1.125rem",
|
||||
}}
|
||||
>
|
||||
{channelId ? "编辑渠道" : "新建渠道"}
|
||||
</DialogTitle>
|
||||
<Divider />
|
||||
<DialogContent>
|
||||
<Formik
|
||||
initialValues={initialInput}
|
||||
enableReinitialize
|
||||
validationSchema={validationSchema}
|
||||
onSubmit={submit}
|
||||
>
|
||||
{({
|
||||
errors,
|
||||
handleBlur,
|
||||
handleChange,
|
||||
handleSubmit,
|
||||
isSubmitting,
|
||||
touched,
|
||||
values,
|
||||
setFieldValue,
|
||||
}) => (
|
||||
<form noValidate onSubmit={handleSubmit}>
|
||||
<FormControl
|
||||
fullWidth
|
||||
error={Boolean(touched.type && errors.type)}
|
||||
sx={{ ...theme.typography.otherInput }}
|
||||
>
|
||||
<InputLabel htmlFor="channel-type-label">
|
||||
{inputLabel.type}
|
||||
</InputLabel>
|
||||
<Select
|
||||
id="channel-type-label"
|
||||
label={inputLabel.type}
|
||||
value={values.type}
|
||||
name="type"
|
||||
onBlur={handleBlur}
|
||||
onChange={(e) => {
|
||||
handleChange(e);
|
||||
handleTypeChange(setFieldValue, e.target.value, values);
|
||||
}}
|
||||
MenuProps={{
|
||||
PaperProps: {
|
||||
style: {
|
||||
maxHeight: 200,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{Object.values(CHANNEL_OPTIONS).map((option) => {
|
||||
return (
|
||||
<MenuItem key={option.value} value={option.value}>
|
||||
{option.text}
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
{touched.type && errors.type ? (
|
||||
<FormHelperText error id="helper-tex-channel-type-label">
|
||||
{errors.type}
|
||||
</FormHelperText>
|
||||
) : (
|
||||
<FormHelperText id="helper-tex-channel-type-label">
|
||||
{" "}
|
||||
{inputPrompt.type}{" "}
|
||||
</FormHelperText>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
<FormControl
|
||||
fullWidth
|
||||
error={Boolean(touched.name && errors.name)}
|
||||
sx={{ ...theme.typography.otherInput }}
|
||||
>
|
||||
<InputLabel htmlFor="channel-name-label">
|
||||
{inputLabel.name}
|
||||
</InputLabel>
|
||||
<OutlinedInput
|
||||
id="channel-name-label"
|
||||
label={inputLabel.name}
|
||||
type="text"
|
||||
value={values.name}
|
||||
name="name"
|
||||
onBlur={handleBlur}
|
||||
onChange={handleChange}
|
||||
inputProps={{ autoComplete: "name" }}
|
||||
aria-describedby="helper-text-channel-name-label"
|
||||
/>
|
||||
{touched.name && errors.name ? (
|
||||
<FormHelperText error id="helper-tex-channel-name-label">
|
||||
{errors.name}
|
||||
</FormHelperText>
|
||||
) : (
|
||||
<FormHelperText id="helper-tex-channel-name-label">
|
||||
{" "}
|
||||
{inputPrompt.name}{" "}
|
||||
</FormHelperText>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
<FormControl
|
||||
fullWidth
|
||||
error={Boolean(touched.base_url && errors.base_url)}
|
||||
sx={{ ...theme.typography.otherInput }}
|
||||
>
|
||||
<InputLabel htmlFor="channel-base_url-label">
|
||||
{inputLabel.base_url}
|
||||
</InputLabel>
|
||||
<OutlinedInput
|
||||
id="channel-base_url-label"
|
||||
label={inputLabel.base_url}
|
||||
type="text"
|
||||
value={values.base_url}
|
||||
name="base_url"
|
||||
onBlur={handleBlur}
|
||||
onChange={handleChange}
|
||||
inputProps={{}}
|
||||
aria-describedby="helper-text-channel-base_url-label"
|
||||
/>
|
||||
{touched.base_url && errors.base_url ? (
|
||||
<FormHelperText error id="helper-tex-channel-base_url-label">
|
||||
{errors.base_url}
|
||||
</FormHelperText>
|
||||
) : (
|
||||
<FormHelperText id="helper-tex-channel-base_url-label">
|
||||
{" "}
|
||||
{inputPrompt.base_url}{" "}
|
||||
</FormHelperText>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
{inputPrompt.other && (
|
||||
<FormControl
|
||||
fullWidth
|
||||
error={Boolean(touched.other && errors.other)}
|
||||
sx={{ ...theme.typography.otherInput }}
|
||||
>
|
||||
<InputLabel htmlFor="channel-other-label">
|
||||
{inputLabel.other}
|
||||
</InputLabel>
|
||||
<OutlinedInput
|
||||
id="channel-other-label"
|
||||
label={inputLabel.other}
|
||||
type="text"
|
||||
value={values.other}
|
||||
name="other"
|
||||
onBlur={handleBlur}
|
||||
onChange={handleChange}
|
||||
inputProps={{}}
|
||||
aria-describedby="helper-text-channel-other-label"
|
||||
/>
|
||||
{touched.other && errors.other ? (
|
||||
<FormHelperText error id="helper-tex-channel-other-label">
|
||||
{errors.other}
|
||||
</FormHelperText>
|
||||
) : (
|
||||
<FormHelperText id="helper-tex-channel-other-label">
|
||||
{" "}
|
||||
{inputPrompt.other}{" "}
|
||||
</FormHelperText>
|
||||
)}
|
||||
</FormControl>
|
||||
)}
|
||||
|
||||
<FormControl fullWidth sx={{ ...theme.typography.otherInput }}>
|
||||
<Autocomplete
|
||||
multiple
|
||||
id="channel-groups-label"
|
||||
options={groupOptions}
|
||||
value={values.groups}
|
||||
onChange={(e, value) => {
|
||||
const event = {
|
||||
target: {
|
||||
name: "groups",
|
||||
value: value,
|
||||
},
|
||||
};
|
||||
handleChange(event);
|
||||
}}
|
||||
onBlur={handleBlur}
|
||||
filterSelectedOptions
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
name="groups"
|
||||
error={Boolean(errors.groups)}
|
||||
label={inputLabel.groups}
|
||||
/>
|
||||
)}
|
||||
aria-describedby="helper-text-channel-groups-label"
|
||||
/>
|
||||
{errors.groups ? (
|
||||
<FormHelperText error id="helper-tex-channel-groups-label">
|
||||
{errors.groups}
|
||||
</FormHelperText>
|
||||
) : (
|
||||
<FormHelperText id="helper-tex-channel-groups-label">
|
||||
{" "}
|
||||
{inputPrompt.groups}{" "}
|
||||
</FormHelperText>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
<FormControl fullWidth sx={{ ...theme.typography.otherInput }}>
|
||||
<Autocomplete
|
||||
multiple
|
||||
freeSolo
|
||||
id="channel-models-label"
|
||||
options={modelOptions}
|
||||
value={values.models}
|
||||
onChange={(e, value) => {
|
||||
const event = {
|
||||
target: {
|
||||
name: "models",
|
||||
value: value.map((item) =>
|
||||
typeof item === "string"
|
||||
? { id: item, group: "自定义:点击或回车输入" }
|
||||
: item
|
||||
),
|
||||
},
|
||||
};
|
||||
handleChange(event);
|
||||
}}
|
||||
onBlur={handleBlur}
|
||||
filterSelectedOptions
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
name="models"
|
||||
error={Boolean(errors.models)}
|
||||
label={inputLabel.models}
|
||||
/>
|
||||
)}
|
||||
groupBy={(option) => option.group}
|
||||
getOptionLabel={(option) => {
|
||||
if (typeof option === "string") {
|
||||
return option;
|
||||
}
|
||||
if (option.inputValue) {
|
||||
return option.inputValue;
|
||||
}
|
||||
return option.id;
|
||||
}}
|
||||
filterOptions={(options, params) => {
|
||||
const filtered = filter(options, params);
|
||||
const { inputValue } = params;
|
||||
const isExisting = options.some(
|
||||
(option) => inputValue === option.id
|
||||
);
|
||||
if (inputValue !== "" && !isExisting) {
|
||||
filtered.push({
|
||||
id: inputValue,
|
||||
group: "自定义:点击或回车输入",
|
||||
});
|
||||
}
|
||||
return filtered;
|
||||
}}
|
||||
/>
|
||||
{errors.models ? (
|
||||
<FormHelperText error id="helper-tex-channel-models-label">
|
||||
{errors.models}
|
||||
</FormHelperText>
|
||||
) : (
|
||||
<FormHelperText id="helper-tex-channel-models-label">
|
||||
{" "}
|
||||
{inputPrompt.models}{" "}
|
||||
</FormHelperText>
|
||||
)}
|
||||
</FormControl>
|
||||
<Container
|
||||
sx={{
|
||||
textAlign: "right",
|
||||
}}
|
||||
>
|
||||
<ButtonGroup
|
||||
variant="outlined"
|
||||
aria-label="small outlined primary button group"
|
||||
>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setFieldValue("models", basicModels(values.type));
|
||||
}}
|
||||
>
|
||||
填入渠道支持模型
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setFieldValue("models", modelOptions);
|
||||
}}
|
||||
>
|
||||
填入所有模型
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</Container>
|
||||
<FormControl
|
||||
fullWidth
|
||||
error={Boolean(touched.key && errors.key)}
|
||||
sx={{ ...theme.typography.otherInput }}
|
||||
>
|
||||
<InputLabel htmlFor="channel-key-label">
|
||||
{inputLabel.key}
|
||||
</InputLabel>
|
||||
<OutlinedInput
|
||||
id="channel-key-label"
|
||||
label={inputLabel.key}
|
||||
type="text"
|
||||
value={values.key}
|
||||
name="key"
|
||||
onBlur={handleBlur}
|
||||
onChange={handleChange}
|
||||
inputProps={{}}
|
||||
aria-describedby="helper-text-channel-key-label"
|
||||
/>
|
||||
{touched.key && errors.key ? (
|
||||
<FormHelperText error id="helper-tex-channel-key-label">
|
||||
{errors.key}
|
||||
</FormHelperText>
|
||||
) : (
|
||||
<FormHelperText id="helper-tex-channel-key-label">
|
||||
{" "}
|
||||
{inputPrompt.key}{" "}
|
||||
</FormHelperText>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl
|
||||
fullWidth
|
||||
error={Boolean(touched.model_mapping && errors.model_mapping)}
|
||||
sx={{ ...theme.typography.otherInput }}
|
||||
>
|
||||
{/* <InputLabel htmlFor="channel-model_mapping-label">{inputLabel.model_mapping}</InputLabel> */}
|
||||
<TextField
|
||||
multiline
|
||||
id="channel-model_mapping-label"
|
||||
label={inputLabel.model_mapping}
|
||||
value={values.model_mapping}
|
||||
name="model_mapping"
|
||||
onBlur={handleBlur}
|
||||
onChange={handleChange}
|
||||
aria-describedby="helper-text-channel-model_mapping-label"
|
||||
minRows={5}
|
||||
placeholder={inputPrompt.model_mapping}
|
||||
/>
|
||||
{touched.model_mapping && errors.model_mapping ? (
|
||||
<FormHelperText
|
||||
error
|
||||
id="helper-tex-channel-model_mapping-label"
|
||||
>
|
||||
{errors.model_mapping}
|
||||
</FormHelperText>
|
||||
) : (
|
||||
<FormHelperText id="helper-tex-channel-model_mapping-label">
|
||||
{" "}
|
||||
{inputPrompt.model_mapping}{" "}
|
||||
</FormHelperText>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl
|
||||
fullWidth
|
||||
error={Boolean(touched.proxy && errors.proxy)}
|
||||
sx={{ ...theme.typography.otherInput }}
|
||||
>
|
||||
<InputLabel htmlFor="channel-proxy-label">
|
||||
{inputLabel.proxy}
|
||||
</InputLabel>
|
||||
<OutlinedInput
|
||||
id="channel-proxy-label"
|
||||
label={inputLabel.proxy}
|
||||
type="text"
|
||||
value={values.proxy}
|
||||
name="proxy"
|
||||
onBlur={handleBlur}
|
||||
onChange={handleChange}
|
||||
inputProps={{}}
|
||||
aria-describedby="helper-text-channel-proxy-label"
|
||||
/>
|
||||
{touched.proxy && errors.proxy ? (
|
||||
<FormHelperText error id="helper-tex-channel-proxy-label">
|
||||
{errors.proxy}
|
||||
</FormHelperText>
|
||||
) : (
|
||||
<FormHelperText id="helper-tex-channel-proxy-label">
|
||||
{" "}
|
||||
{inputPrompt.proxy}{" "}
|
||||
</FormHelperText>
|
||||
)}
|
||||
</FormControl>
|
||||
{inputPrompt.test_model && (
|
||||
<FormControl
|
||||
fullWidth
|
||||
error={Boolean(touched.test_model && errors.test_model)}
|
||||
sx={{ ...theme.typography.otherInput }}
|
||||
>
|
||||
<InputLabel htmlFor="channel-test_model-label">
|
||||
{inputLabel.test_model}
|
||||
</InputLabel>
|
||||
<OutlinedInput
|
||||
id="channel-test_model-label"
|
||||
label={inputLabel.test_model}
|
||||
type="text"
|
||||
value={values.test_model}
|
||||
name="test_model"
|
||||
onBlur={handleBlur}
|
||||
onChange={handleChange}
|
||||
inputProps={{}}
|
||||
aria-describedby="helper-text-channel-test_model-label"
|
||||
/>
|
||||
{touched.test_model && errors.test_model ? (
|
||||
<FormHelperText
|
||||
error
|
||||
id="helper-tex-channel-test_model-label"
|
||||
>
|
||||
{errors.test_model}
|
||||
</FormHelperText>
|
||||
) : (
|
||||
<FormHelperText id="helper-tex-channel-test_model-label">
|
||||
{" "}
|
||||
{inputPrompt.test_model}{" "}
|
||||
</FormHelperText>
|
||||
)}
|
||||
</FormControl>
|
||||
)}
|
||||
<DialogActions>
|
||||
<Button onClick={onCancel}>取消</Button>
|
||||
<Button
|
||||
disableElevation
|
||||
disabled={isSubmitting}
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
>
|
||||
提交
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</form>
|
||||
)}
|
||||
</Formik>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditModal;
|
||||
|
||||
EditModal.propTypes = {
|
||||
open: PropTypes.bool,
|
||||
channelId: PropTypes.number,
|
||||
onCancel: PropTypes.func,
|
||||
onOk: PropTypes.func,
|
||||
};
|
||||
27
web/berry/src/views/Channel/component/GroupLabel.js
Normal file
27
web/berry/src/views/Channel/component/GroupLabel.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import PropTypes from "prop-types";
|
||||
import Label from "ui-component/Label";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Divider from "@mui/material/Divider";
|
||||
|
||||
const GroupLabel = ({ group }) => {
|
||||
let groups = [];
|
||||
if (group === "") {
|
||||
groups = ["default"];
|
||||
} else {
|
||||
groups = group.split(",");
|
||||
groups.sort();
|
||||
}
|
||||
return (
|
||||
<Stack divider={<Divider orientation="vertical" flexItem />} spacing={0.5}>
|
||||
{groups.map((group, index) => {
|
||||
return <Label key={index}>{group}</Label>;
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
GroupLabel.propTypes = {
|
||||
group: PropTypes.string,
|
||||
};
|
||||
|
||||
export default GroupLabel;
|
||||
54
web/berry/src/views/Channel/component/NameLabel.js
Normal file
54
web/berry/src/views/Channel/component/NameLabel.js
Normal file
@@ -0,0 +1,54 @@
|
||||
import PropTypes from "prop-types";
|
||||
import { Tooltip, Stack, Container } from "@mui/material";
|
||||
import Label from "ui-component/Label";
|
||||
import { styled } from "@mui/material/styles";
|
||||
import { showSuccess } from "utils/common";
|
||||
|
||||
const TooltipContainer = styled(Container)({
|
||||
maxHeight: "250px",
|
||||
overflow: "auto",
|
||||
"&::-webkit-scrollbar": {
|
||||
width: "0px", // Set the width to 0 to hide the scrollbar
|
||||
},
|
||||
});
|
||||
|
||||
const NameLabel = ({ name, models }) => {
|
||||
let modelMap = [];
|
||||
modelMap = models.split(",");
|
||||
modelMap.sort();
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
title={
|
||||
<TooltipContainer>
|
||||
<Stack spacing={1}>
|
||||
{modelMap.map((item, index) => {
|
||||
return (
|
||||
<Label
|
||||
variant="ghost"
|
||||
key={index}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(item);
|
||||
showSuccess("复制模型名称成功!");
|
||||
}}
|
||||
>
|
||||
{item}
|
||||
</Label>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</TooltipContainer>
|
||||
}
|
||||
placement="top"
|
||||
>
|
||||
<span>{name}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
NameLabel.propTypes = {
|
||||
name: PropTypes.string,
|
||||
models: PropTypes.string,
|
||||
};
|
||||
|
||||
export default NameLabel;
|
||||
43
web/berry/src/views/Channel/component/ResponseTimeLabel.js
Normal file
43
web/berry/src/views/Channel/component/ResponseTimeLabel.js
Normal file
@@ -0,0 +1,43 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import Label from 'ui-component/Label';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import { timestamp2string } from 'utils/common';
|
||||
|
||||
const ResponseTimeLabel = ({ test_time, response_time, handle_action }) => {
|
||||
let color = 'default';
|
||||
let time = response_time / 1000;
|
||||
time = time.toFixed(2) + ' 秒';
|
||||
|
||||
if (response_time === 0) {
|
||||
color = 'default';
|
||||
} else if (response_time <= 1000) {
|
||||
color = 'success';
|
||||
} else if (response_time <= 3000) {
|
||||
color = 'primary';
|
||||
} else if (response_time <= 5000) {
|
||||
color = 'secondary';
|
||||
} else {
|
||||
color = 'error';
|
||||
}
|
||||
let title = (
|
||||
<>
|
||||
点击测速
|
||||
<br />
|
||||
{test_time != 0 ? '上次测速时间:' + timestamp2string(test_time) : '未测试'}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip title={title} placement="top" onClick={handle_action}>
|
||||
<Label color={color}> {response_time == 0 ? '未测试' : time} </Label>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
ResponseTimeLabel.propTypes = {
|
||||
test_time: PropTypes.number,
|
||||
response_time: PropTypes.number,
|
||||
handle_action: PropTypes.func
|
||||
};
|
||||
|
||||
export default ResponseTimeLabel;
|
||||
21
web/berry/src/views/Channel/component/TableHead.js
Normal file
21
web/berry/src/views/Channel/component/TableHead.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import { TableCell, TableHead, TableRow } from '@mui/material';
|
||||
|
||||
const ChannelTableHead = () => {
|
||||
return (
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>ID</TableCell>
|
||||
<TableCell>名称</TableCell>
|
||||
<TableCell>分组</TableCell>
|
||||
<TableCell>类型</TableCell>
|
||||
<TableCell>状态</TableCell>
|
||||
<TableCell>响应时间</TableCell>
|
||||
<TableCell>余额</TableCell>
|
||||
<TableCell>优先级</TableCell>
|
||||
<TableCell>操作</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelTableHead;
|
||||
284
web/berry/src/views/Channel/component/TableRow.js
Normal file
284
web/berry/src/views/Channel/component/TableRow.js
Normal file
@@ -0,0 +1,284 @@
|
||||
import PropTypes from "prop-types";
|
||||
import { useState } from "react";
|
||||
|
||||
import { showInfo, showError, renderNumber } from "utils/common";
|
||||
import { API } from "utils/api";
|
||||
import { CHANNEL_OPTIONS } from "constants/ChannelConstants";
|
||||
|
||||
import {
|
||||
Popover,
|
||||
TableRow,
|
||||
MenuItem,
|
||||
TableCell,
|
||||
IconButton,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
InputAdornment,
|
||||
Input,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
Tooltip,
|
||||
Button,
|
||||
} from "@mui/material";
|
||||
|
||||
import Label from "ui-component/Label";
|
||||
import TableSwitch from "ui-component/Switch";
|
||||
|
||||
import ResponseTimeLabel from "./ResponseTimeLabel";
|
||||
import GroupLabel from "./GroupLabel";
|
||||
import NameLabel from "./NameLabel";
|
||||
|
||||
import {
|
||||
IconDotsVertical,
|
||||
IconEdit,
|
||||
IconTrash,
|
||||
IconPencil,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
export default function ChannelTableRow({
|
||||
item,
|
||||
manageChannel,
|
||||
handleOpenModal,
|
||||
setModalChannelId,
|
||||
}) {
|
||||
const [open, setOpen] = useState(null);
|
||||
const [openDelete, setOpenDelete] = useState(false);
|
||||
const [statusSwitch, setStatusSwitch] = useState(item.status);
|
||||
const [priorityValve, setPriority] = useState(item.priority);
|
||||
const [responseTimeData, setResponseTimeData] = useState({
|
||||
test_time: item.test_time,
|
||||
response_time: item.response_time,
|
||||
});
|
||||
const [itemBalance, setItemBalance] = useState(item.balance);
|
||||
|
||||
const handleDeleteOpen = () => {
|
||||
handleCloseMenu();
|
||||
setOpenDelete(true);
|
||||
};
|
||||
|
||||
const handleDeleteClose = () => {
|
||||
setOpenDelete(false);
|
||||
};
|
||||
|
||||
const handleOpenMenu = (event) => {
|
||||
setOpen(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleCloseMenu = () => {
|
||||
setOpen(null);
|
||||
};
|
||||
|
||||
const handleStatus = async () => {
|
||||
const switchVlue = statusSwitch === 1 ? 2 : 1;
|
||||
const { success } = await manageChannel(item.id, "status", switchVlue);
|
||||
if (success) {
|
||||
setStatusSwitch(switchVlue);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePriority = async () => {
|
||||
if (priorityValve === "" || priorityValve === item.priority) {
|
||||
return;
|
||||
}
|
||||
await manageChannel(item.id, "priority", priorityValve);
|
||||
};
|
||||
|
||||
const handleResponseTime = async () => {
|
||||
const { success, time } = await manageChannel(item.id, "test", "");
|
||||
if (success) {
|
||||
setResponseTimeData({
|
||||
test_time: Date.now() / 1000,
|
||||
response_time: time * 1000,
|
||||
});
|
||||
showInfo(`通道 ${item.name} 测试成功,耗时 ${time.toFixed(2)} 秒。`);
|
||||
}
|
||||
};
|
||||
|
||||
const updateChannelBalance = async () => {
|
||||
const res = await API.get(`/api/channel/update_balance/${item.id}`);
|
||||
const { success, message, balance } = res.data;
|
||||
if (success) {
|
||||
setItemBalance(balance);
|
||||
|
||||
showInfo(`余额更新成功!`);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
handleCloseMenu();
|
||||
await manageChannel(item.id, "delete", "");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableRow tabIndex={item.id}>
|
||||
<TableCell>{item.id}</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<NameLabel name={item.name} models={item.models} />
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<GroupLabel group={item.group} />
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
{!CHANNEL_OPTIONS[item.type] ? (
|
||||
<Label color="error" variant="outlined">
|
||||
未知
|
||||
</Label>
|
||||
) : (
|
||||
<Label color={CHANNEL_OPTIONS[item.type].color} variant="outlined">
|
||||
{CHANNEL_OPTIONS[item.type].text}
|
||||
</Label>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<Tooltip
|
||||
title={(() => {
|
||||
switch (statusSwitch) {
|
||||
case 1:
|
||||
return "已启用";
|
||||
case 2:
|
||||
return "本渠道被手动禁用";
|
||||
case 3:
|
||||
return "本渠道被程序自动禁用";
|
||||
default:
|
||||
return "未知";
|
||||
}
|
||||
})()}
|
||||
placement="top"
|
||||
>
|
||||
<TableSwitch
|
||||
id={`switch-${item.id}`}
|
||||
checked={statusSwitch === 1}
|
||||
onChange={handleStatus}
|
||||
/>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<ResponseTimeLabel
|
||||
test_time={responseTimeData.test_time}
|
||||
response_time={responseTimeData.response_time}
|
||||
handle_action={handleResponseTime}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Tooltip
|
||||
title={"点击更新余额"}
|
||||
placement="top"
|
||||
onClick={updateChannelBalance}
|
||||
>
|
||||
{renderBalance(item.type, itemBalance)}
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<FormControl sx={{ m: 1, width: "70px" }} variant="standard">
|
||||
<InputLabel htmlFor={`priority-${item.id}`}>优先级</InputLabel>
|
||||
<Input
|
||||
id={`priority-${item.id}`}
|
||||
type="text"
|
||||
value={priorityValve}
|
||||
onChange={(e) => setPriority(e.target.value)}
|
||||
sx={{ textAlign: "center" }}
|
||||
endAdornment={
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
onClick={handlePriority}
|
||||
sx={{ color: "rgb(99, 115, 129)" }}
|
||||
size="small"
|
||||
>
|
||||
<IconPencil />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<IconButton
|
||||
onClick={handleOpenMenu}
|
||||
sx={{ color: "rgb(99, 115, 129)" }}
|
||||
>
|
||||
<IconDotsVertical />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
<Popover
|
||||
open={!!open}
|
||||
anchorEl={open}
|
||||
onClose={handleCloseMenu}
|
||||
anchorOrigin={{ vertical: "top", horizontal: "left" }}
|
||||
transformOrigin={{ vertical: "top", horizontal: "right" }}
|
||||
PaperProps={{
|
||||
sx: { width: 140 },
|
||||
}}
|
||||
>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
handleCloseMenu();
|
||||
handleOpenModal();
|
||||
setModalChannelId(item.id);
|
||||
}}
|
||||
>
|
||||
<IconEdit style={{ marginRight: "16px" }} />
|
||||
编辑
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleDeleteOpen} sx={{ color: "error.main" }}>
|
||||
<IconTrash style={{ marginRight: "16px" }} />
|
||||
删除
|
||||
</MenuItem>
|
||||
</Popover>
|
||||
|
||||
<Dialog open={openDelete} onClose={handleDeleteClose}>
|
||||
<DialogTitle>删除通道</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>是否删除通道 {item.name}?</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleDeleteClose}>关闭</Button>
|
||||
<Button onClick={handleDelete} sx={{ color: "error.main" }} autoFocus>
|
||||
删除
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
ChannelTableRow.propTypes = {
|
||||
item: PropTypes.object,
|
||||
manageChannel: PropTypes.func,
|
||||
handleOpenModal: PropTypes.func,
|
||||
setModalChannelId: PropTypes.func,
|
||||
};
|
||||
|
||||
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>;
|
||||
}
|
||||
}
|
||||
294
web/berry/src/views/Channel/index.js
Normal file
294
web/berry/src/views/Channel/index.js
Normal file
@@ -0,0 +1,294 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { showError, showSuccess, showInfo } from 'utils/common';
|
||||
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import Table from '@mui/material/Table';
|
||||
import TableBody from '@mui/material/TableBody';
|
||||
import TableContainer from '@mui/material/TableContainer';
|
||||
import PerfectScrollbar from 'react-perfect-scrollbar';
|
||||
import TablePagination from '@mui/material/TablePagination';
|
||||
import LinearProgress from '@mui/material/LinearProgress';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import ButtonGroup from '@mui/material/ButtonGroup';
|
||||
import Toolbar from '@mui/material/Toolbar';
|
||||
import useMediaQuery from '@mui/material/useMediaQuery';
|
||||
|
||||
import { Button, IconButton, Card, Box, Stack, Container, Typography, Divider } from '@mui/material';
|
||||
import ChannelTableRow from './component/TableRow';
|
||||
import ChannelTableHead from './component/TableHead';
|
||||
import TableToolBar from 'ui-component/TableToolBar';
|
||||
import { API } from 'utils/api';
|
||||
import { ITEMS_PER_PAGE } from 'constants';
|
||||
import { IconRefresh, IconHttpDelete, IconPlus, IconBrandSpeedtest, IconCoinYuan } from '@tabler/icons-react';
|
||||
import EditeModal from './component/EditModal';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// CHANNEL_OPTIONS,
|
||||
export default function ChannelPage() {
|
||||
const [channels, setChannels] = useState([]);
|
||||
const [activePage, setActivePage] = useState(0);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const theme = useTheme();
|
||||
const matchUpMd = useMediaQuery(theme.breakpoints.up('sm'));
|
||||
const [openModal, setOpenModal] = useState(false);
|
||||
const [editChannelId, setEditChannelId] = useState(0);
|
||||
|
||||
const loadChannels = async (startIdx) => {
|
||||
setSearching(true);
|
||||
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);
|
||||
}
|
||||
setSearching(false);
|
||||
};
|
||||
|
||||
const onPaginationChange = (event, activePage) => {
|
||||
(async () => {
|
||||
if (activePage === Math.ceil(channels.length / ITEMS_PER_PAGE)) {
|
||||
// In this case we have to load more data and then append them.
|
||||
await loadChannels(activePage);
|
||||
}
|
||||
setActivePage(activePage);
|
||||
})();
|
||||
};
|
||||
|
||||
const searchChannels = async (event) => {
|
||||
event.preventDefault();
|
||||
if (searchKeyword === '') {
|
||||
await loadChannels(0);
|
||||
setActivePage(0);
|
||||
return;
|
||||
}
|
||||
setSearching(true);
|
||||
const res = await API.get(`/api/channel/search?keyword=${searchKeyword}`);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
setChannels(data);
|
||||
setActivePage(0);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
setSearching(false);
|
||||
};
|
||||
|
||||
const handleSearchKeyword = (event) => {
|
||||
setSearchKeyword(event.target.value);
|
||||
};
|
||||
|
||||
const manageChannel = async (id, action, value) => {
|
||||
const url = '/api/channel/';
|
||||
let data = { id };
|
||||
let res;
|
||||
switch (action) {
|
||||
case 'delete':
|
||||
res = await API.delete(url + id);
|
||||
break;
|
||||
case 'status':
|
||||
res = await API.put(url, {
|
||||
...data,
|
||||
status: value
|
||||
});
|
||||
break;
|
||||
case 'priority':
|
||||
if (value === '') {
|
||||
return;
|
||||
}
|
||||
res = await API.put(url, {
|
||||
...data,
|
||||
priority: parseInt(value)
|
||||
});
|
||||
break;
|
||||
case 'test':
|
||||
res = await API.get(url + `test/${id}`);
|
||||
break;
|
||||
}
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess('操作成功完成!');
|
||||
if (action === 'delete') {
|
||||
await handleRefresh();
|
||||
}
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
|
||||
return res.data;
|
||||
};
|
||||
|
||||
// 处理刷新
|
||||
const handleRefresh = async () => {
|
||||
await loadChannels(activePage);
|
||||
};
|
||||
|
||||
// 处理测试所有启用渠道
|
||||
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 handleRefresh();
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理更新所有启用渠道余额
|
||||
const updateAllChannelsBalance = async () => {
|
||||
setSearching(true);
|
||||
const res = await API.get(`/api/channel/update_balance`);
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showInfo('已更新完毕所有已启用通道余额!');
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
setSearching(false);
|
||||
};
|
||||
|
||||
const handleOpenModal = (channelId) => {
|
||||
setEditChannelId(channelId);
|
||||
setOpenModal(true);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setOpenModal(false);
|
||||
setEditChannelId(0);
|
||||
};
|
||||
|
||||
const handleOkModal = (status) => {
|
||||
if (status === true) {
|
||||
handleCloseModal();
|
||||
handleRefresh();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadChannels(0)
|
||||
.then()
|
||||
.catch((reason) => {
|
||||
showError(reason);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" mb={5}>
|
||||
<Typography variant="h4">渠道</Typography>
|
||||
|
||||
<Button variant="contained" color="primary" startIcon={<IconPlus />} onClick={() => handleOpenModal(0)}>
|
||||
新建渠道
|
||||
</Button>
|
||||
</Stack>
|
||||
<Stack mb={5}>
|
||||
<Alert severity="info">
|
||||
当前版本测试是通过按照 OpenAI API 格式使用 gpt-3.5-turbo
|
||||
模型进行非流式请求实现的,因此测试报错并不一定代表通道不可用,该功能后续会修复。 另外,OpenAI 渠道已经不再支持通过 key
|
||||
获取余额,因此余额显示为 0。对于支持的渠道类型,请点击余额进行刷新。
|
||||
</Alert>
|
||||
</Stack>
|
||||
<Card>
|
||||
<Box component="form" onSubmit={searchChannels} noValidate>
|
||||
<TableToolBar filterName={searchKeyword} handleFilterName={handleSearchKeyword} placeholder={'搜索渠道的 ID,名称和密钥 ...'} />
|
||||
</Box>
|
||||
<Toolbar
|
||||
sx={{
|
||||
textAlign: 'right',
|
||||
height: 50,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
p: (theme) => theme.spacing(0, 1, 0, 3)
|
||||
}}
|
||||
>
|
||||
<Container>
|
||||
{matchUpMd ? (
|
||||
<ButtonGroup variant="outlined" aria-label="outlined small primary button group">
|
||||
<Button onClick={handleRefresh} startIcon={<IconRefresh width={'18px'} />}>
|
||||
刷新
|
||||
</Button>
|
||||
<Button onClick={testAllChannels} startIcon={<IconBrandSpeedtest width={'18px'} />}>
|
||||
测试启用渠道
|
||||
</Button>
|
||||
<Button onClick={updateAllChannelsBalance} startIcon={<IconCoinYuan width={'18px'} />}>
|
||||
更新启用余额
|
||||
</Button>
|
||||
<Button onClick={deleteAllDisabledChannels} startIcon={<IconHttpDelete width={'18px'} />}>
|
||||
删除禁用渠道
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
) : (
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
divider={<Divider orientation="vertical" flexItem />}
|
||||
justifyContent="space-around"
|
||||
alignItems="center"
|
||||
>
|
||||
<IconButton onClick={handleRefresh} size="large">
|
||||
<IconRefresh />
|
||||
</IconButton>
|
||||
<IconButton onClick={testAllChannels} size="large">
|
||||
<IconBrandSpeedtest />
|
||||
</IconButton>
|
||||
<IconButton onClick={updateAllChannelsBalance} size="large">
|
||||
<IconCoinYuan />
|
||||
</IconButton>
|
||||
<IconButton onClick={deleteAllDisabledChannels} size="large">
|
||||
<IconHttpDelete />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
)}
|
||||
</Container>
|
||||
</Toolbar>
|
||||
{searching && <LinearProgress />}
|
||||
<PerfectScrollbar component="div">
|
||||
<TableContainer sx={{ overflow: 'unset' }}>
|
||||
<Table sx={{ minWidth: 800 }}>
|
||||
<ChannelTableHead />
|
||||
<TableBody>
|
||||
{channels.slice(activePage * ITEMS_PER_PAGE, (activePage + 1) * ITEMS_PER_PAGE).map((row) => (
|
||||
<ChannelTableRow
|
||||
item={row}
|
||||
manageChannel={manageChannel}
|
||||
key={row.id}
|
||||
handleOpenModal={handleOpenModal}
|
||||
setModalChannelId={setEditChannelId}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</PerfectScrollbar>
|
||||
<TablePagination
|
||||
page={activePage}
|
||||
component="div"
|
||||
count={channels.length + (channels.length % ITEMS_PER_PAGE === 0 ? 1 : 0)}
|
||||
rowsPerPage={ITEMS_PER_PAGE}
|
||||
onPageChange={onPaginationChange}
|
||||
rowsPerPageOptions={[ITEMS_PER_PAGE]}
|
||||
/>
|
||||
</Card>
|
||||
<EditeModal open={openModal} onCancel={handleCloseModal} onOk={handleOkModal} channelId={editChannelId} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
144
web/berry/src/views/Channel/type/Config.js
Normal file
144
web/berry/src/views/Channel/type/Config.js
Normal file
@@ -0,0 +1,144 @@
|
||||
const defaultConfig = {
|
||||
input: {
|
||||
name: "",
|
||||
type: 1,
|
||||
key: "",
|
||||
base_url: "",
|
||||
other: "",
|
||||
model_mapping: "",
|
||||
models: [],
|
||||
groups: ["default"],
|
||||
},
|
||||
inputLabel: {
|
||||
name: "渠道名称",
|
||||
type: "渠道类型",
|
||||
base_url: "渠道API地址",
|
||||
key: "密钥",
|
||||
other: "其他参数",
|
||||
models: "模型",
|
||||
model_mapping: "模型映射关系",
|
||||
groups: "用户组",
|
||||
},
|
||||
prompt: {
|
||||
type: "请选择渠道类型",
|
||||
name: "请为渠道命名",
|
||||
base_url: "可空,请输入中转API地址,例如通过cloudflare中转",
|
||||
key: "请输入渠道对应的鉴权密钥",
|
||||
other: "",
|
||||
models: "请选择该渠道所支持的模型",
|
||||
model_mapping:
|
||||
'请输入要修改的模型映射关系,格式为:api请求模型ID:实际转发给渠道的模型ID,使用JSON数组表示,例如:{"gpt-3.5": "gpt-35"}',
|
||||
groups: "请选择该渠道所支持的用户组",
|
||||
},
|
||||
modelGroup: "openai",
|
||||
};
|
||||
|
||||
const typeConfig = {
|
||||
3: {
|
||||
inputLabel: {
|
||||
base_url: "AZURE_OPENAI_ENDPOINT",
|
||||
other: "默认 API 版本",
|
||||
},
|
||||
prompt: {
|
||||
base_url: "请填写AZURE_OPENAI_ENDPOINT",
|
||||
other: "请输入默认API版本,例如:2023-06-01-preview",
|
||||
},
|
||||
},
|
||||
11: {
|
||||
input: {
|
||||
models: ["PaLM-2"],
|
||||
},
|
||||
modelGroup: "google palm",
|
||||
},
|
||||
14: {
|
||||
input: {
|
||||
models: ["claude-instant-1", "claude-2", "claude-2.0", "claude-2.1"],
|
||||
},
|
||||
modelGroup: "anthropic",
|
||||
},
|
||||
15: {
|
||||
input: {
|
||||
models: ["ERNIE-Bot", "ERNIE-Bot-turbo", "ERNIE-Bot-4", "Embedding-V1"],
|
||||
},
|
||||
prompt: {
|
||||
key: "按照如下格式输入:APIKey|SecretKey",
|
||||
},
|
||||
modelGroup: "baidu",
|
||||
},
|
||||
16: {
|
||||
input: {
|
||||
models: ["chatglm_turbo", "chatglm_pro", "chatglm_std", "chatglm_lite"],
|
||||
},
|
||||
modelGroup: "zhipu",
|
||||
},
|
||||
17: {
|
||||
inputLabel: {
|
||||
other: "插件参数",
|
||||
},
|
||||
input: {
|
||||
models: [
|
||||
"qwen-turbo",
|
||||
"qwen-plus",
|
||||
"qwen-max",
|
||||
"qwen-max-longcontext",
|
||||
"text-embedding-v1",
|
||||
],
|
||||
},
|
||||
prompt: {
|
||||
other: "请输入插件参数,即 X-DashScope-Plugin 请求头的取值",
|
||||
},
|
||||
modelGroup: "ali",
|
||||
},
|
||||
18: {
|
||||
inputLabel: {
|
||||
other: "版本号",
|
||||
},
|
||||
input: {
|
||||
models: ["SparkDesk"],
|
||||
},
|
||||
prompt: {
|
||||
key: "按照如下格式输入:APPID|APISecret|APIKey",
|
||||
other: "请输入版本号,例如:v3.1",
|
||||
},
|
||||
modelGroup: "xunfei",
|
||||
},
|
||||
19: {
|
||||
input: {
|
||||
models: [
|
||||
"360GPT_S2_V9",
|
||||
"embedding-bert-512-v1",
|
||||
"embedding_s1_v1",
|
||||
"semantic_similarity_s1_v1",
|
||||
],
|
||||
},
|
||||
modelGroup: "360",
|
||||
},
|
||||
22: {
|
||||
prompt: {
|
||||
key: "按照如下格式输入:APIKey-AppId,例如:fastgpt-0sp2gtvfdgyi4k30jwlgwf1i-64f335d84283f05518e9e041",
|
||||
},
|
||||
},
|
||||
23: {
|
||||
input: {
|
||||
models: ["hunyuan"],
|
||||
},
|
||||
prompt: {
|
||||
key: "按照如下格式输入:AppId|SecretId|SecretKey",
|
||||
},
|
||||
modelGroup: "tencent",
|
||||
},
|
||||
24: {
|
||||
inputLabel: {
|
||||
other: "版本号",
|
||||
},
|
||||
input: {
|
||||
models: ["gemini-pro"],
|
||||
},
|
||||
prompt: {
|
||||
other: "请输入版本号,例如:v1",
|
||||
},
|
||||
modelGroup: "google gemini",
|
||||
},
|
||||
};
|
||||
|
||||
export { defaultConfig, typeConfig };
|
||||
169
web/berry/src/views/Dashboard/component/StatisticalBarChart.js
Normal file
169
web/berry/src/views/Dashboard/component/StatisticalBarChart.js
Normal file
@@ -0,0 +1,169 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
// material-ui
|
||||
import { Grid, Typography } from '@mui/material';
|
||||
|
||||
// third-party
|
||||
import Chart from 'react-apexcharts';
|
||||
|
||||
// project imports
|
||||
import SkeletonTotalGrowthBarChart from 'ui-component/cards/Skeleton/TotalGrowthBarChart';
|
||||
import MainCard from 'ui-component/cards/MainCard';
|
||||
import { gridSpacing } from 'store/constant';
|
||||
import { Box } from '@mui/material';
|
||||
|
||||
// ==============================|| DASHBOARD DEFAULT - TOTAL GROWTH BAR CHART ||============================== //
|
||||
|
||||
const StatisticalBarChart = ({ isLoading, chartDatas }) => {
|
||||
chartData.options.xaxis.categories = chartDatas.xaxis;
|
||||
chartData.series = chartDatas.data;
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoading ? (
|
||||
<SkeletonTotalGrowthBarChart />
|
||||
) : (
|
||||
<MainCard>
|
||||
<Grid container spacing={gridSpacing}>
|
||||
<Grid item xs={12}>
|
||||
<Grid container alignItems="center" justifyContent="space-between">
|
||||
<Grid item>
|
||||
<Typography variant="h3">统计</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
{chartData.series ? (
|
||||
<Chart {...chartData} />
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: '490px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
<Typography variant="h3" color={'#697586'}>
|
||||
暂无数据
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</MainCard>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
StatisticalBarChart.propTypes = {
|
||||
isLoading: PropTypes.bool
|
||||
};
|
||||
|
||||
export default StatisticalBarChart;
|
||||
|
||||
const chartData = {
|
||||
height: 480,
|
||||
type: 'bar',
|
||||
options: {
|
||||
colors: [
|
||||
'#008FFB',
|
||||
'#00E396',
|
||||
'#FEB019',
|
||||
'#FF4560',
|
||||
'#775DD0',
|
||||
'#55efc4',
|
||||
'#81ecec',
|
||||
'#74b9ff',
|
||||
'#a29bfe',
|
||||
'#00b894',
|
||||
'#00cec9',
|
||||
'#0984e3',
|
||||
'#6c5ce7',
|
||||
'#ffeaa7',
|
||||
'#fab1a0',
|
||||
'#ff7675',
|
||||
'#fd79a8',
|
||||
'#fdcb6e',
|
||||
'#e17055',
|
||||
'#d63031',
|
||||
'#e84393'
|
||||
],
|
||||
chart: {
|
||||
id: 'bar-chart',
|
||||
stacked: true,
|
||||
toolbar: {
|
||||
show: true
|
||||
},
|
||||
zoom: {
|
||||
enabled: true
|
||||
}
|
||||
},
|
||||
responsive: [
|
||||
{
|
||||
breakpoint: 480,
|
||||
options: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
offsetX: -10,
|
||||
offsetY: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
plotOptions: {
|
||||
bar: {
|
||||
horizontal: false,
|
||||
columnWidth: '50%'
|
||||
}
|
||||
},
|
||||
xaxis: {
|
||||
type: 'category',
|
||||
categories: []
|
||||
},
|
||||
legend: {
|
||||
show: true,
|
||||
fontSize: '14px',
|
||||
fontFamily: `'Roboto', sans-serif`,
|
||||
position: 'bottom',
|
||||
offsetX: 20,
|
||||
labels: {
|
||||
useSeriesColors: false
|
||||
},
|
||||
markers: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
radius: 5
|
||||
},
|
||||
itemMargin: {
|
||||
horizontal: 15,
|
||||
vertical: 8
|
||||
}
|
||||
},
|
||||
fill: {
|
||||
type: 'solid'
|
||||
},
|
||||
dataLabels: {
|
||||
enabled: false
|
||||
},
|
||||
grid: {
|
||||
show: true
|
||||
},
|
||||
tooltip: {
|
||||
theme: 'dark',
|
||||
fixed: {
|
||||
enabled: false
|
||||
},
|
||||
y: {
|
||||
formatter: function (val) {
|
||||
return '$' + val;
|
||||
}
|
||||
},
|
||||
marker: {
|
||||
show: false
|
||||
}
|
||||
}
|
||||
},
|
||||
series: []
|
||||
};
|
||||
99
web/berry/src/views/Dashboard/component/StatisticalCard.js
Normal file
99
web/berry/src/views/Dashboard/component/StatisticalCard.js
Normal file
@@ -0,0 +1,99 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
// material-ui
|
||||
import { styled, useTheme } from '@mui/material/styles';
|
||||
import { Avatar, Box, List, ListItem, ListItemAvatar, ListItemText, Typography } from '@mui/material';
|
||||
|
||||
// project imports
|
||||
import MainCard from 'ui-component/cards/MainCard';
|
||||
import TotalIncomeCard from 'ui-component/cards/Skeleton/TotalIncomeCard';
|
||||
|
||||
// assets
|
||||
import TableChartOutlinedIcon from '@mui/icons-material/TableChartOutlined';
|
||||
|
||||
// styles
|
||||
const CardWrapper = styled(MainCard)(({ theme }) => ({
|
||||
backgroundColor: theme.palette.primary.dark,
|
||||
color: theme.palette.primary.light,
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
'&:after': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
width: 210,
|
||||
height: 210,
|
||||
background: `linear-gradient(210.04deg, ${theme.palette.primary[200]} -50.94%, rgba(144, 202, 249, 0) 83.49%)`,
|
||||
borderRadius: '50%',
|
||||
top: -30,
|
||||
right: -180
|
||||
},
|
||||
'&:before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
width: 210,
|
||||
height: 210,
|
||||
background: `linear-gradient(140.9deg, ${theme.palette.primary[200]} -14.02%, rgba(144, 202, 249, 0) 77.58%)`,
|
||||
borderRadius: '50%',
|
||||
top: -160,
|
||||
right: -130
|
||||
}
|
||||
}));
|
||||
|
||||
// ==============================|| DASHBOARD - TOTAL INCOME DARK CARD ||============================== //
|
||||
|
||||
const StatisticalCard = ({ isLoading }) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoading ? (
|
||||
<TotalIncomeCard />
|
||||
) : (
|
||||
<CardWrapper border={false} content={false}>
|
||||
<Box sx={{ p: 2 }}>
|
||||
<List sx={{ py: 0 }}>
|
||||
<ListItem alignItems="center" disableGutters sx={{ py: 0 }}>
|
||||
<ListItemAvatar>
|
||||
<Avatar
|
||||
variant="rounded"
|
||||
sx={{
|
||||
...theme.typography.commonAvatar,
|
||||
...theme.typography.largeAvatar,
|
||||
backgroundColor: theme.palette.primary[800],
|
||||
color: '#fff'
|
||||
}}
|
||||
>
|
||||
<TableChartOutlinedIcon fontSize="inherit" />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
sx={{
|
||||
py: 0,
|
||||
mt: 0.45,
|
||||
mb: 0.45
|
||||
}}
|
||||
primary={
|
||||
<Typography variant="h4" sx={{ color: '#fff' }}>
|
||||
$203k
|
||||
</Typography>
|
||||
}
|
||||
secondary={
|
||||
<Typography variant="subtitle2" sx={{ color: 'primary.light', mt: 0.25 }}>
|
||||
Total Income
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
</List>
|
||||
</Box>
|
||||
</CardWrapper>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
StatisticalCard.propTypes = {
|
||||
isLoading: PropTypes.bool
|
||||
};
|
||||
|
||||
export default StatisticalCard;
|
||||
@@ -0,0 +1,122 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
// material-ui
|
||||
import { useTheme, styled } from '@mui/material/styles';
|
||||
import { Box, Grid, Typography } from '@mui/material';
|
||||
|
||||
// third-party
|
||||
import Chart from 'react-apexcharts';
|
||||
|
||||
// project imports
|
||||
import MainCard from 'ui-component/cards/MainCard';
|
||||
import SkeletonTotalOrderCard from 'ui-component/cards/Skeleton/EarningCard';
|
||||
|
||||
const CardWrapper = styled(MainCard)(({ theme }) => ({
|
||||
backgroundColor: theme.palette.primary.dark,
|
||||
color: '#fff',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
'&>div': {
|
||||
position: 'relative',
|
||||
zIndex: 5
|
||||
},
|
||||
'&:after': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
width: 210,
|
||||
height: 210,
|
||||
background: theme.palette.primary[800],
|
||||
borderRadius: '50%',
|
||||
zIndex: 1,
|
||||
top: -85,
|
||||
right: -95,
|
||||
[theme.breakpoints.down('sm')]: {
|
||||
top: -105,
|
||||
right: -140
|
||||
}
|
||||
},
|
||||
'&:before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
zIndex: 1,
|
||||
width: 210,
|
||||
height: 210,
|
||||
background: theme.palette.primary[800],
|
||||
borderRadius: '50%',
|
||||
top: -125,
|
||||
right: -15,
|
||||
opacity: 0.5,
|
||||
[theme.breakpoints.down('sm')]: {
|
||||
top: -155,
|
||||
right: -70
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// ==============================|| DASHBOARD - TOTAL ORDER LINE CHART CARD ||============================== //
|
||||
|
||||
const StatisticalLineChartCard = ({ isLoading, title, chartData, todayValue }) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoading ? (
|
||||
<SkeletonTotalOrderCard />
|
||||
) : (
|
||||
<CardWrapper border={false} content={false}>
|
||||
<Box sx={{ p: 2.25 }}>
|
||||
<Grid container direction="column">
|
||||
<Grid item sx={{ mb: 0.75 }}>
|
||||
<Grid container alignItems="center">
|
||||
<Grid item xs={6}>
|
||||
<Grid container alignItems="center">
|
||||
<Grid item>
|
||||
<Typography sx={{ fontSize: '2.125rem', fontWeight: 500, mr: 1, mt: 1.75, mb: 0.75 }}>
|
||||
{todayValue || '0'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item></Grid>
|
||||
<Grid item xs={12}>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '1rem',
|
||||
fontWeight: 500,
|
||||
color: theme.palette.primary[200]
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
{chartData ? (
|
||||
<Chart {...chartData} />
|
||||
) : (
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '1rem',
|
||||
fontWeight: 500,
|
||||
color: theme.palette.primary[200]
|
||||
}}
|
||||
>
|
||||
无数据
|
||||
</Typography>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</CardWrapper>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
StatisticalLineChartCard.propTypes = {
|
||||
isLoading: PropTypes.bool,
|
||||
title: PropTypes.string
|
||||
};
|
||||
|
||||
export default StatisticalLineChartCard;
|
||||
218
web/berry/src/views/Dashboard/index.js
Normal file
218
web/berry/src/views/Dashboard/index.js
Normal file
@@ -0,0 +1,218 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Grid, Typography } from '@mui/material';
|
||||
import { gridSpacing } from 'store/constant';
|
||||
import StatisticalLineChartCard from './component/StatisticalLineChartCard';
|
||||
import StatisticalBarChart from './component/StatisticalBarChart';
|
||||
import { generateChartOptions, getLastSevenDays } from 'utils/chart';
|
||||
import { API } from 'utils/api';
|
||||
import { showError, calculateQuota, renderNumber } from 'utils/common';
|
||||
import UserCard from 'ui-component/cards/UserCard';
|
||||
|
||||
const Dashboard = () => {
|
||||
const [isLoading, setLoading] = useState(true);
|
||||
const [statisticalData, setStatisticalData] = useState([]);
|
||||
const [requestChart, setRequestChart] = useState(null);
|
||||
const [quotaChart, setQuotaChart] = useState(null);
|
||||
const [tokenChart, setTokenChart] = useState(null);
|
||||
const [users, setUsers] = useState([]);
|
||||
|
||||
const userDashboard = async () => {
|
||||
const res = await API.get('/api/user/dashboard');
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
if (data) {
|
||||
let lineData = getLineDataGroup(data);
|
||||
setRequestChart(getLineCardOption(lineData, 'RequestCount'));
|
||||
setQuotaChart(getLineCardOption(lineData, 'Quota'));
|
||||
setTokenChart(getLineCardOption(lineData, 'PromptTokens'));
|
||||
setStatisticalData(getBarDataGroup(data));
|
||||
}
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const loadUser = async () => {
|
||||
let res = await API.get(`/api/user/self`);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
setUsers(data);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
userDashboard();
|
||||
loadUser();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Grid container spacing={gridSpacing}>
|
||||
<Grid item xs={12}>
|
||||
<Grid container spacing={gridSpacing}>
|
||||
<Grid item lg={4} xs={12}>
|
||||
<StatisticalLineChartCard
|
||||
isLoading={isLoading}
|
||||
title="今日请求量"
|
||||
chartData={requestChart?.chartData}
|
||||
todayValue={requestChart?.todayValue}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item lg={4} xs={12}>
|
||||
<StatisticalLineChartCard
|
||||
isLoading={isLoading}
|
||||
title="今日消费"
|
||||
chartData={quotaChart?.chartData}
|
||||
todayValue={quotaChart?.todayValue}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item lg={4} xs={12}>
|
||||
<StatisticalLineChartCard
|
||||
isLoading={isLoading}
|
||||
title="今日 token"
|
||||
chartData={tokenChart?.chartData}
|
||||
todayValue={tokenChart?.todayValue}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Grid container spacing={gridSpacing}>
|
||||
<Grid item lg={8} xs={12}>
|
||||
<StatisticalBarChart isLoading={isLoading} chartDatas={statisticalData} />
|
||||
</Grid>
|
||||
<Grid item lg={4} xs={12}>
|
||||
<UserCard>
|
||||
<Grid container spacing={gridSpacing} justifyContent="center" alignItems="center" paddingTop={'20px'}>
|
||||
<Grid item xs={4}>
|
||||
<Typography variant="h4">余额:</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={8}>
|
||||
<Typography variant="h3"> {users?.quota ? '$' + calculateQuota(users.quota) : '未知'}</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<Typography variant="h4">已使用:</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={8}>
|
||||
<Typography variant="h3"> {users?.used_quota ? '$' + calculateQuota(users.used_quota) : '未知'}</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<Typography variant="h4">调用次数:</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={8}>
|
||||
<Typography variant="h3"> {users?.request_count || '未知'}</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</UserCard>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
export default Dashboard;
|
||||
|
||||
function getLineDataGroup(statisticalData) {
|
||||
let groupedData = statisticalData.reduce((acc, cur) => {
|
||||
if (!acc[cur.Day]) {
|
||||
acc[cur.Day] = {
|
||||
date: cur.Day,
|
||||
RequestCount: 0,
|
||||
Quota: 0,
|
||||
PromptTokens: 0,
|
||||
CompletionTokens: 0
|
||||
};
|
||||
}
|
||||
acc[cur.Day].RequestCount += cur.RequestCount;
|
||||
acc[cur.Day].Quota += cur.Quota;
|
||||
acc[cur.Day].PromptTokens += cur.PromptTokens;
|
||||
acc[cur.Day].CompletionTokens += cur.CompletionTokens;
|
||||
return acc;
|
||||
}, {});
|
||||
let lastSevenDays = getLastSevenDays();
|
||||
return lastSevenDays.map((day) => {
|
||||
if (!groupedData[day]) {
|
||||
return {
|
||||
date: day,
|
||||
RequestCount: 0,
|
||||
Quota: 0,
|
||||
PromptTokens: 0,
|
||||
CompletionTokens: 0
|
||||
};
|
||||
} else {
|
||||
return groupedData[day];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getBarDataGroup(data) {
|
||||
const lastSevenDays = getLastSevenDays();
|
||||
const result = [];
|
||||
const map = new Map();
|
||||
|
||||
for (const item of data) {
|
||||
if (!map.has(item.ModelName)) {
|
||||
const newData = { name: item.ModelName, data: new Array(7) };
|
||||
map.set(item.ModelName, newData);
|
||||
result.push(newData);
|
||||
}
|
||||
const index = lastSevenDays.indexOf(item.Day);
|
||||
if (index !== -1) {
|
||||
map.get(item.ModelName).data[index] = calculateQuota(item.Quota, 3);
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of result) {
|
||||
for (let i = 0; i < 7; i++) {
|
||||
if (item.data[i] === undefined) {
|
||||
item.data[i] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { data: result, xaxis: lastSevenDays };
|
||||
}
|
||||
|
||||
function getLineCardOption(lineDataGroup, field) {
|
||||
let todayValue = 0;
|
||||
let chartData = null;
|
||||
const lastItem = lineDataGroup.length - 1;
|
||||
let lineData = lineDataGroup.map((item, index) => {
|
||||
let tmp = {
|
||||
date: item.date,
|
||||
value: item[field]
|
||||
};
|
||||
switch (field) {
|
||||
case 'Quota':
|
||||
tmp.value = calculateQuota(item.Quota, 3);
|
||||
break;
|
||||
case 'PromptTokens':
|
||||
tmp.value += item.CompletionTokens;
|
||||
break;
|
||||
}
|
||||
|
||||
if (index == lastItem) {
|
||||
todayValue = tmp.value;
|
||||
}
|
||||
return tmp;
|
||||
});
|
||||
|
||||
switch (field) {
|
||||
case 'RequestCount':
|
||||
chartData = generateChartOptions(lineData, '次');
|
||||
todayValue = renderNumber(todayValue);
|
||||
break;
|
||||
case 'Quota':
|
||||
chartData = generateChartOptions(lineData, '美元');
|
||||
todayValue = '$' + renderNumber(todayValue);
|
||||
break;
|
||||
case 'PromptTokens':
|
||||
chartData = generateChartOptions(lineData, '');
|
||||
todayValue = renderNumber(todayValue);
|
||||
break;
|
||||
}
|
||||
|
||||
return { chartData: chartData, todayValue: todayValue };
|
||||
}
|
||||
47
web/berry/src/views/Error/index.js
Normal file
47
web/berry/src/views/Error/index.js
Normal file
@@ -0,0 +1,47 @@
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Container from '@mui/material/Container';
|
||||
import NotFound from 'assets/images/404.svg';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export default function NotFoundView() {
|
||||
const navigate = useNavigate();
|
||||
const goBack = () => {
|
||||
navigate(-1);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<Container>
|
||||
<Box
|
||||
sx={{
|
||||
py: 12,
|
||||
maxWidth: 480,
|
||||
mx: 'auto',
|
||||
display: 'flex',
|
||||
minHeight: 'calc(100vh - 136px)',
|
||||
textAlign: 'center',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src={NotFound}
|
||||
sx={{
|
||||
mx: 'auto',
|
||||
height: 260,
|
||||
my: { xs: 5, sm: 10 }
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button size="large" variant="contained" onClick={goBack}>
|
||||
返回
|
||||
</Button>
|
||||
</Box>
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
}
|
||||
42
web/berry/src/views/Home/baseIndex.js
Normal file
42
web/berry/src/views/Home/baseIndex.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Box, Typography, Button, Container, Stack } from '@mui/material';
|
||||
import Grid from '@mui/material/Unstable_Grid2';
|
||||
import { GitHub } from '@mui/icons-material';
|
||||
|
||||
const BaseIndex = () => (
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: 'calc(100vh - 136px)',
|
||||
backgroundImage: 'linear-gradient(to right, #ff9966, #ff5e62)',
|
||||
color: 'white',
|
||||
p: 4
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="lg">
|
||||
<Grid container columns={12} wrap="nowrap" alignItems="center" sx={{ minHeight: 'calc(100vh - 230px)' }}>
|
||||
<Grid md={7} lg={6}>
|
||||
<Stack spacing={3}>
|
||||
<Typography variant="h1" sx={{ fontSize: '4rem', color: '#fff', lineHeight: 1.5 }}>
|
||||
One API
|
||||
</Typography>
|
||||
<Typography variant="h4" sx={{ fontSize: '1.5rem', color: '#fff', lineHeight: 1.5 }}>
|
||||
All in one 的 OpenAI 接口 <br />
|
||||
整合各种 API 访问方式 <br />
|
||||
一键部署,开箱即用
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<GitHub />}
|
||||
href="https://github.com/songquanpeng/one-api"
|
||||
target="_blank"
|
||||
sx={{ backgroundColor: '#24292e', color: '#fff', width: 'fit-content', boxShadow: '0 3px 5px 2px rgba(255, 105, 135, .3)' }}
|
||||
>
|
||||
GitHub
|
||||
</Button>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
|
||||
export default BaseIndex;
|
||||
72
web/berry/src/views/Home/index.js
Normal file
72
web/berry/src/views/Home/index.js
Normal file
@@ -0,0 +1,72 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { showError, showNotice } from 'utils/common';
|
||||
import { API } from 'utils/api';
|
||||
import { marked } from 'marked';
|
||||
import BaseIndex from './baseIndex';
|
||||
import { Box, Container } from '@mui/material';
|
||||
|
||||
const Home = () => {
|
||||
const [homePageContentLoaded, setHomePageContentLoaded] = useState(false);
|
||||
const [homePageContent, setHomePageContent] = useState('');
|
||||
const displayNotice = async () => {
|
||||
const res = await API.get('/api/notice');
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
let oldNotice = localStorage.getItem('notice');
|
||||
if (data !== oldNotice && data !== '') {
|
||||
const htmlNotice = marked(data);
|
||||
showNotice(htmlNotice, true);
|
||||
localStorage.setItem('notice', data);
|
||||
}
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
};
|
||||
|
||||
const displayHomePageContent = async () => {
|
||||
setHomePageContent(localStorage.getItem('home_page_content') || '');
|
||||
const res = await API.get('/api/home_page_content');
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
let content = data;
|
||||
if (!data.startsWith('https://')) {
|
||||
content = marked.parse(data);
|
||||
}
|
||||
setHomePageContent(content);
|
||||
localStorage.setItem('home_page_content', content);
|
||||
} else {
|
||||
showError(message);
|
||||
setHomePageContent('加载首页内容失败...');
|
||||
}
|
||||
setHomePageContentLoaded(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
displayNotice().then();
|
||||
displayHomePageContent().then();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{homePageContentLoaded && homePageContent === '' ? (
|
||||
<BaseIndex />
|
||||
) : (
|
||||
<>
|
||||
<Box>
|
||||
{homePageContent.startsWith('https://') ? (
|
||||
<iframe title="home_page_content" src={homePageContent} style={{ width: '100%', height: '100vh', border: 'none' }} />
|
||||
) : (
|
||||
<>
|
||||
<Container>
|
||||
<div style={{ fontSize: 'larger' }} dangerouslySetInnerHTML={{ __html: homePageContent }}></div>
|
||||
</Container>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Home;
|
||||
27
web/berry/src/views/Log/component/TableHead.js
Normal file
27
web/berry/src/views/Log/component/TableHead.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { TableCell, TableHead, TableRow } from '@mui/material';
|
||||
|
||||
const LogTableHead = ({ userIsAdmin }) => {
|
||||
return (
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>时间</TableCell>
|
||||
{userIsAdmin && <TableCell>渠道</TableCell>}
|
||||
{userIsAdmin && <TableCell>用户</TableCell>}
|
||||
<TableCell>令牌</TableCell>
|
||||
<TableCell>类型</TableCell>
|
||||
<TableCell>模型</TableCell>
|
||||
<TableCell>提示</TableCell>
|
||||
<TableCell>补全</TableCell>
|
||||
<TableCell>额度</TableCell>
|
||||
<TableCell>详情</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
);
|
||||
};
|
||||
|
||||
export default LogTableHead;
|
||||
|
||||
LogTableHead.propTypes = {
|
||||
userIsAdmin: PropTypes.bool
|
||||
};
|
||||
69
web/berry/src/views/Log/component/TableRow.js
Normal file
69
web/berry/src/views/Log/component/TableRow.js
Normal file
@@ -0,0 +1,69 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { TableRow, TableCell } from '@mui/material';
|
||||
|
||||
import { timestamp2string, renderQuota } from 'utils/common';
|
||||
import Label from 'ui-component/Label';
|
||||
import LogType from '../type/LogType';
|
||||
|
||||
function renderType(type) {
|
||||
const typeOption = LogType[type];
|
||||
if (typeOption) {
|
||||
return (
|
||||
<Label variant="filled" color={typeOption.color}>
|
||||
{' '}
|
||||
{typeOption.text}{' '}
|
||||
</Label>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Label variant="filled" color="error">
|
||||
{' '}
|
||||
未知{' '}
|
||||
</Label>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default function LogTableRow({ item, userIsAdmin }) {
|
||||
return (
|
||||
<>
|
||||
<TableRow tabIndex={item.id}>
|
||||
<TableCell>{timestamp2string(item.created_at)}</TableCell>
|
||||
|
||||
{userIsAdmin && <TableCell>{item.channel || ''}</TableCell>}
|
||||
{userIsAdmin && (
|
||||
<TableCell>
|
||||
<Label color="default" variant="outlined">
|
||||
{item.username}
|
||||
</Label>
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell>
|
||||
{item.token_name && (
|
||||
<Label color="default" variant="soft">
|
||||
{item.token_name}
|
||||
</Label>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{renderType(item.type)}</TableCell>
|
||||
<TableCell>
|
||||
{item.model_name && (
|
||||
<Label color="primary" variant="outlined">
|
||||
{item.model_name}
|
||||
</Label>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{item.prompt_tokens || ''}</TableCell>
|
||||
<TableCell>{item.completion_tokens || ''}</TableCell>
|
||||
<TableCell>{item.quota ? renderQuota(item.quota, 6) : ''}</TableCell>
|
||||
<TableCell>{item.content}</TableCell>
|
||||
</TableRow>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
LogTableRow.propTypes = {
|
||||
item: PropTypes.object,
|
||||
userIsAdmin: PropTypes.bool
|
||||
};
|
||||
239
web/berry/src/views/Log/component/TableToolBar.js
Normal file
239
web/berry/src/views/Log/component/TableToolBar.js
Normal file
@@ -0,0 +1,239 @@
|
||||
import PropTypes from "prop-types";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import {
|
||||
IconUser,
|
||||
IconKey,
|
||||
IconBrandGithubCopilot,
|
||||
IconSitemap,
|
||||
} from "@tabler/icons-react";
|
||||
import {
|
||||
InputAdornment,
|
||||
OutlinedInput,
|
||||
Stack,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
} from "@mui/material";
|
||||
import { LocalizationProvider, DateTimePicker } from "@mui/x-date-pickers";
|
||||
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
||||
import dayjs from "dayjs";
|
||||
import LogType from "../type/LogType";
|
||||
require("dayjs/locale/zh-cn");
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export default function TableToolBar({
|
||||
filterName,
|
||||
handleFilterName,
|
||||
userIsAdmin,
|
||||
}) {
|
||||
const theme = useTheme();
|
||||
const grey500 = theme.palette.grey[500];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack
|
||||
direction={{ xs: "column", sm: "row" }}
|
||||
spacing={{ xs: 3, sm: 2, md: 4 }}
|
||||
padding={"24px"}
|
||||
paddingBottom={"0px"}
|
||||
>
|
||||
<FormControl>
|
||||
<InputLabel htmlFor="channel-token_name-label">令牌名称</InputLabel>
|
||||
<OutlinedInput
|
||||
id="token_name"
|
||||
name="token_name"
|
||||
sx={{
|
||||
minWidth: "100%",
|
||||
}}
|
||||
label="令牌名称"
|
||||
value={filterName.token_name}
|
||||
onChange={handleFilterName}
|
||||
placeholder="令牌名称"
|
||||
startAdornment={
|
||||
<InputAdornment position="start">
|
||||
<IconKey stroke={1.5} size="20px" color={grey500} />
|
||||
</InputAdornment>
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<InputLabel htmlFor="channel-model_name-label">模型名称</InputLabel>
|
||||
<OutlinedInput
|
||||
id="model_name"
|
||||
name="model_name"
|
||||
sx={{
|
||||
minWidth: "100%",
|
||||
}}
|
||||
label="模型名称"
|
||||
value={filterName.model_name}
|
||||
onChange={handleFilterName}
|
||||
placeholder="模型名称"
|
||||
startAdornment={
|
||||
<InputAdornment position="start">
|
||||
<IconBrandGithubCopilot
|
||||
stroke={1.5}
|
||||
size="20px"
|
||||
color={grey500}
|
||||
/>
|
||||
</InputAdornment>
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<LocalizationProvider
|
||||
dateAdapter={AdapterDayjs}
|
||||
adapterLocale={"zh-cn"}
|
||||
>
|
||||
<DateTimePicker
|
||||
label="起始时间"
|
||||
ampm={false}
|
||||
name="start_timestamp"
|
||||
value={
|
||||
filterName.start_timestamp === 0
|
||||
? null
|
||||
: dayjs.unix(filterName.start_timestamp)
|
||||
}
|
||||
onChange={(value) => {
|
||||
if (value === null) {
|
||||
handleFilterName({
|
||||
target: { name: "start_timestamp", value: 0 },
|
||||
});
|
||||
return;
|
||||
}
|
||||
handleFilterName({
|
||||
target: { name: "start_timestamp", value: value.unix() },
|
||||
});
|
||||
}}
|
||||
slotProps={{
|
||||
actionBar: {
|
||||
actions: ["clear", "today", "accept"],
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</LocalizationProvider>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<LocalizationProvider
|
||||
dateAdapter={AdapterDayjs}
|
||||
adapterLocale={"zh-cn"}
|
||||
>
|
||||
<DateTimePicker
|
||||
label="结束时间"
|
||||
name="end_timestamp"
|
||||
ampm={false}
|
||||
value={
|
||||
filterName.end_timestamp === 0
|
||||
? null
|
||||
: dayjs.unix(filterName.end_timestamp)
|
||||
}
|
||||
onChange={(value) => {
|
||||
if (value === null) {
|
||||
handleFilterName({
|
||||
target: { name: "end_timestamp", value: 0 },
|
||||
});
|
||||
return;
|
||||
}
|
||||
handleFilterName({
|
||||
target: { name: "end_timestamp", value: value.unix() },
|
||||
});
|
||||
}}
|
||||
slotProps={{
|
||||
actionBar: {
|
||||
actions: ["clear", "today", "accept"],
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</LocalizationProvider>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
|
||||
<Stack
|
||||
direction={{ xs: "column", sm: "row" }}
|
||||
spacing={{ xs: 3, sm: 2, md: 4 }}
|
||||
padding={"24px"}
|
||||
>
|
||||
{userIsAdmin && (
|
||||
<FormControl>
|
||||
<InputLabel htmlFor="channel-channel-label">渠道ID</InputLabel>
|
||||
<OutlinedInput
|
||||
id="channel"
|
||||
name="channel"
|
||||
sx={{
|
||||
minWidth: "100%",
|
||||
}}
|
||||
label="渠道ID"
|
||||
value={filterName.channel}
|
||||
onChange={handleFilterName}
|
||||
placeholder="渠道ID"
|
||||
startAdornment={
|
||||
<InputAdornment position="start">
|
||||
<IconSitemap stroke={1.5} size="20px" color={grey500} />
|
||||
</InputAdornment>
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
|
||||
{userIsAdmin && (
|
||||
<FormControl>
|
||||
<InputLabel htmlFor="channel-username-label">用户名称</InputLabel>
|
||||
<OutlinedInput
|
||||
id="username"
|
||||
name="username"
|
||||
sx={{
|
||||
minWidth: "100%",
|
||||
}}
|
||||
label="用户名称"
|
||||
value={filterName.username}
|
||||
onChange={handleFilterName}
|
||||
placeholder="用户名称"
|
||||
startAdornment={
|
||||
<InputAdornment position="start">
|
||||
<IconUser stroke={1.5} size="20px" color={grey500} />
|
||||
</InputAdornment>
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
|
||||
<FormControl sx={{ minWidth: "22%" }}>
|
||||
<InputLabel htmlFor="channel-type-label">类型</InputLabel>
|
||||
<Select
|
||||
id="channel-type-label"
|
||||
label="类型"
|
||||
value={filterName.type}
|
||||
name="type"
|
||||
onChange={handleFilterName}
|
||||
sx={{
|
||||
minWidth: "100%",
|
||||
}}
|
||||
MenuProps={{
|
||||
PaperProps: {
|
||||
style: {
|
||||
maxHeight: 200,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{Object.values(LogType).map((option) => {
|
||||
return (
|
||||
<MenuItem key={option.value} value={option.value}>
|
||||
{option.text}
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
TableToolBar.propTypes = {
|
||||
filterName: PropTypes.object,
|
||||
handleFilterName: PropTypes.func,
|
||||
userIsAdmin: PropTypes.bool,
|
||||
};
|
||||
157
web/berry/src/views/Log/index.js
Normal file
157
web/berry/src/views/Log/index.js
Normal file
@@ -0,0 +1,157 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { showError } from 'utils/common';
|
||||
|
||||
import Table from '@mui/material/Table';
|
||||
import TableBody from '@mui/material/TableBody';
|
||||
import TableContainer from '@mui/material/TableContainer';
|
||||
import PerfectScrollbar from 'react-perfect-scrollbar';
|
||||
import TablePagination from '@mui/material/TablePagination';
|
||||
import LinearProgress from '@mui/material/LinearProgress';
|
||||
import ButtonGroup from '@mui/material/ButtonGroup';
|
||||
import Toolbar from '@mui/material/Toolbar';
|
||||
|
||||
import { Button, Card, Stack, Container, Typography, Box } from '@mui/material';
|
||||
import LogTableRow from './component/TableRow';
|
||||
import LogTableHead from './component/TableHead';
|
||||
import TableToolBar from './component/TableToolBar';
|
||||
import { API } from 'utils/api';
|
||||
import { isAdmin } from 'utils/common';
|
||||
import { ITEMS_PER_PAGE } from 'constants';
|
||||
import { IconRefresh, IconSearch } from '@tabler/icons-react';
|
||||
|
||||
export default function Log() {
|
||||
const originalKeyword = {
|
||||
p: 0,
|
||||
username: '',
|
||||
token_name: '',
|
||||
model_name: '',
|
||||
start_timestamp: 0,
|
||||
end_timestamp: new Date().getTime() / 1000 + 3600,
|
||||
type: 0,
|
||||
channel: ''
|
||||
};
|
||||
const [logs, setLogs] = useState([]);
|
||||
const [activePage, setActivePage] = useState(0);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [searchKeyword, setSearchKeyword] = useState(originalKeyword);
|
||||
const [initPage, setInitPage] = useState(true);
|
||||
const userIsAdmin = isAdmin();
|
||||
|
||||
const loadLogs = async (startIdx) => {
|
||||
setSearching(true);
|
||||
const url = userIsAdmin ? '/api/log/' : '/api/log/self/';
|
||||
const query = searchKeyword;
|
||||
|
||||
query.p = startIdx;
|
||||
if (!userIsAdmin) {
|
||||
delete query.username;
|
||||
delete query.channel;
|
||||
}
|
||||
const res = await API.get(url, { params: query });
|
||||
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);
|
||||
}
|
||||
setSearching(false);
|
||||
};
|
||||
|
||||
const onPaginationChange = (event, activePage) => {
|
||||
(async () => {
|
||||
if (activePage === Math.ceil(logs.length / ITEMS_PER_PAGE)) {
|
||||
// In this case we have to load more data and then append them.
|
||||
await loadLogs(activePage);
|
||||
}
|
||||
setActivePage(activePage);
|
||||
})();
|
||||
};
|
||||
|
||||
const searchLogs = async (event) => {
|
||||
event.preventDefault();
|
||||
await loadLogs(0);
|
||||
setActivePage(0);
|
||||
return;
|
||||
};
|
||||
|
||||
const handleSearchKeyword = (event) => {
|
||||
setSearchKeyword({ ...searchKeyword, [event.target.name]: event.target.value });
|
||||
};
|
||||
|
||||
// 处理刷新
|
||||
const handleRefresh = () => {
|
||||
setInitPage(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setSearchKeyword(originalKeyword);
|
||||
setActivePage(0);
|
||||
loadLogs(0)
|
||||
.then()
|
||||
.catch((reason) => {
|
||||
showError(reason);
|
||||
});
|
||||
setInitPage(false);
|
||||
}, [initPage]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" mb={5}>
|
||||
<Typography variant="h4">日志</Typography>
|
||||
</Stack>
|
||||
<Card>
|
||||
<Box component="form" onSubmit={searchLogs} noValidate>
|
||||
<TableToolBar filterName={searchKeyword} handleFilterName={handleSearchKeyword} userIsAdmin={userIsAdmin} />
|
||||
</Box>
|
||||
<Toolbar
|
||||
sx={{
|
||||
textAlign: 'right',
|
||||
height: 50,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
p: (theme) => theme.spacing(0, 1, 0, 3)
|
||||
}}
|
||||
>
|
||||
<Container>
|
||||
<ButtonGroup variant="outlined" aria-label="outlined small primary button group">
|
||||
<Button onClick={handleRefresh} startIcon={<IconRefresh width={'18px'} />}>
|
||||
刷新/清除搜索条件
|
||||
</Button>
|
||||
|
||||
<Button onClick={searchLogs} startIcon={<IconSearch width={'18px'} />}>
|
||||
搜索
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</Container>
|
||||
</Toolbar>
|
||||
{searching && <LinearProgress />}
|
||||
<PerfectScrollbar component="div">
|
||||
<TableContainer sx={{ overflow: 'unset' }}>
|
||||
<Table sx={{ minWidth: 800 }}>
|
||||
<LogTableHead userIsAdmin={userIsAdmin} />
|
||||
<TableBody>
|
||||
{logs.slice(activePage * ITEMS_PER_PAGE, (activePage + 1) * ITEMS_PER_PAGE).map((row, index) => (
|
||||
<LogTableRow item={row} key={`${row.id}_${index}`} userIsAdmin={userIsAdmin} />
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</PerfectScrollbar>
|
||||
<TablePagination
|
||||
page={activePage}
|
||||
component="div"
|
||||
count={logs.length + (logs.length % ITEMS_PER_PAGE === 0 ? 1 : 0)}
|
||||
rowsPerPage={ITEMS_PER_PAGE}
|
||||
onPageChange={onPaginationChange}
|
||||
rowsPerPageOptions={[ITEMS_PER_PAGE]}
|
||||
/>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
9
web/berry/src/views/Log/type/LogType.js
Normal file
9
web/berry/src/views/Log/type/LogType.js
Normal file
@@ -0,0 +1,9 @@
|
||||
const LOG_TYPE = {
|
||||
0: { value: '0', text: '全部', color: '' },
|
||||
1: { value: '1', text: '充值', color: 'primary' },
|
||||
2: { value: '2', text: '消费', color: 'orange' },
|
||||
3: { value: '3', text: '管理', color: 'default' },
|
||||
4: { value: '4', text: '系统', color: 'secondary' }
|
||||
};
|
||||
|
||||
export default LOG_TYPE;
|
||||
201
web/berry/src/views/Profile/component/EmailModal.js
Normal file
201
web/berry/src/views/Profile/component/EmailModal.js
Normal file
@@ -0,0 +1,201 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import React from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
OutlinedInput,
|
||||
Button,
|
||||
InputLabel,
|
||||
Grid,
|
||||
InputAdornment,
|
||||
FormControl,
|
||||
FormHelperText,
|
||||
} from "@mui/material";
|
||||
import { Formik } from "formik";
|
||||
import { showError, showSuccess } from "utils/common";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import * as Yup from "yup";
|
||||
import useRegister from "hooks/useRegister";
|
||||
import { API } from "utils/api";
|
||||
|
||||
const validationSchema = Yup.object().shape({
|
||||
email: Yup.string().email("请输入正确的邮箱地址").required("邮箱不能为空"),
|
||||
email_verification_code: Yup.string().required("验证码不能为空"),
|
||||
});
|
||||
|
||||
const EmailModal = ({ open, handleClose, turnstileToken }) => {
|
||||
const theme = useTheme();
|
||||
const [countdown, setCountdown] = useState(30);
|
||||
const [disableButton, setDisableButton] = useState(false);
|
||||
const { sendVerificationCode } = useRegister();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const submit = async (values, { setErrors, setStatus, setSubmitting }) => {
|
||||
setLoading(true);
|
||||
setSubmitting(true);
|
||||
const res = await API.get(
|
||||
`/api/oauth/email/bind?email=${values.email}&code=${values.email_verification_code}`
|
||||
);
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess("邮箱账户绑定成功!");
|
||||
setSubmitting(false);
|
||||
setStatus({ success: true });
|
||||
handleClose();
|
||||
} else {
|
||||
showError(message);
|
||||
setErrors({ submit: message });
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
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 handleSendCode = async (email) => {
|
||||
setDisableButton(true);
|
||||
if (email === "") {
|
||||
showError("请输入邮箱");
|
||||
return;
|
||||
}
|
||||
if (turnstileToken === "") {
|
||||
showError("请稍后几秒重试,Turnstile 正在检查用户环境!");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
const { success, message } = await sendVerificationCode(
|
||||
email,
|
||||
turnstileToken
|
||||
);
|
||||
setLoading(false);
|
||||
if (!success) {
|
||||
showError(message);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose}>
|
||||
<DialogTitle>绑定邮箱</DialogTitle>
|
||||
<DialogContent>
|
||||
<Grid container direction="column" alignItems="center">
|
||||
<Formik
|
||||
initialValues={{
|
||||
email: "",
|
||||
email_verification_code: "",
|
||||
}}
|
||||
enableReinitialize
|
||||
validationSchema={validationSchema}
|
||||
onSubmit={submit}
|
||||
>
|
||||
{({
|
||||
errors,
|
||||
touched,
|
||||
handleBlur,
|
||||
handleChange,
|
||||
handleSubmit,
|
||||
values,
|
||||
}) => (
|
||||
<form noValidate onSubmit={handleSubmit}>
|
||||
<FormControl
|
||||
fullWidth
|
||||
error={Boolean(touched.email && errors.email)}
|
||||
sx={{ ...theme.typography.customInput }}
|
||||
>
|
||||
<InputLabel htmlFor="email">Email</InputLabel>
|
||||
<OutlinedInput
|
||||
id="email"
|
||||
type="text"
|
||||
value={values.email}
|
||||
name="email"
|
||||
onBlur={handleBlur}
|
||||
onChange={handleChange}
|
||||
endAdornment={
|
||||
<InputAdornment position="end">
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() => handleSendCode(values.email)}
|
||||
disabled={disableButton || loading}
|
||||
>
|
||||
{disableButton
|
||||
? `重新发送(${countdown})`
|
||||
: "获取验证码"}
|
||||
</Button>
|
||||
</InputAdornment>
|
||||
}
|
||||
inputProps={{}}
|
||||
/>
|
||||
{touched.email && errors.email && (
|
||||
<FormHelperText error id="helper-email">
|
||||
{errors.email}
|
||||
</FormHelperText>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl
|
||||
fullWidth
|
||||
error={Boolean(
|
||||
touched.email_verification_code &&
|
||||
errors.email_verification_code
|
||||
)}
|
||||
sx={{ ...theme.typography.customInput }}
|
||||
>
|
||||
<InputLabel htmlFor="email_verification_code">
|
||||
验证码
|
||||
</InputLabel>
|
||||
<OutlinedInput
|
||||
id="email_verification_code"
|
||||
type="text"
|
||||
value={values.email_verification_code}
|
||||
name="email_verification_code"
|
||||
onBlur={handleBlur}
|
||||
onChange={handleChange}
|
||||
inputProps={{}}
|
||||
/>
|
||||
{touched.email_verification_code &&
|
||||
errors.email_verification_code && (
|
||||
<FormHelperText error id="helper-email_verification_code">
|
||||
{errors.email_verification_code}
|
||||
</FormHelperText>
|
||||
)}
|
||||
</FormControl>
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose}>取消</Button>
|
||||
<Button
|
||||
disableElevation
|
||||
disabled={loading}
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
>
|
||||
提交
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</form>
|
||||
)}
|
||||
</Formik>
|
||||
</Grid>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailModal;
|
||||
|
||||
EmailModal.propTypes = {
|
||||
open: PropTypes.bool,
|
||||
handleClose: PropTypes.func,
|
||||
};
|
||||
293
web/berry/src/views/Profile/index.js
Normal file
293
web/berry/src/views/Profile/index.js
Normal file
@@ -0,0 +1,293 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import UserCard from 'ui-component/cards/UserCard';
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
InputLabel,
|
||||
FormControl,
|
||||
OutlinedInput,
|
||||
Stack,
|
||||
Alert,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Divider
|
||||
} from '@mui/material';
|
||||
import Grid from '@mui/material/Unstable_Grid2';
|
||||
import SubCard from 'ui-component/cards/SubCard';
|
||||
import { IconBrandWechat, IconBrandGithub, IconMail } from '@tabler/icons-react';
|
||||
import Label from 'ui-component/Label';
|
||||
import { API } from 'utils/api';
|
||||
import { showError, showSuccess } from 'utils/common';
|
||||
import { onGitHubOAuthClicked } from 'utils/common';
|
||||
import * as Yup from 'yup';
|
||||
import WechatModal from 'views/Authentication/AuthForms/WechatModal';
|
||||
import { useSelector } from 'react-redux';
|
||||
import EmailModal from './component/EmailModal';
|
||||
import Turnstile from 'react-turnstile';
|
||||
|
||||
const validationSchema = Yup.object().shape({
|
||||
username: Yup.string().required('用户名 不能为空').min(3, '用户名 不能小于 3 个字符'),
|
||||
display_name: Yup.string(),
|
||||
password: Yup.string().test('password', '密码不能小于 8 个字符', (val) => {
|
||||
return !val || val.length >= 8;
|
||||
})
|
||||
});
|
||||
|
||||
export default function Profile() {
|
||||
const [inputs, setInputs] = useState([]);
|
||||
const [showAccountDeleteModal, setShowAccountDeleteModal] = useState(false);
|
||||
const [turnstileEnabled, setTurnstileEnabled] = useState(false);
|
||||
const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
|
||||
const [turnstileToken, setTurnstileToken] = useState('');
|
||||
const [openWechat, setOpenWechat] = useState(false);
|
||||
const [openEmail, setOpenEmail] = useState(false);
|
||||
const status = useSelector((state) => state.siteInfo);
|
||||
|
||||
const handleWechatOpen = () => {
|
||||
setOpenWechat(true);
|
||||
};
|
||||
|
||||
const handleWechatClose = () => {
|
||||
setOpenWechat(false);
|
||||
};
|
||||
|
||||
const handleInputChange = (event) => {
|
||||
let { name, value } = event.target;
|
||||
setInputs((inputs) => ({ ...inputs, [name]: value }));
|
||||
};
|
||||
|
||||
const loadUser = async () => {
|
||||
let res = await API.get(`/api/user/self`);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
setInputs(data);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
};
|
||||
|
||||
const bindWeChat = async (code) => {
|
||||
if (code === '') return;
|
||||
try {
|
||||
const res = await API.get(`/api/oauth/wechat/bind?code=${code}`);
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess('微信账户绑定成功!');
|
||||
}
|
||||
return { success, message };
|
||||
} catch (err) {
|
||||
// 请求失败,设置错误信息
|
||||
return { success: false, message: '' };
|
||||
}
|
||||
};
|
||||
|
||||
const generateAccessToken = async () => {
|
||||
const res = await API.get('/api/user/token');
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
setInputs((inputs) => ({ ...inputs, access_token: data }));
|
||||
navigator.clipboard.writeText(data);
|
||||
showSuccess(`令牌已重置并已复制到剪贴板`);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
|
||||
console.log(turnstileEnabled, turnstileSiteKey, status);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
await validationSchema.validate(inputs);
|
||||
const res = await API.put(`/api/user/self`, inputs);
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess('用户信息更新成功!');
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (status) {
|
||||
if (status.turnstile_check) {
|
||||
setTurnstileEnabled(true);
|
||||
setTurnstileSiteKey(status.turnstile_site_key);
|
||||
}
|
||||
}
|
||||
loadUser().then();
|
||||
}, [status]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<UserCard>
|
||||
<Card sx={{ paddingTop: '20px' }}>
|
||||
<Stack spacing={2}>
|
||||
<Stack direction="row" alignItems="center" justifyContent="center" spacing={2} sx={{ paddingBottom: '20px' }}>
|
||||
<Label variant="ghost" color={inputs.wechat_id ? 'primary' : 'default'}>
|
||||
<IconBrandWechat /> {inputs.wechat_id || '未绑定'}
|
||||
</Label>
|
||||
<Label variant="ghost" color={inputs.github_id ? 'primary' : 'default'}>
|
||||
<IconBrandGithub /> {inputs.github_id || '未绑定'}
|
||||
</Label>
|
||||
<Label variant="ghost" color={inputs.email ? 'primary' : 'default'}>
|
||||
<IconMail /> {inputs.email || '未绑定'}
|
||||
</Label>
|
||||
</Stack>
|
||||
<SubCard title="个人信息">
|
||||
<Grid container spacing={2}>
|
||||
<Grid xs={12}>
|
||||
<FormControl fullWidth variant="outlined">
|
||||
<InputLabel htmlFor="username">用户名</InputLabel>
|
||||
<OutlinedInput
|
||||
id="username"
|
||||
label="用户名"
|
||||
type="text"
|
||||
value={inputs.username || ''}
|
||||
onChange={handleInputChange}
|
||||
name="username"
|
||||
placeholder="请输入用户名"
|
||||
/>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid xs={12}>
|
||||
<FormControl fullWidth variant="outlined">
|
||||
<InputLabel htmlFor="password">密码</InputLabel>
|
||||
<OutlinedInput
|
||||
id="password"
|
||||
label="密码"
|
||||
type="password"
|
||||
value={inputs.password || ''}
|
||||
onChange={handleInputChange}
|
||||
name="password"
|
||||
placeholder="请输入密码"
|
||||
/>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid xs={12}>
|
||||
<FormControl fullWidth variant="outlined">
|
||||
<InputLabel htmlFor="display_name">显示名称</InputLabel>
|
||||
<OutlinedInput
|
||||
id="display_name"
|
||||
label="显示名称"
|
||||
type="text"
|
||||
value={inputs.display_name || ''}
|
||||
onChange={handleInputChange}
|
||||
name="display_name"
|
||||
placeholder="请输入显示名称"
|
||||
/>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid xs={12}>
|
||||
<Button variant="contained" color="primary" onClick={submit}>
|
||||
提交
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</SubCard>
|
||||
<SubCard title="账号绑定">
|
||||
<Grid container spacing={2}>
|
||||
{status.wechat_login && !inputs.wechat_id && (
|
||||
<Grid xs={12} md={4}>
|
||||
<Button variant="contained" onClick={handleWechatOpen}>
|
||||
绑定微信账号
|
||||
</Button>
|
||||
</Grid>
|
||||
)}
|
||||
{status.github_oauth && !inputs.github_id && (
|
||||
<Grid xs={12} md={4}>
|
||||
<Button variant="contained" onClick={() => onGitHubOAuthClicked(status.github_client_id, true)}>
|
||||
绑定 GitHub 账号
|
||||
</Button>
|
||||
</Grid>
|
||||
)}
|
||||
<Grid xs={12} md={4}>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => {
|
||||
setOpenEmail(true);
|
||||
}}
|
||||
>
|
||||
{inputs.email ? '更换邮箱' : '绑定邮箱'}
|
||||
</Button>
|
||||
{turnstileEnabled ? (
|
||||
<Turnstile
|
||||
sitekey={turnstileSiteKey}
|
||||
onVerify={(token) => {
|
||||
setTurnstileToken(token);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</SubCard>
|
||||
<SubCard title="其他">
|
||||
<Grid container spacing={2}>
|
||||
<Grid xs={12}>
|
||||
<Alert severity="info">注意,此处生成的令牌用于系统管理,而非用于请求 OpenAI 相关的服务,请知悉。</Alert>
|
||||
</Grid>
|
||||
{inputs.access_token && (
|
||||
<Grid xs={12}>
|
||||
<Alert severity="error">
|
||||
你的访问令牌是: <b>{inputs.access_token}</b> <br />
|
||||
请妥善保管。如有泄漏,请立即重置。
|
||||
</Alert>
|
||||
</Grid>
|
||||
)}
|
||||
<Grid xs={12}>
|
||||
<Button variant="contained" onClick={generateAccessToken}>
|
||||
{inputs.access_token ? '重置访问令牌' : '生成访问令牌'}
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<Grid xs={12}>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
onClick={() => {
|
||||
setShowAccountDeleteModal(true);
|
||||
}}
|
||||
>
|
||||
删除帐号
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</SubCard>
|
||||
</Stack>
|
||||
</Card>
|
||||
</UserCard>
|
||||
<Dialog open={showAccountDeleteModal} onClose={() => setShowAccountDeleteModal(false)} maxWidth={'md'}>
|
||||
<DialogTitle sx={{ margin: '0px', fontWeight: 500, lineHeight: '1.55556', padding: '24px', fontSize: '1.125rem' }}>
|
||||
危险操作
|
||||
</DialogTitle>
|
||||
<Divider />
|
||||
<DialogContent>您正在删除自己的帐户,将清空所有数据且不可恢复</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setShowAccountDeleteModal(false)}>取消</Button>
|
||||
<Button
|
||||
sx={{ color: 'error.main' }}
|
||||
onClick={async () => {
|
||||
setShowAccountDeleteModal(false);
|
||||
}}
|
||||
>
|
||||
确定
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
<WechatModal open={openWechat} handleClose={handleWechatClose} wechatLogin={bindWeChat} qrCode={status.wechat_qrcode} />
|
||||
<EmailModal
|
||||
open={openEmail}
|
||||
turnstileToken={turnstileToken}
|
||||
handleClose={() => {
|
||||
setOpenEmail(false);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
190
web/berry/src/views/Redemption/component/EditModal.js
Normal file
190
web/berry/src/views/Redemption/component/EditModal.js
Normal file
@@ -0,0 +1,190 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import * as Yup from 'yup';
|
||||
import { Formik } from 'formik';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Divider,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
OutlinedInput,
|
||||
InputAdornment,
|
||||
FormHelperText
|
||||
} from '@mui/material';
|
||||
|
||||
import { renderQuotaWithPrompt, showSuccess, showError, downloadTextAsFile } from 'utils/common';
|
||||
import { API } from 'utils/api';
|
||||
|
||||
const validationSchema = Yup.object().shape({
|
||||
is_edit: Yup.boolean(),
|
||||
name: Yup.string().required('名称 不能为空'),
|
||||
quota: Yup.number().min(0, '必须大于等于0'),
|
||||
count: Yup.number().when('is_edit', {
|
||||
is: false,
|
||||
then: Yup.number().min(1, '必须大于等于1'),
|
||||
otherwise: Yup.number()
|
||||
})
|
||||
});
|
||||
|
||||
const originInputs = {
|
||||
is_edit: false,
|
||||
name: '',
|
||||
quota: 100000,
|
||||
count: 1
|
||||
};
|
||||
|
||||
const EditModal = ({ open, redemptiondId, onCancel, onOk }) => {
|
||||
const theme = useTheme();
|
||||
const [inputs, setInputs] = useState(originInputs);
|
||||
|
||||
const submit = async (values, { setErrors, setStatus, setSubmitting }) => {
|
||||
setSubmitting(true);
|
||||
|
||||
let res;
|
||||
if (values.is_edit) {
|
||||
res = await API.put(`/api/redemption/`, { ...values, id: parseInt(redemptiondId) });
|
||||
} else {
|
||||
res = await API.post(`/api/redemption/`, values);
|
||||
}
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
if (values.is_edit) {
|
||||
showSuccess('兑换码更新成功!');
|
||||
} else {
|
||||
showSuccess('兑换码创建成功!');
|
||||
if (data.length > 1) {
|
||||
let text = '';
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
text += data[i] + '\n';
|
||||
}
|
||||
downloadTextAsFile(text, `${values.name}.txt`);
|
||||
}
|
||||
}
|
||||
setSubmitting(false);
|
||||
setStatus({ success: true });
|
||||
onOk(true);
|
||||
} else {
|
||||
showError(message);
|
||||
setErrors({ submit: message });
|
||||
}
|
||||
};
|
||||
|
||||
const loadRedemptiond = async () => {
|
||||
let res = await API.get(`/api/redemption/${redemptiondId}`);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
data.is_edit = true;
|
||||
setInputs(data);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (redemptiondId) {
|
||||
loadRedemptiond().then();
|
||||
} else {
|
||||
setInputs(originInputs);
|
||||
}
|
||||
}, [redemptiondId]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onCancel} fullWidth maxWidth={'md'}>
|
||||
<DialogTitle sx={{ margin: '0px', fontWeight: 700, lineHeight: '1.55556', padding: '24px', fontSize: '1.125rem' }}>
|
||||
{redemptiondId ? '编辑兑换码' : '新建兑换码'}
|
||||
</DialogTitle>
|
||||
<Divider />
|
||||
<DialogContent>
|
||||
<Formik initialValues={inputs} enableReinitialize validationSchema={validationSchema} onSubmit={submit}>
|
||||
{({ errors, handleBlur, handleChange, handleSubmit, touched, values, isSubmitting }) => (
|
||||
<form noValidate onSubmit={handleSubmit}>
|
||||
<FormControl fullWidth error={Boolean(touched.name && errors.name)} sx={{ ...theme.typography.otherInput }}>
|
||||
<InputLabel htmlFor="channel-name-label">名称</InputLabel>
|
||||
<OutlinedInput
|
||||
id="channel-name-label"
|
||||
label="名称"
|
||||
type="text"
|
||||
value={values.name}
|
||||
name="name"
|
||||
onBlur={handleBlur}
|
||||
onChange={handleChange}
|
||||
inputProps={{ autoComplete: 'name' }}
|
||||
aria-describedby="helper-text-channel-name-label"
|
||||
/>
|
||||
{touched.name && errors.name && (
|
||||
<FormHelperText error id="helper-tex-channel-name-label">
|
||||
{errors.name}
|
||||
</FormHelperText>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
<FormControl fullWidth error={Boolean(touched.quota && errors.quota)} sx={{ ...theme.typography.otherInput }}>
|
||||
<InputLabel htmlFor="channel-quota-label">额度</InputLabel>
|
||||
<OutlinedInput
|
||||
id="channel-quota-label"
|
||||
label="额度"
|
||||
type="number"
|
||||
value={values.quota}
|
||||
name="quota"
|
||||
endAdornment={<InputAdornment position="end">{renderQuotaWithPrompt(values.quota)}</InputAdornment>}
|
||||
onBlur={handleBlur}
|
||||
onChange={handleChange}
|
||||
aria-describedby="helper-text-channel-quota-label"
|
||||
disabled={values.unlimited_quota}
|
||||
/>
|
||||
|
||||
{touched.quota && errors.quota && (
|
||||
<FormHelperText error id="helper-tex-channel-quota-label">
|
||||
{errors.quota}
|
||||
</FormHelperText>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
{!values.is_edit && (
|
||||
<FormControl fullWidth error={Boolean(touched.count && errors.count)} sx={{ ...theme.typography.otherInput }}>
|
||||
<InputLabel htmlFor="channel-count-label">数量</InputLabel>
|
||||
<OutlinedInput
|
||||
id="channel-count-label"
|
||||
label="数量"
|
||||
type="number"
|
||||
value={values.count}
|
||||
name="count"
|
||||
onBlur={handleBlur}
|
||||
onChange={handleChange}
|
||||
aria-describedby="helper-text-channel-count-label"
|
||||
/>
|
||||
|
||||
{touched.count && errors.count && (
|
||||
<FormHelperText error id="helper-tex-channel-count-label">
|
||||
{errors.count}
|
||||
</FormHelperText>
|
||||
)}
|
||||
</FormControl>
|
||||
)}
|
||||
<DialogActions>
|
||||
<Button onClick={onCancel}>取消</Button>
|
||||
<Button disableElevation disabled={isSubmitting} type="submit" variant="contained" color="primary">
|
||||
提交
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</form>
|
||||
)}
|
||||
</Formik>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditModal;
|
||||
|
||||
EditModal.propTypes = {
|
||||
open: PropTypes.bool,
|
||||
redemptiondId: PropTypes.number,
|
||||
onCancel: PropTypes.func,
|
||||
onOk: PropTypes.func
|
||||
};
|
||||
19
web/berry/src/views/Redemption/component/TableHead.js
Normal file
19
web/berry/src/views/Redemption/component/TableHead.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import { TableCell, TableHead, TableRow } from '@mui/material';
|
||||
|
||||
const RedemptionTableHead = () => {
|
||||
return (
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>ID</TableCell>
|
||||
<TableCell>名称</TableCell>
|
||||
<TableCell>状态</TableCell>
|
||||
<TableCell>额度</TableCell>
|
||||
<TableCell>创建时间</TableCell>
|
||||
<TableCell>兑换时间</TableCell>
|
||||
<TableCell>操作</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
);
|
||||
};
|
||||
|
||||
export default RedemptionTableHead;
|
||||
147
web/berry/src/views/Redemption/component/TableRow.js
Normal file
147
web/berry/src/views/Redemption/component/TableRow.js
Normal file
@@ -0,0 +1,147 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { useState } from 'react';
|
||||
|
||||
import {
|
||||
Popover,
|
||||
TableRow,
|
||||
MenuItem,
|
||||
TableCell,
|
||||
IconButton,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
Button,
|
||||
Stack
|
||||
} from '@mui/material';
|
||||
|
||||
import Label from 'ui-component/Label';
|
||||
import TableSwitch from 'ui-component/Switch';
|
||||
import { timestamp2string, renderQuota, showSuccess } from 'utils/common';
|
||||
|
||||
import { IconDotsVertical, IconEdit, IconTrash } from '@tabler/icons-react';
|
||||
|
||||
export default function RedemptionTableRow({ item, manageRedemption, handleOpenModal, setModalRedemptionId }) {
|
||||
const [open, setOpen] = useState(null);
|
||||
const [openDelete, setOpenDelete] = useState(false);
|
||||
const [statusSwitch, setStatusSwitch] = useState(item.status);
|
||||
|
||||
const handleDeleteOpen = () => {
|
||||
handleCloseMenu();
|
||||
setOpenDelete(true);
|
||||
};
|
||||
|
||||
const handleDeleteClose = () => {
|
||||
setOpenDelete(false);
|
||||
};
|
||||
|
||||
const handleOpenMenu = (event) => {
|
||||
setOpen(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleCloseMenu = () => {
|
||||
setOpen(null);
|
||||
};
|
||||
|
||||
const handleStatus = async () => {
|
||||
const switchVlue = statusSwitch === 1 ? 2 : 1;
|
||||
const { success } = await manageRedemption(item.id, 'status', switchVlue);
|
||||
if (success) {
|
||||
setStatusSwitch(switchVlue);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
handleCloseMenu();
|
||||
await manageRedemption(item.id, 'delete', '');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableRow tabIndex={item.id}>
|
||||
<TableCell>{item.id}</TableCell>
|
||||
|
||||
<TableCell>{item.name}</TableCell>
|
||||
|
||||
<TableCell>
|
||||
{item.status !== 1 && item.status !== 2 ? (
|
||||
<Label variant="filled" color={item.status === 3 ? 'success' : 'orange'}>
|
||||
{item.status === 3 ? '已使用' : '未知'}
|
||||
</Label>
|
||||
) : (
|
||||
<TableSwitch id={`switch-${item.id}`} checked={statusSwitch === 1} onChange={handleStatus} />
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell>{renderQuota(item.quota)}</TableCell>
|
||||
<TableCell>{timestamp2string(item.created_time)}</TableCell>
|
||||
<TableCell>{item.redeemed_time ? timestamp2string(item.redeemed_time) : '尚未兑换'}</TableCell>
|
||||
<TableCell>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(item.key);
|
||||
showSuccess('已复制到剪贴板!');
|
||||
}}
|
||||
>
|
||||
复制
|
||||
</Button>
|
||||
<IconButton onClick={handleOpenMenu} sx={{ color: 'rgb(99, 115, 129)' }}>
|
||||
<IconDotsVertical />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
<Popover
|
||||
open={!!open}
|
||||
anchorEl={open}
|
||||
onClose={handleCloseMenu}
|
||||
anchorOrigin={{ vertical: 'top', horizontal: 'left' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
PaperProps={{
|
||||
sx: { width: 140 }
|
||||
}}
|
||||
>
|
||||
<MenuItem
|
||||
disabled={item.status !== 1 && item.status !== 2}
|
||||
onClick={() => {
|
||||
handleCloseMenu();
|
||||
handleOpenModal();
|
||||
setModalRedemptionId(item.id);
|
||||
}}
|
||||
>
|
||||
<IconEdit style={{ marginRight: '16px' }} />
|
||||
编辑
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleDeleteOpen} sx={{ color: 'error.main' }}>
|
||||
<IconTrash style={{ marginRight: '16px' }} />
|
||||
删除
|
||||
</MenuItem>
|
||||
</Popover>
|
||||
|
||||
<Dialog open={openDelete} onClose={handleDeleteClose}>
|
||||
<DialogTitle>删除兑换码</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>是否删除兑换码 {item.name}?</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleDeleteClose}>关闭</Button>
|
||||
<Button onClick={handleDelete} sx={{ color: 'error.main' }} autoFocus>
|
||||
删除
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
RedemptionTableRow.propTypes = {
|
||||
item: PropTypes.object,
|
||||
manageRedemption: PropTypes.func,
|
||||
handleOpenModal: PropTypes.func,
|
||||
setModalRedemptionId: PropTypes.func
|
||||
};
|
||||
203
web/berry/src/views/Redemption/index.js
Normal file
203
web/berry/src/views/Redemption/index.js
Normal file
@@ -0,0 +1,203 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { showError, showSuccess } from 'utils/common';
|
||||
|
||||
import Table from '@mui/material/Table';
|
||||
import TableBody from '@mui/material/TableBody';
|
||||
import TableContainer from '@mui/material/TableContainer';
|
||||
import PerfectScrollbar from 'react-perfect-scrollbar';
|
||||
import TablePagination from '@mui/material/TablePagination';
|
||||
import LinearProgress from '@mui/material/LinearProgress';
|
||||
import ButtonGroup from '@mui/material/ButtonGroup';
|
||||
import Toolbar from '@mui/material/Toolbar';
|
||||
|
||||
import { Button, Card, Box, Stack, Container, Typography } from '@mui/material';
|
||||
import RedemptionTableRow from './component/TableRow';
|
||||
import RedemptionTableHead from './component/TableHead';
|
||||
import TableToolBar from 'ui-component/TableToolBar';
|
||||
import { API } from 'utils/api';
|
||||
import { ITEMS_PER_PAGE } from 'constants';
|
||||
import { IconRefresh, IconPlus } from '@tabler/icons-react';
|
||||
import EditeModal from './component/EditModal';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
export default function Redemption() {
|
||||
const [redemptions, setRedemptions] = useState([]);
|
||||
const [activePage, setActivePage] = useState(0);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const [openModal, setOpenModal] = useState(false);
|
||||
const [editRedemptionId, setEditRedemptionId] = useState(0);
|
||||
|
||||
const loadRedemptions = async (startIdx) => {
|
||||
setSearching(true);
|
||||
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.splice(startIdx * ITEMS_PER_PAGE, data.length, ...data);
|
||||
setRedemptions(newRedemptions);
|
||||
}
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
setSearching(false);
|
||||
};
|
||||
|
||||
const onPaginationChange = (event, activePage) => {
|
||||
(async () => {
|
||||
if (activePage === Math.ceil(redemptions.length / ITEMS_PER_PAGE)) {
|
||||
// In this case we have to load more data and then append them.
|
||||
await loadRedemptions(activePage);
|
||||
}
|
||||
setActivePage(activePage);
|
||||
})();
|
||||
};
|
||||
|
||||
const searchRedemptions = async (event) => {
|
||||
event.preventDefault();
|
||||
if (searchKeyword === '') {
|
||||
await loadRedemptions(0);
|
||||
setActivePage(0);
|
||||
return;
|
||||
}
|
||||
setSearching(true);
|
||||
const res = await API.get(`/api/redemption/search?keyword=${searchKeyword}`);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
setRedemptions(data);
|
||||
setActivePage(0);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
setSearching(false);
|
||||
};
|
||||
|
||||
const handleSearchKeyword = (event) => {
|
||||
setSearchKeyword(event.target.value);
|
||||
};
|
||||
|
||||
const manageRedemptions = async (id, action, value) => {
|
||||
const url = '/api/redemption/';
|
||||
let data = { id };
|
||||
let res;
|
||||
switch (action) {
|
||||
case 'delete':
|
||||
res = await API.delete(url + id);
|
||||
break;
|
||||
case 'status':
|
||||
res = await API.put(url + '?status_only=true', {
|
||||
...data,
|
||||
status: value
|
||||
});
|
||||
break;
|
||||
}
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess('操作成功完成!');
|
||||
if (action === 'delete') {
|
||||
await loadRedemptions(0);
|
||||
}
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
|
||||
return res.data;
|
||||
};
|
||||
|
||||
// 处理刷新
|
||||
const handleRefresh = async () => {
|
||||
await loadRedemptions(0);
|
||||
setActivePage(0);
|
||||
setSearchKeyword('');
|
||||
};
|
||||
|
||||
const handleOpenModal = (redemptionId) => {
|
||||
setEditRedemptionId(redemptionId);
|
||||
setOpenModal(true);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setOpenModal(false);
|
||||
setEditRedemptionId(0);
|
||||
};
|
||||
|
||||
const handleOkModal = (status) => {
|
||||
if (status === true) {
|
||||
handleCloseModal();
|
||||
handleRefresh();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadRedemptions(0)
|
||||
.then()
|
||||
.catch((reason) => {
|
||||
showError(reason);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" mb={5}>
|
||||
<Typography variant="h4">兑换</Typography>
|
||||
|
||||
<Button variant="contained" color="primary" startIcon={<IconPlus />} onClick={() => handleOpenModal(0)}>
|
||||
新建兑换码
|
||||
</Button>
|
||||
</Stack>
|
||||
<Card>
|
||||
<Box component="form" onSubmit={searchRedemptions} noValidate>
|
||||
<TableToolBar filterName={searchKeyword} handleFilterName={handleSearchKeyword} placeholder={'搜索兑换码的ID和名称...'} />
|
||||
</Box>
|
||||
<Toolbar
|
||||
sx={{
|
||||
textAlign: 'right',
|
||||
height: 50,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
p: (theme) => theme.spacing(0, 1, 0, 3)
|
||||
}}
|
||||
>
|
||||
<Container>
|
||||
<ButtonGroup variant="outlined" aria-label="outlined small primary button group">
|
||||
<Button onClick={handleRefresh} startIcon={<IconRefresh width={'18px'} />}>
|
||||
刷新
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</Container>
|
||||
</Toolbar>
|
||||
{searching && <LinearProgress />}
|
||||
<PerfectScrollbar component="div">
|
||||
<TableContainer sx={{ overflow: 'unset' }}>
|
||||
<Table sx={{ minWidth: 800 }}>
|
||||
<RedemptionTableHead />
|
||||
<TableBody>
|
||||
{redemptions.slice(activePage * ITEMS_PER_PAGE, (activePage + 1) * ITEMS_PER_PAGE).map((row) => (
|
||||
<RedemptionTableRow
|
||||
item={row}
|
||||
manageRedemption={manageRedemptions}
|
||||
key={row.id}
|
||||
handleOpenModal={handleOpenModal}
|
||||
setModalRedemptionId={setEditRedemptionId}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</PerfectScrollbar>
|
||||
<TablePagination
|
||||
page={activePage}
|
||||
component="div"
|
||||
count={redemptions.length + (redemptions.length % ITEMS_PER_PAGE === 0 ? 1 : 0)}
|
||||
rowsPerPage={ITEMS_PER_PAGE}
|
||||
onPageChange={onPaginationChange}
|
||||
rowsPerPageOptions={[ITEMS_PER_PAGE]}
|
||||
/>
|
||||
</Card>
|
||||
<EditeModal open={openModal} onCancel={handleCloseModal} onOk={handleOkModal} redemptiondId={editRedemptionId} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
532
web/berry/src/views/Setting/component/OperationSetting.js
Normal file
532
web/berry/src/views/Setting/component/OperationSetting.js
Normal file
@@ -0,0 +1,532 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import SubCard from "ui-component/cards/SubCard";
|
||||
import {
|
||||
Stack,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
OutlinedInput,
|
||||
Checkbox,
|
||||
Button,
|
||||
FormControlLabel,
|
||||
TextField,
|
||||
} from "@mui/material";
|
||||
import { showSuccess, showError, verifyJSON } from "utils/common";
|
||||
import { API } from "utils/api";
|
||||
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
||||
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
|
||||
import { DateTimePicker } from "@mui/x-date-pickers/DateTimePicker";
|
||||
import dayjs from "dayjs";
|
||||
require("dayjs/locale/zh-cn");
|
||||
|
||||
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: "",
|
||||
AutomaticEnableChannelEnabled: "",
|
||||
ChannelDisableThreshold: 0,
|
||||
LogConsumeEnabled: "",
|
||||
DisplayInCurrencyEnabled: "",
|
||||
DisplayTokenStatEnabled: "",
|
||||
ApproximateTokenEnabled: "",
|
||||
RetryTimes: 0,
|
||||
});
|
||||
const [originInputs, setOriginInputs] = useState({});
|
||||
let [loading, setLoading] = useState(false);
|
||||
let [historyTimestamp, setHistoryTimestamp] = useState(
|
||||
now.getTime() / 1000 - 30 * 24 * 3600
|
||||
); // a month ago new Date().getTime() / 1000 + 3600
|
||||
|
||||
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 (event) => {
|
||||
let { name, value } = event.target;
|
||||
|
||||
if (name.endsWith("Enabled")) {
|
||||
await updateOption(name, value);
|
||||
showSuccess("设置成功!");
|
||||
} 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;
|
||||
}
|
||||
|
||||
showSuccess("保存成功!");
|
||||
};
|
||||
|
||||
const deleteHistoryLogs = async () => {
|
||||
const res = await API.delete(
|
||||
`/api/log/?target_timestamp=${Math.floor(historyTimestamp)}`
|
||||
);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
showSuccess(`${data} 条日志已清理!`);
|
||||
return;
|
||||
}
|
||||
showError("日志清理失败:" + message);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<SubCard title="通用设置">
|
||||
<Stack justifyContent="flex-start" alignItems="flex-start" spacing={2}>
|
||||
<Stack
|
||||
direction={{ sm: "column", md: "row" }}
|
||||
spacing={{ xs: 3, sm: 2, md: 4 }}
|
||||
>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel htmlFor="TopUpLink">充值链接</InputLabel>
|
||||
<OutlinedInput
|
||||
id="TopUpLink"
|
||||
name="TopUpLink"
|
||||
value={inputs.TopUpLink}
|
||||
onChange={handleInputChange}
|
||||
label="充值链接"
|
||||
placeholder="例如发卡网站的购买链接"
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel htmlFor="ChatLink">聊天链接</InputLabel>
|
||||
<OutlinedInput
|
||||
id="ChatLink"
|
||||
name="ChatLink"
|
||||
value={inputs.ChatLink}
|
||||
onChange={handleInputChange}
|
||||
label="聊天链接"
|
||||
placeholder="例如 ChatGPT Next Web 的部署地址"
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel htmlFor="QuotaPerUnit">单位额度</InputLabel>
|
||||
<OutlinedInput
|
||||
id="QuotaPerUnit"
|
||||
name="QuotaPerUnit"
|
||||
value={inputs.QuotaPerUnit}
|
||||
onChange={handleInputChange}
|
||||
label="单位额度"
|
||||
placeholder="一单位货币能兑换的额度"
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel htmlFor="RetryTimes">重试次数</InputLabel>
|
||||
<OutlinedInput
|
||||
id="RetryTimes"
|
||||
name="RetryTimes"
|
||||
value={inputs.RetryTimes}
|
||||
onChange={handleInputChange}
|
||||
label="重试次数"
|
||||
placeholder="重试次数"
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
<Stack
|
||||
direction={{ sm: "column", md: "row" }}
|
||||
spacing={{ xs: 3, sm: 2, md: 4 }}
|
||||
justifyContent="flex-start"
|
||||
alignItems="flex-start"
|
||||
>
|
||||
<FormControlLabel
|
||||
sx={{ marginLeft: "0px" }}
|
||||
label="以货币形式显示额度"
|
||||
control={
|
||||
<Checkbox
|
||||
checked={inputs.DisplayInCurrencyEnabled === "true"}
|
||||
onChange={handleInputChange}
|
||||
name="DisplayInCurrencyEnabled"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<FormControlLabel
|
||||
label="Billing 相关 API 显示令牌额度而非用户额度"
|
||||
control={
|
||||
<Checkbox
|
||||
checked={inputs.DisplayTokenStatEnabled === "true"}
|
||||
onChange={handleInputChange}
|
||||
name="DisplayTokenStatEnabled"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<FormControlLabel
|
||||
label="使用近似的方式估算 token 数以减少计算量"
|
||||
control={
|
||||
<Checkbox
|
||||
checked={inputs.ApproximateTokenEnabled === "true"}
|
||||
onChange={handleInputChange}
|
||||
name="ApproximateTokenEnabled"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => {
|
||||
submitConfig("general").then();
|
||||
}}
|
||||
>
|
||||
保存通用设置
|
||||
</Button>
|
||||
</Stack>
|
||||
</SubCard>
|
||||
<SubCard title="日志设置">
|
||||
<Stack
|
||||
direction="column"
|
||||
justifyContent="flex-start"
|
||||
alignItems="flex-start"
|
||||
spacing={2}
|
||||
>
|
||||
<FormControlLabel
|
||||
label="启用日志消费"
|
||||
control={
|
||||
<Checkbox
|
||||
checked={inputs.LogConsumeEnabled === "true"}
|
||||
onChange={handleInputChange}
|
||||
name="LogConsumeEnabled"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<FormControl>
|
||||
<LocalizationProvider
|
||||
dateAdapter={AdapterDayjs}
|
||||
adapterLocale={"zh-cn"}
|
||||
>
|
||||
<DateTimePicker
|
||||
label="日志清理时间"
|
||||
placeholder="日志清理时间"
|
||||
ampm={false}
|
||||
name="historyTimestamp"
|
||||
value={
|
||||
historyTimestamp === null
|
||||
? null
|
||||
: dayjs.unix(historyTimestamp)
|
||||
}
|
||||
disabled={loading}
|
||||
onChange={(newValue) => {
|
||||
setHistoryTimestamp(
|
||||
newValue === null ? null : newValue.unix()
|
||||
);
|
||||
}}
|
||||
slotProps={{
|
||||
actionBar: {
|
||||
actions: ["today", "clear", "accept"],
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</LocalizationProvider>
|
||||
</FormControl>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => {
|
||||
deleteHistoryLogs().then();
|
||||
}}
|
||||
>
|
||||
清理历史日志
|
||||
</Button>
|
||||
</Stack>
|
||||
</SubCard>
|
||||
<SubCard title="监控设置">
|
||||
<Stack justifyContent="flex-start" alignItems="flex-start" spacing={2}>
|
||||
<Stack
|
||||
direction={{ sm: "column", md: "row" }}
|
||||
spacing={{ xs: 3, sm: 2, md: 4 }}
|
||||
>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel htmlFor="ChannelDisableThreshold">
|
||||
最长响应时间
|
||||
</InputLabel>
|
||||
<OutlinedInput
|
||||
id="ChannelDisableThreshold"
|
||||
name="ChannelDisableThreshold"
|
||||
type="number"
|
||||
value={inputs.ChannelDisableThreshold}
|
||||
onChange={handleInputChange}
|
||||
label="最长响应时间"
|
||||
placeholder="单位秒,当运行通道全部测试时,超过此时间将自动禁用通道"
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel htmlFor="QuotaRemindThreshold">
|
||||
额度提醒阈值
|
||||
</InputLabel>
|
||||
<OutlinedInput
|
||||
id="QuotaRemindThreshold"
|
||||
name="QuotaRemindThreshold"
|
||||
type="number"
|
||||
value={inputs.QuotaRemindThreshold}
|
||||
onChange={handleInputChange}
|
||||
label="额度提醒阈值"
|
||||
placeholder="低于此额度时将发送邮件提醒用户"
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
<FormControlLabel
|
||||
label="失败时自动禁用通道"
|
||||
control={
|
||||
<Checkbox
|
||||
checked={inputs.AutomaticDisableChannelEnabled === "true"}
|
||||
onChange={handleInputChange}
|
||||
name="AutomaticDisableChannelEnabled"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormControlLabel
|
||||
label="成功时自动启用通道"
|
||||
control={
|
||||
<Checkbox
|
||||
checked={inputs.AutomaticEnableChannelEnabled === "true"}
|
||||
onChange={handleInputChange}
|
||||
name="AutomaticEnableChannelEnabled"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => {
|
||||
submitConfig("monitor").then();
|
||||
}}
|
||||
>
|
||||
保存监控设置
|
||||
</Button>
|
||||
</Stack>
|
||||
</SubCard>
|
||||
<SubCard title="额度设置">
|
||||
<Stack justifyContent="flex-start" alignItems="flex-start" spacing={2}>
|
||||
<Stack
|
||||
direction={{ sm: "column", md: "row" }}
|
||||
spacing={{ xs: 3, sm: 2, md: 4 }}
|
||||
>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel htmlFor="QuotaForNewUser">新用户初始额度</InputLabel>
|
||||
<OutlinedInput
|
||||
id="QuotaForNewUser"
|
||||
name="QuotaForNewUser"
|
||||
type="number"
|
||||
value={inputs.QuotaForNewUser}
|
||||
onChange={handleInputChange}
|
||||
label="新用户初始额度"
|
||||
placeholder="例如:100"
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel htmlFor="PreConsumedQuota">请求预扣费额度</InputLabel>
|
||||
<OutlinedInput
|
||||
id="PreConsumedQuota"
|
||||
name="PreConsumedQuota"
|
||||
type="number"
|
||||
value={inputs.PreConsumedQuota}
|
||||
onChange={handleInputChange}
|
||||
label="请求预扣费额度"
|
||||
placeholder="请求结束后多退少补"
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel htmlFor="QuotaForInviter">
|
||||
邀请新用户奖励额度
|
||||
</InputLabel>
|
||||
<OutlinedInput
|
||||
id="QuotaForInviter"
|
||||
name="QuotaForInviter"
|
||||
type="number"
|
||||
label="邀请新用户奖励额度"
|
||||
value={inputs.QuotaForInviter}
|
||||
onChange={handleInputChange}
|
||||
placeholder="例如:2000"
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel htmlFor="QuotaForInvitee">
|
||||
新用户使用邀请码奖励额度
|
||||
</InputLabel>
|
||||
<OutlinedInput
|
||||
id="QuotaForInvitee"
|
||||
name="QuotaForInvitee"
|
||||
type="number"
|
||||
label="新用户使用邀请码奖励额度"
|
||||
value={inputs.QuotaForInvitee}
|
||||
onChange={handleInputChange}
|
||||
autoComplete="new-password"
|
||||
placeholder="例如:1000"
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => {
|
||||
submitConfig("quota").then();
|
||||
}}
|
||||
>
|
||||
保存额度设置
|
||||
</Button>
|
||||
</Stack>
|
||||
</SubCard>
|
||||
<SubCard title="倍率设置">
|
||||
<Stack justifyContent="flex-start" alignItems="flex-start" spacing={2}>
|
||||
<FormControl fullWidth>
|
||||
<TextField
|
||||
multiline
|
||||
maxRows={15}
|
||||
id="channel-ModelRatio-label"
|
||||
label="模型倍率"
|
||||
value={inputs.ModelRatio}
|
||||
name="ModelRatio"
|
||||
onChange={handleInputChange}
|
||||
aria-describedby="helper-text-channel-ModelRatio-label"
|
||||
minRows={5}
|
||||
placeholder="为一个 JSON 文本,键为模型名称,值为倍率"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<TextField
|
||||
multiline
|
||||
maxRows={15}
|
||||
id="channel-GroupRatio-label"
|
||||
label="分组倍率"
|
||||
value={inputs.GroupRatio}
|
||||
name="GroupRatio"
|
||||
onChange={handleInputChange}
|
||||
aria-describedby="helper-text-channel-GroupRatio-label"
|
||||
minRows={5}
|
||||
placeholder="为一个 JSON 文本,键为分组名称,值为倍率"
|
||||
/>
|
||||
</FormControl>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => {
|
||||
submitConfig("ratio").then();
|
||||
}}
|
||||
>
|
||||
保存倍率设置
|
||||
</Button>
|
||||
</Stack>
|
||||
</SubCard>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default OperationSetting;
|
||||
286
web/berry/src/views/Setting/component/OtherSetting.js
Normal file
286
web/berry/src/views/Setting/component/OtherSetting.js
Normal file
@@ -0,0 +1,286 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import SubCard from 'ui-component/cards/SubCard';
|
||||
import {
|
||||
Stack,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
OutlinedInput,
|
||||
Button,
|
||||
Alert,
|
||||
TextField,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
Divider
|
||||
} from '@mui/material';
|
||||
import Grid from '@mui/material/Unstable_Grid2';
|
||||
import { showError, showSuccess } from 'utils/common'; //,
|
||||
import { API } from 'utils/api';
|
||||
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 }));
|
||||
showSuccess('保存成功');
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleInputChange = async (event) => {
|
||||
let { name, value } = event.target;
|
||||
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 (
|
||||
<>
|
||||
<Stack spacing={2}>
|
||||
<SubCard title="通用设置">
|
||||
<Grid container spacing={{ xs: 3, sm: 2, md: 4 }}>
|
||||
<Grid xs={12}>
|
||||
<Button variant="contained" onClick={checkUpdate}>
|
||||
检查更新
|
||||
</Button>
|
||||
</Grid>
|
||||
<Grid xs={12}>
|
||||
<FormControl fullWidth>
|
||||
<TextField
|
||||
multiline
|
||||
maxRows={15}
|
||||
id="Notice"
|
||||
label="公告"
|
||||
value={inputs.Notice}
|
||||
name="Notice"
|
||||
onChange={handleInputChange}
|
||||
minRows={10}
|
||||
placeholder="在此输入新的公告内容,支持 Markdown & HTML 代码"
|
||||
/>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid xs={12}>
|
||||
<Button variant="contained" onClick={submitNotice}>
|
||||
保存公告
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</SubCard>
|
||||
<SubCard title="个性化设置">
|
||||
<Grid container spacing={{ xs: 3, sm: 2, md: 4 }}>
|
||||
<Grid xs={12}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel htmlFor="SystemName">系统名称</InputLabel>
|
||||
<OutlinedInput
|
||||
id="SystemName"
|
||||
name="SystemName"
|
||||
value={inputs.SystemName || ''}
|
||||
onChange={handleInputChange}
|
||||
label="系统名称"
|
||||
placeholder="在此输入系统名称"
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid xs={12}>
|
||||
<Button variant="contained" onClick={submitSystemName}>
|
||||
设置系统名称
|
||||
</Button>
|
||||
</Grid>
|
||||
<Grid xs={12}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel htmlFor="Logo">Logo 图片地址</InputLabel>
|
||||
<OutlinedInput
|
||||
id="Logo"
|
||||
name="Logo"
|
||||
value={inputs.Logo || ''}
|
||||
onChange={handleInputChange}
|
||||
label="Logo 图片地址"
|
||||
placeholder="在此输入Logo 图片地址"
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid xs={12}>
|
||||
<Button variant="contained" onClick={submitLogo}>
|
||||
设置 Logo
|
||||
</Button>
|
||||
</Grid>
|
||||
<Grid xs={12}>
|
||||
<FormControl fullWidth>
|
||||
<TextField
|
||||
multiline
|
||||
maxRows={15}
|
||||
id="HomePageContent"
|
||||
label="首页内容"
|
||||
value={inputs.HomePageContent}
|
||||
name="HomePageContent"
|
||||
onChange={handleInputChange}
|
||||
minRows={10}
|
||||
placeholder="在此输入首页内容,支持 Markdown & HTML 代码,设置后首页的状态信息将不再显示。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页。"
|
||||
/>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid xs={12}>
|
||||
<Button variant="contained" onClick={() => submitOption('HomePageContent')}>
|
||||
保存首页内容
|
||||
</Button>
|
||||
</Grid>
|
||||
<Grid xs={12}>
|
||||
<FormControl fullWidth>
|
||||
<TextField
|
||||
multiline
|
||||
maxRows={15}
|
||||
id="About"
|
||||
label="关于"
|
||||
value={inputs.About}
|
||||
name="About"
|
||||
onChange={handleInputChange}
|
||||
minRows={10}
|
||||
placeholder="在此输入新的关于内容,支持 Markdown & HTML 代码。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为关于页面。"
|
||||
/>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid xs={12}>
|
||||
<Button variant="contained" onClick={submitAbout}>
|
||||
保存关于
|
||||
</Button>
|
||||
</Grid>
|
||||
<Grid xs={12}>
|
||||
<Alert severity="warning">
|
||||
移除 One API 的版权标识必须首先获得授权,项目维护需要花费大量精力,如果本项目对你有意义,请主动支持本项目。
|
||||
</Alert>
|
||||
</Grid>
|
||||
<Grid xs={12}>
|
||||
<FormControl fullWidth>
|
||||
<TextField
|
||||
multiline
|
||||
maxRows={15}
|
||||
id="Footer"
|
||||
label="公告"
|
||||
value={inputs.Footer}
|
||||
name="Footer"
|
||||
onChange={handleInputChange}
|
||||
minRows={10}
|
||||
placeholder="在此输入新的页脚,留空则使用默认页脚,支持 HTML 代码"
|
||||
/>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid xs={12}>
|
||||
<Button variant="contained" onClick={submitFooter}>
|
||||
设置页脚
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</SubCard>
|
||||
</Stack>
|
||||
<Dialog open={showUpdateModal} onClose={() => setShowUpdateModal(false)} fullWidth maxWidth={'md'}>
|
||||
<DialogTitle sx={{ margin: '0px', fontWeight: 700, lineHeight: '1.55556', padding: '24px', fontSize: '1.125rem' }}>
|
||||
新版本:{updateData.tag_name}
|
||||
</DialogTitle>
|
||||
<Divider />
|
||||
<DialogContent>
|
||||
{' '}
|
||||
<div dangerouslySetInnerHTML={{ __html: updateData.content }}></div>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setShowUpdateModal(false)}>关闭</Button>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
setShowUpdateModal(false);
|
||||
openGitHubRelease();
|
||||
}}
|
||||
>
|
||||
去GitHub查看
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default OtherSetting;
|
||||
611
web/berry/src/views/Setting/component/SystemSetting.js
Normal file
611
web/berry/src/views/Setting/component/SystemSetting.js
Normal file
@@ -0,0 +1,611 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import SubCard from 'ui-component/cards/SubCard';
|
||||
import {
|
||||
Stack,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
OutlinedInput,
|
||||
Checkbox,
|
||||
Button,
|
||||
FormControlLabel,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Divider,
|
||||
Alert,
|
||||
Autocomplete,
|
||||
TextField
|
||||
} from '@mui/material';
|
||||
import Grid from '@mui/material/Unstable_Grid2';
|
||||
import { showError, showSuccess, removeTrailingSlash } from 'utils/common'; //,
|
||||
import { API } from 'utils/api';
|
||||
import { createFilterOptions } from '@mui/material/Autocomplete';
|
||||
|
||||
const filter = createFilterOptions();
|
||||
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 [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(','));
|
||||
} 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
|
||||
}));
|
||||
showSuccess('设置成功!');
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleInputChange = async (event) => {
|
||||
let { name, value } = event.target;
|
||||
|
||||
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 () => {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack spacing={2}>
|
||||
<SubCard title="通用设置">
|
||||
<Grid container spacing={{ xs: 3, sm: 2, md: 4 }}>
|
||||
<Grid xs={12}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel htmlFor="ServerAddress">服务器地址</InputLabel>
|
||||
<OutlinedInput
|
||||
id="ServerAddress"
|
||||
name="ServerAddress"
|
||||
value={inputs.ServerAddress || ''}
|
||||
onChange={handleInputChange}
|
||||
label="服务器地址"
|
||||
placeholder="例如:https://yourdomain.com"
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid xs={12}>
|
||||
<Button variant="contained" onClick={submitServerAddress}>
|
||||
更新服务器地址
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</SubCard>
|
||||
<SubCard title="配置登录注册">
|
||||
<Grid container spacing={{ xs: 3, sm: 2, md: 4 }}>
|
||||
<Grid xs={12} md={3}>
|
||||
<FormControlLabel
|
||||
label="允许通过密码进行登录"
|
||||
control={
|
||||
<Checkbox checked={inputs.PasswordLoginEnabled === 'true'} onChange={handleInputChange} name="PasswordLoginEnabled" />
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid xs={12} md={3}>
|
||||
<FormControlLabel
|
||||
label="允许通过密码进行注册"
|
||||
control={
|
||||
<Checkbox
|
||||
checked={inputs.PasswordRegisterEnabled === 'true'}
|
||||
onChange={handleInputChange}
|
||||
name="PasswordRegisterEnabled"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid xs={12} md={3}>
|
||||
<FormControlLabel
|
||||
label="通过密码注册时需要进行邮箱验证"
|
||||
control={
|
||||
<Checkbox
|
||||
checked={inputs.EmailVerificationEnabled === 'true'}
|
||||
onChange={handleInputChange}
|
||||
name="EmailVerificationEnabled"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid xs={12} md={3}>
|
||||
<FormControlLabel
|
||||
label="允许通过 GitHub 账户登录 & 注册"
|
||||
control={<Checkbox checked={inputs.GitHubOAuthEnabled === 'true'} onChange={handleInputChange} name="GitHubOAuthEnabled" />}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid xs={12} md={3}>
|
||||
<FormControlLabel
|
||||
label="允许通过微信登录 & 注册"
|
||||
control={<Checkbox checked={inputs.WeChatAuthEnabled === 'true'} onChange={handleInputChange} name="WeChatAuthEnabled" />}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid xs={12} md={3}>
|
||||
<FormControlLabel
|
||||
label="允许新用户注册(此项为否时,新用户将无法以任何方式进行注册)"
|
||||
control={<Checkbox checked={inputs.RegisterEnabled === 'true'} onChange={handleInputChange} name="RegisterEnabled" />}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid xs={12} md={3}>
|
||||
<FormControlLabel
|
||||
label="启用 Turnstile 用户校验"
|
||||
control={
|
||||
<Checkbox checked={inputs.TurnstileCheckEnabled === 'true'} onChange={handleInputChange} name="TurnstileCheckEnabled" />
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</SubCard>
|
||||
<SubCard title="配置邮箱域名白名单" subTitle="用以防止恶意用户利用临时邮箱批量注册">
|
||||
<Grid container spacing={{ xs: 3, sm: 2, md: 4 }}>
|
||||
<Grid xs={12}>
|
||||
<FormControlLabel
|
||||
label="启用邮箱域名白名单"
|
||||
control={
|
||||
<Checkbox
|
||||
checked={inputs.EmailDomainRestrictionEnabled === 'true'}
|
||||
onChange={handleInputChange}
|
||||
name="EmailDomainRestrictionEnabled"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid xs={12}>
|
||||
<FormControl fullWidth>
|
||||
<Autocomplete
|
||||
multiple
|
||||
freeSolo
|
||||
id="EmailDomainWhitelist"
|
||||
options={EmailDomainWhitelist}
|
||||
value={inputs.EmailDomainWhitelist}
|
||||
onChange={(e, value) => {
|
||||
const event = {
|
||||
target: {
|
||||
name: 'EmailDomainWhitelist',
|
||||
value: value
|
||||
}
|
||||
};
|
||||
handleInputChange(event);
|
||||
}}
|
||||
filterSelectedOptions
|
||||
renderInput={(params) => <TextField {...params} name="EmailDomainWhitelist" label="允许的邮箱域名" />}
|
||||
filterOptions={(options, params) => {
|
||||
const filtered = filter(options, params);
|
||||
const { inputValue } = params;
|
||||
const isExisting = options.some((option) => inputValue === option);
|
||||
if (inputValue !== '' && !isExisting) {
|
||||
filtered.push(inputValue);
|
||||
}
|
||||
return filtered;
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid xs={12}>
|
||||
<Button variant="contained" onClick={submitEmailDomainWhitelist}>
|
||||
保存邮箱域名白名单设置
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</SubCard>
|
||||
<SubCard title="配置 SMTP" subTitle="用以支持系统的邮件发送">
|
||||
<Grid container spacing={{ xs: 3, sm: 2, md: 4 }}>
|
||||
<Grid xs={12} md={4}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel htmlFor="SMTPServer">SMTP 服务器地址</InputLabel>
|
||||
<OutlinedInput
|
||||
id="SMTPServer"
|
||||
name="SMTPServer"
|
||||
value={inputs.SMTPServer || ''}
|
||||
onChange={handleInputChange}
|
||||
label="SMTP 服务器地址"
|
||||
placeholder="例如:smtp.qq.com"
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid xs={12} md={4}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel htmlFor="SMTPPort">SMTP 端口</InputLabel>
|
||||
<OutlinedInput
|
||||
id="SMTPPort"
|
||||
name="SMTPPort"
|
||||
value={inputs.SMTPPort || ''}
|
||||
onChange={handleInputChange}
|
||||
label="SMTP 端口"
|
||||
placeholder="默认: 587"
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid xs={12} md={4}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel htmlFor="SMTPAccount">SMTP 账户</InputLabel>
|
||||
<OutlinedInput
|
||||
id="SMTPAccount"
|
||||
name="SMTPAccount"
|
||||
value={inputs.SMTPAccount || ''}
|
||||
onChange={handleInputChange}
|
||||
label="SMTP 账户"
|
||||
placeholder="通常是邮箱地址"
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid xs={12} md={4}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel htmlFor="SMTPFrom">SMTP 发送者邮箱</InputLabel>
|
||||
<OutlinedInput
|
||||
id="SMTPFrom"
|
||||
name="SMTPFrom"
|
||||
value={inputs.SMTPFrom || ''}
|
||||
onChange={handleInputChange}
|
||||
label="SMTP 发送者邮箱"
|
||||
placeholder="通常和邮箱地址保持一致"
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid xs={12} md={4}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel htmlFor="SMTPToken">SMTP 访问凭证</InputLabel>
|
||||
<OutlinedInput
|
||||
id="SMTPToken"
|
||||
name="SMTPToken"
|
||||
value={inputs.SMTPToken || ''}
|
||||
onChange={handleInputChange}
|
||||
label="SMTP 访问凭证"
|
||||
placeholder="敏感信息不会发送到前端显示"
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid xs={12}>
|
||||
<Button variant="contained" onClick={submitSMTP}>
|
||||
保存 SMTP 设置
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</SubCard>
|
||||
<SubCard
|
||||
title="配置 GitHub OAuth App"
|
||||
subTitle={
|
||||
<span>
|
||||
{' '}
|
||||
用以支持通过 GitHub 进行登录注册,
|
||||
<a href="https://github.com/settings/developers" target="_blank" rel="noopener noreferrer">
|
||||
点击此处
|
||||
</a>
|
||||
管理你的 GitHub OAuth App
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Grid container spacing={{ xs: 3, sm: 2, md: 4 }}>
|
||||
<Grid xs={12}>
|
||||
<Alert severity="info" sx={{ wordWrap: 'break-word' }}>
|
||||
Homepage URL 填 <b>{inputs.ServerAddress}</b>
|
||||
,Authorization callback URL 填 <b>{`${inputs.ServerAddress}/oauth/github`}</b>
|
||||
</Alert>
|
||||
</Grid>
|
||||
<Grid xs={12} md={6}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel htmlFor="GitHubClientId">GitHub Client ID</InputLabel>
|
||||
<OutlinedInput
|
||||
id="GitHubClientId"
|
||||
name="GitHubClientId"
|
||||
value={inputs.GitHubClientId || ''}
|
||||
onChange={handleInputChange}
|
||||
label="GitHub Client ID"
|
||||
placeholder="输入你注册的 GitHub OAuth APP 的 ID"
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid xs={12} md={6}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel htmlFor="GitHubClientSecret">GitHub Client Secret</InputLabel>
|
||||
<OutlinedInput
|
||||
id="GitHubClientSecret"
|
||||
name="GitHubClientSecret"
|
||||
value={inputs.GitHubClientSecret || ''}
|
||||
onChange={handleInputChange}
|
||||
label="GitHub Client Secret"
|
||||
placeholder="敏感信息不会发送到前端显示"
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid xs={12}>
|
||||
<Button variant="contained" onClick={submitGitHubOAuth}>
|
||||
保存 GitHub OAuth 设置
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</SubCard>
|
||||
<SubCard
|
||||
title="配置 WeChat Server"
|
||||
subTitle={
|
||||
<span>
|
||||
用以支持通过微信进行登录注册,
|
||||
<a href="https://github.com/songquanpeng/wechat-server" target="_blank" rel="noopener noreferrer">
|
||||
点击此处
|
||||
</a>
|
||||
了解 WeChat Server
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Grid container spacing={{ xs: 3, sm: 2, md: 4 }}>
|
||||
<Grid xs={12} md={4}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel htmlFor="WeChatServerAddress">WeChat Server 服务器地址</InputLabel>
|
||||
<OutlinedInput
|
||||
id="WeChatServerAddress"
|
||||
name="WeChatServerAddress"
|
||||
value={inputs.WeChatServerAddress || ''}
|
||||
onChange={handleInputChange}
|
||||
label="WeChat Server 服务器地址"
|
||||
placeholder="例如:https://yourdomain.com"
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid xs={12} md={4}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel htmlFor="WeChatServerToken">WeChat Server 访问凭证</InputLabel>
|
||||
<OutlinedInput
|
||||
id="WeChatServerToken"
|
||||
name="WeChatServerToken"
|
||||
value={inputs.WeChatServerToken || ''}
|
||||
onChange={handleInputChange}
|
||||
label="WeChat Server 访问凭证"
|
||||
placeholder="敏感信息不会发送到前端显示"
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid xs={12} md={4}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel htmlFor="WeChatAccountQRCodeImageURL">微信公众号二维码图片链接</InputLabel>
|
||||
<OutlinedInput
|
||||
id="WeChatAccountQRCodeImageURL"
|
||||
name="WeChatAccountQRCodeImageURL"
|
||||
value={inputs.WeChatAccountQRCodeImageURL || ''}
|
||||
onChange={handleInputChange}
|
||||
label="微信公众号二维码图片链接"
|
||||
placeholder="输入一个图片链接"
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid xs={12}>
|
||||
<Button variant="contained" onClick={submitWeChat}>
|
||||
保存 WeChat Server 设置
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</SubCard>
|
||||
<SubCard
|
||||
title="配置 Turnstile"
|
||||
subTitle={
|
||||
<span>
|
||||
用以支持用户校验,
|
||||
<a href="https://dash.cloudflare.com/" target="_blank" rel="noopener noreferrer">
|
||||
点击此处
|
||||
</a>
|
||||
管理你的 Turnstile Sites,推荐选择 Invisible Widget Type
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Grid container spacing={{ xs: 3, sm: 2, md: 4 }}>
|
||||
<Grid xs={12} md={6}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel htmlFor="TurnstileSiteKey">Turnstile Site Key</InputLabel>
|
||||
<OutlinedInput
|
||||
id="TurnstileSiteKey"
|
||||
name="TurnstileSiteKey"
|
||||
value={inputs.TurnstileSiteKey || ''}
|
||||
onChange={handleInputChange}
|
||||
label="Turnstile Site Key"
|
||||
placeholder="输入你注册的 Turnstile Site Key"
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid xs={12} md={6}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel htmlFor="TurnstileSecretKey">Turnstile Secret Key</InputLabel>
|
||||
<OutlinedInput
|
||||
id="TurnstileSecretKey"
|
||||
name="TurnstileSecretKey"
|
||||
type="password"
|
||||
value={inputs.TurnstileSecretKey || ''}
|
||||
onChange={handleInputChange}
|
||||
label="Turnstile Secret Key"
|
||||
placeholder="敏感信息不会发送到前端显示"
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid xs={12}>
|
||||
<Button variant="contained" onClick={submitTurnstile}>
|
||||
保存 Turnstile 设置
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</SubCard>
|
||||
</Stack>
|
||||
<Dialog open={showPasswordWarningModal} onClose={() => setShowPasswordWarningModal(false)} maxWidth={'md'}>
|
||||
<DialogTitle sx={{ margin: '0px', fontWeight: 700, lineHeight: '1.55556', padding: '24px', fontSize: '1.125rem' }}>
|
||||
警告
|
||||
</DialogTitle>
|
||||
<Divider />
|
||||
<DialogContent>取消密码登录将导致所有未绑定其他登录方式的用户(包括管理员)无法通过密码登录,确认取消?</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setShowPasswordWarningModal(false)}>取消</Button>
|
||||
<Button
|
||||
sx={{ color: 'error.main' }}
|
||||
onClick={async () => {
|
||||
setShowPasswordWarningModal(false);
|
||||
await updateOption('PasswordLoginEnabled', 'false');
|
||||
}}
|
||||
>
|
||||
确定
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SystemSetting;
|
||||
90
web/berry/src/views/Setting/index.js
Normal file
90
web/berry/src/views/Setting/index.js
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Tabs, Tab, Box, Card } from '@mui/material';
|
||||
import { IconSettings2, IconActivity, IconSettings } from '@tabler/icons-react';
|
||||
import OperationSetting from './component/OperationSetting';
|
||||
import SystemSetting from './component/SystemSetting';
|
||||
import OtherSetting from './component/OtherSetting';
|
||||
import AdminContainer from 'ui-component/AdminContainer';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
function CustomTabPanel(props) {
|
||||
const { children, value, index, ...other } = props;
|
||||
|
||||
return (
|
||||
<div role="tabpanel" hidden={value !== index} id={`setting-tabpanel-${index}`} aria-labelledby={`setting-tab-${index}`} {...other}>
|
||||
{value === index && <Box sx={{ p: 3 }}>{children}</Box>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
CustomTabPanel.propTypes = {
|
||||
children: PropTypes.node,
|
||||
index: PropTypes.number.isRequired,
|
||||
value: PropTypes.number.isRequired
|
||||
};
|
||||
|
||||
function a11yProps(index) {
|
||||
return {
|
||||
id: `setting-tab-${index}`,
|
||||
'aria-controls': `setting-tabpanel-${index}`
|
||||
};
|
||||
}
|
||||
|
||||
const Setting = () => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const hash = location.hash.replace('#', '');
|
||||
const tabMap = {
|
||||
operation: 0,
|
||||
system: 1,
|
||||
other: 2
|
||||
};
|
||||
const [value, setValue] = useState(tabMap[hash] || 0);
|
||||
|
||||
const handleChange = (event, newValue) => {
|
||||
setValue(newValue);
|
||||
const hashArray = Object.keys(tabMap);
|
||||
navigate(`#${hashArray[newValue]}`);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleHashChange = () => {
|
||||
const hash = location.hash.replace('#', '');
|
||||
setValue(tabMap[hash] || 0);
|
||||
};
|
||||
window.addEventListener('hashchange', handleHashChange);
|
||||
return () => {
|
||||
window.removeEventListener('hashchange', handleHashChange);
|
||||
};
|
||||
}, [location, tabMap]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<AdminContainer>
|
||||
<Box sx={{ width: '100%' }}>
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Tabs value={value} onChange={handleChange} variant="scrollable" scrollButtons="auto">
|
||||
<Tab label="运营设置" {...a11yProps(0)} icon={<IconActivity />} iconPosition="start" />
|
||||
<Tab label="系统设置" {...a11yProps(1)} icon={<IconSettings />} iconPosition="start" />
|
||||
<Tab label="其他设置" {...a11yProps(2)} icon={<IconSettings2 />} iconPosition="start" />
|
||||
</Tabs>
|
||||
</Box>
|
||||
<CustomTabPanel value={value} index={0}>
|
||||
<OperationSetting />
|
||||
</CustomTabPanel>
|
||||
<CustomTabPanel value={value} index={1}>
|
||||
<SystemSetting />
|
||||
</CustomTabPanel>
|
||||
<CustomTabPanel value={value} index={2}>
|
||||
<OtherSetting />
|
||||
</CustomTabPanel>
|
||||
</Box>
|
||||
</AdminContainer>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Setting;
|
||||
275
web/berry/src/views/Token/component/EditModal.js
Normal file
275
web/berry/src/views/Token/component/EditModal.js
Normal file
@@ -0,0 +1,275 @@
|
||||
import PropTypes from "prop-types";
|
||||
import * as Yup from "yup";
|
||||
import { Formik } from "formik";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useState, useEffect } from "react";
|
||||
import dayjs from "dayjs";
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Divider,
|
||||
Alert,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
OutlinedInput,
|
||||
InputAdornment,
|
||||
Switch,
|
||||
FormHelperText,
|
||||
} from "@mui/material";
|
||||
|
||||
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
||||
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
|
||||
import { DateTimePicker } from "@mui/x-date-pickers/DateTimePicker";
|
||||
import { renderQuotaWithPrompt, showSuccess, showError } from "utils/common";
|
||||
import { API } from "utils/api";
|
||||
require("dayjs/locale/zh-cn");
|
||||
|
||||
const validationSchema = Yup.object().shape({
|
||||
is_edit: Yup.boolean(),
|
||||
name: Yup.string().required("名称 不能为空"),
|
||||
remain_quota: Yup.number().min(0, "必须大于等于0"),
|
||||
expired_time: Yup.number(),
|
||||
unlimited_quota: Yup.boolean(),
|
||||
});
|
||||
|
||||
const originInputs = {
|
||||
is_edit: false,
|
||||
name: "",
|
||||
remain_quota: 0,
|
||||
expired_time: -1,
|
||||
unlimited_quota: false,
|
||||
};
|
||||
|
||||
const EditModal = ({ open, tokenId, onCancel, onOk }) => {
|
||||
const theme = useTheme();
|
||||
const [inputs, setInputs] = useState(originInputs);
|
||||
|
||||
const submit = async (values, { setErrors, setStatus, setSubmitting }) => {
|
||||
setSubmitting(true);
|
||||
|
||||
values.remain_quota = parseInt(values.remain_quota);
|
||||
let res;
|
||||
if (values.is_edit) {
|
||||
res = await API.put(`/api/token/`, { ...values, id: parseInt(tokenId) });
|
||||
} else {
|
||||
res = await API.post(`/api/token/`, values);
|
||||
}
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
if (values.is_edit) {
|
||||
showSuccess("令牌更新成功!");
|
||||
} else {
|
||||
showSuccess("令牌创建成功,请在列表页面点击复制获取令牌!");
|
||||
}
|
||||
setSubmitting(false);
|
||||
setStatus({ success: true });
|
||||
onOk(true);
|
||||
} else {
|
||||
showError(message);
|
||||
setErrors({ submit: message });
|
||||
}
|
||||
};
|
||||
|
||||
const loadToken = async () => {
|
||||
let res = await API.get(`/api/token/${tokenId}`);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
data.is_edit = true;
|
||||
setInputs(data);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (tokenId) {
|
||||
loadToken().then();
|
||||
}
|
||||
}, [tokenId]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onCancel} fullWidth maxWidth={"md"}>
|
||||
<DialogTitle
|
||||
sx={{
|
||||
margin: "0px",
|
||||
fontWeight: 700,
|
||||
lineHeight: "1.55556",
|
||||
padding: "24px",
|
||||
fontSize: "1.125rem",
|
||||
}}
|
||||
>
|
||||
{tokenId ? "编辑Token" : "新建Token"}
|
||||
</DialogTitle>
|
||||
<Divider />
|
||||
<DialogContent>
|
||||
<Alert severity="info">
|
||||
注意,令牌的额度仅用于限制令牌本身的最大额度使用量,实际的使用受到账户的剩余额度限制。
|
||||
</Alert>
|
||||
<Formik
|
||||
initialValues={inputs}
|
||||
enableReinitialize
|
||||
validationSchema={validationSchema}
|
||||
onSubmit={submit}
|
||||
>
|
||||
{({
|
||||
errors,
|
||||
handleBlur,
|
||||
handleChange,
|
||||
handleSubmit,
|
||||
touched,
|
||||
values,
|
||||
setFieldError,
|
||||
setFieldValue,
|
||||
isSubmitting,
|
||||
}) => (
|
||||
<form noValidate onSubmit={handleSubmit}>
|
||||
<FormControl
|
||||
fullWidth
|
||||
error={Boolean(touched.name && errors.name)}
|
||||
sx={{ ...theme.typography.otherInput }}
|
||||
>
|
||||
<InputLabel htmlFor="channel-name-label">名称</InputLabel>
|
||||
<OutlinedInput
|
||||
id="channel-name-label"
|
||||
label="名称"
|
||||
type="text"
|
||||
value={values.name}
|
||||
name="name"
|
||||
onBlur={handleBlur}
|
||||
onChange={handleChange}
|
||||
inputProps={{ autoComplete: "name" }}
|
||||
aria-describedby="helper-text-channel-name-label"
|
||||
/>
|
||||
{touched.name && errors.name && (
|
||||
<FormHelperText error id="helper-tex-channel-name-label">
|
||||
{errors.name}
|
||||
</FormHelperText>
|
||||
)}
|
||||
</FormControl>
|
||||
{values.expired_time !== -1 && (
|
||||
<FormControl
|
||||
fullWidth
|
||||
error={Boolean(touched.expired_time && errors.expired_time)}
|
||||
sx={{ ...theme.typography.otherInput }}
|
||||
>
|
||||
<LocalizationProvider
|
||||
dateAdapter={AdapterDayjs}
|
||||
adapterLocale={"zh-cn"}
|
||||
>
|
||||
<DateTimePicker
|
||||
label="过期时间"
|
||||
ampm={false}
|
||||
value={dayjs.unix(values.expired_time)}
|
||||
onError={(newError) => {
|
||||
if (newError === null) {
|
||||
setFieldError("expired_time", null);
|
||||
} else {
|
||||
setFieldError("expired_time", "无效的日期");
|
||||
}
|
||||
}}
|
||||
onChange={(newValue) => {
|
||||
setFieldValue("expired_time", newValue.unix());
|
||||
}}
|
||||
slotProps={{
|
||||
actionBar: {
|
||||
actions: ["today", "accept"],
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</LocalizationProvider>
|
||||
{errors.expired_time && (
|
||||
<FormHelperText
|
||||
error
|
||||
id="helper-tex-channel-expired_time-label"
|
||||
>
|
||||
{errors.expired_time}
|
||||
</FormHelperText>
|
||||
)}
|
||||
</FormControl>
|
||||
)}
|
||||
<Switch
|
||||
checked={values.expired_time === -1}
|
||||
onClick={() => {
|
||||
if (values.expired_time === -1) {
|
||||
setFieldValue(
|
||||
"expired_time",
|
||||
Math.floor(Date.now() / 1000)
|
||||
);
|
||||
} else {
|
||||
setFieldValue("expired_time", -1);
|
||||
}
|
||||
}}
|
||||
/>{" "}
|
||||
永不过期
|
||||
<FormControl
|
||||
fullWidth
|
||||
error={Boolean(touched.remain_quota && errors.remain_quota)}
|
||||
sx={{ ...theme.typography.otherInput }}
|
||||
>
|
||||
<InputLabel htmlFor="channel-remain_quota-label">
|
||||
额度
|
||||
</InputLabel>
|
||||
<OutlinedInput
|
||||
id="channel-remain_quota-label"
|
||||
label="额度"
|
||||
type="number"
|
||||
value={values.remain_quota}
|
||||
name="remain_quota"
|
||||
endAdornment={
|
||||
<InputAdornment position="end">
|
||||
{renderQuotaWithPrompt(values.remain_quota)}
|
||||
</InputAdornment>
|
||||
}
|
||||
onBlur={handleBlur}
|
||||
onChange={handleChange}
|
||||
aria-describedby="helper-text-channel-remain_quota-label"
|
||||
disabled={values.unlimited_quota}
|
||||
/>
|
||||
|
||||
{touched.remain_quota && errors.remain_quota && (
|
||||
<FormHelperText
|
||||
error
|
||||
id="helper-tex-channel-remain_quota-label"
|
||||
>
|
||||
{errors.remain_quota}
|
||||
</FormHelperText>
|
||||
)}
|
||||
</FormControl>
|
||||
<Switch
|
||||
checked={values.unlimited_quota === true}
|
||||
onClick={() => {
|
||||
setFieldValue("unlimited_quota", !values.unlimited_quota);
|
||||
}}
|
||||
/>{" "}
|
||||
无限额度
|
||||
<DialogActions>
|
||||
<Button onClick={onCancel}>取消</Button>
|
||||
<Button
|
||||
disableElevation
|
||||
disabled={isSubmitting}
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
>
|
||||
提交
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</form>
|
||||
)}
|
||||
</Formik>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditModal;
|
||||
|
||||
EditModal.propTypes = {
|
||||
open: PropTypes.bool,
|
||||
tokenId: PropTypes.number,
|
||||
onCancel: PropTypes.func,
|
||||
onOk: PropTypes.func,
|
||||
};
|
||||
19
web/berry/src/views/Token/component/TableHead.js
Normal file
19
web/berry/src/views/Token/component/TableHead.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import { TableCell, TableHead, TableRow } from '@mui/material';
|
||||
|
||||
const TokenTableHead = () => {
|
||||
return (
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>名称</TableCell>
|
||||
<TableCell>状态</TableCell>
|
||||
<TableCell>已用额度</TableCell>
|
||||
<TableCell>剩余额度</TableCell>
|
||||
<TableCell>创建时间</TableCell>
|
||||
<TableCell>过期时间</TableCell>
|
||||
<TableCell>操作</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
);
|
||||
};
|
||||
|
||||
export default TokenTableHead;
|
||||
270
web/berry/src/views/Token/component/TableRow.js
Normal file
270
web/berry/src/views/Token/component/TableRow.js
Normal file
@@ -0,0 +1,270 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import {
|
||||
Popover,
|
||||
TableRow,
|
||||
MenuItem,
|
||||
TableCell,
|
||||
IconButton,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
Button,
|
||||
Tooltip,
|
||||
Stack,
|
||||
ButtonGroup
|
||||
} from '@mui/material';
|
||||
|
||||
import TableSwitch from 'ui-component/Switch';
|
||||
import { renderQuota, showSuccess, timestamp2string } from 'utils/common';
|
||||
|
||||
import { IconDotsVertical, IconEdit, IconTrash, IconCaretDownFilled } from '@tabler/icons-react';
|
||||
|
||||
const COPY_OPTIONS = [
|
||||
{
|
||||
key: 'next',
|
||||
text: 'ChatGPT Next',
|
||||
url: 'https://chat.oneapi.pro/#/?settings={"key":"sk-{key}","url":"{serverAddress}"}',
|
||||
encode: false
|
||||
},
|
||||
{ key: 'ama', text: 'AMA 问天', url: 'ama://set-api-key?server={serverAddress}&key=sk-{key}', encode: true },
|
||||
{ key: 'opencat', text: 'OpenCat', url: 'opencat://team/join?domain={serverAddress}&token=sk-{key}', encode: true }
|
||||
];
|
||||
|
||||
function replacePlaceholders(text, key, serverAddress) {
|
||||
return text.replace('{key}', key).replace('{serverAddress}', serverAddress);
|
||||
}
|
||||
|
||||
function createMenu(menuItems) {
|
||||
return (
|
||||
<>
|
||||
{menuItems.map((menuItem, index) => (
|
||||
<MenuItem key={index} onClick={menuItem.onClick} sx={{ color: menuItem.color }}>
|
||||
{menuItem.icon}
|
||||
{menuItem.text}
|
||||
</MenuItem>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TokensTableRow({ item, manageToken, handleOpenModal, setModalTokenId }) {
|
||||
const [open, setOpen] = useState(null);
|
||||
const [menuItems, setMenuItems] = useState(null);
|
||||
const [openDelete, setOpenDelete] = useState(false);
|
||||
const [statusSwitch, setStatusSwitch] = useState(item.status);
|
||||
const siteInfo = useSelector((state) => state.siteInfo);
|
||||
|
||||
const handleDeleteOpen = () => {
|
||||
handleCloseMenu();
|
||||
setOpenDelete(true);
|
||||
};
|
||||
|
||||
const handleDeleteClose = () => {
|
||||
setOpenDelete(false);
|
||||
};
|
||||
|
||||
const handleOpenMenu = (event, type) => {
|
||||
switch (type) {
|
||||
case 'copy':
|
||||
setMenuItems(copyItems);
|
||||
break;
|
||||
case 'link':
|
||||
setMenuItems(linkItems);
|
||||
break;
|
||||
default:
|
||||
setMenuItems(actionItems);
|
||||
}
|
||||
setOpen(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleCloseMenu = () => {
|
||||
setOpen(null);
|
||||
};
|
||||
|
||||
const handleStatus = async () => {
|
||||
const switchVlue = statusSwitch === 1 ? 2 : 1;
|
||||
const { success } = await manageToken(item.id, 'status', switchVlue);
|
||||
if (success) {
|
||||
setStatusSwitch(switchVlue);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
handleCloseMenu();
|
||||
await manageToken(item.id, 'delete', '');
|
||||
};
|
||||
|
||||
const actionItems = createMenu([
|
||||
{
|
||||
text: '编辑',
|
||||
icon: <IconEdit style={{ marginRight: '16px' }} />,
|
||||
onClick: () => {
|
||||
handleCloseMenu();
|
||||
handleOpenModal();
|
||||
setModalTokenId(item.id);
|
||||
},
|
||||
color: undefined
|
||||
},
|
||||
{
|
||||
text: '删除',
|
||||
icon: <IconTrash style={{ marginRight: '16px' }} />,
|
||||
onClick: handleDeleteOpen,
|
||||
color: 'error.main'
|
||||
}
|
||||
]);
|
||||
|
||||
const handleCopy = (option, type) => {
|
||||
let serverAddress = '';
|
||||
if (siteInfo?.server_address) {
|
||||
serverAddress = siteInfo.server_address;
|
||||
} else {
|
||||
serverAddress = window.location.host;
|
||||
}
|
||||
|
||||
if (option.encode) {
|
||||
serverAddress = encodeURIComponent(serverAddress);
|
||||
}
|
||||
|
||||
let url = option.url;
|
||||
|
||||
if (option.key === 'next' && siteInfo?.chat_link) {
|
||||
url = siteInfo.chat_link + `/#/?settings={"key":"sk-{key}","url":"{serverAddress}"}`;
|
||||
}
|
||||
|
||||
const key = item.key;
|
||||
const text = replacePlaceholders(url, key, serverAddress);
|
||||
if (type === 'link') {
|
||||
window.open(text);
|
||||
} else {
|
||||
navigator.clipboard.writeText(text);
|
||||
showSuccess('已复制到剪贴板!');
|
||||
}
|
||||
handleCloseMenu();
|
||||
};
|
||||
|
||||
const copyItems = createMenu(
|
||||
COPY_OPTIONS.map((option) => ({
|
||||
text: option.text,
|
||||
icon: undefined,
|
||||
onClick: () => handleCopy(option, 'copy'),
|
||||
color: undefined
|
||||
}))
|
||||
);
|
||||
|
||||
const linkItems = createMenu(
|
||||
COPY_OPTIONS.map((option) => ({
|
||||
text: option.text,
|
||||
icon: undefined,
|
||||
onClick: () => handleCopy(option, 'link'),
|
||||
color: undefined
|
||||
}))
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableRow tabIndex={item.id}>
|
||||
<TableCell>{item.name}</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<Tooltip
|
||||
title={(() => {
|
||||
switch (statusSwitch) {
|
||||
case 1:
|
||||
return '已启用';
|
||||
case 2:
|
||||
return '已禁用';
|
||||
case 3:
|
||||
return '已过期';
|
||||
case 4:
|
||||
return '已耗尽';
|
||||
default:
|
||||
return '未知';
|
||||
}
|
||||
})()}
|
||||
placement="top"
|
||||
>
|
||||
<TableSwitch
|
||||
id={`switch-${item.id}`}
|
||||
checked={statusSwitch === 1}
|
||||
onChange={handleStatus}
|
||||
disabled={statusSwitch !== 1 && statusSwitch !== 2}
|
||||
/>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>{renderQuota(item.used_quota)}</TableCell>
|
||||
|
||||
<TableCell>{item.unlimited_quota ? '无限制' : renderQuota(item.remain_quota, 2)}</TableCell>
|
||||
|
||||
<TableCell>{timestamp2string(item.created_time)}</TableCell>
|
||||
|
||||
<TableCell>{item.expired_time === -1 ? '永不过期' : timestamp2string(item.expired_time)}</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<ButtonGroup size="small" aria-label="split button">
|
||||
<Button
|
||||
color="primary"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(`sk-${item.key}`);
|
||||
showSuccess('已复制到剪贴板!');
|
||||
}}
|
||||
>
|
||||
复制
|
||||
</Button>
|
||||
<Button size="small" onClick={(e) => handleOpenMenu(e, 'copy')}>
|
||||
<IconCaretDownFilled size={'16px'} />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
<ButtonGroup size="small" aria-label="split button">
|
||||
<Button color="primary">聊天</Button>
|
||||
<Button size="small" onClick={(e) => handleOpenMenu(e, 'link')}>
|
||||
<IconCaretDownFilled size={'16px'} />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
<IconButton onClick={(e) => handleOpenMenu(e, 'action')} sx={{ color: 'rgb(99, 115, 129)' }}>
|
||||
<IconDotsVertical />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<Popover
|
||||
open={!!open}
|
||||
anchorEl={open}
|
||||
onClose={handleCloseMenu}
|
||||
anchorOrigin={{ vertical: 'top', horizontal: 'left' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
PaperProps={{
|
||||
sx: { width: 140 }
|
||||
}}
|
||||
>
|
||||
{menuItems}
|
||||
</Popover>
|
||||
|
||||
<Dialog open={openDelete} onClose={handleDeleteClose}>
|
||||
<DialogTitle>删除Token</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>是否删除Token {item.name}?</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleDeleteClose}>关闭</Button>
|
||||
<Button onClick={handleDelete} sx={{ color: 'error.main' }} autoFocus>
|
||||
删除
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
TokensTableRow.propTypes = {
|
||||
item: PropTypes.object,
|
||||
manageToken: PropTypes.func,
|
||||
handleOpenModal: PropTypes.func,
|
||||
setModalTokenId: PropTypes.func
|
||||
};
|
||||
215
web/berry/src/views/Token/index.js
Normal file
215
web/berry/src/views/Token/index.js
Normal file
@@ -0,0 +1,215 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { showError, showSuccess } from 'utils/common';
|
||||
|
||||
import Table from '@mui/material/Table';
|
||||
import TableBody from '@mui/material/TableBody';
|
||||
import TableContainer from '@mui/material/TableContainer';
|
||||
import PerfectScrollbar from 'react-perfect-scrollbar';
|
||||
import TablePagination from '@mui/material/TablePagination';
|
||||
import LinearProgress from '@mui/material/LinearProgress';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import ButtonGroup from '@mui/material/ButtonGroup';
|
||||
import Toolbar from '@mui/material/Toolbar';
|
||||
|
||||
import { Button, Card, Box, Stack, Container, Typography } from '@mui/material';
|
||||
import TokensTableRow from './component/TableRow';
|
||||
import TokenTableHead from './component/TableHead';
|
||||
import TableToolBar from 'ui-component/TableToolBar';
|
||||
import { API } from 'utils/api';
|
||||
import { ITEMS_PER_PAGE } from 'constants';
|
||||
import { IconRefresh, IconPlus } from '@tabler/icons-react';
|
||||
import EditeModal from './component/EditModal';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
export default function Token() {
|
||||
const [tokens, setTokens] = useState([]);
|
||||
const [activePage, setActivePage] = useState(0);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const [openModal, setOpenModal] = useState(false);
|
||||
const [editTokenId, setEditTokenId] = useState(0);
|
||||
const siteInfo = useSelector((state) => state.siteInfo);
|
||||
|
||||
const loadTokens = async (startIdx) => {
|
||||
setSearching(true);
|
||||
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);
|
||||
}
|
||||
setSearching(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadTokens(0)
|
||||
.then()
|
||||
.catch((reason) => {
|
||||
showError(reason);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onPaginationChange = (event, activePage) => {
|
||||
(async () => {
|
||||
if (activePage === Math.ceil(tokens.length / ITEMS_PER_PAGE)) {
|
||||
// In this case we have to load more data and then append them.
|
||||
await loadTokens(activePage);
|
||||
}
|
||||
setActivePage(activePage);
|
||||
})();
|
||||
};
|
||||
|
||||
const searchTokens = async (event) => {
|
||||
event.preventDefault();
|
||||
if (searchKeyword === '') {
|
||||
await loadTokens(0);
|
||||
setActivePage(0);
|
||||
return;
|
||||
}
|
||||
setSearching(true);
|
||||
const res = await API.get(`/api/token/search?keyword=${searchKeyword}`);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
setTokens(data);
|
||||
setActivePage(0);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
setSearching(false);
|
||||
};
|
||||
|
||||
const handleSearchKeyword = (event) => {
|
||||
setSearchKeyword(event.target.value);
|
||||
};
|
||||
|
||||
const manageToken = async (id, action, value) => {
|
||||
const url = '/api/token/';
|
||||
let data = { id };
|
||||
let res;
|
||||
switch (action) {
|
||||
case 'delete':
|
||||
res = await API.delete(url + id);
|
||||
break;
|
||||
case 'status':
|
||||
res = await API.put(url + `?status_only=true`, {
|
||||
...data,
|
||||
status: value
|
||||
});
|
||||
break;
|
||||
}
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess('操作成功完成!');
|
||||
if (action === 'delete') {
|
||||
await handleRefresh();
|
||||
}
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
|
||||
return res.data;
|
||||
};
|
||||
|
||||
// 处理刷新
|
||||
const handleRefresh = async () => {
|
||||
await loadTokens(activePage);
|
||||
};
|
||||
|
||||
const handleOpenModal = (tokenId) => {
|
||||
setEditTokenId(tokenId);
|
||||
setOpenModal(true);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setOpenModal(false);
|
||||
setEditTokenId(0);
|
||||
};
|
||||
|
||||
const handleOkModal = (status) => {
|
||||
if (status === true) {
|
||||
handleCloseModal();
|
||||
handleRefresh();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" mb={5}>
|
||||
<Typography variant="h4">令牌</Typography>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() => {
|
||||
handleOpenModal(0);
|
||||
}}
|
||||
startIcon={<IconPlus />}
|
||||
>
|
||||
新建令牌
|
||||
</Button>
|
||||
</Stack>
|
||||
<Stack mb={5}>
|
||||
<Alert severity="info">
|
||||
将 OpenAI API 基础地址 https://api.openai.com 替换为 <b>{siteInfo.server_address}</b>,复制下面的密钥即可使用
|
||||
</Alert>
|
||||
</Stack>
|
||||
<Card>
|
||||
<Box component="form" onSubmit={searchTokens} noValidate>
|
||||
<TableToolBar filterName={searchKeyword} handleFilterName={handleSearchKeyword} placeholder={'搜索令牌的名称...'} />
|
||||
</Box>
|
||||
<Toolbar
|
||||
sx={{
|
||||
textAlign: 'right',
|
||||
height: 50,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
p: (theme) => theme.spacing(0, 1, 0, 3)
|
||||
}}
|
||||
>
|
||||
<Container>
|
||||
<ButtonGroup variant="outlined" aria-label="outlined small primary button group">
|
||||
<Button onClick={handleRefresh} startIcon={<IconRefresh width={'18px'} />}>
|
||||
刷新
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</Container>
|
||||
</Toolbar>
|
||||
{searching && <LinearProgress />}
|
||||
<PerfectScrollbar component="div">
|
||||
<TableContainer sx={{ overflow: 'unset' }}>
|
||||
<Table sx={{ minWidth: 800 }}>
|
||||
<TokenTableHead />
|
||||
<TableBody>
|
||||
{tokens.slice(activePage * ITEMS_PER_PAGE, (activePage + 1) * ITEMS_PER_PAGE).map((row) => (
|
||||
<TokensTableRow
|
||||
item={row}
|
||||
manageToken={manageToken}
|
||||
key={row.id}
|
||||
handleOpenModal={handleOpenModal}
|
||||
setModalTokenId={setEditTokenId}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</PerfectScrollbar>
|
||||
<TablePagination
|
||||
page={activePage}
|
||||
component="div"
|
||||
count={tokens.length + (tokens.length % ITEMS_PER_PAGE === 0 ? 1 : 0)}
|
||||
rowsPerPage={ITEMS_PER_PAGE}
|
||||
onPageChange={onPaginationChange}
|
||||
rowsPerPageOptions={[ITEMS_PER_PAGE]}
|
||||
/>
|
||||
</Card>
|
||||
<EditeModal open={openModal} onCancel={handleCloseModal} onOk={handleOkModal} tokenId={editTokenId} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
80
web/berry/src/views/Topup/component/InviteCard.js
Normal file
80
web/berry/src/views/Topup/component/InviteCard.js
Normal file
@@ -0,0 +1,80 @@
|
||||
import { Stack, Typography, Container, Box, OutlinedInput, InputAdornment, Button } from '@mui/material';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import SubCard from 'ui-component/cards/SubCard';
|
||||
import inviteImage from 'assets/images/invite/cwok_casual_19.webp';
|
||||
import { useState } from 'react';
|
||||
import { API } from 'utils/api';
|
||||
import { showError, showSuccess } from 'utils/common';
|
||||
|
||||
const InviteCard = () => {
|
||||
const theme = useTheme();
|
||||
const [inviteUl, setInviteUrl] = useState('');
|
||||
|
||||
const handleInviteUrl = async () => {
|
||||
if (inviteUl) {
|
||||
navigator.clipboard.writeText(inviteUl);
|
||||
showSuccess(`邀请链接已复制到剪切板`);
|
||||
return;
|
||||
}
|
||||
const res = await API.get('/api/user/aff');
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
let link = `${window.location.origin}/register?aff=${data}`;
|
||||
setInviteUrl(link);
|
||||
navigator.clipboard.writeText(link);
|
||||
showSuccess(`邀请链接已复制到剪切板`);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box component="div">
|
||||
<SubCard
|
||||
sx={{
|
||||
background: theme.palette.primary.dark
|
||||
}}
|
||||
>
|
||||
<Stack justifyContent="center" alignItems={'flex-start'} padding={'40px 24px 0px'} spacing={3}>
|
||||
<Container sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
|
||||
<img src={inviteImage} alt="invite" width={'250px'} />
|
||||
</Container>
|
||||
</Stack>
|
||||
</SubCard>
|
||||
<SubCard
|
||||
sx={{
|
||||
marginTop: '-20px'
|
||||
}}
|
||||
>
|
||||
<Stack justifyContent="center" alignItems={'center'} spacing={3}>
|
||||
<Typography variant="h3" sx={{ color: theme.palette.primary.dark }}>
|
||||
邀请奖励
|
||||
</Typography>
|
||||
<Typography variant="body" sx={{ color: theme.palette.primary.dark }}>
|
||||
分享您的邀请链接,邀请好友注册,即可获得奖励!
|
||||
</Typography>
|
||||
|
||||
<OutlinedInput
|
||||
id="invite-url"
|
||||
label="邀请链接"
|
||||
type="text"
|
||||
value={inviteUl}
|
||||
name="invite-url"
|
||||
placeholder="点击生成邀请链接"
|
||||
endAdornment={
|
||||
<InputAdornment position="end">
|
||||
<Button variant="contained" onClick={handleInviteUrl}>
|
||||
{inviteUl ? '复制' : '生成'}
|
||||
</Button>
|
||||
</InputAdornment>
|
||||
}
|
||||
aria-describedby="helper-text-channel-quota-label"
|
||||
disabled={true}
|
||||
/>
|
||||
</Stack>
|
||||
</SubCard>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default InviteCard;
|
||||
122
web/berry/src/views/Topup/component/TopupCard.js
Normal file
122
web/berry/src/views/Topup/component/TopupCard.js
Normal file
@@ -0,0 +1,122 @@
|
||||
import { Typography, Stack, OutlinedInput, InputAdornment, Button, InputLabel, FormControl } from '@mui/material';
|
||||
import { IconWallet } from '@tabler/icons-react';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import SubCard from 'ui-component/cards/SubCard';
|
||||
import UserCard from 'ui-component/cards/UserCard';
|
||||
|
||||
import { API } from 'utils/api';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { showError, showInfo, showSuccess, renderQuota } from 'utils/common';
|
||||
|
||||
const TopupCard = () => {
|
||||
const theme = useTheme();
|
||||
const [redemptionCode, setRedemptionCode] = useState('');
|
||||
const [topUpLink, setTopUpLink] = useState('');
|
||||
const [userQuota, setUserQuota] = useState(0);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const topUp = async () => {
|
||||
if (redemptionCode === '') {
|
||||
showInfo('请输入充值码!');
|
||||
return;
|
||||
}
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const res = await API.post('/api/user/topup', {
|
||||
key: redemptionCode
|
||||
});
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
showSuccess('充值成功!');
|
||||
setUserQuota((quota) => {
|
||||
return quota + data;
|
||||
});
|
||||
setRedemptionCode('');
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
} catch (err) {
|
||||
showError('请求失败');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openTopUpLink = () => {
|
||||
if (!topUpLink) {
|
||||
showError('超级管理员未设置充值链接!');
|
||||
return;
|
||||
}
|
||||
window.open(topUpLink, '_blank');
|
||||
};
|
||||
|
||||
const getUserQuota = async () => {
|
||||
let res = await API.get(`/api/user/self`);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
setUserQuota(data.quota);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let status = localStorage.getItem('siteInfo');
|
||||
if (status) {
|
||||
status = JSON.parse(status);
|
||||
if (status.top_up_link) {
|
||||
setTopUpLink(status.top_up_link);
|
||||
}
|
||||
}
|
||||
getUserQuota().then();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<UserCard>
|
||||
<Stack direction="row" alignItems="center" justifyContent="center" spacing={2} paddingTop={'20px'}>
|
||||
<IconWallet color={theme.palette.primary.main} />
|
||||
<Typography variant="h4">当前额度:</Typography>
|
||||
<Typography variant="h4">{renderQuota(userQuota)}</Typography>
|
||||
</Stack>
|
||||
<SubCard
|
||||
sx={{
|
||||
marginTop: '40px'
|
||||
}}
|
||||
>
|
||||
<FormControl fullWidth variant="outlined">
|
||||
<InputLabel htmlFor="key">兑换码</InputLabel>
|
||||
<OutlinedInput
|
||||
id="key"
|
||||
label="兑换码"
|
||||
type="text"
|
||||
value={redemptionCode}
|
||||
onChange={(e) => {
|
||||
setRedemptionCode(e.target.value);
|
||||
}}
|
||||
name="key"
|
||||
placeholder="请输入兑换码"
|
||||
endAdornment={
|
||||
<InputAdornment position="end">
|
||||
<Button variant="contained" onClick={topUp} disabled={isSubmitting}>
|
||||
{isSubmitting ? '兑换中...' : '兑换'}
|
||||
</Button>
|
||||
</InputAdornment>
|
||||
}
|
||||
aria-describedby="helper-text-channel-quota-label"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<Stack justifyContent="center" alignItems={'center'} spacing={3} paddingTop={'20px'}>
|
||||
<Typography variant={'h4'} color={theme.palette.grey[700]}>
|
||||
还没有兑换码? 点击获取兑换码:
|
||||
</Typography>
|
||||
<Button variant="contained" onClick={openTopUpLink}>
|
||||
获取兑换码
|
||||
</Button>
|
||||
</Stack>
|
||||
</SubCard>
|
||||
</UserCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default TopupCard;
|
||||
26
web/berry/src/views/Topup/index.js
Normal file
26
web/berry/src/views/Topup/index.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Stack, Alert } from '@mui/material';
|
||||
import Grid from '@mui/material/Unstable_Grid2';
|
||||
import TopupCard from './component/TopupCard';
|
||||
import InviteCard from './component/InviteCard';
|
||||
|
||||
const Topup = () => {
|
||||
return (
|
||||
<Grid container spacing={2}>
|
||||
<Grid xs={12}>
|
||||
<Alert severity="warning">
|
||||
充值记录以及邀请记录请在日志中查询。充值记录请在日志中选择类型【充值】查询;邀请记录请在日志中选择【系统】查询{' '}
|
||||
</Alert>
|
||||
</Grid>
|
||||
<Grid xs={12} md={6} lg={8}>
|
||||
<Stack spacing={2}>
|
||||
<TopupCard />
|
||||
</Stack>
|
||||
</Grid>
|
||||
<Grid xs={12} md={6} lg={4}>
|
||||
<InviteCard />
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
export default Topup;
|
||||
288
web/berry/src/views/User/component/EditModal.js
Normal file
288
web/berry/src/views/User/component/EditModal.js
Normal file
@@ -0,0 +1,288 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import * as Yup from 'yup';
|
||||
import { Formik } from 'formik';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Divider,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
OutlinedInput,
|
||||
InputAdornment,
|
||||
Select,
|
||||
MenuItem,
|
||||
IconButton,
|
||||
FormHelperText
|
||||
} from '@mui/material';
|
||||
|
||||
import Visibility from '@mui/icons-material/Visibility';
|
||||
import VisibilityOff from '@mui/icons-material/VisibilityOff';
|
||||
|
||||
import { renderQuotaWithPrompt, showSuccess, showError } from 'utils/common';
|
||||
import { API } from 'utils/api';
|
||||
|
||||
const validationSchema = Yup.object().shape({
|
||||
is_edit: Yup.boolean(),
|
||||
username: Yup.string().required('用户名 不能为空'),
|
||||
display_name: Yup.string(),
|
||||
password: Yup.string().when('is_edit', {
|
||||
is: false,
|
||||
then: Yup.string().required('密码 不能为空'),
|
||||
otherwise: Yup.string()
|
||||
}),
|
||||
group: Yup.string().when('is_edit', {
|
||||
is: false,
|
||||
then: Yup.string().required('用户组 不能为空'),
|
||||
otherwise: Yup.string()
|
||||
}),
|
||||
quota: Yup.number().when('is_edit', {
|
||||
is: false,
|
||||
then: Yup.number().min(0, '额度 不能小于 0'),
|
||||
otherwise: Yup.number()
|
||||
})
|
||||
});
|
||||
|
||||
const originInputs = {
|
||||
is_edit: false,
|
||||
username: '',
|
||||
display_name: '',
|
||||
password: '',
|
||||
group: 'default',
|
||||
quota: 0
|
||||
};
|
||||
|
||||
const EditModal = ({ open, userId, onCancel, onOk }) => {
|
||||
const theme = useTheme();
|
||||
const [inputs, setInputs] = useState(originInputs);
|
||||
const [groupOptions, setGroupOptions] = useState([]);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const submit = async (values, { setErrors, setStatus, setSubmitting }) => {
|
||||
setSubmitting(true);
|
||||
|
||||
let res;
|
||||
if (values.is_edit) {
|
||||
res = await API.put(`/api/user/`, { ...values, id: parseInt(userId) });
|
||||
} else {
|
||||
res = await API.post(`/api/user/`, values);
|
||||
}
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
if (values.is_edit) {
|
||||
showSuccess('用户更新成功!');
|
||||
} else {
|
||||
showSuccess('用户创建成功!');
|
||||
}
|
||||
setSubmitting(false);
|
||||
setStatus({ success: true });
|
||||
onOk(true);
|
||||
} else {
|
||||
showError(message);
|
||||
setErrors({ submit: message });
|
||||
}
|
||||
};
|
||||
|
||||
const handleClickShowPassword = () => {
|
||||
setShowPassword(!showPassword);
|
||||
};
|
||||
|
||||
const handleMouseDownPassword = (event) => {
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
const loadUser = async () => {
|
||||
let res = await API.get(`/api/user/${userId}`);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
data.is_edit = true;
|
||||
setInputs(data);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchGroups = async () => {
|
||||
try {
|
||||
let res = await API.get(`/api/group/`);
|
||||
setGroupOptions(res.data.data);
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchGroups().then();
|
||||
if (userId) {
|
||||
loadUser().then();
|
||||
} else {
|
||||
setInputs(originInputs);
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onCancel} fullWidth maxWidth={'md'}>
|
||||
<DialogTitle sx={{ margin: '0px', fontWeight: 700, lineHeight: '1.55556', padding: '24px', fontSize: '1.125rem' }}>
|
||||
{userId ? '编辑用户' : '新建用户'}
|
||||
</DialogTitle>
|
||||
<Divider />
|
||||
<DialogContent>
|
||||
<Formik initialValues={inputs} enableReinitialize validationSchema={validationSchema} onSubmit={submit}>
|
||||
{({ errors, handleBlur, handleChange, handleSubmit, touched, values, isSubmitting }) => (
|
||||
<form noValidate onSubmit={handleSubmit}>
|
||||
<FormControl fullWidth error={Boolean(touched.username && errors.username)} sx={{ ...theme.typography.otherInput }}>
|
||||
<InputLabel htmlFor="channel-username-label">用户名</InputLabel>
|
||||
<OutlinedInput
|
||||
id="channel-username-label"
|
||||
label="用户名"
|
||||
type="text"
|
||||
value={values.username}
|
||||
name="username"
|
||||
onBlur={handleBlur}
|
||||
onChange={handleChange}
|
||||
inputProps={{ autoComplete: 'username' }}
|
||||
aria-describedby="helper-text-channel-username-label"
|
||||
/>
|
||||
{touched.username && errors.username && (
|
||||
<FormHelperText error id="helper-tex-channel-username-label">
|
||||
{errors.username}
|
||||
</FormHelperText>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
<FormControl fullWidth error={Boolean(touched.display_name && errors.display_name)} sx={{ ...theme.typography.otherInput }}>
|
||||
<InputLabel htmlFor="channel-display_name-label">显示名称</InputLabel>
|
||||
<OutlinedInput
|
||||
id="channel-display_name-label"
|
||||
label="显示名称"
|
||||
type="text"
|
||||
value={values.display_name}
|
||||
name="display_name"
|
||||
onBlur={handleBlur}
|
||||
onChange={handleChange}
|
||||
inputProps={{ autoComplete: 'display_name' }}
|
||||
aria-describedby="helper-text-channel-display_name-label"
|
||||
/>
|
||||
{touched.display_name && errors.display_name && (
|
||||
<FormHelperText error id="helper-tex-channel-display_name-label">
|
||||
{errors.display_name}
|
||||
</FormHelperText>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
<FormControl fullWidth error={Boolean(touched.password && errors.password)} sx={{ ...theme.typography.otherInput }}>
|
||||
<InputLabel htmlFor="channel-password-label">密码</InputLabel>
|
||||
<OutlinedInput
|
||||
id="channel-password-label"
|
||||
label="密码"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={values.password}
|
||||
name="password"
|
||||
onBlur={handleBlur}
|
||||
onChange={handleChange}
|
||||
inputProps={{ autoComplete: 'password' }}
|
||||
endAdornment={
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
aria-label="toggle password visibility"
|
||||
onClick={handleClickShowPassword}
|
||||
onMouseDown={handleMouseDownPassword}
|
||||
edge="end"
|
||||
size="large"
|
||||
>
|
||||
{showPassword ? <Visibility /> : <VisibilityOff />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
}
|
||||
aria-describedby="helper-text-channel-password-label"
|
||||
/>
|
||||
{touched.password && errors.password && (
|
||||
<FormHelperText error id="helper-tex-channel-password-label">
|
||||
{errors.password}
|
||||
</FormHelperText>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
{values.is_edit && (
|
||||
<>
|
||||
<FormControl fullWidth error={Boolean(touched.quota && errors.quota)} sx={{ ...theme.typography.otherInput }}>
|
||||
<InputLabel htmlFor="channel-quota-label">额度</InputLabel>
|
||||
<OutlinedInput
|
||||
id="channel-quota-label"
|
||||
label="额度"
|
||||
type="number"
|
||||
value={values.quota}
|
||||
name="quota"
|
||||
endAdornment={<InputAdornment position="end">{renderQuotaWithPrompt(values.quota)}</InputAdornment>}
|
||||
onBlur={handleBlur}
|
||||
onChange={handleChange}
|
||||
aria-describedby="helper-text-channel-quota-label"
|
||||
disabled={values.unlimited_quota}
|
||||
/>
|
||||
|
||||
{touched.quota && errors.quota && (
|
||||
<FormHelperText error id="helper-tex-channel-quota-label">
|
||||
{errors.quota}
|
||||
</FormHelperText>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
<FormControl fullWidth error={Boolean(touched.group && errors.group)} sx={{ ...theme.typography.otherInput }}>
|
||||
<InputLabel htmlFor="channel-group-label">分组</InputLabel>
|
||||
<Select
|
||||
id="channel-group-label"
|
||||
label="分组"
|
||||
value={values.group}
|
||||
name="group"
|
||||
onBlur={handleBlur}
|
||||
onChange={handleChange}
|
||||
MenuProps={{
|
||||
PaperProps: {
|
||||
style: {
|
||||
maxHeight: 200
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{groupOptions.map((option) => {
|
||||
return (
|
||||
<MenuItem key={option} value={option}>
|
||||
{option}
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
{touched.group && errors.group && (
|
||||
<FormHelperText error id="helper-tex-channel-group-label">
|
||||
{errors.group}
|
||||
</FormHelperText>
|
||||
)}
|
||||
</FormControl>
|
||||
</>
|
||||
)}
|
||||
<DialogActions>
|
||||
<Button onClick={onCancel}>取消</Button>
|
||||
<Button disableElevation disabled={isSubmitting} type="submit" variant="contained" color="primary">
|
||||
提交
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</form>
|
||||
)}
|
||||
</Formik>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditModal;
|
||||
|
||||
EditModal.propTypes = {
|
||||
open: PropTypes.bool,
|
||||
userId: PropTypes.number,
|
||||
onCancel: PropTypes.func,
|
||||
onOk: PropTypes.func
|
||||
};
|
||||
20
web/berry/src/views/User/component/TableHead.js
Normal file
20
web/berry/src/views/User/component/TableHead.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import { TableCell, TableHead, TableRow } from '@mui/material';
|
||||
|
||||
const UsersTableHead = () => {
|
||||
return (
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>ID</TableCell>
|
||||
<TableCell>用户名</TableCell>
|
||||
<TableCell>分组</TableCell>
|
||||
<TableCell>统计信息</TableCell>
|
||||
<TableCell>用户角色</TableCell>
|
||||
<TableCell>绑定</TableCell>
|
||||
<TableCell>状态</TableCell>
|
||||
<TableCell>操作</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
);
|
||||
};
|
||||
|
||||
export default UsersTableHead;
|
||||
193
web/berry/src/views/User/component/TableRow.js
Normal file
193
web/berry/src/views/User/component/TableRow.js
Normal file
@@ -0,0 +1,193 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { useState } from 'react';
|
||||
|
||||
import {
|
||||
Popover,
|
||||
TableRow,
|
||||
MenuItem,
|
||||
TableCell,
|
||||
IconButton,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
Button,
|
||||
Tooltip,
|
||||
Stack
|
||||
} from '@mui/material';
|
||||
|
||||
import Label from 'ui-component/Label';
|
||||
import TableSwitch from 'ui-component/Switch';
|
||||
import { renderQuota, renderNumber } from 'utils/common';
|
||||
import { IconDotsVertical, IconEdit, IconTrash, IconUser, IconBrandWechat, IconBrandGithub, IconMail } from '@tabler/icons-react';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
|
||||
function renderRole(role) {
|
||||
switch (role) {
|
||||
case 1:
|
||||
return <Label color="default">普通用户</Label>;
|
||||
case 10:
|
||||
return <Label color="orange">管理员</Label>;
|
||||
case 100:
|
||||
return <Label color="success">超级管理员</Label>;
|
||||
default:
|
||||
return <Label color="error">未知身份</Label>;
|
||||
}
|
||||
}
|
||||
|
||||
export default function UsersTableRow({ item, manageUser, handleOpenModal, setModalUserId }) {
|
||||
const theme = useTheme();
|
||||
const [open, setOpen] = useState(null);
|
||||
const [openDelete, setOpenDelete] = useState(false);
|
||||
const [statusSwitch, setStatusSwitch] = useState(item.status);
|
||||
|
||||
const handleDeleteOpen = () => {
|
||||
handleCloseMenu();
|
||||
setOpenDelete(true);
|
||||
};
|
||||
|
||||
const handleDeleteClose = () => {
|
||||
setOpenDelete(false);
|
||||
};
|
||||
|
||||
const handleOpenMenu = (event) => {
|
||||
setOpen(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleCloseMenu = () => {
|
||||
setOpen(null);
|
||||
};
|
||||
|
||||
const handleStatus = async () => {
|
||||
const switchVlue = statusSwitch === 1 ? 2 : 1;
|
||||
const { success } = await manageUser(item.username, 'status', switchVlue);
|
||||
if (success) {
|
||||
setStatusSwitch(switchVlue);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
handleCloseMenu();
|
||||
await manageUser(item.username, 'delete', '');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableRow tabIndex={item.id}>
|
||||
<TableCell>{item.id}</TableCell>
|
||||
|
||||
<TableCell>{item.username}</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<Label>{item.group}</Label>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<Stack direction="row" spacing={0.5} alignItems="center" justifyContent="center">
|
||||
<Tooltip title={'剩余额度'} placement="top">
|
||||
<Label color={'primary'} variant="outlined">
|
||||
{' '}
|
||||
{renderQuota(item.quota)}{' '}
|
||||
</Label>
|
||||
</Tooltip>
|
||||
<Tooltip title={'已用额度'} placement="top">
|
||||
<Label color={'primary'} variant="outlined">
|
||||
{' '}
|
||||
{renderQuota(item.used_quota)}{' '}
|
||||
</Label>
|
||||
</Tooltip>
|
||||
<Tooltip title={'请求次数'} placement="top">
|
||||
<Label color={'primary'} variant="outlined">
|
||||
{' '}
|
||||
{renderNumber(item.request_count)}{' '}
|
||||
</Label>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
<TableCell>{renderRole(item.role)}</TableCell>
|
||||
<TableCell>
|
||||
<Stack direction="row" spacing={0.5} alignItems="center" justifyContent="center">
|
||||
<Tooltip title={item.wechat_id ? item.wechat_id : '未绑定'} placement="top">
|
||||
<IconBrandWechat color={item.wechat_id ? theme.palette.success.dark : theme.palette.grey[400]} />
|
||||
</Tooltip>
|
||||
<Tooltip title={item.github_id ? item.github_id : '未绑定'} placement="top">
|
||||
<IconBrandGithub color={item.github_id ? theme.palette.grey[900] : theme.palette.grey[400]} />
|
||||
</Tooltip>
|
||||
<Tooltip title={item.email ? item.email : '未绑定'} placement="top">
|
||||
<IconMail color={item.email ? theme.palette.grey[900] : theme.palette.grey[400]} />
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
{' '}
|
||||
<TableSwitch id={`switch-${item.id}`} checked={statusSwitch === 1} onChange={handleStatus} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<IconButton onClick={handleOpenMenu} sx={{ color: 'rgb(99, 115, 129)' }}>
|
||||
<IconDotsVertical />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
<Popover
|
||||
open={!!open}
|
||||
anchorEl={open}
|
||||
onClose={handleCloseMenu}
|
||||
anchorOrigin={{ vertical: 'top', horizontal: 'left' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
PaperProps={{
|
||||
sx: { width: 140 }
|
||||
}}
|
||||
>
|
||||
{item.role !== 100 && (
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
handleCloseMenu();
|
||||
manageUser(item.username, 'role', item.role === 1 ? true : false);
|
||||
}}
|
||||
>
|
||||
<IconUser style={{ marginRight: '16px' }} />
|
||||
{item.role === 1 ? '设为管理员' : '取消管理员'}
|
||||
</MenuItem>
|
||||
)}
|
||||
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
handleCloseMenu();
|
||||
handleOpenModal();
|
||||
setModalUserId(item.id);
|
||||
}}
|
||||
>
|
||||
<IconEdit style={{ marginRight: '16px' }} />
|
||||
编辑
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleDeleteOpen} sx={{ color: 'error.main' }}>
|
||||
<IconTrash style={{ marginRight: '16px' }} />
|
||||
删除
|
||||
</MenuItem>
|
||||
</Popover>
|
||||
|
||||
<Dialog open={openDelete} onClose={handleDeleteClose}>
|
||||
<DialogTitle>删除用户</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>是否删除用户 {item.name}?</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleDeleteClose}>关闭</Button>
|
||||
<Button onClick={handleDelete} sx={{ color: 'error.main' }} autoFocus>
|
||||
删除
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
UsersTableRow.propTypes = {
|
||||
item: PropTypes.object,
|
||||
manageUser: PropTypes.func,
|
||||
handleOpenModal: PropTypes.func,
|
||||
setModalUserId: PropTypes.func
|
||||
};
|
||||
205
web/berry/src/views/User/index.js
Normal file
205
web/berry/src/views/User/index.js
Normal file
@@ -0,0 +1,205 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { showError, showSuccess } from 'utils/common';
|
||||
|
||||
import Table from '@mui/material/Table';
|
||||
import TableBody from '@mui/material/TableBody';
|
||||
import TableContainer from '@mui/material/TableContainer';
|
||||
import PerfectScrollbar from 'react-perfect-scrollbar';
|
||||
import TablePagination from '@mui/material/TablePagination';
|
||||
import LinearProgress from '@mui/material/LinearProgress';
|
||||
import ButtonGroup from '@mui/material/ButtonGroup';
|
||||
import Toolbar from '@mui/material/Toolbar';
|
||||
|
||||
import { Button, Card, Box, Stack, Container, Typography } from '@mui/material';
|
||||
import UsersTableRow from './component/TableRow';
|
||||
import UsersTableHead from './component/TableHead';
|
||||
import TableToolBar from 'ui-component/TableToolBar';
|
||||
import { API } from 'utils/api';
|
||||
import { ITEMS_PER_PAGE } from 'constants';
|
||||
import { IconRefresh, IconPlus } from '@tabler/icons-react';
|
||||
import EditeModal from './component/EditModal';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
export default function Users() {
|
||||
const [users, setUsers] = useState([]);
|
||||
const [activePage, setActivePage] = useState(0);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const [openModal, setOpenModal] = useState(false);
|
||||
const [editUserId, setEditUserId] = useState(0);
|
||||
|
||||
const loadUsers = async (startIdx) => {
|
||||
setSearching(true);
|
||||
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.splice(startIdx * ITEMS_PER_PAGE, data.length, ...data);
|
||||
setUsers(newUsers);
|
||||
}
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
setSearching(false);
|
||||
};
|
||||
|
||||
const onPaginationChange = (event, activePage) => {
|
||||
(async () => {
|
||||
if (activePage === Math.ceil(users.length / ITEMS_PER_PAGE)) {
|
||||
// In this case we have to load more data and then append them.
|
||||
await loadUsers(activePage);
|
||||
}
|
||||
setActivePage(activePage);
|
||||
})();
|
||||
};
|
||||
|
||||
const searchUsers = async (event) => {
|
||||
event.preventDefault();
|
||||
if (searchKeyword === '') {
|
||||
await loadUsers(0);
|
||||
setActivePage(0);
|
||||
return;
|
||||
}
|
||||
setSearching(true);
|
||||
const res = await API.get(`/api/user/search?keyword=${searchKeyword}`);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
setUsers(data);
|
||||
setActivePage(0);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
setSearching(false);
|
||||
};
|
||||
|
||||
const handleSearchKeyword = (event) => {
|
||||
setSearchKeyword(event.target.value);
|
||||
};
|
||||
|
||||
const manageUser = async (username, action, value) => {
|
||||
const url = '/api/user/manage';
|
||||
let data = { username: username, action: '' };
|
||||
let res;
|
||||
switch (action) {
|
||||
case 'delete':
|
||||
data.action = 'delete';
|
||||
break;
|
||||
case 'status':
|
||||
data.action = value === 1 ? 'enable' : 'disable';
|
||||
break;
|
||||
case 'role':
|
||||
data.action = value === true ? 'promote' : 'demote';
|
||||
break;
|
||||
}
|
||||
|
||||
res = await API.post(url, data);
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess('操作成功完成!');
|
||||
await loadUsers(activePage);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
|
||||
return res.data;
|
||||
};
|
||||
|
||||
// 处理刷新
|
||||
const handleRefresh = async () => {
|
||||
await loadUsers(activePage);
|
||||
};
|
||||
|
||||
const handleOpenModal = (userId) => {
|
||||
setEditUserId(userId);
|
||||
setOpenModal(true);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setOpenModal(false);
|
||||
setEditUserId(0);
|
||||
};
|
||||
|
||||
const handleOkModal = (status) => {
|
||||
if (status === true) {
|
||||
handleCloseModal();
|
||||
handleRefresh();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers(0)
|
||||
.then()
|
||||
.catch((reason) => {
|
||||
showError(reason);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" mb={5}>
|
||||
<Typography variant="h4">用户</Typography>
|
||||
|
||||
<Button variant="contained" color="primary" startIcon={<IconPlus />} onClick={() => handleOpenModal(0)}>
|
||||
新建用户
|
||||
</Button>
|
||||
</Stack>
|
||||
<Card>
|
||||
<Box component="form" onSubmit={searchUsers} noValidate>
|
||||
<TableToolBar
|
||||
filterName={searchKeyword}
|
||||
handleFilterName={handleSearchKeyword}
|
||||
placeholder={'搜索用户的ID,用户名,显示名称,以及邮箱地址...'}
|
||||
/>
|
||||
</Box>
|
||||
<Toolbar
|
||||
sx={{
|
||||
textAlign: 'right',
|
||||
height: 50,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
p: (theme) => theme.spacing(0, 1, 0, 3)
|
||||
}}
|
||||
>
|
||||
<Container>
|
||||
<ButtonGroup variant="outlined" aria-label="outlined small primary button group">
|
||||
<Button onClick={handleRefresh} startIcon={<IconRefresh width={'18px'} />}>
|
||||
刷新
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</Container>
|
||||
</Toolbar>
|
||||
{searching && <LinearProgress />}
|
||||
<PerfectScrollbar component="div">
|
||||
<TableContainer sx={{ overflow: 'unset' }}>
|
||||
<Table sx={{ minWidth: 800 }}>
|
||||
<UsersTableHead />
|
||||
<TableBody>
|
||||
{users.slice(activePage * ITEMS_PER_PAGE, (activePage + 1) * ITEMS_PER_PAGE).map((row) => (
|
||||
<UsersTableRow
|
||||
item={row}
|
||||
manageUser={manageUser}
|
||||
key={row.id}
|
||||
handleOpenModal={handleOpenModal}
|
||||
setModalUserId={setEditUserId}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</PerfectScrollbar>
|
||||
<TablePagination
|
||||
page={activePage}
|
||||
component="div"
|
||||
count={users.length + (users.length % ITEMS_PER_PAGE === 0 ? 1 : 0)}
|
||||
rowsPerPage={ITEMS_PER_PAGE}
|
||||
onPageChange={onPaginationChange}
|
||||
rowsPerPageOptions={[ITEMS_PER_PAGE]}
|
||||
/>
|
||||
</Card>
|
||||
<EditeModal open={openModal} onCancel={handleCloseModal} onOk={handleOkModal} userId={editUserId} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user