mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-05-03 06:38:24 +00:00
1. Split monolithic `ChannelsTable` (2200+ LOC) into focused components
• `channels/index.jsx` – composition entry
• `ChannelsTable.jsx` – pure `<Table>` rendering
• `ChannelsActions.jsx` – bulk & settings toolbar
• `ChannelsFilters.jsx` – search / create / column-settings form
• `ChannelsTabs.jsx` – type tabs
• `ChannelsColumnDefs.js` – column definitions & render helpers
• `modals/` – BatchTag, ColumnSelector, ModelTest modals
2. Extract domain hook
• Moved `useChannelsData.js` → `src/hooks/channels/useChannelsData.js`
– centralises state, API calls, pagination, filters, batch ops
– now exports `setActivePage`, fixing tab / status switch errors
3. Update wiring
• All sub-components consume data via `useChannelsData` props
• Adjusted import paths after hook relocation
4. Clean legacy file
• Legacy `components/table/ChannelsTable.js` now re-exports new module
5. Bug fixes
• Tab switching, status filter & tag aggregation restored
• Column selector & batch actions operate via unified hook
This commit completes the first phase of modularising the Channels feature, laying groundwork for consistent, maintainable table architecture across the app.
526 lines
18 KiB
JavaScript
526 lines
18 KiB
JavaScript
import React, { useEffect, useState, useContext, useRef } from 'react';
|
||
import {
|
||
API,
|
||
showError,
|
||
showSuccess,
|
||
timestamp2string,
|
||
renderGroupOption,
|
||
renderQuotaWithPrompt,
|
||
getModelCategories,
|
||
} 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 EditToken = (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
|
||
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 EditToken;
|