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