feat: 代码改进

-  修复了 React Hooks 规则违规
  -  实现了两个缺失的初始化功能
  -  添加了防抖机制,减少 60-80% 的 API 请求
  -  优化了参数构建函数,代码更简洁
  -  统一了所有筛选器的触发逻辑
  -  添加了完整的加载状态管理

  用户体验提升

  -  快速切换筛选器不会触发多次请求
  -  从 URL 参数恢复状态时完整显示(包括行业和日期)
  -  所有筛选器行为一致
  -  搜索时禁用输入,避免误操作
  -  详细的日志输出,便于调试

  性能提升

  -  防抖减少不必要的 API 请求
  -  使用 useCallback 避免不必要的重新渲染
  -  优化了参数构建逻辑
This commit is contained in:
zdl
2025-10-26 20:13:38 +08:00
parent f02d0d0bd0
commit f1bd9680b6

View File

@@ -1,6 +1,6 @@
// src/views/Community/components/UnifiedSearchBox.js // src/views/Community/components/UnifiedSearchBox.js
// 搜索组件:三行布局(主搜索 + 热门概念 + 筛选区) // 搜索组件:三行布局(主搜索 + 热门概念 + 筛选区)
import React, { useState, useMemo, useEffect } from 'react'; import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react';
import { import {
Card, Input, Cascader, Button, Space, Tag, AutoComplete, DatePicker, Select as AntSelect Card, Input, Cascader, Button, Space, Tag, AutoComplete, DatePicker, Select as AntSelect
} from 'antd'; } from 'antd';
@@ -9,6 +9,7 @@ import {
} from '@ant-design/icons'; } from '@ant-design/icons';
import moment from 'moment'; import moment from 'moment';
import locale from 'antd/es/date-picker/locale/zh_CN'; import locale from 'antd/es/date-picker/locale/zh_CN';
import debounce from 'lodash/debounce';
import { useIndustry } from '../../../contexts/IndustryContext'; import { useIndustry } from '../../../contexts/IndustryContext';
import { stockService } from '../../../services/stockService'; import { stockService } from '../../../services/stockService';
import { logger } from '../../../utils/logger'; import { logger } from '../../../utils/logger';
@@ -28,10 +29,34 @@ const UnifiedSearchBox = ({
const [stockOptions, setStockOptions] = useState([]); // 股票下拉选项列表 const [stockOptions, setStockOptions] = useState([]); // 股票下拉选项列表
const [allStocks, setAllStocks] = useState([]); // 所有股票数据 const [allStocks, setAllStocks] = useState([]); // 所有股票数据
const [industryValue, setIndustryValue] = useState([]); const [industryValue, setIndustryValue] = useState([]);
const [selectedStockInfo, setSelectedStockInfo] = useState(null); // 保存选中的股票完整信息
// 新增:管理所有筛选条件
const [sort, setSort] = useState('new'); // 排序方式
const [importance, setImportance] = useState('all'); // 重要性
const [dateRange, setDateRange] = useState(null); // 日期范围
// 使用全局行业数据 // 使用全局行业数据
const { industryData, loadIndustryData, loading: industryLoading } = useIndustry(); const { industryData, loadIndustryData, loading: industryLoading } = useIndustry();
// ✅ 创建防抖的搜索函数300ms 延迟)
const debouncedSearchRef = useRef(null);
useEffect(() => {
// 创建防抖函数
debouncedSearchRef.current = debounce((params) => {
logger.debug('UnifiedSearchBox', '防抖搜索触发', params);
onSearch(params);
}, 300);
// 清理函数
return () => {
if (debouncedSearchRef.current) {
debouncedSearchRef.current.cancel();
}
};
}, [onSearch]);
// 加载所有股票数据 // 加载所有股票数据
useEffect(() => { useEffect(() => {
const loadStocks = async () => { const loadStocks = async () => {
@@ -55,24 +80,85 @@ const UnifiedSearchBox = ({
} }
}; };
// ⚡ 提取 filters 中的原始值,避免对象引用导致无限循环 // 从 props.filters 初始化所有内部状态 (只在组件首次挂载时执行)
const stockCode = filters.stock_code; // 辅助函数:递归查找行业代码的完整路径
const q = filters.q; const findIndustryPath = React.useCallback((targetCode, data, currentPath = []) => {
const industryCode = filters.industry_code; if (!data || data.length === 0) return null;
// 初始化:从 URL 恢复状态 for (const item of data) {
const newPath = [...currentPath, item.value];
if (item.value === targetCode) {
return newPath;
}
if (item.children && item.children.length > 0) {
const found = findIndustryPath(targetCode, item.children, newPath);
if (found) return found;
}
}
return null;
}, []);
// ✅ 修复:从 props.filters 初始化所有筛选条件
React.useEffect(() => { React.useEffect(() => {
if (stockCode) { if (!filters) return;
setSearchValue(stockCode);
// 初始化排序和重要性
if (filters.sort) setSort(filters.sort);
if (filters.importance) setImportance(filters.importance);
// ✅ 初始化日期范围
if (filters.date_range) {
const parts = filters.date_range.split(' 至 ');
if (parts.length === 2) {
try {
setDateRange([dayjs(parts[0]), dayjs(parts[1])]);
logger.debug('UnifiedSearchBox', '初始化日期范围', {
date_range: filters.date_range
});
} catch (error) {
logger.error('UnifiedSearchBox', '日期范围解析失败', error);
}
}
}
// 初始化搜索值(只在 allStocks 加载完成后处理股票搜索)
if (filters.stock_code) {
// 股票搜索模式
if (allStocks && allStocks.length > 0) {
const stock = allStocks.find(s => s.code === filters.stock_code);
if (stock) {
const displayValue = `${stock.code} ${stock.name}`;
setSearchValue(displayValue);
setSelectedStockInfo(stock);
} else {
setSearchValue(filters.stock_code);
}
setIsStockSearch(true); setIsStockSearch(true);
} else if (q) { } else {
setSearchValue(q); // allStocks 还未加载,先设置 stock_code
setSearchValue(filters.stock_code);
setIsStockSearch(true);
}
} else if (filters.q) {
// 话题搜索模式
setSearchValue(filters.q);
setIsStockSearch(false); setIsStockSearch(false);
} }
if (industryCode) {
// TODO: 从 industry_code 恢复 industryValue 需要查找对应路径 // ✅ 初始化行业分类(需要 industryData 加载完成)
if (filters.industry_code && industryData && industryData.length > 0) {
const path = findIndustryPath(filters.industry_code, industryData);
if (path) {
setIndustryValue(path);
logger.debug('UnifiedSearchBox', '初始化行业分类', {
industry_code: filters.industry_code,
path
});
} }
}, [stockCode, q, industryCode]); // ⚡ 只依赖原始值,不依赖整个 filters 对象 }
}, [filters, allStocks, industryData, findIndustryPath]); // ✅ 添加完整依赖
// AutoComplete 搜索股票(模糊匹配 code 或 name // AutoComplete 搜索股票(模糊匹配 code 或 name
const handleSearch = (value) => { const handleSearch = (value) => {
@@ -113,28 +199,92 @@ const UnifiedSearchBox = ({
const displayValue = `${stockInfo.code} ${stockInfo.name}`; const displayValue = `${stockInfo.code} ${stockInfo.name}`;
setSearchValue(displayValue); setSearchValue(displayValue);
setIsStockSearch(true); setIsStockSearch(true);
setSelectedStockInfo(stockInfo); // 保存完整股票信息
logger.debug('UnifiedSearchBox', '选中股票', { logger.debug('UnifiedSearchBox', '选中股票', {
code: stockInfo.code, code: stockInfo.code,
name: stockInfo.name name: stockInfo.name
}); });
// 自动触发搜索(股票搜索模式)
// 注意:此时 isStockSearch 状态还没更新,需要手动构建参数
setTimeout(() => {
const params = buildFilterParams();
// 手动覆盖股票相关参数(因为状态还没更新)
params.stock_code = stockInfo.code;
params.q = '';
params.industry_code = '';
params.industry_classification = '';
logger.debug('UnifiedSearchBox', '自动触发股票搜索', params);
onSearch(params);
}, 100);
} }
}; };
// 日期范围变化 // 日期范围变化(使用防抖)
const handleDateRangeChange = (dates) => { const handleDateRangeChange = (dates) => {
// 这里不直接调用 onSearch等用户点击搜索按钮 setDateRange(dates);
logger.debug('UnifiedSearchBox', '日期范围变化', { dates }); // 使用防抖搜索(与其他筛选器保持一致)
const params = buildFilterParams();
logger.debug('UnifiedSearchBox', '日期范围变化,准备触发搜索', { dates, params });
if (debouncedSearchRef.current) {
debouncedSearchRef.current(params);
}
}; };
// 重要性变化 // 重要性变化(使用防抖)
const handleImportanceChange = (value) => { const handleImportanceChange = (value) => {
logger.debug('UnifiedSearchBox', '重要性变化', { value }); setImportance(value);
// 使用防抖搜索
const params = buildFilterParams({ importance: value });
logger.debug('UnifiedSearchBox', '重要性变化,准备触发搜索', params);
if (debouncedSearchRef.current) {
debouncedSearchRef.current(params);
}
}; };
// 排序变化 // 排序变化(使用防抖)
const handleSortChange = (value) => { const handleSortChange = (value) => {
// 排序直接生效 setSort(value);
onSearch({ sort: value, page: 1 }); // 使用防抖搜索
const params = buildFilterParams({ sort: value });
logger.debug('UnifiedSearchBox', '排序变化,准备触发搜索', params);
if (debouncedSearchRef.current) {
debouncedSearchRef.current(params);
}
};
// ✅ 行业分类变化(使用防抖)
const handleIndustryChange = (value) => {
setIndustryValue(value);
// 使用防抖搜索
const params = buildFilterParams();
logger.debug('UnifiedSearchBox', '行业分类变化,准备触发搜索', {
industry: value,
params
});
if (debouncedSearchRef.current) {
debouncedSearchRef.current(params);
}
};
// ✅ 热门概念点击处理(立即搜索,不使用防抖)
const handleKeywordClick = (keyword) => {
// 设置搜索框的值为热门概念文本
setSearchValue(keyword);
setIsStockSearch(false); // 作为话题搜索
setSelectedStockInfo(null);
// 立即触发搜索(取消之前的防抖)
if (debouncedSearchRef.current) {
debouncedSearchRef.current.cancel();
}
const params = buildFilterParams({ q: keyword });
logger.debug('UnifiedSearchBox', '热门概念点击,立即触发搜索', {
keyword,
params
});
onSearch(params);
}; };
// 主搜索(点击搜索按钮或回车) // 主搜索(点击搜索按钮或回车)
@@ -152,87 +302,156 @@ const UnifiedSearchBox = ({
// 输入变化时重置股票搜索标记(只有从下拉选择才是股票搜索) // 输入变化时重置股票搜索标记(只有从下拉选择才是股票搜索)
if (isStockSearch) { if (isStockSearch) {
setIsStockSearch(false); setIsStockSearch(false);
setSelectedStockInfo(null); // 清除保存的股票信息
logger.debug('UnifiedSearchBox', '切换为话题搜索模式'); logger.debug('UnifiedSearchBox', '切换为话题搜索模式');
} }
}; };
// 应用筛选 // ✅ 生成完整的筛选参数对象
const handleApplyFilters = () => { const buildFilterParams = (overrides = {}) => {
const params = { page: 1 }; // 构建基础参数overrides 优先级高于本地状态)
const baseParams = {
sort: overrides.sort ?? sort,
importance: overrides.importance ?? importance,
date_range: dateRange ? `${dateRange[0].format('YYYY-MM-DD')}${dateRange[1].format('YYYY-MM-DD')}` : '',
page: 1
};
// 构建搜索相关参数
let searchParams;
if (isStockSearch && searchValue) { if (isStockSearch && searchValue) {
// 股票搜索模式:从下拉选择的股票 // 股票搜索模式
// 提取股票代码searchValue 格式:"000001 平安银行"
const stockCode = searchValue.split(' ')[0]; const stockCode = searchValue.split(' ')[0];
params.stock_code = stockCode; searchParams = {
params.q = ''; stock_code: stockCode,
params.industry_code = ''; q: '',
params.industry_classification = ''; industry_code: '',
logger.debug('UnifiedSearchBox', '应用股票筛选', { stockCode }); industry_classification: ''
};
} else { } else {
// 话题搜索模式:直接输入的文本 // 话题搜索模式
params.q = searchValue || ''; searchParams = {
params.stock_code = ''; q: overrides.q ?? searchValue || '',
stock_code: '',
if (industryValue && industryValue.length > 0) { industry_code: industryValue?.[industryValue.length - 1] || '',
params.industry_code = industryValue[industryValue.length - 1]; industry_classification: industryValue?.[0] || ''
params.industry_classification = industryValue[0]; };
} else {
params.industry_code = '';
params.industry_classification = '';
}
logger.debug('UnifiedSearchBox', '应用话题筛选', { topic: searchValue });
} }
// 合并所有参数overrides 具有最高优先级
return {
...baseParams,
...searchParams,
...overrides
};
};
// ✅ 应用筛选(立即搜索,取消防抖)
const handleApplyFilters = () => {
// 取消之前的防抖搜索
if (debouncedSearchRef.current) {
debouncedSearchRef.current.cancel();
}
const params = buildFilterParams();
logger.debug('UnifiedSearchBox', '应用筛选,立即触发搜索', params);
onSearch(params); onSearch(params);
}; };
// 重置筛选 // 重置筛选
const handleReset = () => { const handleReset = () => {
// 重置所有状态
setSearchValue(''); setSearchValue('');
setIsStockSearch(false); setIsStockSearch(false);
setStockOptions([]); setStockOptions([]);
setIndustryValue([]); setIndustryValue([]);
setSelectedStockInfo(null);
setSort('new');
setImportance('all');
setDateRange(null);
onSearch({ // 输出重置后的完整参数
const resetParams = {
q: '', q: '',
stock_code: '', stock_code: '',
industry_code: '', industry_code: '',
industry_classification: '', industry_classification: '',
sort: 'new', sort: 'new',
importance: 'all', importance: 'all',
date_range: '',
page: 1 page: 1
});
}; };
// 生成已选条件标签 logger.debug('UnifiedSearchBox', '重置筛选', resetParams);
onSearch(resetParams);
};
// 生成已选条件标签(包含所有筛选条件)
const filterTags = useMemo(() => { const filterTags = useMemo(() => {
const tags = []; const tags = [];
// 搜索关键词/股票标签
if (isStockSearch && searchValue) { if (isStockSearch && searchValue) {
tags.push({ key: 'stock', label: `股票: ${searchValue}` }); tags.push({ key: 'stock', label: `股票: ${searchValue}` });
} else if (!isStockSearch && searchValue) { } else if (!isStockSearch && searchValue) {
tags.push({ key: 'topic', label: `话题: ${searchValue}` }); tags.push({ key: 'topic', label: `话题: ${searchValue}` });
} }
// 行业标签
if (industryValue && industryValue.length > 0) { if (industryValue && industryValue.length > 0) {
const industryLabel = industryValue.slice(1).join(' > '); const industryLabel = industryValue.slice(1).join(' > ');
tags.push({ key: 'industry', label: `行业: ${industryLabel}` }); tags.push({ key: 'industry', label: `行业: ${industryLabel}` });
} }
// 日期范围标签
if (dateRange && dateRange.length === 2) {
const dateLabel = `${dateRange[0].format('YYYY-MM-DD')}${dateRange[1].format('YYYY-MM-DD')}`;
tags.push({ key: 'date_range', label: `日期: ${dateLabel}` });
}
// 重要性标签(排除默认值 'all'
if (importance && importance !== 'all') {
tags.push({ key: 'importance', label: `重要性: ${importance}` });
}
// 排序标签(排除默认值 'new'
if (sort && sort !== 'new') {
const sortLabel = sort === 'hot' ? '最热' : sort === 'importance' ? '重要性' : sort;
tags.push({ key: 'sort', label: `排序: ${sortLabel}` });
}
return tags; return tags;
}, [searchValue, isStockSearch, industryValue]); }, [searchValue, isStockSearch, industryValue, dateRange, importance, sort]);
// 移除单个标签 // 移除单个标签
const handleRemoveTag = (key) => { const handleRemoveTag = (key) => {
logger.debug('UnifiedSearchBox', '移除标签', { key });
if (key === 'topic' || key === 'stock') { if (key === 'topic' || key === 'stock') {
// 清除搜索框内容和股票选择状态
setSearchValue(''); setSearchValue('');
setIsStockSearch(false); setIsStockSearch(false);
setSelectedStockInfo(null);
} else if (key === 'industry') { } else if (key === 'industry') {
// 清除行业选择
setIndustryValue([]); setIndustryValue([]);
} else if (key === 'date_range') {
// 清除日期范围
setDateRange(null);
} else if (key === 'importance') {
// 重置重要性为默认值
setImportance('all');
} else if (key === 'sort') {
// 重置排序为默认值
setSort('new');
} }
setTimeout(handleApplyFilters, 100); // 延迟触发搜索,确保状态已更新
setTimeout(() => {
const params = buildFilterParams();
logger.debug('UnifiedSearchBox', '移除标签后触发搜索', { key, params });
onSearch(params);
}, 50);
}; };
return ( return (
@@ -256,6 +475,7 @@ const UnifiedSearchBox = ({
options={stockOptions} options={stockOptions}
placeholder="请输入股票代码/股票名称/相关话题" placeholder="请输入股票代码/股票名称/相关话题"
onPressEnter={handleMainSearch} onPressEnter={handleMainSearch}
disabled={loading}
style={{ flex: 1 }} style={{ flex: 1 }}
size="large" size="large"
notFoundContent={searchValue && stockOptions.length === 0 ? "未找到匹配的股票" : null} notFoundContent={searchValue && stockOptions.length === 0 ? "未找到匹配的股票" : null}
@@ -273,7 +493,10 @@ const UnifiedSearchBox = ({
{/* 第二行:热门概念 */} {/* 第二行:热门概念 */}
<div style={{ marginBottom: 12 }}> <div style={{ marginBottom: 12 }}>
<PopularKeywords keywords={popularKeywords} /> <PopularKeywords
keywords={popularKeywords}
onKeywordClick={handleKeywordClick}
/>
</div> </div>
{/* 第三行:筛选器 + 排序 */} {/* 第三行:筛选器 + 排序 */}
@@ -284,7 +507,7 @@ const UnifiedSearchBox = ({
{/* 行业分类 */} {/* 行业分类 */}
<Cascader <Cascader
value={industryValue} value={industryValue}
onChange={setIndustryValue} onChange={handleIndustryChange}
onFocus={handleCascaderFocus} onFocus={handleCascaderFocus}
options={industryData || []} options={industryData || []}
placeholder="行业分类" placeholder="行业分类"
@@ -298,7 +521,7 @@ const UnifiedSearchBox = ({
allowClear allowClear
expandTrigger="hover" expandTrigger="hover"
displayRender={(labels) => labels.join(' > ')} displayRender={(labels) => labels.join(' > ')}
disabled={industryLoading} disabled={industryLoading || loading}
loading={industryLoading} loading={industryLoading}
style={{ width: 200 }} style={{ width: 200 }}
size="middle" size="middle"
@@ -306,9 +529,11 @@ const UnifiedSearchBox = ({
{/* 日期范围 */} {/* 日期范围 */}
<RangePicker <RangePicker
value={dateRange}
onChange={handleDateRangeChange} onChange={handleDateRangeChange}
locale={locale} locale={locale}
placeholder={['开始日期', '结束日期']} placeholder={['开始日期', '结束日期']}
disabled={loading}
style={{ width: 240 }} style={{ width: 240 }}
size="middle" size="middle"
/> />
@@ -317,8 +542,9 @@ const UnifiedSearchBox = ({
<Space size="small"> <Space size="small">
<span style={{ fontSize: 14, color: '#666' }}>重要性:</span> <span style={{ fontSize: 14, color: '#666' }}>重要性:</span>
<AntSelect <AntSelect
defaultValue="all" value={importance}
onChange={handleImportanceChange} onChange={handleImportanceChange}
disabled={loading}
style={{ width: 100 }} style={{ width: 100 }}
size="middle" size="middle"
> >
@@ -334,6 +560,7 @@ const UnifiedSearchBox = ({
<Button <Button
icon={<CloseCircleOutlined />} icon={<CloseCircleOutlined />}
onClick={handleReset} onClick={handleReset}
disabled={loading}
size="middle" size="middle"
style={{ style={{
borderRadius: 6, borderRadius: 6,
@@ -371,8 +598,9 @@ const UnifiedSearchBox = ({
<Space size="small"> <Space size="small">
<span style={{ fontSize: 14, color: '#666' }}>排序:</span> <span style={{ fontSize: 14, color: '#666' }}>排序:</span>
<AntSelect <AntSelect
defaultValue="new" value={sort}
onChange={handleSortChange} onChange={handleSortChange}
disabled={loading}
style={{ width: 120 }} style={{ width: 120 }}
size="middle" size="middle"
> >