EditTokenModal.jsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546
  1. /*
  2. Copyright (C) 2025 QuantumNous
  3. This program is free software: you can redistribute it and/or modify
  4. it under the terms of the GNU Affero General Public License as
  5. published by the Free Software Foundation, either version 3 of the
  6. License, or (at your option) any later version.
  7. This program is distributed in the hope that it will be useful,
  8. but WITHOUT ANY WARRANTY; without even the implied warranty of
  9. MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  10. GNU Affero General Public License for more details.
  11. You should have received a copy of the GNU Affero General Public License
  12. along with this program. If not, see <https://www.gnu.org/licenses/>.
  13. For commercial licensing, please contact support@quantumnous.com
  14. */
  15. import React, { useEffect, useState, useContext, useRef } from 'react';
  16. import {
  17. API,
  18. showError,
  19. showSuccess,
  20. timestamp2string,
  21. renderGroupOption,
  22. renderQuotaWithPrompt,
  23. getModelCategories,
  24. selectFilter,
  25. } from '../../../../helpers';
  26. import { useIsMobile } from '../../../../hooks/common/useIsMobile.js';
  27. import {
  28. Button,
  29. SideSheet,
  30. Space,
  31. Spin,
  32. Typography,
  33. Card,
  34. Tag,
  35. Avatar,
  36. Form,
  37. Col,
  38. Row,
  39. } from '@douyinfe/semi-ui';
  40. import {
  41. IconCreditCard,
  42. IconLink,
  43. IconSave,
  44. IconClose,
  45. IconKey,
  46. } from '@douyinfe/semi-icons';
  47. import { useTranslation } from 'react-i18next';
  48. import { StatusContext } from '../../../../context/Status';
  49. const { Text, Title } = Typography;
  50. const EditTokenModal = (props) => {
  51. const { t } = useTranslation();
  52. const [statusState, statusDispatch] = useContext(StatusContext);
  53. const [loading, setLoading] = useState(false);
  54. const isMobile = useIsMobile();
  55. const formApiRef = useRef(null);
  56. const [models, setModels] = useState([]);
  57. const [groups, setGroups] = useState([]);
  58. const isEdit = props.editingToken.id !== undefined;
  59. const getInitValues = () => ({
  60. name: '',
  61. remain_quota: 500000,
  62. expired_time: -1,
  63. unlimited_quota: false,
  64. model_limits_enabled: false,
  65. model_limits: [],
  66. allow_ips: '',
  67. group: '',
  68. tokenCount: 1,
  69. });
  70. const handleCancel = () => {
  71. props.handleClose();
  72. };
  73. const setExpiredTime = (month, day, hour, minute) => {
  74. let now = new Date();
  75. let timestamp = now.getTime() / 1000;
  76. let seconds = month * 30 * 24 * 60 * 60;
  77. seconds += day * 24 * 60 * 60;
  78. seconds += hour * 60 * 60;
  79. seconds += minute * 60;
  80. if (!formApiRef.current) return;
  81. if (seconds !== 0) {
  82. timestamp += seconds;
  83. formApiRef.current.setValue('expired_time', timestamp2string(timestamp));
  84. } else {
  85. formApiRef.current.setValue('expired_time', -1);
  86. }
  87. };
  88. const loadModels = async () => {
  89. let res = await API.get(`/api/user/models`);
  90. const { success, message, data } = res.data;
  91. if (success) {
  92. const categories = getModelCategories(t);
  93. let localModelOptions = data.map((model) => {
  94. let icon = null;
  95. for (const [key, category] of Object.entries(categories)) {
  96. if (key !== 'all' && category.filter({ model_name: model })) {
  97. icon = category.icon;
  98. break;
  99. }
  100. }
  101. return {
  102. label: (
  103. <span className="flex items-center gap-1">
  104. {icon}
  105. {model}
  106. </span>
  107. ),
  108. value: model,
  109. };
  110. });
  111. setModels(localModelOptions);
  112. } else {
  113. showError(t(message));
  114. }
  115. };
  116. const loadGroups = async () => {
  117. let res = await API.get(`/api/user/self/groups`);
  118. const { success, message, data } = res.data;
  119. if (success) {
  120. let localGroupOptions = Object.entries(data).map(([group, info]) => ({
  121. label: info.desc,
  122. value: group,
  123. ratio: info.ratio,
  124. }));
  125. if (statusState?.status?.default_use_auto_group) {
  126. if (localGroupOptions.some((group) => group.value === 'auto')) {
  127. localGroupOptions.sort((a, b) => (a.value === 'auto' ? -1 : 1));
  128. } else {
  129. localGroupOptions.unshift({ label: t('自动选择'), value: 'auto' });
  130. }
  131. }
  132. setGroups(localGroupOptions);
  133. if (statusState?.status?.default_use_auto_group && formApiRef.current) {
  134. formApiRef.current.setValue('group', 'auto');
  135. }
  136. } else {
  137. showError(t(message));
  138. }
  139. };
  140. const loadToken = async () => {
  141. setLoading(true);
  142. let res = await API.get(`/api/token/${props.editingToken.id}`);
  143. const { success, message, data } = res.data;
  144. if (success) {
  145. if (data.expired_time !== -1) {
  146. data.expired_time = timestamp2string(data.expired_time);
  147. }
  148. if (data.model_limits !== '') {
  149. data.model_limits = data.model_limits.split(',');
  150. } else {
  151. data.model_limits = [];
  152. }
  153. if (formApiRef.current) {
  154. formApiRef.current.setValues({ ...getInitValues(), ...data });
  155. }
  156. } else {
  157. showError(message);
  158. }
  159. setLoading(false);
  160. };
  161. useEffect(() => {
  162. if (formApiRef.current) {
  163. if (!isEdit) {
  164. formApiRef.current.setValues(getInitValues());
  165. }
  166. }
  167. loadModels();
  168. loadGroups();
  169. }, [props.editingToken.id]);
  170. useEffect(() => {
  171. if (props.visiable) {
  172. if (isEdit) {
  173. loadToken();
  174. } else {
  175. formApiRef.current?.setValues(getInitValues());
  176. }
  177. } else {
  178. formApiRef.current?.reset();
  179. }
  180. }, [props.visiable, props.editingToken.id]);
  181. const generateRandomSuffix = () => {
  182. const characters =
  183. 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
  184. let result = '';
  185. for (let i = 0; i < 6; i++) {
  186. result += characters.charAt(
  187. Math.floor(Math.random() * characters.length),
  188. );
  189. }
  190. return result;
  191. };
  192. const submit = async (values) => {
  193. setLoading(true);
  194. if (isEdit) {
  195. let { tokenCount: _tc, ...localInputs } = values;
  196. localInputs.remain_quota = parseInt(localInputs.remain_quota);
  197. if (localInputs.expired_time !== -1) {
  198. let time = Date.parse(localInputs.expired_time);
  199. if (isNaN(time)) {
  200. showError(t('过期时间格式错误!'));
  201. setLoading(false);
  202. return;
  203. }
  204. localInputs.expired_time = Math.ceil(time / 1000);
  205. }
  206. localInputs.model_limits = localInputs.model_limits.join(',');
  207. localInputs.model_limits_enabled = localInputs.model_limits.length > 0;
  208. let res = await API.put(`/api/token/`, {
  209. ...localInputs,
  210. id: parseInt(props.editingToken.id),
  211. });
  212. const { success, message } = res.data;
  213. if (success) {
  214. showSuccess(t('令牌更新成功!'));
  215. props.refresh();
  216. props.handleClose();
  217. } else {
  218. showError(t(message));
  219. }
  220. } else {
  221. const count = parseInt(values.tokenCount, 10) || 1;
  222. let successCount = 0;
  223. for (let i = 0; i < count; i++) {
  224. let { tokenCount: _tc, ...localInputs } = values;
  225. const baseName = values.name.trim() === '' ? 'default' : values.name.trim();
  226. if (i !== 0 || values.name.trim() === '') {
  227. localInputs.name = `${baseName}-${generateRandomSuffix()}`;
  228. } else {
  229. localInputs.name = baseName;
  230. }
  231. localInputs.remain_quota = parseInt(localInputs.remain_quota);
  232. if (localInputs.expired_time !== -1) {
  233. let time = Date.parse(localInputs.expired_time);
  234. if (isNaN(time)) {
  235. showError(t('过期时间格式错误!'));
  236. setLoading(false);
  237. break;
  238. }
  239. localInputs.expired_time = Math.ceil(time / 1000);
  240. }
  241. localInputs.model_limits = localInputs.model_limits.join(',');
  242. localInputs.model_limits_enabled = localInputs.model_limits.length > 0;
  243. let res = await API.post(`/api/token/`, localInputs);
  244. const { success, message } = res.data;
  245. if (success) {
  246. successCount++;
  247. } else {
  248. showError(t(message));
  249. break;
  250. }
  251. }
  252. if (successCount > 0) {
  253. showSuccess(t('令牌创建成功,请在列表页面点击复制获取令牌!'));
  254. props.refresh();
  255. props.handleClose();
  256. }
  257. }
  258. setLoading(false);
  259. formApiRef.current?.setValues(getInitValues());
  260. };
  261. return (
  262. <SideSheet
  263. placement={isEdit ? 'right' : 'left'}
  264. title={
  265. <Space>
  266. {isEdit ? (
  267. <Tag color='blue' shape='circle'>
  268. {t('更新')}
  269. </Tag>
  270. ) : (
  271. <Tag color='green' shape='circle'>
  272. {t('新建')}
  273. </Tag>
  274. )}
  275. <Title heading={4} className='m-0'>
  276. {isEdit ? t('更新令牌信息') : t('创建新的令牌')}
  277. </Title>
  278. </Space>
  279. }
  280. bodyStyle={{ padding: '0' }}
  281. visible={props.visiable}
  282. width={isMobile ? '100%' : 600}
  283. footer={
  284. <div className='flex justify-end bg-white'>
  285. <Space>
  286. <Button
  287. theme='solid'
  288. className='!rounded-lg'
  289. onClick={() => formApiRef.current?.submitForm()}
  290. icon={<IconSave />}
  291. loading={loading}
  292. >
  293. {t('提交')}
  294. </Button>
  295. <Button
  296. theme='light'
  297. className='!rounded-lg'
  298. type='primary'
  299. onClick={handleCancel}
  300. icon={<IconClose />}
  301. >
  302. {t('取消')}
  303. </Button>
  304. </Space>
  305. </div>
  306. }
  307. closeIcon={null}
  308. onCancel={() => handleCancel()}
  309. >
  310. <Spin spinning={loading}>
  311. <Form
  312. key={isEdit ? 'edit' : 'new'}
  313. initValues={getInitValues()}
  314. getFormApi={(api) => (formApiRef.current = api)}
  315. onSubmit={submit}
  316. >
  317. {({ values }) => (
  318. <div className='p-2'>
  319. {/* 基本信息 */}
  320. <Card className='!rounded-2xl shadow-sm border-0'>
  321. <div className='flex items-center mb-2'>
  322. <Avatar size='small' color='blue' className='mr-2 shadow-md'>
  323. <IconKey size={16} />
  324. </Avatar>
  325. <div>
  326. <Text className='text-lg font-medium'>{t('基本信息')}</Text>
  327. <div className='text-xs text-gray-600'>{t('设置令牌的基本信息')}</div>
  328. </div>
  329. </div>
  330. <Row gutter={12}>
  331. <Col span={24}>
  332. <Form.Input
  333. field='name'
  334. label={t('名称')}
  335. placeholder={t('请输入名称')}
  336. rules={[{ required: true, message: t('请输入名称') }]}
  337. showClear
  338. />
  339. </Col>
  340. <Col span={24}>
  341. {groups.length > 0 ? (
  342. <Form.Select
  343. field='group'
  344. label={t('令牌分组')}
  345. placeholder={t('令牌分组,默认为用户的分组')}
  346. optionList={groups}
  347. renderOptionItem={renderGroupOption}
  348. showClear
  349. style={{ width: '100%' }}
  350. />
  351. ) : (
  352. <Form.Select
  353. placeholder={t('管理员未设置用户可选分组')}
  354. disabled
  355. label={t('令牌分组')}
  356. style={{ width: '100%' }}
  357. />
  358. )}
  359. </Col>
  360. <Col xs={24} sm={24} md={24} lg={10} xl={10}>
  361. <Form.DatePicker
  362. field='expired_time'
  363. label={t('过期时间')}
  364. type='dateTime'
  365. placeholder={t('请选择过期时间')}
  366. rules={[
  367. { required: true, message: t('请选择过期时间') },
  368. {
  369. validator: (rule, value) => {
  370. // 允许 -1 表示永不过期,也允许空值在必填校验时被拦截
  371. if (value === -1 || !value) return Promise.resolve();
  372. const time = Date.parse(value);
  373. if (isNaN(time)) {
  374. return Promise.reject(t('过期时间格式错误!'));
  375. }
  376. if (time <= Date.now()) {
  377. return Promise.reject(t('过期时间不能早于当前时间!'));
  378. }
  379. return Promise.resolve();
  380. },
  381. },
  382. ]}
  383. showClear
  384. style={{ width: '100%' }}
  385. />
  386. </Col>
  387. <Col xs={24} sm={24} md={24} lg={14} xl={14}>
  388. <Form.Slot label={t('过期时间快捷设置')}>
  389. <Space wrap>
  390. <Button
  391. theme='light'
  392. type='primary'
  393. onClick={() => setExpiredTime(0, 0, 0, 0)}
  394. >
  395. {t('永不过期')}
  396. </Button>
  397. <Button
  398. theme='light'
  399. type='tertiary'
  400. onClick={() => setExpiredTime(1, 0, 0, 0)}
  401. >
  402. {t('一个月')}
  403. </Button>
  404. <Button
  405. theme='light'
  406. type='tertiary'
  407. onClick={() => setExpiredTime(0, 1, 0, 0)}
  408. >
  409. {t('一天')}
  410. </Button>
  411. <Button
  412. theme='light'
  413. type='tertiary'
  414. onClick={() => setExpiredTime(0, 0, 1, 0)}
  415. >
  416. {t('一小时')}
  417. </Button>
  418. </Space>
  419. </Form.Slot>
  420. </Col>
  421. {!isEdit && (
  422. <Col span={24}>
  423. <Form.InputNumber
  424. field='tokenCount'
  425. label={t('新建数量')}
  426. min={1}
  427. extraText={t('批量创建时会在名称后自动添加随机后缀')}
  428. rules={[{ required: true, message: t('请输入新建数量') }]}
  429. style={{ width: '100%' }}
  430. />
  431. </Col>
  432. )}
  433. </Row>
  434. </Card>
  435. {/* 额度设置 */}
  436. <Card className='!rounded-2xl shadow-sm border-0'>
  437. <div className='flex items-center mb-2'>
  438. <Avatar size='small' color='green' className='mr-2 shadow-md'>
  439. <IconCreditCard size={16} />
  440. </Avatar>
  441. <div>
  442. <Text className='text-lg font-medium'>{t('额度设置')}</Text>
  443. <div className='text-xs text-gray-600'>{t('设置令牌可用额度和数量')}</div>
  444. </div>
  445. </div>
  446. <Row gutter={12}>
  447. <Col span={24}>
  448. <Form.AutoComplete
  449. field='remain_quota'
  450. label={t('额度')}
  451. placeholder={t('请输入额度')}
  452. type='number'
  453. disabled={values.unlimited_quota}
  454. extraText={renderQuotaWithPrompt(values.remain_quota)}
  455. rules={values.unlimited_quota ? [] : [{ required: true, message: t('请输入额度') }]}
  456. data={[
  457. { value: 500000, label: '1$' },
  458. { value: 5000000, label: '10$' },
  459. { value: 25000000, label: '50$' },
  460. { value: 50000000, label: '100$' },
  461. { value: 250000000, label: '500$' },
  462. { value: 500000000, label: '1000$' },
  463. ]}
  464. />
  465. </Col>
  466. <Col span={24}>
  467. <Form.Switch
  468. field='unlimited_quota'
  469. label={t('无限额度')}
  470. size='large'
  471. extraText={t('令牌的额度仅用于限制令牌本身的最大额度使用量,实际的使用受到账户的剩余额度限制')}
  472. />
  473. </Col>
  474. </Row>
  475. </Card>
  476. {/* 访问限制 */}
  477. <Card className='!rounded-2xl shadow-sm border-0'>
  478. <div className='flex items-center mb-2'>
  479. <Avatar size='small' color='purple' className='mr-2 shadow-md'>
  480. <IconLink size={16} />
  481. </Avatar>
  482. <div>
  483. <Text className='text-lg font-medium'>{t('访问限制')}</Text>
  484. <div className='text-xs text-gray-600'>{t('设置令牌的访问限制')}</div>
  485. </div>
  486. </div>
  487. <Row gutter={12}>
  488. <Col span={24}>
  489. <Form.Select
  490. field='model_limits'
  491. label={t('模型限制列表')}
  492. placeholder={t('请选择该令牌支持的模型,留空支持所有模型')}
  493. multiple
  494. optionList={models}
  495. extraText={t('非必要,不建议启用模型限制')}
  496. filter={selectFilter}
  497. autoClearSearchValue={false}
  498. searchPosition='dropdown'
  499. showClear
  500. style={{ width: '100%' }}
  501. />
  502. </Col>
  503. <Col span={24}>
  504. <Form.TextArea
  505. field='allow_ips'
  506. label={t('IP白名单')}
  507. placeholder={t('允许的IP,一行一个,不填写则不限制')}
  508. autosize
  509. rows={1}
  510. extraText={t('请勿过度信任此功能,IP可能被伪造')}
  511. showClear
  512. style={{ width: '100%' }}
  513. />
  514. </Col>
  515. </Row>
  516. </Card>
  517. </div>
  518. )}
  519. </Form>
  520. </Spin>
  521. </SideSheet>
  522. );
  523. };
  524. export default EditTokenModal;