update pay ui

This commit is contained in:
2025-12-17 17:22:49 +08:00
parent c1fcf6714e
commit 8def7f355b

View File

@@ -3,63 +3,77 @@ import { useSearchParams } from 'react-router-dom';
import { AutoComplete, Spin } from 'antd'; import { AutoComplete, Spin } from 'antd';
import { useStockSearch } from '@hooks/useStockSearch'; import { useStockSearch } from '@hooks/useStockSearch';
import { import {
Container, Box,
Heading, Flex,
Card,
CardBody,
Tabs,
TabList,
TabPanels,
Tab,
TabPanel,
HStack, HStack,
VStack, VStack,
Button,
Text, Text,
Badge, Button,
Divider,
Icon, Icon,
useColorModeValue, Badge,
useColorMode,
IconButton,
useToast, useToast,
Skeleton,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { SearchIcon, MoonIcon, SunIcon, StarIcon } from '@chakra-ui/icons'; import { Search, Star, Building2, TrendingUp, Wallet, FileBarChart } from 'lucide-react';
import { FaChartLine, FaMoneyBillWave, FaChartBar, FaInfoCircle } from 'react-icons/fa';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { logger } from '../../utils/logger'; import { logger } from '../../utils/logger';
import { getApiBase } from '../../utils/apiConfig'; import { getApiBase } from '../../utils/apiConfig';
// Tab 内容组件
import FinancialPanorama from './components/FinancialPanorama'; import FinancialPanorama from './components/FinancialPanorama';
import ForecastReport from './ForecastReport'; import ForecastReport from './ForecastReport';
import MarketDataView from './MarketDataView'; import MarketDataView from './MarketDataView';
import CompanyOverview from './components/CompanyOverview'; import CompanyOverview from './components/CompanyOverview';
// 导入 PostHog 追踪 Hook
// PostHog 追踪
import { useCompanyEvents } from './hooks/useCompanyEvents'; import { useCompanyEvents } from './hooks/useCompanyEvents';
// 页面组件 // 通用组件
import CompanyHeader from './components/CompanyHeader'; import SubTabContainer from '@components/SubTabContainer';
import StockQuoteCard from './components/StockQuoteCard';
import CompanyTabs from './components/CompanyTabs'; // ============================================
// 黑金主题配置
// ============================================
const THEME = {
bg: '#1A1A2E', // 深蓝黑背景
cardBg: '#16213E', // 卡片背景
gold: '#D4AF37', // 金色
goldLight: '#F0D78C', // 浅金色
goldDark: '#B8960C', // 深金色
textPrimary: '#FFFFFF',
textSecondary: 'rgba(255, 255, 255, 0.7)',
textMuted: 'rgba(255, 255, 255, 0.5)',
border: 'rgba(212, 175, 55, 0.2)',
borderHover: 'rgba(212, 175, 55, 0.4)',
};
// ============================================
// Tab 配置
// ============================================
const TAB_CONFIG = [
{ key: 'overview', name: '公司概览', icon: Building2, component: CompanyOverview },
{ key: 'market', name: '股票行情', icon: TrendingUp, component: MarketDataView },
{ key: 'financial', name: '财务全景', icon: Wallet, component: FinancialPanorama },
{ key: 'forecast', name: '盈利预测', icon: FileBarChart, component: ForecastReport },
];
/** /**
* 公司详情页面 * 公司详情页面
* *
* 功能: * 使用黑金主题,紧凑布局
* - 股票搜索与代码管理
* - 自选股添加/移除
* - 多维度数据展示(概览、行情、财务、预测)
* - PostHog 事件追踪
*/ */
const CompanyIndex = () => { 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 [stockName, setStockName] = useState('');
const [stockPrice, setStockPrice] = useState(null);
const [priceChange, setPriceChange] = useState(null);
const prevStockCodeRef = useRef(stockCode); const prevStockCodeRef = useRef(stockCode);
const { colorMode, toggleColorMode } = useColorMode();
const toast = useToast(); const toast = useToast();
const { isAuthenticated } = useAuth(); const { isAuthenticated } = useAuth();
// 🎯 PostHog 事件追踪 // PostHog 事件追踪
const { const {
trackStockSearched, trackStockSearched,
trackTabChanged, trackTabChanged,
@@ -67,7 +81,7 @@ const CompanyIndex = () => {
trackWatchlistRemoved, trackWatchlistRemoved,
} = useCompanyEvents({ stockCode }); } = useCompanyEvents({ stockCode });
// 🔍 股票搜索 Hook(支持代码、名称、拼音缩写) // 股票搜索 Hook
const { const {
searchResults, searchResults,
isSearching, isSearching,
@@ -84,28 +98,26 @@ const CompanyIndex = () => {
return searchResults.map((stock) => ({ return searchResults.map((stock) => ({
value: stock.stock_code, value: stock.stock_code,
label: ( label: (
<span> <Flex justify="space-between" align="center" py={1}>
<strong>{stock.stock_code}</strong> {stock.stock_name} <HStack spacing={2}>
<Text fontWeight="bold" color={THEME.gold}>{stock.stock_code}</Text>
<Text color={THEME.textPrimary}>{stock.stock_name}</Text>
</HStack>
{stock.pinyin_abbr && ( {stock.pinyin_abbr && (
<span style={{ color: '#999', marginLeft: 8 }}> <Text fontSize="xs" color={THEME.textMuted}>
({stock.pinyin_abbr.toUpperCase()}) {stock.pinyin_abbr.toUpperCase()}
</span> </Text>
)} )}
</span> </Flex>
), ),
})); }));
}, [searchResults]); }, [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 [isInWatchlist, setIsInWatchlist] = useState(false);
const [isWatchlistLoading, setIsWatchlistLoading] = useState(false); const [isWatchlistLoading, setIsWatchlistLoading] = useState(false);
// 加载自选股状态
const loadWatchlistStatus = useCallback(async () => { const loadWatchlistStatus = useCallback(async () => {
try { try {
const base = getApiBase(); const base = getApiBase();
@@ -126,23 +138,43 @@ const CompanyIndex = () => {
} }
}, [stockCode]); }, [stockCode]);
// 当URL参数变化时更新股票代码 // 加载股票基本信息
const loadStockInfo = useCallback(async () => {
try {
const base = getApiBase();
const resp = await fetch(base + `/api/financial/stock-info/${stockCode}`, {
credentials: 'include',
});
if (resp.ok) {
const data = await resp.json();
if (data.success && data.data) {
setStockName(data.data.stock_name || '');
setStockPrice(data.data.close_price);
setPriceChange(data.data.change_pct);
}
}
} catch (e) {
logger.error('CompanyIndex', 'loadStockInfo', e);
}
}, [stockCode]);
// URL 参数变化时更新股票代码
useEffect(() => { useEffect(() => {
if (stockCode !== prevStockCodeRef.current) { if (stockCode !== prevStockCodeRef.current) {
trackStockSearched(stockCode, prevStockCodeRef.current); trackStockSearched(stockCode, prevStockCodeRef.current);
prevStockCodeRef.current = stockCode; prevStockCodeRef.current = stockCode;
} }
}, [searchParams, stockCode]); }, [searchParams, stockCode, trackStockSearched]);
useEffect(() => { useEffect(() => {
loadWatchlistStatus(); loadWatchlistStatus();
}, [loadWatchlistStatus]); loadStockInfo();
}, [loadWatchlistStatus, loadStockInfo]);
// 搜索处理
const handleSearch = () => { const handleSearch = () => {
if (inputCode && inputCode !== stockCode) { if (inputCode && inputCode !== stockCode) {
// 🎯 追踪股票搜索
trackStockSearched(inputCode, stockCode); trackStockSearched(inputCode, stockCode);
setStockCode(inputCode); setStockCode(inputCode);
setSearchParams({ scode: inputCode }); setSearchParams({ scode: inputCode });
} }
@@ -159,14 +191,13 @@ const CompanyIndex = () => {
} }
}; };
// 自选股切换
const handleWatchlistToggle = async () => { const handleWatchlistToggle = async () => {
if (!stockCode) { if (!stockCode) {
logger.warn('CompanyIndex', 'handleWatchlistToggle', '无效的股票代码', { stockCode });
toast({ title: '无效的股票代码', status: 'error', duration: 2000 }); toast({ title: '无效的股票代码', status: 'error', duration: 2000 });
return; return;
} }
if (!isAuthenticated) { if (!isAuthenticated) {
logger.warn('CompanyIndex', 'handleWatchlistToggle', '用户未登录', { stockCode });
toast({ title: '请先登录后再加入自选', status: 'warning', duration: 2000 }); toast({ title: '请先登录后再加入自选', status: 'warning', duration: 2000 });
return; return;
} }
@@ -174,219 +205,206 @@ const CompanyIndex = () => {
setIsWatchlistLoading(true); setIsWatchlistLoading(true);
const base = getApiBase(); const base = getApiBase();
if (isInWatchlist) { if (isInWatchlist) {
logger.debug('CompanyIndex', '准备从自选移除', { stockCode }); const resp = await fetch(base + `/api/account/watchlist/${stockCode}`, {
const url = base + `/api/account/watchlist/${stockCode}`;
logger.api.request('DELETE', url, { stockCode });
const resp = await fetch(url, {
method: 'DELETE', method: 'DELETE',
credentials: 'include' credentials: 'include'
}); });
logger.api.response('DELETE', url, resp.status);
if (!resp.ok) throw new Error('删除失败'); if (!resp.ok) throw new Error('删除失败');
// 🎯 追踪移除自选
trackWatchlistRemoved(stockCode); trackWatchlistRemoved(stockCode);
setIsInWatchlist(false); setIsInWatchlist(false);
toast({ title: '已从自选移除', status: 'info', duration: 1500 }); toast({ title: '已从自选移除', status: 'info', duration: 1500 });
} else { } else {
logger.debug('CompanyIndex', '准备添加到自选', { stockCode }); const resp = await fetch(base + '/api/account/watchlist', {
const url = base + '/api/account/watchlist';
const body = { stock_code: stockCode };
logger.api.request('POST', url, body);
const resp = await fetch(url, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
credentials: 'include', credentials: 'include',
body: JSON.stringify(body) body: JSON.stringify({ stock_code: stockCode })
}); });
logger.api.response('POST', url, resp.status);
if (!resp.ok) throw new Error('添加失败'); if (!resp.ok) throw new Error('添加失败');
// 🎯 追踪加入自选
trackWatchlistAdded(stockCode); trackWatchlistAdded(stockCode);
setIsInWatchlist(true); setIsInWatchlist(true);
toast({ title: '已加入自选', status: 'success', duration: 1500 }); toast({ title: '已加入自选', status: 'success', duration: 1500 });
} }
} catch (error) { } catch (error) {
logger.error('CompanyIndex', 'handleWatchlistToggle', error, { stockCode, isInWatchlist }); logger.error('CompanyIndex', 'handleWatchlistToggle', error);
toast({ title: '操作失败,请稍后重试', status: 'error', duration: 2000 }); toast({ title: '操作失败,请稍后重试', status: 'error', duration: 2000 });
} finally { } finally {
setIsWatchlistLoading(false); setIsWatchlistLoading(false);
} }
}; };
// Tab 变更处理
const handleTabChange = useCallback((index, tabKey) => {
const tabNames = TAB_CONFIG.map(t => t.name);
trackTabChanged(index, tabNames[index], index);
}, [trackTabChanged]);
return ( return (
<Container maxW="container.xl" py={5}> <Box bg={THEME.bg} minH="calc(100vh - 60px)">
{/* 页面标题和股票搜索 */} {/* 顶部搜索 */}
<VStack align="stretch" spacing={5}> <Box
<Card bg={bgColor} shadow="md"> bg={THEME.cardBg}
<CardBody> borderBottom="1px solid"
<HStack justify="space-between" align="center"> borderColor={THEME.border}
<VStack align="start" spacing={1}> px={6}
<Heading size="lg">个股详情</Heading> py={4}
<Text color="gray.600" fontSize="sm"> >
查看股票实时行情财务数据和盈利预测 <Flex
maxW="container.xl"
mx="auto"
justify="space-between"
align="center"
wrap="wrap"
gap={4}
>
{/* 左侧:股票信息 */}
<HStack spacing={4}>
{/* 股票代码和名称 */}
<VStack align="start" spacing={0}>
<HStack spacing={2}>
<Text
fontSize="2xl"
fontWeight="bold"
color={THEME.gold}
letterSpacing="wider"
>
{stockCode}
</Text> </Text>
</VStack> {stockName && (
<Text fontSize="xl" fontWeight="medium" color={THEME.textPrimary}>
<HStack spacing={3}> {stockName}
<AutoComplete </Text>
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> {stockPrice !== null && (
<HStack spacing={3} mt={1}>
<Text fontSize="lg" fontWeight="bold" color={THEME.textPrimary}>
¥{stockPrice?.toFixed(2)}
</Text>
{priceChange !== null && (
<Badge
px={2}
py={0.5}
borderRadius="md"
bg={priceChange >= 0 ? 'rgba(239, 68, 68, 0.2)' : 'rgba(34, 197, 94, 0.2)'}
color={priceChange >= 0 ? '#EF4444' : '#22C55E'}
fontSize="sm"
fontWeight="bold"
>
{priceChange >= 0 ? '+' : ''}{priceChange?.toFixed(2)}%
</Badge>
)}
</HStack>
)}
</VStack>
</HStack>
{/* 当前股票信息 */} {/* 右侧:搜索和操作 */}
<HStack mt={4} spacing={4}> <HStack spacing={3}>
<Badge colorScheme="blue" fontSize="md" px={3} py={1}> {/* 搜索框 */}
股票代码: {stockCode} <Box
</Badge> sx={{
<Text fontSize="sm" color="gray.600"> '.ant-select-selector': {
更新时间: {new Date().toLocaleString()} backgroundColor: `${THEME.bg} !important`,
</Text> borderColor: `${THEME.border} !important`,
</HStack> borderRadius: '8px !important',
</CardBody> height: '40px !important',
</Card> '&:hover': {
borderColor: `${THEME.borderHover} !important`,
{/* 数据展示区域 */} },
<Card bg={bgColor} shadow="lg"> },
<CardBody p={0}> '.ant-select-selection-search-input': {
<Tabs color: `${THEME.textPrimary} !important`,
variant="soft-rounded" height: '38px !important',
colorScheme="blue" },
size="lg" '.ant-select-selection-placeholder': {
index={currentTabIndex} color: `${THEME.textMuted} !important`,
onChange={(index) => { },
const tabNames = ['公司概览', '股票行情', '财务全景', '盈利预测']; '.ant-select-dropdown': {
// 🎯 追踪 Tab 切换 backgroundColor: `${THEME.cardBg} !important`,
trackTabChanged(index, tabNames[index], currentTabIndex); borderColor: `${THEME.border} !important`,
setCurrentTabIndex(index); },
'.ant-select-item': {
color: `${THEME.textPrimary} !important`,
'&:hover': {
backgroundColor: `${THEME.bg} !important`,
},
},
'.ant-select-item-option-selected': {
backgroundColor: `rgba(212, 175, 55, 0.2) !important`,
},
}} }}
> >
<TabList p={4} bg={tabBg}> <AutoComplete
<Tab value={inputCode}
_selected={{ options={stockOptions}
bg: activeBg, onSearch={doSearch}
color: 'white', onSelect={handleStockSelect}
transform: 'scale(1.02)', onChange={(value) => setInputCode(value)}
transition: 'all 0.2s' placeholder="输入代码、名称或拼音"
}} style={{ width: 220 }}
mr={2} notFoundContent={isSearching ? <Spin size="small" /> : null}
> onKeyDown={(e) => {
<HStack spacing={2}> if (e.key === 'Enter') handleSearch();
<Icon as={FaInfoCircle} /> }}
<Text>公司概览</Text> />
</HStack> </Box>
</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 /> {/* 搜索按钮 */}
<Button
bg={THEME.gold}
color={THEME.bg}
_hover={{ bg: THEME.goldLight }}
_active={{ bg: THEME.goldDark }}
size="md"
onClick={handleSearch}
leftIcon={<Icon as={Search} boxSize={4} />}
fontWeight="bold"
>
查询
</Button>
<TabPanels> {/* 自选按钮 */}
<TabPanel p={6}> <Button
<CompanyOverview stockCode={stockCode} /> variant={isInWatchlist ? 'solid' : 'outline'}
</TabPanel> bg={isInWatchlist ? THEME.gold : 'transparent'}
<TabPanel p={6}> color={isInWatchlist ? THEME.bg : THEME.gold}
<MarketDataView stockCode={stockCode} /> borderColor={THEME.gold}
</TabPanel> _hover={{
<TabPanel p={6}> bg: isInWatchlist ? THEME.goldLight : 'rgba(212, 175, 55, 0.1)',
<FinancialPanorama stockCode={stockCode} /> }}
</TabPanel> size="md"
<TabPanel p={6}> onClick={handleWatchlistToggle}
<ForecastReport stockCode={stockCode} /> isLoading={isWatchlistLoading}
</TabPanel> leftIcon={<Icon as={Star} boxSize={4} fill={isInWatchlist ? 'currentColor' : 'none'} />}
</TabPanels> fontWeight="bold"
</Tabs> >
</CardBody> {isInWatchlist ? '已自选' : '自选'}
</Card> </Button>
</VStack> </HStack>
</Container> </Flex>
</Box>
{/* 主内容区 - Tab 切换 */}
<Box maxW="container.xl" mx="auto" px={4} py={6}>
<Box
bg={THEME.cardBg}
borderRadius="xl"
border="1px solid"
borderColor={THEME.border}
overflow="hidden"
>
<SubTabContainer
tabs={TAB_CONFIG}
componentProps={{ stockCode }}
onTabChange={handleTabChange}
themePreset="blackGold"
contentPadding={6}
isLazy={true}
/>
</Box>
</Box>
</Box>
); );
}; };