TokensTable.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670
  1. import React, { useEffect, useState } from 'react';
  2. import {
  3. API,
  4. copy,
  5. showError,
  6. showSuccess,
  7. timestamp2string,
  8. renderGroup,
  9. renderQuota
  10. } from '../../helpers';
  11. import { ITEMS_PER_PAGE } from '../../constants';
  12. import {
  13. Button,
  14. Card,
  15. Dropdown,
  16. Modal,
  17. Space,
  18. SplitButtonGroup,
  19. Table,
  20. Tag,
  21. Input,
  22. } from '@douyinfe/semi-ui';
  23. import {
  24. IconPlus,
  25. IconCopy,
  26. IconSearch,
  27. IconTreeTriangleDown,
  28. IconEyeOpened,
  29. IconEdit,
  30. IconDelete,
  31. IconStop,
  32. IconPlay,
  33. IconMore,
  34. } from '@douyinfe/semi-icons';
  35. import EditToken from '../../pages/Token/EditToken';
  36. import { useTranslation } from 'react-i18next';
  37. function renderTimestamp(timestamp) {
  38. return <>{timestamp2string(timestamp)}</>;
  39. }
  40. const TokensTable = () => {
  41. const { t } = useTranslation();
  42. const renderStatus = (status, model_limits_enabled = false) => {
  43. switch (status) {
  44. case 1:
  45. if (model_limits_enabled) {
  46. return (
  47. <Tag color='green' size='large' shape='circle'>
  48. {t('已启用:限制模型')}
  49. </Tag>
  50. );
  51. } else {
  52. return (
  53. <Tag color='green' size='large' shape='circle'>
  54. {t('已启用')}
  55. </Tag>
  56. );
  57. }
  58. case 2:
  59. return (
  60. <Tag color='red' size='large' shape='circle'>
  61. {t('已禁用')}
  62. </Tag>
  63. );
  64. case 3:
  65. return (
  66. <Tag color='yellow' size='large' shape='circle'>
  67. {t('已过期')}
  68. </Tag>
  69. );
  70. case 4:
  71. return (
  72. <Tag color='grey' size='large' shape='circle'>
  73. {t('已耗尽')}
  74. </Tag>
  75. );
  76. default:
  77. return (
  78. <Tag color='black' size='large' shape='circle'>
  79. {t('未知状态')}
  80. </Tag>
  81. );
  82. }
  83. };
  84. const columns = [
  85. {
  86. title: t('名称'),
  87. dataIndex: 'name',
  88. width: 180,
  89. },
  90. {
  91. title: t('状态'),
  92. dataIndex: 'status',
  93. key: 'status',
  94. width: 200,
  95. render: (text, record, index) => {
  96. return (
  97. <div>
  98. <Space>
  99. {renderStatus(text, record.model_limits_enabled)}
  100. {renderGroup(record.group)}
  101. </Space>
  102. </div>
  103. );
  104. },
  105. },
  106. {
  107. title: t('已用额度'),
  108. dataIndex: 'used_quota',
  109. width: 120,
  110. render: (text, record, index) => {
  111. return <div>{renderQuota(parseInt(text))}</div>;
  112. },
  113. },
  114. {
  115. title: t('剩余额度'),
  116. dataIndex: 'remain_quota',
  117. width: 120,
  118. render: (text, record, index) => {
  119. return (
  120. <div>
  121. {record.unlimited_quota ? (
  122. <Tag size={'large'} color={'white'} shape='circle'>
  123. {t('无限制')}
  124. </Tag>
  125. ) : (
  126. <Tag size={'large'} color={'light-blue'} shape='circle'>
  127. {renderQuota(parseInt(text))}
  128. </Tag>
  129. )}
  130. </div>
  131. );
  132. },
  133. },
  134. {
  135. title: t('创建时间'),
  136. dataIndex: 'created_time',
  137. width: 180,
  138. render: (text, record, index) => {
  139. return <div>{renderTimestamp(text)}</div>;
  140. },
  141. },
  142. {
  143. title: t('过期时间'),
  144. dataIndex: 'expired_time',
  145. width: 180,
  146. render: (text, record, index) => {
  147. return (
  148. <div>
  149. {record.expired_time === -1 ? t('永不过期') : renderTimestamp(text)}
  150. </div>
  151. );
  152. },
  153. },
  154. {
  155. title: '',
  156. dataIndex: 'operate',
  157. width: 320,
  158. render: (text, record, index) => {
  159. let chats = localStorage.getItem('chats');
  160. let chatsArray = [];
  161. let shouldUseCustom = true;
  162. if (shouldUseCustom) {
  163. try {
  164. chats = JSON.parse(chats);
  165. if (Array.isArray(chats)) {
  166. for (let i = 0; i < chats.length; i++) {
  167. let chat = {};
  168. chat.node = 'item';
  169. for (let key in chats[i]) {
  170. if (chats[i].hasOwnProperty(key)) {
  171. chat.key = i;
  172. chat.name = key;
  173. chat.onClick = () => {
  174. onOpenLink(key, chats[i][key], record);
  175. };
  176. }
  177. }
  178. chatsArray.push(chat);
  179. }
  180. }
  181. } catch (e) {
  182. console.log(e);
  183. showError(t('聊天链接配置错误,请联系管理员'));
  184. }
  185. }
  186. // 创建更多操作的下拉菜单项
  187. const moreMenuItems = [
  188. {
  189. node: 'item',
  190. name: t('查看'),
  191. icon: <IconEyeOpened />,
  192. onClick: () => {
  193. Modal.info({
  194. title: t('令牌详情'),
  195. content: 'sk-' + record.key,
  196. size: 'large',
  197. });
  198. },
  199. },
  200. {
  201. node: 'item',
  202. name: t('删除'),
  203. icon: <IconDelete />,
  204. type: 'danger',
  205. onClick: () => {
  206. Modal.confirm({
  207. title: t('确定是否要删除此令牌?'),
  208. content: t('此修改将不可逆'),
  209. onOk: () => {
  210. manageToken(record.id, 'delete', record).then(() => {
  211. removeRecord(record.key);
  212. });
  213. },
  214. });
  215. },
  216. }
  217. ];
  218. // 动态添加启用/禁用按钮
  219. if (record.status === 1) {
  220. moreMenuItems.push({
  221. node: 'item',
  222. name: t('禁用'),
  223. icon: <IconStop />,
  224. type: 'warning',
  225. onClick: () => {
  226. manageToken(record.id, 'disable', record);
  227. },
  228. });
  229. } else {
  230. moreMenuItems.push({
  231. node: 'item',
  232. name: t('启用'),
  233. icon: <IconPlay />,
  234. type: 'secondary',
  235. onClick: () => {
  236. manageToken(record.id, 'enable', record);
  237. },
  238. });
  239. }
  240. return (
  241. <Space wrap>
  242. <SplitButtonGroup
  243. className="!rounded-full overflow-hidden"
  244. aria-label={t('项目操作按钮组')}
  245. >
  246. <Button
  247. theme='light'
  248. size="small"
  249. style={{ color: 'rgba(var(--semi-teal-7), 1)' }}
  250. onClick={() => {
  251. if (chatsArray.length === 0) {
  252. showError(t('请联系管理员配置聊天链接'));
  253. } else {
  254. onOpenLink(
  255. 'default',
  256. chats[0][Object.keys(chats[0])[0]],
  257. record,
  258. );
  259. }
  260. }}
  261. >
  262. {t('聊天')}
  263. </Button>
  264. <Dropdown
  265. trigger='click'
  266. position='bottomRight'
  267. menu={chatsArray}
  268. >
  269. <Button
  270. style={{
  271. padding: '4px 4px',
  272. color: 'rgba(var(--semi-teal-7), 1)',
  273. }}
  274. type='primary'
  275. icon={<IconTreeTriangleDown />}
  276. size="small"
  277. ></Button>
  278. </Dropdown>
  279. </SplitButtonGroup>
  280. <Button
  281. icon={<IconCopy />}
  282. theme='light'
  283. type='secondary'
  284. size="small"
  285. className="!rounded-full"
  286. onClick={async (text) => {
  287. await copyText('sk-' + record.key);
  288. }}
  289. >
  290. {t('复制')}
  291. </Button>
  292. <Button
  293. icon={<IconEdit />}
  294. theme='light'
  295. type='tertiary'
  296. size="small"
  297. className="!rounded-full"
  298. onClick={() => {
  299. setEditingToken(record);
  300. setShowEdit(true);
  301. }}
  302. >
  303. {t('编辑')}
  304. </Button>
  305. <Dropdown
  306. trigger='click'
  307. position='bottomRight'
  308. menu={moreMenuItems}
  309. >
  310. <Button
  311. icon={<IconMore />}
  312. theme='light'
  313. type='tertiary'
  314. size="small"
  315. className="!rounded-full"
  316. />
  317. </Dropdown>
  318. </Space>
  319. );
  320. },
  321. },
  322. ];
  323. const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
  324. const [showEdit, setShowEdit] = useState(false);
  325. const [tokens, setTokens] = useState([]);
  326. const [selectedKeys, setSelectedKeys] = useState([]);
  327. const [tokenCount, setTokenCount] = useState(pageSize);
  328. const [loading, setLoading] = useState(true);
  329. const [activePage, setActivePage] = useState(1);
  330. const [searchKeyword, setSearchKeyword] = useState('');
  331. const [searchToken, setSearchToken] = useState('');
  332. const [searching, setSearching] = useState(false);
  333. const [chats, setChats] = useState([]);
  334. const [editingToken, setEditingToken] = useState({
  335. id: undefined,
  336. });
  337. const closeEdit = () => {
  338. setShowEdit(false);
  339. setTimeout(() => {
  340. setEditingToken({
  341. id: undefined,
  342. });
  343. }, 500);
  344. };
  345. const setTokensFormat = (tokens) => {
  346. setTokens(tokens);
  347. if (tokens.length >= pageSize) {
  348. setTokenCount(tokens.length + pageSize);
  349. } else {
  350. setTokenCount(tokens.length);
  351. }
  352. };
  353. let pageData = tokens.slice(
  354. (activePage - 1) * pageSize,
  355. activePage * pageSize,
  356. );
  357. const loadTokens = async (startIdx) => {
  358. setLoading(true);
  359. const res = await API.get(`/api/token/?p=${startIdx}&size=${pageSize}`);
  360. const { success, message, data } = res.data;
  361. if (success) {
  362. if (startIdx === 0) {
  363. setTokensFormat(data);
  364. } else {
  365. let newTokens = [...tokens];
  366. newTokens.splice(startIdx * pageSize, data.length, ...data);
  367. setTokensFormat(newTokens);
  368. }
  369. } else {
  370. showError(message);
  371. }
  372. setLoading(false);
  373. };
  374. const refresh = async () => {
  375. await loadTokens(activePage - 1);
  376. };
  377. const copyText = async (text) => {
  378. if (await copy(text)) {
  379. showSuccess(t('已复制到剪贴板!'));
  380. } else {
  381. Modal.error({
  382. title: t('无法复制到剪贴板,请手动复制'),
  383. content: text,
  384. size: 'large',
  385. });
  386. }
  387. };
  388. const onOpenLink = async (type, url, record) => {
  389. let status = localStorage.getItem('status');
  390. let serverAddress = '';
  391. if (status) {
  392. status = JSON.parse(status);
  393. serverAddress = status.server_address;
  394. }
  395. if (serverAddress === '') {
  396. serverAddress = window.location.origin;
  397. }
  398. let encodedServerAddress = encodeURIComponent(serverAddress);
  399. url = url.replaceAll('{address}', encodedServerAddress);
  400. url = url.replaceAll('{key}', 'sk-' + record.key);
  401. window.open(url, '_blank');
  402. };
  403. useEffect(() => {
  404. loadTokens(0)
  405. .then()
  406. .catch((reason) => {
  407. showError(reason);
  408. });
  409. }, [pageSize]);
  410. const removeRecord = (key) => {
  411. let newDataSource = [...tokens];
  412. if (key != null) {
  413. let idx = newDataSource.findIndex((data) => data.key === key);
  414. if (idx > -1) {
  415. newDataSource.splice(idx, 1);
  416. setTokensFormat(newDataSource);
  417. }
  418. }
  419. };
  420. const manageToken = async (id, action, record) => {
  421. setLoading(true);
  422. let data = { id };
  423. let res;
  424. switch (action) {
  425. case 'delete':
  426. res = await API.delete(`/api/token/${id}/`);
  427. break;
  428. case 'enable':
  429. data.status = 1;
  430. res = await API.put('/api/token/?status_only=true', data);
  431. break;
  432. case 'disable':
  433. data.status = 2;
  434. res = await API.put('/api/token/?status_only=true', data);
  435. break;
  436. }
  437. const { success, message } = res.data;
  438. if (success) {
  439. showSuccess('操作成功完成!');
  440. let token = res.data.data;
  441. let newTokens = [...tokens];
  442. if (action === 'delete') {
  443. } else {
  444. record.status = token.status;
  445. }
  446. setTokensFormat(newTokens);
  447. } else {
  448. showError(message);
  449. }
  450. setLoading(false);
  451. };
  452. const searchTokens = async () => {
  453. if (searchKeyword === '' && searchToken === '') {
  454. await loadTokens(0);
  455. setActivePage(1);
  456. return;
  457. }
  458. setSearching(true);
  459. const res = await API.get(
  460. `/api/token/search?keyword=${searchKeyword}&token=${searchToken}`,
  461. );
  462. const { success, message, data } = res.data;
  463. if (success) {
  464. setTokensFormat(data);
  465. setActivePage(1);
  466. } else {
  467. showError(message);
  468. }
  469. setSearching(false);
  470. };
  471. const handleKeywordChange = async (value) => {
  472. setSearchKeyword(value.trim());
  473. };
  474. const handleSearchTokenChange = async (value) => {
  475. setSearchToken(value.trim());
  476. };
  477. const sortToken = (key) => {
  478. if (tokens.length === 0) return;
  479. setLoading(true);
  480. let sortedTokens = [...tokens];
  481. sortedTokens.sort((a, b) => {
  482. return ('' + a[key]).localeCompare(b[key]);
  483. });
  484. if (sortedTokens[0].id === tokens[0].id) {
  485. sortedTokens.reverse();
  486. }
  487. setTokens(sortedTokens);
  488. setLoading(false);
  489. };
  490. const handlePageChange = (page) => {
  491. setActivePage(page);
  492. if (page === Math.ceil(tokens.length / pageSize) + 1) {
  493. loadTokens(page - 1).then((r) => { });
  494. }
  495. };
  496. const rowSelection = {
  497. onSelect: (record, selected) => { },
  498. onSelectAll: (selected, selectedRows) => { },
  499. onChange: (selectedRowKeys, selectedRows) => {
  500. setSelectedKeys(selectedRows);
  501. },
  502. };
  503. const handleRow = (record, index) => {
  504. if (record.status !== 1) {
  505. return {
  506. style: {
  507. background: 'var(--semi-color-disabled-border)',
  508. },
  509. };
  510. } else {
  511. return {};
  512. }
  513. };
  514. const renderHeader = () => (
  515. <div className="flex flex-col w-full">
  516. <div className="flex flex-col md:flex-row justify-between items-center gap-4 w-full">
  517. <div className="flex gap-2 w-full md:w-auto order-2 md:order-1">
  518. <Button
  519. theme="light"
  520. type="primary"
  521. icon={<IconPlus />}
  522. className="!rounded-full w-full md:w-auto"
  523. onClick={() => {
  524. setEditingToken({
  525. id: undefined,
  526. });
  527. setShowEdit(true);
  528. }}
  529. >
  530. {t('添加令牌')}
  531. </Button>
  532. <Button
  533. theme="light"
  534. type="warning"
  535. icon={<IconCopy />}
  536. className="!rounded-full w-full md:w-auto"
  537. onClick={async () => {
  538. if (selectedKeys.length === 0) {
  539. showError(t('请至少选择一个令牌!'));
  540. return;
  541. }
  542. let keys = '';
  543. for (let i = 0; i < selectedKeys.length; i++) {
  544. keys +=
  545. selectedKeys[i].name + ' sk-' + selectedKeys[i].key + '\n';
  546. }
  547. await copyText(keys);
  548. }}
  549. >
  550. {t('复制所选令牌到剪贴板')}
  551. </Button>
  552. </div>
  553. <div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto order-1 md:order-2">
  554. <div className="relative w-full md:w-56">
  555. <Input
  556. prefix={<IconSearch />}
  557. placeholder={t('搜索关键字')}
  558. value={searchKeyword}
  559. onChange={handleKeywordChange}
  560. className="!rounded-full"
  561. showClear
  562. />
  563. </div>
  564. <div className="relative w-full md:w-56">
  565. <Input
  566. prefix={<IconSearch />}
  567. placeholder={t('密钥')}
  568. value={searchToken}
  569. onChange={handleSearchTokenChange}
  570. className="!rounded-full"
  571. showClear
  572. />
  573. </div>
  574. <Button
  575. type="primary"
  576. onClick={searchTokens}
  577. loading={searching}
  578. className="!rounded-full w-full md:w-auto"
  579. >
  580. {t('查询')}
  581. </Button>
  582. </div>
  583. </div>
  584. </div>
  585. );
  586. return (
  587. <>
  588. <EditToken
  589. refresh={refresh}
  590. editingToken={editingToken}
  591. visiable={showEdit}
  592. handleClose={closeEdit}
  593. ></EditToken>
  594. <Card
  595. className="!rounded-2xl overflow-hidden"
  596. title={renderHeader()}
  597. shadows='always'
  598. bordered={false}
  599. >
  600. <Table
  601. columns={columns}
  602. dataSource={pageData}
  603. pagination={{
  604. currentPage: activePage,
  605. pageSize: pageSize,
  606. total: tokenCount,
  607. showSizeChanger: true,
  608. pageSizeOptions: [10, 20, 50, 100],
  609. formatPageText: (page) =>
  610. t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
  611. start: page.currentStart,
  612. end: page.currentEnd,
  613. total: tokens.length,
  614. }),
  615. onPageSizeChange: (size) => {
  616. setPageSize(size);
  617. setActivePage(1);
  618. },
  619. onPageChange: handlePageChange,
  620. }}
  621. loading={loading}
  622. rowSelection={rowSelection}
  623. onRow={handleRow}
  624. className="rounded-xl overflow-hidden"
  625. size="middle"
  626. ></Table>
  627. </Card>
  628. </>
  629. );
  630. };
  631. export default TokensTable;