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>
This commit is contained in:
@@ -11,7 +11,7 @@ export const lazyComponents = {
|
||||
// Home 模块
|
||||
// ⚡ 直接引用 HomePage,无需中间层(静态页面不需要骨架屏)
|
||||
HomePage: React.lazy(() => import('@views/Home/HomePage')),
|
||||
CenterDashboard: React.lazy(() => import('@views/Dashboard/Center')),
|
||||
CenterDashboard: React.lazy(() => import('@views/Center')),
|
||||
ProfilePage: React.lazy(() => import('@views/Profile/ProfilePage')),
|
||||
// 价值论坛 - 我的积分页面
|
||||
ForumMyPoints: React.lazy(() => import('@views/Profile')),
|
||||
|
||||
349
src/types/center.ts
Normal file
349
src/types/center.ts
Normal file
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* Center(个人中心)模块类型定义
|
||||
*
|
||||
* 包含自选股、实时行情、关注事件等类型
|
||||
*/
|
||||
|
||||
import type { NavigateFunction } from 'react-router-dom';
|
||||
|
||||
// ============================================================
|
||||
// Dashboard Events Hook 类型定义
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* useDashboardEvents Hook 配置选项
|
||||
*/
|
||||
export interface DashboardEventsOptions {
|
||||
/** 页面类型 */
|
||||
pageType?: 'center' | 'profile' | 'settings';
|
||||
/** 路由导航函数 */
|
||||
navigate?: NavigateFunction;
|
||||
}
|
||||
|
||||
/**
|
||||
* useDashboardEvents Hook 返回值
|
||||
*/
|
||||
export interface DashboardEventsResult {
|
||||
/** 追踪功能卡片点击 */
|
||||
trackFunctionCardClicked: (cardName: string, cardData?: { count?: number }) => void;
|
||||
/** 追踪自选股列表查看 */
|
||||
trackWatchlistViewed: (stockCount?: number, hasRealtime?: boolean) => void;
|
||||
/** 追踪自选股点击 */
|
||||
trackWatchlistStockClicked: (stock: { code: string; name?: string }, position?: number) => void;
|
||||
/** 追踪自选股添加 */
|
||||
trackWatchlistStockAdded: (stock: { code: string; name?: string }, source?: string) => void;
|
||||
/** 追踪自选股移除 */
|
||||
trackWatchlistStockRemoved: (stock: { code: string; name?: string }) => void;
|
||||
/** 追踪关注事件列表查看 */
|
||||
trackFollowingEventsViewed: (eventCount?: number) => void;
|
||||
/** 追踪关注事件点击 */
|
||||
trackFollowingEventClicked: (event: { id: number; title?: string }, position?: number) => void;
|
||||
/** 追踪评论列表查看 */
|
||||
trackCommentsViewed: (commentCount?: number) => void;
|
||||
/** 追踪订阅信息查看 */
|
||||
trackSubscriptionViewed: (subscription?: { plan?: string; status?: string }) => void;
|
||||
/** 追踪升级按钮点击 */
|
||||
trackUpgradePlanClicked: (currentPlan?: string, targetPlan?: string, source?: string) => void;
|
||||
/** 追踪个人资料更新 */
|
||||
trackProfileUpdated: (updatedFields?: string[]) => void;
|
||||
/** 追踪设置更改 */
|
||||
trackSettingChanged: (settingName: string, oldValue: unknown, newValue: unknown) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 自选股项目
|
||||
* 来自 /api/account/watchlist 接口
|
||||
*/
|
||||
export interface WatchlistItem {
|
||||
/** 股票代码(如 '600000.SH') */
|
||||
stock_code: string;
|
||||
|
||||
/** 股票名称 */
|
||||
stock_name: string;
|
||||
|
||||
/** 当前价格 */
|
||||
current_price?: number;
|
||||
|
||||
/** 涨跌幅(百分比) */
|
||||
change_percent?: number;
|
||||
|
||||
/** 添加时间 */
|
||||
created_at?: string;
|
||||
|
||||
/** 备注 */
|
||||
note?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 实时行情数据
|
||||
* 来自 /api/account/watchlist/realtime 接口
|
||||
*/
|
||||
export interface RealtimeQuote {
|
||||
/** 股票代码 */
|
||||
stock_code: string;
|
||||
|
||||
/** 当前价格 */
|
||||
current_price: number;
|
||||
|
||||
/** 涨跌幅(百分比) */
|
||||
change_percent: number;
|
||||
|
||||
/** 涨跌额 */
|
||||
change_amount?: number;
|
||||
|
||||
/** 成交量 */
|
||||
volume?: number;
|
||||
|
||||
/** 成交额 */
|
||||
amount?: number;
|
||||
|
||||
/** 最高价 */
|
||||
high?: number;
|
||||
|
||||
/** 最低价 */
|
||||
low?: number;
|
||||
|
||||
/** 开盘价 */
|
||||
open?: number;
|
||||
|
||||
/** 昨收价 */
|
||||
pre_close?: number;
|
||||
|
||||
/** 更新时间戳 */
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 实时行情映射表
|
||||
* key 为股票代码,value 为行情数据
|
||||
*/
|
||||
export type RealtimeQuotesMap = Record<string, RealtimeQuote>;
|
||||
|
||||
/**
|
||||
* 关注的事件
|
||||
* 来自 /api/account/events/following 接口
|
||||
*/
|
||||
export interface FollowingEvent {
|
||||
/** 事件 ID */
|
||||
id: number;
|
||||
|
||||
/** 事件标题 */
|
||||
title: string;
|
||||
|
||||
/** 关注人数 */
|
||||
follower_count?: number;
|
||||
|
||||
/** 相关股票平均涨跌幅(百分比) */
|
||||
related_avg_chg?: number;
|
||||
|
||||
/** 事件类型 */
|
||||
event_type?: string;
|
||||
|
||||
/** 发生日期 */
|
||||
event_date?: string;
|
||||
|
||||
/** 事件描述 */
|
||||
description?: string;
|
||||
|
||||
/** 相关股票列表 */
|
||||
related_stocks?: Array<{
|
||||
code: string;
|
||||
name: string;
|
||||
change_percent?: number;
|
||||
}>;
|
||||
|
||||
/** 创建时间 */
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户评论记录
|
||||
* 来自 /api/account/events/posts 接口
|
||||
*/
|
||||
export interface EventComment {
|
||||
/** 评论 ID */
|
||||
id: number;
|
||||
|
||||
/** 评论内容 */
|
||||
content: string;
|
||||
|
||||
/** 关联事件 ID */
|
||||
event_id: number;
|
||||
|
||||
/** 关联事件标题 */
|
||||
event_title?: string;
|
||||
|
||||
/** 点赞数 */
|
||||
like_count?: number;
|
||||
|
||||
/** 回复数 */
|
||||
reply_count?: number;
|
||||
|
||||
/** 创建时间 */
|
||||
created_at: string;
|
||||
|
||||
/** 更新时间 */
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 组件 Props 类型定义
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* WatchSidebar 组件 Props
|
||||
*/
|
||||
export interface WatchSidebarProps {
|
||||
/** 自选股列表 */
|
||||
watchlist: WatchlistItem[];
|
||||
|
||||
/** 实时行情数据(按股票代码索引) */
|
||||
realtimeQuotes: RealtimeQuotesMap;
|
||||
|
||||
/** 关注的事件列表 */
|
||||
followingEvents: FollowingEvent[];
|
||||
|
||||
/** 点击股票回调 */
|
||||
onStockClick?: (stock: WatchlistItem) => void;
|
||||
|
||||
/** 点击事件回调 */
|
||||
onEventClick?: (event: FollowingEvent) => void;
|
||||
|
||||
/** 添加股票回调 */
|
||||
onAddStock?: () => void;
|
||||
|
||||
/** 添加事件回调 */
|
||||
onAddEvent?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* WatchlistPanel 组件 Props
|
||||
*/
|
||||
export interface WatchlistPanelProps {
|
||||
/** 自选股列表 */
|
||||
watchlist: WatchlistItem[];
|
||||
|
||||
/** 实时行情数据 */
|
||||
realtimeQuotes: RealtimeQuotesMap;
|
||||
|
||||
/** 点击股票回调 */
|
||||
onStockClick?: (stock: WatchlistItem) => void;
|
||||
|
||||
/** 添加股票回调 */
|
||||
onAddStock?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* FollowingEventsPanel 组件 Props
|
||||
*/
|
||||
export interface FollowingEventsPanelProps {
|
||||
/** 事件列表 */
|
||||
events: FollowingEvent[];
|
||||
|
||||
/** 点击事件回调 */
|
||||
onEventClick?: (event: FollowingEvent) => void;
|
||||
|
||||
/** 添加事件回调 */
|
||||
onAddEvent?: () => void;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Hooks 返回值类型定义
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* useCenterColors Hook 返回值
|
||||
* 封装 Center 模块的所有颜色变量
|
||||
*/
|
||||
export interface CenterColors {
|
||||
/** 主要文本颜色 */
|
||||
textColor: string;
|
||||
|
||||
/** 边框颜色 */
|
||||
borderColor: string;
|
||||
|
||||
/** 背景颜色 */
|
||||
bgColor: string;
|
||||
|
||||
/** 悬停背景色 */
|
||||
hoverBg: string;
|
||||
|
||||
/** 次要文本颜色 */
|
||||
secondaryText: string;
|
||||
|
||||
/** 卡片背景色 */
|
||||
cardBg: string;
|
||||
|
||||
/** 区块背景色 */
|
||||
sectionBg: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* useCenterData Hook 返回值
|
||||
* 封装 Center 页面的数据加载逻辑
|
||||
*/
|
||||
export interface UseCenterDataResult {
|
||||
/** 自选股列表 */
|
||||
watchlist: WatchlistItem[];
|
||||
|
||||
/** 实时行情数据 */
|
||||
realtimeQuotes: RealtimeQuotesMap;
|
||||
|
||||
/** 关注的事件列表 */
|
||||
followingEvents: FollowingEvent[];
|
||||
|
||||
/** 用户评论列表 */
|
||||
eventComments: EventComment[];
|
||||
|
||||
/** 加载状态 */
|
||||
loading: boolean;
|
||||
|
||||
/** 行情加载状态 */
|
||||
quotesLoading: boolean;
|
||||
|
||||
/** 刷新数据 */
|
||||
refresh: () => Promise<void>;
|
||||
|
||||
/** 刷新实时行情 */
|
||||
refreshQuotes: () => Promise<void>;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// API 响应类型定义
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 自选股列表 API 响应
|
||||
*/
|
||||
export interface WatchlistApiResponse {
|
||||
success: boolean;
|
||||
data: WatchlistItem[];
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 实时行情 API 响应
|
||||
*/
|
||||
export interface RealtimeQuotesApiResponse {
|
||||
success: boolean;
|
||||
data: RealtimeQuote[];
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 关注事件 API 响应
|
||||
*/
|
||||
export interface FollowingEventsApiResponse {
|
||||
success: boolean;
|
||||
data: FollowingEvent[];
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户评论 API 响应
|
||||
*/
|
||||
export interface EventCommentsApiResponse {
|
||||
success: boolean;
|
||||
data: EventComment[];
|
||||
message?: string;
|
||||
}
|
||||
@@ -63,3 +63,23 @@ export type {
|
||||
PlanFormData,
|
||||
PlanningContextValue,
|
||||
} from './investment';
|
||||
|
||||
// Center(个人中心)相关类型
|
||||
export type {
|
||||
DashboardEventsOptions,
|
||||
DashboardEventsResult,
|
||||
WatchlistItem,
|
||||
RealtimeQuote,
|
||||
RealtimeQuotesMap,
|
||||
FollowingEvent,
|
||||
EventComment,
|
||||
WatchSidebarProps,
|
||||
WatchlistPanelProps,
|
||||
FollowingEventsPanelProps,
|
||||
CenterColors,
|
||||
UseCenterDataResult,
|
||||
WatchlistApiResponse,
|
||||
RealtimeQuotesApiResponse,
|
||||
FollowingEventsApiResponse,
|
||||
EventCommentsApiResponse,
|
||||
} from './center';
|
||||
|
||||
@@ -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<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);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
// 使用 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();
|
||||
@@ -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]);
|
||||
@@ -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() {
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
export default CenterDashboard;
|
||||
@@ -12,7 +12,7 @@
|
||||
* - PlanningContext (数据共享层)
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, Suspense, lazy } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useMemo, Suspense, lazy } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
@@ -119,27 +119,46 @@ const InvestmentPlanningCenter: React.FC = () => {
|
||||
loadAllData();
|
||||
}, [loadAllData]);
|
||||
|
||||
// 提供给子组件的 Context 值
|
||||
const contextValue: PlanningContextValue = {
|
||||
allEvents,
|
||||
setAllEvents,
|
||||
loadAllData,
|
||||
loading,
|
||||
setLoading,
|
||||
openPlanModalTrigger,
|
||||
openReviewModalTrigger,
|
||||
toast,
|
||||
borderColor,
|
||||
textColor,
|
||||
secondaryText,
|
||||
cardBg,
|
||||
setViewMode,
|
||||
setListTab,
|
||||
};
|
||||
// 提供给子组件的 Context 值(使用 useMemo 缓存,避免子组件不必要的重渲染)
|
||||
const contextValue: PlanningContextValue = useMemo(
|
||||
() => ({
|
||||
allEvents,
|
||||
setAllEvents,
|
||||
loadAllData,
|
||||
loading,
|
||||
setLoading,
|
||||
openPlanModalTrigger,
|
||||
openReviewModalTrigger,
|
||||
toast,
|
||||
borderColor,
|
||||
textColor,
|
||||
secondaryText,
|
||||
cardBg,
|
||||
setViewMode,
|
||||
setListTab,
|
||||
}),
|
||||
[
|
||||
allEvents,
|
||||
loadAllData,
|
||||
loading,
|
||||
openPlanModalTrigger,
|
||||
openReviewModalTrigger,
|
||||
toast,
|
||||
borderColor,
|
||||
textColor,
|
||||
secondaryText,
|
||||
cardBg,
|
||||
]
|
||||
);
|
||||
|
||||
// 计算各类型事件数量
|
||||
const planCount = allEvents.filter(e => e.type === 'plan').length;
|
||||
const reviewCount = allEvents.filter(e => e.type === 'review').length;
|
||||
// 计算各类型事件数量(使用 useMemo 缓存,避免每次渲染重复遍历数组)
|
||||
const { planCount, reviewCount } = useMemo(
|
||||
() => ({
|
||||
planCount: allEvents.filter(e => e.type === 'plan').length,
|
||||
reviewCount: allEvents.filter(e => e.type === 'review').length,
|
||||
}),
|
||||
[allEvents]
|
||||
);
|
||||
|
||||
return (
|
||||
<PlanningDataProvider value={contextValue}>
|
||||
5
src/views/Center/hooks/index.ts
Normal file
5
src/views/Center/hooks/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Center 模块 Hooks 导出
|
||||
*/
|
||||
|
||||
export { useCenterColors, default as useCenterColorsDefault } from './useCenterColors';
|
||||
41
src/views/Center/hooks/useCenterColors.ts
Normal file
41
src/views/Center/hooks/useCenterColors.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* useCenterColors Hook
|
||||
*
|
||||
* 封装 Center 模块的所有颜色变量,避免每次渲染重复调用 useColorModeValue
|
||||
* 将 7 次 hook 调用合并为 1 次 useMemo 计算
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useColorModeValue } from '@chakra-ui/react';
|
||||
import type { CenterColors } from '@/types/center';
|
||||
|
||||
/**
|
||||
* 获取 Center 模块的颜色配置
|
||||
* 使用 useMemo 缓存结果,避免每次渲染重新计算
|
||||
*/
|
||||
export function useCenterColors(): CenterColors {
|
||||
// 获取当前主题模式下的基础颜色
|
||||
const textColor = useColorModeValue('gray.700', 'white');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
const bgColor = useColorModeValue('white', 'gray.800');
|
||||
const hoverBg = useColorModeValue('gray.50', 'gray.700');
|
||||
const secondaryText = useColorModeValue('gray.600', 'gray.400');
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const sectionBg = useColorModeValue('gray.50', 'gray.900');
|
||||
|
||||
// 使用 useMemo 缓存颜色对象,只在颜色值变化时重新创建
|
||||
return useMemo(
|
||||
() => ({
|
||||
textColor,
|
||||
borderColor,
|
||||
bgColor,
|
||||
hoverBg,
|
||||
secondaryText,
|
||||
cardBg,
|
||||
sectionBg,
|
||||
}),
|
||||
[textColor, borderColor, bgColor, hoverBg, secondaryText, cardBg, sectionBg]
|
||||
);
|
||||
}
|
||||
|
||||
export default useCenterColors;
|
||||
4
src/views/Center/index.js
Normal file
4
src/views/Center/index.js
Normal file
@@ -0,0 +1,4 @@
|
||||
// src/views/Center/index.js
|
||||
// 入口文件,导出 Center 组件
|
||||
|
||||
export { default } from './Center';
|
||||
87
src/views/Center/utils/formatters.ts
Normal file
87
src/views/Center/utils/formatters.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Center 模块格式化工具函数
|
||||
*
|
||||
* 这些是纯函数,提取到组件外部避免每次渲染重建
|
||||
*/
|
||||
|
||||
/**
|
||||
* 格式化相对时间(如 "5分钟前"、"3天前")
|
||||
* @param dateString 日期字符串
|
||||
* @returns 格式化后的相对时间字符串
|
||||
*/
|
||||
export function formatRelativeTime(dateString: string | null | undefined): string {
|
||||
if (!dateString) return '';
|
||||
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffTime = Math.abs(now.getTime() - date.getTime());
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays < 1) {
|
||||
const diffHours = Math.ceil(diffTime / (1000 * 60 * 60));
|
||||
if (diffHours < 1) {
|
||||
const diffMinutes = Math.ceil(diffTime / (1000 * 60));
|
||||
return `${diffMinutes}分钟前`;
|
||||
}
|
||||
return `${diffHours}小时前`;
|
||||
} else if (diffDays < 7) {
|
||||
return `${diffDays}天前`;
|
||||
} else {
|
||||
return date.toLocaleDateString('zh-CN');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化数字(如 10000 → "1w",1500 → "1.5k")
|
||||
* @param num 数字
|
||||
* @returns 格式化后的字符串
|
||||
*/
|
||||
export function formatCompactNumber(num: number | null | undefined): string {
|
||||
if (!num) return '0';
|
||||
|
||||
if (num >= 10000) {
|
||||
return (num / 10000).toFixed(1) + 'w';
|
||||
} else if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1) + 'k';
|
||||
}
|
||||
return num.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据热度分数获取颜色
|
||||
* @param score 热度分数 (0-100)
|
||||
* @returns Chakra UI 颜色名称
|
||||
*/
|
||||
export function getHeatColor(score: number): string {
|
||||
if (score >= 80) return 'red';
|
||||
if (score >= 60) return 'orange';
|
||||
if (score >= 40) return 'yellow';
|
||||
return 'green';
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据涨跌幅获取颜色
|
||||
* @param changePercent 涨跌幅百分比
|
||||
* @returns 颜色值
|
||||
*/
|
||||
export function getChangeColor(changePercent: number | null | undefined): string {
|
||||
if (changePercent === null || changePercent === undefined) {
|
||||
return 'rgba(255, 255, 255, 0.6)';
|
||||
}
|
||||
if (changePercent > 0) return '#EF4444'; // 红色(涨)
|
||||
if (changePercent < 0) return '#22C55E'; // 绿色(跌)
|
||||
return 'rgba(255, 255, 255, 0.6)'; // 灰色(平)
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化涨跌幅显示
|
||||
* @param changePercent 涨跌幅百分比
|
||||
* @returns 格式化后的字符串(如 "+5.23%")
|
||||
*/
|
||||
export function formatChangePercent(changePercent: number | null | undefined): string {
|
||||
if (changePercent === null || changePercent === undefined) {
|
||||
return '--';
|
||||
}
|
||||
const prefix = changePercent > 0 ? '+' : '';
|
||||
return `${prefix}${Number(changePercent).toFixed(2)}%`;
|
||||
}
|
||||
11
src/views/Center/utils/index.ts
Normal file
11
src/views/Center/utils/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Center 模块工具函数导出
|
||||
*/
|
||||
|
||||
export {
|
||||
formatRelativeTime,
|
||||
formatCompactNumber,
|
||||
getHeatColor,
|
||||
getChangeColor,
|
||||
formatChangePercent,
|
||||
} from './formatters';
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,587 +0,0 @@
|
||||
// src/views/Dashboard/components/InvestmentCalendarChakra.js
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardHeader,
|
||||
CardBody,
|
||||
Heading,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Button,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalFooter,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
useDisclosure,
|
||||
Badge,
|
||||
IconButton,
|
||||
Flex,
|
||||
Grid,
|
||||
useColorModeValue,
|
||||
Divider,
|
||||
Tooltip,
|
||||
Icon,
|
||||
Input,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Textarea,
|
||||
Select,
|
||||
useToast,
|
||||
Spinner,
|
||||
Center,
|
||||
Tag,
|
||||
TagLabel,
|
||||
TagLeftIcon,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
FiCalendar,
|
||||
FiClock,
|
||||
FiStar,
|
||||
FiTrendingUp,
|
||||
FiPlus,
|
||||
FiEdit2,
|
||||
FiTrash2,
|
||||
FiSave,
|
||||
FiX,
|
||||
} from 'react-icons/fi';
|
||||
import FullCalendar from '@fullcalendar/react';
|
||||
import dayGridPlugin from '@fullcalendar/daygrid';
|
||||
import interactionPlugin from '@fullcalendar/interaction';
|
||||
import dayjs from 'dayjs';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import { getApiBase } from '../../../utils/apiConfig';
|
||||
import TimelineChartModal from '../../../components/StockChart/TimelineChartModal';
|
||||
import KLineChartModal from '../../../components/StockChart/KLineChartModal';
|
||||
import './InvestmentCalendar.less';
|
||||
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
export default function InvestmentCalendarChakra() {
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const { isOpen: isAddOpen, onOpen: onAddOpen, onClose: onAddClose } = useDisclosure();
|
||||
const { isOpen: isTimelineModalOpen, onOpen: onTimelineModalOpen, onClose: onTimelineModalClose } = useDisclosure();
|
||||
const { isOpen: isKLineModalOpen, onOpen: onKLineModalOpen, onClose: onKLineModalClose } = useDisclosure();
|
||||
const toast = useToast();
|
||||
|
||||
// 颜色主题
|
||||
const bgColor = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
const textColor = useColorModeValue('gray.700', 'white');
|
||||
const secondaryText = useColorModeValue('gray.600', 'gray.400');
|
||||
|
||||
const [events, setEvents] = useState([]);
|
||||
const [selectedDate, setSelectedDate] = useState(null);
|
||||
const [selectedDateEvents, setSelectedDateEvents] = useState([]);
|
||||
const [selectedStock, setSelectedStock] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [newEvent, setNewEvent] = useState({
|
||||
title: '',
|
||||
description: '',
|
||||
type: 'plan',
|
||||
importance: 3,
|
||||
stocks: '',
|
||||
});
|
||||
|
||||
// 加载事件数据
|
||||
const loadEvents = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const base = getApiBase();
|
||||
|
||||
// 直接加载用户相关的事件(投资计划 + 关注的未来事件)
|
||||
const userResponse = await fetch(base + '/api/account/calendar/events', {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (userResponse.ok) {
|
||||
const userData = await userResponse.json();
|
||||
if (userData.success) {
|
||||
const allEvents = (userData.data || []).map(event => ({
|
||||
...event,
|
||||
id: `${event.source || 'user'}-${event.id}`,
|
||||
title: event.title,
|
||||
start: event.event_date,
|
||||
date: event.event_date,
|
||||
backgroundColor: event.source === 'future' ? '#3182CE' : '#8B5CF6',
|
||||
borderColor: event.source === 'future' ? '#3182CE' : '#8B5CF6',
|
||||
extendedProps: {
|
||||
...event,
|
||||
isSystem: event.source === 'future',
|
||||
}
|
||||
}));
|
||||
|
||||
setEvents(allEvents);
|
||||
logger.debug('InvestmentCalendar', '日历事件加载成功', {
|
||||
count: allEvents.length
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('InvestmentCalendar', 'loadEvents', error);
|
||||
// ❌ 移除数据加载失败 toast(非关键操作)
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []); // ✅ 移除 toast 依赖
|
||||
|
||||
useEffect(() => {
|
||||
loadEvents();
|
||||
}, [loadEvents]);
|
||||
|
||||
// 根据重要性获取颜色
|
||||
const getEventColor = (importance) => {
|
||||
if (importance >= 5) return '#E53E3E'; // 红色
|
||||
if (importance >= 4) return '#ED8936'; // 橙色
|
||||
if (importance >= 3) return '#ECC94B'; // 黄色
|
||||
if (importance >= 2) return '#48BB78'; // 绿色
|
||||
return '#3182CE'; // 蓝色
|
||||
};
|
||||
|
||||
// 处理日期点击
|
||||
const handleDateClick = (info) => {
|
||||
const clickedDate = dayjs(info.date);
|
||||
setSelectedDate(clickedDate);
|
||||
|
||||
// 筛选当天的事件
|
||||
const dayEvents = events.filter(event =>
|
||||
dayjs(event.start).isSame(clickedDate, 'day')
|
||||
);
|
||||
setSelectedDateEvents(dayEvents);
|
||||
onOpen();
|
||||
};
|
||||
|
||||
// 处理事件点击
|
||||
const handleEventClick = (info) => {
|
||||
const event = info.event;
|
||||
const clickedDate = dayjs(event.start);
|
||||
setSelectedDate(clickedDate);
|
||||
setSelectedDateEvents([{
|
||||
title: event.title,
|
||||
start: event.start,
|
||||
extendedProps: {
|
||||
...event.extendedProps,
|
||||
},
|
||||
}]);
|
||||
onOpen();
|
||||
};
|
||||
|
||||
// 添加新事件
|
||||
const handleAddEvent = async () => {
|
||||
try {
|
||||
const base = getApiBase();
|
||||
|
||||
const eventData = {
|
||||
...newEvent,
|
||||
event_date: (selectedDate ? selectedDate.format('YYYY-MM-DD') : dayjs().format('YYYY-MM-DD')),
|
||||
stocks: newEvent.stocks.split(',').map(s => s.trim()).filter(s => s),
|
||||
};
|
||||
|
||||
const response = await fetch(base + '/api/account/calendar/events', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(eventData),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
logger.info('InvestmentCalendar', '添加事件成功', {
|
||||
eventTitle: eventData.title,
|
||||
eventDate: eventData.event_date
|
||||
});
|
||||
toast({
|
||||
title: '添加成功',
|
||||
description: '投资计划已添加',
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
});
|
||||
onAddClose();
|
||||
loadEvents();
|
||||
setNewEvent({
|
||||
title: '',
|
||||
description: '',
|
||||
type: 'plan',
|
||||
importance: 3,
|
||||
stocks: '',
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('InvestmentCalendar', 'handleAddEvent', error, {
|
||||
eventTitle: newEvent?.title
|
||||
});
|
||||
toast({
|
||||
title: '添加失败',
|
||||
description: '无法添加投资计划',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 删除用户事件
|
||||
const handleDeleteEvent = async (eventId) => {
|
||||
if (!eventId) {
|
||||
logger.warn('InvestmentCalendar', '删除事件失败', '缺少事件 ID', { eventId });
|
||||
toast({
|
||||
title: '无法删除',
|
||||
description: '缺少事件 ID',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const base = getApiBase();
|
||||
|
||||
const response = await fetch(base + `/api/account/calendar/events/${eventId}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
logger.info('InvestmentCalendar', '删除事件成功', { eventId });
|
||||
toast({
|
||||
title: '删除成功',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
loadEvents();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('InvestmentCalendar', 'handleDeleteEvent', error, { eventId });
|
||||
toast({
|
||||
title: '删除失败',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 处理股票点击 - 打开图表弹窗
|
||||
const handleStockClick = (stockCodeOrName, eventDate) => {
|
||||
// 解析股票代码(可能是 "600000" 或 "600000 平安银行" 格式)
|
||||
let stockCode = stockCodeOrName;
|
||||
let stockName = '';
|
||||
|
||||
if (typeof stockCodeOrName === 'string') {
|
||||
const parts = stockCodeOrName.trim().split(/\s+/);
|
||||
stockCode = parts[0];
|
||||
stockName = parts.slice(1).join(' ');
|
||||
}
|
||||
|
||||
// 添加交易所后缀(如果没有)
|
||||
if (!stockCode.includes('.')) {
|
||||
if (stockCode.startsWith('6')) {
|
||||
stockCode = `${stockCode}.SH`;
|
||||
} else if (stockCode.startsWith('0') || stockCode.startsWith('3')) {
|
||||
stockCode = `${stockCode}.SZ`;
|
||||
} else if (stockCode.startsWith('8') || stockCode.startsWith('9') || stockCode.startsWith('4')) {
|
||||
// 北交所股票
|
||||
stockCode = `${stockCode}.BJ`;
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedStock({
|
||||
stock_code: stockCode,
|
||||
stock_name: stockName || stockCode,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card bg={bgColor} shadow="md">
|
||||
<CardHeader pb={4}>
|
||||
<Flex justify="space-between" align="center">
|
||||
<HStack>
|
||||
<Icon as={FiCalendar} color="orange.500" boxSize={5} />
|
||||
<Heading size="md">投资日历</Heading>
|
||||
</HStack>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
leftIcon={<FiPlus />}
|
||||
onClick={() => { if (!selectedDate) setSelectedDate(dayjs()); onAddOpen(); }}
|
||||
>
|
||||
添加计划
|
||||
</Button>
|
||||
</Flex>
|
||||
</CardHeader>
|
||||
<CardBody pt={0}>
|
||||
{loading ? (
|
||||
<Center h="560px">
|
||||
<Spinner size="xl" color="blue.500" />
|
||||
</Center>
|
||||
) : (
|
||||
<Box height={{ base: '500px', md: '600px' }}>
|
||||
<FullCalendar
|
||||
plugins={[dayGridPlugin, interactionPlugin]}
|
||||
initialView="dayGridMonth"
|
||||
locale="zh-cn"
|
||||
headerToolbar={{
|
||||
left: 'prev,next today',
|
||||
center: 'title',
|
||||
right: ''
|
||||
}}
|
||||
events={events}
|
||||
dateClick={handleDateClick}
|
||||
eventClick={handleEventClick}
|
||||
height="100%"
|
||||
dayMaxEvents={3}
|
||||
moreLinkText="更多"
|
||||
buttonText={{
|
||||
today: '今天',
|
||||
month: '月',
|
||||
week: '周'
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</CardBody>
|
||||
|
||||
{/* 查看事件详情 Modal - 条件渲染 */}
|
||||
{isOpen && (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="xl" closeOnOverlayClick={false} closeOnEsc={true}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
{selectedDate && selectedDate.format('YYYY年MM月DD日')} 的事件
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
{selectedDateEvents.length === 0 ? (
|
||||
<Center py={8}>
|
||||
<VStack>
|
||||
<Text color={secondaryText}>当天没有事件</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
leftIcon={<FiPlus />}
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onAddOpen();
|
||||
}}
|
||||
>
|
||||
添加投资计划
|
||||
</Button>
|
||||
</VStack>
|
||||
</Center>
|
||||
) : (
|
||||
<VStack align="stretch" spacing={4}>
|
||||
{selectedDateEvents.map((event, idx) => (
|
||||
<Box
|
||||
key={idx}
|
||||
p={4}
|
||||
borderRadius="md"
|
||||
border="1px"
|
||||
borderColor={borderColor}
|
||||
>
|
||||
<Flex justify="space-between" align="start" mb={2}>
|
||||
<VStack align="start" spacing={1} flex={1}>
|
||||
<HStack>
|
||||
<Text fontWeight="bold" fontSize="lg">
|
||||
{event.title}
|
||||
</Text>
|
||||
{event.extendedProps?.isSystem ? (
|
||||
<Badge colorScheme="blue" variant="subtle">系统事件</Badge>
|
||||
) : (
|
||||
<Badge colorScheme="purple" variant="subtle">我的计划</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FiStar} color="yellow.500" />
|
||||
<Text fontSize="sm" color={secondaryText}>
|
||||
重要度: {event.extendedProps?.importance || 3}/5
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
{!event.extendedProps?.isSystem && (
|
||||
<IconButton
|
||||
icon={<FiTrash2 />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="red"
|
||||
onClick={() => handleDeleteEvent(event.extendedProps?.id)}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
{event.extendedProps?.description && (
|
||||
<Text fontSize="sm" color={secondaryText} mb={2}>
|
||||
{event.extendedProps.description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{event.extendedProps?.stocks && event.extendedProps.stocks.length > 0 && (
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
<Text fontSize="sm" color={secondaryText}>相关股票:</Text>
|
||||
{event.extendedProps.stocks.map((stock, i) => (
|
||||
<Tag
|
||||
key={i}
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
cursor="pointer"
|
||||
onClick={() => handleStockClick(stock, event.start)}
|
||||
_hover={{ transform: 'scale(1.05)', shadow: 'md' }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<TagLeftIcon as={FiTrendingUp} />
|
||||
<TagLabel>{stock}</TagLabel>
|
||||
</Tag>
|
||||
))}
|
||||
</HStack>
|
||||
{selectedStock && (
|
||||
<HStack spacing={2}>
|
||||
<Button
|
||||
size="xs"
|
||||
colorScheme="blue"
|
||||
variant="outline"
|
||||
leftIcon={<FiClock />}
|
||||
onClick={onTimelineModalOpen}
|
||||
>
|
||||
分时图
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
colorScheme="purple"
|
||||
variant="outline"
|
||||
leftIcon={<FiTrendingUp />}
|
||||
onClick={onKLineModalOpen}
|
||||
>
|
||||
日K线
|
||||
</Button>
|
||||
</HStack>
|
||||
)}
|
||||
</VStack>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button onClick={onClose}>关闭</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* 添加投资计划 Modal - 条件渲染 */}
|
||||
{isAddOpen && (
|
||||
<Modal isOpen={isAddOpen} onClose={onAddClose} size="lg" closeOnOverlayClick={false} closeOnEsc={true}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
添加投资计划
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack spacing={4}>
|
||||
<FormControl isRequired>
|
||||
<FormLabel>标题</FormLabel>
|
||||
<Input
|
||||
value={newEvent.title}
|
||||
onChange={(e) => setNewEvent({ ...newEvent, title: e.target.value })}
|
||||
placeholder="例如:关注半导体板块"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>描述</FormLabel>
|
||||
<Textarea
|
||||
value={newEvent.description}
|
||||
onChange={(e) => setNewEvent({ ...newEvent, description: e.target.value })}
|
||||
placeholder="详细描述您的投资计划..."
|
||||
rows={3}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>类型</FormLabel>
|
||||
<Select
|
||||
value={newEvent.type}
|
||||
onChange={(e) => setNewEvent({ ...newEvent, type: e.target.value })}
|
||||
>
|
||||
<option value="plan">投资计划</option>
|
||||
<option value="reminder">提醒事项</option>
|
||||
<option value="analysis">分析任务</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>重要度</FormLabel>
|
||||
<Select
|
||||
value={newEvent.importance}
|
||||
onChange={(e) => setNewEvent({ ...newEvent, importance: parseInt(e.target.value) })}
|
||||
>
|
||||
<option value={5}>⭐⭐⭐⭐⭐ 非常重要</option>
|
||||
<option value={4}>⭐⭐⭐⭐ 重要</option>
|
||||
<option value={3}>⭐⭐⭐ 一般</option>
|
||||
<option value={2}>⭐⭐ 次要</option>
|
||||
<option value={1}>⭐ 不重要</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>相关股票(用逗号分隔)</FormLabel>
|
||||
<Input
|
||||
value={newEvent.stocks}
|
||||
onChange={(e) => setNewEvent({ ...newEvent, stocks: e.target.value })}
|
||||
placeholder="例如:600519,000858,002415"
|
||||
/>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" mr={3} onClick={onAddClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
onClick={handleAddEvent}
|
||||
isDisabled={!newEvent.title}
|
||||
>
|
||||
添加
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* 分时图弹窗 */}
|
||||
{selectedStock && (
|
||||
<TimelineChartModal
|
||||
isOpen={isTimelineModalOpen}
|
||||
onClose={() => {
|
||||
onTimelineModalClose();
|
||||
setSelectedStock(null);
|
||||
}}
|
||||
stock={selectedStock}
|
||||
eventTime={selectedDate?.toISOString()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* K线图弹窗 */}
|
||||
{selectedStock && (
|
||||
<KLineChartModal
|
||||
isOpen={isKLineModalOpen}
|
||||
onClose={() => {
|
||||
onKLineModalClose();
|
||||
setSelectedStock(null);
|
||||
}}
|
||||
stock={selectedStock}
|
||||
eventTime={selectedDate?.toISOString()}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,588 +0,0 @@
|
||||
// src/views/Dashboard/components/InvestmentPlansAndReviews.js
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardHeader,
|
||||
CardBody,
|
||||
Heading,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Button,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalFooter,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
useDisclosure,
|
||||
Badge,
|
||||
IconButton,
|
||||
Flex,
|
||||
useColorModeValue,
|
||||
Divider,
|
||||
Icon,
|
||||
Input,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Textarea,
|
||||
Select,
|
||||
useToast,
|
||||
Spinner,
|
||||
Center,
|
||||
Tag,
|
||||
TagLabel,
|
||||
TagLeftIcon,
|
||||
TagCloseButton,
|
||||
Grid,
|
||||
Tabs,
|
||||
TabList,
|
||||
TabPanels,
|
||||
Tab,
|
||||
TabPanel,
|
||||
InputGroup,
|
||||
InputLeftElement,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
FiCalendar,
|
||||
FiClock,
|
||||
FiEdit2,
|
||||
FiTrash2,
|
||||
FiSave,
|
||||
FiPlus,
|
||||
FiFileText,
|
||||
FiTarget,
|
||||
FiTrendingUp,
|
||||
FiHash,
|
||||
FiCheckCircle,
|
||||
FiXCircle,
|
||||
FiAlertCircle,
|
||||
} from 'react-icons/fi';
|
||||
import dayjs from 'dayjs';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import { getApiBase } from '../../../utils/apiConfig';
|
||||
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
export default function InvestmentPlansAndReviews({ type = 'both' }) {
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const toast = useToast();
|
||||
|
||||
// 颜色主题
|
||||
const bgColor = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
const textColor = useColorModeValue('gray.700', 'white');
|
||||
const secondaryText = useColorModeValue('gray.600', 'gray.400');
|
||||
const cardBg = useColorModeValue('gray.50', 'gray.700');
|
||||
|
||||
const [plans, setPlans] = useState([]);
|
||||
const [reviews, setReviews] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState(null);
|
||||
const [formData, setFormData] = useState({
|
||||
date: dayjs().format('YYYY-MM-DD'),
|
||||
title: '',
|
||||
content: '',
|
||||
type: 'plan',
|
||||
stocks: [],
|
||||
tags: [],
|
||||
status: 'active',
|
||||
});
|
||||
const [stockInput, setStockInput] = useState('');
|
||||
const [tagInput, setTagInput] = useState('');
|
||||
|
||||
// 加载数据
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const base = getApiBase();
|
||||
|
||||
const response = await fetch(base + '/api/account/investment-plans', {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
const allItems = data.data || [];
|
||||
setPlans(allItems.filter(item => item.type === 'plan'));
|
||||
setReviews(allItems.filter(item => item.type === 'review'));
|
||||
logger.debug('InvestmentPlansAndReviews', '数据加载成功', {
|
||||
plansCount: allItems.filter(item => item.type === 'plan').length,
|
||||
reviewsCount: allItems.filter(item => item.type === 'review').length
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('InvestmentPlansAndReviews', 'loadData', error);
|
||||
// ❌ 移除数据加载失败 toast(非关键操作)
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []); // ✅ 移除 toast 依赖
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
// 打开编辑/新建模态框
|
||||
const handleOpenModal = (item = null, itemType = 'plan') => {
|
||||
if (item) {
|
||||
setEditingItem(item);
|
||||
setFormData({
|
||||
...item,
|
||||
date: dayjs(item.date).format('YYYY-MM-DD'),
|
||||
});
|
||||
} else {
|
||||
setEditingItem(null);
|
||||
setFormData({
|
||||
date: dayjs().format('YYYY-MM-DD'),
|
||||
title: '',
|
||||
content: '',
|
||||
type: itemType,
|
||||
stocks: [],
|
||||
tags: [],
|
||||
status: 'active',
|
||||
});
|
||||
}
|
||||
onOpen();
|
||||
};
|
||||
|
||||
// 保存数据
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const base = getApiBase();
|
||||
|
||||
const url = editingItem
|
||||
? base + `/api/account/investment-plans/${editingItem.id}`
|
||||
: base + '/api/account/investment-plans';
|
||||
|
||||
const method = editingItem ? 'PUT' : 'POST';
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
logger.info('InvestmentPlansAndReviews', `${editingItem ? '更新' : '创建'}成功`, {
|
||||
itemId: editingItem?.id,
|
||||
title: formData.title,
|
||||
type: formData.type
|
||||
});
|
||||
toast({
|
||||
title: editingItem ? '更新成功' : '创建成功',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
onClose();
|
||||
loadData();
|
||||
} else {
|
||||
throw new Error('保存失败');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('InvestmentPlansAndReviews', 'handleSave', error, {
|
||||
itemId: editingItem?.id,
|
||||
title: formData?.title
|
||||
});
|
||||
toast({
|
||||
title: '保存失败',
|
||||
description: '无法保存数据',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 删除数据
|
||||
const handleDelete = async (id) => {
|
||||
if (!window.confirm('确定要删除吗?')) return;
|
||||
|
||||
try {
|
||||
const base = getApiBase();
|
||||
|
||||
const response = await fetch(base + `/api/account/investment-plans/${id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
logger.info('InvestmentPlansAndReviews', '删除成功', { itemId: id });
|
||||
toast({
|
||||
title: '删除成功',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
loadData();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('InvestmentPlansAndReviews', 'handleDelete', error, { itemId: id });
|
||||
toast({
|
||||
title: '删除失败',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 添加股票
|
||||
const handleAddStock = () => {
|
||||
if (stockInput.trim() && !formData.stocks.includes(stockInput.trim())) {
|
||||
setFormData({
|
||||
...formData,
|
||||
stocks: [...formData.stocks, stockInput.trim()],
|
||||
});
|
||||
setStockInput('');
|
||||
}
|
||||
};
|
||||
|
||||
// 添加标签
|
||||
const handleAddTag = () => {
|
||||
if (tagInput.trim() && !formData.tags.includes(tagInput.trim())) {
|
||||
setFormData({
|
||||
...formData,
|
||||
tags: [...formData.tags, tagInput.trim()],
|
||||
});
|
||||
setTagInput('');
|
||||
}
|
||||
};
|
||||
|
||||
// 获取状态图标和颜色
|
||||
const getStatusInfo = (status) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return { icon: FiCheckCircle, color: 'green' };
|
||||
case 'cancelled':
|
||||
return { icon: FiXCircle, color: 'red' };
|
||||
default:
|
||||
return { icon: FiAlertCircle, color: 'blue' };
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染单个卡片
|
||||
const renderCard = (item) => {
|
||||
const statusInfo = getStatusInfo(item.status);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={item.id}
|
||||
bg={cardBg}
|
||||
shadow="sm"
|
||||
_hover={{ shadow: 'md' }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<CardBody>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
<Flex justify="space-between" align="start">
|
||||
<VStack align="start" spacing={1} flex={1}>
|
||||
<HStack>
|
||||
<Icon as={item.type === 'plan' ? FiTarget : FiFileText} color="blue.500" />
|
||||
<Text fontWeight="bold" fontSize="lg">
|
||||
{item.title}
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FiCalendar} boxSize={3} color={secondaryText} />
|
||||
<Text fontSize="sm" color={secondaryText}>
|
||||
{dayjs(item.date).format('YYYY年MM月DD日')}
|
||||
</Text>
|
||||
<Badge
|
||||
colorScheme={statusInfo.color}
|
||||
variant="subtle"
|
||||
leftIcon={<Icon as={statusInfo.icon} />}
|
||||
>
|
||||
{item.status === 'active' ? '进行中' :
|
||||
item.status === 'completed' ? '已完成' : '已取消'}
|
||||
</Badge>
|
||||
</HStack>
|
||||
</VStack>
|
||||
<HStack>
|
||||
<IconButton
|
||||
icon={<FiEdit2 />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleOpenModal(item)}
|
||||
/>
|
||||
<IconButton
|
||||
icon={<FiTrash2 />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="red"
|
||||
onClick={() => handleDelete(item.id)}
|
||||
/>
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{item.content && (
|
||||
<Text fontSize="sm" color={textColor} noOfLines={3}>
|
||||
{item.content}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
{item.stocks && item.stocks.length > 0 && (
|
||||
<>
|
||||
{item.stocks.map((stock, idx) => (
|
||||
<Tag key={idx} size="sm" colorScheme="blue" variant="subtle">
|
||||
<TagLeftIcon as={FiTrendingUp} />
|
||||
<TagLabel>{stock}</TagLabel>
|
||||
</Tag>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{item.tags && item.tags.length > 0 && (
|
||||
<>
|
||||
{item.tags.map((tag, idx) => (
|
||||
<Tag key={idx} size="sm" colorScheme="purple" variant="subtle">
|
||||
<TagLeftIcon as={FiHash} />
|
||||
<TagLabel>{tag}</TagLabel>
|
||||
</Tag>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</HStack>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Tabs variant="enclosed" colorScheme="blue" defaultIndex={type === 'review' ? 1 : 0}>
|
||||
<TabList>
|
||||
<Tab>
|
||||
<Icon as={FiTarget} mr={2} />
|
||||
我的计划 ({plans.length})
|
||||
</Tab>
|
||||
<Tab>
|
||||
<Icon as={FiFileText} mr={2} />
|
||||
我的复盘 ({reviews.length})
|
||||
</Tab>
|
||||
</TabList>
|
||||
|
||||
<TabPanels>
|
||||
{/* 计划面板 */}
|
||||
<TabPanel px={0}>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<Flex justify="flex-end">
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
leftIcon={<FiPlus />}
|
||||
onClick={() => handleOpenModal(null, 'plan')}
|
||||
>
|
||||
新建计划
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
{loading ? (
|
||||
<Center py={8}>
|
||||
<Spinner size="xl" color="blue.500" />
|
||||
</Center>
|
||||
) : plans.length === 0 ? (
|
||||
<Center py={8}>
|
||||
<VStack spacing={3}>
|
||||
<Icon as={FiTarget} boxSize={12} color="gray.300" />
|
||||
<Text color={secondaryText}>暂无投资计划</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
leftIcon={<FiPlus />}
|
||||
onClick={() => handleOpenModal(null, 'plan')}
|
||||
>
|
||||
创建第一个计划
|
||||
</Button>
|
||||
</VStack>
|
||||
</Center>
|
||||
) : (
|
||||
<Grid templateColumns={{ base: '1fr', md: 'repeat(2, 1fr)' }} gap={4}>
|
||||
{plans.map(renderCard)}
|
||||
</Grid>
|
||||
)}
|
||||
</VStack>
|
||||
</TabPanel>
|
||||
|
||||
{/* 复盘面板 */}
|
||||
<TabPanel px={0}>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<Flex justify="flex-end">
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="green"
|
||||
leftIcon={<FiPlus />}
|
||||
onClick={() => handleOpenModal(null, 'review')}
|
||||
>
|
||||
新建复盘
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
{loading ? (
|
||||
<Center py={8}>
|
||||
<Spinner size="xl" color="blue.500" />
|
||||
</Center>
|
||||
) : reviews.length === 0 ? (
|
||||
<Center py={8}>
|
||||
<VStack spacing={3}>
|
||||
<Icon as={FiFileText} boxSize={12} color="gray.300" />
|
||||
<Text color={secondaryText}>暂无复盘记录</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="green"
|
||||
leftIcon={<FiPlus />}
|
||||
onClick={() => handleOpenModal(null, 'review')}
|
||||
>
|
||||
创建第一个复盘
|
||||
</Button>
|
||||
</VStack>
|
||||
</Center>
|
||||
) : (
|
||||
<Grid templateColumns={{ base: '1fr', md: 'repeat(2, 1fr)' }} gap={4}>
|
||||
{reviews.map(renderCard)}
|
||||
</Grid>
|
||||
)}
|
||||
</VStack>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
|
||||
{/* 编辑/新建模态框 - 条件渲染 */}
|
||||
{isOpen && (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="xl" closeOnOverlayClick={false} closeOnEsc={true}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
{editingItem ? '编辑' : '新建'}
|
||||
{formData.type === 'plan' ? '投资计划' : '复盘记录'}
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack spacing={4}>
|
||||
<FormControl isRequired>
|
||||
<FormLabel>日期</FormLabel>
|
||||
<InputGroup>
|
||||
<InputLeftElement pointerEvents="none">
|
||||
<Icon as={FiCalendar} color={secondaryText} />
|
||||
</InputLeftElement>
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.date}
|
||||
onChange={(e) => setFormData({ ...formData, date: e.target.value })}
|
||||
/>
|
||||
</InputGroup>
|
||||
</FormControl>
|
||||
|
||||
<FormControl isRequired>
|
||||
<FormLabel>标题</FormLabel>
|
||||
<Input
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
placeholder={formData.type === 'plan' ? '例如:布局新能源板块' : '例如:本周交易复盘'}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>内容</FormLabel>
|
||||
<Textarea
|
||||
value={formData.content}
|
||||
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
|
||||
placeholder={formData.type === 'plan' ?
|
||||
'详细描述您的投资计划...' :
|
||||
'记录您的交易心得和经验教训...'}
|
||||
rows={6}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>相关股票</FormLabel>
|
||||
<HStack>
|
||||
<Input
|
||||
value={stockInput}
|
||||
onChange={(e) => setStockInput(e.target.value)}
|
||||
placeholder="输入股票代码"
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleAddStock()}
|
||||
/>
|
||||
<Button onClick={handleAddStock}>添加</Button>
|
||||
</HStack>
|
||||
<HStack mt={2} spacing={2} flexWrap="wrap">
|
||||
{formData.stocks.map((stock, idx) => (
|
||||
<Tag key={idx} size="sm" colorScheme="blue">
|
||||
<TagLeftIcon as={FiTrendingUp} />
|
||||
<TagLabel>{stock}</TagLabel>
|
||||
<TagCloseButton
|
||||
onClick={() => setFormData({
|
||||
...formData,
|
||||
stocks: formData.stocks.filter((_, i) => i !== idx)
|
||||
})}
|
||||
/>
|
||||
</Tag>
|
||||
))}
|
||||
</HStack>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>标签</FormLabel>
|
||||
<HStack>
|
||||
<Input
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
placeholder="输入标签"
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleAddTag()}
|
||||
/>
|
||||
<Button onClick={handleAddTag}>添加</Button>
|
||||
</HStack>
|
||||
<HStack mt={2} spacing={2} flexWrap="wrap">
|
||||
{formData.tags.map((tag, idx) => (
|
||||
<Tag key={idx} size="sm" colorScheme="purple">
|
||||
<TagLeftIcon as={FiHash} />
|
||||
<TagLabel>{tag}</TagLabel>
|
||||
<TagCloseButton
|
||||
onClick={() => setFormData({
|
||||
...formData,
|
||||
tags: formData.tags.filter((_, i) => i !== idx)
|
||||
})}
|
||||
/>
|
||||
</Tag>
|
||||
))}
|
||||
</HStack>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>状态</FormLabel>
|
||||
<Select
|
||||
value={formData.status}
|
||||
onChange={(e) => setFormData({ ...formData, status: e.target.value })}
|
||||
>
|
||||
<option value="active">进行中</option>
|
||||
<option value="completed">已完成</option>
|
||||
<option value="cancelled">已取消</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" mr={3} onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
onClick={handleSave}
|
||||
isDisabled={!formData.title || !formData.date}
|
||||
leftIcon={<FiSave />}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user