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:
Buer
2024-01-07 14:20:07 +08:00
committed by GitHub
parent 6227eee5bc
commit 48989d4a0b
157 changed files with 13979 additions and 5 deletions

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View File

@@ -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;

View File

@@ -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;

View 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
};

View 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;

View 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,
};

View 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;

View 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;

View 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;

View 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;

View 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>;
}
}

View 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} />
</>
);
}

View 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 };

View 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: []
};

View 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;

View File

@@ -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;

View 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 };
}

View 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>
</>
);
}

View 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;

View 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;

View 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
};

View 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
};

View 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,
};

View 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>
</>
);
}

View 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;

View 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,
};

View 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);
}}
/>
</>
);
}

View 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
};

View 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;

View 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
};

View 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} />
</>
);
}

View 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;

View 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;

View 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;

View 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;

View 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,
};

View 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;

View 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
};

View 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} />
</>
);
}

View 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;

View 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;

View 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;

View 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
};

View 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;

View 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
};

View 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} />
</>
);
}