TokensTable.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700
  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: 'ama',
  223. name: 'AMA 问天(BotGem)',
  224. onClick: () => {
  225. onOpenLink('ama', record.key);
  226. },
  227. },
  228. {
  229. node: 'item',
  230. key: 'opencat',
  231. name: 'OpenCat',
  232. onClick: () => {
  233. onOpenLink('opencat', record.key);
  234. },
  235. },
  236. ]}
  237. >
  238. <Button
  239. style={{
  240. padding: '8px 4px',
  241. color: 'rgba(var(--semi-teal-7), 1)',
  242. }}
  243. type='primary'
  244. icon={<IconTreeTriangleDown />}
  245. ></Button>
  246. </Dropdown>
  247. </SplitButtonGroup>
  248. <Popconfirm
  249. title='确定是否要删除此令牌?'
  250. content='此修改将不可逆'
  251. okType={'danger'}
  252. position={'left'}
  253. onConfirm={() => {
  254. manageToken(record.id, 'delete', record).then(() => {
  255. removeRecord(record.key);
  256. });
  257. }}
  258. >
  259. <Button theme='light' type='danger' style={{ marginRight: 1 }}>
  260. 删除
  261. </Button>
  262. </Popconfirm>
  263. {record.status === 1 ? (
  264. <Button
  265. theme='light'
  266. type='warning'
  267. style={{ marginRight: 1 }}
  268. onClick={async () => {
  269. manageToken(record.id, 'disable', record);
  270. }}
  271. >
  272. 禁用
  273. </Button>
  274. ) : (
  275. <Button
  276. theme='light'
  277. type='secondary'
  278. style={{ marginRight: 1 }}
  279. onClick={async () => {
  280. manageToken(record.id, 'enable', record);
  281. }}
  282. >
  283. 启用
  284. </Button>
  285. )}
  286. <Button
  287. theme='light'
  288. type='tertiary'
  289. style={{ marginRight: 1 }}
  290. onClick={() => {
  291. setEditingToken(record);
  292. setShowEdit(true);
  293. }}
  294. >
  295. 编辑
  296. </Button>
  297. </div>
  298. ),
  299. },
  300. ];
  301. const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
  302. const [showEdit, setShowEdit] = useState(false);
  303. const [tokens, setTokens] = useState([]);
  304. const [selectedKeys, setSelectedKeys] = useState([]);
  305. const [tokenCount, setTokenCount] = useState(pageSize);
  306. const [loading, setLoading] = useState(true);
  307. const [activePage, setActivePage] = useState(1);
  308. const [searchKeyword, setSearchKeyword] = useState('');
  309. const [searchToken, setSearchToken] = useState('');
  310. const [searching, setSearching] = useState(false);
  311. const [showTopUpModal, setShowTopUpModal] = useState(false);
  312. const [targetTokenIdx, setTargetTokenIdx] = useState(0);
  313. const [editingToken, setEditingToken] = useState({
  314. id: undefined,
  315. });
  316. const closeEdit = () => {
  317. setShowEdit(false);
  318. setTimeout(() => {
  319. setEditingToken({
  320. id: undefined,
  321. });
  322. }, 500);
  323. };
  324. const setTokensFormat = (tokens) => {
  325. setTokens(tokens);
  326. if (tokens.length >= pageSize) {
  327. setTokenCount(tokens.length + pageSize);
  328. } else {
  329. setTokenCount(tokens.length);
  330. }
  331. };
  332. let pageData = tokens.slice(
  333. (activePage - 1) * pageSize,
  334. activePage * pageSize,
  335. );
  336. const loadTokens = async (startIdx) => {
  337. setLoading(true);
  338. const res = await API.get(`/api/token/?p=${startIdx}&size=${pageSize}`);
  339. const { success, message, data } = res.data;
  340. if (success) {
  341. if (startIdx === 0) {
  342. setTokensFormat(data);
  343. } else {
  344. let newTokens = [...tokens];
  345. newTokens.splice(startIdx * pageSize, data.length, ...data);
  346. setTokensFormat(newTokens);
  347. }
  348. } else {
  349. showError(message);
  350. }
  351. setLoading(false);
  352. };
  353. const onPaginationChange = (e, { activePage }) => {
  354. (async () => {
  355. if (activePage === Math.ceil(tokens.length / pageSize) + 1) {
  356. // In this case we have to load more data and then append them.
  357. await loadTokens(activePage - 1);
  358. }
  359. setActivePage(activePage);
  360. })();
  361. };
  362. const refresh = async () => {
  363. await loadTokens(activePage - 1);
  364. };
  365. const onCopy = async (type, key) => {
  366. let status = localStorage.getItem('status');
  367. let serverAddress = '';
  368. if (status) {
  369. status = JSON.parse(status);
  370. serverAddress = status.server_address;
  371. }
  372. if (serverAddress === '') {
  373. serverAddress = window.location.origin;
  374. }
  375. let encodedServerAddress = encodeURIComponent(serverAddress);
  376. const nextLink = localStorage.getItem('chat_link');
  377. const mjLink = localStorage.getItem('chat_link2');
  378. let nextUrl;
  379. if (nextLink) {
  380. nextUrl =
  381. nextLink + `/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`;
  382. } else {
  383. nextUrl = `https://chat.oneapi.pro/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`;
  384. }
  385. let url;
  386. switch (type) {
  387. case 'ama':
  388. url =
  389. mjLink + `/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`;
  390. break;
  391. case 'opencat':
  392. url = `opencat://team/join?domain=${encodedServerAddress}&token=sk-${key}`;
  393. break;
  394. case 'next':
  395. url = nextUrl;
  396. break;
  397. default:
  398. url = `sk-${key}`;
  399. }
  400. // if (await copy(url)) {
  401. // showSuccess('已复制到剪贴板!');
  402. // } else {
  403. // showWarning('无法复制到剪贴板,请手动复制,已将令牌填入搜索框。');
  404. // setSearchKeyword(url);
  405. // }
  406. };
  407. const copyText = async (text) => {
  408. if (await copy(text)) {
  409. showSuccess('已复制到剪贴板!');
  410. } else {
  411. // setSearchKeyword(text);
  412. Modal.error({ title: '无法复制到剪贴板,请手动复制', content: text });
  413. }
  414. };
  415. const onOpenLink = async (type, key) => {
  416. let status = localStorage.getItem('status');
  417. let serverAddress = '';
  418. if (status) {
  419. status = JSON.parse(status);
  420. serverAddress = status.server_address;
  421. }
  422. if (serverAddress === '') {
  423. serverAddress = window.location.origin;
  424. }
  425. let encodedServerAddress = encodeURIComponent(serverAddress);
  426. const chatLink = localStorage.getItem('chat_link');
  427. const mjLink = localStorage.getItem('chat_link2');
  428. let defaultUrl;
  429. if (chatLink) {
  430. defaultUrl =
  431. chatLink + `/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`;
  432. }
  433. let url;
  434. switch (type) {
  435. case 'ama':
  436. url = `ama://set-api-key?server=${encodedServerAddress}&key=sk-${key}`;
  437. break;
  438. case 'opencat':
  439. url = `opencat://team/join?domain=${encodedServerAddress}&token=sk-${key}`;
  440. break;
  441. case 'next-mj':
  442. url =
  443. mjLink + `/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`;
  444. break;
  445. default:
  446. if (!chatLink) {
  447. showError('管理员未设置聊天链接');
  448. return;
  449. }
  450. url = defaultUrl;
  451. }
  452. window.open(url, '_blank');
  453. };
  454. useEffect(() => {
  455. loadTokens(0)
  456. .then()
  457. .catch((reason) => {
  458. showError(reason);
  459. });
  460. }, [pageSize]);
  461. const removeRecord = (key) => {
  462. let newDataSource = [...tokens];
  463. if (key != null) {
  464. let idx = newDataSource.findIndex((data) => data.key === key);
  465. if (idx > -1) {
  466. newDataSource.splice(idx, 1);
  467. setTokensFormat(newDataSource);
  468. }
  469. }
  470. };
  471. const manageToken = async (id, action, record) => {
  472. setLoading(true);
  473. let data = { id };
  474. let res;
  475. switch (action) {
  476. case 'delete':
  477. res = await API.delete(`/api/token/${id}/`);
  478. break;
  479. case 'enable':
  480. data.status = 1;
  481. res = await API.put('/api/token/?status_only=true', data);
  482. break;
  483. case 'disable':
  484. data.status = 2;
  485. res = await API.put('/api/token/?status_only=true', data);
  486. break;
  487. }
  488. const { success, message } = res.data;
  489. if (success) {
  490. showSuccess('操作成功完成!');
  491. let token = res.data.data;
  492. let newTokens = [...tokens];
  493. // let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx;
  494. if (action === 'delete') {
  495. } else {
  496. record.status = token.status;
  497. // newTokens[realIdx].status = token.status;
  498. }
  499. setTokensFormat(newTokens);
  500. } else {
  501. showError(message);
  502. }
  503. setLoading(false);
  504. };
  505. const searchTokens = async () => {
  506. if (searchKeyword === '' && searchToken === '') {
  507. // if keyword is blank, load files instead.
  508. await loadTokens(0);
  509. setActivePage(1);
  510. return;
  511. }
  512. setSearching(true);
  513. const res = await API.get(
  514. `/api/token/search?keyword=${searchKeyword}&token=${searchToken}`,
  515. );
  516. const { success, message, data } = res.data;
  517. if (success) {
  518. setTokensFormat(data);
  519. setActivePage(1);
  520. } else {
  521. showError(message);
  522. }
  523. setSearching(false);
  524. };
  525. const handleKeywordChange = async (value) => {
  526. setSearchKeyword(value.trim());
  527. };
  528. const handleSearchTokenChange = async (value) => {
  529. setSearchToken(value.trim());
  530. };
  531. const sortToken = (key) => {
  532. if (tokens.length === 0) return;
  533. setLoading(true);
  534. let sortedTokens = [...tokens];
  535. sortedTokens.sort((a, b) => {
  536. return ('' + a[key]).localeCompare(b[key]);
  537. });
  538. if (sortedTokens[0].id === tokens[0].id) {
  539. sortedTokens.reverse();
  540. }
  541. setTokens(sortedTokens);
  542. setLoading(false);
  543. };
  544. const handlePageChange = (page) => {
  545. setActivePage(page);
  546. if (page === Math.ceil(tokens.length / pageSize) + 1) {
  547. // In this case we have to load more data and then append them.
  548. loadTokens(page - 1).then((r) => {});
  549. }
  550. };
  551. const rowSelection = {
  552. onSelect: (record, selected) => {},
  553. onSelectAll: (selected, selectedRows) => {},
  554. onChange: (selectedRowKeys, selectedRows) => {
  555. setSelectedKeys(selectedRows);
  556. },
  557. };
  558. const handleRow = (record, index) => {
  559. if (record.status !== 1) {
  560. return {
  561. style: {
  562. background: 'var(--semi-color-disabled-border)',
  563. },
  564. };
  565. } else {
  566. return {};
  567. }
  568. };
  569. return (
  570. <>
  571. <EditToken
  572. refresh={refresh}
  573. editingToken={editingToken}
  574. visiable={showEdit}
  575. handleClose={closeEdit}
  576. ></EditToken>
  577. <Form
  578. layout='horizontal'
  579. style={{ marginTop: 10 }}
  580. labelPosition={'left'}
  581. >
  582. <Form.Input
  583. field='keyword'
  584. label='搜索关键字'
  585. placeholder='令牌名称'
  586. value={searchKeyword}
  587. loading={searching}
  588. onChange={handleKeywordChange}
  589. />
  590. <Form.Input
  591. field='token'
  592. label='Key'
  593. placeholder='密钥'
  594. value={searchToken}
  595. loading={searching}
  596. onChange={handleSearchTokenChange}
  597. />
  598. <Button
  599. label='查询'
  600. type='primary'
  601. htmlType='submit'
  602. className='btn-margin-right'
  603. onClick={searchTokens}
  604. style={{ marginRight: 8 }}
  605. >
  606. 查询
  607. </Button>
  608. </Form>
  609. <Table
  610. style={{ marginTop: 20 }}
  611. columns={columns}
  612. dataSource={pageData}
  613. pagination={{
  614. currentPage: activePage,
  615. pageSize: pageSize,
  616. total: tokenCount,
  617. showSizeChanger: true,
  618. pageSizeOptions: [10, 20, 50, 100],
  619. formatPageText: (page) =>
  620. `第 ${page.currentStart} - ${page.currentEnd} 条,共 ${tokens.length} 条`,
  621. onPageSizeChange: (size) => {
  622. setPageSize(size);
  623. setActivePage(1);
  624. },
  625. onPageChange: handlePageChange,
  626. }}
  627. loading={loading}
  628. rowSelection={rowSelection}
  629. onRow={handleRow}
  630. ></Table>
  631. <Button
  632. theme='light'
  633. type='primary'
  634. style={{ marginRight: 8 }}
  635. onClick={() => {
  636. setEditingToken({
  637. id: undefined,
  638. });
  639. setShowEdit(true);
  640. }}
  641. >
  642. 添加令牌
  643. </Button>
  644. <Button
  645. label='复制所选令牌'
  646. type='warning'
  647. onClick={async () => {
  648. if (selectedKeys.length === 0) {
  649. showError('请至少选择一个令牌!');
  650. return;
  651. }
  652. let keys = '';
  653. for (let i = 0; i < selectedKeys.length; i++) {
  654. keys +=
  655. selectedKeys[i].name + ' sk-' + selectedKeys[i].key + '\n';
  656. }
  657. await copyText(keys);
  658. }}
  659. >
  660. 复制所选令牌到剪贴板
  661. </Button>
  662. </>
  663. );
  664. };
  665. export default TokensTable;