ChannelsTable.js 55 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874
  1. import React, { useEffect, useState } from 'react';
  2. import {
  3. API,
  4. showError,
  5. showInfo,
  6. showSuccess,
  7. timestamp2string,
  8. renderGroup,
  9. renderNumberWithPoint,
  10. renderQuota
  11. } from '../helpers';
  12. import { CHANNEL_OPTIONS, ITEMS_PER_PAGE } from '../constants';
  13. import {
  14. Button,
  15. Divider,
  16. Dropdown,
  17. Input,
  18. InputNumber,
  19. Modal,
  20. Space,
  21. SplitButtonGroup,
  22. Switch,
  23. Table,
  24. Tag,
  25. Tooltip,
  26. Typography,
  27. Checkbox,
  28. Card,
  29. Select
  30. } from '@douyinfe/semi-ui';
  31. import EditChannel from '../pages/Channel/EditChannel';
  32. import {
  33. IconList,
  34. IconTreeTriangleDown,
  35. IconFilter,
  36. IconPlus,
  37. IconRefresh,
  38. IconSetting,
  39. IconSearch,
  40. IconEdit,
  41. IconDelete,
  42. IconStop,
  43. IconPlay,
  44. IconMore,
  45. IconCopy,
  46. IconSmallTriangleRight
  47. } from '@douyinfe/semi-icons';
  48. import { loadChannelModels } from './utils.js';
  49. import EditTagModal from '../pages/Channel/EditTagModal.js';
  50. import { useTranslation } from 'react-i18next';
  51. const ChannelsTable = () => {
  52. const { t } = useTranslation();
  53. let type2label = undefined;
  54. const renderType = (type) => {
  55. if (!type2label) {
  56. type2label = new Map();
  57. for (let i = 0; i < CHANNEL_OPTIONS.length; i++) {
  58. type2label[CHANNEL_OPTIONS[i].value] = CHANNEL_OPTIONS[i];
  59. }
  60. type2label[0] = { value: 0, label: t('未知类型'), color: 'grey' };
  61. }
  62. return (
  63. <Tag size='large' color={type2label[type]?.color} shape='circle'>
  64. {type2label[type]?.label}
  65. </Tag>
  66. );
  67. };
  68. const renderTagType = () => {
  69. return (
  70. <Tag
  71. color='light-blue'
  72. prefixIcon={<IconList />}
  73. size='large'
  74. shape='circle'
  75. type='light'
  76. >
  77. {t('标签聚合')}
  78. </Tag>
  79. );
  80. };
  81. const renderStatus = (status) => {
  82. switch (status) {
  83. case 1:
  84. return (
  85. <Tag size='large' color='green' shape='circle'>
  86. {t('已启用')}
  87. </Tag>
  88. );
  89. case 2:
  90. return (
  91. <Tag size='large' color='yellow' shape='circle'>
  92. {t('已禁用')}
  93. </Tag>
  94. );
  95. case 3:
  96. return (
  97. <Tag size='large' color='yellow' shape='circle'>
  98. {t('自动禁用')}
  99. </Tag>
  100. );
  101. default:
  102. return (
  103. <Tag size='large' color='grey' shape='circle'>
  104. {t('未知状态')}
  105. </Tag>
  106. );
  107. }
  108. };
  109. const renderResponseTime = (responseTime) => {
  110. let time = responseTime / 1000;
  111. time = time.toFixed(2) + t(' 秒');
  112. if (responseTime === 0) {
  113. return (
  114. <Tag size='large' color='grey' shape='circle'>
  115. {t('未测试')}
  116. </Tag>
  117. );
  118. } else if (responseTime <= 1000) {
  119. return (
  120. <Tag size='large' color='green' shape='circle'>
  121. {time}
  122. </Tag>
  123. );
  124. } else if (responseTime <= 3000) {
  125. return (
  126. <Tag size='large' color='lime' shape='circle'>
  127. {time}
  128. </Tag>
  129. );
  130. } else if (responseTime <= 5000) {
  131. return (
  132. <Tag size='large' color='yellow' shape='circle'>
  133. {time}
  134. </Tag>
  135. );
  136. } else {
  137. return (
  138. <Tag size='large' color='red' shape='circle'>
  139. {time}
  140. </Tag>
  141. );
  142. }
  143. };
  144. // Define column keys for selection
  145. const COLUMN_KEYS = {
  146. ID: 'id',
  147. NAME: 'name',
  148. GROUP: 'group',
  149. TYPE: 'type',
  150. STATUS: 'status',
  151. RESPONSE_TIME: 'response_time',
  152. BALANCE: 'balance',
  153. PRIORITY: 'priority',
  154. WEIGHT: 'weight',
  155. OPERATE: 'operate',
  156. };
  157. // State for column visibility
  158. const [visibleColumns, setVisibleColumns] = useState({});
  159. const [showColumnSelector, setShowColumnSelector] = useState(false);
  160. // Load saved column preferences from localStorage
  161. useEffect(() => {
  162. const savedColumns = localStorage.getItem('channels-table-columns');
  163. if (savedColumns) {
  164. try {
  165. const parsed = JSON.parse(savedColumns);
  166. // Make sure all columns are accounted for
  167. const defaults = getDefaultColumnVisibility();
  168. const merged = { ...defaults, ...parsed };
  169. setVisibleColumns(merged);
  170. } catch (e) {
  171. console.error('Failed to parse saved column preferences', e);
  172. initDefaultColumns();
  173. }
  174. } else {
  175. initDefaultColumns();
  176. }
  177. }, []);
  178. // Update table when column visibility changes
  179. useEffect(() => {
  180. if (Object.keys(visibleColumns).length > 0) {
  181. // Save to localStorage
  182. localStorage.setItem(
  183. 'channels-table-columns',
  184. JSON.stringify(visibleColumns),
  185. );
  186. }
  187. }, [visibleColumns]);
  188. // Get default column visibility
  189. const getDefaultColumnVisibility = () => {
  190. return {
  191. [COLUMN_KEYS.ID]: true,
  192. [COLUMN_KEYS.NAME]: true,
  193. [COLUMN_KEYS.GROUP]: true,
  194. [COLUMN_KEYS.TYPE]: true,
  195. [COLUMN_KEYS.STATUS]: true,
  196. [COLUMN_KEYS.RESPONSE_TIME]: true,
  197. [COLUMN_KEYS.BALANCE]: true,
  198. [COLUMN_KEYS.PRIORITY]: true,
  199. [COLUMN_KEYS.WEIGHT]: true,
  200. [COLUMN_KEYS.OPERATE]: true,
  201. };
  202. };
  203. // Initialize default column visibility
  204. const initDefaultColumns = () => {
  205. const defaults = getDefaultColumnVisibility();
  206. setVisibleColumns(defaults);
  207. };
  208. // Handle column visibility change
  209. const handleColumnVisibilityChange = (columnKey, checked) => {
  210. const updatedColumns = { ...visibleColumns, [columnKey]: checked };
  211. setVisibleColumns(updatedColumns);
  212. };
  213. // Handle "Select All" checkbox
  214. const handleSelectAll = (checked) => {
  215. const allKeys = Object.keys(COLUMN_KEYS).map((key) => COLUMN_KEYS[key]);
  216. const updatedColumns = {};
  217. allKeys.forEach((key) => {
  218. updatedColumns[key] = checked;
  219. });
  220. setVisibleColumns(updatedColumns);
  221. };
  222. // Define all columns with keys
  223. const allColumns = [
  224. {
  225. key: COLUMN_KEYS.ID,
  226. title: t('ID'),
  227. dataIndex: 'id',
  228. width: 50,
  229. },
  230. {
  231. key: COLUMN_KEYS.NAME,
  232. title: t('名称'),
  233. dataIndex: 'name',
  234. width: 80,
  235. },
  236. {
  237. key: COLUMN_KEYS.GROUP,
  238. title: t('分组'),
  239. dataIndex: 'group',
  240. width: 180,
  241. render: (text, record, index) => (
  242. <div>
  243. <Space spacing={2}>
  244. {text
  245. ?.split(',')
  246. .sort((a, b) => {
  247. if (a === 'default') return -1;
  248. if (b === 'default') return 1;
  249. return a.localeCompare(b);
  250. })
  251. .map((item, index) => renderGroup(item))}
  252. </Space>
  253. </div>
  254. ),
  255. },
  256. {
  257. key: COLUMN_KEYS.TYPE,
  258. title: t('类型'),
  259. dataIndex: 'type',
  260. width: 120,
  261. render: (text, record, index) => {
  262. if (record.children === undefined) {
  263. return <>{renderType(text)}</>;
  264. } else {
  265. return <>{renderTagType()}</>;
  266. }
  267. },
  268. },
  269. {
  270. key: COLUMN_KEYS.STATUS,
  271. title: t('状态'),
  272. dataIndex: 'status',
  273. width: 120,
  274. render: (text, record, index) => {
  275. if (text === 3) {
  276. if (record.other_info === '') {
  277. record.other_info = '{}';
  278. }
  279. let otherInfo = JSON.parse(record.other_info);
  280. let reason = otherInfo['status_reason'];
  281. let time = otherInfo['status_time'];
  282. return (
  283. <div>
  284. <Tooltip
  285. content={t('原因:') + reason + t(',时间:') + timestamp2string(time)}
  286. >
  287. {renderStatus(text)}
  288. </Tooltip>
  289. </div>
  290. );
  291. } else {
  292. return renderStatus(text);
  293. }
  294. },
  295. },
  296. {
  297. key: COLUMN_KEYS.RESPONSE_TIME,
  298. title: t('响应时间'),
  299. dataIndex: 'response_time',
  300. width: 120,
  301. render: (text, record, index) => (
  302. <div>{renderResponseTime(text)}</div>
  303. ),
  304. },
  305. {
  306. key: COLUMN_KEYS.BALANCE,
  307. title: t('已用/剩余'),
  308. dataIndex: 'expired_time',
  309. width: 120,
  310. render: (text, record, index) => {
  311. if (record.children === undefined) {
  312. return (
  313. <div>
  314. <Space spacing={1}>
  315. <Tooltip content={t('已用额度')}>
  316. <Tag color='white' type='ghost' size='large' shape='circle'>
  317. {renderQuota(record.used_quota)}
  318. </Tag>
  319. </Tooltip>
  320. <Tooltip content={t('剩余额度') + record.balance + t(',点击更新')}>
  321. <Tag
  322. color='white'
  323. type='ghost'
  324. size='large'
  325. shape='circle'
  326. onClick={() => updateChannelBalance(record)}
  327. >
  328. ${renderNumberWithPoint(record.balance)}
  329. </Tag>
  330. </Tooltip>
  331. </Space>
  332. </div>
  333. );
  334. } else {
  335. return (
  336. <Tooltip content={t('已用额度')}>
  337. <Tag color='white' type='ghost' size='large' shape='circle'>
  338. {renderQuota(record.used_quota)}
  339. </Tag>
  340. </Tooltip>
  341. );
  342. }
  343. },
  344. },
  345. {
  346. key: COLUMN_KEYS.PRIORITY,
  347. title: t('优先级'),
  348. dataIndex: 'priority',
  349. width: 100,
  350. render: (text, record, index) => {
  351. if (record.children === undefined) {
  352. return (
  353. <div>
  354. <InputNumber
  355. style={{ width: 70 }}
  356. name='priority'
  357. onBlur={(e) => {
  358. manageChannel(record.id, 'priority', record, e.target.value);
  359. }}
  360. keepFocus={true}
  361. innerButtons
  362. defaultValue={record.priority}
  363. min={-999}
  364. size="small"
  365. />
  366. </div>
  367. );
  368. } else {
  369. return (
  370. <InputNumber
  371. style={{ width: 70 }}
  372. name='priority'
  373. keepFocus={true}
  374. onBlur={(e) => {
  375. Modal.warning({
  376. title: t('修改子渠道优先级'),
  377. content: t('确定要修改所有子渠道优先级为 ') + e.target.value + t(' 吗?'),
  378. onOk: () => {
  379. if (e.target.value === '') {
  380. return;
  381. }
  382. submitTagEdit('priority', {
  383. tag: record.key,
  384. priority: e.target.value,
  385. });
  386. },
  387. });
  388. }}
  389. innerButtons
  390. defaultValue={record.priority}
  391. min={-999}
  392. size="small"
  393. />
  394. );
  395. }
  396. },
  397. },
  398. {
  399. key: COLUMN_KEYS.WEIGHT,
  400. title: t('权重'),
  401. dataIndex: 'weight',
  402. width: 100,
  403. render: (text, record, index) => {
  404. if (record.children === undefined) {
  405. return (
  406. <div>
  407. <InputNumber
  408. style={{ width: 70 }}
  409. name='weight'
  410. onBlur={(e) => {
  411. manageChannel(record.id, 'weight', record, e.target.value);
  412. }}
  413. keepFocus={true}
  414. innerButtons
  415. defaultValue={record.weight}
  416. min={0}
  417. size="small"
  418. />
  419. </div>
  420. );
  421. } else {
  422. return (
  423. <InputNumber
  424. style={{ width: 70 }}
  425. name='weight'
  426. keepFocus={true}
  427. onBlur={(e) => {
  428. Modal.warning({
  429. title: t('修改子渠道权重'),
  430. content: t('确定要修改所有子渠道权重为 ') + e.target.value + t(' 吗?'),
  431. onOk: () => {
  432. if (e.target.value === '') {
  433. return;
  434. }
  435. submitTagEdit('weight', {
  436. tag: record.key,
  437. weight: e.target.value,
  438. });
  439. },
  440. });
  441. }}
  442. innerButtons
  443. defaultValue={record.weight}
  444. min={-999}
  445. size="small"
  446. />
  447. );
  448. }
  449. },
  450. },
  451. {
  452. key: COLUMN_KEYS.OPERATE,
  453. title: '',
  454. dataIndex: 'operate',
  455. width: 350,
  456. render: (text, record, index) => {
  457. if (record.children === undefined) {
  458. // 创建更多操作的下拉菜单项
  459. const moreMenuItems = [
  460. {
  461. node: 'item',
  462. name: t('删除'),
  463. icon: <IconDelete />,
  464. type: 'danger',
  465. onClick: () => {
  466. Modal.confirm({
  467. title: t('确定是否要删除此渠道?'),
  468. content: t('此修改将不可逆'),
  469. onOk: () => {
  470. manageChannel(record.id, 'delete', record).then(() => {
  471. removeRecord(record);
  472. });
  473. },
  474. });
  475. },
  476. },
  477. {
  478. node: 'item',
  479. name: t('复制'),
  480. icon: <IconCopy />,
  481. type: 'primary',
  482. onClick: () => {
  483. Modal.confirm({
  484. title: t('确定是否要复制此渠道?'),
  485. content: t('复制渠道的所有信息'),
  486. onOk: () => copySelectedChannel(record),
  487. });
  488. },
  489. },
  490. ];
  491. return (
  492. <Space wrap>
  493. <SplitButtonGroup
  494. className="!rounded-full overflow-hidden"
  495. aria-label={t('测试单个渠道操作项目组')}
  496. >
  497. <Button
  498. theme='light'
  499. size="small"
  500. onClick={() => testChannel(record, '')}
  501. >
  502. {t('测试')}
  503. </Button>
  504. <Button
  505. theme='light'
  506. size="small"
  507. icon={<IconTreeTriangleDown />}
  508. onClick={() => {
  509. setCurrentTestChannel(record);
  510. setShowModelTestModal(true);
  511. }}
  512. />
  513. </SplitButtonGroup>
  514. {record.status === 1 ? (
  515. <Button
  516. theme='light'
  517. type='warning'
  518. size="small"
  519. className="!rounded-full"
  520. icon={<IconStop />}
  521. onClick={() => manageChannel(record.id, 'disable', record)}
  522. >
  523. {t('禁用')}
  524. </Button>
  525. ) : (
  526. <Button
  527. theme='light'
  528. type='secondary'
  529. size="small"
  530. className="!rounded-full"
  531. icon={<IconPlay />}
  532. onClick={() => manageChannel(record.id, 'enable', record)}
  533. >
  534. {t('启用')}
  535. </Button>
  536. )}
  537. <Button
  538. theme='light'
  539. type='tertiary'
  540. size="small"
  541. className="!rounded-full"
  542. icon={<IconEdit />}
  543. onClick={() => {
  544. setEditingChannel(record);
  545. setShowEdit(true);
  546. }}
  547. >
  548. {t('编辑')}
  549. </Button>
  550. <Dropdown
  551. trigger='click'
  552. position='bottomRight'
  553. menu={moreMenuItems}
  554. >
  555. <Button
  556. icon={<IconMore />}
  557. theme='light'
  558. type='tertiary'
  559. size="small"
  560. className="!rounded-full"
  561. />
  562. </Dropdown>
  563. </Space>
  564. );
  565. } else {
  566. // 标签操作的下拉菜单项
  567. const tagMenuItems = [
  568. {
  569. node: 'item',
  570. name: t('编辑'),
  571. icon: <IconEdit />,
  572. onClick: () => {
  573. setShowEditTag(true);
  574. setEditingTag(record.key);
  575. },
  576. },
  577. ];
  578. return (
  579. <Space wrap>
  580. <Button
  581. theme='light'
  582. type='secondary'
  583. size="small"
  584. className="!rounded-full"
  585. icon={<IconPlay />}
  586. onClick={() => manageTag(record.key, 'enable')}
  587. >
  588. {t('启用全部')}
  589. </Button>
  590. <Button
  591. theme='light'
  592. type='warning'
  593. size="small"
  594. className="!rounded-full"
  595. icon={<IconStop />}
  596. onClick={() => manageTag(record.key, 'disable')}
  597. >
  598. {t('禁用全部')}
  599. </Button>
  600. <Dropdown
  601. trigger='click'
  602. position='bottomRight'
  603. menu={tagMenuItems}
  604. >
  605. <Button
  606. icon={<IconMore />}
  607. theme='light'
  608. type='tertiary'
  609. size="small"
  610. className="!rounded-full"
  611. />
  612. </Dropdown>
  613. </Space>
  614. );
  615. }
  616. },
  617. },
  618. ];
  619. // Filter columns based on visibility settings
  620. const getVisibleColumns = () => {
  621. return allColumns.filter((column) => visibleColumns[column.key]);
  622. };
  623. // Column selector modal
  624. const renderColumnSelector = () => {
  625. return (
  626. <Modal
  627. title={t('列设置')}
  628. visible={showColumnSelector}
  629. onCancel={() => setShowColumnSelector(false)}
  630. footer={
  631. <div className="flex justify-end">
  632. <Button
  633. theme="light"
  634. onClick={() => initDefaultColumns()}
  635. className="!rounded-full"
  636. >
  637. {t('重置')}
  638. </Button>
  639. <Button
  640. theme="light"
  641. onClick={() => setShowColumnSelector(false)}
  642. className="!rounded-full"
  643. >
  644. {t('取消')}
  645. </Button>
  646. <Button
  647. type='primary'
  648. onClick={() => setShowColumnSelector(false)}
  649. className="!rounded-full"
  650. >
  651. {t('确定')}
  652. </Button>
  653. </div>
  654. }
  655. size="middle"
  656. centered={true}
  657. >
  658. <div style={{ marginBottom: 20 }}>
  659. <Checkbox
  660. checked={Object.values(visibleColumns).every((v) => v === true)}
  661. indeterminate={
  662. Object.values(visibleColumns).some((v) => v === true) &&
  663. !Object.values(visibleColumns).every((v) => v === true)
  664. }
  665. onChange={(e) => handleSelectAll(e.target.checked)}
  666. >
  667. {t('全选')}
  668. </Checkbox>
  669. </div>
  670. <div
  671. className="flex flex-wrap max-h-96 overflow-y-auto rounded-lg p-4"
  672. style={{ border: '1px solid var(--semi-color-border)' }}
  673. >
  674. {allColumns.map((column) => {
  675. // Skip columns without title
  676. if (!column.title) {
  677. return null;
  678. }
  679. return (
  680. <div
  681. key={column.key}
  682. className="w-1/2 mb-4 pr-2"
  683. >
  684. <Checkbox
  685. checked={!!visibleColumns[column.key]}
  686. onChange={(e) =>
  687. handleColumnVisibilityChange(column.key, e.target.checked)
  688. }
  689. >
  690. {column.title}
  691. </Checkbox>
  692. </div>
  693. );
  694. })}
  695. </div>
  696. </Modal>
  697. );
  698. };
  699. const [channels, setChannels] = useState([]);
  700. const [loading, setLoading] = useState(true);
  701. const [activePage, setActivePage] = useState(1);
  702. const [idSort, setIdSort] = useState(false);
  703. const [searchKeyword, setSearchKeyword] = useState('');
  704. const [searchGroup, setSearchGroup] = useState('');
  705. const [searchModel, setSearchModel] = useState('');
  706. const [searching, setSearching] = useState(false);
  707. const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
  708. const [channelCount, setChannelCount] = useState(pageSize);
  709. const [groupOptions, setGroupOptions] = useState([]);
  710. const [showEdit, setShowEdit] = useState(false);
  711. const [enableBatchDelete, setEnableBatchDelete] = useState(false);
  712. const [editingChannel, setEditingChannel] = useState({
  713. id: undefined,
  714. });
  715. const [showEditTag, setShowEditTag] = useState(false);
  716. const [editingTag, setEditingTag] = useState('');
  717. const [selectedChannels, setSelectedChannels] = useState([]);
  718. const [enableTagMode, setEnableTagMode] = useState(false);
  719. const [showBatchSetTag, setShowBatchSetTag] = useState(false);
  720. const [batchSetTagValue, setBatchSetTagValue] = useState('');
  721. const [showModelTestModal, setShowModelTestModal] = useState(false);
  722. const [currentTestChannel, setCurrentTestChannel] = useState(null);
  723. const [modelSearchKeyword, setModelSearchKeyword] = useState('');
  724. const [modelTestResults, setModelTestResults] = useState({});
  725. const [testingModels, setTestingModels] = useState(new Set());
  726. const [isBatchTesting, setIsBatchTesting] = useState(false);
  727. const [testQueue, setTestQueue] = useState([]);
  728. const [isProcessingQueue, setIsProcessingQueue] = useState(false);
  729. const removeRecord = (record) => {
  730. let newDataSource = [...channels];
  731. if (record.id != null) {
  732. let idx = newDataSource.findIndex((data) => {
  733. if (data.children !== undefined) {
  734. for (let i = 0; i < data.children.length; i++) {
  735. if (data.children[i].id === record.id) {
  736. data.children.splice(i, 1);
  737. return false;
  738. }
  739. }
  740. } else {
  741. return data.id === record.id;
  742. }
  743. });
  744. if (idx > -1) {
  745. newDataSource.splice(idx, 1);
  746. setChannels(newDataSource);
  747. }
  748. }
  749. };
  750. const setChannelFormat = (channels, enableTagMode) => {
  751. let channelDates = [];
  752. let channelTags = {};
  753. for (let i = 0; i < channels.length; i++) {
  754. channels[i].key = '' + channels[i].id;
  755. if (!enableTagMode) {
  756. channelDates.push(channels[i]);
  757. } else {
  758. let tag = channels[i].tag ? channels[i].tag : '';
  759. // find from channelTags
  760. let tagIndex = channelTags[tag];
  761. let tagChannelDates = undefined;
  762. if (tagIndex === undefined) {
  763. // not found, create a new tag
  764. channelTags[tag] = 1;
  765. tagChannelDates = {
  766. key: tag,
  767. id: tag,
  768. tag: tag,
  769. name: '标签:' + tag,
  770. group: '',
  771. used_quota: 0,
  772. response_time: 0,
  773. priority: -1,
  774. weight: -1,
  775. };
  776. tagChannelDates.children = [];
  777. channelDates.push(tagChannelDates);
  778. } else {
  779. // found, add to the tag
  780. tagChannelDates = channelDates.find((item) => item.key === tag);
  781. }
  782. if (tagChannelDates.priority === -1) {
  783. tagChannelDates.priority = channels[i].priority;
  784. } else {
  785. if (tagChannelDates.priority !== channels[i].priority) {
  786. tagChannelDates.priority = '';
  787. }
  788. }
  789. if (tagChannelDates.weight === -1) {
  790. tagChannelDates.weight = channels[i].weight;
  791. } else {
  792. if (tagChannelDates.weight !== channels[i].weight) {
  793. tagChannelDates.weight = '';
  794. }
  795. }
  796. if (tagChannelDates.group === '') {
  797. tagChannelDates.group = channels[i].group;
  798. } else {
  799. let channelGroupsStr = channels[i].group;
  800. channelGroupsStr.split(',').forEach((item, index) => {
  801. if (tagChannelDates.group.indexOf(item) === -1) {
  802. // join
  803. tagChannelDates.group += ',' + item;
  804. }
  805. });
  806. }
  807. tagChannelDates.children.push(channels[i]);
  808. if (channels[i].status === 1) {
  809. tagChannelDates.status = 1;
  810. }
  811. tagChannelDates.used_quota += channels[i].used_quota;
  812. tagChannelDates.response_time += channels[i].response_time;
  813. tagChannelDates.response_time = tagChannelDates.response_time / 2;
  814. }
  815. }
  816. // data.key = '' + data.id
  817. setChannels(channelDates);
  818. if (channelDates.length >= pageSize) {
  819. setChannelCount(channelDates.length + pageSize);
  820. } else {
  821. setChannelCount(channelDates.length);
  822. }
  823. };
  824. const loadChannels = async (startIdx, pageSize, idSort, enableTagMode) => {
  825. setLoading(true);
  826. const res = await API.get(
  827. `/api/channel/?p=${startIdx}&page_size=${pageSize}&id_sort=${idSort}&tag_mode=${enableTagMode}`,
  828. );
  829. if (res === undefined) {
  830. return;
  831. }
  832. const { success, message, data } = res.data;
  833. if (success) {
  834. if (startIdx === 0) {
  835. setChannelFormat(data, enableTagMode);
  836. } else {
  837. let newChannels = [...channels];
  838. newChannels.splice(startIdx * pageSize, data.length, ...data);
  839. setChannelFormat(newChannels, enableTagMode);
  840. }
  841. } else {
  842. showError(message);
  843. }
  844. setLoading(false);
  845. };
  846. const copySelectedChannel = async (record) => {
  847. const channelToCopy = record;
  848. channelToCopy.name += t('_复制');
  849. channelToCopy.created_time = null;
  850. channelToCopy.balance = 0;
  851. channelToCopy.used_quota = 0;
  852. if (!channelToCopy) {
  853. showError(t('渠道未找到,请刷新页面后重试。'));
  854. return;
  855. }
  856. try {
  857. const newChannel = { ...channelToCopy, id: undefined };
  858. const response = await API.post('/api/channel/', newChannel);
  859. if (response.data.success) {
  860. showSuccess(t('渠道复制成功'));
  861. await refresh();
  862. } else {
  863. showError(response.data.message);
  864. }
  865. } catch (error) {
  866. showError(t('渠道复制失败: ') + error.message);
  867. }
  868. };
  869. const refresh = async () => {
  870. if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
  871. await loadChannels(activePage - 1, pageSize, idSort, enableTagMode);
  872. } else {
  873. await searchChannels(
  874. searchKeyword,
  875. searchGroup,
  876. searchModel,
  877. enableTagMode,
  878. );
  879. }
  880. };
  881. useEffect(() => {
  882. // console.log('default effect')
  883. const localIdSort = localStorage.getItem('id-sort') === 'true';
  884. const localPageSize =
  885. parseInt(localStorage.getItem('page-size')) || ITEMS_PER_PAGE;
  886. const localEnableTagMode = localStorage.getItem('enable-tag-mode') === 'true';
  887. const localEnableBatchDelete = localStorage.getItem('enable-batch-delete') === 'true';
  888. setIdSort(localIdSort);
  889. setPageSize(localPageSize);
  890. setEnableTagMode(localEnableTagMode);
  891. setEnableBatchDelete(localEnableBatchDelete);
  892. loadChannels(0, localPageSize, localIdSort, localEnableTagMode)
  893. .then()
  894. .catch((reason) => {
  895. showError(reason);
  896. });
  897. fetchGroups().then();
  898. loadChannelModels().then();
  899. }, []);
  900. const manageChannel = async (id, action, record, value) => {
  901. let data = { id };
  902. let res;
  903. switch (action) {
  904. case 'delete':
  905. res = await API.delete(`/api/channel/${id}/`);
  906. break;
  907. case 'enable':
  908. data.status = 1;
  909. res = await API.put('/api/channel/', data);
  910. break;
  911. case 'disable':
  912. data.status = 2;
  913. res = await API.put('/api/channel/', data);
  914. break;
  915. case 'priority':
  916. if (value === '') {
  917. return;
  918. }
  919. data.priority = parseInt(value);
  920. res = await API.put('/api/channel/', data);
  921. break;
  922. case 'weight':
  923. if (value === '') {
  924. return;
  925. }
  926. data.weight = parseInt(value);
  927. if (data.weight < 0) {
  928. data.weight = 0;
  929. }
  930. res = await API.put('/api/channel/', data);
  931. break;
  932. }
  933. const { success, message } = res.data;
  934. if (success) {
  935. showSuccess(t('操作成功完成!'));
  936. let channel = res.data.data;
  937. let newChannels = [...channels];
  938. if (action === 'delete') {
  939. } else {
  940. record.status = channel.status;
  941. }
  942. setChannels(newChannels);
  943. } else {
  944. showError(message);
  945. }
  946. };
  947. const manageTag = async (tag, action) => {
  948. console.log(tag, action);
  949. let res;
  950. switch (action) {
  951. case 'enable':
  952. res = await API.post('/api/channel/tag/enabled', {
  953. tag: tag,
  954. });
  955. break;
  956. case 'disable':
  957. res = await API.post('/api/channel/tag/disabled', {
  958. tag: tag,
  959. });
  960. break;
  961. }
  962. const { success, message } = res.data;
  963. if (success) {
  964. showSuccess('操作成功完成!');
  965. let newChannels = [...channels];
  966. for (let i = 0; i < newChannels.length; i++) {
  967. if (newChannels[i].tag === tag) {
  968. let status = action === 'enable' ? 1 : 2;
  969. newChannels[i]?.children?.forEach((channel) => {
  970. channel.status = status;
  971. });
  972. newChannels[i].status = status;
  973. }
  974. }
  975. setChannels(newChannels);
  976. } else {
  977. showError(message);
  978. }
  979. };
  980. const searchChannels = async (
  981. searchKeyword,
  982. searchGroup,
  983. searchModel,
  984. enableTagMode,
  985. ) => {
  986. if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
  987. await loadChannels(activePage - 1, pageSize, idSort, enableTagMode);
  988. // setActivePage(1);
  989. return;
  990. }
  991. setSearching(true);
  992. const res = await API.get(
  993. `/api/channel/search?keyword=${searchKeyword}&group=${searchGroup}&model=${searchModel}&id_sort=${idSort}&tag_mode=${enableTagMode}`,
  994. );
  995. const { success, message, data } = res.data;
  996. if (success) {
  997. setChannelFormat(data, enableTagMode);
  998. setActivePage(1);
  999. } else {
  1000. showError(message);
  1001. }
  1002. setSearching(false);
  1003. };
  1004. const updateChannelProperty = (channelId, updateFn) => {
  1005. // Create a new copy of channels array
  1006. const newChannels = [...channels];
  1007. let updated = false;
  1008. // Find and update the correct channel
  1009. newChannels.forEach((channel) => {
  1010. if (channel.children !== undefined) {
  1011. // If this is a tag group, search in its children
  1012. channel.children.forEach((child) => {
  1013. if (child.id === channelId) {
  1014. updateFn(child);
  1015. updated = true;
  1016. }
  1017. });
  1018. } else if (channel.id === channelId) {
  1019. // Direct channel match
  1020. updateFn(channel);
  1021. updated = true;
  1022. }
  1023. });
  1024. // Only update state if we actually modified a channel
  1025. if (updated) {
  1026. setChannels(newChannels);
  1027. }
  1028. };
  1029. const processTestQueue = async () => {
  1030. if (!isProcessingQueue || testQueue.length === 0) return;
  1031. const { channel, model } = testQueue[0];
  1032. try {
  1033. setTestingModels(prev => new Set([...prev, model]));
  1034. const res = await API.get(`/api/channel/test/${channel.id}?model=${model}`);
  1035. const { success, message, time } = res.data;
  1036. setModelTestResults(prev => ({
  1037. ...prev,
  1038. [`${channel.id}-${model}`]: { success, time }
  1039. }));
  1040. if (success) {
  1041. updateChannelProperty(channel.id, (ch) => {
  1042. ch.response_time = time * 1000;
  1043. ch.test_time = Date.now() / 1000;
  1044. });
  1045. if (!model) {
  1046. showInfo(
  1047. t('通道 ${name} 测试成功,耗时 ${time.toFixed(2)} 秒。')
  1048. .replace('${name}', channel.name)
  1049. .replace('${time.toFixed(2)}', time.toFixed(2)),
  1050. );
  1051. }
  1052. } else {
  1053. showError(message);
  1054. }
  1055. } catch (error) {
  1056. showError(error.message);
  1057. } finally {
  1058. setTestingModels(prev => {
  1059. const newSet = new Set(prev);
  1060. newSet.delete(model);
  1061. return newSet;
  1062. });
  1063. }
  1064. // 移除已处理的测试
  1065. setTestQueue(prev => prev.slice(1));
  1066. };
  1067. // 监听队列变化
  1068. useEffect(() => {
  1069. if (testQueue.length > 0 && isProcessingQueue) {
  1070. processTestQueue();
  1071. } else if (testQueue.length === 0 && isProcessingQueue) {
  1072. setIsProcessingQueue(false);
  1073. setIsBatchTesting(false);
  1074. }
  1075. }, [testQueue, isProcessingQueue]);
  1076. const testChannel = async (record, model) => {
  1077. setTestQueue(prev => [...prev, { channel: record, model }]);
  1078. if (!isProcessingQueue) {
  1079. setIsProcessingQueue(true);
  1080. }
  1081. };
  1082. const batchTestModels = async () => {
  1083. if (!currentTestChannel) return;
  1084. setIsBatchTesting(true);
  1085. const models = currentTestChannel.models
  1086. .split(',')
  1087. .filter((model) =>
  1088. model.toLowerCase().includes(modelSearchKeyword.toLowerCase())
  1089. );
  1090. setTestQueue(models.map(model => ({
  1091. channel: currentTestChannel,
  1092. model
  1093. })));
  1094. setIsProcessingQueue(true);
  1095. };
  1096. const handleCloseModal = () => {
  1097. if (isBatchTesting) {
  1098. // 清空测试队列来停止测试
  1099. setTestQueue([]);
  1100. setIsProcessingQueue(false);
  1101. setIsBatchTesting(false);
  1102. showSuccess(t('已停止测试'));
  1103. } else {
  1104. setShowModelTestModal(false);
  1105. setModelSearchKeyword('');
  1106. }
  1107. };
  1108. let pageData = channels.slice(
  1109. (activePage - 1) * pageSize,
  1110. activePage * pageSize,
  1111. );
  1112. const handlePageChange = (page) => {
  1113. setActivePage(page);
  1114. if (page === Math.ceil(channels.length / pageSize) + 1) {
  1115. // In this case we have to load more data and then append them.
  1116. loadChannels(page - 1, pageSize, idSort, enableTagMode).then((r) => { });
  1117. }
  1118. };
  1119. const handlePageSizeChange = async (size) => {
  1120. localStorage.setItem('page-size', size + '');
  1121. setPageSize(size);
  1122. setActivePage(1);
  1123. loadChannels(0, size, idSort, enableTagMode)
  1124. .then()
  1125. .catch((reason) => {
  1126. showError(reason);
  1127. });
  1128. };
  1129. const fetchGroups = async () => {
  1130. try {
  1131. let res = await API.get(`/api/group/`);
  1132. // add 'all' option
  1133. // res.data.data.unshift('all');
  1134. if (res === undefined) {
  1135. return;
  1136. }
  1137. setGroupOptions(
  1138. res.data.data.map((group) => ({
  1139. label: group,
  1140. value: group,
  1141. })),
  1142. );
  1143. } catch (error) {
  1144. showError(error.message);
  1145. }
  1146. };
  1147. const submitTagEdit = async (type, data) => {
  1148. switch (type) {
  1149. case 'priority':
  1150. if (data.priority === undefined || data.priority === '') {
  1151. showInfo('优先级必须是整数!');
  1152. return;
  1153. }
  1154. data.priority = parseInt(data.priority);
  1155. break;
  1156. case 'weight':
  1157. if (
  1158. data.weight === undefined ||
  1159. data.weight < 0 ||
  1160. data.weight === ''
  1161. ) {
  1162. showInfo('权重必须是非负整数!');
  1163. return;
  1164. }
  1165. data.weight = parseInt(data.weight);
  1166. break;
  1167. }
  1168. try {
  1169. const res = await API.put('/api/channel/tag', data);
  1170. if (res?.data?.success) {
  1171. showSuccess('更新成功!');
  1172. await refresh();
  1173. }
  1174. } catch (error) {
  1175. showError(error);
  1176. }
  1177. };
  1178. const closeEdit = () => {
  1179. setShowEdit(false);
  1180. };
  1181. const handleRow = (record, index) => {
  1182. if (record.status !== 1) {
  1183. return {
  1184. style: {
  1185. background: 'var(--semi-color-disabled-border)',
  1186. },
  1187. };
  1188. } else {
  1189. return {};
  1190. }
  1191. };
  1192. const batchSetChannelTag = async () => {
  1193. if (selectedChannels.length === 0) {
  1194. showError(t('请先选择要设置标签的渠道!'));
  1195. return;
  1196. }
  1197. if (batchSetTagValue === '') {
  1198. showError(t('标签不能为空!'));
  1199. return;
  1200. }
  1201. let ids = selectedChannels.map((channel) => channel.id);
  1202. const res = await API.post('/api/channel/batch/tag', {
  1203. ids: ids,
  1204. tag: batchSetTagValue === '' ? null : batchSetTagValue,
  1205. });
  1206. if (res.data.success) {
  1207. showSuccess(
  1208. t('已为 ${count} 个渠道设置标签!').replace('${count}', res.data.data),
  1209. );
  1210. await refresh();
  1211. setShowBatchSetTag(false);
  1212. } else {
  1213. showError(res.data.message);
  1214. }
  1215. };
  1216. const testAllChannels = async () => {
  1217. const res = await API.get(`/api/channel/test`);
  1218. const { success, message } = res.data;
  1219. if (success) {
  1220. showInfo(t('已成功开始测试所有已启用通道,请刷新页面查看结果。'));
  1221. } else {
  1222. showError(message);
  1223. }
  1224. };
  1225. const deleteAllDisabledChannels = async () => {
  1226. const res = await API.delete(`/api/channel/disabled`);
  1227. const { success, message, data } = res.data;
  1228. if (success) {
  1229. showSuccess(
  1230. t('已删除所有禁用渠道,共计 ${data} 个').replace('${data}', data),
  1231. );
  1232. await refresh();
  1233. } else {
  1234. showError(message);
  1235. }
  1236. };
  1237. const updateAllChannelsBalance = async () => {
  1238. const res = await API.get(`/api/channel/update_balance`);
  1239. const { success, message } = res.data;
  1240. if (success) {
  1241. showInfo(t('已更新完毕所有已启用通道余额!'));
  1242. } else {
  1243. showError(message);
  1244. }
  1245. };
  1246. const updateChannelBalance = async (record) => {
  1247. const res = await API.get(`/api/channel/update_balance/${record.id}/`);
  1248. const { success, message, balance } = res.data;
  1249. if (success) {
  1250. updateChannelProperty(record.id, (channel) => {
  1251. channel.balance = balance;
  1252. channel.balance_updated_time = Date.now() / 1000;
  1253. });
  1254. showInfo(
  1255. t('通道 ${name} 余额更新成功!').replace('${name}', record.name),
  1256. );
  1257. } else {
  1258. showError(message);
  1259. }
  1260. };
  1261. const batchDeleteChannels = async () => {
  1262. if (selectedChannels.length === 0) {
  1263. showError(t('请先选择要删除的通道!'));
  1264. return;
  1265. }
  1266. setLoading(true);
  1267. let ids = [];
  1268. selectedChannels.forEach((channel) => {
  1269. ids.push(channel.id);
  1270. });
  1271. const res = await API.post(`/api/channel/batch`, { ids: ids });
  1272. const { success, message, data } = res.data;
  1273. if (success) {
  1274. showSuccess(t('已删除 ${data} 个通道!').replace('${data}', data));
  1275. await refresh();
  1276. } else {
  1277. showError(message);
  1278. }
  1279. setLoading(false);
  1280. };
  1281. const fixChannelsAbilities = async () => {
  1282. const res = await API.post(`/api/channel/fix`);
  1283. const { success, message, data } = res.data;
  1284. if (success) {
  1285. showSuccess(t('已修复 ${data} 个通道!').replace('${data}', data));
  1286. await refresh();
  1287. } else {
  1288. showError(message);
  1289. }
  1290. };
  1291. const renderHeader = () => (
  1292. <div className="flex flex-col w-full">
  1293. <div className="flex flex-col md:flex-row justify-between gap-4">
  1294. <div className="flex flex-wrap md:flex-nowrap items-center gap-2 w-full md:w-auto order-2 md:order-1">
  1295. <Button
  1296. disabled={!enableBatchDelete}
  1297. theme='light'
  1298. type='danger'
  1299. className="!rounded-full w-full md:w-auto"
  1300. onClick={() => {
  1301. Modal.confirm({
  1302. title: t('确定是否要删除所选通道?'),
  1303. content: t('此修改将不可逆'),
  1304. onOk: () => batchDeleteChannels(),
  1305. });
  1306. }}
  1307. >
  1308. {t('删除所选通道')}
  1309. </Button>
  1310. <Button
  1311. disabled={!enableBatchDelete}
  1312. theme='light'
  1313. type='primary'
  1314. onClick={() => setShowBatchSetTag(true)}
  1315. className="!rounded-full w-full md:w-auto"
  1316. >
  1317. {t('批量设置标签')}
  1318. </Button>
  1319. <Dropdown
  1320. trigger='click'
  1321. render={
  1322. <Dropdown.Menu>
  1323. <Dropdown.Item>
  1324. <Button
  1325. theme='light'
  1326. type='warning'
  1327. className="!rounded-full w-full"
  1328. onClick={() => {
  1329. Modal.confirm({
  1330. title: t('确定?'),
  1331. content: t('确定要测试所有通道吗?'),
  1332. onOk: () => testAllChannels(),
  1333. size: 'small',
  1334. centered: true,
  1335. });
  1336. }}
  1337. >
  1338. {t('测试所有通道')}
  1339. </Button>
  1340. </Dropdown.Item>
  1341. <Dropdown.Item>
  1342. <Button
  1343. theme='light'
  1344. type='secondary'
  1345. className="!rounded-full w-full"
  1346. onClick={() => {
  1347. Modal.confirm({
  1348. title: t('确定?'),
  1349. content: t('确定要更新所有已启用通道余额吗?'),
  1350. onOk: () => updateAllChannelsBalance(),
  1351. size: 'sm',
  1352. centered: true,
  1353. });
  1354. }}
  1355. >
  1356. {t('更新所有已启用通道余额')}
  1357. </Button>
  1358. </Dropdown.Item>
  1359. <Dropdown.Item>
  1360. <Button
  1361. theme='light'
  1362. type='danger'
  1363. className="!rounded-full w-full"
  1364. onClick={() => {
  1365. Modal.confirm({
  1366. title: t('确定是否要删除禁用通道?'),
  1367. content: t('此修改将不可逆'),
  1368. onOk: () => deleteAllDisabledChannels(),
  1369. size: 'sm',
  1370. centered: true,
  1371. });
  1372. }}
  1373. >
  1374. {t('删除禁用通道')}
  1375. </Button>
  1376. </Dropdown.Item>
  1377. <Dropdown.Item>
  1378. <Button
  1379. theme='light'
  1380. type='tertiary'
  1381. className="!rounded-full w-full"
  1382. onClick={() => {
  1383. Modal.confirm({
  1384. title: t('确定是否要修复数据库一致性?'),
  1385. content: t('进行该操作时,可能导致渠道访问错误,请仅在数据库出现问题时使用'),
  1386. onOk: () => fixChannelsAbilities(),
  1387. size: 'sm',
  1388. centered: true,
  1389. });
  1390. }}
  1391. >
  1392. {t('修复数据库一致性')}
  1393. </Button>
  1394. </Dropdown.Item>
  1395. </Dropdown.Menu>
  1396. }
  1397. >
  1398. <Button theme='light' type='tertiary' icon={<IconSetting />} className="!rounded-full w-full md:w-auto">
  1399. {t('批量操作')}
  1400. </Button>
  1401. </Dropdown>
  1402. </div>
  1403. <div className="flex flex-col md:flex-row items-start md:items-center gap-4 w-full md:w-auto order-1 md:order-2">
  1404. <div className="flex items-center justify-between w-full md:w-auto">
  1405. <Typography.Text strong className="mr-2">
  1406. {t('使用ID排序')}
  1407. </Typography.Text>
  1408. <Switch
  1409. checked={idSort}
  1410. onChange={(v) => {
  1411. localStorage.setItem('id-sort', v + '');
  1412. setIdSort(v);
  1413. loadChannels(0, pageSize, v, enableTagMode);
  1414. }}
  1415. />
  1416. </div>
  1417. <div className="flex items-center justify-between w-full md:w-auto">
  1418. <Typography.Text strong className="mr-2">
  1419. {t('开启批量操作')}
  1420. </Typography.Text>
  1421. <Switch
  1422. checked={enableBatchDelete}
  1423. onChange={(v) => {
  1424. localStorage.setItem('enable-batch-delete', v + '');
  1425. setEnableBatchDelete(v);
  1426. }}
  1427. />
  1428. </div>
  1429. <div className="flex items-center justify-between w-full md:w-auto">
  1430. <Typography.Text strong className="mr-2">
  1431. {t('标签聚合模式')}
  1432. </Typography.Text>
  1433. <Switch
  1434. checked={enableTagMode}
  1435. onChange={(v) => {
  1436. localStorage.setItem('enable-tag-mode', v + '');
  1437. setEnableTagMode(v);
  1438. loadChannels(0, pageSize, idSort, v);
  1439. }}
  1440. />
  1441. </div>
  1442. </div>
  1443. </div>
  1444. <Divider margin="12px" />
  1445. <div className="flex flex-col md:flex-row justify-between items-center gap-4 w-full">
  1446. <div className="flex gap-2 w-full md:w-auto order-2 md:order-1">
  1447. <Button
  1448. theme='light'
  1449. type='primary'
  1450. icon={<IconPlus />}
  1451. className="!rounded-full w-full md:w-auto"
  1452. onClick={() => {
  1453. setEditingChannel({
  1454. id: undefined,
  1455. });
  1456. setShowEdit(true);
  1457. }}
  1458. >
  1459. {t('添加渠道')}
  1460. </Button>
  1461. <Button
  1462. theme='light'
  1463. type='primary'
  1464. icon={<IconRefresh />}
  1465. className="!rounded-full w-full md:w-auto"
  1466. onClick={refresh}
  1467. >
  1468. {t('刷新')}
  1469. </Button>
  1470. <Button
  1471. theme='light'
  1472. type='tertiary'
  1473. icon={<IconSetting />}
  1474. onClick={() => setShowColumnSelector(true)}
  1475. className="!rounded-full w-full md:w-auto"
  1476. >
  1477. {t('列设置')}
  1478. </Button>
  1479. </div>
  1480. <div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto order-1 md:order-2">
  1481. <div className="relative w-full md:w-64">
  1482. <Input
  1483. prefix={<IconSearch />}
  1484. placeholder={t('搜索渠道的 ID,名称,密钥和API地址 ...')}
  1485. value={searchKeyword}
  1486. loading={searching}
  1487. onChange={(v) => {
  1488. setSearchKeyword(v.trim());
  1489. }}
  1490. className="!rounded-full"
  1491. showClear
  1492. />
  1493. </div>
  1494. <div className="w-full md:w-48">
  1495. <Input
  1496. prefix={<IconFilter />}
  1497. placeholder={t('模型关键字')}
  1498. value={searchModel}
  1499. loading={searching}
  1500. onChange={(v) => {
  1501. setSearchModel(v.trim());
  1502. }}
  1503. className="!rounded-full"
  1504. showClear
  1505. />
  1506. </div>
  1507. <div className="w-full md:w-48">
  1508. <Select
  1509. placeholder={t('选择分组')}
  1510. optionList={[
  1511. { label: t('选择分组'), value: null },
  1512. ...groupOptions,
  1513. ]}
  1514. value={searchGroup}
  1515. onChange={(v) => {
  1516. setSearchGroup(v);
  1517. searchChannels(searchKeyword, v, searchModel, enableTagMode);
  1518. }}
  1519. className="!rounded-full w-full"
  1520. showClear
  1521. />
  1522. </div>
  1523. <Button
  1524. type="primary"
  1525. onClick={() => {
  1526. searchChannels(searchKeyword, searchGroup, searchModel, enableTagMode);
  1527. }}
  1528. loading={searching}
  1529. className="!rounded-full w-full md:w-auto"
  1530. >
  1531. {t('查询')}
  1532. </Button>
  1533. </div>
  1534. </div>
  1535. </div>
  1536. );
  1537. return (
  1538. <>
  1539. {renderColumnSelector()}
  1540. <EditTagModal
  1541. visible={showEditTag}
  1542. tag={editingTag}
  1543. handleClose={() => setShowEditTag(false)}
  1544. refresh={refresh}
  1545. />
  1546. <EditChannel
  1547. refresh={refresh}
  1548. visible={showEdit}
  1549. handleClose={closeEdit}
  1550. editingChannel={editingChannel}
  1551. />
  1552. <Card
  1553. className="!rounded-2xl overflow-hidden"
  1554. title={renderHeader()}
  1555. shadows='hover'
  1556. >
  1557. <Table
  1558. columns={getVisibleColumns()}
  1559. dataSource={pageData}
  1560. pagination={{
  1561. currentPage: activePage,
  1562. pageSize: pageSize,
  1563. total: channelCount,
  1564. pageSizeOpts: [10, 20, 50, 100],
  1565. showSizeChanger: true,
  1566. formatPageText: (page) => t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
  1567. start: page.currentStart,
  1568. end: page.currentEnd,
  1569. total: channels.length,
  1570. }),
  1571. onPageSizeChange: (size) => {
  1572. handlePageSizeChange(size);
  1573. },
  1574. onPageChange: handlePageChange,
  1575. }}
  1576. expandAllRows={false}
  1577. onRow={handleRow}
  1578. rowSelection={
  1579. enableBatchDelete
  1580. ? {
  1581. onChange: (selectedRowKeys, selectedRows) => {
  1582. setSelectedChannels(selectedRows);
  1583. },
  1584. }
  1585. : null
  1586. }
  1587. className="rounded-xl overflow-hidden"
  1588. size="middle"
  1589. loading={loading}
  1590. />
  1591. </Card>
  1592. {/* 批量设置标签模态框 */}
  1593. <Modal
  1594. title={t('批量设置标签')}
  1595. visible={showBatchSetTag}
  1596. onOk={batchSetChannelTag}
  1597. onCancel={() => setShowBatchSetTag(false)}
  1598. maskClosable={false}
  1599. centered={true}
  1600. size="small"
  1601. className="!rounded-lg"
  1602. >
  1603. <div className="mb-5">
  1604. <Typography.Text>{t('请输入要设置的标签名称')}</Typography.Text>
  1605. </div>
  1606. <Input
  1607. placeholder={t('请输入标签名称')}
  1608. value={batchSetTagValue}
  1609. onChange={(v) => setBatchSetTagValue(v)}
  1610. size='large'
  1611. className="!rounded-full"
  1612. />
  1613. <div className="mt-4">
  1614. <Typography.Text type='secondary'>
  1615. {t('已选择 ${count} 个渠道').replace('${count}', selectedChannels.length)}
  1616. </Typography.Text>
  1617. </div>
  1618. </Modal>
  1619. {/* 模型测试弹窗 */}
  1620. <Modal
  1621. title={
  1622. currentTestChannel && (
  1623. <div className="flex items-center gap-2">
  1624. <Typography.Text strong className="!text-[var(--semi-color-text-0)] !text-base">
  1625. {currentTestChannel.name} {t('渠道的模型测试')}
  1626. </Typography.Text>
  1627. <Typography.Text type="tertiary" className="!text-xs flex items-center">
  1628. {t('共')} {currentTestChannel.models.split(',').length} {t('个模型')}
  1629. </Typography.Text>
  1630. </div>
  1631. )
  1632. }
  1633. visible={showModelTestModal && currentTestChannel !== null}
  1634. onCancel={handleCloseModal}
  1635. footer={
  1636. <div className="flex justify-end">
  1637. {isBatchTesting ? (
  1638. <Button
  1639. theme='light'
  1640. type='warning'
  1641. className="!rounded-full"
  1642. onClick={handleCloseModal}
  1643. >
  1644. {t('停止测试')}
  1645. </Button>
  1646. ) : (
  1647. <Button
  1648. theme='light'
  1649. type='tertiary'
  1650. className="!rounded-full"
  1651. onClick={handleCloseModal}
  1652. >
  1653. {t('取消')}
  1654. </Button>
  1655. )}
  1656. <Button
  1657. theme='light'
  1658. type='primary'
  1659. className="!rounded-full"
  1660. onClick={batchTestModels}
  1661. loading={isBatchTesting}
  1662. disabled={isBatchTesting}
  1663. >
  1664. {isBatchTesting ? t('测试中...') : t('批量测试${count}个模型').replace(
  1665. '${count}',
  1666. currentTestChannel
  1667. ? currentTestChannel.models
  1668. .split(',')
  1669. .filter((model) =>
  1670. model.toLowerCase().includes(modelSearchKeyword.toLowerCase())
  1671. ).length
  1672. : 0
  1673. )}
  1674. </Button>
  1675. </div>
  1676. }
  1677. maskClosable={!isBatchTesting}
  1678. centered={true}
  1679. className="!rounded-lg"
  1680. size="large"
  1681. >
  1682. <div className="max-h-[600px] overflow-y-auto">
  1683. {currentTestChannel && (
  1684. <div>
  1685. <div className="flex items-center justify-end mb-2">
  1686. <Input
  1687. placeholder={t('搜索模型...')}
  1688. value={modelSearchKeyword}
  1689. onChange={(v) => setModelSearchKeyword(v)}
  1690. className="w-64 !rounded-full"
  1691. prefix={<IconSearch />}
  1692. showClear
  1693. />
  1694. </div>
  1695. <Table
  1696. columns={[
  1697. {
  1698. title: t('模型名称'),
  1699. dataIndex: 'model',
  1700. render: (text) => (
  1701. <div className="flex items-center">
  1702. <Typography.Text strong>{text}</Typography.Text>
  1703. </div>
  1704. )
  1705. },
  1706. {
  1707. title: t('状态'),
  1708. dataIndex: 'status',
  1709. render: (text, record) => {
  1710. const testResult = modelTestResults[`${currentTestChannel.id}-${record.model}`];
  1711. const isTesting = testingModels.has(record.model);
  1712. if (isTesting) {
  1713. return (
  1714. <Tag size='large' color='blue' className="!rounded-full">
  1715. {t('测试中')}
  1716. </Tag>
  1717. );
  1718. }
  1719. if (!testResult) {
  1720. return (
  1721. <Tag size='large' color='grey' className="!rounded-full">
  1722. {t('未开始')}
  1723. </Tag>
  1724. );
  1725. }
  1726. return (
  1727. <div className="flex items-center gap-2">
  1728. <Tag
  1729. size='large'
  1730. color={testResult.success ? 'green' : 'red'}
  1731. className="!rounded-full"
  1732. >
  1733. {testResult.success ? t('成功') : t('失败')}
  1734. </Tag>
  1735. {testResult.success && (
  1736. <Typography.Text type="tertiary">
  1737. {t('请求时长: ${time}s').replace('${time}', testResult.time.toFixed(2))}
  1738. </Typography.Text>
  1739. )}
  1740. </div>
  1741. );
  1742. }
  1743. },
  1744. {
  1745. title: '',
  1746. dataIndex: 'operate',
  1747. render: (text, record) => {
  1748. const isTesting = testingModels.has(record.model);
  1749. return (
  1750. <Button
  1751. theme='light'
  1752. type='primary'
  1753. className="!rounded-full"
  1754. onClick={() => testChannel(currentTestChannel, record.model)}
  1755. loading={isTesting}
  1756. size='small'
  1757. icon={<IconSmallTriangleRight />}
  1758. >
  1759. {t('测试')}
  1760. </Button>
  1761. );
  1762. }
  1763. }
  1764. ]}
  1765. dataSource={currentTestChannel.models
  1766. .split(',')
  1767. .filter((model) =>
  1768. model.toLowerCase().includes(modelSearchKeyword.toLowerCase())
  1769. )
  1770. .map((model) => ({
  1771. model,
  1772. key: model
  1773. }))}
  1774. pagination={false}
  1775. size="middle"
  1776. />
  1777. </div>
  1778. )}
  1779. </div>
  1780. </Modal>
  1781. </>
  1782. );
  1783. };
  1784. export default ChannelsTable;