TokensTable.js 20 KB

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