فهرست منبع

🚀 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 10 ماه پیش
والد
کامیت
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