UpstreamRatioSync.jsx 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869
  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, { useState, useCallback, useMemo, useEffect } from 'react';
  16. import {
  17. Button,
  18. Table,
  19. Tag,
  20. Empty,
  21. Checkbox,
  22. Form,
  23. Input,
  24. Tooltip,
  25. Select,
  26. Modal,
  27. } from '@douyinfe/semi-ui';
  28. import { IconSearch } from '@douyinfe/semi-icons';
  29. import {
  30. RefreshCcw,
  31. CheckSquare,
  32. AlertTriangle,
  33. CheckCircle,
  34. } from 'lucide-react';
  35. import {
  36. API,
  37. showError,
  38. showSuccess,
  39. showWarning,
  40. stringToColor,
  41. } from '../../../helpers';
  42. import { useIsMobile } from '../../../hooks/common/useIsMobile';
  43. import { DEFAULT_ENDPOINT } from '../../../constants';
  44. import { useTranslation } from 'react-i18next';
  45. import {
  46. IllustrationNoResult,
  47. IllustrationNoResultDark,
  48. } from '@douyinfe/semi-illustrations';
  49. import ChannelSelectorModal from '../../../components/settings/ChannelSelectorModal';
  50. function ConflictConfirmModal({ t, visible, items, onOk, onCancel }) {
  51. const isMobile = useIsMobile();
  52. const columns = [
  53. { title: t('渠道'), dataIndex: 'channel' },
  54. { title: t('模型'), dataIndex: 'model' },
  55. {
  56. title: t('当前计费'),
  57. dataIndex: 'current',
  58. render: (text) => <div style={{ whiteSpace: 'pre-wrap' }}>{text}</div>,
  59. },
  60. {
  61. title: t('修改为'),
  62. dataIndex: 'newVal',
  63. render: (text) => <div style={{ whiteSpace: 'pre-wrap' }}>{text}</div>,
  64. },
  65. ];
  66. return (
  67. <Modal
  68. title={t('确认冲突项修改')}
  69. visible={visible}
  70. onCancel={onCancel}
  71. onOk={onOk}
  72. size={isMobile ? 'full-width' : 'large'}
  73. >
  74. <Table
  75. columns={columns}
  76. dataSource={items}
  77. pagination={false}
  78. size='small'
  79. />
  80. </Modal>
  81. );
  82. }
  83. export default function UpstreamRatioSync(props) {
  84. const { t } = useTranslation();
  85. const [modalVisible, setModalVisible] = useState(false);
  86. const [loading, setLoading] = useState(false);
  87. const [syncLoading, setSyncLoading] = useState(false);
  88. const isMobile = useIsMobile();
  89. // 渠道选择相关
  90. const [allChannels, setAllChannels] = useState([]);
  91. const [selectedChannelIds, setSelectedChannelIds] = useState([]);
  92. // 渠道端点配置
  93. const [channelEndpoints, setChannelEndpoints] = useState({}); // { channelId: endpoint }
  94. // 差异数据和测试结果
  95. const [differences, setDifferences] = useState({});
  96. const [resolutions, setResolutions] = useState({});
  97. // 是否已经执行过同步
  98. const [hasSynced, setHasSynced] = useState(false);
  99. // 分页相关状态
  100. const [currentPage, setCurrentPage] = useState(1);
  101. const [pageSize, setPageSize] = useState(10);
  102. // 搜索相关状态
  103. const [searchKeyword, setSearchKeyword] = useState('');
  104. // 倍率类型过滤
  105. const [ratioTypeFilter, setRatioTypeFilter] = useState('');
  106. // 冲突确认弹窗相关
  107. const [confirmVisible, setConfirmVisible] = useState(false);
  108. const [conflictItems, setConflictItems] = useState([]); // {channel, model, current, newVal, ratioType}
  109. const channelSelectorRef = React.useRef(null);
  110. useEffect(() => {
  111. setCurrentPage(1);
  112. }, [ratioTypeFilter, searchKeyword]);
  113. const fetchAllChannels = async () => {
  114. setLoading(true);
  115. try {
  116. const res = await API.get('/api/ratio_sync/channels');
  117. if (res.data.success) {
  118. const channels = res.data.data || [];
  119. const transferData = channels.map((channel) => ({
  120. key: channel.id,
  121. label: channel.name,
  122. value: channel.id,
  123. disabled: false,
  124. _originalData: channel,
  125. }));
  126. setAllChannels(transferData);
  127. // 合并已有 endpoints,避免每次打开弹窗都重置
  128. setChannelEndpoints((prev) => {
  129. const merged = { ...prev };
  130. transferData.forEach((channel) => {
  131. const id = channel.key;
  132. const base = channel._originalData?.base_url || '';
  133. const name = channel.label || '';
  134. const isOfficial =
  135. id === -100 ||
  136. base === 'https://basellm.github.io' ||
  137. name === '官方倍率预设';
  138. if (!merged[id]) {
  139. merged[id] = isOfficial
  140. ? '/llm-metadata/api/newapi/ratio_config-v1-base.json'
  141. : DEFAULT_ENDPOINT;
  142. }
  143. });
  144. return merged;
  145. });
  146. } else {
  147. showError(res.data.message);
  148. }
  149. } catch (error) {
  150. showError(t('获取渠道失败:') + error.message);
  151. } finally {
  152. setLoading(false);
  153. }
  154. };
  155. const confirmChannelSelection = () => {
  156. const selected = allChannels
  157. .filter((ch) => selectedChannelIds.includes(ch.value))
  158. .map((ch) => ch._originalData);
  159. if (selected.length === 0) {
  160. showWarning(t('请至少选择一个渠道'));
  161. return;
  162. }
  163. setModalVisible(false);
  164. fetchRatiosFromChannels(selected);
  165. };
  166. const fetchRatiosFromChannels = async (channelList) => {
  167. setSyncLoading(true);
  168. const upstreams = channelList.map((ch) => ({
  169. id: ch.id,
  170. name: ch.name,
  171. base_url: ch.base_url,
  172. endpoint: channelEndpoints[ch.id] || DEFAULT_ENDPOINT,
  173. }));
  174. const payload = {
  175. upstreams: upstreams,
  176. timeout: 10,
  177. };
  178. try {
  179. const res = await API.post('/api/ratio_sync/fetch', payload);
  180. if (!res.data.success) {
  181. showError(res.data.message || t('后端请求失败'));
  182. setSyncLoading(false);
  183. return;
  184. }
  185. const { differences = {}, test_results = [] } = res.data.data;
  186. const errorResults = test_results.filter((r) => r.status === 'error');
  187. if (errorResults.length > 0) {
  188. showWarning(
  189. t('部分渠道测试失败:') +
  190. errorResults.map((r) => `${r.name}: ${r.error}`).join(', '),
  191. );
  192. }
  193. setDifferences(differences);
  194. setResolutions({});
  195. setHasSynced(true);
  196. if (Object.keys(differences).length === 0) {
  197. showSuccess(t('未找到差异化倍率,无需同步'));
  198. }
  199. } catch (e) {
  200. showError(t('请求后端接口失败:') + e.message);
  201. } finally {
  202. setSyncLoading(false);
  203. }
  204. };
  205. function getBillingCategory(ratioType) {
  206. return ratioType === 'model_price' ? 'price' : 'ratio';
  207. }
  208. const selectValue = useCallback(
  209. (model, ratioType, value) => {
  210. const category = getBillingCategory(ratioType);
  211. setResolutions((prev) => {
  212. const newModelRes = { ...(prev[model] || {}) };
  213. Object.keys(newModelRes).forEach((rt) => {
  214. if (getBillingCategory(rt) !== category) {
  215. delete newModelRes[rt];
  216. }
  217. });
  218. newModelRes[ratioType] = value;
  219. return {
  220. ...prev,
  221. [model]: newModelRes,
  222. };
  223. });
  224. },
  225. [setResolutions],
  226. );
  227. const applySync = async () => {
  228. const currentRatios = {
  229. ModelRatio: JSON.parse(props.options.ModelRatio || '{}'),
  230. CompletionRatio: JSON.parse(props.options.CompletionRatio || '{}'),
  231. CacheRatio: JSON.parse(props.options.CacheRatio || '{}'),
  232. ModelPrice: JSON.parse(props.options.ModelPrice || '{}'),
  233. };
  234. const conflicts = [];
  235. const getLocalBillingCategory = (model) => {
  236. if (currentRatios.ModelPrice[model] !== undefined) return 'price';
  237. if (
  238. currentRatios.ModelRatio[model] !== undefined ||
  239. currentRatios.CompletionRatio[model] !== undefined ||
  240. currentRatios.CacheRatio[model] !== undefined
  241. )
  242. return 'ratio';
  243. return null;
  244. };
  245. const findSourceChannel = (model, ratioType, value) => {
  246. if (differences[model] && differences[model][ratioType]) {
  247. const upMap = differences[model][ratioType].upstreams || {};
  248. const entry = Object.entries(upMap).find(([_, v]) => v === value);
  249. if (entry) return entry[0];
  250. }
  251. return t('未知');
  252. };
  253. Object.entries(resolutions).forEach(([model, ratios]) => {
  254. const localCat = getLocalBillingCategory(model);
  255. const newCat = 'model_price' in ratios ? 'price' : 'ratio';
  256. if (localCat && localCat !== newCat) {
  257. const currentDesc =
  258. localCat === 'price'
  259. ? `${t('固定价格')} : ${currentRatios.ModelPrice[model]}`
  260. : `${t('模型倍率')} : ${currentRatios.ModelRatio[model] ?? '-'}\n${t('补全倍率')} : ${currentRatios.CompletionRatio[model] ?? '-'}`;
  261. let newDesc = '';
  262. if (newCat === 'price') {
  263. newDesc = `${t('固定价格')} : ${ratios['model_price']}`;
  264. } else {
  265. const newModelRatio = ratios['model_ratio'] ?? '-';
  266. const newCompRatio = ratios['completion_ratio'] ?? '-';
  267. newDesc = `${t('模型倍率')} : ${newModelRatio}\n${t('补全倍率')} : ${newCompRatio}`;
  268. }
  269. const channels = Object.entries(ratios)
  270. .map(([rt, val]) => findSourceChannel(model, rt, val))
  271. .filter((v, idx, arr) => arr.indexOf(v) === idx)
  272. .join(', ');
  273. conflicts.push({
  274. channel: channels,
  275. model,
  276. current: currentDesc,
  277. newVal: newDesc,
  278. });
  279. }
  280. });
  281. if (conflicts.length > 0) {
  282. setConflictItems(conflicts);
  283. setConfirmVisible(true);
  284. return;
  285. }
  286. await performSync(currentRatios);
  287. };
  288. const performSync = useCallback(
  289. async (currentRatios) => {
  290. const finalRatios = {
  291. ModelRatio: { ...currentRatios.ModelRatio },
  292. CompletionRatio: { ...currentRatios.CompletionRatio },
  293. CacheRatio: { ...currentRatios.CacheRatio },
  294. ModelPrice: { ...currentRatios.ModelPrice },
  295. };
  296. Object.entries(resolutions).forEach(([model, ratios]) => {
  297. const selectedTypes = Object.keys(ratios);
  298. const hasPrice = selectedTypes.includes('model_price');
  299. const hasRatio = selectedTypes.some((rt) => rt !== 'model_price');
  300. if (hasPrice) {
  301. delete finalRatios.ModelRatio[model];
  302. delete finalRatios.CompletionRatio[model];
  303. delete finalRatios.CacheRatio[model];
  304. }
  305. if (hasRatio) {
  306. delete finalRatios.ModelPrice[model];
  307. }
  308. Object.entries(ratios).forEach(([ratioType, value]) => {
  309. const optionKey = ratioType
  310. .split('_')
  311. .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
  312. .join('');
  313. finalRatios[optionKey][model] = parseFloat(value);
  314. });
  315. });
  316. setLoading(true);
  317. try {
  318. const updates = Object.entries(finalRatios).map(([key, value]) =>
  319. API.put('/api/option/', {
  320. key,
  321. value: JSON.stringify(value, null, 2),
  322. }),
  323. );
  324. const results = await Promise.all(updates);
  325. if (results.every((res) => res.data.success)) {
  326. showSuccess(t('同步成功'));
  327. props.refresh();
  328. setDifferences((prevDifferences) => {
  329. const newDifferences = { ...prevDifferences };
  330. Object.entries(resolutions).forEach(([model, ratios]) => {
  331. Object.keys(ratios).forEach((ratioType) => {
  332. if (newDifferences[model] && newDifferences[model][ratioType]) {
  333. delete newDifferences[model][ratioType];
  334. if (Object.keys(newDifferences[model]).length === 0) {
  335. delete newDifferences[model];
  336. }
  337. }
  338. });
  339. });
  340. return newDifferences;
  341. });
  342. setResolutions({});
  343. } else {
  344. showError(t('部分保存失败'));
  345. }
  346. } catch (error) {
  347. showError(t('保存失败'));
  348. } finally {
  349. setLoading(false);
  350. }
  351. },
  352. [resolutions, props.options, props.refresh],
  353. );
  354. const getCurrentPageData = (dataSource) => {
  355. const startIndex = (currentPage - 1) * pageSize;
  356. const endIndex = startIndex + pageSize;
  357. return dataSource.slice(startIndex, endIndex);
  358. };
  359. const renderHeader = () => (
  360. <div className='flex flex-col w-full'>
  361. <div className='flex flex-col md:flex-row justify-between items-center gap-4 w-full'>
  362. <div className='flex flex-col md:flex-row gap-2 w-full md:w-auto order-2 md:order-1'>
  363. <Button
  364. icon={<RefreshCcw size={14} />}
  365. className='w-full md:w-auto mt-2'
  366. onClick={() => {
  367. setModalVisible(true);
  368. if (allChannels.length === 0) {
  369. fetchAllChannels();
  370. }
  371. }}
  372. >
  373. {t('选择同步渠道')}
  374. </Button>
  375. {(() => {
  376. const hasSelections = Object.keys(resolutions).length > 0;
  377. return (
  378. <Button
  379. icon={<CheckSquare size={14} />}
  380. type='secondary'
  381. onClick={applySync}
  382. disabled={!hasSelections}
  383. className='w-full md:w-auto mt-2'
  384. >
  385. {t('应用同步')}
  386. </Button>
  387. );
  388. })()}
  389. <div className='flex flex-col sm:flex-row gap-2 w-full md:w-auto mt-2'>
  390. <Input
  391. prefix={<IconSearch size={14} />}
  392. placeholder={t('搜索模型名称')}
  393. value={searchKeyword}
  394. onChange={setSearchKeyword}
  395. className='w-full sm:w-64'
  396. showClear
  397. />
  398. <Select
  399. placeholder={t('按倍率类型筛选')}
  400. value={ratioTypeFilter}
  401. onChange={setRatioTypeFilter}
  402. className='w-full sm:w-48'
  403. showClear
  404. onClear={() => setRatioTypeFilter('')}
  405. >
  406. <Select.Option value='model_ratio'>{t('模型倍率')}</Select.Option>
  407. <Select.Option value='completion_ratio'>
  408. {t('补全倍率')}
  409. </Select.Option>
  410. <Select.Option value='cache_ratio'>{t('缓存倍率')}</Select.Option>
  411. <Select.Option value='model_price'>{t('固定价格')}</Select.Option>
  412. </Select>
  413. </div>
  414. </div>
  415. </div>
  416. </div>
  417. );
  418. const renderDifferenceTable = () => {
  419. const dataSource = useMemo(() => {
  420. const tmp = [];
  421. Object.entries(differences).forEach(([model, ratioTypes]) => {
  422. const hasPrice = 'model_price' in ratioTypes;
  423. const hasOtherRatio = [
  424. 'model_ratio',
  425. 'completion_ratio',
  426. 'cache_ratio',
  427. ].some((rt) => rt in ratioTypes);
  428. const billingConflict = hasPrice && hasOtherRatio;
  429. Object.entries(ratioTypes).forEach(([ratioType, diff]) => {
  430. tmp.push({
  431. key: `${model}_${ratioType}`,
  432. model,
  433. ratioType,
  434. current: diff.current,
  435. upstreams: diff.upstreams,
  436. confidence: diff.confidence || {},
  437. billingConflict,
  438. });
  439. });
  440. });
  441. return tmp;
  442. }, [differences]);
  443. const filteredDataSource = useMemo(() => {
  444. if (!searchKeyword.trim() && !ratioTypeFilter) {
  445. return dataSource;
  446. }
  447. return dataSource.filter((item) => {
  448. const matchesKeyword =
  449. !searchKeyword.trim() ||
  450. item.model.toLowerCase().includes(searchKeyword.toLowerCase().trim());
  451. const matchesRatioType =
  452. !ratioTypeFilter || item.ratioType === ratioTypeFilter;
  453. return matchesKeyword && matchesRatioType;
  454. });
  455. }, [dataSource, searchKeyword, ratioTypeFilter]);
  456. const upstreamNames = useMemo(() => {
  457. const set = new Set();
  458. filteredDataSource.forEach((row) => {
  459. Object.keys(row.upstreams || {}).forEach((name) => set.add(name));
  460. });
  461. return Array.from(set);
  462. }, [filteredDataSource]);
  463. if (filteredDataSource.length === 0) {
  464. return (
  465. <Empty
  466. image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
  467. darkModeImage={
  468. <IllustrationNoResultDark style={{ width: 150, height: 150 }} />
  469. }
  470. description={
  471. searchKeyword.trim()
  472. ? t('未找到匹配的模型')
  473. : Object.keys(differences).length === 0
  474. ? hasSynced
  475. ? t('暂无差异化倍率显示')
  476. : t('请先选择同步渠道')
  477. : t('请先选择同步渠道')
  478. }
  479. style={{ padding: 30 }}
  480. />
  481. );
  482. }
  483. const columns = [
  484. {
  485. title: t('模型'),
  486. dataIndex: 'model',
  487. fixed: 'left',
  488. },
  489. {
  490. title: t('倍率类型'),
  491. dataIndex: 'ratioType',
  492. render: (text, record) => {
  493. const typeMap = {
  494. model_ratio: t('模型倍率'),
  495. completion_ratio: t('补全倍率'),
  496. cache_ratio: t('缓存倍率'),
  497. model_price: t('固定价格'),
  498. };
  499. const baseTag = (
  500. <Tag color={stringToColor(text)} shape='circle'>
  501. {typeMap[text] || text}
  502. </Tag>
  503. );
  504. if (record?.billingConflict) {
  505. return (
  506. <div className='flex items-center gap-1'>
  507. {baseTag}
  508. <Tooltip
  509. position='top'
  510. content={t(
  511. '该模型存在固定价格与倍率计费方式冲突,请确认选择',
  512. )}
  513. >
  514. <AlertTriangle size={14} className='text-yellow-500' />
  515. </Tooltip>
  516. </div>
  517. );
  518. }
  519. return baseTag;
  520. },
  521. },
  522. {
  523. title: t('置信度'),
  524. dataIndex: 'confidence',
  525. render: (_, record) => {
  526. const allConfident = Object.values(record.confidence || {}).every(
  527. (v) => v !== false,
  528. );
  529. if (allConfident) {
  530. return (
  531. <Tooltip content={t('所有上游数据均可信')}>
  532. <Tag
  533. color='green'
  534. shape='circle'
  535. type='light'
  536. prefixIcon={<CheckCircle size={14} />}
  537. >
  538. {t('可信')}
  539. </Tag>
  540. </Tooltip>
  541. );
  542. } else {
  543. const untrustedSources = Object.entries(record.confidence || {})
  544. .filter(([_, isConfident]) => isConfident === false)
  545. .map(([name]) => name)
  546. .join(', ');
  547. return (
  548. <Tooltip
  549. content={t('以下上游数据可能不可信:') + untrustedSources}
  550. >
  551. <Tag
  552. color='yellow'
  553. shape='circle'
  554. type='light'
  555. prefixIcon={<AlertTriangle size={14} />}
  556. >
  557. {t('谨慎')}
  558. </Tag>
  559. </Tooltip>
  560. );
  561. }
  562. },
  563. },
  564. {
  565. title: t('当前值'),
  566. dataIndex: 'current',
  567. render: (text) => (
  568. <Tag
  569. color={text !== null && text !== undefined ? 'blue' : 'default'}
  570. shape='circle'
  571. >
  572. {text !== null && text !== undefined ? text : t('未设置')}
  573. </Tag>
  574. ),
  575. },
  576. ...upstreamNames.map((upName) => {
  577. const channelStats = (() => {
  578. let selectableCount = 0;
  579. let selectedCount = 0;
  580. filteredDataSource.forEach((row) => {
  581. const upstreamVal = row.upstreams?.[upName];
  582. if (
  583. upstreamVal !== null &&
  584. upstreamVal !== undefined &&
  585. upstreamVal !== 'same'
  586. ) {
  587. selectableCount++;
  588. const isSelected =
  589. resolutions[row.model]?.[row.ratioType] === upstreamVal;
  590. if (isSelected) {
  591. selectedCount++;
  592. }
  593. }
  594. });
  595. return {
  596. selectableCount,
  597. selectedCount,
  598. allSelected:
  599. selectableCount > 0 && selectedCount === selectableCount,
  600. partiallySelected:
  601. selectedCount > 0 && selectedCount < selectableCount,
  602. hasSelectableItems: selectableCount > 0,
  603. };
  604. })();
  605. const handleBulkSelect = (checked) => {
  606. if (checked) {
  607. filteredDataSource.forEach((row) => {
  608. const upstreamVal = row.upstreams?.[upName];
  609. if (
  610. upstreamVal !== null &&
  611. upstreamVal !== undefined &&
  612. upstreamVal !== 'same'
  613. ) {
  614. selectValue(row.model, row.ratioType, upstreamVal);
  615. }
  616. });
  617. } else {
  618. setResolutions((prev) => {
  619. const newRes = { ...prev };
  620. filteredDataSource.forEach((row) => {
  621. if (newRes[row.model]) {
  622. delete newRes[row.model][row.ratioType];
  623. if (Object.keys(newRes[row.model]).length === 0) {
  624. delete newRes[row.model];
  625. }
  626. }
  627. });
  628. return newRes;
  629. });
  630. }
  631. };
  632. return {
  633. title: channelStats.hasSelectableItems ? (
  634. <Checkbox
  635. checked={channelStats.allSelected}
  636. indeterminate={channelStats.partiallySelected}
  637. onChange={(e) => handleBulkSelect(e.target.checked)}
  638. >
  639. {upName}
  640. </Checkbox>
  641. ) : (
  642. <span>{upName}</span>
  643. ),
  644. dataIndex: upName,
  645. render: (_, record) => {
  646. const upstreamVal = record.upstreams?.[upName];
  647. const isConfident = record.confidence?.[upName] !== false;
  648. if (upstreamVal === null || upstreamVal === undefined) {
  649. return (
  650. <Tag color='default' shape='circle'>
  651. {t('未设置')}
  652. </Tag>
  653. );
  654. }
  655. if (upstreamVal === 'same') {
  656. return (
  657. <Tag color='blue' shape='circle'>
  658. {t('与本地相同')}
  659. </Tag>
  660. );
  661. }
  662. const isSelected =
  663. resolutions[record.model]?.[record.ratioType] === upstreamVal;
  664. return (
  665. <div className='flex items-center gap-2'>
  666. <Checkbox
  667. checked={isSelected}
  668. onChange={(e) => {
  669. const isChecked = e.target.checked;
  670. if (isChecked) {
  671. selectValue(record.model, record.ratioType, upstreamVal);
  672. } else {
  673. setResolutions((prev) => {
  674. const newRes = { ...prev };
  675. if (newRes[record.model]) {
  676. delete newRes[record.model][record.ratioType];
  677. if (Object.keys(newRes[record.model]).length === 0) {
  678. delete newRes[record.model];
  679. }
  680. }
  681. return newRes;
  682. });
  683. }
  684. }}
  685. >
  686. {upstreamVal}
  687. </Checkbox>
  688. {!isConfident && (
  689. <Tooltip
  690. position='left'
  691. content={t('该数据可能不可信,请谨慎使用')}
  692. >
  693. <AlertTriangle size={16} className='text-yellow-500' />
  694. </Tooltip>
  695. )}
  696. </div>
  697. );
  698. },
  699. };
  700. }),
  701. ];
  702. return (
  703. <Table
  704. columns={columns}
  705. dataSource={getCurrentPageData(filteredDataSource)}
  706. pagination={{
  707. currentPage: currentPage,
  708. pageSize: pageSize,
  709. total: filteredDataSource.length,
  710. showSizeChanger: true,
  711. showQuickJumper: true,
  712. pageSizeOptions: ['5', '10', '20', '50'],
  713. onChange: (page, size) => {
  714. setCurrentPage(page);
  715. setPageSize(size);
  716. },
  717. onShowSizeChange: (current, size) => {
  718. setCurrentPage(1);
  719. setPageSize(size);
  720. },
  721. }}
  722. scroll={{ x: 'max-content' }}
  723. size='middle'
  724. loading={loading || syncLoading}
  725. />
  726. );
  727. };
  728. const updateChannelEndpoint = useCallback((channelId, endpoint) => {
  729. setChannelEndpoints((prev) => ({ ...prev, [channelId]: endpoint }));
  730. }, []);
  731. const handleModalClose = () => {
  732. setModalVisible(false);
  733. if (channelSelectorRef.current) {
  734. channelSelectorRef.current.resetPagination();
  735. }
  736. };
  737. return (
  738. <>
  739. <Form.Section text={renderHeader()}>
  740. {renderDifferenceTable()}
  741. </Form.Section>
  742. <ChannelSelectorModal
  743. ref={channelSelectorRef}
  744. t={t}
  745. visible={modalVisible}
  746. onCancel={handleModalClose}
  747. onOk={confirmChannelSelection}
  748. allChannels={allChannels}
  749. selectedChannelIds={selectedChannelIds}
  750. setSelectedChannelIds={setSelectedChannelIds}
  751. channelEndpoints={channelEndpoints}
  752. updateChannelEndpoint={updateChannelEndpoint}
  753. />
  754. <ConflictConfirmModal
  755. t={t}
  756. visible={confirmVisible}
  757. items={conflictItems}
  758. onOk={async () => {
  759. setConfirmVisible(false);
  760. const curRatios = {
  761. ModelRatio: JSON.parse(props.options.ModelRatio || '{}'),
  762. CompletionRatio: JSON.parse(props.options.CompletionRatio || '{}'),
  763. CacheRatio: JSON.parse(props.options.CacheRatio || '{}'),
  764. ModelPrice: JSON.parse(props.options.ModelPrice || '{}'),
  765. };
  766. await performSync(curRatios);
  767. }}
  768. onCancel={() => setConfirmVisible(false)}
  769. />
  770. </>
  771. );
  772. }