Merge branch 'feature_bugfix/251201_vf_h5_ui' into feature_bugfix/251201_py_h5_ui
* feature_bugfix/251201_vf_h5_ui: feat: Company 页面搜索框支持拼音缩写搜索
This commit is contained in:
99
src/hooks/useStockSearch.js
Normal file
99
src/hooks/useStockSearch.js
Normal file
@@ -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;
|
||||||
@@ -7,6 +7,42 @@ import { generateTimelineData, generateDailyData } from '../data/kline';
|
|||||||
// 模拟延迟
|
// 模拟延迟
|
||||||
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
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股主要股票数据(包含各大指数成分股)
|
// 生成A股主要股票数据(包含各大指数成分股)
|
||||||
const generateStockList = () => {
|
const generateStockList = () => {
|
||||||
const stocks = [
|
const stocks = [
|
||||||
@@ -118,7 +154,11 @@ const generateStockList = () => {
|
|||||||
{ code: '603288', name: '海天味业' },
|
{ code: '603288', name: '海天味业' },
|
||||||
];
|
];
|
||||||
|
|
||||||
return stocks;
|
// 添加拼音缩写
|
||||||
|
return stocks.map(s => ({
|
||||||
|
...s,
|
||||||
|
pinyin_abbr: generatePinyinAbbr(s.name)
|
||||||
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
// 股票相关的 Handlers
|
// 股票相关的 Handlers
|
||||||
@@ -143,11 +183,12 @@ export const stockHandlers = [
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 模糊搜索:代码 + 名称(不区分大小写)
|
// 模糊搜索:代码 + 名称 + 拼音缩写(不区分大小写)
|
||||||
const results = stocks.filter(s => {
|
const results = stocks.filter(s => {
|
||||||
const code = s.code.toLowerCase();
|
const code = s.code.toLowerCase();
|
||||||
const name = s.name.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 bCode = b.code.toLowerCase();
|
||||||
const aName = a.name.toLowerCase();
|
const aName = a.name.toLowerCase();
|
||||||
const bName = b.name.toLowerCase();
|
const bName = b.name.toLowerCase();
|
||||||
|
const aPinyin = (a.pinyin_abbr || '').toLowerCase();
|
||||||
|
const bPinyin = (b.pinyin_abbr || '').toLowerCase();
|
||||||
|
|
||||||
// 计算匹配分数
|
// 计算匹配分数(包含拼音匹配)
|
||||||
const getScore = (code, name) => {
|
const getScore = (code, name, pinyin) => {
|
||||||
if (code === query || name === query) return 100; // 完全匹配
|
if (code === query || name === query || pinyin === query) return 100; // 完全匹配
|
||||||
if (code.startsWith(query)) return 80; // 代码开头
|
if (code.startsWith(query)) return 80; // 代码开头
|
||||||
|
if (pinyin.startsWith(query)) return 70; // 拼音开头
|
||||||
if (name.startsWith(query)) return 60; // 名称开头
|
if (name.startsWith(query)) return 60; // 名称开头
|
||||||
if (code.includes(query)) return 40; // 代码包含
|
if (code.includes(query)) return 40; // 代码包含
|
||||||
|
if (pinyin.includes(query)) return 30; // 拼音包含
|
||||||
if (name.includes(query)) return 20; // 名称包含
|
if (name.includes(query)) return 20; // 名称包含
|
||||||
return 0;
|
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 => ({
|
data: results.slice(0, limit).map(s => ({
|
||||||
stock_code: s.code,
|
stock_code: s.code,
|
||||||
stock_name: s.name,
|
stock_name: s.name,
|
||||||
|
pinyin_abbr: s.pinyin_abbr,
|
||||||
market: s.code.startsWith('6') ? 'SH' : 'SZ',
|
market: s.code.startsWith('6') ? 'SH' : 'SZ',
|
||||||
industry: ['银行', '证券', '保险', '白酒', '医药', '科技', '新能源', '汽车', '地产', '家电'][Math.floor(Math.random() * 10)],
|
industry: ['银行', '证券', '保险', '白酒', '医药', '科技', '新能源', '汽车', '地产', '家电'][Math.floor(Math.random() * 10)],
|
||||||
change_pct: parseFloat((Math.random() * 10 - 3).toFixed(2)),
|
change_pct: parseFloat((Math.random() * 10 - 3).toFixed(2)),
|
||||||
|
|||||||
@@ -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 { useSearchParams } from 'react-router-dom';
|
||||||
import { useSelector, useDispatch } from 'react-redux';
|
import { AutoComplete, Spin } from 'antd';
|
||||||
import { loadAllStocks } from '@store/slices/stockSlice';
|
import { useStockSearch } from '@hooks/useStockSearch';
|
||||||
import { AutoComplete } from 'antd';
|
|
||||||
import { stockService } from '@services/stockService';
|
|
||||||
import {
|
import {
|
||||||
Container,
|
Container,
|
||||||
Heading,
|
Heading,
|
||||||
@@ -42,22 +40,10 @@ const CompanyIndex = () => {
|
|||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [stockCode, setStockCode] = useState(searchParams.get('scode') || '000001');
|
const [stockCode, setStockCode] = useState(searchParams.get('scode') || '000001');
|
||||||
const [inputCode, setInputCode] = useState(stockCode);
|
const [inputCode, setInputCode] = useState(stockCode);
|
||||||
const [stockOptions, setStockOptions] = useState([]);
|
|
||||||
const { colorMode, toggleColorMode } = useColorMode();
|
const { colorMode, toggleColorMode } = useColorMode();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const { isAuthenticated } = useAuth();
|
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 事件追踪
|
// 🎯 PostHog 事件追踪
|
||||||
const {
|
const {
|
||||||
trackStockSearched,
|
trackStockSearched,
|
||||||
@@ -66,6 +52,35 @@ const CompanyIndex = () => {
|
|||||||
trackWatchlistRemoved,
|
trackWatchlistRemoved,
|
||||||
} = useCompanyEvents({ stockCode });
|
} = 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: (
|
||||||
|
<span>
|
||||||
|
<strong>{stock.stock_code}</strong> {stock.stock_name}
|
||||||
|
{stock.pinyin_abbr && (
|
||||||
|
<span style={{ color: '#999', marginLeft: 8 }}>
|
||||||
|
({stock.pinyin_abbr.toUpperCase()})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}, [searchResults]);
|
||||||
|
|
||||||
// Tab 索引状态(用于追踪 Tab 切换)
|
// Tab 索引状态(用于追踪 Tab 切换)
|
||||||
const [currentTabIndex, setCurrentTabIndex] = useState(0);
|
const [currentTabIndex, setCurrentTabIndex] = useState(0);
|
||||||
|
|
||||||
@@ -119,31 +134,11 @@ const CompanyIndex = () => {
|
|||||||
setSearchParams({ scode: inputCode });
|
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) => {
|
const handleStockSelect = (value) => {
|
||||||
setInputCode(value);
|
setInputCode(value);
|
||||||
setStockOptions([]);
|
clearSearch();
|
||||||
if (value !== stockCode) {
|
if (value !== stockCode) {
|
||||||
trackStockSearched(value, stockCode);
|
trackStockSearched(value, stockCode);
|
||||||
setStockCode(value);
|
setStockCode(value);
|
||||||
@@ -231,12 +226,13 @@ const CompanyIndex = () => {
|
|||||||
<AutoComplete
|
<AutoComplete
|
||||||
value={inputCode}
|
value={inputCode}
|
||||||
options={stockOptions}
|
options={stockOptions}
|
||||||
onSearch={handleStockSearch}
|
onSearch={doSearch}
|
||||||
onSelect={handleStockSelect}
|
onSelect={handleStockSelect}
|
||||||
onChange={(value) => setInputCode(value)}
|
onChange={(value) => setInputCode(value)}
|
||||||
placeholder="输入股票代码或名称"
|
placeholder="输入代码、名称或拼音缩写"
|
||||||
style={{ width: 260 }}
|
style={{ width: 280 }}
|
||||||
size="large"
|
size="large"
|
||||||
|
notFoundContent={isSearching ? <Spin size="small" /> : null}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
handleSearch();
|
handleSearch();
|
||||||
|
|||||||
Reference in New Issue
Block a user