ChannelSelectorModal.jsx 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300
  1. /*
  2. Copyright (C) 2025 QuantumNous
  3. This program is free software: you can redistribute it and/or modify
  4. it under the terms of the GNU Affero General Public License as
  5. published by the Free Software Foundation, either version 3 of the
  6. License, or (at your option) any later version.
  7. This program is distributed in the hope that it will be useful,
  8. but WITHOUT ANY WARRANTY; without even the implied warranty of
  9. MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  10. GNU Affero General Public License for more details.
  11. You should have received a copy of the GNU Affero General Public License
  12. along with this program. If not, see <https://www.gnu.org/licenses/>.
  13. For commercial licensing, please contact support@quantumnous.com
  14. */
  15. import React, {
  16. useState,
  17. useEffect,
  18. forwardRef,
  19. useImperativeHandle,
  20. } from 'react';
  21. import { useIsMobile } from '../../hooks/common/useIsMobile';
  22. import {
  23. Modal,
  24. Table,
  25. Input,
  26. Space,
  27. Highlight,
  28. Select,
  29. Tag,
  30. } from '@douyinfe/semi-ui';
  31. import { IconSearch } from '@douyinfe/semi-icons';
  32. const ChannelSelectorModal = forwardRef(
  33. (
  34. {
  35. visible,
  36. onCancel,
  37. onOk,
  38. allChannels,
  39. selectedChannelIds,
  40. setSelectedChannelIds,
  41. channelEndpoints,
  42. updateChannelEndpoint,
  43. t,
  44. },
  45. ref,
  46. ) => {
  47. const [searchText, setSearchText] = useState('');
  48. const [currentPage, setCurrentPage] = useState(1);
  49. const [pageSize, setPageSize] = useState(10);
  50. const isMobile = useIsMobile();
  51. const [filteredData, setFilteredData] = useState([]);
  52. useImperativeHandle(ref, () => ({
  53. resetPagination: () => {
  54. setCurrentPage(1);
  55. setSearchText('');
  56. },
  57. }));
  58. // 官方渠道识别
  59. const isOfficialChannel = (record) => {
  60. const id = record?.key ?? record?.value ?? record?._originalData?.id;
  61. const base = record?._originalData?.base_url || '';
  62. const name = record?.label || '';
  63. return (
  64. id === -100 ||
  65. base === 'https://basellm.github.io' ||
  66. name === '官方倍率预设'
  67. );
  68. };
  69. useEffect(() => {
  70. if (!allChannels) return;
  71. const searchLower = searchText.trim().toLowerCase();
  72. const matched = searchLower
  73. ? allChannels.filter((item) => {
  74. const name = (item.label || '').toLowerCase();
  75. const baseUrl = (item._originalData?.base_url || '').toLowerCase();
  76. return name.includes(searchLower) || baseUrl.includes(searchLower);
  77. })
  78. : allChannels;
  79. const sorted = [...matched].sort((a, b) => {
  80. const wa = isOfficialChannel(a) ? 0 : 1;
  81. const wb = isOfficialChannel(b) ? 0 : 1;
  82. return wa - wb;
  83. });
  84. setFilteredData(sorted);
  85. }, [allChannels, searchText]);
  86. const total = filteredData.length;
  87. const paginatedData = filteredData.slice(
  88. (currentPage - 1) * pageSize,
  89. currentPage * pageSize,
  90. );
  91. const updateEndpoint = (channelId, endpoint) => {
  92. if (typeof updateChannelEndpoint === 'function') {
  93. updateChannelEndpoint(channelId, endpoint);
  94. }
  95. };
  96. const renderEndpointCell = (text, record) => {
  97. const channelId = record.key || record.value;
  98. const currentEndpoint = channelEndpoints[channelId] || '';
  99. const getEndpointType = (ep) => {
  100. if (ep === '/api/ratio_config') return 'ratio_config';
  101. if (ep === '/api/pricing') return 'pricing';
  102. if (ep === 'openrouter') return 'openrouter';
  103. return 'custom';
  104. };
  105. const currentType = getEndpointType(currentEndpoint);
  106. const handleTypeChange = (val) => {
  107. if (val === 'ratio_config') {
  108. updateEndpoint(channelId, '/api/ratio_config');
  109. } else if (val === 'pricing') {
  110. updateEndpoint(channelId, '/api/pricing');
  111. } else if (val === 'openrouter') {
  112. updateEndpoint(channelId, 'openrouter');
  113. } else {
  114. if (currentType !== 'custom') {
  115. updateEndpoint(channelId, '');
  116. }
  117. }
  118. };
  119. return (
  120. <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
  121. <Select
  122. size='small'
  123. value={currentType}
  124. onChange={handleTypeChange}
  125. style={{ width: 120 }}
  126. optionList={[
  127. { label: 'ratio_config', value: 'ratio_config' },
  128. { label: 'pricing', value: 'pricing' },
  129. { label: 'OpenRouter', value: 'openrouter' },
  130. { label: 'custom', value: 'custom' },
  131. ]}
  132. />
  133. {currentType === 'custom' && (
  134. <Input
  135. size='small'
  136. value={currentEndpoint}
  137. onChange={(val) => updateEndpoint(channelId, val)}
  138. placeholder='/your/endpoint'
  139. style={{ width: 160, fontSize: 12 }}
  140. />
  141. )}
  142. </div>
  143. );
  144. };
  145. const renderStatusCell = (record) => {
  146. const status = record?._originalData?.status || 0;
  147. const official = isOfficialChannel(record);
  148. let statusTag = null;
  149. switch (status) {
  150. case 1:
  151. statusTag = (
  152. <Tag color='green' shape='circle'>
  153. {t('已启用')}
  154. </Tag>
  155. );
  156. break;
  157. case 2:
  158. statusTag = (
  159. <Tag color='red' shape='circle'>
  160. {t('已禁用')}
  161. </Tag>
  162. );
  163. break;
  164. case 3:
  165. statusTag = (
  166. <Tag color='yellow' shape='circle'>
  167. {t('自动禁用')}
  168. </Tag>
  169. );
  170. break;
  171. default:
  172. statusTag = (
  173. <Tag color='grey' shape='circle'>
  174. {t('未知状态')}
  175. </Tag>
  176. );
  177. }
  178. return (
  179. <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
  180. {statusTag}
  181. {official && (
  182. <Tag color='green' shape='circle' type='light'>
  183. {t('官方')}
  184. </Tag>
  185. )}
  186. </div>
  187. );
  188. };
  189. const renderNameCell = (text) => (
  190. <Highlight sourceString={text} searchWords={[searchText]} />
  191. );
  192. const renderBaseUrlCell = (text) => (
  193. <Highlight sourceString={text} searchWords={[searchText]} />
  194. );
  195. const columns = [
  196. {
  197. title: t('名称'),
  198. dataIndex: 'label',
  199. render: renderNameCell,
  200. },
  201. {
  202. title: t('源地址'),
  203. dataIndex: '_originalData.base_url',
  204. render: (_, record) =>
  205. renderBaseUrlCell(record._originalData?.base_url || ''),
  206. },
  207. {
  208. title: t('状态'),
  209. dataIndex: '_originalData.status',
  210. render: (_, record) => renderStatusCell(record),
  211. },
  212. {
  213. title: t('同步接口'),
  214. dataIndex: 'endpoint',
  215. fixed: 'right',
  216. render: renderEndpointCell,
  217. },
  218. ];
  219. const rowSelection = {
  220. selectedRowKeys: selectedChannelIds,
  221. onChange: (keys) => setSelectedChannelIds(keys),
  222. };
  223. return (
  224. <Modal
  225. visible={visible}
  226. onCancel={onCancel}
  227. onOk={onOk}
  228. title={
  229. <span className='text-lg font-semibold'>{t('选择同步渠道')}</span>
  230. }
  231. size={isMobile ? 'full-width' : 'large'}
  232. keepDOM
  233. lazyRender={false}
  234. >
  235. <Space vertical style={{ width: '100%' }}>
  236. <Input
  237. prefix={<IconSearch size={14} />}
  238. placeholder={t('搜索渠道名称或地址')}
  239. value={searchText}
  240. onChange={setSearchText}
  241. showClear
  242. />
  243. <Table
  244. columns={columns}
  245. dataSource={paginatedData}
  246. rowKey='key'
  247. rowSelection={rowSelection}
  248. pagination={{
  249. currentPage: currentPage,
  250. pageSize: pageSize,
  251. total: total,
  252. showSizeChanger: true,
  253. showQuickJumper: true,
  254. pageSizeOptions: ['10', '20', '50', '100'],
  255. onChange: (page, size) => {
  256. setCurrentPage(page);
  257. setPageSize(size);
  258. },
  259. onShowSizeChange: (curr, size) => {
  260. setCurrentPage(1);
  261. setPageSize(size);
  262. },
  263. }}
  264. size='small'
  265. />
  266. </Space>
  267. </Modal>
  268. );
  269. },
  270. );
  271. export default ChannelSelectorModal;