diff --git a/src/hooks/useStockSearch.js b/src/hooks/useStockSearch.js new file mode 100644 index 00000000..4b5da131 --- /dev/null +++ b/src/hooks/useStockSearch.js @@ -0,0 +1,99 @@ +// src/hooks/useStockSearch.js +// 通用股票搜索 Hook - 支持代码、名称、拼音缩写搜索 + +import { useState, useCallback, useMemo, useEffect, useRef } from 'react'; +import debounce from 'lodash/debounce'; + +/** + * 股票搜索 Hook + * @param {Object} options 配置项 + * @param {number} options.limit 返回结果数量限制,默认 10 + * @param {number} options.debounceMs 防抖延迟,默认 300ms + * @param {Function} options.onSearch 搜索回调(用于追踪) + * @returns {Object} 搜索状态和方法 + */ +export const useStockSearch = (options = {}) => { + const { limit = 10, debounceMs = 300, onSearch } = options; + + // 使用 ref 存储 onSearch,避免因回调变化导致防抖函数重建 + const onSearchRef = useRef(onSearch); + useEffect(() => { + onSearchRef.current = onSearch; + }, [onSearch]); + + const [searchQuery, setSearchQuery] = useState(''); + const [searchResults, setSearchResults] = useState([]); + const [isSearching, setIsSearching] = useState(false); + const [showResults, setShowResults] = useState(false); + + // 调用 API 搜索 + const searchStocks = useCallback(async (query) => { + if (!query || !query.trim()) { + setSearchResults([]); + setShowResults(false); + return; + } + + setIsSearching(true); + try { + const response = await fetch( + `/api/stocks/search?q=${encodeURIComponent(query.trim())}&limit=${limit}` + ); + const data = await response.json(); + + if (data.success && data.data) { + setSearchResults(data.data); + setShowResults(true); + // 触发搜索回调(用于追踪) + onSearchRef.current?.(query, data.data.length); + } else { + setSearchResults([]); + setShowResults(true); + onSearchRef.current?.(query, 0); + } + } catch (error) { + console.error('搜索股票失败:', error); + setSearchResults([]); + } finally { + setIsSearching(false); + } + }, [limit]); + + // 防抖搜索 + const debouncedSearch = useMemo( + () => debounce(searchStocks, debounceMs), + [searchStocks, debounceMs] + ); + + // 处理搜索输入 + const handleSearch = useCallback((value) => { + setSearchQuery(value); + debouncedSearch(value); + }, [debouncedSearch]); + + // 清空搜索 + const clearSearch = useCallback(() => { + setSearchQuery(''); + setSearchResults([]); + setShowResults(false); + }, []); + + // 清理防抖 + useEffect(() => { + return () => debouncedSearch.cancel?.(); + }, [debouncedSearch]); + + return { + // 状态 + searchQuery, + searchResults, + isSearching, + showResults, + // 方法 + handleSearch, + clearSearch, + setShowResults, + }; +}; + +export default useStockSearch; diff --git a/src/mocks/handlers/stock.js b/src/mocks/handlers/stock.js index c8219b70..a345259c 100644 --- a/src/mocks/handlers/stock.js +++ b/src/mocks/handlers/stock.js @@ -7,6 +7,42 @@ import { generateTimelineData, generateDailyData } from '../data/kline'; // 模拟延迟 const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms)); +// 常用字拼音首字母映射(简化版) +const PINYIN_MAP = { + '平': 'p', '安': 'a', '银': 'y', '行': 'h', '浦': 'p', '发': 'f', + '招': 'z', '商': 's', '兴': 'x', '业': 'y', '北': 'b', '京': 'j', + '农': 'n', '交': 'j', '通': 't', '工': 'g', '光': 'g', '大': 'd', + '建': 'j', '设': 's', '中': 'z', '信': 'x', '证': 'z', '券': 'q', + '国': 'g', '金': 'j', '海': 'h', '华': 'h', '泰': 't', '方': 'f', + '正': 'z', '新': 'x', '保': 'b', '险': 'x', '太': 't', '人': 'r', + '寿': 's', '泸': 'l', '州': 'z', '老': 'l', '窖': 'j', '古': 'g', + '井': 'j', '贡': 'g', '酒': 'j', '五': 'w', '粮': 'l', '液': 'y', + '贵': 'g', '茅': 'm', '台': 't', '青': 'q', '岛': 'd', '啤': 'p', + '水': 's', '坊': 'f', '今': 'j', '世': 's', '缘': 'y', '云': 'y', + '南': 'n', '白': 'b', '药': 'y', '长': 'c', '春': 'c', '高': 'g', + '科': 'k', '伦': 'l', '比': 'b', '亚': 'y', '迪': 'd', '恒': 'h', + '瑞': 'r', '医': 'y', '片': 'p', '仔': 'z', '癀': 'h', '明': 'm', + '康': 'k', '德': 'd', '讯': 'x', '东': 'd', '威': 'w', '视': 's', + '立': 'l', '精': 'j', '密': 'm', '电': 'd', '航': 'h', + '动': 'd', '力': 'l', '韦': 'w', '尔': 'e', '股': 'g', '份': 'f', + '万': 'w', '赣': 'g', '锋': 'f', '锂': 'l', '宁': 'n', '时': 's', + '代': 'd', '隆': 'l', '基': 'j', '绿': 'l', '能': 'n', + '筑': 'z', '汽': 'q', '车': 'c', '宇': 'y', '客': 'k', '上': 's', + '集': 'j', '团': 't', '广': 'g', '城': 'c', '侨': 'q', '夏': 'x', + '幸': 'x', '福': 'f', '地': 'd', '控': 'k', '美': 'm', '格': 'g', + '苏': 's', '智': 'z', '家': 'j', '易': 'y', '购': 'g', + '轩': 'x', '财': 'c', '富': 'f', '石': 's', '化': 'h', '学': 'x', + '山': 's', '黄': 'h', '螺': 'l', '泥': 'n', '神': 's', '油': 'y', + '联': 'l', '移': 'y', '伊': 'y', '利': 'l', '紫': 'z', '矿': 'k', + '天': 't', '味': 'w', '港': 'g', '微': 'w', + '技': 'j', '的': 'd', '器': 'q', '泊': 'b', '铁': 't', +}; + +// 生成拼音缩写 +const generatePinyinAbbr = (name) => { + return name.split('').map(char => PINYIN_MAP[char] || '').join(''); +}; + // 生成A股主要股票数据(包含各大指数成分股) const generateStockList = () => { const stocks = [ @@ -118,7 +154,11 @@ const generateStockList = () => { { code: '603288', name: '海天味业' }, ]; - return stocks; + // 添加拼音缩写 + return stocks.map(s => ({ + ...s, + pinyin_abbr: generatePinyinAbbr(s.name) + })); }; // 股票相关的 Handlers @@ -143,11 +183,12 @@ export const stockHandlers = [ }); } - // 模糊搜索:代码 + 名称(不区分大小写) + // 模糊搜索:代码 + 名称 + 拼音缩写(不区分大小写) const results = stocks.filter(s => { const code = s.code.toLowerCase(); const name = s.name.toLowerCase(); - return code.includes(query) || name.includes(query); + const pinyin = (s.pinyin_abbr || '').toLowerCase(); + return code.includes(query) || name.includes(query) || pinyin.includes(query); }); // 按相关性排序:完全匹配 > 开头匹配 > 包含匹配 @@ -156,18 +197,22 @@ export const stockHandlers = [ const bCode = b.code.toLowerCase(); const aName = a.name.toLowerCase(); const bName = b.name.toLowerCase(); + const aPinyin = (a.pinyin_abbr || '').toLowerCase(); + const bPinyin = (b.pinyin_abbr || '').toLowerCase(); - // 计算匹配分数 - const getScore = (code, name) => { - if (code === query || name === query) return 100; // 完全匹配 + // 计算匹配分数(包含拼音匹配) + const getScore = (code, name, pinyin) => { + if (code === query || name === query || pinyin === query) return 100; // 完全匹配 if (code.startsWith(query)) return 80; // 代码开头 + if (pinyin.startsWith(query)) return 70; // 拼音开头 if (name.startsWith(query)) return 60; // 名称开头 if (code.includes(query)) return 40; // 代码包含 + if (pinyin.includes(query)) return 30; // 拼音包含 if (name.includes(query)) return 20; // 名称包含 return 0; }; - return getScore(bCode, bName) - getScore(aCode, aName); + return getScore(bCode, bName, bPinyin) - getScore(aCode, aName, aPinyin); }); // 返回格式化数据 @@ -176,6 +221,7 @@ export const stockHandlers = [ data: results.slice(0, limit).map(s => ({ stock_code: s.code, stock_name: s.name, + pinyin_abbr: s.pinyin_abbr, market: s.code.startsWith('6') ? 'SH' : 'SZ', industry: ['银行', '证券', '保险', '白酒', '医药', '科技', '新能源', '汽车', '地产', '家电'][Math.floor(Math.random() * 10)], change_pct: parseFloat((Math.random() * 10 - 3).toFixed(2)), diff --git a/src/views/Company/index.js b/src/views/Company/index.js index 0eb747e2..ea1357d4 100644 --- a/src/views/Company/index.js +++ b/src/views/Company/index.js @@ -1,9 +1,7 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { useSearchParams } from 'react-router-dom'; -import { useSelector, useDispatch } from 'react-redux'; -import { loadAllStocks } from '@store/slices/stockSlice'; -import { AutoComplete } from 'antd'; -import { stockService } from '@services/stockService'; +import { AutoComplete, Spin } from 'antd'; +import { useStockSearch } from '@hooks/useStockSearch'; import { Container, Heading, @@ -42,22 +40,10 @@ const CompanyIndex = () => { const [searchParams, setSearchParams] = useSearchParams(); const [stockCode, setStockCode] = useState(searchParams.get('scode') || '000001'); const [inputCode, setInputCode] = useState(stockCode); - const [stockOptions, setStockOptions] = useState([]); const { colorMode, toggleColorMode } = useColorMode(); const toast = useToast(); const { isAuthenticated } = useAuth(); - // 从 Redux 获取股票列表数据 - const dispatch = useDispatch(); - const allStocks = useSelector((state) => state.stock.allStocks); - - // 确保股票数据已加载 - useEffect(() => { - if (!allStocks || allStocks.length === 0) { - dispatch(loadAllStocks()); - } - }, [dispatch, allStocks]); - // 🎯 PostHog 事件追踪 const { trackStockSearched, @@ -66,6 +52,35 @@ const CompanyIndex = () => { trackWatchlistRemoved, } = useCompanyEvents({ stockCode }); + // 🔍 股票搜索 Hook(支持代码、名称、拼音缩写) + const { + searchResults, + isSearching, + handleSearch: doSearch, + clearSearch, + } = useStockSearch({ + limit: 10, + debounceMs: 300, + onSearch: (query, _count) => trackStockSearched(query, stockCode), + }); + + // 转换为 AutoComplete options + const stockOptions = useMemo(() => { + return searchResults.map((stock) => ({ + value: stock.stock_code, + label: ( + + {stock.stock_code} {stock.stock_name} + {stock.pinyin_abbr && ( + + ({stock.pinyin_abbr.toUpperCase()}) + + )} + + ), + })); + }, [searchResults]); + // Tab 索引状态(用于追踪 Tab 切换) const [currentTabIndex, setCurrentTabIndex] = useState(0); @@ -119,31 +134,11 @@ const CompanyIndex = () => { setSearchParams({ scode: inputCode }); } }; - - const handleKeyPress = (e) => { - if (e.key === 'Enter') { - handleSearch(); - } - }; - - // 模糊搜索股票(由 onSearch 触发) - const handleStockSearch = (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: `${stock.code} ${stock.name}`, - })); - setStockOptions(options); - }; // 选中股票 const handleStockSelect = (value) => { setInputCode(value); - setStockOptions([]); + clearSearch(); if (value !== stockCode) { trackStockSearched(value, stockCode); setStockCode(value); @@ -231,12 +226,13 @@ const CompanyIndex = () => { setInputCode(value)} - placeholder="输入股票代码或名称" - style={{ width: 260 }} + placeholder="输入代码、名称或拼音缩写" + style={{ width: 280 }} size="large" + notFoundContent={isSearching ? : null} onKeyDown={(e) => { if (e.key === 'Enter') { handleSearch();