Files
vf_react/src/views/Company/index.js
zdl b151400c65 feat: Company 页面搜索框支持拼音缩写搜索
- 新增 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>
2025-12-10 15:45:30 +08:00

383 lines
12 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.

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;