ChannelSelectorModal.jsx 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310
  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 OFFICIAL_RATIO_PRESET_ID = -100;
  33. const MODELS_DEV_PRESET_ID = -101;
  34. const OFFICIAL_RATIO_PRESET_NAME = '官方倍率预设';
  35. const MODELS_DEV_PRESET_NAME = 'models.dev 价格预设';
  36. const OFFICIAL_RATIO_PRESET_BASE_URL = 'https://basellm.github.io';
  37. const MODELS_DEV_PRESET_BASE_URL = 'https://models.dev';
  38. const ChannelSelectorModal = forwardRef(
  39. (
  40. {
  41. visible,
  42. onCancel,
  43. onOk,
  44. allChannels,
  45. selectedChannelIds,
  46. setSelectedChannelIds,
  47. channelEndpoints,
  48. updateChannelEndpoint,
  49. t,
  50. },
  51. ref,
  52. ) => {
  53. const [searchText, setSearchText] = useState('');
  54. const [currentPage, setCurrentPage] = useState(1);
  55. const [pageSize, setPageSize] = useState(10);
  56. const isMobile = useIsMobile();
  57. const [filteredData, setFilteredData] = useState([]);
  58. useImperativeHandle(ref, () => ({
  59. resetPagination: () => {
  60. setCurrentPage(1);
  61. setSearchText('');
  62. },
  63. }));
  64. // 官方渠道识别
  65. const isOfficialChannel = (record) => {
  66. const id = record?.key ?? record?.value ?? record?._originalData?.id;
  67. const base = record?._originalData?.base_url || '';
  68. const name = record?.label || '';
  69. return (
  70. id === OFFICIAL_RATIO_PRESET_ID ||
  71. id === MODELS_DEV_PRESET_ID ||
  72. base === OFFICIAL_RATIO_PRESET_BASE_URL ||
  73. base === MODELS_DEV_PRESET_BASE_URL ||
  74. name === OFFICIAL_RATIO_PRESET_NAME ||
  75. name === MODELS_DEV_PRESET_NAME
  76. );
  77. };
  78. useEffect(() => {
  79. if (!allChannels) return;
  80. const searchLower = searchText.trim().toLowerCase();
  81. const matched = searchLower
  82. ? allChannels.filter((item) => {
  83. const name = (item.label || '').toLowerCase();
  84. const baseUrl = (item._originalData?.base_url || '').toLowerCase();
  85. return name.includes(searchLower) || baseUrl.includes(searchLower);
  86. })
  87. : allChannels;
  88. const sorted = [...matched].sort((a, b) => {
  89. const wa = isOfficialChannel(a) ? 0 : 1;
  90. const wb = isOfficialChannel(b) ? 0 : 1;
  91. return wa - wb;
  92. });
  93. setFilteredData(sorted);
  94. }, [allChannels, searchText]);
  95. const total = filteredData.length;
  96. const paginatedData = filteredData.slice(
  97. (currentPage - 1) * pageSize,
  98. currentPage * pageSize,
  99. );
  100. const updateEndpoint = (channelId, endpoint) => {
  101. if (typeof updateChannelEndpoint === 'function') {
  102. updateChannelEndpoint(channelId, endpoint);
  103. }
  104. };
  105. const renderEndpointCell = (text, record) => {
  106. const channelId = record.key || record.value;
  107. const currentEndpoint = channelEndpoints[channelId] || '';
  108. const getEndpointType = (ep) => {
  109. if (ep === '/api/ratio_config') return 'ratio_config';
  110. if (ep === '/api/pricing') return 'pricing';
  111. if (ep === 'openrouter') return 'openrouter';
  112. return 'custom';
  113. };
  114. const currentType = getEndpointType(currentEndpoint);
  115. const handleTypeChange = (val) => {
  116. if (val === 'ratio_config') {
  117. updateEndpoint(channelId, '/api/ratio_config');
  118. } else if (val === 'pricing') {
  119. updateEndpoint(channelId, '/api/pricing');
  120. } else if (val === 'openrouter') {
  121. updateEndpoint(channelId, 'openrouter');
  122. } else {
  123. if (currentType !== 'custom') {
  124. updateEndpoint(channelId, '');
  125. }
  126. }
  127. };
  128. return (
  129. <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
  130. <Select
  131. size='small'
  132. value={currentType}
  133. onChange={handleTypeChange}
  134. style={{ width: 120 }}
  135. optionList={[
  136. { label: 'ratio_config', value: 'ratio_config' },
  137. { label: 'pricing', value: 'pricing' },
  138. { label: 'OpenRouter', value: 'openrouter' },
  139. { label: 'custom', value: 'custom' },
  140. ]}
  141. />
  142. {currentType === 'custom' && (
  143. <Input
  144. size='small'
  145. value={currentEndpoint}
  146. onChange={(val) => updateEndpoint(channelId, val)}
  147. placeholder='/your/endpoint'
  148. style={{ width: 160, fontSize: 12 }}
  149. />
  150. )}
  151. </div>
  152. );
  153. };
  154. const renderStatusCell = (record) => {
  155. const status = record?._originalData?.status || 0;
  156. const official = isOfficialChannel(record);
  157. let statusTag = null;
  158. switch (status) {
  159. case 1:
  160. statusTag = (
  161. <Tag color='green' shape='circle'>
  162. {t('已启用')}
  163. </Tag>
  164. );
  165. break;
  166. case 2:
  167. statusTag = (
  168. <Tag color='red' shape='circle'>
  169. {t('已禁用')}
  170. </Tag>
  171. );
  172. break;
  173. case 3:
  174. statusTag = (
  175. <Tag color='yellow' shape='circle'>
  176. {t('自动禁用')}
  177. </Tag>
  178. );
  179. break;
  180. default:
  181. statusTag = (
  182. <Tag color='grey' shape='circle'>
  183. {t('未知状态')}
  184. </Tag>
  185. );
  186. }
  187. return (
  188. <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
  189. {statusTag}
  190. {official && (
  191. <Tag color='green' shape='circle' type='light'>
  192. {t('官方')}
  193. </Tag>
  194. )}
  195. </div>
  196. );
  197. };
  198. const renderNameCell = (text) => (
  199. <Highlight sourceString={text} searchWords={[searchText]} />
  200. );
  201. const renderBaseUrlCell = (text) => (
  202. <Highlight sourceString={text} searchWords={[searchText]} />
  203. );
  204. const columns = [
  205. {
  206. title: t('名称'),
  207. dataIndex: 'label',
  208. render: renderNameCell,
  209. },
  210. {
  211. title: t('源地址'),
  212. dataIndex: '_originalData.base_url',
  213. render: (_, record) =>
  214. renderBaseUrlCell(record._originalData?.base_url || ''),
  215. },
  216. {
  217. title: t('状态'),
  218. dataIndex: '_originalData.status',
  219. render: (_, record) => renderStatusCell(record),
  220. },
  221. {
  222. title: t('同步接口'),
  223. dataIndex: 'endpoint',
  224. fixed: 'right',
  225. render: renderEndpointCell,
  226. },
  227. ];
  228. const rowSelection = {
  229. selectedRowKeys: selectedChannelIds,
  230. onChange: (keys) => setSelectedChannelIds(keys),
  231. };
  232. return (
  233. <Modal
  234. visible={visible}
  235. onCancel={onCancel}
  236. onOk={onOk}
  237. title={
  238. <span className='text-lg font-semibold'>{t('选择同步渠道')}</span>
  239. }
  240. size={isMobile ? 'full-width' : 'large'}
  241. keepDOM
  242. lazyRender={false}
  243. >
  244. <Space vertical style={{ width: '100%' }}>
  245. <Input
  246. prefix={<IconSearch size={14} />}
  247. placeholder={t('搜索渠道名称或地址')}
  248. value={searchText}
  249. onChange={setSearchText}
  250. showClear
  251. />
  252. <Table
  253. columns={columns}
  254. dataSource={paginatedData}
  255. rowKey='key'
  256. rowSelection={rowSelection}
  257. pagination={{
  258. currentPage: currentPage,
  259. pageSize: pageSize,
  260. total: total,
  261. showSizeChanger: true,
  262. showQuickJumper: true,
  263. pageSizeOptions: ['10', '20', '50', '100'],
  264. onChange: (page, size) => {
  265. setCurrentPage(page);
  266. setPageSize(size);
  267. },
  268. onShowSizeChange: (curr, size) => {
  269. setCurrentPage(1);
  270. setPageSize(size);
  271. },
  272. }}
  273. size='small'
  274. />
  275. </Space>
  276. </Modal>
  277. );
  278. },
  279. );
  280. export default ChannelSelectorModal;