ChannelsTable.js 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655
  1. import React, {useEffect, useState} from 'react';
  2. import {Input, Label, Message, Popup} from 'semantic-ui-react';
  3. import {Link} from 'react-router-dom';
  4. import {
  5. API,
  6. isMobile,
  7. setPromptShown,
  8. shouldShowPrompt,
  9. showError,
  10. showInfo,
  11. showSuccess,
  12. timestamp2string
  13. } from '../helpers';
  14. import {CHANNEL_OPTIONS, ITEMS_PER_PAGE} from '../constants';
  15. import {renderGroup, renderNumber, renderNumberWithPoint, renderQuota, renderQuotaWithPrompt} from '../helpers/render';
  16. import {
  17. Avatar,
  18. Tag,
  19. Table,
  20. Button,
  21. Popover,
  22. Form,
  23. Modal,
  24. Popconfirm,
  25. Space,
  26. Tooltip,
  27. Switch,
  28. Typography, InputNumber
  29. } from "@douyinfe/semi-ui";
  30. import EditChannel from "../pages/Channel/EditChannel";
  31. function renderTimestamp(timestamp) {
  32. return (
  33. <>
  34. {timestamp2string(timestamp)}
  35. </>
  36. );
  37. }
  38. let type2label = undefined;
  39. function renderType(type) {
  40. if (!type2label) {
  41. type2label = new Map;
  42. for (let i = 0; i < CHANNEL_OPTIONS.length; i++) {
  43. type2label[CHANNEL_OPTIONS[i].value] = CHANNEL_OPTIONS[i];
  44. }
  45. type2label[0] = {value: 0, text: '未知类型', color: 'grey'};
  46. }
  47. return <Tag size='large' color={type2label[type]?.color}>{type2label[type]?.text}</Tag>;
  48. }
  49. function renderBalance(type, balance) {
  50. switch (type) {
  51. case 1: // OpenAI
  52. return <span>${balance.toFixed(2)}</span>;
  53. case 4: // CloseAI
  54. return <span>¥{balance.toFixed(2)}</span>;
  55. case 8: // 自定义
  56. return <span>${balance.toFixed(2)}</span>;
  57. case 5: // OpenAI-SB
  58. return <span>¥{(balance / 10000).toFixed(2)}</span>;
  59. case 10: // AI Proxy
  60. return <span>{renderNumber(balance)}</span>;
  61. case 12: // API2GPT
  62. return <span>¥{balance.toFixed(2)}</span>;
  63. case 13: // AIGC2D
  64. return <span>{renderNumber(balance)}</span>;
  65. default:
  66. return <span>不支持</span>;
  67. }
  68. }
  69. const ChannelsTable = () => {
  70. const columns = [
  71. {
  72. title: 'ID',
  73. dataIndex: 'id',
  74. },
  75. {
  76. title: '名称',
  77. dataIndex: 'name',
  78. },
  79. {
  80. title: '分组',
  81. dataIndex: 'group',
  82. render: (text, record, index) => {
  83. return (
  84. <div>
  85. <Space spacing={2}>
  86. {
  87. text.split(',').map((item, index) => {
  88. return (renderGroup(item))
  89. })
  90. }
  91. </Space>
  92. </div>
  93. );
  94. },
  95. },
  96. {
  97. title: '类型',
  98. dataIndex: 'type',
  99. render: (text, record, index) => {
  100. return (
  101. <div>
  102. {renderType(text)}
  103. </div>
  104. );
  105. },
  106. },
  107. {
  108. title: '状态',
  109. dataIndex: 'status',
  110. render: (text, record, index) => {
  111. return (
  112. <div>
  113. {renderStatus(text)}
  114. </div>
  115. );
  116. },
  117. },
  118. {
  119. title: '响应时间',
  120. dataIndex: 'response_time',
  121. render: (text, record, index) => {
  122. return (
  123. <div>
  124. {renderResponseTime(text)}
  125. </div>
  126. );
  127. },
  128. },
  129. {
  130. title: '已用/剩余',
  131. dataIndex: 'expired_time',
  132. render: (text, record, index) => {
  133. return (
  134. <div>
  135. <Space spacing={1}>
  136. <Tooltip content={'已用额度'}>
  137. <Tag color='white' type='ghost' size='large'>{renderQuota(record.used_quota)}</Tag>
  138. </Tooltip>
  139. <Tooltip content={'剩余额度' + record.balance + ',点击更新'}>
  140. <Tag color='white' type='ghost' size='large' onClick={() => {updateChannelBalance(record)}}>${renderNumberWithPoint(record.balance)}</Tag>
  141. </Tooltip>
  142. </Space>
  143. </div>
  144. );
  145. },
  146. },
  147. {
  148. title: '优先级',
  149. dataIndex: 'priority',
  150. render: (text, record, index) => {
  151. return (
  152. <div>
  153. <InputNumber
  154. style={{width: 70}}
  155. name='name'
  156. onChange={value => {
  157. manageChannel(record.id, 'priority', record, value);
  158. }}
  159. defaultValue={record.priority}
  160. min={0}
  161. />
  162. </div>
  163. );
  164. },
  165. },
  166. {
  167. title: '',
  168. dataIndex: 'operate',
  169. render: (text, record, index) => (
  170. <div>
  171. <Button theme='light' type='primary' style={{marginRight: 1}} onClick={()=>testChannel(record)}>测试</Button>
  172. <Popconfirm
  173. title="确定是否要删除此渠道?"
  174. content="此修改将不可逆"
  175. okType={'danger'}
  176. position={'left'}
  177. onConfirm={() => {
  178. manageChannel(record.id, 'delete', record).then(
  179. () => {
  180. removeRecord(record.id);
  181. }
  182. )
  183. }}
  184. >
  185. <Button theme='light' type='danger' style={{marginRight: 1}}>删除</Button>
  186. </Popconfirm>
  187. {
  188. record.status === 1 ?
  189. <Button theme='light' type='warning' style={{marginRight: 1}} onClick={
  190. async () => {
  191. manageChannel(
  192. record.id,
  193. 'disable',
  194. record
  195. )
  196. }
  197. }>禁用</Button> :
  198. <Button theme='light' type='secondary' style={{marginRight: 1}} onClick={
  199. async () => {
  200. manageChannel(
  201. record.id,
  202. 'enable',
  203. record
  204. );
  205. }
  206. }>启用</Button>
  207. }
  208. <Button theme='light' type='tertiary' style={{marginRight: 1}} onClick={
  209. () => {
  210. setEditingChannel(record);
  211. setShowEdit(true);
  212. }
  213. }>编辑</Button>
  214. </div>
  215. ),
  216. },
  217. ];
  218. const [channels, setChannels] = useState([]);
  219. const [loading, setLoading] = useState(true);
  220. const [activePage, setActivePage] = useState(1);
  221. const [idSort, setIdSort] = useState(false);
  222. const [searchKeyword, setSearchKeyword] = useState('');
  223. const [searchGroup, setSearchGroup] = useState('');
  224. const [searching, setSearching] = useState(false);
  225. const [updatingBalance, setUpdatingBalance] = useState(false);
  226. const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
  227. const [showPrompt, setShowPrompt] = useState(shouldShowPrompt("channel-test"));
  228. const [channelCount, setChannelCount] = useState(pageSize);
  229. const [groupOptions, setGroupOptions] = useState([]);
  230. const [showEdit, setShowEdit] = useState(false);
  231. const [editingChannel, setEditingChannel] = useState({
  232. id: undefined,
  233. });
  234. const removeRecord = id => {
  235. let newDataSource = [...channels];
  236. if (id != null) {
  237. let idx = newDataSource.findIndex(data => data.id === id);
  238. if (idx > -1) {
  239. newDataSource.splice(idx, 1);
  240. setChannels(newDataSource);
  241. }
  242. }
  243. };
  244. const setChannelFormat = (channels) => {
  245. for (let i = 0; i < channels.length; i++) {
  246. channels[i].key = '' + channels[i].id;
  247. }
  248. // data.key = '' + data.id
  249. setChannels(channels);
  250. if (channels.length >= pageSize) {
  251. setChannelCount(channels.length + pageSize);
  252. } else {
  253. setChannelCount(channels.length);
  254. }
  255. }
  256. const loadChannels = async (startIdx, pageSize, idSort) => {
  257. setLoading(true);
  258. const res = await API.get(`/api/channel/?p=${startIdx}&page_size=${pageSize}&id_sort=${idSort}`);
  259. const {success, message, data} = res.data;
  260. if (success) {
  261. if (startIdx === 0) {
  262. setChannelFormat(data);
  263. } else {
  264. let newChannels = [...channels];
  265. newChannels.splice(startIdx * pageSize, data.length, ...data);
  266. setChannelFormat(newChannels);
  267. }
  268. } else {
  269. showError(message);
  270. }
  271. setLoading(false);
  272. };
  273. const refresh = async () => {
  274. await loadChannels(activePage - 1, pageSize, idSort);
  275. };
  276. useEffect(() => {
  277. // console.log('default effect')
  278. const localIdSort = localStorage.getItem('id-sort') === 'true';
  279. setIdSort(localIdSort)
  280. loadChannels(0, pageSize, localIdSort)
  281. .then()
  282. .catch((reason) => {
  283. showError(reason);
  284. });
  285. fetchGroups().then();
  286. }, []);
  287. // useEffect(() => {
  288. // console.log('search effect')
  289. // searchChannels()
  290. // }, [searchGroup]);
  291. // useEffect(() => {
  292. // localStorage.setItem('id-sort', idSort + '');
  293. // refresh()
  294. // }, [idSort]);
  295. const manageChannel = async (id, action, record, value) => {
  296. let data = {id};
  297. let res;
  298. switch (action) {
  299. case 'delete':
  300. res = await API.delete(`/api/channel/${id}/`);
  301. break;
  302. case 'enable':
  303. data.status = 1;
  304. res = await API.put('/api/channel/', data);
  305. break;
  306. case 'disable':
  307. data.status = 2;
  308. res = await API.put('/api/channel/', data);
  309. break;
  310. case 'priority':
  311. if (value === '') {
  312. return;
  313. }
  314. data.priority = parseInt(value);
  315. res = await API.put('/api/channel/', data);
  316. break;
  317. case 'weight':
  318. if (value === '') {
  319. return;
  320. }
  321. data.weight = parseInt(value);
  322. if (data.weight < 0) {
  323. data.weight = 0;
  324. }
  325. res = await API.put('/api/channel/', data);
  326. break;
  327. }
  328. const {success, message} = res.data;
  329. if (success) {
  330. showSuccess('操作成功完成!');
  331. let channel = res.data.data;
  332. let newChannels = [...channels];
  333. if (action === 'delete') {
  334. } else {
  335. record.status = channel.status;
  336. }
  337. setChannels(newChannels);
  338. } else {
  339. showError(message);
  340. }
  341. };
  342. const renderStatus = (status) => {
  343. switch (status) {
  344. case 1:
  345. return <Tag size='large' color='green'>已启用</Tag>;
  346. case 2:
  347. return (
  348. <Popup
  349. trigger={<Tag size='large' color='red'>
  350. 已禁用
  351. </Tag>}
  352. content='本渠道被手动禁用'
  353. basic
  354. />
  355. );
  356. case 3:
  357. return (
  358. <Popup
  359. trigger={<Tag size='large' color='yellow'>
  360. 已禁用
  361. </Tag>}
  362. content='本渠道被程序自动禁用'
  363. basic
  364. />
  365. );
  366. default:
  367. return (
  368. <Tag size='large' color='grey'>
  369. 未知状态
  370. </Tag>
  371. );
  372. }
  373. };
  374. const renderResponseTime = (responseTime) => {
  375. let time = responseTime / 1000;
  376. time = time.toFixed(2) + ' 秒';
  377. if (responseTime === 0) {
  378. return <Tag size='large' color='grey'>未测试</Tag>;
  379. } else if (responseTime <= 1000) {
  380. return <Tag size='large' color='green'>{time}</Tag>;
  381. } else if (responseTime <= 3000) {
  382. return <Tag size='large' color='lime'>{time}</Tag>;
  383. } else if (responseTime <= 5000) {
  384. return <Tag size='large' color='yellow'>{time}</Tag>;
  385. } else {
  386. return <Tag size='large' color='red'>{time}</Tag>;
  387. }
  388. };
  389. const searchChannels = async (searchKeyword, searchGroup) => {
  390. if (searchKeyword === '' && searchGroup === '') {
  391. // if keyword is blank, load files instead.
  392. await loadChannels(0, pageSize, idSort);
  393. setActivePage(1);
  394. return;
  395. }
  396. setSearching(true);
  397. const res = await API.get(`/api/channel/search?keyword=${searchKeyword}&group=${searchGroup}`);
  398. const {success, message, data} = res.data;
  399. if (success) {
  400. setChannels(data);
  401. setActivePage(1);
  402. } else {
  403. showError(message);
  404. }
  405. setSearching(false);
  406. };
  407. const testChannel = async (record) => {
  408. const res = await API.get(`/api/channel/test/${record.id}/`);
  409. const {success, message, time} = res.data;
  410. if (success) {
  411. let newChannels = [...channels];
  412. record.response_time = time * 1000;
  413. record.test_time = Date.now() / 1000;
  414. setChannels(newChannels);
  415. showInfo(`通道 ${record.name} 测试成功,耗时 ${time.toFixed(2)} 秒。`);
  416. } else {
  417. showError(message);
  418. }
  419. };
  420. const testAllChannels = async () => {
  421. const res = await API.get(`/api/channel/test`);
  422. const {success, message} = res.data;
  423. if (success) {
  424. showInfo('已成功开始测试所有已启用通道,请刷新页面查看结果。');
  425. } else {
  426. showError(message);
  427. }
  428. };
  429. const deleteAllDisabledChannels = async () => {
  430. const res = await API.delete(`/api/channel/disabled`);
  431. const {success, message, data} = res.data;
  432. if (success) {
  433. showSuccess(`已删除所有禁用渠道,共计 ${data} 个`);
  434. await refresh();
  435. } else {
  436. showError(message);
  437. }
  438. };
  439. const updateChannelBalance = async (record) => {
  440. const res = await API.get(`/api/channel/update_balance/${record.id}/`);
  441. const {success, message, balance} = res.data;
  442. if (success) {
  443. record.balance = balance;
  444. record.balance_updated_time = Date.now() / 1000;
  445. showInfo(`通道 ${record.name} 余额更新成功!`);
  446. } else {
  447. showError(message);
  448. }
  449. };
  450. const updateAllChannelsBalance = async () => {
  451. setUpdatingBalance(true);
  452. const res = await API.get(`/api/channel/update_balance`);
  453. const {success, message} = res.data;
  454. if (success) {
  455. showInfo('已更新完毕所有已启用通道余额!');
  456. } else {
  457. showError(message);
  458. }
  459. setUpdatingBalance(false);
  460. };
  461. const sortChannel = (key) => {
  462. if (channels.length === 0) return;
  463. setLoading(true);
  464. let sortedChannels = [...channels];
  465. if (typeof sortedChannels[0][key] === 'string') {
  466. sortedChannels.sort((a, b) => {
  467. return ('' + a[key]).localeCompare(b[key]);
  468. });
  469. } else {
  470. sortedChannels.sort((a, b) => {
  471. if (a[key] === b[key]) return 0;
  472. if (a[key] > b[key]) return -1;
  473. if (a[key] < b[key]) return 1;
  474. });
  475. }
  476. if (sortedChannels[0].id === channels[0].id) {
  477. sortedChannels.reverse();
  478. }
  479. setChannels(sortedChannels);
  480. setLoading(false);
  481. };
  482. let pageData = channels.slice((activePage - 1) * pageSize, activePage * pageSize);
  483. const handlePageChange = page => {
  484. setActivePage(page);
  485. if (page === Math.ceil(channels.length / pageSize) + 1) {
  486. // In this case we have to load more data and then append them.
  487. loadChannels(page - 1, pageSize, idSort).then(r => {
  488. });
  489. }
  490. };
  491. const handlePageSizeChange = async(size) => {
  492. setPageSize(size)
  493. setActivePage(1)
  494. loadChannels(0, size, idSort)
  495. .then()
  496. .catch((reason) => {
  497. showError(reason);
  498. })
  499. };
  500. const fetchGroups = async () => {
  501. try {
  502. let res = await API.get(`/api/group/`);
  503. // add 'all' option
  504. // res.data.data.unshift('all');
  505. setGroupOptions(res.data.data.map((group) => ({
  506. label: group,
  507. value: group,
  508. })));
  509. } catch (error) {
  510. showError(error.message);
  511. }
  512. };
  513. const closeEdit = () => {
  514. setShowEdit(false);
  515. }
  516. const handleRow = (record, index) => {
  517. if (record.status !== 1) {
  518. return {
  519. style: {
  520. background: 'var(--semi-color-disabled-border)',
  521. },
  522. };
  523. } else {
  524. return {};
  525. }
  526. };
  527. return (
  528. <>
  529. <EditChannel refresh={refresh} visible={showEdit} handleClose={closeEdit} editingChannel={editingChannel}/>
  530. <Form onSubmit={() => {searchChannels(searchKeyword, searchGroup)}} labelPosition='left'>
  531. <div style={{display: 'flex'}}>
  532. <Space>
  533. <Form.Input
  534. field='search'
  535. label='关键词'
  536. placeholder='ID,名称和密钥 ...'
  537. value={searchKeyword}
  538. loading={searching}
  539. onChange={(v)=>{
  540. setSearchKeyword(v.trim())
  541. }}
  542. />
  543. <Form.Select field="group" label='分组' optionList={groupOptions} onChange={(v) => {
  544. setSearchGroup(v)
  545. searchChannels(searchKeyword, v)
  546. }}/>
  547. </Space>
  548. </div>
  549. </Form>
  550. <div style={{marginTop: 10, display: 'flex'}}>
  551. <Space>
  552. <Typography.Text strong>使用ID排序</Typography.Text>
  553. <Switch checked={idSort} label='使用ID排序' uncheckedText="关" aria-label="是否用ID排序" onChange={(v) => {
  554. localStorage.setItem('id-sort', v + '')
  555. setIdSort(v)
  556. loadChannels(0, pageSize, v)
  557. .then()
  558. .catch((reason) => {
  559. showError(reason);
  560. })
  561. }}></Switch>
  562. </Space>
  563. </div>
  564. <Table columns={columns} dataSource={pageData} pagination={{
  565. currentPage: activePage,
  566. pageSize: pageSize,
  567. total: channelCount,
  568. pageSizeOpts: [10, 20, 50, 100],
  569. showSizeChanger: true,
  570. formatPageText:(page) => '',
  571. onPageSizeChange: (size) => {
  572. handlePageSizeChange(size).then()
  573. },
  574. onPageChange: handlePageChange,
  575. }} loading={loading} onRow={handleRow}/>
  576. <div style={{display: isMobile()?'':'flex', marginTop: isMobile()?0:-45, zIndex: 999, position: 'relative', pointerEvents: 'none'}}>
  577. <Space style={{pointerEvents: 'auto'}}>
  578. <Button theme='light' type='primary' style={{marginRight: 8}} onClick={
  579. () => {
  580. setEditingChannel({
  581. id: undefined,
  582. });
  583. setShowEdit(true)
  584. }
  585. }>添加渠道</Button>
  586. <Popconfirm
  587. title="确定?"
  588. okType={'warning'}
  589. onConfirm={testAllChannels}
  590. position={isMobile()?'top':''}
  591. >
  592. <Button theme='light' type='warning' style={{marginRight: 8}}>测试所有已启用通道</Button>
  593. </Popconfirm>
  594. <Popconfirm
  595. title="确定?"
  596. okType={'secondary'}
  597. onConfirm={updateAllChannelsBalance}
  598. >
  599. <Button theme='light' type='secondary' style={{marginRight: 8}}>更新所有已启用通道余额</Button>
  600. </Popconfirm>
  601. <Popconfirm
  602. title="确定是否要删除禁用通道?"
  603. content="此修改将不可逆"
  604. okType={'danger'}
  605. onConfirm={deleteAllDisabledChannels}
  606. >
  607. <Button theme='light' type='danger' style={{marginRight: 8}}>删除禁用通道</Button>
  608. </Popconfirm>
  609. <Button theme='light' type='primary' style={{marginRight: 8}} onClick={refresh}>刷新</Button>
  610. </Space>
  611. {/*<div style={{width: '100%', pointerEvents: 'none', position: 'absolute'}}>*/}
  612. {/*</div>*/}
  613. </div>
  614. </>
  615. );
  616. };
  617. export default ChannelsTable;