EditToken.js 21 KB

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