diff --git a/src/components/GlobalSidebar/HotConceptsPanel/components/ConceptCard.tsx b/src/components/GlobalSidebar/HotConceptsPanel/components/ConceptCard.tsx new file mode 100644 index 00000000..8ca47ae7 --- /dev/null +++ b/src/components/GlobalSidebar/HotConceptsPanel/components/ConceptCard.tsx @@ -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 = 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 ( + onConceptClick(concept)} + > + {/* 第一行:排名 + 名称 + 标签 + 涨跌幅 */} + + + + {index + 1} + + + {concept.concept_name} + + {/* 标签紧跟名称 */} + {concept.tags && concept.tags.length > 0 && ( + + {concept.tags.slice(0, CONFIG.maxTags).map((tag, idx) => ( + + {tag} + + ))} + + )} + + + + + {formattedChange} + + + + + {/* 第二行:概念描述 */} + {concept.description && ( + + {concept.description} + + )} + + {/* 第三行:相关 + 股票链接 + 爆发日期 */} + + {concept.stocks && concept.stocks.length > 0 && ( + <> + 相关 + {concept.stocks.slice(0, CONFIG.maxStocks).map((stock, idx) => ( + onStockClick(e, stock.stock_code)} + > + {stock.stock_name} + + ))} + {(concept.stock_count ?? 0) > CONFIG.maxStocks && ( + { + e.stopPropagation(); + onViewStocks?.(concept); + }} + > + +{(concept.stock_count ?? 0) - CONFIG.maxStocks}更多 + + )} + + )} + {/* 爆发日期 */} + {concept.outbreak_dates && concept.outbreak_dates.length > 0 && ( + + + + {concept.outbreak_dates[0]} + + + )} + + + ); +}); + +ConceptCard.displayName = 'ConceptCard'; + +export default ConceptCard; diff --git a/src/components/GlobalSidebar/HotConceptsPanel/components/index.ts b/src/components/GlobalSidebar/HotConceptsPanel/components/index.ts new file mode 100644 index 00000000..33d70e0e --- /dev/null +++ b/src/components/GlobalSidebar/HotConceptsPanel/components/index.ts @@ -0,0 +1 @@ +export { default as ConceptCard } from './ConceptCard'; diff --git a/src/components/GlobalSidebar/HotConceptsPanel/hooks/index.ts b/src/components/GlobalSidebar/HotConceptsPanel/hooks/index.ts new file mode 100644 index 00000000..afc62cdf --- /dev/null +++ b/src/components/GlobalSidebar/HotConceptsPanel/hooks/index.ts @@ -0,0 +1 @@ +export { useHotConcepts } from './useHotConcepts'; diff --git a/src/components/GlobalSidebar/HotConceptsPanel/hooks/useHotConcepts.ts b/src/components/GlobalSidebar/HotConceptsPanel/hooks/useHotConcepts.ts new file mode 100644 index 00000000..73fcd5f9 --- /dev/null +++ b/src/components/GlobalSidebar/HotConceptsPanel/hooks/useHotConcepts.ts @@ -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; +} + +export const useHotConcepts = (): UseHotConceptsResult => { + const [concepts, setConcepts] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchConcepts = useCallback(async (): Promise => { + 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, + }; +}; diff --git a/src/components/GlobalSidebar/HotConceptsPanel/index.tsx b/src/components/GlobalSidebar/HotConceptsPanel/index.tsx new file mode 100644 index 00000000..863307a5 --- /dev/null +++ b/src/components/GlobalSidebar/HotConceptsPanel/index.tsx @@ -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 = ({ + 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 ( +
+ +
+ ); + } + + // 空状态 + if (concepts.length === 0) { + return ( +
+ + 暂无热门概念 + +
+ ); + } + + return ( + + {/* 标题栏 */} + + + + + 热门概念 + + + ({concepts.length}) + + + + + 更多 + + + {onClose && ( + + )} + + + + {/* 概念列表 */} + + + {concepts.map((concept, index) => ( + + ))} + + + + ); +}; + +export default HotConceptsPanel; + +// 导出类型供外部使用 +export type { Concept, HotConceptsPanelProps } from './types'; diff --git a/src/components/GlobalSidebar/HotConceptsPanel/styles.ts b/src/components/GlobalSidebar/HotConceptsPanel/styles.ts new file mode 100644 index 00000000..684d3b46 --- /dev/null +++ b/src/components/GlobalSidebar/HotConceptsPanel/styles.ts @@ -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, + }, +}; diff --git a/src/components/GlobalSidebar/HotConceptsPanel/types.ts b/src/components/GlobalSidebar/HotConceptsPanel/types.ts new file mode 100644 index 00000000..578c46bf --- /dev/null +++ b/src/components/GlobalSidebar/HotConceptsPanel/types.ts @@ -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; +} diff --git a/src/components/GlobalSidebar/HotConceptsPanel/utils.ts b/src/components/GlobalSidebar/HotConceptsPanel/utils.ts new file mode 100644 index 00000000..dede9aa0 --- /dev/null +++ b/src/components/GlobalSidebar/HotConceptsPanel/utils.ts @@ -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; +}; diff --git a/src/components/GlobalSidebar/index.js b/src/components/GlobalSidebar/index.tsx similarity index 77% rename from src/components/GlobalSidebar/index.js rename to src/components/GlobalSidebar/index.tsx index ea9d052c..85c37cb9 100644 --- a/src/components/GlobalSidebar/index.js +++ b/src/components/GlobalSidebar/index.tsx @@ -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; + 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 ( + + + + + + 热门概念 + + + + + + + + + + + + ); +}; /** * 收起状态下的图标菜单(带悬浮弹窗) */ -const CollapsedMenu = ({ +const CollapsedMenu: React.FC = ({ watchlist, realtimeQuotes, followingEvents, @@ -234,11 +324,14 @@ const CollapsedMenu = ({ > - + {}} /> + + {/* 热门概念 - 悬浮弹窗 */} + ); }; @@ -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}