Compare commits
24 Commits
307d80c808
...
feature_bu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18ba36a539 | ||
|
|
c639b418f0 | ||
|
|
712090accb | ||
|
|
bc844bb4dc | ||
|
|
10e34d911f | ||
|
|
1a55e037c9 | ||
|
|
16c30b45b9 | ||
| 317bdb1daf | |||
| 5843029b9c | |||
| 0b95953db9 | |||
| 3ef1e6ea29 | |||
| 8936118133 | |||
| 1071405aaf | |||
| 144cc256cf | |||
| 82e4fab55c | |||
| 22c5c166bf | |||
| 61a29ce5ce | |||
| 20bcf3770a | |||
| 6d878df27c | |||
| a2a233bb0f | |||
|
|
174fe32850 | ||
|
|
77ea38e5c9 | ||
|
|
9e271747da | ||
|
|
88b836e75a |
96
app.py
96
app.py
@@ -11004,7 +11004,9 @@ def get_events_by_mainline():
|
||||
4. 按指定层级分组返回
|
||||
|
||||
参数:
|
||||
- recent_days: 近N天(默认7天)
|
||||
- recent_days: 近N天(默认7天,当有 start_date/end_date 时忽略)
|
||||
- start_date: 开始时间(精确时间范围,格式 YYYY-MM-DD HH:mm:ss)
|
||||
- end_date: 结束时间(精确时间范围,格式 YYYY-MM-DD HH:mm:ss)
|
||||
- importance: 重要性筛选(S,A,B,C 或 all)
|
||||
- group_by: 分组方式 (lv1/lv2/lv3/具体概念ID如L2_AI_INFRA),默认lv2
|
||||
|
||||
@@ -11036,12 +11038,32 @@ def get_events_by_mainline():
|
||||
from sqlalchemy import exists
|
||||
|
||||
# 获取请求参数
|
||||
recent_days = request.args.get('recent_days', 7, type=int)
|
||||
recent_days = request.args.get('recent_days', type=int)
|
||||
start_date_str = request.args.get('start_date', '')
|
||||
end_date_str = request.args.get('end_date', '')
|
||||
importance = request.args.get('importance', 'all')
|
||||
group_by = request.args.get('group_by', 'lv2') # lv1/lv2/lv3 或具体ID
|
||||
|
||||
# 计算日期范围
|
||||
since_date = datetime.now() - timedelta(days=recent_days)
|
||||
# 优先使用精确时间范围,其次使用 recent_days
|
||||
if start_date_str and end_date_str:
|
||||
try:
|
||||
since_date = datetime.strptime(start_date_str, '%Y-%m-%d %H:%M:%S')
|
||||
until_date = datetime.strptime(end_date_str, '%Y-%m-%d %H:%M:%S')
|
||||
app.logger.info(f'[mainline] 使用精确时间范围: {since_date} - {until_date}')
|
||||
except ValueError as e:
|
||||
app.logger.warning(f'[mainline] 时间格式解析失败: {e}, 降级使用 recent_days')
|
||||
since_date = datetime.now() - timedelta(days=recent_days or 7)
|
||||
until_date = None
|
||||
elif recent_days:
|
||||
since_date = datetime.now() - timedelta(days=recent_days)
|
||||
until_date = None
|
||||
app.logger.info(f'[mainline] 使用 recent_days: {recent_days}')
|
||||
else:
|
||||
# 默认7天
|
||||
since_date = datetime.now() - timedelta(days=7)
|
||||
until_date = None
|
||||
app.logger.info(f'[mainline] 使用默认时间范围: 近7天')
|
||||
|
||||
# ==================== 1. 获取概念层级映射 ====================
|
||||
# 调用 concept-api 获取层级结构
|
||||
@@ -11128,6 +11150,8 @@ def get_events_by_mainline():
|
||||
|
||||
# 日期筛选
|
||||
query = query.filter(Event.created_at >= since_date)
|
||||
if until_date:
|
||||
query = query.filter(Event.created_at <= until_date)
|
||||
|
||||
# 重要性筛选
|
||||
if importance != 'all':
|
||||
@@ -11279,35 +11303,60 @@ def get_events_by_mainline():
|
||||
else:
|
||||
ungrouped_events.append(event_data)
|
||||
|
||||
# ==================== 5. 获取 lv2 概念涨跌幅 ====================
|
||||
lv2_price_map = {}
|
||||
# ==================== 5. 获取概念涨跌幅(根据 group_by 参数) ====================
|
||||
price_map = {}
|
||||
|
||||
# 确定当前分组层级和对应的数据库类型
|
||||
if group_by == 'lv1' or group_by.startswith('L1_'):
|
||||
current_level = 'lv1'
|
||||
db_concept_type = 'lv1'
|
||||
name_prefix = '[一级] '
|
||||
name_field = 'lv1_name'
|
||||
elif group_by == 'lv3' or group_by.startswith('L2_'):
|
||||
current_level = 'lv3'
|
||||
db_concept_type = 'lv3'
|
||||
name_prefix = '[三级] '
|
||||
name_field = 'lv3_name'
|
||||
else: # lv2 或 L3_ 开头(查看 lv3 下的具体分类,显示 lv2 涨跌幅)
|
||||
current_level = 'lv2'
|
||||
db_concept_type = 'lv2'
|
||||
name_prefix = '[二级] '
|
||||
name_field = 'lv2_name'
|
||||
|
||||
try:
|
||||
# 获取所有 lv2 名称
|
||||
lv2_names = [group['lv2_name'] for group in mainline_groups.values() if group.get('lv2_name')]
|
||||
if lv2_names:
|
||||
# 数据库中的 concept_name 带有 "[二级] " 前缀,需要添加前缀来匹配
|
||||
lv2_names_with_prefix = [f'[二级] {name}' for name in lv2_names]
|
||||
# 获取所有对应层级的名称
|
||||
group_names = [group.get('group_name') or group.get(name_field) for group in mainline_groups.values()]
|
||||
group_names = [n for n in group_names if n] # 过滤掉空值
|
||||
|
||||
if group_names:
|
||||
# 数据库中的 concept_name 带有前缀,需要添加前缀来匹配
|
||||
names_with_prefix = [f'{name_prefix}{name}' for name in group_names]
|
||||
|
||||
# 查询 concept_daily_stats 表获取最新涨跌幅
|
||||
price_sql = text('''
|
||||
SELECT concept_name, avg_change_pct, trade_date
|
||||
FROM concept_daily_stats
|
||||
WHERE concept_type = 'lv2'
|
||||
WHERE concept_type = :concept_type
|
||||
AND concept_name IN :names
|
||||
AND trade_date = (
|
||||
SELECT MAX(trade_date) FROM concept_daily_stats WHERE concept_type = 'lv2'
|
||||
SELECT MAX(trade_date) FROM concept_daily_stats WHERE concept_type = :concept_type
|
||||
)
|
||||
''')
|
||||
price_result = db.session.execute(price_sql, {'names': tuple(lv2_names_with_prefix)}).fetchall()
|
||||
price_result = db.session.execute(price_sql, {
|
||||
'concept_type': db_concept_type,
|
||||
'names': tuple(names_with_prefix)
|
||||
}).fetchall()
|
||||
|
||||
for row in price_result:
|
||||
# 去掉 "[二级] " 前缀,用原始名称作为 key
|
||||
original_name = row.concept_name.replace('[二级] ', '') if row.concept_name else ''
|
||||
lv2_price_map[original_name] = {
|
||||
# 去掉前缀,用原始名称作为 key
|
||||
original_name = row.concept_name.replace(name_prefix, '') if row.concept_name else ''
|
||||
price_map[original_name] = {
|
||||
'avg_change_pct': float(row.avg_change_pct) if row.avg_change_pct else None,
|
||||
'trade_date': str(row.trade_date) if row.trade_date else None
|
||||
}
|
||||
app.logger.info(f'[mainline] 获取 lv2 涨跌幅: {len(lv2_price_map)} 条, lv2_names 数量: {len(lv2_names)}')
|
||||
app.logger.info(f'[mainline] 获取 {current_level} 涨跌幅: {len(price_map)} 条, 查询名称数量: {len(group_names)}')
|
||||
except Exception as price_err:
|
||||
app.logger.warning(f'[mainline] 获取 lv2 涨跌幅失败: {price_err}')
|
||||
app.logger.warning(f'[mainline] 获取 {current_level} 涨跌幅失败: {price_err}')
|
||||
|
||||
# ==================== 6. 整理返回数据 ====================
|
||||
mainlines = []
|
||||
@@ -11319,11 +11368,12 @@ def get_events_by_mainline():
|
||||
reverse=True
|
||||
)
|
||||
group['event_count'] = len(group['events'])
|
||||
# 添加涨跌幅数据(目前只支持 lv2)
|
||||
lv2_name = group.get('lv2_name', '') or group.get('group_name', '')
|
||||
if lv2_name in lv2_price_map:
|
||||
group['avg_change_pct'] = lv2_price_map[lv2_name]['avg_change_pct']
|
||||
group['price_date'] = lv2_price_map[lv2_name]['trade_date']
|
||||
|
||||
# 添加涨跌幅数据(根据当前分组层级)
|
||||
group_name = group.get('group_name') or group.get(name_field, '')
|
||||
if group_name in price_map:
|
||||
group['avg_change_pct'] = price_map[group_name]['avg_change_pct']
|
||||
group['price_date'] = price_map[group_name]['trade_date']
|
||||
else:
|
||||
group['avg_change_pct'] = None
|
||||
group['price_date'] = None
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// src/components/EventDetailPanel/RelatedConceptsSection/index.js
|
||||
// 相关概念区组件 - 折叠手风琴样式
|
||||
// 相关概念区组件 - 便当盒网格布局
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
@@ -10,94 +10,72 @@ import {
|
||||
Spinner,
|
||||
Text,
|
||||
Badge,
|
||||
VStack,
|
||||
SimpleGrid,
|
||||
HStack,
|
||||
Icon,
|
||||
Collapse,
|
||||
Tooltip,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { ChevronRightIcon, ChevronDownIcon } from '@chakra-ui/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { logger } from '@utils/logger';
|
||||
import { getApiBase } from '@utils/apiConfig';
|
||||
|
||||
/**
|
||||
* 单个概念项组件(手风琴项)
|
||||
* 单个概念卡片组件(便当盒样式)
|
||||
*/
|
||||
const ConceptItem = ({ concept, isExpanded, onToggle, onNavigate }) => {
|
||||
const itemBg = useColorModeValue('white', 'gray.700');
|
||||
const itemHoverBg = useColorModeValue('gray.50', 'gray.650');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
const conceptColor = useColorModeValue('blue.600', 'blue.300');
|
||||
const reasonBg = useColorModeValue('blue.50', 'gray.800');
|
||||
const reasonColor = useColorModeValue('gray.700', 'gray.200');
|
||||
const iconColor = useColorModeValue('gray.500', 'gray.400');
|
||||
const ConceptCard = ({ concept, onNavigate, isLocked, onLockedClick }) => {
|
||||
// 深色主题固定颜色
|
||||
const cardBg = 'rgba(252, 129, 129, 0.15)'; // 浅红色背景
|
||||
const cardHoverBg = 'rgba(252, 129, 129, 0.25)';
|
||||
const borderColor = 'rgba(252, 129, 129, 0.3)';
|
||||
const conceptColor = '#fc8181'; // 红色文字(与股票涨色一致)
|
||||
|
||||
const handleClick = () => {
|
||||
if (isLocked && onLockedClick) {
|
||||
onLockedClick();
|
||||
return;
|
||||
}
|
||||
onNavigate(concept);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
<Tooltip
|
||||
label={concept.reason || concept.concept}
|
||||
placement="top"
|
||||
hasArrow
|
||||
bg="gray.800"
|
||||
color="white"
|
||||
p={2}
|
||||
borderRadius="md"
|
||||
overflow="hidden"
|
||||
bg={itemBg}
|
||||
maxW="300px"
|
||||
fontSize="xs"
|
||||
>
|
||||
{/* 概念标题行 - 可点击展开 */}
|
||||
<Flex
|
||||
<Box
|
||||
bg={cardBg}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
borderRadius="lg"
|
||||
px={3}
|
||||
py={2.5}
|
||||
py={2}
|
||||
cursor="pointer"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
_hover={{ bg: itemHoverBg }}
|
||||
onClick={onToggle}
|
||||
transition="background 0.2s"
|
||||
onClick={handleClick}
|
||||
_hover={{
|
||||
bg: cardHoverBg,
|
||||
transform: 'translateY(-1px)',
|
||||
boxShadow: 'sm',
|
||||
}}
|
||||
transition="all 0.15s ease"
|
||||
textAlign="center"
|
||||
>
|
||||
<HStack spacing={2} flex={1}>
|
||||
<Icon
|
||||
as={isExpanded ? ChevronDownIcon : ChevronRightIcon}
|
||||
color={iconColor}
|
||||
boxSize={4}
|
||||
transition="transform 0.2s"
|
||||
/>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="medium"
|
||||
color={conceptColor}
|
||||
cursor="pointer"
|
||||
_hover={{ textDecoration: 'underline' }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onNavigate(concept);
|
||||
}}
|
||||
>
|
||||
{concept.concept}
|
||||
</Text>
|
||||
<Badge colorScheme="green" fontSize="xs" flexShrink={0}>
|
||||
AI 分析
|
||||
</Badge>
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{/* 关联原因 - 可折叠 */}
|
||||
<Collapse in={isExpanded} animateOpacity>
|
||||
<Box
|
||||
px={4}
|
||||
py={3}
|
||||
bg={reasonBg}
|
||||
borderTop="1px solid"
|
||||
borderTopColor={borderColor}
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="semibold"
|
||||
color={conceptColor}
|
||||
noOfLines={1}
|
||||
>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
color={reasonColor}
|
||||
lineHeight="1.8"
|
||||
whiteSpace="pre-wrap"
|
||||
>
|
||||
{concept.reason || '暂无关联原因说明'}
|
||||
</Text>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
{concept.concept}
|
||||
</Text>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -120,16 +98,14 @@ const RelatedConceptsSection = ({
|
||||
const [concepts, setConcepts] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
// 记录每个概念的展开状态
|
||||
const [expandedItems, setExpandedItems] = useState({});
|
||||
const navigate = useNavigate();
|
||||
|
||||
// 颜色配置
|
||||
const sectionBg = useColorModeValue('gray.50', 'gray.750');
|
||||
const headingColor = useColorModeValue('gray.700', 'gray.200');
|
||||
const textColor = useColorModeValue('gray.600', 'gray.400');
|
||||
const countBadgeBg = useColorModeValue('blue.100', 'blue.800');
|
||||
const countBadgeColor = useColorModeValue('blue.700', 'blue.200');
|
||||
// 颜色配置 - 使用深色主题固定颜色
|
||||
const sectionBg = 'transparent';
|
||||
const headingColor = '#e2e8f0';
|
||||
const textColor = '#a0aec0';
|
||||
const countBadgeBg = '#3182ce';
|
||||
const countBadgeColor = '#ffffff';
|
||||
|
||||
// 获取相关概念
|
||||
useEffect(() => {
|
||||
@@ -162,10 +138,6 @@ const RelatedConceptsSection = ({
|
||||
const data = await response.json();
|
||||
if (data.success && Array.isArray(data.data)) {
|
||||
setConcepts(data.data);
|
||||
// 默认展开第一个
|
||||
if (data.data.length > 0) {
|
||||
setExpandedItems({ 0: true });
|
||||
}
|
||||
} else {
|
||||
setConcepts([]);
|
||||
}
|
||||
@@ -182,18 +154,6 @@ const RelatedConceptsSection = ({
|
||||
fetchConcepts();
|
||||
}, [eventId]);
|
||||
|
||||
// 切换某个概念的展开状态
|
||||
const toggleItem = (index) => {
|
||||
if (isLocked && onLockedClick) {
|
||||
onLockedClick();
|
||||
return;
|
||||
}
|
||||
setExpandedItems(prev => ({
|
||||
...prev,
|
||||
[index]: !prev[index]
|
||||
}));
|
||||
};
|
||||
|
||||
// 跳转到概念中心
|
||||
const handleNavigate = (concept) => {
|
||||
navigate(`/concepts?q=${encodeURIComponent(concept.concept)}`);
|
||||
@@ -237,7 +197,7 @@ const RelatedConceptsSection = ({
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{/* 概念列表 - 手风琴样式 */}
|
||||
{/* 概念列表 - 便当盒网格布局 */}
|
||||
{hasNoConcepts ? (
|
||||
<Box py={2}>
|
||||
{error ? (
|
||||
@@ -247,17 +207,17 @@ const RelatedConceptsSection = ({
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<VStack spacing={2} align="stretch">
|
||||
<SimpleGrid columns={{ base: 2, sm: 3, md: 4 }} spacing={2}>
|
||||
{concepts.map((concept, index) => (
|
||||
<ConceptItem
|
||||
<ConceptCard
|
||||
key={concept.id || index}
|
||||
concept={concept}
|
||||
isExpanded={!!expandedItems[index]}
|
||||
onToggle={() => toggleItem(index)}
|
||||
onNavigate={handleNavigate}
|
||||
isLocked={isLocked}
|
||||
onLockedClick={onLockedClick}
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
179
src/components/GlassCard/index.js
Normal file
179
src/components/GlassCard/index.js
Normal file
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* GlassCard - 通用毛玻璃卡片组件
|
||||
*
|
||||
* 复用自 Company 页面的 Glassmorphism 风格
|
||||
* 可在全局使用
|
||||
*/
|
||||
|
||||
import React, { memo, forwardRef } from 'react';
|
||||
import { Box } from '@chakra-ui/react';
|
||||
|
||||
// 主题配置
|
||||
const GLASS_THEME = {
|
||||
colors: {
|
||||
gold: {
|
||||
400: '#D4AF37',
|
||||
500: '#B8960C',
|
||||
},
|
||||
bg: {
|
||||
deep: '#0A0A14',
|
||||
primary: '#0F0F1A',
|
||||
elevated: '#1A1A2E',
|
||||
surface: '#252540',
|
||||
},
|
||||
line: {
|
||||
subtle: 'rgba(212, 175, 55, 0.1)',
|
||||
default: 'rgba(212, 175, 55, 0.2)',
|
||||
emphasis: 'rgba(212, 175, 55, 0.4)',
|
||||
},
|
||||
},
|
||||
blur: {
|
||||
sm: 'blur(8px)',
|
||||
md: 'blur(16px)',
|
||||
lg: 'blur(24px)',
|
||||
},
|
||||
glow: {
|
||||
sm: '0 0 8px rgba(212, 175, 55, 0.3)',
|
||||
md: '0 0 16px rgba(212, 175, 55, 0.4)',
|
||||
},
|
||||
};
|
||||
|
||||
// 变体样式
|
||||
const VARIANTS = {
|
||||
default: {
|
||||
bg: `linear-gradient(135deg, ${GLASS_THEME.colors.bg.elevated} 0%, ${GLASS_THEME.colors.bg.primary} 100%)`,
|
||||
border: `1px solid ${GLASS_THEME.colors.line.default}`,
|
||||
backdropFilter: GLASS_THEME.blur.md,
|
||||
},
|
||||
elevated: {
|
||||
bg: `linear-gradient(145deg, ${GLASS_THEME.colors.bg.surface} 0%, ${GLASS_THEME.colors.bg.elevated} 100%)`,
|
||||
border: `1px solid ${GLASS_THEME.colors.line.emphasis}`,
|
||||
backdropFilter: GLASS_THEME.blur.lg,
|
||||
},
|
||||
subtle: {
|
||||
bg: 'rgba(212, 175, 55, 0.05)',
|
||||
border: `1px solid ${GLASS_THEME.colors.line.subtle}`,
|
||||
backdropFilter: GLASS_THEME.blur.sm,
|
||||
},
|
||||
transparent: {
|
||||
bg: 'rgba(15, 15, 26, 0.8)',
|
||||
border: `1px solid ${GLASS_THEME.colors.line.default}`,
|
||||
backdropFilter: GLASS_THEME.blur.lg,
|
||||
},
|
||||
};
|
||||
|
||||
const ROUNDED_MAP = {
|
||||
sm: '8px',
|
||||
md: '12px',
|
||||
lg: '16px',
|
||||
xl: '20px',
|
||||
'2xl': '24px',
|
||||
};
|
||||
|
||||
const PADDING_MAP = {
|
||||
none: 0,
|
||||
sm: 3,
|
||||
md: 4,
|
||||
lg: 6,
|
||||
};
|
||||
|
||||
// 角落装饰
|
||||
const CornerDecor = memo(({ position }) => {
|
||||
const baseStyle = {
|
||||
position: 'absolute',
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
borderColor: GLASS_THEME.colors.gold[400],
|
||||
borderStyle: 'solid',
|
||||
borderWidth: 0,
|
||||
opacity: 0.6,
|
||||
};
|
||||
|
||||
const positions = {
|
||||
tl: { top: '8px', left: '8px', borderTopWidth: '2px', borderLeftWidth: '2px' },
|
||||
tr: { top: '8px', right: '8px', borderTopWidth: '2px', borderRightWidth: '2px' },
|
||||
bl: { bottom: '8px', left: '8px', borderBottomWidth: '2px', borderLeftWidth: '2px' },
|
||||
br: { bottom: '8px', right: '8px', borderBottomWidth: '2px', borderRightWidth: '2px' },
|
||||
};
|
||||
|
||||
return <Box sx={{ ...baseStyle, ...positions[position] }} />;
|
||||
});
|
||||
|
||||
CornerDecor.displayName = 'CornerDecor';
|
||||
|
||||
/**
|
||||
* GlassCard 组件
|
||||
*
|
||||
* @param {string} variant - 变体: 'default' | 'elevated' | 'subtle' | 'transparent'
|
||||
* @param {boolean} hoverable - 是否启用悬停效果
|
||||
* @param {boolean} glowing - 是否启用发光效果
|
||||
* @param {boolean} cornerDecor - 是否显示角落装饰
|
||||
* @param {string} rounded - 圆角: 'sm' | 'md' | 'lg' | 'xl' | '2xl'
|
||||
* @param {string} padding - 内边距: 'none' | 'sm' | 'md' | 'lg'
|
||||
*/
|
||||
const GlassCard = forwardRef(
|
||||
(
|
||||
{
|
||||
children,
|
||||
variant = 'default',
|
||||
hoverable = true,
|
||||
glowing = false,
|
||||
cornerDecor = false,
|
||||
rounded = 'lg',
|
||||
padding = 'md',
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const variantStyle = VARIANTS[variant] || VARIANTS.default;
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={ref}
|
||||
position="relative"
|
||||
bg={variantStyle.bg}
|
||||
border={variantStyle.border}
|
||||
borderRadius={ROUNDED_MAP[rounded]}
|
||||
backdropFilter={variantStyle.backdropFilter}
|
||||
p={PADDING_MAP[padding]}
|
||||
transition="all 0.3s cubic-bezier(0.25, 0.1, 0.25, 1)"
|
||||
overflow="hidden"
|
||||
_hover={
|
||||
hoverable
|
||||
? {
|
||||
borderColor: GLASS_THEME.colors.line.emphasis,
|
||||
boxShadow: glowing ? GLASS_THEME.glow.md : GLASS_THEME.glow.sm,
|
||||
transform: 'translateY(-2px)',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
sx={{
|
||||
...(glowing && {
|
||||
boxShadow: GLASS_THEME.glow.sm,
|
||||
}),
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{/* 角落装饰 */}
|
||||
{cornerDecor && (
|
||||
<>
|
||||
<CornerDecor position="tl" />
|
||||
<CornerDecor position="tr" />
|
||||
<CornerDecor position="bl" />
|
||||
<CornerDecor position="br" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 内容 */}
|
||||
<Box position="relative" zIndex={1}>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
GlassCard.displayName = 'GlassCard';
|
||||
|
||||
export default memo(GlassCard);
|
||||
export { GLASS_THEME };
|
||||
@@ -55,41 +55,77 @@ export const generateMarketData = (stockCode) => {
|
||||
repay: Math.floor(Math.random() * 500000000) + 80000000 // 融资偿还
|
||||
},
|
||||
securities: {
|
||||
balance: Math.floor(Math.random() * 100000000) + 50000000, // 融券余额
|
||||
balance: Math.floor(Math.random() * 100000000) + 50000000, // 融券余额(股数)
|
||||
balance_amount: Math.floor(Math.random() * 2000000000) + 1000000000, // 融券余额(金额)
|
||||
sell: Math.floor(Math.random() * 10000000) + 5000000, // 融券卖出
|
||||
repay: Math.floor(Math.random() * 10000000) + 3000000 // 融券偿还
|
||||
}
|
||||
}))
|
||||
},
|
||||
|
||||
// 大单统计 - 包含 daily_stats 数组
|
||||
// 大宗交易 - 包含 daily_stats 数组,符合 BigDealDayStats 类型
|
||||
bigDealData: {
|
||||
success: true,
|
||||
data: [],
|
||||
daily_stats: Array(10).fill(null).map((_, i) => ({
|
||||
date: new Date(Date.now() - (9 - i) * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
|
||||
big_buy: Math.floor(Math.random() * 300000000) + 100000000,
|
||||
big_sell: Math.floor(Math.random() * 300000000) + 80000000,
|
||||
medium_buy: Math.floor(Math.random() * 200000000) + 60000000,
|
||||
medium_sell: Math.floor(Math.random() * 200000000) + 50000000,
|
||||
small_buy: Math.floor(Math.random() * 100000000) + 30000000,
|
||||
small_sell: Math.floor(Math.random() * 100000000) + 25000000
|
||||
}))
|
||||
daily_stats: Array(10).fill(null).map((_, i) => {
|
||||
const count = Math.floor(Math.random() * 5) + 1; // 1-5 笔交易
|
||||
const avgPrice = parseFloat((basePrice * (0.95 + Math.random() * 0.1)).toFixed(2)); // 折价/溢价 -5%~+5%
|
||||
const deals = Array(count).fill(null).map(() => {
|
||||
const volume = parseFloat((Math.random() * 500 + 100).toFixed(2)); // 100-600 万股
|
||||
const price = parseFloat((avgPrice * (0.98 + Math.random() * 0.04)).toFixed(2));
|
||||
return {
|
||||
buyer_dept: ['中信证券北京总部', '国泰君安上海分公司', '华泰证券深圳营业部', '招商证券广州分公司'][Math.floor(Math.random() * 4)],
|
||||
seller_dept: ['中金公司北京营业部', '海通证券上海分公司', '广发证券深圳营业部', '平安证券广州分公司'][Math.floor(Math.random() * 4)],
|
||||
price,
|
||||
volume,
|
||||
amount: parseFloat((price * volume).toFixed(2))
|
||||
};
|
||||
});
|
||||
const totalVolume = deals.reduce((sum, d) => sum + d.volume, 0);
|
||||
const totalAmount = deals.reduce((sum, d) => sum + d.amount, 0);
|
||||
return {
|
||||
date: new Date(Date.now() - (9 - i) * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
|
||||
count,
|
||||
total_volume: parseFloat(totalVolume.toFixed(2)),
|
||||
total_amount: parseFloat(totalAmount.toFixed(2)),
|
||||
avg_price: avgPrice,
|
||||
deals
|
||||
};
|
||||
})
|
||||
},
|
||||
|
||||
// 异动分析 - 包含 grouped_data 数组
|
||||
// 龙虎榜数据 - 包含 grouped_data 数组,符合 UnusualDayData 类型
|
||||
unusualData: {
|
||||
success: true,
|
||||
data: [],
|
||||
grouped_data: Array(5).fill(null).map((_, i) => ({
|
||||
date: new Date(Date.now() - (4 - i) * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
|
||||
events: [
|
||||
{ time: '14:35:22', type: '快速拉升', change: '+2.3%', description: '5分钟内上涨2.3%' },
|
||||
{ time: '11:28:45', type: '大单买入', amount: '5680万', description: '单笔大单买入' },
|
||||
{ time: '10:15:30', type: '量比异动', ratio: '3.2', description: '量比达到3.2倍' }
|
||||
],
|
||||
count: 3
|
||||
}))
|
||||
grouped_data: Array(5).fill(null).map((_, i) => {
|
||||
const buyerDepts = ['中信证券北京总部', '国泰君安上海分公司', '华泰证券深圳营业部', '招商证券广州分公司', '中金公司北京营业部'];
|
||||
const sellerDepts = ['海通证券上海分公司', '广发证券深圳营业部', '平安证券广州分公司', '东方证券上海营业部', '兴业证券福州营业部'];
|
||||
const infoTypes = ['日涨幅偏离值达7%', '日振幅达15%', '连续三日涨幅偏离20%', '换手率达20%'];
|
||||
|
||||
const buyers = buyerDepts.map(dept => ({
|
||||
dept_name: dept,
|
||||
buy_amount: Math.floor(Math.random() * 50000000) + 10000000 // 1000万-6000万
|
||||
})).sort((a, b) => b.buy_amount - a.buy_amount);
|
||||
|
||||
const sellers = sellerDepts.map(dept => ({
|
||||
dept_name: dept,
|
||||
sell_amount: Math.floor(Math.random() * 40000000) + 8000000 // 800万-4800万
|
||||
})).sort((a, b) => b.sell_amount - a.sell_amount);
|
||||
|
||||
const totalBuy = buyers.reduce((sum, b) => sum + b.buy_amount, 0);
|
||||
const totalSell = sellers.reduce((sum, s) => sum + s.sell_amount, 0);
|
||||
|
||||
return {
|
||||
date: new Date(Date.now() - (4 - i) * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
|
||||
total_buy: totalBuy,
|
||||
total_sell: totalSell,
|
||||
net_amount: totalBuy - totalSell,
|
||||
buyers,
|
||||
sellers,
|
||||
info_types: infoTypes.slice(0, Math.floor(Math.random() * 3) + 1) // 随机选1-3个类型
|
||||
};
|
||||
})
|
||||
},
|
||||
|
||||
// 股权质押 - 匹配 PledgeData[] 类型
|
||||
|
||||
@@ -1726,12 +1726,59 @@ export const eventHandlers = [
|
||||
});
|
||||
});
|
||||
|
||||
// 生成各层级的模拟涨跌幅数据
|
||||
const generatePriceData = () => {
|
||||
// 生成 -5% 到 +8% 之间的随机涨跌幅
|
||||
return parseFloat((Math.random() * 13 - 5).toFixed(2));
|
||||
};
|
||||
|
||||
// 为各层级生成涨跌幅 Map
|
||||
const priceDataMap = {
|
||||
lv1: {},
|
||||
lv2: {},
|
||||
lv3: {}
|
||||
};
|
||||
|
||||
// 为每个层级生成涨跌幅
|
||||
mainlineDefinitions.forEach(def => {
|
||||
if (!priceDataMap.lv1[def.lv1_name]) {
|
||||
priceDataMap.lv1[def.lv1_name] = generatePriceData();
|
||||
}
|
||||
if (!priceDataMap.lv2[def.lv2_name]) {
|
||||
priceDataMap.lv2[def.lv2_name] = generatePriceData();
|
||||
}
|
||||
if (!priceDataMap.lv3[def.lv3_name]) {
|
||||
priceDataMap.lv3[def.lv3_name] = generatePriceData();
|
||||
}
|
||||
});
|
||||
|
||||
const mainlines = Object.values(mainlineGroups)
|
||||
.map(group => ({
|
||||
...group,
|
||||
events: group.events.slice(0, limitPerMainline),
|
||||
event_count: Math.min(group.events.length, limitPerMainline)
|
||||
}))
|
||||
.map(group => {
|
||||
// 根据分组层级获取对应的涨跌幅
|
||||
let avgChangePct = null;
|
||||
if (groupBy === 'lv1' || groupBy.startsWith('L1_')) {
|
||||
// lv1 分组,使用 lv1 涨跌幅(如果是 L1_ 开头,则显示的是 lv2 分组,使用 lv2 涨跌幅)
|
||||
if (groupBy.startsWith('L1_')) {
|
||||
avgChangePct = priceDataMap.lv2[group.group_name] ?? null;
|
||||
} else {
|
||||
avgChangePct = priceDataMap.lv1[group.group_name] ?? null;
|
||||
}
|
||||
} else if (groupBy === 'lv3' || groupBy.startsWith('L2_')) {
|
||||
// lv3 分组,使用 lv3 涨跌幅
|
||||
avgChangePct = priceDataMap.lv3[group.group_name] ?? null;
|
||||
} else {
|
||||
// lv2 分组(默认),使用 lv2 涨跌幅
|
||||
avgChangePct = priceDataMap.lv2[group.group_name] ?? null;
|
||||
}
|
||||
|
||||
return {
|
||||
...group,
|
||||
events: group.events.slice(0, limitPerMainline),
|
||||
event_count: Math.min(group.events.length, limitPerMainline),
|
||||
avg_change_pct: avgChangePct,
|
||||
price_date: new Date().toISOString().split('T')[0]
|
||||
};
|
||||
})
|
||||
.filter(group => group.event_count > 0)
|
||||
.sort((a, b) => b.event_count - a.event_count);
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ export const lazyComponents = {
|
||||
// Home 模块
|
||||
// ⚡ 直接引用 HomePage,无需中间层(静态页面不需要骨架屏)
|
||||
HomePage: React.lazy(() => import('@views/Home/HomePage')),
|
||||
CenterDashboard: React.lazy(() => import('@views/Dashboard/Center')),
|
||||
CenterDashboard: React.lazy(() => import('@views/Center')),
|
||||
ProfilePage: React.lazy(() => import('@views/Profile/ProfilePage')),
|
||||
// 价值论坛 - 我的积分页面
|
||||
ForumMyPoints: React.lazy(() => import('@views/Profile')),
|
||||
|
||||
349
src/types/center.ts
Normal file
349
src/types/center.ts
Normal file
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* Center(个人中心)模块类型定义
|
||||
*
|
||||
* 包含自选股、实时行情、关注事件等类型
|
||||
*/
|
||||
|
||||
import type { NavigateFunction } from 'react-router-dom';
|
||||
|
||||
// ============================================================
|
||||
// Dashboard Events Hook 类型定义
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* useDashboardEvents Hook 配置选项
|
||||
*/
|
||||
export interface DashboardEventsOptions {
|
||||
/** 页面类型 */
|
||||
pageType?: 'center' | 'profile' | 'settings';
|
||||
/** 路由导航函数 */
|
||||
navigate?: NavigateFunction;
|
||||
}
|
||||
|
||||
/**
|
||||
* useDashboardEvents Hook 返回值
|
||||
*/
|
||||
export interface DashboardEventsResult {
|
||||
/** 追踪功能卡片点击 */
|
||||
trackFunctionCardClicked: (cardName: string, cardData?: { count?: number }) => void;
|
||||
/** 追踪自选股列表查看 */
|
||||
trackWatchlistViewed: (stockCount?: number, hasRealtime?: boolean) => void;
|
||||
/** 追踪自选股点击 */
|
||||
trackWatchlistStockClicked: (stock: { code: string; name?: string }, position?: number) => void;
|
||||
/** 追踪自选股添加 */
|
||||
trackWatchlistStockAdded: (stock: { code: string; name?: string }, source?: string) => void;
|
||||
/** 追踪自选股移除 */
|
||||
trackWatchlistStockRemoved: (stock: { code: string; name?: string }) => void;
|
||||
/** 追踪关注事件列表查看 */
|
||||
trackFollowingEventsViewed: (eventCount?: number) => void;
|
||||
/** 追踪关注事件点击 */
|
||||
trackFollowingEventClicked: (event: { id: number; title?: string }, position?: number) => void;
|
||||
/** 追踪评论列表查看 */
|
||||
trackCommentsViewed: (commentCount?: number) => void;
|
||||
/** 追踪订阅信息查看 */
|
||||
trackSubscriptionViewed: (subscription?: { plan?: string; status?: string }) => void;
|
||||
/** 追踪升级按钮点击 */
|
||||
trackUpgradePlanClicked: (currentPlan?: string, targetPlan?: string, source?: string) => void;
|
||||
/** 追踪个人资料更新 */
|
||||
trackProfileUpdated: (updatedFields?: string[]) => void;
|
||||
/** 追踪设置更改 */
|
||||
trackSettingChanged: (settingName: string, oldValue: unknown, newValue: unknown) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 自选股项目
|
||||
* 来自 /api/account/watchlist 接口
|
||||
*/
|
||||
export interface WatchlistItem {
|
||||
/** 股票代码(如 '600000.SH') */
|
||||
stock_code: string;
|
||||
|
||||
/** 股票名称 */
|
||||
stock_name: string;
|
||||
|
||||
/** 当前价格 */
|
||||
current_price?: number;
|
||||
|
||||
/** 涨跌幅(百分比) */
|
||||
change_percent?: number;
|
||||
|
||||
/** 添加时间 */
|
||||
created_at?: string;
|
||||
|
||||
/** 备注 */
|
||||
note?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 实时行情数据
|
||||
* 来自 /api/account/watchlist/realtime 接口
|
||||
*/
|
||||
export interface RealtimeQuote {
|
||||
/** 股票代码 */
|
||||
stock_code: string;
|
||||
|
||||
/** 当前价格 */
|
||||
current_price: number;
|
||||
|
||||
/** 涨跌幅(百分比) */
|
||||
change_percent: number;
|
||||
|
||||
/** 涨跌额 */
|
||||
change_amount?: number;
|
||||
|
||||
/** 成交量 */
|
||||
volume?: number;
|
||||
|
||||
/** 成交额 */
|
||||
amount?: number;
|
||||
|
||||
/** 最高价 */
|
||||
high?: number;
|
||||
|
||||
/** 最低价 */
|
||||
low?: number;
|
||||
|
||||
/** 开盘价 */
|
||||
open?: number;
|
||||
|
||||
/** 昨收价 */
|
||||
pre_close?: number;
|
||||
|
||||
/** 更新时间戳 */
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 实时行情映射表
|
||||
* key 为股票代码,value 为行情数据
|
||||
*/
|
||||
export type RealtimeQuotesMap = Record<string, RealtimeQuote>;
|
||||
|
||||
/**
|
||||
* 关注的事件
|
||||
* 来自 /api/account/events/following 接口
|
||||
*/
|
||||
export interface FollowingEvent {
|
||||
/** 事件 ID */
|
||||
id: number;
|
||||
|
||||
/** 事件标题 */
|
||||
title: string;
|
||||
|
||||
/** 关注人数 */
|
||||
follower_count?: number;
|
||||
|
||||
/** 相关股票平均涨跌幅(百分比) */
|
||||
related_avg_chg?: number;
|
||||
|
||||
/** 事件类型 */
|
||||
event_type?: string;
|
||||
|
||||
/** 发生日期 */
|
||||
event_date?: string;
|
||||
|
||||
/** 事件描述 */
|
||||
description?: string;
|
||||
|
||||
/** 相关股票列表 */
|
||||
related_stocks?: Array<{
|
||||
code: string;
|
||||
name: string;
|
||||
change_percent?: number;
|
||||
}>;
|
||||
|
||||
/** 创建时间 */
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户评论记录
|
||||
* 来自 /api/account/events/posts 接口
|
||||
*/
|
||||
export interface EventComment {
|
||||
/** 评论 ID */
|
||||
id: number;
|
||||
|
||||
/** 评论内容 */
|
||||
content: string;
|
||||
|
||||
/** 关联事件 ID */
|
||||
event_id: number;
|
||||
|
||||
/** 关联事件标题 */
|
||||
event_title?: string;
|
||||
|
||||
/** 点赞数 */
|
||||
like_count?: number;
|
||||
|
||||
/** 回复数 */
|
||||
reply_count?: number;
|
||||
|
||||
/** 创建时间 */
|
||||
created_at: string;
|
||||
|
||||
/** 更新时间 */
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 组件 Props 类型定义
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* WatchSidebar 组件 Props
|
||||
*/
|
||||
export interface WatchSidebarProps {
|
||||
/** 自选股列表 */
|
||||
watchlist: WatchlistItem[];
|
||||
|
||||
/** 实时行情数据(按股票代码索引) */
|
||||
realtimeQuotes: RealtimeQuotesMap;
|
||||
|
||||
/** 关注的事件列表 */
|
||||
followingEvents: FollowingEvent[];
|
||||
|
||||
/** 点击股票回调 */
|
||||
onStockClick?: (stock: WatchlistItem) => void;
|
||||
|
||||
/** 点击事件回调 */
|
||||
onEventClick?: (event: FollowingEvent) => void;
|
||||
|
||||
/** 添加股票回调 */
|
||||
onAddStock?: () => void;
|
||||
|
||||
/** 添加事件回调 */
|
||||
onAddEvent?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* WatchlistPanel 组件 Props
|
||||
*/
|
||||
export interface WatchlistPanelProps {
|
||||
/** 自选股列表 */
|
||||
watchlist: WatchlistItem[];
|
||||
|
||||
/** 实时行情数据 */
|
||||
realtimeQuotes: RealtimeQuotesMap;
|
||||
|
||||
/** 点击股票回调 */
|
||||
onStockClick?: (stock: WatchlistItem) => void;
|
||||
|
||||
/** 添加股票回调 */
|
||||
onAddStock?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* FollowingEventsPanel 组件 Props
|
||||
*/
|
||||
export interface FollowingEventsPanelProps {
|
||||
/** 事件列表 */
|
||||
events: FollowingEvent[];
|
||||
|
||||
/** 点击事件回调 */
|
||||
onEventClick?: (event: FollowingEvent) => void;
|
||||
|
||||
/** 添加事件回调 */
|
||||
onAddEvent?: () => void;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Hooks 返回值类型定义
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* useCenterColors Hook 返回值
|
||||
* 封装 Center 模块的所有颜色变量
|
||||
*/
|
||||
export interface CenterColors {
|
||||
/** 主要文本颜色 */
|
||||
textColor: string;
|
||||
|
||||
/** 边框颜色 */
|
||||
borderColor: string;
|
||||
|
||||
/** 背景颜色 */
|
||||
bgColor: string;
|
||||
|
||||
/** 悬停背景色 */
|
||||
hoverBg: string;
|
||||
|
||||
/** 次要文本颜色 */
|
||||
secondaryText: string;
|
||||
|
||||
/** 卡片背景色 */
|
||||
cardBg: string;
|
||||
|
||||
/** 区块背景色 */
|
||||
sectionBg: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* useCenterData Hook 返回值
|
||||
* 封装 Center 页面的数据加载逻辑
|
||||
*/
|
||||
export interface UseCenterDataResult {
|
||||
/** 自选股列表 */
|
||||
watchlist: WatchlistItem[];
|
||||
|
||||
/** 实时行情数据 */
|
||||
realtimeQuotes: RealtimeQuotesMap;
|
||||
|
||||
/** 关注的事件列表 */
|
||||
followingEvents: FollowingEvent[];
|
||||
|
||||
/** 用户评论列表 */
|
||||
eventComments: EventComment[];
|
||||
|
||||
/** 加载状态 */
|
||||
loading: boolean;
|
||||
|
||||
/** 行情加载状态 */
|
||||
quotesLoading: boolean;
|
||||
|
||||
/** 刷新数据 */
|
||||
refresh: () => Promise<void>;
|
||||
|
||||
/** 刷新实时行情 */
|
||||
refreshQuotes: () => Promise<void>;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// API 响应类型定义
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 自选股列表 API 响应
|
||||
*/
|
||||
export interface WatchlistApiResponse {
|
||||
success: boolean;
|
||||
data: WatchlistItem[];
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 实时行情 API 响应
|
||||
*/
|
||||
export interface RealtimeQuotesApiResponse {
|
||||
success: boolean;
|
||||
data: RealtimeQuote[];
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 关注事件 API 响应
|
||||
*/
|
||||
export interface FollowingEventsApiResponse {
|
||||
success: boolean;
|
||||
data: FollowingEvent[];
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户评论 API 响应
|
||||
*/
|
||||
export interface EventCommentsApiResponse {
|
||||
success: boolean;
|
||||
data: EventComment[];
|
||||
message?: string;
|
||||
}
|
||||
@@ -63,3 +63,23 @@ export type {
|
||||
PlanFormData,
|
||||
PlanningContextValue,
|
||||
} from './investment';
|
||||
|
||||
// Center(个人中心)相关类型
|
||||
export type {
|
||||
DashboardEventsOptions,
|
||||
DashboardEventsResult,
|
||||
WatchlistItem,
|
||||
RealtimeQuote,
|
||||
RealtimeQuotesMap,
|
||||
FollowingEvent,
|
||||
EventComment,
|
||||
WatchSidebarProps,
|
||||
WatchlistPanelProps,
|
||||
FollowingEventsPanelProps,
|
||||
CenterColors,
|
||||
UseCenterDataResult,
|
||||
WatchlistApiResponse,
|
||||
RealtimeQuotesApiResponse,
|
||||
FollowingEventsApiResponse,
|
||||
EventCommentsApiResponse,
|
||||
} from './center';
|
||||
|
||||
282
src/views/Center/Center.tsx
Normal file
282
src/views/Center/Center.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* Center - 个人中心仪表板主页面
|
||||
*
|
||||
* 对应路由:/home/center
|
||||
* 功能:自选股监控、关注事件、投资规划等
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { getApiBase } from '@/utils/apiConfig';
|
||||
import { useDashboardEvents } from '@/hooks/useDashboardEvents';
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
Text,
|
||||
VStack,
|
||||
useToast,
|
||||
Spinner,
|
||||
Center,
|
||||
} from '@chakra-ui/react';
|
||||
import { useCenterColors } from './hooks';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import InvestmentPlanningCenter from './components/InvestmentPlanningCenter';
|
||||
import { getEventDetailUrl } from '@/utils/idEncoder';
|
||||
import MarketDashboard from '@views/Profile/components/MarketDashboard';
|
||||
import StrategyCenter from '@views/Profile/components/StrategyCenter';
|
||||
import ForumCenter from '@views/Profile/components/ForumCenter';
|
||||
import WatchSidebar from '@views/Profile/components/WatchSidebar';
|
||||
import { THEME } from '@views/Profile/components/MarketDashboard/constants';
|
||||
|
||||
import type {
|
||||
WatchlistItem,
|
||||
RealtimeQuotesMap,
|
||||
FollowingEvent,
|
||||
EventComment,
|
||||
WatchlistApiResponse,
|
||||
RealtimeQuotesApiResponse,
|
||||
FollowingEventsApiResponse,
|
||||
EventCommentsApiResponse,
|
||||
DashboardEventsResult,
|
||||
} from '@/types';
|
||||
|
||||
/**
|
||||
* CenterDashboard 组件
|
||||
* 个人中心仪表板主页面
|
||||
*/
|
||||
const CenterDashboard: React.FC = () => {
|
||||
const { user } = useAuth();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
|
||||
// 提取 userId 为独立变量(优化依赖项)
|
||||
const userId = user?.id;
|
||||
|
||||
// 初始化 Dashboard 埋点 Hook(类型断言为 DashboardEventsResult)
|
||||
const dashboardEvents = useDashboardEvents({
|
||||
pageType: 'center',
|
||||
navigate
|
||||
}) as DashboardEventsResult;
|
||||
|
||||
// 颜色主题(使用 useCenterColors 封装,避免 7 次 useColorModeValue 调用)
|
||||
const { secondaryText } = useCenterColors();
|
||||
|
||||
// 数据状态
|
||||
const [watchlist, setWatchlist] = useState<WatchlistItem[]>([]);
|
||||
const [realtimeQuotes, setRealtimeQuotes] = useState<RealtimeQuotesMap>({});
|
||||
const [followingEvents, setFollowingEvents] = useState<FollowingEvent[]>([]);
|
||||
const [eventComments, setEventComments] = useState<EventComment[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [quotesLoading, setQuotesLoading] = useState<boolean>(false);
|
||||
|
||||
// 使用 ref 跟踪是否已经加载过数据(首次加载标记)
|
||||
const hasLoadedRef = useRef<boolean>(false);
|
||||
|
||||
/**
|
||||
* 加载实时行情
|
||||
*/
|
||||
const loadRealtimeQuotes = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
setQuotesLoading(true);
|
||||
const base = getApiBase();
|
||||
const response = await fetch(base + '/api/account/watchlist/realtime', {
|
||||
credentials: 'include',
|
||||
cache: 'no-store'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data: RealtimeQuotesApiResponse = await response.json();
|
||||
if (data.success) {
|
||||
const quotesMap: RealtimeQuotesMap = {};
|
||||
data.data.forEach(item => {
|
||||
quotesMap[item.stock_code] = item;
|
||||
});
|
||||
setRealtimeQuotes(quotesMap);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Center', 'loadRealtimeQuotes', error, {
|
||||
userId,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
} finally {
|
||||
setQuotesLoading(false);
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
/**
|
||||
* 加载所有数据(自选股、关注事件、评论)
|
||||
*/
|
||||
const loadData = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
const base = getApiBase();
|
||||
const ts = Date.now();
|
||||
|
||||
const [w, e, c] = await Promise.all([
|
||||
fetch(base + `/api/account/watchlist?_=${ts}`, { credentials: 'include', cache: 'no-store' }),
|
||||
fetch(base + `/api/account/events/following?_=${ts}`, { credentials: 'include', cache: 'no-store' }),
|
||||
fetch(base + `/api/account/events/posts?_=${ts}`, { credentials: 'include', cache: 'no-store' }),
|
||||
]);
|
||||
|
||||
const jw: WatchlistApiResponse = await w.json();
|
||||
const je: FollowingEventsApiResponse = await e.json();
|
||||
const jc: EventCommentsApiResponse = await c.json();
|
||||
|
||||
if (jw.success) {
|
||||
const watchlistData = Array.isArray(jw.data) ? jw.data : [];
|
||||
setWatchlist(watchlistData);
|
||||
|
||||
// 追踪自选股列表查看
|
||||
if (watchlistData.length > 0) {
|
||||
dashboardEvents.trackWatchlistViewed(watchlistData.length, true);
|
||||
}
|
||||
|
||||
// 加载实时行情
|
||||
if (jw.data && jw.data.length > 0) {
|
||||
loadRealtimeQuotes();
|
||||
}
|
||||
}
|
||||
|
||||
if (je.success) {
|
||||
const eventsData = Array.isArray(je.data) ? je.data : [];
|
||||
setFollowingEvents(eventsData);
|
||||
|
||||
// 追踪关注的事件列表查看
|
||||
dashboardEvents.trackFollowingEventsViewed(eventsData.length);
|
||||
}
|
||||
|
||||
if (jc.success) {
|
||||
const commentsData = Array.isArray(jc.data) ? jc.data : [];
|
||||
setEventComments(commentsData);
|
||||
|
||||
// 追踪评论列表查看
|
||||
dashboardEvents.trackCommentsViewed(commentsData.length);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Center', 'loadData', err, {
|
||||
userId,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [userId, loadRealtimeQuotes, dashboardEvents]);
|
||||
|
||||
// 首次加载和页面可见性变化时加载数据
|
||||
useEffect(() => {
|
||||
const isOnCenterPage = location.pathname.includes('/home/center');
|
||||
|
||||
// 首次进入页面且有用户时加载数据
|
||||
if (user && isOnCenterPage && !hasLoadedRef.current) {
|
||||
console.log('[Center] 🚀 首次加载数据');
|
||||
hasLoadedRef.current = true;
|
||||
loadData();
|
||||
}
|
||||
|
||||
const onVis = (): void => {
|
||||
if (document.visibilityState === 'visible' && location.pathname.includes('/home/center')) {
|
||||
console.log('[Center] 👁️ visibilitychange 触发 loadData');
|
||||
loadData();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', onVis);
|
||||
return () => document.removeEventListener('visibilitychange', onVis);
|
||||
}, [userId, location.pathname, loadData, user]);
|
||||
|
||||
// 当用户登出再登入(userId 变化)时,重置加载标记
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
hasLoadedRef.current = false;
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
// 定时刷新实时行情(每分钟一次)
|
||||
useEffect(() => {
|
||||
if (watchlist.length > 0) {
|
||||
const interval = setInterval(() => {
|
||||
loadRealtimeQuotes();
|
||||
}, 60000); // 60秒刷新一次
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [watchlist.length, loadRealtimeQuotes]);
|
||||
|
||||
// 渲染加载状态
|
||||
if (loading) {
|
||||
return (
|
||||
<Center h="60vh">
|
||||
<VStack spacing={4}>
|
||||
<Spinner size="xl" color="blue.500" thickness="4px" />
|
||||
<Text color={secondaryText}>加载个人中心数据...</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box bg={THEME.bg.primary} minH="100vh" overflowX="hidden">
|
||||
<Box px={{ base: 3, md: 4 }} py={{ base: 4, md: 6 }} maxW="container.xl" mx="auto">
|
||||
{/* 左右布局:左侧自适应,右侧固定200px */}
|
||||
<Flex gap={4}>
|
||||
{/* 左侧主内容区 */}
|
||||
<Box flex={1} minW={0}>
|
||||
{/* 市场概览仪表盘 */}
|
||||
<Box mb={4}>
|
||||
<MarketDashboard />
|
||||
</Box>
|
||||
|
||||
{/* 投资规划中心 */}
|
||||
<Box mb={4}>
|
||||
<StrategyCenter />
|
||||
</Box>
|
||||
|
||||
{/* 价值论坛 / 互动中心 */}
|
||||
<Box mb={4}>
|
||||
<ForumCenter />
|
||||
</Box>
|
||||
|
||||
{/* 投资规划中心(整合了日历、计划、复盘) */}
|
||||
<Box>
|
||||
<InvestmentPlanningCenter />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 右侧固定宽度侧边栏 */}
|
||||
<Box
|
||||
w={{ base: '100%', md: '200px' }}
|
||||
flexShrink={0}
|
||||
display={{ base: 'none', md: 'block' }}
|
||||
position="sticky"
|
||||
top="80px"
|
||||
alignSelf="flex-start"
|
||||
maxH="calc(100vh - 100px)"
|
||||
overflowY="auto"
|
||||
css={{
|
||||
'&::-webkit-scrollbar': { width: '4px' },
|
||||
'&::-webkit-scrollbar-track': { background: 'transparent' },
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
background: 'rgba(212, 175, 55, 0.3)',
|
||||
borderRadius: '2px',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<WatchSidebar
|
||||
watchlist={watchlist}
|
||||
realtimeQuotes={realtimeQuotes}
|
||||
followingEvents={followingEvents}
|
||||
onStockClick={(stock: WatchlistItem) => navigate(`/company/${stock.stock_code}`)}
|
||||
onEventClick={(event: FollowingEvent) => navigate(getEventDetailUrl(event.id))}
|
||||
onAddStock={() => navigate('/stocks')}
|
||||
onAddEvent={() => navigate('/community')}
|
||||
/>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default CenterDashboard;
|
||||
@@ -12,7 +12,7 @@
|
||||
* - PlanningContext (数据共享层)
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, Suspense, lazy } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useMemo, Suspense, lazy } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
@@ -119,27 +119,46 @@ const InvestmentPlanningCenter: React.FC = () => {
|
||||
loadAllData();
|
||||
}, [loadAllData]);
|
||||
|
||||
// 提供给子组件的 Context 值
|
||||
const contextValue: PlanningContextValue = {
|
||||
allEvents,
|
||||
setAllEvents,
|
||||
loadAllData,
|
||||
loading,
|
||||
setLoading,
|
||||
openPlanModalTrigger,
|
||||
openReviewModalTrigger,
|
||||
toast,
|
||||
borderColor,
|
||||
textColor,
|
||||
secondaryText,
|
||||
cardBg,
|
||||
setViewMode,
|
||||
setListTab,
|
||||
};
|
||||
// 提供给子组件的 Context 值(使用 useMemo 缓存,避免子组件不必要的重渲染)
|
||||
const contextValue: PlanningContextValue = useMemo(
|
||||
() => ({
|
||||
allEvents,
|
||||
setAllEvents,
|
||||
loadAllData,
|
||||
loading,
|
||||
setLoading,
|
||||
openPlanModalTrigger,
|
||||
openReviewModalTrigger,
|
||||
toast,
|
||||
borderColor,
|
||||
textColor,
|
||||
secondaryText,
|
||||
cardBg,
|
||||
setViewMode,
|
||||
setListTab,
|
||||
}),
|
||||
[
|
||||
allEvents,
|
||||
loadAllData,
|
||||
loading,
|
||||
openPlanModalTrigger,
|
||||
openReviewModalTrigger,
|
||||
toast,
|
||||
borderColor,
|
||||
textColor,
|
||||
secondaryText,
|
||||
cardBg,
|
||||
]
|
||||
);
|
||||
|
||||
// 计算各类型事件数量
|
||||
const planCount = allEvents.filter(e => e.type === 'plan').length;
|
||||
const reviewCount = allEvents.filter(e => e.type === 'review').length;
|
||||
// 计算各类型事件数量(使用 useMemo 缓存,避免每次渲染重复遍历数组)
|
||||
const { planCount, reviewCount } = useMemo(
|
||||
() => ({
|
||||
planCount: allEvents.filter(e => e.type === 'plan').length,
|
||||
reviewCount: allEvents.filter(e => e.type === 'review').length,
|
||||
}),
|
||||
[allEvents]
|
||||
);
|
||||
|
||||
return (
|
||||
<PlanningDataProvider value={contextValue}>
|
||||
5
src/views/Center/hooks/index.ts
Normal file
5
src/views/Center/hooks/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Center 模块 Hooks 导出
|
||||
*/
|
||||
|
||||
export { useCenterColors, default as useCenterColorsDefault } from './useCenterColors';
|
||||
41
src/views/Center/hooks/useCenterColors.ts
Normal file
41
src/views/Center/hooks/useCenterColors.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* useCenterColors Hook
|
||||
*
|
||||
* 封装 Center 模块的所有颜色变量,避免每次渲染重复调用 useColorModeValue
|
||||
* 将 7 次 hook 调用合并为 1 次 useMemo 计算
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useColorModeValue } from '@chakra-ui/react';
|
||||
import type { CenterColors } from '@/types/center';
|
||||
|
||||
/**
|
||||
* 获取 Center 模块的颜色配置
|
||||
* 使用 useMemo 缓存结果,避免每次渲染重新计算
|
||||
*/
|
||||
export function useCenterColors(): CenterColors {
|
||||
// 获取当前主题模式下的基础颜色
|
||||
const textColor = useColorModeValue('gray.700', 'white');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
const bgColor = useColorModeValue('white', 'gray.800');
|
||||
const hoverBg = useColorModeValue('gray.50', 'gray.700');
|
||||
const secondaryText = useColorModeValue('gray.600', 'gray.400');
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const sectionBg = useColorModeValue('gray.50', 'gray.900');
|
||||
|
||||
// 使用 useMemo 缓存颜色对象,只在颜色值变化时重新创建
|
||||
return useMemo(
|
||||
() => ({
|
||||
textColor,
|
||||
borderColor,
|
||||
bgColor,
|
||||
hoverBg,
|
||||
secondaryText,
|
||||
cardBg,
|
||||
sectionBg,
|
||||
}),
|
||||
[textColor, borderColor, bgColor, hoverBg, secondaryText, cardBg, sectionBg]
|
||||
);
|
||||
}
|
||||
|
||||
export default useCenterColors;
|
||||
4
src/views/Center/index.js
Normal file
4
src/views/Center/index.js
Normal file
@@ -0,0 +1,4 @@
|
||||
// src/views/Center/index.js
|
||||
// 入口文件,导出 Center 组件
|
||||
|
||||
export { default } from './Center';
|
||||
87
src/views/Center/utils/formatters.ts
Normal file
87
src/views/Center/utils/formatters.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Center 模块格式化工具函数
|
||||
*
|
||||
* 这些是纯函数,提取到组件外部避免每次渲染重建
|
||||
*/
|
||||
|
||||
/**
|
||||
* 格式化相对时间(如 "5分钟前"、"3天前")
|
||||
* @param dateString 日期字符串
|
||||
* @returns 格式化后的相对时间字符串
|
||||
*/
|
||||
export function formatRelativeTime(dateString: string | null | undefined): string {
|
||||
if (!dateString) return '';
|
||||
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffTime = Math.abs(now.getTime() - date.getTime());
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays < 1) {
|
||||
const diffHours = Math.ceil(diffTime / (1000 * 60 * 60));
|
||||
if (diffHours < 1) {
|
||||
const diffMinutes = Math.ceil(diffTime / (1000 * 60));
|
||||
return `${diffMinutes}分钟前`;
|
||||
}
|
||||
return `${diffHours}小时前`;
|
||||
} else if (diffDays < 7) {
|
||||
return `${diffDays}天前`;
|
||||
} else {
|
||||
return date.toLocaleDateString('zh-CN');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化数字(如 10000 → "1w",1500 → "1.5k")
|
||||
* @param num 数字
|
||||
* @returns 格式化后的字符串
|
||||
*/
|
||||
export function formatCompactNumber(num: number | null | undefined): string {
|
||||
if (!num) return '0';
|
||||
|
||||
if (num >= 10000) {
|
||||
return (num / 10000).toFixed(1) + 'w';
|
||||
} else if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1) + 'k';
|
||||
}
|
||||
return num.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据热度分数获取颜色
|
||||
* @param score 热度分数 (0-100)
|
||||
* @returns Chakra UI 颜色名称
|
||||
*/
|
||||
export function getHeatColor(score: number): string {
|
||||
if (score >= 80) return 'red';
|
||||
if (score >= 60) return 'orange';
|
||||
if (score >= 40) return 'yellow';
|
||||
return 'green';
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据涨跌幅获取颜色
|
||||
* @param changePercent 涨跌幅百分比
|
||||
* @returns 颜色值
|
||||
*/
|
||||
export function getChangeColor(changePercent: number | null | undefined): string {
|
||||
if (changePercent === null || changePercent === undefined) {
|
||||
return 'rgba(255, 255, 255, 0.6)';
|
||||
}
|
||||
if (changePercent > 0) return '#EF4444'; // 红色(涨)
|
||||
if (changePercent < 0) return '#22C55E'; // 绿色(跌)
|
||||
return 'rgba(255, 255, 255, 0.6)'; // 灰色(平)
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化涨跌幅显示
|
||||
* @param changePercent 涨跌幅百分比
|
||||
* @returns 格式化后的字符串(如 "+5.23%")
|
||||
*/
|
||||
export function formatChangePercent(changePercent: number | null | undefined): string {
|
||||
if (changePercent === null || changePercent === undefined) {
|
||||
return '--';
|
||||
}
|
||||
const prefix = changePercent > 0 ? '+' : '';
|
||||
return `${prefix}${Number(changePercent).toFixed(2)}%`;
|
||||
}
|
||||
11
src/views/Center/utils/index.ts
Normal file
11
src/views/Center/utils/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Center 模块工具函数导出
|
||||
*/
|
||||
|
||||
export {
|
||||
formatRelativeTime,
|
||||
formatCompactNumber,
|
||||
getHeatColor,
|
||||
getChangeColor,
|
||||
formatChangePercent,
|
||||
} from './formatters';
|
||||
@@ -25,9 +25,11 @@ import {
|
||||
} from "@chakra-ui/react";
|
||||
import { ChevronDownIcon, ChevronUpIcon, RepeatIcon } from "@chakra-ui/icons";
|
||||
import { FiTrendingUp, FiZap } from "react-icons/fi";
|
||||
import { FireOutlined } from "@ant-design/icons";
|
||||
import dayjs from "dayjs";
|
||||
import { Select } from "antd";
|
||||
import MiniEventCard from "../../EventCard/MiniEventCard";
|
||||
import { getApiBase } from "@utils/apiConfig";
|
||||
import { getChangeColor } from "@utils/colorUtils";
|
||||
import "../../SearchFilters/CompactSearchBox.css";
|
||||
|
||||
// 固定深色主题颜色
|
||||
@@ -47,6 +49,179 @@ const COLORS = {
|
||||
// 每次加载的事件数量
|
||||
const EVENTS_PER_LOAD = 12;
|
||||
|
||||
/**
|
||||
* 格式化时间显示 - 始终显示日期,避免跨天混淆
|
||||
*/
|
||||
const formatEventTime = (dateStr) => {
|
||||
if (!dateStr) return "";
|
||||
const date = dayjs(dateStr);
|
||||
const now = dayjs();
|
||||
const isToday = date.isSame(now, "day");
|
||||
const isYesterday = date.isSame(now.subtract(1, "day"), "day");
|
||||
|
||||
// 始终显示日期,用标签区分今天/昨天
|
||||
if (isToday) {
|
||||
return `今天 ${date.format("MM-DD HH:mm")}`;
|
||||
} else if (isYesterday) {
|
||||
return `昨天 ${date.format("MM-DD HH:mm")}`;
|
||||
} else {
|
||||
return date.format("MM-DD HH:mm");
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据涨跌幅获取背景色
|
||||
*/
|
||||
const getChangeBgColor = (value) => {
|
||||
if (value == null || isNaN(value)) return "transparent";
|
||||
const absChange = Math.abs(value);
|
||||
if (value > 0) {
|
||||
if (absChange >= 5) return "rgba(239, 68, 68, 0.12)";
|
||||
if (absChange >= 3) return "rgba(239, 68, 68, 0.08)";
|
||||
return "rgba(239, 68, 68, 0.05)";
|
||||
} else if (value < 0) {
|
||||
if (absChange >= 5) return "rgba(16, 185, 129, 0.12)";
|
||||
if (absChange >= 3) return "rgba(16, 185, 129, 0.08)";
|
||||
return "rgba(16, 185, 129, 0.05)";
|
||||
}
|
||||
return "transparent";
|
||||
};
|
||||
|
||||
/**
|
||||
* 单个事件项组件 - 卡片式布局
|
||||
*/
|
||||
const TimelineEventItem = React.memo(({ event, isSelected, onEventClick }) => {
|
||||
// 使用 related_max_chg 作为主要涨幅显示
|
||||
const maxChange = event.related_max_chg;
|
||||
const avgChange = event.related_avg_chg;
|
||||
const hasMaxChange = maxChange != null && !isNaN(maxChange);
|
||||
const hasAvgChange = avgChange != null && !isNaN(avgChange);
|
||||
|
||||
// 用于背景色的涨幅(使用平均超额)
|
||||
const bgValue = avgChange;
|
||||
|
||||
return (
|
||||
<Box
|
||||
w="100%"
|
||||
cursor="pointer"
|
||||
onClick={() => onEventClick?.(event)}
|
||||
bg={isSelected ? "rgba(66, 153, 225, 0.15)" : getChangeBgColor(bgValue)}
|
||||
borderWidth="1px"
|
||||
borderColor={isSelected ? "#4299e1" : COLORS.cardBorderColor}
|
||||
borderRadius="lg"
|
||||
p={3}
|
||||
mb={2}
|
||||
_hover={{
|
||||
bg: isSelected ? "rgba(66, 153, 225, 0.2)" : "rgba(255, 255, 255, 0.06)",
|
||||
borderColor: isSelected ? "#63b3ed" : "#5a6070",
|
||||
transform: "translateY(-1px)",
|
||||
}}
|
||||
transition="all 0.2s ease"
|
||||
>
|
||||
{/* 第一行:时间 */}
|
||||
<Text
|
||||
fontSize="xs"
|
||||
color={COLORS.secondaryTextColor}
|
||||
mb={1.5}
|
||||
>
|
||||
{formatEventTime(event.created_at || event.event_time)}
|
||||
</Text>
|
||||
|
||||
{/* 第二行:标题 */}
|
||||
<Text
|
||||
fontSize="sm"
|
||||
color="#63b3ed"
|
||||
fontWeight="medium"
|
||||
noOfLines={2}
|
||||
lineHeight="1.5"
|
||||
mb={2}
|
||||
_hover={{ textDecoration: "underline" }}
|
||||
>
|
||||
{event.title}
|
||||
</Text>
|
||||
|
||||
{/* 第三行:涨跌幅指标 */}
|
||||
{(hasMaxChange || hasAvgChange) && (
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
{/* 最大超额 */}
|
||||
{hasMaxChange && (
|
||||
<Box
|
||||
bg={maxChange > 0 ? "rgba(239, 68, 68, 0.15)" : "rgba(16, 185, 129, 0.15)"}
|
||||
borderWidth="1px"
|
||||
borderColor={maxChange > 0 ? "rgba(239, 68, 68, 0.3)" : "rgba(16, 185, 129, 0.3)"}
|
||||
borderRadius="md"
|
||||
px={2}
|
||||
py={1}
|
||||
>
|
||||
<Text fontSize="xs" color={COLORS.secondaryTextColor} mb={0.5}>
|
||||
最大超额
|
||||
</Text>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="bold"
|
||||
color={maxChange > 0 ? "#fc8181" : "#68d391"}
|
||||
>
|
||||
{maxChange > 0 ? "+" : ""}{maxChange.toFixed(2)}%
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 平均超额 */}
|
||||
{hasAvgChange && (
|
||||
<Box
|
||||
bg={avgChange > 0 ? "rgba(239, 68, 68, 0.15)" : "rgba(16, 185, 129, 0.15)"}
|
||||
borderWidth="1px"
|
||||
borderColor={avgChange > 0 ? "rgba(239, 68, 68, 0.3)" : "rgba(16, 185, 129, 0.3)"}
|
||||
borderRadius="md"
|
||||
px={2}
|
||||
py={1}
|
||||
>
|
||||
<Text fontSize="xs" color={COLORS.secondaryTextColor} mb={0.5}>
|
||||
平均超额
|
||||
</Text>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="bold"
|
||||
color={avgChange > 0 ? "#fc8181" : "#68d391"}
|
||||
>
|
||||
{avgChange > 0 ? "+" : ""}{avgChange.toFixed(2)}%
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 超预期得分 */}
|
||||
{event.expectation_surprise_score != null && (
|
||||
<Box
|
||||
bg={event.expectation_surprise_score >= 60 ? "rgba(239, 68, 68, 0.15)" :
|
||||
event.expectation_surprise_score >= 40 ? "rgba(237, 137, 54, 0.15)" : "rgba(66, 153, 225, 0.15)"}
|
||||
borderWidth="1px"
|
||||
borderColor={event.expectation_surprise_score >= 60 ? "rgba(239, 68, 68, 0.3)" :
|
||||
event.expectation_surprise_score >= 40 ? "rgba(237, 137, 54, 0.3)" : "rgba(66, 153, 225, 0.3)"}
|
||||
borderRadius="md"
|
||||
px={2}
|
||||
py={1}
|
||||
>
|
||||
<Text fontSize="xs" color={COLORS.secondaryTextColor} mb={0.5}>
|
||||
超预期
|
||||
</Text>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="bold"
|
||||
color={event.expectation_surprise_score >= 60 ? "#fc8181" :
|
||||
event.expectation_surprise_score >= 40 ? "#ed8936" : "#63b3ed"}
|
||||
>
|
||||
{Math.round(event.expectation_surprise_score)}分
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</HStack>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
TimelineEventItem.displayName = "TimelineEventItem";
|
||||
|
||||
/**
|
||||
* 单个主线卡片组件 - 支持懒加载
|
||||
*/
|
||||
@@ -70,6 +245,23 @@ const MainlineCard = React.memo(
|
||||
}
|
||||
}, [isExpanded]);
|
||||
|
||||
// 找出最大超额涨幅最高的事件(HOT 事件)
|
||||
const hotEvent = useMemo(() => {
|
||||
if (!mainline.events || mainline.events.length === 0) return null;
|
||||
let maxChange = -Infinity;
|
||||
let hot = null;
|
||||
mainline.events.forEach((event) => {
|
||||
// 统一使用 related_max_chg(最大超额)
|
||||
const change = event.related_max_chg ?? -Infinity;
|
||||
if (change > maxChange) {
|
||||
maxChange = change;
|
||||
hot = event;
|
||||
}
|
||||
});
|
||||
// 只有当最大超额 > 0 时才显示 HOT
|
||||
return maxChange > 0 ? hot : null;
|
||||
}, [mainline.events]);
|
||||
|
||||
// 当前显示的事件
|
||||
const displayedEvents = useMemo(() => {
|
||||
return mainline.events.slice(0, displayCount);
|
||||
@@ -101,8 +293,8 @@ const MainlineCard = React.memo(
|
||||
borderColor={COLORS.cardBorderColor}
|
||||
borderTopWidth="3px"
|
||||
borderTopColor={`${colorScheme}.500`}
|
||||
minW={isExpanded ? "280px" : "200px"}
|
||||
maxW={isExpanded ? "320px" : "240px"}
|
||||
minW={isExpanded ? "320px" : "280px"}
|
||||
maxW={isExpanded ? "380px" : "320px"}
|
||||
h="100%"
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
@@ -114,72 +306,132 @@ const MainlineCard = React.memo(
|
||||
}}
|
||||
>
|
||||
{/* 卡片头部 */}
|
||||
<Flex
|
||||
align="center"
|
||||
justify="space-between"
|
||||
px={3}
|
||||
py={2}
|
||||
cursor="pointer"
|
||||
onClick={onToggle}
|
||||
_hover={{ bg: COLORS.headerHoverBg }}
|
||||
transition="all 0.15s"
|
||||
borderBottomWidth="1px"
|
||||
borderBottomColor={COLORS.cardBorderColor}
|
||||
flexShrink={0}
|
||||
>
|
||||
<VStack align="start" spacing={0} flex={1} minW={0}>
|
||||
<HStack spacing={2} w="100%">
|
||||
<Text
|
||||
fontWeight="bold"
|
||||
fontSize="sm"
|
||||
color={COLORS.textColor}
|
||||
noOfLines={1}
|
||||
flex={1}
|
||||
>
|
||||
{mainline.group_name || mainline.lv2_name || mainline.lv1_name || "其他"}
|
||||
</Text>
|
||||
{/* 涨跌幅显示 - 在概念名称旁边 */}
|
||||
{mainline.avg_change_pct != null && (
|
||||
<Box flexShrink={0}>
|
||||
{/* 第一行:概念名称 + 涨跌幅 + 事件数 */}
|
||||
<Flex
|
||||
align="center"
|
||||
justify="space-between"
|
||||
px={3}
|
||||
py={2}
|
||||
cursor="pointer"
|
||||
onClick={onToggle}
|
||||
_hover={{ bg: COLORS.headerHoverBg }}
|
||||
transition="all 0.15s"
|
||||
>
|
||||
<VStack align="start" spacing={0} flex={1} minW={0}>
|
||||
<HStack spacing={2} w="100%">
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="bold"
|
||||
color={mainline.avg_change_pct >= 0 ? "#fc8181" : "#68d391"}
|
||||
fontSize="sm"
|
||||
color={COLORS.textColor}
|
||||
noOfLines={1}
|
||||
flex={1}
|
||||
>
|
||||
{mainline.group_name || mainline.lv2_name || mainline.lv1_name || "其他"}
|
||||
</Text>
|
||||
{/* 涨跌幅显示 - 在概念名称旁边 */}
|
||||
{mainline.avg_change_pct != null && (
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="bold"
|
||||
color={mainline.avg_change_pct >= 0 ? "#fc8181" : "#68d391"}
|
||||
flexShrink={0}
|
||||
>
|
||||
{mainline.avg_change_pct >= 0 ? "+" : ""}
|
||||
{mainline.avg_change_pct.toFixed(2)}%
|
||||
</Text>
|
||||
)}
|
||||
<Badge
|
||||
colorScheme={colorScheme}
|
||||
fontSize="xs"
|
||||
borderRadius="full"
|
||||
px={2}
|
||||
flexShrink={0}
|
||||
>
|
||||
{mainline.avg_change_pct >= 0 ? "+" : ""}
|
||||
{mainline.avg_change_pct.toFixed(2)}%
|
||||
{mainline.event_count}
|
||||
</Badge>
|
||||
</HStack>
|
||||
{/* 显示上级概念名称作为副标题 */}
|
||||
{mainline.parent_name && (
|
||||
<Text
|
||||
fontSize="xs"
|
||||
color={COLORS.secondaryTextColor}
|
||||
noOfLines={1}
|
||||
>
|
||||
{mainline.grandparent_name ? `${mainline.grandparent_name} > ` : ""}
|
||||
{mainline.parent_name}
|
||||
</Text>
|
||||
)}
|
||||
<Badge
|
||||
colorScheme={colorScheme}
|
||||
fontSize="xs"
|
||||
borderRadius="full"
|
||||
px={2}
|
||||
flexShrink={0}
|
||||
>
|
||||
{mainline.event_count}
|
||||
</Badge>
|
||||
</HStack>
|
||||
{/* 显示上级概念名称作为副标题 */}
|
||||
{mainline.parent_name && (
|
||||
</VStack>
|
||||
<Icon
|
||||
as={isExpanded ? ChevronUpIcon : ChevronDownIcon}
|
||||
boxSize={4}
|
||||
color={COLORS.secondaryTextColor}
|
||||
ml={2}
|
||||
flexShrink={0}
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
{/* HOT 事件展示区域 */}
|
||||
{hotEvent && (
|
||||
<Box
|
||||
px={3}
|
||||
py={3}
|
||||
bg="rgba(245, 101, 101, 0.1)"
|
||||
borderBottomWidth="1px"
|
||||
borderBottomColor={COLORS.cardBorderColor}
|
||||
cursor="pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEventSelect?.(hotEvent);
|
||||
}}
|
||||
_hover={{ bg: "rgba(245, 101, 101, 0.18)" }}
|
||||
transition="all 0.15s"
|
||||
>
|
||||
{/* 第一行:HOT 标签 + 最大超额 */}
|
||||
<HStack spacing={2} mb={1.5}>
|
||||
<Badge
|
||||
bg="linear-gradient(135deg, #f56565 0%, #ed8936 100%)"
|
||||
color="white"
|
||||
fontSize="xs"
|
||||
px={2}
|
||||
py={0.5}
|
||||
borderRadius="sm"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
gap="3px"
|
||||
fontWeight="bold"
|
||||
>
|
||||
<FireOutlined style={{ fontSize: 11 }} />
|
||||
HOT
|
||||
</Badge>
|
||||
{/* 最大超额涨幅 */}
|
||||
{hotEvent.related_max_chg != null && (
|
||||
<Box
|
||||
bg="rgba(239, 68, 68, 0.2)"
|
||||
borderRadius="md"
|
||||
px={2}
|
||||
py={0.5}
|
||||
>
|
||||
<Text fontSize="xs" color="#fc8181" fontWeight="bold">
|
||||
最大超额 +{hotEvent.related_max_chg.toFixed(2)}%
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</HStack>
|
||||
{/* 第二行:标题 */}
|
||||
<Text
|
||||
fontSize="xs"
|
||||
color={COLORS.secondaryTextColor}
|
||||
noOfLines={1}
|
||||
fontSize="sm"
|
||||
color={COLORS.textColor}
|
||||
noOfLines={2}
|
||||
lineHeight="1.5"
|
||||
fontWeight="medium"
|
||||
>
|
||||
{mainline.grandparent_name ? `${mainline.grandparent_name} > ` : ""}
|
||||
{mainline.parent_name}
|
||||
{hotEvent.title}
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
<Icon
|
||||
as={isExpanded ? ChevronUpIcon : ChevronDownIcon}
|
||||
boxSize={4}
|
||||
color={COLORS.secondaryTextColor}
|
||||
ml={2}
|
||||
flexShrink={0}
|
||||
/>
|
||||
</Flex>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 事件列表区域 */}
|
||||
{isExpanded ? (
|
||||
@@ -199,17 +451,15 @@ const MainlineCard = React.memo(
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* 事件列表 - 单列布局 */}
|
||||
<VStack spacing={2} align="stretch">
|
||||
{displayedEvents.map((event) => (
|
||||
<MiniEventCard
|
||||
key={event.id}
|
||||
event={event}
|
||||
isSelected={selectedEvent?.id === event.id}
|
||||
onEventClick={onEventSelect}
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
{/* 事件列表 - 卡片式 */}
|
||||
{displayedEvents.map((event) => (
|
||||
<TimelineEventItem
|
||||
key={event.id}
|
||||
event={event}
|
||||
isSelected={selectedEvent?.id === event.id}
|
||||
onEventClick={onEventSelect}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 加载更多按钮 */}
|
||||
{hasMore && (
|
||||
@@ -221,7 +471,7 @@ const MainlineCard = React.memo(
|
||||
isLoading={isLoadingMore}
|
||||
loadingText="加载中..."
|
||||
w="100%"
|
||||
mt={2}
|
||||
mt={1}
|
||||
_hover={{ bg: COLORS.headerHoverBg }}
|
||||
>
|
||||
加载更多 ({mainline.events.length - displayCount} 条)
|
||||
@@ -229,31 +479,21 @@ const MainlineCard = React.memo(
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
/* 折叠时显示简要信息 */
|
||||
<Box px={3} py={2} flex={1} overflow="hidden">
|
||||
<VStack spacing={1} align="stretch">
|
||||
{mainline.events.slice(0, 4).map((event) => (
|
||||
<Text
|
||||
key={event.id}
|
||||
fontSize="xs"
|
||||
color={COLORS.secondaryTextColor}
|
||||
noOfLines={1}
|
||||
cursor="pointer"
|
||||
_hover={{ color: COLORS.textColor }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEventSelect?.(event);
|
||||
}}
|
||||
>
|
||||
• {event.title}
|
||||
</Text>
|
||||
))}
|
||||
{mainline.events.length > 4 && (
|
||||
<Text fontSize="xs" color={COLORS.secondaryTextColor}>
|
||||
... 还有 {mainline.events.length - 4} 条
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
/* 折叠时显示简要信息 - 卡片式 */
|
||||
<Box px={2} py={2} flex={1} overflow="hidden">
|
||||
{mainline.events.slice(0, 3).map((event) => (
|
||||
<TimelineEventItem
|
||||
key={event.id}
|
||||
event={event}
|
||||
isSelected={selectedEvent?.id === event.id}
|
||||
onEventClick={onEventSelect}
|
||||
/>
|
||||
))}
|
||||
{mainline.events.length > 3 && (
|
||||
<Text fontSize="sm" color={COLORS.secondaryTextColor} textAlign="center" pt={1}>
|
||||
... 还有 {mainline.events.length - 3} 条
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
@@ -288,6 +528,8 @@ const MainlineTimelineViewComponent = forwardRef(
|
||||
const [groupBy, setGroupBy] = useState("lv2");
|
||||
// 层级选项(从 API 获取)
|
||||
const [hierarchyOptions, setHierarchyOptions] = useState({ lv1: [], lv2: [], lv3: [] });
|
||||
// 排序方式: 'event_count' | 'change_desc' | 'change_asc'
|
||||
const [sortBy, setSortBy] = useState("event_count");
|
||||
|
||||
// 根据主线类型获取配色
|
||||
const getColorScheme = useCallback((lv2Name) => {
|
||||
@@ -372,11 +614,18 @@ const MainlineTimelineViewComponent = forwardRef(
|
||||
const apiBase = getApiBase();
|
||||
const params = new URLSearchParams();
|
||||
|
||||
// 添加筛选参数
|
||||
if (filters.recent_days)
|
||||
// 添加筛选参数(主线模式支持时间范围筛选)
|
||||
// 优先使用精确时间范围(start_date/end_date),其次使用 recent_days
|
||||
if (filters.start_date) {
|
||||
params.append("start_date", filters.start_date);
|
||||
}
|
||||
if (filters.end_date) {
|
||||
params.append("end_date", filters.end_date);
|
||||
}
|
||||
if (filters.recent_days && !filters.start_date && !filters.end_date) {
|
||||
// 只有在没有精确时间范围时才使用 recent_days
|
||||
params.append("recent_days", filters.recent_days);
|
||||
if (filters.importance && filters.importance !== "all")
|
||||
params.append("importance", filters.importance);
|
||||
}
|
||||
// 添加分组方式参数
|
||||
params.append("group_by", groupBy);
|
||||
|
||||
@@ -397,15 +646,8 @@ const MainlineTimelineViewComponent = forwardRef(
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
// 按事件数量从多到少排序
|
||||
const sortedMainlines = [...(result.data.mainlines || [])].sort(
|
||||
(a, b) => b.event_count - a.event_count
|
||||
);
|
||||
|
||||
setMainlineData({
|
||||
...result.data,
|
||||
mainlines: sortedMainlines,
|
||||
});
|
||||
// 保存原始数据,排序在渲染时根据 sortBy 状态进行
|
||||
setMainlineData(result.data);
|
||||
|
||||
// 保存层级选项供下拉框使用
|
||||
if (result.data.hierarchy_options) {
|
||||
@@ -414,7 +656,7 @@ const MainlineTimelineViewComponent = forwardRef(
|
||||
|
||||
// 初始化展开状态(默认全部展开)
|
||||
const initialExpanded = {};
|
||||
sortedMainlines.forEach((mainline) => {
|
||||
(result.data.mainlines || []).forEach((mainline) => {
|
||||
const groupId = mainline.group_id || mainline.lv2_id || mainline.lv1_id;
|
||||
initialExpanded[groupId] = true;
|
||||
});
|
||||
@@ -428,7 +670,7 @@ const MainlineTimelineViewComponent = forwardRef(
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [display, filters.recent_days, filters.importance, groupBy]);
|
||||
}, [display, filters.start_date, filters.end_date, filters.recent_days, groupBy]);
|
||||
|
||||
// 初始加载 & 筛选变化时刷新
|
||||
useEffect(() => {
|
||||
@@ -466,6 +708,25 @@ const MainlineTimelineViewComponent = forwardRef(
|
||||
[mainlineData]
|
||||
);
|
||||
|
||||
// 根据排序方式排序主线列表(必须在条件渲染之前,遵循 Hooks 规则)
|
||||
const sortedMainlines = useMemo(() => {
|
||||
const rawMainlines = mainlineData?.mainlines;
|
||||
if (!rawMainlines) return [];
|
||||
const sorted = [...rawMainlines];
|
||||
switch (sortBy) {
|
||||
case "change_desc":
|
||||
// 按涨跌幅从高到低(涨幅大的在前)
|
||||
return sorted.sort((a, b) => (b.avg_change_pct ?? -999) - (a.avg_change_pct ?? -999));
|
||||
case "change_asc":
|
||||
// 按涨跌幅从低到高(跌幅大的在前)
|
||||
return sorted.sort((a, b) => (a.avg_change_pct ?? 999) - (b.avg_change_pct ?? 999));
|
||||
case "event_count":
|
||||
default:
|
||||
// 按事件数量从多到少
|
||||
return sorted.sort((a, b) => b.event_count - a.event_count);
|
||||
}
|
||||
}, [mainlineData?.mainlines, sortBy]);
|
||||
|
||||
// 渲染加载状态
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -517,12 +778,14 @@ const MainlineTimelineViewComponent = forwardRef(
|
||||
}
|
||||
|
||||
const {
|
||||
mainlines,
|
||||
total_events,
|
||||
mainline_count,
|
||||
ungrouped_count,
|
||||
} = mainlineData;
|
||||
|
||||
// 使用排序后的主线列表
|
||||
const mainlines = sortedMainlines;
|
||||
|
||||
return (
|
||||
<Box
|
||||
display={display}
|
||||
@@ -565,7 +828,7 @@ const MainlineTimelineViewComponent = forwardRef(
|
||||
onChange={setGroupBy}
|
||||
size="small"
|
||||
style={{
|
||||
width: 200,
|
||||
width: 180,
|
||||
backgroundColor: "transparent",
|
||||
}}
|
||||
popupClassName="dark-select-dropdown"
|
||||
@@ -620,6 +883,26 @@ const MainlineTimelineViewComponent = forwardRef(
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
{/* 排序方式选择器 */}
|
||||
<Select
|
||||
value={sortBy}
|
||||
onChange={setSortBy}
|
||||
size="small"
|
||||
style={{
|
||||
width: 140,
|
||||
backgroundColor: "transparent",
|
||||
}}
|
||||
popupClassName="dark-select-dropdown"
|
||||
dropdownStyle={{
|
||||
backgroundColor: "#252a34",
|
||||
borderColor: "#3a3f4b",
|
||||
}}
|
||||
options={[
|
||||
{ value: "event_count", label: "按事件数量" },
|
||||
{ value: "change_desc", label: "按涨幅↓" },
|
||||
{ value: "change_asc", label: "按跌幅↓" },
|
||||
]}
|
||||
/>
|
||||
<Tooltip label="全部展开">
|
||||
<IconButton
|
||||
icon={<ChevronDownIcon />}
|
||||
|
||||
@@ -163,13 +163,20 @@ const CompactSearchBox = ({
|
||||
stockDisplayValueRef.current = null;
|
||||
}
|
||||
|
||||
const hasTimeInFilters = filters.start_date || filters.end_date || filters.recent_days;
|
||||
const hasTimeInFilters = filters.start_date || filters.end_date || filters.recent_days || filters.time_filter_key;
|
||||
|
||||
if (hasTimeInFilters && (!tradingTimeRange || !tradingTimeRange.key)) {
|
||||
let inferredKey = 'custom';
|
||||
// 优先使用 time_filter_key(来自 useEventFilters 的默认值)
|
||||
let inferredKey = filters.time_filter_key || 'custom';
|
||||
let inferredLabel = '';
|
||||
|
||||
if (filters.recent_days) {
|
||||
if (filters.time_filter_key === 'current-trading-day') {
|
||||
inferredKey = 'current-trading-day';
|
||||
inferredLabel = '当前交易日';
|
||||
} else if (filters.time_filter_key === 'all') {
|
||||
inferredKey = 'all';
|
||||
inferredLabel = '全部';
|
||||
} else if (filters.recent_days) {
|
||||
if (filters.recent_days === '7') {
|
||||
inferredKey = 'week';
|
||||
inferredLabel = '近一周';
|
||||
@@ -377,7 +384,12 @@ const CompactSearchBox = ({
|
||||
const { range, type, label, key } = timeConfig;
|
||||
let params = {};
|
||||
|
||||
if (type === 'recent_days') {
|
||||
if (type === 'all') {
|
||||
// "全部"按钮:清除所有时间限制
|
||||
params.start_date = '';
|
||||
params.end_date = '';
|
||||
params.recent_days = '';
|
||||
} else if (type === 'recent_days') {
|
||||
params.recent_days = range;
|
||||
params.start_date = '';
|
||||
params.end_date = '';
|
||||
@@ -524,90 +536,92 @@ const CompactSearchBox = ({
|
||||
</div>
|
||||
</Flex>
|
||||
|
||||
{/* 第二行:筛选条件 */}
|
||||
<Flex justify="space-between" align="center">
|
||||
{/* 左侧筛选 */}
|
||||
<Space size={isMobile ? 4 : 8}>
|
||||
{/* 行业筛选 */}
|
||||
<Cascader
|
||||
value={industryValue}
|
||||
onChange={handleIndustryChange}
|
||||
onFocus={handleCascaderFocus}
|
||||
options={industryData || []}
|
||||
placeholder={
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<FilterOutlined style={{ fontSize: 12 }} />
|
||||
{isMobile ? '行业' : '行业筛选'}
|
||||
</span>
|
||||
}
|
||||
changeOnSelect
|
||||
showSearch={{
|
||||
filter: (inputValue, path) =>
|
||||
path.some(option =>
|
||||
option.label.toLowerCase().includes(inputValue.toLowerCase())
|
||||
)
|
||||
}}
|
||||
allowClear
|
||||
expandTrigger="hover"
|
||||
displayRender={(labels) => labels[labels.length - 1] || (isMobile ? '行业' : '行业筛选')}
|
||||
disabled={industryLoading}
|
||||
style={{ minWidth: isMobile ? 70 : 80 }}
|
||||
suffixIcon={null}
|
||||
className="transparent-cascader"
|
||||
/>
|
||||
|
||||
{/* 事件等级 */}
|
||||
<AntSelect
|
||||
mode="multiple"
|
||||
value={importance}
|
||||
onChange={handleImportanceChange}
|
||||
style={{ minWidth: isMobile ? 100 : 120 }}
|
||||
placeholder={
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<ThunderboltOutlined style={{ fontSize: 12 }} />
|
||||
{isMobile ? '等级' : '事件等级'}
|
||||
</span>
|
||||
}
|
||||
maxTagCount={0}
|
||||
maxTagPlaceholder={(omittedValues) => isMobile ? `${omittedValues.length}项` : `已选 ${omittedValues.length} 项`}
|
||||
className="bracket-select"
|
||||
>
|
||||
{IMPORTANCE_OPTIONS.map(opt => (
|
||||
<Option key={opt.value} value={opt.value}>{opt.label}</Option>
|
||||
))}
|
||||
</AntSelect>
|
||||
</Space>
|
||||
|
||||
{/* 右侧排序和重置 */}
|
||||
<Space size={isMobile ? 4 : 8}>
|
||||
{/* 排序 */}
|
||||
<AntSelect
|
||||
value={sort}
|
||||
onChange={handleSortChange}
|
||||
style={{ minWidth: isMobile ? 55 : 120 }}
|
||||
className="bracket-select"
|
||||
>
|
||||
{SORT_OPTIONS.map(opt => (
|
||||
<Option key={opt.value} value={opt.value}>
|
||||
{/* 第二行:筛选条件 - 主线模式下隐藏(主线模式有自己的筛选器) */}
|
||||
{mode !== 'mainline' && (
|
||||
<Flex justify="space-between" align="center">
|
||||
{/* 左侧筛选 */}
|
||||
<Space size={isMobile ? 4 : 8}>
|
||||
{/* 行业筛选 */}
|
||||
<Cascader
|
||||
value={industryValue}
|
||||
onChange={handleIndustryChange}
|
||||
onFocus={handleCascaderFocus}
|
||||
options={industryData || []}
|
||||
placeholder={
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<SortAscendingOutlined style={{ fontSize: 12 }} />
|
||||
{isMobile ? opt.mobileLabel : opt.label}
|
||||
<FilterOutlined style={{ fontSize: 12 }} />
|
||||
{isMobile ? '行业' : '行业筛选'}
|
||||
</span>
|
||||
</Option>
|
||||
))}
|
||||
</AntSelect>
|
||||
}
|
||||
changeOnSelect
|
||||
showSearch={{
|
||||
filter: (inputValue, path) =>
|
||||
path.some(option =>
|
||||
option.label.toLowerCase().includes(inputValue.toLowerCase())
|
||||
)
|
||||
}}
|
||||
allowClear
|
||||
expandTrigger="hover"
|
||||
displayRender={(labels) => labels[labels.length - 1] || (isMobile ? '行业' : '行业筛选')}
|
||||
disabled={industryLoading}
|
||||
style={{ minWidth: isMobile ? 70 : 80 }}
|
||||
suffixIcon={null}
|
||||
className="transparent-cascader"
|
||||
/>
|
||||
|
||||
{/* 重置按钮 */}
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={handleReset}
|
||||
type="text"
|
||||
style={{ color: PROFESSIONAL_COLORS.text.secondary }}
|
||||
>
|
||||
{!isMobile && '重置筛选'}
|
||||
</Button>
|
||||
</Space>
|
||||
</Flex>
|
||||
{/* 事件等级 */}
|
||||
<AntSelect
|
||||
mode="multiple"
|
||||
value={importance}
|
||||
onChange={handleImportanceChange}
|
||||
style={{ minWidth: isMobile ? 100 : 120 }}
|
||||
placeholder={
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<ThunderboltOutlined style={{ fontSize: 12 }} />
|
||||
{isMobile ? '等级' : '事件等级'}
|
||||
</span>
|
||||
}
|
||||
maxTagCount={0}
|
||||
maxTagPlaceholder={(omittedValues) => isMobile ? `${omittedValues.length}项` : `已选 ${omittedValues.length} 项`}
|
||||
className="bracket-select"
|
||||
>
|
||||
{IMPORTANCE_OPTIONS.map(opt => (
|
||||
<Option key={opt.value} value={opt.value}>{opt.label}</Option>
|
||||
))}
|
||||
</AntSelect>
|
||||
</Space>
|
||||
|
||||
{/* 右侧排序和重置 */}
|
||||
<Space size={isMobile ? 4 : 8}>
|
||||
{/* 排序 */}
|
||||
<AntSelect
|
||||
value={sort}
|
||||
onChange={handleSortChange}
|
||||
style={{ minWidth: isMobile ? 55 : 120 }}
|
||||
className="bracket-select"
|
||||
>
|
||||
{SORT_OPTIONS.map(opt => (
|
||||
<Option key={opt.value} value={opt.value}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<SortAscendingOutlined style={{ fontSize: 12 }} />
|
||||
{isMobile ? opt.mobileLabel : opt.label}
|
||||
</span>
|
||||
</Option>
|
||||
))}
|
||||
</AntSelect>
|
||||
|
||||
{/* 重置按钮 */}
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={handleReset}
|
||||
type="text"
|
||||
style={{ color: PROFESSIONAL_COLORS.text.secondary }}
|
||||
>
|
||||
{!isMobile && '重置筛选'}
|
||||
</Button>
|
||||
</Space>
|
||||
</Flex>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ import dayjs from 'dayjs';
|
||||
import locale from 'antd/es/date-picker/locale/zh_CN';
|
||||
import { logger } from '@utils/logger';
|
||||
import { PROFESSIONAL_COLORS } from '@constants/professionalTheme';
|
||||
import tradingDayUtils from '@utils/tradingDayUtils';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
@@ -83,28 +84,10 @@ const TradingTimeFilter = ({ value, onChange, compact = false, mobile = false })
|
||||
const yesterdayEnd = now.subtract(1, 'day').endOf('day');
|
||||
|
||||
// 动态按钮配置(根据时段返回不同按钮数组)
|
||||
// 注意:"当前交易日"已在固定按钮中,这里只放特定时段的快捷按钮
|
||||
const dynamicButtonsMap = {
|
||||
'pre-market': [
|
||||
{
|
||||
key: 'latest',
|
||||
label: '最新',
|
||||
range: [yesterday1500, today0930],
|
||||
tooltip: '盘前资讯',
|
||||
timeHint: `昨日 15:00 - 今日 09:30`,
|
||||
color: 'purple',
|
||||
type: 'precise'
|
||||
}
|
||||
],
|
||||
'pre-market': [], // 盘前:使用"当前交易日"即可
|
||||
'morning': [
|
||||
{
|
||||
key: 'latest',
|
||||
label: '最新',
|
||||
range: [today0930, now],
|
||||
tooltip: '早盘最新',
|
||||
timeHint: `今日 09:30 - ${now.format('HH:mm')}`,
|
||||
color: 'green',
|
||||
type: 'precise'
|
||||
},
|
||||
{
|
||||
key: 'intraday',
|
||||
label: '盘中',
|
||||
@@ -115,27 +98,8 @@ const TradingTimeFilter = ({ value, onChange, compact = false, mobile = false })
|
||||
type: 'precise'
|
||||
}
|
||||
],
|
||||
'lunch': [
|
||||
{
|
||||
key: 'latest',
|
||||
label: '最新',
|
||||
range: [today1130, now],
|
||||
tooltip: '午休时段',
|
||||
timeHint: `今日 11:30 - ${now.format('HH:mm')}`,
|
||||
color: 'orange',
|
||||
type: 'precise'
|
||||
}
|
||||
],
|
||||
'lunch': [], // 午休:使用"当前交易日"即可
|
||||
'afternoon': [
|
||||
{
|
||||
key: 'latest',
|
||||
label: '最新',
|
||||
range: [today1300, now],
|
||||
tooltip: '午盘最新',
|
||||
timeHint: `今日 13:00 - ${now.format('HH:mm')}`,
|
||||
color: 'green',
|
||||
type: 'precise'
|
||||
},
|
||||
{
|
||||
key: 'intraday',
|
||||
label: '盘中',
|
||||
@@ -155,21 +119,35 @@ const TradingTimeFilter = ({ value, onChange, compact = false, mobile = false })
|
||||
type: 'precise'
|
||||
}
|
||||
],
|
||||
'after-hours': [
|
||||
{
|
||||
key: 'latest',
|
||||
label: '最新',
|
||||
range: [today1500, now],
|
||||
tooltip: '盘后最新',
|
||||
timeHint: `今日 15:00 - ${now.format('HH:mm')}`,
|
||||
color: 'red',
|
||||
type: 'precise'
|
||||
}
|
||||
]
|
||||
'after-hours': [] // 盘后:使用"当前交易日"即可
|
||||
};
|
||||
|
||||
// 获取上一个交易日(使用 tdays.csv 数据)
|
||||
const getPrevTradingDay = () => {
|
||||
try {
|
||||
const prevTradingDay = tradingDayUtils.getPreviousTradingDay(now.toDate());
|
||||
return dayjs(prevTradingDay);
|
||||
} catch (e) {
|
||||
// 降级:简单地减一天(不考虑周末节假日)
|
||||
logger.warn('TradingTimeFilter', '获取上一交易日失败,降级处理', e);
|
||||
return now.subtract(1, 'day');
|
||||
}
|
||||
};
|
||||
|
||||
const prevTradingDay = getPrevTradingDay();
|
||||
const prevTradingDay1500 = prevTradingDay.hour(15).minute(0).second(0);
|
||||
|
||||
// 固定按钮配置(始终显示)
|
||||
const fixedButtons = [
|
||||
{
|
||||
key: 'current-trading-day',
|
||||
label: '当前交易日',
|
||||
range: [prevTradingDay1500, now],
|
||||
tooltip: '当前交易日事件',
|
||||
timeHint: `${prevTradingDay.format('MM-DD')} 15:00 - 现在`,
|
||||
color: 'green',
|
||||
type: 'precise'
|
||||
},
|
||||
{
|
||||
key: 'morning-fixed',
|
||||
label: '早盘',
|
||||
@@ -214,6 +192,15 @@ const TradingTimeFilter = ({ value, onChange, compact = false, mobile = false })
|
||||
timeHint: '过去30天',
|
||||
color: 'volcano',
|
||||
type: 'recent_days'
|
||||
},
|
||||
{
|
||||
key: 'all',
|
||||
label: '全部',
|
||||
range: null, // 无时间限制
|
||||
tooltip: '显示全部事件',
|
||||
timeHint: '不限时间',
|
||||
color: 'default',
|
||||
type: 'all'
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ import { logger } from '../../../utils/logger';
|
||||
import { usePostHogTrack } from '../../../hooks/usePostHogRedux';
|
||||
import { RETENTION_EVENTS } from '../../../lib/constants';
|
||||
import { getEventDetailUrl } from '@/utils/idEncoder';
|
||||
import tradingDayUtils from '@utils/tradingDayUtils';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
/**
|
||||
* 事件筛选逻辑 Hook
|
||||
@@ -22,16 +24,43 @@ export const useEventFilters = ({ navigate, onEventClick, eventTimelineRef } = {
|
||||
|
||||
// 筛选参数状态 - 初始化时从URL读取,之后只用本地状态
|
||||
const [filters, setFilters] = useState(() => {
|
||||
// 计算当前交易日的默认时间范围
|
||||
const getDefaultTimeRange = () => {
|
||||
try {
|
||||
const now = dayjs();
|
||||
const prevTradingDay = tradingDayUtils.getPreviousTradingDay(now.toDate());
|
||||
const prevTradingDay1500 = dayjs(prevTradingDay).hour(15).minute(0).second(0);
|
||||
return {
|
||||
start_date: prevTradingDay1500.format('YYYY-MM-DD HH:mm:ss'),
|
||||
end_date: now.format('YYYY-MM-DD HH:mm:ss'),
|
||||
recent_days: '', // 使用精确时间范围,不使用 recent_days
|
||||
time_filter_key: 'current-trading-day' // 标记当前选中的时间按钮
|
||||
};
|
||||
} catch (e) {
|
||||
// 降级:使用近一周
|
||||
logger.warn('useEventFilters', '获取上一交易日失败,降级为近一周', e);
|
||||
return {
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
recent_days: '7',
|
||||
time_filter_key: 'week'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const defaultTimeRange = getDefaultTimeRange();
|
||||
|
||||
return {
|
||||
sort: searchParams.get('sort') || 'new',
|
||||
importance: searchParams.get('importance') || 'all',
|
||||
q: searchParams.get('q') || '',
|
||||
industry_code: searchParams.get('industry_code') || '',
|
||||
// 时间筛选参数(从 TradingTimeFilter 传递)
|
||||
// 默认显示近一周数据(recent_days=7)
|
||||
start_date: searchParams.get('start_date') || '',
|
||||
end_date: searchParams.get('end_date') || '',
|
||||
recent_days: searchParams.get('recent_days') || '7', // 默认近一周
|
||||
// 默认显示当前交易日数据(上一交易日15:00 - 现在)
|
||||
start_date: searchParams.get('start_date') || defaultTimeRange.start_date,
|
||||
end_date: searchParams.get('end_date') || defaultTimeRange.end_date,
|
||||
recent_days: searchParams.get('recent_days') || defaultTimeRange.recent_days,
|
||||
time_filter_key: searchParams.get('time_filter_key') || defaultTimeRange.time_filter_key,
|
||||
page: parseInt(searchParams.get('page') || '1', 10)
|
||||
};
|
||||
});
|
||||
|
||||
@@ -49,7 +49,7 @@ const AnnouncementsPanel: React.FC<AnnouncementsPanelProps> = ({ stockCode, isAc
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <LoadingState message="加载公告数据..." />;
|
||||
return <LoadingState variant="skeleton" skeletonType="list" skeletonCount={5} />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -27,7 +27,7 @@ const DisclosureSchedulePanel: React.FC<DisclosureSchedulePanelProps> = ({ stock
|
||||
const { disclosureSchedule, loading } = useDisclosureData({ stockCode, enabled: isActive });
|
||||
|
||||
if (loading) {
|
||||
return <LoadingState message="加载披露日程..." />;
|
||||
return <LoadingState variant="skeleton" skeletonType="grid" skeletonCount={4} />;
|
||||
}
|
||||
|
||||
if (disclosureSchedule.length === 0) {
|
||||
|
||||
@@ -1,22 +1,110 @@
|
||||
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/LoadingState.tsx
|
||||
// 复用的加载状态组件
|
||||
// 复用的加载状态组件 - 支持骨架屏
|
||||
|
||||
import React from "react";
|
||||
import { Center, VStack, Spinner, Text } from "@chakra-ui/react";
|
||||
import React, { memo } from "react";
|
||||
import {
|
||||
Center,
|
||||
VStack,
|
||||
Spinner,
|
||||
Text,
|
||||
Box,
|
||||
Skeleton,
|
||||
SimpleGrid,
|
||||
HStack,
|
||||
} from "@chakra-ui/react";
|
||||
import { THEME } from "../config";
|
||||
|
||||
// 骨架屏颜色配置
|
||||
const SKELETON_COLORS = {
|
||||
startColor: "rgba(26, 32, 44, 0.6)",
|
||||
endColor: "rgba(212, 175, 55, 0.2)",
|
||||
};
|
||||
|
||||
interface LoadingStateProps {
|
||||
message?: string;
|
||||
height?: string;
|
||||
/** 使用骨架屏模式(更好的视觉体验) */
|
||||
variant?: "spinner" | "skeleton";
|
||||
/** 骨架屏类型:grid(网格布局)或 list(列表布局) */
|
||||
skeletonType?: "grid" | "list";
|
||||
/** 骨架屏项目数量 */
|
||||
skeletonCount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载状态组件(黑金主题)
|
||||
* 网格骨架屏(用于披露日程等)
|
||||
*/
|
||||
const LoadingState: React.FC<LoadingStateProps> = ({
|
||||
const GridSkeleton: React.FC<{ count: number }> = memo(({ count }) => (
|
||||
<SimpleGrid columns={{ base: 2, md: 4 }} spacing={3}>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
height="80px"
|
||||
borderRadius="md"
|
||||
{...SKELETON_COLORS}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
));
|
||||
|
||||
GridSkeleton.displayName = "GridSkeleton";
|
||||
|
||||
/**
|
||||
* 列表骨架屏(用于公告列表等)
|
||||
*/
|
||||
const ListSkeleton: React.FC<{ count: number }> = memo(({ count }) => (
|
||||
<VStack spacing={2} align="stretch">
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<Box
|
||||
key={i}
|
||||
p={3}
|
||||
borderRadius="md"
|
||||
bg="rgba(26, 32, 44, 0.4)"
|
||||
border="1px solid"
|
||||
borderColor="rgba(212, 175, 55, 0.2)"
|
||||
>
|
||||
<HStack justify="space-between">
|
||||
<VStack align="start" spacing={2} flex={1}>
|
||||
<HStack>
|
||||
<Skeleton height="20px" width="60px" borderRadius="sm" {...SKELETON_COLORS} />
|
||||
<Skeleton height="16px" width="80px" borderRadius="sm" {...SKELETON_COLORS} />
|
||||
</HStack>
|
||||
<Skeleton height="18px" width="90%" borderRadius="sm" {...SKELETON_COLORS} />
|
||||
</VStack>
|
||||
<Skeleton height="32px" width="32px" borderRadius="md" {...SKELETON_COLORS} />
|
||||
</HStack>
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
));
|
||||
|
||||
ListSkeleton.displayName = "ListSkeleton";
|
||||
|
||||
/**
|
||||
* 加载状态组件(黑金主题)
|
||||
*
|
||||
* @param variant - "spinner"(默认)或 "skeleton"(骨架屏)
|
||||
* @param skeletonType - 骨架屏类型:"grid" 或 "list"
|
||||
*/
|
||||
const LoadingState: React.FC<LoadingStateProps> = memo(({
|
||||
message = "加载中...",
|
||||
height = "200px",
|
||||
variant = "spinner",
|
||||
skeletonType = "list",
|
||||
skeletonCount = 4,
|
||||
}) => {
|
||||
if (variant === "skeleton") {
|
||||
return (
|
||||
<Box minH={height} p={4}>
|
||||
{skeletonType === "grid" ? (
|
||||
<GridSkeleton count={skeletonCount} />
|
||||
) : (
|
||||
<ListSkeleton count={skeletonCount} />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Center h={height}>
|
||||
<VStack>
|
||||
@@ -27,6 +115,8 @@ const LoadingState: React.FC<LoadingStateProps> = ({
|
||||
</VStack>
|
||||
</Center>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
LoadingState.displayName = "LoadingState";
|
||||
|
||||
export default LoadingState;
|
||||
|
||||
@@ -77,7 +77,7 @@ const DeepAnalysisTab: React.FC<DeepAnalysisTabProps> = ({
|
||||
themePreset="blackGold"
|
||||
size="sm"
|
||||
/>
|
||||
<LoadingState message="加载数据中..." height="200px" />
|
||||
<LoadingState variant="skeleton" height="300px" skeletonRows={6} />
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/views/Company/components/CompanyOverview/hooks/useAnnouncementsData.ts
|
||||
// 公告数据 Hook - 用于公司公告 Tab
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { logger } from "@utils/logger";
|
||||
import axios from "@utils/axiosConfig";
|
||||
import type { Announcement } from "../types";
|
||||
@@ -39,7 +39,11 @@ export const useAnnouncementsData = (options: UseAnnouncementsDataOptions): UseA
|
||||
const [announcements, setAnnouncements] = useState<Announcement[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [hasLoaded, setHasLoaded] = useState(false);
|
||||
// 使用 ref 跟踪是否已加载,避免 Tab 切换时重复请求
|
||||
const hasLoadedRef = useRef(false);
|
||||
// 记录上次加载的 stockCode 和 refreshKey
|
||||
const lastStockCodeRef = useRef<string | undefined>(undefined);
|
||||
const lastRefreshKeyRef = useRef<number | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
// 只有 enabled 且有 stockCode 时才请求
|
||||
@@ -48,6 +52,26 @@ export const useAnnouncementsData = (options: UseAnnouncementsDataOptions): UseA
|
||||
return;
|
||||
}
|
||||
|
||||
// stockCode 或 refreshKey 变化时重置加载状态
|
||||
if (lastStockCodeRef.current !== stockCode || lastRefreshKeyRef.current !== refreshKey) {
|
||||
// refreshKey 变化时强制重新加载
|
||||
if (lastRefreshKeyRef.current !== refreshKey && lastRefreshKeyRef.current !== undefined) {
|
||||
hasLoadedRef.current = false;
|
||||
}
|
||||
// stockCode 变化时重置
|
||||
if (lastStockCodeRef.current !== stockCode) {
|
||||
hasLoadedRef.current = false;
|
||||
}
|
||||
lastStockCodeRef.current = stockCode;
|
||||
lastRefreshKeyRef.current = refreshKey;
|
||||
}
|
||||
|
||||
// 如果已经加载过数据,不再重新请求(Tab 切换回来时保持缓存)
|
||||
if (hasLoadedRef.current) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
const loadData = async () => {
|
||||
@@ -66,7 +90,7 @@ export const useAnnouncementsData = (options: UseAnnouncementsDataOptions): UseA
|
||||
setError("加载公告数据失败");
|
||||
}
|
||||
setLoading(false);
|
||||
setHasLoaded(true);
|
||||
hasLoadedRef.current = true;
|
||||
} catch (err: any) {
|
||||
// 请求被取消时,不更新任何状态
|
||||
if (err.name === "CanceledError") {
|
||||
@@ -75,7 +99,7 @@ export const useAnnouncementsData = (options: UseAnnouncementsDataOptions): UseA
|
||||
logger.error("useAnnouncementsData", "loadData", err, { stockCode });
|
||||
setError("网络请求失败");
|
||||
setLoading(false);
|
||||
setHasLoaded(true);
|
||||
hasLoadedRef.current = true;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -83,7 +107,7 @@ export const useAnnouncementsData = (options: UseAnnouncementsDataOptions): UseA
|
||||
return () => controller.abort();
|
||||
}, [stockCode, enabled, refreshKey]);
|
||||
|
||||
const isLoading = loading || (enabled && !hasLoaded && !error);
|
||||
const isLoading = loading || (enabled && !hasLoadedRef.current && !error);
|
||||
|
||||
return { announcements, loading: isLoading, error };
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/views/Company/components/CompanyOverview/hooks/useBranchesData.ts
|
||||
// 分支机构数据 Hook - 用于分支机构 Tab
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { logger } from "@utils/logger";
|
||||
import axios from "@utils/axiosConfig";
|
||||
import type { Branch } from "../types";
|
||||
@@ -36,7 +36,10 @@ export const useBranchesData = (options: UseBranchesDataOptions): UseBranchesDat
|
||||
const [branches, setBranches] = useState<Branch[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [hasLoaded, setHasLoaded] = useState(false);
|
||||
// 使用 ref 跟踪是否已加载,避免 Tab 切换时重复请求
|
||||
const hasLoadedRef = useRef(false);
|
||||
// 记录上次加载的 stockCode,stockCode 变化时需要重新加载
|
||||
const lastStockCodeRef = useRef<string | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !stockCode) {
|
||||
@@ -44,6 +47,18 @@ export const useBranchesData = (options: UseBranchesDataOptions): UseBranchesDat
|
||||
return;
|
||||
}
|
||||
|
||||
// stockCode 变化时重置加载状态
|
||||
if (lastStockCodeRef.current !== stockCode) {
|
||||
hasLoadedRef.current = false;
|
||||
lastStockCodeRef.current = stockCode;
|
||||
}
|
||||
|
||||
// 如果已经加载过数据,不再重新请求(Tab 切换回来时保持缓存)
|
||||
if (hasLoadedRef.current) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
const loadData = async () => {
|
||||
@@ -62,7 +77,7 @@ export const useBranchesData = (options: UseBranchesDataOptions): UseBranchesDat
|
||||
setError("加载分支机构数据失败");
|
||||
}
|
||||
setLoading(false);
|
||||
setHasLoaded(true);
|
||||
hasLoadedRef.current = true;
|
||||
} catch (err: any) {
|
||||
// 请求被取消时,不更新任何状态
|
||||
if (err.name === "CanceledError") {
|
||||
@@ -71,7 +86,7 @@ export const useBranchesData = (options: UseBranchesDataOptions): UseBranchesDat
|
||||
logger.error("useBranchesData", "loadData", err, { stockCode });
|
||||
setError("网络请求失败");
|
||||
setLoading(false);
|
||||
setHasLoaded(true);
|
||||
hasLoadedRef.current = true;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -79,7 +94,7 @@ export const useBranchesData = (options: UseBranchesDataOptions): UseBranchesDat
|
||||
return () => controller.abort();
|
||||
}, [stockCode, enabled]);
|
||||
|
||||
const isLoading = loading || (enabled && !hasLoaded && !error);
|
||||
const isLoading = loading || (enabled && !hasLoadedRef.current && !error);
|
||||
|
||||
return { branches, loading: isLoading, error };
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/views/Company/components/CompanyOverview/hooks/useDisclosureData.ts
|
||||
// 披露日程数据 Hook - 用于工商信息 Tab
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { logger } from "@utils/logger";
|
||||
import axios from "@utils/axiosConfig";
|
||||
import type { DisclosureSchedule } from "../types";
|
||||
@@ -36,7 +36,10 @@ export const useDisclosureData = (options: UseDisclosureDataOptions): UseDisclos
|
||||
const [disclosureSchedule, setDisclosureSchedule] = useState<DisclosureSchedule[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [hasLoaded, setHasLoaded] = useState(false);
|
||||
// 使用 ref 跟踪是否已加载,避免 Tab 切换时重复请求
|
||||
const hasLoadedRef = useRef(false);
|
||||
// 记录上次加载的 stockCode,stockCode 变化时需要重新加载
|
||||
const lastStockCodeRef = useRef<string | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
// 只有 enabled 且有 stockCode 时才请求
|
||||
@@ -45,6 +48,18 @@ export const useDisclosureData = (options: UseDisclosureDataOptions): UseDisclos
|
||||
return;
|
||||
}
|
||||
|
||||
// stockCode 变化时重置加载状态
|
||||
if (lastStockCodeRef.current !== stockCode) {
|
||||
hasLoadedRef.current = false;
|
||||
lastStockCodeRef.current = stockCode;
|
||||
}
|
||||
|
||||
// 如果已经加载过数据,不再重新请求(Tab 切换回来时保持缓存)
|
||||
if (hasLoadedRef.current) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
const loadData = async () => {
|
||||
@@ -63,7 +78,7 @@ export const useDisclosureData = (options: UseDisclosureDataOptions): UseDisclos
|
||||
setError("加载披露日程数据失败");
|
||||
}
|
||||
setLoading(false);
|
||||
setHasLoaded(true);
|
||||
hasLoadedRef.current = true;
|
||||
} catch (err: any) {
|
||||
// 请求被取消时,不更新任何状态
|
||||
if (err.name === "CanceledError") {
|
||||
@@ -72,7 +87,7 @@ export const useDisclosureData = (options: UseDisclosureDataOptions): UseDisclos
|
||||
logger.error("useDisclosureData", "loadData", err, { stockCode });
|
||||
setError("网络请求失败");
|
||||
setLoading(false);
|
||||
setHasLoaded(true);
|
||||
hasLoadedRef.current = true;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -80,7 +95,7 @@ export const useDisclosureData = (options: UseDisclosureDataOptions): UseDisclos
|
||||
return () => controller.abort();
|
||||
}, [stockCode, enabled]);
|
||||
|
||||
const isLoading = loading || (enabled && !hasLoaded && !error);
|
||||
const isLoading = loading || (enabled && !hasLoadedRef.current && !error);
|
||||
|
||||
return { disclosureSchedule, loading: isLoading, error };
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/views/Company/components/CompanyOverview/hooks/useManagementData.ts
|
||||
// 管理团队数据 Hook - 用于管理团队 Tab
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { logger } from "@utils/logger";
|
||||
import axios from "@utils/axiosConfig";
|
||||
import type { Management } from "../types";
|
||||
@@ -36,7 +36,10 @@ export const useManagementData = (options: UseManagementDataOptions): UseManagem
|
||||
const [management, setManagement] = useState<Management[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [hasLoaded, setHasLoaded] = useState(false);
|
||||
// 使用 ref 跟踪是否已加载,避免 Tab 切换时重复请求
|
||||
const hasLoadedRef = useRef(false);
|
||||
// 记录上次加载的 stockCode,stockCode 变化时需要重新加载
|
||||
const lastStockCodeRef = useRef<string | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
// 只有 enabled 且有 stockCode 时才请求
|
||||
@@ -45,6 +48,18 @@ export const useManagementData = (options: UseManagementDataOptions): UseManagem
|
||||
return;
|
||||
}
|
||||
|
||||
// stockCode 变化时重置加载状态
|
||||
if (lastStockCodeRef.current !== stockCode) {
|
||||
hasLoadedRef.current = false;
|
||||
lastStockCodeRef.current = stockCode;
|
||||
}
|
||||
|
||||
// 如果已经加载过数据,不再重新请求(Tab 切换回来时保持缓存)
|
||||
if (hasLoadedRef.current) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
const loadData = async () => {
|
||||
@@ -63,7 +78,7 @@ export const useManagementData = (options: UseManagementDataOptions): UseManagem
|
||||
setError("加载管理团队数据失败");
|
||||
}
|
||||
setLoading(false);
|
||||
setHasLoaded(true);
|
||||
hasLoadedRef.current = true;
|
||||
} catch (err: any) {
|
||||
// 请求被取消时,不更新任何状态
|
||||
if (err.name === "CanceledError") {
|
||||
@@ -72,7 +87,7 @@ export const useManagementData = (options: UseManagementDataOptions): UseManagem
|
||||
logger.error("useManagementData", "loadData", err, { stockCode });
|
||||
setError("网络请求失败");
|
||||
setLoading(false);
|
||||
setHasLoaded(true);
|
||||
hasLoadedRef.current = true;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -82,7 +97,7 @@ export const useManagementData = (options: UseManagementDataOptions): UseManagem
|
||||
|
||||
// 派生 loading 状态:enabled 但尚未完成首次加载时,视为 loading
|
||||
// 这样可以在渲染时同步判断,避免 useEffect 异步导致的空状态闪烁
|
||||
const isLoading = loading || (enabled && !hasLoaded && !error);
|
||||
const isLoading = loading || (enabled && !hasLoadedRef.current && !error);
|
||||
|
||||
return { management, loading: isLoading, error };
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/views/Company/components/CompanyOverview/hooks/useShareholderData.ts
|
||||
// 股权结构数据 Hook - 用于股权结构 Tab
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { logger } from "@utils/logger";
|
||||
import axios from "@utils/axiosConfig";
|
||||
import type { ActualControl, Concentration, Shareholder } from "../types";
|
||||
@@ -42,7 +42,10 @@ export const useShareholderData = (options: UseShareholderDataOptions): UseShare
|
||||
const [topCirculationShareholders, setTopCirculationShareholders] = useState<Shareholder[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [hasLoaded, setHasLoaded] = useState(false);
|
||||
// 使用 ref 跟踪是否已加载,避免 Tab 切换时重复请求
|
||||
const hasLoadedRef = useRef(false);
|
||||
// 记录上次加载的 stockCode,stockCode 变化时需要重新加载
|
||||
const lastStockCodeRef = useRef<string | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
// 只有 enabled 且有 stockCode 时才请求
|
||||
@@ -51,6 +54,18 @@ export const useShareholderData = (options: UseShareholderDataOptions): UseShare
|
||||
return;
|
||||
}
|
||||
|
||||
// stockCode 变化时重置加载状态
|
||||
if (lastStockCodeRef.current !== stockCode) {
|
||||
hasLoadedRef.current = false;
|
||||
lastStockCodeRef.current = stockCode;
|
||||
}
|
||||
|
||||
// 如果已经加载过数据,不再重新请求(Tab 切换回来时保持缓存)
|
||||
if (hasLoadedRef.current) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
const loadData = async () => {
|
||||
@@ -75,7 +90,7 @@ export const useShareholderData = (options: UseShareholderDataOptions): UseShare
|
||||
if (shareholdersRes.success) setTopShareholders(shareholdersRes.data);
|
||||
if (circulationRes.success) setTopCirculationShareholders(circulationRes.data);
|
||||
setLoading(false);
|
||||
setHasLoaded(true);
|
||||
hasLoadedRef.current = true;
|
||||
} catch (err: any) {
|
||||
// 请求被取消时,不更新任何状态
|
||||
if (err.name === "CanceledError") {
|
||||
@@ -84,7 +99,7 @@ export const useShareholderData = (options: UseShareholderDataOptions): UseShare
|
||||
logger.error("useShareholderData", "loadData", err, { stockCode });
|
||||
setError("加载股权结构数据失败");
|
||||
setLoading(false);
|
||||
setHasLoaded(true);
|
||||
hasLoadedRef.current = true;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -92,7 +107,7 @@ export const useShareholderData = (options: UseShareholderDataOptions): UseShare
|
||||
return () => controller.abort();
|
||||
}, [stockCode, enabled]);
|
||||
|
||||
const isLoading = loading || (enabled && !hasLoaded && !error);
|
||||
const isLoading = loading || (enabled && !hasLoadedRef.current && !error);
|
||||
|
||||
return {
|
||||
actualControl,
|
||||
|
||||
@@ -136,5 +136,5 @@ const MarketDataSkeleton: React.FC = memo(() => (
|
||||
|
||||
MarketDataSkeleton.displayName = 'MarketDataSkeleton';
|
||||
|
||||
export { MarketDataSkeleton };
|
||||
export { MarketDataSkeleton, SummaryCardSkeleton };
|
||||
export default MarketDataSkeleton;
|
||||
|
||||
@@ -5,4 +5,4 @@ export { default as ThemedCard } from './ThemedCard';
|
||||
export { default as MarkdownRenderer } from './MarkdownRenderer';
|
||||
export { default as StockSummaryCard } from './StockSummaryCard';
|
||||
export { default as AnalysisModal, AnalysisContent } from './AnalysisModal';
|
||||
export { MarketDataSkeleton } from './MarketDataSkeleton';
|
||||
export { MarketDataSkeleton, SummaryCardSkeleton } from './MarketDataSkeleton';
|
||||
|
||||
@@ -33,8 +33,9 @@ export const useMarketData = (
|
||||
period: number = DEFAULT_PERIOD
|
||||
): UseMarketDataReturn => {
|
||||
// 主数据状态
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [tradeLoading, setTradeLoading] = useState(false);
|
||||
const [hasLoaded, setHasLoaded] = useState(false);
|
||||
const [summary, setSummary] = useState<MarketSummary | null>(null);
|
||||
const [tradeData, setTradeData] = useState<TradeDayData[]>([]);
|
||||
const [fundingData, setFundingData] = useState<FundingDayData[]>([]);
|
||||
@@ -153,15 +154,17 @@ export const useMarketData = (
|
||||
if (loadedTradeData.length > 0) {
|
||||
loadRiseAnalysis(loadedTradeData);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
setHasLoaded(true);
|
||||
} catch (error) {
|
||||
// 取消请求不作为错误处理
|
||||
if (isCancelError(error)) return;
|
||||
logger.error('useMarketData', 'loadCoreData', error, { stockCode, period });
|
||||
} finally {
|
||||
// 只有当前请求没有被取消时才设置 loading 状态
|
||||
if (!controller.signal.aborted) {
|
||||
setLoading(false);
|
||||
// 请求被取消时,不更新任何状态
|
||||
if (isCancelError(error)) {
|
||||
return;
|
||||
}
|
||||
logger.error('useMarketData', 'loadCoreData', error, { stockCode, period });
|
||||
setLoading(false);
|
||||
setHasLoaded(true);
|
||||
}
|
||||
}, [stockCode, period, loadRiseAnalysis]);
|
||||
|
||||
@@ -363,8 +366,11 @@ export const useMarketData = (
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 派生 loading 状态:stockCode 存在但尚未完成首次加载时,视为 loading
|
||||
const isLoading = loading || (!!stockCode && !hasLoaded);
|
||||
|
||||
return {
|
||||
loading,
|
||||
loading: isLoading,
|
||||
tradeLoading,
|
||||
summary,
|
||||
tradeData,
|
||||
|
||||
@@ -22,6 +22,7 @@ import { useMarketData } from './hooks/useMarketData';
|
||||
import {
|
||||
ThemedCard,
|
||||
StockSummaryCard,
|
||||
SummaryCardSkeleton,
|
||||
AnalysisModal,
|
||||
AnalysisContent,
|
||||
} from './components';
|
||||
@@ -89,13 +90,12 @@ const MarketDataView: React.FC<MarketDataViewProps> = ({ stockCode: propStockCod
|
||||
}
|
||||
}, [propStockCode, stockCode]);
|
||||
|
||||
// 首次渲染时加载默认 Tab(融资融券)的数据
|
||||
// 首次挂载时加载默认 Tab(融资融券)的数据
|
||||
// 注意:SubTabContainer 的 onChange 只在切换时触发,首次渲染不会触发
|
||||
useEffect(() => {
|
||||
// 默认 Tab 是融资融券(index 0)
|
||||
if (activeTab === 0) {
|
||||
loadDataByType('funding');
|
||||
}
|
||||
}, [loadDataByType, activeTab]);
|
||||
loadDataByType('funding');
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // 只在首次挂载时执行
|
||||
|
||||
// 处理图表点击事件
|
||||
const handleChartClick = useCallback(
|
||||
@@ -137,8 +137,8 @@ const MarketDataView: React.FC<MarketDataViewProps> = ({ stockCode: propStockCod
|
||||
<Box bg={'#1A202C'} minH="100vh" color={theme.textPrimary}>
|
||||
<Container maxW="container.xl" py={4}>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
{/* 股票概览 */}
|
||||
{summary && <StockSummaryCard summary={summary} theme={theme} />}
|
||||
{/* 股票概览 - 未加载时显示骨架屏占位,避免布局跳动 */}
|
||||
{summary ? <StockSummaryCard summary={summary} theme={theme} /> : <SummaryCardSkeleton />}
|
||||
|
||||
{/* 交易数据 - 日K/分钟K线(独立显示在 Tab 上方) */}
|
||||
<TradeDataPanel
|
||||
|
||||
@@ -1,610 +0,0 @@
|
||||
// src/views/Dashboard/Center.js
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { getApiBase } from '../../utils/apiConfig';
|
||||
import { useDashboardEvents } from '../../hooks/useDashboardEvents';
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
Grid,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Badge,
|
||||
Button,
|
||||
VStack,
|
||||
HStack,
|
||||
Card,
|
||||
CardHeader,
|
||||
CardBody,
|
||||
Heading,
|
||||
useColorModeValue,
|
||||
Icon,
|
||||
IconButton,
|
||||
Stat,
|
||||
StatLabel,
|
||||
StatNumber,
|
||||
StatHelpText,
|
||||
StatArrow,
|
||||
Divider,
|
||||
Tag,
|
||||
TagLabel,
|
||||
TagLeftIcon,
|
||||
Wrap,
|
||||
WrapItem,
|
||||
Avatar,
|
||||
Tooltip,
|
||||
Progress,
|
||||
useToast,
|
||||
LinkBox,
|
||||
LinkOverlay,
|
||||
Spinner,
|
||||
Center,
|
||||
Image,
|
||||
} from '@chakra-ui/react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useLocation, useNavigate, Link } from 'react-router-dom';
|
||||
import {
|
||||
FiTrendingUp,
|
||||
FiEye,
|
||||
FiMessageSquare,
|
||||
FiThumbsUp,
|
||||
FiClock,
|
||||
FiCalendar,
|
||||
FiRefreshCw,
|
||||
FiTrash2,
|
||||
FiExternalLink,
|
||||
FiPlus,
|
||||
FiBarChart2,
|
||||
FiStar,
|
||||
FiActivity,
|
||||
FiAlertCircle,
|
||||
FiUsers,
|
||||
} from 'react-icons/fi';
|
||||
import MyFutureEvents from './components/MyFutureEvents';
|
||||
import InvestmentPlanningCenter from './components/InvestmentPlanningCenter';
|
||||
import { getEventDetailUrl } from '@/utils/idEncoder';
|
||||
|
||||
export default function CenterDashboard() {
|
||||
const { user } = useAuth();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
|
||||
// ⚡ 提取 userId 为独立变量
|
||||
const userId = user?.id;
|
||||
|
||||
// 🎯 初始化Dashboard埋点Hook
|
||||
const dashboardEvents = useDashboardEvents({
|
||||
pageType: 'center',
|
||||
navigate
|
||||
});
|
||||
|
||||
// 颜色主题
|
||||
const textColor = useColorModeValue('gray.700', 'white');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
const bgColor = useColorModeValue('white', 'gray.800');
|
||||
const hoverBg = useColorModeValue('gray.50', 'gray.700');
|
||||
const secondaryText = useColorModeValue('gray.600', 'gray.400');
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const sectionBg = useColorModeValue('gray.50', 'gray.900');
|
||||
|
||||
const [watchlist, setWatchlist] = useState([]);
|
||||
const [realtimeQuotes, setRealtimeQuotes] = useState({});
|
||||
const [followingEvents, setFollowingEvents] = useState([]);
|
||||
const [eventComments, setEventComments] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [quotesLoading, setQuotesLoading] = useState(false);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
const base = getApiBase();
|
||||
const ts = Date.now();
|
||||
|
||||
const [w, e, c] = await Promise.all([
|
||||
fetch(base + `/api/account/watchlist?_=${ts}`, { credentials: 'include', cache: 'no-store' }),
|
||||
fetch(base + `/api/account/events/following?_=${ts}`, { credentials: 'include', cache: 'no-store' }),
|
||||
fetch(base + `/api/account/events/posts?_=${ts}`, { credentials: 'include', cache: 'no-store' }),
|
||||
]);
|
||||
|
||||
const jw = await w.json();
|
||||
const je = await e.json();
|
||||
const jc = await c.json();
|
||||
if (jw.success) {
|
||||
const watchlistData = Array.isArray(jw.data) ? jw.data : [];
|
||||
setWatchlist(watchlistData);
|
||||
|
||||
// 🎯 追踪自选股列表查看
|
||||
if (watchlistData.length > 0) {
|
||||
dashboardEvents.trackWatchlistViewed(watchlistData.length, true);
|
||||
}
|
||||
|
||||
// 加载实时行情
|
||||
if (jw.data && jw.data.length > 0) {
|
||||
loadRealtimeQuotes();
|
||||
}
|
||||
}
|
||||
if (je.success) {
|
||||
const eventsData = Array.isArray(je.data) ? je.data : [];
|
||||
setFollowingEvents(eventsData);
|
||||
|
||||
// 🎯 追踪关注的事件列表查看
|
||||
dashboardEvents.trackFollowingEventsViewed(eventsData.length);
|
||||
}
|
||||
if (jc.success) {
|
||||
const commentsData = Array.isArray(jc.data) ? jc.data : [];
|
||||
setEventComments(commentsData);
|
||||
|
||||
// 🎯 追踪评论列表查看
|
||||
dashboardEvents.trackCommentsViewed(commentsData.length);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Center', 'loadData', err, {
|
||||
userId,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [userId]); // ⚡ 使用 userId 而不是 user?.id
|
||||
|
||||
// 加载实时行情
|
||||
const loadRealtimeQuotes = useCallback(async () => {
|
||||
try {
|
||||
setQuotesLoading(true);
|
||||
const base = getApiBase();
|
||||
const response = await fetch(base + '/api/account/watchlist/realtime', {
|
||||
credentials: 'include',
|
||||
cache: 'no-store'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
const quotesMap = {};
|
||||
data.data.forEach(item => {
|
||||
quotesMap[item.stock_code] = item;
|
||||
});
|
||||
setRealtimeQuotes(quotesMap);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Center', 'loadRealtimeQuotes', error, {
|
||||
userId: user?.id,
|
||||
watchlistLength: watchlist.length
|
||||
});
|
||||
} finally {
|
||||
setQuotesLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return '';
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffTime = Math.abs(now - date);
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays < 1) {
|
||||
const diffHours = Math.ceil(diffTime / (1000 * 60 * 60));
|
||||
if (diffHours < 1) {
|
||||
const diffMinutes = Math.ceil(diffTime / (1000 * 60));
|
||||
return `${diffMinutes}分钟前`;
|
||||
}
|
||||
return `${diffHours}小时前`;
|
||||
} else if (diffDays < 7) {
|
||||
return `${diffDays}天前`;
|
||||
} else {
|
||||
return date.toLocaleDateString('zh-CN');
|
||||
}
|
||||
};
|
||||
|
||||
// 格式化数字
|
||||
const formatNumber = (num) => {
|
||||
if (!num) return '0';
|
||||
if (num >= 10000) {
|
||||
return (num / 10000).toFixed(1) + 'w';
|
||||
} else if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1) + 'k';
|
||||
}
|
||||
return num.toString();
|
||||
};
|
||||
|
||||
// 获取事件热度颜色
|
||||
const getHeatColor = (score) => {
|
||||
if (score >= 80) return 'red';
|
||||
if (score >= 60) return 'orange';
|
||||
if (score >= 40) return 'yellow';
|
||||
return 'green';
|
||||
};
|
||||
|
||||
// 🔧 使用 ref 跟踪是否已经加载过数据(首次加载标记)
|
||||
const hasLoadedRef = React.useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const isOnCenterPage = location.pathname.includes('/home/center');
|
||||
|
||||
// 首次进入页面且有用户时加载数据
|
||||
if (user && isOnCenterPage && !hasLoadedRef.current) {
|
||||
console.log('[Center] 🚀 首次加载数据');
|
||||
hasLoadedRef.current = true;
|
||||
loadData();
|
||||
}
|
||||
|
||||
const onVis = () => {
|
||||
if (document.visibilityState === 'visible' && location.pathname.includes('/home/center')) {
|
||||
console.log('[Center] 👁️ visibilitychange 触发 loadData');
|
||||
loadData();
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', onVis);
|
||||
return () => document.removeEventListener('visibilitychange', onVis);
|
||||
}, [userId, location.pathname, loadData, user]);
|
||||
|
||||
// 当用户登出再登入(userId 变化)时,重置加载标记
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
hasLoadedRef.current = false;
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
// 定时刷新实时行情(每分钟一次)
|
||||
useEffect(() => {
|
||||
if (watchlist.length > 0) {
|
||||
const interval = setInterval(() => {
|
||||
loadRealtimeQuotes();
|
||||
}, 60000); // 60秒刷新一次
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [watchlist.length, loadRealtimeQuotes]);
|
||||
|
||||
// 渲染加载状态
|
||||
if (loading) {
|
||||
return (
|
||||
<Center h="60vh">
|
||||
<VStack spacing={4}>
|
||||
<Spinner size="xl" color="blue.500" thickness="4px" />
|
||||
<Text color={secondaryText}>加载个人中心数据...</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box bg={sectionBg} minH="100vh" overflowX="hidden">
|
||||
<Box px={{ base: 3, md: 8 }} py={{ base: 4, md: 6 }} maxW="1400px" mx="auto">
|
||||
{/* 主要内容区域 */}
|
||||
<Grid templateColumns={{ base: '1fr', lg: 'repeat(3, 1fr)' }} gap={6} mb={8}>
|
||||
{/* 左列:自选股票 */}
|
||||
<VStack spacing={6} align="stretch" minW={0}>
|
||||
<Card bg={cardBg} shadow="md" maxW="100%" height={{ base: 'auto', md: '600px' }} minH={{ base: '300px', md: '600px' }} display="flex" flexDirection="column">
|
||||
<CardHeader pb={{ base: 2, md: 4 }}>
|
||||
<Flex justify="space-between" align="center">
|
||||
<HStack>
|
||||
<Icon as={FiBarChart2} color="blue.500" boxSize={{ base: 4, md: 5 }} />
|
||||
<Heading size={{ base: 'sm', md: 'md' }}>自选股票</Heading>
|
||||
<Badge colorScheme="blue" variant="subtle">
|
||||
{watchlist.length}
|
||||
</Badge>
|
||||
{quotesLoading && <Spinner size="sm" color="blue.500" />}
|
||||
</HStack>
|
||||
<IconButton
|
||||
icon={<FiPlus />}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigate('/stocks')}
|
||||
aria-label="添加自选股"
|
||||
/>
|
||||
</Flex>
|
||||
</CardHeader>
|
||||
<CardBody pt={0} flex="1" overflowY="auto">
|
||||
{watchlist.length === 0 ? (
|
||||
<Center py={8}>
|
||||
<VStack spacing={3}>
|
||||
<Icon as={FiBarChart2} boxSize={12} color="gray.300" />
|
||||
<Text color={secondaryText} fontSize="sm">
|
||||
暂无自选股
|
||||
</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
colorScheme="blue"
|
||||
onClick={() => navigate('/stocks')}
|
||||
>
|
||||
添加自选股
|
||||
</Button>
|
||||
</VStack>
|
||||
</Center>
|
||||
) : (
|
||||
<VStack align="stretch" spacing={2}>
|
||||
{watchlist.slice(0, 10).map((stock) => (
|
||||
<LinkBox
|
||||
key={stock.stock_code}
|
||||
p={3}
|
||||
borderRadius="md"
|
||||
_hover={{ bg: hoverBg }}
|
||||
transition="all 0.2s"
|
||||
cursor="pointer"
|
||||
>
|
||||
<HStack justify="space-between">
|
||||
<VStack align="start" spacing={0}>
|
||||
<LinkOverlay
|
||||
as={Link}
|
||||
to={`/company/${stock.stock_code}`}
|
||||
>
|
||||
<Text fontWeight="medium" fontSize="sm">
|
||||
{stock.stock_name || stock.stock_code}
|
||||
</Text>
|
||||
</LinkOverlay>
|
||||
<HStack spacing={2}>
|
||||
<Badge variant="subtle" fontSize="xs">
|
||||
{stock.stock_code}
|
||||
</Badge>
|
||||
{realtimeQuotes[stock.stock_code] ? (
|
||||
<Badge
|
||||
colorScheme={realtimeQuotes[stock.stock_code].change_percent > 0 ? 'red' : 'green'}
|
||||
fontSize="xs"
|
||||
>
|
||||
{realtimeQuotes[stock.stock_code].change_percent > 0 ? '+' : ''}
|
||||
{realtimeQuotes[stock.stock_code].change_percent.toFixed(2)}%
|
||||
</Badge>
|
||||
) : stock.change_percent ? (
|
||||
<Badge
|
||||
colorScheme={stock.change_percent > 0 ? 'red' : 'green'}
|
||||
fontSize="xs"
|
||||
>
|
||||
{stock.change_percent > 0 ? '+' : ''}
|
||||
{stock.change_percent}%
|
||||
</Badge>
|
||||
) : null}
|
||||
</HStack>
|
||||
</VStack>
|
||||
<VStack align="end" spacing={0}>
|
||||
<Text fontWeight="bold" fontSize="sm">
|
||||
{realtimeQuotes[stock.stock_code]?.current_price?.toFixed(2) || stock.current_price || '--'}
|
||||
</Text>
|
||||
<Text fontSize="xs" color={secondaryText}>
|
||||
{realtimeQuotes[stock.stock_code]?.update_time || stock.industry || '未分类'}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</LinkBox>
|
||||
))}
|
||||
{watchlist.length > 10 && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => navigate('/stocks')}
|
||||
>
|
||||
查看全部 ({watchlist.length})
|
||||
</Button>
|
||||
)}
|
||||
</VStack>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</VStack>
|
||||
|
||||
{/* 中列:关注事件 */}
|
||||
<VStack spacing={6} align="stretch" minW={0}>
|
||||
{/* 关注事件 */}
|
||||
<Card bg={cardBg} shadow="md" maxW="100%" height={{ base: 'auto', md: '600px' }} minH={{ base: '300px', md: '600px' }} display="flex" flexDirection="column">
|
||||
<CardHeader pb={{ base: 2, md: 4 }}>
|
||||
<Flex justify="space-between" align="center">
|
||||
<HStack>
|
||||
<Icon as={FiStar} color="yellow.500" boxSize={{ base: 4, md: 5 }} />
|
||||
<Heading size={{ base: 'sm', md: 'md' }}>关注事件</Heading>
|
||||
<Badge colorScheme="yellow" variant="subtle">
|
||||
{followingEvents.length}
|
||||
</Badge>
|
||||
</HStack>
|
||||
<IconButton
|
||||
icon={<FiPlus />}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigate('/community')}
|
||||
aria-label="添加关注事件"
|
||||
/>
|
||||
</Flex>
|
||||
</CardHeader>
|
||||
<CardBody pt={0} flex="1" overflowY="auto">
|
||||
{followingEvents.length === 0 ? (
|
||||
<Center py={8}>
|
||||
<VStack spacing={3}>
|
||||
<Icon as={FiActivity} boxSize={12} color="gray.300" />
|
||||
<Text color={secondaryText} fontSize="sm">
|
||||
暂无关注事件
|
||||
</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
colorScheme="blue"
|
||||
onClick={() => navigate('/community')}
|
||||
>
|
||||
探索事件
|
||||
</Button>
|
||||
</VStack>
|
||||
</Center>
|
||||
) : (
|
||||
<VStack align="stretch" spacing={3}>
|
||||
{followingEvents.slice(0, 5).map((event) => (
|
||||
<LinkBox
|
||||
key={event.id}
|
||||
p={4}
|
||||
borderRadius="lg"
|
||||
border="1px"
|
||||
borderColor={borderColor}
|
||||
_hover={{ shadow: 'md', transform: 'translateY(-2px)' }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
<LinkOverlay
|
||||
as={Link}
|
||||
to={getEventDetailUrl(event.id)}
|
||||
>
|
||||
<Text fontWeight="medium" fontSize="md" noOfLines={2}>
|
||||
{event.title}
|
||||
</Text>
|
||||
</LinkOverlay>
|
||||
|
||||
{/* 事件标签 */}
|
||||
{event.tags && event.tags.length > 0 && (
|
||||
<Wrap>
|
||||
{event.tags.slice(0, 3).map((tag, idx) => (
|
||||
<WrapItem key={idx}>
|
||||
<Tag size="sm" variant="subtle" colorScheme="blue">
|
||||
<TagLabel>{tag}</TagLabel>
|
||||
</Tag>
|
||||
</WrapItem>
|
||||
))}
|
||||
</Wrap>
|
||||
)}
|
||||
|
||||
{/* 事件统计 */}
|
||||
<HStack spacing={4} fontSize="sm" color={secondaryText}>
|
||||
{event.related_avg_chg !== undefined && event.related_avg_chg !== null && (
|
||||
<Badge
|
||||
colorScheme={event.related_avg_chg > 0 ? 'red' : 'green'}
|
||||
variant="subtle"
|
||||
>
|
||||
平均超额 {event.related_avg_chg > 0 ? '+' : ''}{Number(event.related_avg_chg).toFixed(2)}%
|
||||
</Badge>
|
||||
)}
|
||||
<HStack spacing={1}>
|
||||
<Icon as={FiUsers} />
|
||||
<Text>{event.follower_count || 0} 关注</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
{/* 事件信息 */}
|
||||
<Flex justify="space-between" align="center">
|
||||
<HStack spacing={2} fontSize="xs" color={secondaryText}>
|
||||
<Avatar
|
||||
size="xs"
|
||||
name={event.creator?.username || '系统'}
|
||||
src={event.creator?.avatar_url}
|
||||
/>
|
||||
<Text>{event.creator?.username || '系统'}</Text>
|
||||
<Text>·</Text>
|
||||
<Text>{formatDate(event.created_at)}</Text>
|
||||
</HStack>
|
||||
{event.exceed_expectation_score && (
|
||||
<Badge
|
||||
colorScheme={event.exceed_expectation_score > 70 ? 'red' : 'orange'}
|
||||
variant="solid"
|
||||
fontSize="xs"
|
||||
>
|
||||
超预期 {event.exceed_expectation_score}
|
||||
</Badge>
|
||||
)}
|
||||
</Flex>
|
||||
</VStack>
|
||||
</LinkBox>
|
||||
))}
|
||||
{followingEvents.length > 5 && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => navigate('/community')}
|
||||
>
|
||||
查看全部 ({followingEvents.length})
|
||||
</Button>
|
||||
)}
|
||||
</VStack>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
</VStack>
|
||||
|
||||
{/* 右列:我的评论 */}
|
||||
<VStack spacing={6} align="stretch" minW={0}>
|
||||
{/* 我的评论 */}
|
||||
<Card bg={cardBg} shadow="md" maxW="100%" height={{ base: 'auto', md: '600px' }} minH={{ base: '300px', md: '600px' }} display="flex" flexDirection="column">
|
||||
<CardHeader pb={{ base: 2, md: 4 }}>
|
||||
<Flex justify="space-between" align="center">
|
||||
<HStack>
|
||||
<Icon as={FiMessageSquare} color="purple.500" boxSize={{ base: 4, md: 5 }} />
|
||||
<Heading size={{ base: 'sm', md: 'md' }}>我的评论</Heading>
|
||||
<Badge colorScheme="purple" variant="subtle">
|
||||
{eventComments.length}
|
||||
</Badge>
|
||||
</HStack>
|
||||
</Flex>
|
||||
</CardHeader>
|
||||
<CardBody pt={0} flex="1" overflowY="auto">
|
||||
{eventComments.length === 0 ? (
|
||||
<Center py={8}>
|
||||
<VStack spacing={3}>
|
||||
<Icon as={FiMessageSquare} boxSize={12} color="gray.300" />
|
||||
<Text color={secondaryText} fontSize="sm">
|
||||
暂无评论记录
|
||||
</Text>
|
||||
<Text color={secondaryText} fontSize="xs" textAlign="center">
|
||||
参与事件讨论,分享您的观点
|
||||
</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
) : (
|
||||
<VStack align="stretch" spacing={3}>
|
||||
{eventComments.slice(0, 5).map((comment) => (
|
||||
<Box
|
||||
key={comment.id}
|
||||
p={3}
|
||||
borderRadius="md"
|
||||
border="1px"
|
||||
borderColor={borderColor}
|
||||
_hover={{ bg: hoverBg }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<Text fontSize="sm" noOfLines={3}>
|
||||
{comment.content}
|
||||
</Text>
|
||||
<HStack justify="space-between" fontSize="xs" color={secondaryText} spacing={2}>
|
||||
<HStack flexShrink={0}>
|
||||
<Icon as={FiClock} />
|
||||
<Text>{formatDate(comment.created_at)}</Text>
|
||||
</HStack>
|
||||
{comment.event_title && (
|
||||
<Tooltip label={comment.event_title}>
|
||||
<Badge
|
||||
variant="subtle"
|
||||
fontSize="xs"
|
||||
maxW={{ base: '120px', md: '180px' }}
|
||||
overflow="hidden"
|
||||
textOverflow="ellipsis"
|
||||
whiteSpace="nowrap"
|
||||
>
|
||||
{comment.event_title}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
))}
|
||||
{eventComments.length > 5 && (
|
||||
<Text fontSize="sm" color={secondaryText} textAlign="center">
|
||||
共 {eventComments.length} 条评论
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</VStack>
|
||||
</Grid>
|
||||
|
||||
{/* 投资规划中心(整合了日历、计划、复盘) */}
|
||||
<Box>
|
||||
<InvestmentPlanningCenter />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,587 +0,0 @@
|
||||
// src/views/Dashboard/components/InvestmentCalendarChakra.js
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardHeader,
|
||||
CardBody,
|
||||
Heading,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Button,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalFooter,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
useDisclosure,
|
||||
Badge,
|
||||
IconButton,
|
||||
Flex,
|
||||
Grid,
|
||||
useColorModeValue,
|
||||
Divider,
|
||||
Tooltip,
|
||||
Icon,
|
||||
Input,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Textarea,
|
||||
Select,
|
||||
useToast,
|
||||
Spinner,
|
||||
Center,
|
||||
Tag,
|
||||
TagLabel,
|
||||
TagLeftIcon,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
FiCalendar,
|
||||
FiClock,
|
||||
FiStar,
|
||||
FiTrendingUp,
|
||||
FiPlus,
|
||||
FiEdit2,
|
||||
FiTrash2,
|
||||
FiSave,
|
||||
FiX,
|
||||
} from 'react-icons/fi';
|
||||
import FullCalendar from '@fullcalendar/react';
|
||||
import dayGridPlugin from '@fullcalendar/daygrid';
|
||||
import interactionPlugin from '@fullcalendar/interaction';
|
||||
import dayjs from 'dayjs';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import { getApiBase } from '../../../utils/apiConfig';
|
||||
import TimelineChartModal from '../../../components/StockChart/TimelineChartModal';
|
||||
import KLineChartModal from '../../../components/StockChart/KLineChartModal';
|
||||
import './InvestmentCalendar.less';
|
||||
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
export default function InvestmentCalendarChakra() {
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const { isOpen: isAddOpen, onOpen: onAddOpen, onClose: onAddClose } = useDisclosure();
|
||||
const { isOpen: isTimelineModalOpen, onOpen: onTimelineModalOpen, onClose: onTimelineModalClose } = useDisclosure();
|
||||
const { isOpen: isKLineModalOpen, onOpen: onKLineModalOpen, onClose: onKLineModalClose } = useDisclosure();
|
||||
const toast = useToast();
|
||||
|
||||
// 颜色主题
|
||||
const bgColor = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
const textColor = useColorModeValue('gray.700', 'white');
|
||||
const secondaryText = useColorModeValue('gray.600', 'gray.400');
|
||||
|
||||
const [events, setEvents] = useState([]);
|
||||
const [selectedDate, setSelectedDate] = useState(null);
|
||||
const [selectedDateEvents, setSelectedDateEvents] = useState([]);
|
||||
const [selectedStock, setSelectedStock] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [newEvent, setNewEvent] = useState({
|
||||
title: '',
|
||||
description: '',
|
||||
type: 'plan',
|
||||
importance: 3,
|
||||
stocks: '',
|
||||
});
|
||||
|
||||
// 加载事件数据
|
||||
const loadEvents = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const base = getApiBase();
|
||||
|
||||
// 直接加载用户相关的事件(投资计划 + 关注的未来事件)
|
||||
const userResponse = await fetch(base + '/api/account/calendar/events', {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (userResponse.ok) {
|
||||
const userData = await userResponse.json();
|
||||
if (userData.success) {
|
||||
const allEvents = (userData.data || []).map(event => ({
|
||||
...event,
|
||||
id: `${event.source || 'user'}-${event.id}`,
|
||||
title: event.title,
|
||||
start: event.event_date,
|
||||
date: event.event_date,
|
||||
backgroundColor: event.source === 'future' ? '#3182CE' : '#8B5CF6',
|
||||
borderColor: event.source === 'future' ? '#3182CE' : '#8B5CF6',
|
||||
extendedProps: {
|
||||
...event,
|
||||
isSystem: event.source === 'future',
|
||||
}
|
||||
}));
|
||||
|
||||
setEvents(allEvents);
|
||||
logger.debug('InvestmentCalendar', '日历事件加载成功', {
|
||||
count: allEvents.length
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('InvestmentCalendar', 'loadEvents', error);
|
||||
// ❌ 移除数据加载失败 toast(非关键操作)
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []); // ✅ 移除 toast 依赖
|
||||
|
||||
useEffect(() => {
|
||||
loadEvents();
|
||||
}, [loadEvents]);
|
||||
|
||||
// 根据重要性获取颜色
|
||||
const getEventColor = (importance) => {
|
||||
if (importance >= 5) return '#E53E3E'; // 红色
|
||||
if (importance >= 4) return '#ED8936'; // 橙色
|
||||
if (importance >= 3) return '#ECC94B'; // 黄色
|
||||
if (importance >= 2) return '#48BB78'; // 绿色
|
||||
return '#3182CE'; // 蓝色
|
||||
};
|
||||
|
||||
// 处理日期点击
|
||||
const handleDateClick = (info) => {
|
||||
const clickedDate = dayjs(info.date);
|
||||
setSelectedDate(clickedDate);
|
||||
|
||||
// 筛选当天的事件
|
||||
const dayEvents = events.filter(event =>
|
||||
dayjs(event.start).isSame(clickedDate, 'day')
|
||||
);
|
||||
setSelectedDateEvents(dayEvents);
|
||||
onOpen();
|
||||
};
|
||||
|
||||
// 处理事件点击
|
||||
const handleEventClick = (info) => {
|
||||
const event = info.event;
|
||||
const clickedDate = dayjs(event.start);
|
||||
setSelectedDate(clickedDate);
|
||||
setSelectedDateEvents([{
|
||||
title: event.title,
|
||||
start: event.start,
|
||||
extendedProps: {
|
||||
...event.extendedProps,
|
||||
},
|
||||
}]);
|
||||
onOpen();
|
||||
};
|
||||
|
||||
// 添加新事件
|
||||
const handleAddEvent = async () => {
|
||||
try {
|
||||
const base = getApiBase();
|
||||
|
||||
const eventData = {
|
||||
...newEvent,
|
||||
event_date: (selectedDate ? selectedDate.format('YYYY-MM-DD') : dayjs().format('YYYY-MM-DD')),
|
||||
stocks: newEvent.stocks.split(',').map(s => s.trim()).filter(s => s),
|
||||
};
|
||||
|
||||
const response = await fetch(base + '/api/account/calendar/events', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(eventData),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
logger.info('InvestmentCalendar', '添加事件成功', {
|
||||
eventTitle: eventData.title,
|
||||
eventDate: eventData.event_date
|
||||
});
|
||||
toast({
|
||||
title: '添加成功',
|
||||
description: '投资计划已添加',
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
});
|
||||
onAddClose();
|
||||
loadEvents();
|
||||
setNewEvent({
|
||||
title: '',
|
||||
description: '',
|
||||
type: 'plan',
|
||||
importance: 3,
|
||||
stocks: '',
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('InvestmentCalendar', 'handleAddEvent', error, {
|
||||
eventTitle: newEvent?.title
|
||||
});
|
||||
toast({
|
||||
title: '添加失败',
|
||||
description: '无法添加投资计划',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 删除用户事件
|
||||
const handleDeleteEvent = async (eventId) => {
|
||||
if (!eventId) {
|
||||
logger.warn('InvestmentCalendar', '删除事件失败', '缺少事件 ID', { eventId });
|
||||
toast({
|
||||
title: '无法删除',
|
||||
description: '缺少事件 ID',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const base = getApiBase();
|
||||
|
||||
const response = await fetch(base + `/api/account/calendar/events/${eventId}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
logger.info('InvestmentCalendar', '删除事件成功', { eventId });
|
||||
toast({
|
||||
title: '删除成功',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
loadEvents();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('InvestmentCalendar', 'handleDeleteEvent', error, { eventId });
|
||||
toast({
|
||||
title: '删除失败',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 处理股票点击 - 打开图表弹窗
|
||||
const handleStockClick = (stockCodeOrName, eventDate) => {
|
||||
// 解析股票代码(可能是 "600000" 或 "600000 平安银行" 格式)
|
||||
let stockCode = stockCodeOrName;
|
||||
let stockName = '';
|
||||
|
||||
if (typeof stockCodeOrName === 'string') {
|
||||
const parts = stockCodeOrName.trim().split(/\s+/);
|
||||
stockCode = parts[0];
|
||||
stockName = parts.slice(1).join(' ');
|
||||
}
|
||||
|
||||
// 添加交易所后缀(如果没有)
|
||||
if (!stockCode.includes('.')) {
|
||||
if (stockCode.startsWith('6')) {
|
||||
stockCode = `${stockCode}.SH`;
|
||||
} else if (stockCode.startsWith('0') || stockCode.startsWith('3')) {
|
||||
stockCode = `${stockCode}.SZ`;
|
||||
} else if (stockCode.startsWith('8') || stockCode.startsWith('9') || stockCode.startsWith('4')) {
|
||||
// 北交所股票
|
||||
stockCode = `${stockCode}.BJ`;
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedStock({
|
||||
stock_code: stockCode,
|
||||
stock_name: stockName || stockCode,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card bg={bgColor} shadow="md">
|
||||
<CardHeader pb={4}>
|
||||
<Flex justify="space-between" align="center">
|
||||
<HStack>
|
||||
<Icon as={FiCalendar} color="orange.500" boxSize={5} />
|
||||
<Heading size="md">投资日历</Heading>
|
||||
</HStack>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
leftIcon={<FiPlus />}
|
||||
onClick={() => { if (!selectedDate) setSelectedDate(dayjs()); onAddOpen(); }}
|
||||
>
|
||||
添加计划
|
||||
</Button>
|
||||
</Flex>
|
||||
</CardHeader>
|
||||
<CardBody pt={0}>
|
||||
{loading ? (
|
||||
<Center h="560px">
|
||||
<Spinner size="xl" color="blue.500" />
|
||||
</Center>
|
||||
) : (
|
||||
<Box height={{ base: '500px', md: '600px' }}>
|
||||
<FullCalendar
|
||||
plugins={[dayGridPlugin, interactionPlugin]}
|
||||
initialView="dayGridMonth"
|
||||
locale="zh-cn"
|
||||
headerToolbar={{
|
||||
left: 'prev,next today',
|
||||
center: 'title',
|
||||
right: ''
|
||||
}}
|
||||
events={events}
|
||||
dateClick={handleDateClick}
|
||||
eventClick={handleEventClick}
|
||||
height="100%"
|
||||
dayMaxEvents={3}
|
||||
moreLinkText="更多"
|
||||
buttonText={{
|
||||
today: '今天',
|
||||
month: '月',
|
||||
week: '周'
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</CardBody>
|
||||
|
||||
{/* 查看事件详情 Modal - 条件渲染 */}
|
||||
{isOpen && (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="xl" closeOnOverlayClick={false} closeOnEsc={true}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
{selectedDate && selectedDate.format('YYYY年MM月DD日')} 的事件
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
{selectedDateEvents.length === 0 ? (
|
||||
<Center py={8}>
|
||||
<VStack>
|
||||
<Text color={secondaryText}>当天没有事件</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
leftIcon={<FiPlus />}
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onAddOpen();
|
||||
}}
|
||||
>
|
||||
添加投资计划
|
||||
</Button>
|
||||
</VStack>
|
||||
</Center>
|
||||
) : (
|
||||
<VStack align="stretch" spacing={4}>
|
||||
{selectedDateEvents.map((event, idx) => (
|
||||
<Box
|
||||
key={idx}
|
||||
p={4}
|
||||
borderRadius="md"
|
||||
border="1px"
|
||||
borderColor={borderColor}
|
||||
>
|
||||
<Flex justify="space-between" align="start" mb={2}>
|
||||
<VStack align="start" spacing={1} flex={1}>
|
||||
<HStack>
|
||||
<Text fontWeight="bold" fontSize="lg">
|
||||
{event.title}
|
||||
</Text>
|
||||
{event.extendedProps?.isSystem ? (
|
||||
<Badge colorScheme="blue" variant="subtle">系统事件</Badge>
|
||||
) : (
|
||||
<Badge colorScheme="purple" variant="subtle">我的计划</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FiStar} color="yellow.500" />
|
||||
<Text fontSize="sm" color={secondaryText}>
|
||||
重要度: {event.extendedProps?.importance || 3}/5
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
{!event.extendedProps?.isSystem && (
|
||||
<IconButton
|
||||
icon={<FiTrash2 />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="red"
|
||||
onClick={() => handleDeleteEvent(event.extendedProps?.id)}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
{event.extendedProps?.description && (
|
||||
<Text fontSize="sm" color={secondaryText} mb={2}>
|
||||
{event.extendedProps.description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{event.extendedProps?.stocks && event.extendedProps.stocks.length > 0 && (
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
<Text fontSize="sm" color={secondaryText}>相关股票:</Text>
|
||||
{event.extendedProps.stocks.map((stock, i) => (
|
||||
<Tag
|
||||
key={i}
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
cursor="pointer"
|
||||
onClick={() => handleStockClick(stock, event.start)}
|
||||
_hover={{ transform: 'scale(1.05)', shadow: 'md' }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<TagLeftIcon as={FiTrendingUp} />
|
||||
<TagLabel>{stock}</TagLabel>
|
||||
</Tag>
|
||||
))}
|
||||
</HStack>
|
||||
{selectedStock && (
|
||||
<HStack spacing={2}>
|
||||
<Button
|
||||
size="xs"
|
||||
colorScheme="blue"
|
||||
variant="outline"
|
||||
leftIcon={<FiClock />}
|
||||
onClick={onTimelineModalOpen}
|
||||
>
|
||||
分时图
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
colorScheme="purple"
|
||||
variant="outline"
|
||||
leftIcon={<FiTrendingUp />}
|
||||
onClick={onKLineModalOpen}
|
||||
>
|
||||
日K线
|
||||
</Button>
|
||||
</HStack>
|
||||
)}
|
||||
</VStack>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button onClick={onClose}>关闭</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* 添加投资计划 Modal - 条件渲染 */}
|
||||
{isAddOpen && (
|
||||
<Modal isOpen={isAddOpen} onClose={onAddClose} size="lg" closeOnOverlayClick={false} closeOnEsc={true}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
添加投资计划
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack spacing={4}>
|
||||
<FormControl isRequired>
|
||||
<FormLabel>标题</FormLabel>
|
||||
<Input
|
||||
value={newEvent.title}
|
||||
onChange={(e) => setNewEvent({ ...newEvent, title: e.target.value })}
|
||||
placeholder="例如:关注半导体板块"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>描述</FormLabel>
|
||||
<Textarea
|
||||
value={newEvent.description}
|
||||
onChange={(e) => setNewEvent({ ...newEvent, description: e.target.value })}
|
||||
placeholder="详细描述您的投资计划..."
|
||||
rows={3}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>类型</FormLabel>
|
||||
<Select
|
||||
value={newEvent.type}
|
||||
onChange={(e) => setNewEvent({ ...newEvent, type: e.target.value })}
|
||||
>
|
||||
<option value="plan">投资计划</option>
|
||||
<option value="reminder">提醒事项</option>
|
||||
<option value="analysis">分析任务</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>重要度</FormLabel>
|
||||
<Select
|
||||
value={newEvent.importance}
|
||||
onChange={(e) => setNewEvent({ ...newEvent, importance: parseInt(e.target.value) })}
|
||||
>
|
||||
<option value={5}>⭐⭐⭐⭐⭐ 非常重要</option>
|
||||
<option value={4}>⭐⭐⭐⭐ 重要</option>
|
||||
<option value={3}>⭐⭐⭐ 一般</option>
|
||||
<option value={2}>⭐⭐ 次要</option>
|
||||
<option value={1}>⭐ 不重要</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>相关股票(用逗号分隔)</FormLabel>
|
||||
<Input
|
||||
value={newEvent.stocks}
|
||||
onChange={(e) => setNewEvent({ ...newEvent, stocks: e.target.value })}
|
||||
placeholder="例如:600519,000858,002415"
|
||||
/>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" mr={3} onClick={onAddClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
onClick={handleAddEvent}
|
||||
isDisabled={!newEvent.title}
|
||||
>
|
||||
添加
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* 分时图弹窗 */}
|
||||
{selectedStock && (
|
||||
<TimelineChartModal
|
||||
isOpen={isTimelineModalOpen}
|
||||
onClose={() => {
|
||||
onTimelineModalClose();
|
||||
setSelectedStock(null);
|
||||
}}
|
||||
stock={selectedStock}
|
||||
eventTime={selectedDate?.toISOString()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* K线图弹窗 */}
|
||||
{selectedStock && (
|
||||
<KLineChartModal
|
||||
isOpen={isKLineModalOpen}
|
||||
onClose={() => {
|
||||
onKLineModalClose();
|
||||
setSelectedStock(null);
|
||||
}}
|
||||
stock={selectedStock}
|
||||
eventTime={selectedDate?.toISOString()}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,588 +0,0 @@
|
||||
// src/views/Dashboard/components/InvestmentPlansAndReviews.js
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardHeader,
|
||||
CardBody,
|
||||
Heading,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Button,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalFooter,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
useDisclosure,
|
||||
Badge,
|
||||
IconButton,
|
||||
Flex,
|
||||
useColorModeValue,
|
||||
Divider,
|
||||
Icon,
|
||||
Input,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Textarea,
|
||||
Select,
|
||||
useToast,
|
||||
Spinner,
|
||||
Center,
|
||||
Tag,
|
||||
TagLabel,
|
||||
TagLeftIcon,
|
||||
TagCloseButton,
|
||||
Grid,
|
||||
Tabs,
|
||||
TabList,
|
||||
TabPanels,
|
||||
Tab,
|
||||
TabPanel,
|
||||
InputGroup,
|
||||
InputLeftElement,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
FiCalendar,
|
||||
FiClock,
|
||||
FiEdit2,
|
||||
FiTrash2,
|
||||
FiSave,
|
||||
FiPlus,
|
||||
FiFileText,
|
||||
FiTarget,
|
||||
FiTrendingUp,
|
||||
FiHash,
|
||||
FiCheckCircle,
|
||||
FiXCircle,
|
||||
FiAlertCircle,
|
||||
} from 'react-icons/fi';
|
||||
import dayjs from 'dayjs';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import { getApiBase } from '../../../utils/apiConfig';
|
||||
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
export default function InvestmentPlansAndReviews({ type = 'both' }) {
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const toast = useToast();
|
||||
|
||||
// 颜色主题
|
||||
const bgColor = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
const textColor = useColorModeValue('gray.700', 'white');
|
||||
const secondaryText = useColorModeValue('gray.600', 'gray.400');
|
||||
const cardBg = useColorModeValue('gray.50', 'gray.700');
|
||||
|
||||
const [plans, setPlans] = useState([]);
|
||||
const [reviews, setReviews] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState(null);
|
||||
const [formData, setFormData] = useState({
|
||||
date: dayjs().format('YYYY-MM-DD'),
|
||||
title: '',
|
||||
content: '',
|
||||
type: 'plan',
|
||||
stocks: [],
|
||||
tags: [],
|
||||
status: 'active',
|
||||
});
|
||||
const [stockInput, setStockInput] = useState('');
|
||||
const [tagInput, setTagInput] = useState('');
|
||||
|
||||
// 加载数据
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const base = getApiBase();
|
||||
|
||||
const response = await fetch(base + '/api/account/investment-plans', {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
const allItems = data.data || [];
|
||||
setPlans(allItems.filter(item => item.type === 'plan'));
|
||||
setReviews(allItems.filter(item => item.type === 'review'));
|
||||
logger.debug('InvestmentPlansAndReviews', '数据加载成功', {
|
||||
plansCount: allItems.filter(item => item.type === 'plan').length,
|
||||
reviewsCount: allItems.filter(item => item.type === 'review').length
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('InvestmentPlansAndReviews', 'loadData', error);
|
||||
// ❌ 移除数据加载失败 toast(非关键操作)
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []); // ✅ 移除 toast 依赖
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
// 打开编辑/新建模态框
|
||||
const handleOpenModal = (item = null, itemType = 'plan') => {
|
||||
if (item) {
|
||||
setEditingItem(item);
|
||||
setFormData({
|
||||
...item,
|
||||
date: dayjs(item.date).format('YYYY-MM-DD'),
|
||||
});
|
||||
} else {
|
||||
setEditingItem(null);
|
||||
setFormData({
|
||||
date: dayjs().format('YYYY-MM-DD'),
|
||||
title: '',
|
||||
content: '',
|
||||
type: itemType,
|
||||
stocks: [],
|
||||
tags: [],
|
||||
status: 'active',
|
||||
});
|
||||
}
|
||||
onOpen();
|
||||
};
|
||||
|
||||
// 保存数据
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const base = getApiBase();
|
||||
|
||||
const url = editingItem
|
||||
? base + `/api/account/investment-plans/${editingItem.id}`
|
||||
: base + '/api/account/investment-plans';
|
||||
|
||||
const method = editingItem ? 'PUT' : 'POST';
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
logger.info('InvestmentPlansAndReviews', `${editingItem ? '更新' : '创建'}成功`, {
|
||||
itemId: editingItem?.id,
|
||||
title: formData.title,
|
||||
type: formData.type
|
||||
});
|
||||
toast({
|
||||
title: editingItem ? '更新成功' : '创建成功',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
onClose();
|
||||
loadData();
|
||||
} else {
|
||||
throw new Error('保存失败');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('InvestmentPlansAndReviews', 'handleSave', error, {
|
||||
itemId: editingItem?.id,
|
||||
title: formData?.title
|
||||
});
|
||||
toast({
|
||||
title: '保存失败',
|
||||
description: '无法保存数据',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 删除数据
|
||||
const handleDelete = async (id) => {
|
||||
if (!window.confirm('确定要删除吗?')) return;
|
||||
|
||||
try {
|
||||
const base = getApiBase();
|
||||
|
||||
const response = await fetch(base + `/api/account/investment-plans/${id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
logger.info('InvestmentPlansAndReviews', '删除成功', { itemId: id });
|
||||
toast({
|
||||
title: '删除成功',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
loadData();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('InvestmentPlansAndReviews', 'handleDelete', error, { itemId: id });
|
||||
toast({
|
||||
title: '删除失败',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 添加股票
|
||||
const handleAddStock = () => {
|
||||
if (stockInput.trim() && !formData.stocks.includes(stockInput.trim())) {
|
||||
setFormData({
|
||||
...formData,
|
||||
stocks: [...formData.stocks, stockInput.trim()],
|
||||
});
|
||||
setStockInput('');
|
||||
}
|
||||
};
|
||||
|
||||
// 添加标签
|
||||
const handleAddTag = () => {
|
||||
if (tagInput.trim() && !formData.tags.includes(tagInput.trim())) {
|
||||
setFormData({
|
||||
...formData,
|
||||
tags: [...formData.tags, tagInput.trim()],
|
||||
});
|
||||
setTagInput('');
|
||||
}
|
||||
};
|
||||
|
||||
// 获取状态图标和颜色
|
||||
const getStatusInfo = (status) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return { icon: FiCheckCircle, color: 'green' };
|
||||
case 'cancelled':
|
||||
return { icon: FiXCircle, color: 'red' };
|
||||
default:
|
||||
return { icon: FiAlertCircle, color: 'blue' };
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染单个卡片
|
||||
const renderCard = (item) => {
|
||||
const statusInfo = getStatusInfo(item.status);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={item.id}
|
||||
bg={cardBg}
|
||||
shadow="sm"
|
||||
_hover={{ shadow: 'md' }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<CardBody>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
<Flex justify="space-between" align="start">
|
||||
<VStack align="start" spacing={1} flex={1}>
|
||||
<HStack>
|
||||
<Icon as={item.type === 'plan' ? FiTarget : FiFileText} color="blue.500" />
|
||||
<Text fontWeight="bold" fontSize="lg">
|
||||
{item.title}
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FiCalendar} boxSize={3} color={secondaryText} />
|
||||
<Text fontSize="sm" color={secondaryText}>
|
||||
{dayjs(item.date).format('YYYY年MM月DD日')}
|
||||
</Text>
|
||||
<Badge
|
||||
colorScheme={statusInfo.color}
|
||||
variant="subtle"
|
||||
leftIcon={<Icon as={statusInfo.icon} />}
|
||||
>
|
||||
{item.status === 'active' ? '进行中' :
|
||||
item.status === 'completed' ? '已完成' : '已取消'}
|
||||
</Badge>
|
||||
</HStack>
|
||||
</VStack>
|
||||
<HStack>
|
||||
<IconButton
|
||||
icon={<FiEdit2 />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleOpenModal(item)}
|
||||
/>
|
||||
<IconButton
|
||||
icon={<FiTrash2 />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="red"
|
||||
onClick={() => handleDelete(item.id)}
|
||||
/>
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{item.content && (
|
||||
<Text fontSize="sm" color={textColor} noOfLines={3}>
|
||||
{item.content}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
{item.stocks && item.stocks.length > 0 && (
|
||||
<>
|
||||
{item.stocks.map((stock, idx) => (
|
||||
<Tag key={idx} size="sm" colorScheme="blue" variant="subtle">
|
||||
<TagLeftIcon as={FiTrendingUp} />
|
||||
<TagLabel>{stock}</TagLabel>
|
||||
</Tag>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{item.tags && item.tags.length > 0 && (
|
||||
<>
|
||||
{item.tags.map((tag, idx) => (
|
||||
<Tag key={idx} size="sm" colorScheme="purple" variant="subtle">
|
||||
<TagLeftIcon as={FiHash} />
|
||||
<TagLabel>{tag}</TagLabel>
|
||||
</Tag>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</HStack>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Tabs variant="enclosed" colorScheme="blue" defaultIndex={type === 'review' ? 1 : 0}>
|
||||
<TabList>
|
||||
<Tab>
|
||||
<Icon as={FiTarget} mr={2} />
|
||||
我的计划 ({plans.length})
|
||||
</Tab>
|
||||
<Tab>
|
||||
<Icon as={FiFileText} mr={2} />
|
||||
我的复盘 ({reviews.length})
|
||||
</Tab>
|
||||
</TabList>
|
||||
|
||||
<TabPanels>
|
||||
{/* 计划面板 */}
|
||||
<TabPanel px={0}>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<Flex justify="flex-end">
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
leftIcon={<FiPlus />}
|
||||
onClick={() => handleOpenModal(null, 'plan')}
|
||||
>
|
||||
新建计划
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
{loading ? (
|
||||
<Center py={8}>
|
||||
<Spinner size="xl" color="blue.500" />
|
||||
</Center>
|
||||
) : plans.length === 0 ? (
|
||||
<Center py={8}>
|
||||
<VStack spacing={3}>
|
||||
<Icon as={FiTarget} boxSize={12} color="gray.300" />
|
||||
<Text color={secondaryText}>暂无投资计划</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
leftIcon={<FiPlus />}
|
||||
onClick={() => handleOpenModal(null, 'plan')}
|
||||
>
|
||||
创建第一个计划
|
||||
</Button>
|
||||
</VStack>
|
||||
</Center>
|
||||
) : (
|
||||
<Grid templateColumns={{ base: '1fr', md: 'repeat(2, 1fr)' }} gap={4}>
|
||||
{plans.map(renderCard)}
|
||||
</Grid>
|
||||
)}
|
||||
</VStack>
|
||||
</TabPanel>
|
||||
|
||||
{/* 复盘面板 */}
|
||||
<TabPanel px={0}>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<Flex justify="flex-end">
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="green"
|
||||
leftIcon={<FiPlus />}
|
||||
onClick={() => handleOpenModal(null, 'review')}
|
||||
>
|
||||
新建复盘
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
{loading ? (
|
||||
<Center py={8}>
|
||||
<Spinner size="xl" color="blue.500" />
|
||||
</Center>
|
||||
) : reviews.length === 0 ? (
|
||||
<Center py={8}>
|
||||
<VStack spacing={3}>
|
||||
<Icon as={FiFileText} boxSize={12} color="gray.300" />
|
||||
<Text color={secondaryText}>暂无复盘记录</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="green"
|
||||
leftIcon={<FiPlus />}
|
||||
onClick={() => handleOpenModal(null, 'review')}
|
||||
>
|
||||
创建第一个复盘
|
||||
</Button>
|
||||
</VStack>
|
||||
</Center>
|
||||
) : (
|
||||
<Grid templateColumns={{ base: '1fr', md: 'repeat(2, 1fr)' }} gap={4}>
|
||||
{reviews.map(renderCard)}
|
||||
</Grid>
|
||||
)}
|
||||
</VStack>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
|
||||
{/* 编辑/新建模态框 - 条件渲染 */}
|
||||
{isOpen && (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="xl" closeOnOverlayClick={false} closeOnEsc={true}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
{editingItem ? '编辑' : '新建'}
|
||||
{formData.type === 'plan' ? '投资计划' : '复盘记录'}
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack spacing={4}>
|
||||
<FormControl isRequired>
|
||||
<FormLabel>日期</FormLabel>
|
||||
<InputGroup>
|
||||
<InputLeftElement pointerEvents="none">
|
||||
<Icon as={FiCalendar} color={secondaryText} />
|
||||
</InputLeftElement>
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.date}
|
||||
onChange={(e) => setFormData({ ...formData, date: e.target.value })}
|
||||
/>
|
||||
</InputGroup>
|
||||
</FormControl>
|
||||
|
||||
<FormControl isRequired>
|
||||
<FormLabel>标题</FormLabel>
|
||||
<Input
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
placeholder={formData.type === 'plan' ? '例如:布局新能源板块' : '例如:本周交易复盘'}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>内容</FormLabel>
|
||||
<Textarea
|
||||
value={formData.content}
|
||||
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
|
||||
placeholder={formData.type === 'plan' ?
|
||||
'详细描述您的投资计划...' :
|
||||
'记录您的交易心得和经验教训...'}
|
||||
rows={6}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>相关股票</FormLabel>
|
||||
<HStack>
|
||||
<Input
|
||||
value={stockInput}
|
||||
onChange={(e) => setStockInput(e.target.value)}
|
||||
placeholder="输入股票代码"
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleAddStock()}
|
||||
/>
|
||||
<Button onClick={handleAddStock}>添加</Button>
|
||||
</HStack>
|
||||
<HStack mt={2} spacing={2} flexWrap="wrap">
|
||||
{formData.stocks.map((stock, idx) => (
|
||||
<Tag key={idx} size="sm" colorScheme="blue">
|
||||
<TagLeftIcon as={FiTrendingUp} />
|
||||
<TagLabel>{stock}</TagLabel>
|
||||
<TagCloseButton
|
||||
onClick={() => setFormData({
|
||||
...formData,
|
||||
stocks: formData.stocks.filter((_, i) => i !== idx)
|
||||
})}
|
||||
/>
|
||||
</Tag>
|
||||
))}
|
||||
</HStack>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>标签</FormLabel>
|
||||
<HStack>
|
||||
<Input
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
placeholder="输入标签"
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleAddTag()}
|
||||
/>
|
||||
<Button onClick={handleAddTag}>添加</Button>
|
||||
</HStack>
|
||||
<HStack mt={2} spacing={2} flexWrap="wrap">
|
||||
{formData.tags.map((tag, idx) => (
|
||||
<Tag key={idx} size="sm" colorScheme="purple">
|
||||
<TagLeftIcon as={FiHash} />
|
||||
<TagLabel>{tag}</TagLabel>
|
||||
<TagCloseButton
|
||||
onClick={() => setFormData({
|
||||
...formData,
|
||||
tags: formData.tags.filter((_, i) => i !== idx)
|
||||
})}
|
||||
/>
|
||||
</Tag>
|
||||
))}
|
||||
</HStack>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>状态</FormLabel>
|
||||
<Select
|
||||
value={formData.status}
|
||||
onChange={(e) => setFormData({ ...formData, status: e.target.value })}
|
||||
>
|
||||
<option value="active">进行中</option>
|
||||
<option value="completed">已完成</option>
|
||||
<option value="cancelled">已取消</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" mr={3} onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
onClick={handleSave}
|
||||
isDisabled={!formData.title || !formData.date}
|
||||
leftIcon={<FiSave />}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// 社区动态卡片
|
||||
import React, { useState } from 'react';
|
||||
import { Box, Text, VStack, HStack, Icon, Button } from '@chakra-ui/react';
|
||||
import { Newspaper, Flame, MessageCircle } from 'lucide-react';
|
||||
|
||||
const CommunityFeedCard = ({
|
||||
myPosts = [
|
||||
{ id: 1, title: '关于新能源车下半场的思考', date: '2025/12/18', replies: 32, isHot: true },
|
||||
{ id: 2, title: '半导体行业深度分析', date: '2025/12/15', replies: 18, isHot: false },
|
||||
],
|
||||
participatedPosts = [
|
||||
{ id: 3, title: 'AI产业链投资机会分析', date: '2025/12/17', replies: 45, isHot: true },
|
||||
{ id: 4, title: '消费板块复苏节奏讨论', date: '2025/12/14', replies: 12, isHot: false },
|
||||
],
|
||||
onPostClick,
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState('my'); // 'my' | 'participated'
|
||||
|
||||
const currentPosts = activeTab === 'my' ? myPosts : participatedPosts;
|
||||
|
||||
return (
|
||||
<Box
|
||||
bg="rgba(26, 26, 46, 0.7)"
|
||||
borderRadius="lg"
|
||||
overflow="hidden"
|
||||
border="1px solid"
|
||||
borderColor="rgba(212, 175, 55, 0.15)"
|
||||
backdropFilter="blur(8px)"
|
||||
>
|
||||
{/* 标题栏 */}
|
||||
<HStack
|
||||
px={4}
|
||||
py={2}
|
||||
bg="rgba(15, 15, 26, 0.8)"
|
||||
borderBottom="1px solid"
|
||||
borderColor="rgba(212, 175, 55, 0.1)"
|
||||
>
|
||||
<Icon as={Newspaper} boxSize={4} color="#3B82F6" />
|
||||
<Text fontSize="sm" fontWeight="bold" color="rgba(255, 255, 255, 0.95)">
|
||||
社区动态
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{/* 内容区 */}
|
||||
<Box p={4}>
|
||||
<VStack spacing={3} align="stretch">
|
||||
{/* Tab 切换 */}
|
||||
<HStack spacing={4}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
color={activeTab === 'my' ? 'rgba(212, 175, 55, 0.9)' : 'rgba(255, 255, 255, 0.5)'}
|
||||
fontWeight={activeTab === 'my' ? 'bold' : 'normal'}
|
||||
_hover={{ color: 'rgba(212, 175, 55, 0.9)' }}
|
||||
onClick={() => setActiveTab('my')}
|
||||
px={2}
|
||||
>
|
||||
[我发布的]
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
color={activeTab === 'participated' ? 'rgba(212, 175, 55, 0.9)' : 'rgba(255, 255, 255, 0.5)'}
|
||||
fontWeight={activeTab === 'participated' ? 'bold' : 'normal'}
|
||||
_hover={{ color: 'rgba(212, 175, 55, 0.9)' }}
|
||||
onClick={() => setActiveTab('participated')}
|
||||
px={2}
|
||||
>
|
||||
[我参与的]
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
{/* 帖子列表 */}
|
||||
<VStack spacing={3} align="stretch">
|
||||
{currentPosts.map((post) => (
|
||||
<Box
|
||||
key={post.id}
|
||||
p={3}
|
||||
bg="rgba(37, 37, 64, 0.5)"
|
||||
borderRadius="md"
|
||||
cursor="pointer"
|
||||
transition="all 0.2s"
|
||||
_hover={{
|
||||
bg: 'rgba(37, 37, 64, 0.8)',
|
||||
transform: 'translateX(4px)',
|
||||
}}
|
||||
onClick={() => onPostClick?.(post)}
|
||||
>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="medium"
|
||||
color="rgba(255, 255, 255, 0.9)"
|
||||
noOfLines={1}
|
||||
mb={1}
|
||||
>
|
||||
{post.title}
|
||||
</Text>
|
||||
<HStack spacing={3} fontSize="xs" color="rgba(255, 255, 255, 0.5)">
|
||||
<Text>{post.date}</Text>
|
||||
<Text>·</Text>
|
||||
<HStack spacing={1}>
|
||||
{post.isHot ? (
|
||||
<Icon as={Flame} boxSize={3} color="#F97316" />
|
||||
) : (
|
||||
<Icon as={MessageCircle} boxSize={3} />
|
||||
)}
|
||||
<Text color={post.isHot ? '#F97316' : 'inherit'}>
|
||||
{post.replies}回复
|
||||
</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommunityFeedCard;
|
||||
@@ -0,0 +1,169 @@
|
||||
// 我的预测卡片
|
||||
import React from 'react';
|
||||
import { Box, Text, VStack, HStack, Button, Icon } from '@chakra-ui/react';
|
||||
import { Zap, History, TrendingUp, TrendingDown } from 'lucide-react';
|
||||
|
||||
const PredictionCard = ({
|
||||
question = '大A 2025年收盘价?',
|
||||
myBet = { type: '看涨', points: 500 },
|
||||
winRate = 58,
|
||||
odds = 1.8,
|
||||
onBullish,
|
||||
onBearish,
|
||||
onViewHistory,
|
||||
}) => {
|
||||
return (
|
||||
<Box
|
||||
bg="rgba(26, 26, 46, 0.7)"
|
||||
borderRadius="lg"
|
||||
overflow="hidden"
|
||||
border="1px solid"
|
||||
borderColor="rgba(212, 175, 55, 0.15)"
|
||||
backdropFilter="blur(8px)"
|
||||
>
|
||||
{/* 标题栏 */}
|
||||
<HStack
|
||||
px={4}
|
||||
py={2}
|
||||
bg="rgba(15, 15, 26, 0.8)"
|
||||
borderBottom="1px solid"
|
||||
borderColor="rgba(212, 175, 55, 0.1)"
|
||||
>
|
||||
<Icon as={Zap} boxSize={4} color="#FBBF24" />
|
||||
<Text fontSize="sm" fontWeight="bold" color="rgba(255, 255, 255, 0.95)">
|
||||
我的预测
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{/* 内容区 */}
|
||||
<Box p={4}>
|
||||
<VStack spacing={4} align="stretch">
|
||||
{/* 预测问题 - 带渐变背景 */}
|
||||
<Box
|
||||
bg="linear-gradient(135deg, rgba(30, 30, 50, 0.9) 0%, rgba(20, 20, 35, 0.95) 100%)"
|
||||
borderRadius="lg"
|
||||
p={4}
|
||||
textAlign="center"
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
>
|
||||
{/* 装饰性弧线 */}
|
||||
<Box
|
||||
position="absolute"
|
||||
top="50%"
|
||||
left="50%"
|
||||
transform="translate(-50%, -50%)"
|
||||
w="120px"
|
||||
h="60px"
|
||||
borderRadius="50%"
|
||||
border="2px solid"
|
||||
borderColor="rgba(212, 175, 55, 0.2)"
|
||||
borderBottomColor="transparent"
|
||||
borderLeftColor="transparent"
|
||||
/>
|
||||
|
||||
<Text
|
||||
fontSize="lg"
|
||||
fontWeight="bold"
|
||||
color="rgba(255, 255, 255, 0.95)"
|
||||
position="relative"
|
||||
zIndex={1}
|
||||
>
|
||||
{question}
|
||||
</Text>
|
||||
|
||||
{/* 看涨/看跌按钮 */}
|
||||
<HStack spacing={3} mt={4} justify="center">
|
||||
<Button
|
||||
flex={1}
|
||||
maxW="140px"
|
||||
h="40px"
|
||||
bg="linear-gradient(135deg, #DC2626 0%, #EF4444 100%)"
|
||||
color="white"
|
||||
fontWeight="bold"
|
||||
fontSize="md"
|
||||
borderRadius="full"
|
||||
_hover={{
|
||||
bg: 'linear-gradient(135deg, #B91C1C 0%, #DC2626 100%)',
|
||||
transform: 'scale(1.02)',
|
||||
}}
|
||||
_active={{ transform: 'scale(0.98)' }}
|
||||
leftIcon={<Icon as={TrendingUp} boxSize={4} />}
|
||||
onClick={onBullish}
|
||||
>
|
||||
看涨
|
||||
</Button>
|
||||
<Button
|
||||
flex={1}
|
||||
maxW="140px"
|
||||
h="40px"
|
||||
bg="linear-gradient(135deg, #16A34A 0%, #22C55E 100%)"
|
||||
color="white"
|
||||
fontWeight="bold"
|
||||
fontSize="md"
|
||||
borderRadius="full"
|
||||
_hover={{
|
||||
bg: 'linear-gradient(135deg, #15803D 0%, #16A34A 100%)',
|
||||
transform: 'scale(1.02)',
|
||||
}}
|
||||
_active={{ transform: 'scale(0.98)' }}
|
||||
leftIcon={<Icon as={TrendingDown} boxSize={4} />}
|
||||
onClick={onBearish}
|
||||
>
|
||||
看跌
|
||||
</Button>
|
||||
</HStack>
|
||||
</Box>
|
||||
|
||||
{/* 底部信息 */}
|
||||
<HStack justify="space-between" fontSize="xs" px={1}>
|
||||
<HStack spacing={4}>
|
||||
<HStack spacing={1}>
|
||||
<Text color="rgba(255, 255, 255, 0.5)">我的下注:</Text>
|
||||
<Text color="#EF4444" fontWeight="medium">
|
||||
{myBet.type}
|
||||
</Text>
|
||||
<Text color="rgba(212, 175, 55, 0.9)" fontWeight="medium">
|
||||
{myBet.points}积分
|
||||
</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
<HStack justify="space-between" fontSize="xs" px={1}>
|
||||
<HStack spacing={4}>
|
||||
<HStack spacing={1}>
|
||||
<Text color="rgba(255, 255, 255, 0.5)">当前胜率:</Text>
|
||||
<Text color="rgba(255, 255, 255, 0.9)" fontWeight="medium">
|
||||
{winRate}%
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack spacing={1}>
|
||||
<Text color="rgba(255, 255, 255, 0.5)">赔率:</Text>
|
||||
<Text color="rgba(255, 255, 255, 0.9)" fontWeight="medium">
|
||||
{odds}
|
||||
</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
color="rgba(255, 255, 255, 0.6)"
|
||||
leftIcon={<Icon as={History} boxSize={3} />}
|
||||
_hover={{
|
||||
color: 'rgba(212, 175, 55, 0.9)',
|
||||
bg: 'rgba(212, 175, 55, 0.1)',
|
||||
}}
|
||||
onClick={onViewHistory}
|
||||
>
|
||||
历史战绩
|
||||
</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default PredictionCard;
|
||||
@@ -0,0 +1,3 @@
|
||||
// 价值论坛子组件导出
|
||||
export { default as PredictionCard } from './PredictionCard';
|
||||
export { default as CommunityFeedCard } from './CommunityFeedCard';
|
||||
49
src/views/Profile/components/ForumCenter/index.js
Normal file
49
src/views/Profile/components/ForumCenter/index.js
Normal file
@@ -0,0 +1,49 @@
|
||||
// 价值论坛 / 互动中心组件 (Forum Center)
|
||||
import React from 'react';
|
||||
import { Box, Text, HStack, SimpleGrid, Icon } from '@chakra-ui/react';
|
||||
import { MessageCircle } from 'lucide-react';
|
||||
import GlassCard from '@components/GlassCard';
|
||||
import { PredictionCard, CommunityFeedCard } from './components';
|
||||
|
||||
const ForumCenter = () => {
|
||||
return (
|
||||
<GlassCard
|
||||
variant="transparent"
|
||||
rounded="2xl"
|
||||
padding="md"
|
||||
hoverable={false}
|
||||
cornerDecor
|
||||
>
|
||||
{/* 标题栏 */}
|
||||
<HStack mb={4} spacing={2}>
|
||||
<Icon
|
||||
as={MessageCircle}
|
||||
boxSize={5}
|
||||
color="rgba(212, 175, 55, 0.9)"
|
||||
/>
|
||||
<Text
|
||||
fontSize="lg"
|
||||
fontWeight="bold"
|
||||
color="rgba(255, 255, 255, 0.95)"
|
||||
letterSpacing="wide"
|
||||
>
|
||||
价值论坛 / 互动中心
|
||||
</Text>
|
||||
<Box
|
||||
h="1px"
|
||||
flex={1}
|
||||
bgGradient="linear(to-r, rgba(212, 175, 55, 0.4), transparent)"
|
||||
ml={2}
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
{/* 两列布局:预测卡片 + 社区动态 */}
|
||||
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
|
||||
<PredictionCard />
|
||||
<CommunityFeedCard />
|
||||
</SimpleGrid>
|
||||
</GlassCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default ForumCenter;
|
||||
@@ -0,0 +1,50 @@
|
||||
// 市场概览仪表盘主组件 - 投资仪表盘
|
||||
import React from 'react';
|
||||
import { Box, Text, HStack, Icon } from '@chakra-ui/react';
|
||||
import { TrendingUp } from 'lucide-react';
|
||||
import GlassCard from '@components/GlassCard';
|
||||
import { MarketOverview } from './components';
|
||||
import { MOCK_INDICES, MOCK_MARKET_STATS } from './constants';
|
||||
|
||||
const MarketDashboard = ({
|
||||
indices = MOCK_INDICES,
|
||||
marketStats = MOCK_MARKET_STATS,
|
||||
}) => {
|
||||
return (
|
||||
<GlassCard
|
||||
variant="transparent"
|
||||
rounded="2xl"
|
||||
padding="md"
|
||||
hoverable={false}
|
||||
cornerDecor
|
||||
>
|
||||
{/* 标题栏 */}
|
||||
<HStack mb={4} spacing={2}>
|
||||
<Icon
|
||||
as={TrendingUp}
|
||||
boxSize={5}
|
||||
color="rgba(212, 175, 55, 0.9)"
|
||||
/>
|
||||
<Text
|
||||
fontSize="lg"
|
||||
fontWeight="bold"
|
||||
color="rgba(255, 255, 255, 0.95)"
|
||||
letterSpacing="wide"
|
||||
>
|
||||
投资仪表盘
|
||||
</Text>
|
||||
<Box
|
||||
h="1px"
|
||||
flex={1}
|
||||
bgGradient="linear(to-r, rgba(212, 175, 55, 0.4), transparent)"
|
||||
ml={2}
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
{/* 市场概况:指数卡片 + 成交额 + 涨跌分布 + 热门板块 */}
|
||||
<MarketOverview indices={indices} marketStats={marketStats} />
|
||||
</GlassCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarketDashboard;
|
||||
@@ -0,0 +1,45 @@
|
||||
// 热点概念组件
|
||||
import React from 'react';
|
||||
import { Box, Text, VStack, SimpleGrid, HStack, Icon } from '@chakra-ui/react';
|
||||
import { Flame } from 'lucide-react';
|
||||
import { ConceptItem } from './atoms';
|
||||
import { THEME } from '../constants';
|
||||
|
||||
const HotConcepts = ({ concepts = [], onConceptClick }) => {
|
||||
return (
|
||||
<Box
|
||||
borderRadius="xl"
|
||||
p={4}
|
||||
h="100%"
|
||||
>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
{/* 标题 */}
|
||||
<HStack spacing={2}>
|
||||
<Icon as={Flame} boxSize={4} color={THEME.status.up} />
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="bold"
|
||||
color="rgba(255, 255, 255, 0.9)"
|
||||
>
|
||||
热点概念
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{/* 概念列表 */}
|
||||
<SimpleGrid columns={{ base: 1, sm: 2 }} spacing={2}>
|
||||
{concepts.map((concept) => (
|
||||
<ConceptItem
|
||||
key={concept.id}
|
||||
name={concept.name}
|
||||
change={concept.change}
|
||||
trend={concept.trend}
|
||||
onClick={() => onConceptClick?.(concept)}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default HotConcepts;
|
||||
@@ -0,0 +1,81 @@
|
||||
// 市场概况组件 - 顶部横条(匹配设计图布局)
|
||||
import React from 'react';
|
||||
import { Box, SimpleGrid } from '@chakra-ui/react';
|
||||
import {
|
||||
IndexChartCard,
|
||||
TurnoverChart,
|
||||
RiseFallChart,
|
||||
HotSectorsRanking,
|
||||
} from './atoms';
|
||||
|
||||
const MarketOverview = ({ indices = [], marketStats = {} }) => {
|
||||
// 默认指数数据(带图表数据)
|
||||
const defaultIndices = [
|
||||
{
|
||||
code: 'sh000001',
|
||||
name: '上证指数',
|
||||
value: 3391.88,
|
||||
change: 1.23,
|
||||
chartData: [3350, 3360, 3355, 3370, 3365, 3380, 3375, 3390, 3385, 3392],
|
||||
},
|
||||
{
|
||||
code: 'sz399001',
|
||||
name: '深证成指',
|
||||
value: 10728.54,
|
||||
change: 0.86,
|
||||
chartData: [10650, 10680, 10660, 10700, 10690, 10720, 10710, 10730, 10720, 10728],
|
||||
},
|
||||
{
|
||||
code: 'sz399006',
|
||||
name: '创业板指',
|
||||
value: 2156.32,
|
||||
change: -0.45,
|
||||
chartData: [2180, 2175, 2170, 2165, 2168, 2160, 2165, 2158, 2160, 2156],
|
||||
},
|
||||
];
|
||||
|
||||
const displayIndices = indices.length > 0 ? indices : defaultIndices;
|
||||
|
||||
return (
|
||||
<Box borderRadius="xl">
|
||||
{/* 6列网格布局:3个指数卡片 + 成交额 + 涨跌分布 + 热门板块 */}
|
||||
<SimpleGrid
|
||||
columns={{ base: 2, md: 3, lg: 6 }}
|
||||
spacing={3}
|
||||
>
|
||||
{/* 指数卡片(带迷你图表) */}
|
||||
{displayIndices.map((index) => (
|
||||
<IndexChartCard
|
||||
key={index.code}
|
||||
name={index.name}
|
||||
value={index.value}
|
||||
change={index.change}
|
||||
chartData={index.chartData || []}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 成交额柱状图 */}
|
||||
<TurnoverChart
|
||||
data={marketStats.turnoverData || []}
|
||||
title="成交额"
|
||||
/>
|
||||
|
||||
{/* 涨跌分布图 */}
|
||||
<RiseFallChart
|
||||
riseCount={marketStats.riseCount || 2156}
|
||||
fallCount={marketStats.fallCount || 2034}
|
||||
flatCount={marketStats.flatCount || 312}
|
||||
title="涨跌分布"
|
||||
/>
|
||||
|
||||
{/* 热门板块排行 */}
|
||||
<HotSectorsRanking
|
||||
sectors={marketStats.hotSectors || []}
|
||||
title="热门板块"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarketOverview;
|
||||
@@ -0,0 +1,34 @@
|
||||
// AI平台能力统计组件 - 底部横条
|
||||
import React from 'react';
|
||||
import { Box, HStack, Divider } from '@chakra-ui/react';
|
||||
import { StatItem } from './atoms';
|
||||
import { THEME } from '../constants';
|
||||
|
||||
const PlatformStats = ({ stats = [] }) => {
|
||||
return (
|
||||
<Box
|
||||
borderRadius="xl"
|
||||
py={4}
|
||||
px={6}
|
||||
>
|
||||
<HStack justify="space-around" divider={
|
||||
<Divider
|
||||
orientation="vertical"
|
||||
h="40px"
|
||||
borderColor="rgba(212, 175, 55, 0.2)"
|
||||
/>
|
||||
}>
|
||||
{stats.map((stat, index) => (
|
||||
<StatItem
|
||||
key={index}
|
||||
icon={stat.icon}
|
||||
value={stat.value}
|
||||
label={stat.label}
|
||||
/>
|
||||
))}
|
||||
</HStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlatformStats;
|
||||
@@ -0,0 +1,179 @@
|
||||
// 交易日历组件
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Text,
|
||||
VStack,
|
||||
HStack,
|
||||
Grid,
|
||||
GridItem,
|
||||
IconButton,
|
||||
} from '@chakra-ui/react';
|
||||
import { ChevronLeft, ChevronRight, Calendar } from 'lucide-react';
|
||||
import { DayCell } from './atoms';
|
||||
import { THEME, WEEKDAY_LABELS } from '../constants';
|
||||
|
||||
const TradingCalendar = ({ tradingDays = [] }) => {
|
||||
const [currentDate, setCurrentDate] = useState(new Date());
|
||||
|
||||
const calendarData = useMemo(() => {
|
||||
const year = currentDate.getFullYear();
|
||||
const month = currentDate.getMonth();
|
||||
|
||||
// 当月第一天
|
||||
const firstDay = new Date(year, month, 1);
|
||||
// 当月最后一天
|
||||
const lastDay = new Date(year, month + 1, 0);
|
||||
// 第一天是星期几
|
||||
const startWeekday = firstDay.getDay();
|
||||
// 当月天数
|
||||
const daysInMonth = lastDay.getDate();
|
||||
|
||||
// 上月最后几天
|
||||
const prevMonthLastDay = new Date(year, month, 0).getDate();
|
||||
|
||||
const days = [];
|
||||
|
||||
// 填充上月日期
|
||||
for (let i = startWeekday - 1; i >= 0; i--) {
|
||||
days.push({
|
||||
day: prevMonthLastDay - i,
|
||||
isCurrentMonth: false,
|
||||
isWeekend: false,
|
||||
isTrading: false,
|
||||
});
|
||||
}
|
||||
|
||||
// 填充当月日期
|
||||
const today = new Date();
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
const date = new Date(year, month, day);
|
||||
const weekday = date.getDay();
|
||||
const isWeekend = weekday === 0 || weekday === 6;
|
||||
const isToday =
|
||||
day === today.getDate() &&
|
||||
month === today.getMonth() &&
|
||||
year === today.getFullYear();
|
||||
|
||||
// 检查是否为交易日(简化逻辑:非周末即交易日)
|
||||
// 实际应用中应该从 tradingDays 数组判断
|
||||
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
const isTrading = tradingDays.length > 0
|
||||
? tradingDays.includes(dateStr)
|
||||
: !isWeekend;
|
||||
|
||||
days.push({
|
||||
day,
|
||||
isCurrentMonth: true,
|
||||
isWeekend,
|
||||
isTrading,
|
||||
isToday,
|
||||
});
|
||||
}
|
||||
|
||||
// 填充下月日期(补满 6 行 * 7 天 = 42 格)
|
||||
const remaining = 42 - days.length;
|
||||
for (let day = 1; day <= remaining; day++) {
|
||||
days.push({
|
||||
day,
|
||||
isCurrentMonth: false,
|
||||
isWeekend: false,
|
||||
isTrading: false,
|
||||
});
|
||||
}
|
||||
|
||||
return days;
|
||||
}, [currentDate, tradingDays]);
|
||||
|
||||
const handlePrevMonth = () => {
|
||||
setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, 1));
|
||||
};
|
||||
|
||||
const handleNextMonth = () => {
|
||||
setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 1));
|
||||
};
|
||||
|
||||
const monthText = `${currentDate.getFullYear()}年${currentDate.getMonth() + 1}月`;
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderRadius="xl"
|
||||
p={4}
|
||||
h="100%"
|
||||
>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
{/* 日历头部 */}
|
||||
<HStack justify="space-between">
|
||||
<HStack spacing={2}>
|
||||
<Calendar size={16} color={THEME.text.gold} />
|
||||
<Text fontSize="sm" fontWeight="bold" color="rgba(255, 255, 255, 0.9)">
|
||||
交易日历
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack spacing={1}>
|
||||
<IconButton
|
||||
icon={<ChevronLeft size={16} />}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
color="rgba(255, 255, 255, 0.6)"
|
||||
onClick={handlePrevMonth}
|
||||
aria-label="上月"
|
||||
_hover={{ bg: 'rgba(212, 175, 55, 0.15)' }}
|
||||
/>
|
||||
<Text fontSize="xs" color="rgba(255, 255, 255, 0.9)" minW="70px" textAlign="center">
|
||||
{monthText}
|
||||
</Text>
|
||||
<IconButton
|
||||
icon={<ChevronRight size={16} />}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
color="rgba(255, 255, 255, 0.6)"
|
||||
onClick={handleNextMonth}
|
||||
aria-label="下月"
|
||||
_hover={{ bg: 'rgba(212, 175, 55, 0.15)' }}
|
||||
/>
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
{/* 星期标题 */}
|
||||
<Grid templateColumns="repeat(7, 1fr)" gap={0}>
|
||||
{WEEKDAY_LABELS.map((label, index) => (
|
||||
<GridItem key={label}>
|
||||
<Text
|
||||
fontSize="xs"
|
||||
color="rgba(255, 255, 255, 0.5)"
|
||||
textAlign="center"
|
||||
py={1}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</GridItem>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
{/* 日期网格 */}
|
||||
<Grid templateColumns="repeat(7, 1fr)" gap={0}>
|
||||
{calendarData.map((dayData, index) => (
|
||||
<GridItem
|
||||
key={index}
|
||||
display="flex"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
py={0.5}
|
||||
>
|
||||
<DayCell
|
||||
day={dayData.day}
|
||||
isTrading={dayData.isTrading}
|
||||
isToday={dayData.isToday || false}
|
||||
isWeekend={dayData.isWeekend}
|
||||
isCurrentMonth={dayData.isCurrentMonth}
|
||||
/>
|
||||
</GridItem>
|
||||
))}
|
||||
</Grid>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default TradingCalendar;
|
||||
@@ -0,0 +1,57 @@
|
||||
// 概念项组件
|
||||
import React from 'react';
|
||||
import { Box, Text, HStack } from '@chakra-ui/react';
|
||||
import { THEME } from '../../constants';
|
||||
import MiniTrendLine from './MiniTrendLine';
|
||||
|
||||
const ConceptItem = ({ name, change, trend = [], onClick }) => {
|
||||
const isUp = change >= 0;
|
||||
const changeColor = isUp ? THEME.status.up : THEME.status.down;
|
||||
const changeText = isUp ? `+${change.toFixed(2)}%` : `${change.toFixed(2)}%`;
|
||||
|
||||
return (
|
||||
<Box
|
||||
bg="rgba(26, 26, 46, 0.5)"
|
||||
borderRadius="md"
|
||||
px={3}
|
||||
py={2}
|
||||
cursor={onClick ? 'pointer' : 'default'}
|
||||
transition="all 0.2s"
|
||||
border="1px solid"
|
||||
borderColor="rgba(212, 175, 55, 0.1)"
|
||||
backdropFilter="blur(8px)"
|
||||
_hover={{
|
||||
bg: 'rgba(37, 37, 64, 0.6)',
|
||||
borderColor: 'rgba(212, 175, 55, 0.25)',
|
||||
}}
|
||||
onClick={onClick}
|
||||
>
|
||||
<HStack justify="space-between">
|
||||
<Text fontSize="sm" color="rgba(255, 255, 255, 0.9)" fontWeight="medium">
|
||||
{name}
|
||||
</Text>
|
||||
<HStack spacing={2}>
|
||||
{trend.length > 0 && (
|
||||
<MiniTrendLine
|
||||
data={trend}
|
||||
color={isUp ? 'red' : 'green'}
|
||||
width={36}
|
||||
height={16}
|
||||
/>
|
||||
)}
|
||||
<Text
|
||||
fontSize="sm"
|
||||
color={changeColor}
|
||||
fontWeight="medium"
|
||||
minW="55px"
|
||||
textAlign="right"
|
||||
>
|
||||
{changeText}
|
||||
</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConceptItem;
|
||||
@@ -0,0 +1,87 @@
|
||||
// 日期单元格组件
|
||||
import React from 'react';
|
||||
import { Box, Text } from '@chakra-ui/react';
|
||||
import { THEME } from '../../constants';
|
||||
|
||||
const DayCell = ({
|
||||
day,
|
||||
isTrading = true,
|
||||
isToday = false,
|
||||
isWeekend = false,
|
||||
isCurrentMonth = true,
|
||||
}) => {
|
||||
// 今天的样式(金色背景)
|
||||
if (isToday) {
|
||||
return (
|
||||
<Box
|
||||
w="28px"
|
||||
h="28px"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
borderRadius="full"
|
||||
bg={THEME.text.gold}
|
||||
boxShadow="0 0 8px rgba(212, 175, 55, 0.5)"
|
||||
>
|
||||
<Text fontSize="xs" color="#000" fontWeight="bold">
|
||||
{day}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// 非当月日期
|
||||
if (!isCurrentMonth) {
|
||||
return (
|
||||
<Box
|
||||
w="28px"
|
||||
h="28px"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<Text fontSize="xs" color="rgba(139, 149, 165, 0.3)">
|
||||
{day}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// 周末(非交易日)
|
||||
if (isWeekend || !isTrading) {
|
||||
return (
|
||||
<Box
|
||||
w="28px"
|
||||
h="28px"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<Text fontSize="xs" color="rgba(255, 255, 255, 0.3)">
|
||||
{day}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// 普通交易日
|
||||
return (
|
||||
<Box
|
||||
w="28px"
|
||||
h="28px"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
borderRadius="md"
|
||||
cursor="pointer"
|
||||
transition="all 0.15s"
|
||||
_hover={{ bg: 'rgba(212, 175, 55, 0.15)' }}
|
||||
>
|
||||
<Text fontSize="xs" color="rgba(255, 255, 255, 0.8)">
|
||||
{day}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default DayCell;
|
||||
@@ -0,0 +1,78 @@
|
||||
// 热门板块排行组件
|
||||
import React from 'react';
|
||||
import { Box, Text, VStack, HStack } from '@chakra-ui/react';
|
||||
import MiniTrendLine from './MiniTrendLine';
|
||||
|
||||
const HotSectorsRanking = ({ sectors = [], title = '热门板块排行' }) => {
|
||||
// 默认数据
|
||||
const defaultSectors = [
|
||||
{ rank: 1, name: '人工智能', change: 3.2, trend: [100, 102, 101, 104, 103, 106] },
|
||||
{ rank: 2, name: '新能源车', change: 1.8, trend: [100, 99, 101, 102, 101, 103] },
|
||||
{ rank: 3, name: '生物医药', change: 1.3, trend: [100, 101, 100, 102, 101, 102] },
|
||||
{ rank: 4, name: '消费科技', change: 1.2, trend: [100, 100, 101, 100, 102, 102] },
|
||||
{ rank: 5, name: '其他', change: 0.4, trend: [100, 100, 100, 101, 100, 101] },
|
||||
];
|
||||
|
||||
const data = sectors.length > 0 ? sectors : defaultSectors;
|
||||
|
||||
return (
|
||||
<Box
|
||||
bg="rgba(26, 26, 46, 0.7)"
|
||||
borderRadius="lg"
|
||||
p={3}
|
||||
minW="180px"
|
||||
border="1px solid"
|
||||
borderColor="rgba(212, 175, 55, 0.15)"
|
||||
backdropFilter="blur(8px)"
|
||||
>
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<Text fontSize="xs" color="rgba(255, 255, 255, 0.6)" fontWeight="medium">
|
||||
{title}
|
||||
</Text>
|
||||
|
||||
{/* 排行列表 */}
|
||||
<VStack spacing={1.5} align="stretch">
|
||||
{data.map((sector, index) => (
|
||||
<HStack key={index} justify="space-between" fontSize="xs">
|
||||
{/* 排名 */}
|
||||
<HStack spacing={2} flex={1}>
|
||||
<Text
|
||||
color={index < 3 ? 'rgba(212, 175, 55, 0.9)' : 'rgba(255, 255, 255, 0.5)'}
|
||||
fontWeight={index < 3 ? 'bold' : 'normal'}
|
||||
w="16px"
|
||||
>
|
||||
{sector.rank}
|
||||
</Text>
|
||||
<Text color="rgba(255, 255, 255, 0.85)" noOfLines={1}>
|
||||
{sector.name}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{/* 趋势线 */}
|
||||
<Box w="40px">
|
||||
<MiniTrendLine
|
||||
data={sector.trend}
|
||||
color={sector.change >= 0 ? 'red' : 'green'}
|
||||
width={40}
|
||||
height={14}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 涨跌幅 */}
|
||||
<Text
|
||||
color={sector.change >= 0 ? '#EF4444' : '#22C55E'}
|
||||
fontWeight="medium"
|
||||
w="50px"
|
||||
textAlign="right"
|
||||
>
|
||||
{sector.change >= 0 ? '+' : ''}{sector.change.toFixed(1)}%
|
||||
</Text>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default HotSectorsRanking;
|
||||
@@ -0,0 +1,55 @@
|
||||
// 指数卡片组件
|
||||
import React from 'react';
|
||||
import { Box, Text, HStack, VStack } from '@chakra-ui/react';
|
||||
import { THEME } from '../../constants';
|
||||
import MiniTrendLine from './MiniTrendLine';
|
||||
|
||||
const IndexCard = ({ name, value, change, trend = [] }) => {
|
||||
const isUp = change >= 0;
|
||||
const changeColor = isUp ? THEME.status.up : THEME.status.down;
|
||||
const changeText = isUp ? `+${change.toFixed(2)}%` : `${change.toFixed(2)}%`;
|
||||
|
||||
return (
|
||||
<Box
|
||||
bg="rgba(26, 26, 46, 0.6)"
|
||||
borderRadius="lg"
|
||||
p={3}
|
||||
minW="120px"
|
||||
transition="all 0.2s"
|
||||
border="1px solid"
|
||||
borderColor="rgba(212, 175, 55, 0.15)"
|
||||
backdropFilter="blur(8px)"
|
||||
_hover={{
|
||||
bg: 'rgba(37, 37, 64, 0.7)',
|
||||
borderColor: 'rgba(212, 175, 55, 0.3)',
|
||||
boxShadow: '0 0 12px rgba(212, 175, 55, 0.2)'
|
||||
}}
|
||||
>
|
||||
<VStack align="flex-start" spacing={1}>
|
||||
<Text fontSize="xs" color="rgba(255, 255, 255, 0.6)">
|
||||
{name}
|
||||
</Text>
|
||||
<HStack justify="space-between" w="100%" align="flex-end">
|
||||
<VStack align="flex-start" spacing={0}>
|
||||
<Text fontSize="lg" fontWeight="bold" color="rgba(255, 255, 255, 0.95)">
|
||||
{value.toLocaleString()}
|
||||
</Text>
|
||||
<Text fontSize="xs" color={changeColor} fontWeight="medium">
|
||||
{changeText}
|
||||
</Text>
|
||||
</VStack>
|
||||
{trend.length > 0 && (
|
||||
<MiniTrendLine
|
||||
data={trend}
|
||||
color={isUp ? 'red' : 'green'}
|
||||
width={40}
|
||||
height={20}
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default IndexCard;
|
||||
@@ -0,0 +1,96 @@
|
||||
// 指数图表卡片 - 带迷你K线图
|
||||
import React, { useMemo } from 'react';
|
||||
import { Box, Text, VStack, HStack } from '@chakra-ui/react';
|
||||
|
||||
const IndexChartCard = ({ name, value, change, chartData = [] }) => {
|
||||
const isUp = change >= 0;
|
||||
const changeColor = isUp ? '#EF4444' : '#22C55E';
|
||||
const changeText = isUp ? `+${change.toFixed(2)}%` : `${change.toFixed(2)}%`;
|
||||
|
||||
// 生成迷你图表路径
|
||||
const chartPath = useMemo(() => {
|
||||
if (!chartData || chartData.length < 2) return '';
|
||||
|
||||
const width = 120;
|
||||
const height = 40;
|
||||
const padding = 4;
|
||||
|
||||
const min = Math.min(...chartData);
|
||||
const max = Math.max(...chartData);
|
||||
const range = max - min || 1;
|
||||
|
||||
const points = chartData.map((val, i) => {
|
||||
const x = padding + (i / (chartData.length - 1)) * (width - padding * 2);
|
||||
const y = height - padding - ((val - min) / range) * (height - padding * 2);
|
||||
return `${x},${y}`;
|
||||
});
|
||||
|
||||
return `M ${points.join(' L ')}`;
|
||||
}, [chartData]);
|
||||
|
||||
// 生成填充区域
|
||||
const areaPath = useMemo(() => {
|
||||
if (!chartPath) return '';
|
||||
const width = 120;
|
||||
const height = 40;
|
||||
return `${chartPath} L ${width - 4},${height - 4} L 4,${height - 4} Z`;
|
||||
}, [chartPath]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
bg="rgba(26, 26, 46, 0.7)"
|
||||
borderRadius="lg"
|
||||
p={3}
|
||||
minW="160px"
|
||||
border="1px solid"
|
||||
borderColor="rgba(212, 175, 55, 0.15)"
|
||||
backdropFilter="blur(8px)"
|
||||
transition="all 0.2s"
|
||||
_hover={{
|
||||
borderColor: 'rgba(212, 175, 55, 0.3)',
|
||||
boxShadow: '0 0 12px rgba(212, 175, 55, 0.15)'
|
||||
}}
|
||||
>
|
||||
<VStack align="stretch" spacing={2}>
|
||||
{/* 标题 */}
|
||||
<Text fontSize="xs" color="rgba(255, 255, 255, 0.6)" fontWeight="medium">
|
||||
{name}
|
||||
</Text>
|
||||
|
||||
{/* 数值和涨跌幅 */}
|
||||
<HStack justify="space-between" align="baseline">
|
||||
<Text fontSize="lg" fontWeight="bold" color={changeColor}>
|
||||
{typeof value === 'number' ? value.toLocaleString(undefined, { minimumFractionDigits: 2 }) : value}
|
||||
</Text>
|
||||
<Text fontSize="sm" color={changeColor} fontWeight="medium">
|
||||
{changeText}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{/* 迷你图表 */}
|
||||
{chartData.length > 0 && (
|
||||
<Box h="40px" w="100%">
|
||||
<svg width="100%" height="40" viewBox="0 0 120 40" preserveAspectRatio="none">
|
||||
{/* 填充区域 */}
|
||||
<path
|
||||
d={areaPath}
|
||||
fill={isUp ? 'rgba(239, 68, 68, 0.1)' : 'rgba(34, 197, 94, 0.1)'}
|
||||
/>
|
||||
{/* 线条 */}
|
||||
<path
|
||||
d={chartPath}
|
||||
fill="none"
|
||||
stroke={changeColor}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</Box>
|
||||
)}
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default IndexChartCard;
|
||||
@@ -0,0 +1,50 @@
|
||||
// 迷你趋势线组件 - 基于 SVG
|
||||
import React, { useMemo } from 'react';
|
||||
import { Box } from '@chakra-ui/react';
|
||||
import { THEME } from '../../constants';
|
||||
|
||||
const MiniTrendLine = ({
|
||||
data = [],
|
||||
color = 'green',
|
||||
width = 60,
|
||||
height = 24,
|
||||
}) => {
|
||||
const pathD = useMemo(() => {
|
||||
if (!data || data.length < 2) return '';
|
||||
|
||||
const min = Math.min(...data);
|
||||
const max = Math.max(...data);
|
||||
const range = max - min || 1;
|
||||
|
||||
const points = data.map((value, index) => {
|
||||
const x = (index / (data.length - 1)) * width;
|
||||
const y = height - ((value - min) / range) * height * 0.8 - height * 0.1;
|
||||
return `${x},${y}`;
|
||||
});
|
||||
|
||||
return `M ${points.join(' L ')}`;
|
||||
}, [data, width, height]);
|
||||
|
||||
const strokeColor = color === 'red' ? THEME.status.up : THEME.status.down;
|
||||
|
||||
if (!data || data.length < 2) {
|
||||
return <Box w={`${width}px`} h={`${height}px`} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box w={`${width}px`} h={`${height}px`}>
|
||||
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`}>
|
||||
<path
|
||||
d={pathD}
|
||||
fill="none"
|
||||
stroke={strokeColor}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default MiniTrendLine;
|
||||
@@ -0,0 +1,92 @@
|
||||
// 涨跌分布图组件
|
||||
import React from 'react';
|
||||
import { Box, Text, VStack, HStack } from '@chakra-ui/react';
|
||||
|
||||
const RiseFallChart = ({
|
||||
riseCount = 2156,
|
||||
fallCount = 2034,
|
||||
flatCount = 312,
|
||||
title = '涨跌分布'
|
||||
}) => {
|
||||
const total = riseCount + fallCount + flatCount;
|
||||
const risePercent = ((riseCount / total) * 100).toFixed(1);
|
||||
const fallPercent = ((fallCount / total) * 100).toFixed(1);
|
||||
const flatPercent = ((flatCount / total) * 100).toFixed(1);
|
||||
|
||||
// 分布数据 - 模拟不同涨跌幅区间
|
||||
const distribution = [
|
||||
{ range: '>7%', rise: 86, fall: 12, label: '涨停' },
|
||||
{ range: '3-7%', rise: 420, fall: 180 },
|
||||
{ range: '0-3%', rise: 1650, fall: 0 },
|
||||
{ range: '-3-0%', rise: 0, fall: 1542 },
|
||||
{ range: '-7--3%', rise: 0, fall: 280 },
|
||||
{ range: '<-7%', rise: 0, fall: 20, label: '跌停' },
|
||||
];
|
||||
|
||||
const maxCount = Math.max(...distribution.map(d => Math.max(d.rise, d.fall)));
|
||||
|
||||
return (
|
||||
<Box
|
||||
bg="rgba(26, 26, 46, 0.7)"
|
||||
borderRadius="lg"
|
||||
p={3}
|
||||
minW="160px"
|
||||
border="1px solid"
|
||||
borderColor="rgba(212, 175, 55, 0.15)"
|
||||
backdropFilter="blur(8px)"
|
||||
>
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<Text fontSize="xs" color="rgba(255, 255, 255, 0.6)" fontWeight="medium">
|
||||
{title}
|
||||
</Text>
|
||||
|
||||
{/* 水平柱状图 */}
|
||||
<VStack spacing={1} align="stretch">
|
||||
{distribution.map((item, index) => (
|
||||
<HStack key={index} spacing={1} h="12px">
|
||||
{/* 涨(红色,向右) */}
|
||||
<Box flex={1} display="flex" justifyContent="flex-end">
|
||||
{item.rise > 0 && (
|
||||
<Box
|
||||
h="10px"
|
||||
w={`${(item.rise / maxCount) * 100}%`}
|
||||
bg="linear-gradient(90deg, rgba(239, 68, 68, 0.4) 0%, #EF4444 100%)"
|
||||
borderRadius="sm"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
{/* 中心线 */}
|
||||
<Box w="1px" h="10px" bg="rgba(255, 255, 255, 0.2)" />
|
||||
{/* 跌(绿色,向左显示但实际向右) */}
|
||||
<Box flex={1}>
|
||||
{item.fall > 0 && (
|
||||
<Box
|
||||
h="10px"
|
||||
w={`${(item.fall / maxCount) * 100}%`}
|
||||
bg="linear-gradient(90deg, #22C55E 0%, rgba(34, 197, 94, 0.4) 100%)"
|
||||
borderRadius="sm"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
|
||||
{/* 统计数字 */}
|
||||
<HStack justify="space-between" fontSize="xs">
|
||||
<Text color="#EF4444" fontWeight="medium">
|
||||
涨 {riseCount}
|
||||
</Text>
|
||||
<Text color="rgba(255, 255, 255, 0.5)">
|
||||
平 {flatCount}
|
||||
</Text>
|
||||
<Text color="#22C55E" fontWeight="medium">
|
||||
跌 {fallCount}
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default RiseFallChart;
|
||||
@@ -0,0 +1,44 @@
|
||||
// 统计卡片组件(涨停/跌停/成交额)
|
||||
import React from 'react';
|
||||
import { Box, Text, VStack } from '@chakra-ui/react';
|
||||
import { THEME } from '../../constants';
|
||||
|
||||
const StatCard = ({ label, value, subLabel, valueColor }) => {
|
||||
return (
|
||||
<Box
|
||||
bg="rgba(26, 26, 46, 0.6)"
|
||||
borderRadius="lg"
|
||||
p={3}
|
||||
minW="80px"
|
||||
textAlign="center"
|
||||
transition="all 0.2s"
|
||||
border="1px solid"
|
||||
borderColor="rgba(212, 175, 55, 0.15)"
|
||||
backdropFilter="blur(8px)"
|
||||
_hover={{
|
||||
bg: 'rgba(37, 37, 64, 0.7)',
|
||||
borderColor: 'rgba(212, 175, 55, 0.3)',
|
||||
}}
|
||||
>
|
||||
<VStack spacing={0}>
|
||||
<Text fontSize="xs" color="rgba(255, 255, 255, 0.6)">
|
||||
{label}
|
||||
</Text>
|
||||
<Text
|
||||
fontSize="xl"
|
||||
fontWeight="bold"
|
||||
color={valueColor || 'rgba(255, 255, 255, 0.95)'}
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
{subLabel && (
|
||||
<Text fontSize="xs" color="rgba(255, 255, 255, 0.5)">
|
||||
{subLabel}
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatCard;
|
||||
@@ -0,0 +1,47 @@
|
||||
// AI平台能力统计项组件
|
||||
import React from 'react';
|
||||
import { Box, Text, VStack, Icon } from '@chakra-ui/react';
|
||||
import {
|
||||
Building2,
|
||||
BarChart3,
|
||||
Calendar,
|
||||
Bot,
|
||||
TrendingUp,
|
||||
Database,
|
||||
} from 'lucide-react';
|
||||
import { THEME } from '../../constants';
|
||||
|
||||
// 图标映射
|
||||
const iconMap = {
|
||||
building: Building2,
|
||||
chart: BarChart3,
|
||||
calendar: Calendar,
|
||||
robot: Bot,
|
||||
trending: TrendingUp,
|
||||
database: Database,
|
||||
};
|
||||
|
||||
const StatItem = ({ icon, value, label }) => {
|
||||
const IconComponent = iconMap[icon] || Database;
|
||||
|
||||
return (
|
||||
<Box textAlign="center" px={4}>
|
||||
<VStack spacing={1}>
|
||||
<Icon
|
||||
as={IconComponent}
|
||||
boxSize={5}
|
||||
color={THEME.text.gold}
|
||||
mb={1}
|
||||
/>
|
||||
<Text fontSize="lg" fontWeight="bold" color="rgba(255, 255, 255, 0.95)">
|
||||
{value}
|
||||
</Text>
|
||||
<Text fontSize="xs" color="rgba(255, 255, 255, 0.6)">
|
||||
{label}
|
||||
</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatItem;
|
||||
@@ -0,0 +1,56 @@
|
||||
// 成交额柱状图组件
|
||||
import React from 'react';
|
||||
import { Box, Text, VStack, HStack } from '@chakra-ui/react';
|
||||
|
||||
const TurnoverChart = ({ data = [], title = '成交额' }) => {
|
||||
// 默认数据
|
||||
const chartData = data.length > 0 ? data : [
|
||||
{ time: '10:30', value: 0.85 },
|
||||
{ time: '11:00', value: 0.92 },
|
||||
{ time: '11:15', value: 0.78 },
|
||||
{ time: '13:00', value: 1.05 },
|
||||
{ time: '13:30', value: 1.12 },
|
||||
{ time: '14:00', value: 0.95 },
|
||||
];
|
||||
|
||||
const maxValue = Math.max(...chartData.map(d => d.value));
|
||||
|
||||
return (
|
||||
<Box
|
||||
bg="rgba(26, 26, 46, 0.7)"
|
||||
borderRadius="lg"
|
||||
p={3}
|
||||
minW="140px"
|
||||
border="1px solid"
|
||||
borderColor="rgba(212, 175, 55, 0.15)"
|
||||
backdropFilter="blur(8px)"
|
||||
>
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<Text fontSize="xs" color="rgba(255, 255, 255, 0.6)" fontWeight="medium">
|
||||
{title}
|
||||
</Text>
|
||||
|
||||
{/* 柱状图 */}
|
||||
<HStack spacing={1} align="flex-end" h="50px">
|
||||
{chartData.map((item, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
flex={1}
|
||||
h={`${(item.value / maxValue) * 100}%`}
|
||||
bg="linear-gradient(180deg, rgba(212, 175, 55, 0.8) 0%, rgba(212, 175, 55, 0.4) 100%)"
|
||||
borderRadius="sm"
|
||||
minH="4px"
|
||||
/>
|
||||
))}
|
||||
</HStack>
|
||||
|
||||
{/* 当前值 */}
|
||||
<Text fontSize="sm" fontWeight="bold" color="rgba(212, 175, 55, 0.9)">
|
||||
1.25亿
|
||||
</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default TurnoverChart;
|
||||
@@ -0,0 +1,11 @@
|
||||
// 原子组件导出
|
||||
export { default as MiniTrendLine } from './MiniTrendLine';
|
||||
export { default as IndexCard } from './IndexCard';
|
||||
export { default as StatCard } from './StatCard';
|
||||
export { default as ConceptItem } from './ConceptItem';
|
||||
export { default as DayCell } from './DayCell';
|
||||
export { default as StatItem } from './StatItem';
|
||||
export { default as IndexChartCard } from './IndexChartCard';
|
||||
export { default as TurnoverChart } from './TurnoverChart';
|
||||
export { default as RiseFallChart } from './RiseFallChart';
|
||||
export { default as HotSectorsRanking } from './HotSectorsRanking';
|
||||
@@ -0,0 +1,6 @@
|
||||
// 组件导出
|
||||
export { default as MarketOverview } from './MarketOverview';
|
||||
export { default as HotConcepts } from './HotConcepts';
|
||||
export { default as TradingCalendar } from './TradingCalendar';
|
||||
export { default as PlatformStats } from './PlatformStats';
|
||||
export * from './atoms';
|
||||
108
src/views/Profile/components/MarketDashboard/constants.ts
Normal file
108
src/views/Profile/components/MarketDashboard/constants.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
// MarketDashboard 常量定义
|
||||
|
||||
// 黑金主题配色
|
||||
export const THEME = {
|
||||
bg: {
|
||||
primary: '#0A0A0A', // 纯黑背景
|
||||
card: '#141414', // 卡片背景
|
||||
cardHover: '#1A1A1A', // 卡片悬停
|
||||
gradient: 'linear-gradient(135deg, #1a1a1a 0%, #0a0a0a 100%)',
|
||||
},
|
||||
text: {
|
||||
primary: '#FFFFFF', // 主文字(白色)
|
||||
secondary: '#8B8B8B', // 次要文字(灰色)
|
||||
accent: '#D4AF37', // 强调色(金色)
|
||||
gold: '#D4AF37', // 金色
|
||||
goldLight: '#F0D78C', // 浅金色
|
||||
},
|
||||
status: {
|
||||
up: '#EF4444', // 上涨(红色)
|
||||
down: '#22C55E', // 下跌(绿色)
|
||||
},
|
||||
border: 'rgba(212, 175, 55, 0.2)', // 金色边框
|
||||
borderGold: 'rgba(212, 175, 55, 0.4)',
|
||||
shadow: '0 4px 20px rgba(212, 175, 55, 0.1)',
|
||||
};
|
||||
|
||||
// 模拟数据(后续替换为真实 API)
|
||||
export const MOCK_INDICES: Array<{
|
||||
name: string;
|
||||
code: string;
|
||||
value: number;
|
||||
change: number;
|
||||
chartData: number[];
|
||||
}> = [
|
||||
{
|
||||
name: '上证指数',
|
||||
code: '000001.SH',
|
||||
value: 3391.88,
|
||||
change: 0.52,
|
||||
chartData: [3350, 3360, 3355, 3370, 3365, 3380, 3375, 3390, 3385, 3392],
|
||||
},
|
||||
{
|
||||
name: '深证成指',
|
||||
code: '399001.SZ',
|
||||
value: 10723.49,
|
||||
change: 0.68,
|
||||
chartData: [10650, 10680, 10660, 10700, 10690, 10720, 10710, 10730, 10720, 10723],
|
||||
},
|
||||
{
|
||||
name: '创业板指',
|
||||
code: '399006.SZ',
|
||||
value: 2156.78,
|
||||
change: 1.23,
|
||||
chartData: [2130, 2140, 2135, 2150, 2145, 2155, 2150, 2160, 2155, 2157],
|
||||
},
|
||||
];
|
||||
|
||||
export const MOCK_MARKET_STATS = {
|
||||
limitUp: 86,
|
||||
limitDown: 12,
|
||||
turnover: '1.2万亿',
|
||||
riseCount: 2156,
|
||||
fallCount: 2034,
|
||||
flatCount: 312,
|
||||
turnoverData: [
|
||||
{ time: '10:30', value: 0.85 },
|
||||
{ time: '11:00', value: 0.92 },
|
||||
{ time: '11:15', value: 0.78 },
|
||||
{ time: '13:00', value: 1.05 },
|
||||
{ time: '13:30', value: 1.12 },
|
||||
{ time: '14:00', value: 0.95 },
|
||||
],
|
||||
hotSectors: [
|
||||
{ rank: 1, name: '人工智能', change: 3.2, trend: [100, 102, 101, 104, 103, 106] },
|
||||
{ rank: 2, name: '新能源车', change: 1.8, trend: [100, 99, 101, 102, 101, 103] },
|
||||
{ rank: 3, name: '生物医药', change: 1.3, trend: [100, 101, 100, 102, 101, 102] },
|
||||
{ rank: 4, name: '消费科技', change: 1.2, trend: [100, 100, 101, 100, 102, 102] },
|
||||
{ rank: 5, name: '半导体', change: 0.9, trend: [100, 100, 100, 101, 100, 101] },
|
||||
],
|
||||
};
|
||||
|
||||
export const MOCK_HOT_CONCEPTS: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
change: number;
|
||||
trend: number[];
|
||||
}> = [
|
||||
{ id: '1', name: '人工智能', change: 3.25, trend: [100, 102, 101, 103, 105, 108] },
|
||||
{ id: '2', name: '芯片概念', change: 2.87, trend: [100, 99, 101, 102, 104, 106] },
|
||||
{ id: '3', name: '新能源车', change: 2.15, trend: [100, 101, 100, 102, 103, 105] },
|
||||
{ id: '4', name: '光伏', change: 1.92, trend: [100, 99, 100, 101, 102, 104] },
|
||||
{ id: '5', name: '医药生物', change: 1.56, trend: [100, 100, 101, 101, 102, 103] },
|
||||
{ id: '6', name: '消费电子', change: 1.33, trend: [100, 101, 100, 101, 102, 103] },
|
||||
];
|
||||
|
||||
export const MOCK_PLATFORM_STATS: Array<{
|
||||
icon: string;
|
||||
value: string;
|
||||
label: string;
|
||||
}> = [
|
||||
{ icon: 'building', value: '4300+', label: '上市公司' },
|
||||
{ icon: 'chart', value: '500+', label: '概念板块' },
|
||||
{ icon: 'calendar', value: '10年+', label: '历史数据' },
|
||||
{ icon: 'robot', value: '24/7', label: 'AI分析' },
|
||||
];
|
||||
|
||||
// 星期标题
|
||||
export const WEEKDAY_LABELS = ['日', '一', '二', '三', '四', '五', '六'];
|
||||
3
src/views/Profile/components/MarketDashboard/index.js
Normal file
3
src/views/Profile/components/MarketDashboard/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
// MarketDashboard 组件导出
|
||||
export { default } from './MarketDashboard';
|
||||
export { default as MarketDashboard } from './MarketDashboard';
|
||||
88
src/views/Profile/components/MarketDashboard/types.ts
Normal file
88
src/views/Profile/components/MarketDashboard/types.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
// MarketDashboard 类型定义
|
||||
|
||||
// 迷你趋势线
|
||||
export interface MiniTrendLineProps {
|
||||
data: number[];
|
||||
color?: 'green' | 'red';
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
// 指数卡片
|
||||
export interface IndexCardProps {
|
||||
name: string;
|
||||
value: number;
|
||||
change: number;
|
||||
trend?: number[];
|
||||
}
|
||||
|
||||
// 统计卡片(涨停/成交额)
|
||||
export interface StatCardProps {
|
||||
label: string;
|
||||
value: string | number;
|
||||
subLabel?: string;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
// 概念项
|
||||
export interface ConceptItemProps {
|
||||
name: string;
|
||||
change: number;
|
||||
trend?: number[];
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
// 日期单元格
|
||||
export interface DayCellProps {
|
||||
day: number;
|
||||
isTrading: boolean;
|
||||
isToday: boolean;
|
||||
isWeekend: boolean;
|
||||
isCurrentMonth?: boolean;
|
||||
}
|
||||
|
||||
// 统计项(AI平台能力)
|
||||
export interface StatItemProps {
|
||||
icon: React.ReactNode;
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
// 指数数据
|
||||
export interface IndexData {
|
||||
name: string;
|
||||
code: string;
|
||||
value: number;
|
||||
change: number;
|
||||
trend: number[];
|
||||
}
|
||||
|
||||
// 概念数据
|
||||
export interface ConceptData {
|
||||
id: string;
|
||||
name: string;
|
||||
change: number;
|
||||
trend: number[];
|
||||
}
|
||||
|
||||
// 市场统计数据
|
||||
export interface MarketStats {
|
||||
limitUp: number;
|
||||
limitDown: number;
|
||||
turnover: string;
|
||||
}
|
||||
|
||||
// 平台能力数据
|
||||
export interface PlatformStat {
|
||||
icon: string;
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
// 完整仪表盘数据
|
||||
export interface MarketDashboardData {
|
||||
indices: IndexData[];
|
||||
marketStats: MarketStats;
|
||||
hotConcepts: ConceptData[];
|
||||
platformStats: PlatformStat[];
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// AI 算力交易卡片
|
||||
import React from 'react';
|
||||
import { Box, Text, VStack, HStack, Icon } from '@chakra-ui/react';
|
||||
import { Cpu, TrendingUp, Lightbulb } from 'lucide-react';
|
||||
|
||||
const AITradingCard = ({
|
||||
title = 'AI 算力交易',
|
||||
currentProfit = 8.5,
|
||||
targetProfit = 8.5,
|
||||
strategies = [
|
||||
'AI:界缓充提镂',
|
||||
'筱略:高源分析',
|
||||
],
|
||||
}) => {
|
||||
const profitColor = currentProfit >= 0 ? '#EF4444' : '#22C55E';
|
||||
const profitSign = currentProfit >= 0 ? '+' : '';
|
||||
|
||||
return (
|
||||
<Box
|
||||
bg="rgba(26, 26, 46, 0.7)"
|
||||
borderRadius="lg"
|
||||
p={4}
|
||||
border="1px solid"
|
||||
borderColor="rgba(212, 175, 55, 0.15)"
|
||||
backdropFilter="blur(8px)"
|
||||
h="100%"
|
||||
>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
{/* 标题 */}
|
||||
<HStack spacing={2}>
|
||||
<Icon as={Cpu} boxSize={4} color="#22C55E" />
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="bold"
|
||||
color="rgba(255, 255, 255, 0.95)"
|
||||
>
|
||||
[{title}]
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{/* 浮盈数据 */}
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<HStack spacing={2}>
|
||||
<Icon as={TrendingUp} boxSize={3} color={profitColor} />
|
||||
<Text fontSize="xs" color="rgba(255, 255, 255, 0.6)">
|
||||
当前浮盈:
|
||||
</Text>
|
||||
<Text fontSize="xs" color={profitColor} fontWeight="bold">
|
||||
{profitSign}{currentProfit}%
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
<HStack spacing={2}>
|
||||
<Icon as={TrendingUp} boxSize={3} color={profitColor} />
|
||||
<Text fontSize="xs" color="rgba(255, 255, 255, 0.6)">
|
||||
当前浮盈:
|
||||
</Text>
|
||||
<Text fontSize="xs" color={profitColor} fontWeight="bold">
|
||||
{profitSign}{targetProfit}%
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
{/* 策略列表 */}
|
||||
<VStack align="stretch" spacing={1}>
|
||||
<HStack spacing={1}>
|
||||
<Icon as={Lightbulb} boxSize={3} color="rgba(212, 175, 55, 0.9)" />
|
||||
<Text fontSize="xs" color="rgba(255, 255, 255, 0.6)">
|
||||
策略:
|
||||
</Text>
|
||||
</HStack>
|
||||
{strategies.map((item, index) => (
|
||||
<HStack key={index} spacing={1} fontSize="xs" pl={4}>
|
||||
<Text color="rgba(255, 255, 255, 0.5)">•</Text>
|
||||
<Text color="rgba(255, 255, 255, 0.85)">{item}</Text>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AITradingCard;
|
||||
@@ -0,0 +1,81 @@
|
||||
// 银行股防守卡片
|
||||
import React from 'react';
|
||||
import { Box, Text, VStack, HStack, Icon } from '@chakra-ui/react';
|
||||
import { Building2, Lock, TrendingUp, Lightbulb } from 'lucide-react';
|
||||
|
||||
const DefenseStrategyCard = ({
|
||||
title = '银行股防守',
|
||||
position = '30%',
|
||||
strategy = '高股息',
|
||||
strategies = [
|
||||
'AI辅AEI银行化分析',
|
||||
'AI:筏跌股鬈股',
|
||||
],
|
||||
}) => {
|
||||
return (
|
||||
<Box
|
||||
bg="rgba(26, 26, 46, 0.7)"
|
||||
borderRadius="lg"
|
||||
p={4}
|
||||
border="1px solid"
|
||||
borderColor="rgba(212, 175, 55, 0.15)"
|
||||
backdropFilter="blur(8px)"
|
||||
h="100%"
|
||||
>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
{/* 标题 */}
|
||||
<HStack spacing={2}>
|
||||
<Icon as={Building2} boxSize={4} color="rgba(212, 175, 55, 0.9)" />
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="bold"
|
||||
color="rgba(255, 255, 255, 0.95)"
|
||||
>
|
||||
[{title}]
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{/* 仓位和策略 */}
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<HStack spacing={2}>
|
||||
<Icon as={Lock} boxSize={3} color="rgba(255, 255, 255, 0.5)" />
|
||||
<Text fontSize="xs" color="rgba(255, 255, 255, 0.6)">
|
||||
仓位:
|
||||
</Text>
|
||||
<Text fontSize="xs" color="rgba(255, 255, 255, 0.9)" fontWeight="medium">
|
||||
{position}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
<HStack spacing={2}>
|
||||
<Icon as={TrendingUp} boxSize={3} color="rgba(255, 255, 255, 0.5)" />
|
||||
<Text fontSize="xs" color="rgba(255, 255, 255, 0.6)">
|
||||
策略:
|
||||
</Text>
|
||||
<Text fontSize="xs" color="rgba(255, 255, 255, 0.9)" fontWeight="medium">
|
||||
{strategy}
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
{/* 策略列表 */}
|
||||
<VStack align="stretch" spacing={1}>
|
||||
<HStack spacing={1}>
|
||||
<Icon as={Lightbulb} boxSize={3} color="#22C55E" />
|
||||
<Text fontSize="xs" color="rgba(255, 255, 255, 0.6)">
|
||||
策略:
|
||||
</Text>
|
||||
</HStack>
|
||||
{strategies.map((item, index) => (
|
||||
<HStack key={index} spacing={1} fontSize="xs" pl={4}>
|
||||
<Text color="rgba(255, 255, 255, 0.5)">•</Text>
|
||||
<Text color="rgba(255, 255, 255, 0.85)">{item}</Text>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default DefenseStrategyCard;
|
||||
@@ -0,0 +1,126 @@
|
||||
// 季度计划卡片 - 2025 Q1 计划
|
||||
import React from 'react';
|
||||
import { Box, Text, VStack, HStack, Progress, Icon } from '@chakra-ui/react';
|
||||
import { Calendar } from 'lucide-react';
|
||||
|
||||
const QuarterPlanCard = ({
|
||||
title = '2025 Q1 计划',
|
||||
progress = { execute: 70, strategy: 100, target: 15 },
|
||||
keyPoints = [
|
||||
{ label: '重点', value: 'AI、数字经济' },
|
||||
{ label: '重点', value: 'AI、数字经济' },
|
||||
{ label: '目标', value: '收益率+15%' },
|
||||
],
|
||||
}) => {
|
||||
return (
|
||||
<Box
|
||||
bg="rgba(26, 26, 46, 0.7)"
|
||||
borderRadius="lg"
|
||||
p={4}
|
||||
border="1px solid"
|
||||
borderColor="rgba(212, 175, 55, 0.15)"
|
||||
backdropFilter="blur(8px)"
|
||||
h="100%"
|
||||
>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
{/* 标题 */}
|
||||
<HStack spacing={2}>
|
||||
<Icon as={Calendar} boxSize={4} color="rgba(100, 149, 237, 0.9)" />
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="bold"
|
||||
color="rgba(255, 255, 255, 0.95)"
|
||||
>
|
||||
[{title}]
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{/* 进度条区域 */}
|
||||
<VStack align="stretch" spacing={2}>
|
||||
{/* 进行 */}
|
||||
<HStack spacing={2}>
|
||||
<Text fontSize="xs" color="rgba(255, 255, 255, 0.6)" w="35px">
|
||||
进行
|
||||
</Text>
|
||||
<Box flex={1}>
|
||||
<Progress
|
||||
value={progress.execute}
|
||||
size="sm"
|
||||
borderRadius="full"
|
||||
bg="rgba(255, 255, 255, 0.1)"
|
||||
sx={{
|
||||
'& > div': {
|
||||
bg: 'linear-gradient(90deg, #3B82F6 0%, #60A5FA 100%)',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Text fontSize="xs" color="rgba(255, 255, 255, 0.8)" w="35px" textAlign="right">
|
||||
{progress.execute}%
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{/* 缓略 */}
|
||||
<HStack spacing={2}>
|
||||
<Text fontSize="xs" color="rgba(255, 255, 255, 0.6)" w="35px">
|
||||
缓略
|
||||
</Text>
|
||||
<Box flex={1}>
|
||||
<Progress
|
||||
value={progress.strategy}
|
||||
size="sm"
|
||||
borderRadius="full"
|
||||
bg="rgba(255, 255, 255, 0.1)"
|
||||
sx={{
|
||||
'& > div': {
|
||||
bg: 'linear-gradient(90deg, #3B82F6 0%, #60A5FA 100%)',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Text fontSize="xs" color="rgba(255, 255, 255, 0.8)" w="35px" textAlign="right">
|
||||
{progress.strategy}%
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{/* 目标 */}
|
||||
<HStack spacing={2}>
|
||||
<Text fontSize="xs" color="rgba(255, 255, 255, 0.6)" w="35px">
|
||||
目标
|
||||
</Text>
|
||||
<Box flex={1}>
|
||||
<Progress
|
||||
value={Math.abs(progress.target)}
|
||||
max={20}
|
||||
size="sm"
|
||||
borderRadius="full"
|
||||
bg="rgba(255, 255, 255, 0.1)"
|
||||
sx={{
|
||||
'& > div': {
|
||||
bg: 'linear-gradient(90deg, #3B82F6 0%, #60A5FA 100%)',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Text fontSize="xs" color="#22C55E" w="35px" textAlign="right">
|
||||
+{progress.target}%
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
{/* 要点列表 */}
|
||||
<VStack align="stretch" spacing={1} mt={1}>
|
||||
{keyPoints.map((point, index) => (
|
||||
<HStack key={index} spacing={1} fontSize="xs">
|
||||
<Text color="rgba(255, 255, 255, 0.5)">•</Text>
|
||||
<Text color="rgba(255, 255, 255, 0.6)">{point.label}:</Text>
|
||||
<Text color="rgba(255, 255, 255, 0.85)">{point.value}</Text>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuarterPlanCard;
|
||||
@@ -0,0 +1,143 @@
|
||||
// 消费复盘卡片(带图表)
|
||||
import React, { useMemo } from 'react';
|
||||
import { Box, Text, VStack, HStack, Icon } from '@chakra-ui/react';
|
||||
import { ShoppingBag, Heart } from 'lucide-react';
|
||||
|
||||
const ReviewCard = ({
|
||||
title = '消费复盘',
|
||||
chartData = [8000, 10000, 9500, 11000, 12000, 11500, 10500, 9000, 10000, 11000, 12500, 11000],
|
||||
insight = '关注复苏节奏',
|
||||
}) => {
|
||||
// 生成图表路径
|
||||
const { linePath, areaPath } = useMemo(() => {
|
||||
if (!chartData || chartData.length < 2) return { linePath: '', areaPath: '' };
|
||||
|
||||
const width = 140;
|
||||
const height = 60;
|
||||
const padding = { top: 8, right: 4, bottom: 16, left: 4 };
|
||||
|
||||
const chartWidth = width - padding.left - padding.right;
|
||||
const chartHeight = height - padding.top - padding.bottom;
|
||||
|
||||
const min = Math.min(...chartData);
|
||||
const max = Math.max(...chartData);
|
||||
const range = max - min || 1;
|
||||
|
||||
const points = chartData.map((val, i) => {
|
||||
const x = padding.left + (i / (chartData.length - 1)) * chartWidth;
|
||||
const y = padding.top + chartHeight - ((val - min) / range) * chartHeight;
|
||||
return { x, y };
|
||||
});
|
||||
|
||||
const linePathStr = points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x},${p.y}`).join(' ');
|
||||
|
||||
const areaPathStr = `${linePathStr} L ${points[points.length - 1].x},${height - padding.bottom} L ${padding.left},${height - padding.bottom} Z`;
|
||||
|
||||
return { linePath: linePathStr, areaPath: areaPathStr };
|
||||
}, [chartData]);
|
||||
|
||||
// Y轴刻度
|
||||
const yTicks = useMemo(() => {
|
||||
const max = Math.max(...chartData);
|
||||
return [max, Math.round(max / 2), 0];
|
||||
}, [chartData]);
|
||||
|
||||
// X轴刻度
|
||||
const xTicks = ['10:05', '11:13', '12:15', '13:05'];
|
||||
|
||||
return (
|
||||
<Box
|
||||
bg="rgba(26, 26, 46, 0.7)"
|
||||
borderRadius="lg"
|
||||
p={4}
|
||||
border="1px solid"
|
||||
borderColor="rgba(212, 175, 55, 0.15)"
|
||||
backdropFilter="blur(8px)"
|
||||
h="100%"
|
||||
>
|
||||
<VStack align="stretch" spacing={2}>
|
||||
{/* 标题 */}
|
||||
<HStack spacing={2}>
|
||||
<Icon as={ShoppingBag} boxSize={4} color="#F59E0B" />
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="bold"
|
||||
color="rgba(255, 255, 255, 0.95)"
|
||||
>
|
||||
[{title}]
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{/* 图表区域 */}
|
||||
<Box position="relative" h="70px">
|
||||
<svg width="100%" height="70" viewBox="0 0 140 70" preserveAspectRatio="none">
|
||||
{/* Y轴标签 */}
|
||||
<text x="2" y="12" fontSize="6" fill="rgba(255,255,255,0.4)">
|
||||
{yTicks[0]}
|
||||
</text>
|
||||
<text x="2" y="35" fontSize="6" fill="rgba(255,255,255,0.4)">
|
||||
{yTicks[1]}
|
||||
</text>
|
||||
<text x="2" y="58" fontSize="6" fill="rgba(255,255,255,0.4)">
|
||||
{yTicks[2]}
|
||||
</text>
|
||||
|
||||
{/* 网格线 */}
|
||||
<line x1="20" y1="8" x2="136" y2="8" stroke="rgba(255,255,255,0.1)" strokeWidth="0.5" strokeDasharray="2,2" />
|
||||
<line x1="20" y1="30" x2="136" y2="30" stroke="rgba(255,255,255,0.1)" strokeWidth="0.5" strokeDasharray="2,2" />
|
||||
<line x1="20" y1="52" x2="136" y2="52" stroke="rgba(255,255,255,0.1)" strokeWidth="0.5" strokeDasharray="2,2" />
|
||||
|
||||
{/* 填充区域 */}
|
||||
<path
|
||||
d={areaPath}
|
||||
fill="url(#reviewGradient)"
|
||||
/>
|
||||
|
||||
{/* 线条 */}
|
||||
<path
|
||||
d={linePath}
|
||||
fill="none"
|
||||
stroke="#F59E0B"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
|
||||
{/* 渐变定义 */}
|
||||
<defs>
|
||||
<linearGradient id="reviewGradient" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stopColor="rgba(245, 158, 11, 0.3)" />
|
||||
<stop offset="100%" stopColor="rgba(245, 158, 11, 0.05)" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
{/* X轴标签 */}
|
||||
<HStack
|
||||
position="absolute"
|
||||
bottom="0"
|
||||
left="15px"
|
||||
right="4px"
|
||||
justify="space-between"
|
||||
fontSize="6px"
|
||||
color="rgba(255,255,255,0.4)"
|
||||
>
|
||||
{xTicks.map((tick, i) => (
|
||||
<Text key={i}>{tick}</Text>
|
||||
))}
|
||||
</HStack>
|
||||
</Box>
|
||||
|
||||
{/* 心得 */}
|
||||
<HStack spacing={1} fontSize="xs">
|
||||
<Text color="rgba(255, 255, 255, 0.5)">•</Text>
|
||||
<Icon as={Heart} boxSize={3} color="#EF4444" />
|
||||
<Text color="rgba(255, 255, 255, 0.6)">心得:</Text>
|
||||
<Text color="rgba(255, 255, 255, 0.85)">{insight}</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReviewCard;
|
||||
@@ -0,0 +1,5 @@
|
||||
// 投资规划中心子组件导出
|
||||
export { default as QuarterPlanCard } from './QuarterPlanCard';
|
||||
export { default as DefenseStrategyCard } from './DefenseStrategyCard';
|
||||
export { default as AITradingCard } from './AITradingCard';
|
||||
export { default as ReviewCard } from './ReviewCard';
|
||||
63
src/views/Profile/components/StrategyCenter/index.js
Normal file
63
src/views/Profile/components/StrategyCenter/index.js
Normal file
@@ -0,0 +1,63 @@
|
||||
// 投资规划中心组件 (Strategy Center)
|
||||
import React from 'react';
|
||||
import { Box, Text, HStack, SimpleGrid, Icon } from '@chakra-ui/react';
|
||||
import { Target } from 'lucide-react';
|
||||
import GlassCard from '@components/GlassCard';
|
||||
import {
|
||||
QuarterPlanCard,
|
||||
DefenseStrategyCard,
|
||||
AITradingCard,
|
||||
ReviewCard,
|
||||
} from './components';
|
||||
|
||||
const StrategyCenter = () => {
|
||||
return (
|
||||
<GlassCard
|
||||
variant="transparent"
|
||||
rounded="2xl"
|
||||
padding="md"
|
||||
hoverable={false}
|
||||
cornerDecor
|
||||
>
|
||||
{/* 标题栏 */}
|
||||
<HStack mb={4} spacing={2}>
|
||||
<Icon
|
||||
as={Target}
|
||||
boxSize={5}
|
||||
color="rgba(212, 175, 55, 0.9)"
|
||||
/>
|
||||
<Text
|
||||
fontSize="lg"
|
||||
fontWeight="bold"
|
||||
color="rgba(255, 255, 255, 0.95)"
|
||||
letterSpacing="wide"
|
||||
>
|
||||
投资规划中心
|
||||
</Text>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
color="rgba(255, 255, 255, 0.5)"
|
||||
fontWeight="normal"
|
||||
>
|
||||
(Strategy Center)
|
||||
</Text>
|
||||
<Box
|
||||
h="1px"
|
||||
flex={1}
|
||||
bgGradient="linear(to-r, rgba(212, 175, 55, 0.4), transparent)"
|
||||
ml={2}
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
{/* 4列卡片布局 */}
|
||||
<SimpleGrid columns={{ base: 1, md: 2, lg: 4 }} spacing={4}>
|
||||
<QuarterPlanCard />
|
||||
<DefenseStrategyCard />
|
||||
<AITradingCard />
|
||||
<ReviewCard />
|
||||
</SimpleGrid>
|
||||
</GlassCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default StrategyCenter;
|
||||
@@ -0,0 +1,110 @@
|
||||
// 关注事件面板 - 紧凑版
|
||||
import React from 'react';
|
||||
import { Box, Text, VStack, HStack, Icon } from '@chakra-ui/react';
|
||||
import { Star, Plus, Users } from 'lucide-react';
|
||||
|
||||
const FollowingEventsPanel = ({
|
||||
events = [],
|
||||
onEventClick,
|
||||
onAddEvent,
|
||||
}) => {
|
||||
return (
|
||||
<Box>
|
||||
{/* 标题 */}
|
||||
<HStack justify="space-between" mb={2}>
|
||||
<HStack spacing={1}>
|
||||
<Icon as={Star} boxSize={3.5} color="rgba(234, 179, 8, 0.9)" />
|
||||
<Text fontSize="xs" fontWeight="bold" color="rgba(255, 255, 255, 0.9)">
|
||||
关注事件
|
||||
</Text>
|
||||
<Text fontSize="xs" color="rgba(255, 255, 255, 0.5)">
|
||||
({events.length})
|
||||
</Text>
|
||||
</HStack>
|
||||
<Icon
|
||||
as={Plus}
|
||||
boxSize={3.5}
|
||||
color="rgba(255, 255, 255, 0.5)"
|
||||
cursor="pointer"
|
||||
_hover={{ color: 'rgba(212, 175, 55, 0.9)' }}
|
||||
onClick={onAddEvent}
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
{/* 事件列表 */}
|
||||
<VStack spacing={1.5} align="stretch">
|
||||
{events.length === 0 ? (
|
||||
<Box
|
||||
py={4}
|
||||
textAlign="center"
|
||||
cursor="pointer"
|
||||
onClick={onAddEvent}
|
||||
_hover={{ bg: 'rgba(255, 255, 255, 0.05)' }}
|
||||
borderRadius="md"
|
||||
>
|
||||
<Icon as={Star} boxSize={6} color="rgba(255, 255, 255, 0.2)" mb={1} />
|
||||
<Text fontSize="xs" color="rgba(255, 255, 255, 0.4)">
|
||||
关注事件
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
events.slice(0, 6).map((event) => {
|
||||
const avgChg = event.related_avg_chg;
|
||||
const isUp = avgChg > 0;
|
||||
const changeColor = isUp ? '#EF4444' : avgChg < 0 ? '#22C55E' : 'rgba(255, 255, 255, 0.6)';
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={event.id}
|
||||
py={2}
|
||||
px={2}
|
||||
cursor="pointer"
|
||||
borderRadius="md"
|
||||
bg="rgba(37, 37, 64, 0.3)"
|
||||
_hover={{ bg: 'rgba(37, 37, 64, 0.6)' }}
|
||||
onClick={() => onEventClick?.(event)}
|
||||
>
|
||||
<Text
|
||||
fontSize="xs"
|
||||
fontWeight="medium"
|
||||
color="rgba(255, 255, 255, 0.9)"
|
||||
noOfLines={2}
|
||||
mb={1}
|
||||
lineHeight="1.4"
|
||||
>
|
||||
{event.title}
|
||||
</Text>
|
||||
<HStack justify="space-between" fontSize="10px">
|
||||
<HStack spacing={1} color="rgba(255, 255, 255, 0.4)">
|
||||
<Icon as={Users} boxSize={2.5} />
|
||||
<Text>{event.follower_count || 0}</Text>
|
||||
</HStack>
|
||||
{avgChg !== undefined && avgChg !== null && (
|
||||
<Text color={changeColor} fontWeight="medium">
|
||||
{isUp ? '+' : ''}{Number(avgChg).toFixed(2)}%
|
||||
</Text>
|
||||
)}
|
||||
</HStack>
|
||||
</Box>
|
||||
);
|
||||
})
|
||||
)}
|
||||
{events.length > 6 && (
|
||||
<Text
|
||||
fontSize="xs"
|
||||
color="rgba(212, 175, 55, 0.7)"
|
||||
textAlign="center"
|
||||
cursor="pointer"
|
||||
py={1}
|
||||
_hover={{ color: 'rgba(212, 175, 55, 0.9)' }}
|
||||
onClick={onAddEvent}
|
||||
>
|
||||
查看全部 ({events.length})
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default FollowingEventsPanel;
|
||||
@@ -0,0 +1,114 @@
|
||||
// 关注股票面板 - 紧凑版
|
||||
import React from 'react';
|
||||
import { Box, Text, VStack, HStack, Icon } from '@chakra-ui/react';
|
||||
import { BarChart2, Plus } from 'lucide-react';
|
||||
|
||||
const WatchlistPanel = ({
|
||||
watchlist = [],
|
||||
realtimeQuotes = {},
|
||||
onStockClick,
|
||||
onAddStock,
|
||||
}) => {
|
||||
return (
|
||||
<Box>
|
||||
{/* 标题 */}
|
||||
<HStack justify="space-between" mb={2}>
|
||||
<HStack spacing={1}>
|
||||
<Icon as={BarChart2} boxSize={3.5} color="rgba(59, 130, 246, 0.9)" />
|
||||
<Text fontSize="xs" fontWeight="bold" color="rgba(255, 255, 255, 0.9)">
|
||||
关注股票
|
||||
</Text>
|
||||
<Text fontSize="xs" color="rgba(255, 255, 255, 0.5)">
|
||||
({watchlist.length})
|
||||
</Text>
|
||||
</HStack>
|
||||
<Icon
|
||||
as={Plus}
|
||||
boxSize={3.5}
|
||||
color="rgba(255, 255, 255, 0.5)"
|
||||
cursor="pointer"
|
||||
_hover={{ color: 'rgba(212, 175, 55, 0.9)' }}
|
||||
onClick={onAddStock}
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
{/* 股票列表 */}
|
||||
<VStack spacing={1} align="stretch">
|
||||
{watchlist.length === 0 ? (
|
||||
<Box
|
||||
py={4}
|
||||
textAlign="center"
|
||||
cursor="pointer"
|
||||
onClick={onAddStock}
|
||||
_hover={{ bg: 'rgba(255, 255, 255, 0.05)' }}
|
||||
borderRadius="md"
|
||||
>
|
||||
<Icon as={BarChart2} boxSize={6} color="rgba(255, 255, 255, 0.2)" mb={1} />
|
||||
<Text fontSize="xs" color="rgba(255, 255, 255, 0.4)">
|
||||
添加自选股
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
watchlist.slice(0, 8).map((stock) => {
|
||||
const quote = realtimeQuotes[stock.stock_code];
|
||||
const changePercent = quote?.change_percent ?? stock.change_percent;
|
||||
const isUp = changePercent > 0;
|
||||
const changeColor = isUp ? '#EF4444' : changePercent < 0 ? '#22C55E' : 'rgba(255, 255, 255, 0.6)';
|
||||
|
||||
return (
|
||||
<HStack
|
||||
key={stock.stock_code}
|
||||
py={1.5}
|
||||
px={2}
|
||||
justify="space-between"
|
||||
cursor="pointer"
|
||||
borderRadius="md"
|
||||
_hover={{ bg: 'rgba(255, 255, 255, 0.05)' }}
|
||||
onClick={() => onStockClick?.(stock)}
|
||||
>
|
||||
<VStack align="start" spacing={0} flex={1} minW={0}>
|
||||
<Text
|
||||
fontSize="xs"
|
||||
fontWeight="medium"
|
||||
color="rgba(255, 255, 255, 0.9)"
|
||||
noOfLines={1}
|
||||
>
|
||||
{stock.stock_name || stock.stock_code}
|
||||
</Text>
|
||||
<Text fontSize="10px" color="rgba(255, 255, 255, 0.4)">
|
||||
{stock.stock_code}
|
||||
</Text>
|
||||
</VStack>
|
||||
<VStack align="end" spacing={0}>
|
||||
<Text fontSize="xs" fontWeight="bold" color={changeColor}>
|
||||
{quote?.current_price?.toFixed(2) || stock.current_price || '--'}
|
||||
</Text>
|
||||
<Text fontSize="10px" color={changeColor}>
|
||||
{changePercent !== undefined && changePercent !== null
|
||||
? `${isUp ? '+' : ''}${Number(changePercent).toFixed(2)}%`
|
||||
: '--'}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
);
|
||||
})
|
||||
)}
|
||||
{watchlist.length > 8 && (
|
||||
<Text
|
||||
fontSize="xs"
|
||||
color="rgba(212, 175, 55, 0.7)"
|
||||
textAlign="center"
|
||||
cursor="pointer"
|
||||
py={1}
|
||||
_hover={{ color: 'rgba(212, 175, 55, 0.9)' }}
|
||||
onClick={onAddStock}
|
||||
>
|
||||
查看全部 ({watchlist.length})
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default WatchlistPanel;
|
||||
@@ -0,0 +1,3 @@
|
||||
// 侧边栏子组件导出
|
||||
export { default as WatchlistPanel } from './WatchlistPanel';
|
||||
export { default as FollowingEventsPanel } from './FollowingEventsPanel';
|
||||
50
src/views/Profile/components/WatchSidebar/index.js
Normal file
50
src/views/Profile/components/WatchSidebar/index.js
Normal file
@@ -0,0 +1,50 @@
|
||||
// 右侧边栏 - 关注股票和关注事件(两个独立模块)
|
||||
import React from 'react';
|
||||
import { VStack } from '@chakra-ui/react';
|
||||
import GlassCard from '@components/GlassCard';
|
||||
import { WatchlistPanel, FollowingEventsPanel } from './components';
|
||||
|
||||
const WatchSidebar = ({
|
||||
watchlist = [],
|
||||
realtimeQuotes = {},
|
||||
followingEvents = [],
|
||||
onStockClick,
|
||||
onEventClick,
|
||||
onAddStock,
|
||||
onAddEvent,
|
||||
}) => {
|
||||
return (
|
||||
<VStack spacing={4} align="stretch">
|
||||
{/* 关注股票 - 独立模块 */}
|
||||
<GlassCard
|
||||
variant="transparent"
|
||||
rounded="xl"
|
||||
padding="sm"
|
||||
hoverable={false}
|
||||
>
|
||||
<WatchlistPanel
|
||||
watchlist={watchlist}
|
||||
realtimeQuotes={realtimeQuotes}
|
||||
onStockClick={onStockClick}
|
||||
onAddStock={onAddStock}
|
||||
/>
|
||||
</GlassCard>
|
||||
|
||||
{/* 关注事件 - 独立模块 */}
|
||||
<GlassCard
|
||||
variant="transparent"
|
||||
rounded="xl"
|
||||
padding="sm"
|
||||
hoverable={false}
|
||||
>
|
||||
<FollowingEventsPanel
|
||||
events={followingEvents}
|
||||
onEventClick={onEventClick}
|
||||
onAddEvent={onAddEvent}
|
||||
/>
|
||||
</GlassCard>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default WatchSidebar;
|
||||
Reference in New Issue
Block a user