Files
vf_react/src/views/Community/components/UnifiedSearchBox.js
2025-10-25 18:23:20 +08:00

406 lines
15 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 } 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;