🚀 chore(ui): Refactor UpstreamRatioSync with conflict-modal component, performance hooks & cleanup (#1286)
WHAT’S NEW • Extracted reusable ConflictConfirmModal for clearer JSX hierarchy • Added detailed conflict detection & confirmation flow before syncing options • Refactored state-heavy callbacks (`selectValue`, `performSync`) with `useCallback` to avoid unnecessary renders • Introduced build-time constants (later removed unused export) and unified helper utilities • Ensured final ratios are rebuilt accurately before API `PUT`, fixing “value not updated” bug • Enhanced UI hints: warning icon on conflict, multiline billing info, mobile-friendly modal size • General code cleanup: removed dead variables, adopted early returns, improved comments WHY Improves maintainability, user clarity when billing-type collisions occur, and guarantees data consistency after synchronisation.
This commit is contained in:
@@ -1728,5 +1728,9 @@
|
|||||||
"自适应列表": "Adaptive list",
|
"自适应列表": "Adaptive list",
|
||||||
"紧凑列表": "Compact list",
|
"紧凑列表": "Compact list",
|
||||||
"仅显示矛盾倍率": "Only show conflicting ratios",
|
"仅显示矛盾倍率": "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"
|
||||||
}
|
}
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Select,
|
Select,
|
||||||
|
Modal,
|
||||||
} from '@douyinfe/semi-ui';
|
} from '@douyinfe/semi-ui';
|
||||||
import { IconSearch } from '@douyinfe/semi-icons';
|
import { IconSearch } from '@douyinfe/semi-icons';
|
||||||
import {
|
import {
|
||||||
@@ -17,7 +18,7 @@ import {
|
|||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
} from 'lucide-react';
|
} 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 { DEFAULT_ENDPOINT } from '../../../constants';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
@@ -26,6 +27,35 @@ import {
|
|||||||
} from '@douyinfe/semi-illustrations';
|
} from '@douyinfe/semi-illustrations';
|
||||||
import ChannelSelectorModal from '../../../components/settings/ChannelSelectorModal';
|
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) => <div style={{ whiteSpace: 'pre-wrap' }}>{text}</div>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('修改为'),
|
||||||
|
dataIndex: 'newVal',
|
||||||
|
render: (text) => <div style={{ whiteSpace: 'pre-wrap' }}>{text}</div>,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={t('确认冲突项修改')}
|
||||||
|
visible={visible}
|
||||||
|
onCancel={onCancel}
|
||||||
|
onOk={onOk}
|
||||||
|
size={isMobile() ? 'full-width' : 'large'}
|
||||||
|
>
|
||||||
|
<Table columns={columns} dataSource={items} pagination={false} size="small" />
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function UpstreamRatioSync(props) {
|
export default function UpstreamRatioSync(props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [modalVisible, setModalVisible] = useState(false);
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
@@ -56,6 +86,10 @@ export default function UpstreamRatioSync(props) {
|
|||||||
// 倍率类型过滤
|
// 倍率类型过滤
|
||||||
const [ratioTypeFilter, setRatioTypeFilter] = useState('');
|
const [ratioTypeFilter, setRatioTypeFilter] = useState('');
|
||||||
|
|
||||||
|
// 冲突确认弹窗相关
|
||||||
|
const [confirmVisible, setConfirmVisible] = useState(false);
|
||||||
|
const [conflictItems, setConflictItems] = useState([]); // {channel, model, current, newVal, ratioType}
|
||||||
|
|
||||||
const channelSelectorRef = React.useRef(null);
|
const channelSelectorRef = React.useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -159,15 +193,30 @@ export default function UpstreamRatioSync(props) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectValue = (model, ratioType, value) => {
|
function getBillingCategory(ratioType) {
|
||||||
setResolutions(prev => ({
|
return ratioType === 'model_price' ? 'price' : 'ratio';
|
||||||
...prev,
|
}
|
||||||
[model]: {
|
|
||||||
...prev[model],
|
const selectValue = useCallback((model, ratioType, value) => {
|
||||||
[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 applySync = async () => {
|
||||||
const currentRatios = {
|
const currentRatios = {
|
||||||
@@ -177,19 +226,100 @@ export default function UpstreamRatioSync(props) {
|
|||||||
ModelPrice: JSON.parse(props.options.ModelPrice || '{}'),
|
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]) => {
|
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]) => {
|
Object.entries(ratios).forEach(([ratioType, value]) => {
|
||||||
const optionKey = ratioType
|
const optionKey = ratioType
|
||||||
.split('_')
|
.split('_')
|
||||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||||
.join('');
|
.join('');
|
||||||
currentRatios[optionKey][model] = parseFloat(value);
|
finalRatios[optionKey][model] = parseFloat(value);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const updates = Object.entries(currentRatios).map(([key, value]) =>
|
const updates = Object.entries(finalRatios).map(([key, value]) =>
|
||||||
API.put('/api/option/', {
|
API.put('/api/option/', {
|
||||||
key,
|
key,
|
||||||
value: JSON.stringify(value, null, 2),
|
value: JSON.stringify(value, null, 2),
|
||||||
@@ -229,7 +359,7 @@ export default function UpstreamRatioSync(props) {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [resolutions, props.options, props.refresh]);
|
||||||
|
|
||||||
const getCurrentPageData = (dataSource) => {
|
const getCurrentPageData = (dataSource) => {
|
||||||
const startIndex = (currentPage - 1) * pageSize;
|
const startIndex = (currentPage - 1) * pageSize;
|
||||||
@@ -304,6 +434,10 @@ export default function UpstreamRatioSync(props) {
|
|||||||
const tmp = [];
|
const tmp = [];
|
||||||
|
|
||||||
Object.entries(differences).forEach(([model, ratioTypes]) => {
|
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]) => {
|
Object.entries(ratioTypes).forEach(([ratioType, diff]) => {
|
||||||
tmp.push({
|
tmp.push({
|
||||||
key: `${model}_${ratioType}`,
|
key: `${model}_${ratioType}`,
|
||||||
@@ -312,6 +446,7 @@ export default function UpstreamRatioSync(props) {
|
|||||||
current: diff.current,
|
current: diff.current,
|
||||||
upstreams: diff.upstreams,
|
upstreams: diff.upstreams,
|
||||||
confidence: diff.confidence || {},
|
confidence: diff.confidence || {},
|
||||||
|
billingConflict,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -369,14 +504,25 @@ export default function UpstreamRatioSync(props) {
|
|||||||
{
|
{
|
||||||
title: t('倍率类型'),
|
title: t('倍率类型'),
|
||||||
dataIndex: 'ratioType',
|
dataIndex: 'ratioType',
|
||||||
render: (text) => {
|
render: (text, record) => {
|
||||||
const typeMap = {
|
const typeMap = {
|
||||||
model_ratio: t('模型倍率'),
|
model_ratio: t('模型倍率'),
|
||||||
completion_ratio: t('补全倍率'),
|
completion_ratio: t('补全倍率'),
|
||||||
cache_ratio: t('缓存倍率'),
|
cache_ratio: t('缓存倍率'),
|
||||||
model_price: t('固定价格'),
|
model_price: t('固定价格'),
|
||||||
};
|
};
|
||||||
return <Tag color={stringToColor(text)} shape="circle">{typeMap[text] || text}</Tag>;
|
const baseTag = <Tag color={stringToColor(text)} shape="circle">{typeMap[text] || text}</Tag>;
|
||||||
|
if (record?.billingConflict) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{baseTag}
|
||||||
|
<Tooltip position="top" content={t('该模型存在固定价格与倍率计费方式冲突,请确认选择')}>
|
||||||
|
<AlertTriangle size={14} className="text-yellow-500" />
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return baseTag;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -444,28 +590,27 @@ export default function UpstreamRatioSync(props) {
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
const handleBulkSelect = (checked) => {
|
const handleBulkSelect = (checked) => {
|
||||||
setResolutions((prev) => {
|
if (checked) {
|
||||||
const newRes = { ...prev };
|
|
||||||
|
|
||||||
filteredDataSource.forEach((row) => {
|
filteredDataSource.forEach((row) => {
|
||||||
const upstreamVal = row.upstreams?.[upName];
|
const upstreamVal = row.upstreams?.[upName];
|
||||||
if (upstreamVal !== null && upstreamVal !== undefined && upstreamVal !== 'same') {
|
if (upstreamVal !== null && upstreamVal !== undefined && upstreamVal !== 'same') {
|
||||||
if (checked) {
|
selectValue(row.model, row.ratioType, upstreamVal);
|
||||||
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];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
return newRes;
|
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 {
|
return {
|
||||||
@@ -593,6 +738,23 @@ export default function UpstreamRatioSync(props) {
|
|||||||
channelEndpoints={channelEndpoints}
|
channelEndpoints={channelEndpoints}
|
||||||
updateChannelEndpoint={updateChannelEndpoint}
|
updateChannelEndpoint={updateChannelEndpoint}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ConflictConfirmModal
|
||||||
|
t={t}
|
||||||
|
visible={confirmVisible}
|
||||||
|
items={conflictItems}
|
||||||
|
onOk={async () => {
|
||||||
|
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)}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user