UpstreamRatioSync.js 24 KB

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