ChannelsTable.js 61 KB

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