diff --git a/src/routes/lazy-components.js b/src/routes/lazy-components.js index 37fed848..b383e89a 100644 --- a/src/routes/lazy-components.js +++ b/src/routes/lazy-components.js @@ -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')), diff --git a/src/types/center.ts b/src/types/center.ts new file mode 100644 index 00000000..a0e377a4 --- /dev/null +++ b/src/types/center.ts @@ -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; + +/** + * 关注的事件 + * 来自 /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; + + /** 刷新实时行情 */ + refreshQuotes: () => Promise; +} + +// ============================================================ +// 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; +} diff --git a/src/types/index.ts b/src/types/index.ts index 4c1540e9..0ea713f8 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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'; diff --git a/src/views/Dashboard/Center.js b/src/views/Center/Center.tsx similarity index 61% rename from src/views/Dashboard/Center.js rename to src/views/Center/Center.tsx index 4e855d82..345c87c4 100644 --- a/src/views/Dashboard/Center.js +++ b/src/views/Center/Center.tsx @@ -1,66 +1,26 @@ -// 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'; +/** + * 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, - 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 { 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'; @@ -69,38 +29,87 @@ import ForumCenter from '@views/Profile/components/ForumCenter'; import WatchSidebar from '@views/Profile/components/WatchSidebar'; import { THEME } from '@views/Profile/components/MarketDashboard/constants'; -export default function CenterDashboard() { +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 为独立变量 + // 提取 userId 为独立变量(优化依赖项) const userId = user?.id; - // 🎯 初始化Dashboard埋点Hook + // 初始化 Dashboard 埋点 Hook(类型断言为 DashboardEventsResult) const dashboardEvents = useDashboardEvents({ pageType: 'center', navigate - }); + }) as DashboardEventsResult; - // 颜色主题 - 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'); + // 颜色主题(使用 useCenterColors 封装,避免 7 次 useColorModeValue 调用) + const { secondaryText } = useCenterColors(); - 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 [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 () => { + // 使用 ref 跟踪是否已经加载过数据(首次加载标记) + const hasLoadedRef = useRef(false); + + /** + * 加载实时行情 + */ + const loadRealtimeQuotes = useCallback(async (): Promise => { + 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 => { try { const base = getApiBase(); const ts = Date.now(); @@ -111,14 +120,15 @@ export default function CenterDashboard() { 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(); + 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); } @@ -128,18 +138,20 @@ export default function CenterDashboard() { 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) { @@ -150,82 +162,9 @@ export default function CenterDashboard() { } 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); + }, [userId, loadRealtimeQuotes, dashboardEvents]); + // 首次加载和页面可见性变化时加载数据 useEffect(() => { const isOnCenterPage = location.pathname.includes('/home/center'); @@ -236,12 +175,13 @@ export default function CenterDashboard() { loadData(); } - const onVis = () => { + 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]); @@ -259,7 +199,7 @@ export default function CenterDashboard() { const interval = setInterval(() => { loadRealtimeQuotes(); }, 60000); // 60秒刷新一次 - + return () => clearInterval(interval); } }, [watchlist.length, loadRealtimeQuotes]); @@ -327,8 +267,8 @@ export default function CenterDashboard() { watchlist={watchlist} realtimeQuotes={realtimeQuotes} followingEvents={followingEvents} - onStockClick={(stock) => navigate(`/company/${stock.stock_code}`)} - onEventClick={(event) => navigate(getEventDetailUrl(event.id))} + onStockClick={(stock: WatchlistItem) => navigate(`/company/${stock.stock_code}`)} + onEventClick={(event: FollowingEvent) => navigate(getEventDetailUrl(event.id))} onAddStock={() => navigate('/stocks')} onAddEvent={() => navigate('/community')} /> @@ -337,6 +277,6 @@ export default function CenterDashboard() { ); -} - +}; +export default CenterDashboard; diff --git a/src/views/Dashboard/components/CalendarPanel.tsx b/src/views/Center/components/CalendarPanel.tsx similarity index 100% rename from src/views/Dashboard/components/CalendarPanel.tsx rename to src/views/Center/components/CalendarPanel.tsx diff --git a/src/views/Dashboard/components/EventCard.tsx b/src/views/Center/components/EventCard.tsx similarity index 100% rename from src/views/Dashboard/components/EventCard.tsx rename to src/views/Center/components/EventCard.tsx diff --git a/src/views/Dashboard/components/EventDetailModal.tsx b/src/views/Center/components/EventDetailModal.tsx similarity index 100% rename from src/views/Dashboard/components/EventDetailModal.tsx rename to src/views/Center/components/EventDetailModal.tsx diff --git a/src/views/Dashboard/components/EventEmptyState.tsx b/src/views/Center/components/EventEmptyState.tsx similarity index 100% rename from src/views/Dashboard/components/EventEmptyState.tsx rename to src/views/Center/components/EventEmptyState.tsx diff --git a/src/views/Dashboard/components/EventFormModal.less b/src/views/Center/components/EventFormModal.less similarity index 100% rename from src/views/Dashboard/components/EventFormModal.less rename to src/views/Center/components/EventFormModal.less diff --git a/src/views/Dashboard/components/EventFormModal.tsx b/src/views/Center/components/EventFormModal.tsx similarity index 100% rename from src/views/Dashboard/components/EventFormModal.tsx rename to src/views/Center/components/EventFormModal.tsx diff --git a/src/views/Dashboard/components/EventPanel.tsx b/src/views/Center/components/EventPanel.tsx similarity index 100% rename from src/views/Dashboard/components/EventPanel.tsx rename to src/views/Center/components/EventPanel.tsx diff --git a/src/views/Dashboard/components/InvestmentCalendar.less b/src/views/Center/components/InvestmentCalendar.less similarity index 100% rename from src/views/Dashboard/components/InvestmentCalendar.less rename to src/views/Center/components/InvestmentCalendar.less diff --git a/src/views/Dashboard/components/InvestmentPlanningCenter.tsx b/src/views/Center/components/InvestmentPlanningCenter.tsx similarity index 87% rename from src/views/Dashboard/components/InvestmentPlanningCenter.tsx rename to src/views/Center/components/InvestmentPlanningCenter.tsx index 06899ff7..92402ac1 100644 --- a/src/views/Dashboard/components/InvestmentPlanningCenter.tsx +++ b/src/views/Center/components/InvestmentPlanningCenter.tsx @@ -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 ( diff --git a/src/views/Dashboard/components/MyFutureEvents.js b/src/views/Center/components/MyFutureEvents.js similarity index 100% rename from src/views/Dashboard/components/MyFutureEvents.js rename to src/views/Center/components/MyFutureEvents.js diff --git a/src/views/Dashboard/components/PlanningContext.tsx b/src/views/Center/components/PlanningContext.tsx similarity index 100% rename from src/views/Dashboard/components/PlanningContext.tsx rename to src/views/Center/components/PlanningContext.tsx diff --git a/src/views/Center/hooks/index.ts b/src/views/Center/hooks/index.ts new file mode 100644 index 00000000..7dfe76df --- /dev/null +++ b/src/views/Center/hooks/index.ts @@ -0,0 +1,5 @@ +/** + * Center 模块 Hooks 导出 + */ + +export { useCenterColors, default as useCenterColorsDefault } from './useCenterColors'; diff --git a/src/views/Center/hooks/useCenterColors.ts b/src/views/Center/hooks/useCenterColors.ts new file mode 100644 index 00000000..db2d0edf --- /dev/null +++ b/src/views/Center/hooks/useCenterColors.ts @@ -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; diff --git a/src/views/Center/index.js b/src/views/Center/index.js new file mode 100644 index 00000000..37dca6a2 --- /dev/null +++ b/src/views/Center/index.js @@ -0,0 +1,4 @@ +// src/views/Center/index.js +// 入口文件,导出 Center 组件 + +export { default } from './Center'; diff --git a/src/views/Center/utils/formatters.ts b/src/views/Center/utils/formatters.ts new file mode 100644 index 00000000..19c2af30 --- /dev/null +++ b/src/views/Center/utils/formatters.ts @@ -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)}%`; +} diff --git a/src/views/Center/utils/index.ts b/src/views/Center/utils/index.ts new file mode 100644 index 00000000..ebecc059 --- /dev/null +++ b/src/views/Center/utils/index.ts @@ -0,0 +1,11 @@ +/** + * Center 模块工具函数导出 + */ + +export { + formatRelativeTime, + formatCompactNumber, + getHeatColor, + getChangeColor, + formatChangePercent, +} from './formatters'; diff --git a/src/views/Dashboard/Default.js b/src/views/Dashboard/Default.js deleted file mode 100755 index 81353909..00000000 --- a/src/views/Dashboard/Default.js +++ /dev/null @@ -1,1421 +0,0 @@ -/*! - -========================================================= -* Argon Dashboard Chakra PRO - v1.0.0 -========================================================= - -* Product Page: https://www.creative-tim.com/product/argon-dashboard-chakra-pro -* Copyright 2022 Creative Tim (https://www.creative-tim.com/) - -* Designed and Coded by Simmmple & Creative Tim - -========================================================= - -* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -*/ - -// Chakra imports -import { - Avatar, - AvatarGroup, - Badge, - Box, - Button, - Checkbox, - Flex, - Grid, - Icon, - Image, - Input, - Progress, - SimpleGrid, - Spacer, - Stack, - Stat, - StatLabel, - StatNumber, - Table, - Tbody, - Td, - Text, - Th, - Thead, - Tr, - useColorMode, - useColorModeValue, -} from '@chakra-ui/react'; -import avatar10 from 'assets/img/avatars/avatar10.png'; -import avatar2 from 'assets/img/avatars/avatar2.png'; -import avatar3 from 'assets/img/avatars/avatar3.png'; -import avatar4 from 'assets/img/avatars/avatar4.png'; -import avatar5 from 'assets/img/avatars/avatar5.png'; -import handBg from 'assets/img/hand-background.png'; -import teamsImage from 'assets/img/teams-image.png'; -// Custom components -import Card from 'components/Card/Card.js'; -import CardBody from 'components/Card/CardBody.js'; -import CardHeader from 'components/Card/CardHeader.js'; -import LineChart from 'components/Charts/LineChart'; -import IconBox from 'components/Icons/IconBox'; -import { BitcoinLogo } from 'components/Icons/Icons'; -// Custom icons -import { - AdobexdLogo, - CartIcon, - DocumentIcon, - GlobeIcon, - JiraLogo, - RocketIcon, - SettingsIcon, - SlackLogo, - SpotifyLogo, - WalletIcon, -} from 'components/Icons/Icons.js'; -import { HSeparator } from 'components/Separator/Separator'; -import TablesReportsRow from 'components/Tables/TablesReportsRow'; -import TablesTableRow from 'components/Tables/TablesTableRow'; -import React from 'react'; -import { AiFillLike, AiOutlinePlus } from 'react-icons/ai'; -import { - FaChevronDown, - FaChevronUp, - FaCommentDots, - FaUser, -} from 'react-icons/fa'; -import { IoMdShareAlt } from 'react-icons/io'; -import { IoBulb } from 'react-icons/io5'; -import { RiArrowDropRightLine } from 'react-icons/ri'; -import { - lineChartDataDefault, - lineChartOptionsDefault, -} from 'variables/charts'; -import { tablesReportsData, tablesTableData } from 'variables/general'; - -export default function Default() { - // Chakra Color Mode - const iconBlue = useColorModeValue('blue.500', 'blue.500'); - const iconBoxInside = useColorModeValue('white', 'white'); - const textColor = useColorModeValue('gray.700', 'white'); - const borderColor = useColorModeValue('gray.200', 'gray.600'); - const cardColor = useColorModeValue('gray.800', 'navy.800'); - const bgBox = useColorModeValue('gray.800', 'blue.500'); - - const { colorMode } = useColorMode(); - - return ( - - - - - - - - Today's Money - - - - $53,897 - - - - - - - - - - +3.48%{' '} - - Since last month - - - - - - - - - Today's Users - - - - $3,200 - - - - - - - - - - +5.2%{' '} - - Since last month - - - - - - - - - New Clients - - - - +2,503 - - - - - - - - - - -2.82%{' '} - - Since last month - - - - - - - - - Total Sales - - - - $173,000 - - - - - - - - - - +8.12%{' '} - - Since last month - - - - - - - - - - - - Get started with Argon - - - Start your development process with an innovative admin dashboard! - - - - - - - Sales Overview - - - - (+5%) more - {' '} - in 2022 - - - - - - - - - - Team Members - - - - - - - - Esthera Jackson - - - Online - - - - - - - - - - - Esthera Jackson - - - In meeting - - - - - - - - - - - Esthera Jackson - - - Offline - - - - - - - - - - - Esthera Jackson - - - Online - - - - - - - - - - - - - To Do List - - - - - - Call with Dave - - - 09:30 AM - - - - - - - - Brunch Meeting - - - 11:00 AM - - - - - - - - Argon Dashboard Launch - - - 02:00 PM - - - - - - - - Winter Hackaton - - - 11:30 AM - - - - - - - - - - - Progress Track - - - - - - - React Material Dashboard - - - - - - - - - Argon Design System - - - - - - - - - VueJs Now UI Kit PRO - - - - - - - - - Soft UI Dashboard - - - - - - - - - - - - - - - - - - - Esthera Jackson - - - 3 days ago - - - - - - - - - Personal profiles are the perfect way for you to grab their - attention and persuade recruiters to continue reading your CV - because you’re telling them from the off exactly why they should - hire you. - - - - - - - 1502 - - - - 36 - - - - 12 - - - - - - - - - - - and 30+ more - - - - - - - - - - - Michael Lewis - - - I always felt like I could do anything. That’s the main thing - people are controlled by! Thoughts- their perception of - themselves! - - - - - 3 likes - - - - 2 shares - - - - - - - - - - - Jessica Stones - - - Society has put up so many boundaries, so many limitations on - what’s right and wrong that it’s almost impossible to get a - pure thought out. It’s like a little kid, a little boy. - - - - - 10 likes - - - - 1 share - - - - - - - - - - - Anthony Joshua - - - It's all about work ! Great ideas mean nothing if they aren't - realised by hungry, desiring people. - - - - - 42 likes - - - - 6 shares - - - - - - - - - - - - - - - - - - - - - - - - - - - - {tablesReportsData - .filter((_, idx) => idx < 4) - .map((row, index, arr) => { - return ( - - ); - })} - -
- Author - - Function - - Review - - Employed - - Date - - Id -
-
-
- - - - - - ${' '} - - 3,300 - - - Your current balance - - - - +15%{' '} - - ($250) - - - - - - Orders: 60% - - - - - - Sales: 40% - - - - - - - - - - - - Active - - - - Address - - - 0yx8Wkasd8uWpa083Jj81qZhs923K21 - - - - - Name - - - John Snow - - - - - - - - - - - - - - - - - - - - - Sales by Country - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- Country: - - Sales: - - Value: - - Bounce: -
- - 🇺🇸 - - United States - - - - - 2500 - - - - $214,000 - - - - 40,22% - -
- - 🇩🇪 - - Germany - - - - - 3900 - - - - $446,700 - - - - 19,22% - -
- - 🇬🇧 - - Great Britain - - - - - 1300 - - - - $121,900 - - - - 39,22% - -
- - 🇧🇷 - - Brasil - - - - - 920 - - - - $52,100 - - - {' '} - - 29,9% - -
-
-
-
-
- - - - - - - - - - - - - - - {tablesTableData.map((row, index, arr) => { - return ( - - - - ); - })} - -
- Author - - Function - - Status - - Employed -
-
-
- - - - Categories - - - - - - - - - - - - Devices - - - 250 in stock,{' '} - - 346+ sold - - - - - - - - - - - - - - - Tickets - - - 123 closed,{' '} - - 15 open - - - - - - - - - - - - - - - Error logs - - - 1 is active,{' '} - - 40 closed - - - - - - - - - - - - - - - Happy Users - - - - +430 - - - - - - - - - - - - - - - Tickets - - - 123 closed,{' '} - - 15 open - - - - - - - - - - -
-
- ); -} diff --git a/src/views/Dashboard/components/InvestmentCalendarChakra.js b/src/views/Dashboard/components/InvestmentCalendarChakra.js deleted file mode 100644 index 4f1a72e9..00000000 --- a/src/views/Dashboard/components/InvestmentCalendarChakra.js +++ /dev/null @@ -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 ( - - - - - - 投资日历 - - - - - - {loading ? ( -
- -
- ) : ( - - - - )} -
- - {/* 查看事件详情 Modal - 条件渲染 */} - {isOpen && ( - - - - - {selectedDate && selectedDate.format('YYYY年MM月DD日')} 的事件 - - - - {selectedDateEvents.length === 0 ? ( -
- - 当天没有事件 - - -
- ) : ( - - {selectedDateEvents.map((event, idx) => ( - - - - - - {event.title} - - {event.extendedProps?.isSystem ? ( - 系统事件 - ) : ( - 我的计划 - )} - - - - - 重要度: {event.extendedProps?.importance || 3}/5 - - - - {!event.extendedProps?.isSystem && ( - } - size="sm" - variant="ghost" - colorScheme="red" - onClick={() => handleDeleteEvent(event.extendedProps?.id)} - /> - )} - - - {event.extendedProps?.description && ( - - {event.extendedProps.description} - - )} - - {event.extendedProps?.stocks && event.extendedProps.stocks.length > 0 && ( - - - 相关股票: - {event.extendedProps.stocks.map((stock, i) => ( - handleStockClick(stock, event.start)} - _hover={{ transform: 'scale(1.05)', shadow: 'md' }} - transition="all 0.2s" - > - - {stock} - - ))} - - {selectedStock && ( - - - - - )} - - )} - - ))} - - )} -
- - - -
-
- )} - - {/* 添加投资计划 Modal - 条件渲染 */} - {isAddOpen && ( - - - - - 添加投资计划 - - - - - - 标题 - setNewEvent({ ...newEvent, title: e.target.value })} - placeholder="例如:关注半导体板块" - /> - - - - 描述 -