소스 검색

✨ 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 8 달 전
부모
커밋
67546f4b2a
4개의 변경된 파일144개의 추가작업 그리고 59개의 파일을 삭제
  1. 1 1
      controller/ratio_sync.go
  2. 75 50
      web/src/components/settings/ChannelSelectorModal.js
  3. 68 0
      web/src/index.css
  4. 0 8
      web/src/pages/Setting/Ratio/UpstreamRatioSync.js

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