- StockQuoteCard: 添加 memo 包装减少重渲染 - Company/index: componentProps 使用 useMemo 缓存 - useCompanyEvents: 页面浏览事件只触发一次,避免重复追踪 - useCompanyData: 自选股状态改用单股票查询接口,减少数据传输 - CompanyHeader: inputCode 状态下移到 SearchActions,减少父组件重渲染 - CompanyHeader: 移除重复环境光效果,由全局 AmbientGlow 统一处理 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
409 lines
12 KiB
TypeScript
409 lines
12 KiB
TypeScript
/**
|
|
* Company 页面顶部搜索栏组件 - FUI 科幻风格
|
|
*
|
|
* 设计特点:
|
|
* - Glassmorphism 毛玻璃背景
|
|
* - 发光效果和微动画
|
|
* - Ash Thorp 风格的数据展示
|
|
* - James Turrell 柔和光影
|
|
*/
|
|
|
|
import React, { memo, useMemo, useCallback, useState } from 'react';
|
|
import {
|
|
Box,
|
|
Flex,
|
|
HStack,
|
|
VStack,
|
|
Text,
|
|
Button,
|
|
Icon,
|
|
Skeleton,
|
|
} from '@chakra-ui/react';
|
|
import { AutoComplete, Spin } from 'antd';
|
|
import { Search, Star, TrendingUp, TrendingDown } from 'lucide-react';
|
|
import { useStockSearch } from '@hooks/useStockSearch';
|
|
import { THEME, getSearchBoxStyles } 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 (
|
|
<VStack align="start" spacing={1}>
|
|
{/* 股票代码 & 名称 */}
|
|
<HStack spacing={3} align="baseline">
|
|
<Text
|
|
fontSize="2xl"
|
|
fontWeight="bold"
|
|
color={FUI_COLORS.gold[400]}
|
|
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}
|
|
>
|
|
{isPositive ? '+' : ''}{change.toFixed(2)}%
|
|
</Text>
|
|
</Box>
|
|
)}
|
|
</HStack>
|
|
)}
|
|
</VStack>
|
|
);
|
|
});
|
|
|
|
StockInfoDisplay.displayName = 'StockInfoDisplay';
|
|
|
|
/**
|
|
* 搜索操作区组件(状态自管理,减少父组件重渲染)
|
|
*/
|
|
const SearchActions = 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]);
|
|
|
|
// 股票搜索 Hook
|
|
const searchHook = useStockSearch({
|
|
limit: 10,
|
|
debounceMs: 300,
|
|
onSearch: () => {}, // 空回调,追踪在父组件处理
|
|
}) as {
|
|
searchResults: StockSearchResult[];
|
|
isSearching: boolean;
|
|
handleSearch: (query: string) => void;
|
|
clearSearch: () => void;
|
|
};
|
|
|
|
const { searchResults, isSearching, handleSearch: doSearch, clearSearch } = searchHook;
|
|
|
|
// 转换为 AutoComplete options
|
|
const stockOptions = useMemo(() => {
|
|
return searchResults.map((stock: StockSearchResult) => ({
|
|
value: stock.stock_code,
|
|
label: (
|
|
<Flex justify="space-between" align="center" py={1}>
|
|
<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 && (
|
|
<Text fontSize="xs" color={THEME.textMuted}>
|
|
{stock.pinyin_abbr.toUpperCase()}
|
|
</Text>
|
|
)}
|
|
</Flex>
|
|
),
|
|
}));
|
|
}, [searchResults]);
|
|
|
|
// 处理搜索按钮点击
|
|
const handleSearch = useCallback(() => {
|
|
if (inputCode && inputCode !== stockCode) {
|
|
onStockChange(inputCode);
|
|
}
|
|
}, [inputCode, stockCode, onStockChange]);
|
|
|
|
// 选中股票
|
|
const handleSelect = useCallback((value: string) => {
|
|
clearSearch();
|
|
setInputCode(value);
|
|
if (value !== stockCode) {
|
|
onStockChange(value);
|
|
}
|
|
}, [clearSearch, stockCode, onStockChange]);
|
|
|
|
// 键盘事件
|
|
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
|
if (e.key === 'Enter') {
|
|
handleSearch();
|
|
}
|
|
}, [handleSearch]);
|
|
|
|
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,
|
|
transition: `all ${FUI_ANIMATION.duration.fast} ${FUI_ANIMATION.easing.default}`,
|
|
'&:hover': {
|
|
borderColor: `${FUI_COLORS.line.emphasis} !important`,
|
|
boxShadow: FUI_GLOW.gold.sm,
|
|
},
|
|
'&:focus-within': {
|
|
borderColor: `${FUI_COLORS.gold[400]} !important`,
|
|
boxShadow: FUI_GLOW.gold.md,
|
|
},
|
|
},
|
|
'.ant-select-selection-search-input': {
|
|
color: `${FUI_COLORS.text.primary} !important`,
|
|
height: '40px !important',
|
|
fontFamily: 'inherit',
|
|
},
|
|
'.ant-select-selection-placeholder': {
|
|
color: `${FUI_COLORS.text.muted} !important`,
|
|
},
|
|
}}
|
|
>
|
|
<AutoComplete
|
|
popupClassName="fui-autocomplete-dropdown"
|
|
value={inputCode}
|
|
options={stockOptions}
|
|
onSearch={doSearch}
|
|
onSelect={handleSelect}
|
|
onChange={setInputCode}
|
|
placeholder="输入代码、名称或拼音"
|
|
style={{ width: 240 }}
|
|
dropdownStyle={{
|
|
backgroundColor: FUI_COLORS.bg.elevated,
|
|
borderRadius: '10px',
|
|
border: `1px solid ${FUI_COLORS.line.emphasis}`,
|
|
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.5)',
|
|
}}
|
|
notFoundContent={isSearching ? <Spin size="small" /> : null}
|
|
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)',
|
|
borderColor: FUI_COLORS.gold[400],
|
|
boxShadow: FUI_GLOW.gold.sm,
|
|
transform: 'translateY(-1px)',
|
|
}}
|
|
_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>
|
|
);
|
|
});
|
|
|
|
SearchActions.displayName = 'SearchActions';
|
|
|
|
/**
|
|
* 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%)`}
|
|
borderBottom="1px solid"
|
|
borderColor={FUI_COLORS.line.default}
|
|
px={6}
|
|
py={5}
|
|
backdropFilter={FUI_GLASS.blur.md}
|
|
overflow="hidden"
|
|
>
|
|
{/* 顶部发光线(环境光效果由全局 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}
|
|
maxW="container.xl"
|
|
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}
|
|
/>
|
|
|
|
{/* 右侧:搜索和操作 */}
|
|
<SearchActions
|
|
stockCode={stockCode}
|
|
onStockChange={onStockChange}
|
|
isInWatchlist={isInWatchlist}
|
|
watchlistLoading={watchlistLoading}
|
|
onWatchlistToggle={onWatchlistToggle}
|
|
/>
|
|
</Flex>
|
|
</Box>
|
|
);
|
|
});
|
|
|
|
CompanyHeader.displayName = 'CompanyHeader';
|
|
|
|
export default CompanyHeader;
|