ChannelsTable.js 60 KB

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