🚀 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.
This commit is contained in:
@@ -52,6 +52,14 @@ func GetAllChannels(c *gin.Context) {
|
|||||||
channelData := make([]*model.Channel, 0)
|
channelData := make([]*model.Channel, 0)
|
||||||
idSort, _ := strconv.ParseBool(c.Query("id_sort"))
|
idSort, _ := strconv.ParseBool(c.Query("id_sort"))
|
||||||
enableTagMode, _ := strconv.ParseBool(c.Query("tag_mode"))
|
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
|
var total int64
|
||||||
|
|
||||||
@@ -72,6 +80,14 @@ func GetAllChannels(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
// 计算 tag 总数用于分页
|
// 计算 tag 总数用于分页
|
||||||
total, _ = model.CountAllTags()
|
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 {
|
} else {
|
||||||
channels, err := model.GetAllChannels((p-1)*pageSize, pageSize, false, idSort)
|
channels, err := model.GetAllChannels((p-1)*pageSize, pageSize, false, idSort)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -82,14 +98,18 @@ func GetAllChannels(c *gin.Context) {
|
|||||||
total, _ = model.CountAllChannels()
|
total, _ = model.CountAllChannels()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// calculate type counts
|
||||||
|
typeCounts, _ := model.CountChannelsGroupByType()
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": true,
|
"success": true,
|
||||||
"message": "",
|
"message": "",
|
||||||
"data": gin.H{
|
"data": gin.H{
|
||||||
"items": channelData,
|
"items": channelData,
|
||||||
"total": total,
|
"total": total,
|
||||||
"page": p,
|
"page": p,
|
||||||
"page_size": pageSize,
|
"page_size": pageSize,
|
||||||
|
"type_counts": typeCounts,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -597,3 +597,39 @@ func CountAllTags() (int64, error) {
|
|||||||
err := DB.Model(&Channel{}).Where("tag is not null AND tag != ''").Distinct("tag").Count(&total).Error
|
err := DB.Model(&Channel{}).Where("tag is not null AND tag != ''").Distinct("tag").Count(&total).Error
|
||||||
return total, err
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState, useMemo, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
API,
|
API,
|
||||||
showError,
|
showError,
|
||||||
@@ -16,11 +16,6 @@ import {
|
|||||||
XCircle,
|
XCircle,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
TestTube,
|
|
||||||
Zap,
|
|
||||||
Timer,
|
|
||||||
Clock,
|
|
||||||
AlertTriangle,
|
|
||||||
Coins,
|
Coins,
|
||||||
Tags
|
Tags
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
@@ -43,7 +38,9 @@ import {
|
|||||||
Typography,
|
Typography,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
Card,
|
Card,
|
||||||
Form
|
Form,
|
||||||
|
Tabs,
|
||||||
|
TabPane
|
||||||
} from '@douyinfe/semi-ui';
|
} from '@douyinfe/semi-ui';
|
||||||
import {
|
import {
|
||||||
IllustrationNoResult,
|
IllustrationNoResult,
|
||||||
@@ -141,31 +138,31 @@ const ChannelsTable = () => {
|
|||||||
time = time.toFixed(2) + t(' 秒');
|
time = time.toFixed(2) + t(' 秒');
|
||||||
if (responseTime === 0) {
|
if (responseTime === 0) {
|
||||||
return (
|
return (
|
||||||
<Tag size='large' color='grey' shape='circle' prefixIcon={<TestTube size={14} />}>
|
<Tag size='large' color='grey' shape='circle'>
|
||||||
{t('未测试')}
|
{t('未测试')}
|
||||||
</Tag>
|
</Tag>
|
||||||
);
|
);
|
||||||
} else if (responseTime <= 1000) {
|
} else if (responseTime <= 1000) {
|
||||||
return (
|
return (
|
||||||
<Tag size='large' color='green' shape='circle' prefixIcon={<Zap size={14} />}>
|
<Tag size='large' color='green' shape='circle'>
|
||||||
{time}
|
{time}
|
||||||
</Tag>
|
</Tag>
|
||||||
);
|
);
|
||||||
} else if (responseTime <= 3000) {
|
} else if (responseTime <= 3000) {
|
||||||
return (
|
return (
|
||||||
<Tag size='large' color='lime' shape='circle' prefixIcon={<Timer size={14} />}>
|
<Tag size='large' color='lime' shape='circle'>
|
||||||
{time}
|
{time}
|
||||||
</Tag>
|
</Tag>
|
||||||
);
|
);
|
||||||
} else if (responseTime <= 5000) {
|
} else if (responseTime <= 5000) {
|
||||||
return (
|
return (
|
||||||
<Tag size='large' color='yellow' shape='circle' prefixIcon={<Clock size={14} />}>
|
<Tag size='large' color='yellow' shape='circle'>
|
||||||
{time}
|
{time}
|
||||||
</Tag>
|
</Tag>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<Tag size='large' color='red' shape='circle' prefixIcon={<AlertTriangle size={14} />}>
|
<Tag size='large' color='red' shape='circle'>
|
||||||
{time}
|
{time}
|
||||||
</Tag>
|
</Tag>
|
||||||
);
|
);
|
||||||
@@ -682,11 +679,10 @@ const ChannelsTable = () => {
|
|||||||
const [isBatchTesting, setIsBatchTesting] = useState(false);
|
const [isBatchTesting, setIsBatchTesting] = useState(false);
|
||||||
const [testQueue, setTestQueue] = useState([]);
|
const [testQueue, setTestQueue] = useState([]);
|
||||||
const [isProcessingQueue, setIsProcessingQueue] = useState(false);
|
const [isProcessingQueue, setIsProcessingQueue] = useState(false);
|
||||||
|
const [activeTypeKey, setActiveTypeKey] = useState('all');
|
||||||
// Form API 引用
|
const [typeCounts, setTypeCounts] = useState({});
|
||||||
|
const requestCounter = useRef(0);
|
||||||
const [formApi, setFormApi] = useState(null);
|
const [formApi, setFormApi] = useState(null);
|
||||||
|
|
||||||
// Form 初始值
|
|
||||||
const formInitValues = {
|
const formInitValues = {
|
||||||
searchKeyword: '',
|
searchKeyword: '',
|
||||||
searchGroup: '',
|
searchGroup: '',
|
||||||
@@ -868,17 +864,23 @@ const ChannelsTable = () => {
|
|||||||
setChannels(channelDates);
|
setChannels(channelDates);
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadChannels = async (page, pageSize, idSort, enableTagMode) => {
|
const loadChannels = async (page, pageSize, idSort, enableTagMode, typeKey = activeTypeKey) => {
|
||||||
|
const reqId = ++requestCounter.current; // 记录当前请求序号
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
const typeParam = typeKey === 'all' ? '' : `&type=${typeKey}`;
|
||||||
const res = await API.get(
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const { success, message, data } = res.data;
|
const { success, message, data } = res.data;
|
||||||
if (success) {
|
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);
|
setChannelFormat(items, enableTagMode);
|
||||||
setChannelCount(total);
|
setChannelCount(total);
|
||||||
} else {
|
} else {
|
||||||
@@ -1044,12 +1046,16 @@ const ChannelsTable = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const typeParam = activeTypeKey === 'all' ? '' : `&type=${activeTypeKey}`;
|
||||||
const res = await API.get(
|
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;
|
const { success, message, data } = res.data;
|
||||||
if (success) {
|
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);
|
setActivePage(1);
|
||||||
} else {
|
} else {
|
||||||
showError(message);
|
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;
|
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) => {
|
const handlePageChange = (page) => {
|
||||||
setActivePage(page);
|
setActivePage(page);
|
||||||
@@ -1371,6 +1462,7 @@ const ChannelsTable = () => {
|
|||||||
|
|
||||||
const renderHeader = () => (
|
const renderHeader = () => (
|
||||||
<div className="flex flex-col w-full">
|
<div className="flex flex-col w-full">
|
||||||
|
{renderTypeTabs()}
|
||||||
<div className="flex flex-col md:flex-row justify-between gap-4">
|
<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">
|
<div className="flex flex-wrap md:flex-nowrap items-center gap-2 w-full md:w-auto order-2 md:order-1">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
Reference in New Issue
Block a user