TokensTable.js 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931
  1. import React, { useEffect, useState } from 'react';
  2. import {
  3. API,
  4. copy,
  5. showError,
  6. showSuccess,
  7. timestamp2string,
  8. renderGroup,
  9. renderQuota,
  10. getModelCategories
  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. AvatarGroup,
  26. Avatar,
  27. Tooltip,
  28. Progress,
  29. Switch,
  30. Input,
  31. Typography
  32. } from '@douyinfe/semi-ui';
  33. import {
  34. IllustrationNoResult,
  35. IllustrationNoResultDark
  36. } from '@douyinfe/semi-illustrations';
  37. import {
  38. IconSearch,
  39. IconTreeTriangleDown,
  40. IconCopy,
  41. IconEyeOpened,
  42. IconEyeClosed,
  43. IconBolt,
  44. } from '@douyinfe/semi-icons';
  45. import { Key } from 'lucide-react';
  46. import EditToken from '../../pages/Token/EditToken';
  47. import { useTranslation } from 'react-i18next';
  48. import { useTableCompactMode } from '../../hooks/useTableCompactMode';
  49. const { Text } = Typography;
  50. function renderTimestamp(timestamp) {
  51. return <>{timestamp2string(timestamp)}</>;
  52. }
  53. const TokensTable = () => {
  54. const { t } = useTranslation();
  55. const columns = [
  56. {
  57. title: t('名称'),
  58. dataIndex: 'name',
  59. },
  60. {
  61. title: t('状态'),
  62. dataIndex: 'status',
  63. key: 'status',
  64. render: (text, record) => {
  65. const enabled = text === 1;
  66. const handleToggle = (checked) => {
  67. if (checked) {
  68. manageToken(record.id, 'enable', record);
  69. } else {
  70. manageToken(record.id, 'disable', record);
  71. }
  72. };
  73. let tagColor = 'black';
  74. let tagText = t('未知状态');
  75. if (enabled) {
  76. tagColor = 'green';
  77. tagText = t('已启用');
  78. } else if (text === 2) {
  79. tagColor = 'red';
  80. tagText = t('已禁用');
  81. } else if (text === 3) {
  82. tagColor = 'yellow';
  83. tagText = t('已过期');
  84. } else if (text === 4) {
  85. tagColor = 'grey';
  86. tagText = t('已耗尽');
  87. }
  88. return (
  89. <Tag
  90. color={tagColor}
  91. shape='circle'
  92. prefixIcon={
  93. <Switch
  94. size='small'
  95. checked={enabled}
  96. onChange={handleToggle}
  97. aria-label='token status switch'
  98. />
  99. }
  100. >
  101. {tagText}
  102. </Tag>
  103. );
  104. },
  105. },
  106. {
  107. title: t('分组'),
  108. dataIndex: 'group',
  109. key: 'group',
  110. render: (text) => {
  111. if (text === 'auto') {
  112. return (
  113. <Tooltip
  114. content={t('当前分组为 auto,会自动选择最优分组,当一个组不可用时自动降级到下一个组(熔断机制)')}
  115. position='top'
  116. >
  117. <Tag color='blue' shape='circle' prefixIcon={<IconBolt />}> {t('智能熔断')} </Tag>
  118. </Tooltip>
  119. );
  120. }
  121. return renderGroup(text);
  122. },
  123. },
  124. {
  125. title: t('密钥'),
  126. key: 'token_key',
  127. render: (text, record) => {
  128. const fullKey = 'sk-' + record.key;
  129. const maskedKey = 'sk-' + record.key.slice(0, 4) + '**********' + record.key.slice(-4);
  130. const revealed = !!showKeys[record.id];
  131. return (
  132. <div className='w-[200px]'>
  133. <Input
  134. readOnly
  135. value={revealed ? fullKey : maskedKey}
  136. size='small'
  137. suffix={
  138. <div className='flex items-center'>
  139. <Button
  140. theme='borderless'
  141. size='small'
  142. type='tertiary'
  143. icon={revealed ? <IconEyeClosed /> : <IconEyeOpened />}
  144. aria-label='toggle token visibility'
  145. onClick={(e) => {
  146. e.stopPropagation();
  147. setShowKeys(prev => ({ ...prev, [record.id]: !revealed }));
  148. }}
  149. />
  150. <Button
  151. theme='borderless'
  152. size='small'
  153. type='tertiary'
  154. icon={<IconCopy />}
  155. aria-label='copy token key'
  156. onClick={async (e) => {
  157. e.stopPropagation();
  158. await copyText(fullKey);
  159. }}
  160. />
  161. </div>
  162. }
  163. />
  164. </div>
  165. );
  166. },
  167. },
  168. {
  169. title: t('剩余额度'),
  170. key: 'quota',
  171. render: (text, record) => {
  172. if (record.unlimited_quota) {
  173. return <Tag color='white' shape='circle'>{t('无限制')}</Tag>;
  174. }
  175. const used = parseInt(record.used_quota) || 0;
  176. const remain = parseInt(record.remain_quota) || 0;
  177. const total = used + remain;
  178. // 计算剩余额度百分比,100% 表示额度未使用
  179. const percent = total > 0 ? (remain / total) * 100 : 0;
  180. // 根据剩余百分比动态设置颜色,100% 绿色,<=10% 红色,<=30% 黄色,其余默认
  181. const getProgressColor = (pct) => {
  182. if (pct === 100) return 'var(--semi-color-success)';
  183. if (pct <= 10) return 'var(--semi-color-danger)';
  184. if (pct <= 30) return 'var(--semi-color-warning)';
  185. return undefined; // 默认颜色
  186. };
  187. return (
  188. <Tooltip
  189. content={
  190. <div className='text-xs'>
  191. <div>{t('已用额度')}: {renderQuota(used)}</div>
  192. <div>{t('剩余额度')}: {renderQuota(remain)} ({percent.toFixed(0)}%)</div>
  193. <div>{t('总额度')}: {renderQuota(total)}</div>
  194. </div>
  195. }
  196. >
  197. <div className='w-[30px]'>
  198. <Progress
  199. percent={percent}
  200. stroke={getProgressColor(percent)}
  201. showInfo={false}
  202. aria-label='quota usage'
  203. type="circle"
  204. size='small'
  205. />
  206. </div>
  207. </Tooltip>
  208. );
  209. },
  210. },
  211. {
  212. title: t('可用模型'),
  213. dataIndex: 'model_limits',
  214. render: (text, record) => {
  215. if (record.model_limits_enabled && text) {
  216. const models = text.split(',').filter(Boolean);
  217. const categories = getModelCategories(t);
  218. const vendorAvatars = [];
  219. const matchedModels = new Set();
  220. Object.entries(categories).forEach(([key, category]) => {
  221. if (key === 'all') return;
  222. if (!category.icon || !category.filter) return;
  223. const vendorModels = models.filter((m) => category.filter({ model_name: m }));
  224. if (vendorModels.length > 0) {
  225. vendorAvatars.push(
  226. <Tooltip key={key} content={vendorModels.join(', ')} position='top' showArrow>
  227. <Avatar size='extra-extra-small' alt={category.label} color='transparent'>
  228. {category.icon}
  229. </Avatar>
  230. </Tooltip>
  231. );
  232. vendorModels.forEach((m) => matchedModels.add(m));
  233. }
  234. });
  235. const unmatchedModels = models.filter((m) => !matchedModels.has(m));
  236. if (unmatchedModels.length > 0) {
  237. vendorAvatars.push(
  238. <Tooltip key='unknown' content={unmatchedModels.join(', ')} position='top' showArrow>
  239. <Avatar size='extra-extra-small' alt='unknown'>
  240. {t('其他')}
  241. </Avatar>
  242. </Tooltip>
  243. );
  244. }
  245. return (
  246. <AvatarGroup size='extra-extra-small'>
  247. {vendorAvatars}
  248. </AvatarGroup>
  249. );
  250. } else {
  251. return (
  252. <Tag color='white' shape='circle'>
  253. {t('无限制')}
  254. </Tag>
  255. );
  256. }
  257. },
  258. },
  259. {
  260. title: t('IP限制'),
  261. dataIndex: 'allow_ips',
  262. render: (text) => {
  263. if (!text || text.trim() === '') {
  264. return (
  265. <Tag color='white' shape='circle'>
  266. {t('无限制')}
  267. </Tag>
  268. );
  269. }
  270. const ips = text
  271. .split('\n')
  272. .map((ip) => ip.trim())
  273. .filter(Boolean);
  274. const displayIps = ips.slice(0, 1);
  275. const extraCount = ips.length - displayIps.length;
  276. const ipTags = displayIps.map((ip, idx) => (
  277. <Tag key={idx} shape='circle'>
  278. {ip}
  279. </Tag>
  280. ));
  281. if (extraCount > 0) {
  282. ipTags.push(
  283. <Tooltip
  284. key='extra'
  285. content={ips.slice(1).join(', ')}
  286. position='top'
  287. showArrow
  288. >
  289. <Tag shape='circle'>
  290. {'+' + extraCount}
  291. </Tag>
  292. </Tooltip>
  293. );
  294. }
  295. return <Space wrap>{ipTags}</Space>;
  296. },
  297. },
  298. {
  299. title: t('创建时间'),
  300. dataIndex: 'created_time',
  301. render: (text, record, index) => {
  302. return <div>{renderTimestamp(text)}</div>;
  303. },
  304. },
  305. {
  306. title: t('过期时间'),
  307. dataIndex: 'expired_time',
  308. render: (text, record, index) => {
  309. return (
  310. <div>
  311. {record.expired_time === -1 ? t('永不过期') : renderTimestamp(text)}
  312. </div>
  313. );
  314. },
  315. },
  316. {
  317. title: '',
  318. dataIndex: 'operate',
  319. fixed: 'right',
  320. render: (text, record, index) => {
  321. let chats = localStorage.getItem('chats');
  322. let chatsArray = [];
  323. let shouldUseCustom = true;
  324. if (shouldUseCustom) {
  325. try {
  326. chats = JSON.parse(chats);
  327. if (Array.isArray(chats)) {
  328. for (let i = 0; i < chats.length; i++) {
  329. let chat = {};
  330. chat.node = 'item';
  331. for (let key in chats[i]) {
  332. if (chats[i].hasOwnProperty(key)) {
  333. chat.key = i;
  334. chat.name = key;
  335. chat.onClick = () => {
  336. onOpenLink(key, chats[i][key], record);
  337. };
  338. }
  339. }
  340. chatsArray.push(chat);
  341. }
  342. }
  343. } catch (e) {
  344. console.log(e);
  345. showError(t('聊天链接配置错误,请联系管理员'));
  346. }
  347. }
  348. return (
  349. <Space wrap>
  350. <SplitButtonGroup
  351. className="overflow-hidden"
  352. aria-label={t('项目操作按钮组')}
  353. >
  354. <Button
  355. theme='light'
  356. size="small"
  357. style={{ color: 'rgba(var(--semi-teal-7), 1)' }}
  358. onClick={() => {
  359. if (chatsArray.length === 0) {
  360. showError(t('请联系管理员配置聊天链接'));
  361. } else {
  362. onOpenLink(
  363. 'default',
  364. chats[0][Object.keys(chats[0])[0]],
  365. record,
  366. );
  367. }
  368. }}
  369. >
  370. {t('聊天')}
  371. </Button>
  372. <Dropdown
  373. trigger='click'
  374. position='bottomRight'
  375. menu={chatsArray}
  376. >
  377. <Button
  378. style={{
  379. padding: '4px 4px',
  380. color: 'rgba(var(--semi-teal-7), 1)',
  381. }}
  382. type='primary'
  383. icon={<IconTreeTriangleDown />}
  384. size="small"
  385. ></Button>
  386. </Dropdown>
  387. </SplitButtonGroup>
  388. <Button
  389. theme='light'
  390. type='tertiary'
  391. size="small"
  392. onClick={() => {
  393. setEditingToken(record);
  394. setShowEdit(true);
  395. }}
  396. >
  397. {t('编辑')}
  398. </Button>
  399. <Button
  400. theme='light'
  401. type='danger'
  402. size="small"
  403. onClick={() => {
  404. Modal.confirm({
  405. title: t('确定是否要删除此令牌?'),
  406. content: t('此修改将不可逆'),
  407. onOk: () => {
  408. manageToken(record.id, 'delete', record).then(() => {
  409. removeRecord(record.key);
  410. });
  411. },
  412. });
  413. }}
  414. >
  415. {t('删除')}
  416. </Button>
  417. </Space>
  418. );
  419. },
  420. },
  421. ];
  422. const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
  423. const [showEdit, setShowEdit] = useState(false);
  424. const [tokens, setTokens] = useState([]);
  425. const [selectedKeys, setSelectedKeys] = useState([]);
  426. const [tokenCount, setTokenCount] = useState(pageSize);
  427. const [loading, setLoading] = useState(true);
  428. const [activePage, setActivePage] = useState(1);
  429. const [searching, setSearching] = useState(false);
  430. const [editingToken, setEditingToken] = useState({
  431. id: undefined,
  432. });
  433. const [compactMode, setCompactMode] = useTableCompactMode('tokens');
  434. const [showKeys, setShowKeys] = useState({});
  435. // Form 初始值
  436. const formInitValues = {
  437. searchKeyword: '',
  438. searchToken: '',
  439. };
  440. // Form API 引用
  441. const [formApi, setFormApi] = useState(null);
  442. // 获取表单值的辅助函数
  443. const getFormValues = () => {
  444. const formValues = formApi ? formApi.getValues() : {};
  445. return {
  446. searchKeyword: formValues.searchKeyword || '',
  447. searchToken: formValues.searchToken || '',
  448. };
  449. };
  450. const closeEdit = () => {
  451. setShowEdit(false);
  452. setTimeout(() => {
  453. setEditingToken({
  454. id: undefined,
  455. });
  456. }, 500);
  457. };
  458. // 将后端返回的数据写入状态
  459. const syncPageData = (payload) => {
  460. setTokens(payload.items || []);
  461. setTokenCount(payload.total || 0);
  462. setActivePage(payload.page || 1);
  463. setPageSize(payload.page_size || pageSize);
  464. };
  465. const loadTokens = async (page = 1, size = pageSize) => {
  466. setLoading(true);
  467. const res = await API.get(`/api/token/?p=${page}&size=${size}`);
  468. const { success, message, data } = res.data;
  469. if (success) {
  470. syncPageData(data);
  471. } else {
  472. showError(message);
  473. }
  474. setLoading(false);
  475. };
  476. const refresh = async () => {
  477. await loadTokens(1);
  478. setSelectedKeys([]);
  479. };
  480. const copyText = async (text) => {
  481. if (await copy(text)) {
  482. showSuccess(t('已复制到剪贴板!'));
  483. } else {
  484. Modal.error({
  485. title: t('无法复制到剪贴板,请手动复制'),
  486. content: text,
  487. size: 'large',
  488. });
  489. }
  490. };
  491. const onOpenLink = async (type, url, record) => {
  492. let status = localStorage.getItem('status');
  493. let serverAddress = '';
  494. if (status) {
  495. status = JSON.parse(status);
  496. serverAddress = status.server_address;
  497. }
  498. if (serverAddress === '') {
  499. serverAddress = window.location.origin;
  500. }
  501. if (url.includes('{cherryConfig}') === true) {
  502. let cherryConfig = {
  503. id: 'new-api',
  504. baseUrl: serverAddress,
  505. apiKey: 'sk-' + record.key,
  506. }
  507. // 替换 {cherryConfig} 为base64编码的JSON字符串
  508. let encodedConfig = encodeURIComponent(
  509. btoa(JSON.stringify(cherryConfig))
  510. );
  511. url = url.replaceAll('{cherryConfig}', encodedConfig);
  512. } else {
  513. let encodedServerAddress = encodeURIComponent(serverAddress);
  514. url = url.replaceAll('{address}', encodedServerAddress);
  515. url = url.replaceAll('{key}', 'sk-' + record.key);
  516. }
  517. window.open(url, '_blank');
  518. };
  519. useEffect(() => {
  520. loadTokens(1)
  521. .then()
  522. .catch((reason) => {
  523. showError(reason);
  524. });
  525. }, [pageSize]);
  526. const removeRecord = (key) => {
  527. let newDataSource = [...tokens];
  528. if (key != null) {
  529. let idx = newDataSource.findIndex((data) => data.key === key);
  530. if (idx > -1) {
  531. newDataSource.splice(idx, 1);
  532. setTokens(newDataSource);
  533. }
  534. }
  535. };
  536. const manageToken = async (id, action, record) => {
  537. setLoading(true);
  538. let data = { id };
  539. let res;
  540. switch (action) {
  541. case 'delete':
  542. res = await API.delete(`/api/token/${id}/`);
  543. break;
  544. case 'enable':
  545. data.status = 1;
  546. res = await API.put('/api/token/?status_only=true', data);
  547. break;
  548. case 'disable':
  549. data.status = 2;
  550. res = await API.put('/api/token/?status_only=true', data);
  551. break;
  552. }
  553. const { success, message } = res.data;
  554. if (success) {
  555. showSuccess('操作成功完成!');
  556. let token = res.data.data;
  557. let newTokens = [...tokens];
  558. if (action === 'delete') {
  559. } else {
  560. record.status = token.status;
  561. }
  562. setTokens(newTokens);
  563. } else {
  564. showError(message);
  565. }
  566. setLoading(false);
  567. };
  568. const searchTokens = async () => {
  569. const { searchKeyword, searchToken } = getFormValues();
  570. if (searchKeyword === '' && searchToken === '') {
  571. await loadTokens(1);
  572. return;
  573. }
  574. setSearching(true);
  575. const res = await API.get(
  576. `/api/token/search?keyword=${searchKeyword}&token=${searchToken}`,
  577. );
  578. const { success, message, data } = res.data;
  579. if (success) {
  580. setTokens(data);
  581. setTokenCount(data.length);
  582. setActivePage(1);
  583. } else {
  584. showError(message);
  585. }
  586. setSearching(false);
  587. };
  588. const sortToken = (key) => {
  589. if (tokens.length === 0) return;
  590. setLoading(true);
  591. let sortedTokens = [...tokens];
  592. sortedTokens.sort((a, b) => {
  593. return ('' + a[key]).localeCompare(b[key]);
  594. });
  595. if (sortedTokens[0].id === tokens[0].id) {
  596. sortedTokens.reverse();
  597. }
  598. setTokens(sortedTokens);
  599. setLoading(false);
  600. };
  601. const handlePageChange = (page) => {
  602. loadTokens(page, pageSize).then();
  603. };
  604. const handlePageSizeChange = async (size) => {
  605. setPageSize(size);
  606. await loadTokens(1, size);
  607. };
  608. const rowSelection = {
  609. onSelect: (record, selected) => { },
  610. onSelectAll: (selected, selectedRows) => { },
  611. onChange: (selectedRowKeys, selectedRows) => {
  612. setSelectedKeys(selectedRows);
  613. },
  614. };
  615. const handleRow = (record, index) => {
  616. if (record.status !== 1) {
  617. return {
  618. style: {
  619. background: 'var(--semi-color-disabled-border)',
  620. },
  621. };
  622. } else {
  623. return {};
  624. }
  625. };
  626. const batchDeleteTokens = async () => {
  627. if (selectedKeys.length === 0) {
  628. showError(t('请先选择要删除的令牌!'));
  629. return;
  630. }
  631. setLoading(true);
  632. try {
  633. const ids = selectedKeys.map((token) => token.id);
  634. const res = await API.post('/api/token/batch', { ids });
  635. if (res?.data?.success) {
  636. const count = res.data.data || 0;
  637. showSuccess(t('已删除 {{count}} 个令牌!', { count }));
  638. await refresh();
  639. } else {
  640. showError(res?.data?.message || t('删除失败'));
  641. }
  642. } catch (error) {
  643. showError(error.message);
  644. } finally {
  645. setLoading(false);
  646. }
  647. };
  648. const renderHeader = () => (
  649. <div className="flex flex-col w-full">
  650. <div className="mb-2">
  651. <div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full">
  652. <div className="flex items-center text-blue-500">
  653. <Key size={16} className="mr-2" />
  654. <Text>{t('令牌用于API访问认证,可以设置额度限制和模型权限。')}</Text>
  655. </div>
  656. <Button
  657. theme="light"
  658. type="secondary"
  659. className="w-full md:w-auto"
  660. onClick={() => setCompactMode(!compactMode)}
  661. size="small"
  662. >
  663. {compactMode ? t('自适应列表') : t('紧凑列表')}
  664. </Button>
  665. </div>
  666. </div>
  667. <Divider margin="12px" />
  668. <div className="flex flex-col md:flex-row justify-between items-center gap-4 w-full">
  669. <div className="flex flex-wrap gap-2 w-full md:w-auto order-2 md:order-1">
  670. <Button
  671. theme="light"
  672. type="primary"
  673. className="flex-1 md:flex-initial"
  674. onClick={() => {
  675. setEditingToken({
  676. id: undefined,
  677. });
  678. setShowEdit(true);
  679. }}
  680. size="small"
  681. >
  682. {t('添加令牌')}
  683. </Button>
  684. <Button
  685. theme="light"
  686. type="warning"
  687. className="flex-1 md:flex-initial"
  688. onClick={() => {
  689. if (selectedKeys.length === 0) {
  690. showError(t('请至少选择一个令牌!'));
  691. return;
  692. }
  693. Modal.info({
  694. title: t('复制令牌'),
  695. icon: null,
  696. content: t('请选择你的复制方式'),
  697. footer: (
  698. <Space>
  699. <Button
  700. type="primary"
  701. theme="solid"
  702. onClick={async () => {
  703. let content = '';
  704. for (let i = 0; i < selectedKeys.length; i++) {
  705. content +=
  706. selectedKeys[i].name + ' sk-' + selectedKeys[i].key + '\n';
  707. }
  708. await copyText(content);
  709. Modal.destroyAll();
  710. }}
  711. >
  712. {t('名称+密钥')}
  713. </Button>
  714. <Button
  715. theme="light"
  716. onClick={async () => {
  717. let content = '';
  718. for (let i = 0; i < selectedKeys.length; i++) {
  719. content += 'sk-' + selectedKeys[i].key + '\n';
  720. }
  721. await copyText(content);
  722. Modal.destroyAll();
  723. }}
  724. >
  725. {t('仅密钥')}
  726. </Button>
  727. </Space>
  728. ),
  729. });
  730. }}
  731. size="small"
  732. >
  733. {t('复制所选令牌')}
  734. </Button>
  735. <Button
  736. theme="light"
  737. type="danger"
  738. className="w-full md:w-auto"
  739. onClick={() => {
  740. if (selectedKeys.length === 0) {
  741. showError(t('请至少选择一个令牌!'));
  742. return;
  743. }
  744. Modal.confirm({
  745. title: t('批量删除令牌'),
  746. content: (
  747. <div>
  748. {t('确定要删除所选的 {{count}} 个令牌吗?', { count: selectedKeys.length })}
  749. </div>
  750. ),
  751. onOk: () => batchDeleteTokens(),
  752. });
  753. }}
  754. size="small"
  755. >
  756. {t('删除所选令牌')}
  757. </Button>
  758. </div>
  759. <Form
  760. initValues={formInitValues}
  761. getFormApi={(api) => setFormApi(api)}
  762. onSubmit={searchTokens}
  763. allowEmpty={true}
  764. autoComplete="off"
  765. layout="horizontal"
  766. trigger="change"
  767. stopValidateWithError={false}
  768. className="w-full md:w-auto order-1 md:order-2"
  769. >
  770. <div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto">
  771. <div className="relative w-full md:w-56">
  772. <Form.Input
  773. field="searchKeyword"
  774. prefix={<IconSearch />}
  775. placeholder={t('搜索关键字')}
  776. showClear
  777. pure
  778. size="small"
  779. />
  780. </div>
  781. <div className="relative w-full md:w-56">
  782. <Form.Input
  783. field="searchToken"
  784. prefix={<IconSearch />}
  785. placeholder={t('密钥')}
  786. showClear
  787. pure
  788. size="small"
  789. />
  790. </div>
  791. <div className="flex gap-2 w-full md:w-auto">
  792. <Button
  793. type="primary"
  794. htmlType="submit"
  795. loading={loading || searching}
  796. className="flex-1 md:flex-initial md:w-auto"
  797. size="small"
  798. >
  799. {t('查询')}
  800. </Button>
  801. <Button
  802. theme="light"
  803. onClick={() => {
  804. if (formApi) {
  805. formApi.reset();
  806. // 重置后立即查询,使用setTimeout确保表单重置完成
  807. setTimeout(() => {
  808. searchTokens();
  809. }, 100);
  810. }
  811. }}
  812. className="flex-1 md:flex-initial md:w-auto"
  813. size="small"
  814. >
  815. {t('重置')}
  816. </Button>
  817. </div>
  818. </div>
  819. </Form>
  820. </div>
  821. </div>
  822. );
  823. return (
  824. <>
  825. <EditToken
  826. refresh={refresh}
  827. editingToken={editingToken}
  828. visiable={showEdit}
  829. handleClose={closeEdit}
  830. ></EditToken>
  831. <Card
  832. className="!rounded-2xl"
  833. title={renderHeader()}
  834. shadows='always'
  835. bordered={false}
  836. >
  837. <Table
  838. columns={compactMode ? columns.map(col => {
  839. if (col.dataIndex === 'operate') {
  840. const { fixed, ...rest } = col;
  841. return rest;
  842. }
  843. return col;
  844. }) : columns}
  845. dataSource={tokens}
  846. scroll={compactMode ? undefined : { x: 'max-content' }}
  847. pagination={{
  848. currentPage: activePage,
  849. pageSize: pageSize,
  850. total: tokenCount,
  851. showSizeChanger: true,
  852. pageSizeOptions: [10, 20, 50, 100],
  853. formatPageText: (page) =>
  854. t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
  855. start: page.currentStart,
  856. end: page.currentEnd,
  857. total: tokenCount,
  858. }),
  859. onPageSizeChange: handlePageSizeChange,
  860. onPageChange: handlePageChange,
  861. }}
  862. loading={loading}
  863. rowSelection={rowSelection}
  864. onRow={handleRow}
  865. empty={
  866. <Empty
  867. image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
  868. darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
  869. description={t('搜索无结果')}
  870. style={{ padding: 30 }}
  871. />
  872. }
  873. className="rounded-xl overflow-hidden"
  874. size="middle"
  875. ></Table>
  876. </Card>
  877. </>
  878. );
  879. };
  880. export default TokensTable;