350 lines
11 KiB
JavaScript
350 lines
11 KiB
JavaScript
import React, { useState, useEffect, useCallback } from 'react';
|
|
import { useSearchParams } from 'react-router-dom';
|
|
import {
|
|
Container,
|
|
Heading,
|
|
Card,
|
|
CardBody,
|
|
Tabs,
|
|
TabList,
|
|
TabPanels,
|
|
Tab,
|
|
TabPanel,
|
|
HStack,
|
|
VStack,
|
|
Input,
|
|
Button,
|
|
InputGroup,
|
|
InputLeftElement,
|
|
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 });
|
|
|
|
// 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]);
|
|
|
|
useEffect(() => {
|
|
loadWatchlistStatus();
|
|
}, [loadWatchlistStatus]);
|
|
|
|
const handleSearch = () => {
|
|
if (inputCode && inputCode !== stockCode) {
|
|
// 🎯 追踪股票搜索
|
|
trackStockSearched(inputCode, stockCode);
|
|
|
|
setStockCode(inputCode);
|
|
setSearchParams({ scode: inputCode });
|
|
}
|
|
};
|
|
|
|
const handleKeyPress = (e) => {
|
|
if (e.key === 'Enter') {
|
|
handleSearch();
|
|
}
|
|
};
|
|
|
|
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}>
|
|
<InputGroup size="lg" maxW="300px">
|
|
<InputLeftElement pointerEvents="none">
|
|
<SearchIcon color="gray.400" />
|
|
</InputLeftElement>
|
|
<Input
|
|
placeholder="输入股票代码"
|
|
value={inputCode}
|
|
onChange={(e) => setInputCode(e.target.value)}
|
|
onKeyPress={handleKeyPress}
|
|
borderRadius="md"
|
|
_focus={{
|
|
borderColor: 'blue.500',
|
|
boxShadow: '0 0 0 1px #3182ce'
|
|
}}
|
|
/>
|
|
</InputGroup>
|
|
<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;
|
|
|
|
|