Commit 8248185e by t0ng7u

feat(ratio-sync): support /api/pricing parsing, confidence verification & UI enhancements

Backend
- controller/ratio_sync.go
  • Parse /api/pricing response and convert to ratio / price maps.
  • Introduce confidence heuristic (model_ratio = 37.5 && completion_ratio = 1) to flag unreliable data.
  • Include confidence map when building differences and filter “same”/empty entries.
- dto/ratio_sync.go
  • Add `ID` to UpstreamDTO, `upstreams` to UpstreamRequest, and `Confidence` to DifferenceItem.

Frontend
- ChannelSelectorModal.js
  • Re-implement with table layout, pagination, search, endpoint-type selector and mobile support.
- UpstreamRatioSync.js
  • Send full upstream objects, add ratio-type filter, confidence badges/tooltips, retain endpoints.
  • Leverage ChannelSelectorModal’s pagination reset.
- ChannelsTable.js – fix tag color for disabled status.
- en.json – add translations for new UI labels.

Motivation
These changes let users sync model ratios / prices from different upstream endpoints and visually identify potentially unreliable data, improving operational safety and flexibility.
parent 277ffdcb
package dto package dto
// UpstreamDTO 提交到后端同步倍率的上游渠道信息
// Endpoint 可以为空,后端会默认使用 /api/ratio_config
// BaseURL 必须以 http/https 开头,不要以 / 结尾
// 例如: https://api.example.com
// Endpoint: /api/ratio_config
// 提交示例:
// {
// "name": "openai",
// "base_url": "https://api.openai.com",
// "endpoint": "/ratio_config"
// }
type UpstreamDTO struct { type UpstreamDTO struct {
ID int `json:"id,omitempty"`
Name string `json:"name" binding:"required"` Name string `json:"name" binding:"required"`
BaseURL string `json:"base_url" binding:"required"` BaseURL string `json:"base_url" binding:"required"`
Endpoint string `json:"endpoint"` Endpoint string `json:"endpoint"`
...@@ -20,6 +9,7 @@ type UpstreamDTO struct { ...@@ -20,6 +9,7 @@ type UpstreamDTO struct {
type UpstreamRequest struct { type UpstreamRequest struct {
ChannelIDs []int64 `json:"channel_ids"` ChannelIDs []int64 `json:"channel_ids"`
Upstreams []UpstreamDTO `json:"upstreams"`
Timeout int `json:"timeout"` Timeout int `json:"timeout"`
} }
...@@ -37,10 +27,9 @@ type TestResult struct { ...@@ -37,10 +27,9 @@ type TestResult struct {
type DifferenceItem struct { type DifferenceItem struct {
Current interface{} `json:"current"` Current interface{} `json:"current"`
Upstreams map[string]interface{} `json:"upstreams"` Upstreams map[string]interface{} `json:"upstreams"`
Confidence map[string]bool `json:"confidence"`
} }
// SyncableChannel 可同步的渠道信息(base_url 不为空)
type SyncableChannel struct { type SyncableChannel struct {
ID int `json:"id"` ID int `json:"id"`
Name string `json:"name"` Name string `json:"name"`
......
...@@ -114,7 +114,7 @@ const ChannelsTable = () => { ...@@ -114,7 +114,7 @@ const ChannelsTable = () => {
); );
case 2: case 2:
return ( return (
<Tag size='large' color='yellow' shape='circle' prefixIcon={<XCircle size={14} />}> <Tag size='large' color='red' shape='circle' prefixIcon={<XCircle size={14} />}>
{t('已禁用')} {t('已禁用')}
</Tag> </Tag>
); );
......
...@@ -1701,5 +1701,14 @@ ...@@ -1701,5 +1701,14 @@
"充值分组倍率": "Recharge group ratio", "充值分组倍率": "Recharge group ratio",
"充值方式设置": "Recharge method settings", "充值方式设置": "Recharge method settings",
"更新支付设置": "Update payment settings", "更新支付设置": "Update payment settings",
"通知": "Notice" "通知": "Notice",
"源地址": "Source address",
"同步接口": "Synchronization interface",
"置信度": "Confidence",
"谨慎": "Cautious",
"该数据可能不可信,请谨慎使用": "This data may not be reliable, please use with caution",
"可信": "Reliable",
"所有上游数据均可信": "All upstream data is reliable",
"以下上游数据可能不可信:": "The following upstream data may not be reliable: ",
"按倍率类型筛选": "Filter by ratio type"
} }
\ No newline at end of file
...@@ -7,11 +7,15 @@ import { ...@@ -7,11 +7,15 @@ import {
Checkbox, Checkbox,
Form, Form,
Input, Input,
Tooltip,
Select,
} from '@douyinfe/semi-ui'; } from '@douyinfe/semi-ui';
import { IconSearch } from '@douyinfe/semi-icons'; import { IconSearch } from '@douyinfe/semi-icons';
import { import {
RefreshCcw, RefreshCcw,
CheckSquare, CheckSquare,
AlertTriangle,
CheckCircle,
} from 'lucide-react'; } from 'lucide-react';
import { API, showError, showSuccess, showWarning, stringToColor } from '../../../helpers'; import { API, showError, showSuccess, showWarning, stringToColor } from '../../../helpers';
import { DEFAULT_ENDPOINT } from '../../../constants'; import { DEFAULT_ENDPOINT } from '../../../constants';
...@@ -49,6 +53,11 @@ export default function UpstreamRatioSync(props) { ...@@ -49,6 +53,11 @@ export default function UpstreamRatioSync(props) {
// 搜索相关状态 // 搜索相关状态
const [searchKeyword, setSearchKeyword] = useState(''); const [searchKeyword, setSearchKeyword] = useState('');
// 倍率类型过滤
const [ratioTypeFilter, setRatioTypeFilter] = useState('');
const channelSelectorRef = React.useRef(null);
const fetchAllChannels = async () => { const fetchAllChannels = async () => {
setLoading(true); setLoading(true);
try { try {
...@@ -67,11 +76,16 @@ export default function UpstreamRatioSync(props) { ...@@ -67,11 +76,16 @@ export default function UpstreamRatioSync(props) {
setAllChannels(transferData); setAllChannels(transferData);
const initialEndpoints = {}; // 合并已有 endpoints,避免每次打开弹窗都重置
setChannelEndpoints(prev => {
const merged = { ...prev };
transferData.forEach(channel => { transferData.forEach(channel => {
initialEndpoints[channel.key] = DEFAULT_ENDPOINT; if (!merged[channel.key]) {
merged[channel.key] = DEFAULT_ENDPOINT;
}
});
return merged;
}); });
setChannelEndpoints(initialEndpoints);
} else { } else {
showError(res.data.message); showError(res.data.message);
} }
...@@ -99,8 +113,15 @@ export default function UpstreamRatioSync(props) { ...@@ -99,8 +113,15 @@ export default function UpstreamRatioSync(props) {
const fetchRatiosFromChannels = async (channelList) => { const fetchRatiosFromChannels = async (channelList) => {
setSyncLoading(true); setSyncLoading(true);
const upstreams = channelList.map(ch => ({
id: ch.id,
name: ch.name,
base_url: ch.base_url,
endpoint: channelEndpoints[ch.id] || DEFAULT_ENDPOINT,
}));
const payload = { const payload = {
channel_ids: channelList.map(ch => parseInt(ch.id)), upstreams: upstreams,
timeout: 10, timeout: 10,
}; };
...@@ -215,13 +236,15 @@ export default function UpstreamRatioSync(props) { ...@@ -215,13 +236,15 @@ export default function UpstreamRatioSync(props) {
const renderHeader = () => ( const renderHeader = () => (
<div className="flex flex-col w-full"> <div className="flex flex-col w-full">
<div className="flex flex-col md:flex-row justify-between items-center gap-4 w-full"> <div className="flex flex-col md:flex-row justify-between items-center gap-4 w-full">
<div className="flex gap-2 w-full md:w-auto order-2 md:order-1"> <div className="flex flex-col md:flex-row gap-2 w-full md:w-auto order-2 md:order-1">
<Button <Button
icon={<RefreshCcw size={14} />} icon={<RefreshCcw size={14} />}
className="!rounded-full w-full md:w-auto mt-2" className="!rounded-full w-full md:w-auto mt-2"
onClick={() => { onClick={() => {
setModalVisible(true); setModalVisible(true);
if (allChannels.length === 0) {
fetchAllChannels(); fetchAllChannels();
}
}} }}
> >
{t('选择同步渠道')} {t('选择同步渠道')}
...@@ -243,14 +266,30 @@ export default function UpstreamRatioSync(props) { ...@@ -243,14 +266,30 @@ export default function UpstreamRatioSync(props) {
); );
})()} })()}
<div className="flex flex-col sm:flex-row gap-2 w-full md:w-auto mt-2">
<Input <Input
prefix={<IconSearch size={14} />} prefix={<IconSearch size={14} />}
placeholder={t('搜索模型名称')} placeholder={t('搜索模型名称')}
value={searchKeyword} value={searchKeyword}
onChange={setSearchKeyword} onChange={setSearchKeyword}
className="!rounded-full w-full md:w-64 mt-2" className="!rounded-full w-full sm:w-64"
showClear showClear
/> />
<Select
placeholder={t('按倍率类型筛选')}
value={ratioTypeFilter}
onChange={setRatioTypeFilter}
className="!rounded-full w-full sm:w-48"
showClear
onClear={() => setRatioTypeFilter('')}
>
<Select.Option value="model_ratio">{t('模型倍率')}</Select.Option>
<Select.Option value="completion_ratio">{t('补全倍率')}</Select.Option>
<Select.Option value="cache_ratio">{t('缓存倍率')}</Select.Option>
<Select.Option value="model_price">{t('固定价格')}</Select.Option>
</Select>
</div>
</div> </div>
</div> </div>
</div> </div>
...@@ -268,6 +307,7 @@ export default function UpstreamRatioSync(props) { ...@@ -268,6 +307,7 @@ export default function UpstreamRatioSync(props) {
ratioType, ratioType,
current: diff.current, current: diff.current,
upstreams: diff.upstreams, upstreams: diff.upstreams,
confidence: diff.confidence || {},
}); });
}); });
}); });
...@@ -276,15 +316,20 @@ export default function UpstreamRatioSync(props) { ...@@ -276,15 +316,20 @@ export default function UpstreamRatioSync(props) {
}, [differences]); }, [differences]);
const filteredDataSource = useMemo(() => { const filteredDataSource = useMemo(() => {
if (!searchKeyword.trim()) { if (!searchKeyword.trim() && !ratioTypeFilter) {
return dataSource; return dataSource;
} }
const keyword = searchKeyword.toLowerCase().trim(); return dataSource.filter(item => {
return dataSource.filter(item => const matchesKeyword = !searchKeyword.trim() ||
item.model.toLowerCase().includes(keyword) item.model.toLowerCase().includes(searchKeyword.toLowerCase().trim());
);
}, [dataSource, searchKeyword]); const matchesRatioType = !ratioTypeFilter ||
item.ratioType === ratioTypeFilter;
return matchesKeyword && matchesRatioType;
});
}, [dataSource, searchKeyword, ratioTypeFilter]);
const upstreamNames = useMemo(() => { const upstreamNames = useMemo(() => {
const set = new Set(); const set = new Set();
...@@ -331,6 +376,36 @@ export default function UpstreamRatioSync(props) { ...@@ -331,6 +376,36 @@ export default function UpstreamRatioSync(props) {
}, },
}, },
{ {
title: t('置信度'),
dataIndex: 'confidence',
render: (_, record) => {
const allConfident = Object.values(record.confidence || {}).every(v => v !== false);
if (allConfident) {
return (
<Tooltip content={t('所有上游数据均可信')}>
<Tag color="green" shape="circle" type="light" prefixIcon={<CheckCircle size={14} />}>
{t('可信')}
</Tag>
</Tooltip>
);
} else {
const untrustedSources = Object.entries(record.confidence || {})
.filter(([_, isConfident]) => isConfident === false)
.map(([name]) => name)
.join(', ');
return (
<Tooltip content={t('以下上游数据可能不可信:') + untrustedSources}>
<Tag color="yellow" shape="circle" type="light" prefixIcon={<AlertTriangle size={14} />}>
{t('谨慎')}
</Tag>
</Tooltip>
);
}
},
},
{
title: t('当前值'), title: t('当前值'),
dataIndex: 'current', dataIndex: 'current',
render: (text) => ( render: (text) => (
...@@ -404,6 +479,7 @@ export default function UpstreamRatioSync(props) { ...@@ -404,6 +479,7 @@ export default function UpstreamRatioSync(props) {
dataIndex: upName, dataIndex: upName,
render: (_, record) => { render: (_, record) => {
const upstreamVal = record.upstreams?.[upName]; const upstreamVal = record.upstreams?.[upName];
const isConfident = record.confidence?.[upName] !== false;
if (upstreamVal === null || upstreamVal === undefined) { if (upstreamVal === null || upstreamVal === undefined) {
return <Tag color="default" shape="circle">{t('未设置')}</Tag>; return <Tag color="default" shape="circle">{t('未设置')}</Tag>;
...@@ -416,6 +492,7 @@ export default function UpstreamRatioSync(props) { ...@@ -416,6 +492,7 @@ export default function UpstreamRatioSync(props) {
const isSelected = resolutions[record.model]?.[record.ratioType] === upstreamVal; const isSelected = resolutions[record.model]?.[record.ratioType] === upstreamVal;
return ( return (
<div className="flex items-center gap-2">
<Checkbox <Checkbox
checked={isSelected} checked={isSelected}
onChange={(e) => { onChange={(e) => {
...@@ -438,6 +515,12 @@ export default function UpstreamRatioSync(props) { ...@@ -438,6 +515,12 @@ export default function UpstreamRatioSync(props) {
> >
{upstreamVal} {upstreamVal}
</Checkbox> </Checkbox>
{!isConfident && (
<Tooltip position='left' content={t('该数据可能不可信,请谨慎使用')}>
<AlertTriangle size={16} className="text-yellow-500" />
</Tooltip>
)}
</div>
); );
}, },
}; };
...@@ -481,6 +564,13 @@ export default function UpstreamRatioSync(props) { ...@@ -481,6 +564,13 @@ export default function UpstreamRatioSync(props) {
setChannelEndpoints(prev => ({ ...prev, [channelId]: endpoint })); setChannelEndpoints(prev => ({ ...prev, [channelId]: endpoint }));
}, []); }, []);
const handleModalClose = () => {
setModalVisible(false);
if (channelSelectorRef.current) {
channelSelectorRef.current.resetPagination();
}
};
return ( return (
<> <>
<Form.Section text={renderHeader()}> <Form.Section text={renderHeader()}>
...@@ -488,9 +578,10 @@ export default function UpstreamRatioSync(props) { ...@@ -488,9 +578,10 @@ export default function UpstreamRatioSync(props) {
</Form.Section> </Form.Section>
<ChannelSelectorModal <ChannelSelectorModal
ref={channelSelectorRef}
t={t} t={t}
visible={modalVisible} visible={modalVisible}
onCancel={() => setModalVisible(false)} onCancel={handleModalClose}
onOk={confirmChannelSelection} onOk={confirmChannelSelection}
allChannels={allChannels} allChannels={allChannels}
selectedChannelIds={selectedChannelIds} selectedChannelIds={selectedChannelIds}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment