TokensColumnDefs.jsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511
  1. /*
  2. Copyright (C) 2025 QuantumNous
  3. This program is free software: you can redistribute it and/or modify
  4. it under the terms of the GNU Affero General Public License as
  5. published by the Free Software Foundation, either version 3 of the
  6. License, or (at your option) any later version.
  7. This program is distributed in the hope that it will be useful,
  8. but WITHOUT ANY WARRANTY; without even the implied warranty of
  9. MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  10. GNU Affero General Public License for more details.
  11. You should have received a copy of the GNU Affero General Public License
  12. along with this program. If not, see <https://www.gnu.org/licenses/>.
  13. For commercial licensing, please contact [email protected]
  14. */
  15. import React from 'react';
  16. import {
  17. Button,
  18. Dropdown,
  19. Space,
  20. SplitButtonGroup,
  21. Tag,
  22. AvatarGroup,
  23. Avatar,
  24. Tooltip,
  25. Progress,
  26. Popover,
  27. Typography,
  28. Input,
  29. Modal,
  30. } from '@douyinfe/semi-ui';
  31. import {
  32. timestamp2string,
  33. renderGroup,
  34. renderQuota,
  35. getModelCategories,
  36. showError,
  37. } from '../../../helpers';
  38. import {
  39. IconTreeTriangleDown,
  40. IconCopy,
  41. IconEyeOpened,
  42. IconEyeClosed,
  43. } from '@douyinfe/semi-icons';
  44. // progress color helper
  45. const getProgressColor = (pct) => {
  46. if (pct === 100) return 'var(--semi-color-success)';
  47. if (pct <= 10) return 'var(--semi-color-danger)';
  48. if (pct <= 30) return 'var(--semi-color-warning)';
  49. return undefined;
  50. };
  51. // Render functions
  52. function renderTimestamp(timestamp) {
  53. return <>{timestamp2string(timestamp)}</>;
  54. }
  55. // Render status column only (no usage)
  56. const renderStatus = (text, record, t) => {
  57. const enabled = text === 1;
  58. let tagColor = 'black';
  59. let tagText = t('未知状态');
  60. if (enabled) {
  61. tagColor = 'green';
  62. tagText = t('已启用');
  63. } else if (text === 2) {
  64. tagColor = 'red';
  65. tagText = t('已禁用');
  66. } else if (text === 3) {
  67. tagColor = 'yellow';
  68. tagText = t('已过期');
  69. } else if (text === 4) {
  70. tagColor = 'grey';
  71. tagText = t('已耗尽');
  72. }
  73. return (
  74. <Tag color={tagColor} shape='circle' size='small'>
  75. {tagText}
  76. </Tag>
  77. );
  78. };
  79. // Render group column
  80. const renderGroupColumn = (text, record, t) => {
  81. if (text === 'auto') {
  82. return (
  83. <Tooltip
  84. content={t(
  85. '当前分组为 auto,会自动选择最优分组,当一个组不可用时自动降级到下一个组(熔断机制)',
  86. )}
  87. position='top'
  88. >
  89. <Tag color='white' shape='circle'>
  90. {t('智能熔断')}
  91. {record && record.cross_group_retry ? `(${t('跨分组')})` : ''}
  92. </Tag>
  93. </Tooltip>
  94. );
  95. }
  96. return renderGroup(text);
  97. };
  98. // Render token key column with show/hide and copy functionality
  99. const renderTokenKey = (text, record, showKeys, setShowKeys, copyText) => {
  100. const fullKey = 'sk-' + record.key;
  101. const maskedKey =
  102. 'sk-' + record.key.slice(0, 4) + '**********' + record.key.slice(-4);
  103. const revealed = !!showKeys[record.id];
  104. return (
  105. <div className='w-[200px]'>
  106. <Input
  107. readOnly
  108. value={revealed ? fullKey : maskedKey}
  109. size='small'
  110. suffix={
  111. <div className='flex items-center'>
  112. <Button
  113. theme='borderless'
  114. size='small'
  115. type='tertiary'
  116. icon={revealed ? <IconEyeClosed /> : <IconEyeOpened />}
  117. aria-label='toggle token visibility'
  118. onClick={(e) => {
  119. e.stopPropagation();
  120. setShowKeys((prev) => ({ ...prev, [record.id]: !revealed }));
  121. }}
  122. />
  123. <Button
  124. theme='borderless'
  125. size='small'
  126. type='tertiary'
  127. icon={<IconCopy />}
  128. aria-label='copy token key'
  129. onClick={async (e) => {
  130. e.stopPropagation();
  131. await copyText(fullKey);
  132. }}
  133. />
  134. </div>
  135. }
  136. />
  137. </div>
  138. );
  139. };
  140. // Render model limits column
  141. const renderModelLimits = (text, record, t) => {
  142. if (record.model_limits_enabled && text) {
  143. const models = text.split(',').filter(Boolean);
  144. const categories = getModelCategories(t);
  145. const vendorAvatars = [];
  146. const matchedModels = new Set();
  147. Object.entries(categories).forEach(([key, category]) => {
  148. if (key === 'all') return;
  149. if (!category.icon || !category.filter) return;
  150. const vendorModels = models.filter((m) =>
  151. category.filter({ model_name: m }),
  152. );
  153. if (vendorModels.length > 0) {
  154. vendorAvatars.push(
  155. <Tooltip
  156. key={key}
  157. content={vendorModels.join(', ')}
  158. position='top'
  159. showArrow
  160. >
  161. <Avatar
  162. size='extra-extra-small'
  163. alt={category.label}
  164. color='transparent'
  165. >
  166. {category.icon}
  167. </Avatar>
  168. </Tooltip>,
  169. );
  170. vendorModels.forEach((m) => matchedModels.add(m));
  171. }
  172. });
  173. const unmatchedModels = models.filter((m) => !matchedModels.has(m));
  174. if (unmatchedModels.length > 0) {
  175. vendorAvatars.push(
  176. <Tooltip
  177. key='unknown'
  178. content={unmatchedModels.join(', ')}
  179. position='top'
  180. showArrow
  181. >
  182. <Avatar size='extra-extra-small' alt='unknown'>
  183. {t('其他')}
  184. </Avatar>
  185. </Tooltip>,
  186. );
  187. }
  188. return <AvatarGroup size='extra-extra-small'>{vendorAvatars}</AvatarGroup>;
  189. } else {
  190. return (
  191. <Tag color='white' shape='circle'>
  192. {t('无限制')}
  193. </Tag>
  194. );
  195. }
  196. };
  197. // Render IP restrictions column
  198. const renderAllowIps = (text, t) => {
  199. if (!text || text.trim() === '') {
  200. return (
  201. <Tag color='white' shape='circle'>
  202. {t('无限制')}
  203. </Tag>
  204. );
  205. }
  206. const ips = text
  207. .split('\n')
  208. .map((ip) => ip.trim())
  209. .filter(Boolean);
  210. const displayIps = ips.slice(0, 1);
  211. const extraCount = ips.length - displayIps.length;
  212. const ipTags = displayIps.map((ip, idx) => (
  213. <Tag key={idx} shape='circle'>
  214. {ip}
  215. </Tag>
  216. ));
  217. if (extraCount > 0) {
  218. ipTags.push(
  219. <Tooltip
  220. key='extra'
  221. content={ips.slice(1).join(', ')}
  222. position='top'
  223. showArrow
  224. >
  225. <Tag shape='circle'>{'+' + extraCount}</Tag>
  226. </Tooltip>,
  227. );
  228. }
  229. return <Space wrap>{ipTags}</Space>;
  230. };
  231. // Render separate quota usage column
  232. const renderQuotaUsage = (text, record, t) => {
  233. const { Paragraph } = Typography;
  234. const used = parseInt(record.used_quota) || 0;
  235. const remain = parseInt(record.remain_quota) || 0;
  236. const total = used + remain;
  237. if (record.unlimited_quota) {
  238. const popoverContent = (
  239. <div className='text-xs p-2'>
  240. <Paragraph copyable={{ content: renderQuota(used) }}>
  241. {t('已用额度')}: {renderQuota(used)}
  242. </Paragraph>
  243. </div>
  244. );
  245. return (
  246. <Popover content={popoverContent} position='top'>
  247. <Tag color='white' shape='circle'>
  248. {t('无限额度')}
  249. </Tag>
  250. </Popover>
  251. );
  252. }
  253. const percent = total > 0 ? (remain / total) * 100 : 0;
  254. const popoverContent = (
  255. <div className='text-xs p-2'>
  256. <Paragraph copyable={{ content: renderQuota(used) }}>
  257. {t('已用额度')}: {renderQuota(used)}
  258. </Paragraph>
  259. <Paragraph copyable={{ content: renderQuota(remain) }}>
  260. {t('剩余额度')}: {renderQuota(remain)} ({percent.toFixed(0)}%)
  261. </Paragraph>
  262. <Paragraph copyable={{ content: renderQuota(total) }}>
  263. {t('总额度')}: {renderQuota(total)}
  264. </Paragraph>
  265. </div>
  266. );
  267. return (
  268. <Popover content={popoverContent} position='top'>
  269. <Tag color='white' shape='circle'>
  270. <div className='flex flex-col items-end'>
  271. <span className='text-xs leading-none'>{`${renderQuota(remain)} / ${renderQuota(total)}`}</span>
  272. <Progress
  273. percent={percent}
  274. stroke={getProgressColor(percent)}
  275. aria-label='quota usage'
  276. format={() => `${percent.toFixed(0)}%`}
  277. style={{ width: '100%', marginTop: '1px', marginBottom: 0 }}
  278. />
  279. </div>
  280. </Tag>
  281. </Popover>
  282. );
  283. };
  284. // Render operations column
  285. const renderOperations = (
  286. text,
  287. record,
  288. onOpenLink,
  289. setEditingToken,
  290. setShowEdit,
  291. manageToken,
  292. refresh,
  293. t,
  294. ) => {
  295. let chatsArray = [];
  296. try {
  297. const raw = localStorage.getItem('chats');
  298. const parsed = JSON.parse(raw);
  299. if (Array.isArray(parsed)) {
  300. for (let i = 0; i < parsed.length; i++) {
  301. const item = parsed[i];
  302. const name = Object.keys(item)[0];
  303. if (!name) continue;
  304. chatsArray.push({
  305. node: 'item',
  306. key: i,
  307. name,
  308. value: item[name],
  309. onClick: () => onOpenLink(name, item[name], record),
  310. });
  311. }
  312. }
  313. } catch (_) {
  314. showError(t('聊天链接配置错误,请联系管理员'));
  315. }
  316. return (
  317. <Space wrap>
  318. <SplitButtonGroup
  319. className='overflow-hidden'
  320. aria-label={t('项目操作按钮组')}
  321. >
  322. <Button
  323. size='small'
  324. type='tertiary'
  325. onClick={() => {
  326. if (chatsArray.length === 0) {
  327. showError(t('请联系管理员配置聊天链接'));
  328. } else {
  329. const first = chatsArray[0];
  330. onOpenLink(first.name, first.value, record);
  331. }
  332. }}
  333. >
  334. {t('聊天')}
  335. </Button>
  336. <Dropdown trigger='click' position='bottomRight' menu={chatsArray}>
  337. <Button
  338. type='tertiary'
  339. icon={<IconTreeTriangleDown />}
  340. size='small'
  341. ></Button>
  342. </Dropdown>
  343. </SplitButtonGroup>
  344. {record.status === 1 ? (
  345. <Button
  346. type='danger'
  347. size='small'
  348. onClick={async () => {
  349. await manageToken(record.id, 'disable', record);
  350. await refresh();
  351. }}
  352. >
  353. {t('禁用')}
  354. </Button>
  355. ) : (
  356. <Button
  357. size='small'
  358. onClick={async () => {
  359. await manageToken(record.id, 'enable', record);
  360. await refresh();
  361. }}
  362. >
  363. {t('启用')}
  364. </Button>
  365. )}
  366. <Button
  367. type='tertiary'
  368. size='small'
  369. onClick={() => {
  370. setEditingToken(record);
  371. setShowEdit(true);
  372. }}
  373. >
  374. {t('编辑')}
  375. </Button>
  376. <Button
  377. type='danger'
  378. size='small'
  379. onClick={() => {
  380. Modal.confirm({
  381. title: t('确定是否要删除此令牌?'),
  382. content: t('此修改将不可逆'),
  383. onOk: () => {
  384. (async () => {
  385. await manageToken(record.id, 'delete', record);
  386. await refresh();
  387. })();
  388. },
  389. });
  390. }}
  391. >
  392. {t('删除')}
  393. </Button>
  394. </Space>
  395. );
  396. };
  397. export const getTokensColumns = ({
  398. t,
  399. showKeys,
  400. setShowKeys,
  401. copyText,
  402. manageToken,
  403. onOpenLink,
  404. setEditingToken,
  405. setShowEdit,
  406. refresh,
  407. }) => {
  408. return [
  409. {
  410. title: t('名称'),
  411. dataIndex: 'name',
  412. },
  413. {
  414. title: t('状态'),
  415. dataIndex: 'status',
  416. key: 'status',
  417. render: (text, record) => renderStatus(text, record, t),
  418. },
  419. {
  420. title: t('剩余额度/总额度'),
  421. key: 'quota_usage',
  422. render: (text, record) => renderQuotaUsage(text, record, t),
  423. },
  424. {
  425. title: t('分组'),
  426. dataIndex: 'group',
  427. key: 'group',
  428. render: (text, record) => renderGroupColumn(text, record, t),
  429. },
  430. {
  431. title: t('密钥'),
  432. key: 'token_key',
  433. render: (text, record) =>
  434. renderTokenKey(text, record, showKeys, setShowKeys, copyText),
  435. },
  436. {
  437. title: t('可用模型'),
  438. dataIndex: 'model_limits',
  439. render: (text, record) => renderModelLimits(text, record, t),
  440. },
  441. {
  442. title: t('IP限制'),
  443. dataIndex: 'allow_ips',
  444. render: (text) => renderAllowIps(text, t),
  445. },
  446. {
  447. title: t('创建时间'),
  448. dataIndex: 'created_time',
  449. render: (text, record, index) => {
  450. return <div>{renderTimestamp(text)}</div>;
  451. },
  452. },
  453. {
  454. title: t('过期时间'),
  455. dataIndex: 'expired_time',
  456. render: (text, record, index) => {
  457. return (
  458. <div>
  459. {record.expired_time === -1 ? t('永不过期') : renderTimestamp(text)}
  460. </div>
  461. );
  462. },
  463. },
  464. {
  465. title: '',
  466. dataIndex: 'operate',
  467. fixed: 'right',
  468. render: (text, record, index) =>
  469. renderOperations(
  470. text,
  471. record,
  472. onOpenLink,
  473. setEditingToken,
  474. setShowEdit,
  475. manageToken,
  476. refresh,
  477. t,
  478. ),
  479. },
  480. ];
  481. };