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 模块
|
// Home 模块
|
||||||
// ⚡ 直接引用 HomePage,无需中间层(静态页面不需要骨架屏)
|
// ⚡ 直接引用 HomePage,无需中间层(静态页面不需要骨架屏)
|
||||||
HomePage: React.lazy(() => import('@views/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')),
|
ProfilePage: React.lazy(() => import('@views/Profile/ProfilePage')),
|
||||||
// 价值论坛 - 我的积分页面
|
// 价值论坛 - 我的积分页面
|
||||||
ForumMyPoints: React.lazy(() => import('@views/Profile')),
|
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,
|
PlanFormData,
|
||||||
PlanningContextValue,
|
PlanningContextValue,
|
||||||
} from './investment';
|
} 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';
|
* Center - 个人中心仪表板主页面
|
||||||
import { logger } from '../../utils/logger';
|
*
|
||||||
import { getApiBase } from '../../utils/apiConfig';
|
* 对应路由:/home/center
|
||||||
import { useDashboardEvents } from '../../hooks/useDashboardEvents';
|
* 功能:自选股监控、关注事件、投资规划等
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||||
|
import { logger } from '@/utils/logger';
|
||||||
|
import { getApiBase } from '@/utils/apiConfig';
|
||||||
|
import { useDashboardEvents } from '@/hooks/useDashboardEvents';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Flex,
|
Flex,
|
||||||
Grid,
|
|
||||||
SimpleGrid,
|
|
||||||
Stack,
|
|
||||||
Text,
|
Text,
|
||||||
Badge,
|
|
||||||
Button,
|
|
||||||
VStack,
|
VStack,
|
||||||
HStack,
|
|
||||||
Card,
|
|
||||||
CardHeader,
|
|
||||||
CardBody,
|
|
||||||
Heading,
|
|
||||||
useColorModeValue,
|
|
||||||
Icon,
|
|
||||||
IconButton,
|
|
||||||
Stat,
|
|
||||||
StatLabel,
|
|
||||||
StatNumber,
|
|
||||||
StatHelpText,
|
|
||||||
StatArrow,
|
|
||||||
Divider,
|
|
||||||
Tag,
|
|
||||||
TagLabel,
|
|
||||||
TagLeftIcon,
|
|
||||||
Wrap,
|
|
||||||
WrapItem,
|
|
||||||
Avatar,
|
|
||||||
Tooltip,
|
|
||||||
Progress,
|
|
||||||
useToast,
|
useToast,
|
||||||
LinkBox,
|
|
||||||
LinkOverlay,
|
|
||||||
Spinner,
|
Spinner,
|
||||||
Center,
|
Center,
|
||||||
Image,
|
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useCenterColors } from './hooks';
|
||||||
import { useLocation, useNavigate, Link } from 'react-router-dom';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
import {
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
FiTrendingUp,
|
|
||||||
FiEye,
|
|
||||||
FiMessageSquare,
|
|
||||||
FiThumbsUp,
|
|
||||||
FiClock,
|
|
||||||
FiCalendar,
|
|
||||||
FiRefreshCw,
|
|
||||||
FiTrash2,
|
|
||||||
FiExternalLink,
|
|
||||||
FiPlus,
|
|
||||||
FiBarChart2,
|
|
||||||
FiStar,
|
|
||||||
FiActivity,
|
|
||||||
FiAlertCircle,
|
|
||||||
FiUsers,
|
|
||||||
} from 'react-icons/fi';
|
|
||||||
import InvestmentPlanningCenter from './components/InvestmentPlanningCenter';
|
import InvestmentPlanningCenter from './components/InvestmentPlanningCenter';
|
||||||
import { getEventDetailUrl } from '@/utils/idEncoder';
|
import { getEventDetailUrl } from '@/utils/idEncoder';
|
||||||
import MarketDashboard from '@views/Profile/components/MarketDashboard';
|
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 WatchSidebar from '@views/Profile/components/WatchSidebar';
|
||||||
import { THEME } from '@views/Profile/components/MarketDashboard/constants';
|
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 { user } = useAuth();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
// ⚡ 提取 userId 为独立变量
|
// 提取 userId 为独立变量(优化依赖项)
|
||||||
const userId = user?.id;
|
const userId = user?.id;
|
||||||
|
|
||||||
// 🎯 初始化Dashboard埋点Hook
|
// 初始化 Dashboard 埋点 Hook(类型断言为 DashboardEventsResult)
|
||||||
const dashboardEvents = useDashboardEvents({
|
const dashboardEvents = useDashboardEvents({
|
||||||
pageType: 'center',
|
pageType: 'center',
|
||||||
navigate
|
navigate
|
||||||
});
|
}) as DashboardEventsResult;
|
||||||
|
|
||||||
// 颜色主题
|
// 颜色主题(使用 useCenterColors 封装,避免 7 次 useColorModeValue 调用)
|
||||||
const textColor = useColorModeValue('gray.700', 'white');
|
const { secondaryText } = useCenterColors();
|
||||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
|
||||||
const bgColor = useColorModeValue('white', 'gray.800');
|
|
||||||
const hoverBg = useColorModeValue('gray.50', 'gray.700');
|
|
||||||
const secondaryText = useColorModeValue('gray.600', 'gray.400');
|
|
||||||
const cardBg = useColorModeValue('white', 'gray.800');
|
|
||||||
const sectionBg = useColorModeValue('gray.50', 'gray.900');
|
|
||||||
|
|
||||||
const [watchlist, setWatchlist] = useState([]);
|
// 数据状态
|
||||||
const [realtimeQuotes, setRealtimeQuotes] = useState({});
|
const [watchlist, setWatchlist] = useState<WatchlistItem[]>([]);
|
||||||
const [followingEvents, setFollowingEvents] = useState([]);
|
const [realtimeQuotes, setRealtimeQuotes] = useState<RealtimeQuotesMap>({});
|
||||||
const [eventComments, setEventComments] = useState([]);
|
const [followingEvents, setFollowingEvents] = useState<FollowingEvent[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [eventComments, setEventComments] = useState<EventComment[]>([]);
|
||||||
const [quotesLoading, setQuotesLoading] = useState(false);
|
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 {
|
try {
|
||||||
const base = getApiBase();
|
const base = getApiBase();
|
||||||
const ts = Date.now();
|
const ts = Date.now();
|
||||||
@@ -111,14 +120,15 @@ export default function CenterDashboard() {
|
|||||||
fetch(base + `/api/account/events/posts?_=${ts}`, { credentials: 'include', cache: 'no-store' }),
|
fetch(base + `/api/account/events/posts?_=${ts}`, { credentials: 'include', cache: 'no-store' }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const jw = await w.json();
|
const jw: WatchlistApiResponse = await w.json();
|
||||||
const je = await e.json();
|
const je: FollowingEventsApiResponse = await e.json();
|
||||||
const jc = await c.json();
|
const jc: EventCommentsApiResponse = await c.json();
|
||||||
|
|
||||||
if (jw.success) {
|
if (jw.success) {
|
||||||
const watchlistData = Array.isArray(jw.data) ? jw.data : [];
|
const watchlistData = Array.isArray(jw.data) ? jw.data : [];
|
||||||
setWatchlist(watchlistData);
|
setWatchlist(watchlistData);
|
||||||
|
|
||||||
// 🎯 追踪自选股列表查看
|
// 追踪自选股列表查看
|
||||||
if (watchlistData.length > 0) {
|
if (watchlistData.length > 0) {
|
||||||
dashboardEvents.trackWatchlistViewed(watchlistData.length, true);
|
dashboardEvents.trackWatchlistViewed(watchlistData.length, true);
|
||||||
}
|
}
|
||||||
@@ -128,18 +138,20 @@ export default function CenterDashboard() {
|
|||||||
loadRealtimeQuotes();
|
loadRealtimeQuotes();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (je.success) {
|
if (je.success) {
|
||||||
const eventsData = Array.isArray(je.data) ? je.data : [];
|
const eventsData = Array.isArray(je.data) ? je.data : [];
|
||||||
setFollowingEvents(eventsData);
|
setFollowingEvents(eventsData);
|
||||||
|
|
||||||
// 🎯 追踪关注的事件列表查看
|
// 追踪关注的事件列表查看
|
||||||
dashboardEvents.trackFollowingEventsViewed(eventsData.length);
|
dashboardEvents.trackFollowingEventsViewed(eventsData.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (jc.success) {
|
if (jc.success) {
|
||||||
const commentsData = Array.isArray(jc.data) ? jc.data : [];
|
const commentsData = Array.isArray(jc.data) ? jc.data : [];
|
||||||
setEventComments(commentsData);
|
setEventComments(commentsData);
|
||||||
|
|
||||||
// 🎯 追踪评论列表查看
|
// 追踪评论列表查看
|
||||||
dashboardEvents.trackCommentsViewed(commentsData.length);
|
dashboardEvents.trackCommentsViewed(commentsData.length);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -150,82 +162,9 @@ export default function CenterDashboard() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [userId]); // ⚡ 使用 userId 而不是 user?.id
|
}, [userId, loadRealtimeQuotes, dashboardEvents]);
|
||||||
|
|
||||||
// 加载实时行情
|
|
||||||
const loadRealtimeQuotes = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
setQuotesLoading(true);
|
|
||||||
const base = getApiBase();
|
|
||||||
const response = await fetch(base + '/api/account/watchlist/realtime', {
|
|
||||||
credentials: 'include',
|
|
||||||
cache: 'no-store'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
if (data.success) {
|
|
||||||
const quotesMap = {};
|
|
||||||
data.data.forEach(item => {
|
|
||||||
quotesMap[item.stock_code] = item;
|
|
||||||
});
|
|
||||||
setRealtimeQuotes(quotesMap);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Center', 'loadRealtimeQuotes', error, {
|
|
||||||
userId: user?.id,
|
|
||||||
watchlistLength: watchlist.length
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setQuotesLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 格式化日期
|
|
||||||
const formatDate = (dateString) => {
|
|
||||||
if (!dateString) return '';
|
|
||||||
const date = new Date(dateString);
|
|
||||||
const now = new Date();
|
|
||||||
const diffTime = Math.abs(now - date);
|
|
||||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
|
||||||
|
|
||||||
if (diffDays < 1) {
|
|
||||||
const diffHours = Math.ceil(diffTime / (1000 * 60 * 60));
|
|
||||||
if (diffHours < 1) {
|
|
||||||
const diffMinutes = Math.ceil(diffTime / (1000 * 60));
|
|
||||||
return `${diffMinutes}分钟前`;
|
|
||||||
}
|
|
||||||
return `${diffHours}小时前`;
|
|
||||||
} else if (diffDays < 7) {
|
|
||||||
return `${diffDays}天前`;
|
|
||||||
} else {
|
|
||||||
return date.toLocaleDateString('zh-CN');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 格式化数字
|
|
||||||
const formatNumber = (num) => {
|
|
||||||
if (!num) return '0';
|
|
||||||
if (num >= 10000) {
|
|
||||||
return (num / 10000).toFixed(1) + 'w';
|
|
||||||
} else if (num >= 1000) {
|
|
||||||
return (num / 1000).toFixed(1) + 'k';
|
|
||||||
}
|
|
||||||
return num.toString();
|
|
||||||
};
|
|
||||||
|
|
||||||
// 获取事件热度颜色
|
|
||||||
const getHeatColor = (score) => {
|
|
||||||
if (score >= 80) return 'red';
|
|
||||||
if (score >= 60) return 'orange';
|
|
||||||
if (score >= 40) return 'yellow';
|
|
||||||
return 'green';
|
|
||||||
};
|
|
||||||
|
|
||||||
// 🔧 使用 ref 跟踪是否已经加载过数据(首次加载标记)
|
|
||||||
const hasLoadedRef = React.useRef(false);
|
|
||||||
|
|
||||||
|
// 首次加载和页面可见性变化时加载数据
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const isOnCenterPage = location.pathname.includes('/home/center');
|
const isOnCenterPage = location.pathname.includes('/home/center');
|
||||||
|
|
||||||
@@ -236,12 +175,13 @@ export default function CenterDashboard() {
|
|||||||
loadData();
|
loadData();
|
||||||
}
|
}
|
||||||
|
|
||||||
const onVis = () => {
|
const onVis = (): void => {
|
||||||
if (document.visibilityState === 'visible' && location.pathname.includes('/home/center')) {
|
if (document.visibilityState === 'visible' && location.pathname.includes('/home/center')) {
|
||||||
console.log('[Center] 👁️ visibilitychange 触发 loadData');
|
console.log('[Center] 👁️ visibilitychange 触发 loadData');
|
||||||
loadData();
|
loadData();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener('visibilitychange', onVis);
|
document.addEventListener('visibilitychange', onVis);
|
||||||
return () => document.removeEventListener('visibilitychange', onVis);
|
return () => document.removeEventListener('visibilitychange', onVis);
|
||||||
}, [userId, location.pathname, loadData, user]);
|
}, [userId, location.pathname, loadData, user]);
|
||||||
@@ -259,7 +199,7 @@ export default function CenterDashboard() {
|
|||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
loadRealtimeQuotes();
|
loadRealtimeQuotes();
|
||||||
}, 60000); // 60秒刷新一次
|
}, 60000); // 60秒刷新一次
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}
|
}
|
||||||
}, [watchlist.length, loadRealtimeQuotes]);
|
}, [watchlist.length, loadRealtimeQuotes]);
|
||||||
@@ -327,8 +267,8 @@ export default function CenterDashboard() {
|
|||||||
watchlist={watchlist}
|
watchlist={watchlist}
|
||||||
realtimeQuotes={realtimeQuotes}
|
realtimeQuotes={realtimeQuotes}
|
||||||
followingEvents={followingEvents}
|
followingEvents={followingEvents}
|
||||||
onStockClick={(stock) => navigate(`/company/${stock.stock_code}`)}
|
onStockClick={(stock: WatchlistItem) => navigate(`/company/${stock.stock_code}`)}
|
||||||
onEventClick={(event) => navigate(getEventDetailUrl(event.id))}
|
onEventClick={(event: FollowingEvent) => navigate(getEventDetailUrl(event.id))}
|
||||||
onAddStock={() => navigate('/stocks')}
|
onAddStock={() => navigate('/stocks')}
|
||||||
onAddEvent={() => navigate('/community')}
|
onAddEvent={() => navigate('/community')}
|
||||||
/>
|
/>
|
||||||
@@ -337,6 +277,6 @@ export default function CenterDashboard() {
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export default CenterDashboard;
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
* - PlanningContext (数据共享层)
|
* - PlanningContext (数据共享层)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback, Suspense, lazy } from 'react';
|
import React, { useState, useEffect, useCallback, useMemo, Suspense, lazy } from 'react';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Card,
|
Card,
|
||||||
@@ -119,27 +119,46 @@ const InvestmentPlanningCenter: React.FC = () => {
|
|||||||
loadAllData();
|
loadAllData();
|
||||||
}, [loadAllData]);
|
}, [loadAllData]);
|
||||||
|
|
||||||
// 提供给子组件的 Context 值
|
// 提供给子组件的 Context 值(使用 useMemo 缓存,避免子组件不必要的重渲染)
|
||||||
const contextValue: PlanningContextValue = {
|
const contextValue: PlanningContextValue = useMemo(
|
||||||
allEvents,
|
() => ({
|
||||||
setAllEvents,
|
allEvents,
|
||||||
loadAllData,
|
setAllEvents,
|
||||||
loading,
|
loadAllData,
|
||||||
setLoading,
|
loading,
|
||||||
openPlanModalTrigger,
|
setLoading,
|
||||||
openReviewModalTrigger,
|
openPlanModalTrigger,
|
||||||
toast,
|
openReviewModalTrigger,
|
||||||
borderColor,
|
toast,
|
||||||
textColor,
|
borderColor,
|
||||||
secondaryText,
|
textColor,
|
||||||
cardBg,
|
secondaryText,
|
||||||
setViewMode,
|
cardBg,
|
||||||
setListTab,
|
setViewMode,
|
||||||
};
|
setListTab,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
allEvents,
|
||||||
|
loadAllData,
|
||||||
|
loading,
|
||||||
|
openPlanModalTrigger,
|
||||||
|
openReviewModalTrigger,
|
||||||
|
toast,
|
||||||
|
borderColor,
|
||||||
|
textColor,
|
||||||
|
secondaryText,
|
||||||
|
cardBg,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
// 计算各类型事件数量
|
// 计算各类型事件数量(使用 useMemo 缓存,避免每次渲染重复遍历数组)
|
||||||
const planCount = allEvents.filter(e => e.type === 'plan').length;
|
const { planCount, reviewCount } = useMemo(
|
||||||
const reviewCount = allEvents.filter(e => e.type === 'review').length;
|
() => ({
|
||||||
|
planCount: allEvents.filter(e => e.type === 'plan').length,
|
||||||
|
reviewCount: allEvents.filter(e => e.type === 'review').length,
|
||||||
|
}),
|
||||||
|
[allEvents]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PlanningDataProvider value={contextValue}>
|
<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