EditToken.js 18 KB

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