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