Files
vf_react/src/views/Community/components/UnifiedSearchBox.js
2025-11-06 12:40:58 +08:00

765 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// src/views/Community/components/UnifiedSearchBox.js
// 搜索组件:三行布局(主搜索 + 热门概念 + 筛选区)
import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react';
import {
Card, Input, Cascader, Button, Space, Tag, AutoComplete, Select as AntSelect
} from 'antd';
import {
SearchOutlined, CloseCircleOutlined, StockOutlined
} from '@ant-design/icons';
import dayjs from 'dayjs';
import debounce from 'lodash/debounce';
import { useSelector, useDispatch } from 'react-redux';
import { fetchIndustryData, selectIndustryData, selectIndustryLoading } from '../../../store/slices/industrySlice';
import { stockService } from '../../../services/stockService';
import { logger } from '../../../utils/logger';
import PopularKeywords from './PopularKeywords';
import TradingTimeFilter from './TradingTimeFilter';
const { Option } = AntSelect;
const UnifiedSearchBox = ({
onSearch,
onSearchFocus,
popularKeywords = [],
filters = {}
}) => {
// 其他状态
const [stockOptions, setStockOptions] = useState([]); // 股票下拉选项列表
const [allStocks, setAllStocks] = useState([]); // 所有股票数据
const [industryValue, setIndustryValue] = useState([]);
// 筛选条件状态
const [sort, setSort] = useState('new'); // 排序方式
const [importance, setImportance] = useState([]); // 重要性(数组,支持多选)
const [tradingTimeRange, setTradingTimeRange] = useState(null); // 交易时段筛选
// ✅ 本地输入状态 - 管理用户的实时输入
const [inputValue, setInputValue] = useState('');
// 使用 Redux 获取行业数据
const dispatch = useDispatch();
const industryData = useSelector(selectIndustryData);
const industryLoading = useSelector(selectIndustryLoading);
// 加载行业数据函数
const loadIndustryData = useCallback(() => {
if (!industryData) {
dispatch(fetchIndustryData());
}
}, [dispatch, industryData]);
// 搜索触发函数
const triggerSearch = useCallback((params) => {
logger.debug('UnifiedSearchBox', '【5/5】✅ 最终触发搜索 - 调用onSearch回调', {
params: params,
timestamp: new Date().toISOString()
});
onSearch(params);
}, [onSearch]);
// ✅ 创建防抖的搜索函数300ms 延迟)
const debouncedSearchRef = useRef(null);
useEffect(() => {
// 创建防抖函数,使用 triggerSearch 而不是直接调用 onSearch
debouncedSearchRef.current = debounce((params) => {
logger.debug('UnifiedSearchBox', '⏱️ 防抖延迟结束,执行搜索', {
params: params,
delayMs: 300
});
triggerSearch(params);
}, 300);
// 清理函数
return () => {
if (debouncedSearchRef.current) {
debouncedSearchRef.current.cancel();
}
};
}, [triggerSearch]);
// 加载所有股票数据
useEffect(() => {
const loadStocks = async () => {
const response = await stockService.getAllStocks();
if (response.success && response.data) {
setAllStocks(response.data);
logger.debug('UnifiedSearchBox', '股票数据加载成功', {
count: response.data.length
});
}
};
loadStocks();
}, []);
// Cascader 获得焦点时加载数据
const handleCascaderFocus = async () => {
if (!industryData || industryData.length === 0) {
logger.debug('UnifiedSearchBox', 'Cascader 获得焦点,开始加载行业数据');
await loadIndustryData();
}
};
// 从 props.filters 初始化所有内部状态 (只在组件首次挂载时执行)
// 辅助函数:递归查找行业代码的完整路径
const findIndustryPath = React.useCallback((targetCode, data, currentPath = []) => {
if (!data || data.length === 0) return null;
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 初始化筛选条件和输入框值
useEffect(() => {
if (!filters) return;
// 初始化排序
if (filters.sort) setSort(filters.sort);
// 初始化重要性(字符串解析为数组)
if (filters.importance) {
const importanceArray = filters.importance === 'all'
? [] // 'all' 对应空数组(不显示任何选中)
: filters.importance.split(',').map(v => v.trim()).filter(Boolean);
setImportance(importanceArray);
logger.debug('UnifiedSearchBox', '初始化重要性', {
filters_importance: filters.importance,
importanceArray
});
} else {
setImportance([]);
}
// ✅ 初始化行业分类(需要 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
});
}
}
// ✅ 同步 filters.q 到输入框显示值
if (filters.q) {
setInputValue(filters.q);
} else if (!filters.q) {
// 如果 filters 中没有搜索关键词,清空输入框
setInputValue('');
}
}, [filters.sort, filters.importance, filters.industry_code, filters.q, industryData, findIndustryPath]);
// AutoComplete 搜索股票(模糊匹配 code 或 name
const handleSearch = (value) => {
if (!value || !allStocks || allStocks.length === 0) {
setStockOptions([]);
return;
}
// 使用 stockService 进行模糊搜索
const results = stockService.fuzzySearch(value, allStocks, 10);
// 转换为 AutoComplete 选项格式
const options = results.map(stock => ({
value: stock.code,
label: (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<StockOutlined style={{ color: '#1890ff' }} />
<span style={{ fontWeight: 500, color: '#333' }}>{stock.code}</span>
<span style={{ color: '#666' }}>{stock.name}</span>
</div>
),
// 保存完整的股票信息,用于选中后显示
stockInfo: stock
}));
setStockOptions(options);
logger.debug('UnifiedSearchBox', '股票模糊搜索', {
query: value,
resultCount: options.length
});
};
// ✅ 选中股票(从下拉选择) - 更新输入框并触发搜索
const handleStockSelect = (_value, option) => {
const stockInfo = option.stockInfo;
if (stockInfo) {
logger.debug('UnifiedSearchBox', '选中股票', {
code: stockInfo.code,
name: stockInfo.name
});
// 更新输入框显示
setInputValue(`${stockInfo.code} ${stockInfo.name}`);
// 直接构建参数并触发搜索 - 使用股票代码作为 q 参数
const params = buildFilterParams({
q: stockInfo.code, // 使用股票代码作为搜索关键词
industry_code: ''
});
logger.debug('UnifiedSearchBox', '自动触发股票搜索', params);
triggerSearch(params);
}
};
// ✅ 重要性变化(立即执行)- 支持多选
const handleImportanceChange = (value) => {
logger.debug('UnifiedSearchBox', '重要性值改变', {
oldValue: importance,
newValue: value
});
setImportance(value);
// 取消之前的防抖搜索
if (debouncedSearchRef.current) {
debouncedSearchRef.current.cancel();
}
// 转换为逗号分隔字符串传给后端(空数组表示"全部"
const importanceStr = value.length === 0 ? 'all' : value.join(',');
// 立即触发搜索
const params = buildFilterParams({ importance: importanceStr });
logger.debug('UnifiedSearchBox', '重要性改变,立即触发搜索', params);
triggerSearch(params);
};
// ✅ 排序变化(使用防抖)
const handleSortChange = (value) => {
logger.debug('UnifiedSearchBox', '【1/5】排序值改变', {
oldValue: sort,
newValue: value
});
setSort(value);
// ⚠️ 注意:setState是异步的,此时sort仍是旧值
logger.debug('UnifiedSearchBox', '【2/5】调用buildFilterParams前的状态', {
sort: sort, // 旧值
importance: importance,
dateRange: dateRange,
industryValue: industryValue
});
// 使用防抖搜索
const params = buildFilterParams({ sort: value });
logger.debug('UnifiedSearchBox', '【3/5】buildFilterParams返回的参数', params);
if (debouncedSearchRef.current) {
logger.debug('UnifiedSearchBox', '【4/5】调用防抖函数(300ms延迟)');
debouncedSearchRef.current(params);
}
};
// ✅ 行业分类变化(使用防抖)
const handleIndustryChange = (value) => {
logger.debug('UnifiedSearchBox', '【1/5】行业分类值改变', {
oldValue: industryValue,
newValue: value
});
setIndustryValue(value);
// ⚠️ 注意:setState是异步的,此时industryValue仍是旧值
logger.debug('UnifiedSearchBox', '【2/5】调用buildFilterParams前的状态', {
industryValue: industryValue, // 旧值
sort: sort,
importance: importance,
dateRange: dateRange
});
// 使用防抖搜索 (需要从新值推导参数)
const params = {
...buildFilterParams(),
industry_code: value?.[value.length - 1] || ''
};
logger.debug('UnifiedSearchBox', '【3/5】buildFilterParams返回的参数', params);
if (debouncedSearchRef.current) {
logger.debug('UnifiedSearchBox', '【4/5】调用防抖函数(300ms延迟)');
debouncedSearchRef.current(params);
}
};
// ✅ 热门概念点击处理(立即搜索,不使用防抖) - 更新输入框并触发搜索
const handleKeywordClick = (keyword) => {
// 更新输入框显示
setInputValue(keyword);
// 立即触发搜索(取消之前的防抖)
if (debouncedSearchRef.current) {
debouncedSearchRef.current.cancel();
}
const params = buildFilterParams({
q: keyword,
industry_code: ''
});
logger.debug('UnifiedSearchBox', '热门概念点击,立即触发搜索', {
keyword,
params
});
triggerSearch(params);
};
// ✅ 交易时段筛选变化(立即触发搜索)
const handleTradingTimeChange = (timeConfig) => {
if (!timeConfig) {
// 清空筛选
setTradingTimeRange(null);
const params = buildFilterParams({
start_date: '',
end_date: '',
recent_days: ''
});
triggerSearch(params);
return;
}
const { range, type, label, key } = timeConfig;
let params = {};
if (type === 'recent_days') {
// 近一周/近一月使用 recent_days
params.recent_days = range;
params.start_date = '';
params.end_date = '';
} else {
// 其他使用 start_date + end_date
params.start_date = range[0].format('YYYY-MM-DD HH:mm:ss');
params.end_date = range[1].format('YYYY-MM-DD HH:mm:ss');
params.recent_days = '';
}
setTradingTimeRange({ ...params, label, key });
// 立即触发搜索
const searchParams = buildFilterParams(params);
logger.debug('UnifiedSearchBox', '交易时段筛选变化,立即触发搜索', {
timeConfig,
params: searchParams
});
triggerSearch(searchParams);
};
// 主搜索(点击搜索按钮或回车)
const handleMainSearch = () => {
// 取消之前的防抖
if (debouncedSearchRef.current) {
debouncedSearchRef.current.cancel();
}
// 构建参数并触发搜索 - 使用用户输入作为 q 参数
const params = buildFilterParams({
q: inputValue, // 使用用户输入(可能是话题、股票代码、股票名称等)
industry_code: ''
});
logger.debug('UnifiedSearchBox', '主搜索触发', {
inputValue,
params
});
triggerSearch(params);
};
// ✅ 处理输入变化 - 更新本地输入状态
const handleInputChange = (value) => {
logger.debug('UnifiedSearchBox', '输入变化', { value });
setInputValue(value);
};
// ✅ 生成完整的筛选参数对象 - 直接从 filters 和本地筛选器状态构建
const buildFilterParams = useCallback((overrides = {}) => {
logger.debug('UnifiedSearchBox', '🔧 buildFilterParams - 输入参数', {
overrides: overrides,
currentState: {
sort,
importance,
industryValue,
'filters.q': filters.q
}
});
// 处理排序参数 - 将 returns_avg/returns_week 转换为 sort=returns + return_type
const sortValue = overrides.sort ?? sort;
let actualSort = sortValue;
let returnType;
if (sortValue === 'returns_avg') {
actualSort = 'returns';
returnType = 'avg';
} else if (sortValue === 'returns_week') {
actualSort = 'returns';
returnType = 'week';
}
// 处理重要性参数:数组转换为逗号分隔字符串
let importanceValue = overrides.importance ?? importance;
if (Array.isArray(importanceValue)) {
importanceValue = importanceValue.length === 0
? 'all'
: importanceValue.join(',');
}
const result = {
// 基础参数overrides 优先级高于本地状态)
sort: actualSort,
importance: importanceValue,
page: 1,
// 搜索参数: 统一使用 q 参数进行搜索(话题/股票/关键词)
q: (overrides.q ?? filters.q) ?? '',
// 行业代码: 取选中路径的最后一级(最具体的行业代码)
industry_code: overrides.industry_code ?? (industryValue?.[industryValue.length - 1] || ''),
// 交易时段筛选参数
start_date: overrides.start_date ?? (tradingTimeRange?.start_date || ''),
end_date: overrides.end_date ?? (tradingTimeRange?.end_date || ''),
recent_days: overrides.recent_days ?? (tradingTimeRange?.recent_days || ''),
// 最终 overrides 具有最高优先级
...overrides
};
// 添加 return_type 参数(如果需要)
if (returnType) {
result.return_type = returnType;
}
logger.debug('UnifiedSearchBox', '🔧 buildFilterParams - 输出结果', result);
return result;
}, [sort, importance, filters.q, industryValue, tradingTimeRange]);
// ✅ 重置筛选 - 清空所有筛选器并触发搜索
const handleReset = () => {
// 重置所有筛选器状态
setInputValue(''); // 清空输入框
setStockOptions([]);
setIndustryValue([]);
setSort('new');
setImportance([]); // 改为空数组
setTradingTimeRange(null); // 清空交易时段筛选
// 输出重置后的完整参数
const resetParams = {
q: '',
industry_code: '',
sort: 'new',
importance: 'all', // 传给后端时转为'all'
start_date: '',
end_date: '',
recent_days: '',
page: 1
};
logger.debug('UnifiedSearchBox', '重置筛选', resetParams);
onSearch(resetParams);
};
// 生成已选条件标签(包含所有筛选条件) - 从 filters 和本地状态读取
const filterTags = useMemo(() => {
const tags = [];
// 搜索关键词标签 - 从 filters.q 读取
if (filters.q) {
tags.push({ key: 'search', label: `搜索: ${filters.q}` });
}
// 行业标签
if (industryValue && industryValue.length > 0 && industryData) {
// 递归查找每个层级的 label
const findLabel = (code, data) => {
for (const item of data) {
if (code.startsWith(item.value)) {
if (item.value === code) {
return item.label;
} else {
return findLabel(code, item.children);
}
}
}
return null;
};
// 只显示最后一级的 label
const lastLevelCode = industryValue[industryValue.length - 1];
const lastLevelLabel = findLabel(lastLevelCode, industryData);
tags.push({
key: 'industry',
label: `行业: ${lastLevelLabel}`
});
}
// 交易时段筛选标签
if (tradingTimeRange?.label) {
tags.push({
key: 'trading_time',
label: `时间: ${tradingTimeRange.label}`
});
}
// 重要性标签(多选合并显示为单个标签)
if (importance && importance.length > 0) {
const importanceLabel = importance.map(imp => `${imp}`).join(', ');
tags.push({ key: 'importance', label: `重要性: ${importanceLabel}` });
}
// 排序标签(排除默认值 'new'
if (sort && sort !== 'new') {
let sortLabel;
if (sort === 'hot') sortLabel = '最热';
else if (sort === 'importance') sortLabel = '重要性';
else if (sort === 'returns_avg') sortLabel = '平均收益率';
else if (sort === 'returns_week') sortLabel = '周收益率';
else sortLabel = sort;
tags.push({ key: 'sort', label: `排序: ${sortLabel}` });
}
return tags;
}, [filters.q, industryValue, importance, sort, tradingTimeRange]);
// ✅ 移除单个标签 - 构建新参数并触发搜索
const handleRemoveTag = (key) => {
logger.debug('UnifiedSearchBox', '移除标签', { key });
// 取消所有待执行的防抖搜索(避免旧的防抖覆盖删除操作)
if (debouncedSearchRef.current) {
debouncedSearchRef.current.cancel();
}
if (key === 'search') {
// 清除搜索关键词和输入框,立即触发搜索
setInputValue(''); // 清空输入框
const params = buildFilterParams({ q: '' });
logger.debug('UnifiedSearchBox', '移除搜索标签后触发搜索', { key, params });
triggerSearch(params);
} else if (key === 'industry') {
// 清除行业选择
setIndustryValue([]);
const params = buildFilterParams({ industry_code: '' });
triggerSearch(params);
} else if (key === 'trading_time') {
// 清除交易时段筛选
setTradingTimeRange(null);
const params = buildFilterParams({
start_date: '',
end_date: '',
recent_days: ''
});
triggerSearch(params);
} else if (key === 'importance') {
// 重置重要性为空数组(传给后端为'all'
setImportance([]);
const params = buildFilterParams({ importance: 'all' });
triggerSearch(params);
} else if (key === 'sort') {
// 重置排序为默认值
setSort('new');
const params = buildFilterParams({ sort: 'new' });
triggerSearch(params);
}
};
return (
<Card>
{/* 第三行:行业 + 重要性 + 排序 */}
<Space style={{ width: '100%', justifyContent: 'space-between' }} size="middle">
{/* 左侧:筛选器组 */}
<Space size="middle" wrap>
<span style={{ fontSize: 14, color: '#666', fontWeight: 'bold' }}>筛选:</span>
{/* 行业分类 */}
<Cascader
value={industryValue}
onChange={handleIndustryChange}
onFocus={handleCascaderFocus}
options={industryData || []}
placeholder="行业分类"
changeOnSelect
showSearch={{
filter: (inputValue, path) =>
path.some(option =>
option.label.toLowerCase().includes(inputValue.toLowerCase())
)
}}
allowClear
expandTrigger="hover"
displayRender={(labels) => labels.join(' > ')}
disabled={industryLoading}
style={{ width: 200 }}
size="middle"
/>
{/* 重要性 */}
<Space size="small">
<span style={{ fontSize: 14, color: '#666' }}>重要性:</span>
<AntSelect
mode="multiple"
value={importance}
onChange={handleImportanceChange}
style={{ width: 150 }}
size="middle"
placeholder="全部"
maxTagCount={3}
>
<Option value="S">S级</Option>
<Option value="A">A级</Option>
<Option value="B">B级</Option>
<Option value="C">C级</Option>
</AntSelect>
</Space>
{/* 搜索图标(可点击) + 搜索框 */}
<Space.Compact style={{ flex: 1, minWidth: 300 }}>
<SearchOutlined
onClick={handleMainSearch}
style={{
fontSize: 18,
padding: '8px 12px',
background: '#f5f5f5',
borderRadius: '6px 0 0 6px',
display: 'flex',
alignItems: 'center',
color: '#666',
cursor: 'pointer',
transition: 'all 0.3s'
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = '#1890ff';
e.currentTarget.style.background = '#e6f7ff';
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = '#666';
e.currentTarget.style.background = '#f5f5f5';
}}
/>
<AutoComplete
value={inputValue}
onChange={handleInputChange}
onSearch={handleSearch}
onSelect={handleStockSelect}
onFocus={onSearchFocus}
options={stockOptions}
placeholder="请输入股票代码/股票名称/相关话题"
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleMainSearch();
}
}}
style={{ flex: 1 }}
size="middle"
notFoundContent={inputValue && stockOptions.length === 0 ? "未找到匹配的股票" : null}
/>
</Space.Compact>
{/* 重置按钮 - 现代化设计 */}
<Button
icon={<CloseCircleOutlined />}
onClick={handleReset}
size="middle"
style={{
borderRadius: 6,
border: '1px solid #d9d9d9',
backgroundColor: '#fff',
color: '#666',
fontWeight: 500,
padding: '4px 12px',
display: 'flex',
alignItems: 'center',
gap: 4,
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.05)'
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#ff4d4f';
e.currentTarget.style.color = '#ff4d4f';
e.currentTarget.style.backgroundColor = '#fff1f0';
e.currentTarget.style.boxShadow = '0 2px 8px rgba(255, 77, 79, 0.15)';
e.currentTarget.style.transform = 'translateY(-1px)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = '#d9d9d9';
e.currentTarget.style.color = '#666';
e.currentTarget.style.backgroundColor = '#fff';
e.currentTarget.style.boxShadow = '0 1px 2px rgba(0, 0, 0, 0.05)';
e.currentTarget.style.transform = 'translateY(0)';
}}
>
重置
</Button>
</Space>
{/* 右侧:排序 */}
<Space size="small">
<span style={{ fontSize: 14, color: '#666' }}>排序:</span>
<AntSelect
value={sort}
onChange={handleSortChange}
style={{ width: 120 }}
size="middle"
>
<Option value="new">最新</Option>
<Option value="hot">最热</Option>
<Option value="importance">重要性</Option>
<Option value="returns_avg">平均收益率</Option>
<Option value="returns_week">周收益率</Option>
</AntSelect>
</Space>
</Space>
{/* 第一行:筛选 + 时间按钮 + 搜索图标 + 搜索框 */}
<Space wrap style={{ width: '100%', marginBottom: 12, marginTop: 8 }} size="middle">
<span style={{ fontSize: 14, color: '#666', fontWeight: 'bold' }}>时间筛选:</span>
{/* 交易时段筛选 */}
<TradingTimeFilter
value={tradingTimeRange?.key || null}
onChange={handleTradingTimeChange}
/>
</Space>
{/* 第二行:热门概念 */}
<div style={{ marginTop: 8 }}>
<PopularKeywords
keywords={popularKeywords}
onKeywordClick={handleKeywordClick}
/>
</div>
{/* 已选条件标签 */}
{filterTags.length > 0 && (
<Space size={[8, 8]} wrap style={{ marginTop: 12 }}>
{filterTags.map(tag => (
<Tag
key={tag.key}
closable
onClose={() => handleRemoveTag(tag.key)}
color="blue"
>
{tag.label}
</Tag>
))}
</Space>
)}
</Card>
);
};
export default UnifiedSearchBox;