TokensTable.js 19 KB

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