ChannelsTable.js 65 KB

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