Просмотр исходного кода

🚀 feat(Channels): Enhance Channel Filtering & Performance

feat(api):
• Add optional `type` query param to `/api/channel` endpoint for type-specific pagination
• Return `type_counts` map with counts for each channel type
• Implement `GetChannelsByType`, `CountChannelsByType`, `CountChannelsGroupByType` in `model/channel.go`

feat(frontend):
• Introduce type Tabs in `ChannelsTable` to switch between channel types
• Tabs show dynamic counts using backend `type_counts`; “All” is computed from sum
• Persist active type, reload data on tab change (with proper query params)

perf(frontend):
• Use a request counter (`useRef`) to discard stale responses when tabs switch quickly
• Move all `useMemo` hooks to top level to satisfy React Hook rules
• Remove redundant local type counting fallback when backend data present

ui:
• Remove icons from response-time tags for cleaner look
• Use Semi-UI native arrow controls for Tabs; custom arrow code deleted

chore:
• Minor refactor & comments for clarity
• Ensure ESLint Hook rules pass

Result: Channel list now supports fast, accurate type filtering with correct counts, improved concurrency safety, and cleaner UI.
Apple\Apple 6 месяцев назад
Родитель
Сommit
547da2da60
3 измененных файлов с 177 добавлено и 29 удалено
  1. 27 7
      controller/channel.go
  2. 36 0
      model/channel.go
  3. 114 22
      web/src/components/table/ChannelsTable.js

+ 27 - 7
controller/channel.go

@@ -52,6 +52,14 @@ func GetAllChannels(c *gin.Context) {
 	channelData := make([]*model.Channel, 0)
 	idSort, _ := strconv.ParseBool(c.Query("id_sort"))
 	enableTagMode, _ := strconv.ParseBool(c.Query("tag_mode"))
+	// type filter
+	typeStr := c.Query("type")
+	typeFilter := -1
+	if typeStr != "" {
+		if t, err := strconv.Atoi(typeStr); err == nil {
+			typeFilter = t
+		}
+	}
 
 	var total int64
 
@@ -72,6 +80,14 @@ func GetAllChannels(c *gin.Context) {
 		}
 		// 计算 tag 总数用于分页
 		total, _ = model.CountAllTags()
+	} else if typeFilter >= 0 {
+		channels, err := model.GetChannelsByType((p-1)*pageSize, pageSize, idSort, typeFilter)
+		if err != nil {
+			c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
+			return
+		}
+		channelData = channels
+		total, _ = model.CountChannelsByType(typeFilter)
 	} else {
 		channels, err := model.GetAllChannels((p-1)*pageSize, pageSize, false, idSort)
 		if err != nil {
@@ -82,14 +98,18 @@ func GetAllChannels(c *gin.Context) {
 		total, _ = model.CountAllChannels()
 	}
 
+	// calculate type counts
+	typeCounts, _ := model.CountChannelsGroupByType()
+
 	c.JSON(http.StatusOK, gin.H{
-		"success": true,
-		"message": "",
-		"data": gin.H{
-			"items":     channelData,
-			"total":     total,
-			"page":      p,
-			"page_size": pageSize,
+		"success":     true,
+		"message":     "",
+		"data":        gin.H{
+			"items":         channelData,
+			"total":         total,
+			"page":          p,
+			"page_size":     pageSize,
+			"type_counts":   typeCounts,
 		},
 	})
 	return

+ 36 - 0
model/channel.go

@@ -597,3 +597,39 @@ func CountAllTags() (int64, error) {
 	err := DB.Model(&Channel{}).Where("tag is not null AND tag != ''").Distinct("tag").Count(&total).Error
 	return total, err
 }
+
+// Get channels of specified type with pagination
+func GetChannelsByType(startIdx int, num int, idSort bool, channelType int) ([]*Channel, error) {
+	var channels []*Channel
+	order := "priority desc"
+	if idSort {
+		order = "id desc"
+	}
+	err := DB.Where("type = ?", channelType).Order(order).Limit(num).Offset(startIdx).Omit("key").Find(&channels).Error
+	return channels, err
+}
+
+// Count channels of specific type
+func CountChannelsByType(channelType int) (int64, error) {
+	var count int64
+	err := DB.Model(&Channel{}).Where("type = ?", channelType).Count(&count).Error
+	return count, err
+}
+
+// Return map[type]count for all channels
+func CountChannelsGroupByType() (map[int64]int64, error) {
+	type result struct {
+		Type  int64 `gorm:"column:type"`
+		Count int64 `gorm:"column:count"`
+	}
+	var results []result
+	err := DB.Model(&Channel{}).Select("type, count(*) as count").Group("type").Find(&results).Error
+	if err != nil {
+		return nil, err
+	}
+	counts := make(map[int64]int64)
+	for _, r := range results {
+		counts[r.Type] = r.Count
+	}
+	return counts, nil
+}

+ 114 - 22
web/src/components/table/ChannelsTable.js

@@ -1,4 +1,4 @@
-import React, { useEffect, useState } from 'react';
+import React, { useEffect, useState, useMemo, useRef } from 'react';
 import {
   API,
   showError,
@@ -16,11 +16,6 @@ import {
   XCircle,
   AlertCircle,
   HelpCircle,
-  TestTube,
-  Zap,
-  Timer,
-  Clock,
-  AlertTriangle,
   Coins,
   Tags
 } from 'lucide-react';
@@ -43,7 +38,9 @@ import {
   Typography,
   Checkbox,
   Card,
-  Form
+  Form,
+  Tabs,
+  TabPane
 } from '@douyinfe/semi-ui';
 import {
   IllustrationNoResult,
@@ -141,31 +138,31 @@ const ChannelsTable = () => {
     time = time.toFixed(2) + t(' 秒');
     if (responseTime === 0) {
       return (
-        <Tag size='large' color='grey' shape='circle' prefixIcon={<TestTube size={14} />}>
+        <Tag size='large' color='grey' shape='circle'>
           {t('未测试')}
         </Tag>
       );
     } else if (responseTime <= 1000) {
       return (
-        <Tag size='large' color='green' shape='circle' prefixIcon={<Zap size={14} />}>
+        <Tag size='large' color='green' shape='circle'>
           {time}
         </Tag>
       );
     } else if (responseTime <= 3000) {
       return (
-        <Tag size='large' color='lime' shape='circle' prefixIcon={<Timer size={14} />}>
+        <Tag size='large' color='lime' shape='circle'>
           {time}
         </Tag>
       );
     } else if (responseTime <= 5000) {
       return (
-        <Tag size='large' color='yellow' shape='circle' prefixIcon={<Clock size={14} />}>
+        <Tag size='large' color='yellow' shape='circle'>
           {time}
         </Tag>
       );
     } else {
       return (
-        <Tag size='large' color='red' shape='circle' prefixIcon={<AlertTriangle size={14} />}>
+        <Tag size='large' color='red' shape='circle'>
           {time}
         </Tag>
       );
@@ -682,11 +679,10 @@ const ChannelsTable = () => {
   const [isBatchTesting, setIsBatchTesting] = useState(false);
   const [testQueue, setTestQueue] = useState([]);
   const [isProcessingQueue, setIsProcessingQueue] = useState(false);
-
-  // Form API 引用
+  const [activeTypeKey, setActiveTypeKey] = useState('all');
+  const [typeCounts, setTypeCounts] = useState({});
+  const requestCounter = useRef(0);
   const [formApi, setFormApi] = useState(null);
-
-  // Form 初始值
   const formInitValues = {
     searchKeyword: '',
     searchGroup: '',
@@ -868,17 +864,23 @@ const ChannelsTable = () => {
     setChannels(channelDates);
   };
 
-  const loadChannels = async (page, pageSize, idSort, enableTagMode) => {
+  const loadChannels = async (page, pageSize, idSort, enableTagMode, typeKey = activeTypeKey) => {
+    const reqId = ++requestCounter.current; // 记录当前请求序号
     setLoading(true);
+    const typeParam = typeKey === 'all' ? '' : `&type=${typeKey}`;
     const res = await API.get(
-      `/api/channel/?p=${page}&page_size=${pageSize}&id_sort=${idSort}&tag_mode=${enableTagMode}`,
+      `/api/channel/?p=${page}&page_size=${pageSize}&id_sort=${idSort}&tag_mode=${enableTagMode}${typeParam}`,
     );
-    if (res === undefined) {
+    if (res === undefined || reqId !== requestCounter.current) {
       return;
     }
     const { success, message, data } = res.data;
     if (success) {
-      const { items, total } = data;
+      const { items, total, type_counts } = data;
+      if (type_counts) {
+        const sumAll = Object.values(type_counts).reduce((acc, v) => acc + v, 0);
+        setTypeCounts({ ...type_counts, all: sumAll });
+      }
       setChannelFormat(items, enableTagMode);
       setChannelCount(total);
     } else {
@@ -1044,12 +1046,16 @@ const ChannelsTable = () => {
         return;
       }
 
+      const typeParam = activeTypeKey === 'all' ? '' : `&type=${activeTypeKey}`;
       const res = await API.get(
-        `/api/channel/search?keyword=${searchKeyword}&group=${searchGroup}&model=${searchModel}&id_sort=${idSort}&tag_mode=${enableTagMode}`,
+        `/api/channel/search?keyword=${searchKeyword}&group=${searchGroup}&model=${searchModel}&id_sort=${idSort}&tag_mode=${enableTagMode}${typeParam}`,
       );
       const { success, message, data } = res.data;
       if (success) {
-        setChannelFormat(data, enableTagMode);
+        const { items = [], type_counts = {} } = data;
+        const sumAll = Object.values(type_counts).reduce((acc, v) => acc + v, 0);
+        setTypeCounts({ ...type_counts, all: sumAll });
+        setChannelFormat(items, enableTagMode);
         setActivePage(1);
       } else {
         showError(message);
@@ -1179,7 +1185,92 @@ const ChannelsTable = () => {
     }
   };
 
+  const channelTypeCounts = useMemo(() => {
+    if (Object.keys(typeCounts).length > 0) return typeCounts;
+    // fallback 本地计算
+    const counts = { all: channels.length };
+    channels.forEach((channel) => {
+      const collect = (ch) => {
+        const type = ch.type;
+        counts[type] = (counts[type] || 0) + 1;
+      };
+      if (channel.children !== undefined) {
+        channel.children.forEach(collect);
+      } else {
+        collect(channel);
+      }
+    });
+    return counts;
+  }, [typeCounts, channels]);
+
+  const availableTypeKeys = useMemo(() => {
+    const keys = ['all'];
+    Object.entries(channelTypeCounts).forEach(([k, v]) => {
+      if (k !== 'all' && v > 0) keys.push(String(k));
+    });
+    return keys;
+  }, [channelTypeCounts]);
+
+  const renderTypeTabs = () => {
+    return (
+      <Tabs
+        activeKey={activeTypeKey}
+        type="card"
+        collapsible
+        onChange={(key) => {
+          setActiveTypeKey(key);
+          setActivePage(1);
+          loadChannels(1, pageSize, idSort, enableTagMode, key);
+        }}
+        className="mb-4"
+      >
+        <TabPane
+          itemKey="all"
+          tab={
+            <span className="flex items-center gap-2">
+              {t('全部')}
+              <Tag color={activeTypeKey === 'all' ? 'red' : 'grey'} size='small' shape='circle'>
+                {channelTypeCounts['all'] || 0}
+              </Tag>
+            </span>
+          }
+        />
+
+        {CHANNEL_OPTIONS.filter((opt) => availableTypeKeys.includes(String(opt.value))).map((option) => {
+          const key = String(option.value);
+          const count = channelTypeCounts[option.value] || 0;
+          return (
+            <TabPane
+              key={key}
+              itemKey={key}
+              tab={
+                <span className="flex items-center gap-2">
+                  {getChannelIcon(option.value)}
+                  {option.label}
+                  <Tag color={activeTypeKey === key ? 'red' : 'grey'} size='small' shape='circle'>
+                    {count}
+                  </Tag>
+                </span>
+              }
+            />
+          );
+        })}
+      </Tabs>
+    );
+  };
+
   let pageData = channels;
+  if (activeTypeKey !== 'all') {
+    const typeVal = parseInt(activeTypeKey);
+    if (!isNaN(typeVal)) {
+      pageData = pageData.filter((ch) => {
+        if (ch.children !== undefined) {
+          return ch.children.some((c) => c.type === typeVal);
+        }
+        return ch.type === typeVal;
+      });
+    }
+  }
 
   const handlePageChange = (page) => {
     setActivePage(page);
@@ -1371,6 +1462,7 @@ const ChannelsTable = () => {
 
   const renderHeader = () => (
     <div className="flex flex-col w-full">
+      {renderTypeTabs()}
       <div className="flex flex-col md:flex-row justify-between gap-4">
         <div className="flex flex-wrap md:flex-nowrap items-center gap-2 w-full md:w-auto order-2 md:order-1">
           <Button