refactor(ui/token): migrate EditToken page to Semi Form API and polish UX

SUMMARY
• Re-implemented `EditToken.js` with Semi Form components, eliminating manual state handling and reducing re-renders.
• Added grid-based layout; “Expiration Time” selector now sits inline with quick-set buttons for consistent alignment on desktop & mobile.
• Introduced dedicated “Quota”, “Access”, “Model Limits”, and “Group” cards for clearer field grouping.
• Reworked model-limit interaction: single multi-select list replaces checkbox toggle; backend flag `model_limits_enabled` is now inferred automatically.
• Applied required validation rules to critical fields (`name`, `remain_quota`, `group`, `expired_time`, `tokenCount`) with localized messages.
• Enabled dynamic option loading for models & groups; default auto-group honoured.
• Added unlimited-quota switch, quota presets, and helpful extraText/tooltips.
• Removed obsolete `handleInputChange` & `setUnlimitedQuota` helpers; formApi now manages all data flow.
• Cleaned imports (e.g., dropped unused `IconUserGroup`), fixed linter errors, and updated submit logic to use `formApi.submitForm()`.

RESULT
The token creation/editing experience is faster, more accessible, and easier to maintain, fully aligned with Semi Design best practices.
This commit is contained in:
t0ng7u
2025-06-26 22:58:25 +08:00
parent 1a0aac81df
commit aa15d45a3d
2 changed files with 228 additions and 347 deletions

View File

@@ -457,7 +457,7 @@
"令牌分组,默认为用户的分组": "Token group, default is the your's group",
"IP白名单": "IP whitelist",
"注意,令牌的额度仅用于限制令牌本身的最大额度使用量,实际的使用受到账户的剩余额度限制。": "Note that the quota of the token is only used to limit the maximum quota usage of the token itself, and the actual usage is limited by the remaining quota of the account.",
"设为无限额度": "Set to unlimited quota",
"无限额度": "Unlimited quota",
"更新令牌信息": "Update Token Information",
"请输入充值码!": "Please enter the recharge code!",
"请输入名称": "Please enter a name",
@@ -516,7 +516,6 @@
"注意系统请求的时模型名称中的点会被剔除例如gpt-4.1会请求为gpt-41所以在Azure部署的时候部署模型名称需要手动改为gpt-41": "Note that the dot in the model name requested by the system will be removed, for example: gpt-4.1 will be requested as gpt-41, so when deploying on Azure, the deployment model name needs to be manually changed to gpt-41",
"2025年5月10日后添加的渠道不需要再在部署的时候移除模型名称中的\".\"": "After May 10, 2025, channels added do not need to remove the dot in the model name during deployment",
"模型映射必须是合法的 JSON 格式!": "Model mapping must be in valid JSON format!",
"取消无限额度": "Cancel unlimited quota",
"取消": "Cancel",
"重置": "Reset",
"请输入新的剩余额度": "Please enter the new remaining quota",
@@ -1459,7 +1458,8 @@
"访问限制": "Access Restrictions",
"设置令牌的访问限制": "Set token access restrictions",
"请勿过度信任此功能IP可能被伪造": "Do not over-trust this feature, IP can be spoofed",
"勾选启用模型限制后可选择": "Select after checking to enable model restrictions",
"模型限制列表": "Model restrictions list",
"请选择该令牌支持的模型,留空支持所有模型": "Select models supported by the token, leave blank to support all models",
"非必要,不建议启用模型限制": "Not necessary, model restrictions are not recommended",
"分组信息": "Group Information",
"设置令牌的分组": "Set token grouping",
@@ -1743,5 +1743,7 @@
"暂无成功模型": "No successful models",
"请先选择模型!": "Please select a model first!",
"已复制 ${count} 个模型": "Copied ${count} models",
"复制失败,请手动复制": "Copy failed, please copy manually"
"复制失败,请手动复制": "Copy failed, please copy manually",
"快捷设置": "Quick settings",
"批量创建时会在名称后自动添加随机后缀": "When creating in batches, a random suffix will be automatically added to the name"
}

View File

@@ -1,5 +1,4 @@
import React, { useEffect, useState, useContext } from 'react';
import { useNavigate } from 'react-router-dom';
import React, { useEffect, useState, useContext, useRef } from 'react';
import {
API,
isMobile,
@@ -10,26 +9,22 @@ import {
renderQuotaWithPrompt,
} from '../../helpers';
import {
AutoComplete,
Banner,
Button,
Checkbox,
DatePicker,
Input,
Select,
SideSheet,
Space,
Spin,
TextArea,
Typography,
Card,
Tag,
Avatar,
Form,
Col,
Row,
} from '@douyinfe/semi-ui';
import {
IconCreditCard,
IconLink,
IconUserGroup,
IconSave,
IconClose,
IconPlusCircle,
@@ -43,35 +38,22 @@ const EditToken = (props) => {
const { t } = useTranslation();
const [statusState, statusDispatch] = useContext(StatusContext);
const [isEdit, setIsEdit] = useState(false);
const [loading, setLoading] = useState(isEdit);
const originInputs = {
const [loading, setLoading] = useState(false);
const formApiRef = useRef(null);
const [models, setModels] = useState([]);
const [groups, setGroups] = useState([]);
const getInitValues = () => ({
name: '',
remain_quota: isEdit ? 0 : 500000,
remain_quota: 500000,
expired_time: -1,
unlimited_quota: false,
model_limits_enabled: false,
model_limits: [],
allow_ips: '',
group: '',
};
const [inputs, setInputs] = useState(originInputs);
const {
name,
remain_quota,
expired_time,
unlimited_quota,
model_limits_enabled,
model_limits,
allow_ips,
group,
} = inputs;
const [models, setModels] = useState([]);
const [groups, setGroups] = useState([]);
const navigate = useNavigate();
const handleInputChange = (name, value) => {
setInputs((inputs) => ({ ...inputs, [name]: value }));
};
tokenCount: 1,
});
const handleCancel = () => {
props.handleClose();
@@ -84,18 +66,15 @@ const EditToken = (props) => {
seconds += day * 24 * 60 * 60;
seconds += hour * 60 * 60;
seconds += minute * 60;
if (!formApiRef.current) return;
if (seconds !== 0) {
timestamp += seconds;
setInputs({ ...inputs, expired_time: timestamp2string(timestamp) });
formApiRef.current.setValue('expired_time', timestamp2string(timestamp));
} else {
setInputs({ ...inputs, expired_time: -1 });
formApiRef.current.setValue('expired_time', -1);
}
};
const setUnlimitedQuota = () => {
setInputs({ ...inputs, unlimited_quota: !unlimited_quota });
};
const loadModels = async () => {
let res = await API.get(`/api/user/models`);
const { success, message, data } = res.data;
@@ -120,17 +99,15 @@ const EditToken = (props) => {
ratio: info.ratio,
}));
if (statusState?.status?.default_use_auto_group) {
// if contain auto, add it to the first position
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) {
setInputs({ ...inputs, group: 'auto' });
if (statusState?.status?.default_use_auto_group && formApiRef.current) {
formApiRef.current.setValue('group', 'auto');
}
} else {
showError(t(message));
@@ -150,7 +127,9 @@ const EditToken = (props) => {
} else {
data.model_limits = [];
}
setInputs(data);
if (formApiRef.current) {
formApiRef.current.setValues({ ...getInitValues(), ...data });
}
} else {
showError(message);
}
@@ -162,30 +141,17 @@ const EditToken = (props) => {
}, [props.editingToken.id]);
useEffect(() => {
if (!isEdit) {
setInputs(originInputs);
} else {
loadToken().then(() => {
// console.log(inputs);
});
if (formApiRef.current) {
if (!isEdit) {
formApiRef.current.setValues(getInitValues());
} else {
loadToken();
}
}
loadModels();
loadGroups();
}, [isEdit]);
// 新增 state 变量 tokenCount 来记录用户想要创建的令牌数量,默认为 1
const [tokenCount, setTokenCount] = useState(1);
// 新增处理 tokenCount 变化的函数
const handleTokenCountChange = (value) => {
// 确保用户输入的是正整数
const count = parseInt(value, 10);
if (!isNaN(count) && count > 0) {
setTokenCount(count);
}
};
// 生成一个随机的四位字母数字字符串
const generateRandomSuffix = () => {
const characters =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
@@ -198,11 +164,10 @@ const EditToken = (props) => {
return result;
};
const submit = async () => {
const submit = async (values) => {
setLoading(true);
if (isEdit) {
// 编辑令牌的逻辑保持不变
let localInputs = { ...inputs };
let { tokenCount: _tc, ...localInputs } = values;
localInputs.remain_quota = parseInt(localInputs.remain_quota);
if (localInputs.expired_time !== -1) {
let time = Date.parse(localInputs.expired_time);
@@ -214,6 +179,7 @@ const EditToken = (props) => {
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),
@@ -227,16 +193,12 @@ const EditToken = (props) => {
showError(t(message));
}
} else {
// 处理新增多个令牌的情况
let successCount = 0; // 记录成功创建的令牌数量
for (let i = 0; i < tokenCount; i++) {
let localInputs = { ...inputs };
// 检查用户是否填写了令牌名称
const baseName = inputs.name.trim() === '' ? 'default' : inputs.name;
if (i !== 0 || inputs.name.trim() === '') {
// 如果创建多个令牌i !== 0或者用户没有填写名称则添加随机后缀
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;
@@ -253,17 +215,16 @@ const EditToken = (props) => {
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; // 如果创建失败,终止循环
break;
}
}
if (successCount > 0) {
showSuccess(t('令牌创建成功,请在列表页面点击复制获取令牌!'));
props.refresh();
@@ -271,8 +232,7 @@ const EditToken = (props) => {
}
}
setLoading(false);
setInputs(originInputs); // 重置表单
setTokenCount(1); // 重置数量为默认值
formApiRef.current?.setValues(getInitValues());
};
return (
@@ -307,7 +267,7 @@ const EditToken = (props) => {
<Button
theme='solid'
className='!rounded-full'
onClick={submit}
onClick={() => formApiRef.current?.submitForm()}
icon={<IconSave />}
loading={loading}
>
@@ -329,274 +289,193 @@ const EditToken = (props) => {
onCancel={() => handleCancel()}
>
<Spin spinning={loading}>
<div className='p-6'>
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
{/* Header: Basic Info */}
<div className='flex items-center mb-2'>
<Avatar size='small' color='blue' className='mr-2 shadow-md'>
<IconPlusCircle size={16} />
</Avatar>
<div>
<Text className='text-lg font-medium'>{t('基本信息')}</Text>
<div className='text-xs text-gray-600'>{t('设置令牌的基本信息')}</div>
</div>
</div>
<div className='space-y-4'>
<div>
<Text strong className='block mb-2'>
{t('名称')}
</Text>
<Input
placeholder={t('请输入名称')}
onChange={(value) => handleInputChange('name', value)}
value={name}
autoComplete='new-password'
className='!rounded-lg'
showClear
required
/>
</div>
<div>
<Text strong className='block mb-2'>
{t('过期时间')}
</Text>
<div className='mb-2'>
<DatePicker
placeholder={t('请选择过期时间')}
onChange={(value) =>
handleInputChange('expired_time', value)
}
value={expired_time}
autoComplete='new-password'
type='dateTime'
className='w-full !rounded-lg'
/>
</div>
<div className='flex flex-wrap gap-2'>
<Button
theme='light'
type='primary'
onClick={() => setExpiredTime(0, 0, 0, 0)}
className='!rounded-full'
>
{t('永不过期')}
</Button>
<Button
theme='light'
type='tertiary'
onClick={() => setExpiredTime(0, 0, 1, 0)}
className='!rounded-full'
>
{t('一小时')}
</Button>
<Button
theme='light'
type='tertiary'
onClick={() => setExpiredTime(0, 1, 0, 0)}
className='!rounded-full'
>
{t('一天')}
</Button>
<Button
theme='light'
type='tertiary'
onClick={() => setExpiredTime(1, 0, 0, 0)}
className='!rounded-full'
>
{t('一个月')}
</Button>
</div>
</div>
</div>
</Card>
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
{/* Header: Quota Settings */}
<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>
<Banner
type='warning'
description={t(
'注意,令牌的额度仅用于限制令牌本身的最大额度使用量,实际的使用受到账户的剩余额度限制。',
)}
className='mb-4 !rounded-lg'
/>
<div className='space-y-4'>
<div>
<div className='flex justify-between mb-2'>
<Text strong>{t('额度')}</Text>
<Text type='tertiary'>
{renderQuotaWithPrompt(remain_quota)}
</Text>
</div>
<AutoComplete
placeholder={t('请输入额度')}
onChange={(value) => handleInputChange('remain_quota', value)}
value={remain_quota}
autoComplete='new-password'
type='number'
className='w-full !rounded-lg'
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$' },
]}
disabled={unlimited_quota}
/>
</div>
{!isEdit && (
<div>
<Text strong className='block mb-2'>
{t('新建数量')}
</Text>
<AutoComplete
placeholder={t('请选择或输入创建令牌的数量')}
onChange={(value) => handleTokenCountChange(value)}
onSelect={(value) => handleTokenCountChange(value)}
value={tokenCount.toString()}
autoComplete='off'
type='number'
className='w-full !rounded-lg'
data={[
{ value: 10, label: t('10个') },
{ value: 20, label: t('20个') },
{ value: 30, label: t('30个') },
{ value: 100, label: t('100个') },
]}
disabled={unlimited_quota}
/>
</div>
)}
<div className='flex justify-end'>
<Button
theme='light'
type={unlimited_quota ? 'danger' : 'warning'}
onClick={setUnlimitedQuota}
className='!rounded-full'
>
{unlimited_quota ? t('取消无限额度') : t('设为无限额度')}
</Button>
</div>
</div>
</Card>
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
{/* Header: Access Limits */}
<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>
<div className='space-y-4'>
<div>
<Text strong className='block mb-2'>
{t('IP白名单')}
</Text>
<TextArea
placeholder={t('允许的IP一行一个不填写则不限制')}
onChange={(value) => handleInputChange('allow_ips', value)}
value={inputs.allow_ips}
style={{ fontFamily: 'JetBrains Mono, Consolas' }}
className='!rounded-lg'
rows={4}
/>
<Text type='tertiary' className='mt-1 block text-xs'>
{t('请勿过度信任此功能IP可能被伪造')}
</Text>
</div>
<div>
<Form
key={isEdit ? 'edit' : 'new'}
initValues={getInitValues()}
getFormApi={(api) => (formApiRef.current = api)}
onSubmit={submit}
>
{({ values }) => (
<div className='p-6 space-y-6'>
{/* 基本信息 */}
<Card className='!rounded-2xl shadow-sm border-0'>
<div className='flex items-center mb-2'>
<Checkbox
checked={model_limits_enabled}
onChange={(e) =>
handleInputChange(
'model_limits_enabled',
e.target.checked,
)
}
>
<Text strong>{t('模型限制')}</Text>
</Checkbox>
<Avatar size='small' color='blue' className='mr-2 shadow-md'>
<IconPlusCircle size={16} />
</Avatar>
<div>
<Text className='text-lg font-medium'>{t('基本信息')}</Text>
<div className='text-xs text-gray-600'>{t('设置令牌的基本信息')}</div>
</div>
</div>
<Select
placeholder={
model_limits_enabled
? t('请选择该渠道所支持的模型')
: t('勾选启用模型限制后可选择')
}
onChange={(value) => handleInputChange('model_limits', value)}
value={inputs.model_limits}
multiple
className='w-full !rounded-lg'
optionList={models}
disabled={!model_limits_enabled}
maxTagCount={3}
/>
<Text type='tertiary' className='mt-1 block text-xs'>
{t('非必要,不建议启用模型限制')}
</Text>
</div>
</div>
</Card>
<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}
/>
) : (
<Form.Select
placeholder={t('管理员未设置用户可选分组')}
disabled
label={t('令牌分组')}
/>
)}
</Col>
<Col span={10}>
<Form.DatePicker
field='expired_time'
label={t('过期时间')}
type='dateTime'
placeholder={t('请选择过期时间')}
style={{ width: '100%' }}
rules={[{ required: true, message: t('请选择过期时间') }]}
/>
</Col>
<Col span={14} className='flex flex-col justify-end'>
<Form.Slot label={t('快捷设置')}>
<Space wrap>
<Button
theme='light'
type='primary'
onClick={() => setExpiredTime(0, 0, 0, 0)}
className='!rounded-full'
>
{t('永不过期')}
</Button>
<Button
theme='light'
type='tertiary'
onClick={() => setExpiredTime(1, 0, 0, 0)}
className='!rounded-full'
>
{t('一个月')}
</Button>
<Button
theme='light'
type='tertiary'
onClick={() => setExpiredTime(0, 1, 0, 0)}
className='!rounded-full'
>
{t('一天')}
</Button>
<Button
theme='light'
type='tertiary'
onClick={() => setExpiredTime(0, 0, 1, 0)}
className='!rounded-full'
>
{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('请输入新建数量') }]}
/>
</Col>
)}
</Row>
</Card>
<Card className='!rounded-2xl shadow-sm border-0'>
{/* Header: Group Info */}
<div className='flex items-center mb-2'>
<Avatar size='small' color='orange' className='mr-2 shadow-md'>
<IconUserGroup size={16} />
</Avatar>
<div>
<Text className='text-lg font-medium'>{t('分组信息')}</Text>
<div className='text-xs text-gray-600'>{t('设置令牌的分组')}</div>
</div>
</div>
{/* 额度设置 */}
<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={10}>
<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={14} className='flex justify-end'>
<Form.Switch field='unlimited_quota' label={t('无限额度')} size='large' />
</Col>
</Row>
<Banner
type='warning'
description={t('注意,令牌的额度仅用于限制令牌本身的最大额度使用量,实际的使用受到账户的剩余额度限制。')}
className='mb-4 !rounded-lg'
/>
</Card>
<div>
<Text strong className='block mb-2'>
{t('令牌分组')}
</Text>
{groups.length > 0 ? (
<Select
placeholder={t('令牌分组,默认为用户的分组')}
onChange={(value) => handleInputChange('group', value)}
renderOptionItem={renderGroupOption}
value={inputs.group}
className='w-full !rounded-lg'
optionList={groups}
/>
) : (
<Select
placeholder={t('管理员未设置用户可选分组')}
disabled={true}
className='w-full !rounded-lg'
/>
)}
{/* 访问限制 */}
<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.TextArea
field='allow_ips'
label={t('IP白名单')}
placeholder={t('允许的IP一行一个不填写则不限制')}
rows={4}
extraText={t('请勿过度信任此功能IP可能被伪造')}
/>
</Col>
<Col span={24}>
<Form.Select
field='model_limits'
label={t('模型限制列表')}
placeholder={t('请选择该令牌支持的模型,留空支持所有模型')}
multiple
optionList={models}
maxTagCount={3}
extraText={t('非必要,不建议启用模型限制')}
/>
</Col>
</Row>
</Card>
</div>
</Card>
</div>
)}
</Form>
</Spin>
</SideSheet>
);