Compare commits

...

7 Commits

Author SHA1 Message Date
zdl
18ba36a539 refactor(Center): 全面优化个人中心模块
- 目录重命名:Dashboard → Center(匹配路由 /home/center)
- 删除遗留代码:Default.js、InvestmentPlansAndReviews.js、InvestmentCalendarChakra.js(共 2596 行)
- 创建 src/types/center.ts 类型定义(15+ 接口)
- 性能优化:
  - 创建 useCenterColors Hook 封装 7 个 useColorModeValue
  - 创建 utils/formatters.ts 提取纯函数
  - 修复 loadRealtimeQuotes 的 useCallback 依赖项
  - InvestmentPlanningCenter 添加 useMemo 缓存
- TypeScript 迁移:Center.js → Center.tsx

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-22 18:59:09 +08:00
zdl
c639b418f0 refactor(Center): 重构个人中心为左右布局
- 左侧自适应:投资仪表盘、规划中心、论坛
- 右侧固定200px:关注股票、关注事件
- 使用 THEME 黑金配色
- 宽度与导航栏保持一致

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-22 18:59:09 +08:00
zdl
712090accb feat(WatchSidebar): 新增右侧边栏组件
- 关注股票面板(独立模块)
- 关注事件面板(独立模块)
- 固定200px宽度,粘性定位

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-22 18:59:08 +08:00
zdl
bc844bb4dc feat(ForumCenter): 新增价值论坛/互动中心组件
- 我的预测卡片(看涨/看跌投票)
- 社区动态卡片(我发布的/我参与的)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-22 18:59:08 +08:00
zdl
10e34d911f feat(StrategyCenter): 新增投资规划中心组件
- Q1计划卡片(进度条+要点列表)
- 银行股防守卡片(仓位+策略)
- AI算力交易卡片(浮盈数据)
- 消费复盘卡片(趋势图+心得)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-22 18:59:08 +08:00
zdl
1a55e037c9 feat(MarketDashboard): 新增投资仪表盘组件
- 指数卡片组件(带迷你面积图)
- 成交额柱状图、涨跌分布图组件
- 热门板块排行组件
- 毛玻璃背景,黑金配色

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-22 18:59:08 +08:00
zdl
16c30b45b9 feat(GlassCard): 新增通用毛玻璃卡片组件
- 支持多种变体: default, elevated, subtle, transparent
- 支持悬停效果、发光效果、角落装饰
- 黑金配色主题,可全局复用

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-22 18:59:08 +08:00
59 changed files with 3407 additions and 3228 deletions

View 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 };

View File

@@ -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
View 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;
}

View File

@@ -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
View 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;

View File

@@ -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}>

View File

@@ -0,0 +1,5 @@
/**
* Center 模块 Hooks 导出
*/
export { useCenterColors, default as useCenterColorsDefault } from './useCenterColors';

View 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;

View File

@@ -0,0 +1,4 @@
// src/views/Center/index.js
// 入口文件,导出 Center 组件
export { default } from './Center';

View 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)}%`;
}

View File

@@ -0,0 +1,11 @@
/**
* Center 模块工具函数导出
*/
export {
formatRelativeTime,
formatCompactNumber,
getHeatColor,
getChangeColor,
formatChangePercent,
} from './formatters';

View File

@@ -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

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -0,0 +1,3 @@
// 价值论坛子组件导出
export { default as PredictionCard } from './PredictionCard';
export { default as CommunityFeedCard } from './CommunityFeedCard';

View 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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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';

View File

@@ -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';

View 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 = ['日', '一', '二', '三', '四', '五', '六'];

View File

@@ -0,0 +1,3 @@
// MarketDashboard 组件导出
export { default } from './MarketDashboard';
export { default as MarketDashboard } from './MarketDashboard';

View 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[];
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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';

View 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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -0,0 +1,3 @@
// 侧边栏子组件导出
export { default as WatchlistPanel } from './WatchlistPanel';
export { default as FollowingEventsPanel } from './FollowingEventsPanel';

View 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;