| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546 |
- /*
- 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;
|