EditToken.js 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708
  1. import React, { useEffect, useState, useContext } from 'react';
  2. import { useNavigate } from 'react-router-dom';
  3. import {
  4. API,
  5. isMobile,
  6. showError,
  7. showSuccess,
  8. timestamp2string,
  9. renderGroupOption,
  10. renderQuotaWithPrompt,
  11. } from '../../helpers';
  12. import {
  13. AutoComplete,
  14. Banner,
  15. Button,
  16. Checkbox,
  17. DatePicker,
  18. Input,
  19. Select,
  20. SideSheet,
  21. Space,
  22. Spin,
  23. TextArea,
  24. Typography,
  25. Card,
  26. Tag,
  27. } from '@douyinfe/semi-ui';
  28. import {
  29. IconClock,
  30. IconCalendar,
  31. IconCreditCard,
  32. IconLink,
  33. IconServer,
  34. IconUserGroup,
  35. IconSave,
  36. IconClose,
  37. IconPlusCircle,
  38. } from '@douyinfe/semi-icons';
  39. import { useTranslation } from 'react-i18next';
  40. import { StatusContext } from '../../context/Status';
  41. const { Text, Title } = Typography;
  42. const EditToken = (props) => {
  43. const { t } = useTranslation();
  44. const [statusState, statusDispatch] = useContext(StatusContext);
  45. const [isEdit, setIsEdit] = useState(false);
  46. const [loading, setLoading] = useState(isEdit);
  47. const originInputs = {
  48. name: '',
  49. remain_quota: isEdit ? 0 : 500000,
  50. expired_time: -1,
  51. unlimited_quota: false,
  52. model_limits_enabled: false,
  53. model_limits: [],
  54. allow_ips: '',
  55. group: '',
  56. };
  57. const [inputs, setInputs] = useState(originInputs);
  58. const {
  59. name,
  60. remain_quota,
  61. expired_time,
  62. unlimited_quota,
  63. model_limits_enabled,
  64. model_limits,
  65. allow_ips,
  66. group,
  67. } = inputs;
  68. const [models, setModels] = useState([]);
  69. const [groups, setGroups] = useState([]);
  70. const navigate = useNavigate();
  71. const handleInputChange = (name, value) => {
  72. setInputs((inputs) => ({ ...inputs, [name]: value }));
  73. };
  74. const handleCancel = () => {
  75. props.handleClose();
  76. };
  77. const setExpiredTime = (month, day, hour, minute) => {
  78. let now = new Date();
  79. let timestamp = now.getTime() / 1000;
  80. let seconds = month * 30 * 24 * 60 * 60;
  81. seconds += day * 24 * 60 * 60;
  82. seconds += hour * 60 * 60;
  83. seconds += minute * 60;
  84. if (seconds !== 0) {
  85. timestamp += seconds;
  86. setInputs({ ...inputs, expired_time: timestamp2string(timestamp) });
  87. } else {
  88. setInputs({ ...inputs, expired_time: -1 });
  89. }
  90. };
  91. const setUnlimitedQuota = () => {
  92. setInputs({ ...inputs, unlimited_quota: !unlimited_quota });
  93. };
  94. const loadModels = async () => {
  95. let res = await API.get(`/api/user/models`);
  96. const { success, message, data } = res.data;
  97. if (success) {
  98. let localModelOptions = data.map((model) => ({
  99. label: model,
  100. value: model,
  101. }));
  102. setModels(localModelOptions);
  103. } else {
  104. showError(t(message));
  105. }
  106. };
  107. const loadGroups = async () => {
  108. let res = await API.get(`/api/user/self/groups`);
  109. const { success, message, data } = res.data;
  110. if (success) {
  111. let localGroupOptions = Object.entries(data).map(([group, info]) => ({
  112. label: info.desc,
  113. value: group,
  114. ratio: info.ratio,
  115. }));
  116. if (statusState?.status?.default_use_auto_group) {
  117. // if contain auto, add it to the first position
  118. if (localGroupOptions.some((group) => group.value === 'auto')) {
  119. // 排序
  120. localGroupOptions.sort((a, b) => (a.value === 'auto' ? -1 : 1));
  121. } else {
  122. localGroupOptions.unshift({ label: t('自动选择'), value: 'auto' });
  123. }
  124. }
  125. setGroups(localGroupOptions);
  126. if (statusState?.status?.default_use_auto_group) {
  127. setInputs({ ...inputs, group: 'auto' });
  128. }
  129. } else {
  130. showError(t(message));
  131. }
  132. };
  133. const loadToken = async () => {
  134. setLoading(true);
  135. let res = await API.get(`/api/token/${props.editingToken.id}`);
  136. const { success, message, data } = res.data;
  137. if (success) {
  138. if (data.expired_time !== -1) {
  139. data.expired_time = timestamp2string(data.expired_time);
  140. }
  141. if (data.model_limits !== '') {
  142. data.model_limits = data.model_limits.split(',');
  143. } else {
  144. data.model_limits = [];
  145. }
  146. setInputs(data);
  147. } else {
  148. showError(message);
  149. }
  150. setLoading(false);
  151. };
  152. useEffect(() => {
  153. setIsEdit(props.editingToken.id !== undefined);
  154. }, [props.editingToken.id]);
  155. useEffect(() => {
  156. if (!isEdit) {
  157. setInputs(originInputs);
  158. } else {
  159. loadToken().then(() => {
  160. // console.log(inputs);
  161. });
  162. }
  163. loadModels();
  164. loadGroups();
  165. }, [isEdit]);
  166. // 新增 state 变量 tokenCount 来记录用户想要创建的令牌数量,默认为 1
  167. const [tokenCount, setTokenCount] = useState(1);
  168. // 新增处理 tokenCount 变化的函数
  169. const handleTokenCountChange = (value) => {
  170. // 确保用户输入的是正整数
  171. const count = parseInt(value, 10);
  172. if (!isNaN(count) && count > 0) {
  173. setTokenCount(count);
  174. }
  175. };
  176. // 生成一个随机的四位字母数字字符串
  177. const generateRandomSuffix = () => {
  178. const characters =
  179. 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
  180. let result = '';
  181. for (let i = 0; i < 6; i++) {
  182. result += characters.charAt(
  183. Math.floor(Math.random() * characters.length),
  184. );
  185. }
  186. return result;
  187. };
  188. const submit = async () => {
  189. setLoading(true);
  190. if (isEdit) {
  191. // 编辑令牌的逻辑保持不变
  192. let localInputs = { ...inputs };
  193. localInputs.remain_quota = parseInt(localInputs.remain_quota);
  194. if (localInputs.expired_time !== -1) {
  195. let time = Date.parse(localInputs.expired_time);
  196. if (isNaN(time)) {
  197. showError(t('过期时间格式错误!'));
  198. setLoading(false);
  199. return;
  200. }
  201. localInputs.expired_time = Math.ceil(time / 1000);
  202. }
  203. localInputs.model_limits = localInputs.model_limits.join(',');
  204. let res = await API.put(`/api/token/`, {
  205. ...localInputs,
  206. id: parseInt(props.editingToken.id),
  207. });
  208. const { success, message } = res.data;
  209. if (success) {
  210. showSuccess(t('令牌更新成功!'));
  211. props.refresh();
  212. props.handleClose();
  213. } else {
  214. showError(t(message));
  215. }
  216. } else {
  217. // 处理新增多个令牌的情况
  218. let successCount = 0; // 记录成功创建的令牌数量
  219. for (let i = 0; i < tokenCount; i++) {
  220. let localInputs = { ...inputs };
  221. // 检查用户是否填写了令牌名称
  222. const baseName = inputs.name.trim() === '' ? 'default' : inputs.name;
  223. if (i !== 0 || inputs.name.trim() === '') {
  224. // 如果创建多个令牌(i !== 0)或者用户没有填写名称,则添加随机后缀
  225. localInputs.name = `${baseName}-${generateRandomSuffix()}`;
  226. } else {
  227. localInputs.name = baseName;
  228. }
  229. localInputs.remain_quota = parseInt(localInputs.remain_quota);
  230. if (localInputs.expired_time !== -1) {
  231. let time = Date.parse(localInputs.expired_time);
  232. if (isNaN(time)) {
  233. showError(t('过期时间格式错误!'));
  234. setLoading(false);
  235. break;
  236. }
  237. localInputs.expired_time = Math.ceil(time / 1000);
  238. }
  239. localInputs.model_limits = localInputs.model_limits.join(',');
  240. let res = await API.post(`/api/token/`, localInputs);
  241. const { success, message } = res.data;
  242. if (success) {
  243. successCount++;
  244. } else {
  245. showError(t(message));
  246. break; // 如果创建失败,终止循环
  247. }
  248. }
  249. if (successCount > 0) {
  250. showSuccess(t('令牌创建成功,请在列表页面点击复制获取令牌!'));
  251. props.refresh();
  252. props.handleClose();
  253. }
  254. }
  255. setLoading(false);
  256. setInputs(originInputs); // 重置表单
  257. setTokenCount(1); // 重置数量为默认值
  258. };
  259. return (
  260. <SideSheet
  261. placement={isEdit ? 'right' : 'left'}
  262. title={
  263. <Space>
  264. {isEdit ? (
  265. <Tag color='blue' shape='circle'>
  266. {t('更新')}
  267. </Tag>
  268. ) : (
  269. <Tag color='green' shape='circle'>
  270. {t('新建')}
  271. </Tag>
  272. )}
  273. <Title heading={4} className='m-0'>
  274. {isEdit ? t('更新令牌信息') : t('创建新的令牌')}
  275. </Title>
  276. </Space>
  277. }
  278. headerStyle={{
  279. borderBottom: '1px solid var(--semi-color-border)',
  280. padding: '24px',
  281. }}
  282. bodyStyle={{
  283. backgroundColor: 'var(--semi-color-bg-0)',
  284. padding: '0',
  285. }}
  286. visible={props.visiable}
  287. width={isMobile() ? '100%' : 600}
  288. footer={
  289. <div className='flex justify-end bg-white'>
  290. <Space>
  291. <Button
  292. theme='solid'
  293. size='large'
  294. className='!rounded-full'
  295. onClick={submit}
  296. icon={<IconSave />}
  297. loading={loading}
  298. >
  299. {t('提交')}
  300. </Button>
  301. <Button
  302. theme='light'
  303. size='large'
  304. className='!rounded-full'
  305. type='primary'
  306. onClick={handleCancel}
  307. icon={<IconClose />}
  308. >
  309. {t('取消')}
  310. </Button>
  311. </Space>
  312. </div>
  313. }
  314. closeIcon={null}
  315. onCancel={() => handleCancel()}
  316. >
  317. <Spin spinning={loading}>
  318. <div className='p-6'>
  319. <Card className='!rounded-2xl shadow-sm border-0 mb-6'>
  320. <div
  321. className='flex items-center mb-4 p-6 rounded-xl'
  322. style={{
  323. background:
  324. 'linear-gradient(135deg, #1e3a8a 0%, #2563eb 50%, #3b82f6 100%)',
  325. position: 'relative',
  326. }}
  327. >
  328. <div className='absolute inset-0 overflow-hidden'>
  329. <div className='absolute -top-10 -right-10 w-40 h-40 bg-white opacity-5 rounded-full'></div>
  330. <div className='absolute -bottom-8 -left-8 w-24 h-24 bg-white opacity-10 rounded-full'></div>
  331. </div>
  332. <div className='w-10 h-10 rounded-full bg-white/20 flex items-center justify-center mr-4 relative'>
  333. <IconPlusCircle size='large' style={{ color: '#ffffff' }} />
  334. </div>
  335. <div className='relative'>
  336. <Text
  337. style={{ color: '#ffffff' }}
  338. className='text-lg font-medium'
  339. >
  340. {t('基本信息')}
  341. </Text>
  342. <div
  343. style={{ color: '#ffffff' }}
  344. className='text-sm opacity-80'
  345. >
  346. {t('设置令牌的基本信息')}
  347. </div>
  348. </div>
  349. </div>
  350. <div className='space-y-4'>
  351. <div>
  352. <Text strong className='block mb-2'>
  353. {t('名称')}
  354. </Text>
  355. <Input
  356. placeholder={t('请输入名称')}
  357. onChange={(value) => handleInputChange('name', value)}
  358. value={name}
  359. autoComplete='new-password'
  360. size='large'
  361. className='!rounded-lg'
  362. showClear
  363. required
  364. />
  365. </div>
  366. <div>
  367. <Text strong className='block mb-2'>
  368. {t('过期时间')}
  369. </Text>
  370. <div className='mb-2'>
  371. <DatePicker
  372. placeholder={t('请选择过期时间')}
  373. onChange={(value) =>
  374. handleInputChange('expired_time', value)
  375. }
  376. value={expired_time}
  377. autoComplete='new-password'
  378. type='dateTime'
  379. className='w-full !rounded-lg'
  380. size='large'
  381. prefix={<IconCalendar />}
  382. />
  383. </div>
  384. <div className='flex flex-wrap gap-2'>
  385. <Button
  386. theme='light'
  387. type='primary'
  388. onClick={() => setExpiredTime(0, 0, 0, 0)}
  389. className='!rounded-full'
  390. >
  391. {t('永不过期')}
  392. </Button>
  393. <Button
  394. theme='light'
  395. type='tertiary'
  396. onClick={() => setExpiredTime(0, 0, 1, 0)}
  397. className='!rounded-full'
  398. icon={<IconClock />}
  399. >
  400. {t('一小时')}
  401. </Button>
  402. <Button
  403. theme='light'
  404. type='tertiary'
  405. onClick={() => setExpiredTime(0, 1, 0, 0)}
  406. className='!rounded-full'
  407. icon={<IconCalendar />}
  408. >
  409. {t('一天')}
  410. </Button>
  411. <Button
  412. theme='light'
  413. type='tertiary'
  414. onClick={() => setExpiredTime(1, 0, 0, 0)}
  415. className='!rounded-full'
  416. icon={<IconCalendar />}
  417. >
  418. {t('一个月')}
  419. </Button>
  420. </div>
  421. </div>
  422. </div>
  423. </Card>
  424. <Card className='!rounded-2xl shadow-sm border-0 mb-6'>
  425. <div
  426. className='flex items-center mb-4 p-6 rounded-xl'
  427. style={{
  428. background:
  429. 'linear-gradient(135deg, #065f46 0%, #059669 50%, #10b981 100%)',
  430. position: 'relative',
  431. }}
  432. >
  433. <div className='absolute inset-0 overflow-hidden'>
  434. <div className='absolute -top-10 -right-10 w-40 h-40 bg-white opacity-5 rounded-full'></div>
  435. <div className='absolute -bottom-8 -left-8 w-24 h-24 bg-white opacity-10 rounded-full'></div>
  436. </div>
  437. <div className='w-10 h-10 rounded-full bg-white/20 flex items-center justify-center mr-4 relative'>
  438. <IconCreditCard size='large' style={{ color: '#ffffff' }} />
  439. </div>
  440. <div className='relative'>
  441. <Text
  442. style={{ color: '#ffffff' }}
  443. className='text-lg font-medium'
  444. >
  445. {t('额度设置')}
  446. </Text>
  447. <div
  448. style={{ color: '#ffffff' }}
  449. className='text-sm opacity-80'
  450. >
  451. {t('设置令牌可用额度和数量')}
  452. </div>
  453. </div>
  454. </div>
  455. <Banner
  456. type='warning'
  457. description={t(
  458. '注意,令牌的额度仅用于限制令牌本身的最大额度使用量,实际的使用受到账户的剩余额度限制。',
  459. )}
  460. className='mb-4 !rounded-lg'
  461. />
  462. <div className='space-y-4'>
  463. <div>
  464. <div className='flex justify-between mb-2'>
  465. <Text strong>{t('额度')}</Text>
  466. <Text type='tertiary'>
  467. {renderQuotaWithPrompt(remain_quota)}
  468. </Text>
  469. </div>
  470. <AutoComplete
  471. placeholder={t('请输入额度')}
  472. onChange={(value) => handleInputChange('remain_quota', value)}
  473. value={remain_quota}
  474. autoComplete='new-password'
  475. type='number'
  476. size='large'
  477. className='w-full !rounded-lg'
  478. prefix={<IconCreditCard />}
  479. data={[
  480. { value: 500000, label: '1$' },
  481. { value: 5000000, label: '10$' },
  482. { value: 25000000, label: '50$' },
  483. { value: 50000000, label: '100$' },
  484. { value: 250000000, label: '500$' },
  485. { value: 500000000, label: '1000$' },
  486. ]}
  487. disabled={unlimited_quota}
  488. />
  489. </div>
  490. {!isEdit && (
  491. <div>
  492. <Text strong className='block mb-2'>
  493. {t('新建数量')}
  494. </Text>
  495. <AutoComplete
  496. placeholder={t('请选择或输入创建令牌的数量')}
  497. onChange={(value) => handleTokenCountChange(value)}
  498. onSelect={(value) => handleTokenCountChange(value)}
  499. value={tokenCount.toString()}
  500. autoComplete='off'
  501. type='number'
  502. className='w-full !rounded-lg'
  503. size='large'
  504. prefix={<IconPlusCircle />}
  505. data={[
  506. { value: 10, label: t('10个') },
  507. { value: 20, label: t('20个') },
  508. { value: 30, label: t('30个') },
  509. { value: 100, label: t('100个') },
  510. ]}
  511. disabled={unlimited_quota}
  512. />
  513. </div>
  514. )}
  515. <div className='flex justify-end'>
  516. <Button
  517. theme='light'
  518. type={unlimited_quota ? 'danger' : 'warning'}
  519. onClick={setUnlimitedQuota}
  520. className='!rounded-full'
  521. >
  522. {unlimited_quota ? t('取消无限额度') : t('设为无限额度')}
  523. </Button>
  524. </div>
  525. </div>
  526. </Card>
  527. <Card className='!rounded-2xl shadow-sm border-0 mb-6'>
  528. <div
  529. className='flex items-center mb-4 p-6 rounded-xl'
  530. style={{
  531. background:
  532. 'linear-gradient(135deg, #4c1d95 0%, #6d28d9 50%, #7c3aed 100%)',
  533. position: 'relative',
  534. }}
  535. >
  536. <div className='absolute inset-0 overflow-hidden'>
  537. <div className='absolute -top-10 -right-10 w-40 h-40 bg-white opacity-5 rounded-full'></div>
  538. <div className='absolute -bottom-8 -left-8 w-24 h-24 bg-white opacity-10 rounded-full'></div>
  539. </div>
  540. <div className='w-10 h-10 rounded-full bg-white/20 flex items-center justify-center mr-4 relative'>
  541. <IconLink size='large' style={{ color: '#ffffff' }} />
  542. </div>
  543. <div className='relative'>
  544. <Text
  545. style={{ color: '#ffffff' }}
  546. className='text-lg font-medium'
  547. >
  548. {t('访问限制')}
  549. </Text>
  550. <div
  551. style={{ color: '#ffffff' }}
  552. className='text-sm opacity-80'
  553. >
  554. {t('设置令牌的访问限制')}
  555. </div>
  556. </div>
  557. </div>
  558. <div className='space-y-4'>
  559. <div>
  560. <Text strong className='block mb-2'>
  561. {t('IP白名单')}
  562. </Text>
  563. <TextArea
  564. placeholder={t('允许的IP,一行一个,不填写则不限制')}
  565. onChange={(value) => handleInputChange('allow_ips', value)}
  566. value={inputs.allow_ips}
  567. style={{ fontFamily: 'JetBrains Mono, Consolas' }}
  568. className='!rounded-lg'
  569. rows={4}
  570. />
  571. <Text type='tertiary' className='mt-1 block text-xs'>
  572. {t('请勿过度信任此功能,IP可能被伪造')}
  573. </Text>
  574. </div>
  575. <div>
  576. <div className='flex items-center mb-2'>
  577. <Checkbox
  578. checked={model_limits_enabled}
  579. onChange={(e) =>
  580. handleInputChange(
  581. 'model_limits_enabled',
  582. e.target.checked,
  583. )
  584. }
  585. >
  586. <Text strong>{t('模型限制')}</Text>
  587. </Checkbox>
  588. </div>
  589. <Select
  590. placeholder={
  591. model_limits_enabled
  592. ? t('请选择该渠道所支持的模型')
  593. : t('勾选启用模型限制后可选择')
  594. }
  595. onChange={(value) => handleInputChange('model_limits', value)}
  596. value={inputs.model_limits}
  597. multiple
  598. size='large'
  599. className='w-full !rounded-lg'
  600. prefix={<IconServer />}
  601. optionList={models}
  602. disabled={!model_limits_enabled}
  603. maxTagCount={3}
  604. />
  605. <Text type='tertiary' className='mt-1 block text-xs'>
  606. {t('非必要,不建议启用模型限制')}
  607. </Text>
  608. </div>
  609. </div>
  610. </Card>
  611. <Card className='!rounded-2xl shadow-sm border-0'>
  612. <div
  613. className='flex items-center mb-4 p-6 rounded-xl'
  614. style={{
  615. background:
  616. 'linear-gradient(135deg, #92400e 0%, #d97706 50%, #f59e0b 100%)',
  617. position: 'relative',
  618. }}
  619. >
  620. <div className='absolute inset-0 overflow-hidden'>
  621. <div className='absolute -top-10 -right-10 w-40 h-40 bg-white opacity-5 rounded-full'></div>
  622. <div className='absolute -bottom-8 -left-8 w-24 h-24 bg-white opacity-10 rounded-full'></div>
  623. </div>
  624. <div className='w-10 h-10 rounded-full bg-white/20 flex items-center justify-center mr-4 relative'>
  625. <IconUserGroup size='large' style={{ color: '#ffffff' }} />
  626. </div>
  627. <div className='relative'>
  628. <Text
  629. style={{ color: '#ffffff' }}
  630. className='text-lg font-medium'
  631. >
  632. {t('分组信息')}
  633. </Text>
  634. <div
  635. style={{ color: '#ffffff' }}
  636. className='text-sm opacity-80'
  637. >
  638. {t('设置令牌的分组')}
  639. </div>
  640. </div>
  641. </div>
  642. <div>
  643. <Text strong className='block mb-2'>
  644. {t('令牌分组')}
  645. </Text>
  646. {groups.length > 0 ? (
  647. <Select
  648. placeholder={t('令牌分组,默认为用户的分组')}
  649. onChange={(value) => handleInputChange('group', value)}
  650. renderOptionItem={renderGroupOption}
  651. value={inputs.group}
  652. size='large'
  653. className='w-full !rounded-lg'
  654. prefix={<IconUserGroup />}
  655. optionList={groups}
  656. />
  657. ) : (
  658. <Select
  659. placeholder={t('管理员未设置用户可选分组')}
  660. disabled={true}
  661. size='large'
  662. className='w-full !rounded-lg'
  663. prefix={<IconUserGroup />}
  664. />
  665. )}
  666. </div>
  667. </Card>
  668. </div>
  669. </Spin>
  670. </SideSheet>
  671. );
  672. };
  673. export default EditToken;