Browse Source

✨ 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.
Apple\Apple 10 tháng trước cách đây
mục cha
commit
67546f4b2a

+ 1 - 1
controller/ratio_sync.go

@@ -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

+ 75 - 50
web/src/components/settings/ChannelSelectorModal.js

@@ -1,92 +1,116 @@
-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>
-            <Input
-              size="small"
-              value={currentEndpoint}
-              onChange={(value) => updateChannelEndpoint(channelId, value)}
-              placeholder="/api/ratio_config"
-              className="flex-1 text-xs"
-              style={{ fontSize: '12px' }}
-            />
+            {showEndpoint && (
+              <Input
+                size="small"
+                value={currentEndpoint}
+                onChange={(value) => updateChannelEndpoint(channelId, value)}
+                placeholder="/api/ratio_config"
+                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('暂无选择'),

+ 68 - 0
web/src/index.css

@@ -432,4 +432,72 @@ code {
   .semi-table-tbody>.semi-table-row {
     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);
 }

+ 0 - 8
web/src/pages/Setting/Ratio/UpstreamRatioSync.js

@@ -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);