refactor(GlobalSidebar): TypeScript 重构与 HotConceptsPanel 模块化

- GlobalSidebar: JS 转换为 TypeScript,添加完整类型定义
- HotConceptsPanel 拆分为模块化目录结构:
  - types.ts: 类型定义 (Concept, ConceptStock, Props)
  - styles.ts: 样式常量 + 配置 (COLORS, CONFIG)
  - utils.ts: 工具函数 (formatChangePercent, getChangeColor)
  - hooks/useHotConcepts.ts: 数据获取 Hook
  - components/ConceptCard.tsx: 概念卡片原子组件 (memo 优化)
- 性能优化:useMemo 缓存计算,useCallback 缓存事件处理

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
zdl
2025-12-31 17:23:17 +08:00
parent ff62205720
commit 21b58c7c68
9 changed files with 599 additions and 10 deletions

View File

@@ -0,0 +1,144 @@
/**
* ConceptCard - 概念卡片原子组件
* 展示单个概念的信息:排名、名称、标签、涨跌幅、描述、相关股票
*/
import React, { memo, useMemo } from 'react';
import { Box, HStack, Text, Badge, Icon, Tag } from '@chakra-ui/react';
import { ArrowUp, ArrowDown, Zap } from 'lucide-react';
import type { ConceptCardProps } from '../types';
import { formatChangePercent, getChangeColor } from '../utils';
import { COLORS, CONFIG, conceptCardStyles, linkStyles } from '../styles';
const ConceptCard: React.FC<ConceptCardProps> = memo(({
concept,
index,
onConceptClick,
onStockClick,
onViewStocks,
}) => {
// 缓存计算结果
const changeColor = useMemo(() => getChangeColor(concept.change_percent), [concept.change_percent]);
const formattedChange = useMemo(() => formatChangePercent(concept.change_percent), [concept.change_percent]);
const isUp = concept.change_percent > 0;
const isTopRank = index < CONFIG.topRankCount;
return (
<Box
sx={conceptCardStyles}
onClick={() => onConceptClick(concept)}
>
{/* 第一行:排名 + 名称 + 标签 + 涨跌幅 */}
<HStack justify="space-between" mb={0.5}>
<HStack spacing={1.5} flex={1} minW={0}>
<Badge
bg={isTopRank ? COLORS.accent : 'gray.600'}
color="white"
fontSize="10px"
px={1.5}
py={0}
borderRadius="full"
fontWeight="bold"
flexShrink={0}
>
{index + 1}
</Badge>
<Text
fontSize="sm"
fontWeight="medium"
color={COLORS.textPrimary}
noOfLines={1}
flexShrink={0}
>
{concept.concept_name}
</Text>
{/* 标签紧跟名称 */}
{concept.tags && concept.tags.length > 0 && (
<HStack spacing={0.5} flexShrink={1} overflow="hidden">
{concept.tags.slice(0, CONFIG.maxTags).map((tag, idx) => (
<Tag
key={idx}
size="sm"
bg={COLORS.tagBg}
color={COLORS.textPrimary}
borderRadius="full"
fontSize="9px"
px={1.5}
py={0}
lineHeight="1.3"
flexShrink={0}
>
{tag}
</Tag>
))}
</HStack>
)}
</HStack>
<HStack spacing={1} flexShrink={0}>
<Icon
as={isUp ? ArrowUp : ArrowDown}
boxSize={3}
color={changeColor}
/>
<Text fontSize="xs" fontWeight="bold" color={changeColor}>
{formattedChange}
</Text>
</HStack>
</HStack>
{/* 第二行:概念描述 */}
{concept.description && (
<Text
fontSize="xs"
color={COLORS.textSecondary}
noOfLines={1}
mb={0.5}
pl={5}
>
{concept.description}
</Text>
)}
{/* 第三行:相关 + 股票链接 + 爆发日期 */}
<HStack fontSize="xs" spacing={1} pl={5} flexWrap="wrap">
{concept.stocks && concept.stocks.length > 0 && (
<>
<Text color={COLORS.textMuted}></Text>
{concept.stocks.slice(0, CONFIG.maxStocks).map((stock, idx) => (
<Text
key={idx}
sx={linkStyles}
onClick={(e) => onStockClick(e, stock.stock_code)}
>
{stock.stock_name}
</Text>
))}
{(concept.stock_count ?? 0) > CONFIG.maxStocks && (
<Text
sx={linkStyles}
onClick={(e) => {
e.stopPropagation();
onViewStocks?.(concept);
}}
>
+{(concept.stock_count ?? 0) - CONFIG.maxStocks}
</Text>
)}
</>
)}
{/* 爆发日期 */}
{concept.outbreak_dates && concept.outbreak_dates.length > 0 && (
<HStack spacing={0.5} ml={concept.stocks?.length ? 1 : 0}>
<Icon as={Zap} boxSize={3} color="orange.400" />
<Text color="orange.400" fontSize="10px">
{concept.outbreak_dates[0]}
</Text>
</HStack>
)}
</HStack>
</Box>
);
});
ConceptCard.displayName = 'ConceptCard';
export default ConceptCard;

View File

@@ -0,0 +1 @@
export { default as ConceptCard } from './ConceptCard';

View File

@@ -0,0 +1 @@
export { useHotConcepts } from './useHotConcepts';

View File

@@ -0,0 +1,61 @@
/**
* useHotConcepts - 热门概念数据 Hook
* 封装数据获取逻辑,支持缓存和错误处理
*/
import { useState, useEffect, useCallback } from 'react';
import { getApiBase } from '@utils/apiConfig';
import type { Concept, ConceptsApiResponse } from '../types';
import { CONFIG } from '../styles';
interface UseHotConceptsResult {
concepts: Concept[];
loading: boolean;
error: Error | null;
refetch: () => Promise<void>;
}
export const useHotConcepts = (): UseHotConceptsResult => {
const [concepts, setConcepts] = useState<Concept[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const fetchConcepts = useCallback(async (): Promise<void> => {
setLoading(true);
setError(null);
try {
const response = await fetch(
`${getApiBase()}/api/concepts/daily-top?limit=${CONFIG.fetchLimit}`
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ConceptsApiResponse = await response.json();
if (data.success) {
setConcepts(data.data || []);
} else {
throw new Error('API returned unsuccessful response');
}
} catch (err) {
const errorInstance = err instanceof Error ? err : new Error('获取热门概念失败');
setError(errorInstance);
console.error('获取热门概念失败:', err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchConcepts();
}, [fetchConcepts]);
return {
concepts,
loading,
error,
refetch: fetchConcepts,
};
};

View File

@@ -0,0 +1,141 @@
/**
* HotConceptsPanel - 热门概念面板
* 用于 GlobalSidebar 的热门概念弹窗内容
*
* 优化点:
* 1. 类型拆分到 types.ts
* 2. 样式常量拆分到 styles.ts
* 3. 工具函数拆分到 utils.ts
* 4. 数据获取逻辑拆分到 hooks/useHotConcepts.ts
* 5. 概念卡片拆分为 ConceptCard 原子组件,使用 memo 优化
* 6. 使用 useCallback 缓存事件处理函数
*/
import React, { useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { Box, VStack, HStack, Text, Icon, Spinner, Center } from '@chakra-ui/react';
import { Flame, ChevronRight, X } from 'lucide-react';
import { getConceptHtmlUrl } from '@/utils/textUtils';
import type { HotConceptsPanelProps, Concept } from './types';
import { useHotConcepts } from './hooks';
import { ConceptCard } from './components';
import { COLORS, CONFIG, scrollbarStyles } from './styles';
const HotConceptsPanel: React.FC<HotConceptsPanelProps> = ({
onConceptClick,
onStockClick,
onViewStocks,
onClose,
}) => {
const navigate = useNavigate();
const { concepts, loading } = useHotConcepts();
// 缓存事件处理函数
const handleConceptClick = useCallback(
(concept: Concept): void => {
if (onConceptClick) {
onConceptClick(concept);
} else {
const htmlPath = getConceptHtmlUrl(concept.concept_name);
window.open(htmlPath, '_blank');
}
},
[onConceptClick]
);
const handleStockClick = useCallback(
(e: React.MouseEvent, stockCode: string): void => {
e.stopPropagation();
if (onStockClick) {
onStockClick(stockCode);
} else {
navigate(`/company/${stockCode}`);
}
},
[onStockClick, navigate]
);
const handleMoreClick = useCallback(() => {
navigate('/concepts');
}, [navigate]);
// 加载状态
if (loading) {
return (
<Center py={8}>
<Spinner size="md" color={COLORS.accent} />
</Center>
);
}
// 空状态
if (concepts.length === 0) {
return (
<Center py={8}>
<Text fontSize="sm" color={COLORS.textSecondary}>
</Text>
</Center>
);
}
return (
<Box>
{/* 标题栏 */}
<HStack spacing={2} mb={3} justify="space-between">
<HStack spacing={2}>
<Icon as={Flame} boxSize={4} color={COLORS.accent} />
<Text fontSize="sm" fontWeight="bold" color={COLORS.textPrimary}>
</Text>
<Text fontSize="xs" color={COLORS.textSecondary}>
({concepts.length})
</Text>
</HStack>
<HStack spacing={2}>
<HStack
spacing={0}
cursor="pointer"
color={COLORS.accentLight}
_hover={{ color: COLORS.accentHover }}
onClick={handleMoreClick}
>
<Text fontSize="xs"></Text>
<Icon as={ChevronRight} boxSize={4} />
</HStack>
{onClose && (
<Icon
as={X}
boxSize={4}
color={COLORS.textSecondary}
cursor="pointer"
_hover={{ color: COLORS.textPrimary }}
onClick={onClose}
/>
)}
</HStack>
</HStack>
{/* 概念列表 */}
<Box maxH={CONFIG.listMaxHeight} overflowY="auto" css={scrollbarStyles}>
<VStack spacing={1} align="stretch">
{concepts.map((concept, index) => (
<ConceptCard
key={concept.concept_id || index}
concept={concept}
index={index}
onConceptClick={handleConceptClick}
onStockClick={handleStockClick}
onViewStocks={onViewStocks}
/>
))}
</VStack>
</Box>
</Box>
);
};
export default HotConceptsPanel;
// 导出类型供外部使用
export type { Concept, HotConceptsPanelProps } from './types';

View File

@@ -0,0 +1,75 @@
/**
* HotConceptsPanel 样式常量
*/
import type { SystemStyleObject } from '@chakra-ui/react';
/** 颜色常量 */
export const COLORS = {
// 主题色
accent: 'rgba(139, 92, 246, 0.9)',
accentLight: 'rgba(139, 92, 246, 0.8)',
accentHover: 'rgba(139, 92, 246, 1)',
tagBg: 'rgba(139, 92, 246, 0.35)',
// 文字颜色
textPrimary: 'rgba(255, 255, 255, 0.9)',
textSecondary: 'rgba(255, 255, 255, 0.5)',
textMuted: 'rgba(255, 255, 255, 0.4)',
// 涨跌颜色
up: '#EF4444',
down: '#22C55E',
neutral: 'gray',
// 背景
hoverBg: 'rgba(255, 255, 255, 0.05)',
borderColor: 'rgba(255, 255, 255, 0.05)',
scrollbarThumb: 'rgba(255, 255, 255, 0.2)',
} as const;
/** 配置常量 */
export const CONFIG = {
/** API 请求数量限制 */
fetchLimit: 8,
/** 最大显示标签数 */
maxTags: 2,
/** 最大显示股票数 */
maxStocks: 2,
/** 列表最大高度 */
listMaxHeight: '350px',
/** 前几名使用高亮徽章 */
topRankCount: 3,
} as const;
/** 滚动条样式 - 用于 css prop */
export const scrollbarStyles = {
'&::-webkit-scrollbar': { width: '4px' },
'&::-webkit-scrollbar-track': { background: 'transparent' },
'&::-webkit-scrollbar-thumb': {
background: COLORS.scrollbarThumb,
borderRadius: '2px',
},
} as const;
/** 概念卡片样式 */
export const conceptCardStyles: SystemStyleObject = {
px: 2,
py: 1.5,
borderRadius: 'md',
cursor: 'pointer',
transition: 'background 0.15s',
borderBottom: `1px solid ${COLORS.borderColor}`,
_hover: { bg: COLORS.hoverBg },
_last: { borderBottom: 'none' },
};
/** 链接样式 */
export const linkStyles: SystemStyleObject = {
color: COLORS.accentLight,
fontWeight: 'bold',
cursor: 'pointer',
_hover: {
textDecoration: 'underline',
color: COLORS.accentHover,
},
};

View File

@@ -0,0 +1,48 @@
/**
* HotConceptsPanel 类型定义
*/
/** 相关股票信息 */
export interface ConceptStock {
stock_code: string;
stock_name: string;
}
/** 概念数据 */
export interface Concept {
concept_id?: string | number;
concept_name: string;
change_percent: number;
description?: string;
tags?: string[];
stocks?: ConceptStock[];
stock_count?: number;
outbreak_dates?: string[];
}
/** API 响应 */
export interface ConceptsApiResponse {
success: boolean;
data?: Concept[];
}
/** HotConceptsPanel 组件 Props */
export interface HotConceptsPanelProps {
/** 概念点击回调 */
onConceptClick?: (concept: Concept) => void;
/** 股票点击回调 */
onStockClick?: (stockCode: string) => void;
/** 查看更多股票回调 */
onViewStocks?: (concept: Concept) => void;
/** 关闭面板回调 */
onClose?: () => void;
}
/** ConceptCard 组件 Props */
export interface ConceptCardProps {
concept: Concept;
index: number;
onConceptClick: (concept: Concept) => void;
onStockClick: (e: React.MouseEvent, stockCode: string) => void;
onViewStocks?: (concept: Concept) => void;
}

View File

@@ -0,0 +1,25 @@
/**
* HotConceptsPanel 工具函数
*/
import { COLORS } from './styles';
/**
* 格式化涨跌幅显示
* 复用自 src/views/Company/components/StockQuoteCard/components/formatters.ts
*/
export const formatChangePercent = (value: number | null | undefined): string => {
if (value == null) return '--';
const sign = value >= 0 ? '+' : '';
return `${sign}${value.toFixed(2)}%`;
};
/**
* 获取涨跌颜色
* 简化版本,深色主题专用
*/
export const getChangeColor = (value: number | null | undefined): string => {
if (value == null) return COLORS.neutral;
if (value > 0) return COLORS.up;
if (value < 0) return COLORS.down;
return COLORS.neutral;
};

View File

@@ -23,8 +23,9 @@ import {
Text,
HStack,
Portal,
useDisclosure,
} from '@chakra-ui/react';
import { ChevronLeft, ChevronRight, BarChart2, Star, TrendingUp } from 'lucide-react';
import { ChevronLeft, ChevronRight, BarChart2, Star, TrendingUp, Flame } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { useGlobalSidebar } from '@/contexts/GlobalSidebarContext';
import { useAuth } from '@/contexts/AuthContext';
@@ -33,11 +34,100 @@ import { Z_INDEX, LAYOUT_SIZE } from '@/layouts/config/layoutConfig';
import WatchSidebar from '@views/Profile/components/WatchSidebar';
import { WatchlistPanel, FollowingEventsPanel } from '@views/Profile/components/WatchSidebar/components';
import HotSectorsRanking from '@views/Profile/components/MarketDashboard/components/atoms/HotSectorsRanking';
import HotConceptsPanel from './HotConceptsPanel';
/** 股票信息 */
interface Stock {
stock_code: string;
stock_name?: string;
}
/** 事件信息 */
interface Event {
id: string | number;
title?: string;
}
/** 评论信息 */
interface Comment {
id: string | number;
event_id: string | number;
content?: string;
}
/** 实时行情 */
interface RealtimeQuote {
price?: number;
change_percent?: number;
}
/** 收起菜单 Props */
interface CollapsedMenuProps {
watchlist: Stock[];
realtimeQuotes: Record<string, RealtimeQuote>;
followingEvents: Event[];
eventComments: Comment[];
onToggle: () => void;
onStockClick: (stock: Stock) => void;
onEventClick: (event: Event) => void;
onCommentClick: (comment: Comment) => void;
onAddStock: () => void;
onAddEvent: () => void;
onUnwatch: (stockCode: string) => void;
onUnfollow: (eventId: string | number) => void;
}
/**
* 使 controlled
*/
const HotConceptsPopover: React.FC = () => {
const { isOpen, onOpen, onClose } = useDisclosure();
return (
<Popover
placement="left-start"
isLazy
isOpen={isOpen}
onOpen={onOpen}
onClose={onClose}
>
<PopoverTrigger>
<VStack
spacing={1}
align="center"
cursor="pointer"
p={2}
borderRadius="md"
_hover={{ bg: 'rgba(255, 255, 255, 0.05)' }}
position="relative"
>
<Icon as={Flame} boxSize={5} color="rgba(139, 92, 246, 0.9)" />
<Text fontSize="xs" color="rgba(255, 255, 255, 0.85)" fontWeight="medium" whiteSpace="nowrap">
</Text>
</VStack>
</PopoverTrigger>
<Portal>
<PopoverContent
w="340px"
bg="rgba(26, 32, 44, 0.95)"
borderColor="rgba(139, 92, 246, 0.3)"
boxShadow="0 8px 32px rgba(139, 92, 246, 0.2)"
_focus={{ outline: 'none' }}
>
<PopoverBody p={3}>
<HotConceptsPanel onClose={onClose} />
</PopoverBody>
</PopoverContent>
</Portal>
</Popover>
);
};
/**
*
*/
const CollapsedMenu = ({
const CollapsedMenu: React.FC<CollapsedMenuProps> = ({
watchlist,
realtimeQuotes,
followingEvents,
@@ -234,11 +324,14 @@ const CollapsedMenu = ({
>
<PopoverCloseButton color="rgba(255, 255, 255, 0.5)" />
<PopoverBody p={2}>
<HotSectorsRanking title="热门板块" />
<HotSectorsRanking title="热门板块" onSectorClick={() => {}} />
</PopoverBody>
</PopoverContent>
</Portal>
</Popover>
{/* 热门概念 - 悬浮弹窗 */}
<HotConceptsPopover />
</VStack>
);
};
@@ -246,7 +339,7 @@ const CollapsedMenu = ({
/**
* GlobalSidebar
*/
const GlobalSidebar = () => {
const GlobalSidebar: React.FC = () => {
const { user } = useAuth();
const navigate = useNavigate();
@@ -319,9 +412,9 @@ const GlobalSidebar = () => {
realtimeQuotes={realtimeQuotes}
followingEvents={followingEvents}
eventComments={eventComments}
onStockClick={(stock) => navigate(`/company/${stock.stock_code}`)}
onEventClick={(event) => navigate(getEventDetailUrl(event.id))}
onCommentClick={(comment) => navigate(getEventDetailUrl(comment.event_id))}
onStockClick={(stock: Stock) => navigate(`/company/${stock.stock_code}`)}
onEventClick={(event: Event) => navigate(getEventDetailUrl(event.id))}
onCommentClick={(comment: Comment) => navigate(getEventDetailUrl(comment.event_id))}
onAddStock={() => navigate('/stocks')}
onAddEvent={() => navigate('/community')}
onUnwatch={unwatchStock}
@@ -337,9 +430,9 @@ const GlobalSidebar = () => {
followingEvents={followingEvents}
eventComments={eventComments}
onToggle={toggle}
onStockClick={(stock) => navigate(`/company/${stock.stock_code}`)}
onEventClick={(event) => navigate(getEventDetailUrl(event.id))}
onCommentClick={(comment) => navigate(getEventDetailUrl(comment.event_id))}
onStockClick={(stock: Stock) => navigate(`/company/${stock.stock_code}`)}
onEventClick={(event: Event) => navigate(getEventDetailUrl(event.id))}
onCommentClick={(comment: Comment) => navigate(getEventDetailUrl(comment.event_id))}
onAddStock={() => navigate('/stocks')}
onAddEvent={() => navigate('/community')}
onUnwatch={unwatchStock}