♻️ refactor: Refactor price module (#123) (#109) (#128)

This commit is contained in:
Buer
2024-03-28 16:53:34 +08:00
committed by GitHub
parent 646cb74154
commit a58e538c26
32 changed files with 2361 additions and 663 deletions

View File

@@ -10,7 +10,8 @@ import {
IconUser,
IconUserScan,
IconActivity,
IconBrandTelegram
IconBrandTelegram,
IconReceipt2
} from '@tabler/icons-react';
// constant
@@ -25,7 +26,8 @@ const icons = {
IconUser,
IconUserScan,
IconActivity,
IconBrandTelegram
IconBrandTelegram,
IconReceipt2
};
// ==============================|| DASHBOARD MENU ITEMS ||============================== //
@@ -112,6 +114,15 @@ const panel = {
breadcrumbs: false,
isAdmin: true
},
{
id: 'pricing',
title: '模型价格',
type: 'item',
url: '/panel/pricing',
icon: icons.IconReceipt2,
breadcrumbs: false,
isAdmin: true
},
{
id: 'setting',
title: '设置',

View File

@@ -15,6 +15,7 @@ const Profile = Loadable(lazy(() => import('views/Profile')));
const NotFoundView = Loadable(lazy(() => import('views/Error')));
const Analytics = Loadable(lazy(() => import('views/Analytics')));
const Telegram = Loadable(lazy(() => import('views/Telegram')));
const Pricing = Loadable(lazy(() => import('views/Pricing')));
// dashboard routing
const Dashboard = Loadable(lazy(() => import('views/Dashboard')));
@@ -76,6 +77,10 @@ const MainRoutes = {
{
path: 'telegram',
element: <Telegram />
},
{
path: 'pricing',
element: <Pricing />
}
]
};

View File

@@ -32,7 +32,8 @@ const defaultConfig = {
other: '',
proxy: '单独设置代理地址支持http和socks5例如http://127.0.0.1:1080',
test_model: '用于测试使用的模型,为空时无法测速,如gpt-3.5-turbo',
models: '请选择该渠道所支持的模型',
models:
'请选择该渠道所支持的模型,你也可以输入通配符*来匹配模型例如gpt-3.5*表示支持所有gpt-3.5开头的模型,*号只能在最后一位使用前面必须有字符例如gpt-3.5*是正确的,*gpt-3.5是错误的',
model_mapping:
'请输入要修改的模型映射关系格式为api请求模型ID:实际转发给渠道的模型ID使用JSON数组表示例如{"gpt-3.5": "gpt-35"}',
groups: '请选择该渠道所支持的用户组'

View File

@@ -198,12 +198,12 @@ export default function Log() {
},
{
id: 'message',
label: '提示',
label: '输入',
disableSort: true
},
{
id: 'completion',
label: '补全',
label: '输出',
disableSort: true
},
{

View File

@@ -0,0 +1,195 @@
import PropTypes from 'prop-types';
import { useState, useEffect } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Divider,
Button,
TextField,
Grid,
FormControl,
Alert,
Stack,
Typography
} from '@mui/material';
import { API } from 'utils/api';
import { showError, showSuccess } from 'utils/common';
import LoadingButton from '@mui/lab/LoadingButton';
import Label from 'ui-component/Label';
export const CheckUpdates = ({ open, onCancel, onOk, row }) => {
const [url, setUrl] = useState('https://raw.githubusercontent.com/MartialBE/one-api/prices/prices.json');
const [loading, setLoading] = useState(false);
const [updateLoading, setUpdateLoading] = useState(false);
const [newPricing, setNewPricing] = useState([]);
const [addModel, setAddModel] = useState([]);
const [diffModel, setDiffModel] = useState([]);
const handleCheckUpdates = async () => {
setLoading(true);
try {
const res = await API.get(url);
// 检测是否是一个列表
if (!Array.isArray(res.data)) {
showError('数据格式不正确');
} else {
setNewPricing(res.data);
}
} catch (err) {
console.error(err);
}
setLoading(false);
};
const syncPricing = async (overwrite) => {
setUpdateLoading(true);
if (!newPricing.length) {
showError('请先获取数据');
return;
}
if (!overwrite && !addModel.length) {
showError('没有新增模型');
return;
}
try {
overwrite = overwrite ? 'true' : 'false';
const res = await API.post('/api/prices/sync?overwrite=' + overwrite, newPricing);
const { success, message } = res.data;
if (success) {
showSuccess('操作成功完成!');
onOk(true);
} else {
showError(message);
}
} catch (err) {
console.error(err);
}
setUpdateLoading(false);
};
useEffect(() => {
const newModels = newPricing.filter((np) => !row.some((r) => r.model === np.model));
const changeModel = row.filter((r) =>
newPricing.some((np) => np.model === r.model && (np.input !== r.input || np.output !== r.output))
);
if (newModels.length > 0) {
const newModelsList = newModels.map((model) => model.model);
setAddModel(newModelsList);
} else {
setAddModel('');
}
if (changeModel.length > 0) {
const changeModelList = changeModel.map((model) => {
const newModel = newPricing.find((np) => np.model === model.model);
let changes = '';
if (model.input !== newModel.input) {
changes += `输入倍率由 ${model.input} 变为 ${newModel.input},`;
}
if (model.output !== newModel.output) {
changes += `输出倍率由 ${model.output} 变为 ${newModel.output}`;
}
return `${model.model}:${changes}`;
});
setDiffModel(changeModelList);
} else {
setDiffModel('');
}
}, [row, newPricing]);
return (
<Dialog open={open} onClose={onCancel} fullWidth maxWidth={'md'}>
<DialogTitle sx={{ margin: '0px', fontWeight: 700, lineHeight: '1.55556', padding: '24px', fontSize: '1.125rem' }}>
检查更新
</DialogTitle>
<Divider />
<DialogContent>
<Grid container justifyContent="center" alignItems="center" spacing={2}>
<Grid item xs={12} md={10}>
<FormControl fullWidth component="fieldset">
<TextField label="URL" variant="outlined" value={url} onChange={(e) => setUrl(e.target.value)} />
</FormControl>
</Grid>
<Grid item xs={12} md={2}>
<LoadingButton variant="contained" color="primary" onClick={handleCheckUpdates} loading={loading}>
获取数据
</LoadingButton>
</Grid>
{newPricing.length > 0 && (
<Grid item xs={12}>
{!addModel.length && !diffModel.length && <Alert severity="success">无更新</Alert>}
{addModel.length > 0 && (
<Alert severity="warning">
新增模型
<Stack direction="row" spacing={1} flexWrap="wrap">
{addModel.map((model) => (
<Label color="info" key={model} variant="outlined">
{model}
</Label>
))}
</Stack>
</Alert>
)}
{diffModel.length > 0 && (
<Alert severity="warning">
价格变动模型(仅供参考如果你自己修改了对应模型的价格请忽略)
{diffModel.map((model) => (
<Typography variant="button" display="block" gutterBottom key={model}>
{model}
</Typography>
))}
</Alert>
)}
<Alert severity="warning">
注意:
你可以选择覆盖或者仅添加新增如果你选择覆盖将会删除你自己添加的模型价格完全使用远程配置如果你选择仅添加新增将会只会添加
新增模型的价格
</Alert>
<Stack direction="row" justifyContent="center" spacing={1} flexWrap="wrap">
<LoadingButton
variant="contained"
color="primary"
onClick={() => {
syncPricing(true);
}}
loading={updateLoading}
>
覆盖数据
</LoadingButton>
<LoadingButton
variant="contained"
color="primary"
onClick={() => {
syncPricing(false);
}}
loading={updateLoading}
>
仅添加新增
</LoadingButton>
</Stack>
</Grid>
)}
</Grid>
</DialogContent>
<DialogActions>
<Button onClick={onCancel} color="primary">
取消
</Button>
</DialogActions>
</Dialog>
);
};
CheckUpdates.propTypes = {
open: PropTypes.bool,
row: PropTypes.array,
onCancel: PropTypes.func,
onOk: PropTypes.func
};

View File

@@ -0,0 +1,299 @@
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,
Select,
Autocomplete,
TextField,
Checkbox,
MenuItem
} from '@mui/material';
import { showSuccess, showError } from 'utils/common';
import { API } from 'utils/api';
import { createFilterOptions } from '@mui/material/Autocomplete';
import { ValueFormatter, priceType } from './util';
import CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank';
import CheckBoxIcon from '@mui/icons-material/CheckBox';
const icon = <CheckBoxOutlineBlankIcon fontSize="small" />;
const checkedIcon = <CheckBoxIcon fontSize="small" />;
const filter = createFilterOptions();
const validationSchema = Yup.object().shape({
is_edit: Yup.boolean(),
type: Yup.string().oneOf(['tokens', 'times'], '类型 错误').required('类型 不能为空'),
channel_type: Yup.number().min(1, '渠道类型 错误').required('渠道类型 不能为空'),
input: Yup.number().required('输入倍率 不能为空'),
output: Yup.number().required('输出倍率 不能为空'),
models: Yup.array().min(1, '模型 不能为空')
});
const originInputs = {
is_edit: false,
type: 'tokens',
channel_type: 1,
input: 0,
output: 0,
models: []
};
const EditModal = ({ open, pricesItem, onCancel, onOk, ownedby, noPriceModel }) => {
const theme = useTheme();
const [inputs, setInputs] = useState(originInputs);
const [selectModel, setSelectModel] = useState([]);
const submit = async (values, { setErrors, setStatus, setSubmitting }) => {
setSubmitting(true);
try {
const res = await API.post(`/api/prices/multiple`, {
original_models: inputs.models,
models: values.models,
price: {
model: 'batch',
type: values.type,
channel_type: values.channel_type,
input: values.input,
output: values.output
}
});
const { success, message } = res.data;
if (success) {
showSuccess('保存成功!');
setSubmitting(false);
setStatus({ success: true });
onOk(true);
return;
} else {
setStatus({ success: false });
showError(message);
setErrors({ submit: message });
}
} catch (error) {
setStatus({ success: false });
showError(error.message);
setErrors({ submit: error.message });
return;
}
onOk();
};
useEffect(() => {
if (pricesItem) {
setSelectModel(pricesItem.models.concat(noPriceModel));
} else {
setSelectModel(noPriceModel);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pricesItem, noPriceModel]);
useEffect(() => {
if (pricesItem) {
setInputs(pricesItem);
} else {
setInputs(originInputs);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pricesItem]);
return (
<Dialog open={open} onClose={onCancel} fullWidth maxWidth={'md'}>
<DialogTitle sx={{ margin: '0px', fontWeight: 700, lineHeight: '1.55556', padding: '24px', fontSize: '1.125rem' }}>
{pricesItem ? '编辑' : '新建'}
</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.type && errors.type)} sx={{ ...theme.typography.otherInput }}>
<InputLabel htmlFor="type-label">名称</InputLabel>
<Select
id="type-label"
label="类型"
value={values.type}
name="type"
onBlur={handleBlur}
onChange={handleChange}
MenuProps={{
PaperProps: {
style: {
maxHeight: 200
}
}
}}
>
{Object.values(priceType).map((option) => {
return (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
);
})}
</Select>
{touched.type && errors.type && (
<FormHelperText error id="helper-tex-type-label">
{errors.type}
</FormHelperText>
)}
</FormControl>
<FormControl fullWidth error={Boolean(touched.channel_type && errors.channel_type)} sx={{ ...theme.typography.otherInput }}>
<InputLabel htmlFor="channel_type-label">渠道类型</InputLabel>
<Select
id="channel_type-label"
label="渠道类型"
value={values.channel_type}
name="channel_type"
onBlur={handleBlur}
onChange={handleChange}
MenuProps={{
PaperProps: {
style: {
maxHeight: 200
}
}
}}
>
{Object.values(ownedby).map((option) => {
return (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
);
})}
</Select>
{touched.channel_type && errors.channel_type && (
<FormHelperText error id="helper-tex-channel_type-label">
{errors.channel_type}
</FormHelperText>
)}
</FormControl>
<FormControl fullWidth error={Boolean(touched.input && errors.input)} sx={{ ...theme.typography.otherInput }}>
<InputLabel htmlFor="channel-input-label">输入倍率</InputLabel>
<OutlinedInput
id="channel-input-label"
label="输入倍率"
type="number"
value={values.input}
name="input"
endAdornment={<InputAdornment position="end">{ValueFormatter(values.input)}</InputAdornment>}
onBlur={handleBlur}
onChange={handleChange}
aria-describedby="helper-text-channel-input-label"
/>
{touched.input && errors.input && (
<FormHelperText error id="helper-tex-channel-input-label">
{errors.input}
</FormHelperText>
)}
</FormControl>
<FormControl fullWidth error={Boolean(touched.output && errors.output)} sx={{ ...theme.typography.otherInput }}>
<InputLabel htmlFor="channel-output-label">输出倍率</InputLabel>
<OutlinedInput
id="channel-output-label"
label="输出倍率"
type="number"
value={values.output}
name="output"
endAdornment={<InputAdornment position="end">{ValueFormatter(values.output)}</InputAdornment>}
onBlur={handleBlur}
onChange={handleChange}
aria-describedby="helper-text-channel-output-label"
/>
{touched.output && errors.output && (
<FormHelperText error id="helper-tex-channel-output-label">
{errors.output}
</FormHelperText>
)}
</FormControl>
<FormControl fullWidth sx={{ ...theme.typography.otherInput }}>
<Autocomplete
multiple
freeSolo
id="channel-models-label"
options={selectModel}
value={values.models}
onChange={(e, value) => {
const event = {
target: {
name: 'models',
value: value
}
};
handleChange(event);
}}
onBlur={handleBlur}
// filterSelectedOptions
disableCloseOnSelect
renderInput={(params) => <TextField {...params} name="models" error={Boolean(errors.models)} 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;
}}
renderOption={(props, option, { selected }) => (
<li {...props}>
<Checkbox icon={icon} checkedIcon={checkedIcon} style={{ marginRight: 8 }} checked={selected} />
{option}
</li>
)}
/>
{errors.models ? (
<FormHelperText error id="helper-tex-channel-models-label">
{errors.models}
</FormHelperText>
) : (
<FormHelperText id="helper-tex-channel-models-label">
{' '}
请选择该价格所支持的模型,你也可以输入通配符*来匹配模型例如gpt-3.5*表示支持所有gpt-3.5开头的模型*号只能在最后一位使用前面必须有字符例如gpt-3.5*是正确的*gpt-3.5是错误的{' '}
</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,
pricesItem: PropTypes.object,
onCancel: PropTypes.func,
onOk: PropTypes.func,
ownedby: PropTypes.array,
noPriceModel: PropTypes.array
};

View File

@@ -0,0 +1,156 @@
import PropTypes from 'prop-types';
import { useState } from 'react';
import {
Popover,
TableRow,
MenuItem,
TableCell,
IconButton,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
Collapse,
Grid,
Box,
Typography,
Button
} from '@mui/material';
import { IconDotsVertical, IconEdit, IconTrash } from '@tabler/icons-react';
import { ValueFormatter, priceType } from './util';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
import Label from 'ui-component/Label';
import { copy } from 'utils/common';
export default function PricesTableRow({ item, managePrices, handleOpenModal, setModalPricesItem, ownedby }) {
const [open, setOpen] = useState(null);
const [openRow, setOpenRow] = useState(false);
const [openDelete, setOpenDelete] = useState(false);
const type_label = priceType.find((pt) => pt.value === item.type);
const channel_label = ownedby.find((ob) => ob.value === item.channel_type);
const handleDeleteOpen = () => {
handleCloseMenu();
setOpenDelete(true);
};
const handleDeleteClose = () => {
setOpenDelete(false);
};
const handleOpenMenu = (event) => {
setOpen(event.currentTarget);
};
const handleCloseMenu = () => {
setOpen(null);
};
const handleDelete = async () => {
handleDeleteClose();
await managePrices(item, 'delete', '');
};
return (
<>
<TableRow tabIndex={item.id} onClick={() => setOpenRow(!openRow)}>
<TableCell>
<IconButton aria-label="expand row" size="small" onClick={() => setOpenRow(!openRow)}>
{openRow ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}
</IconButton>
</TableCell>
<TableCell>{type_label?.label}</TableCell>
<TableCell>{channel_label?.label}</TableCell>
<TableCell>{ValueFormatter(item.input)}</TableCell>
<TableCell>{ValueFormatter(item.output)}</TableCell>
<TableCell>{item.models.length}</TableCell>
<TableCell onClick={(event) => event.stopPropagation()}>
<IconButton onClick={handleOpenMenu} sx={{ color: 'rgb(99, 115, 129)' }}>
<IconDotsVertical />
</IconButton>
</TableCell>
</TableRow>
<TableRow>
<TableCell style={{ paddingBottom: 0, paddingTop: 0, textAlign: 'left' }} colSpan={10}>
<Collapse in={openRow} timeout="auto" unmountOnExit>
<Grid container spacing={1}>
<Grid item xs={12}>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: '10px', margin: 1 }}>
<Typography variant="h6" gutterBottom component="div">
可用模型:
</Typography>
{item.models.map((model) => (
<Label
variant="outlined"
color="primary"
key={model}
onClick={() => {
copy(model, '模型名称');
}}
>
{model}
</Label>
))}
</Box>
</Grid>
</Grid>
</Collapse>
</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();
setModalPricesItem(item);
}}
>
<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>是否删除价格组</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={handleDeleteClose}>关闭</Button>
<Button onClick={handleDelete} sx={{ color: 'error.main' }} autoFocus>
删除
</Button>
</DialogActions>
</Dialog>
</>
);
}
PricesTableRow.propTypes = {
item: PropTypes.object,
managePrices: PropTypes.func,
handleOpenModal: PropTypes.func,
setModalPricesItem: PropTypes.func,
priceType: PropTypes.array,
ownedby: PropTypes.array
};

View File

@@ -0,0 +1,11 @@
export const priceType = [
{ value: 'tokens', label: '按Token收费' },
{ value: 'times', label: '按次收费' }
];
export function ValueFormatter(value) {
if (value == null) {
return '';
}
return `$${parseFloat(value * 0.002).toFixed(4)} / ¥${parseFloat(value * 0.014).toFixed(4)}`;
}

View File

@@ -0,0 +1,221 @@
import { useState, useEffect, useMemo, useCallback } from 'react';
import PropTypes from 'prop-types';
import { Tabs, Tab, Box, Card, Alert, Stack, Button } from '@mui/material';
import { IconTag, IconTags } from '@tabler/icons-react';
import Single from './single';
import Multiple from './multiple';
import { useLocation, useNavigate } from 'react-router-dom';
import AdminContainer from 'ui-component/AdminContainer';
import { API } from 'utils/api';
import { showError } from 'utils/common';
import { CheckUpdates } from './component/CheckUpdates';
function CustomTabPanel(props) {
const { children, value, index, ...other } = props;
return (
<div role="tabpanel" hidden={value !== index} id={`pricing-tabpanel-${index}`} aria-labelledby={`pricing-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: `pricing-tab-${index}`,
'aria-controls': `pricing-tabpanel-${index}`
};
}
const Pricing = () => {
const [ownedby, setOwnedby] = useState([]);
const [modelList, setModelList] = useState([]);
const [openModal, setOpenModal] = useState(false);
const [errPrices, setErrPrices] = useState('');
const [prices, setPrices] = useState([]);
const [noPriceModel, setNoPriceModel] = useState([]);
const location = useLocation();
const navigate = useNavigate();
const hash = location.hash.replace('#', '');
const tabMap = useMemo(
() => ({
single: 0,
multiple: 1
}),
[]
);
const [value, setValue] = useState(tabMap[hash] || 0);
const handleChange = (event, newValue) => {
setValue(newValue);
const hashArray = Object.keys(tabMap);
navigate(`#${hashArray[newValue]}`);
};
const reloadData = () => {
fetchModelList();
fetchPrices();
};
const handleOkModal = (status) => {
if (status === true) {
reloadData();
setOpenModal(false);
}
};
useEffect(() => {
const missingModels = modelList.filter((model) => !prices.some((price) => price.model === model));
setNoPriceModel(missingModels);
}, [modelList, prices]);
useEffect(() => {
// check if there is any price that is not valid
const invalidPrices = prices.filter((price) => price.channel_type <= 0);
if (invalidPrices.length > 0) {
setErrPrices(invalidPrices.map((price) => price.model).join(', '));
} else {
setErrPrices('');
}
}, [prices]);
const fetchOwnedby = useCallback(async () => {
try {
const res = await API.get('/api/ownedby');
const { success, message, data } = res.data;
if (success) {
let ownedbyList = [];
for (let key in data) {
ownedbyList.push({ value: parseInt(key), label: data[key] });
}
setOwnedby(ownedbyList);
} else {
showError(message);
}
} catch (error) {
console.error(error);
}
}, []);
const fetchModelList = useCallback(async () => {
try {
const res = await API.get('/api/prices/model_list');
const { success, message, data } = res.data;
if (success) {
setModelList(data);
} else {
showError(message);
}
} catch (error) {
console.error(error);
}
}, []);
const fetchPrices = useCallback(async () => {
try {
const res = await API.get('/api/prices');
const { success, message, data } = res.data;
if (success) {
setPrices(data);
} else {
showError(message);
}
} catch (error) {
console.error(error);
}
}, []);
useEffect(() => {
const handleHashChange = () => {
const hash = location.hash.replace('#', '');
setValue(tabMap[hash] || 0);
};
window.addEventListener('hashchange', handleHashChange);
return () => {
window.removeEventListener('hashchange', handleHashChange);
};
}, [location, tabMap, fetchOwnedby]);
useEffect(() => {
const fetchData = async () => {
try {
await Promise.all([fetchOwnedby(), fetchModelList()]);
fetchPrices();
} catch (error) {
console.error(error);
}
};
fetchData();
}, [fetchOwnedby, fetchModelList, fetchPrices]);
return (
<Stack spacing={3}>
<Alert severity="info">
<b>美元</b>1 === $0.002 / 1K tokens <b>人民币</b> 1 === ¥0.014 / 1k tokens
<br /> <b>例如</b><br /> gpt-4 输入 $0.03 / 1K tokens 完成$0.06 / 1K tokens <br />
0.03 / 0.002 = 15, 0.06 / 0.002 = 30即输入倍率为 15完成倍率为 30
</Alert>
{noPriceModel.length > 0 && (
<Alert severity="warning">
<b>存在未配置价格的模型请及时配置价格</b>
{noPriceModel.map((model) => (
<span key={model}>{model}, </span>
))}
</Alert>
)}
{errPrices && (
<Alert severity="warning">
<b>存在供应商类型错误的模型请及时配置</b>{errPrices}
</Alert>
)}
<Stack direction="row" alignItems="center" justifyContent="flex-end" mb={5} spacing={2}>
<Button
variant="contained"
onClick={() => {
setOpenModal(true);
}}
>
更新价格
</Button>
</Stack>
<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={<IconTag />} iconPosition="start" />
<Tab label="合并操作" {...a11yProps(1)} icon={<IconTags />} iconPosition="start" />
</Tabs>
</Box>
<CustomTabPanel value={value} index={0}>
<Single ownedby={ownedby} reloadData={reloadData} prices={prices} />
</CustomTabPanel>
<CustomTabPanel value={value} index={1}>
<Multiple ownedby={ownedby} reloadData={reloadData} prices={prices} noPriceModel={noPriceModel} />
</CustomTabPanel>
</Box>
</AdminContainer>
</Card>
<CheckUpdates
open={openModal}
onCancel={() => {
setOpenModal(false);
}}
row={prices}
onOk={handleOkModal}
/>
</Stack>
);
};
export default Pricing;

View File

@@ -0,0 +1,149 @@
import PropTypes from 'prop-types';
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 { Button, Card, Stack } from '@mui/material';
import PricesTableRow from './component/TableRow';
import KeywordTableHead from 'ui-component/TableHead';
import { API } from 'utils/api';
import { IconRefresh, IconPlus } from '@tabler/icons-react';
import EditeModal from './component/EditModal';
// ----------------------------------------------------------------------
export default function Multiple({ ownedby, prices, reloadData, noPriceModel }) {
const [rows, setRows] = useState([]);
const [openModal, setOpenModal] = useState(false);
const [editPricesItem, setEditPricesItem] = useState(null);
// 处理刷新
const handleRefresh = async () => {
reloadData();
};
useEffect(() => {
const grouped = prices.reduce((acc, item, index) => {
const key = `${item.type}-${item.channel_type}-${item.input}-${item.output}`;
if (!acc[key]) {
acc[key] = {
...item,
models: [item.model],
id: index + 1
};
} else {
acc[key].models.push(item.model);
}
return acc;
}, {});
setRows(Object.values(grouped));
}, [prices]);
const managePrices = async (item, action) => {
let res;
try {
switch (action) {
case 'delete':
res = await API.put('/api/prices/multiple/delete', {
models: item.models
});
break;
}
const { success, message } = res.data;
if (success) {
showSuccess('操作成功完成!');
if (action === 'delete') {
await handleRefresh();
}
} else {
showError(message);
}
return res.data;
} catch (error) {
return;
}
};
const handleOpenModal = (item) => {
setEditPricesItem(item);
setOpenModal(true);
};
const handleCloseModal = () => {
setOpenModal(false);
setEditPricesItem(null);
};
const handleOkModal = (status) => {
if (status === true) {
handleCloseModal();
handleRefresh();
}
};
return (
<>
<Stack direction="row" alignItems="center" justifyContent="flex-start" mb={5} spacing={2}>
<Button variant="contained" color="primary" startIcon={<IconPlus />} onClick={() => handleOpenModal(0)}>
新建
</Button>
<Button variant="contained" onClick={handleRefresh} startIcon={<IconRefresh width={'18px'} />}>
刷新
</Button>
</Stack>
<Card>
<PerfectScrollbar component="div">
<TableContainer sx={{ overflow: 'unset' }}>
<Table sx={{ minWidth: 800 }}>
<KeywordTableHead
headLabel={[
{ id: 'collapse', label: '', disableSort: true },
{ id: 'type', label: '类型', disableSort: true },
{ id: 'channel_type', label: '供应商', disableSort: true },
{ id: 'input', label: '输入倍率', disableSort: true },
{ id: 'output', label: '输出倍率', disableSort: true },
{ id: 'count', label: '模型数量', disableSort: true },
{ id: 'action', label: '操作', disableSort: true }
]}
/>
<TableBody>
{rows.map((row) => (
<PricesTableRow
item={row}
managePrices={managePrices}
key={row.id}
handleOpenModal={handleOpenModal}
setModalPricesItem={setEditPricesItem}
ownedby={ownedby}
/>
))}
</TableBody>
</Table>
</TableContainer>
</PerfectScrollbar>
</Card>
<EditeModal
open={openModal}
onCancel={handleCloseModal}
onOk={handleOkModal}
pricesItem={editPricesItem}
ownedby={ownedby}
noPriceModel={noPriceModel}
/>
</>
);
}
Multiple.propTypes = {
prices: PropTypes.array,
ownedby: PropTypes.array,
reloadData: PropTypes.func,
noPriceModel: PropTypes.array
};

View File

@@ -1,19 +1,30 @@
import PropTypes from 'prop-types';
import { useState, useEffect, useMemo, useCallback } from 'react';
import { GridRowModes, DataGrid, GridToolbarContainer, GridActionsCellItem } from '@mui/x-data-grid';
import { Box, Button } from '@mui/material';
import { Box, Button, Dialog, DialogActions, DialogContent, DialogTitle } from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/DeleteOutlined';
import SaveIcon from '@mui/icons-material/Save';
import CancelIcon from '@mui/icons-material/Close';
import { showError } from 'utils/common';
import { showError, showSuccess } from 'utils/common';
import { API } from 'utils/api';
import { ValueFormatter, priceType } from './component/util';
function validation(row, rows) {
if (row.model === '') {
return '模型名称不能为空';
}
// 判断 type 是否是 等于 tokens || times
if (row.type !== 'tokens' && row.type !== 'times') {
return '类型只能是tokens或times';
}
if (row.channel_type <= 0) {
return '所属渠道类型错误';
}
// 判断 model是否是唯一值
if (rows.filter((r) => r.model === row.model && (row.isNew || r.id !== row.id)).length > 0) {
return '模型名称不能重复';
@@ -22,8 +33,8 @@ function validation(row, rows) {
if (row.input === '' || row.input < 0) {
return '输入倍率必须大于等于0';
}
if (row.complete === '' || row.complete < 0) {
return '完成倍率必须大于等于0';
if (row.output === '' || row.output < 0) {
return '输出倍率必须大于等于0';
}
return false;
}
@@ -35,7 +46,7 @@ function randomId() {
function EditToolbar({ setRows, setRowModesModel }) {
const handleClick = () => {
const id = randomId();
setRows((oldRows) => [{ id, model: '', input: 0, complete: 0, isNew: true }, ...oldRows]);
setRows((oldRows) => [{ id, model: '', type: 'tokens', channel_type: 1, input: 0, output: 0, isNew: true }, ...oldRows]);
setRowModesModel((oldModel) => ({
[id]: { mode: GridRowModes.Edit, fieldToFocus: 'name' },
...oldModel
@@ -56,19 +67,33 @@ EditToolbar.propTypes = {
setRowModesModel: PropTypes.func.isRequired
};
const ModelRationDataGrid = ({ ratio, onChange }) => {
const Single = ({ ownedby, prices, reloadData }) => {
const [rows, setRows] = useState([]);
const [rowModesModel, setRowModesModel] = useState({});
const [selectedRow, setSelectedRow] = useState(null);
const setRatio = useCallback(
(ratioRow) => {
let ratioJson = {};
ratioRow.forEach((row) => {
ratioJson[row.model] = [row.input, row.complete];
});
onChange({ target: { name: 'ModelRatio', value: JSON.stringify(ratioJson, null, 2) } });
const addOrUpdatePirces = useCallback(
async (newRow, oldRow, reject, resolve) => {
try {
let res;
if (oldRow.model == '') {
res = await API.post('/api/prices/single', newRow);
} else {
res = await API.put('/api/prices/single/' + oldRow.model, newRow);
}
const { success, message } = res.data;
if (success) {
showSuccess('保存成功');
resolve(newRow);
reloadData();
} else {
reject(new Error(message));
}
} catch (error) {
reject(new Error(error));
}
},
[onChange]
[reloadData]
);
const handleEditClick = useCallback(
@@ -87,11 +112,21 @@ const ModelRationDataGrid = ({ ratio, onChange }) => {
const handleDeleteClick = useCallback(
(id) => () => {
setRatio(rows.filter((row) => row.id !== id));
setSelectedRow(rows.find((row) => row.id === id));
},
[rows, setRatio]
[rows]
);
const handleClose = () => {
setSelectedRow(null);
};
const handleConfirmDelete = async () => {
// 执行删除操作
await deletePirces(selectedRow.model);
setSelectedRow(null);
};
const handleCancelClick = useCallback(
(id) => () => {
setRowModesModel({
@@ -107,18 +142,30 @@ const ModelRationDataGrid = ({ ratio, onChange }) => {
[rowModesModel, rows]
);
const processRowUpdate = (newRow, oldRows) => {
if (!newRow.isNew && newRow.model === oldRows.model && newRow.input === oldRows.input && newRow.complete === oldRows.complete) {
return oldRows;
}
const updatedRow = { ...newRow, isNew: false };
const error = validation(updatedRow, rows);
if (error) {
return Promise.reject(new Error(error));
}
setRatio(rows.map((row) => (row.id === newRow.id ? updatedRow : row)));
return updatedRow;
};
const processRowUpdate = useCallback(
(newRow, oldRows) =>
new Promise((resolve, reject) => {
if (
!newRow.isNew &&
newRow.model === oldRows.model &&
newRow.input === oldRows.input &&
newRow.output === oldRows.output &&
newRow.type === oldRows.type &&
newRow.channel_type === oldRows.channel_type
) {
return resolve(oldRows);
}
const updatedRow = { ...newRow, isNew: false };
const error = validation(updatedRow, rows);
if (error) {
return reject(new Error(error));
}
const response = addOrUpdatePirces(updatedRow, oldRows, reject, resolve);
return response;
}),
[rows, addOrUpdatePirces]
);
const handleProcessRowUpdateError = useCallback((error) => {
showError(error.message);
@@ -138,6 +185,26 @@ const ModelRationDataGrid = ({ ratio, onChange }) => {
editable: true,
hideable: false
},
{
field: 'type',
sortable: true,
headerName: '类型',
width: 220,
type: 'singleSelect',
valueOptions: priceType,
editable: true,
hideable: false
},
{
field: 'channel_type',
sortable: true,
headerName: '供应商',
width: 220,
type: 'singleSelect',
valueOptions: ownedby,
editable: true,
hideable: false
},
{
field: 'input',
sortable: false,
@@ -145,27 +212,17 @@ const ModelRationDataGrid = ({ ratio, onChange }) => {
width: 150,
type: 'number',
editable: true,
valueFormatter: (params) => {
if (params.value == null) {
return '';
}
return `$${parseFloat(params.value * 0.002).toFixed(4)} / ¥${parseFloat(params.value * 0.014).toFixed(4)}`;
},
valueFormatter: (params) => ValueFormatter(params.value),
hideable: false
},
{
field: 'complete',
field: 'output',
sortable: false,
headerName: '完成倍率',
headerName: '输出倍率',
width: 150,
type: 'number',
editable: true,
valueFormatter: (params) => {
if (params.value == null) {
return '';
}
return `$${parseFloat(params.value * 0.002).toFixed(4)} / ¥${parseFloat(params.value * 0.014).toFixed(4)}`;
},
valueFormatter: (params) => ValueFormatter(params.value),
hideable: false
},
{
@@ -220,18 +277,32 @@ const ModelRationDataGrid = ({ ratio, onChange }) => {
}
}
],
[handleEditClick, handleSaveClick, handleDeleteClick, handleCancelClick, rowModesModel]
[handleCancelClick, handleDeleteClick, handleEditClick, handleSaveClick, rowModesModel, ownedby]
);
const deletePirces = async (modelName) => {
try {
const res = await API.delete('/api/prices/single/' + modelName);
const { success, message } = res.data;
if (success) {
showSuccess('保存成功');
await reloadData();
} else {
showError(message);
}
} catch (error) {
console.error(error);
}
};
useEffect(() => {
let modelRatioList = [];
let itemJson = JSON.parse(ratio);
let id = 0;
for (let key in itemJson) {
modelRatioList.push({ id: id++, model: key, input: itemJson[key][0], complete: itemJson[key][1] });
for (let key in prices) {
modelRatioList.push({ id: id++, ...prices[key] });
}
setRows(modelRatioList);
}, [ratio]);
}, [prices]);
return (
<Box
@@ -256,6 +327,14 @@ const ModelRationDataGrid = ({ ratio, onChange }) => {
onRowModesModelChange={handleRowModesModelChange}
processRowUpdate={processRowUpdate}
onProcessRowUpdateError={handleProcessRowUpdateError}
// onCellDoubleClick={(params, event) => {
// event.defaultMuiPrevented = true;
// }}
onRowEditStop={(params, event) => {
if (params.reason === 'rowFocusOut') {
event.defaultMuiPrevented = true;
}
}}
slots={{
toolbar: EditToolbar
}}
@@ -263,13 +342,27 @@ const ModelRationDataGrid = ({ ratio, onChange }) => {
toolbar: { setRows, setRowModesModel }
}}
/>
<Dialog
maxWidth="xs"
// TransitionProps={{ onEntered: handleEntered }}
open={!!selectedRow}
>
<DialogTitle>确定删除?</DialogTitle>
<DialogContent dividers>{`确定删除 ${selectedRow?.model} 吗?`}</DialogContent>
<DialogActions>
<Button onClick={handleClose}>取消</Button>
<Button onClick={handleConfirmDelete}>删除</Button>
</DialogActions>
</Dialog>
</Box>
);
};
ModelRationDataGrid.propTypes = {
ratio: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired
};
export default Single;
export default ModelRationDataGrid;
Single.propTypes = {
prices: PropTypes.array,
ownedby: PropTypes.array,
reloadData: PropTypes.func
};

View File

@@ -1,12 +1,11 @@
import { useState, useEffect } from 'react';
import SubCard from 'ui-component/cards/SubCard';
import { Stack, FormControl, InputLabel, OutlinedInput, Checkbox, Button, FormControlLabel, TextField, Alert, Switch } from '@mui/material';
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 ModelRationDataGrid from './ModelRationDataGrid';
import dayjs from 'dayjs';
require('dayjs/locale/zh-cn');
@@ -18,7 +17,6 @@ const OperationSetting = () => {
QuotaForInvitee: 0,
QuotaRemindThreshold: 0,
PreConsumedQuota: 0,
ModelRatio: '',
GroupRatio: '',
TopUpLink: '',
ChatLink: '',
@@ -34,7 +32,6 @@ const OperationSetting = () => {
RetryCooldownSeconds: 0
});
const [originInputs, setOriginInputs] = useState({});
const [newModelRatioView, setNewModelRatioView] = useState(false);
let [loading, setLoading] = useState(false);
let [historyTimestamp, setHistoryTimestamp] = useState(now.getTime() / 1000 - 30 * 24 * 3600); // a month ago new Date().getTime() / 1000 + 3600
@@ -45,7 +42,7 @@ const OperationSetting = () => {
if (success) {
let newInputs = {};
data.forEach((item) => {
if (item.key === 'ModelRatio' || item.key === 'GroupRatio') {
if (item.key === 'GroupRatio') {
item.value = JSON.stringify(JSON.parse(item.value), null, 2);
}
newInputs[item.key] = item.value;
@@ -110,13 +107,6 @@ const OperationSetting = () => {
}
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 字符串');
@@ -469,44 +459,6 @@ const OperationSetting = () => {
/>
</FormControl>
<FormControl fullWidth>
<Alert severity="info">
配置格式为 JSON 文本键为模型名称值第一位为输入倍率第二位为完成倍率如果只有单一倍率则两者值相同
<br /> <b>美元</b>1 === $0.002 / 1K tokens <b>人民币</b> 1 === ¥0.014 / 1k tokens
<br /> <b>例如</b><br /> gpt-4 输入 $0.03 / 1K tokens 完成$0.06 / 1K tokens <br />
0.03 / 0.002 = 15, 0.06 / 0.002 = 30即输入倍率为 15完成倍率为 30
</Alert>
<FormControlLabel
control={
<Switch
checked={newModelRatioView}
onChange={() => {
setNewModelRatioView(!newModelRatioView);
}}
/>
}
label="使用新编辑器"
/>
</FormControl>
{newModelRatioView ? (
<ModelRationDataGrid ratio={inputs.ModelRatio} onChange={handleInputChange} />
) : (
<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>
)}
<Button
variant="contained"
onClick={() => {