Summary • Introduced a unified `selectFilter` helper that matches both `option.value` and `option.label`, ensuring all `<Select>` components support intuitive search (fixes channel “type” dropdown not filtering). • Replaced all usages of the old `modelSelectFilter` with `selectFilter` in: • `EditChannelModal.jsx` • `SettingsPanel.js` • `EditTokenModal.jsx` • `EditTagModal.jsx` • Removed the deprecated `modelSelectFilter` export from `utils.js` (no backward-compat alias). • Updated documentation comments accordingly. Why The old filter only inspected `option.value`, causing searches to fail when `label` carried the meaningful text (e.g., numeric IDs for channel types). The new helper searches both fields, covering all scenarios and unifying the API across the codebase. Notes No functional regressions expected; all components have been migrated.
546 lines
19 KiB
JavaScript
546 lines
19 KiB
JavaScript
/*
|
||
Copyright (C) 2025 QuantumNous
|
||
|
||
This program is free software: you can redistribute it and/or modify
|
||
it under the terms of the GNU Affero General Public License as
|
||
published by the Free Software Foundation, either version 3 of the
|
||
License, or (at your option) any later version.
|
||
|
||
This program is distributed in the hope that it will be useful,
|
||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||
GNU Affero General Public License for more details.
|
||
|
||
You should have received a copy of the GNU Affero General Public License
|
||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||
|
||
For commercial licensing, please contact support@quantumnous.com
|
||
*/
|
||
|
||
import React, { useEffect, useState, useContext, useRef } from 'react';
|
||
import {
|
||
API,
|
||
showError,
|
||
showSuccess,
|
||
timestamp2string,
|
||
renderGroupOption,
|
||
renderQuotaWithPrompt,
|
||
getModelCategories,
|
||
selectFilter,
|
||
} from '../../../../helpers';
|
||
import { useIsMobile } from '../../../../hooks/common/useIsMobile.js';
|
||
import {
|
||
Button,
|
||
SideSheet,
|
||
Space,
|
||
Spin,
|
||
Typography,
|
||
Card,
|
||
Tag,
|
||
Avatar,
|
||
Form,
|
||
Col,
|
||
Row,
|
||
} from '@douyinfe/semi-ui';
|
||
import {
|
||
IconCreditCard,
|
||
IconLink,
|
||
IconSave,
|
||
IconClose,
|
||
IconKey,
|
||
} from '@douyinfe/semi-icons';
|
||
import { useTranslation } from 'react-i18next';
|
||
import { StatusContext } from '../../../../context/Status';
|
||
|
||
const { Text, Title } = Typography;
|
||
|
||
const EditTokenModal = (props) => {
|
||
const { t } = useTranslation();
|
||
const [statusState, statusDispatch] = useContext(StatusContext);
|
||
const [loading, setLoading] = useState(false);
|
||
const isMobile = useIsMobile();
|
||
const formApiRef = useRef(null);
|
||
const [models, setModels] = useState([]);
|
||
const [groups, setGroups] = useState([]);
|
||
const isEdit = props.editingToken.id !== undefined;
|
||
|
||
const getInitValues = () => ({
|
||
name: '',
|
||
remain_quota: 500000,
|
||
expired_time: -1,
|
||
unlimited_quota: false,
|
||
model_limits_enabled: false,
|
||
model_limits: [],
|
||
allow_ips: '',
|
||
group: '',
|
||
tokenCount: 1,
|
||
});
|
||
|
||
const handleCancel = () => {
|
||
props.handleClose();
|
||
};
|
||
|
||
const setExpiredTime = (month, day, hour, minute) => {
|
||
let now = new Date();
|
||
let timestamp = now.getTime() / 1000;
|
||
let seconds = month * 30 * 24 * 60 * 60;
|
||
seconds += day * 24 * 60 * 60;
|
||
seconds += hour * 60 * 60;
|
||
seconds += minute * 60;
|
||
if (!formApiRef.current) return;
|
||
if (seconds !== 0) {
|
||
timestamp += seconds;
|
||
formApiRef.current.setValue('expired_time', timestamp2string(timestamp));
|
||
} else {
|
||
formApiRef.current.setValue('expired_time', -1);
|
||
}
|
||
};
|
||
|
||
const loadModels = async () => {
|
||
let res = await API.get(`/api/user/models`);
|
||
const { success, message, data } = res.data;
|
||
if (success) {
|
||
const categories = getModelCategories(t);
|
||
let localModelOptions = data.map((model) => {
|
||
let icon = null;
|
||
for (const [key, category] of Object.entries(categories)) {
|
||
if (key !== 'all' && category.filter({ model_name: model })) {
|
||
icon = category.icon;
|
||
break;
|
||
}
|
||
}
|
||
return {
|
||
label: (
|
||
<span className="flex items-center gap-1">
|
||
{icon}
|
||
{model}
|
||
</span>
|
||
),
|
||
value: model,
|
||
};
|
||
});
|
||
setModels(localModelOptions);
|
||
} else {
|
||
showError(t(message));
|
||
}
|
||
};
|
||
|
||
const loadGroups = async () => {
|
||
let res = await API.get(`/api/user/self/groups`);
|
||
const { success, message, data } = res.data;
|
||
if (success) {
|
||
let localGroupOptions = Object.entries(data).map(([group, info]) => ({
|
||
label: info.desc,
|
||
value: group,
|
||
ratio: info.ratio,
|
||
}));
|
||
if (statusState?.status?.default_use_auto_group) {
|
||
if (localGroupOptions.some((group) => group.value === 'auto')) {
|
||
localGroupOptions.sort((a, b) => (a.value === 'auto' ? -1 : 1));
|
||
} else {
|
||
localGroupOptions.unshift({ label: t('自动选择'), value: 'auto' });
|
||
}
|
||
}
|
||
setGroups(localGroupOptions);
|
||
if (statusState?.status?.default_use_auto_group && formApiRef.current) {
|
||
formApiRef.current.setValue('group', 'auto');
|
||
}
|
||
} else {
|
||
showError(t(message));
|
||
}
|
||
};
|
||
|
||
const loadToken = async () => {
|
||
setLoading(true);
|
||
let res = await API.get(`/api/token/${props.editingToken.id}`);
|
||
const { success, message, data } = res.data;
|
||
if (success) {
|
||
if (data.expired_time !== -1) {
|
||
data.expired_time = timestamp2string(data.expired_time);
|
||
}
|
||
if (data.model_limits !== '') {
|
||
data.model_limits = data.model_limits.split(',');
|
||
} else {
|
||
data.model_limits = [];
|
||
}
|
||
if (formApiRef.current) {
|
||
formApiRef.current.setValues({ ...getInitValues(), ...data });
|
||
}
|
||
} else {
|
||
showError(message);
|
||
}
|
||
setLoading(false);
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (formApiRef.current) {
|
||
if (!isEdit) {
|
||
formApiRef.current.setValues(getInitValues());
|
||
}
|
||
}
|
||
loadModels();
|
||
loadGroups();
|
||
}, [props.editingToken.id]);
|
||
|
||
useEffect(() => {
|
||
if (props.visiable) {
|
||
if (isEdit) {
|
||
loadToken();
|
||
} else {
|
||
formApiRef.current?.setValues(getInitValues());
|
||
}
|
||
} else {
|
||
formApiRef.current?.reset();
|
||
}
|
||
}, [props.visiable, props.editingToken.id]);
|
||
|
||
const generateRandomSuffix = () => {
|
||
const characters =
|
||
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||
let result = '';
|
||
for (let i = 0; i < 6; i++) {
|
||
result += characters.charAt(
|
||
Math.floor(Math.random() * characters.length),
|
||
);
|
||
}
|
||
return result;
|
||
};
|
||
|
||
const submit = async (values) => {
|
||
setLoading(true);
|
||
if (isEdit) {
|
||
let { tokenCount: _tc, ...localInputs } = values;
|
||
localInputs.remain_quota = parseInt(localInputs.remain_quota);
|
||
if (localInputs.expired_time !== -1) {
|
||
let time = Date.parse(localInputs.expired_time);
|
||
if (isNaN(time)) {
|
||
showError(t('过期时间格式错误!'));
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
localInputs.expired_time = Math.ceil(time / 1000);
|
||
}
|
||
localInputs.model_limits = localInputs.model_limits.join(',');
|
||
localInputs.model_limits_enabled = localInputs.model_limits.length > 0;
|
||
let res = await API.put(`/api/token/`, {
|
||
...localInputs,
|
||
id: parseInt(props.editingToken.id),
|
||
});
|
||
const { success, message } = res.data;
|
||
if (success) {
|
||
showSuccess(t('令牌更新成功!'));
|
||
props.refresh();
|
||
props.handleClose();
|
||
} else {
|
||
showError(t(message));
|
||
}
|
||
} else {
|
||
const count = parseInt(values.tokenCount, 10) || 1;
|
||
let successCount = 0;
|
||
for (let i = 0; i < count; i++) {
|
||
let { tokenCount: _tc, ...localInputs } = values;
|
||
const baseName = values.name.trim() === '' ? 'default' : values.name.trim();
|
||
if (i !== 0 || values.name.trim() === '') {
|
||
localInputs.name = `${baseName}-${generateRandomSuffix()}`;
|
||
} else {
|
||
localInputs.name = baseName;
|
||
}
|
||
localInputs.remain_quota = parseInt(localInputs.remain_quota);
|
||
|
||
if (localInputs.expired_time !== -1) {
|
||
let time = Date.parse(localInputs.expired_time);
|
||
if (isNaN(time)) {
|
||
showError(t('过期时间格式错误!'));
|
||
setLoading(false);
|
||
break;
|
||
}
|
||
localInputs.expired_time = Math.ceil(time / 1000);
|
||
}
|
||
localInputs.model_limits = localInputs.model_limits.join(',');
|
||
localInputs.model_limits_enabled = localInputs.model_limits.length > 0;
|
||
let res = await API.post(`/api/token/`, localInputs);
|
||
const { success, message } = res.data;
|
||
if (success) {
|
||
successCount++;
|
||
} else {
|
||
showError(t(message));
|
||
break;
|
||
}
|
||
}
|
||
if (successCount > 0) {
|
||
showSuccess(t('令牌创建成功,请在列表页面点击复制获取令牌!'));
|
||
props.refresh();
|
||
props.handleClose();
|
||
}
|
||
}
|
||
setLoading(false);
|
||
formApiRef.current?.setValues(getInitValues());
|
||
};
|
||
|
||
return (
|
||
<SideSheet
|
||
placement={isEdit ? 'right' : 'left'}
|
||
title={
|
||
<Space>
|
||
{isEdit ? (
|
||
<Tag color='blue' shape='circle'>
|
||
{t('更新')}
|
||
</Tag>
|
||
) : (
|
||
<Tag color='green' shape='circle'>
|
||
{t('新建')}
|
||
</Tag>
|
||
)}
|
||
<Title heading={4} className='m-0'>
|
||
{isEdit ? t('更新令牌信息') : t('创建新的令牌')}
|
||
</Title>
|
||
</Space>
|
||
}
|
||
bodyStyle={{ padding: '0' }}
|
||
visible={props.visiable}
|
||
width={isMobile ? '100%' : 600}
|
||
footer={
|
||
<div className='flex justify-end bg-white'>
|
||
<Space>
|
||
<Button
|
||
theme='solid'
|
||
className='!rounded-lg'
|
||
onClick={() => formApiRef.current?.submitForm()}
|
||
icon={<IconSave />}
|
||
loading={loading}
|
||
>
|
||
{t('提交')}
|
||
</Button>
|
||
<Button
|
||
theme='light'
|
||
className='!rounded-lg'
|
||
type='primary'
|
||
onClick={handleCancel}
|
||
icon={<IconClose />}
|
||
>
|
||
{t('取消')}
|
||
</Button>
|
||
</Space>
|
||
</div>
|
||
}
|
||
closeIcon={null}
|
||
onCancel={() => handleCancel()}
|
||
>
|
||
<Spin spinning={loading}>
|
||
<Form
|
||
key={isEdit ? 'edit' : 'new'}
|
||
initValues={getInitValues()}
|
||
getFormApi={(api) => (formApiRef.current = api)}
|
||
onSubmit={submit}
|
||
>
|
||
{({ values }) => (
|
||
<div className='p-2'>
|
||
{/* 基本信息 */}
|
||
<Card className='!rounded-2xl shadow-sm border-0'>
|
||
<div className='flex items-center mb-2'>
|
||
<Avatar size='small' color='blue' className='mr-2 shadow-md'>
|
||
<IconKey size={16} />
|
||
</Avatar>
|
||
<div>
|
||
<Text className='text-lg font-medium'>{t('基本信息')}</Text>
|
||
<div className='text-xs text-gray-600'>{t('设置令牌的基本信息')}</div>
|
||
</div>
|
||
</div>
|
||
<Row gutter={12}>
|
||
<Col span={24}>
|
||
<Form.Input
|
||
field='name'
|
||
label={t('名称')}
|
||
placeholder={t('请输入名称')}
|
||
rules={[{ required: true, message: t('请输入名称') }]}
|
||
showClear
|
||
/>
|
||
</Col>
|
||
<Col span={24}>
|
||
{groups.length > 0 ? (
|
||
<Form.Select
|
||
field='group'
|
||
label={t('令牌分组')}
|
||
placeholder={t('令牌分组,默认为用户的分组')}
|
||
optionList={groups}
|
||
renderOptionItem={renderGroupOption}
|
||
showClear
|
||
style={{ width: '100%' }}
|
||
/>
|
||
) : (
|
||
<Form.Select
|
||
placeholder={t('管理员未设置用户可选分组')}
|
||
disabled
|
||
label={t('令牌分组')}
|
||
style={{ width: '100%' }}
|
||
/>
|
||
)}
|
||
</Col>
|
||
<Col xs={24} sm={24} md={24} lg={10} xl={10}>
|
||
<Form.DatePicker
|
||
field='expired_time'
|
||
label={t('过期时间')}
|
||
type='dateTime'
|
||
placeholder={t('请选择过期时间')}
|
||
rules={[
|
||
{ required: true, message: t('请选择过期时间') },
|
||
{
|
||
validator: (rule, value) => {
|
||
// 允许 -1 表示永不过期,也允许空值在必填校验时被拦截
|
||
if (value === -1 || !value) return Promise.resolve();
|
||
const time = Date.parse(value);
|
||
if (isNaN(time)) {
|
||
return Promise.reject(t('过期时间格式错误!'));
|
||
}
|
||
if (time <= Date.now()) {
|
||
return Promise.reject(t('过期时间不能早于当前时间!'));
|
||
}
|
||
return Promise.resolve();
|
||
},
|
||
},
|
||
]}
|
||
showClear
|
||
style={{ width: '100%' }}
|
||
/>
|
||
</Col>
|
||
<Col xs={24} sm={24} md={24} lg={14} xl={14}>
|
||
<Form.Slot label={t('过期时间快捷设置')}>
|
||
<Space wrap>
|
||
<Button
|
||
theme='light'
|
||
type='primary'
|
||
onClick={() => setExpiredTime(0, 0, 0, 0)}
|
||
>
|
||
{t('永不过期')}
|
||
</Button>
|
||
<Button
|
||
theme='light'
|
||
type='tertiary'
|
||
onClick={() => setExpiredTime(1, 0, 0, 0)}
|
||
>
|
||
{t('一个月')}
|
||
</Button>
|
||
<Button
|
||
theme='light'
|
||
type='tertiary'
|
||
onClick={() => setExpiredTime(0, 1, 0, 0)}
|
||
>
|
||
{t('一天')}
|
||
</Button>
|
||
<Button
|
||
theme='light'
|
||
type='tertiary'
|
||
onClick={() => setExpiredTime(0, 0, 1, 0)}
|
||
>
|
||
{t('一小时')}
|
||
</Button>
|
||
</Space>
|
||
</Form.Slot>
|
||
</Col>
|
||
{!isEdit && (
|
||
<Col span={24}>
|
||
<Form.InputNumber
|
||
field='tokenCount'
|
||
label={t('新建数量')}
|
||
min={1}
|
||
extraText={t('批量创建时会在名称后自动添加随机后缀')}
|
||
rules={[{ required: true, message: t('请输入新建数量') }]}
|
||
style={{ width: '100%' }}
|
||
/>
|
||
</Col>
|
||
)}
|
||
</Row>
|
||
</Card>
|
||
|
||
{/* 额度设置 */}
|
||
<Card className='!rounded-2xl shadow-sm border-0'>
|
||
<div className='flex items-center mb-2'>
|
||
<Avatar size='small' color='green' className='mr-2 shadow-md'>
|
||
<IconCreditCard size={16} />
|
||
</Avatar>
|
||
<div>
|
||
<Text className='text-lg font-medium'>{t('额度设置')}</Text>
|
||
<div className='text-xs text-gray-600'>{t('设置令牌可用额度和数量')}</div>
|
||
</div>
|
||
</div>
|
||
<Row gutter={12}>
|
||
<Col span={24}>
|
||
<Form.AutoComplete
|
||
field='remain_quota'
|
||
label={t('额度')}
|
||
placeholder={t('请输入额度')}
|
||
type='number'
|
||
disabled={values.unlimited_quota}
|
||
extraText={renderQuotaWithPrompt(values.remain_quota)}
|
||
rules={values.unlimited_quota ? [] : [{ required: true, message: t('请输入额度') }]}
|
||
data={[
|
||
{ value: 500000, label: '1$' },
|
||
{ value: 5000000, label: '10$' },
|
||
{ value: 25000000, label: '50$' },
|
||
{ value: 50000000, label: '100$' },
|
||
{ value: 250000000, label: '500$' },
|
||
{ value: 500000000, label: '1000$' },
|
||
]}
|
||
/>
|
||
</Col>
|
||
<Col span={24}>
|
||
<Form.Switch
|
||
field='unlimited_quota'
|
||
label={t('无限额度')}
|
||
size='large'
|
||
extraText={t('令牌的额度仅用于限制令牌本身的最大额度使用量,实际的使用受到账户的剩余额度限制')}
|
||
/>
|
||
</Col>
|
||
</Row>
|
||
</Card>
|
||
|
||
{/* 访问限制 */}
|
||
<Card className='!rounded-2xl shadow-sm border-0'>
|
||
<div className='flex items-center mb-2'>
|
||
<Avatar size='small' color='purple' className='mr-2 shadow-md'>
|
||
<IconLink size={16} />
|
||
</Avatar>
|
||
<div>
|
||
<Text className='text-lg font-medium'>{t('访问限制')}</Text>
|
||
<div className='text-xs text-gray-600'>{t('设置令牌的访问限制')}</div>
|
||
</div>
|
||
</div>
|
||
<Row gutter={12}>
|
||
<Col span={24}>
|
||
<Form.Select
|
||
field='model_limits'
|
||
label={t('模型限制列表')}
|
||
placeholder={t('请选择该令牌支持的模型,留空支持所有模型')}
|
||
multiple
|
||
optionList={models}
|
||
extraText={t('非必要,不建议启用模型限制')}
|
||
filter={selectFilter}
|
||
autoClearSearchValue={false}
|
||
searchPosition='dropdown'
|
||
showClear
|
||
style={{ width: '100%' }}
|
||
/>
|
||
</Col>
|
||
<Col span={24}>
|
||
<Form.TextArea
|
||
field='allow_ips'
|
||
label={t('IP白名单')}
|
||
placeholder={t('允许的IP,一行一个,不填写则不限制')}
|
||
autosize
|
||
rows={1}
|
||
extraText={t('请勿过度信任此功能,IP可能被伪造')}
|
||
showClear
|
||
style={{ width: '100%' }}
|
||
/>
|
||
</Col>
|
||
</Row>
|
||
</Card>
|
||
</div>
|
||
)}
|
||
</Form>
|
||
</Spin>
|
||
</SideSheet>
|
||
);
|
||
};
|
||
|
||
export default EditTokenModal;
|