| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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'; |
| 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: 0, |
| expired_time: -1, |
| unlimited_quota: true, |
| 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)); |
| } |
| } |
| setGroups(localGroupOptions); |
| |
| |
| |
| } 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; |
|
|