✨ 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.
This commit is contained in:
@@ -3,6 +3,7 @@ package controller
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -43,7 +44,17 @@ func FetchUpstreamRatios(c *gin.Context) {
|
|||||||
|
|
||||||
var upstreams []dto.UpstreamDTO
|
var upstreams []dto.UpstreamDTO
|
||||||
|
|
||||||
if len(req.ChannelIDs) > 0 {
|
if len(req.Upstreams) > 0 {
|
||||||
|
for _, u := range req.Upstreams {
|
||||||
|
if strings.HasPrefix(u.BaseURL, "http") {
|
||||||
|
if u.Endpoint == "" {
|
||||||
|
u.Endpoint = defaultEndpoint
|
||||||
|
}
|
||||||
|
u.BaseURL = strings.TrimRight(u.BaseURL, "/")
|
||||||
|
upstreams = append(upstreams, u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if len(req.ChannelIDs) > 0 {
|
||||||
intIds := make([]int, 0, len(req.ChannelIDs))
|
intIds := make([]int, 0, len(req.ChannelIDs))
|
||||||
for _, id64 := range req.ChannelIDs {
|
for _, id64 := range req.ChannelIDs {
|
||||||
intIds = append(intIds, int(id64))
|
intIds = append(intIds, int(id64))
|
||||||
@@ -57,6 +68,7 @@ func FetchUpstreamRatios(c *gin.Context) {
|
|||||||
for _, ch := range dbChannels {
|
for _, ch := range dbChannels {
|
||||||
if base := ch.GetBaseURL(); strings.HasPrefix(base, "http") {
|
if base := ch.GetBaseURL(); strings.HasPrefix(base, "http") {
|
||||||
upstreams = append(upstreams, dto.UpstreamDTO{
|
upstreams = append(upstreams, dto.UpstreamDTO{
|
||||||
|
ID: ch.Id,
|
||||||
Name: ch.Name,
|
Name: ch.Name,
|
||||||
BaseURL: strings.TrimRight(base, "/"),
|
BaseURL: strings.TrimRight(base, "/"),
|
||||||
Endpoint: "",
|
Endpoint: "",
|
||||||
@@ -93,43 +105,125 @@ func FetchUpstreamRatios(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
fullURL := chItem.BaseURL + endpoint
|
fullURL := chItem.BaseURL + endpoint
|
||||||
|
|
||||||
|
uniqueName := chItem.Name
|
||||||
|
if chItem.ID != 0 {
|
||||||
|
uniqueName = fmt.Sprintf("%s(%d)", chItem.Name, chItem.ID)
|
||||||
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(c.Request.Context(), time.Duration(req.Timeout)*time.Second)
|
ctx, cancel := context.WithTimeout(c.Request.Context(), time.Duration(req.Timeout)*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil)
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.LogWarn(c.Request.Context(), "build request failed: "+err.Error())
|
common.LogWarn(c.Request.Context(), "build request failed: "+err.Error())
|
||||||
ch <- upstreamResult{Name: chItem.Name, Err: err.Error()}
|
ch <- upstreamResult{Name: uniqueName, Err: err.Error()}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.LogWarn(c.Request.Context(), "http error on "+chItem.Name+": "+err.Error())
|
common.LogWarn(c.Request.Context(), "http error on "+chItem.Name+": "+err.Error())
|
||||||
ch <- upstreamResult{Name: chItem.Name, Err: err.Error()}
|
ch <- upstreamResult{Name: uniqueName, Err: err.Error()}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
common.LogWarn(c.Request.Context(), "non-200 from "+chItem.Name+": "+resp.Status)
|
common.LogWarn(c.Request.Context(), "non-200 from "+chItem.Name+": "+resp.Status)
|
||||||
ch <- upstreamResult{Name: chItem.Name, Err: resp.Status}
|
ch <- upstreamResult{Name: uniqueName, Err: resp.Status}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 兼容两种上游接口格式:
|
||||||
|
// type1: /api/ratio_config -> data 为 map[string]any,包含 model_ratio/completion_ratio/cache_ratio/model_price
|
||||||
|
// type2: /api/pricing -> data 为 []Pricing 列表,需要转换为与 type1 相同的 map 格式
|
||||||
var body struct {
|
var body struct {
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
Data map[string]any `json:"data"`
|
Data json.RawMessage `json:"data"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||||
common.LogWarn(c.Request.Context(), "json decode failed from "+chItem.Name+": "+err.Error())
|
common.LogWarn(c.Request.Context(), "json decode failed from "+chItem.Name+": "+err.Error())
|
||||||
ch <- upstreamResult{Name: chItem.Name, Err: err.Error()}
|
ch <- upstreamResult{Name: uniqueName, Err: err.Error()}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !body.Success {
|
if !body.Success {
|
||||||
ch <- upstreamResult{Name: chItem.Name, Err: body.Message}
|
ch <- upstreamResult{Name: uniqueName, Err: body.Message}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ch <- upstreamResult{Name: chItem.Name, Data: body.Data}
|
|
||||||
|
// 尝试按 type1 解析
|
||||||
|
var type1Data map[string]any
|
||||||
|
if err := json.Unmarshal(body.Data, &type1Data); err == nil {
|
||||||
|
// 如果包含至少一个 ratioTypes 字段,则认为是 type1
|
||||||
|
isType1 := false
|
||||||
|
for _, rt := range ratioTypes {
|
||||||
|
if _, ok := type1Data[rt]; ok {
|
||||||
|
isType1 = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if isType1 {
|
||||||
|
ch <- upstreamResult{Name: uniqueName, Data: type1Data}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果不是 type1,则尝试按 type2 (/api/pricing) 解析
|
||||||
|
var pricingItems []struct {
|
||||||
|
ModelName string `json:"model_name"`
|
||||||
|
QuotaType int `json:"quota_type"`
|
||||||
|
ModelRatio float64 `json:"model_ratio"`
|
||||||
|
ModelPrice float64 `json:"model_price"`
|
||||||
|
CompletionRatio float64 `json:"completion_ratio"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body.Data, &pricingItems); err != nil {
|
||||||
|
common.LogWarn(c.Request.Context(), "unrecognized data format from "+chItem.Name+": "+err.Error())
|
||||||
|
ch <- upstreamResult{Name: uniqueName, Err: "无法解析上游返回数据"}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
modelRatioMap := make(map[string]float64)
|
||||||
|
completionRatioMap := make(map[string]float64)
|
||||||
|
modelPriceMap := make(map[string]float64)
|
||||||
|
|
||||||
|
for _, item := range pricingItems {
|
||||||
|
if item.QuotaType == 1 {
|
||||||
|
modelPriceMap[item.ModelName] = item.ModelPrice
|
||||||
|
} else {
|
||||||
|
modelRatioMap[item.ModelName] = item.ModelRatio
|
||||||
|
// completionRatio 可能为 0,此时也直接赋值,保持与上游一致
|
||||||
|
completionRatioMap[item.ModelName] = item.CompletionRatio
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
converted := make(map[string]any)
|
||||||
|
|
||||||
|
if len(modelRatioMap) > 0 {
|
||||||
|
ratioAny := make(map[string]any, len(modelRatioMap))
|
||||||
|
for k, v := range modelRatioMap {
|
||||||
|
ratioAny[k] = v
|
||||||
|
}
|
||||||
|
converted["model_ratio"] = ratioAny
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(completionRatioMap) > 0 {
|
||||||
|
compAny := make(map[string]any, len(completionRatioMap))
|
||||||
|
for k, v := range completionRatioMap {
|
||||||
|
compAny[k] = v
|
||||||
|
}
|
||||||
|
converted["completion_ratio"] = compAny
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(modelPriceMap) > 0 {
|
||||||
|
priceAny := make(map[string]any, len(modelPriceMap))
|
||||||
|
for k, v := range modelPriceMap {
|
||||||
|
priceAny[k] = v
|
||||||
|
}
|
||||||
|
converted["model_price"] = priceAny
|
||||||
|
}
|
||||||
|
|
||||||
|
ch <- upstreamResult{Name: uniqueName, Data: converted}
|
||||||
}(chn)
|
}(chn)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,6 +296,43 @@ func buildDifferences(localData map[string]any, successfulChannels []struct {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
confidenceMap := make(map[string]map[string]bool)
|
||||||
|
|
||||||
|
// 预处理阶段:检查pricing接口的可信度
|
||||||
|
for _, channel := range successfulChannels {
|
||||||
|
confidenceMap[channel.name] = make(map[string]bool)
|
||||||
|
|
||||||
|
modelRatios, hasModelRatio := channel.data["model_ratio"].(map[string]any)
|
||||||
|
completionRatios, hasCompletionRatio := channel.data["completion_ratio"].(map[string]any)
|
||||||
|
|
||||||
|
if hasModelRatio && hasCompletionRatio {
|
||||||
|
// 遍历所有模型,检查是否满足不可信条件
|
||||||
|
for modelName := range allModels {
|
||||||
|
// 默认为可信
|
||||||
|
confidenceMap[channel.name][modelName] = true
|
||||||
|
|
||||||
|
// 检查是否满足不可信条件:model_ratio为37.5且completion_ratio为1
|
||||||
|
if modelRatioVal, ok := modelRatios[modelName]; ok {
|
||||||
|
if completionRatioVal, ok := completionRatios[modelName]; ok {
|
||||||
|
// 转换为float64进行比较
|
||||||
|
if modelRatioFloat, ok := modelRatioVal.(float64); ok {
|
||||||
|
if completionRatioFloat, ok := completionRatioVal.(float64); ok {
|
||||||
|
if modelRatioFloat == 37.5 && completionRatioFloat == 1.0 {
|
||||||
|
confidenceMap[channel.name][modelName] = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 如果不是从pricing接口获取的数据,则全部标记为可信
|
||||||
|
for modelName := range allModels {
|
||||||
|
confidenceMap[channel.name][modelName] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for modelName := range allModels {
|
for modelName := range allModels {
|
||||||
for _, ratioType := range ratioTypes {
|
for _, ratioType := range ratioTypes {
|
||||||
var localValue interface{} = nil
|
var localValue interface{} = nil
|
||||||
@@ -214,6 +345,7 @@ func buildDifferences(localData map[string]any, successfulChannels []struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
upstreamValues := make(map[string]interface{})
|
upstreamValues := make(map[string]interface{})
|
||||||
|
confidenceValues := make(map[string]bool)
|
||||||
hasUpstreamValue := false
|
hasUpstreamValue := false
|
||||||
hasDifference := false
|
hasDifference := false
|
||||||
|
|
||||||
@@ -241,6 +373,8 @@ func buildDifferences(localData map[string]any, successfulChannels []struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
upstreamValues[channel.name] = upstreamValue
|
upstreamValues[channel.name] = upstreamValue
|
||||||
|
|
||||||
|
confidenceValues[channel.name] = confidenceMap[channel.name][modelName]
|
||||||
}
|
}
|
||||||
|
|
||||||
shouldInclude := false
|
shouldInclude := false
|
||||||
@@ -262,6 +396,7 @@ func buildDifferences(localData map[string]any, successfulChannels []struct {
|
|||||||
differences[modelName][ratioType] = dto.DifferenceItem{
|
differences[modelName][ratioType] = dto.DifferenceItem{
|
||||||
Current: localValue,
|
Current: localValue,
|
||||||
Upstreams: upstreamValues,
|
Upstreams: upstreamValues,
|
||||||
|
Confidence: confidenceValues,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -283,9 +418,26 @@ func buildDifferences(localData map[string]any, successfulChannels []struct {
|
|||||||
for chName := range item.Upstreams {
|
for chName := range item.Upstreams {
|
||||||
if !channelHasDiff[chName] {
|
if !channelHasDiff[chName] {
|
||||||
delete(item.Upstreams, chName)
|
delete(item.Upstreams, chName)
|
||||||
|
delete(item.Confidence, chName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
differences[modelName][ratioType] = item
|
|
||||||
|
allSame := true
|
||||||
|
for _, v := range item.Upstreams {
|
||||||
|
if v != "same" {
|
||||||
|
allSame = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(item.Upstreams) == 0 || allSame {
|
||||||
|
delete(ratioMap, ratioType)
|
||||||
|
} else {
|
||||||
|
differences[modelName][ratioType] = item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(ratioMap) == 0 {
|
||||||
|
delete(differences, modelName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,7 @@
|
|||||||
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 {
|
|||||||
|
|
||||||
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 {
|
|||||||
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"`
|
||||||
|
|||||||
@@ -1,115 +1,183 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
|
||||||
|
import { isMobile } from '../../helpers';
|
||||||
import {
|
import {
|
||||||
Modal,
|
Modal,
|
||||||
Transfer,
|
Table,
|
||||||
Input,
|
Input,
|
||||||
Space,
|
Space,
|
||||||
Checkbox,
|
|
||||||
Avatar,
|
|
||||||
Highlight,
|
Highlight,
|
||||||
|
Select,
|
||||||
|
Tag,
|
||||||
} from '@douyinfe/semi-ui';
|
} from '@douyinfe/semi-ui';
|
||||||
import { IconClose } from '@douyinfe/semi-icons';
|
import { IconSearch } from '@douyinfe/semi-icons';
|
||||||
|
import { CheckCircle, XCircle, AlertCircle, HelpCircle } from 'lucide-react';
|
||||||
|
|
||||||
const CHANNEL_STATUS_CONFIG = {
|
const ChannelSelectorModal = forwardRef(({
|
||||||
1: { color: 'green', text: '启用' },
|
|
||||||
2: { color: 'red', text: '禁用' },
|
|
||||||
3: { color: 'amber', text: '自禁' },
|
|
||||||
default: { color: 'grey', text: '未知' }
|
|
||||||
};
|
|
||||||
|
|
||||||
const getChannelStatusConfig = (status) => {
|
|
||||||
return CHANNEL_STATUS_CONFIG[status] || CHANNEL_STATUS_CONFIG.default;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ChannelSelectorModal({
|
|
||||||
t,
|
|
||||||
visible,
|
visible,
|
||||||
onCancel,
|
onCancel,
|
||||||
onOk,
|
onOk,
|
||||||
allChannels = [],
|
allChannels,
|
||||||
selectedChannelIds = [],
|
selectedChannelIds,
|
||||||
setSelectedChannelIds,
|
setSelectedChannelIds,
|
||||||
channelEndpoints,
|
channelEndpoints,
|
||||||
updateChannelEndpoint,
|
updateChannelEndpoint,
|
||||||
}) {
|
t,
|
||||||
|
}, ref) => {
|
||||||
const [searchText, setSearchText] = useState('');
|
const [searchText, setSearchText] = useState('');
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(10);
|
||||||
|
|
||||||
const ChannelInfo = ({ item, showEndpoint = false, isSelected = false }) => {
|
const [filteredData, setFilteredData] = useState([]);
|
||||||
const channelId = item.key || item.value;
|
|
||||||
const currentEndpoint = channelEndpoints[channelId];
|
|
||||||
const baseUrl = item._originalData?.base_url || '';
|
|
||||||
const status = item._originalData?.status || 0;
|
|
||||||
const statusConfig = getChannelStatusConfig(status);
|
|
||||||
|
|
||||||
return (
|
useImperativeHandle(ref, () => ({
|
||||||
<>
|
resetPagination: () => {
|
||||||
<Avatar color={statusConfig.color} size="small">
|
setCurrentPage(1);
|
||||||
{statusConfig.text}
|
setSearchText('');
|
||||||
</Avatar>
|
},
|
||||||
<div className="info">
|
}));
|
||||||
<div className="name">
|
|
||||||
{isSelected ? (
|
useEffect(() => {
|
||||||
item.label
|
if (!allChannels) return;
|
||||||
) : (
|
|
||||||
<Highlight sourceString={item.label} searchWords={[searchText]} />
|
const searchLower = searchText.trim().toLowerCase();
|
||||||
)}
|
const matched = searchLower
|
||||||
</div>
|
? allChannels.filter((item) => {
|
||||||
<div className="email" style={showEndpoint ? { display: 'flex', alignItems: 'center', gap: '4px' } : {}}>
|
const name = (item.label || '').toLowerCase();
|
||||||
<span className="text-xs text-gray-500 truncate max-w-[200px]" title={baseUrl}>
|
const baseUrl = (item._originalData?.base_url || '').toLowerCase();
|
||||||
{isSelected ? (
|
return name.includes(searchLower) || baseUrl.includes(searchLower);
|
||||||
baseUrl
|
})
|
||||||
) : (
|
: allChannels;
|
||||||
<Highlight sourceString={baseUrl} searchWords={[searchText]} />
|
|
||||||
)}
|
setFilteredData(matched);
|
||||||
</span>
|
}, [allChannels, searchText]);
|
||||||
{showEndpoint && (
|
|
||||||
<Input
|
const total = filteredData.length;
|
||||||
size="small"
|
|
||||||
value={currentEndpoint}
|
const paginatedData = filteredData.slice(
|
||||||
onChange={(value) => updateChannelEndpoint(channelId, value)}
|
(currentPage - 1) * pageSize,
|
||||||
placeholder="/api/ratio_config"
|
currentPage * pageSize,
|
||||||
className="flex-1 text-xs"
|
);
|
||||||
style={{ fontSize: '12px' }}
|
|
||||||
/>
|
const updateEndpoint = (channelId, endpoint) => {
|
||||||
)}
|
if (typeof updateChannelEndpoint === 'function') {
|
||||||
{isSelected && !showEndpoint && (
|
updateChannelEndpoint(channelId, endpoint);
|
||||||
<span className="text-xs text-gray-700 font-mono bg-gray-100 px-2 py-1 rounded ml-2">
|
}
|
||||||
{currentEndpoint}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderSourceItem = (item) => {
|
const renderEndpointCell = (text, record) => {
|
||||||
|
const channelId = record.key || record.value;
|
||||||
|
const currentEndpoint = channelEndpoints[channelId] || '';
|
||||||
|
|
||||||
|
const getEndpointType = (ep) => {
|
||||||
|
if (ep === '/api/ratio_config') return 'ratio_config';
|
||||||
|
if (ep === '/api/pricing') return 'pricing';
|
||||||
|
return 'custom';
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentType = getEndpointType(currentEndpoint);
|
||||||
|
|
||||||
|
const handleTypeChange = (val) => {
|
||||||
|
if (val === 'ratio_config') {
|
||||||
|
updateEndpoint(channelId, '/api/ratio_config');
|
||||||
|
} else if (val === 'pricing') {
|
||||||
|
updateEndpoint(channelId, '/api/pricing');
|
||||||
|
} else {
|
||||||
|
if (currentType !== 'custom') {
|
||||||
|
updateEndpoint(channelId, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="components-transfer-source-item" key={item.key}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
<Checkbox
|
<Select
|
||||||
onChange={item.onChange}
|
size="small"
|
||||||
checked={item.checked}
|
value={currentType}
|
||||||
style={{ height: 52, alignItems: 'center' }}
|
onChange={handleTypeChange}
|
||||||
>
|
style={{ width: 120 }}
|
||||||
<ChannelInfo item={item} showEndpoint={true} />
|
optionList={[
|
||||||
</Checkbox>
|
{ label: 'ratio_config', value: 'ratio_config' },
|
||||||
|
{ label: 'pricing', value: 'pricing' },
|
||||||
|
{ label: 'custom', value: 'custom' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
{currentType === 'custom' && (
|
||||||
|
<Input
|
||||||
|
size="small"
|
||||||
|
value={currentEndpoint}
|
||||||
|
onChange={(val) => updateEndpoint(channelId, val)}
|
||||||
|
placeholder="/your/endpoint"
|
||||||
|
style={{ width: 160, fontSize: 12 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderSelectedItem = (item) => {
|
const renderStatusCell = (status) => {
|
||||||
return (
|
switch (status) {
|
||||||
<div className="components-transfer-selected-item" key={item.key}>
|
case 1:
|
||||||
<ChannelInfo item={item} isSelected={true} />
|
return (
|
||||||
<IconClose style={{ cursor: 'pointer' }} onClick={item.onRemove} />
|
<Tag size='large' color='green' shape='circle' prefixIcon={<CheckCircle size={14} />}>
|
||||||
</div>
|
{t('已启用')}
|
||||||
);
|
</Tag>
|
||||||
|
);
|
||||||
|
case 2:
|
||||||
|
return (
|
||||||
|
<Tag size='large' color='red' shape='circle' prefixIcon={<XCircle size={14} />}>
|
||||||
|
{t('已禁用')}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
case 3:
|
||||||
|
return (
|
||||||
|
<Tag size='large' color='yellow' shape='circle' prefixIcon={<AlertCircle size={14} />}>
|
||||||
|
{t('自动禁用')}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<Tag size='large' color='grey' shape='circle' prefixIcon={<HelpCircle size={14} />}>
|
||||||
|
{t('未知状态')}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const channelFilter = (input, item) => {
|
const renderNameCell = (text) => (
|
||||||
const searchLower = input.toLowerCase();
|
<Highlight sourceString={text} searchWords={[searchText]} />
|
||||||
return item.label.toLowerCase().includes(searchLower) ||
|
);
|
||||||
(item._originalData?.base_url || '').toLowerCase().includes(searchLower);
|
|
||||||
|
const renderBaseUrlCell = (text) => (
|
||||||
|
<Highlight sourceString={text} searchWords={[searchText]} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: t('名称'),
|
||||||
|
dataIndex: 'label',
|
||||||
|
render: renderNameCell,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('源地址'),
|
||||||
|
dataIndex: '_originalData.base_url',
|
||||||
|
render: (_, record) => renderBaseUrlCell(record._originalData?.base_url || ''),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('状态'),
|
||||||
|
dataIndex: '_originalData.status',
|
||||||
|
render: (_, record) => renderStatusCell(record._originalData?.status || 0),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('同步接口'),
|
||||||
|
dataIndex: 'endpoint',
|
||||||
|
fixed: 'right',
|
||||||
|
render: renderEndpointCell,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const rowSelection = {
|
||||||
|
selectedRowKeys: selectedChannelIds,
|
||||||
|
onChange: (keys) => setSelectedChannelIds(keys),
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -118,26 +186,51 @@ export default function ChannelSelectorModal({
|
|||||||
onCancel={onCancel}
|
onCancel={onCancel}
|
||||||
onOk={onOk}
|
onOk={onOk}
|
||||||
title={<span className="text-lg font-semibold">{t('选择同步渠道')}</span>}
|
title={<span className="text-lg font-semibold">{t('选择同步渠道')}</span>}
|
||||||
width={1000}
|
size={isMobile() ? 'full-width' : 'large'}
|
||||||
|
keepDOM
|
||||||
|
lazyRender={false}
|
||||||
>
|
>
|
||||||
<Space vertical style={{ width: '100%' }}>
|
<Space vertical style={{ width: '100%' }}>
|
||||||
<Transfer
|
<Input
|
||||||
style={{ width: '100%' }}
|
prefix={<IconSearch size={14} />}
|
||||||
dataSource={allChannels}
|
placeholder={t('搜索渠道名称或地址')}
|
||||||
value={selectedChannelIds}
|
value={searchText}
|
||||||
onChange={setSelectedChannelIds}
|
onChange={setSearchText}
|
||||||
renderSourceItem={renderSourceItem}
|
showClear
|
||||||
renderSelectedItem={renderSelectedItem}
|
className="!rounded-full"
|
||||||
filter={channelFilter}
|
/>
|
||||||
inputProps={{ placeholder: t('搜索渠道名称或地址') }}
|
|
||||||
onSearch={setSearchText}
|
<Table
|
||||||
emptyContent={{
|
columns={columns}
|
||||||
left: t('暂无渠道'),
|
dataSource={paginatedData}
|
||||||
right: t('暂无选择'),
|
rowKey="key"
|
||||||
search: t('无搜索结果'),
|
rowSelection={rowSelection}
|
||||||
|
pagination={{
|
||||||
|
currentPage: currentPage,
|
||||||
|
pageSize: pageSize,
|
||||||
|
total: total,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showQuickJumper: true,
|
||||||
|
pageSizeOptions: ['10', '20', '50', '100'],
|
||||||
|
formatPageText: (page) => t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
|
||||||
|
start: page.currentStart,
|
||||||
|
end: page.currentEnd,
|
||||||
|
total: total,
|
||||||
|
}),
|
||||||
|
onChange: (page, size) => {
|
||||||
|
setCurrentPage(page);
|
||||||
|
setPageSize(size);
|
||||||
|
},
|
||||||
|
onShowSizeChange: (curr, size) => {
|
||||||
|
setCurrentPage(1);
|
||||||
|
setPageSize(size);
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
|
size="small"
|
||||||
/>
|
/>
|
||||||
</Space>
|
</Space>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
|
export default ChannelSelectorModal;
|
||||||
@@ -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 @@
|
|||||||
"充值分组倍率": "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"
|
||||||
}
|
}
|
||||||
@@ -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) {
|
|||||||
// 搜索相关状态
|
// 搜索相关状态
|
||||||
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) {
|
|||||||
|
|
||||||
setAllChannels(transferData);
|
setAllChannels(transferData);
|
||||||
|
|
||||||
const initialEndpoints = {};
|
// 合并已有 endpoints,避免每次打开弹窗都重置
|
||||||
transferData.forEach(channel => {
|
setChannelEndpoints(prev => {
|
||||||
initialEndpoints[channel.key] = DEFAULT_ENDPOINT;
|
const merged = { ...prev };
|
||||||
|
transferData.forEach(channel => {
|
||||||
|
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) {
|
|||||||
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) {
|
|||||||
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);
|
||||||
fetchAllChannels();
|
if (allChannels.length === 0) {
|
||||||
|
fetchAllChannels();
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('选择同步渠道')}
|
{t('选择同步渠道')}
|
||||||
@@ -243,14 +266,30 @@ export default function UpstreamRatioSync(props) {
|
|||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
<Input
|
<div className="flex flex-col sm:flex-row gap-2 w-full md:w-auto mt-2">
|
||||||
prefix={<IconSearch size={14} />}
|
<Input
|
||||||
placeholder={t('搜索模型名称')}
|
prefix={<IconSearch size={14} />}
|
||||||
value={searchKeyword}
|
placeholder={t('搜索模型名称')}
|
||||||
onChange={setSearchKeyword}
|
value={searchKeyword}
|
||||||
className="!rounded-full w-full md:w-64 mt-2"
|
onChange={setSearchKeyword}
|
||||||
showClear
|
className="!rounded-full w-full sm:w-64"
|
||||||
/>
|
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) {
|
|||||||
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) {
|
|||||||
}, [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();
|
||||||
@@ -330,6 +375,36 @@ export default function UpstreamRatioSync(props) {
|
|||||||
return <Tag color={stringToColor(text)} shape="circle">{typeMap[text] || text}</Tag>;
|
return <Tag color={stringToColor(text)} shape="circle">{typeMap[text] || text}</Tag>;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
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',
|
||||||
@@ -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,28 +492,35 @@ export default function UpstreamRatioSync(props) {
|
|||||||
const isSelected = resolutions[record.model]?.[record.ratioType] === upstreamVal;
|
const isSelected = resolutions[record.model]?.[record.ratioType] === upstreamVal;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Checkbox
|
<div className="flex items-center gap-2">
|
||||||
checked={isSelected}
|
<Checkbox
|
||||||
onChange={(e) => {
|
checked={isSelected}
|
||||||
const isChecked = e.target.checked;
|
onChange={(e) => {
|
||||||
if (isChecked) {
|
const isChecked = e.target.checked;
|
||||||
selectValue(record.model, record.ratioType, upstreamVal);
|
if (isChecked) {
|
||||||
} else {
|
selectValue(record.model, record.ratioType, upstreamVal);
|
||||||
setResolutions((prev) => {
|
} else {
|
||||||
const newRes = { ...prev };
|
setResolutions((prev) => {
|
||||||
if (newRes[record.model]) {
|
const newRes = { ...prev };
|
||||||
delete newRes[record.model][record.ratioType];
|
if (newRes[record.model]) {
|
||||||
if (Object.keys(newRes[record.model]).length === 0) {
|
delete newRes[record.model][record.ratioType];
|
||||||
delete newRes[record.model];
|
if (Object.keys(newRes[record.model]).length === 0) {
|
||||||
|
delete newRes[record.model];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
return newRes;
|
||||||
return newRes;
|
});
|
||||||
});
|
}
|
||||||
}
|
}}
|
||||||
}}
|
>
|
||||||
>
|
{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) {
|
|||||||
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) {
|
|||||||
</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}
|
||||||
|
|||||||
Reference in New Issue
Block a user