feat: 添加行业筛选器Box
This commit is contained in:
405
src/views/Community/components/UnifiedSearchBox.js
Normal file
405
src/views/Community/components/UnifiedSearchBox.js
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
// src/views/Community/components/UnifiedSearchBox.js
|
||||||
|
// 搜索组件:三行布局(主搜索 + 热门概念 + 筛选区)
|
||||||
|
import React, { useState, useMemo, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Card, Input, Cascader, Button, Space, Tag, AutoComplete, DatePicker, Select as AntSelect
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
SearchOutlined, CloseCircleOutlined, StockOutlined
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import moment from 'moment';
|
||||||
|
import locale from 'antd/es/date-picker/locale/zh_CN';
|
||||||
|
import { useIndustry } from '../../../contexts/IndustryContext';
|
||||||
|
import { stockService } from '../../../services/stockService';
|
||||||
|
import { logger } from '../../../utils/logger';
|
||||||
|
import PopularKeywords from './PopularKeywords';
|
||||||
|
|
||||||
|
const { RangePicker } = DatePicker;
|
||||||
|
const { Option } = AntSelect;
|
||||||
|
|
||||||
|
const UnifiedSearchBox = ({
|
||||||
|
onSearch,
|
||||||
|
popularKeywords = [],
|
||||||
|
filters = {},
|
||||||
|
loading = false
|
||||||
|
}) => {
|
||||||
|
const [searchValue, setSearchValue] = useState(''); // 统一搜索值
|
||||||
|
const [isStockSearch, setIsStockSearch] = useState(false); // 是否股票搜索
|
||||||
|
const [stockOptions, setStockOptions] = useState([]); // 股票下拉选项列表
|
||||||
|
const [allStocks, setAllStocks] = useState([]); // 所有股票数据
|
||||||
|
const [industryValue, setIndustryValue] = useState([]);
|
||||||
|
|
||||||
|
// 使用全局行业数据
|
||||||
|
const { industryData, loadIndustryData, loading: industryLoading } = useIndustry();
|
||||||
|
|
||||||
|
// 加载所有股票数据
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ⚡ 提取 filters 中的原始值,避免对象引用导致无限循环
|
||||||
|
const stockCode = filters.stock_code;
|
||||||
|
const q = filters.q;
|
||||||
|
const industryCode = filters.industry_code;
|
||||||
|
|
||||||
|
// 初始化:从 URL 恢复状态
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (stockCode) {
|
||||||
|
setSearchValue(stockCode);
|
||||||
|
setIsStockSearch(true);
|
||||||
|
} else if (q) {
|
||||||
|
setSearchValue(q);
|
||||||
|
setIsStockSearch(false);
|
||||||
|
}
|
||||||
|
if (industryCode) {
|
||||||
|
// TODO: 从 industry_code 恢复 industryValue 需要查找对应路径
|
||||||
|
}
|
||||||
|
}, [stockCode, q, industryCode]); // ⚡ 只依赖原始值,不依赖整个 filters 对象
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
// 显示格式:股票代码 + 股票名称
|
||||||
|
const displayValue = `${stockInfo.code} ${stockInfo.name}`;
|
||||||
|
setSearchValue(displayValue);
|
||||||
|
setIsStockSearch(true);
|
||||||
|
logger.debug('UnifiedSearchBox', '选中股票', {
|
||||||
|
code: stockInfo.code,
|
||||||
|
name: stockInfo.name
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 日期范围变化
|
||||||
|
const handleDateRangeChange = (dates) => {
|
||||||
|
// 这里不直接调用 onSearch,等用户点击搜索按钮
|
||||||
|
logger.debug('UnifiedSearchBox', '日期范围变化', { dates });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 重要性变化
|
||||||
|
const handleImportanceChange = (value) => {
|
||||||
|
logger.debug('UnifiedSearchBox', '重要性变化', { value });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 排序变化
|
||||||
|
const handleSortChange = (value) => {
|
||||||
|
// 排序直接生效
|
||||||
|
onSearch({ sort: value, page: 1 });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 主搜索(点击搜索按钮或回车)
|
||||||
|
const handleMainSearch = () => {
|
||||||
|
// 直接输入文本(未选择下拉股票)时,作为话题搜索
|
||||||
|
if (!isStockSearch && searchValue) {
|
||||||
|
logger.debug('UnifiedSearchBox', '话题搜索', { topic: searchValue });
|
||||||
|
}
|
||||||
|
handleApplyFilters();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理输入变化
|
||||||
|
const handleInputChange = (value) => {
|
||||||
|
setSearchValue(value);
|
||||||
|
// 输入变化时重置股票搜索标记(只有从下拉选择才是股票搜索)
|
||||||
|
if (isStockSearch) {
|
||||||
|
setIsStockSearch(false);
|
||||||
|
logger.debug('UnifiedSearchBox', '切换为话题搜索模式');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 应用筛选
|
||||||
|
const handleApplyFilters = () => {
|
||||||
|
const params = { page: 1 };
|
||||||
|
|
||||||
|
if (isStockSearch && searchValue) {
|
||||||
|
// 股票搜索模式:从下拉选择的股票
|
||||||
|
// 提取股票代码(searchValue 格式:"000001 平安银行")
|
||||||
|
const stockCode = searchValue.split(' ')[0];
|
||||||
|
params.stock_code = stockCode;
|
||||||
|
params.q = '';
|
||||||
|
params.industry_code = '';
|
||||||
|
params.industry_classification = '';
|
||||||
|
logger.debug('UnifiedSearchBox', '应用股票筛选', { stockCode });
|
||||||
|
} else {
|
||||||
|
// 话题搜索模式:直接输入的文本
|
||||||
|
params.q = searchValue || '';
|
||||||
|
params.stock_code = '';
|
||||||
|
|
||||||
|
if (industryValue && industryValue.length > 0) {
|
||||||
|
params.industry_code = industryValue[industryValue.length - 1];
|
||||||
|
params.industry_classification = industryValue[0];
|
||||||
|
} else {
|
||||||
|
params.industry_code = '';
|
||||||
|
params.industry_classification = '';
|
||||||
|
}
|
||||||
|
logger.debug('UnifiedSearchBox', '应用话题筛选', { topic: searchValue });
|
||||||
|
}
|
||||||
|
|
||||||
|
onSearch(params);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 重置筛选
|
||||||
|
const handleReset = () => {
|
||||||
|
setSearchValue('');
|
||||||
|
setIsStockSearch(false);
|
||||||
|
setStockOptions([]);
|
||||||
|
setIndustryValue([]);
|
||||||
|
|
||||||
|
onSearch({
|
||||||
|
q: '',
|
||||||
|
stock_code: '',
|
||||||
|
industry_code: '',
|
||||||
|
industry_classification: '',
|
||||||
|
sort: 'new',
|
||||||
|
importance: 'all',
|
||||||
|
page: 1
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 生成已选条件标签
|
||||||
|
const filterTags = useMemo(() => {
|
||||||
|
const tags = [];
|
||||||
|
|
||||||
|
if (isStockSearch && searchValue) {
|
||||||
|
tags.push({ key: 'stock', label: `股票: ${searchValue}` });
|
||||||
|
} else if (!isStockSearch && searchValue) {
|
||||||
|
tags.push({ key: 'topic', label: `话题: ${searchValue}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (industryValue && industryValue.length > 0) {
|
||||||
|
const industryLabel = industryValue.slice(1).join(' > ');
|
||||||
|
tags.push({ key: 'industry', label: `行业: ${industryLabel}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
return tags;
|
||||||
|
}, [searchValue, isStockSearch, industryValue]);
|
||||||
|
|
||||||
|
// 移除单个标签
|
||||||
|
const handleRemoveTag = (key) => {
|
||||||
|
if (key === 'topic' || key === 'stock') {
|
||||||
|
setSearchValue('');
|
||||||
|
setIsStockSearch(false);
|
||||||
|
} else if (key === 'industry') {
|
||||||
|
setIndustryValue([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(handleApplyFilters, 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
{/* 第一行:主搜索框 */}
|
||||||
|
<Space.Compact style={{ width: '100%', marginBottom: 12 }} size="large">
|
||||||
|
<SearchOutlined style={{
|
||||||
|
fontSize: 20,
|
||||||
|
padding: '8px 12px',
|
||||||
|
background: '#f5f5f5',
|
||||||
|
borderRadius: '6px 0 0 6px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
color: '#666'
|
||||||
|
}} />
|
||||||
|
<AutoComplete
|
||||||
|
value={searchValue}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
onSearch={handleSearch}
|
||||||
|
onSelect={handleStockSelect}
|
||||||
|
options={stockOptions}
|
||||||
|
placeholder="请输入股票代码/股票名称/相关话题"
|
||||||
|
onPressEnter={handleMainSearch}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
size="large"
|
||||||
|
notFoundContent={searchValue && stockOptions.length === 0 ? "未找到匹配的股票" : null}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={handleMainSearch}
|
||||||
|
loading={loading}
|
||||||
|
size="large"
|
||||||
|
style={{ minWidth: 80 }}
|
||||||
|
>
|
||||||
|
搜索
|
||||||
|
</Button>
|
||||||
|
</Space.Compact>
|
||||||
|
|
||||||
|
{/* 第二行:热门概念 */}
|
||||||
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
<PopularKeywords />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 第三行:筛选器 + 排序 */}
|
||||||
|
<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={setIndustryValue}
|
||||||
|
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}
|
||||||
|
loading={industryLoading}
|
||||||
|
style={{ width: 200 }}
|
||||||
|
size="middle"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 日期范围 */}
|
||||||
|
<RangePicker
|
||||||
|
onChange={handleDateRangeChange}
|
||||||
|
locale={locale}
|
||||||
|
placeholder={['开始日期', '结束日期']}
|
||||||
|
style={{ width: 240 }}
|
||||||
|
size="middle"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 重要性 */}
|
||||||
|
<Space size="small">
|
||||||
|
<span style={{ fontSize: 14, color: '#666' }}>重要性:</span>
|
||||||
|
<AntSelect
|
||||||
|
defaultValue="all"
|
||||||
|
onChange={handleImportanceChange}
|
||||||
|
style={{ width: 100 }}
|
||||||
|
size="middle"
|
||||||
|
>
|
||||||
|
<Option value="all">全部</Option>
|
||||||
|
<Option value="S">S级</Option>
|
||||||
|
<Option value="A">A级</Option>
|
||||||
|
<Option value="B">B级</Option>
|
||||||
|
<Option value="C">C级</Option>
|
||||||
|
</AntSelect>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
{/* 重置按钮 - 现代化设计 */}
|
||||||
|
<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
|
||||||
|
defaultValue="new"
|
||||||
|
onChange={handleSortChange}
|
||||||
|
style={{ width: 120 }}
|
||||||
|
size="middle"
|
||||||
|
>
|
||||||
|
<Option value="new">最新</Option>
|
||||||
|
<Option value="hot">最热</Option>
|
||||||
|
<Option value="importance">重要性</Option>
|
||||||
|
</AntSelect>
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
{/* 已选条件标签 */}
|
||||||
|
{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;
|
||||||
Reference in New Issue
Block a user