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