- 新增 useStockSearch Hook,提取通用股票搜索能力 - 支持代码、名称、拼音缩写模糊搜索 - 内置 300ms 防抖,避免频繁 API 调用 - 使用 useRef 存储回调,防止防抖函数重建 - Company/index.js 使用新 Hook 替换本地搜索 - 搜索结果显示拼音缩写 (如 GZMT) - 搜索框宽度调整为 280px - Mock handler 添加拼音缩写支持 - 新增 PINYIN_MAP 字符映射表 - 搜索逻辑支持拼音匹配和排序 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
383 lines
12 KiB
JavaScript
383 lines
12 KiB
JavaScript
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||
import { useSearchParams } from 'react-router-dom';
|
||
import { AutoComplete, Spin } from 'antd';
|
||
import { useStockSearch } from '@hooks/useStockSearch';
|
||
import {
|
||
Container,
|
||
Heading,
|
||
Card,
|
||
CardBody,
|
||
Tabs,
|
||
TabList,
|
||
TabPanels,
|
||
Tab,
|
||
TabPanel,
|
||
HStack,
|
||
VStack,
|
||
Button,
|
||
Text,
|
||
Badge,
|
||
Divider,
|
||
Icon,
|
||
useColorModeValue,
|
||
useColorMode,
|
||
IconButton,
|
||
useToast,
|
||
} from '@chakra-ui/react';
|
||
import { SearchIcon, MoonIcon, SunIcon, StarIcon } from '@chakra-ui/icons';
|
||
import { FaChartLine, FaMoneyBillWave, FaChartBar, FaInfoCircle } from 'react-icons/fa';
|
||
import { useAuth } from '../../contexts/AuthContext';
|
||
import { logger } from '../../utils/logger';
|
||
import { getApiBase } from '../../utils/apiConfig';
|
||
import FinancialPanorama from './FinancialPanorama';
|
||
import ForecastReport from './ForecastReport';
|
||
import MarketDataView from './MarketDataView';
|
||
import CompanyOverview from './CompanyOverview';
|
||
// 导入 PostHog 追踪 Hook
|
||
import { useCompanyEvents } from './hooks/useCompanyEvents';
|
||
|
||
const CompanyIndex = () => {
|
||
const [searchParams, setSearchParams] = useSearchParams();
|
||
const [stockCode, setStockCode] = useState(searchParams.get('scode') || '000001');
|
||
const [inputCode, setInputCode] = useState(stockCode);
|
||
const { colorMode, toggleColorMode } = useColorMode();
|
||
const toast = useToast();
|
||
const { isAuthenticated } = useAuth();
|
||
|
||
// 🎯 PostHog 事件追踪
|
||
const {
|
||
trackStockSearched,
|
||
trackTabChanged,
|
||
trackWatchlistAdded,
|
||
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: (
|
||
<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 切换)
|
||
const [currentTabIndex, setCurrentTabIndex] = useState(0);
|
||
|
||
const bgColor = useColorModeValue('white', 'gray.800');
|
||
const tabBg = useColorModeValue('gray.50', 'gray.700');
|
||
const activeBg = useColorModeValue('blue.500', 'blue.400');
|
||
|
||
const [isInWatchlist, setIsInWatchlist] = useState(false);
|
||
const [isWatchlistLoading, setIsWatchlistLoading] = useState(false);
|
||
|
||
const loadWatchlistStatus = useCallback(async () => {
|
||
try {
|
||
const base = getApiBase();
|
||
const resp = await fetch(base + '/api/account/watchlist', {
|
||
credentials: 'include',
|
||
cache: 'no-store',
|
||
headers: { 'Cache-Control': 'no-cache' }
|
||
});
|
||
if (!resp.ok) {
|
||
setIsInWatchlist(false);
|
||
return;
|
||
}
|
||
const data = await resp.json();
|
||
const list = Array.isArray(data?.data) ? data.data : [];
|
||
const codes = new Set(list.map((item) => item.stock_code));
|
||
setIsInWatchlist(codes.has(stockCode));
|
||
} catch (e) {
|
||
setIsInWatchlist(false);
|
||
}
|
||
}, [stockCode]);
|
||
|
||
// 当URL参数变化时更新股票代码
|
||
useEffect(() => {
|
||
const scode = searchParams.get('scode');
|
||
if (scode && scode !== stockCode) {
|
||
setStockCode(scode);
|
||
setInputCode(scode);
|
||
}
|
||
}, [searchParams, stockCode]);
|
||
|
||
useEffect(() => {
|
||
loadWatchlistStatus();
|
||
}, [loadWatchlistStatus]);
|
||
|
||
const handleSearch = () => {
|
||
if (inputCode && inputCode !== stockCode) {
|
||
// 🎯 追踪股票搜索
|
||
trackStockSearched(inputCode, stockCode);
|
||
|
||
setStockCode(inputCode);
|
||
setSearchParams({ scode: inputCode });
|
||
}
|
||
};
|
||
|
||
// 选中股票
|
||
const handleStockSelect = (value) => {
|
||
setInputCode(value);
|
||
clearSearch();
|
||
if (value !== stockCode) {
|
||
trackStockSearched(value, stockCode);
|
||
setStockCode(value);
|
||
setSearchParams({ scode: value });
|
||
}
|
||
};
|
||
|
||
const handleWatchlistToggle = async () => {
|
||
if (!stockCode) {
|
||
logger.warn('CompanyIndex', 'handleWatchlistToggle', '无效的股票代码', { stockCode });
|
||
toast({ title: '无效的股票代码', status: 'error', duration: 2000 });
|
||
return;
|
||
}
|
||
if (!isAuthenticated) {
|
||
logger.warn('CompanyIndex', 'handleWatchlistToggle', '用户未登录', { stockCode });
|
||
toast({ title: '请先登录后再加入自选', status: 'warning', duration: 2000 });
|
||
return;
|
||
}
|
||
try {
|
||
setIsWatchlistLoading(true);
|
||
const base = getApiBase();
|
||
if (isInWatchlist) {
|
||
logger.debug('CompanyIndex', '准备从自选移除', { stockCode });
|
||
const url = base + `/api/account/watchlist/${stockCode}`;
|
||
logger.api.request('DELETE', url, { stockCode });
|
||
|
||
const resp = await fetch(url, {
|
||
method: 'DELETE',
|
||
credentials: 'include'
|
||
});
|
||
|
||
logger.api.response('DELETE', url, resp.status);
|
||
if (!resp.ok) throw new Error('删除失败');
|
||
|
||
// 🎯 追踪移除自选
|
||
trackWatchlistRemoved(stockCode);
|
||
|
||
setIsInWatchlist(false);
|
||
toast({ title: '已从自选移除', status: 'info', duration: 1500 });
|
||
} else {
|
||
logger.debug('CompanyIndex', '准备添加到自选', { stockCode });
|
||
const url = base + '/api/account/watchlist';
|
||
const body = { stock_code: stockCode };
|
||
logger.api.request('POST', url, body);
|
||
|
||
const resp = await fetch(url, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
credentials: 'include',
|
||
body: JSON.stringify(body)
|
||
});
|
||
|
||
logger.api.response('POST', url, resp.status);
|
||
if (!resp.ok) throw new Error('添加失败');
|
||
|
||
// 🎯 追踪加入自选
|
||
trackWatchlistAdded(stockCode);
|
||
|
||
setIsInWatchlist(true);
|
||
toast({ title: '已加入自选', status: 'success', duration: 1500 });
|
||
}
|
||
} catch (error) {
|
||
logger.error('CompanyIndex', 'handleWatchlistToggle', error, { stockCode, isInWatchlist });
|
||
toast({ title: '操作失败,请稍后重试', status: 'error', duration: 2000 });
|
||
} finally {
|
||
setIsWatchlistLoading(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<Container maxW="container.xl" py={5}>
|
||
{/* 页面标题和股票搜索 */}
|
||
<VStack align="stretch" spacing={5}>
|
||
<Card bg={bgColor} shadow="md">
|
||
<CardBody>
|
||
<HStack justify="space-between" align="center">
|
||
<VStack align="start" spacing={1}>
|
||
<Heading size="lg">个股详情</Heading>
|
||
<Text color="gray.600" fontSize="sm">
|
||
查看股票实时行情、财务数据和盈利预测
|
||
</Text>
|
||
</VStack>
|
||
|
||
<HStack spacing={3}>
|
||
<AutoComplete
|
||
value={inputCode}
|
||
options={stockOptions}
|
||
onSearch={doSearch}
|
||
onSelect={handleStockSelect}
|
||
onChange={(value) => setInputCode(value)}
|
||
placeholder="输入代码、名称或拼音缩写"
|
||
style={{ width: 280 }}
|
||
size="large"
|
||
notFoundContent={isSearching ? <Spin size="small" /> : null}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter') {
|
||
handleSearch();
|
||
}
|
||
}}
|
||
/>
|
||
<Button
|
||
colorScheme="blue"
|
||
size="lg"
|
||
onClick={handleSearch}
|
||
leftIcon={<SearchIcon />}
|
||
>
|
||
查询
|
||
</Button>
|
||
<Button
|
||
colorScheme={isInWatchlist ? 'yellow' : 'teal'}
|
||
variant={isInWatchlist ? 'solid' : 'outline'}
|
||
size="lg"
|
||
onClick={handleWatchlistToggle}
|
||
leftIcon={<StarIcon />}
|
||
isLoading={isWatchlistLoading}
|
||
>
|
||
{isInWatchlist ? '已在自选' : '加入自选'}
|
||
</Button>
|
||
<IconButton
|
||
icon={colorMode === 'light' ? <MoonIcon /> : <SunIcon />}
|
||
onClick={toggleColorMode}
|
||
variant="outline"
|
||
colorScheme={colorMode === 'light' ? 'blue' : 'yellow'}
|
||
size="lg"
|
||
aria-label="Toggle color mode"
|
||
/>
|
||
</HStack>
|
||
</HStack>
|
||
|
||
{/* 当前股票信息 */}
|
||
<HStack mt={4} spacing={4}>
|
||
<Badge colorScheme="blue" fontSize="md" px={3} py={1}>
|
||
股票代码: {stockCode}
|
||
</Badge>
|
||
<Text fontSize="sm" color="gray.600">
|
||
更新时间: {new Date().toLocaleString()}
|
||
</Text>
|
||
</HStack>
|
||
</CardBody>
|
||
</Card>
|
||
|
||
{/* 数据展示区域 */}
|
||
<Card bg={bgColor} shadow="lg">
|
||
<CardBody p={0}>
|
||
<Tabs
|
||
variant="soft-rounded"
|
||
colorScheme="blue"
|
||
size="lg"
|
||
index={currentTabIndex}
|
||
onChange={(index) => {
|
||
const tabNames = ['公司概览', '股票行情', '财务全景', '盈利预测'];
|
||
// 🎯 追踪 Tab 切换
|
||
trackTabChanged(index, tabNames[index], currentTabIndex);
|
||
setCurrentTabIndex(index);
|
||
}}
|
||
>
|
||
<TabList p={4} bg={tabBg}>
|
||
<Tab
|
||
_selected={{
|
||
bg: activeBg,
|
||
color: 'white',
|
||
transform: 'scale(1.02)',
|
||
transition: 'all 0.2s'
|
||
}}
|
||
mr={2}
|
||
>
|
||
<HStack spacing={2}>
|
||
<Icon as={FaInfoCircle} />
|
||
<Text>公司概览</Text>
|
||
</HStack>
|
||
</Tab>
|
||
<Tab
|
||
_selected={{
|
||
bg: activeBg,
|
||
color: 'white',
|
||
transform: 'scale(1.02)',
|
||
transition: 'all 0.2s'
|
||
}}
|
||
mr={2}
|
||
>
|
||
<HStack spacing={2}>
|
||
<Icon as={FaChartLine} />
|
||
<Text>股票行情</Text>
|
||
</HStack>
|
||
</Tab>
|
||
<Tab
|
||
_selected={{
|
||
bg: activeBg,
|
||
color: 'white',
|
||
transform: 'scale(1.02)',
|
||
transition: 'all 0.2s'
|
||
}}
|
||
mr={2}
|
||
>
|
||
<HStack spacing={2}>
|
||
<Icon as={FaMoneyBillWave} />
|
||
<Text>财务全景</Text>
|
||
</HStack>
|
||
</Tab>
|
||
<Tab
|
||
_selected={{
|
||
bg: activeBg,
|
||
color: 'white',
|
||
transform: 'scale(1.02)',
|
||
transition: 'all 0.2s'
|
||
}}
|
||
>
|
||
<HStack spacing={2}>
|
||
<Icon as={FaChartBar} />
|
||
<Text>盈利预测</Text>
|
||
</HStack>
|
||
</Tab>
|
||
</TabList>
|
||
|
||
<Divider />
|
||
|
||
<TabPanels>
|
||
<TabPanel p={6}>
|
||
<CompanyOverview stockCode={stockCode} />
|
||
</TabPanel>
|
||
<TabPanel p={6}>
|
||
<MarketDataView stockCode={stockCode} />
|
||
</TabPanel>
|
||
<TabPanel p={6}>
|
||
<FinancialPanorama stockCode={stockCode} />
|
||
</TabPanel>
|
||
<TabPanel p={6}>
|
||
<ForecastReport stockCode={stockCode} />
|
||
</TabPanel>
|
||
</TabPanels>
|
||
</Tabs>
|
||
</CardBody>
|
||
</Card>
|
||
</VStack>
|
||
</Container>
|
||
);
|
||
};
|
||
|
||
export default CompanyIndex;
|
||
|
||
|