diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 8c808d01..d7f87efd 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1728,5 +1728,9 @@ "自适应列表": "Adaptive list", "紧凑列表": "Compact list", "仅显示矛盾倍率": "Only show conflicting ratios", - "矛盾": "Conflict" + "矛盾": "Conflict", + "确认冲突项修改": "Confirm conflict item modification", + "该模型存在固定价格与倍率计费方式冲突,请确认选择": "The model has a fixed price and ratio billing method conflict, please confirm the selection", + "当前计费": "Current billing", + "修改为": "Modify to" } \ No newline at end of file diff --git a/web/src/pages/Setting/Ratio/UpstreamRatioSync.js b/web/src/pages/Setting/Ratio/UpstreamRatioSync.js index 2dd6a009..0794d606 100644 --- a/web/src/pages/Setting/Ratio/UpstreamRatioSync.js +++ b/web/src/pages/Setting/Ratio/UpstreamRatioSync.js @@ -9,6 +9,7 @@ import { Input, Tooltip, Select, + Modal, } from '@douyinfe/semi-ui'; import { IconSearch } from '@douyinfe/semi-icons'; import { @@ -17,7 +18,7 @@ import { AlertTriangle, CheckCircle, } from 'lucide-react'; -import { API, showError, showSuccess, showWarning, stringToColor } from '../../../helpers'; +import { API, showError, showSuccess, showWarning, stringToColor, isMobile } from '../../../helpers'; import { DEFAULT_ENDPOINT } from '../../../constants'; import { useTranslation } from 'react-i18next'; import { @@ -26,6 +27,35 @@ import { } from '@douyinfe/semi-illustrations'; import ChannelSelectorModal from '../../../components/settings/ChannelSelectorModal'; +function ConflictConfirmModal({ t, visible, items, onOk, onCancel }) { + const columns = [ + { title: t('渠道'), dataIndex: 'channel' }, + { title: t('模型'), dataIndex: 'model' }, + { + title: t('当前计费'), + dataIndex: 'current', + render: (text) =>
{text}
, + }, + { + title: t('修改为'), + dataIndex: 'newVal', + render: (text) =>
{text}
, + }, + ]; + + return ( + + + + ); +} + export default function UpstreamRatioSync(props) { const { t } = useTranslation(); const [modalVisible, setModalVisible] = useState(false); @@ -56,6 +86,10 @@ export default function UpstreamRatioSync(props) { // 倍率类型过滤 const [ratioTypeFilter, setRatioTypeFilter] = useState(''); + // 冲突确认弹窗相关 + const [confirmVisible, setConfirmVisible] = useState(false); + const [conflictItems, setConflictItems] = useState([]); // {channel, model, current, newVal, ratioType} + const channelSelectorRef = React.useRef(null); useEffect(() => { @@ -159,15 +193,30 @@ export default function UpstreamRatioSync(props) { } }; - const selectValue = (model, ratioType, value) => { - setResolutions(prev => ({ - ...prev, - [model]: { - ...prev[model], - [ratioType]: value, - }, - })); - }; + function getBillingCategory(ratioType) { + return ratioType === 'model_price' ? 'price' : 'ratio'; + } + + const selectValue = useCallback((model, ratioType, value) => { + const category = getBillingCategory(ratioType); + + setResolutions(prev => { + const newModelRes = { ...(prev[model] || {}) }; + + Object.keys(newModelRes).forEach((rt) => { + if (getBillingCategory(rt) !== category) { + delete newModelRes[rt]; + } + }); + + newModelRes[ratioType] = value; + + return { + ...prev, + [model]: newModelRes, + }; + }); + }, [setResolutions]); const applySync = async () => { const currentRatios = { @@ -177,19 +226,100 @@ export default function UpstreamRatioSync(props) { ModelPrice: JSON.parse(props.options.ModelPrice || '{}'), }; + const conflicts = []; + + const getLocalBillingCategory = (model) => { + if (currentRatios.ModelPrice[model] !== undefined) return 'price'; + if (currentRatios.ModelRatio[model] !== undefined || + currentRatios.CompletionRatio[model] !== undefined || + currentRatios.CacheRatio[model] !== undefined) return 'ratio'; + return null; + }; + + const findSourceChannel = (model, ratioType, value) => { + if (differences[model] && differences[model][ratioType]) { + const upMap = differences[model][ratioType].upstreams || {}; + const entry = Object.entries(upMap).find(([_, v]) => v === value); + if (entry) return entry[0]; + } + return t('未知'); + }; + Object.entries(resolutions).forEach(([model, ratios]) => { + const localCat = getLocalBillingCategory(model); + const newCat = 'model_price' in ratios ? 'price' : 'ratio'; + + if (localCat && localCat !== newCat) { + const currentDesc = localCat === 'price' + ? `${t('固定价格')} : ${currentRatios.ModelPrice[model]}` + : `${t('模型倍率')} : ${currentRatios.ModelRatio[model] ?? '-'}\n${t('补全倍率')} : ${currentRatios.CompletionRatio[model] ?? '-'}`; + + let newDesc = ''; + if (newCat === 'price') { + newDesc = `${t('固定价格')} : ${ratios['model_price']}`; + } else { + const newModelRatio = ratios['model_ratio'] ?? '-'; + const newCompRatio = ratios['completion_ratio'] ?? '-'; + newDesc = `${t('模型倍率')} : ${newModelRatio}\n${t('补全倍率')} : ${newCompRatio}`; + } + + const channels = Object.entries(ratios) + .map(([rt, val]) => findSourceChannel(model, rt, val)) + .filter((v, idx, arr) => arr.indexOf(v) === idx) + .join(', '); + + conflicts.push({ + channel: channels, + model, + current: currentDesc, + newVal: newDesc, + }); + } + }); + + if (conflicts.length > 0) { + setConflictItems(conflicts); + setConfirmVisible(true); + return; + } + + await performSync(currentRatios); + }; + + const performSync = useCallback(async (currentRatios) => { + const finalRatios = { + ModelRatio: { ...currentRatios.ModelRatio }, + CompletionRatio: { ...currentRatios.CompletionRatio }, + CacheRatio: { ...currentRatios.CacheRatio }, + ModelPrice: { ...currentRatios.ModelPrice }, + }; + + Object.entries(resolutions).forEach(([model, ratios]) => { + const selectedTypes = Object.keys(ratios); + const hasPrice = selectedTypes.includes('model_price'); + const hasRatio = selectedTypes.some(rt => rt !== 'model_price'); + + if (hasPrice) { + delete finalRatios.ModelRatio[model]; + delete finalRatios.CompletionRatio[model]; + delete finalRatios.CacheRatio[model]; + } + if (hasRatio) { + delete finalRatios.ModelPrice[model]; + } + Object.entries(ratios).forEach(([ratioType, value]) => { const optionKey = ratioType .split('_') .map(word => word.charAt(0).toUpperCase() + word.slice(1)) .join(''); - currentRatios[optionKey][model] = parseFloat(value); + finalRatios[optionKey][model] = parseFloat(value); }); }); setLoading(true); try { - const updates = Object.entries(currentRatios).map(([key, value]) => + const updates = Object.entries(finalRatios).map(([key, value]) => API.put('/api/option/', { key, value: JSON.stringify(value, null, 2), @@ -229,7 +359,7 @@ export default function UpstreamRatioSync(props) { } finally { setLoading(false); } - }; + }, [resolutions, props.options, props.refresh]); const getCurrentPageData = (dataSource) => { const startIndex = (currentPage - 1) * pageSize; @@ -304,6 +434,10 @@ export default function UpstreamRatioSync(props) { const tmp = []; Object.entries(differences).forEach(([model, ratioTypes]) => { + const hasPrice = 'model_price' in ratioTypes; + const hasOtherRatio = ['model_ratio', 'completion_ratio', 'cache_ratio'].some(rt => rt in ratioTypes); + const billingConflict = hasPrice && hasOtherRatio; + Object.entries(ratioTypes).forEach(([ratioType, diff]) => { tmp.push({ key: `${model}_${ratioType}`, @@ -312,6 +446,7 @@ export default function UpstreamRatioSync(props) { current: diff.current, upstreams: diff.upstreams, confidence: diff.confidence || {}, + billingConflict, }); }); }); @@ -369,14 +504,25 @@ export default function UpstreamRatioSync(props) { { title: t('倍率类型'), dataIndex: 'ratioType', - render: (text) => { + render: (text, record) => { const typeMap = { model_ratio: t('模型倍率'), completion_ratio: t('补全倍率'), cache_ratio: t('缓存倍率'), model_price: t('固定价格'), }; - return {typeMap[text] || text}; + const baseTag = {typeMap[text] || text}; + if (record?.billingConflict) { + return ( +
+ {baseTag} + + + +
+ ); + } + return baseTag; }, }, { @@ -444,28 +590,27 @@ export default function UpstreamRatioSync(props) { })(); const handleBulkSelect = (checked) => { - setResolutions((prev) => { - const newRes = { ...prev }; - + if (checked) { filteredDataSource.forEach((row) => { const upstreamVal = row.upstreams?.[upName]; if (upstreamVal !== null && upstreamVal !== undefined && upstreamVal !== 'same') { - if (checked) { - if (!newRes[row.model]) newRes[row.model] = {}; - newRes[row.model][row.ratioType] = upstreamVal; - } else { - if (newRes[row.model]) { - delete newRes[row.model][row.ratioType]; - if (Object.keys(newRes[row.model]).length === 0) { - delete newRes[row.model]; - } - } - } + selectValue(row.model, row.ratioType, upstreamVal); } }); - - return newRes; - }); + } else { + setResolutions((prev) => { + const newRes = { ...prev }; + filteredDataSource.forEach((row) => { + if (newRes[row.model]) { + delete newRes[row.model][row.ratioType]; + if (Object.keys(newRes[row.model]).length === 0) { + delete newRes[row.model]; + } + } + }); + return newRes; + }); + } }; return { @@ -593,6 +738,23 @@ export default function UpstreamRatioSync(props) { channelEndpoints={channelEndpoints} updateChannelEndpoint={updateChannelEndpoint} /> + + { + setConfirmVisible(false); + const curRatios = { + ModelRatio: JSON.parse(props.options.ModelRatio || '{}'), + CompletionRatio: JSON.parse(props.options.CompletionRatio || '{}'), + CacheRatio: JSON.parse(props.options.CacheRatio || '{}'), + ModelPrice: JSON.parse(props.options.ModelPrice || '{}'), + }; + await performSync(curRatios); + }} + onCancel={() => setConfirmVisible(false)} + /> ); } \ No newline at end of file