TokensTable.js 19 KB

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