636 lines
23 KiB
JavaScript
636 lines
23 KiB
JavaScript
// src/views/Community/components/CompactSearchBox.js
|
|
// 紧凑版搜索和筛选组件 - 优化布局
|
|
|
|
import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react';
|
|
import {
|
|
Input, Cascader, Button, Space, Tag, AutoComplete, Select as AntSelect,
|
|
Tooltip
|
|
} from 'antd';
|
|
import {
|
|
SearchOutlined, CloseCircleOutlined, StockOutlined, FilterOutlined,
|
|
CalendarOutlined, SortAscendingOutlined
|
|
} 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 TradingTimeFilter from './TradingTimeFilter';
|
|
import { PROFESSIONAL_COLORS } from '../../../constants/professionalTheme';
|
|
|
|
const { Option } = AntSelect;
|
|
|
|
const CompactSearchBox = ({
|
|
onSearch,
|
|
onSearchFocus,
|
|
filters = {},
|
|
mode,
|
|
pageSize,
|
|
trackingFunctions = {}
|
|
}) => {
|
|
// 状态
|
|
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 debouncedSearchRef = useRef(null);
|
|
|
|
const triggerSearch = useCallback((params) => {
|
|
logger.debug('CompactSearchBox', '触发搜索', { params });
|
|
onSearch(params);
|
|
}, [onSearch]);
|
|
|
|
useEffect(() => {
|
|
debouncedSearchRef.current = debounce((params) => {
|
|
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);
|
|
}
|
|
};
|
|
loadStocks();
|
|
}, []);
|
|
|
|
// 初始化筛选条件
|
|
const findIndustryPath = 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;
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!filters) return;
|
|
|
|
if (filters.sort) setSort(filters.sort);
|
|
|
|
if (filters.importance) {
|
|
const importanceArray = filters.importance === 'all'
|
|
? []
|
|
: filters.importance.split(',').map(v => v.trim()).filter(Boolean);
|
|
setImportance(importanceArray);
|
|
} else {
|
|
setImportance([]);
|
|
}
|
|
|
|
if (filters.industry_code && industryData && industryData.length > 0 && (!industryValue || industryValue.length === 0)) {
|
|
const path = findIndustryPath(filters.industry_code, industryData);
|
|
if (path) {
|
|
setIndustryValue(path);
|
|
}
|
|
} else if (!filters.industry_code && industryValue && industryValue.length > 0) {
|
|
setIndustryValue([]);
|
|
}
|
|
|
|
if (filters.q) {
|
|
setInputValue(filters.q);
|
|
} else if (!filters.q) {
|
|
setInputValue('');
|
|
}
|
|
|
|
const hasTimeInFilters = filters.start_date || filters.end_date || filters.recent_days;
|
|
|
|
if (hasTimeInFilters && (!tradingTimeRange || !tradingTimeRange.key)) {
|
|
let inferredKey = 'custom';
|
|
let inferredLabel = '';
|
|
|
|
if (filters.recent_days) {
|
|
if (filters.recent_days === '7') {
|
|
inferredKey = 'week';
|
|
inferredLabel = '近一周';
|
|
} else if (filters.recent_days === '30') {
|
|
inferredKey = 'month';
|
|
inferredLabel = '近一月';
|
|
} else {
|
|
inferredLabel = `近${filters.recent_days}天`;
|
|
}
|
|
} else if (filters.start_date && filters.end_date) {
|
|
inferredLabel = `${dayjs(filters.start_date).format('MM-DD HH:mm')} - ${dayjs(filters.end_date).format('MM-DD HH:mm')}`;
|
|
}
|
|
|
|
const timeRange = {
|
|
start_date: filters.start_date || '',
|
|
end_date: filters.end_date || '',
|
|
recent_days: filters.recent_days || '',
|
|
label: inferredLabel,
|
|
key: inferredKey
|
|
};
|
|
setTradingTimeRange(timeRange);
|
|
} else if (!hasTimeInFilters && tradingTimeRange) {
|
|
setTradingTimeRange(null);
|
|
}
|
|
}, [filters, industryData, findIndustryPath, industryValue, tradingTimeRange]);
|
|
|
|
// 搜索股票
|
|
const handleSearch = (value) => {
|
|
if (!value || !allStocks || allStocks.length === 0) {
|
|
setStockOptions([]);
|
|
return;
|
|
}
|
|
|
|
const results = stockService.fuzzySearch(value, allStocks, 10);
|
|
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);
|
|
};
|
|
|
|
const buildFilterParams = useCallback((overrides = {}) => {
|
|
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 = {
|
|
sort: actualSort,
|
|
importance: importanceValue,
|
|
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,
|
|
page: 1,
|
|
};
|
|
|
|
delete result.per_page;
|
|
|
|
if (returnType) {
|
|
result.return_type = returnType;
|
|
}
|
|
|
|
if (mode !== undefined && mode !== null) {
|
|
result.mode = mode;
|
|
}
|
|
if (pageSize !== undefined && pageSize !== null) {
|
|
result.per_page = pageSize;
|
|
}
|
|
|
|
return result;
|
|
}, [sort, importance, filters.q, industryValue, tradingTimeRange, mode, pageSize]);
|
|
|
|
const handleStockSelect = (_value, option) => {
|
|
const stockInfo = option.stockInfo;
|
|
if (stockInfo) {
|
|
if (trackingFunctions.trackRelatedStockClicked) {
|
|
trackingFunctions.trackRelatedStockClicked({
|
|
stockCode: stockInfo.code,
|
|
stockName: stockInfo.name,
|
|
source: 'search_box_autocomplete',
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
}
|
|
|
|
setInputValue(`${stockInfo.code} ${stockInfo.name}`);
|
|
|
|
const params = buildFilterParams({
|
|
q: stockInfo.code,
|
|
industry_code: ''
|
|
});
|
|
triggerSearch(params);
|
|
}
|
|
};
|
|
|
|
const handleImportanceChange = (value) => {
|
|
setImportance(value);
|
|
|
|
if (debouncedSearchRef.current) {
|
|
debouncedSearchRef.current.cancel();
|
|
}
|
|
|
|
const importanceStr = value.length === 0 ? 'all' : value.join(',');
|
|
|
|
if (trackingFunctions.trackNewsFilterApplied) {
|
|
trackingFunctions.trackNewsFilterApplied({
|
|
filterType: 'importance',
|
|
filterValue: importanceStr,
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
}
|
|
|
|
const params = buildFilterParams({ importance: importanceStr });
|
|
triggerSearch(params);
|
|
};
|
|
|
|
const handleSortChange = (value) => {
|
|
setSort(value);
|
|
|
|
if (debouncedSearchRef.current) {
|
|
debouncedSearchRef.current.cancel();
|
|
}
|
|
|
|
if (trackingFunctions.trackNewsSorted) {
|
|
trackingFunctions.trackNewsSorted({
|
|
sortBy: value,
|
|
previousSortBy: sort,
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
}
|
|
|
|
const params = buildFilterParams({ sort: value });
|
|
triggerSearch(params);
|
|
};
|
|
|
|
const handleIndustryChange = (value) => {
|
|
setIndustryValue(value);
|
|
|
|
if (debouncedSearchRef.current) {
|
|
debouncedSearchRef.current.cancel();
|
|
}
|
|
|
|
if (trackingFunctions.trackNewsFilterApplied) {
|
|
trackingFunctions.trackNewsFilterApplied({
|
|
filterType: 'industry',
|
|
filterValue: value?.[value.length - 1] || '',
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
}
|
|
|
|
const params = buildFilterParams({
|
|
industry_code: value?.[value.length - 1] || ''
|
|
});
|
|
triggerSearch(params);
|
|
};
|
|
|
|
const handleTradingTimeChange = (timeConfig) => {
|
|
if (!timeConfig) {
|
|
setTradingTimeRange(null);
|
|
|
|
if (trackingFunctions.trackNewsFilterApplied) {
|
|
trackingFunctions.trackNewsFilterApplied({
|
|
filterType: 'time_range',
|
|
filterValue: 'cleared',
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
}
|
|
|
|
const params = buildFilterParams({
|
|
start_date: '',
|
|
end_date: '',
|
|
recent_days: ''
|
|
});
|
|
triggerSearch(params);
|
|
return;
|
|
}
|
|
|
|
const { range, type, label, key } = timeConfig;
|
|
let params = {};
|
|
|
|
if (type === 'recent_days') {
|
|
params.recent_days = range;
|
|
params.start_date = '';
|
|
params.end_date = '';
|
|
} else {
|
|
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 });
|
|
|
|
if (trackingFunctions.trackNewsFilterApplied) {
|
|
trackingFunctions.trackNewsFilterApplied({
|
|
filterType: 'time_range',
|
|
filterValue: label,
|
|
timeRangeType: type,
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
}
|
|
|
|
const searchParams = buildFilterParams({ ...params, mode });
|
|
triggerSearch(searchParams);
|
|
};
|
|
|
|
const handleMainSearch = () => {
|
|
if (debouncedSearchRef.current) {
|
|
debouncedSearchRef.current.cancel();
|
|
}
|
|
|
|
const params = buildFilterParams({
|
|
q: inputValue,
|
|
industry_code: ''
|
|
});
|
|
|
|
if (trackingFunctions.trackNewsSearched && inputValue) {
|
|
trackingFunctions.trackNewsSearched({
|
|
searchQuery: inputValue,
|
|
searchType: 'main_search',
|
|
filters: params,
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
}
|
|
|
|
triggerSearch(params);
|
|
};
|
|
|
|
const handleInputChange = (value) => {
|
|
setInputValue(value);
|
|
};
|
|
|
|
const handleReset = () => {
|
|
setInputValue('');
|
|
setStockOptions([]);
|
|
setIndustryValue([]);
|
|
setSort('new');
|
|
setImportance([]);
|
|
setTradingTimeRange(null);
|
|
|
|
if (trackingFunctions.trackNewsFilterApplied) {
|
|
trackingFunctions.trackNewsFilterApplied({
|
|
filterType: 'reset',
|
|
filterValue: 'all_filters_cleared',
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
}
|
|
|
|
const resetParams = {
|
|
q: '',
|
|
industry_code: '',
|
|
sort: 'new',
|
|
importance: 'all',
|
|
start_date: '',
|
|
end_date: '',
|
|
recent_days: '',
|
|
page: 1,
|
|
_forceRefresh: Date.now()
|
|
};
|
|
|
|
onSearch(resetParams);
|
|
};
|
|
|
|
const handleCascaderFocus = async () => {
|
|
if (!industryData || industryData.length === 0) {
|
|
dispatch(fetchIndustryData());
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div style={{
|
|
padding: window.innerWidth < 768 ? '12px 16px' : '16px 20px',
|
|
background: PROFESSIONAL_COLORS.background.card,
|
|
borderRadius: '12px',
|
|
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.3), 0 0 20px rgba(255, 195, 0, 0.1)',
|
|
border: `1px solid ${PROFESSIONAL_COLORS.border.default}`,
|
|
backdropFilter: 'blur(10px)'
|
|
}}>
|
|
{/* 单行紧凑布局 - 移动端自动换行 */}
|
|
<Space wrap style={{ width: '100%' }} size={window.innerWidth < 768 ? 'small' : 'medium'}>
|
|
{/* 搜索框 */}
|
|
<AutoComplete
|
|
value={inputValue}
|
|
onChange={handleInputChange}
|
|
onSearch={handleSearch}
|
|
onSelect={handleStockSelect}
|
|
onFocus={onSearchFocus}
|
|
options={stockOptions}
|
|
placeholder="搜索股票/话题..."
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
handleMainSearch();
|
|
}
|
|
}}
|
|
style={{ width: window.innerWidth < 768 ? '100%' : 240, minWidth: window.innerWidth < 768 ? 0 : 240 }}
|
|
>
|
|
<Input
|
|
prefix={<SearchOutlined style={{ color: PROFESSIONAL_COLORS.gold[500] }} />}
|
|
style={{
|
|
borderRadius: '8px',
|
|
border: `1px solid ${PROFESSIONAL_COLORS.border.default}`,
|
|
boxShadow: `0 2px 8px rgba(255, 195, 0, 0.1)`,
|
|
background: PROFESSIONAL_COLORS.background.secondary,
|
|
color: PROFESSIONAL_COLORS.text.primary
|
|
}}
|
|
/>
|
|
</AutoComplete>
|
|
|
|
{/* 时间筛选 */}
|
|
<Tooltip title="时间筛选">
|
|
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
|
<CalendarOutlined style={{ color: PROFESSIONAL_COLORS.gold[500], fontSize: 12 }} />
|
|
<TradingTimeFilter
|
|
value={tradingTimeRange?.key || null}
|
|
onChange={handleTradingTimeChange}
|
|
compact
|
|
/>
|
|
</div>
|
|
</Tooltip>
|
|
|
|
{/* 行业筛选 */}
|
|
<Tooltip title="行业分类">
|
|
<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[labels.length - 1] || '行业'}
|
|
disabled={industryLoading}
|
|
style={{
|
|
width: window.innerWidth < 768 ? '100%' : 120,
|
|
minWidth: window.innerWidth < 768 ? 0 : 120,
|
|
borderRadius: '8px'
|
|
}}
|
|
suffixIcon={<FilterOutlined style={{ fontSize: 14, color: PROFESSIONAL_COLORS.gold[500] }} />}
|
|
/>
|
|
</Tooltip>
|
|
|
|
{/* 重要性筛选 */}
|
|
<Tooltip title="事件等级筛选">
|
|
<AntSelect
|
|
mode="multiple"
|
|
value={importance}
|
|
onChange={handleImportanceChange}
|
|
style={{
|
|
width: window.innerWidth < 768 ? '100%' : 120,
|
|
minWidth: window.innerWidth < 768 ? 0 : 120,
|
|
borderRadius: '8px'
|
|
}}
|
|
placeholder="事件等级"
|
|
maxTagCount={0}
|
|
maxTagPlaceholder={(omittedValues) => `已选 ${omittedValues.length} 项`}
|
|
>
|
|
<Option value="S">S级</Option>
|
|
<Option value="A">A级</Option>
|
|
<Option value="B">B级</Option>
|
|
<Option value="C">C级</Option>
|
|
</AntSelect>
|
|
</Tooltip>
|
|
|
|
{/* 排序 */}
|
|
<Tooltip title="排序方式">
|
|
<AntSelect
|
|
value={sort}
|
|
onChange={handleSortChange}
|
|
style={{
|
|
width: window.innerWidth < 768 ? '100%' : 130,
|
|
minWidth: window.innerWidth < 768 ? 0 : 130,
|
|
borderRadius: '8px'
|
|
}}
|
|
suffixIcon={<SortAscendingOutlined style={{ fontSize: 14, color: PROFESSIONAL_COLORS.gold[500] }} />}
|
|
>
|
|
<Option value="new">⏰ 最新</Option>
|
|
<Option value="hot">🔥 最热</Option>
|
|
<Option value="importance">⭐ 重要性</Option>
|
|
<Option value="returns_avg">📊 平均收益</Option>
|
|
<Option value="returns_week">📈 周收益</Option>
|
|
</AntSelect>
|
|
</Tooltip>
|
|
|
|
{/* 重置按钮 */}
|
|
<Tooltip title="重置所有筛选">
|
|
<Button
|
|
icon={<CloseCircleOutlined />}
|
|
onClick={handleReset}
|
|
danger
|
|
type="primary"
|
|
style={{
|
|
borderRadius: '8px',
|
|
boxShadow: '0 2px 8px rgba(255, 77, 79, 0.2)'
|
|
}}
|
|
>
|
|
重置
|
|
</Button>
|
|
</Tooltip>
|
|
</Space>
|
|
|
|
{/* 激活的筛选标签(如果有的话) */}
|
|
{(inputValue || industryValue.length > 0 || importance.length > 0 || tradingTimeRange || sort !== 'new') && (
|
|
<div style={{ marginTop: 8, display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
|
{inputValue && (
|
|
<Tag closable onClose={() => {
|
|
setInputValue('');
|
|
const params = buildFilterParams({ q: '' });
|
|
triggerSearch(params);
|
|
}} color="blue">
|
|
搜索: {inputValue}
|
|
</Tag>
|
|
)}
|
|
{tradingTimeRange && (
|
|
<Tag closable onClose={() => {
|
|
setTradingTimeRange(null);
|
|
const params = buildFilterParams({
|
|
start_date: '',
|
|
end_date: '',
|
|
recent_days: ''
|
|
});
|
|
triggerSearch(params);
|
|
}} color="green">
|
|
{tradingTimeRange.label}
|
|
</Tag>
|
|
)}
|
|
{industryValue.length > 0 && industryData && (
|
|
<Tag closable onClose={() => {
|
|
setIndustryValue([]);
|
|
const params = buildFilterParams({ industry_code: '' });
|
|
triggerSearch(params);
|
|
}} color="orange">
|
|
行业: {(() => {
|
|
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;
|
|
};
|
|
const lastLevelCode = industryValue[industryValue.length - 1];
|
|
return findLabel(lastLevelCode, industryData);
|
|
})()}
|
|
</Tag>
|
|
)}
|
|
{importance.length > 0 && (
|
|
<Tag closable onClose={() => {
|
|
setImportance([]);
|
|
const params = buildFilterParams({ importance: 'all' });
|
|
triggerSearch(params);
|
|
}} color="purple">
|
|
重要性: {importance.map(imp => ({ 'S': '极高', 'A': '高', 'B': '中', 'C': '低' }[imp] || imp)).join(', ')}
|
|
</Tag>
|
|
)}
|
|
{sort && sort !== 'new' && (
|
|
<Tag closable onClose={() => {
|
|
setSort('new');
|
|
const params = buildFilterParams({ sort: 'new' });
|
|
triggerSearch(params);
|
|
}} color="cyan">
|
|
排序: {({ 'hot': '最热', 'importance': '重要性', 'returns_avg': '平均收益', 'returns_week': '周收益' }[sort] || sort)}
|
|
</Tag>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default CompactSearchBox;
|