refactor(Company): 简化 CompanyHeader,添加详细代码注释
- CompanyHeader: 移除冗余的股票信息展示(已在 StockQuoteCard 中) - index.tsx: 添加完整的 JSDoc 注释和架构说明 - types.ts: 简化 CompanyHeaderProps,移除不再需要的属性 - useStockQuoteData: 优化数据获取逻辑 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,147 +0,0 @@
|
||||
// src/views/Company/components/CompanyHeader/SearchBar.js
|
||||
// 股票搜索栏组件 - 金色主题 + 模糊搜索下拉
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import {
|
||||
Box,
|
||||
HStack,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputLeftElement,
|
||||
Text,
|
||||
VStack,
|
||||
} from '@chakra-ui/react';
|
||||
import { SearchIcon } from '@chakra-ui/icons';
|
||||
import { useStockSearch } from '../../hooks/useStockSearch';
|
||||
|
||||
/**
|
||||
* 股票搜索栏组件(带模糊搜索下拉)
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {string} props.inputCode - 输入框当前值
|
||||
* @param {Function} props.onInputChange - 输入变化回调
|
||||
* @param {Function} props.onSearch - 搜索按钮点击回调
|
||||
* @param {Function} props.onKeyDown - 键盘事件回调
|
||||
*/
|
||||
const SearchBar = ({
|
||||
inputCode,
|
||||
onInputChange,
|
||||
onSearch,
|
||||
onKeyDown,
|
||||
}) => {
|
||||
// 下拉状态
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const containerRef = useRef(null);
|
||||
|
||||
// 从 Redux 获取全部股票列表
|
||||
const allStocks = useSelector(state => state.stock.allStocks);
|
||||
|
||||
// 使用共享的搜索 Hook
|
||||
const filteredStocks = useStockSearch(allStocks, inputCode, { limit: 10 });
|
||||
|
||||
// 根据搜索结果更新下拉显示状态
|
||||
useEffect(() => {
|
||||
setShowDropdown(filteredStocks.length > 0 && !!inputCode?.trim());
|
||||
}, [filteredStocks, inputCode]);
|
||||
|
||||
// 点击外部关闭下拉
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target)) {
|
||||
setShowDropdown(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// 选择股票 - 直接触发搜索跳转
|
||||
const handleSelectStock = (stock) => {
|
||||
onInputChange(stock.code);
|
||||
setShowDropdown(false);
|
||||
onSearch(stock.code);
|
||||
};
|
||||
|
||||
// 处理键盘事件
|
||||
const handleKeyDownWrapper = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
setShowDropdown(false);
|
||||
}
|
||||
onKeyDown?.(e);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box ref={containerRef} position="relative" w="300px">
|
||||
<InputGroup size="lg">
|
||||
<InputLeftElement pointerEvents="none">
|
||||
<SearchIcon color="#C9A961" />
|
||||
</InputLeftElement>
|
||||
<Input
|
||||
placeholder="输入股票代码或名称"
|
||||
value={inputCode}
|
||||
onChange={(e) => onInputChange(e.target.value)}
|
||||
onKeyDown={handleKeyDownWrapper}
|
||||
onFocus={() => inputCode && filteredStocks.length > 0 && setShowDropdown(true)}
|
||||
borderRadius="md"
|
||||
color="white"
|
||||
borderColor="#C9A961"
|
||||
_placeholder={{ color: '#C9A961' }}
|
||||
_focus={{
|
||||
borderColor: '#F4D03F',
|
||||
boxShadow: '0 0 0 1px #F4D03F',
|
||||
}}
|
||||
_hover={{
|
||||
borderColor: '#F4D03F',
|
||||
}}
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
{/* 模糊搜索下拉列表 */}
|
||||
{showDropdown && (
|
||||
<Box
|
||||
position="absolute"
|
||||
top="100%"
|
||||
left={0}
|
||||
mt={1}
|
||||
w="100%"
|
||||
bg="#1A202C"
|
||||
border="1px solid #C9A961"
|
||||
borderRadius="md"
|
||||
maxH="300px"
|
||||
overflowY="auto"
|
||||
zIndex={1000}
|
||||
boxShadow="0 4px 12px rgba(0, 0, 0, 0.3)"
|
||||
>
|
||||
<VStack align="stretch" spacing={0}>
|
||||
{filteredStocks.map((stock) => (
|
||||
<Box
|
||||
key={stock.code}
|
||||
px={4}
|
||||
py={2}
|
||||
cursor="pointer"
|
||||
_hover={{ bg: 'whiteAlpha.100' }}
|
||||
onClick={() => handleSelectStock(stock)}
|
||||
borderBottom="1px solid"
|
||||
borderColor="whiteAlpha.100"
|
||||
_last={{ borderBottom: 'none' }}
|
||||
>
|
||||
<HStack justify="space-between">
|
||||
<Text color="#F4D03F" fontWeight="bold" fontSize="sm">
|
||||
{stock.code}
|
||||
</Text>
|
||||
<Text color="#C9A961" fontSize="sm" noOfLines={1} maxW="180px">
|
||||
{stock.name}
|
||||
</Text>
|
||||
</HStack>
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchBar;
|
||||
@@ -2,10 +2,9 @@
|
||||
* Company 页面顶部搜索栏组件 - FUI 科幻风格
|
||||
*
|
||||
* 设计特点:
|
||||
* - Glassmorphism 毛玻璃背景
|
||||
* - 发光效果和微动画
|
||||
* - Ash Thorp 风格的数据展示
|
||||
* - James Turrell 柔和光影
|
||||
* - 左侧固定标题 + 副标题
|
||||
* - 右侧简洁搜索框
|
||||
* - 深色背景 + 金色强调色
|
||||
*/
|
||||
|
||||
import React, { memo, useMemo, useCallback, useState } from 'react';
|
||||
@@ -15,43 +14,19 @@ import {
|
||||
HStack,
|
||||
VStack,
|
||||
Text,
|
||||
Button,
|
||||
Icon,
|
||||
Skeleton,
|
||||
} from '@chakra-ui/react';
|
||||
import { AutoComplete, Spin } from 'antd';
|
||||
import { Search, Star, TrendingUp, TrendingDown } from 'lucide-react';
|
||||
import { AutoComplete, Input, Spin } from 'antd';
|
||||
import { SearchOutlined } from '@ant-design/icons';
|
||||
import { useStockSearch } from '@hooks/useStockSearch';
|
||||
import { THEME, getSearchBoxStyles } from '../../config';
|
||||
import { THEME } from '../../config';
|
||||
import { FUI_COLORS, FUI_GLOW, FUI_ANIMATION, FUI_GLASS } from '../../theme/fui';
|
||||
import type { CompanyHeaderProps, StockSearchResult } from '../../types';
|
||||
|
||||
/**
|
||||
* 股票信息展示组件 - FUI 风格
|
||||
* 页面标题组件
|
||||
*/
|
||||
const StockInfoDisplay = memo<{
|
||||
stockCode: string;
|
||||
stockName?: string;
|
||||
price?: number | null;
|
||||
change?: number | null;
|
||||
loading: boolean;
|
||||
}>(({ stockCode, stockName, price, change, loading }) => {
|
||||
if (loading) {
|
||||
return (
|
||||
<VStack align="start" spacing={2}>
|
||||
<Skeleton height="36px" width="220px" startColor={FUI_COLORS.bg.surface} endColor={FUI_COLORS.bg.elevated} />
|
||||
<Skeleton height="28px" width="160px" startColor={FUI_COLORS.bg.surface} endColor={FUI_COLORS.bg.elevated} />
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
const isPositive = change !== null && change !== undefined && change >= 0;
|
||||
const TrendIcon = isPositive ? TrendingUp : TrendingDown;
|
||||
|
||||
return (
|
||||
const PageTitle = memo(() => (
|
||||
<VStack align="start" spacing={1}>
|
||||
{/* 股票代码 & 名称 */}
|
||||
<HStack spacing={3} align="baseline">
|
||||
<Text
|
||||
fontSize="2xl"
|
||||
fontWeight="bold"
|
||||
@@ -59,106 +34,32 @@ const StockInfoDisplay = memo<{
|
||||
letterSpacing="wider"
|
||||
textShadow={FUI_GLOW.text.gold}
|
||||
>
|
||||
{stockCode}
|
||||
个股详情
|
||||
</Text>
|
||||
{stockName && (
|
||||
<Text
|
||||
fontSize="xl"
|
||||
fontWeight="medium"
|
||||
color={FUI_COLORS.text.primary}
|
||||
letterSpacing="wide"
|
||||
>
|
||||
{stockName}
|
||||
</Text>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
{/* 价格 & 涨跌幅 */}
|
||||
{price !== null && price !== undefined && (
|
||||
<HStack spacing={4} mt={1}>
|
||||
{/* 价格 */}
|
||||
<HStack spacing={1}>
|
||||
<Text
|
||||
fontSize="xs"
|
||||
color={FUI_COLORS.text.muted}
|
||||
textTransform="uppercase"
|
||||
letterSpacing="wider"
|
||||
>
|
||||
Price
|
||||
</Text>
|
||||
<Text
|
||||
fontSize="xl"
|
||||
fontWeight="bold"
|
||||
color={FUI_COLORS.text.primary}
|
||||
fontFamily="mono"
|
||||
>
|
||||
¥{price.toFixed(2)}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{/* 涨跌幅 Badge */}
|
||||
{change !== null && change !== undefined && (
|
||||
<Box
|
||||
display="inline-flex"
|
||||
alignItems="center"
|
||||
gap={1}
|
||||
px={3}
|
||||
py={1}
|
||||
borderRadius="full"
|
||||
bg={isPositive ? 'rgba(239, 68, 68, 0.15)' : 'rgba(34, 197, 94, 0.15)'}
|
||||
border="1px solid"
|
||||
borderColor={isPositive ? 'rgba(239, 68, 68, 0.3)' : 'rgba(34, 197, 94, 0.3)'}
|
||||
boxShadow={isPositive
|
||||
? '0 0 12px rgba(239, 68, 68, 0.2)'
|
||||
: '0 0 12px rgba(34, 197, 94, 0.2)'
|
||||
}
|
||||
>
|
||||
<Icon
|
||||
as={TrendIcon}
|
||||
boxSize={3.5}
|
||||
color={isPositive ? FUI_COLORS.status.positive : FUI_COLORS.status.negative}
|
||||
/>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="bold"
|
||||
fontFamily="mono"
|
||||
color={isPositive ? FUI_COLORS.status.positive : FUI_COLORS.status.negative}
|
||||
color={FUI_COLORS.text.muted}
|
||||
letterSpacing="wide"
|
||||
>
|
||||
{isPositive ? '+' : ''}{change.toFixed(2)}%
|
||||
查看股票实时行情、财务数据和盈利预测
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</HStack>
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
});
|
||||
));
|
||||
|
||||
StockInfoDisplay.displayName = 'StockInfoDisplay';
|
||||
PageTitle.displayName = 'PageTitle';
|
||||
|
||||
/**
|
||||
* 搜索操作区组件(状态自管理,减少父组件重渲染)
|
||||
* 搜索框组件(状态自管理,减少父组件重渲染)
|
||||
*/
|
||||
const SearchActions = memo<{
|
||||
const SearchBox = memo<{
|
||||
stockCode: string;
|
||||
onStockChange: (value: string) => void;
|
||||
isInWatchlist: boolean;
|
||||
watchlistLoading: boolean;
|
||||
onWatchlistToggle: () => void;
|
||||
}>(({
|
||||
stockCode,
|
||||
onStockChange,
|
||||
isInWatchlist,
|
||||
watchlistLoading,
|
||||
onWatchlistToggle,
|
||||
}) => {
|
||||
// 输入状态自管理(避免父组件重渲染)
|
||||
const [inputCode, setInputCode] = useState(stockCode);
|
||||
|
||||
// 同步外部 stockCode 变化
|
||||
React.useEffect(() => {
|
||||
setInputCode(stockCode);
|
||||
}, [stockCode]);
|
||||
// 输入状态 - 默认为空,显示 placeholder
|
||||
const [inputCode, setInputCode] = useState('');
|
||||
|
||||
// 股票搜索 Hook
|
||||
const searchHook = useStockSearch({
|
||||
@@ -194,13 +95,6 @@ const SearchActions = memo<{
|
||||
}));
|
||||
}, [searchResults]);
|
||||
|
||||
// 处理搜索按钮点击
|
||||
const handleSearch = useCallback(() => {
|
||||
if (inputCode && inputCode !== stockCode) {
|
||||
onStockChange(inputCode);
|
||||
}
|
||||
}, [inputCode, stockCode, onStockChange]);
|
||||
|
||||
// 选中股票
|
||||
const handleSelect = useCallback((value: string) => {
|
||||
clearSearch();
|
||||
@@ -210,42 +104,47 @@ const SearchActions = memo<{
|
||||
}
|
||||
}, [clearSearch, stockCode, onStockChange]);
|
||||
|
||||
// 键盘事件
|
||||
// 键盘事件 - 回车搜索
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSearch();
|
||||
if (e.key === 'Enter' && inputCode && inputCode !== stockCode) {
|
||||
onStockChange(inputCode);
|
||||
}
|
||||
}, [handleSearch]);
|
||||
}, [inputCode, stockCode, onStockChange]);
|
||||
|
||||
return (
|
||||
<HStack spacing={3}>
|
||||
{/* 搜索框 - FUI 风格 */}
|
||||
<Box
|
||||
sx={{
|
||||
...getSearchBoxStyles(THEME),
|
||||
'.ant-select-selector': {
|
||||
backgroundColor: `${FUI_COLORS.bg.primary} !important`,
|
||||
borderColor: `${FUI_COLORS.line.default} !important`,
|
||||
borderRadius: '10px !important',
|
||||
height: '42px !important',
|
||||
backdropFilter: FUI_GLASS.blur.sm,
|
||||
'.ant-select': {
|
||||
width: '320px !important',
|
||||
},
|
||||
'.ant-input-affix-wrapper': {
|
||||
backgroundColor: 'transparent !important',
|
||||
borderColor: `${FUI_COLORS.gold[400]} !important`,
|
||||
borderWidth: '1px !important',
|
||||
borderRadius: '6px !important',
|
||||
height: '44px !important',
|
||||
padding: '0 12px !important',
|
||||
transition: `all ${FUI_ANIMATION.duration.fast} ${FUI_ANIMATION.easing.default}`,
|
||||
'&:hover': {
|
||||
borderColor: `${FUI_COLORS.line.emphasis} !important`,
|
||||
borderColor: `${FUI_COLORS.gold[300]} !important`,
|
||||
boxShadow: FUI_GLOW.gold.sm,
|
||||
},
|
||||
'&:focus-within': {
|
||||
borderColor: `${FUI_COLORS.gold[400]} !important`,
|
||||
boxShadow: FUI_GLOW.gold.md,
|
||||
'&:focus-within, &.ant-input-affix-wrapper-focused': {
|
||||
borderColor: `${FUI_COLORS.gold[300]} !important`,
|
||||
boxShadow: `${FUI_GLOW.gold.md} !important`,
|
||||
},
|
||||
},
|
||||
'.ant-select-selection-search-input': {
|
||||
color: `${FUI_COLORS.text.primary} !important`,
|
||||
height: '40px !important',
|
||||
fontFamily: 'inherit',
|
||||
'.ant-input': {
|
||||
backgroundColor: 'transparent !important',
|
||||
color: `${FUI_COLORS.gold[400]} !important`,
|
||||
fontSize: '14px !important',
|
||||
'&::placeholder': {
|
||||
color: `${FUI_COLORS.gold[400]} !important`,
|
||||
opacity: '0.7 !important',
|
||||
},
|
||||
'.ant-select-selection-placeholder': {
|
||||
color: `${FUI_COLORS.text.muted} !important`,
|
||||
},
|
||||
'.ant-input-prefix': {
|
||||
marginRight: '8px !important',
|
||||
},
|
||||
}}
|
||||
>
|
||||
@@ -256,121 +155,50 @@ const SearchActions = memo<{
|
||||
onSearch={doSearch}
|
||||
onSelect={handleSelect}
|
||||
onChange={setInputCode}
|
||||
placeholder="输入代码、名称或拼音"
|
||||
style={{ width: 240 }}
|
||||
style={{ width: 320 }}
|
||||
dropdownStyle={{
|
||||
backgroundColor: FUI_COLORS.bg.elevated,
|
||||
borderRadius: '10px',
|
||||
border: `1px solid ${FUI_COLORS.line.emphasis}`,
|
||||
borderRadius: '6px',
|
||||
border: `1px solid ${FUI_COLORS.gold[400]}`,
|
||||
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.5)',
|
||||
}}
|
||||
notFoundContent={isSearching ? <Spin size="small" /> : null}
|
||||
>
|
||||
<Input
|
||||
placeholder="输入股票代码或名称"
|
||||
prefix={<SearchOutlined style={{ color: FUI_COLORS.gold[400], fontSize: 16 }} />}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 搜索按钮 - 发光效果 */}
|
||||
<Button
|
||||
bg={`linear-gradient(135deg, ${FUI_COLORS.gold[500]} 0%, ${FUI_COLORS.gold[400]} 100%)`}
|
||||
color={FUI_COLORS.bg.deep}
|
||||
_hover={{
|
||||
bg: `linear-gradient(135deg, ${FUI_COLORS.gold[400]} 0%, ${FUI_COLORS.gold[300]} 100%)`,
|
||||
boxShadow: FUI_GLOW.gold.md,
|
||||
transform: 'translateY(-1px)',
|
||||
}}
|
||||
_active={{
|
||||
bg: FUI_COLORS.gold[600],
|
||||
transform: 'translateY(0)',
|
||||
}}
|
||||
size="md"
|
||||
h="42px"
|
||||
px={5}
|
||||
onClick={handleSearch}
|
||||
leftIcon={<Icon as={Search} boxSize={4} />}
|
||||
fontWeight="bold"
|
||||
borderRadius="10px"
|
||||
transition={`all ${FUI_ANIMATION.duration.fast} ${FUI_ANIMATION.easing.default}`}
|
||||
>
|
||||
查询
|
||||
</Button>
|
||||
|
||||
{/* 自选按钮 - FUI 风格 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
bg={isInWatchlist ? 'rgba(212, 175, 55, 0.2)' : 'transparent'}
|
||||
color={FUI_COLORS.gold[400]}
|
||||
borderColor={isInWatchlist ? FUI_COLORS.gold[400] : FUI_COLORS.line.emphasis}
|
||||
borderWidth="1px"
|
||||
_hover={{
|
||||
bg: 'rgba(212, 175, 55, 0.15)',
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
borderColor: FUI_COLORS.gold[400],
|
||||
boxShadow: FUI_GLOW.gold.sm,
|
||||
transform: 'translateY(-1px)',
|
||||
borderRadius: 6,
|
||||
height: 44,
|
||||
color: FUI_COLORS.gold[400],
|
||||
}}
|
||||
_active={{
|
||||
bg: 'rgba(212, 175, 55, 0.25)',
|
||||
transform: 'translateY(0)',
|
||||
}}
|
||||
size="md"
|
||||
h="42px"
|
||||
px={5}
|
||||
onClick={onWatchlistToggle}
|
||||
isLoading={watchlistLoading}
|
||||
leftIcon={
|
||||
<Icon
|
||||
as={Star}
|
||||
boxSize={4}
|
||||
fill={isInWatchlist ? 'currentColor' : 'none'}
|
||||
filter={isInWatchlist ? 'drop-shadow(0 0 4px rgba(212, 175, 55, 0.6))' : 'none'}
|
||||
/>
|
||||
}
|
||||
fontWeight="bold"
|
||||
borderRadius="10px"
|
||||
transition={`all ${FUI_ANIMATION.duration.fast} ${FUI_ANIMATION.easing.default}`}
|
||||
sx={isInWatchlist ? { animation: 'glowPulse 3s ease-in-out infinite' } : undefined}
|
||||
>
|
||||
{isInWatchlist ? '已自选' : '自选'}
|
||||
</Button>
|
||||
</HStack>
|
||||
</AutoComplete>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
SearchActions.displayName = 'SearchActions';
|
||||
SearchBox.displayName = 'SearchBox';
|
||||
|
||||
/**
|
||||
* Company 页面顶部组件
|
||||
*/
|
||||
const CompanyHeader: React.FC<CompanyHeaderProps> = memo(({
|
||||
stockCode,
|
||||
stockInfo,
|
||||
stockInfoLoading,
|
||||
isInWatchlist,
|
||||
watchlistLoading,
|
||||
onStockChange,
|
||||
onWatchlistToggle,
|
||||
}) => {
|
||||
return (
|
||||
<Box
|
||||
position="relative"
|
||||
bg={`linear-gradient(180deg, ${FUI_COLORS.bg.elevated} 0%, ${FUI_COLORS.bg.primary} 100%)`}
|
||||
bg={FUI_COLORS.bg.primary}
|
||||
borderBottom="1px solid"
|
||||
borderColor={FUI_COLORS.line.default}
|
||||
px={6}
|
||||
py={5}
|
||||
backdropFilter={FUI_GLASS.blur.md}
|
||||
overflow="hidden"
|
||||
py={4}
|
||||
>
|
||||
{/* 顶部发光线(环境光效果由全局 AmbientGlow 提供) */}
|
||||
<Box
|
||||
position="absolute"
|
||||
top={0}
|
||||
left="10%"
|
||||
right="10%"
|
||||
h="1px"
|
||||
bg={`linear-gradient(90deg, transparent 0%, ${FUI_COLORS.gold[400]} 50%, transparent 100%)`}
|
||||
opacity={0.4}
|
||||
/>
|
||||
|
||||
<Flex
|
||||
position="relative"
|
||||
zIndex={1}
|
||||
@@ -378,25 +206,14 @@ const CompanyHeader: React.FC<CompanyHeaderProps> = memo(({
|
||||
mx="auto"
|
||||
justify="space-between"
|
||||
align="center"
|
||||
wrap="wrap"
|
||||
gap={4}
|
||||
>
|
||||
{/* 左侧:股票信息 */}
|
||||
<StockInfoDisplay
|
||||
stockCode={stockCode}
|
||||
stockName={stockInfo?.stock_name}
|
||||
price={stockInfo?.close_price}
|
||||
change={stockInfo?.change_pct}
|
||||
loading={stockInfoLoading}
|
||||
/>
|
||||
{/* 左侧:页面标题 */}
|
||||
<PageTitle />
|
||||
|
||||
{/* 右侧:搜索和操作 */}
|
||||
<SearchActions
|
||||
{/* 右侧:搜索框 */}
|
||||
<SearchBox
|
||||
stockCode={stockCode}
|
||||
onStockChange={onStockChange}
|
||||
isInWatchlist={isInWatchlist}
|
||||
watchlistLoading={watchlistLoading}
|
||||
onWatchlistToggle={onWatchlistToggle}
|
||||
/>
|
||||
</Flex>
|
||||
</Box>
|
||||
|
||||
@@ -74,45 +74,46 @@ export const useStockQuoteData = (stockCode?: string): UseStockQuoteDataResult =
|
||||
const [basicLoading, setBasicLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 用于手动刷新的 ref
|
||||
// 用于手动刷新的 ref(并行请求)
|
||||
const refetchRef = useCallback(async () => {
|
||||
if (!stockCode) return;
|
||||
|
||||
// 标准化股票代码(去除后缀)
|
||||
const baseCode = stockCode.split('.')[0];
|
||||
|
||||
// 获取行情详情数据(使用新的 quote-detail 接口)
|
||||
// 并行获取行情详情和基本信息
|
||||
setQuoteLoading(true);
|
||||
setBasicLoading(true);
|
||||
setError(null);
|
||||
|
||||
logger.debug('useStockQuoteData', '刷新股票数据', { stockCode, baseCode });
|
||||
|
||||
try {
|
||||
logger.debug('useStockQuoteData', '获取股票行情详情', { stockCode, baseCode });
|
||||
const { data: result } = await axios.get(`/api/stock/${baseCode}/quote-detail`);
|
||||
if (result.success && result.data) {
|
||||
const transformedData = transformQuoteData(result.data, stockCode);
|
||||
const [quoteResult, basicResult] = await Promise.all([
|
||||
axios.get(`/api/stock/${baseCode}/quote-detail`),
|
||||
axios.get(`/api/stock/${baseCode}/basic-info`),
|
||||
]);
|
||||
|
||||
// 处理行情数据
|
||||
if (quoteResult.data.success && quoteResult.data.data) {
|
||||
const transformedData = transformQuoteData(quoteResult.data.data, stockCode);
|
||||
logger.debug('useStockQuoteData', '行情数据转换完成', { stockCode, hasData: !!transformedData });
|
||||
setQuoteData(transformedData);
|
||||
} else {
|
||||
setError('获取行情数据失败');
|
||||
setQuoteData(null);
|
||||
}
|
||||
|
||||
// 处理基本信息
|
||||
if (basicResult.data.success) {
|
||||
setBasicInfo(basicResult.data.data);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('useStockQuoteData', '获取行情失败', err);
|
||||
setError('获取行情数据失败');
|
||||
logger.error('useStockQuoteData', '刷新数据失败', err);
|
||||
setError('刷新数据失败');
|
||||
setQuoteData(null);
|
||||
} finally {
|
||||
setQuoteLoading(false);
|
||||
}
|
||||
|
||||
// 获取基本信息(公司简介等)
|
||||
setBasicLoading(true);
|
||||
try {
|
||||
const { data: result } = await axios.get(`/api/stock/${baseCode}/basic-info`);
|
||||
if (result.success) {
|
||||
setBasicInfo(result.data);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('useStockQuoteData', '获取基本信息失败', err);
|
||||
} finally {
|
||||
setBasicLoading(false);
|
||||
}
|
||||
}, [stockCode]);
|
||||
@@ -132,47 +133,45 @@ export const useStockQuoteData = (stockCode?: string): UseStockQuoteDataResult =
|
||||
const baseCode = stockCode.split('.')[0];
|
||||
|
||||
const fetchData = async () => {
|
||||
// 获取行情详情数据(使用新的 quote-detail 接口)
|
||||
// 并行获取行情详情和基本信息(优化:原串行改为并行,节省 ~120ms)
|
||||
setQuoteLoading(true);
|
||||
setBasicLoading(true);
|
||||
setError(null);
|
||||
|
||||
logger.debug('useStockQuoteData', '并行获取股票数据', { stockCode, baseCode });
|
||||
|
||||
try {
|
||||
logger.debug('useStockQuoteData', '获取股票行情详情', { stockCode, baseCode });
|
||||
const { data: result } = await axios.get(`/api/stock/${baseCode}/quote-detail`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
const [quoteResult, basicResult] = await Promise.all([
|
||||
axios.get(`/api/stock/${baseCode}/quote-detail`, { signal: controller.signal }),
|
||||
axios.get(`/api/stock/${baseCode}/basic-info`, { signal: controller.signal }),
|
||||
]);
|
||||
|
||||
if (isCancelled) return;
|
||||
if (result.success && result.data) {
|
||||
const transformedData = transformQuoteData(result.data, stockCode);
|
||||
|
||||
// 处理行情数据
|
||||
if (quoteResult.data.success && quoteResult.data.data) {
|
||||
const transformedData = transformQuoteData(quoteResult.data.data, stockCode);
|
||||
logger.debug('useStockQuoteData', '行情数据转换完成', { stockCode, hasData: !!transformedData });
|
||||
setQuoteData(transformedData);
|
||||
} else {
|
||||
setError('获取行情数据失败');
|
||||
setQuoteData(null);
|
||||
}
|
||||
|
||||
// 处理基本信息
|
||||
if (basicResult.data.success) {
|
||||
setBasicInfo(basicResult.data.data);
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (isCancelled || err.name === 'CanceledError') return;
|
||||
logger.error('useStockQuoteData', '获取行情失败', err);
|
||||
setError('获取行情数据失败');
|
||||
logger.error('useStockQuoteData', '获取数据失败', err);
|
||||
setError('获取数据失败');
|
||||
setQuoteData(null);
|
||||
} finally {
|
||||
if (!isCancelled) setQuoteLoading(false);
|
||||
if (!isCancelled) {
|
||||
setQuoteLoading(false);
|
||||
setBasicLoading(false);
|
||||
}
|
||||
|
||||
// 获取基本信息(公司简介等)
|
||||
setBasicLoading(true);
|
||||
try {
|
||||
const { data: result } = await axios.get(`/api/stock/${baseCode}/basic-info`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (isCancelled) return;
|
||||
if (result.success) {
|
||||
setBasicInfo(result.data);
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (isCancelled || err.name === 'CanceledError') return;
|
||||
logger.error('useStockQuoteData', '获取基本信息失败', err);
|
||||
} finally {
|
||||
if (!isCancelled) setBasicLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,100 +1,189 @@
|
||||
/**
|
||||
* 公司详情页面 - FUI 科幻风格
|
||||
* ============================================================================
|
||||
* 公司详情页面 (Company Detail Page)
|
||||
* ============================================================================
|
||||
*
|
||||
* 特性:
|
||||
* - Ash Thorp 风格 FUI 设计
|
||||
* - James Turrell 光影效果
|
||||
* - Glassmorphism 毛玻璃卡片
|
||||
* - Linear.app 风格微交互
|
||||
* - HeroUI 现代组件风格
|
||||
* 📍 路由: /company?scode=000001
|
||||
*
|
||||
* 📋 功能概述:
|
||||
* - 展示个股详情信息,包括股票行情、公司资料、财务数据等
|
||||
* - 支持通过 URL 参数 `scode` 指定股票代码
|
||||
* - 提供自选股添加/移除功能
|
||||
* - 多 Tab 切换展示不同维度的公司信息
|
||||
*
|
||||
* 🎨 设计风格:
|
||||
* - FUI (Futuristic User Interface) 科幻风格
|
||||
* - Ash Thorp 风格 - 电影级 UI 设计美学
|
||||
* - James Turrell 光影效果 - 环境光渲染
|
||||
* - Glassmorphism 毛玻璃卡片效果
|
||||
* - Linear.app 风格微交互动画
|
||||
*
|
||||
* 🏗️ 组件架构:
|
||||
* CompanyIndex (本文件)
|
||||
* ├── AmbientGlow - 全局环境光效果背景
|
||||
* ├── CompanyHeader - 顶部区域 (页面标题 + 搜索栏)
|
||||
* ├── StockQuoteCard - 股票实时行情卡片 (价格、涨跌幅等)
|
||||
* └── SubTabContainer - Tab 切换容器
|
||||
* ├── 概览 Tab
|
||||
* ├── 财务 Tab
|
||||
* ├── 公告 Tab
|
||||
* └── ... 其他 Tab (由 TAB_CONFIG 配置)
|
||||
*
|
||||
* 📊 数据流:
|
||||
* 1. URL 参数 scode → stockCode 状态
|
||||
* 2. stockCode → useCompanyData Hook → 获取股票信息、自选股状态
|
||||
* 3. stockCode → useCompanyEvents Hook → 用户行为追踪
|
||||
* 4. 数据传递给子组件进行渲染
|
||||
*
|
||||
* 🔧 性能优化:
|
||||
* - 使用 memo() 包装组件,避免父组件更新时不必要的重渲染
|
||||
* - 使用 useCallback 缓存事件处理函数
|
||||
* - 使用 useMemo 缓存传递给子组件的 props 对象
|
||||
* - Tab 内容使用 isLazy 延迟加载,减少首屏渲染负担
|
||||
* - 使用 useRef 追踪前一个股票代码,避免重复触发事件
|
||||
*/
|
||||
|
||||
import React, { memo, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||
|
||||
// FUI 动画样式
|
||||
// ============================================
|
||||
// 样式导入
|
||||
// ============================================
|
||||
// FUI 动画样式 - 包含扫描线、发光效果等科幻动画
|
||||
import './theme/fui-animations.css';
|
||||
|
||||
// ============================================
|
||||
// 第三方库导入
|
||||
// ============================================
|
||||
// React Router - 用于读取和修改 URL 查询参数
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
// Chakra UI - 基础布局组件
|
||||
import { Box } from '@chakra-ui/react';
|
||||
|
||||
// ============================================
|
||||
// 内部组件和工具导入
|
||||
// ============================================
|
||||
// 通用 Tab 切换容器组件 - 支持懒加载和主题配置
|
||||
import SubTabContainer from '@components/SubTabContainer';
|
||||
// FUI 风格组件 - 科幻容器和环境光效果
|
||||
import { FuiContainer, AmbientGlow } from '@components/FUI';
|
||||
// 动态网页标题 Hook - 根据股票名称更新浏览器标签页标题
|
||||
import { useStockDocumentTitle } from '@hooks/useDocumentTitle';
|
||||
|
||||
// ============================================
|
||||
// 页面级 Hooks
|
||||
// ============================================
|
||||
// 用户行为事件追踪 Hook - 发送分析数据到 PostHog
|
||||
import { useCompanyEvents } from './hooks/useCompanyEvents';
|
||||
// 公司数据获取 Hook - 封装股票信息和自选股相关 API
|
||||
import { useCompanyData } from './hooks/useCompanyData';
|
||||
|
||||
// ============================================
|
||||
// 页面子组件
|
||||
// ============================================
|
||||
// 顶部 Header - 包含页面标题和搜索框
|
||||
import CompanyHeader from './components/CompanyHeader';
|
||||
// 股票行情卡片 - 显示实时价格、涨跌幅、成交量等
|
||||
import StockQuoteCard from './components/StockQuoteCard';
|
||||
|
||||
// ============================================
|
||||
// 配置常量
|
||||
// ============================================
|
||||
// THEME - 页面主题配置 (背景色、文字色等)
|
||||
// TAB_CONFIG - Tab 页签配置数组 (名称、图标、对应组件)
|
||||
import { THEME, TAB_CONFIG } from './config';
|
||||
|
||||
// ============================================
|
||||
// 主内容区组件 - FUI 风格
|
||||
// ============================================
|
||||
|
||||
interface CompanyContentProps {
|
||||
stockCode: string;
|
||||
isInWatchlist: boolean;
|
||||
watchlistLoading: boolean;
|
||||
onWatchlistToggle: () => void;
|
||||
onTabChange: (index: number, tabKey: string) => void;
|
||||
}
|
||||
|
||||
const CompanyContent: React.FC<CompanyContentProps> = memo(({
|
||||
stockCode,
|
||||
isInWatchlist,
|
||||
watchlistLoading,
|
||||
onWatchlistToggle,
|
||||
onTabChange,
|
||||
}) => {
|
||||
// 缓存 componentProps,避免每次渲染创建新对象
|
||||
const memoizedComponentProps = useMemo(() => ({ stockCode }), [stockCode]);
|
||||
|
||||
return (
|
||||
<Box maxW="container.xl" mx="auto" px={4} py={6}>
|
||||
{/* 股票行情卡片 - 放在 Tab 切换器上方,始终可见 */}
|
||||
<Box mb={6}>
|
||||
<StockQuoteCard
|
||||
stockCode={stockCode}
|
||||
isInWatchlist={isInWatchlist}
|
||||
isWatchlistLoading={watchlistLoading}
|
||||
onWatchlistToggle={onWatchlistToggle}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Tab 内容区 - 使用 FuiContainer */}
|
||||
<FuiContainer variant="default">
|
||||
<SubTabContainer
|
||||
tabs={TAB_CONFIG}
|
||||
componentProps={memoizedComponentProps}
|
||||
onTabChange={onTabChange}
|
||||
themePreset="blackGold"
|
||||
contentPadding={0}
|
||||
isLazy={true}
|
||||
/>
|
||||
</FuiContainer>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
CompanyContent.displayName = 'CompanyContent';
|
||||
|
||||
// ============================================
|
||||
// 主页面组件
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* CompanyIndex - 公司详情页主组件
|
||||
*
|
||||
* 这是一个容器组件 (Container Component),主要负责:
|
||||
* 1. 状态管理 - 管理 URL 参数、数据加载状态
|
||||
* 2. 数据获取 - 通过自定义 Hook 获取股票数据
|
||||
* 3. 事件处理 - 处理用户交互(搜索、Tab 切换、自选操作)
|
||||
* 4. 布局编排 - 组合子组件构成完整页面
|
||||
*
|
||||
* 具体的 UI 渲染和业务逻辑委托给各个子组件处理
|
||||
*/
|
||||
const CompanyIndex: React.FC = () => {
|
||||
// ==========================================
|
||||
// URL 参数管理
|
||||
// ==========================================
|
||||
/**
|
||||
* useSearchParams - React Router v6 的 Hook
|
||||
* 用于读取和修改 URL 中的查询参数 (query string)
|
||||
*
|
||||
* 示例 URL: /company?scode=600519
|
||||
* searchParams.get('scode') 返回 '600519'
|
||||
*/
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
/**
|
||||
* 当前股票代码
|
||||
* - 从 URL 参数 `scode` 读取
|
||||
* - 默认值 '000001' (平安银行) 作为兜底
|
||||
*/
|
||||
const stockCode = searchParams.get('scode') || '000001';
|
||||
|
||||
/**
|
||||
* 前一个股票代码的引用
|
||||
* - 用于检测股票代码是否发生变化
|
||||
* - 避免在股票未变化时重复触发追踪事件
|
||||
* - 使用 useRef 而非 useState,因为不需要触发重渲染
|
||||
*/
|
||||
const prevStockCodeRef = useRef(stockCode);
|
||||
|
||||
// ==========================================
|
||||
// 数据加载 Hook
|
||||
// ==========================================
|
||||
/**
|
||||
* useCompanyData - 自定义 Hook,封装公司数据获取逻辑
|
||||
*
|
||||
* 返回值说明:
|
||||
* @property {Object} stockInfo - 股票基础信息对象
|
||||
* - stock_name: 股票名称 (如 "贵州茅台")
|
||||
* - stock_code: 股票代码 (如 "600519")
|
||||
* - industry: 所属行业
|
||||
* - ... 其他字段
|
||||
*
|
||||
* @property {boolean} stockInfoLoading - 股票信息加载中状态
|
||||
* - true: 正在请求数据,显示骨架屏/loading
|
||||
* - false: 数据加载完成
|
||||
*
|
||||
* @property {boolean} isInWatchlist - 是否已添加到自选股
|
||||
* - true: 已在自选列表中,显示"已自选"状态
|
||||
* - false: 未添加,显示"添加自选"按钮
|
||||
*
|
||||
* @property {boolean} watchlistLoading - 自选股操作加载中
|
||||
* - 用于禁用按钮,防止重复点击
|
||||
*
|
||||
* @property {Function} toggleWatchlist - 切换自选股状态
|
||||
* - 异步函数,调用后台 API 添加/移除自选
|
||||
*/
|
||||
const {
|
||||
stockInfo,
|
||||
stockInfoLoading,
|
||||
isInWatchlist,
|
||||
watchlistLoading,
|
||||
toggleWatchlist,
|
||||
} = useCompanyData({ stockCode });
|
||||
|
||||
// ==========================================
|
||||
// 事件追踪 Hook
|
||||
// ==========================================
|
||||
/**
|
||||
* useCompanyEvents - 用户行为追踪 Hook
|
||||
*
|
||||
* 用于记录用户在页面上的关键操作,发送到分析平台 (PostHog)
|
||||
* 这些数据用于产品分析、用户行为研究、功能优化等
|
||||
*
|
||||
* 追踪的事件类型:
|
||||
* - trackStockSearched: 用户搜索/切换股票
|
||||
* - trackTabChanged: 用户切换 Tab 页签
|
||||
* - trackWatchlistAdded: 用户添加自选股
|
||||
* - trackWatchlistRemoved: 用户移除自选股
|
||||
*/
|
||||
const companyEvents = useCompanyEvents({ stockCode }) as {
|
||||
trackStockSearched: (newCode: string, oldCode: string | null) => void;
|
||||
trackTabChanged: (index: number, name: string, prevIndex: number) => void;
|
||||
@@ -102,81 +191,257 @@ const CompanyIndex: React.FC = () => {
|
||||
trackWatchlistRemoved: (code: string) => void;
|
||||
};
|
||||
|
||||
// 解构追踪函数,方便使用
|
||||
const { trackStockSearched, trackTabChanged, trackWatchlistAdded, trackWatchlistRemoved } = companyEvents;
|
||||
|
||||
// 设置网页标题
|
||||
// ==========================================
|
||||
// 副作用 Effects
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* 设置网页标题
|
||||
*
|
||||
* 根据当前股票代码和名称动态更新浏览器标签页标题
|
||||
* 示例: "600519 贵州茅台 - 公司详情"
|
||||
*
|
||||
* 这提升了用户体验,特别是当用户打开多个标签页时
|
||||
* 可以通过标题快速识别每个页面展示的股票
|
||||
*/
|
||||
useStockDocumentTitle(stockCode, stockInfo?.stock_name);
|
||||
|
||||
// 股票代码变化追踪
|
||||
/**
|
||||
* 股票代码变化追踪
|
||||
*
|
||||
* 当股票代码发生变化时(通过 URL 参数改变),
|
||||
* 触发追踪事件记录用户的浏览行为
|
||||
*
|
||||
* 注意:
|
||||
* - 只在代码真正变化时触发,避免初始化时的重复追踪
|
||||
* - 使用 useRef 存储前值,而非 usePrevious Hook,减少依赖
|
||||
*/
|
||||
useEffect(() => {
|
||||
// 只有当股票代码真正发生变化时才触发追踪
|
||||
if (stockCode !== prevStockCodeRef.current) {
|
||||
// 记录用户从哪只股票切换到哪只股票
|
||||
trackStockSearched(stockCode, prevStockCodeRef.current);
|
||||
// 更新引用值
|
||||
prevStockCodeRef.current = stockCode;
|
||||
}
|
||||
}, [stockCode, trackStockSearched]);
|
||||
|
||||
// 处理股票切换
|
||||
// ==========================================
|
||||
// 事件处理函数
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* 处理股票切换
|
||||
*
|
||||
* 当用户通过搜索框选择新股票时调用
|
||||
* 1. 验证新代码有效且与当前不同
|
||||
* 2. 触发追踪事件
|
||||
* 3. 更新 URL 参数(触发组件重新渲染,加载新数据)
|
||||
*
|
||||
* @param {string} newCode - 用户选择的新股票代码
|
||||
*
|
||||
* 使用 useCallback 缓存函数引用,避免子组件不必要的重渲染
|
||||
*/
|
||||
const handleStockChange = useCallback((newCode: string) => {
|
||||
// 验证: 新代码存在 且 与当前代码不同
|
||||
if (newCode && newCode !== stockCode) {
|
||||
// 追踪: 记录股票切换行为
|
||||
trackStockSearched(newCode, stockCode);
|
||||
// 更新 URL: 这会触发组件重新渲染,进而重新获取数据
|
||||
setSearchParams({ scode: newCode });
|
||||
}
|
||||
}, [stockCode, setSearchParams, trackStockSearched]);
|
||||
|
||||
// 处理自选股切换(带追踪)
|
||||
/**
|
||||
* 处理自选股切换(带追踪)
|
||||
*
|
||||
* 当用户点击"添加/移除自选"按钮时调用
|
||||
* 1. 记录操作前的状态(用于判断是添加还是移除)
|
||||
* 2. 调用 API 执行实际操作
|
||||
* 3. 根据操作类型触发对应的追踪事件
|
||||
*
|
||||
* 注意: 使用 async/await 确保 API 调用完成后再触发追踪
|
||||
*/
|
||||
const handleWatchlistToggle = useCallback(async () => {
|
||||
// 记录操作前的状态
|
||||
const wasInWatchlist = isInWatchlist;
|
||||
|
||||
// 执行 API 调用(添加或移除自选股)
|
||||
await toggleWatchlist();
|
||||
|
||||
// 追踪事件(根据操作前的状态判断)
|
||||
// 追踪事件(根据操作前的状态判断是添加还是移除)
|
||||
if (wasInWatchlist) {
|
||||
// 之前在自选中 → 现在移除了
|
||||
trackWatchlistRemoved(stockCode);
|
||||
} else {
|
||||
// 之前不在自选中 → 现在添加了
|
||||
trackWatchlistAdded(stockCode);
|
||||
}
|
||||
}, [stockCode, isInWatchlist, toggleWatchlist, trackWatchlistAdded, trackWatchlistRemoved]);
|
||||
|
||||
// 处理 Tab 切换
|
||||
/**
|
||||
* 处理 Tab 切换
|
||||
*
|
||||
* 当用户点击不同的 Tab 页签时调用
|
||||
* 记录用户查看了哪个 Tab,用于分析用户最关注的信息类型
|
||||
*
|
||||
* @param {number} index - Tab 的索引位置 (0, 1, 2, ...)
|
||||
* @param {string} tabKey - Tab 的唯一标识符
|
||||
*/
|
||||
const handleTabChange = useCallback((index: number, tabKey: string) => {
|
||||
// 从配置中获取 Tab 的显示名称,如果没找到则使用 tabKey
|
||||
const tabName = TAB_CONFIG[index]?.name || tabKey;
|
||||
// 触发追踪事件
|
||||
trackTabChanged(index, tabName, index);
|
||||
}, [trackTabChanged]);
|
||||
|
||||
// ==========================================
|
||||
// 性能优化: 缓存 Props
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* 缓存传递给 SubTabContainer 的 componentProps
|
||||
*
|
||||
* 为什么需要 useMemo?
|
||||
* - 每次组件渲染时,`{ stockCode }` 会创建一个新对象
|
||||
* - 即使 stockCode 值没变,新对象的引用也不同
|
||||
* - 这会导致 SubTabContainer 认为 props 变了,触发不必要的重渲染
|
||||
*
|
||||
* 使用 useMemo 后:
|
||||
* - 只有当 stockCode 真正变化时,才创建新对象
|
||||
* - 保持对象引用稳定,避免子组件重渲染
|
||||
*/
|
||||
const memoizedComponentProps = useMemo(() => ({ stockCode }), [stockCode]);
|
||||
|
||||
|
||||
// ==========================================
|
||||
// 渲染 UI
|
||||
// ==========================================
|
||||
return (
|
||||
/**
|
||||
* 最外层容器
|
||||
* - position="relative": 为内部绝对定位元素提供定位上下文
|
||||
* - bg={THEME.bg}: 使用主题配置的背景色
|
||||
* - minH: 最小高度 = 视口高度 - 顶部导航栏高度 (60px)
|
||||
* - overflow="hidden": 隐藏溢出内容,配合光效动画使用
|
||||
*/
|
||||
<Box
|
||||
position="relative"
|
||||
bg={THEME.bg}
|
||||
minH="calc(100vh - 60px)"
|
||||
overflow="hidden"
|
||||
>
|
||||
{/* 全局环境光效果 - James Turrell 风格 */}
|
||||
{/* ========================================
|
||||
全局环境光效果
|
||||
========================================
|
||||
AmbientGlow 组件创建 James Turrell 风格的光影效果
|
||||
- 在页面背景上渲染柔和的渐变光晕
|
||||
- 增强科幻 UI 的氛围感
|
||||
- variant="default" 使用默认的光效配置
|
||||
*/}
|
||||
<AmbientGlow variant="default" />
|
||||
|
||||
{/* 顶部搜索栏 */}
|
||||
{/* ========================================
|
||||
顶部搜索栏区域
|
||||
========================================
|
||||
zIndex={1} 确保 Header 在环境光效果之上显示
|
||||
*/}
|
||||
<Box position="relative" zIndex={1}>
|
||||
{/*
|
||||
CompanyHeader 组件
|
||||
负责展示:
|
||||
- 左侧:页面标题和副标题
|
||||
- 右侧:股票搜索框 (支持代码/名称搜索)
|
||||
|
||||
Props 说明:
|
||||
- stockCode: 当前股票代码,用于搜索框默认值
|
||||
- onStockChange: 股票切换回调
|
||||
*/}
|
||||
<CompanyHeader
|
||||
stockCode={stockCode}
|
||||
stockInfo={stockInfo}
|
||||
stockInfoLoading={stockInfoLoading}
|
||||
isInWatchlist={isInWatchlist}
|
||||
watchlistLoading={watchlistLoading}
|
||||
onStockChange={handleStockChange}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* ========================================
|
||||
主内容区
|
||||
========================================
|
||||
包含股票行情卡片和 Tab 内容区
|
||||
*/}
|
||||
<Box position="relative" zIndex={1}>
|
||||
|
||||
{/*
|
||||
内容容器
|
||||
- maxW="container.xl": 最大宽度限制,保持内容可读性
|
||||
- mx="auto": 水平居中
|
||||
- px={4}: 左右内边距 16px
|
||||
- py={6}: 上下内边距 24px
|
||||
*/}
|
||||
<Box maxW="container.xl" mx="auto" px={4} py={6}>
|
||||
{/* ========================================
|
||||
股票行情卡片
|
||||
========================================
|
||||
放在 Tab 切换器上方,始终可见
|
||||
显示实时股价、涨跌幅、成交量、换手率等核心行情数据
|
||||
|
||||
这个卡片独立于 Tab 系统,因为行情数据是用户
|
||||
无论查看哪个 Tab 都需要看到的核心信息
|
||||
|
||||
mb={6}: 底部外边距 24px,与下方 Tab 区域保持间距
|
||||
*/}
|
||||
<Box mb={6}>
|
||||
<StockQuoteCard
|
||||
stockCode={stockCode}
|
||||
isInWatchlist={isInWatchlist}
|
||||
isWatchlistLoading={watchlistLoading}
|
||||
onWatchlistToggle={handleWatchlistToggle}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 主内容区 */}
|
||||
<Box position="relative" zIndex={1}>
|
||||
<CompanyContent
|
||||
stockCode={stockCode}
|
||||
isInWatchlist={isInWatchlist}
|
||||
watchlistLoading={watchlistLoading}
|
||||
onWatchlistToggle={handleWatchlistToggle}
|
||||
{/* ========================================
|
||||
Tab 内容区
|
||||
========================================
|
||||
FuiContainer 提供 FUI 风格的容器样式:
|
||||
- 毛玻璃背景效果
|
||||
- 边框发光效果
|
||||
- 科幻风格圆角
|
||||
|
||||
SubTabContainer 是通用的 Tab 切换组件:
|
||||
- tabs: Tab 配置数组,定义每个 Tab 的名称、图标、组件
|
||||
- componentProps: 传递给每个 Tab 组件的共享 props
|
||||
- onTabChange: Tab 切换时的回调函数
|
||||
- themePreset: 主题预设 ("blackGold" = 黑金配色)
|
||||
- contentPadding: Tab 内容区内边距 (0 = 无内边距)
|
||||
- isLazy: 懒加载,只有激活的 Tab 才渲染内容
|
||||
*/}
|
||||
<FuiContainer variant="default">
|
||||
<SubTabContainer
|
||||
tabs={TAB_CONFIG}
|
||||
componentProps={memoizedComponentProps}
|
||||
onTabChange={handleTabChange}
|
||||
themePreset="blackGold"
|
||||
contentPadding={0}
|
||||
isLazy={true}
|
||||
/>
|
||||
</FuiContainer>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 导出组件
|
||||
*
|
||||
* 使用 React.memo 包装组件
|
||||
* memo 是一个高阶组件,用于性能优化:
|
||||
* - 当组件的 props 没有变化时,跳过重新渲染
|
||||
* - 对于这个页面级组件,可以避免父组件(如 MainLayout)
|
||||
* 更新时导致的不必要重渲染
|
||||
*
|
||||
* 注意: memo 只做浅比较,对于复杂 props 需要配合 useMemo
|
||||
*/
|
||||
export default memo(CompanyIndex);
|
||||
|
||||
@@ -111,12 +111,7 @@ export interface UseCompanyDataReturn {
|
||||
|
||||
export interface CompanyHeaderProps {
|
||||
stockCode: string;
|
||||
stockInfo: StockInfo | null;
|
||||
stockInfoLoading: boolean;
|
||||
isInWatchlist: boolean;
|
||||
watchlistLoading: boolean;
|
||||
onStockChange: (code: string) => void;
|
||||
onWatchlistToggle: () => void;
|
||||
}
|
||||
|
||||
export interface CompanyPageProps {
|
||||
|
||||
Reference in New Issue
Block a user