TokensTable.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669
  1. import React, { useEffect, useState } from 'react';
  2. import {
  3. API,
  4. copy,
  5. showError,
  6. showSuccess,
  7. timestamp2string,
  8. } from '../helpers';
  9. import { ITEMS_PER_PAGE } from '../constants';
  10. import { renderQuota } from '../helpers/render';
  11. import {
  12. Button,
  13. Dropdown,
  14. Form,
  15. Modal,
  16. Popconfirm,
  17. Popover,
  18. SplitButtonGroup,
  19. Table,
  20. Tag,
  21. } from '@douyinfe/semi-ui';
  22. import { IconTreeTriangleDown } from '@douyinfe/semi-icons';
  23. import EditToken from '../pages/Token/EditToken';
  24. const COPY_OPTIONS = [
  25. { key: 'next', text: 'ChatGPT Next Web', value: 'next' },
  26. { key: 'ama', text: 'ChatGPT Web & Midjourney', value: 'ama' },
  27. { key: 'opencat', text: 'OpenCat', value: 'opencat' },
  28. ];
  29. const OPEN_LINK_OPTIONS = [
  30. { key: 'ama', text: 'ChatGPT Web & Midjourney', value: 'ama' },
  31. { key: 'opencat', text: 'OpenCat', value: 'opencat' },
  32. ];
  33. function renderTimestamp(timestamp) {
  34. return <>{timestamp2string(timestamp)}</>;
  35. }
  36. function renderStatus(status, model_limits_enabled = false) {
  37. switch (status) {
  38. case 1:
  39. if (model_limits_enabled) {
  40. return (
  41. <Tag color='green' size='large'>
  42. 已启用:限制模型
  43. </Tag>
  44. );
  45. } else {
  46. return (
  47. <Tag color='green' size='large'>
  48. 已启用
  49. </Tag>
  50. );
  51. }
  52. case 2:
  53. return (
  54. <Tag color='red' size='large'>
  55. {' '}
  56. 已禁用{' '}
  57. </Tag>
  58. );
  59. case 3:
  60. return (
  61. <Tag color='yellow' size='large'>
  62. {' '}
  63. 已过期{' '}
  64. </Tag>
  65. );
  66. case 4:
  67. return (
  68. <Tag color='grey' size='large'>
  69. {' '}
  70. 已耗尽{' '}
  71. </Tag>
  72. );
  73. default:
  74. return (
  75. <Tag color='black' size='large'>
  76. {' '}
  77. 未知状态{' '}
  78. </Tag>
  79. );
  80. }
  81. }
  82. const TokensTable = () => {
  83. const link_menu = [
  84. {
  85. node: 'item',
  86. key: 'next',
  87. name: 'ChatGPT Next Web',
  88. onClick: () => {
  89. onOpenLink('next');
  90. },
  91. },
  92. { node: 'item', key: 'ama', name: 'AMA 问天', value: 'ama' },
  93. {
  94. node: 'item',
  95. key: 'next-mj',
  96. name: 'ChatGPT Web & Midjourney',
  97. value: 'next-mj',
  98. onClick: () => {
  99. onOpenLink('next-mj');
  100. },
  101. },
  102. { node: 'item', key: 'opencat', name: 'OpenCat', value: 'opencat' },
  103. ];
  104. const columns = [
  105. {
  106. title: '名称',
  107. dataIndex: 'name',
  108. },
  109. {
  110. title: '状态',
  111. dataIndex: 'status',
  112. key: 'status',
  113. render: (text, record, index) => {
  114. return <div>{renderStatus(text, record.model_limits_enabled)}</div>;
  115. },
  116. },
  117. {
  118. title: '已用额度',
  119. dataIndex: 'used_quota',
  120. render: (text, record, index) => {
  121. return <div>{renderQuota(parseInt(text))}</div>;
  122. },
  123. },
  124. {
  125. title: '剩余额度',
  126. dataIndex: 'remain_quota',
  127. render: (text, record, index) => {
  128. return (
  129. <div>
  130. {record.unlimited_quota ? (
  131. <Tag size={'large'} color={'white'}>
  132. 无限制
  133. </Tag>
  134. ) : (
  135. <Tag size={'large'} color={'light-blue'}>
  136. {renderQuota(parseInt(text))}
  137. </Tag>
  138. )}
  139. </div>
  140. );
  141. },
  142. },
  143. {
  144. title: '创建时间',
  145. dataIndex: 'created_time',
  146. render: (text, record, index) => {
  147. return <div>{renderTimestamp(text)}</div>;
  148. },
  149. },
  150. {
  151. title: '过期时间',
  152. dataIndex: 'expired_time',
  153. render: (text, record, index) => {
  154. return (
  155. <div>
  156. {record.expired_time === -1 ? '永不过期' : renderTimestamp(text)}
  157. </div>
  158. );
  159. },
  160. },
  161. {
  162. title: '',
  163. dataIndex: 'operate',
  164. render: (text, record, index) => (
  165. <div>
  166. <Popover
  167. content={'sk-' + record.key}
  168. style={{ padding: 20 }}
  169. position='top'
  170. >
  171. <Button theme='light' type='tertiary' style={{ marginRight: 1 }}>
  172. 查看
  173. </Button>
  174. </Popover>
  175. <Button
  176. theme='light'
  177. type='secondary'
  178. style={{ marginRight: 1 }}
  179. onClick={async (text) => {
  180. await copyText('sk-' + record.key);
  181. }}
  182. >
  183. 复制
  184. </Button>
  185. <SplitButtonGroup
  186. style={{ marginRight: 1 }}
  187. aria-label='项目操作按钮组'
  188. >
  189. <Button
  190. theme='light'
  191. style={{ color: 'rgba(var(--semi-teal-7), 1)' }}
  192. onClick={() => {
  193. onOpenLink('next', record.key);
  194. }}
  195. >
  196. 聊天
  197. </Button>
  198. <Dropdown
  199. trigger='click'
  200. position='bottomRight'
  201. menu={[
  202. {
  203. node: 'item',
  204. key: 'next',
  205. disabled: !localStorage.getItem('chat_link'),
  206. name: 'ChatGPT Next Web',
  207. onClick: () => {
  208. onOpenLink('next', record.key);
  209. },
  210. },
  211. {
  212. node: 'item',
  213. key: 'next-mj',
  214. disabled: !localStorage.getItem('chat_link2'),
  215. name: 'ChatGPT Web & Midjourney',
  216. onClick: () => {
  217. onOpenLink('next-mj', record.key);
  218. },
  219. },
  220. // {
  221. // node: 'item',
  222. // key: 'lobe',
  223. // name: 'Lobe Chat',
  224. // onClick: () => {
  225. // onOpenLink('lobe', record.key);
  226. // },
  227. // },
  228. {
  229. node: 'item',
  230. key: 'ama',
  231. name: 'AMA 问天(BotGem)',
  232. onClick: () => {
  233. onOpenLink('ama', record.key);
  234. },
  235. },
  236. {
  237. node: 'item',
  238. key: 'opencat',
  239. name: 'OpenCat',
  240. onClick: () => {
  241. onOpenLink('opencat', record.key);
  242. },
  243. },
  244. ]}
  245. >
  246. <Button
  247. style={{
  248. padding: '8px 4px',
  249. color: 'rgba(var(--semi-teal-7), 1)',
  250. }}
  251. type='primary'
  252. icon={<IconTreeTriangleDown />}
  253. ></Button>
  254. </Dropdown>
  255. </SplitButtonGroup>
  256. <Popconfirm
  257. title='确定是否要删除此令牌?'
  258. content='此修改将不可逆'
  259. okType={'danger'}
  260. position={'left'}
  261. onConfirm={() => {
  262. manageToken(record.id, 'delete', record).then(() => {
  263. removeRecord(record.key);
  264. });
  265. }}
  266. >
  267. <Button theme='light' type='danger' style={{ marginRight: 1 }}>
  268. 删除
  269. </Button>
  270. </Popconfirm>
  271. {record.status === 1 ? (
  272. <Button
  273. theme='light'
  274. type='warning'
  275. style={{ marginRight: 1 }}
  276. onClick={async () => {
  277. manageToken(record.id, 'disable', record);
  278. }}
  279. >
  280. 禁用
  281. </Button>
  282. ) : (
  283. <Button
  284. theme='light'
  285. type='secondary'
  286. style={{ marginRight: 1 }}
  287. onClick={async () => {
  288. manageToken(record.id, 'enable', record);
  289. }}
  290. >
  291. 启用
  292. </Button>
  293. )}
  294. <Button
  295. theme='light'
  296. type='tertiary'
  297. style={{ marginRight: 1 }}
  298. onClick={() => {
  299. setEditingToken(record);
  300. setShowEdit(true);
  301. }}
  302. >
  303. 编辑
  304. </Button>
  305. </div>
  306. ),
  307. },
  308. ];
  309. const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
  310. const [showEdit, setShowEdit] = useState(false);
  311. const [tokens, setTokens] = useState([]);
  312. const [selectedKeys, setSelectedKeys] = useState([]);
  313. const [tokenCount, setTokenCount] = useState(pageSize);
  314. const [loading, setLoading] = useState(true);
  315. const [activePage, setActivePage] = useState(1);
  316. const [searchKeyword, setSearchKeyword] = useState('');
  317. const [searchToken, setSearchToken] = useState('');
  318. const [searching, setSearching] = useState(false);
  319. const [showTopUpModal, setShowTopUpModal] = useState(false);
  320. const [targetTokenIdx, setTargetTokenIdx] = useState(0);
  321. const [editingToken, setEditingToken] = useState({
  322. id: undefined,
  323. });
  324. const closeEdit = () => {
  325. setShowEdit(false);
  326. setTimeout(() => {
  327. setEditingToken({
  328. id: undefined,
  329. });
  330. }, 500);
  331. };
  332. const setTokensFormat = (tokens) => {
  333. setTokens(tokens);
  334. if (tokens.length >= pageSize) {
  335. setTokenCount(tokens.length + pageSize);
  336. } else {
  337. setTokenCount(tokens.length);
  338. }
  339. };
  340. let pageData = tokens.slice(
  341. (activePage - 1) * pageSize,
  342. activePage * pageSize,
  343. );
  344. const loadTokens = async (startIdx) => {
  345. setLoading(true);
  346. const res = await API.get(`/api/token/?p=${startIdx}&size=${pageSize}`);
  347. const { success, message, data } = res.data;
  348. if (success) {
  349. if (startIdx === 0) {
  350. setTokensFormat(data);
  351. } else {
  352. let newTokens = [...tokens];
  353. newTokens.splice(startIdx * pageSize, data.length, ...data);
  354. setTokensFormat(newTokens);
  355. }
  356. } else {
  357. showError(message);
  358. }
  359. setLoading(false);
  360. };
  361. const onPaginationChange = (e, { activePage }) => {
  362. (async () => {
  363. if (activePage === Math.ceil(tokens.length / pageSize) + 1) {
  364. // In this case we have to load more data and then append them.
  365. await loadTokens(activePage - 1);
  366. }
  367. setActivePage(activePage);
  368. })();
  369. };
  370. const refresh = async () => {
  371. await loadTokens(activePage - 1);
  372. };
  373. const copyText = async (text) => {
  374. if (await copy(text)) {
  375. showSuccess('已复制到剪贴板!');
  376. } else {
  377. Modal.error({
  378. title: '无法复制到剪贴板,请手动复制',
  379. content: text,
  380. size: 'large',
  381. });
  382. }
  383. };
  384. const onOpenLink = async (type, key) => {
  385. let status = localStorage.getItem('status');
  386. let serverAddress = '';
  387. if (status) {
  388. status = JSON.parse(status);
  389. serverAddress = status.server_address;
  390. }
  391. if (serverAddress === '') {
  392. serverAddress = window.location.origin;
  393. }
  394. let encodedServerAddress = encodeURIComponent(serverAddress);
  395. const chatLink = localStorage.getItem('chat_link');
  396. const mjLink = localStorage.getItem('chat_link2');
  397. let defaultUrl;
  398. if (chatLink) {
  399. defaultUrl =
  400. chatLink + `/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`;
  401. }
  402. let url;
  403. switch (type) {
  404. case 'ama':
  405. url = `ama://set-api-key?server=${encodedServerAddress}&key=sk-${key}`;
  406. break;
  407. case 'opencat':
  408. url = `opencat://team/join?domain=${encodedServerAddress}&token=sk-${key}`;
  409. break;
  410. case 'lobe':
  411. url = `https://chat-preview.lobehub.com/?settings={"keyVaults":{"openai":{"apiKey":"sk-${key}","baseURL":"${encodedServerAddress}/v1"}}}`;
  412. break;
  413. case 'next-mj':
  414. url =
  415. mjLink + `/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`;
  416. break;
  417. default:
  418. if (!chatLink) {
  419. showError('管理员未设置聊天链接');
  420. return;
  421. }
  422. url = defaultUrl;
  423. }
  424. window.open(url, '_blank');
  425. };
  426. useEffect(() => {
  427. loadTokens(0)
  428. .then()
  429. .catch((reason) => {
  430. showError(reason);
  431. });
  432. }, [pageSize]);
  433. const removeRecord = (key) => {
  434. let newDataSource = [...tokens];
  435. if (key != null) {
  436. let idx = newDataSource.findIndex((data) => data.key === key);
  437. if (idx > -1) {
  438. newDataSource.splice(idx, 1);
  439. setTokensFormat(newDataSource);
  440. }
  441. }
  442. };
  443. const manageToken = async (id, action, record) => {
  444. setLoading(true);
  445. let data = { id };
  446. let res;
  447. switch (action) {
  448. case 'delete':
  449. res = await API.delete(`/api/token/${id}/`);
  450. break;
  451. case 'enable':
  452. data.status = 1;
  453. res = await API.put('/api/token/?status_only=true', data);
  454. break;
  455. case 'disable':
  456. data.status = 2;
  457. res = await API.put('/api/token/?status_only=true', data);
  458. break;
  459. }
  460. const { success, message } = res.data;
  461. if (success) {
  462. showSuccess('操作成功完成!');
  463. let token = res.data.data;
  464. let newTokens = [...tokens];
  465. // let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx;
  466. if (action === 'delete') {
  467. } else {
  468. record.status = token.status;
  469. // newTokens[realIdx].status = token.status;
  470. }
  471. setTokensFormat(newTokens);
  472. } else {
  473. showError(message);
  474. }
  475. setLoading(false);
  476. };
  477. const searchTokens = async () => {
  478. if (searchKeyword === '' && searchToken === '') {
  479. // if keyword is blank, load files instead.
  480. await loadTokens(0);
  481. setActivePage(1);
  482. return;
  483. }
  484. setSearching(true);
  485. const res = await API.get(
  486. `/api/token/search?keyword=${searchKeyword}&token=${searchToken}`,
  487. );
  488. const { success, message, data } = res.data;
  489. if (success) {
  490. setTokensFormat(data);
  491. setActivePage(1);
  492. } else {
  493. showError(message);
  494. }
  495. setSearching(false);
  496. };
  497. const handleKeywordChange = async (value) => {
  498. setSearchKeyword(value.trim());
  499. };
  500. const handleSearchTokenChange = async (value) => {
  501. setSearchToken(value.trim());
  502. };
  503. const sortToken = (key) => {
  504. if (tokens.length === 0) return;
  505. setLoading(true);
  506. let sortedTokens = [...tokens];
  507. sortedTokens.sort((a, b) => {
  508. return ('' + a[key]).localeCompare(b[key]);
  509. });
  510. if (sortedTokens[0].id === tokens[0].id) {
  511. sortedTokens.reverse();
  512. }
  513. setTokens(sortedTokens);
  514. setLoading(false);
  515. };
  516. const handlePageChange = (page) => {
  517. setActivePage(page);
  518. if (page === Math.ceil(tokens.length / pageSize) + 1) {
  519. // In this case we have to load more data and then append them.
  520. loadTokens(page - 1).then((r) => {});
  521. }
  522. };
  523. const rowSelection = {
  524. onSelect: (record, selected) => {},
  525. onSelectAll: (selected, selectedRows) => {},
  526. onChange: (selectedRowKeys, selectedRows) => {
  527. setSelectedKeys(selectedRows);
  528. },
  529. };
  530. const handleRow = (record, index) => {
  531. if (record.status !== 1) {
  532. return {
  533. style: {
  534. background: 'var(--semi-color-disabled-border)',
  535. },
  536. };
  537. } else {
  538. return {};
  539. }
  540. };
  541. return (
  542. <>
  543. <EditToken
  544. refresh={refresh}
  545. editingToken={editingToken}
  546. visiable={showEdit}
  547. handleClose={closeEdit}
  548. ></EditToken>
  549. <Form
  550. layout='horizontal'
  551. style={{ marginTop: 10 }}
  552. labelPosition={'left'}
  553. >
  554. <Form.Input
  555. field='keyword'
  556. label='搜索关键字'
  557. placeholder='令牌名称'
  558. value={searchKeyword}
  559. loading={searching}
  560. onChange={handleKeywordChange}
  561. />
  562. <Form.Input
  563. field='token'
  564. label='Key'
  565. placeholder='密钥'
  566. value={searchToken}
  567. loading={searching}
  568. onChange={handleSearchTokenChange}
  569. />
  570. <Button
  571. label='查询'
  572. type='primary'
  573. htmlType='submit'
  574. className='btn-margin-right'
  575. onClick={searchTokens}
  576. style={{ marginRight: 8 }}
  577. >
  578. 查询
  579. </Button>
  580. </Form>
  581. <Table
  582. style={{ marginTop: 20 }}
  583. columns={columns}
  584. dataSource={pageData}
  585. pagination={{
  586. currentPage: activePage,
  587. pageSize: pageSize,
  588. total: tokenCount,
  589. showSizeChanger: true,
  590. pageSizeOptions: [10, 20, 50, 100],
  591. formatPageText: (page) =>
  592. `第 ${page.currentStart} - ${page.currentEnd} 条,共 ${tokens.length} 条`,
  593. onPageSizeChange: (size) => {
  594. setPageSize(size);
  595. setActivePage(1);
  596. },
  597. onPageChange: handlePageChange,
  598. }}
  599. loading={loading}
  600. rowSelection={rowSelection}
  601. onRow={handleRow}
  602. ></Table>
  603. <Button
  604. theme='light'
  605. type='primary'
  606. style={{ marginRight: 8 }}
  607. onClick={() => {
  608. setEditingToken({
  609. id: undefined,
  610. });
  611. setShowEdit(true);
  612. }}
  613. >
  614. 添加令牌
  615. </Button>
  616. <Button
  617. label='复制所选令牌'
  618. type='warning'
  619. onClick={async () => {
  620. if (selectedKeys.length === 0) {
  621. showError('请至少选择一个令牌!');
  622. return;
  623. }
  624. let keys = '';
  625. for (let i = 0; i < selectedKeys.length; i++) {
  626. keys +=
  627. selectedKeys[i].name + ' sk-' + selectedKeys[i].key + '\n';
  628. }
  629. await copyText(keys);
  630. }}
  631. >
  632. 复制所选令牌到剪贴板
  633. </Button>
  634. </>
  635. );
  636. };
  637. export default TokensTable;