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:
@@ -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;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as ConceptCard } from './ConceptCard';
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { useHotConcepts } from './useHotConcepts';
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
141
src/components/GlobalSidebar/HotConceptsPanel/index.tsx
Normal file
141
src/components/GlobalSidebar/HotConceptsPanel/index.tsx
Normal 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';
|
||||||
75
src/components/GlobalSidebar/HotConceptsPanel/styles.ts
Normal file
75
src/components/GlobalSidebar/HotConceptsPanel/styles.ts
Normal 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
48
src/components/GlobalSidebar/HotConceptsPanel/types.ts
Normal file
48
src/components/GlobalSidebar/HotConceptsPanel/types.ts
Normal 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;
|
||||||
|
}
|
||||||
25
src/components/GlobalSidebar/HotConceptsPanel/utils.ts
Normal file
25
src/components/GlobalSidebar/HotConceptsPanel/utils.ts
Normal 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;
|
||||||
|
};
|
||||||
@@ -23,8 +23,9 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
HStack,
|
HStack,
|
||||||
Portal,
|
Portal,
|
||||||
|
useDisclosure,
|
||||||
} from '@chakra-ui/react';
|
} 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 { useNavigate } from 'react-router-dom';
|
||||||
import { useGlobalSidebar } from '@/contexts/GlobalSidebarContext';
|
import { useGlobalSidebar } from '@/contexts/GlobalSidebarContext';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
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 WatchSidebar from '@views/Profile/components/WatchSidebar';
|
||||||
import { WatchlistPanel, FollowingEventsPanel } from '@views/Profile/components/WatchSidebar/components';
|
import { WatchlistPanel, FollowingEventsPanel } from '@views/Profile/components/WatchSidebar/components';
|
||||||
import HotSectorsRanking from '@views/Profile/components/MarketDashboard/components/atoms/HotSectorsRanking';
|
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,
|
watchlist,
|
||||||
realtimeQuotes,
|
realtimeQuotes,
|
||||||
followingEvents,
|
followingEvents,
|
||||||
@@ -234,11 +324,14 @@ const CollapsedMenu = ({
|
|||||||
>
|
>
|
||||||
<PopoverCloseButton color="rgba(255, 255, 255, 0.5)" />
|
<PopoverCloseButton color="rgba(255, 255, 255, 0.5)" />
|
||||||
<PopoverBody p={2}>
|
<PopoverBody p={2}>
|
||||||
<HotSectorsRanking title="热门板块" />
|
<HotSectorsRanking title="热门板块" onSectorClick={() => {}} />
|
||||||
</PopoverBody>
|
</PopoverBody>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Portal>
|
</Portal>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|
||||||
|
{/* 热门概念 - 悬浮弹窗 */}
|
||||||
|
<HotConceptsPopover />
|
||||||
</VStack>
|
</VStack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -246,7 +339,7 @@ const CollapsedMenu = ({
|
|||||||
/**
|
/**
|
||||||
* GlobalSidebar 主组件
|
* GlobalSidebar 主组件
|
||||||
*/
|
*/
|
||||||
const GlobalSidebar = () => {
|
const GlobalSidebar: React.FC = () => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
@@ -319,9 +412,9 @@ const GlobalSidebar = () => {
|
|||||||
realtimeQuotes={realtimeQuotes}
|
realtimeQuotes={realtimeQuotes}
|
||||||
followingEvents={followingEvents}
|
followingEvents={followingEvents}
|
||||||
eventComments={eventComments}
|
eventComments={eventComments}
|
||||||
onStockClick={(stock) => navigate(`/company/${stock.stock_code}`)}
|
onStockClick={(stock: Stock) => navigate(`/company/${stock.stock_code}`)}
|
||||||
onEventClick={(event) => navigate(getEventDetailUrl(event.id))}
|
onEventClick={(event: Event) => navigate(getEventDetailUrl(event.id))}
|
||||||
onCommentClick={(comment) => navigate(getEventDetailUrl(comment.event_id))}
|
onCommentClick={(comment: Comment) => navigate(getEventDetailUrl(comment.event_id))}
|
||||||
onAddStock={() => navigate('/stocks')}
|
onAddStock={() => navigate('/stocks')}
|
||||||
onAddEvent={() => navigate('/community')}
|
onAddEvent={() => navigate('/community')}
|
||||||
onUnwatch={unwatchStock}
|
onUnwatch={unwatchStock}
|
||||||
@@ -337,9 +430,9 @@ const GlobalSidebar = () => {
|
|||||||
followingEvents={followingEvents}
|
followingEvents={followingEvents}
|
||||||
eventComments={eventComments}
|
eventComments={eventComments}
|
||||||
onToggle={toggle}
|
onToggle={toggle}
|
||||||
onStockClick={(stock) => navigate(`/company/${stock.stock_code}`)}
|
onStockClick={(stock: Stock) => navigate(`/company/${stock.stock_code}`)}
|
||||||
onEventClick={(event) => navigate(getEventDetailUrl(event.id))}
|
onEventClick={(event: Event) => navigate(getEventDetailUrl(event.id))}
|
||||||
onCommentClick={(comment) => navigate(getEventDetailUrl(comment.event_id))}
|
onCommentClick={(comment: Comment) => navigate(getEventDetailUrl(comment.event_id))}
|
||||||
onAddStock={() => navigate('/stocks')}
|
onAddStock={() => navigate('/stocks')}
|
||||||
onAddEvent={() => navigate('/community')}
|
onAddEvent={() => navigate('/community')}
|
||||||
onUnwatch={unwatchStock}
|
onUnwatch={unwatchStock}
|
||||||
Reference in New Issue
Block a user