TokensTable.js 20 KB

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