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