✨ chore(ui): enhance channel selector with status avatars and UI improvements
Add visual status indicators and improve user experience for the upstream ratio sync channel selector modal. Features: - Add status-based avatar indicators for channels (enabled/disabled/auto-disabled) - Implement search functionality with text highlighting - Add endpoint configuration input for each channel - Optimize component structure with reusable ChannelInfo component UI Improvements: - Custom styling for transfer component items - Hide scrollbars for cleaner appearance in transfer lists - Responsive layout adjustments for channel information display - Color-coded avatars: green (enabled), red (disabled), amber (auto-disabled), grey (unknown) Code Quality: - Extract channel status configuration to constants - Create reusable ChannelInfo component to reduce code duplication - Implement proper search filtering for both channel names and URLs - Add consistent styling classes for transfer demo components Files modified: - web/src/components/settings/ChannelSelectorModal.js - web/src/pages/Setting/Ratio/UpstreamRatioSync.js - web/src/index.css This enhancement provides better visual feedback for channel status and improves the overall user experience when selecting channels for ratio synchronization.
This commit is contained in:
@@ -49,7 +49,7 @@ func FetchUpstreamRatios(c *gin.Context) {
|
||||
req.Timeout = 10
|
||||
}
|
||||
|
||||
// build upstream list from ids + custom
|
||||
// build upstream list from ids
|
||||
var upstreams []dto.UpstreamDTO
|
||||
if len(req.ChannelIDs) > 0 {
|
||||
// convert []int64 -> []int for model function
|
||||
|
||||
@@ -1,49 +1,68 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Transfer,
|
||||
Input,
|
||||
Space,
|
||||
Checkbox,
|
||||
Avatar,
|
||||
Highlight,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { IconClose } from '@douyinfe/semi-icons';
|
||||
|
||||
/**
|
||||
* ChannelSelectorModal
|
||||
* 负责选择同步渠道、测试与批量测试等 UI,纯展示组件。
|
||||
* 业务状态与动作通过 props 注入,保持可复用与可测试。
|
||||
*/
|
||||
const CHANNEL_STATUS_CONFIG = {
|
||||
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,
|
||||
onCancel,
|
||||
onOk,
|
||||
// 渠道选择
|
||||
allChannels = [],
|
||||
selectedChannelIds = [],
|
||||
setSelectedChannelIds,
|
||||
// 渠道端点
|
||||
channelEndpoints,
|
||||
updateChannelEndpoint,
|
||||
}) {
|
||||
// Transfer 自定义渲染
|
||||
const renderSourceItem = (item) => {
|
||||
const [searchText, setSearchText] = useState('');
|
||||
|
||||
const ChannelInfo = ({ item, showEndpoint = false, isSelected = false }) => {
|
||||
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 (
|
||||
<div key={item.key} style={{ padding: 8 }}>
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<div className="flex items-center w-full">
|
||||
<Checkbox checked={item.checked} onChange={item.onChange}>
|
||||
<span className="font-medium">{item.label}</span>
|
||||
</Checkbox>
|
||||
<>
|
||||
<Avatar color={statusConfig.color} size="small">
|
||||
{statusConfig.text}
|
||||
</Avatar>
|
||||
<div className="info">
|
||||
<div className="name">
|
||||
{isSelected ? (
|
||||
item.label
|
||||
) : (
|
||||
<Highlight sourceString={item.label} searchWords={[searchText]} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 ml-4">
|
||||
<span className="text-xs text-gray-500 truncate max-w-[120px]" title={baseUrl}>
|
||||
{baseUrl}
|
||||
<div className="email" style={showEndpoint ? { display: 'flex', alignItems: 'center', gap: '4px' } : {}}>
|
||||
<span className="text-xs text-gray-500 truncate max-w-[200px]" title={baseUrl}>
|
||||
{isSelected ? (
|
||||
baseUrl
|
||||
) : (
|
||||
<Highlight sourceString={baseUrl} searchWords={[searchText]} />
|
||||
)}
|
||||
</span>
|
||||
{showEndpoint && (
|
||||
<Input
|
||||
size="small"
|
||||
value={currentEndpoint}
|
||||
@@ -52,41 +71,46 @@ export default function ChannelSelectorModal({
|
||||
className="flex-1 text-xs"
|
||||
style={{ fontSize: '12px' }}
|
||||
/>
|
||||
)}
|
||||
{isSelected && !showEndpoint && (
|
||||
<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) => {
|
||||
return (
|
||||
<div className="components-transfer-source-item" key={item.key}>
|
||||
<Checkbox
|
||||
onChange={item.onChange}
|
||||
checked={item.checked}
|
||||
style={{ height: 52, alignItems: 'center' }}
|
||||
>
|
||||
<ChannelInfo item={item} showEndpoint={true} />
|
||||
</Checkbox>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderSelectedItem = (item) => {
|
||||
const channelId = item.key || item.value;
|
||||
const currentEndpoint = channelEndpoints[channelId];
|
||||
const baseUrl = item._originalData?.base_url || '';
|
||||
|
||||
return (
|
||||
<div key={item.key} style={{ padding: 6 }}>
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<div className="flex items-center w-full">
|
||||
<span className="font-medium">{item.label}</span>
|
||||
<IconClose style={{ cursor: 'pointer' }} onClick={item.onRemove} className="ml-auto" />
|
||||
</div>
|
||||
<div className="flex items-center gap-1 ml-4">
|
||||
<span
|
||||
className="text-xs text-gray-500 truncate max-w-[120px]"
|
||||
title={baseUrl}
|
||||
>
|
||||
{baseUrl}
|
||||
</span>
|
||||
<span className="text-xs text-gray-700 font-mono bg-gray-100 px-2 py-1 rounded flex-1">
|
||||
{currentEndpoint}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="components-transfer-selected-item" key={item.key}>
|
||||
<ChannelInfo item={item} isSelected={true} />
|
||||
<IconClose style={{ cursor: 'pointer' }} onClick={item.onRemove} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const channelFilter = (input, item) => item.label.toLowerCase().includes(input.toLowerCase());
|
||||
const channelFilter = (input, item) => {
|
||||
const searchLower = input.toLowerCase();
|
||||
return item.label.toLowerCase().includes(searchLower) ||
|
||||
(item._originalData?.base_url || '').toLowerCase().includes(searchLower);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -106,6 +130,7 @@ export default function ChannelSelectorModal({
|
||||
renderSelectedItem={renderSelectedItem}
|
||||
filter={channelFilter}
|
||||
inputProps={{ placeholder: t('搜索渠道名称或地址') }}
|
||||
onSearch={setSearchText}
|
||||
emptyContent={{
|
||||
left: t('暂无渠道'),
|
||||
right: t('暂无选择'),
|
||||
|
||||
@@ -433,3 +433,71 @@ code {
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
/* ==================== 同步倍率 - 渠道选择器 ==================== */
|
||||
|
||||
.components-transfer-source-item,
|
||||
.components-transfer-selected-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.semi-transfer-left-list,
|
||||
.semi-transfer-right-list {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.semi-transfer-left-list::-webkit-scrollbar,
|
||||
.semi-transfer-right-list::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.components-transfer-source-item .semi-checkbox,
|
||||
.components-transfer-selected-item .semi-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.components-transfer-source-item .semi-avatar,
|
||||
.components-transfer-selected-item .semi-avatar {
|
||||
margin-right: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.components-transfer-source-item .info,
|
||||
.components-transfer-selected-item .info {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.components-transfer-source-item .name,
|
||||
.components-transfer-selected-item .name {
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.components-transfer-source-item .email,
|
||||
.components-transfer-selected-item .email {
|
||||
font-size: 12px;
|
||||
color: var(--semi-color-text-2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.components-transfer-selected-item .semi-icon-close {
|
||||
margin-left: 8px;
|
||||
cursor: pointer;
|
||||
color: var(--semi-color-text-2);
|
||||
}
|
||||
|
||||
.components-transfer-selected-item .semi-icon-close:hover {
|
||||
color: var(--semi-color-text-0);
|
||||
}
|
||||
@@ -42,14 +42,6 @@ export default function UpstreamRatioSync(props) {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
|
||||
// 当前倍率快照
|
||||
const currentRatiosSnapshot = useMemo(() => ({
|
||||
model_ratio: JSON.parse(props.options.ModelRatio || '{}'),
|
||||
completion_ratio: JSON.parse(props.options.CompletionRatio || '{}'),
|
||||
cache_ratio: JSON.parse(props.options.CacheRatio || '{}'),
|
||||
model_price: JSON.parse(props.options.ModelPrice || '{}'),
|
||||
}), [props.options]);
|
||||
|
||||
// 获取所有渠道
|
||||
const fetchAllChannels = async () => {
|
||||
setLoading(true);
|
||||
|
||||
Reference in New Issue
Block a user