406 lines
15 KiB
JavaScript
406 lines
15 KiB
JavaScript
// 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;
|