Compare commits
75 Commits
307d80c808
...
feature_bu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9156da410d | ||
|
|
073fba5c57 | ||
|
|
06475f82a4 | ||
|
|
b578504591 | ||
|
|
60aa5c80a5 | ||
|
|
a332d5571a | ||
| 1eb94cc213 | |||
|
|
e0e1e7e444 | ||
|
|
f1ae48bd42 | ||
|
|
602dcf8eee | ||
|
|
d9dbf65e7d | ||
|
|
12a57f2fa2 | ||
|
|
39fb70a1eb | ||
|
|
068d59634b | ||
|
|
4cae6fe5b6 | ||
|
|
145b6575d8 | ||
|
|
7d859e18ca | ||
|
|
939b4e736c | ||
|
|
b2ade04b00 | ||
|
|
6a21a57f4c | ||
|
|
2fe535e553 | ||
|
|
d24f9c7b16 | ||
|
|
ab5b19847f | ||
|
|
0b683f4227 | ||
|
|
fd5b74ec16 | ||
|
|
92e6fb254b | ||
|
|
c325d51316 | ||
|
|
a41cd71a65 | ||
|
|
3dabddf222 | ||
|
|
89ed59640e | ||
|
|
dafef2c572 | ||
|
|
bb0506b2bb | ||
|
|
8b9e35e55c | ||
|
|
5ca19d11a4 | ||
|
|
7a079a86b1 | ||
|
|
0a9ae6507b | ||
|
|
22d731167c | ||
|
|
600d9cc846 | ||
|
|
dcba97a121 | ||
|
|
a2a15e45a4 | ||
|
|
4f6bfe0b8c | ||
|
|
bbd965a307 | ||
|
|
f557ef96cf | ||
|
|
b6ed68244e | ||
|
|
e93d5532bf | ||
|
|
429737c111 | ||
| 9750ab75ba | |||
|
|
93928f4ee7 | ||
|
|
30b831e880 | ||
| 8a9e4f018a | |||
| a626c6c872 | |||
|
|
18ba36a539 | ||
|
|
c639b418f0 | ||
|
|
712090accb | ||
|
|
bc844bb4dc | ||
|
|
10e34d911f | ||
|
|
1a55e037c9 | ||
|
|
16c30b45b9 | ||
| 317bdb1daf | |||
| 5843029b9c | |||
| 0b95953db9 | |||
| 3ef1e6ea29 | |||
| 8936118133 | |||
| 1071405aaf | |||
| 144cc256cf | |||
| 82e4fab55c | |||
| 22c5c166bf | |||
| 61a29ce5ce | |||
| 20bcf3770a | |||
| 6d878df27c | |||
| a2a233bb0f | |||
|
|
174fe32850 | ||
|
|
77ea38e5c9 | ||
|
|
9e271747da | ||
|
|
88b836e75a |
95
app.py
95
app.py
@@ -351,6 +351,7 @@ def generate_events_cache_key(args_dict):
|
|||||||
params_str = json.dumps(filtered_params, sort_keys=True)
|
params_str = json.dumps(filtered_params, sort_keys=True)
|
||||||
params_hash = hashlib.md5(params_str.encode()).hexdigest()
|
params_hash = hashlib.md5(params_str.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
return f"{EVENTS_CACHE_PREFIX}{params_hash}"
|
return f"{EVENTS_CACHE_PREFIX}{params_hash}"
|
||||||
|
|
||||||
|
|
||||||
@@ -11004,7 +11005,9 @@ def get_events_by_mainline():
|
|||||||
4. 按指定层级分组返回
|
4. 按指定层级分组返回
|
||||||
|
|
||||||
参数:
|
参数:
|
||||||
- recent_days: 近N天(默认7天)
|
- recent_days: 近N天(默认7天,当有 start_date/end_date 时忽略)
|
||||||
|
- start_date: 开始时间(精确时间范围,格式 YYYY-MM-DD HH:mm:ss)
|
||||||
|
- end_date: 结束时间(精确时间范围,格式 YYYY-MM-DD HH:mm:ss)
|
||||||
- importance: 重要性筛选(S,A,B,C 或 all)
|
- importance: 重要性筛选(S,A,B,C 或 all)
|
||||||
- group_by: 分组方式 (lv1/lv2/lv3/具体概念ID如L2_AI_INFRA),默认lv2
|
- group_by: 分组方式 (lv1/lv2/lv3/具体概念ID如L2_AI_INFRA),默认lv2
|
||||||
|
|
||||||
@@ -11036,12 +11039,32 @@ def get_events_by_mainline():
|
|||||||
from sqlalchemy import exists
|
from sqlalchemy import exists
|
||||||
|
|
||||||
# 获取请求参数
|
# 获取请求参数
|
||||||
recent_days = request.args.get('recent_days', 7, type=int)
|
recent_days = request.args.get('recent_days', type=int)
|
||||||
|
start_date_str = request.args.get('start_date', '')
|
||||||
|
end_date_str = request.args.get('end_date', '')
|
||||||
importance = request.args.get('importance', 'all')
|
importance = request.args.get('importance', 'all')
|
||||||
group_by = request.args.get('group_by', 'lv2') # lv1/lv2/lv3 或具体ID
|
group_by = request.args.get('group_by', 'lv2') # lv1/lv2/lv3 或具体ID
|
||||||
|
|
||||||
# 计算日期范围
|
# 计算日期范围
|
||||||
|
# 优先使用精确时间范围,其次使用 recent_days
|
||||||
|
if start_date_str and end_date_str:
|
||||||
|
try:
|
||||||
|
since_date = datetime.strptime(start_date_str, '%Y-%m-%d %H:%M:%S')
|
||||||
|
until_date = datetime.strptime(end_date_str, '%Y-%m-%d %H:%M:%S')
|
||||||
|
app.logger.info(f'[mainline] 使用精确时间范围: {since_date} - {until_date}')
|
||||||
|
except ValueError as e:
|
||||||
|
app.logger.warning(f'[mainline] 时间格式解析失败: {e}, 降级使用 recent_days')
|
||||||
|
since_date = datetime.now() - timedelta(days=recent_days or 7)
|
||||||
|
until_date = None
|
||||||
|
elif recent_days:
|
||||||
since_date = datetime.now() - timedelta(days=recent_days)
|
since_date = datetime.now() - timedelta(days=recent_days)
|
||||||
|
until_date = None
|
||||||
|
app.logger.info(f'[mainline] 使用 recent_days: {recent_days}')
|
||||||
|
else:
|
||||||
|
# 默认7天
|
||||||
|
since_date = datetime.now() - timedelta(days=7)
|
||||||
|
until_date = None
|
||||||
|
app.logger.info(f'[mainline] 使用默认时间范围: 近7天')
|
||||||
|
|
||||||
# ==================== 1. 获取概念层级映射 ====================
|
# ==================== 1. 获取概念层级映射 ====================
|
||||||
# 调用 concept-api 获取层级结构
|
# 调用 concept-api 获取层级结构
|
||||||
@@ -11128,6 +11151,8 @@ def get_events_by_mainline():
|
|||||||
|
|
||||||
# 日期筛选
|
# 日期筛选
|
||||||
query = query.filter(Event.created_at >= since_date)
|
query = query.filter(Event.created_at >= since_date)
|
||||||
|
if until_date:
|
||||||
|
query = query.filter(Event.created_at <= until_date)
|
||||||
|
|
||||||
# 重要性筛选
|
# 重要性筛选
|
||||||
if importance != 'all':
|
if importance != 'all':
|
||||||
@@ -11279,35 +11304,60 @@ def get_events_by_mainline():
|
|||||||
else:
|
else:
|
||||||
ungrouped_events.append(event_data)
|
ungrouped_events.append(event_data)
|
||||||
|
|
||||||
# ==================== 5. 获取 lv2 概念涨跌幅 ====================
|
# ==================== 5. 获取概念涨跌幅(根据 group_by 参数) ====================
|
||||||
lv2_price_map = {}
|
price_map = {}
|
||||||
|
|
||||||
|
# 确定当前分组层级和对应的数据库类型
|
||||||
|
if group_by == 'lv1' or group_by.startswith('L1_'):
|
||||||
|
current_level = 'lv1'
|
||||||
|
db_concept_type = 'lv1'
|
||||||
|
name_prefix = '[一级] '
|
||||||
|
name_field = 'lv1_name'
|
||||||
|
elif group_by == 'lv3' or group_by.startswith('L2_'):
|
||||||
|
current_level = 'lv3'
|
||||||
|
db_concept_type = 'lv3'
|
||||||
|
name_prefix = '[三级] '
|
||||||
|
name_field = 'lv3_name'
|
||||||
|
else: # lv2 或 L3_ 开头(查看 lv3 下的具体分类,显示 lv2 涨跌幅)
|
||||||
|
current_level = 'lv2'
|
||||||
|
db_concept_type = 'lv2'
|
||||||
|
name_prefix = '[二级] '
|
||||||
|
name_field = 'lv2_name'
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 获取所有 lv2 名称
|
# 获取所有对应层级的名称
|
||||||
lv2_names = [group['lv2_name'] for group in mainline_groups.values() if group.get('lv2_name')]
|
group_names = [group.get('group_name') or group.get(name_field) for group in mainline_groups.values()]
|
||||||
if lv2_names:
|
group_names = [n for n in group_names if n] # 过滤掉空值
|
||||||
# 数据库中的 concept_name 带有 "[二级] " 前缀,需要添加前缀来匹配
|
|
||||||
lv2_names_with_prefix = [f'[二级] {name}' for name in lv2_names]
|
if group_names:
|
||||||
|
# 数据库中的 concept_name 带有前缀,需要添加前缀来匹配
|
||||||
|
names_with_prefix = [f'{name_prefix}{name}' for name in group_names]
|
||||||
|
|
||||||
# 查询 concept_daily_stats 表获取最新涨跌幅
|
# 查询 concept_daily_stats 表获取最新涨跌幅
|
||||||
price_sql = text('''
|
price_sql = text('''
|
||||||
SELECT concept_name, avg_change_pct, trade_date
|
SELECT concept_name, avg_change_pct, trade_date
|
||||||
FROM concept_daily_stats
|
FROM concept_daily_stats
|
||||||
WHERE concept_type = 'lv2'
|
WHERE concept_type = :concept_type
|
||||||
AND concept_name IN :names
|
AND concept_name IN :names
|
||||||
AND trade_date = (
|
AND trade_date = (
|
||||||
SELECT MAX(trade_date) FROM concept_daily_stats WHERE concept_type = 'lv2'
|
SELECT MAX(trade_date) FROM concept_daily_stats WHERE concept_type = :concept_type
|
||||||
)
|
)
|
||||||
''')
|
''')
|
||||||
price_result = db.session.execute(price_sql, {'names': tuple(lv2_names_with_prefix)}).fetchall()
|
price_result = db.session.execute(price_sql, {
|
||||||
|
'concept_type': db_concept_type,
|
||||||
|
'names': tuple(names_with_prefix)
|
||||||
|
}).fetchall()
|
||||||
|
|
||||||
for row in price_result:
|
for row in price_result:
|
||||||
# 去掉 "[二级] " 前缀,用原始名称作为 key
|
# 去掉前缀,用原始名称作为 key
|
||||||
original_name = row.concept_name.replace('[二级] ', '') if row.concept_name else ''
|
original_name = row.concept_name.replace(name_prefix, '') if row.concept_name else ''
|
||||||
lv2_price_map[original_name] = {
|
price_map[original_name] = {
|
||||||
'avg_change_pct': float(row.avg_change_pct) if row.avg_change_pct else None,
|
'avg_change_pct': float(row.avg_change_pct) if row.avg_change_pct else None,
|
||||||
'trade_date': str(row.trade_date) if row.trade_date else None
|
'trade_date': str(row.trade_date) if row.trade_date else None
|
||||||
}
|
}
|
||||||
app.logger.info(f'[mainline] 获取 lv2 涨跌幅: {len(lv2_price_map)} 条, lv2_names 数量: {len(lv2_names)}')
|
app.logger.info(f'[mainline] 获取 {current_level} 涨跌幅: {len(price_map)} 条, 查询名称数量: {len(group_names)}')
|
||||||
except Exception as price_err:
|
except Exception as price_err:
|
||||||
app.logger.warning(f'[mainline] 获取 lv2 涨跌幅失败: {price_err}')
|
app.logger.warning(f'[mainline] 获取 {current_level} 涨跌幅失败: {price_err}')
|
||||||
|
|
||||||
# ==================== 6. 整理返回数据 ====================
|
# ==================== 6. 整理返回数据 ====================
|
||||||
mainlines = []
|
mainlines = []
|
||||||
@@ -11319,11 +11369,12 @@ def get_events_by_mainline():
|
|||||||
reverse=True
|
reverse=True
|
||||||
)
|
)
|
||||||
group['event_count'] = len(group['events'])
|
group['event_count'] = len(group['events'])
|
||||||
# 添加涨跌幅数据(目前只支持 lv2)
|
|
||||||
lv2_name = group.get('lv2_name', '') or group.get('group_name', '')
|
# 添加涨跌幅数据(根据当前分组层级)
|
||||||
if lv2_name in lv2_price_map:
|
group_name = group.get('group_name') or group.get(name_field, '')
|
||||||
group['avg_change_pct'] = lv2_price_map[lv2_name]['avg_change_pct']
|
if group_name in price_map:
|
||||||
group['price_date'] = lv2_price_map[lv2_name]['trade_date']
|
group['avg_change_pct'] = price_map[group_name]['avg_change_pct']
|
||||||
|
group['price_date'] = price_map[group_name]['trade_date']
|
||||||
else:
|
else:
|
||||||
group['avg_change_pct'] = None
|
group['avg_change_pct'] = None
|
||||||
group['price_date'] = None
|
group['price_date'] = None
|
||||||
|
|||||||
@@ -14,10 +14,6 @@
|
|||||||
"@fontsource/open-sans": "^4.5.0",
|
"@fontsource/open-sans": "^4.5.0",
|
||||||
"@fontsource/raleway": "^4.5.0",
|
"@fontsource/raleway": "^4.5.0",
|
||||||
"@fontsource/roboto": "^4.5.0",
|
"@fontsource/roboto": "^4.5.0",
|
||||||
"@fullcalendar/core": "^6.1.19",
|
|
||||||
"@fullcalendar/daygrid": "^6.1.19",
|
|
||||||
"@fullcalendar/interaction": "^6.1.19",
|
|
||||||
"@fullcalendar/react": "^6.1.19",
|
|
||||||
"@reduxjs/toolkit": "^2.9.2",
|
"@reduxjs/toolkit": "^2.9.2",
|
||||||
"@splidejs/react-splide": "^0.7.12",
|
"@splidejs/react-splide": "^0.7.12",
|
||||||
"@tanstack/react-virtual": "^3.13.12",
|
"@tanstack/react-virtual": "^3.13.12",
|
||||||
|
|||||||
@@ -203,44 +203,46 @@ When it's NOT activated, the fc-button classes won't even be in the DOM.
|
|||||||
|
|
||||||
}
|
}
|
||||||
.fc .fc-button-primary {
|
.fc .fc-button-primary {
|
||||||
color: #fff;
|
color: #0A0A14;
|
||||||
color: var(--fc-button-text-color, #fff);
|
color: var(--fc-button-text-color, #0A0A14);
|
||||||
background-color: #805AD5;
|
background-color: #D4AF37;
|
||||||
background-color: var(--fc-button-bg-color, #805AD5);
|
background-color: var(--fc-button-bg-color, #D4AF37);
|
||||||
border-color: #805AD5;
|
border-color: #D4AF37;
|
||||||
border-color: var(--fc-button-border-color, #805AD5);
|
border-color: var(--fc-button-border-color, #D4AF37);
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.fc .fc-button-primary:hover {
|
.fc .fc-button-primary:hover {
|
||||||
color: #fff;
|
color: #0A0A14;
|
||||||
color: var(--fc-button-text-color, #fff);
|
color: var(--fc-button-text-color, #0A0A14);
|
||||||
background-color: #6B46C1;
|
background-color: #B8960C;
|
||||||
background-color: var(--fc-button-hover-bg-color, #6B46C1);
|
background-color: var(--fc-button-hover-bg-color, #B8960C);
|
||||||
border-color: #6B46C1;
|
border-color: #B8960C;
|
||||||
border-color: var(--fc-button-hover-border-color, #6B46C1);
|
border-color: var(--fc-button-hover-border-color, #B8960C);
|
||||||
}
|
}
|
||||||
.fc .fc-button-primary:disabled { /* not DRY */
|
.fc .fc-button-primary:disabled { /* not DRY */
|
||||||
color: #fff;
|
color: #0A0A14;
|
||||||
color: var(--fc-button-text-color, #fff);
|
color: var(--fc-button-text-color, #0A0A14);
|
||||||
background-color: #805AD5;
|
background-color: #B8960C;
|
||||||
background-color: var(--fc-button-bg-color, #805AD5);
|
background-color: var(--fc-button-bg-color, #B8960C);
|
||||||
border-color: #805AD5;
|
border-color: #B8960C;
|
||||||
border-color: var(--fc-button-border-color, #805AD5); /* overrides :hover */
|
border-color: var(--fc-button-border-color, #B8960C); /* overrides :hover */
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
.fc .fc-button-primary:focus {
|
.fc .fc-button-primary:focus {
|
||||||
box-shadow: 0 0 0 0.2rem rgba(128, 90, 213, 0.5);
|
box-shadow: 0 0 0 0.2rem rgba(212, 175, 55, 0.5);
|
||||||
}
|
}
|
||||||
.fc .fc-button-primary:not(:disabled):active,
|
.fc .fc-button-primary:not(:disabled):active,
|
||||||
.fc .fc-button-primary:not(:disabled).fc-button-active {
|
.fc .fc-button-primary:not(:disabled).fc-button-active {
|
||||||
color: #fff;
|
color: #0A0A14;
|
||||||
color: var(--fc-button-text-color, #fff);
|
color: var(--fc-button-text-color, #0A0A14);
|
||||||
background-color: #6B46C1;
|
background-color: #B8960C;
|
||||||
background-color: var(--fc-button-active-bg-color, #6B46C1);
|
background-color: var(--fc-button-active-bg-color, #B8960C);
|
||||||
border-color: #6B46C1;
|
border-color: #B8960C;
|
||||||
border-color: var(--fc-button-active-border-color, #6B46C1);
|
border-color: var(--fc-button-active-border-color, #B8960C);
|
||||||
}
|
}
|
||||||
.fc .fc-button-primary:not(:disabled):active:focus,
|
.fc .fc-button-primary:not(:disabled):active:focus,
|
||||||
.fc .fc-button-primary:not(:disabled).fc-button-active:focus {
|
.fc .fc-button-primary:not(:disabled).fc-button-active:focus {
|
||||||
box-shadow: 0 0 0 0.2rem rgba(128, 90, 213, 0.5);
|
box-shadow: 0 0 0 0.2rem rgba(212, 175, 55, 0.5);
|
||||||
}
|
}
|
||||||
.fc {
|
.fc {
|
||||||
|
|
||||||
|
|||||||
269
src/components/Calendar/BaseCalendar.tsx
Normal file
269
src/components/Calendar/BaseCalendar.tsx
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
/**
|
||||||
|
* BaseCalendar - 基础日历组件
|
||||||
|
* 封装 Ant Design Calendar,提供统一的黑金主题和接口
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useCallback } from 'react';
|
||||||
|
import { Calendar, ConfigProvider, Button } from 'antd';
|
||||||
|
import type { CalendarProps } from 'antd';
|
||||||
|
import { LeftOutlined, RightOutlined } from '@ant-design/icons';
|
||||||
|
import type { Dayjs } from 'dayjs';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import 'dayjs/locale/zh-cn';
|
||||||
|
import zhCN from 'antd/locale/zh_CN';
|
||||||
|
import { Box, HStack, Text } from '@chakra-ui/react';
|
||||||
|
import { CALENDAR_THEME, CALENDAR_COLORS, CALENDAR_STYLES } from './theme';
|
||||||
|
|
||||||
|
dayjs.locale('zh-cn');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单元格渲染信息
|
||||||
|
*/
|
||||||
|
export interface CellRenderInfo {
|
||||||
|
type: 'date' | 'month';
|
||||||
|
isToday: boolean;
|
||||||
|
isCurrentMonth: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BaseCalendar Props
|
||||||
|
*/
|
||||||
|
export interface BaseCalendarProps {
|
||||||
|
/** 当前选中日期 */
|
||||||
|
value?: Dayjs;
|
||||||
|
/** 日期变化回调(月份切换等) */
|
||||||
|
onChange?: (date: Dayjs) => void;
|
||||||
|
/** 日期选择回调(点击日期) */
|
||||||
|
onSelect?: (date: Dayjs) => void;
|
||||||
|
/** 自定义单元格内容渲染 */
|
||||||
|
cellRender?: (date: Dayjs, info: CellRenderInfo) => React.ReactNode;
|
||||||
|
/** 日历高度 */
|
||||||
|
height?: string | number;
|
||||||
|
/** 是否显示工具栏 */
|
||||||
|
showToolbar?: boolean;
|
||||||
|
/** 工具栏标题格式 */
|
||||||
|
titleFormat?: string;
|
||||||
|
/** 额外的 className */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 默认工具栏组件
|
||||||
|
*/
|
||||||
|
const CalendarToolbar: React.FC<{
|
||||||
|
value: Dayjs;
|
||||||
|
onChange: (date: Dayjs) => void;
|
||||||
|
titleFormat?: string;
|
||||||
|
}> = ({ value, onChange, titleFormat = 'YYYY年M月' }) => {
|
||||||
|
const handlePrev = () => onChange(value.subtract(1, 'month'));
|
||||||
|
const handleNext = () => onChange(value.add(1, 'month'));
|
||||||
|
const handleToday = () => onChange(dayjs());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HStack justify="flex-start" mb={4} px={2} spacing={4}>
|
||||||
|
<HStack spacing={2}>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<LeftOutlined />}
|
||||||
|
onClick={handlePrev}
|
||||||
|
style={{
|
||||||
|
backgroundColor: CALENDAR_STYLES.toolbar.buttonBg,
|
||||||
|
borderColor: CALENDAR_STYLES.toolbar.buttonBg,
|
||||||
|
color: CALENDAR_STYLES.toolbar.buttonColor,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<RightOutlined />}
|
||||||
|
onClick={handleNext}
|
||||||
|
style={{
|
||||||
|
backgroundColor: CALENDAR_STYLES.toolbar.buttonBg,
|
||||||
|
borderColor: CALENDAR_STYLES.toolbar.buttonBg,
|
||||||
|
color: CALENDAR_STYLES.toolbar.buttonColor,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={handleToday}
|
||||||
|
style={{
|
||||||
|
backgroundColor: CALENDAR_STYLES.toolbar.buttonBg,
|
||||||
|
borderColor: CALENDAR_STYLES.toolbar.buttonBg,
|
||||||
|
color: CALENDAR_STYLES.toolbar.buttonColor,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
今天
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
<Text
|
||||||
|
fontSize={{ base: 'lg', md: 'xl' }}
|
||||||
|
fontWeight="700"
|
||||||
|
bgGradient={`linear(135deg, ${CALENDAR_COLORS.gold.primary} 0%, #F5E6A3 100%)`}
|
||||||
|
bgClip="text"
|
||||||
|
>
|
||||||
|
{value.format(titleFormat)}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BaseCalendar 组件
|
||||||
|
*/
|
||||||
|
export const BaseCalendar: React.FC<BaseCalendarProps> = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onSelect,
|
||||||
|
cellRender,
|
||||||
|
height = '100%',
|
||||||
|
showToolbar = true,
|
||||||
|
titleFormat = 'YYYY年M月',
|
||||||
|
className,
|
||||||
|
}) => {
|
||||||
|
const [currentValue, setCurrentValue] = React.useState<Dayjs>(value || dayjs());
|
||||||
|
|
||||||
|
// 同步外部 value
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (value) {
|
||||||
|
setCurrentValue(value);
|
||||||
|
}
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
// 处理日期变化
|
||||||
|
const handleChange = useCallback((date: Dayjs) => {
|
||||||
|
setCurrentValue(date);
|
||||||
|
onChange?.(date);
|
||||||
|
}, [onChange]);
|
||||||
|
|
||||||
|
// 处理日期选择(只在点击日期时触发,不在切换面板时触发)
|
||||||
|
const handleSelect: CalendarProps<Dayjs>['onSelect'] = useCallback((date: Dayjs, selectInfo) => {
|
||||||
|
// selectInfo.source: 'date' 表示点击日期,'month' 表示切换月份面板
|
||||||
|
// 只在点击日期时触发 onSelect
|
||||||
|
if (selectInfo.source === 'date') {
|
||||||
|
setCurrentValue(date);
|
||||||
|
onSelect?.(date);
|
||||||
|
}
|
||||||
|
}, [onSelect]);
|
||||||
|
|
||||||
|
// 自定义单元格渲染
|
||||||
|
const fullCellRender = useCallback((date: Dayjs) => {
|
||||||
|
const isToday = date.isSame(dayjs(), 'day');
|
||||||
|
const isCurrentMonth = date.isSame(currentValue, 'month');
|
||||||
|
|
||||||
|
const info: CellRenderInfo = {
|
||||||
|
type: 'date',
|
||||||
|
isToday,
|
||||||
|
isCurrentMonth,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 基础日期单元格样式
|
||||||
|
const cellStyle: React.CSSProperties = {
|
||||||
|
minHeight: CALENDAR_STYLES.cell.minHeight,
|
||||||
|
padding: CALENDAR_STYLES.cell.padding,
|
||||||
|
borderRadius: '8px',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
cursor: 'pointer',
|
||||||
|
...(isToday ? {
|
||||||
|
backgroundColor: CALENDAR_STYLES.today.bg,
|
||||||
|
border: CALENDAR_STYLES.today.border,
|
||||||
|
} : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={cellStyle}
|
||||||
|
className="base-calendar-cell"
|
||||||
|
>
|
||||||
|
{/* 日期数字 */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: isToday ? 700 : 600,
|
||||||
|
color: isToday
|
||||||
|
? CALENDAR_COLORS.gold.primary
|
||||||
|
: isCurrentMonth
|
||||||
|
? '#FFFFFF' // 纯白色,更亮
|
||||||
|
: 'rgba(255, 255, 255, 0.4)',
|
||||||
|
marginBottom: '4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{date.date()}
|
||||||
|
</div>
|
||||||
|
{/* 自定义内容 */}
|
||||||
|
{cellRender?.(date, info)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}, [currentValue, cellRender]);
|
||||||
|
|
||||||
|
// 隐藏默认 header
|
||||||
|
const headerRender = useCallback((): React.ReactNode => null, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box height={height} className={className}>
|
||||||
|
<ConfigProvider theme={CALENDAR_THEME} locale={zhCN}>
|
||||||
|
{showToolbar && (
|
||||||
|
<CalendarToolbar
|
||||||
|
value={currentValue}
|
||||||
|
onChange={handleChange}
|
||||||
|
titleFormat={titleFormat}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Box
|
||||||
|
height={showToolbar ? 'calc(100% - 60px)' : '100%'}
|
||||||
|
sx={{
|
||||||
|
// 日历整体样式
|
||||||
|
'.ant-picker-calendar': {
|
||||||
|
bg: 'transparent',
|
||||||
|
},
|
||||||
|
'.ant-picker-panel': {
|
||||||
|
bg: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
},
|
||||||
|
// 星期头 - 居中显示
|
||||||
|
'.ant-picker-content thead th': {
|
||||||
|
color: `${CALENDAR_COLORS.gold.primary} !important`,
|
||||||
|
fontWeight: '600 !important',
|
||||||
|
fontSize: '14px',
|
||||||
|
padding: '8px 0',
|
||||||
|
textAlign: 'center !important',
|
||||||
|
},
|
||||||
|
// 日期单元格
|
||||||
|
'.ant-picker-cell': {
|
||||||
|
padding: '2px',
|
||||||
|
},
|
||||||
|
'.ant-picker-cell-inner': {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
// 非当前月份
|
||||||
|
'.ant-picker-cell-in-view': {
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
'.ant-picker-cell:not(.ant-picker-cell-in-view)': {
|
||||||
|
opacity: 0.5,
|
||||||
|
},
|
||||||
|
// hover 效果
|
||||||
|
'.base-calendar-cell:hover': {
|
||||||
|
bg: 'rgba(212, 175, 55, 0.1)',
|
||||||
|
},
|
||||||
|
// 选中状态
|
||||||
|
'.ant-picker-cell-selected .base-calendar-cell': {
|
||||||
|
bg: 'rgba(212, 175, 55, 0.15)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Calendar
|
||||||
|
fullscreen={true}
|
||||||
|
value={currentValue}
|
||||||
|
onChange={handleChange}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
fullCellRender={fullCellRender}
|
||||||
|
headerRender={headerRender}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</ConfigProvider>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BaseCalendar;
|
||||||
142
src/components/Calendar/CalendarEventBlock.tsx
Normal file
142
src/components/Calendar/CalendarEventBlock.tsx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
/**
|
||||||
|
* CalendarEventBlock - 日历事件块组件
|
||||||
|
* 用于在日历单元格中显示事件列表,支持多种事件类型和 "更多" 折叠
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { HStack, Text, Badge, VStack, Box } from '@chakra-ui/react';
|
||||||
|
import { CALENDAR_COLORS } from './theme';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 事件类型定义
|
||||||
|
*/
|
||||||
|
export type EventType = 'news' | 'report' | 'plan' | 'review' | 'system' | 'priceUp' | 'priceDown';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 日历事件接口
|
||||||
|
*/
|
||||||
|
export interface CalendarEvent {
|
||||||
|
id: string | number;
|
||||||
|
type: EventType;
|
||||||
|
title: string;
|
||||||
|
date: string;
|
||||||
|
count?: number;
|
||||||
|
data?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 事件块 Props
|
||||||
|
*/
|
||||||
|
interface CalendarEventBlockProps {
|
||||||
|
events: CalendarEvent[];
|
||||||
|
maxDisplay?: number;
|
||||||
|
onEventClick?: (event: CalendarEvent) => void;
|
||||||
|
onMoreClick?: (events: CalendarEvent[]) => void;
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 事件类型配置
|
||||||
|
*/
|
||||||
|
const EVENT_CONFIG: Record<EventType, { label: string; color: string; emoji?: string }> = {
|
||||||
|
news: { label: '新闻', color: CALENDAR_COLORS.events.news, emoji: '📰' },
|
||||||
|
report: { label: '研报', color: CALENDAR_COLORS.events.report, emoji: '📊' },
|
||||||
|
plan: { label: '计划', color: CALENDAR_COLORS.events.plan },
|
||||||
|
review: { label: '复盘', color: CALENDAR_COLORS.events.review },
|
||||||
|
system: { label: '系统', color: CALENDAR_COLORS.events.system },
|
||||||
|
priceUp: { label: '涨', color: CALENDAR_COLORS.events.priceUp, emoji: '🔥' },
|
||||||
|
priceDown: { label: '跌', color: CALENDAR_COLORS.events.priceDown },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单个事件行组件
|
||||||
|
*/
|
||||||
|
const EventLine: React.FC<{
|
||||||
|
event: CalendarEvent;
|
||||||
|
compact?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
|
}> = ({ event, compact, onClick }) => {
|
||||||
|
const config = EVENT_CONFIG[event.type] || { label: event.type, color: '#888' };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
fontSize={compact ? '9px' : '10px'}
|
||||||
|
color={config.color}
|
||||||
|
cursor="pointer"
|
||||||
|
px={1}
|
||||||
|
py={0.5}
|
||||||
|
borderRadius="sm"
|
||||||
|
_hover={{ bg: 'rgba(255, 255, 255, 0.1)' }}
|
||||||
|
w="100%"
|
||||||
|
overflow="hidden"
|
||||||
|
textAlign="left"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClick?.();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 格式:计划:年末布局XX+1 */}
|
||||||
|
<Text fontWeight="600" fontSize={compact ? '9px' : '10px'} isTruncated>
|
||||||
|
{config.emoji ? `${config.emoji} ` : ''}{config.label}:{event.title || ''}
|
||||||
|
{(event.count ?? 0) > 1 && <Text as="span" color={config.color}>+{(event.count ?? 1) - 1}</Text>}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 日历事件块组件
|
||||||
|
*/
|
||||||
|
export const CalendarEventBlock: React.FC<CalendarEventBlockProps> = ({
|
||||||
|
events,
|
||||||
|
maxDisplay = 3,
|
||||||
|
onEventClick,
|
||||||
|
onMoreClick,
|
||||||
|
compact = false,
|
||||||
|
}) => {
|
||||||
|
// 计算显示的事件和剩余事件
|
||||||
|
const { displayEvents, remainingCount, remainingEvents } = useMemo(() => {
|
||||||
|
if (events.length <= maxDisplay) {
|
||||||
|
return { displayEvents: events, remainingCount: 0, remainingEvents: [] };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
displayEvents: events.slice(0, maxDisplay),
|
||||||
|
remainingCount: events.length - maxDisplay,
|
||||||
|
remainingEvents: events.slice(maxDisplay),
|
||||||
|
};
|
||||||
|
}, [events, maxDisplay]);
|
||||||
|
|
||||||
|
if (events.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VStack spacing={0} align="stretch" w="100%">
|
||||||
|
{displayEvents.map((event) => (
|
||||||
|
<EventLine
|
||||||
|
key={event.id}
|
||||||
|
event={event}
|
||||||
|
compact={compact}
|
||||||
|
onClick={() => onEventClick?.(event)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{remainingCount > 0 && (
|
||||||
|
<Box
|
||||||
|
fontSize="9px"
|
||||||
|
color={CALENDAR_COLORS.text.secondary}
|
||||||
|
cursor="pointer"
|
||||||
|
px={1}
|
||||||
|
py={0.5}
|
||||||
|
borderRadius="sm"
|
||||||
|
_hover={{ bg: 'rgba(255, 255, 255, 0.1)' }}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onMoreClick?.(remainingEvents);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text>+{remainingCount} 更多</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CalendarEventBlock;
|
||||||
19
src/components/Calendar/index.ts
Normal file
19
src/components/Calendar/index.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Calendar 公共组件库
|
||||||
|
* 统一的日历组件,基于 Ant Design Calendar + 黑金主题
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 基础日历组件
|
||||||
|
export { BaseCalendar } from './BaseCalendar';
|
||||||
|
export type { BaseCalendarProps, CellRenderInfo } from './BaseCalendar';
|
||||||
|
|
||||||
|
// 事件块组件
|
||||||
|
export { CalendarEventBlock } from './CalendarEventBlock';
|
||||||
|
export type { CalendarEvent, EventType } from './CalendarEventBlock';
|
||||||
|
|
||||||
|
// 主题配置
|
||||||
|
export {
|
||||||
|
CALENDAR_THEME,
|
||||||
|
CALENDAR_COLORS,
|
||||||
|
CALENDAR_STYLES,
|
||||||
|
} from './theme';
|
||||||
111
src/components/Calendar/theme.ts
Normal file
111
src/components/Calendar/theme.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
/**
|
||||||
|
* Calendar 黑金主题配置
|
||||||
|
* 统一的 Ant Design Calendar 主题,用于所有日历组件
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ThemeConfig } from 'antd';
|
||||||
|
|
||||||
|
// 黑金主题色值
|
||||||
|
export const CALENDAR_COLORS = {
|
||||||
|
// 主色
|
||||||
|
gold: {
|
||||||
|
primary: '#D4AF37',
|
||||||
|
secondary: '#B8960C',
|
||||||
|
gradient: 'linear-gradient(135deg, #D4AF37 0%, #F5E6A3 100%)',
|
||||||
|
},
|
||||||
|
// 背景色
|
||||||
|
bg: {
|
||||||
|
deep: '#0A0A14',
|
||||||
|
primary: '#0F0F1A',
|
||||||
|
elevated: '#1A1A2E',
|
||||||
|
surface: '#252540',
|
||||||
|
},
|
||||||
|
// 边框色
|
||||||
|
border: {
|
||||||
|
subtle: 'rgba(212, 175, 55, 0.1)',
|
||||||
|
default: 'rgba(212, 175, 55, 0.2)',
|
||||||
|
emphasis: 'rgba(212, 175, 55, 0.4)',
|
||||||
|
},
|
||||||
|
// 文字色
|
||||||
|
text: {
|
||||||
|
primary: 'rgba(255, 255, 255, 0.95)',
|
||||||
|
secondary: 'rgba(255, 255, 255, 0.6)',
|
||||||
|
muted: 'rgba(255, 255, 255, 0.4)',
|
||||||
|
},
|
||||||
|
// 事件类型颜色
|
||||||
|
events: {
|
||||||
|
news: '#9F7AEA', // 紫色 - 新闻
|
||||||
|
report: '#805AD5', // 深紫 - 研报
|
||||||
|
plan: '#D4AF37', // 金色 - 计划
|
||||||
|
review: '#10B981', // 绿色 - 复盘
|
||||||
|
system: '#3B82F6', // 蓝色 - 系统事件
|
||||||
|
priceUp: '#FC8181', // 红色 - 上涨
|
||||||
|
priceDown: '#68D391', // 绿色 - 下跌
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ant Design Calendar 黑金主题配置
|
||||||
|
*/
|
||||||
|
export const CALENDAR_THEME: ThemeConfig = {
|
||||||
|
token: {
|
||||||
|
// 基础色
|
||||||
|
colorBgContainer: 'transparent',
|
||||||
|
colorBgElevated: CALENDAR_COLORS.bg.elevated,
|
||||||
|
colorText: CALENDAR_COLORS.text.primary,
|
||||||
|
colorTextSecondary: CALENDAR_COLORS.text.secondary,
|
||||||
|
colorTextTertiary: CALENDAR_COLORS.text.muted,
|
||||||
|
colorTextHeading: CALENDAR_COLORS.gold.primary,
|
||||||
|
|
||||||
|
// 边框
|
||||||
|
colorBorder: CALENDAR_COLORS.border.default,
|
||||||
|
colorBorderSecondary: CALENDAR_COLORS.border.subtle,
|
||||||
|
|
||||||
|
// 主色
|
||||||
|
colorPrimary: CALENDAR_COLORS.gold.primary,
|
||||||
|
colorPrimaryHover: CALENDAR_COLORS.gold.secondary,
|
||||||
|
colorPrimaryActive: CALENDAR_COLORS.gold.secondary,
|
||||||
|
|
||||||
|
// 链接色
|
||||||
|
colorLink: CALENDAR_COLORS.gold.primary,
|
||||||
|
colorLinkHover: CALENDAR_COLORS.gold.secondary,
|
||||||
|
|
||||||
|
// 圆角
|
||||||
|
borderRadius: 8,
|
||||||
|
borderRadiusLG: 12,
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
Calendar: {
|
||||||
|
// 日历整体背景
|
||||||
|
fullBg: 'transparent',
|
||||||
|
fullPanelBg: 'transparent',
|
||||||
|
|
||||||
|
// 选中项背景
|
||||||
|
itemActiveBg: 'rgba(212, 175, 55, 0.15)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 日历样式常量(用于内联样式或 CSS-in-JS)
|
||||||
|
*/
|
||||||
|
export const CALENDAR_STYLES = {
|
||||||
|
// 今天高亮
|
||||||
|
today: {
|
||||||
|
bg: 'rgba(212, 175, 55, 0.1)',
|
||||||
|
border: `2px solid ${CALENDAR_COLORS.gold.primary}`,
|
||||||
|
},
|
||||||
|
// 日期单元格
|
||||||
|
cell: {
|
||||||
|
minHeight: '85px',
|
||||||
|
padding: '4px',
|
||||||
|
},
|
||||||
|
// 工具栏
|
||||||
|
toolbar: {
|
||||||
|
buttonBg: CALENDAR_COLORS.gold.primary,
|
||||||
|
buttonColor: CALENDAR_COLORS.bg.deep,
|
||||||
|
buttonHoverBg: CALENDAR_COLORS.gold.secondary,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CALENDAR_THEME;
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
/*!
|
|
||||||
|
|
||||||
=========================================================
|
|
||||||
* Argon Dashboard Chakra PRO - v1.0.0
|
|
||||||
=========================================================
|
|
||||||
|
|
||||||
* Product Page: https://www.creative-tim.com/product/argon-dashboard-chakra-pro
|
|
||||||
* Copyright 2022 Creative Tim (https://www.creative-tim.com/)
|
|
||||||
|
|
||||||
* Designed and Coded by Simmmple & Creative Tim
|
|
||||||
|
|
||||||
=========================================================
|
|
||||||
|
|
||||||
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import FullCalendar from '@fullcalendar/react'; // must go before plugins
|
|
||||||
import dayGridPlugin from '@fullcalendar/daygrid'; // a plugin!
|
|
||||||
import interactionPlugin from '@fullcalendar/interaction'; // needed for dayClick
|
|
||||||
|
|
||||||
function EventCalendar(props) {
|
|
||||||
const { calendarData, initialDate } = props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FullCalendar
|
|
||||||
plugins={[dayGridPlugin, interactionPlugin]}
|
|
||||||
headerToolbar={false}
|
|
||||||
initialView='dayGridMonth'
|
|
||||||
initialDate={initialDate}
|
|
||||||
contentHeight='600'
|
|
||||||
events={calendarData}
|
|
||||||
editable={true}
|
|
||||||
height='100%'
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default EventCalendar;
|
|
||||||
@@ -10,9 +10,9 @@ import {
|
|||||||
Badge,
|
Badge,
|
||||||
IconButton,
|
IconButton,
|
||||||
Button,
|
Button,
|
||||||
useColorModeValue,
|
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import { ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons';
|
import { ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons';
|
||||||
|
import { PROFESSIONAL_COLORS } from '@constants/professionalTheme';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 可折叠模块标题组件
|
* 可折叠模块标题组件
|
||||||
@@ -38,9 +38,10 @@ const CollapsibleHeader = ({
|
|||||||
onModeToggle = null,
|
onModeToggle = null,
|
||||||
isLocked = false
|
isLocked = false
|
||||||
}) => {
|
}) => {
|
||||||
const sectionBg = useColorModeValue('gray.50', 'gray.750');
|
// 深色主题 - 标题区块背景稍亮
|
||||||
const hoverBg = useColorModeValue('gray.100', 'gray.700');
|
const sectionBg = '#3D4A5C';
|
||||||
const headingColor = useColorModeValue('gray.700', 'gray.200');
|
const hoverBg = '#4A5568';
|
||||||
|
const headingColor = '#F7FAFC';
|
||||||
|
|
||||||
// 获取按钮文案
|
// 获取按钮文案
|
||||||
const getButtonText = () => {
|
const getButtonText = () => {
|
||||||
|
|||||||
@@ -5,10 +5,8 @@ import React, { useState } from 'react';
|
|||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Collapse,
|
Collapse,
|
||||||
useColorModeValue,
|
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import CollapsibleHeader from './CollapsibleHeader';
|
import CollapsibleHeader from './CollapsibleHeader';
|
||||||
import { PROFESSIONAL_COLORS } from '@constants/professionalTheme';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 通用可折叠区块组件
|
* 通用可折叠区块组件
|
||||||
@@ -38,7 +36,8 @@ const CollapsibleSection = ({
|
|||||||
showModeToggle = false,
|
showModeToggle = false,
|
||||||
defaultMode = 'detailed'
|
defaultMode = 'detailed'
|
||||||
}) => {
|
}) => {
|
||||||
const sectionBg = PROFESSIONAL_COLORS.background.secondary;
|
// 深色主题 - 折叠区块背景稍亮
|
||||||
|
const sectionBg = '#354259';
|
||||||
|
|
||||||
// 模式状态:'detailed' | 'simple'
|
// 模式状态:'detailed' | 'simple'
|
||||||
const [displayMode, setDisplayMode] = useState(defaultMode);
|
const [displayMode, setDisplayMode] = useState(defaultMode);
|
||||||
|
|||||||
@@ -86,9 +86,10 @@ const sectionReducer = (state, action) => {
|
|||||||
const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
|
const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const cardBg = PROFESSIONAL_COLORS.background.card;
|
// 深色主题 - 与弹窗背景一致
|
||||||
const borderColor = PROFESSIONAL_COLORS.border.default;
|
const cardBg = '#2D3748';
|
||||||
const textColor = PROFESSIONAL_COLORS.text.secondary;
|
const borderColor = 'rgba(255, 255, 255, 0.08)';
|
||||||
|
const textColor = '#CBD5E0';
|
||||||
|
|
||||||
// 使用 useWatchlist Hook 管理自选股
|
// 使用 useWatchlist Hook 管理自选股
|
||||||
const {
|
const {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// src/components/EventDetailPanel/RelatedConceptsSection/index.js
|
// src/components/EventDetailPanel/RelatedConceptsSection/index.js
|
||||||
// 相关概念区组件 - 折叠手风琴样式
|
// 相关概念区组件 - 便当盒网格布局
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
@@ -10,94 +10,72 @@ import {
|
|||||||
Spinner,
|
Spinner,
|
||||||
Text,
|
Text,
|
||||||
Badge,
|
Badge,
|
||||||
VStack,
|
SimpleGrid,
|
||||||
HStack,
|
HStack,
|
||||||
Icon,
|
Tooltip,
|
||||||
Collapse,
|
|
||||||
useColorModeValue,
|
useColorModeValue,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import { ChevronRightIcon, ChevronDownIcon } from '@chakra-ui/icons';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { logger } from '@utils/logger';
|
import { logger } from '@utils/logger';
|
||||||
import { getApiBase } from '@utils/apiConfig';
|
import { getApiBase } from '@utils/apiConfig';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 单个概念项组件(手风琴项)
|
* 单个概念卡片组件(便当盒样式)
|
||||||
*/
|
*/
|
||||||
const ConceptItem = ({ concept, isExpanded, onToggle, onNavigate }) => {
|
const ConceptCard = ({ concept, onNavigate, isLocked, onLockedClick }) => {
|
||||||
const itemBg = useColorModeValue('white', 'gray.700');
|
// 深色主题固定颜色
|
||||||
const itemHoverBg = useColorModeValue('gray.50', 'gray.650');
|
const cardBg = 'rgba(252, 129, 129, 0.15)'; // 浅红色背景
|
||||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
const cardHoverBg = 'rgba(252, 129, 129, 0.25)';
|
||||||
const conceptColor = useColorModeValue('blue.600', 'blue.300');
|
const borderColor = 'rgba(252, 129, 129, 0.3)';
|
||||||
const reasonBg = useColorModeValue('blue.50', 'gray.800');
|
const conceptColor = '#fc8181'; // 红色文字(与股票涨色一致)
|
||||||
const reasonColor = useColorModeValue('gray.700', 'gray.200');
|
|
||||||
const iconColor = useColorModeValue('gray.500', 'gray.400');
|
const handleClick = () => {
|
||||||
|
if (isLocked && onLockedClick) {
|
||||||
|
onLockedClick();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onNavigate(concept);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<Tooltip
|
||||||
|
label={concept.reason || concept.concept}
|
||||||
|
placement="top"
|
||||||
|
hasArrow
|
||||||
|
bg="gray.800"
|
||||||
|
color="white"
|
||||||
|
p={2}
|
||||||
|
borderRadius="md"
|
||||||
|
maxW="300px"
|
||||||
|
fontSize="xs"
|
||||||
|
>
|
||||||
<Box
|
<Box
|
||||||
|
bg={cardBg}
|
||||||
borderWidth="1px"
|
borderWidth="1px"
|
||||||
borderColor={borderColor}
|
borderColor={borderColor}
|
||||||
borderRadius="md"
|
borderRadius="lg"
|
||||||
overflow="hidden"
|
|
||||||
bg={itemBg}
|
|
||||||
>
|
|
||||||
{/* 概念标题行 - 可点击展开 */}
|
|
||||||
<Flex
|
|
||||||
px={3}
|
px={3}
|
||||||
py={2.5}
|
py={2}
|
||||||
cursor="pointer"
|
cursor="pointer"
|
||||||
align="center"
|
onClick={handleClick}
|
||||||
justify="space-between"
|
_hover={{
|
||||||
_hover={{ bg: itemHoverBg }}
|
bg: cardHoverBg,
|
||||||
onClick={onToggle}
|
transform: 'translateY(-1px)',
|
||||||
transition="background 0.2s"
|
boxShadow: 'sm',
|
||||||
|
}}
|
||||||
|
transition="all 0.15s ease"
|
||||||
|
textAlign="center"
|
||||||
>
|
>
|
||||||
<HStack spacing={2} flex={1}>
|
|
||||||
<Icon
|
|
||||||
as={isExpanded ? ChevronDownIcon : ChevronRightIcon}
|
|
||||||
color={iconColor}
|
|
||||||
boxSize={4}
|
|
||||||
transition="transform 0.2s"
|
|
||||||
/>
|
|
||||||
<Text
|
<Text
|
||||||
fontSize="sm"
|
fontSize="sm"
|
||||||
fontWeight="medium"
|
fontWeight="semibold"
|
||||||
color={conceptColor}
|
color={conceptColor}
|
||||||
cursor="pointer"
|
noOfLines={1}
|
||||||
_hover={{ textDecoration: 'underline' }}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onNavigate(concept);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{concept.concept}
|
{concept.concept}
|
||||||
</Text>
|
</Text>
|
||||||
<Badge colorScheme="green" fontSize="xs" flexShrink={0}>
|
|
||||||
AI 分析
|
|
||||||
</Badge>
|
|
||||||
</HStack>
|
|
||||||
</Flex>
|
|
||||||
|
|
||||||
{/* 关联原因 - 可折叠 */}
|
|
||||||
<Collapse in={isExpanded} animateOpacity>
|
|
||||||
<Box
|
|
||||||
px={4}
|
|
||||||
py={3}
|
|
||||||
bg={reasonBg}
|
|
||||||
borderTop="1px solid"
|
|
||||||
borderTopColor={borderColor}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
fontSize="sm"
|
|
||||||
color={reasonColor}
|
|
||||||
lineHeight="1.8"
|
|
||||||
whiteSpace="pre-wrap"
|
|
||||||
>
|
|
||||||
{concept.reason || '暂无关联原因说明'}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
</Collapse>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
</Tooltip>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -120,16 +98,14 @@ const RelatedConceptsSection = ({
|
|||||||
const [concepts, setConcepts] = useState([]);
|
const [concepts, setConcepts] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
// 记录每个概念的展开状态
|
|
||||||
const [expandedItems, setExpandedItems] = useState({});
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// 颜色配置
|
// 颜色配置 - 使用深色主题固定颜色
|
||||||
const sectionBg = useColorModeValue('gray.50', 'gray.750');
|
const sectionBg = 'transparent';
|
||||||
const headingColor = useColorModeValue('gray.700', 'gray.200');
|
const headingColor = '#e2e8f0';
|
||||||
const textColor = useColorModeValue('gray.600', 'gray.400');
|
const textColor = '#a0aec0';
|
||||||
const countBadgeBg = useColorModeValue('blue.100', 'blue.800');
|
const countBadgeBg = '#3182ce';
|
||||||
const countBadgeColor = useColorModeValue('blue.700', 'blue.200');
|
const countBadgeColor = '#ffffff';
|
||||||
|
|
||||||
// 获取相关概念
|
// 获取相关概念
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -162,10 +138,6 @@ const RelatedConceptsSection = ({
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (data.success && Array.isArray(data.data)) {
|
if (data.success && Array.isArray(data.data)) {
|
||||||
setConcepts(data.data);
|
setConcepts(data.data);
|
||||||
// 默认展开第一个
|
|
||||||
if (data.data.length > 0) {
|
|
||||||
setExpandedItems({ 0: true });
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
setConcepts([]);
|
setConcepts([]);
|
||||||
}
|
}
|
||||||
@@ -182,18 +154,6 @@ const RelatedConceptsSection = ({
|
|||||||
fetchConcepts();
|
fetchConcepts();
|
||||||
}, [eventId]);
|
}, [eventId]);
|
||||||
|
|
||||||
// 切换某个概念的展开状态
|
|
||||||
const toggleItem = (index) => {
|
|
||||||
if (isLocked && onLockedClick) {
|
|
||||||
onLockedClick();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setExpandedItems(prev => ({
|
|
||||||
...prev,
|
|
||||||
[index]: !prev[index]
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
// 跳转到概念中心
|
// 跳转到概念中心
|
||||||
const handleNavigate = (concept) => {
|
const handleNavigate = (concept) => {
|
||||||
navigate(`/concepts?q=${encodeURIComponent(concept.concept)}`);
|
navigate(`/concepts?q=${encodeURIComponent(concept.concept)}`);
|
||||||
@@ -237,7 +197,7 @@ const RelatedConceptsSection = ({
|
|||||||
</HStack>
|
</HStack>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
{/* 概念列表 - 手风琴样式 */}
|
{/* 概念列表 - 便当盒网格布局 */}
|
||||||
{hasNoConcepts ? (
|
{hasNoConcepts ? (
|
||||||
<Box py={2}>
|
<Box py={2}>
|
||||||
{error ? (
|
{error ? (
|
||||||
@@ -247,17 +207,17 @@ const RelatedConceptsSection = ({
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<VStack spacing={2} align="stretch">
|
<SimpleGrid columns={{ base: 2, sm: 3, md: 4 }} spacing={2}>
|
||||||
{concepts.map((concept, index) => (
|
{concepts.map((concept, index) => (
|
||||||
<ConceptItem
|
<ConceptCard
|
||||||
key={concept.id || index}
|
key={concept.id || index}
|
||||||
concept={concept}
|
concept={concept}
|
||||||
isExpanded={!!expandedItems[index]}
|
|
||||||
onToggle={() => toggleItem(index)}
|
|
||||||
onNavigate={handleNavigate}
|
onNavigate={handleNavigate}
|
||||||
|
isLocked={isLocked}
|
||||||
|
onLockedClick={onLockedClick}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</VStack>
|
</SimpleGrid>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
38
src/components/GlassCard/index.d.ts
vendored
Normal file
38
src/components/GlassCard/index.d.ts
vendored
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* GlassCard 组件类型声明
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { BoxProps } from '@chakra-ui/react';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export interface GlassCardProps extends Omit<BoxProps, 'children'> {
|
||||||
|
/** 变体: 'default' | 'elevated' | 'subtle' | 'transparent' */
|
||||||
|
variant?: 'default' | 'elevated' | 'subtle' | 'transparent';
|
||||||
|
/** 是否启用悬停效果 */
|
||||||
|
hoverable?: boolean;
|
||||||
|
/** 是否启用发光效果 */
|
||||||
|
glowing?: boolean;
|
||||||
|
/** 是否显示角落装饰 */
|
||||||
|
cornerDecor?: boolean;
|
||||||
|
/** 圆角: 'sm' | 'md' | 'lg' | 'xl' | '2xl' */
|
||||||
|
rounded?: 'sm' | 'md' | 'lg' | 'xl' | '2xl';
|
||||||
|
/** 内边距: 'none' | 'sm' | 'md' | 'lg' */
|
||||||
|
padding?: 'none' | 'sm' | 'md' | 'lg';
|
||||||
|
/** 子元素 */
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GlassTheme {
|
||||||
|
colors: {
|
||||||
|
gold: { 400: string; 500: string };
|
||||||
|
bg: { deep: string; primary: string; elevated: string; surface: string };
|
||||||
|
line: { subtle: string; default: string; emphasis: string };
|
||||||
|
};
|
||||||
|
blur: { sm: string; md: string; lg: string };
|
||||||
|
glow: { sm: string; md: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
declare const GlassCard: React.ForwardRefExoticComponent<GlassCardProps & React.RefAttributes<HTMLDivElement>>;
|
||||||
|
|
||||||
|
export { GLASS_THEME } from './index';
|
||||||
|
export default GlassCard;
|
||||||
179
src/components/GlassCard/index.js
Normal file
179
src/components/GlassCard/index.js
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
/**
|
||||||
|
* GlassCard - 通用毛玻璃卡片组件
|
||||||
|
*
|
||||||
|
* 复用自 Company 页面的 Glassmorphism 风格
|
||||||
|
* 可在全局使用
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { memo, forwardRef } from 'react';
|
||||||
|
import { Box } from '@chakra-ui/react';
|
||||||
|
|
||||||
|
// 主题配置
|
||||||
|
const GLASS_THEME = {
|
||||||
|
colors: {
|
||||||
|
gold: {
|
||||||
|
400: '#D4AF37',
|
||||||
|
500: '#B8960C',
|
||||||
|
},
|
||||||
|
bg: {
|
||||||
|
deep: '#0A0A14',
|
||||||
|
primary: '#0F0F1A',
|
||||||
|
elevated: '#1A1A2E',
|
||||||
|
surface: '#252540',
|
||||||
|
},
|
||||||
|
line: {
|
||||||
|
subtle: 'rgba(212, 175, 55, 0.1)',
|
||||||
|
default: 'rgba(212, 175, 55, 0.2)',
|
||||||
|
emphasis: 'rgba(212, 175, 55, 0.4)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
blur: {
|
||||||
|
sm: 'blur(8px)',
|
||||||
|
md: 'blur(16px)',
|
||||||
|
lg: 'blur(24px)',
|
||||||
|
},
|
||||||
|
glow: {
|
||||||
|
sm: '0 0 8px rgba(212, 175, 55, 0.3)',
|
||||||
|
md: '0 0 16px rgba(212, 175, 55, 0.4)',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 变体样式
|
||||||
|
const VARIANTS = {
|
||||||
|
default: {
|
||||||
|
bg: `linear-gradient(135deg, ${GLASS_THEME.colors.bg.elevated} 0%, ${GLASS_THEME.colors.bg.primary} 100%)`,
|
||||||
|
border: `1px solid ${GLASS_THEME.colors.line.default}`,
|
||||||
|
backdropFilter: GLASS_THEME.blur.md,
|
||||||
|
},
|
||||||
|
elevated: {
|
||||||
|
bg: `linear-gradient(145deg, ${GLASS_THEME.colors.bg.surface} 0%, ${GLASS_THEME.colors.bg.elevated} 100%)`,
|
||||||
|
border: `1px solid ${GLASS_THEME.colors.line.emphasis}`,
|
||||||
|
backdropFilter: GLASS_THEME.blur.lg,
|
||||||
|
},
|
||||||
|
subtle: {
|
||||||
|
bg: 'rgba(212, 175, 55, 0.05)',
|
||||||
|
border: `1px solid ${GLASS_THEME.colors.line.subtle}`,
|
||||||
|
backdropFilter: GLASS_THEME.blur.sm,
|
||||||
|
},
|
||||||
|
transparent: {
|
||||||
|
bg: 'rgba(15, 15, 26, 0.8)',
|
||||||
|
border: `1px solid ${GLASS_THEME.colors.line.default}`,
|
||||||
|
backdropFilter: GLASS_THEME.blur.lg,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const ROUNDED_MAP = {
|
||||||
|
sm: '8px',
|
||||||
|
md: '12px',
|
||||||
|
lg: '16px',
|
||||||
|
xl: '20px',
|
||||||
|
'2xl': '24px',
|
||||||
|
};
|
||||||
|
|
||||||
|
const PADDING_MAP = {
|
||||||
|
none: 0,
|
||||||
|
sm: 3,
|
||||||
|
md: 4,
|
||||||
|
lg: 6,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 角落装饰
|
||||||
|
const CornerDecor = memo(({ position }) => {
|
||||||
|
const baseStyle = {
|
||||||
|
position: 'absolute',
|
||||||
|
width: '12px',
|
||||||
|
height: '12px',
|
||||||
|
borderColor: GLASS_THEME.colors.gold[400],
|
||||||
|
borderStyle: 'solid',
|
||||||
|
borderWidth: 0,
|
||||||
|
opacity: 0.6,
|
||||||
|
};
|
||||||
|
|
||||||
|
const positions = {
|
||||||
|
tl: { top: '8px', left: '8px', borderTopWidth: '2px', borderLeftWidth: '2px' },
|
||||||
|
tr: { top: '8px', right: '8px', borderTopWidth: '2px', borderRightWidth: '2px' },
|
||||||
|
bl: { bottom: '8px', left: '8px', borderBottomWidth: '2px', borderLeftWidth: '2px' },
|
||||||
|
br: { bottom: '8px', right: '8px', borderBottomWidth: '2px', borderRightWidth: '2px' },
|
||||||
|
};
|
||||||
|
|
||||||
|
return <Box sx={{ ...baseStyle, ...positions[position] }} />;
|
||||||
|
});
|
||||||
|
|
||||||
|
CornerDecor.displayName = 'CornerDecor';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GlassCard 组件
|
||||||
|
*
|
||||||
|
* @param {string} variant - 变体: 'default' | 'elevated' | 'subtle' | 'transparent'
|
||||||
|
* @param {boolean} hoverable - 是否启用悬停效果
|
||||||
|
* @param {boolean} glowing - 是否启用发光效果
|
||||||
|
* @param {boolean} cornerDecor - 是否显示角落装饰
|
||||||
|
* @param {string} rounded - 圆角: 'sm' | 'md' | 'lg' | 'xl' | '2xl'
|
||||||
|
* @param {string} padding - 内边距: 'none' | 'sm' | 'md' | 'lg'
|
||||||
|
*/
|
||||||
|
const GlassCard = forwardRef(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
children,
|
||||||
|
variant = 'default',
|
||||||
|
hoverable = true,
|
||||||
|
glowing = false,
|
||||||
|
cornerDecor = false,
|
||||||
|
rounded = 'lg',
|
||||||
|
padding = 'md',
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const variantStyle = VARIANTS[variant] || VARIANTS.default;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
ref={ref}
|
||||||
|
position="relative"
|
||||||
|
bg={variantStyle.bg}
|
||||||
|
border={variantStyle.border}
|
||||||
|
borderRadius={ROUNDED_MAP[rounded]}
|
||||||
|
backdropFilter={variantStyle.backdropFilter}
|
||||||
|
p={PADDING_MAP[padding]}
|
||||||
|
transition="all 0.3s cubic-bezier(0.25, 0.1, 0.25, 1)"
|
||||||
|
overflow="hidden"
|
||||||
|
_hover={
|
||||||
|
hoverable
|
||||||
|
? {
|
||||||
|
borderColor: GLASS_THEME.colors.line.emphasis,
|
||||||
|
boxShadow: glowing ? GLASS_THEME.glow.md : GLASS_THEME.glow.sm,
|
||||||
|
transform: 'translateY(-2px)',
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
sx={{
|
||||||
|
...(glowing && {
|
||||||
|
boxShadow: GLASS_THEME.glow.sm,
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{/* 角落装饰 */}
|
||||||
|
{cornerDecor && (
|
||||||
|
<>
|
||||||
|
<CornerDecor position="tl" />
|
||||||
|
<CornerDecor position="tr" />
|
||||||
|
<CornerDecor position="bl" />
|
||||||
|
<CornerDecor position="br" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 内容 */}
|
||||||
|
<Box position="relative" zIndex={1}>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
GlassCard.displayName = 'GlassCard';
|
||||||
|
|
||||||
|
export default memo(GlassCard);
|
||||||
|
export { GLASS_THEME };
|
||||||
353
src/components/GlobalSidebar/index.js
Normal file
353
src/components/GlobalSidebar/index.js
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
/**
|
||||||
|
* GlobalSidebar - 全局右侧工具栏
|
||||||
|
*
|
||||||
|
* 可收起/展开的侧边栏,包含关注股票和事件动态
|
||||||
|
* 收起时点击图标显示悬浮弹窗
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
VStack,
|
||||||
|
Icon,
|
||||||
|
IconButton,
|
||||||
|
Badge,
|
||||||
|
Spinner,
|
||||||
|
Center,
|
||||||
|
Popover,
|
||||||
|
PopoverTrigger,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverBody,
|
||||||
|
PopoverHeader,
|
||||||
|
PopoverCloseButton,
|
||||||
|
Text,
|
||||||
|
HStack,
|
||||||
|
Portal,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import { ChevronLeft, ChevronRight, BarChart2, Star, TrendingUp } from 'lucide-react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useGlobalSidebar } from '@/contexts/GlobalSidebarContext';
|
||||||
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
import { getEventDetailUrl } from '@/utils/idEncoder';
|
||||||
|
import { Z_INDEX, LAYOUT_SIZE } from '@/layouts/config/layoutConfig';
|
||||||
|
import WatchSidebar from '@views/Profile/components/WatchSidebar';
|
||||||
|
import { WatchlistPanel, FollowingEventsPanel } from '@views/Profile/components/WatchSidebar/components';
|
||||||
|
import HotSectorsRanking from '@views/Profile/components/MarketDashboard/components/atoms/HotSectorsRanking';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 收起状态下的图标菜单(带悬浮弹窗)
|
||||||
|
*/
|
||||||
|
const CollapsedMenu = ({
|
||||||
|
watchlist,
|
||||||
|
realtimeQuotes,
|
||||||
|
followingEvents,
|
||||||
|
eventComments,
|
||||||
|
onToggle,
|
||||||
|
onStockClick,
|
||||||
|
onEventClick,
|
||||||
|
onCommentClick,
|
||||||
|
onAddStock,
|
||||||
|
onAddEvent,
|
||||||
|
onUnwatch,
|
||||||
|
onUnfollow,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<VStack spacing={4} py={4} align="center">
|
||||||
|
{/* 展开按钮 */}
|
||||||
|
<HStack spacing={1} w="100%" justify="center" cursor="pointer" onClick={onToggle} _hover={{ bg: 'rgba(255, 255, 255, 0.05)' }} py={1} borderRadius="md">
|
||||||
|
<Icon as={ChevronLeft} boxSize={4} color="rgba(255, 255, 255, 0.6)" />
|
||||||
|
<Text fontSize="10px" color="rgba(255, 255, 255, 0.5)">
|
||||||
|
展开
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{/* 关注股票 - 悬浮弹窗 */}
|
||||||
|
<Popover placement="left-start" trigger="click" isLazy>
|
||||||
|
<PopoverTrigger>
|
||||||
|
<VStack
|
||||||
|
spacing={1}
|
||||||
|
align="center"
|
||||||
|
cursor="pointer"
|
||||||
|
p={2}
|
||||||
|
borderRadius="md"
|
||||||
|
_hover={{ bg: 'rgba(255, 255, 255, 0.05)' }}
|
||||||
|
position="relative"
|
||||||
|
>
|
||||||
|
<Box position="relative">
|
||||||
|
<Icon as={BarChart2} boxSize={5} color="rgba(59, 130, 246, 0.9)" />
|
||||||
|
{watchlist.length > 0 && (
|
||||||
|
<Badge
|
||||||
|
position="absolute"
|
||||||
|
top="-4px"
|
||||||
|
right="-8px"
|
||||||
|
colorScheme="red"
|
||||||
|
fontSize="9px"
|
||||||
|
minW="16px"
|
||||||
|
h="16px"
|
||||||
|
borderRadius="full"
|
||||||
|
display="flex"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
>
|
||||||
|
{watchlist.length > 99 ? '99+' : watchlist.length}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Text fontSize="10px" color="rgba(255, 255, 255, 0.6)" whiteSpace="nowrap">
|
||||||
|
关注股票
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<Portal>
|
||||||
|
<PopoverContent
|
||||||
|
w="300px"
|
||||||
|
bg="rgba(26, 32, 44, 0.95)"
|
||||||
|
borderColor="rgba(255, 255, 255, 0.1)"
|
||||||
|
boxShadow="0 8px 32px rgba(0, 0, 0, 0.4)"
|
||||||
|
_focus={{ outline: 'none' }}
|
||||||
|
>
|
||||||
|
<PopoverHeader
|
||||||
|
borderBottomColor="rgba(255, 255, 255, 0.1)"
|
||||||
|
py={2}
|
||||||
|
px={3}
|
||||||
|
>
|
||||||
|
<HStack spacing={2}>
|
||||||
|
<Icon as={BarChart2} boxSize={4} color="rgba(59, 130, 246, 0.9)" />
|
||||||
|
<Text fontSize="sm" fontWeight="bold" color="rgba(255, 255, 255, 0.9)">
|
||||||
|
关注股票 ({watchlist.length})
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
</PopoverHeader>
|
||||||
|
<PopoverCloseButton color="rgba(255, 255, 255, 0.5)" />
|
||||||
|
<PopoverBody p={2}>
|
||||||
|
<WatchlistPanel
|
||||||
|
watchlist={watchlist}
|
||||||
|
realtimeQuotes={realtimeQuotes}
|
||||||
|
onStockClick={onStockClick}
|
||||||
|
onAddStock={onAddStock}
|
||||||
|
onUnwatch={onUnwatch}
|
||||||
|
hideTitle={true}
|
||||||
|
/>
|
||||||
|
</PopoverBody>
|
||||||
|
</PopoverContent>
|
||||||
|
</Portal>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
{/* 事件动态 - 悬浮弹窗 */}
|
||||||
|
<Popover placement="left-start" trigger="click" isLazy>
|
||||||
|
<PopoverTrigger>
|
||||||
|
<VStack
|
||||||
|
spacing={1}
|
||||||
|
align="center"
|
||||||
|
cursor="pointer"
|
||||||
|
p={2}
|
||||||
|
borderRadius="md"
|
||||||
|
_hover={{ bg: 'rgba(255, 255, 255, 0.05)' }}
|
||||||
|
position="relative"
|
||||||
|
>
|
||||||
|
<Box position="relative">
|
||||||
|
<Icon as={Star} boxSize={5} color="rgba(234, 179, 8, 0.9)" />
|
||||||
|
{followingEvents.length > 0 && (
|
||||||
|
<Badge
|
||||||
|
position="absolute"
|
||||||
|
top="-4px"
|
||||||
|
right="-8px"
|
||||||
|
colorScheme="yellow"
|
||||||
|
fontSize="9px"
|
||||||
|
minW="16px"
|
||||||
|
h="16px"
|
||||||
|
borderRadius="full"
|
||||||
|
display="flex"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
>
|
||||||
|
{followingEvents.length > 99 ? '99+' : followingEvents.length}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Text fontSize="10px" color="rgba(255, 255, 255, 0.6)" whiteSpace="nowrap">
|
||||||
|
关注事件
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<Portal>
|
||||||
|
<PopoverContent
|
||||||
|
w="300px"
|
||||||
|
bg="rgba(26, 32, 44, 0.95)"
|
||||||
|
borderColor="rgba(255, 255, 255, 0.1)"
|
||||||
|
boxShadow="0 8px 32px rgba(0, 0, 0, 0.4)"
|
||||||
|
_focus={{ outline: 'none' }}
|
||||||
|
>
|
||||||
|
<PopoverHeader
|
||||||
|
borderBottomColor="rgba(255, 255, 255, 0.1)"
|
||||||
|
py={2}
|
||||||
|
px={3}
|
||||||
|
>
|
||||||
|
<HStack spacing={2}>
|
||||||
|
<Icon as={Star} boxSize={4} color="rgba(234, 179, 8, 0.9)" />
|
||||||
|
<Text fontSize="sm" fontWeight="bold" color="rgba(255, 255, 255, 0.9)">
|
||||||
|
事件动态
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
</PopoverHeader>
|
||||||
|
<PopoverCloseButton color="rgba(255, 255, 255, 0.5)" />
|
||||||
|
<PopoverBody p={2}>
|
||||||
|
<FollowingEventsPanel
|
||||||
|
events={followingEvents}
|
||||||
|
eventComments={eventComments}
|
||||||
|
onEventClick={onEventClick}
|
||||||
|
onCommentClick={onCommentClick}
|
||||||
|
onAddEvent={onAddEvent}
|
||||||
|
onUnfollow={onUnfollow}
|
||||||
|
hideTitle={true}
|
||||||
|
/>
|
||||||
|
</PopoverBody>
|
||||||
|
</PopoverContent>
|
||||||
|
</Portal>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
{/* 热门板块 - 悬浮弹窗 */}
|
||||||
|
<Popover placement="left-start" trigger="click" isLazy>
|
||||||
|
<PopoverTrigger>
|
||||||
|
<VStack
|
||||||
|
spacing={1}
|
||||||
|
align="center"
|
||||||
|
cursor="pointer"
|
||||||
|
p={2}
|
||||||
|
borderRadius="md"
|
||||||
|
_hover={{ bg: 'rgba(255, 255, 255, 0.05)' }}
|
||||||
|
position="relative"
|
||||||
|
>
|
||||||
|
<Icon as={TrendingUp} boxSize={5} color="rgba(34, 197, 94, 0.9)" />
|
||||||
|
<Text fontSize="10px" color="rgba(255, 255, 255, 0.6)" whiteSpace="nowrap">
|
||||||
|
热门板块
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<Portal>
|
||||||
|
<PopoverContent
|
||||||
|
w="280px"
|
||||||
|
bg="rgba(26, 32, 44, 0.95)"
|
||||||
|
borderColor="rgba(255, 255, 255, 0.1)"
|
||||||
|
boxShadow="0 8px 32px rgba(0, 0, 0, 0.4)"
|
||||||
|
_focus={{ outline: 'none' }}
|
||||||
|
>
|
||||||
|
<PopoverCloseButton color="rgba(255, 255, 255, 0.5)" />
|
||||||
|
<PopoverBody p={2}>
|
||||||
|
<HotSectorsRanking title="热门板块" />
|
||||||
|
</PopoverBody>
|
||||||
|
</PopoverContent>
|
||||||
|
</Portal>
|
||||||
|
</Popover>
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GlobalSidebar 主组件
|
||||||
|
*/
|
||||||
|
const GlobalSidebar = () => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const {
|
||||||
|
isOpen,
|
||||||
|
toggle,
|
||||||
|
watchlist,
|
||||||
|
realtimeQuotes,
|
||||||
|
followingEvents,
|
||||||
|
eventComments,
|
||||||
|
loading,
|
||||||
|
unwatchStock,
|
||||||
|
unfollowEvent,
|
||||||
|
} = useGlobalSidebar();
|
||||||
|
|
||||||
|
// 未登录时不显示
|
||||||
|
if (!user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
w={isOpen ? '300px' : '72px'}
|
||||||
|
h="100%"
|
||||||
|
pt={LAYOUT_SIZE.navbarHeight}
|
||||||
|
flexShrink={0}
|
||||||
|
transition="width 0.2s ease-in-out"
|
||||||
|
display={{ base: 'none', md: 'block' }}
|
||||||
|
bg="rgba(26, 32, 44, 0.98)"
|
||||||
|
borderLeft="1px solid rgba(255, 255, 255, 0.08)"
|
||||||
|
position="relative"
|
||||||
|
zIndex={Z_INDEX.SIDEBAR}
|
||||||
|
>
|
||||||
|
{/* 加载状态 */}
|
||||||
|
{loading && (
|
||||||
|
<Center position="absolute" top={4} left={0} right={0} zIndex={1}>
|
||||||
|
<Spinner size="sm" color="rgba(212, 175, 55, 0.6)" />
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isOpen ? (
|
||||||
|
/* 展开状态 */
|
||||||
|
<Box h="100%" display="flex" flexDirection="column">
|
||||||
|
{/* 标题栏 - 收起按钮 + 标题 */}
|
||||||
|
<HStack
|
||||||
|
px={3}
|
||||||
|
py={3}
|
||||||
|
bg="rgba(26, 32, 44, 1)"
|
||||||
|
borderBottom="1px solid rgba(255, 255, 255, 0.1)"
|
||||||
|
flexShrink={0}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
icon={<Icon as={ChevronRight} />}
|
||||||
|
size="xs"
|
||||||
|
variant="ghost"
|
||||||
|
color="rgba(255, 255, 255, 0.5)"
|
||||||
|
_hover={{ color: 'rgba(212, 175, 55, 0.9)', bg: 'rgba(255, 255, 255, 0.05)' }}
|
||||||
|
onClick={toggle}
|
||||||
|
aria-label="收起工具栏"
|
||||||
|
/>
|
||||||
|
<Text fontSize="sm" fontWeight="medium" color="rgba(255, 255, 255, 0.7)">
|
||||||
|
工具栏
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{/* WatchSidebar 内容 */}
|
||||||
|
<Box flex="1" overflowY="auto" pt={2} px={2}>
|
||||||
|
<WatchSidebar
|
||||||
|
watchlist={watchlist}
|
||||||
|
realtimeQuotes={realtimeQuotes}
|
||||||
|
followingEvents={followingEvents}
|
||||||
|
eventComments={eventComments}
|
||||||
|
onStockClick={(stock) => navigate(`/company/${stock.stock_code}`)}
|
||||||
|
onEventClick={(event) => navigate(getEventDetailUrl(event.id))}
|
||||||
|
onCommentClick={(comment) => navigate(getEventDetailUrl(comment.event_id))}
|
||||||
|
onAddStock={() => navigate('/stocks')}
|
||||||
|
onAddEvent={() => navigate('/community')}
|
||||||
|
onUnwatch={unwatchStock}
|
||||||
|
onUnfollow={unfollowEvent}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
/* 收起状态 - 点击图标显示悬浮弹窗 */
|
||||||
|
<CollapsedMenu
|
||||||
|
watchlist={watchlist}
|
||||||
|
realtimeQuotes={realtimeQuotes}
|
||||||
|
followingEvents={followingEvents}
|
||||||
|
eventComments={eventComments}
|
||||||
|
onToggle={toggle}
|
||||||
|
onStockClick={(stock) => navigate(`/company/${stock.stock_code}`)}
|
||||||
|
onEventClick={(event) => navigate(getEventDetailUrl(event.id))}
|
||||||
|
onCommentClick={(comment) => navigate(getEventDetailUrl(comment.event_id))}
|
||||||
|
onAddStock={() => navigate('/stocks')}
|
||||||
|
onAddEvent={() => navigate('/community')}
|
||||||
|
onUnwatch={unwatchStock}
|
||||||
|
onUnfollow={unfollowEvent}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GlobalSidebar;
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
// src/components/Navbars/components/FeatureMenus/FollowingEventsMenu.js
|
// src/components/Navbars/components/FeatureMenus/FollowingEventsMenu.js
|
||||||
// 关注事件下拉菜单组件
|
// 关注事件下拉菜单组件
|
||||||
|
|
||||||
import React, { memo } from 'react';
|
import React, { memo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Menu,
|
Menu,
|
||||||
MenuButton,
|
MenuButton,
|
||||||
@@ -22,6 +22,7 @@ import { FiCalendar } from 'react-icons/fi';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useFollowingEvents } from '../../../../hooks/useFollowingEvents';
|
import { useFollowingEvents } from '../../../../hooks/useFollowingEvents';
|
||||||
import { getEventDetailUrl } from '@/utils/idEncoder';
|
import { getEventDetailUrl } from '@/utils/idEncoder';
|
||||||
|
import FavoriteButton from '@/components/FavoriteButton';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 关注事件下拉菜单组件
|
* 关注事件下拉菜单组件
|
||||||
@@ -30,6 +31,7 @@ import { getEventDetailUrl } from '@/utils/idEncoder';
|
|||||||
*/
|
*/
|
||||||
const FollowingEventsMenu = memo(() => {
|
const FollowingEventsMenu = memo(() => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [unfollowingId, setUnfollowingId] = useState(null);
|
||||||
const {
|
const {
|
||||||
followingEvents,
|
followingEvents,
|
||||||
eventsLoading,
|
eventsLoading,
|
||||||
@@ -40,6 +42,17 @@ const FollowingEventsMenu = memo(() => {
|
|||||||
handleUnfollowEvent
|
handleUnfollowEvent
|
||||||
} = useFollowingEvents();
|
} = useFollowingEvents();
|
||||||
|
|
||||||
|
// 处理取消关注(带 loading 状态)
|
||||||
|
const handleUnfollow = async (eventId) => {
|
||||||
|
if (unfollowingId) return;
|
||||||
|
setUnfollowingId(eventId);
|
||||||
|
try {
|
||||||
|
await handleUnfollowEvent(eventId);
|
||||||
|
} finally {
|
||||||
|
setUnfollowingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const titleColor = useColorModeValue('gray.600', 'gray.300');
|
const titleColor = useColorModeValue('gray.600', 'gray.300');
|
||||||
const loadingTextColor = useColorModeValue('gray.500', 'gray.300');
|
const loadingTextColor = useColorModeValue('gray.500', 'gray.300');
|
||||||
const emptyTextColor = useColorModeValue('gray.500', 'gray.300');
|
const emptyTextColor = useColorModeValue('gray.500', 'gray.300');
|
||||||
@@ -108,27 +121,6 @@ const FollowingEventsMenu = memo(() => {
|
|||||||
</HStack>
|
</HStack>
|
||||||
</Box>
|
</Box>
|
||||||
<HStack flexShrink={0} spacing={1}>
|
<HStack flexShrink={0} spacing={1}>
|
||||||
{/* 热度 */}
|
|
||||||
{typeof ev.hot_score === 'number' && (
|
|
||||||
<Badge
|
|
||||||
colorScheme={
|
|
||||||
ev.hot_score >= 80 ? 'red' :
|
|
||||||
(ev.hot_score >= 60 ? 'orange' : 'gray')
|
|
||||||
}
|
|
||||||
fontSize="xs"
|
|
||||||
>
|
|
||||||
🔥 {ev.hot_score}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{/* 关注数 */}
|
|
||||||
{typeof ev.follower_count === 'number' && ev.follower_count > 0 && (
|
|
||||||
<Badge
|
|
||||||
colorScheme="purple"
|
|
||||||
fontSize="xs"
|
|
||||||
>
|
|
||||||
👥 {ev.follower_count}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{/* 日均涨跌幅 */}
|
{/* 日均涨跌幅 */}
|
||||||
{typeof ev.related_avg_chg === 'number' && (
|
{typeof ev.related_avg_chg === 'number' && (
|
||||||
<Badge
|
<Badge
|
||||||
@@ -155,23 +147,21 @@ const FollowingEventsMenu = memo(() => {
|
|||||||
{ev.related_week_chg.toFixed(2)}%
|
{ev.related_week_chg.toFixed(2)}%
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{/* 取消关注按钮 */}
|
{/* 取消关注按钮 - 使用 FavoriteButton */}
|
||||||
<Box
|
<Box
|
||||||
as="span"
|
|
||||||
fontSize="xs"
|
|
||||||
color="red.500"
|
|
||||||
cursor="pointer"
|
|
||||||
px={2}
|
|
||||||
py={1}
|
|
||||||
borderRadius="md"
|
|
||||||
_hover={{ bg: 'red.50' }}
|
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleUnfollowEvent(ev.id);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
取消
|
<FavoriteButton
|
||||||
|
isFavorite={true}
|
||||||
|
isLoading={unfollowingId === ev.id}
|
||||||
|
onClick={() => handleUnfollow(ev.id)}
|
||||||
|
size="sm"
|
||||||
|
colorScheme="gold"
|
||||||
|
showTooltip={true}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</HStack>
|
</HStack>
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// src/components/Navbars/components/FeatureMenus/WatchlistMenu.js
|
// src/components/Navbars/components/FeatureMenus/WatchlistMenu.js
|
||||||
// 自选股下拉菜单组件
|
// 自选股下拉菜单组件
|
||||||
|
|
||||||
import React, { memo } from 'react';
|
import React, { memo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Menu,
|
Menu,
|
||||||
MenuButton,
|
MenuButton,
|
||||||
@@ -21,6 +21,7 @@ import { ChevronDownIcon } from '@chakra-ui/icons';
|
|||||||
import { FiStar } from 'react-icons/fi';
|
import { FiStar } from 'react-icons/fi';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useWatchlist } from '../../../../hooks/useWatchlist';
|
import { useWatchlist } from '../../../../hooks/useWatchlist';
|
||||||
|
import FavoriteButton from '@/components/FavoriteButton';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 自选股下拉菜单组件
|
* 自选股下拉菜单组件
|
||||||
@@ -29,6 +30,7 @@ import { useWatchlist } from '../../../../hooks/useWatchlist';
|
|||||||
*/
|
*/
|
||||||
const WatchlistMenu = memo(() => {
|
const WatchlistMenu = memo(() => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [removingCode, setRemovingCode] = useState(null);
|
||||||
const {
|
const {
|
||||||
watchlistQuotes,
|
watchlistQuotes,
|
||||||
watchlistLoading,
|
watchlistLoading,
|
||||||
@@ -39,6 +41,17 @@ const WatchlistMenu = memo(() => {
|
|||||||
handleRemoveFromWatchlist
|
handleRemoveFromWatchlist
|
||||||
} = useWatchlist();
|
} = useWatchlist();
|
||||||
|
|
||||||
|
// 处理取消关注(带 loading 状态)
|
||||||
|
const handleUnwatch = async (stockCode) => {
|
||||||
|
if (removingCode) return;
|
||||||
|
setRemovingCode(stockCode);
|
||||||
|
try {
|
||||||
|
await handleRemoveFromWatchlist(stockCode);
|
||||||
|
} finally {
|
||||||
|
setRemovingCode(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const titleColor = useColorModeValue('gray.600', 'gray.300');
|
const titleColor = useColorModeValue('gray.600', 'gray.300');
|
||||||
const loadingTextColor = useColorModeValue('gray.500', 'gray.300');
|
const loadingTextColor = useColorModeValue('gray.500', 'gray.300');
|
||||||
const emptyTextColor = useColorModeValue('gray.500', 'gray.300');
|
const emptyTextColor = useColorModeValue('gray.500', 'gray.300');
|
||||||
@@ -114,21 +127,19 @@ const WatchlistMenu = memo(() => {
|
|||||||
(item.current_price || '-')}
|
(item.current_price || '-')}
|
||||||
</Text>
|
</Text>
|
||||||
<Box
|
<Box
|
||||||
as="span"
|
|
||||||
fontSize="xs"
|
|
||||||
color="red.500"
|
|
||||||
cursor="pointer"
|
|
||||||
px={2}
|
|
||||||
py={1}
|
|
||||||
borderRadius="md"
|
|
||||||
_hover={{ bg: 'red.50' }}
|
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleRemoveFromWatchlist(item.stock_code);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
取消
|
<FavoriteButton
|
||||||
|
isFavorite={true}
|
||||||
|
isLoading={removingCode === item.stock_code}
|
||||||
|
onClick={() => handleUnwatch(item.stock_code)}
|
||||||
|
size="sm"
|
||||||
|
colorScheme="gold"
|
||||||
|
showTooltip={true}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</HStack>
|
</HStack>
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|||||||
216
src/contexts/GlobalSidebarContext.js
Normal file
216
src/contexts/GlobalSidebarContext.js
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
/**
|
||||||
|
* GlobalSidebarContext - 全局右侧工具栏状态管理
|
||||||
|
*
|
||||||
|
* 管理侧边栏的展开/收起状态和数据加载
|
||||||
|
* 自选股和关注事件数据都从 Redux 获取,与导航栏共用数据源
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { createContext, useContext, useState, useCallback, useEffect, useRef } from 'react';
|
||||||
|
import { useSelector, useDispatch } from 'react-redux';
|
||||||
|
import { useAuth } from './AuthContext';
|
||||||
|
import { logger } from '@/utils/logger';
|
||||||
|
import {
|
||||||
|
loadWatchlist,
|
||||||
|
loadWatchlistQuotes,
|
||||||
|
toggleWatchlist,
|
||||||
|
loadFollowingEvents,
|
||||||
|
loadEventComments,
|
||||||
|
toggleFollowEvent
|
||||||
|
} from '@/store/slices/stockSlice';
|
||||||
|
|
||||||
|
const GlobalSidebarContext = createContext(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GlobalSidebarProvider - 全局侧边栏 Provider
|
||||||
|
*/
|
||||||
|
export const GlobalSidebarProvider = ({ children }) => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const userId = user?.id;
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
|
// 侧边栏展开/收起状态(默认折叠)
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
// 从 Redux 获取自选股数据(与导航栏共用)
|
||||||
|
const watchlist = useSelector(state => state.stock.watchlist || []);
|
||||||
|
const watchlistQuotes = useSelector(state => state.stock.watchlistQuotes || []);
|
||||||
|
const watchlistLoading = useSelector(state => state.stock.loading?.watchlist);
|
||||||
|
const quotesLoading = useSelector(state => state.stock.loading?.watchlistQuotes);
|
||||||
|
|
||||||
|
// 将 watchlistQuotes 数组转换为 { stock_code: quote } 格式(兼容现有组件)
|
||||||
|
const realtimeQuotes = React.useMemo(() => {
|
||||||
|
const quotesMap = {};
|
||||||
|
watchlistQuotes.forEach(item => {
|
||||||
|
quotesMap[item.stock_code] = item;
|
||||||
|
});
|
||||||
|
return quotesMap;
|
||||||
|
}, [watchlistQuotes]);
|
||||||
|
|
||||||
|
// 从 Redux 获取关注事件数据(与导航栏共用)
|
||||||
|
const followingEvents = useSelector(state => state.stock.followingEvents || []);
|
||||||
|
const eventComments = useSelector(state => state.stock.eventComments || []);
|
||||||
|
const eventsLoading = useSelector(state => state.stock.loading?.followingEvents || false);
|
||||||
|
|
||||||
|
// 防止重复加载
|
||||||
|
const hasLoadedRef = useRef(false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换侧边栏展开/收起
|
||||||
|
*/
|
||||||
|
const toggle = useCallback(() => {
|
||||||
|
setIsOpen(prev => !prev);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载实时行情(通过 Redux)
|
||||||
|
*/
|
||||||
|
const loadRealtimeQuotes = useCallback(() => {
|
||||||
|
if (!userId) return;
|
||||||
|
dispatch(loadWatchlistQuotes());
|
||||||
|
}, [userId, dispatch]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载所有数据(自选股和关注事件都从 Redux 获取)
|
||||||
|
*/
|
||||||
|
const loadData = useCallback(() => {
|
||||||
|
if (!userId) return;
|
||||||
|
|
||||||
|
// 自选股通过 Redux 加载
|
||||||
|
dispatch(loadWatchlist());
|
||||||
|
dispatch(loadWatchlistQuotes());
|
||||||
|
|
||||||
|
// 关注事件和评论通过 Redux 加载
|
||||||
|
dispatch(loadFollowingEvents());
|
||||||
|
dispatch(loadEventComments());
|
||||||
|
}, [userId, dispatch]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刷新数据
|
||||||
|
*/
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
await loadData();
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消关注股票(通过 Redux)
|
||||||
|
*/
|
||||||
|
const unwatchStock = useCallback(async (stockCode) => {
|
||||||
|
if (!userId) return;
|
||||||
|
try {
|
||||||
|
// 找到股票名称
|
||||||
|
const stockItem = watchlist.find(s => s.stock_code === stockCode);
|
||||||
|
const stockName = stockItem?.stock_name || '';
|
||||||
|
|
||||||
|
// 通过 Redux action 移除(乐观更新)
|
||||||
|
await dispatch(toggleWatchlist({
|
||||||
|
stockCode,
|
||||||
|
stockName,
|
||||||
|
isInWatchlist: true // 表示当前在自选股中,需要移除
|
||||||
|
})).unwrap();
|
||||||
|
|
||||||
|
logger.debug('GlobalSidebar', 'unwatchStock 成功', { stockCode });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('GlobalSidebar', 'unwatchStock', error, { stockCode, userId });
|
||||||
|
}
|
||||||
|
}, [userId, dispatch, watchlist]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消关注事件(通过 Redux)
|
||||||
|
*/
|
||||||
|
const unfollowEvent = useCallback(async (eventId) => {
|
||||||
|
if (!userId) return;
|
||||||
|
try {
|
||||||
|
// 通过 Redux action 取消关注(乐观更新)
|
||||||
|
await dispatch(toggleFollowEvent({
|
||||||
|
eventId,
|
||||||
|
isFollowing: true // 表示当前已关注,需要取消
|
||||||
|
})).unwrap();
|
||||||
|
|
||||||
|
logger.debug('GlobalSidebar', 'unfollowEvent 成功', { eventId });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('GlobalSidebar', 'unfollowEvent', error, { eventId, userId });
|
||||||
|
// 失败时重新加载列表
|
||||||
|
dispatch(loadFollowingEvents());
|
||||||
|
}
|
||||||
|
}, [userId, dispatch]);
|
||||||
|
|
||||||
|
// 用户登录后加载数据
|
||||||
|
useEffect(() => {
|
||||||
|
if (user && !hasLoadedRef.current) {
|
||||||
|
console.log('[GlobalSidebar] 用户登录,加载数据');
|
||||||
|
hasLoadedRef.current = true;
|
||||||
|
loadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户登出时重置(所有状态由 Redux 管理)
|
||||||
|
if (!user) {
|
||||||
|
hasLoadedRef.current = false;
|
||||||
|
}
|
||||||
|
}, [user, loadData]);
|
||||||
|
|
||||||
|
// 页面可见性变化时刷新数据
|
||||||
|
useEffect(() => {
|
||||||
|
const onVisibilityChange = () => {
|
||||||
|
if (document.visibilityState === 'visible' && user) {
|
||||||
|
console.log('[GlobalSidebar] 页面可见,刷新数据');
|
||||||
|
loadData();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||||
|
return () => document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||||
|
}, [user, loadData]);
|
||||||
|
|
||||||
|
// 定时刷新实时行情(每分钟一次,两个面板共用)
|
||||||
|
useEffect(() => {
|
||||||
|
if (watchlist.length > 0 && userId) {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
console.log('[GlobalSidebar] 定时刷新行情');
|
||||||
|
dispatch(loadWatchlistQuotes());
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}
|
||||||
|
}, [watchlist.length, userId, dispatch]);
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
// 状态
|
||||||
|
isOpen,
|
||||||
|
toggle,
|
||||||
|
|
||||||
|
// 数据(watchlist 和 realtimeQuotes 从 Redux 获取)
|
||||||
|
watchlist,
|
||||||
|
realtimeQuotes,
|
||||||
|
followingEvents,
|
||||||
|
eventComments,
|
||||||
|
|
||||||
|
// 加载状态
|
||||||
|
loading: watchlistLoading || eventsLoading,
|
||||||
|
quotesLoading,
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
refresh,
|
||||||
|
loadRealtimeQuotes,
|
||||||
|
unwatchStock,
|
||||||
|
unfollowEvent,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GlobalSidebarContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</GlobalSidebarContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useGlobalSidebar - 获取全局侧边栏 Context
|
||||||
|
*/
|
||||||
|
export const useGlobalSidebar = () => {
|
||||||
|
const context = useContext(GlobalSidebarContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useGlobalSidebar must be used within a GlobalSidebarProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GlobalSidebarContext;
|
||||||
@@ -1,16 +1,21 @@
|
|||||||
// src/hooks/useFollowingEvents.js
|
// src/hooks/useFollowingEvents.js
|
||||||
// 关注事件管理自定义 Hook
|
// 关注事件管理自定义 Hook(与 Redux 状态同步,支持多组件共用)
|
||||||
|
|
||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||||
|
import { useSelector, useDispatch } from 'react-redux';
|
||||||
import { useToast } from '@chakra-ui/react';
|
import { useToast } from '@chakra-ui/react';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
import { getApiBase } from '../utils/apiConfig';
|
import {
|
||||||
|
loadFollowingEvents as loadFollowingEventsAction,
|
||||||
|
toggleFollowEvent
|
||||||
|
} from '../store/slices/stockSlice';
|
||||||
|
|
||||||
const EVENTS_PAGE_SIZE = 8;
|
const EVENTS_PAGE_SIZE = 8;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 关注事件管理 Hook
|
* 关注事件管理 Hook(导航栏专用)
|
||||||
* 提供事件加载、分页、取消关注等功能
|
* 提供关注事件加载、分页、取消关注等功能
|
||||||
|
* 监听 Redux 中的 followingEvents 变化,自动同步
|
||||||
*
|
*
|
||||||
* @returns {{
|
* @returns {{
|
||||||
* followingEvents: Array,
|
* followingEvents: Array,
|
||||||
@@ -24,77 +29,66 @@ const EVENTS_PAGE_SIZE = 8;
|
|||||||
*/
|
*/
|
||||||
export const useFollowingEvents = () => {
|
export const useFollowingEvents = () => {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [followingEvents, setFollowingEvents] = useState([]);
|
const dispatch = useDispatch();
|
||||||
const [eventsLoading, setEventsLoading] = useState(false);
|
|
||||||
const [eventsPage, setEventsPage] = useState(1);
|
const [eventsPage, setEventsPage] = useState(1);
|
||||||
|
|
||||||
// 加载关注的事件
|
// 从 Redux 获取关注事件数据(与 GlobalSidebar 共用)
|
||||||
const loadFollowingEvents = useCallback(async () => {
|
const followingEvents = useSelector(state => state.stock.followingEvents || []);
|
||||||
try {
|
const eventsLoading = useSelector(state => state.stock.loading?.followingEvents || false);
|
||||||
setEventsLoading(true);
|
|
||||||
const base = getApiBase();
|
|
||||||
const resp = await fetch(base + '/api/account/events/following', {
|
|
||||||
credentials: 'include',
|
|
||||||
cache: 'no-store'
|
|
||||||
});
|
|
||||||
if (resp.ok) {
|
|
||||||
const data = await resp.json();
|
|
||||||
if (data && data.success && Array.isArray(data.data)) {
|
|
||||||
// 合并重复的事件(用最新的数据)
|
|
||||||
const eventMap = new Map();
|
|
||||||
for (const evt of data.data) {
|
|
||||||
if (evt && evt.id) {
|
|
||||||
eventMap.set(evt.id, evt);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const merged = Array.from(eventMap.values());
|
|
||||||
// 按创建时间降序排列(假设事件有 created_at 或 id)
|
|
||||||
if (merged.length > 0 && merged[0].created_at) {
|
|
||||||
merged.sort((a, b) => new Date(b.created_at || 0) - new Date(a.created_at || 0));
|
|
||||||
} else {
|
|
||||||
merged.sort((a, b) => (b.id || 0) - (a.id || 0));
|
|
||||||
}
|
|
||||||
setFollowingEvents(merged);
|
|
||||||
} else {
|
|
||||||
setFollowingEvents([]);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setFollowingEvents([]);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
logger.warn('useFollowingEvents', '加载关注事件失败', {
|
|
||||||
error: e.message
|
|
||||||
});
|
|
||||||
setFollowingEvents([]);
|
|
||||||
} finally {
|
|
||||||
setEventsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 取消关注事件
|
// 从 Redux 获取关注事件列表长度(用于监听变化)
|
||||||
|
const reduxEventsLength = useSelector(state => state.stock.followingEvents?.length || 0);
|
||||||
|
|
||||||
|
// 用于跟踪上一次的事件长度
|
||||||
|
const prevEventsLengthRef = useRef(-1);
|
||||||
|
|
||||||
|
// 初始化时加载 Redux followingEvents(确保 Redux 状态被初始化)
|
||||||
|
const hasInitializedRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasInitializedRef.current) {
|
||||||
|
hasInitializedRef.current = true;
|
||||||
|
logger.debug('useFollowingEvents', '初始化 Redux followingEvents');
|
||||||
|
dispatch(loadFollowingEventsAction());
|
||||||
|
}
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
// 加载关注事件(通过 Redux)
|
||||||
|
const loadFollowingEvents = useCallback(() => {
|
||||||
|
logger.debug('useFollowingEvents', '触发 loadFollowingEvents');
|
||||||
|
dispatch(loadFollowingEventsAction());
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
// 监听 Redux followingEvents 长度变化,自动更新分页
|
||||||
|
useEffect(() => {
|
||||||
|
const currentLength = reduxEventsLength;
|
||||||
|
const prevLength = prevEventsLengthRef.current;
|
||||||
|
|
||||||
|
// 当事件列表长度变化时,更新分页(确保不超出范围)
|
||||||
|
if (prevLength !== -1 && currentLength !== prevLength) {
|
||||||
|
const newMaxPage = Math.max(1, Math.ceil(currentLength / EVENTS_PAGE_SIZE));
|
||||||
|
setEventsPage(p => Math.min(p, newMaxPage));
|
||||||
|
}
|
||||||
|
|
||||||
|
prevEventsLengthRef.current = currentLength;
|
||||||
|
}, [reduxEventsLength]);
|
||||||
|
|
||||||
|
// 取消关注事件(通过 Redux)
|
||||||
const handleUnfollowEvent = useCallback(async (eventId) => {
|
const handleUnfollowEvent = useCallback(async (eventId) => {
|
||||||
try {
|
try {
|
||||||
const base = getApiBase();
|
// 通过 Redux action 取消关注(乐观更新)
|
||||||
const resp = await fetch(base + `/api/events/${eventId}/follow`, {
|
await dispatch(toggleFollowEvent({
|
||||||
method: 'POST',
|
eventId,
|
||||||
credentials: 'include'
|
isFollowing: true // 表示当前已关注,需要取消
|
||||||
});
|
})).unwrap();
|
||||||
const data = await resp.json().catch(() => ({}));
|
|
||||||
if (resp.ok && data && data.success !== false) {
|
|
||||||
setFollowingEvents((prev) => {
|
|
||||||
const updated = (prev || []).filter((x) => x.id !== eventId);
|
|
||||||
const newMaxPage = Math.max(1, Math.ceil((updated.length || 0) / EVENTS_PAGE_SIZE));
|
|
||||||
setEventsPage((p) => Math.min(p, newMaxPage));
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
toast({ title: '已取消关注该事件', status: 'info', duration: 1500 });
|
toast({ title: '已取消关注该事件', status: 'info', duration: 1500 });
|
||||||
} else {
|
|
||||||
toast({ title: '操作失败', status: 'error', duration: 2000 });
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast({ title: '网络错误,操作失败', status: 'error', duration: 2000 });
|
logger.error('useFollowingEvents', '取消关注事件失败', e);
|
||||||
|
toast({ title: e.message || '操作失败', status: 'error', duration: 2000 });
|
||||||
|
// 失败时重新加载列表
|
||||||
|
dispatch(loadFollowingEventsAction());
|
||||||
}
|
}
|
||||||
}, [toast]);
|
}, [dispatch, toast]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
followingEvents,
|
followingEvents,
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
// src/hooks/useWatchlist.js
|
// src/hooks/useWatchlist.js
|
||||||
// 自选股管理自定义 Hook(导航栏专用,与 Redux 状态同步)
|
// 自选股管理自定义 Hook(与 Redux 状态同步,支持多组件共用)
|
||||||
|
|
||||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||||
import { useSelector, useDispatch } from 'react-redux';
|
import { useSelector, useDispatch } from 'react-redux';
|
||||||
import { useToast } from '@chakra-ui/react';
|
import { useToast } from '@chakra-ui/react';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
import { getApiBase } from '../utils/apiConfig';
|
import { getApiBase } from '../utils/apiConfig';
|
||||||
import { toggleWatchlist as toggleWatchlistAction, loadWatchlist } from '../store/slices/stockSlice';
|
import {
|
||||||
|
toggleWatchlist as toggleWatchlistAction,
|
||||||
|
loadWatchlist,
|
||||||
|
loadWatchlistQuotes
|
||||||
|
} from '../store/slices/stockSlice';
|
||||||
|
|
||||||
const WATCHLIST_PAGE_SIZE = 10;
|
const WATCHLIST_PAGE_SIZE = 10;
|
||||||
|
|
||||||
@@ -31,20 +35,18 @@ const WATCHLIST_PAGE_SIZE = 10;
|
|||||||
export const useWatchlist = () => {
|
export const useWatchlist = () => {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const [watchlistQuotes, setWatchlistQuotes] = useState([]);
|
|
||||||
const [watchlistLoading, setWatchlistLoading] = useState(false);
|
|
||||||
const [watchlistPage, setWatchlistPage] = useState(1);
|
const [watchlistPage, setWatchlistPage] = useState(1);
|
||||||
const [followingEvents, setFollowingEvents] = useState([]);
|
const [followingEvents, setFollowingEvents] = useState([]);
|
||||||
|
|
||||||
|
// 从 Redux 获取自选股数据(与 GlobalSidebar 共用)
|
||||||
|
const watchlistQuotes = useSelector(state => state.stock.watchlistQuotes || []);
|
||||||
|
const watchlistLoading = useSelector(state => state.stock.loading?.watchlistQuotes || false);
|
||||||
|
|
||||||
// 从 Redux 获取自选股列表长度(用于监听变化)
|
// 从 Redux 获取自选股列表长度(用于监听变化)
|
||||||
// 使用 length 作为依赖,避免数组引用变化导致不必要的重新渲染
|
|
||||||
const reduxWatchlistLength = useSelector(state => state.stock.watchlist?.length || 0);
|
const reduxWatchlistLength = useSelector(state => state.stock.watchlist?.length || 0);
|
||||||
|
|
||||||
// 检查 Redux watchlist 是否已初始化(加载状态)
|
|
||||||
const reduxWatchlistLoading = useSelector(state => state.stock.loading?.watchlist);
|
|
||||||
|
|
||||||
// 用于跟踪上一次的 watchlist 长度
|
// 用于跟踪上一次的 watchlist 长度
|
||||||
const prevWatchlistLengthRef = useRef(-1); // 初始设为 -1,确保第一次变化也能检测到
|
const prevWatchlistLengthRef = useRef(-1);
|
||||||
|
|
||||||
// 初始化时加载 Redux watchlist(确保 Redux 状态被初始化)
|
// 初始化时加载 Redux watchlist(确保 Redux 状态被初始化)
|
||||||
const hasInitializedRef = useRef(false);
|
const hasInitializedRef = useRef(false);
|
||||||
@@ -56,35 +58,11 @@ export const useWatchlist = () => {
|
|||||||
}
|
}
|
||||||
}, [dispatch]);
|
}, [dispatch]);
|
||||||
|
|
||||||
// 加载自选股实时行情
|
// 加载自选股实时行情(通过 Redux)
|
||||||
const loadWatchlistQuotes = useCallback(async () => {
|
const loadWatchlistQuotesFunc = useCallback(() => {
|
||||||
try {
|
logger.debug('useWatchlist', '触发 loadWatchlistQuotes');
|
||||||
setWatchlistLoading(true);
|
dispatch(loadWatchlistQuotes());
|
||||||
const base = getApiBase();
|
}, [dispatch]);
|
||||||
const resp = await fetch(base + '/api/account/watchlist/realtime', {
|
|
||||||
credentials: 'include',
|
|
||||||
cache: 'no-store'
|
|
||||||
});
|
|
||||||
if (resp.ok) {
|
|
||||||
const data = await resp.json();
|
|
||||||
if (data && data.success && Array.isArray(data.data)) {
|
|
||||||
setWatchlistQuotes(data.data);
|
|
||||||
logger.debug('useWatchlist', '自选股行情加载成功', { count: data.data.length });
|
|
||||||
} else {
|
|
||||||
setWatchlistQuotes([]);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setWatchlistQuotes([]);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
logger.warn('useWatchlist', '加载自选股实时行情失败', {
|
|
||||||
error: e.message
|
|
||||||
});
|
|
||||||
setWatchlistQuotes([]);
|
|
||||||
} finally {
|
|
||||||
setWatchlistLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 监听 Redux watchlist 长度变化,自动刷新行情数据
|
// 监听 Redux watchlist 长度变化,自动刷新行情数据
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -102,7 +80,7 @@ export const useWatchlist = () => {
|
|||||||
// 延迟一小段时间再刷新,确保后端数据已更新
|
// 延迟一小段时间再刷新,确保后端数据已更新
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
logger.debug('useWatchlist', '执行 loadWatchlistQuotes');
|
logger.debug('useWatchlist', '执行 loadWatchlistQuotes');
|
||||||
loadWatchlistQuotes();
|
dispatch(loadWatchlistQuotes());
|
||||||
}, 500);
|
}, 500);
|
||||||
|
|
||||||
prevWatchlistLengthRef.current = currentLength;
|
prevWatchlistLengthRef.current = currentLength;
|
||||||
@@ -111,66 +89,53 @@ export const useWatchlist = () => {
|
|||||||
|
|
||||||
// 更新 ref
|
// 更新 ref
|
||||||
prevWatchlistLengthRef.current = currentLength;
|
prevWatchlistLengthRef.current = currentLength;
|
||||||
}, [reduxWatchlistLength, loadWatchlistQuotes]);
|
}, [reduxWatchlistLength, dispatch]);
|
||||||
|
|
||||||
// 添加到自选股
|
// 添加到自选股(通过 Redux)
|
||||||
const handleAddToWatchlist = useCallback(async (stockCode, stockName) => {
|
const handleAddToWatchlist = useCallback(async (stockCode, stockName) => {
|
||||||
try {
|
try {
|
||||||
const base = getApiBase();
|
// 通过 Redux action 添加(乐观更新)
|
||||||
const resp = await fetch(base + '/api/account/watchlist', {
|
await dispatch(toggleWatchlistAction({
|
||||||
method: 'POST',
|
stockCode,
|
||||||
credentials: 'include',
|
stockName,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
isInWatchlist: false // 表示当前不在自选股中,需要添加
|
||||||
body: JSON.stringify({ stock_code: stockCode, stock_name: stockName })
|
})).unwrap();
|
||||||
});
|
|
||||||
const data = await resp.json().catch(() => ({}));
|
// 刷新行情
|
||||||
if (resp.ok && data.success) {
|
dispatch(loadWatchlistQuotes());
|
||||||
// 刷新自选股列表
|
|
||||||
loadWatchlistQuotes();
|
|
||||||
toast({ title: '已添加至自选股', status: 'success', duration: 1500 });
|
toast({ title: '已添加至自选股', status: 'success', duration: 1500 });
|
||||||
return true;
|
return true;
|
||||||
} else {
|
|
||||||
toast({ title: '添加失败', status: 'error', duration: 2000 });
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast({ title: '网络错误,添加失败', status: 'error', duration: 2000 });
|
logger.error('useWatchlist', '添加自选股失败', e);
|
||||||
|
toast({ title: e.message || '添加失败', status: 'error', duration: 2000 });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}, [toast, loadWatchlistQuotes]);
|
}, [dispatch, toast]);
|
||||||
|
|
||||||
// 从自选股移除
|
// 从自选股移除(通过 Redux)
|
||||||
const handleRemoveFromWatchlist = useCallback(async (stockCode) => {
|
const handleRemoveFromWatchlist = useCallback(async (stockCode) => {
|
||||||
try {
|
try {
|
||||||
// 找到股票名称
|
// 找到股票名称
|
||||||
const stockItem = watchlistQuotes.find(item => {
|
|
||||||
const normalize6 = (code) => {
|
const normalize6 = (code) => {
|
||||||
const m = String(code || '').match(/(\d{6})/);
|
const m = String(code || '').match(/(\d{6})/);
|
||||||
return m ? m[1] : String(code || '');
|
return m ? m[1] : String(code || '');
|
||||||
};
|
};
|
||||||
return normalize6(item.stock_code) === normalize6(stockCode);
|
const stockItem = watchlistQuotes.find(item =>
|
||||||
});
|
normalize6(item.stock_code) === normalize6(stockCode)
|
||||||
|
);
|
||||||
const stockName = stockItem?.stock_name || '';
|
const stockName = stockItem?.stock_name || '';
|
||||||
|
|
||||||
// 通过 Redux action 移除(会同步更新 Redux 状态)
|
// 通过 Redux action 移除(乐观更新)
|
||||||
await dispatch(toggleWatchlistAction({
|
await dispatch(toggleWatchlistAction({
|
||||||
stockCode,
|
stockCode,
|
||||||
stockName,
|
stockName,
|
||||||
isInWatchlist: true // 表示当前在自选股中,需要移除
|
isInWatchlist: true // 表示当前在自选股中,需要移除
|
||||||
})).unwrap();
|
})).unwrap();
|
||||||
|
|
||||||
// 更新本地状态(立即响应 UI)
|
// 更新分页(如果当前页超出范围)
|
||||||
setWatchlistQuotes((prev) => {
|
const newLength = watchlistQuotes.length - 1;
|
||||||
const normalize6 = (code) => {
|
const newMaxPage = Math.max(1, Math.ceil(newLength / WATCHLIST_PAGE_SIZE));
|
||||||
const m = String(code || '').match(/(\d{6})/);
|
setWatchlistPage(p => Math.min(p, newMaxPage));
|
||||||
return m ? m[1] : String(code || '');
|
|
||||||
};
|
|
||||||
const target = normalize6(stockCode);
|
|
||||||
const updated = (prev || []).filter((x) => normalize6(x.stock_code) !== target);
|
|
||||||
const newMaxPage = Math.max(1, Math.ceil((updated.length || 0) / WATCHLIST_PAGE_SIZE));
|
|
||||||
setWatchlistPage((p) => Math.min(p, newMaxPage));
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
|
|
||||||
toast({ title: '已从自选股移除', status: 'info', duration: 1500 });
|
toast({ title: '已从自选股移除', status: 'info', duration: 1500 });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -195,7 +160,7 @@ export const useWatchlist = () => {
|
|||||||
watchlistPage,
|
watchlistPage,
|
||||||
setWatchlistPage,
|
setWatchlistPage,
|
||||||
WATCHLIST_PAGE_SIZE,
|
WATCHLIST_PAGE_SIZE,
|
||||||
loadWatchlistQuotes,
|
loadWatchlistQuotes: loadWatchlistQuotesFunc,
|
||||||
followingEvents,
|
followingEvents,
|
||||||
handleAddToWatchlist,
|
handleAddToWatchlist,
|
||||||
handleRemoveFromWatchlist,
|
handleRemoveFromWatchlist,
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ performanceMonitor.mark('app-start');
|
|||||||
// ⚡ 已删除 brainwave.css(项目未安装 Tailwind CSS,该文件无效)
|
// ⚡ 已删除 brainwave.css(项目未安装 Tailwind CSS,该文件无效)
|
||||||
// import './styles/brainwave.css';
|
// import './styles/brainwave.css';
|
||||||
|
|
||||||
|
// 导入全局滚动条隐藏样式
|
||||||
|
import './styles/scrollbar-hide.css';
|
||||||
|
|
||||||
// 导入 Select 下拉框颜色修复样式
|
// 导入 Select 下拉框颜色修复样式
|
||||||
import './styles/select-fix.css';
|
import './styles/select-fix.css';
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,14 @@
|
|||||||
// 主布局组件 - 为所有带导航栏的页面提供统一布局
|
// 主布局组件 - 为所有带导航栏的页面提供统一布局
|
||||||
import React, { memo, Suspense } from "react";
|
import React, { memo, Suspense } from "react";
|
||||||
import { Outlet } from "react-router-dom";
|
import { Outlet } from "react-router-dom";
|
||||||
import { Box } from '@chakra-ui/react';
|
import { Box, Flex } from '@chakra-ui/react';
|
||||||
import HomeNavbar from "../components/Navbars/HomeNavbar";
|
import HomeNavbar from "../components/Navbars/HomeNavbar";
|
||||||
import AppFooter from "./AppFooter";
|
import AppFooter from "./AppFooter";
|
||||||
import BackToTopButton from "./components/BackToTopButton";
|
import BackToTopButton from "./components/BackToTopButton";
|
||||||
import ErrorBoundary from "../components/ErrorBoundary";
|
import ErrorBoundary from "../components/ErrorBoundary";
|
||||||
import PageLoader from "../components/Loading/PageLoader";
|
import PageLoader from "../components/Loading/PageLoader";
|
||||||
import { BACK_TO_TOP_CONFIG } from "./config/layoutConfig";
|
import GlobalSidebar from "../components/GlobalSidebar";
|
||||||
|
import { BACK_TO_TOP_CONFIG, LAYOUT_SIZE } from "./config/layoutConfig";
|
||||||
|
|
||||||
// ✅ P0 性能优化:缓存静态组件,避免路由切换时不必要的重新渲染
|
// ✅ P0 性能优化:缓存静态组件,避免路由切换时不必要的重新渲染
|
||||||
// HomeNavbar (1623行) 和 AppFooter 不依赖路由参数,使用 memo 可大幅减少渲染次数
|
// HomeNavbar (1623行) 和 AppFooter 不依赖路由参数,使用 memo 可大幅减少渲染次数
|
||||||
@@ -27,6 +28,7 @@ const MemoizedAppFooter = memo(AppFooter);
|
|||||||
* - ✅ 错误隔离 - ErrorBoundary 包裹页面内容,确保导航栏可用
|
* - ✅ 错误隔离 - ErrorBoundary 包裹页面内容,确保导航栏可用
|
||||||
* - ✅ 懒加载支持 - Suspense 统一处理懒加载
|
* - ✅ 懒加载支持 - Suspense 统一处理懒加载
|
||||||
* - ✅ 布局简化 - 直接内联容器逻辑,减少嵌套层级
|
* - ✅ 布局简化 - 直接内联容器逻辑,减少嵌套层级
|
||||||
|
* - ✅ 全局侧边栏 - 右侧可收起的工具栏(关注股票、事件动态)
|
||||||
*/
|
*/
|
||||||
export default function MainLayout() {
|
export default function MainLayout() {
|
||||||
return (
|
return (
|
||||||
@@ -34,17 +36,26 @@ export default function MainLayout() {
|
|||||||
{/* 导航栏 - 在所有页面间共享,memo 后不会在路由切换时重新渲染 */}
|
{/* 导航栏 - 在所有页面间共享,memo 后不会在路由切换时重新渲染 */}
|
||||||
<MemoizedHomeNavbar />
|
<MemoizedHomeNavbar />
|
||||||
|
|
||||||
{/* 页面内容区域 - flex: 1 占据剩余空间,包含错误边界、懒加载 */}
|
{/* 主体区域 - 页面内容 + 右侧全局侧边栏(绝对定位覆盖) */}
|
||||||
<Box flex="1" pt="60px" bg="#1A202C">
|
<Box flex="1" bg="#1A202C" position="relative" overflow="hidden">
|
||||||
|
{/* 页面内容区域 - 全宽度,与导航栏对齐 */}
|
||||||
|
<Box h="100%" overflowY="auto" display="flex" flexDirection="column">
|
||||||
|
<Box flex="1" pt={LAYOUT_SIZE.navbarHeight}>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<Suspense fallback={<PageLoader message="页面加载中..." />}>
|
<Suspense fallback={<PageLoader message="页面加载中..." />}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</Box>
|
</Box>
|
||||||
|
{/* 页脚 - 在滚动区域内,随内容滚动 */}
|
||||||
{/* 页脚 - 在所有页面间共享,memo 后不会在路由切换时重新渲染 */}
|
|
||||||
<MemoizedAppFooter />
|
<MemoizedAppFooter />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 全局右侧工具栏 - 绝对定位覆盖在内容上方 */}
|
||||||
|
<Box position="absolute" top={0} right={0} bottom={0}>
|
||||||
|
<GlobalSidebar />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
{/* 返回顶部按钮 - 滚动超过阈值时显示 */}
|
{/* 返回顶部按钮 - 滚动超过阈值时显示 */}
|
||||||
{/* <BackToTopButton
|
{/* <BackToTopButton
|
||||||
|
|||||||
@@ -12,13 +12,62 @@
|
|||||||
/**
|
/**
|
||||||
* Z-Index 层级管理
|
* Z-Index 层级管理
|
||||||
* 统一管理 z-index,避免层级冲突
|
* 统一管理 z-index,避免层级冲突
|
||||||
|
*
|
||||||
|
* 层级规则(从低到高):
|
||||||
|
* - 0-99: 页面内部元素(背景、卡片内部层级)
|
||||||
|
* - 100-499: 页面级浮动元素(侧边栏、面板)
|
||||||
|
* - 500-999: 全局固定元素(工具栏、返回顶部)
|
||||||
|
* - 1000-1499: 导航相关(导航栏、状态栏)
|
||||||
|
* - 1500-1999: 弹出层(下拉菜单、Popover)
|
||||||
|
* - 2000-2999: 模态框(普通 Modal)
|
||||||
|
* - 3000-8999: 特殊模态框(图表全屏、预览)
|
||||||
|
* - 9000-9999: 全局提示(Toast、通知)
|
||||||
|
* - 10000+: 系统级覆盖(第三方组件、客服系统)
|
||||||
*/
|
*/
|
||||||
export const Z_INDEX = {
|
export const Z_INDEX = {
|
||||||
BACK_TO_TOP: 1000, // 返回顶部按钮
|
// === 页面内部元素 (0-99) ===
|
||||||
NAVBAR: 1100, // 导航栏
|
BACKGROUND: 0, // 背景层
|
||||||
MODAL: 1200, // 模态框
|
CARD_CONTENT: 1, // 卡片内容
|
||||||
TOAST: 1300, // 提示消息
|
CARD_OVERLAY: 2, // 卡片覆盖层
|
||||||
TOOLTIP: 1400, // 工具提示
|
|
||||||
|
// === 页面级浮动元素 (100-499) ===
|
||||||
|
SIDEBAR: 100, // 全局侧边栏
|
||||||
|
STICKY_HEADER: 200, // 粘性表头
|
||||||
|
|
||||||
|
// === 全局固定元素 (500-999) ===
|
||||||
|
BACK_TO_TOP: 900, // 返回顶部按钮
|
||||||
|
AUTH_MODAL_BG: 999, // 认证模态框背景
|
||||||
|
|
||||||
|
// === 导航相关 (1000-1499) ===
|
||||||
|
NAVBAR: 1000, // 顶部导航栏
|
||||||
|
CONNECTION_STATUS: 1050, // 连接状态栏
|
||||||
|
PROFILE_ALERT: 1100, // 个人资料提示
|
||||||
|
|
||||||
|
// === 弹出层 (1500-1999) ===
|
||||||
|
DROPDOWN: 1500, // 下拉菜单
|
||||||
|
POPOVER: 1600, // Popover 弹出
|
||||||
|
TOOLTIP: 1700, // 工具提示
|
||||||
|
CITATION: 1800, // 引用标记
|
||||||
|
|
||||||
|
// === 模态框 (2000-2999) ===
|
||||||
|
MODAL: 2000, // 普通模态框
|
||||||
|
MODAL_OVERLAY: 2001, // 模态框遮罩
|
||||||
|
STOCK_CHART_MODAL: 2500, // 股票图表模态框
|
||||||
|
|
||||||
|
// === 特殊模态框 (3000-8999) ===
|
||||||
|
FULLSCREEN: 3000, // 全屏模式
|
||||||
|
IMAGE_PREVIEW: 5000, // 图片预览
|
||||||
|
|
||||||
|
// === 全局提示 (9000-9999) ===
|
||||||
|
NOTIFICATION: 9000, // 通知容器
|
||||||
|
TOAST: 9500, // Toast 提示
|
||||||
|
SEARCH_DROPDOWN: 9800, // 搜索下拉框
|
||||||
|
PERFORMANCE_PANEL: 9900, // 性能面板(开发用)
|
||||||
|
|
||||||
|
// === 系统级覆盖 (10000+) ===
|
||||||
|
KLINE_FULLSCREEN: 10000, // K线图全屏
|
||||||
|
THIRD_PARTY: 99999, // 第三方组件
|
||||||
|
BYTEDESK: 999999, // Bytedesk 客服系统
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -116,9 +165,9 @@ export const PAGE_LOADER_CONFIG = {
|
|||||||
* 布局尺寸配置
|
* 布局尺寸配置
|
||||||
*/
|
*/
|
||||||
export const LAYOUT_SIZE = {
|
export const LAYOUT_SIZE = {
|
||||||
navbarHeight: '80px',
|
navbarHeight: '60px', // 导航栏统一高度
|
||||||
footerHeight: 'auto',
|
footerHeight: 'auto',
|
||||||
contentMinHeight: 'calc(100vh - 80px)', // 100vh - navbar高度
|
contentMinHeight: 'calc(100vh - 60px)', // 100vh - navbar高度
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -770,6 +770,144 @@ export const mockInvestmentPlans = [
|
|||||||
updated_at: '2024-10-08T10:00:00Z',
|
updated_at: '2024-10-08T10:00:00Z',
|
||||||
tags: ['季度复盘', '半导体', 'Q3'],
|
tags: ['季度复盘', '半导体', 'Q3'],
|
||||||
stocks: ['688981.SH', '002371.SZ']
|
stocks: ['688981.SH', '002371.SZ']
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==================== 今日数据(用于日历视图展示) ====================
|
||||||
|
// 测试同日期多事件显示:计划 x3, 复盘 x3, 系统 x3
|
||||||
|
{
|
||||||
|
id: 320,
|
||||||
|
user_id: 1,
|
||||||
|
type: 'plan',
|
||||||
|
title: '今日交易计划 - 年末布局',
|
||||||
|
content: `【今日目标】
|
||||||
|
重点关注年末资金流向,寻找低位优质标的布局机会。
|
||||||
|
|
||||||
|
【操作计划】
|
||||||
|
1. 白酒板块:观察茅台、五粮液走势,若出现回调可适当加仓
|
||||||
|
2. 新能源:宁德时代逢低补仓,目标价位160元附近
|
||||||
|
3. AI算力:关注寒武纪的突破信号
|
||||||
|
|
||||||
|
【资金安排】
|
||||||
|
- 当前仓位:65%
|
||||||
|
- 可动用资金:35%
|
||||||
|
- 计划使用资金:15%(分3笔建仓)
|
||||||
|
|
||||||
|
【风险控制】
|
||||||
|
- 单笔止损:-3%
|
||||||
|
- 日内最大亏损:-5%
|
||||||
|
- 不追涨,只接回调`,
|
||||||
|
target_date: '2025-12-23',
|
||||||
|
status: 'active',
|
||||||
|
created_at: '2025-12-23T08:30:00Z',
|
||||||
|
updated_at: '2025-12-23T08:30:00Z',
|
||||||
|
tags: ['日计划', '年末布局'],
|
||||||
|
stocks: ['600519.SH', '300750.SZ', '688256.SH']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 321,
|
||||||
|
user_id: 1,
|
||||||
|
type: 'review',
|
||||||
|
title: '今日交易复盘 - 市场震荡',
|
||||||
|
content: `【操作回顾】
|
||||||
|
1. 上午10:30 在茅台1580元位置加仓0.5%
|
||||||
|
2. 下午14:00 宁德时代触及160元支撑位,建仓1%
|
||||||
|
3. AI算力板块异动,寒武纪涨幅超5%,观望未操作
|
||||||
|
|
||||||
|
【盈亏分析】
|
||||||
|
- 茅台加仓部分:浮盈+0.8%
|
||||||
|
- 宁德时代:浮亏-0.3%(正常波动范围内)
|
||||||
|
- 当日账户变动:+0.15%
|
||||||
|
|
||||||
|
【经验总结】
|
||||||
|
- 茅台买点把握较好,符合预期的回调位置
|
||||||
|
- 宁德时代略显急躁,可以再等一等
|
||||||
|
- AI算力虽然错过涨幅,但不追高的纪律执行到位
|
||||||
|
|
||||||
|
【明日计划】
|
||||||
|
- 继续持有今日新增仓位
|
||||||
|
- 如茅台继续上涨至1620,可考虑获利了结一半
|
||||||
|
- 关注周五PMI数据公布对市场影响`,
|
||||||
|
target_date: '2025-12-23',
|
||||||
|
status: 'completed',
|
||||||
|
created_at: '2025-12-23T15:30:00Z',
|
||||||
|
updated_at: '2025-12-23T16:00:00Z',
|
||||||
|
tags: ['日复盘', '年末交易'],
|
||||||
|
stocks: ['600519.SH', '300750.SZ']
|
||||||
|
},
|
||||||
|
// 额外计划2:测试同日期多计划显示
|
||||||
|
{
|
||||||
|
id: 322,
|
||||||
|
user_id: 1,
|
||||||
|
type: 'plan',
|
||||||
|
title: 'AI算力板块布局',
|
||||||
|
content: `【目标】捕捉AI算力板块机会
|
||||||
|
|
||||||
|
【策略】
|
||||||
|
- 寒武纪:关注突破信号
|
||||||
|
- 中科曙光:服务器龙头
|
||||||
|
- 浪潮信息:算力基础设施`,
|
||||||
|
target_date: '2025-12-23',
|
||||||
|
status: 'pending',
|
||||||
|
created_at: '2025-12-23T09:00:00Z',
|
||||||
|
updated_at: '2025-12-23T09:00:00Z',
|
||||||
|
tags: ['AI', '算力'],
|
||||||
|
stocks: ['688256.SH', '603019.SH', '000977.SZ']
|
||||||
|
},
|
||||||
|
// 额外计划3:测试同日期多计划显示
|
||||||
|
{
|
||||||
|
id: 323,
|
||||||
|
user_id: 1,
|
||||||
|
type: 'plan',
|
||||||
|
title: '医药板块观察计划',
|
||||||
|
content: `【目标】关注创新药投资机会
|
||||||
|
|
||||||
|
【策略】
|
||||||
|
- 恒瑞医药:创新药龙头
|
||||||
|
- 药明康德:CRO龙头`,
|
||||||
|
target_date: '2025-12-23',
|
||||||
|
status: 'pending',
|
||||||
|
created_at: '2025-12-23T10:00:00Z',
|
||||||
|
updated_at: '2025-12-23T10:00:00Z',
|
||||||
|
tags: ['医药', '创新药'],
|
||||||
|
stocks: ['600276.SH', '603259.SH']
|
||||||
|
},
|
||||||
|
// 额外复盘2:测试同日期多复盘显示
|
||||||
|
{
|
||||||
|
id: 324,
|
||||||
|
user_id: 1,
|
||||||
|
type: 'review',
|
||||||
|
title: '半导体操作复盘',
|
||||||
|
content: `【操作回顾】
|
||||||
|
- 中芯国际:持仓未动
|
||||||
|
- 北方华创:观望
|
||||||
|
|
||||||
|
【经验总结】
|
||||||
|
半导体板块整体震荡,等待突破信号`,
|
||||||
|
target_date: '2025-12-23',
|
||||||
|
status: 'completed',
|
||||||
|
created_at: '2025-12-23T16:00:00Z',
|
||||||
|
updated_at: '2025-12-23T16:30:00Z',
|
||||||
|
tags: ['半导体复盘'],
|
||||||
|
stocks: ['688981.SH', '002371.SZ']
|
||||||
|
},
|
||||||
|
// 额外复盘3:测试同日期多复盘显示
|
||||||
|
{
|
||||||
|
id: 325,
|
||||||
|
user_id: 1,
|
||||||
|
type: 'review',
|
||||||
|
title: '本周白酒持仓复盘',
|
||||||
|
content: `【操作回顾】
|
||||||
|
- 茅台:本周加仓0.5%
|
||||||
|
- 五粮液:持仓未动
|
||||||
|
|
||||||
|
【盈亏分析】
|
||||||
|
白酒板块本周表现平稳,继续持有`,
|
||||||
|
target_date: '2025-12-23',
|
||||||
|
status: 'completed',
|
||||||
|
created_at: '2025-12-23T17:00:00Z',
|
||||||
|
updated_at: '2025-12-23T17:30:00Z',
|
||||||
|
tags: ['白酒复盘', '周复盘'],
|
||||||
|
stocks: ['600519.SH', '000858.SZ']
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -1101,6 +1239,87 @@ export const mockCalendarEvents = [
|
|||||||
is_recurring: true,
|
is_recurring: true,
|
||||||
recurrence_rule: 'weekly',
|
recurrence_rule: 'weekly',
|
||||||
created_at: '2025-01-01T10:00:00Z'
|
created_at: '2025-01-01T10:00:00Z'
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==================== 今日事件(2025-12-23) ====================
|
||||||
|
{
|
||||||
|
id: 409,
|
||||||
|
user_id: 1,
|
||||||
|
title: '比亚迪全球发布会',
|
||||||
|
date: '2025-12-23',
|
||||||
|
event_date: '2025-12-23',
|
||||||
|
type: 'earnings',
|
||||||
|
category: 'company_event',
|
||||||
|
description: `比亚迪将于今日14:00召开全球发布会,预计发布新一代刀片电池技术和2026年新车规划。
|
||||||
|
|
||||||
|
重点关注:
|
||||||
|
1. 刀片电池2.0技术参数:能量密度提升预期
|
||||||
|
2. 2026年新车型规划:高端品牌仰望系列
|
||||||
|
3. 海外市场扩张计划:欧洲建厂进度
|
||||||
|
4. 年度交付量预告
|
||||||
|
|
||||||
|
投资建议:
|
||||||
|
- 关注发布会后股价走势
|
||||||
|
- 若技术突破超预期,可考虑加仓
|
||||||
|
- 设置止损位:当前价-5%`,
|
||||||
|
stock_code: '002594.SZ',
|
||||||
|
stock_name: '比亚迪',
|
||||||
|
importance: 5,
|
||||||
|
source: 'future',
|
||||||
|
stocks: ['002594.SZ', '300750.SZ', '601238.SH'],
|
||||||
|
created_at: '2025-12-20T10:00:00Z'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 410,
|
||||||
|
user_id: 1,
|
||||||
|
title: '12月LPR报价公布',
|
||||||
|
date: '2025-12-23',
|
||||||
|
event_date: '2025-12-23',
|
||||||
|
type: 'policy',
|
||||||
|
category: 'macro_policy',
|
||||||
|
description: `中国人民银行将于今日9:30公布12月贷款市场报价利率(LPR)。
|
||||||
|
|
||||||
|
市场预期:
|
||||||
|
- 1年期LPR:3.10%(维持不变)
|
||||||
|
- 5年期以上LPR:3.60%(维持不变)
|
||||||
|
|
||||||
|
影响板块:
|
||||||
|
1. 银行板块:利差压力关注
|
||||||
|
2. 房地产:按揭成本影响
|
||||||
|
3. 基建:融资成本变化
|
||||||
|
|
||||||
|
投资策略:
|
||||||
|
- 若降息,利好成长股,可加仓科技板块
|
||||||
|
- 若维持,银行股防守价值凸显`,
|
||||||
|
importance: 5,
|
||||||
|
source: 'future',
|
||||||
|
stocks: ['601398.SH', '600036.SH', '000001.SZ'],
|
||||||
|
created_at: '2025-12-20T08:00:00Z'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 411,
|
||||||
|
user_id: 1,
|
||||||
|
title: 'A股年末交易策略会议',
|
||||||
|
date: '2025-12-23',
|
||||||
|
event_date: '2025-12-23',
|
||||||
|
type: 'reminder',
|
||||||
|
category: 'personal',
|
||||||
|
description: `个人备忘:年末交易策略规划
|
||||||
|
|
||||||
|
待办事项:
|
||||||
|
1. 回顾2025年度投资收益
|
||||||
|
2. 分析持仓股票基本面变化
|
||||||
|
3. 制定2026年Q1布局计划
|
||||||
|
4. 检查止盈止损纪律执行情况
|
||||||
|
|
||||||
|
重点关注:
|
||||||
|
- 白酒板块持仓是否需要调整
|
||||||
|
- 新能源板块估值是否合理
|
||||||
|
- 是否需要增加防守性配置`,
|
||||||
|
importance: 3,
|
||||||
|
source: 'user',
|
||||||
|
stocks: [],
|
||||||
|
created_at: '2025-12-22T20:00:00Z'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1142,6 +1142,138 @@ function generateTransmissionChain(industry, index) {
|
|||||||
return { nodes, edges };
|
return { nodes, edges };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 主线事件标题模板 - 确保生成的事件能够匹配主线定义的关键词
|
||||||
|
// 每个主线定义至少有 2-3 个事件模板,确保数据充足
|
||||||
|
const mainlineEventTemplates = [
|
||||||
|
// ==================== TMT (科技/媒体/通信) ====================
|
||||||
|
|
||||||
|
// AI基础设施 - 算力/芯片 (L3_AI_CHIP)
|
||||||
|
{ title: '英伟达发布新一代GPU,AI算力大幅提升', keywords: ['算力', 'AI芯片', 'GPU', '英伟达'], industry: '人工智能' },
|
||||||
|
{ title: '华为昇腾芯片出货量创新高,国产AI算力加速', keywords: ['华为昇腾', 'AI芯片', '算力'], industry: '人工智能' },
|
||||||
|
{ title: '寒武纪发布新一代AI训练芯片,性能提升50%', keywords: ['寒武纪', 'AI芯片', '算力'], industry: '人工智能' },
|
||||||
|
{ title: 'AI芯片需求激增,GPU供不应求价格上涨', keywords: ['AI芯片', 'GPU', '算力'], industry: '人工智能' },
|
||||||
|
|
||||||
|
// AI基础设施 - 服务器与数据中心 (L3_AI_SERVER)
|
||||||
|
{ title: '智算中心建设加速,多地启动算力基础设施项目', keywords: ['智算中心', '算力', '数据中心'], industry: '人工智能' },
|
||||||
|
{ title: '液冷技术成数据中心标配,服务器散热升级', keywords: ['液冷', '数据中心', '服务器'], industry: '人工智能' },
|
||||||
|
{ title: 'AI服务器订单暴增,数据中心扩容需求旺盛', keywords: ['服务器', '数据中心', '智算中心'], industry: '人工智能' },
|
||||||
|
|
||||||
|
// AI基础设施 - 光通信 (L3_OPTICAL)
|
||||||
|
{ title: 'CPO技术迎来突破,光模块成本大幅下降', keywords: ['CPO', '光模块', '光通信'], industry: '通信' },
|
||||||
|
{ title: '800G光模块量产加速,AI训练网络升级', keywords: ['光模块', '光通信', '光芯片'], industry: '通信' },
|
||||||
|
{ title: '光芯片技术突破,CPO方案渗透率提升', keywords: ['光芯片', 'CPO', '光通信', '光模块'], industry: '通信' },
|
||||||
|
|
||||||
|
// AI基础设施 - PCB与封装 (L3_PCB)
|
||||||
|
{ title: 'AI PCB需求激增,高多层板产能紧张', keywords: ['PCB', 'AI PCB', '封装'], industry: '电子' },
|
||||||
|
{ title: '先进封装技术突破,PCB产业链升级', keywords: ['封装', 'PCB'], industry: '电子' },
|
||||||
|
|
||||||
|
// AI应用与大模型 (L3_AI_APP)
|
||||||
|
{ title: 'DeepSeek发布最新大模型,推理能力超越GPT-4', keywords: ['DeepSeek', '大模型', 'AI', '人工智能'], industry: '人工智能' },
|
||||||
|
{ title: 'KIMI月活突破1亿,国产大模型竞争白热化', keywords: ['KIMI', '大模型', 'AI', '人工智能'], industry: '人工智能' },
|
||||||
|
{ title: 'ChatGPT新版本发布,AI Agent智能体能力升级', keywords: ['ChatGPT', '大模型', '智能体', 'AI'], industry: '人工智能' },
|
||||||
|
{ title: '人工智能+医疗深度融合,AI辅助诊断准确率超90%', keywords: ['人工智能', 'AI', '医疗'], industry: '人工智能' },
|
||||||
|
{ title: '多模态大模型技术突破,AI应用场景扩展', keywords: ['大模型', 'AI', '人工智能'], industry: '人工智能' },
|
||||||
|
|
||||||
|
// 半导体 - 芯片设计 (L3_CHIP_DESIGN)
|
||||||
|
{ title: '芯片设计企业扩产,IC设计产能大幅提升', keywords: ['芯片设计', 'IC设计', '半导体'], industry: '半导体' },
|
||||||
|
{ title: '国产芯片设计工具取得突破,EDA软件自主可控', keywords: ['芯片设计', 'IC设计', '半导体'], industry: '半导体' },
|
||||||
|
|
||||||
|
// 半导体 - 芯片制造 (L3_CHIP_MFG)
|
||||||
|
{ title: '中芯国际N+1工艺量产,芯片制造技术再突破', keywords: ['中芯国际', '芯片制造', '晶圆'], industry: '半导体' },
|
||||||
|
{ title: '国产光刻机实现技术突破,半导体设备自主可控', keywords: ['光刻', '半导体', '芯片制造'], industry: '半导体' },
|
||||||
|
{ title: '晶圆产能利用率回升,半导体行业景气度上行', keywords: ['晶圆', '半导体', '芯片制造'], industry: '半导体' },
|
||||||
|
|
||||||
|
// 机器人 - 人形机器人 (L3_HUMANOID)
|
||||||
|
{ title: '特斯拉人形机器人Optimus量产提速,成本降至2万美元', keywords: ['人形机器人', '特斯拉机器人', '具身智能'], industry: '人工智能' },
|
||||||
|
{ title: '具身智能迎来突破,人形机器人商业化加速', keywords: ['具身智能', '人形机器人', '机器人'], industry: '人工智能' },
|
||||||
|
{ title: '人形机器人产业链爆发,核心零部件需求激增', keywords: ['人形机器人', '具身智能'], industry: '人工智能' },
|
||||||
|
|
||||||
|
// 机器人 - 工业机器人 (L3_INDUSTRIAL_ROBOT)
|
||||||
|
{ title: '工业机器人出货量同比增长30%,自动化渗透率提升', keywords: ['工业机器人', '自动化', '机器人'], industry: '机械' },
|
||||||
|
{ title: '智能制造升级,机器人自动化需求持续增长', keywords: ['机器人', '自动化', '工业机器人'], industry: '机械' },
|
||||||
|
|
||||||
|
// 消费电子 - 智能手机 (L3_MOBILE)
|
||||||
|
{ title: '华为Mate系列销量火爆,折叠屏手机市场爆发', keywords: ['华为', '手机', '折叠屏'], industry: '消费电子' },
|
||||||
|
{ title: '小米可穿戴设备出货量全球第一,智能手表市场扩张', keywords: ['小米', '可穿戴', '手机'], industry: '消费电子' },
|
||||||
|
{ title: '手机市场复苏,5G手机换机潮来临', keywords: ['手机', '华为', '小米'], industry: '消费电子' },
|
||||||
|
|
||||||
|
// 消费电子 - XR与可穿戴 (L3_XR)
|
||||||
|
{ title: '苹果Vision Pro销量不及预期,XR设备面临挑战', keywords: ['苹果', 'Vision Pro', 'XR', 'MR'], industry: '消费电子' },
|
||||||
|
{ title: 'AR眼镜成新风口,VR/AR设备渗透率提升', keywords: ['AR', 'VR', 'XR'], industry: '消费电子' },
|
||||||
|
{ title: 'Meta发布新一代VR头显,XR市场竞争加剧', keywords: ['VR', 'XR', 'MR', '可穿戴'], industry: '消费电子' },
|
||||||
|
|
||||||
|
// 通信 - 5G/6G通信 (L3_5G)
|
||||||
|
{ title: '5G基站建设加速,运营商资本开支超预期', keywords: ['5G', '基站', '通信'], industry: '通信' },
|
||||||
|
{ title: '6G技术标准制定启动,下一代通信网络布局', keywords: ['6G', '5G', '通信'], industry: '通信' },
|
||||||
|
|
||||||
|
// 通信 - 云计算与软件 (L3_CLOUD)
|
||||||
|
{ title: '云计算市场规模突破万亿,SaaS企业业绩增长', keywords: ['云计算', 'SaaS', '软件', '互联网'], industry: '互联网' },
|
||||||
|
{ title: '数字化转型加速,企业级软件需求旺盛', keywords: ['数字化', '软件', '互联网'], industry: '互联网' },
|
||||||
|
{ title: '国产软件替代加速,信创产业迎来发展机遇', keywords: ['软件', '数字化', '互联网'], industry: '互联网' },
|
||||||
|
|
||||||
|
// ==================== 新能源与智能汽车 ====================
|
||||||
|
|
||||||
|
// 新能源 - 光伏 (L3_PV)
|
||||||
|
{ title: '光伏装机量创新高,太阳能发电成本持续下降', keywords: ['光伏', '太阳能', '硅片', '组件'], industry: '新能源' },
|
||||||
|
{ title: '光伏组件价格企稳,行业出清接近尾声', keywords: ['光伏', '组件', '硅片'], industry: '新能源' },
|
||||||
|
{ title: '分布式光伏爆发,太阳能产业链受益', keywords: ['光伏', '太阳能'], industry: '新能源' },
|
||||||
|
|
||||||
|
// 新能源 - 储能与电池 (L3_STORAGE)
|
||||||
|
{ title: '储能市场爆发式增长,电池需求大幅提升', keywords: ['储能', '电池', '锂电', '新能源'], industry: '新能源' },
|
||||||
|
{ title: '固态电池技术突破,新能源汽车续航大幅提升', keywords: ['固态电池', '电池', '锂电', '新能源'], industry: '新能源' },
|
||||||
|
{ title: '钠电池产业化加速,储能成本有望大幅下降', keywords: ['电池', '储能', '新能源'], industry: '新能源' },
|
||||||
|
|
||||||
|
// 智能汽车 - 新能源整车 (L3_EV_OEM)
|
||||||
|
{ title: '比亚迪月销量突破50万辆,新能源汽车市占率第一', keywords: ['比亚迪', '新能源汽车', '电动车'], industry: '新能源' },
|
||||||
|
{ title: '新能源整车出口创新高,中国汽车品牌走向全球', keywords: ['新能源汽车', '整车', '电动车'], industry: '新能源' },
|
||||||
|
{ title: '电动车价格战持续,新能源汽车渗透率突破50%', keywords: ['电动车', '新能源汽车', '整车'], industry: '新能源' },
|
||||||
|
|
||||||
|
// 智能汽车 - 智能驾驶 (L3_AUTO_DRIVE)
|
||||||
|
{ title: '特斯拉FSD自动驾驶进入中国市场,智能驾驶加速', keywords: ['特斯拉', '自动驾驶', '智能驾驶', '智能网联'], industry: '新能源' },
|
||||||
|
{ title: '车路协同试点扩大,智能网联汽车基建提速', keywords: ['车路协同', '智能网联', '智能驾驶'], industry: '新能源' },
|
||||||
|
{ title: 'L3级自动驾驶获批,智能驾驶产业化加速', keywords: ['自动驾驶', '智能驾驶', '智能网联'], industry: '新能源' },
|
||||||
|
|
||||||
|
// ==================== 先进制造 ====================
|
||||||
|
|
||||||
|
// 低空经济 - 无人机 (L3_DRONE)
|
||||||
|
{ title: '低空经济政策密集出台,无人机产业迎来风口', keywords: ['低空', '无人机', '空域'], industry: '航空' },
|
||||||
|
{ title: '无人机应用场景拓展,低空经济市场规模扩大', keywords: ['无人机', '低空', '空域'], industry: '航空' },
|
||||||
|
|
||||||
|
// 低空经济 - eVTOL (L3_EVTOL)
|
||||||
|
{ title: 'eVTOL飞行汽车完成首飞,空中出租车商业化在即', keywords: ['eVTOL', '飞行汽车', '空中出租车'], industry: '航空' },
|
||||||
|
{ title: '飞行汽车获适航认证,eVTOL商业运营启动', keywords: ['飞行汽车', 'eVTOL', '空中出租车'], industry: '航空' },
|
||||||
|
|
||||||
|
// 军工 - 航空航天 (L3_AEROSPACE)
|
||||||
|
{ title: '商业航天发射成功,卫星互联网建设加速', keywords: ['航天', '卫星', '火箭'], industry: '军工' },
|
||||||
|
{ title: '航空发动机国产化取得突破,航空产业链升级', keywords: ['航空', '军工'], industry: '军工' },
|
||||||
|
{ title: '卫星通信需求爆发,航天发射频次创新高', keywords: ['卫星', '航天', '火箭'], industry: '军工' },
|
||||||
|
|
||||||
|
// 军工 - 国防军工 (L3_DEFENSE)
|
||||||
|
{ title: '军工订单饱满,国防装备现代化提速', keywords: ['军工', '国防', '军工装备'], industry: '军工' },
|
||||||
|
{ title: '国防预算增长,军工装备需求持续提升', keywords: ['国防', '军工', '军工装备', '导弹'], industry: '军工' },
|
||||||
|
|
||||||
|
// ==================== 医药健康 ====================
|
||||||
|
|
||||||
|
// 医药 - 创新药 (L3_DRUG)
|
||||||
|
{ title: '创新药获批上市,医药板块迎来业绩兑现', keywords: ['创新药', '医药', '生物'], industry: '医药' },
|
||||||
|
{ title: 'CXO订单持续增长,医药研发外包景气度高', keywords: ['CXO', '医药', '生物'], industry: '医药' },
|
||||||
|
{ title: '生物医药融资回暖,创新药研发管线丰富', keywords: ['医药', '生物', '创新药'], industry: '医药' },
|
||||||
|
|
||||||
|
// 医药 - 医疗器械 (L3_DEVICE)
|
||||||
|
{ title: '医疗器械集采扩面,国产替代加速', keywords: ['医疗器械', '医疗', '器械'], industry: '医药' },
|
||||||
|
{ title: '高端医疗设备国产化突破,医疗器械出口增长', keywords: ['医疗器械', '医疗', '器械'], industry: '医药' },
|
||||||
|
|
||||||
|
// ==================== 金融 ====================
|
||||||
|
|
||||||
|
// 金融 - 银行 (L3_BANK)
|
||||||
|
{ title: '银行净息差企稳,金融板块估值修复', keywords: ['银行', '金融'], industry: '金融' },
|
||||||
|
{ title: '银行业绩超预期,金融股迎来价值重估', keywords: ['银行', '金融'], industry: '金融' },
|
||||||
|
|
||||||
|
// 金融 - 券商 (L3_BROKER)
|
||||||
|
{ title: '券商业绩大幅增长,证券板块活跃', keywords: ['券商', '证券', '金融'], industry: '金融' },
|
||||||
|
{ title: 'A股成交量放大,证券行业业绩弹性显现', keywords: ['证券', '券商', '金融'], industry: '金融' },
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成动态新闻事件(实时要闻·动态追踪专用)
|
* 生成动态新闻事件(实时要闻·动态追踪专用)
|
||||||
* @param {Object} timeRange - 时间范围 { startTime, endTime }
|
* @param {Object} timeRange - 时间范围 { startTime, endTime }
|
||||||
@@ -1166,7 +1298,10 @@ export function generateDynamicNewsEvents(timeRange = null, count = 30) {
|
|||||||
const timeSpan = endTime.getTime() - startTime.getTime();
|
const timeSpan = endTime.getTime() - startTime.getTime();
|
||||||
|
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
const industry = industries[i % industries.length];
|
// 使用主线事件模板生成事件,确保能匹配主线关键词
|
||||||
|
const templateIndex = i % mainlineEventTemplates.length;
|
||||||
|
const template = mainlineEventTemplates[templateIndex];
|
||||||
|
const industry = template.industry;
|
||||||
const imp = importanceLevels[i % importanceLevels.length];
|
const imp = importanceLevels[i % importanceLevels.length];
|
||||||
const eventType = eventTypes[i % eventTypes.length];
|
const eventType = eventTypes[i % eventTypes.length];
|
||||||
|
|
||||||
@@ -1217,11 +1352,33 @@ export function generateDynamicNewsEvents(timeRange = null, count = 30) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 使用模板标题,并生成包含模板关键词的 keywords 数组
|
||||||
|
const eventTitle = template.title;
|
||||||
|
const eventDescription = generateEventDescription(industry, imp, i);
|
||||||
|
|
||||||
|
// 生成关键词对象数组,包含模板中的关键词
|
||||||
|
const templateKeywords = template.keywords.map((kw, idx) => ({
|
||||||
|
concept: kw,
|
||||||
|
stock_count: 10 + Math.floor(Math.random() * 20),
|
||||||
|
score: parseFloat((0.7 + Math.random() * 0.25).toFixed(2)),
|
||||||
|
description: `${kw}相关概念,市场关注度较高`,
|
||||||
|
price_info: {
|
||||||
|
avg_change_pct: parseFloat((Math.random() * 15 - 5).toFixed(2))
|
||||||
|
},
|
||||||
|
match_type: ['hybrid_knn', 'keyword', 'semantic'][idx % 3]
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 合并模板关键词和行业关键词
|
||||||
|
const mergedKeywords = [
|
||||||
|
...templateKeywords,
|
||||||
|
...generateKeywords(industry, i).slice(0, 2)
|
||||||
|
];
|
||||||
|
|
||||||
events.push({
|
events.push({
|
||||||
id: `dynamic_${i + 1}`,
|
id: `dynamic_${i + 1}`,
|
||||||
title: generateEventTitle(industry, i),
|
title: eventTitle,
|
||||||
description: generateEventDescription(industry, imp, i),
|
description: eventDescription,
|
||||||
content: generateEventDescription(industry, imp, i),
|
content: eventDescription,
|
||||||
event_type: eventType,
|
event_type: eventType,
|
||||||
importance: imp,
|
importance: imp,
|
||||||
status: 'published',
|
status: 'published',
|
||||||
@@ -1234,7 +1391,9 @@ export function generateDynamicNewsEvents(timeRange = null, count = 30) {
|
|||||||
related_avg_chg: parseFloat(relatedAvgChg),
|
related_avg_chg: parseFloat(relatedAvgChg),
|
||||||
related_max_chg: parseFloat(relatedMaxChg),
|
related_max_chg: parseFloat(relatedMaxChg),
|
||||||
related_week_chg: parseFloat(relatedWeekChg),
|
related_week_chg: parseFloat(relatedWeekChg),
|
||||||
keywords: generateKeywords(industry, i),
|
expectation_surprise_score: Math.floor(Math.random() * 60) + 30, // 30-90 超预期得分
|
||||||
|
keywords: mergedKeywords,
|
||||||
|
related_concepts: mergedKeywords, // 添加 related_concepts 字段,兼容主线匹配逻辑
|
||||||
is_ai_generated: i % 3 === 0, // 33% 的事件是AI生成
|
is_ai_generated: i % 3 === 0, // 33% 的事件是AI生成
|
||||||
industry: industry,
|
industry: industry,
|
||||||
related_stocks: relatedStocks,
|
related_stocks: relatedStocks,
|
||||||
|
|||||||
@@ -55,41 +55,77 @@ export const generateMarketData = (stockCode) => {
|
|||||||
repay: Math.floor(Math.random() * 500000000) + 80000000 // 融资偿还
|
repay: Math.floor(Math.random() * 500000000) + 80000000 // 融资偿还
|
||||||
},
|
},
|
||||||
securities: {
|
securities: {
|
||||||
balance: Math.floor(Math.random() * 100000000) + 50000000, // 融券余额
|
balance: Math.floor(Math.random() * 100000000) + 50000000, // 融券余额(股数)
|
||||||
|
balance_amount: Math.floor(Math.random() * 2000000000) + 1000000000, // 融券余额(金额)
|
||||||
sell: Math.floor(Math.random() * 10000000) + 5000000, // 融券卖出
|
sell: Math.floor(Math.random() * 10000000) + 5000000, // 融券卖出
|
||||||
repay: Math.floor(Math.random() * 10000000) + 3000000 // 融券偿还
|
repay: Math.floor(Math.random() * 10000000) + 3000000 // 融券偿还
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
},
|
},
|
||||||
|
|
||||||
// 大单统计 - 包含 daily_stats 数组
|
// 大宗交易 - 包含 daily_stats 数组,符合 BigDealDayStats 类型
|
||||||
bigDealData: {
|
bigDealData: {
|
||||||
success: true,
|
success: true,
|
||||||
data: [],
|
data: [],
|
||||||
daily_stats: Array(10).fill(null).map((_, i) => ({
|
daily_stats: Array(10).fill(null).map((_, i) => {
|
||||||
|
const count = Math.floor(Math.random() * 5) + 1; // 1-5 笔交易
|
||||||
|
const avgPrice = parseFloat((basePrice * (0.95 + Math.random() * 0.1)).toFixed(2)); // 折价/溢价 -5%~+5%
|
||||||
|
const deals = Array(count).fill(null).map(() => {
|
||||||
|
const volume = parseFloat((Math.random() * 500 + 100).toFixed(2)); // 100-600 万股
|
||||||
|
const price = parseFloat((avgPrice * (0.98 + Math.random() * 0.04)).toFixed(2));
|
||||||
|
return {
|
||||||
|
buyer_dept: ['中信证券北京总部', '国泰君安上海分公司', '华泰证券深圳营业部', '招商证券广州分公司'][Math.floor(Math.random() * 4)],
|
||||||
|
seller_dept: ['中金公司北京营业部', '海通证券上海分公司', '广发证券深圳营业部', '平安证券广州分公司'][Math.floor(Math.random() * 4)],
|
||||||
|
price,
|
||||||
|
volume,
|
||||||
|
amount: parseFloat((price * volume).toFixed(2))
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const totalVolume = deals.reduce((sum, d) => sum + d.volume, 0);
|
||||||
|
const totalAmount = deals.reduce((sum, d) => sum + d.amount, 0);
|
||||||
|
return {
|
||||||
date: new Date(Date.now() - (9 - i) * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
|
date: new Date(Date.now() - (9 - i) * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
|
||||||
big_buy: Math.floor(Math.random() * 300000000) + 100000000,
|
count,
|
||||||
big_sell: Math.floor(Math.random() * 300000000) + 80000000,
|
total_volume: parseFloat(totalVolume.toFixed(2)),
|
||||||
medium_buy: Math.floor(Math.random() * 200000000) + 60000000,
|
total_amount: parseFloat(totalAmount.toFixed(2)),
|
||||||
medium_sell: Math.floor(Math.random() * 200000000) + 50000000,
|
avg_price: avgPrice,
|
||||||
small_buy: Math.floor(Math.random() * 100000000) + 30000000,
|
deals
|
||||||
small_sell: Math.floor(Math.random() * 100000000) + 25000000
|
};
|
||||||
}))
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
// 异动分析 - 包含 grouped_data 数组
|
// 龙虎榜数据 - 包含 grouped_data 数组,符合 UnusualDayData 类型
|
||||||
unusualData: {
|
unusualData: {
|
||||||
success: true,
|
success: true,
|
||||||
data: [],
|
data: [],
|
||||||
grouped_data: Array(5).fill(null).map((_, i) => ({
|
grouped_data: Array(5).fill(null).map((_, i) => {
|
||||||
|
const buyerDepts = ['中信证券北京总部', '国泰君安上海分公司', '华泰证券深圳营业部', '招商证券广州分公司', '中金公司北京营业部'];
|
||||||
|
const sellerDepts = ['海通证券上海分公司', '广发证券深圳营业部', '平安证券广州分公司', '东方证券上海营业部', '兴业证券福州营业部'];
|
||||||
|
const infoTypes = ['日涨幅偏离值达7%', '日振幅达15%', '连续三日涨幅偏离20%', '换手率达20%'];
|
||||||
|
|
||||||
|
const buyers = buyerDepts.map(dept => ({
|
||||||
|
dept_name: dept,
|
||||||
|
buy_amount: Math.floor(Math.random() * 50000000) + 10000000 // 1000万-6000万
|
||||||
|
})).sort((a, b) => b.buy_amount - a.buy_amount);
|
||||||
|
|
||||||
|
const sellers = sellerDepts.map(dept => ({
|
||||||
|
dept_name: dept,
|
||||||
|
sell_amount: Math.floor(Math.random() * 40000000) + 8000000 // 800万-4800万
|
||||||
|
})).sort((a, b) => b.sell_amount - a.sell_amount);
|
||||||
|
|
||||||
|
const totalBuy = buyers.reduce((sum, b) => sum + b.buy_amount, 0);
|
||||||
|
const totalSell = sellers.reduce((sum, s) => sum + s.sell_amount, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
date: new Date(Date.now() - (4 - i) * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
|
date: new Date(Date.now() - (4 - i) * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
|
||||||
events: [
|
total_buy: totalBuy,
|
||||||
{ time: '14:35:22', type: '快速拉升', change: '+2.3%', description: '5分钟内上涨2.3%' },
|
total_sell: totalSell,
|
||||||
{ time: '11:28:45', type: '大单买入', amount: '5680万', description: '单笔大单买入' },
|
net_amount: totalBuy - totalSell,
|
||||||
{ time: '10:15:30', type: '量比异动', ratio: '3.2', description: '量比达到3.2倍' }
|
buyers,
|
||||||
],
|
sellers,
|
||||||
count: 3
|
info_types: infoTypes.slice(0, Math.floor(Math.random() * 3) + 1) // 随机选1-3个类型
|
||||||
}))
|
};
|
||||||
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
// 股权质押 - 匹配 PledgeData[] 类型
|
// 股权质押 - 匹配 PledgeData[] 类型
|
||||||
|
|||||||
@@ -351,15 +351,21 @@ export const accountHandlers = [
|
|||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
console.log('[Mock] 创建投资计划:', body);
|
console.log('[Mock] 创建投资计划:', body);
|
||||||
|
|
||||||
|
// 生成唯一 ID(使用时间戳避免冲突)
|
||||||
|
const newId = Date.now();
|
||||||
|
|
||||||
const newPlan = {
|
const newPlan = {
|
||||||
id: mockInvestmentPlans.length + 301,
|
id: newId,
|
||||||
user_id: currentUser.id,
|
user_id: currentUser.id,
|
||||||
...body,
|
...body,
|
||||||
|
// 确保 target_date 字段存在(兼容前端发送的 date 字段)
|
||||||
|
target_date: body.target_date || body.date,
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
updated_at: new Date().toISOString()
|
updated_at: new Date().toISOString()
|
||||||
};
|
};
|
||||||
|
|
||||||
mockInvestmentPlans.push(newPlan);
|
mockInvestmentPlans.push(newPlan);
|
||||||
|
console.log('[Mock] 新增计划/复盘,当前总数:', mockInvestmentPlans.length);
|
||||||
|
|
||||||
return HttpResponse.json({
|
return HttpResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -488,13 +494,29 @@ export const accountHandlers = [
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 5. 按日期倒序排序(最新的在前面)
|
||||||
|
filteredEvents.sort((a, b) => {
|
||||||
|
const dateA = new Date(a.date || a.event_date);
|
||||||
|
const dateB = new Date(b.date || b.event_date);
|
||||||
|
return dateB - dateA; // 倒序:新日期在前
|
||||||
|
});
|
||||||
|
|
||||||
|
// 打印今天的事件(方便调试)
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
const todayEvents = filteredEvents.filter(e =>
|
||||||
|
(e.date === today || e.event_date === today)
|
||||||
|
);
|
||||||
|
|
||||||
console.log('[Mock] 日历事件详情:', {
|
console.log('[Mock] 日历事件详情:', {
|
||||||
currentUserId: currentUser.id,
|
currentUserId: currentUser.id,
|
||||||
calendarEvents: calendarEvents.length,
|
calendarEvents: calendarEvents.length,
|
||||||
investmentPlansAsEvents: investmentPlansAsEvents.length,
|
investmentPlansAsEvents: investmentPlansAsEvents.length,
|
||||||
total: filteredEvents.length,
|
total: filteredEvents.length,
|
||||||
plansCount: filteredEvents.filter(e => e.type === 'plan').length,
|
plansCount: filteredEvents.filter(e => e.type === 'plan').length,
|
||||||
reviewsCount: filteredEvents.filter(e => e.type === 'review').length
|
reviewsCount: filteredEvents.filter(e => e.type === 'review').length,
|
||||||
|
today,
|
||||||
|
todayEventsCount: todayEvents.length,
|
||||||
|
todayEventTitles: todayEvents.map(e => `[${e.type}] ${e.title}`)
|
||||||
});
|
});
|
||||||
|
|
||||||
return HttpResponse.json({
|
return HttpResponse.json({
|
||||||
|
|||||||
@@ -257,6 +257,159 @@ export const eventHandlers = [
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// ==================== 主线模式相关(必须在 :eventId 之前,否则会被通配符匹配)====================
|
||||||
|
|
||||||
|
// 获取按主线(lv1/lv2/lv3概念)分组的事件列表
|
||||||
|
http.get('/api/events/mainline', async ({ request }) => {
|
||||||
|
await delay(500);
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const recentDays = parseInt(url.searchParams.get('recent_days') || '7', 10);
|
||||||
|
const importance = url.searchParams.get('importance') || 'all';
|
||||||
|
const limitPerMainline = parseInt(url.searchParams.get('limit') || '20', 10);
|
||||||
|
const groupBy = url.searchParams.get('group_by') || 'lv2';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 生成 mock 事件数据 - 第一个参数是 timeRange(null 表示默认24小时),第二个参数是 count
|
||||||
|
const allEvents = generateDynamicNewsEvents(null, 100);
|
||||||
|
|
||||||
|
const mainlineDefinitions = [
|
||||||
|
{ lv3_id: 'L3_AI_CHIP', lv3_name: 'AI芯片与算力', lv2_id: 'L2_AI_INFRA', lv2_name: 'AI基础设施 (算力/CPO/PCB)', lv1_id: 'L1_TMT', lv1_name: 'TMT (科技/媒体/通信)', keywords: ['算力', 'AI芯片', 'GPU', '英伟达', '华为昇腾', '寒武纪'] },
|
||||||
|
{ lv3_id: 'L3_AI_SERVER', lv3_name: '服务器与数据中心', lv2_id: 'L2_AI_INFRA', lv2_name: 'AI基础设施 (算力/CPO/PCB)', lv1_id: 'L1_TMT', lv1_name: 'TMT (科技/媒体/通信)', keywords: ['服务器', '数据中心', '智算中心', '液冷'] },
|
||||||
|
{ lv3_id: 'L3_OPTICAL', lv3_name: '光通信与CPO', lv2_id: 'L2_AI_INFRA', lv2_name: 'AI基础设施 (算力/CPO/PCB)', lv1_id: 'L1_TMT', lv1_name: 'TMT (科技/媒体/通信)', keywords: ['CPO', '光通信', '光模块', '光芯片'] },
|
||||||
|
{ lv3_id: 'L3_PCB', lv3_name: 'PCB与封装', lv2_id: 'L2_AI_INFRA', lv2_name: 'AI基础设施 (算力/CPO/PCB)', lv1_id: 'L1_TMT', lv1_name: 'TMT (科技/媒体/通信)', keywords: ['PCB', '封装', 'AI PCB'] },
|
||||||
|
{ lv3_id: 'L3_AI_APP', lv3_name: 'AI应用与大模型', lv2_id: 'L2_AI_INFRA', lv2_name: 'AI基础设施 (算力/CPO/PCB)', lv1_id: 'L1_TMT', lv1_name: 'TMT (科技/媒体/通信)', keywords: ['大模型', '智能体', 'AI', '人工智能', 'DeepSeek', 'KIMI', 'ChatGPT'] },
|
||||||
|
{ lv3_id: 'L3_CHIP_DESIGN', lv3_name: '芯片设计', lv2_id: 'L2_SEMICONDUCTOR', lv2_name: '半导体 (设计/制造/封测)', lv1_id: 'L1_TMT', lv1_name: 'TMT (科技/媒体/通信)', keywords: ['芯片设计', '半导体', 'IC设计'] },
|
||||||
|
{ lv3_id: 'L3_CHIP_MFG', lv3_name: '芯片制造', lv2_id: 'L2_SEMICONDUCTOR', lv2_name: '半导体 (设计/制造/封测)', lv1_id: 'L1_TMT', lv1_name: 'TMT (科技/媒体/通信)', keywords: ['晶圆', '光刻', '芯片制造', '中芯国际'] },
|
||||||
|
{ lv3_id: 'L3_HUMANOID', lv3_name: '人形机器人', lv2_id: 'L2_ROBOT', lv2_name: '机器人 (人形机器人/工业机器人)', lv1_id: 'L1_TMT', lv1_name: 'TMT (科技/媒体/通信)', keywords: ['人形机器人', '具身智能', '特斯拉机器人'] },
|
||||||
|
{ lv3_id: 'L3_INDUSTRIAL_ROBOT', lv3_name: '工业机器人', lv2_id: 'L2_ROBOT', lv2_name: '机器人 (人形机器人/工业机器人)', lv1_id: 'L1_TMT', lv1_name: 'TMT (科技/媒体/通信)', keywords: ['工业机器人', '自动化', '机器人'] },
|
||||||
|
{ lv3_id: 'L3_MOBILE', lv3_name: '智能手机', lv2_id: 'L2_CONSUMER_ELEC', lv2_name: '消费电子 (手机/XR/可穿戴)', lv1_id: 'L1_TMT', lv1_name: 'TMT (科技/媒体/通信)', keywords: ['手机', '华为', '苹果', '小米', '折叠屏'] },
|
||||||
|
{ lv3_id: 'L3_XR', lv3_name: 'XR与可穿戴', lv2_id: 'L2_CONSUMER_ELEC', lv2_name: '消费电子 (手机/XR/可穿戴)', lv1_id: 'L1_TMT', lv1_name: 'TMT (科技/媒体/通信)', keywords: ['XR', 'VR', 'AR', '可穿戴', 'MR', 'Vision Pro'] },
|
||||||
|
{ lv3_id: 'L3_5G', lv3_name: '5G/6G通信', lv2_id: 'L2_TELECOM', lv2_name: '通信、互联网与软件', lv1_id: 'L1_TMT', lv1_name: 'TMT (科技/媒体/通信)', keywords: ['5G', '6G', '通信', '基站'] },
|
||||||
|
{ lv3_id: 'L3_CLOUD', lv3_name: '云计算与软件', lv2_id: 'L2_TELECOM', lv2_name: '通信、互联网与软件', lv1_id: 'L1_TMT', lv1_name: 'TMT (科技/媒体/通信)', keywords: ['云计算', '软件', 'SaaS', '互联网', '数字化'] },
|
||||||
|
{ lv3_id: 'L3_PV', lv3_name: '光伏', lv2_id: 'L2_NEW_ENERGY', lv2_name: '新能源 (光伏/储能/电池)', lv1_id: 'L1_NEW_ENERGY_ENV', lv1_name: '新能源与智能汽车', keywords: ['光伏', '太阳能', '硅片', '组件'] },
|
||||||
|
{ lv3_id: 'L3_STORAGE', lv3_name: '储能与电池', lv2_id: 'L2_NEW_ENERGY', lv2_name: '新能源 (光伏/储能/电池)', lv1_id: 'L1_NEW_ENERGY_ENV', lv1_name: '新能源与智能汽车', keywords: ['储能', '电池', '锂电', '固态电池', '新能源'] },
|
||||||
|
{ lv3_id: 'L3_EV_OEM', lv3_name: '新能源整车', lv2_id: 'L2_EV', lv2_name: '智能网联汽车', lv1_id: 'L1_NEW_ENERGY_ENV', lv1_name: '新能源与智能汽车', keywords: ['新能源汽车', '电动车', '比亚迪', '特斯拉', '整车'] },
|
||||||
|
{ lv3_id: 'L3_AUTO_DRIVE', lv3_name: '智能驾驶', lv2_id: 'L2_EV', lv2_name: '智能网联汽车', lv1_id: 'L1_NEW_ENERGY_ENV', lv1_name: '新能源与智能汽车', keywords: ['智能驾驶', '自动驾驶', '智能网联', '车路协同'] },
|
||||||
|
{ lv3_id: 'L3_DRONE', lv3_name: '无人机', lv2_id: 'L2_LOW_ALTITUDE', lv2_name: '低空经济 (无人机/eVTOL)', lv1_id: 'L1_ADVANCED_MFG', lv1_name: '先进制造', keywords: ['无人机', '低空', '空域'] },
|
||||||
|
{ lv3_id: 'L3_EVTOL', lv3_name: 'eVTOL', lv2_id: 'L2_LOW_ALTITUDE', lv2_name: '低空经济 (无人机/eVTOL)', lv1_id: 'L1_ADVANCED_MFG', lv1_name: '先进制造', keywords: ['eVTOL', '飞行汽车', '空中出租车'] },
|
||||||
|
{ lv3_id: 'L3_AEROSPACE', lv3_name: '航空航天', lv2_id: 'L2_MILITARY', lv2_name: '军工 (航空航天/国防)', lv1_id: 'L1_ADVANCED_MFG', lv1_name: '先进制造', keywords: ['航空', '航天', '卫星', '火箭', '军工'] },
|
||||||
|
{ lv3_id: 'L3_DEFENSE', lv3_name: '国防军工', lv2_id: 'L2_MILITARY', lv2_name: '军工 (航空航天/国防)', lv1_id: 'L1_ADVANCED_MFG', lv1_name: '先进制造', keywords: ['国防', '导弹', '军工装备'] },
|
||||||
|
{ lv3_id: 'L3_DRUG', lv3_name: '创新药', lv2_id: 'L2_PHARMA', lv2_name: '医药医疗 (创新药/器械)', lv1_id: 'L1_PHARMA', lv1_name: '医药健康', keywords: ['创新药', '医药', '生物', 'CXO'] },
|
||||||
|
{ lv3_id: 'L3_DEVICE', lv3_name: '医疗器械', lv2_id: 'L2_PHARMA', lv2_name: '医药医疗 (创新药/器械)', lv1_id: 'L1_PHARMA', lv1_name: '医药健康', keywords: ['医疗器械', '医疗', '器械'] },
|
||||||
|
{ lv3_id: 'L3_BANK', lv3_name: '银行', lv2_id: 'L2_FINANCE', lv2_name: '金融 (银行/券商/保险)', lv1_id: 'L1_FINANCE', lv1_name: '金融', keywords: ['银行', '金融'] },
|
||||||
|
{ lv3_id: 'L3_BROKER', lv3_name: '券商', lv2_id: 'L2_FINANCE', lv2_name: '金融 (银行/券商/保险)', lv1_id: 'L1_FINANCE', lv1_name: '金融', keywords: ['券商', '证券'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const hierarchyOptions = {
|
||||||
|
lv1: [...new Map(mainlineDefinitions.map(m => [m.lv1_id, { id: m.lv1_id, name: m.lv1_name }])).values()],
|
||||||
|
lv2: [...new Map(mainlineDefinitions.map(m => [m.lv2_id, { id: m.lv2_id, name: m.lv2_name, lv1_id: m.lv1_id, lv1_name: m.lv1_name }])).values()],
|
||||||
|
lv3: mainlineDefinitions.map(m => ({ id: m.lv3_id, name: m.lv3_name, lv2_id: m.lv2_id, lv2_name: m.lv2_name, lv1_id: m.lv1_id, lv1_name: m.lv1_name })),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mainlineGroups = {};
|
||||||
|
const isSpecificId = groupBy.startsWith('L1_') || groupBy.startsWith('L2_') || groupBy.startsWith('L3_');
|
||||||
|
|
||||||
|
allEvents.forEach(event => {
|
||||||
|
const keywords = event.keywords || event.related_concepts || [];
|
||||||
|
const conceptNames = keywords.map(k => {
|
||||||
|
if (typeof k === 'string') return k;
|
||||||
|
if (typeof k === 'object' && k !== null) return k.concept || k.name || '';
|
||||||
|
return '';
|
||||||
|
}).filter(Boolean).join(' ');
|
||||||
|
const titleAndDesc = `${event.title || ''} ${event.description || ''}`;
|
||||||
|
const textToMatch = `${conceptNames} ${titleAndDesc}`.toLowerCase();
|
||||||
|
|
||||||
|
mainlineDefinitions.forEach(mainline => {
|
||||||
|
const matched = mainline.keywords.some(kw => textToMatch.includes(kw.toLowerCase()));
|
||||||
|
if (matched) {
|
||||||
|
let groupKey, groupData;
|
||||||
|
|
||||||
|
if (isSpecificId) {
|
||||||
|
if (groupBy.startsWith('L1_') && mainline.lv1_id === groupBy) {
|
||||||
|
groupKey = mainline.lv2_id;
|
||||||
|
groupData = { group_id: mainline.lv2_id, group_name: mainline.lv2_name, parent_name: mainline.lv1_name, events: [] };
|
||||||
|
} else if (groupBy.startsWith('L2_') && mainline.lv2_id === groupBy) {
|
||||||
|
groupKey = mainline.lv3_id;
|
||||||
|
groupData = { group_id: mainline.lv3_id, group_name: mainline.lv3_name, parent_name: mainline.lv2_name, grandparent_name: mainline.lv1_name, events: [] };
|
||||||
|
} else if (groupBy.startsWith('L3_') && mainline.lv3_id === groupBy) {
|
||||||
|
groupKey = mainline.lv3_id;
|
||||||
|
groupData = { group_id: mainline.lv3_id, group_name: mainline.lv3_name, parent_name: mainline.lv2_name, grandparent_name: mainline.lv1_name, events: [] };
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (groupBy === 'lv1') {
|
||||||
|
groupKey = mainline.lv1_id;
|
||||||
|
groupData = { group_id: mainline.lv1_id, group_name: mainline.lv1_name, events: [] };
|
||||||
|
} else if (groupBy === 'lv3') {
|
||||||
|
groupKey = mainline.lv3_id;
|
||||||
|
groupData = { group_id: mainline.lv3_id, group_name: mainline.lv3_name, parent_name: mainline.lv2_name, grandparent_name: mainline.lv1_name, events: [] };
|
||||||
|
} else {
|
||||||
|
groupKey = mainline.lv2_id;
|
||||||
|
groupData = { group_id: mainline.lv2_id, group_name: mainline.lv2_name, parent_name: mainline.lv1_name, events: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mainlineGroups[groupKey]) {
|
||||||
|
mainlineGroups[groupKey] = groupData;
|
||||||
|
}
|
||||||
|
if (!mainlineGroups[groupKey].events.find(e => e.id === event.id)) {
|
||||||
|
mainlineGroups[groupKey].events.push(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const generatePriceData = () => parseFloat((Math.random() * 13 - 5).toFixed(2));
|
||||||
|
const priceDataMap = { lv1: {}, lv2: {}, lv3: {} };
|
||||||
|
|
||||||
|
mainlineDefinitions.forEach(def => {
|
||||||
|
if (!priceDataMap.lv1[def.lv1_name]) priceDataMap.lv1[def.lv1_name] = generatePriceData();
|
||||||
|
if (!priceDataMap.lv2[def.lv2_name]) priceDataMap.lv2[def.lv2_name] = generatePriceData();
|
||||||
|
if (!priceDataMap.lv3[def.lv3_name]) priceDataMap.lv3[def.lv3_name] = generatePriceData();
|
||||||
|
});
|
||||||
|
|
||||||
|
const mainlines = Object.values(mainlineGroups)
|
||||||
|
.map(group => {
|
||||||
|
let avgChangePct = null;
|
||||||
|
if (groupBy === 'lv1' || groupBy.startsWith('L1_')) {
|
||||||
|
avgChangePct = groupBy.startsWith('L1_') ? priceDataMap.lv2[group.group_name] : priceDataMap.lv1[group.group_name];
|
||||||
|
} else if (groupBy === 'lv3' || groupBy.startsWith('L2_')) {
|
||||||
|
avgChangePct = priceDataMap.lv3[group.group_name];
|
||||||
|
} else {
|
||||||
|
avgChangePct = priceDataMap.lv2[group.group_name];
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...group,
|
||||||
|
events: group.events.slice(0, limitPerMainline),
|
||||||
|
event_count: Math.min(group.events.length, limitPerMainline),
|
||||||
|
avg_change_pct: avgChangePct ?? null,
|
||||||
|
price_date: new Date().toISOString().split('T')[0]
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(group => group.event_count > 0)
|
||||||
|
.sort((a, b) => b.event_count - a.event_count);
|
||||||
|
|
||||||
|
const groupedEventIds = new Set();
|
||||||
|
mainlines.forEach(m => m.events.forEach(e => groupedEventIds.add(e.id)));
|
||||||
|
const ungroupedCount = allEvents.filter(e => !groupedEventIds.has(e.id)).length;
|
||||||
|
|
||||||
|
return HttpResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
mainlines,
|
||||||
|
total_events: allEvents.length,
|
||||||
|
mainline_count: mainlines.length,
|
||||||
|
ungrouped_count: ungroupedCount,
|
||||||
|
group_by: groupBy,
|
||||||
|
hierarchy_options: hierarchyOptions,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Mock Event] 主线数据获取失败:', error);
|
||||||
|
return HttpResponse.json({ success: false, error: error.message || '获取主线数据失败' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
// ==================== 事件详情相关 ====================
|
// ==================== 事件详情相关 ====================
|
||||||
|
|
||||||
// 获取事件详情
|
// 获取事件详情
|
||||||
@@ -1585,187 +1738,4 @@ export const eventHandlers = [
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// ==================== 主线模式相关 ====================
|
|
||||||
|
|
||||||
// 获取按主线(lv1/lv2/lv3概念)分组的事件列表
|
|
||||||
http.get('/api/events/mainline', async ({ request }) => {
|
|
||||||
await delay(500);
|
|
||||||
|
|
||||||
const url = new URL(request.url);
|
|
||||||
const recentDays = parseInt(url.searchParams.get('recent_days') || '7', 10);
|
|
||||||
const importance = url.searchParams.get('importance') || 'all';
|
|
||||||
const limitPerMainline = parseInt(url.searchParams.get('limit') || '20', 10);
|
|
||||||
// 分组方式: 'lv1' | 'lv2' | 'lv3' | 具体的概念ID(如 'L1_TMT', 'L2_AI_INFRA', 'L3_AI_CHIP')
|
|
||||||
const groupBy = url.searchParams.get('group_by') || 'lv2';
|
|
||||||
|
|
||||||
console.log('[Mock Event] 获取主线数据:', { recentDays, importance, limitPerMainline, groupBy });
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 生成 mock 事件数据
|
|
||||||
const allEvents = generateDynamicNewsEvents(100);
|
|
||||||
|
|
||||||
// 定义完整的 lv1 -> lv2 -> lv3 层级结构
|
|
||||||
const mainlineDefinitions = [
|
|
||||||
{ lv3_id: 'L3_AI_CHIP', lv3_name: 'AI芯片与算力', lv2_id: 'L2_AI_INFRA', lv2_name: 'AI基础设施 (算力/CPO/PCB)', lv1_id: 'L1_TMT', lv1_name: 'TMT (科技/媒体/通信)', keywords: ['算力', 'AI芯片', 'GPU', '英伟达', '华为昇腾', '寒武纪'] },
|
|
||||||
{ lv3_id: 'L3_AI_SERVER', lv3_name: '服务器与数据中心', lv2_id: 'L2_AI_INFRA', lv2_name: 'AI基础设施 (算力/CPO/PCB)', lv1_id: 'L1_TMT', lv1_name: 'TMT (科技/媒体/通信)', keywords: ['服务器', '数据中心', '智算中心', '液冷'] },
|
|
||||||
{ lv3_id: 'L3_OPTICAL', lv3_name: '光通信与CPO', lv2_id: 'L2_AI_INFRA', lv2_name: 'AI基础设施 (算力/CPO/PCB)', lv1_id: 'L1_TMT', lv1_name: 'TMT (科技/媒体/通信)', keywords: ['CPO', '光通信', '光模块', '光芯片'] },
|
|
||||||
{ lv3_id: 'L3_PCB', lv3_name: 'PCB与封装', lv2_id: 'L2_AI_INFRA', lv2_name: 'AI基础设施 (算力/CPO/PCB)', lv1_id: 'L1_TMT', lv1_name: 'TMT (科技/媒体/通信)', keywords: ['PCB', '封装', 'AI PCB'] },
|
|
||||||
{ lv3_id: 'L3_AI_APP', lv3_name: 'AI应用与大模型', lv2_id: 'L2_AI_INFRA', lv2_name: 'AI基础设施 (算力/CPO/PCB)', lv1_id: 'L1_TMT', lv1_name: 'TMT (科技/媒体/通信)', keywords: ['大模型', '智能体', 'AI', '人工智能', 'DeepSeek', 'KIMI', 'ChatGPT'] },
|
|
||||||
{ lv3_id: 'L3_CHIP_DESIGN', lv3_name: '芯片设计', lv2_id: 'L2_SEMICONDUCTOR', lv2_name: '半导体 (设计/制造/封测)', lv1_id: 'L1_TMT', lv1_name: 'TMT (科技/媒体/通信)', keywords: ['芯片设计', '半导体', 'IC设计'] },
|
|
||||||
{ lv3_id: 'L3_CHIP_MFG', lv3_name: '芯片制造', lv2_id: 'L2_SEMICONDUCTOR', lv2_name: '半导体 (设计/制造/封测)', lv1_id: 'L1_TMT', lv1_name: 'TMT (科技/媒体/通信)', keywords: ['晶圆', '光刻', '芯片制造', '中芯国际'] },
|
|
||||||
{ lv3_id: 'L3_HUMANOID', lv3_name: '人形机器人', lv2_id: 'L2_ROBOT', lv2_name: '机器人 (人形机器人/工业机器人)', lv1_id: 'L1_TMT', lv1_name: 'TMT (科技/媒体/通信)', keywords: ['人形机器人', '具身智能', '特斯拉机器人'] },
|
|
||||||
{ lv3_id: 'L3_INDUSTRIAL_ROBOT', lv3_name: '工业机器人', lv2_id: 'L2_ROBOT', lv2_name: '机器人 (人形机器人/工业机器人)', lv1_id: 'L1_TMT', lv1_name: 'TMT (科技/媒体/通信)', keywords: ['工业机器人', '自动化', '机器人'] },
|
|
||||||
{ lv3_id: 'L3_MOBILE', lv3_name: '智能手机', lv2_id: 'L2_CONSUMER_ELEC', lv2_name: '消费电子 (手机/XR/可穿戴)', lv1_id: 'L1_TMT', lv1_name: 'TMT (科技/媒体/通信)', keywords: ['手机', '华为', '苹果', '小米', '折叠屏'] },
|
|
||||||
{ lv3_id: 'L3_XR', lv3_name: 'XR与可穿戴', lv2_id: 'L2_CONSUMER_ELEC', lv2_name: '消费电子 (手机/XR/可穿戴)', lv1_id: 'L1_TMT', lv1_name: 'TMT (科技/媒体/通信)', keywords: ['XR', 'VR', 'AR', '可穿戴', 'MR', 'Vision Pro'] },
|
|
||||||
{ lv3_id: 'L3_5G', lv3_name: '5G/6G通信', lv2_id: 'L2_TELECOM', lv2_name: '通信、互联网与软件', lv1_id: 'L1_TMT', lv1_name: 'TMT (科技/媒体/通信)', keywords: ['5G', '6G', '通信', '基站'] },
|
|
||||||
{ lv3_id: 'L3_CLOUD', lv3_name: '云计算与软件', lv2_id: 'L2_TELECOM', lv2_name: '通信、互联网与软件', lv1_id: 'L1_TMT', lv1_name: 'TMT (科技/媒体/通信)', keywords: ['云计算', '软件', 'SaaS', '互联网', '数字化'] },
|
|
||||||
{ lv3_id: 'L3_PV', lv3_name: '光伏', lv2_id: 'L2_NEW_ENERGY', lv2_name: '新能源 (光伏/储能/电池)', lv1_id: 'L1_NEW_ENERGY_ENV', lv1_name: '新能源与智能汽车', keywords: ['光伏', '太阳能', '硅片', '组件'] },
|
|
||||||
{ lv3_id: 'L3_STORAGE', lv3_name: '储能与电池', lv2_id: 'L2_NEW_ENERGY', lv2_name: '新能源 (光伏/储能/电池)', lv1_id: 'L1_NEW_ENERGY_ENV', lv1_name: '新能源与智能汽车', keywords: ['储能', '电池', '锂电', '固态电池', '新能源'] },
|
|
||||||
{ lv3_id: 'L3_EV_OEM', lv3_name: '新能源整车', lv2_id: 'L2_EV', lv2_name: '智能网联汽车', lv1_id: 'L1_NEW_ENERGY_ENV', lv1_name: '新能源与智能汽车', keywords: ['新能源汽车', '电动车', '比亚迪', '特斯拉', '整车'] },
|
|
||||||
{ lv3_id: 'L3_AUTO_DRIVE', lv3_name: '智能驾驶', lv2_id: 'L2_EV', lv2_name: '智能网联汽车', lv1_id: 'L1_NEW_ENERGY_ENV', lv1_name: '新能源与智能汽车', keywords: ['智能驾驶', '自动驾驶', '智能网联', '车路协同'] },
|
|
||||||
{ lv3_id: 'L3_DRONE', lv3_name: '无人机', lv2_id: 'L2_LOW_ALTITUDE', lv2_name: '低空经济 (无人机/eVTOL)', lv1_id: 'L1_ADVANCED_MFG', lv1_name: '先进制造', keywords: ['无人机', '低空', '空域'] },
|
|
||||||
{ lv3_id: 'L3_EVTOL', lv3_name: 'eVTOL', lv2_id: 'L2_LOW_ALTITUDE', lv2_name: '低空经济 (无人机/eVTOL)', lv1_id: 'L1_ADVANCED_MFG', lv1_name: '先进制造', keywords: ['eVTOL', '飞行汽车', '空中出租车'] },
|
|
||||||
{ lv3_id: 'L3_AEROSPACE', lv3_name: '航空航天', lv2_id: 'L2_MILITARY', lv2_name: '军工 (航空航天/国防)', lv1_id: 'L1_ADVANCED_MFG', lv1_name: '先进制造', keywords: ['航空', '航天', '卫星', '火箭', '军工'] },
|
|
||||||
{ lv3_id: 'L3_DEFENSE', lv3_name: '国防军工', lv2_id: 'L2_MILITARY', lv2_name: '军工 (航空航天/国防)', lv1_id: 'L1_ADVANCED_MFG', lv1_name: '先进制造', keywords: ['国防', '导弹', '军工装备'] },
|
|
||||||
{ lv3_id: 'L3_DRUG', lv3_name: '创新药', lv2_id: 'L2_PHARMA', lv2_name: '医药医疗 (创新药/器械)', lv1_id: 'L1_PHARMA', lv1_name: '医药健康', keywords: ['创新药', '医药', '生物', 'CXO'] },
|
|
||||||
{ lv3_id: 'L3_DEVICE', lv3_name: '医疗器械', lv2_id: 'L2_PHARMA', lv2_name: '医药医疗 (创新药/器械)', lv1_id: 'L1_PHARMA', lv1_name: '医药健康', keywords: ['医疗器械', '医疗', '器械'] },
|
|
||||||
{ lv3_id: 'L3_BANK', lv3_name: '银行', lv2_id: 'L2_FINANCE', lv2_name: '金融 (银行/券商/保险)', lv1_id: 'L1_FINANCE', lv1_name: '金融', keywords: ['银行', '金融'] },
|
|
||||||
{ lv3_id: 'L3_BROKER', lv3_name: '券商', lv2_id: 'L2_FINANCE', lv2_name: '金融 (银行/券商/保险)', lv1_id: 'L1_FINANCE', lv1_name: '金融', keywords: ['券商', '证券'] },
|
|
||||||
];
|
|
||||||
|
|
||||||
// 生成层级选项供前端下拉框使用
|
|
||||||
const hierarchyOptions = {
|
|
||||||
lv1: [...new Map(mainlineDefinitions.map(m => [m.lv1_id, { id: m.lv1_id, name: m.lv1_name }])).values()],
|
|
||||||
lv2: [...new Map(mainlineDefinitions.map(m => [m.lv2_id, { id: m.lv2_id, name: m.lv2_name, lv1_id: m.lv1_id, lv1_name: m.lv1_name }])).values()],
|
|
||||||
lv3: mainlineDefinitions.map(m => ({ id: m.lv3_id, name: m.lv3_name, lv2_id: m.lv2_id, lv2_name: m.lv2_name, lv1_id: m.lv1_id, lv1_name: m.lv1_name })),
|
|
||||||
};
|
|
||||||
|
|
||||||
// 按主线分组事件
|
|
||||||
const mainlineGroups = {};
|
|
||||||
|
|
||||||
// 判断分组方式
|
|
||||||
const isSpecificId = groupBy.startsWith('L1_') || groupBy.startsWith('L2_') || groupBy.startsWith('L3_');
|
|
||||||
|
|
||||||
allEvents.forEach(event => {
|
|
||||||
const keywords = event.keywords || event.related_concepts || [];
|
|
||||||
const conceptNames = keywords.map(k => k.concept || k.name || k).join(' ');
|
|
||||||
const titleAndDesc = `${event.title || ''} ${event.description || ''}`;
|
|
||||||
const textToMatch = `${conceptNames} ${titleAndDesc}`.toLowerCase();
|
|
||||||
|
|
||||||
mainlineDefinitions.forEach(mainline => {
|
|
||||||
const matched = mainline.keywords.some(kw => textToMatch.includes(kw.toLowerCase()));
|
|
||||||
if (matched) {
|
|
||||||
let groupKey, groupData;
|
|
||||||
|
|
||||||
if (isSpecificId) {
|
|
||||||
// 筛选特定概念ID
|
|
||||||
if (groupBy.startsWith('L1_') && mainline.lv1_id === groupBy) {
|
|
||||||
groupKey = mainline.lv2_id;
|
|
||||||
groupData = {
|
|
||||||
group_id: mainline.lv2_id,
|
|
||||||
group_name: mainline.lv2_name,
|
|
||||||
parent_name: mainline.lv1_name,
|
|
||||||
events: []
|
|
||||||
};
|
|
||||||
} else if (groupBy.startsWith('L2_') && mainline.lv2_id === groupBy) {
|
|
||||||
groupKey = mainline.lv3_id;
|
|
||||||
groupData = {
|
|
||||||
group_id: mainline.lv3_id,
|
|
||||||
group_name: mainline.lv3_name,
|
|
||||||
parent_name: mainline.lv2_name,
|
|
||||||
grandparent_name: mainline.lv1_name,
|
|
||||||
events: []
|
|
||||||
};
|
|
||||||
} else if (groupBy.startsWith('L3_') && mainline.lv3_id === groupBy) {
|
|
||||||
groupKey = mainline.lv3_id;
|
|
||||||
groupData = {
|
|
||||||
group_id: mainline.lv3_id,
|
|
||||||
group_name: mainline.lv3_name,
|
|
||||||
parent_name: mainline.lv2_name,
|
|
||||||
grandparent_name: mainline.lv1_name,
|
|
||||||
events: []
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else if (groupBy === 'lv1') {
|
|
||||||
groupKey = mainline.lv1_id;
|
|
||||||
groupData = {
|
|
||||||
group_id: mainline.lv1_id,
|
|
||||||
group_name: mainline.lv1_name,
|
|
||||||
events: []
|
|
||||||
};
|
|
||||||
} else if (groupBy === 'lv3') {
|
|
||||||
groupKey = mainline.lv3_id;
|
|
||||||
groupData = {
|
|
||||||
group_id: mainline.lv3_id,
|
|
||||||
group_name: mainline.lv3_name,
|
|
||||||
parent_name: mainline.lv2_name,
|
|
||||||
grandparent_name: mainline.lv1_name,
|
|
||||||
events: []
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
// 默认 lv2
|
|
||||||
groupKey = mainline.lv2_id;
|
|
||||||
groupData = {
|
|
||||||
group_id: mainline.lv2_id,
|
|
||||||
group_name: mainline.lv2_name,
|
|
||||||
parent_name: mainline.lv1_name,
|
|
||||||
events: []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!mainlineGroups[groupKey]) {
|
|
||||||
mainlineGroups[groupKey] = groupData;
|
|
||||||
}
|
|
||||||
if (!mainlineGroups[groupKey].events.find(e => e.id === event.id)) {
|
|
||||||
mainlineGroups[groupKey].events.push(event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const mainlines = Object.values(mainlineGroups)
|
|
||||||
.map(group => ({
|
|
||||||
...group,
|
|
||||||
events: group.events.slice(0, limitPerMainline),
|
|
||||||
event_count: Math.min(group.events.length, limitPerMainline)
|
|
||||||
}))
|
|
||||||
.filter(group => group.event_count > 0)
|
|
||||||
.sort((a, b) => b.event_count - a.event_count);
|
|
||||||
|
|
||||||
const groupedEventIds = new Set();
|
|
||||||
mainlines.forEach(m => m.events.forEach(e => groupedEventIds.add(e.id)));
|
|
||||||
const ungroupedCount = allEvents.filter(e => !groupedEventIds.has(e.id)).length;
|
|
||||||
|
|
||||||
console.log('[Mock Event] 主线数据生成完成:', {
|
|
||||||
mainlineCount: mainlines.length,
|
|
||||||
totalEvents: allEvents.length,
|
|
||||||
ungroupedCount,
|
|
||||||
groupBy
|
|
||||||
});
|
|
||||||
|
|
||||||
return HttpResponse.json({
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
mainlines,
|
|
||||||
total_events: allEvents.length,
|
|
||||||
mainline_count: mainlines.length,
|
|
||||||
ungrouped_count: ungroupedCount,
|
|
||||||
group_by: groupBy,
|
|
||||||
hierarchy_options: hierarchyOptions,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Mock Event] 主线数据获取失败:', error);
|
|
||||||
return HttpResponse.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
error: error.message || '获取主线数据失败'
|
|
||||||
},
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -512,7 +512,55 @@ export const marketHandlers = [
|
|||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// 12. 市场统计数据(个股中心页面使用)
|
// 12. 市场概况数据(投资仪表盘使用)- 上证/深证/总市值/成交额
|
||||||
|
http.get('/api/market/summary', async () => {
|
||||||
|
await delay(150);
|
||||||
|
|
||||||
|
// 生成实时数据(基于当前时间产生小波动)
|
||||||
|
const now = new Date();
|
||||||
|
const seed = now.getHours() * 60 + now.getMinutes();
|
||||||
|
|
||||||
|
// 上证指数(基准 3400)
|
||||||
|
const shBasePrice = 3400;
|
||||||
|
const shChange = parseFloat(((Math.sin(seed / 30) + Math.random() - 0.5) * 2).toFixed(2));
|
||||||
|
const shPrice = parseFloat((shBasePrice * (1 + shChange / 100)).toFixed(2));
|
||||||
|
const shChangeAmount = parseFloat((shPrice - shBasePrice).toFixed(2));
|
||||||
|
|
||||||
|
// 深证指数(基准 10800)
|
||||||
|
const szBasePrice = 10800;
|
||||||
|
const szChange = parseFloat(((Math.sin(seed / 25) + Math.random() - 0.5) * 2.5).toFixed(2));
|
||||||
|
const szPrice = parseFloat((szBasePrice * (1 + szChange / 100)).toFixed(2));
|
||||||
|
const szChangeAmount = parseFloat((szPrice - szBasePrice).toFixed(2));
|
||||||
|
|
||||||
|
// 总市值(约 100-110 万亿波动)
|
||||||
|
const totalMarketCap = parseFloat((105 + (Math.sin(seed / 60) * 5)).toFixed(1)) * 1000000000000;
|
||||||
|
|
||||||
|
// 成交额(约 0.8-1.5 万亿波动)
|
||||||
|
const turnover = parseFloat((1.0 + (Math.random() * 0.5 - 0.2)).toFixed(2)) * 1000000000000;
|
||||||
|
|
||||||
|
console.log('[Mock Market] 获取市场概况数据');
|
||||||
|
|
||||||
|
return HttpResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
shanghai: {
|
||||||
|
value: shPrice,
|
||||||
|
change: shChange,
|
||||||
|
changeAmount: shChangeAmount,
|
||||||
|
},
|
||||||
|
shenzhen: {
|
||||||
|
value: szPrice,
|
||||||
|
change: szChange,
|
||||||
|
changeAmount: szChangeAmount,
|
||||||
|
},
|
||||||
|
totalMarketCap,
|
||||||
|
turnover,
|
||||||
|
updateTime: now.toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
|
||||||
|
// 13. 市场统计数据(个股中心页面使用)
|
||||||
http.get('/api/market/statistics', async ({ request }) => {
|
http.get('/api/market/statistics', async ({ request }) => {
|
||||||
await delay(200);
|
await delay(200);
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import theme from '../theme/theme.js';
|
|||||||
// Contexts
|
// Contexts
|
||||||
import { AuthProvider } from '../contexts/AuthContext';
|
import { AuthProvider } from '../contexts/AuthContext';
|
||||||
import { NotificationProvider } from '../contexts/NotificationContext';
|
import { NotificationProvider } from '../contexts/NotificationContext';
|
||||||
|
import { GlobalSidebarProvider } from '../contexts/GlobalSidebarContext';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AppProviders - 应用的 Provider 容器
|
* AppProviders - 应用的 Provider 容器
|
||||||
@@ -57,7 +58,9 @@ export function AppProviders({ children }) {
|
|||||||
>
|
>
|
||||||
<NotificationProvider>
|
<NotificationProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
|
<GlobalSidebarProvider>
|
||||||
{children}
|
{children}
|
||||||
|
</GlobalSidebarProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</NotificationProvider>
|
</NotificationProvider>
|
||||||
</ChakraProvider>
|
</ChakraProvider>
|
||||||
|
|||||||
@@ -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')),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import stockReducer from './slices/stockSlice';
|
|||||||
import authModalReducer from './slices/authModalSlice';
|
import authModalReducer from './slices/authModalSlice';
|
||||||
import subscriptionReducer from './slices/subscriptionSlice';
|
import subscriptionReducer from './slices/subscriptionSlice';
|
||||||
import deviceReducer from './slices/deviceSlice'; // ✅ 设备检测状态管理
|
import deviceReducer from './slices/deviceSlice'; // ✅ 设备检测状态管理
|
||||||
|
import planningReducer from './slices/planningSlice'; // ✅ 投资规划中心状态管理
|
||||||
import { eventsApi } from './api/eventsApi'; // ✅ RTK Query API
|
import { eventsApi } from './api/eventsApi'; // ✅ RTK Query API
|
||||||
|
|
||||||
// ⚡ 基础 reducers(首屏必需)
|
// ⚡ 基础 reducers(首屏必需)
|
||||||
@@ -19,6 +20,7 @@ const staticReducers = {
|
|||||||
authModal: authModalReducer, // ✅ 认证弹窗状态管理
|
authModal: authModalReducer, // ✅ 认证弹窗状态管理
|
||||||
subscription: subscriptionReducer, // ✅ 订阅信息状态管理
|
subscription: subscriptionReducer, // ✅ 订阅信息状态管理
|
||||||
device: deviceReducer, // ✅ 设备检测状态管理(移动端/桌面端)
|
device: deviceReducer, // ✅ 设备检测状态管理(移动端/桌面端)
|
||||||
|
planning: planningReducer, // ✅ 投资规划中心状态管理
|
||||||
[eventsApi.reducerPath]: eventsApi.reducer, // ✅ RTK Query 事件 API
|
[eventsApi.reducerPath]: eventsApi.reducer, // ✅ RTK Query 事件 API
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
301
src/store/slices/planningSlice.ts
Normal file
301
src/store/slices/planningSlice.ts
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
/**
|
||||||
|
* planningSlice - 投资规划中心 Redux Slice
|
||||||
|
* 管理计划、复盘和日历事件数据
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
import type { InvestmentEvent } from '@/types';
|
||||||
|
import { getApiBase } from '@/utils/apiConfig';
|
||||||
|
import { logger } from '@/utils/logger';
|
||||||
|
|
||||||
|
// ==================== State 类型定义 ====================
|
||||||
|
|
||||||
|
interface PlanningState {
|
||||||
|
/** 所有事件(计划 + 复盘 + 系统事件) */
|
||||||
|
allEvents: InvestmentEvent[];
|
||||||
|
/** 加载状态 */
|
||||||
|
loading: boolean;
|
||||||
|
/** 错误信息 */
|
||||||
|
error: string | null;
|
||||||
|
/** 最后更新时间 */
|
||||||
|
lastUpdated: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 初始状态 ====================
|
||||||
|
|
||||||
|
const initialState: PlanningState = {
|
||||||
|
allEvents: [],
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
lastUpdated: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== Async Thunks ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载所有事件数据
|
||||||
|
*/
|
||||||
|
export const fetchAllEvents = createAsyncThunk(
|
||||||
|
'planning/fetchAllEvents',
|
||||||
|
async (_, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const base = getApiBase();
|
||||||
|
const response = await fetch(`${base}/api/account/calendar/events`, {
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
logger.debug('planningSlice', '数据加载成功', {
|
||||||
|
count: data.data?.length || 0,
|
||||||
|
});
|
||||||
|
return data.data || [];
|
||||||
|
} else {
|
||||||
|
throw new Error(data.error || '加载失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('planningSlice', 'fetchAllEvents', error);
|
||||||
|
return rejectWithValue(error instanceof Error ? error.message : '加载失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建计划/复盘
|
||||||
|
*/
|
||||||
|
export const createEvent = createAsyncThunk(
|
||||||
|
'planning/createEvent',
|
||||||
|
async (eventData: Partial<InvestmentEvent>, { rejectWithValue, dispatch }) => {
|
||||||
|
try {
|
||||||
|
const base = getApiBase();
|
||||||
|
const response = await fetch(`${base}/api/account/investment-plans`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify(eventData),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
logger.info('planningSlice', '创建成功', { title: eventData.title });
|
||||||
|
// 创建成功后重新加载所有数据
|
||||||
|
dispatch(fetchAllEvents());
|
||||||
|
return data.data;
|
||||||
|
} else {
|
||||||
|
throw new Error(data.error || '创建失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('planningSlice', 'createEvent', error);
|
||||||
|
return rejectWithValue(error instanceof Error ? error.message : '创建失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新计划/复盘
|
||||||
|
*/
|
||||||
|
export const updateEvent = createAsyncThunk(
|
||||||
|
'planning/updateEvent',
|
||||||
|
async ({ id, data }: { id: number; data: Partial<InvestmentEvent> }, { rejectWithValue, dispatch }) => {
|
||||||
|
try {
|
||||||
|
const base = getApiBase();
|
||||||
|
const response = await fetch(`${base}/api/account/investment-plans/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
logger.info('planningSlice', '更新成功', { id });
|
||||||
|
// 更新成功后重新加载所有数据
|
||||||
|
dispatch(fetchAllEvents());
|
||||||
|
return result.data;
|
||||||
|
} else {
|
||||||
|
throw new Error(result.error || '更新失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('planningSlice', 'updateEvent', error);
|
||||||
|
return rejectWithValue(error instanceof Error ? error.message : '更新失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除计划/复盘
|
||||||
|
*/
|
||||||
|
export const deleteEvent = createAsyncThunk(
|
||||||
|
'planning/deleteEvent',
|
||||||
|
async (id: number, { rejectWithValue, dispatch }) => {
|
||||||
|
try {
|
||||||
|
const base = getApiBase();
|
||||||
|
const response = await fetch(`${base}/api/account/investment-plans/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
logger.info('planningSlice', '删除成功', { id });
|
||||||
|
// 删除成功后重新加载所有数据
|
||||||
|
dispatch(fetchAllEvents());
|
||||||
|
return id;
|
||||||
|
} else {
|
||||||
|
throw new Error(data.error || '删除失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('planningSlice', 'deleteEvent', error);
|
||||||
|
return rejectWithValue(error instanceof Error ? error.message : '删除失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ==================== Slice ====================
|
||||||
|
|
||||||
|
const planningSlice = createSlice({
|
||||||
|
name: 'planning',
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
/** 清空数据 */
|
||||||
|
clearEvents: (state) => {
|
||||||
|
state.allEvents = [];
|
||||||
|
state.error = null;
|
||||||
|
},
|
||||||
|
/** 直接设置事件(用于乐观更新) */
|
||||||
|
setEvents: (state, action: PayloadAction<InvestmentEvent[]>) => {
|
||||||
|
state.allEvents = action.payload;
|
||||||
|
state.lastUpdated = Date.now();
|
||||||
|
},
|
||||||
|
/** 添加单个事件(乐观更新) */
|
||||||
|
addEvent: (state, action: PayloadAction<InvestmentEvent>) => {
|
||||||
|
state.allEvents.push(action.payload);
|
||||||
|
state.lastUpdated = Date.now();
|
||||||
|
},
|
||||||
|
/** 乐观添加事件(插入到开头,使用临时 ID) */
|
||||||
|
optimisticAddEvent: (state, action: PayloadAction<InvestmentEvent>) => {
|
||||||
|
state.allEvents.unshift(action.payload); // 新事件插入开头
|
||||||
|
state.lastUpdated = Date.now();
|
||||||
|
},
|
||||||
|
/** 替换临时事件为真实事件 */
|
||||||
|
replaceEvent: (state, action: PayloadAction<{ tempId: number; realEvent: InvestmentEvent }>) => {
|
||||||
|
const { tempId, realEvent } = action.payload;
|
||||||
|
const index = state.allEvents.findIndex(e => e.id === tempId);
|
||||||
|
if (index !== -1) {
|
||||||
|
state.allEvents[index] = realEvent;
|
||||||
|
}
|
||||||
|
state.lastUpdated = Date.now();
|
||||||
|
},
|
||||||
|
/** 移除事件(用于回滚) */
|
||||||
|
removeEvent: (state, action: PayloadAction<number>) => {
|
||||||
|
state.allEvents = state.allEvents.filter(e => e.id !== action.payload);
|
||||||
|
state.lastUpdated = Date.now();
|
||||||
|
},
|
||||||
|
/** 乐观更新事件(编辑时使用) */
|
||||||
|
optimisticUpdateEvent: (state, action: PayloadAction<InvestmentEvent>) => {
|
||||||
|
const index = state.allEvents.findIndex(e => e.id === action.payload.id);
|
||||||
|
if (index !== -1) {
|
||||||
|
state.allEvents[index] = action.payload;
|
||||||
|
state.lastUpdated = Date.now();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extraReducers: (builder) => {
|
||||||
|
builder
|
||||||
|
// fetchAllEvents
|
||||||
|
.addCase(fetchAllEvents.pending, (state) => {
|
||||||
|
state.loading = true;
|
||||||
|
state.error = null;
|
||||||
|
})
|
||||||
|
.addCase(fetchAllEvents.fulfilled, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.allEvents = action.payload;
|
||||||
|
state.lastUpdated = Date.now();
|
||||||
|
})
|
||||||
|
.addCase(fetchAllEvents.rejected, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.error = action.payload as string;
|
||||||
|
})
|
||||||
|
// createEvent
|
||||||
|
.addCase(createEvent.pending, (state) => {
|
||||||
|
state.loading = true;
|
||||||
|
})
|
||||||
|
.addCase(createEvent.fulfilled, (state) => {
|
||||||
|
state.loading = false;
|
||||||
|
})
|
||||||
|
.addCase(createEvent.rejected, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.error = action.payload as string;
|
||||||
|
})
|
||||||
|
// updateEvent
|
||||||
|
.addCase(updateEvent.pending, (state) => {
|
||||||
|
state.loading = true;
|
||||||
|
})
|
||||||
|
.addCase(updateEvent.fulfilled, (state) => {
|
||||||
|
state.loading = false;
|
||||||
|
})
|
||||||
|
.addCase(updateEvent.rejected, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.error = action.payload as string;
|
||||||
|
})
|
||||||
|
// deleteEvent
|
||||||
|
.addCase(deleteEvent.pending, (state) => {
|
||||||
|
state.loading = true;
|
||||||
|
})
|
||||||
|
.addCase(deleteEvent.fulfilled, (state) => {
|
||||||
|
state.loading = false;
|
||||||
|
})
|
||||||
|
.addCase(deleteEvent.rejected, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.error = action.payload as string;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== 导出 ====================
|
||||||
|
|
||||||
|
export const {
|
||||||
|
clearEvents,
|
||||||
|
setEvents,
|
||||||
|
addEvent,
|
||||||
|
optimisticAddEvent,
|
||||||
|
replaceEvent,
|
||||||
|
removeEvent,
|
||||||
|
optimisticUpdateEvent,
|
||||||
|
} = planningSlice.actions;
|
||||||
|
|
||||||
|
export default planningSlice.reducer;
|
||||||
|
|
||||||
|
// ==================== Selectors ====================
|
||||||
|
|
||||||
|
export const selectAllEvents = (state: { planning: PlanningState }) => state.planning.allEvents;
|
||||||
|
export const selectPlanningLoading = (state: { planning: PlanningState }) => state.planning.loading;
|
||||||
|
export const selectPlanningError = (state: { planning: PlanningState }) => state.planning.error;
|
||||||
|
export const selectPlans = (state: { planning: PlanningState }) =>
|
||||||
|
state.planning.allEvents.filter(e => e.type === 'plan' && e.source !== 'future');
|
||||||
|
export const selectReviews = (state: { planning: PlanningState }) =>
|
||||||
|
state.planning.allEvents.filter(e => e.type === 'review' && e.source !== 'future');
|
||||||
@@ -292,6 +292,132 @@ export const loadAllStocks = createAsyncThunk(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载自选股实时行情
|
||||||
|
* 用于统一行情刷新,两个面板共用
|
||||||
|
*/
|
||||||
|
export const loadWatchlistQuotes = createAsyncThunk(
|
||||||
|
'stock/loadWatchlistQuotes',
|
||||||
|
async () => {
|
||||||
|
logger.debug('stockSlice', 'loadWatchlistQuotes');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const apiBase = getApiBase();
|
||||||
|
const response = await fetch(`${apiBase}/api/account/watchlist/realtime`, {
|
||||||
|
credentials: 'include',
|
||||||
|
cache: 'no-store'
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success && Array.isArray(data.data)) {
|
||||||
|
logger.debug('stockSlice', '自选股行情加载成功', { count: data.data.length });
|
||||||
|
return data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('stockSlice', 'loadWatchlistQuotes', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载关注事件列表
|
||||||
|
* 用于统一关注事件数据源,两个面板共用
|
||||||
|
*/
|
||||||
|
export const loadFollowingEvents = createAsyncThunk(
|
||||||
|
'stock/loadFollowingEvents',
|
||||||
|
async () => {
|
||||||
|
logger.debug('stockSlice', 'loadFollowingEvents');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const apiBase = getApiBase();
|
||||||
|
const response = await fetch(`${apiBase}/api/account/events/following`, {
|
||||||
|
credentials: 'include',
|
||||||
|
cache: 'no-store'
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success && Array.isArray(data.data)) {
|
||||||
|
// 合并重复的事件(用最新的数据)
|
||||||
|
const eventMap = new Map();
|
||||||
|
for (const evt of data.data) {
|
||||||
|
if (evt && evt.id) {
|
||||||
|
eventMap.set(evt.id, evt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const merged = Array.from(eventMap.values());
|
||||||
|
// 按创建时间降序排列
|
||||||
|
if (merged.length > 0 && merged[0].created_at) {
|
||||||
|
merged.sort((a, b) => new Date(b.created_at || 0) - new Date(a.created_at || 0));
|
||||||
|
} else {
|
||||||
|
merged.sort((a, b) => (b.id || 0) - (a.id || 0));
|
||||||
|
}
|
||||||
|
logger.debug('stockSlice', '关注事件列表加载成功', { count: merged.length });
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('stockSlice', 'loadFollowingEvents', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载用户评论列表
|
||||||
|
*/
|
||||||
|
export const loadEventComments = createAsyncThunk(
|
||||||
|
'stock/loadEventComments',
|
||||||
|
async () => {
|
||||||
|
logger.debug('stockSlice', 'loadEventComments');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const apiBase = getApiBase();
|
||||||
|
const response = await fetch(`${apiBase}/api/account/events/posts`, {
|
||||||
|
credentials: 'include',
|
||||||
|
cache: 'no-store'
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success && Array.isArray(data.data)) {
|
||||||
|
logger.debug('stockSlice', '用户评论列表加载成功', { count: data.data.length });
|
||||||
|
return data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('stockSlice', 'loadEventComments', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换关注事件状态(关注/取消关注)
|
||||||
|
*/
|
||||||
|
export const toggleFollowEvent = createAsyncThunk(
|
||||||
|
'stock/toggleFollowEvent',
|
||||||
|
async ({ eventId, isFollowing }) => {
|
||||||
|
logger.debug('stockSlice', 'toggleFollowEvent', { eventId, isFollowing });
|
||||||
|
|
||||||
|
const apiBase = getApiBase();
|
||||||
|
const response = await fetch(`${apiBase}/api/events/${eventId}/follow`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok || data.success === false) {
|
||||||
|
throw new Error(data.error || '操作失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { eventId, isFollowing };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 切换自选股状态
|
* 切换自选股状态
|
||||||
*/
|
*/
|
||||||
@@ -359,6 +485,15 @@ const stockSlice = createSlice({
|
|||||||
// 自选股列表 [{ stock_code, stock_name }]
|
// 自选股列表 [{ stock_code, stock_name }]
|
||||||
watchlist: [],
|
watchlist: [],
|
||||||
|
|
||||||
|
// 自选股实时行情 [{ stock_code, stock_name, price, change_percent, ... }]
|
||||||
|
watchlistQuotes: [],
|
||||||
|
|
||||||
|
// 关注事件列表 [{ id, title, event_type, ... }]
|
||||||
|
followingEvents: [],
|
||||||
|
|
||||||
|
// 用户评论列表 [{ id, content, event_id, ... }]
|
||||||
|
eventComments: [],
|
||||||
|
|
||||||
// 全部股票列表(用于前端模糊搜索)[{ code, name }]
|
// 全部股票列表(用于前端模糊搜索)[{ code, name }]
|
||||||
allStocks: [],
|
allStocks: [],
|
||||||
|
|
||||||
@@ -370,6 +505,9 @@ const stockSlice = createSlice({
|
|||||||
historicalEvents: false,
|
historicalEvents: false,
|
||||||
chainAnalysis: false,
|
chainAnalysis: false,
|
||||||
watchlist: false,
|
watchlist: false,
|
||||||
|
watchlistQuotes: false,
|
||||||
|
followingEvents: false,
|
||||||
|
eventComments: false,
|
||||||
allStocks: false
|
allStocks: false
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -517,6 +655,18 @@ const stockSlice = createSlice({
|
|||||||
state.loading.watchlist = false;
|
state.loading.watchlist = false;
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ===== loadWatchlistQuotes =====
|
||||||
|
.addCase(loadWatchlistQuotes.pending, (state) => {
|
||||||
|
state.loading.watchlistQuotes = true;
|
||||||
|
})
|
||||||
|
.addCase(loadWatchlistQuotes.fulfilled, (state, action) => {
|
||||||
|
state.watchlistQuotes = action.payload;
|
||||||
|
state.loading.watchlistQuotes = false;
|
||||||
|
})
|
||||||
|
.addCase(loadWatchlistQuotes.rejected, (state) => {
|
||||||
|
state.loading.watchlistQuotes = false;
|
||||||
|
})
|
||||||
|
|
||||||
// ===== loadAllStocks =====
|
// ===== loadAllStocks =====
|
||||||
.addCase(loadAllStocks.pending, (state) => {
|
.addCase(loadAllStocks.pending, (state) => {
|
||||||
state.loading.allStocks = true;
|
state.loading.allStocks = true;
|
||||||
@@ -563,6 +713,47 @@ const stockSlice = createSlice({
|
|||||||
.addCase(toggleWatchlist.fulfilled, (state) => {
|
.addCase(toggleWatchlist.fulfilled, (state) => {
|
||||||
// 状态已在 pending 时更新,这里同步到 localStorage
|
// 状态已在 pending 时更新,这里同步到 localStorage
|
||||||
saveWatchlistToCache(state.watchlist);
|
saveWatchlistToCache(state.watchlist);
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===== loadFollowingEvents =====
|
||||||
|
.addCase(loadFollowingEvents.pending, (state) => {
|
||||||
|
state.loading.followingEvents = true;
|
||||||
|
})
|
||||||
|
.addCase(loadFollowingEvents.fulfilled, (state, action) => {
|
||||||
|
state.followingEvents = action.payload;
|
||||||
|
state.loading.followingEvents = false;
|
||||||
|
})
|
||||||
|
.addCase(loadFollowingEvents.rejected, (state) => {
|
||||||
|
state.loading.followingEvents = false;
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===== loadEventComments =====
|
||||||
|
.addCase(loadEventComments.pending, (state) => {
|
||||||
|
state.loading.eventComments = true;
|
||||||
|
})
|
||||||
|
.addCase(loadEventComments.fulfilled, (state, action) => {
|
||||||
|
state.eventComments = action.payload;
|
||||||
|
state.loading.eventComments = false;
|
||||||
|
})
|
||||||
|
.addCase(loadEventComments.rejected, (state) => {
|
||||||
|
state.loading.eventComments = false;
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===== toggleFollowEvent(乐观更新)=====
|
||||||
|
// pending: 立即更新状态
|
||||||
|
.addCase(toggleFollowEvent.pending, (state, action) => {
|
||||||
|
const { eventId, isFollowing } = action.meta.arg;
|
||||||
|
if (isFollowing) {
|
||||||
|
// 当前已关注,取消关注 → 移除
|
||||||
|
state.followingEvents = state.followingEvents.filter(evt => evt.id !== eventId);
|
||||||
|
}
|
||||||
|
// 添加关注的情况需要事件完整数据,不在这里处理
|
||||||
|
})
|
||||||
|
// rejected: 回滚状态(仅取消关注需要回滚)
|
||||||
|
.addCase(toggleFollowEvent.rejected, (state, action) => {
|
||||||
|
// 取消关注失败时,需要刷新列表恢复数据
|
||||||
|
// 由于没有原始事件数据,这里只能触发重新加载
|
||||||
|
logger.warn('stockSlice', 'toggleFollowEvent rejected, 需要重新加载关注事件列表');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
9
src/styles/scrollbar-hide.css
Normal file
9
src/styles/scrollbar-hide.css
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/* 全局隐藏滚动条(保持滚动功能) */
|
||||||
|
* {
|
||||||
|
scrollbar-width: none; /* Firefox */
|
||||||
|
-ms-overflow-style: none; /* IE/Edge */
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar {
|
||||||
|
display: none; /* Chrome, Safari, Opera */
|
||||||
|
}
|
||||||
361
src/types/center.ts
Normal file
361
src/types/center.ts
Normal file
@@ -0,0 +1,361 @@
|
|||||||
|
/**
|
||||||
|
* 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[];
|
||||||
|
|
||||||
|
/** 用户评论列表 */
|
||||||
|
eventComments?: EventComment[];
|
||||||
|
|
||||||
|
/** 点击股票回调 */
|
||||||
|
onStockClick?: (stock: WatchlistItem) => void;
|
||||||
|
|
||||||
|
/** 点击事件回调 */
|
||||||
|
onEventClick?: (event: FollowingEvent) => void;
|
||||||
|
|
||||||
|
/** 点击评论回调 */
|
||||||
|
onCommentClick?: (comment: EventComment) => 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[];
|
||||||
|
|
||||||
|
/** 用户评论列表 */
|
||||||
|
eventComments?: EventComment[];
|
||||||
|
|
||||||
|
/** 点击事件回调 */
|
||||||
|
onEventClick?: (event: FollowingEvent) => void;
|
||||||
|
|
||||||
|
/** 点击评论回调 */
|
||||||
|
onCommentClick?: (comment: EventComment) => 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';
|
||||||
|
|||||||
45
src/views/Center/Center.tsx
Normal file
45
src/views/Center/Center.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* Center - 个人中心仪表板主页面
|
||||||
|
*
|
||||||
|
* 对应路由:/home/center
|
||||||
|
* 功能:自选股监控、关注事件、投资规划等
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Box } from '@chakra-ui/react';
|
||||||
|
import InvestmentPlanningCenter from './components/InvestmentPlanningCenter';
|
||||||
|
import MarketDashboard from '@views/Profile/components/MarketDashboard';
|
||||||
|
import ForumCenter from '@views/Profile/components/ForumCenter';
|
||||||
|
import { THEME } from '@views/Profile/components/MarketDashboard/constants';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CenterDashboard 组件
|
||||||
|
* 个人中心仪表板主页面
|
||||||
|
*
|
||||||
|
* 注意:右侧 WatchSidebar 已移至全局 GlobalSidebar(在 MainLayout 中渲染)
|
||||||
|
*/
|
||||||
|
const CenterDashboard: React.FC = () => {
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box bg={THEME.bg.primary} minH="100vh" overflowX="hidden">
|
||||||
|
<Box px={{ base: 3, md: 4 }} py={{ base: 4, md: 6 }} maxW="container.xl" mx="auto">
|
||||||
|
{/* 市场概览仪表盘 */}
|
||||||
|
<Box mb={4}>
|
||||||
|
<MarketDashboard />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 价值论坛 / 互动中心 */}
|
||||||
|
<Box mb={4}>
|
||||||
|
<ForumCenter />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 投资规划中心(整合了日历、计划、复盘,应用 FUI 毛玻璃风格) */}
|
||||||
|
<Box>
|
||||||
|
<InvestmentPlanningCenter />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CenterDashboard;
|
||||||
238
src/views/Center/components/CalendarPanel.tsx
Normal file
238
src/views/Center/components/CalendarPanel.tsx
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
/**
|
||||||
|
* CalendarPanel - 投资日历面板组件
|
||||||
|
* 使用 Ant Design Calendar 展示投资计划、复盘等事件
|
||||||
|
*
|
||||||
|
* 聚合展示模式:每个日期格子显示各类型事件的汇总信息
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, lazy, Suspense, useMemo, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Modal,
|
||||||
|
ModalOverlay,
|
||||||
|
ModalContent,
|
||||||
|
ModalHeader,
|
||||||
|
ModalBody,
|
||||||
|
ModalCloseButton,
|
||||||
|
Spinner,
|
||||||
|
Center,
|
||||||
|
VStack,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import dayjs, { Dayjs } from 'dayjs';
|
||||||
|
import 'dayjs/locale/zh-cn';
|
||||||
|
|
||||||
|
import { useAppSelector } from '@/store/hooks';
|
||||||
|
import { selectAllEvents } from '@/store/slices/planningSlice';
|
||||||
|
import { usePlanningData } from './PlanningContext';
|
||||||
|
import { EventDetailModal } from './EventDetailModal';
|
||||||
|
import type { InvestmentEvent } from '@/types';
|
||||||
|
|
||||||
|
// 使用新的公共日历组件
|
||||||
|
import {
|
||||||
|
BaseCalendar,
|
||||||
|
CalendarEventBlock,
|
||||||
|
type CellRenderInfo,
|
||||||
|
type CalendarEvent,
|
||||||
|
CALENDAR_COLORS,
|
||||||
|
} from '@components/Calendar';
|
||||||
|
|
||||||
|
// 懒加载投资日历组件
|
||||||
|
const InvestmentCalendar = lazy(() => import('@components/InvestmentCalendar'));
|
||||||
|
|
||||||
|
dayjs.locale('zh-cn');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 事件聚合信息(用于日历格子显示)
|
||||||
|
*/
|
||||||
|
interface EventSummary {
|
||||||
|
plans: InvestmentEvent[];
|
||||||
|
reviews: InvestmentEvent[];
|
||||||
|
systems: InvestmentEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CalendarPanel 组件
|
||||||
|
* 日历视图面板,显示所有投资事件
|
||||||
|
*/
|
||||||
|
export const CalendarPanel: React.FC = () => {
|
||||||
|
// 从 Redux 获取数据(确保与列表视图同步)
|
||||||
|
const allEvents = useAppSelector(selectAllEvents);
|
||||||
|
|
||||||
|
// UI 相关状态仍从 Context 获取
|
||||||
|
const {
|
||||||
|
borderColor,
|
||||||
|
secondaryText,
|
||||||
|
setViewMode,
|
||||||
|
setListTab,
|
||||||
|
} = usePlanningData();
|
||||||
|
|
||||||
|
// 弹窗状态(统一使用 useState)
|
||||||
|
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false);
|
||||||
|
const [isInvestmentCalendarOpen, setIsInvestmentCalendarOpen] = useState(false);
|
||||||
|
|
||||||
|
const [selectedDate, setSelectedDate] = useState<Dayjs | null>(null);
|
||||||
|
const [selectedDateEvents, setSelectedDateEvents] = useState<InvestmentEvent[]>([]);
|
||||||
|
const [initialFilter, setInitialFilter] = useState<'all' | 'plan' | 'review' | 'system'>('all');
|
||||||
|
|
||||||
|
// 按日期分组事件(用于聚合展示)
|
||||||
|
const eventsByDate = useMemo(() => {
|
||||||
|
const map: Record<string, EventSummary> = {};
|
||||||
|
allEvents.forEach(event => {
|
||||||
|
const dateStr = event.event_date;
|
||||||
|
if (!map[dateStr]) {
|
||||||
|
map[dateStr] = { plans: [], reviews: [], systems: [] };
|
||||||
|
}
|
||||||
|
if (event.source === 'future') {
|
||||||
|
map[dateStr].systems.push(event);
|
||||||
|
} else if (event.type === 'plan') {
|
||||||
|
map[dateStr].plans.push(event);
|
||||||
|
} else if (event.type === 'review') {
|
||||||
|
map[dateStr].reviews.push(event);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [allEvents]);
|
||||||
|
|
||||||
|
// 将事件摘要转换为 CalendarEvent 格式
|
||||||
|
const getCalendarEvents = useCallback((dateStr: string): CalendarEvent[] => {
|
||||||
|
const summary = eventsByDate[dateStr];
|
||||||
|
if (!summary) return [];
|
||||||
|
|
||||||
|
const events: CalendarEvent[] = [];
|
||||||
|
|
||||||
|
// 系统事件
|
||||||
|
if (summary.systems.length > 0) {
|
||||||
|
events.push({
|
||||||
|
id: `${dateStr}-system`,
|
||||||
|
type: 'system',
|
||||||
|
title: summary.systems[0]?.title || '',
|
||||||
|
date: dateStr,
|
||||||
|
count: summary.systems.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计划
|
||||||
|
if (summary.plans.length > 0) {
|
||||||
|
events.push({
|
||||||
|
id: `${dateStr}-plan`,
|
||||||
|
type: 'plan',
|
||||||
|
title: summary.plans[0]?.title || '',
|
||||||
|
date: dateStr,
|
||||||
|
count: summary.plans.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复盘
|
||||||
|
if (summary.reviews.length > 0) {
|
||||||
|
events.push({
|
||||||
|
id: `${dateStr}-review`,
|
||||||
|
type: 'review',
|
||||||
|
title: summary.reviews[0]?.title || '',
|
||||||
|
date: dateStr,
|
||||||
|
count: summary.reviews.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return events;
|
||||||
|
}, [eventsByDate]);
|
||||||
|
|
||||||
|
// 处理日期选择(点击日期空白区域)
|
||||||
|
const handleDateSelect = useCallback((date: Dayjs): void => {
|
||||||
|
setSelectedDate(date);
|
||||||
|
|
||||||
|
const dayEvents = allEvents.filter(event =>
|
||||||
|
dayjs(event.event_date).isSame(date, 'day')
|
||||||
|
);
|
||||||
|
setSelectedDateEvents(dayEvents);
|
||||||
|
setInitialFilter('all'); // 点击日期时显示全部
|
||||||
|
setIsDetailModalOpen(true);
|
||||||
|
}, [allEvents]);
|
||||||
|
|
||||||
|
// 处理事件点击(点击具体事件类型)
|
||||||
|
const handleEventClick = useCallback((event: CalendarEvent): void => {
|
||||||
|
const date = dayjs(event.date);
|
||||||
|
setSelectedDate(date);
|
||||||
|
|
||||||
|
const dayEvents = allEvents.filter(e =>
|
||||||
|
dayjs(e.event_date).isSame(date, 'day')
|
||||||
|
);
|
||||||
|
setSelectedDateEvents(dayEvents);
|
||||||
|
setInitialFilter(event.type as 'plan' | 'review' | 'system'); // 定位到对应 Tab
|
||||||
|
setIsDetailModalOpen(true);
|
||||||
|
}, [allEvents]);
|
||||||
|
|
||||||
|
// 自定义日期格子内容渲染
|
||||||
|
const renderCellContent = useCallback((date: Dayjs, _info: CellRenderInfo) => {
|
||||||
|
const dateStr = date.format('YYYY-MM-DD');
|
||||||
|
const events = getCalendarEvents(dateStr);
|
||||||
|
|
||||||
|
if (events.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VStack spacing={0} align="stretch" w="100%" mt={1}>
|
||||||
|
<CalendarEventBlock
|
||||||
|
events={events}
|
||||||
|
maxDisplay={3}
|
||||||
|
compact
|
||||||
|
onEventClick={handleEventClick}
|
||||||
|
/>
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
}, [getCalendarEvents, handleEventClick]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
{/* 日历容器 - 使用 BaseCalendar */}
|
||||||
|
<Box height={{ base: '420px', md: '600px' }}>
|
||||||
|
<BaseCalendar
|
||||||
|
onSelect={handleDateSelect}
|
||||||
|
cellRender={renderCellContent}
|
||||||
|
height="100%"
|
||||||
|
showToolbar={true}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 查看事件详情 Modal */}
|
||||||
|
<EventDetailModal
|
||||||
|
isOpen={isDetailModalOpen}
|
||||||
|
onClose={() => setIsDetailModalOpen(false)}
|
||||||
|
selectedDate={selectedDate}
|
||||||
|
events={selectedDateEvents}
|
||||||
|
borderColor={borderColor}
|
||||||
|
secondaryText={secondaryText}
|
||||||
|
initialFilter={initialFilter}
|
||||||
|
onNavigateToPlan={() => {
|
||||||
|
setViewMode('list');
|
||||||
|
setListTab(0);
|
||||||
|
}}
|
||||||
|
onNavigateToReview={() => {
|
||||||
|
setViewMode('list');
|
||||||
|
setListTab(1);
|
||||||
|
}}
|
||||||
|
onOpenInvestmentCalendar={() => {
|
||||||
|
setIsInvestmentCalendarOpen(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 投资日历 Modal */}
|
||||||
|
{isInvestmentCalendarOpen && (
|
||||||
|
<Modal
|
||||||
|
isOpen={isInvestmentCalendarOpen}
|
||||||
|
onClose={() => setIsInvestmentCalendarOpen(false)}
|
||||||
|
size={{ base: 'full', md: '6xl' }}
|
||||||
|
>
|
||||||
|
<ModalOverlay />
|
||||||
|
<ModalContent maxW={{ base: '100%', md: '1200px' }} mx={{ base: 0, md: 4 }}>
|
||||||
|
<ModalHeader fontSize={{ base: 'md', md: 'lg' }} py={{ base: 3, md: 4 }}>投资日历</ModalHeader>
|
||||||
|
<ModalCloseButton />
|
||||||
|
<ModalBody pb={6}>
|
||||||
|
<Suspense fallback={<Center py={{ base: 6, md: 8 }}><Spinner size={{ base: 'lg', md: 'xl' }} color="blue.500" /></Center>}>
|
||||||
|
<InvestmentCalendar />
|
||||||
|
</Suspense>
|
||||||
|
</ModalBody>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
142
src/views/Center/components/EventDetailModal.less
Normal file
142
src/views/Center/components/EventDetailModal.less
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
/* EventDetailModal.less - 事件详情弹窗黑金主题样式 */
|
||||||
|
|
||||||
|
// ==================== 变量定义 ====================
|
||||||
|
// 与 GlassCard transparent 变体保持一致
|
||||||
|
@color-bg-deep: rgba(15, 15, 26, 0.95);
|
||||||
|
@color-bg-primary: #0F0F1A;
|
||||||
|
@color-bg-elevated: #1A1A2E;
|
||||||
|
|
||||||
|
@color-gold-400: #D4AF37;
|
||||||
|
@color-gold-500: #B8960C;
|
||||||
|
@color-gold-gradient: linear-gradient(135deg, #D4AF37 0%, #F5E6A3 100%);
|
||||||
|
|
||||||
|
@color-line-subtle: rgba(212, 175, 55, 0.1);
|
||||||
|
@color-line-default: rgba(212, 175, 55, 0.2);
|
||||||
|
|
||||||
|
@color-text-primary: rgba(255, 255, 255, 0.95);
|
||||||
|
@color-text-secondary: rgba(255, 255, 255, 0.6);
|
||||||
|
|
||||||
|
// ==================== 全局 Modal 遮罩层样式 ====================
|
||||||
|
.event-detail-modal-root {
|
||||||
|
.ant-modal-mask {
|
||||||
|
background: rgba(0, 0, 0, 0.7) !important;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 主样式 ====================
|
||||||
|
.event-detail-modal {
|
||||||
|
// Modal 整体
|
||||||
|
.ant-modal-content {
|
||||||
|
background: @color-bg-deep !important;
|
||||||
|
border: 1px solid @color-line-default;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6), 0 0 24px rgba(212, 175, 55, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modal 头部
|
||||||
|
.ant-modal-header {
|
||||||
|
background: transparent !important;
|
||||||
|
border-bottom: 1px solid @color-line-subtle;
|
||||||
|
padding: 16px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modal 标题
|
||||||
|
.ant-modal-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: @color-gold-gradient;
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭按钮
|
||||||
|
.ant-modal-close {
|
||||||
|
color: rgba(255, 255, 255, 0.9) !important;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
.ant-modal-close-x {
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: @color-gold-400 !important;
|
||||||
|
background: rgba(212, 175, 55, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modal 内容区域
|
||||||
|
.ant-modal-body {
|
||||||
|
padding: 16px 24px 24px;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 响应式适配 ====================
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.event-detail-modal {
|
||||||
|
.ant-modal-content {
|
||||||
|
border-radius: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-modal-header {
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-modal-title {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-modal-body {
|
||||||
|
padding: 12px 16px 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-detail-modal-root {
|
||||||
|
.ant-modal {
|
||||||
|
max-width: 100vw !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
top: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-modal-centered .ant-modal {
|
||||||
|
top: 0 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 滚动条样式(备用,已在组件内定义) ====================
|
||||||
|
.event-detail-modal {
|
||||||
|
// 自定义滚动条
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(212, 175, 55, 0.3);
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(212, 175, 55, 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
315
src/views/Center/components/EventDetailModal.tsx
Normal file
315
src/views/Center/components/EventDetailModal.tsx
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
/**
|
||||||
|
* EventDetailModal - 事件详情弹窗组件
|
||||||
|
* 用于展示某一天的所有投资事件
|
||||||
|
*
|
||||||
|
* 功能:
|
||||||
|
* - Tab 筛选(全部/计划/复盘/系统)
|
||||||
|
* - 两列网格布局
|
||||||
|
* - 响应式宽度
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import { Modal } from 'antd';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Grid,
|
||||||
|
HStack,
|
||||||
|
Button,
|
||||||
|
Text,
|
||||||
|
Badge,
|
||||||
|
Icon,
|
||||||
|
useBreakpointValue,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import { Target, Heart, Calendar, LayoutGrid } from 'lucide-react';
|
||||||
|
import type { Dayjs } from 'dayjs';
|
||||||
|
|
||||||
|
import { FUIEventCard } from './FUIEventCard';
|
||||||
|
import { EventEmptyState } from './EventEmptyState';
|
||||||
|
import type { InvestmentEvent } from '@/types';
|
||||||
|
import './EventDetailModal.less';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 筛选类型
|
||||||
|
*/
|
||||||
|
type FilterType = 'all' | 'plan' | 'review' | 'system';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 筛选器配置
|
||||||
|
*/
|
||||||
|
interface FilterOption {
|
||||||
|
key: FilterType;
|
||||||
|
label: string;
|
||||||
|
icon?: React.ElementType;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FILTER_OPTIONS: FilterOption[] = [
|
||||||
|
{
|
||||||
|
key: 'all',
|
||||||
|
label: '全部',
|
||||||
|
icon: LayoutGrid,
|
||||||
|
color: '#9B59B6', // 紫罗兰色,汇总概念用独特色
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'plan',
|
||||||
|
label: '计划',
|
||||||
|
icon: Target,
|
||||||
|
color: '#D4AF37',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'review',
|
||||||
|
label: '复盘',
|
||||||
|
icon: Heart,
|
||||||
|
color: '#10B981',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'system',
|
||||||
|
label: '系统',
|
||||||
|
icon: Calendar,
|
||||||
|
color: '#3B82F6',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据 hex 颜色生成 rgba 颜色
|
||||||
|
*/
|
||||||
|
const hexToRgba = (hex: string, alpha: number): string => {
|
||||||
|
// 处理 rgba 格式
|
||||||
|
if (hex.startsWith('rgba')) {
|
||||||
|
return hex;
|
||||||
|
}
|
||||||
|
// 处理 hex 格式
|
||||||
|
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||||
|
if (result) {
|
||||||
|
const r = parseInt(result[1], 16);
|
||||||
|
const g = parseInt(result[2], 16);
|
||||||
|
const b = parseInt(result[3], 16);
|
||||||
|
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||||
|
}
|
||||||
|
return hex;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EventDetailModal Props
|
||||||
|
*/
|
||||||
|
export interface EventDetailModalProps {
|
||||||
|
/** 是否打开 */
|
||||||
|
isOpen: boolean;
|
||||||
|
/** 关闭回调 */
|
||||||
|
onClose: () => void;
|
||||||
|
/** 选中的日期 */
|
||||||
|
selectedDate: Dayjs | null;
|
||||||
|
/** 选中日期的事件列表 */
|
||||||
|
events: InvestmentEvent[];
|
||||||
|
/** 边框颜色 */
|
||||||
|
borderColor?: string;
|
||||||
|
/** 次要文字颜色 */
|
||||||
|
secondaryText?: string;
|
||||||
|
/** 导航到计划列表 */
|
||||||
|
onNavigateToPlan?: () => void;
|
||||||
|
/** 导航到复盘列表 */
|
||||||
|
onNavigateToReview?: () => void;
|
||||||
|
/** 打开投资日历 */
|
||||||
|
onOpenInvestmentCalendar?: () => void;
|
||||||
|
/** 初始筛选类型(点击事件时定位到对应 Tab) */
|
||||||
|
initialFilter?: FilterType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取各类型事件数量
|
||||||
|
*/
|
||||||
|
const getEventCounts = (events: InvestmentEvent[]) => {
|
||||||
|
const counts = { all: events.length, plan: 0, review: 0, system: 0 };
|
||||||
|
events.forEach(event => {
|
||||||
|
if (event.source === 'future') {
|
||||||
|
counts.system++;
|
||||||
|
} else if (event.type === 'plan') {
|
||||||
|
counts.plan++;
|
||||||
|
} else if (event.type === 'review') {
|
||||||
|
counts.review++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return counts;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EventDetailModal 组件
|
||||||
|
*/
|
||||||
|
export const EventDetailModal: React.FC<EventDetailModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
selectedDate,
|
||||||
|
events,
|
||||||
|
onNavigateToPlan,
|
||||||
|
onNavigateToReview,
|
||||||
|
onOpenInvestmentCalendar,
|
||||||
|
initialFilter,
|
||||||
|
}) => {
|
||||||
|
// 筛选状态
|
||||||
|
const [activeFilter, setActiveFilter] = useState<FilterType>('all');
|
||||||
|
|
||||||
|
// 响应式弹窗宽度
|
||||||
|
const modalWidth = useBreakpointValue({ base: '100%', md: 600, lg: 800 }) || 600;
|
||||||
|
|
||||||
|
// 响应式网格列数
|
||||||
|
const gridColumns = useBreakpointValue({ base: 1, md: 2 }) || 1;
|
||||||
|
|
||||||
|
// 各类型事件数量
|
||||||
|
const eventCounts = useMemo(() => getEventCounts(events), [events]);
|
||||||
|
|
||||||
|
// 筛选后的事件
|
||||||
|
const filteredEvents = useMemo(() => {
|
||||||
|
if (activeFilter === 'all') return events;
|
||||||
|
if (activeFilter === 'system') return events.filter(e => e.source === 'future');
|
||||||
|
return events.filter(e => e.type === activeFilter && e.source !== 'future');
|
||||||
|
}, [events, activeFilter]);
|
||||||
|
|
||||||
|
// 弹窗打开时设置筛选(使用 initialFilter 或默认 'all')
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setActiveFilter(initialFilter || 'all');
|
||||||
|
}
|
||||||
|
}, [isOpen, initialFilter]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={isOpen}
|
||||||
|
onCancel={onClose}
|
||||||
|
title={
|
||||||
|
<HStack spacing={2}>
|
||||||
|
<Text color="rgba(255, 255, 255, 0.85)" fontWeight="500">
|
||||||
|
{selectedDate?.format('YYYY年MM月DD日') || ''} 的事件
|
||||||
|
</Text>
|
||||||
|
<Badge
|
||||||
|
bg="rgba(212, 175, 55, 0.15)"
|
||||||
|
color="#D4AF37"
|
||||||
|
fontSize="xs"
|
||||||
|
px={2}
|
||||||
|
borderRadius="full"
|
||||||
|
>
|
||||||
|
{events.length}
|
||||||
|
</Badge>
|
||||||
|
</HStack>
|
||||||
|
}
|
||||||
|
footer={null}
|
||||||
|
width={modalWidth}
|
||||||
|
maskClosable={false}
|
||||||
|
keyboard={true}
|
||||||
|
centered
|
||||||
|
className="event-detail-modal"
|
||||||
|
rootClassName="event-detail-modal-root"
|
||||||
|
styles={{
|
||||||
|
content: {
|
||||||
|
background: 'rgba(15, 15, 26, 0.95)',
|
||||||
|
border: '1px solid rgba(212, 175, 55, 0.2)',
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
background: 'transparent',
|
||||||
|
},
|
||||||
|
body: { paddingTop: 8, paddingBottom: 24 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{events.length === 0 ? (
|
||||||
|
<EventEmptyState
|
||||||
|
onNavigateToPlan={() => {
|
||||||
|
onClose();
|
||||||
|
onNavigateToPlan?.();
|
||||||
|
}}
|
||||||
|
onNavigateToReview={() => {
|
||||||
|
onClose();
|
||||||
|
onNavigateToReview?.();
|
||||||
|
}}
|
||||||
|
onOpenInvestmentCalendar={() => {
|
||||||
|
onClose();
|
||||||
|
onOpenInvestmentCalendar?.();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Box>
|
||||||
|
{/* Tab 筛选器 */}
|
||||||
|
<HStack spacing={2} mb={4} flexWrap="wrap">
|
||||||
|
{FILTER_OPTIONS.map(option => {
|
||||||
|
const count = eventCounts[option.key];
|
||||||
|
const isActive = activeFilter === option.key;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={option.key}
|
||||||
|
size="sm"
|
||||||
|
variant={isActive ? 'solid' : 'ghost'}
|
||||||
|
bg={isActive ? hexToRgba(option.color, 0.2) : 'rgba(168, 180, 192, 0.05)'}
|
||||||
|
color={isActive ? option.color : 'rgba(168, 180, 192, 0.6)'}
|
||||||
|
border="1px solid"
|
||||||
|
borderColor={isActive ? hexToRgba(option.color, 0.5) : 'rgba(168, 180, 192, 0.15)'}
|
||||||
|
boxShadow={isActive ? `0 0 12px ${hexToRgba(option.color, 0.3)}` : 'none'}
|
||||||
|
_hover={{
|
||||||
|
bg: hexToRgba(option.color, 0.12),
|
||||||
|
borderColor: hexToRgba(option.color, 0.3),
|
||||||
|
color: hexToRgba(option.color, 0.9),
|
||||||
|
}}
|
||||||
|
onClick={() => setActiveFilter(option.key)}
|
||||||
|
leftIcon={option.icon ? <Icon as={option.icon} boxSize={3.5} /> : undefined}
|
||||||
|
rightIcon={
|
||||||
|
count > 0 ? (
|
||||||
|
<Badge
|
||||||
|
bg={isActive ? hexToRgba(option.color, 0.3) : 'rgba(168, 180, 192, 0.1)'}
|
||||||
|
color={isActive ? option.color : 'rgba(168, 180, 192, 0.6)'}
|
||||||
|
fontSize="10px"
|
||||||
|
px={1.5}
|
||||||
|
borderRadius="full"
|
||||||
|
ml={1}
|
||||||
|
>
|
||||||
|
{count}
|
||||||
|
</Badge>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{/* 事件网格 */}
|
||||||
|
<Box
|
||||||
|
maxH="60vh"
|
||||||
|
overflowY="auto"
|
||||||
|
pr={2}
|
||||||
|
css={{
|
||||||
|
'&::-webkit-scrollbar': { width: '6px' },
|
||||||
|
'&::-webkit-scrollbar-track': { background: 'rgba(255, 255, 255, 0.05)' },
|
||||||
|
'&::-webkit-scrollbar-thumb': {
|
||||||
|
background: 'rgba(212, 175, 55, 0.3)',
|
||||||
|
borderRadius: '3px',
|
||||||
|
},
|
||||||
|
'&::-webkit-scrollbar-thumb:hover': { background: 'rgba(212, 175, 55, 0.5)' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{filteredEvents.length === 0 ? (
|
||||||
|
<Box textAlign="center" py={8}>
|
||||||
|
<Text color="rgba(255, 255, 255, 0.5)" fontSize="sm">
|
||||||
|
该分类下暂无事件
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Grid
|
||||||
|
templateColumns={`repeat(${gridColumns}, 1fr)`}
|
||||||
|
gap={4}
|
||||||
|
>
|
||||||
|
{filteredEvents.map((event, idx) => (
|
||||||
|
<FUIEventCard
|
||||||
|
key={event.id || idx}
|
||||||
|
event={event}
|
||||||
|
variant="modal"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EventDetailModal;
|
||||||
576
src/views/Center/components/EventFormModal.less
Normal file
576
src/views/Center/components/EventFormModal.less
Normal file
@@ -0,0 +1,576 @@
|
|||||||
|
/* EventFormModal.less - 投资计划/复盘弹窗黑金主题样式 */
|
||||||
|
|
||||||
|
// ==================== 变量定义 ====================
|
||||||
|
@mobile-breakpoint: 768px;
|
||||||
|
@modal-border-radius-mobile: 16px;
|
||||||
|
@modal-border-radius-desktop: 12px;
|
||||||
|
|
||||||
|
// 间距
|
||||||
|
@spacing-xs: 4px;
|
||||||
|
@spacing-sm: 8px;
|
||||||
|
@spacing-md: 12px;
|
||||||
|
@spacing-lg: 16px;
|
||||||
|
@spacing-xl: 20px;
|
||||||
|
@spacing-xxl: 24px;
|
||||||
|
|
||||||
|
// 字体大小
|
||||||
|
@font-size-xs: 12px;
|
||||||
|
@font-size-sm: 14px;
|
||||||
|
@font-size-md: 16px;
|
||||||
|
|
||||||
|
// 黑金主题色
|
||||||
|
@color-bg-deep: #0A0A14;
|
||||||
|
@color-bg-primary: #0F0F1A;
|
||||||
|
@color-bg-elevated: #1A1A2E;
|
||||||
|
@color-bg-surface: #252540;
|
||||||
|
@color-bg-input: rgba(26, 26, 46, 0.8);
|
||||||
|
|
||||||
|
@color-gold-400: #D4AF37;
|
||||||
|
@color-gold-500: #B8960C;
|
||||||
|
@color-gold-gradient: linear-gradient(135deg, #D4AF37 0%, #F5E6A3 100%);
|
||||||
|
|
||||||
|
@color-line-subtle: rgba(212, 175, 55, 0.1);
|
||||||
|
@color-line-default: rgba(212, 175, 55, 0.2);
|
||||||
|
@color-line-emphasis: rgba(212, 175, 55, 0.4);
|
||||||
|
|
||||||
|
@color-text-primary: rgba(255, 255, 255, 0.95);
|
||||||
|
@color-text-secondary: rgba(255, 255, 255, 0.6);
|
||||||
|
@color-text-muted: rgba(255, 255, 255, 0.4);
|
||||||
|
|
||||||
|
@color-error: #EF4444;
|
||||||
|
|
||||||
|
// ==================== 全局 Modal 遮罩层样式 ====================
|
||||||
|
// 使用 rootClassName="event-form-modal-root" 来控制 mask 样式
|
||||||
|
.event-form-modal-root {
|
||||||
|
.ant-modal-mask {
|
||||||
|
background: rgba(0, 0, 0, 0.7) !important;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-modal-wrap {
|
||||||
|
// 确保 wrap 层也有正确的样式
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 主样式 ====================
|
||||||
|
.event-form-modal {
|
||||||
|
|
||||||
|
// Modal 整体
|
||||||
|
.ant-modal-content {
|
||||||
|
background: linear-gradient(135deg, @color-bg-elevated 0%, @color-bg-primary 100%);
|
||||||
|
border: 1px solid @color-line-default;
|
||||||
|
border-radius: @modal-border-radius-desktop;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), 0 0 24px rgba(212, 175, 55, 0.1);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modal 头部
|
||||||
|
.ant-modal-header {
|
||||||
|
background: transparent;
|
||||||
|
border-bottom: 1px solid @color-line-subtle;
|
||||||
|
padding: @spacing-lg @spacing-xxl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modal 标题
|
||||||
|
.ant-modal-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: @color-gold-gradient;
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭按钮
|
||||||
|
.ant-modal-close {
|
||||||
|
color: @color-text-secondary;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: @color-gold-400;
|
||||||
|
background: rgba(212, 175, 55, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-modal-body {
|
||||||
|
padding: @spacing-xxl;
|
||||||
|
padding-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modal 底部
|
||||||
|
.ant-modal-footer {
|
||||||
|
background: transparent;
|
||||||
|
border-top: 1px solid @color-line-subtle;
|
||||||
|
padding: @spacing-lg @spacing-xxl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-form-item {
|
||||||
|
margin-bottom: @spacing-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表单标签
|
||||||
|
.ant-form-item-label {
|
||||||
|
text-align: left !important;
|
||||||
|
|
||||||
|
> label {
|
||||||
|
font-weight: 600 !important;
|
||||||
|
color: @color-text-primary !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 输入框通用样式(支持 Ant Design v5 的 outlined 类名)
|
||||||
|
.ant-input,
|
||||||
|
.ant-input-outlined,
|
||||||
|
.ant-picker,
|
||||||
|
.ant-picker-outlined,
|
||||||
|
.ant-select-selector,
|
||||||
|
.ant-select-outlined .ant-select-selector {
|
||||||
|
background: @color-bg-input !important;
|
||||||
|
border: 1px solid @color-line-default !important;
|
||||||
|
color: @color-text-primary !important;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: @color-line-emphasis !important;
|
||||||
|
background: @color-bg-input !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus,
|
||||||
|
&:focus-within,
|
||||||
|
&.ant-input-focused,
|
||||||
|
&.ant-picker-focused,
|
||||||
|
&.ant-select-focused {
|
||||||
|
border-color: @color-gold-400 !important;
|
||||||
|
box-shadow: 0 0 0 2px rgba(212, 175, 55, 0.2) !important;
|
||||||
|
background: @color-bg-input !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: @color-text-muted !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 输入框 focus 状态增强
|
||||||
|
.ant-input:focus,
|
||||||
|
.ant-input-outlined:focus,
|
||||||
|
.ant-input-affix-wrapper:focus,
|
||||||
|
.ant-input-affix-wrapper-focused {
|
||||||
|
border-color: @color-gold-400 !important;
|
||||||
|
box-shadow: 0 0 0 2px rgba(212, 175, 55, 0.2) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 输入框 wrapper
|
||||||
|
.ant-input-affix-wrapper {
|
||||||
|
background: @color-bg-input !important;
|
||||||
|
border-color: @color-line-default !important;
|
||||||
|
|
||||||
|
.ant-input {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-input-suffix {
|
||||||
|
color: @color-text-muted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 输入框 placeholder
|
||||||
|
.ant-input::placeholder,
|
||||||
|
.ant-picker-input input::placeholder,
|
||||||
|
.ant-select-selection-placeholder {
|
||||||
|
color: @color-text-muted !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文本域
|
||||||
|
.ant-input-textarea,
|
||||||
|
.ant-input-textarea-show-count {
|
||||||
|
textarea,
|
||||||
|
.ant-input {
|
||||||
|
background: @color-bg-input !important;
|
||||||
|
border: 1px solid @color-line-default !important;
|
||||||
|
color: @color-text-primary !important;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: @color-line-emphasis !important;
|
||||||
|
background: @color-bg-input !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: @color-gold-400 !important;
|
||||||
|
box-shadow: 0 0 0 2px rgba(212, 175, 55, 0.2) !important;
|
||||||
|
background: @color-bg-input !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: @color-text-muted !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文本域 outlined 变体
|
||||||
|
.ant-input-textarea-outlined,
|
||||||
|
textarea.ant-input-outlined {
|
||||||
|
background: @color-bg-input !important;
|
||||||
|
border: 1px solid @color-line-default !important;
|
||||||
|
color: @color-text-primary !important;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: @color-line-emphasis !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: @color-gold-400 !important;
|
||||||
|
box-shadow: 0 0 0 2px rgba(212, 175, 55, 0.2) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 字符计数样式
|
||||||
|
.ant-input-textarea-show-count::after {
|
||||||
|
font-size: @font-size-xs;
|
||||||
|
color: @color-text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 日期选择器
|
||||||
|
.ant-picker {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.ant-picker-input > input {
|
||||||
|
color: @color-text-primary !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-picker-suffix,
|
||||||
|
.ant-picker-clear {
|
||||||
|
color: @color-text-secondary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select 选择器
|
||||||
|
.ant-select {
|
||||||
|
.ant-select-selector {
|
||||||
|
min-height: 38px;
|
||||||
|
background: @color-bg-input !important;
|
||||||
|
border: 1px solid @color-line-default !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.ant-select-focused .ant-select-selector,
|
||||||
|
&:hover .ant-select-selector {
|
||||||
|
border-color: @color-line-emphasis !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.ant-select-focused .ant-select-selector {
|
||||||
|
border-color: @color-gold-400 !important;
|
||||||
|
box-shadow: 0 0 0 2px rgba(212, 175, 55, 0.2) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-select-selection-item {
|
||||||
|
color: @color-text-primary !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-select-selection-search-input {
|
||||||
|
color: @color-text-primary !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-select-arrow {
|
||||||
|
color: @color-text-secondary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-select-clear {
|
||||||
|
background: @color-bg-elevated;
|
||||||
|
color: @color-text-secondary;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: @color-gold-400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select 多选模式下的标签
|
||||||
|
.ant-select-multiple .ant-select-selection-item {
|
||||||
|
background: rgba(212, 175, 55, 0.15) !important;
|
||||||
|
border: 1px solid rgba(212, 175, 55, 0.3) !important;
|
||||||
|
color: @color-gold-400 !important;
|
||||||
|
|
||||||
|
.ant-select-selection-item-remove {
|
||||||
|
color: @color-gold-400;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: @color-text-primary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 股票标签样式
|
||||||
|
.ant-tag {
|
||||||
|
margin: 2px;
|
||||||
|
border-radius: @spacing-xs;
|
||||||
|
background: rgba(212, 175, 55, 0.15) !important;
|
||||||
|
border: 1px solid rgba(212, 175, 55, 0.3) !important;
|
||||||
|
color: @color-gold-400 !important;
|
||||||
|
|
||||||
|
.ant-tag-close-icon {
|
||||||
|
color: @color-gold-400;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: @color-text-primary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模板按钮组
|
||||||
|
.template-buttons {
|
||||||
|
.ant-btn {
|
||||||
|
font-size: @font-size-xs;
|
||||||
|
background: rgba(212, 175, 55, 0.1);
|
||||||
|
border: 1px solid @color-line-default;
|
||||||
|
color: @color-text-secondary;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(212, 175, 55, 0.2);
|
||||||
|
border-color: @color-line-emphasis;
|
||||||
|
color: @color-gold-400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 底部操作栏布局
|
||||||
|
.modal-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
|
||||||
|
.ant-btn-primary {
|
||||||
|
background: linear-gradient(135deg, @color-gold-400 0%, @color-gold-500 100%);
|
||||||
|
border: none;
|
||||||
|
color: @color-bg-deep;
|
||||||
|
font-weight: 600;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 24px;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: linear-gradient(135deg, lighten(@color-gold-400, 5%) 0%, lighten(@color-gold-500, 5%) 100%);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(212, 175, 55, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
background: rgba(212, 175, 55, 0.3);
|
||||||
|
color: @color-text-muted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载状态
|
||||||
|
.ant-btn-loading {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 错误状态
|
||||||
|
.ant-form-item-has-error {
|
||||||
|
.ant-input,
|
||||||
|
.ant-picker,
|
||||||
|
.ant-select-selector {
|
||||||
|
border-color: @color-error !important;
|
||||||
|
animation: shake 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-form-item-explain-error {
|
||||||
|
color: @color-error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select 下拉菜单(挂载在 body 上,需要全局样式)
|
||||||
|
.ant-select-dropdown {
|
||||||
|
background: @color-bg-elevated !important;
|
||||||
|
border: 1px solid @color-line-default;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||||
|
|
||||||
|
.ant-select-item {
|
||||||
|
color: @color-text-primary;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(212, 175, 55, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.ant-select-item-option-selected {
|
||||||
|
background: rgba(212, 175, 55, 0.2);
|
||||||
|
color: @color-gold-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.ant-select-item-option-active {
|
||||||
|
background: rgba(212, 175, 55, 0.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-select-item-empty {
|
||||||
|
color: @color-text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分割线
|
||||||
|
.ant-divider {
|
||||||
|
border-color: @color-line-subtle;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提示文字
|
||||||
|
span[style*="color: #999"] {
|
||||||
|
color: @color-text-muted !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 日期选择器下拉面板
|
||||||
|
.ant-picker-dropdown {
|
||||||
|
.ant-picker-panel-container {
|
||||||
|
background: @color-bg-elevated;
|
||||||
|
border: 1px solid @color-line-default;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-picker-header {
|
||||||
|
color: @color-text-primary;
|
||||||
|
border-bottom: 1px solid @color-line-subtle;
|
||||||
|
|
||||||
|
button {
|
||||||
|
color: @color-text-secondary;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: @color-gold-400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-picker-content th {
|
||||||
|
color: @color-text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-picker-cell {
|
||||||
|
color: @color-text-secondary;
|
||||||
|
|
||||||
|
&:hover .ant-picker-cell-inner {
|
||||||
|
background: rgba(212, 175, 55, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.ant-picker-cell-in-view {
|
||||||
|
color: @color-text-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.ant-picker-cell-selected .ant-picker-cell-inner {
|
||||||
|
background: @color-gold-400;
|
||||||
|
color: @color-bg-deep;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.ant-picker-cell-today .ant-picker-cell-inner::before {
|
||||||
|
border-color: @color-gold-400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-picker-footer {
|
||||||
|
border-top: 1px solid @color-line-subtle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-picker-today-btn {
|
||||||
|
color: @color-gold-400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 移动端适配 ====================
|
||||||
|
@media (max-width: @mobile-breakpoint) {
|
||||||
|
.event-form-modal {
|
||||||
|
// Modal 整体尺寸
|
||||||
|
.ant-modal {
|
||||||
|
width: calc(100vw - 32px) !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
margin: @spacing-lg auto;
|
||||||
|
top: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-modal-content {
|
||||||
|
max-height: calc(100vh - 32px);
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-radius: @modal-border-radius-mobile;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modal 头部
|
||||||
|
.ant-modal-header {
|
||||||
|
padding: @spacing-md @spacing-lg;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-modal-title {
|
||||||
|
font-size: @font-size-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modal 内容区域
|
||||||
|
.ant-modal-body {
|
||||||
|
padding: @spacing-lg;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modal 底部
|
||||||
|
.ant-modal-footer {
|
||||||
|
padding: @spacing-md @spacing-lg;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表单项间距
|
||||||
|
.ant-form-item {
|
||||||
|
margin-bottom: @spacing-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表单标签
|
||||||
|
.ant-form-item-label > label {
|
||||||
|
font-size: @font-size-sm;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 输入框字体 - iOS 防止缩放需要 16px
|
||||||
|
.ant-input,
|
||||||
|
.ant-picker-input > input,
|
||||||
|
.ant-select-selection-search-input {
|
||||||
|
font-size: @font-size-md !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文本域高度
|
||||||
|
.ant-input-textarea textarea {
|
||||||
|
font-size: @font-size-md !important;
|
||||||
|
min-height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模板按钮组
|
||||||
|
.template-buttons .ant-btn {
|
||||||
|
font-size: @font-size-xs;
|
||||||
|
padding: 2px @spacing-sm;
|
||||||
|
height: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 股票选择器
|
||||||
|
.ant-select-selector {
|
||||||
|
min-height: 40px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 底部按钮
|
||||||
|
.modal-footer .ant-btn-primary {
|
||||||
|
font-size: @font-size-md;
|
||||||
|
height: 44px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 动画 ====================
|
||||||
|
@keyframes shake {
|
||||||
|
0%, 100% { transform: translateX(0); }
|
||||||
|
25% { transform: translateX(-4px); }
|
||||||
|
75% { transform: translateX(4px); }
|
||||||
|
}
|
||||||
@@ -23,6 +23,8 @@ import {
|
|||||||
message,
|
message,
|
||||||
Space,
|
Space,
|
||||||
Spin,
|
Spin,
|
||||||
|
ConfigProvider,
|
||||||
|
theme,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { SelectProps } from 'antd';
|
import type { SelectProps } from 'antd';
|
||||||
import {
|
import {
|
||||||
@@ -34,7 +36,13 @@ import 'dayjs/locale/zh-cn';
|
|||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
|
|
||||||
import { useAppDispatch } from '@/store/hooks';
|
import { useAppDispatch } from '@/store/hooks';
|
||||||
import { usePlanningData } from './PlanningContext';
|
import {
|
||||||
|
fetchAllEvents,
|
||||||
|
optimisticAddEvent,
|
||||||
|
replaceEvent,
|
||||||
|
removeEvent,
|
||||||
|
optimisticUpdateEvent,
|
||||||
|
} from '@/store/slices/planningSlice';
|
||||||
import './EventFormModal.less';
|
import './EventFormModal.less';
|
||||||
import type { InvestmentEvent, EventType } from '@/types';
|
import type { InvestmentEvent, EventType } from '@/types';
|
||||||
import { logger } from '@/utils/logger';
|
import { logger } from '@/utils/logger';
|
||||||
@@ -184,7 +192,6 @@ export const EventFormModal: React.FC<EventFormModalProps> = ({
|
|||||||
label = '事件',
|
label = '事件',
|
||||||
apiEndpoint = 'investment-plans',
|
apiEndpoint = 'investment-plans',
|
||||||
}) => {
|
}) => {
|
||||||
const { loadAllData } = usePlanningData();
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const [form] = Form.useForm<FormData>();
|
const [form] = Form.useForm<FormData>();
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -275,7 +282,7 @@ export const EventFormModal: React.FC<EventFormModalProps> = ({
|
|||||||
setStockOptions(options.length > 0 ? options : watchlistOptions);
|
setStockOptions(options.length > 0 ? options : watchlistOptions);
|
||||||
}, [allStocks, watchlistOptions]);
|
}, [allStocks, watchlistOptions]);
|
||||||
|
|
||||||
// 保存数据
|
// 保存数据(新建模式使用乐观更新)
|
||||||
const handleSave = useCallback(async (): Promise<void> => {
|
const handleSave = useCallback(async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const values = await form.validateFields();
|
const values = await form.validateFields();
|
||||||
@@ -315,29 +322,104 @@ export const EventFormModal: React.FC<EventFormModalProps> = ({
|
|||||||
? `${base}/api/account/${apiEndpoint}/${editingEvent.id}`
|
? `${base}/api/account/${apiEndpoint}/${editingEvent.id}`
|
||||||
: `${base}/api/account/${apiEndpoint}`;
|
: `${base}/api/account/${apiEndpoint}`;
|
||||||
|
|
||||||
const method = mode === 'edit' ? 'PUT' : 'POST';
|
// ===== 新建模式:乐观更新 =====
|
||||||
|
if (mode === 'create') {
|
||||||
|
const tempId = -Date.now(); // 负数临时 ID,避免与服务器 ID 冲突
|
||||||
|
const tempEvent: InvestmentEvent = {
|
||||||
|
id: tempId,
|
||||||
|
title: values.title,
|
||||||
|
content: values.content || '',
|
||||||
|
description: values.content || '',
|
||||||
|
date: values.date.format('YYYY-MM-DD'),
|
||||||
|
event_date: values.date.format('YYYY-MM-DD'),
|
||||||
|
type: eventType,
|
||||||
|
stocks: stocksWithNames,
|
||||||
|
status: 'active',
|
||||||
|
source: 'user',
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ① 立即更新 UI
|
||||||
|
dispatch(optimisticAddEvent(tempEvent));
|
||||||
|
setSaving(false);
|
||||||
|
onClose(); // 立即关闭弹窗
|
||||||
|
|
||||||
|
// ② 后台发送 API 请求
|
||||||
|
try {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method,
|
method: 'POST',
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
'Content-Type': 'application/json',
|
credentials: 'include',
|
||||||
},
|
body: JSON.stringify(requestData),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok && data.success) {
|
||||||
|
// ③ 用真实数据替换临时数据
|
||||||
|
dispatch(replaceEvent({ tempId, realEvent: data.data }));
|
||||||
|
logger.info('EventFormModal', `创建${label}成功`, { title: values.title });
|
||||||
|
message.success('添加成功');
|
||||||
|
onSuccess();
|
||||||
|
} else {
|
||||||
|
throw new Error(data.error || '创建失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// ④ 失败回滚
|
||||||
|
dispatch(removeEvent(tempId));
|
||||||
|
logger.error('EventFormModal', 'handleSave optimistic rollback', error);
|
||||||
|
message.error('创建失败,请重试');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 编辑模式:乐观更新 =====
|
||||||
|
if (editingEvent) {
|
||||||
|
// 构建更新后的事件对象
|
||||||
|
const updatedEvent: InvestmentEvent = {
|
||||||
|
...editingEvent,
|
||||||
|
title: values.title,
|
||||||
|
content: values.content || '',
|
||||||
|
description: values.content || '',
|
||||||
|
date: values.date.format('YYYY-MM-DD'),
|
||||||
|
event_date: values.date.format('YYYY-MM-DD'),
|
||||||
|
stocks: stocksWithNames,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ① 立即更新 UI
|
||||||
|
dispatch(optimisticUpdateEvent(updatedEvent));
|
||||||
|
setSaving(false);
|
||||||
|
onClose(); // 立即关闭弹窗
|
||||||
|
|
||||||
|
// ② 后台发送 API 请求
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: JSON.stringify(requestData),
|
body: JSON.stringify(requestData),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
logger.info('EventFormModal', `${mode === 'edit' ? '更新' : '创建'}${label}成功`, {
|
logger.info('EventFormModal', `更新${label}成功`, {
|
||||||
itemId: editingEvent?.id,
|
itemId: editingEvent.id,
|
||||||
title: values.title,
|
title: values.title,
|
||||||
});
|
});
|
||||||
message.success(mode === 'edit' ? '修改成功' : '添加成功');
|
message.success('修改成功');
|
||||||
onClose();
|
|
||||||
onSuccess();
|
onSuccess();
|
||||||
loadAllData();
|
|
||||||
} else {
|
} else {
|
||||||
throw new Error('保存失败');
|
throw new Error('保存失败');
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// ③ 失败回滚 - 重新加载数据
|
||||||
|
dispatch(fetchAllEvents());
|
||||||
|
logger.error('EventFormModal', 'handleSave edit rollback', error);
|
||||||
|
message.error('修改失败,请重试');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.message !== '保存失败') {
|
if (error instanceof Error && error.message !== '保存失败') {
|
||||||
// 表单验证错误,不显示额外提示
|
// 表单验证错误,不显示额外提示
|
||||||
@@ -350,7 +432,7 @@ export const EventFormModal: React.FC<EventFormModalProps> = ({
|
|||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
}, [form, eventType, apiEndpoint, mode, editingEvent, label, onClose, onSuccess, loadAllData, allStocks, watchlist]);
|
}, [form, eventType, apiEndpoint, mode, editingEvent, label, onClose, onSuccess, dispatch, allStocks, watchlist]);
|
||||||
|
|
||||||
// 监听键盘快捷键 Ctrl + Enter
|
// 监听键盘快捷键 Ctrl + Enter
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -393,7 +475,7 @@ export const EventFormModal: React.FC<EventFormModalProps> = ({
|
|||||||
// 判断是否显示自选股列表
|
// 判断是否显示自选股列表
|
||||||
const isShowingWatchlist = !searchText && stockOptions === watchlistOptions;
|
const isShowingWatchlist = !searchText && stockOptions === watchlistOptions;
|
||||||
|
|
||||||
// 股票选择器选项配置
|
// 股票选择器选项配置(黑金主题)
|
||||||
const selectProps: SelectProps<string[]> = {
|
const selectProps: SelectProps<string[]> = {
|
||||||
mode: 'multiple',
|
mode: 'multiple',
|
||||||
placeholder: allStocksLoading ? '加载股票列表中...' : '输入股票代码或名称搜索',
|
placeholder: allStocksLoading ? '加载股票列表中...' : '输入股票代码或名称搜索',
|
||||||
@@ -401,12 +483,15 @@ export const EventFormModal: React.FC<EventFormModalProps> = ({
|
|||||||
onSearch: handleStockSearch,
|
onSearch: handleStockSearch,
|
||||||
loading: watchlistLoading || allStocksLoading,
|
loading: watchlistLoading || allStocksLoading,
|
||||||
notFoundContent: allStocksLoading ? (
|
notFoundContent: allStocksLoading ? (
|
||||||
<div style={{ textAlign: 'center', padding: '8px' }}>
|
<div style={{ textAlign: 'center', padding: '8px', color: 'rgba(255,255,255,0.6)' }}>
|
||||||
<Spin size="small" />
|
<Spin size="small" />
|
||||||
<span style={{ marginLeft: 8 }}>加载中...</span>
|
<span style={{ marginLeft: 8 }}>加载中...</span>
|
||||||
</div>
|
</div>
|
||||||
) : '暂无结果',
|
) : <span style={{ color: 'rgba(255,255,255,0.4)' }}>暂无结果</span>,
|
||||||
options: stockOptions,
|
options: stockOptions,
|
||||||
|
style: {
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
onFocus: () => {
|
onFocus: () => {
|
||||||
if (stockOptions.length === 0) {
|
if (stockOptions.length === 0) {
|
||||||
setStockOptions(watchlistOptions);
|
setStockOptions(watchlistOptions);
|
||||||
@@ -416,41 +501,49 @@ export const EventFormModal: React.FC<EventFormModalProps> = ({
|
|||||||
const { label: tagLabel, closable, onClose: onTagClose } = props;
|
const { label: tagLabel, closable, onClose: onTagClose } = props;
|
||||||
return (
|
return (
|
||||||
<Tag
|
<Tag
|
||||||
color="blue"
|
|
||||||
closable={closable}
|
closable={closable}
|
||||||
onClose={onTagClose}
|
onClose={onTagClose}
|
||||||
style={{ marginRight: 3 }}
|
style={{
|
||||||
|
marginRight: 3,
|
||||||
|
background: 'rgba(212, 175, 55, 0.15)',
|
||||||
|
border: '1px solid rgba(212, 175, 55, 0.3)',
|
||||||
|
color: '#D4AF37',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{tagLabel}
|
{tagLabel}
|
||||||
</Tag>
|
</Tag>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
popupRender: (menu) => (
|
popupRender: (menu) => (
|
||||||
<>
|
<div style={{
|
||||||
|
background: '#1A1A2E',
|
||||||
|
border: '1px solid rgba(212, 175, 55, 0.2)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
}}>
|
||||||
{isShowingWatchlist && watchlistOptions.length > 0 && (
|
{isShowingWatchlist && watchlistOptions.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div style={{ padding: '4px 8px 0' }}>
|
<div style={{ padding: '8px 12px 4px' }}>
|
||||||
<span style={{ fontSize: 12, color: '#999' }}>
|
<span style={{ fontSize: 12, color: 'rgba(255,255,255,0.6)' }}>
|
||||||
<StarOutlined style={{ marginRight: 4, color: '#faad14' }} />
|
<StarOutlined style={{ marginRight: 4, color: '#D4AF37' }} />
|
||||||
我的自选股
|
我的自选股
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Divider style={{ margin: '4px 0 0' }} />
|
<Divider style={{ margin: '4px 0 0', borderColor: 'rgba(212, 175, 55, 0.1)' }} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{menu}
|
{menu}
|
||||||
{!isShowingWatchlist && searchText && (
|
{!isShowingWatchlist && searchText && (
|
||||||
<>
|
<>
|
||||||
<Divider style={{ margin: '8px 0' }} />
|
<Divider style={{ margin: '8px 0', borderColor: 'rgba(212, 175, 55, 0.1)' }} />
|
||||||
<div style={{ padding: '0 8px 4px' }}>
|
<div style={{ padding: '0 12px 8px' }}>
|
||||||
<span style={{ fontSize: 12, color: '#999' }}>
|
<span style={{ fontSize: 12, color: 'rgba(255,255,255,0.4)' }}>
|
||||||
<BulbOutlined style={{ marginRight: 4 }} />
|
<BulbOutlined style={{ marginRight: 4 }} />
|
||||||
搜索结果(输入代码或名称)
|
搜索结果(输入代码或名称)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -462,9 +555,47 @@ export const EventFormModal: React.FC<EventFormModalProps> = ({
|
|||||||
return eventType === 'plan' ? '创建计划' : '创建复盘';
|
return eventType === 'plan' ? '创建计划' : '创建复盘';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 黑金主题样式
|
||||||
|
const modalStyles = {
|
||||||
|
mask: {
|
||||||
|
background: 'rgba(0, 0, 0, 0.7)',
|
||||||
|
backdropFilter: 'blur(4px)',
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
background: 'linear-gradient(135deg, #1A1A2E 0%, #0F0F1A 100%)',
|
||||||
|
border: '1px solid rgba(212, 175, 55, 0.2)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.5), 0 0 24px rgba(212, 175, 55, 0.1)',
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
background: 'transparent',
|
||||||
|
borderBottom: '1px solid rgba(212, 175, 55, 0.1)',
|
||||||
|
padding: '16px 24px',
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
padding: '24px',
|
||||||
|
paddingTop: '24px',
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
background: 'transparent',
|
||||||
|
borderTop: '1px solid rgba(212, 175, 55, 0.1)',
|
||||||
|
padding: '16px 24px',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title={`${mode === 'edit' ? '编辑' : '新建'}${eventType === 'plan' ? '投资计划' : '复盘'}`}
|
title={
|
||||||
|
<span style={{
|
||||||
|
fontSize: '20px',
|
||||||
|
fontWeight: 700,
|
||||||
|
background: 'linear-gradient(135deg, #D4AF37 0%, #F5E6A3 100%)',
|
||||||
|
WebkitBackgroundClip: 'text',
|
||||||
|
WebkitTextFillColor: 'transparent',
|
||||||
|
}}>
|
||||||
|
{`${mode === 'edit' ? '编辑' : '新建'}${eventType === 'plan' ? '投资计划' : '复盘'}`}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
open={isOpen}
|
open={isOpen}
|
||||||
onCancel={onClose}
|
onCancel={onClose}
|
||||||
width={600}
|
width={600}
|
||||||
@@ -472,18 +603,64 @@ export const EventFormModal: React.FC<EventFormModalProps> = ({
|
|||||||
maskClosable={true}
|
maskClosable={true}
|
||||||
keyboard
|
keyboard
|
||||||
className="event-form-modal"
|
className="event-form-modal"
|
||||||
|
styles={modalStyles}
|
||||||
|
closeIcon={
|
||||||
|
<span style={{ color: 'rgba(255,255,255,0.6)', fontSize: '16px' }}>✕</span>
|
||||||
|
}
|
||||||
footer={
|
footer={
|
||||||
<div className="modal-footer">
|
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
loading={saving}
|
loading={saving}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(135deg, #D4AF37 0%, #B8960C 100%)',
|
||||||
|
border: 'none',
|
||||||
|
color: '#0A0A14',
|
||||||
|
fontWeight: 600,
|
||||||
|
height: '40px',
|
||||||
|
padding: '0 24px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{getButtonText()}
|
{getButtonText()}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
>
|
||||||
|
<ConfigProvider
|
||||||
|
theme={{
|
||||||
|
algorithm: theme.darkAlgorithm,
|
||||||
|
token: {
|
||||||
|
colorPrimary: '#D4AF37',
|
||||||
|
colorBgContainer: 'rgba(26, 26, 46, 0.8)',
|
||||||
|
colorBorder: 'rgba(212, 175, 55, 0.2)',
|
||||||
|
colorBorderSecondary: 'rgba(212, 175, 55, 0.1)',
|
||||||
|
colorText: 'rgba(255, 255, 255, 0.95)',
|
||||||
|
colorTextSecondary: 'rgba(255, 255, 255, 0.6)',
|
||||||
|
colorTextPlaceholder: 'rgba(255, 255, 255, 0.4)',
|
||||||
|
colorBgElevated: '#1A1A2E',
|
||||||
|
colorFillSecondary: 'rgba(212, 175, 55, 0.1)',
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
Input: {
|
||||||
|
activeBorderColor: '#D4AF37',
|
||||||
|
hoverBorderColor: 'rgba(212, 175, 55, 0.4)',
|
||||||
|
activeShadow: '0 0 0 2px rgba(212, 175, 55, 0.2)',
|
||||||
|
},
|
||||||
|
Select: {
|
||||||
|
optionActiveBg: 'rgba(212, 175, 55, 0.1)',
|
||||||
|
optionSelectedBg: 'rgba(212, 175, 55, 0.2)',
|
||||||
|
optionSelectedColor: '#D4AF37',
|
||||||
|
},
|
||||||
|
DatePicker: {
|
||||||
|
activeBorderColor: '#D4AF37',
|
||||||
|
hoverBorderColor: 'rgba(212, 175, 55, 0.4)',
|
||||||
|
activeShadow: '0 0 0 2px rgba(212, 175, 55, 0.2)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div ref={modalContentRef}>
|
<div ref={modalContentRef}>
|
||||||
{isOpen && <Form
|
{isOpen && <Form
|
||||||
@@ -501,7 +678,7 @@ export const EventFormModal: React.FC<EventFormModalProps> = ({
|
|||||||
{/* 标题 */}
|
{/* 标题 */}
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="title"
|
name="title"
|
||||||
label={<span style={{ fontWeight: 600 }}>标题 <span style={{ color: '#ff4d4f' }}>*</span></span>}
|
label={<span style={{ fontWeight: 600, color: 'rgba(255,255,255,0.95)' }}>标题 <span style={{ color: '#D4AF37' }}>*</span></span>}
|
||||||
rules={[
|
rules={[
|
||||||
{ required: true, message: '请输入标题' },
|
{ required: true, message: '请输入标题' },
|
||||||
{ max: 50, message: '标题不能超过50个字符' },
|
{ max: 50, message: '标题不能超过50个字符' },
|
||||||
@@ -511,17 +688,31 @@ export const EventFormModal: React.FC<EventFormModalProps> = ({
|
|||||||
placeholder={getTitlePlaceholder()}
|
placeholder={getTitlePlaceholder()}
|
||||||
maxLength={50}
|
maxLength={50}
|
||||||
showCount
|
showCount
|
||||||
|
styles={{
|
||||||
|
input: {
|
||||||
|
background: 'rgba(26, 26, 46, 0.8)',
|
||||||
|
borderColor: 'rgba(212, 175, 55, 0.2)',
|
||||||
|
color: 'rgba(255, 255, 255, 0.95)',
|
||||||
|
},
|
||||||
|
count: {
|
||||||
|
color: 'rgba(255, 255, 255, 0.4)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
{/* 日期 */}
|
{/* 日期 */}
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="date"
|
name="date"
|
||||||
label={<span style={{ fontWeight: 600 }}>{eventType === 'plan' ? '计划日期' : '复盘日期'} <span style={{ color: '#ff4d4f' }}>*</span></span>}
|
label={<span style={{ fontWeight: 600, color: 'rgba(255,255,255,0.95)' }}>{eventType === 'plan' ? '计划日期' : '复盘日期'} <span style={{ color: '#D4AF37' }}>*</span></span>}
|
||||||
rules={[{ required: true, message: '请选择日期' }]}
|
rules={[{ required: true, message: '请选择日期' }]}
|
||||||
>
|
>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
style={{ width: '100%' }}
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
background: 'rgba(26, 26, 46, 0.8)',
|
||||||
|
borderColor: 'rgba(212, 175, 55, 0.2)',
|
||||||
|
}}
|
||||||
format="YYYY-MM-DD"
|
format="YYYY-MM-DD"
|
||||||
placeholder="选择日期"
|
placeholder="选择日期"
|
||||||
allowClear={false}
|
allowClear={false}
|
||||||
@@ -531,7 +722,7 @@ export const EventFormModal: React.FC<EventFormModalProps> = ({
|
|||||||
{/* 描述/内容 - 上下布局 */}
|
{/* 描述/内容 - 上下布局 */}
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="content"
|
name="content"
|
||||||
label={<span style={{ fontWeight: 600 }}>{eventType === 'plan' ? '计划详情' : '复盘内容'} <span style={{ color: '#ff4d4f' }}>*</span></span>}
|
label={<span style={{ fontWeight: 600, color: 'rgba(255,255,255,0.95)' }}>{eventType === 'plan' ? '计划详情' : '复盘内容'} <span style={{ color: '#D4AF37' }}>*</span></span>}
|
||||||
rules={[{ required: true, message: '请输入内容' }]}
|
rules={[{ required: true, message: '请输入内容' }]}
|
||||||
labelCol={{ span: 24 }}
|
labelCol={{ span: 24 }}
|
||||||
wrapperCol={{ span: 24 }}
|
wrapperCol={{ span: 24 }}
|
||||||
@@ -542,18 +733,28 @@ export const EventFormModal: React.FC<EventFormModalProps> = ({
|
|||||||
rows={8}
|
rows={8}
|
||||||
showCount
|
showCount
|
||||||
maxLength={2000}
|
maxLength={2000}
|
||||||
style={{ resize: 'vertical' }}
|
style={{
|
||||||
|
resize: 'vertical',
|
||||||
|
background: 'rgba(26, 26, 46, 0.8)',
|
||||||
|
borderColor: 'rgba(212, 175, 55, 0.2)',
|
||||||
|
color: 'rgba(255, 255, 255, 0.95)',
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
{/* 模板快捷按钮 - 独立放置在 Form.Item 外部 */}
|
{/* 模板快捷按钮 - 独立放置在 Form.Item 外部 */}
|
||||||
<div style={{ marginBottom: 24 }}>
|
<div style={{ marginBottom: 24 }}>
|
||||||
<Space wrap size="small" className="template-buttons">
|
<Space wrap size="small">
|
||||||
{templates.map((template) => (
|
{templates.map((template) => (
|
||||||
<Button
|
<Button
|
||||||
key={template.label}
|
key={template.label}
|
||||||
size="small"
|
size="small"
|
||||||
onClick={() => handleInsertTemplate(template)}
|
onClick={() => handleInsertTemplate(template)}
|
||||||
|
style={{
|
||||||
|
background: 'rgba(212, 175, 55, 0.1)',
|
||||||
|
borderColor: 'rgba(212, 175, 55, 0.2)',
|
||||||
|
color: 'rgba(255, 255, 255, 0.6)',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{template.label}
|
{template.label}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -564,12 +765,13 @@ export const EventFormModal: React.FC<EventFormModalProps> = ({
|
|||||||
{/* 关联股票 */}
|
{/* 关联股票 */}
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="stocks"
|
name="stocks"
|
||||||
label={<span style={{ fontWeight: 600 }}>关联股票</span>}
|
label={<span style={{ fontWeight: 600, color: 'rgba(255,255,255,0.95)' }}>关联股票</span>}
|
||||||
>
|
>
|
||||||
<Select {...selectProps} />
|
<Select {...selectProps} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>}
|
</Form>}
|
||||||
</div>
|
</div>
|
||||||
|
</ConfigProvider>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* EventPanel - 通用事件面板组件
|
* EventPanel - 通用事件面板组件 (Redux 版本)
|
||||||
* 用于显示、编辑和管理投资计划或复盘
|
* 用于显示、编辑和管理投资计划或复盘
|
||||||
*
|
*
|
||||||
* 通过 props 配置差异化行为:
|
* 通过 props 配置差异化行为:
|
||||||
@@ -17,15 +17,23 @@ import {
|
|||||||
Spinner,
|
Spinner,
|
||||||
Center,
|
Center,
|
||||||
Icon,
|
Icon,
|
||||||
|
useToast,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import { FiFileText } from 'react-icons/fi';
|
import { FiFileText } from 'react-icons/fi';
|
||||||
|
|
||||||
import { usePlanningData } from './PlanningContext';
|
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||||
|
import {
|
||||||
|
fetchAllEvents,
|
||||||
|
removeEvent,
|
||||||
|
selectPlans,
|
||||||
|
selectReviews,
|
||||||
|
selectPlanningLoading,
|
||||||
|
} from '@/store/slices/planningSlice';
|
||||||
|
import { getApiBase } from '@/utils/apiConfig';
|
||||||
import { EventFormModal } from './EventFormModal';
|
import { EventFormModal } from './EventFormModal';
|
||||||
import { EventCard } from './EventCard';
|
import { FUIEventCard } from './FUIEventCard';
|
||||||
import type { InvestmentEvent } from '@/types';
|
import type { InvestmentEvent } from '@/types';
|
||||||
import { logger } from '@/utils/logger';
|
import { logger } from '@/utils/logger';
|
||||||
import { getApiBase } from '@/utils/apiConfig';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* EventPanel Props
|
* EventPanel Props
|
||||||
@@ -51,15 +59,16 @@ export const EventPanel: React.FC<EventPanelProps> = ({
|
|||||||
label,
|
label,
|
||||||
openModalTrigger,
|
openModalTrigger,
|
||||||
}) => {
|
}) => {
|
||||||
const {
|
const dispatch = useAppDispatch();
|
||||||
allEvents,
|
const toast = useToast();
|
||||||
loadAllData,
|
|
||||||
loading,
|
// Redux 状态
|
||||||
toast,
|
const plans = useAppSelector(selectPlans);
|
||||||
textColor,
|
const reviews = useAppSelector(selectReviews);
|
||||||
secondaryText,
|
const loading = useAppSelector(selectPlanningLoading);
|
||||||
cardBg,
|
|
||||||
} = usePlanningData();
|
// 根据类型选择事件列表
|
||||||
|
const events = type === 'plan' ? plans : reviews;
|
||||||
|
|
||||||
// 弹窗状态
|
// 弹窗状态
|
||||||
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
|
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
|
||||||
@@ -69,9 +78,6 @@ export const EventPanel: React.FC<EventPanelProps> = ({
|
|||||||
// 使用 ref 记录上一次的 trigger 值,避免组件挂载时误触发
|
// 使用 ref 记录上一次的 trigger 值,避免组件挂载时误触发
|
||||||
const prevTriggerRef = useRef<number>(openModalTrigger || 0);
|
const prevTriggerRef = useRef<number>(openModalTrigger || 0);
|
||||||
|
|
||||||
// 筛选事件列表(按类型过滤,排除系统事件)
|
|
||||||
const events = allEvents.filter(event => event.type === type && event.source !== 'future');
|
|
||||||
|
|
||||||
// 监听外部触发打开新建模态框(修复 bug:只在值变化时触发)
|
// 监听外部触发打开新建模态框(修复 bug:只在值变化时触发)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (openModalTrigger !== undefined && openModalTrigger > prevTriggerRef.current) {
|
if (openModalTrigger !== undefined && openModalTrigger > prevTriggerRef.current) {
|
||||||
@@ -99,14 +105,17 @@ export const EventPanel: React.FC<EventPanelProps> = ({
|
|||||||
setEditingItem(null);
|
setEditingItem(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 删除数据
|
// 删除数据 - 乐观更新模式
|
||||||
const handleDelete = async (id: number): Promise<void> => {
|
const handleDelete = async (id: number): Promise<void> => {
|
||||||
if (!window.confirm('确定要删除吗?')) return;
|
if (!window.confirm('确定要删除吗?')) return;
|
||||||
|
|
||||||
|
// ① 立即从 UI 移除
|
||||||
|
dispatch(removeEvent(id));
|
||||||
|
|
||||||
|
// ② 后台发送 API 请求
|
||||||
try {
|
try {
|
||||||
const base = getApiBase();
|
const base = getApiBase();
|
||||||
|
const response = await fetch(`${base}/api/account/investment-plans/${id}`, {
|
||||||
const response = await fetch(base + `/api/account/investment-plans/${id}`, {
|
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
});
|
});
|
||||||
@@ -118,23 +127,34 @@ export const EventPanel: React.FC<EventPanelProps> = ({
|
|||||||
status: 'success',
|
status: 'success',
|
||||||
duration: 2000,
|
duration: 2000,
|
||||||
});
|
});
|
||||||
loadAllData();
|
} else {
|
||||||
|
throw new Error('删除失败');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('EventPanel', 'handleDelete', error, { itemId: id });
|
// ③ 失败回滚 - 重新加载数据
|
||||||
|
dispatch(fetchAllEvents());
|
||||||
|
logger.error('EventPanel', 'handleDelete rollback', error, { itemId: id });
|
||||||
toast({
|
toast({
|
||||||
title: '删除失败',
|
title: '删除失败,请重试',
|
||||||
status: 'error',
|
status: 'error',
|
||||||
duration: 3000,
|
duration: 3000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 刷新数据
|
||||||
|
const handleRefresh = useCallback(() => {
|
||||||
|
dispatch(fetchAllEvents());
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
// 使用 useCallback 优化回调函数
|
// 使用 useCallback 优化回调函数
|
||||||
const handleEdit = useCallback((item: InvestmentEvent) => {
|
const handleEdit = useCallback((item: InvestmentEvent) => {
|
||||||
handleOpenModal(item);
|
handleOpenModal(item);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 颜色主题
|
||||||
|
const secondaryText = 'rgba(255, 255, 255, 0.6)';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<VStack align="stretch" spacing={4}>
|
<VStack align="stretch" spacing={4}>
|
||||||
@@ -150,17 +170,13 @@ export const EventPanel: React.FC<EventPanelProps> = ({
|
|||||||
</VStack>
|
</VStack>
|
||||||
</Center>
|
</Center>
|
||||||
) : (
|
) : (
|
||||||
<Grid templateColumns={{ base: '1fr', md: 'repeat(2, 1fr)' }} gap={{ base: 3, md: 4 }}>
|
<Grid templateColumns={{ base: '1fr', sm: 'repeat(2, 1fr)', lg: 'repeat(3, 1fr)' }} gap={{ base: 3, md: 4 }}>
|
||||||
{events.map(event => (
|
{events.map(event => (
|
||||||
<EventCard
|
<FUIEventCard
|
||||||
key={event.id}
|
key={event.id}
|
||||||
event={event}
|
event={event}
|
||||||
variant="list"
|
|
||||||
colorScheme={colorScheme}
|
colorScheme={colorScheme}
|
||||||
label={label}
|
label={label}
|
||||||
textColor={textColor}
|
|
||||||
secondaryText={secondaryText}
|
|
||||||
cardBg={cardBg}
|
|
||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
/>
|
/>
|
||||||
@@ -176,7 +192,7 @@ export const EventPanel: React.FC<EventPanelProps> = ({
|
|||||||
mode={modalMode}
|
mode={modalMode}
|
||||||
eventType={type}
|
eventType={type}
|
||||||
editingEvent={editingItem}
|
editingEvent={editingItem}
|
||||||
onSuccess={loadAllData}
|
onSuccess={handleRefresh}
|
||||||
label={label}
|
label={label}
|
||||||
apiEndpoint="investment-plans"
|
apiEndpoint="investment-plans"
|
||||||
/>
|
/>
|
||||||
287
src/views/Center/components/FUIEventCard.tsx
Normal file
287
src/views/Center/components/FUIEventCard.tsx
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
/**
|
||||||
|
* FUIEventCard - 毛玻璃风格投资事件卡片组件
|
||||||
|
*
|
||||||
|
* 融合 ReviewCard 的 UI 风格(毛玻璃 + 金色主题)
|
||||||
|
* 与 EventCard 的功能(编辑、删除、展开描述)
|
||||||
|
*
|
||||||
|
* 用于复盘列表的高级视觉呈现
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useRef, memo } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
IconButton,
|
||||||
|
Flex,
|
||||||
|
VStack,
|
||||||
|
HStack,
|
||||||
|
Text,
|
||||||
|
Tag,
|
||||||
|
TagLabel,
|
||||||
|
TagLeftIcon,
|
||||||
|
Button,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import {
|
||||||
|
FiEdit2,
|
||||||
|
FiTrash2,
|
||||||
|
FiCalendar,
|
||||||
|
FiTrendingUp,
|
||||||
|
FiChevronDown,
|
||||||
|
FiChevronUp,
|
||||||
|
} from 'react-icons/fi';
|
||||||
|
import { FileText, Heart, Target } from 'lucide-react';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import 'dayjs/locale/zh-cn';
|
||||||
|
|
||||||
|
import type { InvestmentEvent } from '@/types';
|
||||||
|
|
||||||
|
dayjs.locale('zh-cn');
|
||||||
|
|
||||||
|
// 主题颜色常量(与 ReviewCard 保持一致)
|
||||||
|
const FUI_THEME = {
|
||||||
|
bg: 'rgba(26, 26, 46, 0.7)',
|
||||||
|
border: 'rgba(212, 175, 55, 0.15)',
|
||||||
|
borderHover: 'rgba(212, 175, 55, 0.3)',
|
||||||
|
text: {
|
||||||
|
primary: 'rgba(255, 255, 255, 0.95)',
|
||||||
|
secondary: 'rgba(255, 255, 255, 0.6)',
|
||||||
|
muted: 'rgba(255, 255, 255, 0.4)',
|
||||||
|
},
|
||||||
|
accent: '#D4AF37', // 金色
|
||||||
|
icon: '#F59E0B', // 橙色图标
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FUIEventCard Props
|
||||||
|
*/
|
||||||
|
export interface FUIEventCardProps {
|
||||||
|
/** 事件数据 */
|
||||||
|
event: InvestmentEvent;
|
||||||
|
/** 显示变体: list(列表视图) | modal(弹窗只读) */
|
||||||
|
variant?: 'list' | 'modal';
|
||||||
|
/** 主题颜色 */
|
||||||
|
colorScheme?: string;
|
||||||
|
/** 显示标签(用于 aria-label) */
|
||||||
|
label?: string;
|
||||||
|
/** 编辑回调 (modal 模式不显示) */
|
||||||
|
onEdit?: (event: InvestmentEvent) => void;
|
||||||
|
/** 删除回调 (modal 模式不显示) */
|
||||||
|
onDelete?: (id: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 描述最大显示行数 */
|
||||||
|
const MAX_LINES = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取事件类型徽章配置
|
||||||
|
*/
|
||||||
|
const getTypeBadge = (event: InvestmentEvent) => {
|
||||||
|
if (event.source === 'future') {
|
||||||
|
return { label: '系统事件', color: '#3B82F6', bg: 'rgba(59, 130, 246, 0.15)' };
|
||||||
|
}
|
||||||
|
if (event.type === 'plan') {
|
||||||
|
return { label: '我的计划', color: '#D4AF37', bg: 'rgba(212, 175, 55, 0.15)' };
|
||||||
|
}
|
||||||
|
return { label: '我的复盘', color: '#10B981', bg: 'rgba(16, 185, 129, 0.15)' };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FUIEventCard 组件
|
||||||
|
*/
|
||||||
|
export const FUIEventCard = memo<FUIEventCardProps>(({
|
||||||
|
event,
|
||||||
|
variant = 'list',
|
||||||
|
colorScheme = 'orange',
|
||||||
|
label = '复盘',
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
}) => {
|
||||||
|
const isModalVariant = variant === 'modal';
|
||||||
|
const typeBadge = getTypeBadge(event);
|
||||||
|
// 展开/收起状态
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
const [isOverflow, setIsOverflow] = useState(false);
|
||||||
|
const descriptionRef = useRef<HTMLParagraphElement>(null);
|
||||||
|
|
||||||
|
// 获取描述内容
|
||||||
|
const description = event.description || event.content || '';
|
||||||
|
|
||||||
|
// 检测描述是否溢出
|
||||||
|
useEffect(() => {
|
||||||
|
const el = descriptionRef.current;
|
||||||
|
if (el && description) {
|
||||||
|
const lineHeight = parseInt(getComputedStyle(el).lineHeight) || 20;
|
||||||
|
const maxHeight = lineHeight * MAX_LINES;
|
||||||
|
setIsOverflow(el.scrollHeight > maxHeight + 5);
|
||||||
|
} else {
|
||||||
|
setIsOverflow(false);
|
||||||
|
}
|
||||||
|
}, [description]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
bg={FUI_THEME.bg}
|
||||||
|
borderRadius="lg"
|
||||||
|
p={4}
|
||||||
|
border="1px solid"
|
||||||
|
borderColor={FUI_THEME.border}
|
||||||
|
backdropFilter="blur(8px)"
|
||||||
|
transition="all 0.3s ease"
|
||||||
|
_hover={{
|
||||||
|
borderColor: FUI_THEME.borderHover,
|
||||||
|
transform: 'translateY(-2px)',
|
||||||
|
boxShadow: '0 4px 12px rgba(212, 175, 55, 0.15)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<VStack align="stretch" spacing={3}>
|
||||||
|
{/* 头部区域:图标 + 标题 + 操作按钮/类型徽章 */}
|
||||||
|
<Flex justify="space-between" align="start" gap={2}>
|
||||||
|
<HStack spacing={2} flex={1}>
|
||||||
|
<Box
|
||||||
|
as={FileText}
|
||||||
|
boxSize={4}
|
||||||
|
color={FUI_THEME.icon}
|
||||||
|
flexShrink={0}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
fontSize="sm"
|
||||||
|
fontWeight="bold"
|
||||||
|
color={FUI_THEME.text.primary}
|
||||||
|
noOfLines={1}
|
||||||
|
>
|
||||||
|
[{event.title}]
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{/* modal 模式: 显示类型徽章 */}
|
||||||
|
{isModalVariant ? (
|
||||||
|
<Tag
|
||||||
|
size="sm"
|
||||||
|
bg={typeBadge.bg}
|
||||||
|
color={typeBadge.color}
|
||||||
|
border="1px solid"
|
||||||
|
borderColor={`${typeBadge.color}40`}
|
||||||
|
flexShrink={0}
|
||||||
|
>
|
||||||
|
<TagLabel fontSize="xs">{typeBadge.label}</TagLabel>
|
||||||
|
</Tag>
|
||||||
|
) : (
|
||||||
|
/* list 模式: 显示编辑/删除按钮 */
|
||||||
|
(onEdit || onDelete) && (
|
||||||
|
<HStack spacing={0}>
|
||||||
|
{onEdit && (
|
||||||
|
<IconButton
|
||||||
|
icon={<FiEdit2 size={14} />}
|
||||||
|
size="xs"
|
||||||
|
variant="ghost"
|
||||||
|
color={FUI_THEME.text.secondary}
|
||||||
|
_hover={{ color: FUI_THEME.accent, bg: 'rgba(212, 175, 55, 0.1)' }}
|
||||||
|
onClick={() => onEdit(event)}
|
||||||
|
aria-label={`编辑${label}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{onDelete && (
|
||||||
|
<IconButton
|
||||||
|
icon={<FiTrash2 size={14} />}
|
||||||
|
size="xs"
|
||||||
|
variant="ghost"
|
||||||
|
color={FUI_THEME.text.secondary}
|
||||||
|
_hover={{ color: '#EF4444', bg: 'rgba(239, 68, 68, 0.1)' }}
|
||||||
|
onClick={() => onDelete(event.id)}
|
||||||
|
aria-label={`删除${label}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{/* 日期行 */}
|
||||||
|
<HStack spacing={2}>
|
||||||
|
<FiCalendar size={12} color={FUI_THEME.text.muted} />
|
||||||
|
<Text fontSize="xs" color={FUI_THEME.text.secondary}>
|
||||||
|
{dayjs(event.event_date || event.date).format('YYYY年MM月DD日')}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{/* 描述内容(可展开/收起) */}
|
||||||
|
{description && (
|
||||||
|
<Box>
|
||||||
|
<HStack spacing={1} align="start">
|
||||||
|
<Text color={FUI_THEME.text.muted} fontSize="xs">•</Text>
|
||||||
|
{event.type === 'plan' ? (
|
||||||
|
<>
|
||||||
|
<Box as={Target} boxSize={3} color={FUI_THEME.accent} flexShrink={0} mt="2px" />
|
||||||
|
<Text color={FUI_THEME.text.secondary} fontSize="xs" flexShrink={0}>
|
||||||
|
计划内容:
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Box as={Heart} boxSize={3} color="#EF4444" flexShrink={0} mt="2px" />
|
||||||
|
<Text color={FUI_THEME.text.secondary} fontSize="xs" flexShrink={0}>
|
||||||
|
心得:
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
<Text
|
||||||
|
ref={descriptionRef}
|
||||||
|
fontSize="xs"
|
||||||
|
color={FUI_THEME.text.primary}
|
||||||
|
noOfLines={isExpanded ? undefined : MAX_LINES}
|
||||||
|
whiteSpace="pre-wrap"
|
||||||
|
mt={1}
|
||||||
|
pl={5}
|
||||||
|
>
|
||||||
|
{description}
|
||||||
|
</Text>
|
||||||
|
{isOverflow && (
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="ghost"
|
||||||
|
color={FUI_THEME.accent}
|
||||||
|
mt={1}
|
||||||
|
ml={4}
|
||||||
|
_hover={{ bg: 'rgba(212, 175, 55, 0.1)' }}
|
||||||
|
rightIcon={isExpanded ? <FiChevronUp /> : <FiChevronDown />}
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
>
|
||||||
|
{isExpanded ? '收起' : '展开'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 股票标签 */}
|
||||||
|
{event.stocks && event.stocks.length > 0 && (
|
||||||
|
<HStack spacing={2} flexWrap="wrap" gap={1}>
|
||||||
|
<Text fontSize="xs" color={FUI_THEME.text.secondary}>
|
||||||
|
相关股票:
|
||||||
|
</Text>
|
||||||
|
{event.stocks.map((stock, idx) => {
|
||||||
|
const stockCode = typeof stock === 'string' ? stock : stock.code;
|
||||||
|
const displayText = typeof stock === 'string' ? stock : `${stock.name}(${stock.code})`;
|
||||||
|
return (
|
||||||
|
<Tag
|
||||||
|
key={stockCode || idx}
|
||||||
|
size="sm"
|
||||||
|
bg="rgba(212, 175, 55, 0.1)"
|
||||||
|
color={FUI_THEME.accent}
|
||||||
|
border="1px solid"
|
||||||
|
borderColor="rgba(212, 175, 55, 0.2)"
|
||||||
|
>
|
||||||
|
<TagLeftIcon as={FiTrendingUp} boxSize={3} />
|
||||||
|
<TagLabel fontSize="xs">{displayText}</TagLabel>
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
FUIEventCard.displayName = 'FUIEventCard';
|
||||||
|
|
||||||
|
export default FUIEventCard;
|
||||||
285
src/views/Center/components/InvestmentPlanningCenter.tsx
Normal file
285
src/views/Center/components/InvestmentPlanningCenter.tsx
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
/**
|
||||||
|
* InvestmentPlanningCenter - 投资规划中心主组件 (Redux 版本)
|
||||||
|
*
|
||||||
|
* 使用 Redux 管理数据,确保列表和日历视图数据同步
|
||||||
|
*
|
||||||
|
* 组件架构:
|
||||||
|
* - InvestmentPlanningCenter (主组件)
|
||||||
|
* - CalendarPanel (日历面板,懒加载)
|
||||||
|
* - EventPanel (通用事件面板,用于计划和复盘)
|
||||||
|
* - PlanningContext (UI 状态共享)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useMemo, Suspense, lazy } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Heading,
|
||||||
|
HStack,
|
||||||
|
Flex,
|
||||||
|
Icon,
|
||||||
|
useColorModeValue,
|
||||||
|
useToast,
|
||||||
|
Tabs,
|
||||||
|
TabList,
|
||||||
|
TabPanels,
|
||||||
|
Tab,
|
||||||
|
TabPanel,
|
||||||
|
Button,
|
||||||
|
ButtonGroup,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import {
|
||||||
|
FiCalendar,
|
||||||
|
FiFileText,
|
||||||
|
FiList,
|
||||||
|
FiPlus,
|
||||||
|
} from 'react-icons/fi';
|
||||||
|
import { Target } from 'lucide-react';
|
||||||
|
import GlassCard from '@components/GlassCard';
|
||||||
|
|
||||||
|
import { PlanningDataProvider } from './PlanningContext';
|
||||||
|
import { EventPanelSkeleton, CalendarPanelSkeleton } from './skeletons';
|
||||||
|
import type { PlanningContextValue } from '@/types';
|
||||||
|
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||||
|
import {
|
||||||
|
fetchAllEvents,
|
||||||
|
selectAllEvents,
|
||||||
|
selectPlanningLoading,
|
||||||
|
selectPlans,
|
||||||
|
selectReviews,
|
||||||
|
} from '@/store/slices/planningSlice';
|
||||||
|
|
||||||
|
// 懒加载子面板组件(实现代码分割)
|
||||||
|
const CalendarPanel = lazy(() =>
|
||||||
|
import('./CalendarPanel').then(module => ({ default: module.CalendarPanel }))
|
||||||
|
);
|
||||||
|
const EventPanel = lazy(() =>
|
||||||
|
import('./EventPanel').then(module => ({ default: module.EventPanel }))
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* InvestmentPlanningCenter 主组件
|
||||||
|
*/
|
||||||
|
const InvestmentPlanningCenter: React.FC = () => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
// Redux 状态
|
||||||
|
const allEvents = useAppSelector(selectAllEvents);
|
||||||
|
const loading = useAppSelector(selectPlanningLoading);
|
||||||
|
const plans = useAppSelector(selectPlans);
|
||||||
|
const reviews = useAppSelector(selectReviews);
|
||||||
|
|
||||||
|
// 颜色主题
|
||||||
|
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');
|
||||||
|
|
||||||
|
// UI 状态
|
||||||
|
const [viewMode, setViewMode] = useState<'calendar' | 'list'>('list');
|
||||||
|
const [listTab, setListTab] = useState<number>(0); // 0: 我的计划, 1: 我的复盘
|
||||||
|
const [openPlanModalTrigger, setOpenPlanModalTrigger] = useState<number>(0);
|
||||||
|
const [openReviewModalTrigger, setOpenReviewModalTrigger] = useState<number>(0);
|
||||||
|
|
||||||
|
// 组件挂载时加载数据
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(fetchAllEvents());
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
// 刷新数据的方法(供子组件调用)
|
||||||
|
const loadAllData = async (): Promise<void> => {
|
||||||
|
await dispatch(fetchAllEvents());
|
||||||
|
};
|
||||||
|
|
||||||
|
// 提供给子组件的 Context 值
|
||||||
|
const contextValue: PlanningContextValue = useMemo(
|
||||||
|
() => ({
|
||||||
|
allEvents,
|
||||||
|
setAllEvents: () => {}, // Redux 管理,不需要 setter
|
||||||
|
loadAllData,
|
||||||
|
loading,
|
||||||
|
setLoading: () => {}, // Redux 管理,不需要 setter
|
||||||
|
openPlanModalTrigger,
|
||||||
|
openReviewModalTrigger,
|
||||||
|
toast,
|
||||||
|
borderColor,
|
||||||
|
textColor,
|
||||||
|
secondaryText,
|
||||||
|
cardBg,
|
||||||
|
setViewMode,
|
||||||
|
setListTab,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
allEvents,
|
||||||
|
loading,
|
||||||
|
openPlanModalTrigger,
|
||||||
|
openReviewModalTrigger,
|
||||||
|
toast,
|
||||||
|
borderColor,
|
||||||
|
textColor,
|
||||||
|
secondaryText,
|
||||||
|
cardBg,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 金色主题色
|
||||||
|
const goldAccent = 'rgba(212, 175, 55, 0.9)';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PlanningDataProvider value={contextValue}>
|
||||||
|
<GlassCard variant="transparent" cornerDecor padding="lg">
|
||||||
|
{/* 标题区域 */}
|
||||||
|
<Flex justify="space-between" align="center" wrap="wrap" gap={2} mb={{ base: 3, md: 4 }}>
|
||||||
|
<HStack spacing={{ base: 2, md: 3 }}>
|
||||||
|
<Box
|
||||||
|
as={Target}
|
||||||
|
boxSize={{ base: 5, md: 6 }}
|
||||||
|
color={goldAccent}
|
||||||
|
/>
|
||||||
|
<Heading
|
||||||
|
size={{ base: 'sm', md: 'md' }}
|
||||||
|
bgGradient="linear(to-r, #D4AF37, #F5E6A3)"
|
||||||
|
bgClip="text"
|
||||||
|
>
|
||||||
|
投资规划中心
|
||||||
|
</Heading>
|
||||||
|
</HStack>
|
||||||
|
{/* 视图切换按钮组 - H5隐藏 */}
|
||||||
|
<ButtonGroup size="sm" isAttached display={{ base: 'none', md: 'flex' }}>
|
||||||
|
<Button
|
||||||
|
leftIcon={<Icon as={FiList} boxSize={4} />}
|
||||||
|
bg={viewMode === 'list' ? 'rgba(212, 175, 55, 0.2)' : 'transparent'}
|
||||||
|
color={viewMode === 'list' ? goldAccent : 'rgba(255, 255, 255, 0.6)'}
|
||||||
|
border="1px solid"
|
||||||
|
borderColor={viewMode === 'list' ? 'rgba(212, 175, 55, 0.4)' : 'rgba(212, 175, 55, 0.2)'}
|
||||||
|
_hover={{ bg: 'rgba(212, 175, 55, 0.15)', color: goldAccent }}
|
||||||
|
onClick={() => setViewMode('list')}
|
||||||
|
>
|
||||||
|
列表视图
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
leftIcon={<Icon as={FiCalendar} boxSize={4} />}
|
||||||
|
bg={viewMode === 'calendar' ? 'rgba(212, 175, 55, 0.2)' : 'transparent'}
|
||||||
|
color={viewMode === 'calendar' ? goldAccent : 'rgba(255, 255, 255, 0.6)'}
|
||||||
|
border="1px solid"
|
||||||
|
borderColor={viewMode === 'calendar' ? 'rgba(212, 175, 55, 0.4)' : 'rgba(212, 175, 55, 0.2)'}
|
||||||
|
_hover={{ bg: 'rgba(212, 175, 55, 0.15)', color: goldAccent }}
|
||||||
|
onClick={() => setViewMode('calendar')}
|
||||||
|
>
|
||||||
|
日历视图
|
||||||
|
</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{/* 渐变分割线 */}
|
||||||
|
<Box
|
||||||
|
h="1px"
|
||||||
|
bgGradient="linear(to-r, transparent, rgba(212, 175, 55, 0.4), transparent)"
|
||||||
|
mb={{ base: 3, md: 4 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 内容区域 */}
|
||||||
|
<Box>
|
||||||
|
{viewMode === 'calendar' ? (
|
||||||
|
/* 日历视图 */
|
||||||
|
<Suspense fallback={<CalendarPanelSkeleton />}>
|
||||||
|
<CalendarPanel />
|
||||||
|
</Suspense>
|
||||||
|
) : (
|
||||||
|
/* 列表视图:我的计划 / 我的复盘 切换 */
|
||||||
|
<Tabs
|
||||||
|
index={listTab}
|
||||||
|
onChange={setListTab}
|
||||||
|
variant="unstyled"
|
||||||
|
size={{ base: 'sm', md: 'md' }}
|
||||||
|
>
|
||||||
|
<Flex justify="space-between" align="center" mb={{ base: 2, md: 4 }} flexWrap="nowrap" gap={1}>
|
||||||
|
<TabList mb={0} flex="1" minW={0}>
|
||||||
|
<Tab
|
||||||
|
fontSize={{ base: '11px', md: 'sm' }}
|
||||||
|
px={{ base: 2, md: 4 }}
|
||||||
|
py={2}
|
||||||
|
whiteSpace="nowrap"
|
||||||
|
color="rgba(255, 255, 255, 0.6)"
|
||||||
|
_selected={{
|
||||||
|
color: goldAccent,
|
||||||
|
borderBottom: '2px solid',
|
||||||
|
borderColor: goldAccent,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box as={Target} boxSize={{ base: 3, md: 4 }} mr={1} />
|
||||||
|
我的计划 ({plans.length})
|
||||||
|
</Tab>
|
||||||
|
<Tab
|
||||||
|
fontSize={{ base: '11px', md: 'sm' }}
|
||||||
|
px={{ base: 2, md: 4 }}
|
||||||
|
py={2}
|
||||||
|
whiteSpace="nowrap"
|
||||||
|
color="rgba(255, 255, 255, 0.6)"
|
||||||
|
_selected={{
|
||||||
|
color: goldAccent,
|
||||||
|
borderBottom: '2px solid',
|
||||||
|
borderColor: goldAccent,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon as={FiFileText} mr={1} boxSize={{ base: 3, md: 4 }} />
|
||||||
|
我的复盘 ({reviews.length})
|
||||||
|
</Tab>
|
||||||
|
</TabList>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
bg="rgba(212, 175, 55, 0.2)"
|
||||||
|
color={goldAccent}
|
||||||
|
border="1px solid"
|
||||||
|
borderColor="rgba(212, 175, 55, 0.3)"
|
||||||
|
leftIcon={<Icon as={FiPlus} boxSize={3} />}
|
||||||
|
fontSize={{ base: '11px', md: 'sm' }}
|
||||||
|
flexShrink={0}
|
||||||
|
_hover={{ bg: 'rgba(212, 175, 55, 0.3)' }}
|
||||||
|
onClick={() => {
|
||||||
|
if (listTab === 0) {
|
||||||
|
setOpenPlanModalTrigger(prev => prev + 1);
|
||||||
|
} else {
|
||||||
|
setOpenReviewModalTrigger(prev => prev + 1);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{listTab === 0 ? '新建计划' : '新建复盘'}
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<TabPanels>
|
||||||
|
{/* 计划列表面板 */}
|
||||||
|
<TabPanel px={0}>
|
||||||
|
<Suspense fallback={<EventPanelSkeleton />}>
|
||||||
|
<EventPanel
|
||||||
|
type="plan"
|
||||||
|
colorScheme="orange"
|
||||||
|
label="计划"
|
||||||
|
openModalTrigger={openPlanModalTrigger}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
{/* 复盘列表面板 */}
|
||||||
|
<TabPanel px={0}>
|
||||||
|
<Suspense fallback={<EventPanelSkeleton />}>
|
||||||
|
<EventPanel
|
||||||
|
type="review"
|
||||||
|
colorScheme="orange"
|
||||||
|
label="复盘"
|
||||||
|
openModalTrigger={openReviewModalTrigger}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</TabPanel>
|
||||||
|
</TabPanels>
|
||||||
|
</Tabs>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</GlassCard>
|
||||||
|
</PlanningDataProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default InvestmentPlanningCenter;
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* CalendarPanelSkeleton - 日历面板骨架屏组件
|
||||||
|
* 用于视图切换时的加载占位
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { memo } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
HStack,
|
||||||
|
SimpleGrid,
|
||||||
|
Skeleton,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
|
||||||
|
// 骨架屏主题配色(黑金主题)
|
||||||
|
const SKELETON_THEME = {
|
||||||
|
startColor: 'rgba(26, 32, 44, 0.6)',
|
||||||
|
endColor: 'rgba(212, 175, 55, 0.2)',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CalendarPanelSkeleton 组件
|
||||||
|
* 模拟 FullCalendar 的布局结构
|
||||||
|
*/
|
||||||
|
export const CalendarPanelSkeleton: React.FC = memo(() => {
|
||||||
|
return (
|
||||||
|
<Box height={{ base: '380px', md: '560px' }}>
|
||||||
|
{/* 工具栏骨架 */}
|
||||||
|
<HStack justify="space-between" mb={4}>
|
||||||
|
{/* 左侧:导航按钮 */}
|
||||||
|
<HStack spacing={2}>
|
||||||
|
<Skeleton
|
||||||
|
height="32px"
|
||||||
|
width="32px"
|
||||||
|
borderRadius="md"
|
||||||
|
startColor={SKELETON_THEME.startColor}
|
||||||
|
endColor={SKELETON_THEME.endColor}
|
||||||
|
/>
|
||||||
|
<Skeleton
|
||||||
|
height="32px"
|
||||||
|
width="32px"
|
||||||
|
borderRadius="md"
|
||||||
|
startColor={SKELETON_THEME.startColor}
|
||||||
|
endColor={SKELETON_THEME.endColor}
|
||||||
|
/>
|
||||||
|
<Skeleton
|
||||||
|
height="32px"
|
||||||
|
width="60px"
|
||||||
|
borderRadius="md"
|
||||||
|
startColor={SKELETON_THEME.startColor}
|
||||||
|
endColor={SKELETON_THEME.endColor}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{/* 中间:月份标题 */}
|
||||||
|
<Skeleton
|
||||||
|
height="28px"
|
||||||
|
width="150px"
|
||||||
|
startColor={SKELETON_THEME.startColor}
|
||||||
|
endColor={SKELETON_THEME.endColor}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 右侧占位(保持对称) */}
|
||||||
|
<Box width="126px" />
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{/* 日历网格骨架 */}
|
||||||
|
<SimpleGrid columns={7} spacing={1}>
|
||||||
|
{/* 星期头(周日 - 周六) */}
|
||||||
|
{[...Array(7)].map((_, i) => (
|
||||||
|
<Skeleton
|
||||||
|
key={`header-${i}`}
|
||||||
|
height="30px"
|
||||||
|
borderRadius="sm"
|
||||||
|
startColor={SKELETON_THEME.startColor}
|
||||||
|
endColor={SKELETON_THEME.endColor}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 日期格子(5行 x 7列 = 35个) */}
|
||||||
|
{[...Array(35)].map((_, i) => (
|
||||||
|
<Skeleton
|
||||||
|
key={`cell-${i}`}
|
||||||
|
height="85px"
|
||||||
|
borderRadius="sm"
|
||||||
|
startColor={SKELETON_THEME.startColor}
|
||||||
|
endColor={SKELETON_THEME.endColor}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
CalendarPanelSkeleton.displayName = 'CalendarPanelSkeleton';
|
||||||
|
|
||||||
|
export default CalendarPanelSkeleton;
|
||||||
112
src/views/Center/components/skeletons/EventPanelSkeleton.tsx
Normal file
112
src/views/Center/components/skeletons/EventPanelSkeleton.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* EventPanelSkeleton - 事件列表骨架屏组件
|
||||||
|
* 用于视图切换时的加载占位
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { memo } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
VStack,
|
||||||
|
HStack,
|
||||||
|
Skeleton,
|
||||||
|
SkeletonText,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
|
||||||
|
// 骨架屏主题配色(黑金主题)
|
||||||
|
const SKELETON_THEME = {
|
||||||
|
startColor: 'rgba(26, 32, 44, 0.6)',
|
||||||
|
endColor: 'rgba(212, 175, 55, 0.2)',
|
||||||
|
cardBg: 'rgba(26, 26, 46, 0.7)',
|
||||||
|
cardBorder: 'rgba(212, 175, 55, 0.15)',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单个事件卡片骨架
|
||||||
|
*/
|
||||||
|
const EventCardSkeleton: React.FC = () => (
|
||||||
|
<Box
|
||||||
|
p={4}
|
||||||
|
borderRadius="lg"
|
||||||
|
bg={SKELETON_THEME.cardBg}
|
||||||
|
border="1px solid"
|
||||||
|
borderColor={SKELETON_THEME.cardBorder}
|
||||||
|
>
|
||||||
|
{/* 头部:图标 + 标题 */}
|
||||||
|
<HStack spacing={3} mb={3}>
|
||||||
|
<Skeleton
|
||||||
|
height="16px"
|
||||||
|
width="16px"
|
||||||
|
borderRadius="sm"
|
||||||
|
startColor={SKELETON_THEME.startColor}
|
||||||
|
endColor={SKELETON_THEME.endColor}
|
||||||
|
/>
|
||||||
|
<Skeleton
|
||||||
|
height="16px"
|
||||||
|
width="180px"
|
||||||
|
startColor={SKELETON_THEME.startColor}
|
||||||
|
endColor={SKELETON_THEME.endColor}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{/* 日期行 */}
|
||||||
|
<HStack spacing={2} mb={2}>
|
||||||
|
<Skeleton
|
||||||
|
height="12px"
|
||||||
|
width="12px"
|
||||||
|
startColor={SKELETON_THEME.startColor}
|
||||||
|
endColor={SKELETON_THEME.endColor}
|
||||||
|
/>
|
||||||
|
<Skeleton
|
||||||
|
height="12px"
|
||||||
|
width="120px"
|
||||||
|
startColor={SKELETON_THEME.startColor}
|
||||||
|
endColor={SKELETON_THEME.endColor}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{/* 描述内容 */}
|
||||||
|
<SkeletonText
|
||||||
|
noOfLines={2}
|
||||||
|
spacing={2}
|
||||||
|
skeletonHeight={3}
|
||||||
|
startColor={SKELETON_THEME.startColor}
|
||||||
|
endColor={SKELETON_THEME.endColor}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 股票标签行 */}
|
||||||
|
<HStack spacing={2} mt={3}>
|
||||||
|
<Skeleton
|
||||||
|
height="20px"
|
||||||
|
width="60px"
|
||||||
|
borderRadius="full"
|
||||||
|
startColor={SKELETON_THEME.startColor}
|
||||||
|
endColor={SKELETON_THEME.endColor}
|
||||||
|
/>
|
||||||
|
<Skeleton
|
||||||
|
height="20px"
|
||||||
|
width="80px"
|
||||||
|
borderRadius="full"
|
||||||
|
startColor={SKELETON_THEME.startColor}
|
||||||
|
endColor={SKELETON_THEME.endColor}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EventPanelSkeleton 组件
|
||||||
|
* 显示多个卡片骨架屏
|
||||||
|
*/
|
||||||
|
export const EventPanelSkeleton: React.FC = memo(() => {
|
||||||
|
return (
|
||||||
|
<VStack spacing={4} align="stretch" py={2}>
|
||||||
|
{[1, 2, 3, 4].map(i => (
|
||||||
|
<EventCardSkeleton key={i} />
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
EventPanelSkeleton.displayName = 'EventPanelSkeleton';
|
||||||
|
|
||||||
|
export default EventPanelSkeleton;
|
||||||
6
src/views/Center/components/skeletons/index.ts
Normal file
6
src/views/Center/components/skeletons/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* Center 模块骨架屏组件统一导出
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { EventPanelSkeleton } from './EventPanelSkeleton';
|
||||||
|
export { CalendarPanelSkeleton } from './CalendarPanelSkeleton';
|
||||||
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';
|
||||||
@@ -16,12 +16,6 @@ import {
|
|||||||
Badge,
|
Badge,
|
||||||
Center,
|
Center,
|
||||||
Spinner,
|
Spinner,
|
||||||
Modal,
|
|
||||||
ModalOverlay,
|
|
||||||
ModalContent,
|
|
||||||
ModalHeader,
|
|
||||||
ModalBody,
|
|
||||||
ModalCloseButton,
|
|
||||||
useColorModeValue,
|
useColorModeValue,
|
||||||
useToast,
|
useToast,
|
||||||
useDisclosure,
|
useDisclosure,
|
||||||
@@ -34,7 +28,7 @@ import { useNotification } from '@contexts/NotificationContext';
|
|||||||
import EventScrollList from './EventScrollList';
|
import EventScrollList from './EventScrollList';
|
||||||
import ModeToggleButtons from './ModeToggleButtons';
|
import ModeToggleButtons from './ModeToggleButtons';
|
||||||
import PaginationControl from './PaginationControl';
|
import PaginationControl from './PaginationControl';
|
||||||
import DynamicNewsDetailPanel from '@components/EventDetailPanel';
|
import EventDetailModal from '../EventDetailModal';
|
||||||
import CompactSearchBox from '../SearchFilters/CompactSearchBox';
|
import CompactSearchBox from '../SearchFilters/CompactSearchBox';
|
||||||
import {
|
import {
|
||||||
fetchDynamicNews,
|
fetchDynamicNews,
|
||||||
@@ -692,21 +686,12 @@ const [currentMode, setCurrentMode] = useState('vertical');
|
|||||||
</Box>
|
</Box>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
|
|
||||||
{/* 四排模式详情弹窗 - 未打开时不渲染 */}
|
{/* 四排/主线模式详情弹窗 - 深色风格 */}
|
||||||
{isModalOpen && (
|
<EventDetailModal
|
||||||
<Modal isOpen={isModalOpen} onClose={onModalClose} size="full" scrollBehavior="inside">
|
open={isModalOpen}
|
||||||
<ModalOverlay />
|
onClose={onModalClose}
|
||||||
<ModalContent maxW="1600px" mx="auto" my={8}>
|
event={modalEvent}
|
||||||
<ModalHeader>
|
/>
|
||||||
{modalEvent?.title || '事件详情'}
|
|
||||||
</ModalHeader>
|
|
||||||
<ModalCloseButton />
|
|
||||||
<ModalBody pb={6}>
|
|
||||||
{modalEvent && <DynamicNewsDetailPanel event={modalEvent} />}
|
|
||||||
</ModalBody>
|
|
||||||
</ModalContent>
|
|
||||||
</Modal>
|
|
||||||
)}
|
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,9 +25,11 @@ import {
|
|||||||
} from "@chakra-ui/react";
|
} from "@chakra-ui/react";
|
||||||
import { ChevronDownIcon, ChevronUpIcon, RepeatIcon } from "@chakra-ui/icons";
|
import { ChevronDownIcon, ChevronUpIcon, RepeatIcon } from "@chakra-ui/icons";
|
||||||
import { FiTrendingUp, FiZap } from "react-icons/fi";
|
import { FiTrendingUp, FiZap } from "react-icons/fi";
|
||||||
|
import { FireOutlined } from "@ant-design/icons";
|
||||||
|
import dayjs from "dayjs";
|
||||||
import { Select } from "antd";
|
import { Select } from "antd";
|
||||||
import MiniEventCard from "../../EventCard/MiniEventCard";
|
|
||||||
import { getApiBase } from "@utils/apiConfig";
|
import { getApiBase } from "@utils/apiConfig";
|
||||||
|
import { getChangeColor } from "@utils/colorUtils";
|
||||||
import "../../SearchFilters/CompactSearchBox.css";
|
import "../../SearchFilters/CompactSearchBox.css";
|
||||||
|
|
||||||
// 固定深色主题颜色
|
// 固定深色主题颜色
|
||||||
@@ -47,6 +49,179 @@ const COLORS = {
|
|||||||
// 每次加载的事件数量
|
// 每次加载的事件数量
|
||||||
const EVENTS_PER_LOAD = 12;
|
const EVENTS_PER_LOAD = 12;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化时间显示 - 始终显示日期,避免跨天混淆
|
||||||
|
*/
|
||||||
|
const formatEventTime = (dateStr) => {
|
||||||
|
if (!dateStr) return "";
|
||||||
|
const date = dayjs(dateStr);
|
||||||
|
const now = dayjs();
|
||||||
|
const isToday = date.isSame(now, "day");
|
||||||
|
const isYesterday = date.isSame(now.subtract(1, "day"), "day");
|
||||||
|
|
||||||
|
// 始终显示日期,用标签区分今天/昨天
|
||||||
|
if (isToday) {
|
||||||
|
return `今天 ${date.format("MM-DD HH:mm")}`;
|
||||||
|
} else if (isYesterday) {
|
||||||
|
return `昨天 ${date.format("MM-DD HH:mm")}`;
|
||||||
|
} else {
|
||||||
|
return date.format("MM-DD HH:mm");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据涨跌幅获取背景色
|
||||||
|
*/
|
||||||
|
const getChangeBgColor = (value) => {
|
||||||
|
if (value == null || isNaN(value)) return "transparent";
|
||||||
|
const absChange = Math.abs(value);
|
||||||
|
if (value > 0) {
|
||||||
|
if (absChange >= 5) return "rgba(239, 68, 68, 0.12)";
|
||||||
|
if (absChange >= 3) return "rgba(239, 68, 68, 0.08)";
|
||||||
|
return "rgba(239, 68, 68, 0.05)";
|
||||||
|
} else if (value < 0) {
|
||||||
|
if (absChange >= 5) return "rgba(16, 185, 129, 0.12)";
|
||||||
|
if (absChange >= 3) return "rgba(16, 185, 129, 0.08)";
|
||||||
|
return "rgba(16, 185, 129, 0.05)";
|
||||||
|
}
|
||||||
|
return "transparent";
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单个事件项组件 - 卡片式布局
|
||||||
|
*/
|
||||||
|
const TimelineEventItem = React.memo(({ event, isSelected, onEventClick }) => {
|
||||||
|
// 使用 related_max_chg 作为主要涨幅显示
|
||||||
|
const maxChange = event.related_max_chg;
|
||||||
|
const avgChange = event.related_avg_chg;
|
||||||
|
const hasMaxChange = maxChange != null && !isNaN(maxChange);
|
||||||
|
const hasAvgChange = avgChange != null && !isNaN(avgChange);
|
||||||
|
|
||||||
|
// 用于背景色的涨幅(使用平均超额)
|
||||||
|
const bgValue = avgChange;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
w="100%"
|
||||||
|
cursor="pointer"
|
||||||
|
onClick={() => onEventClick?.(event)}
|
||||||
|
bg={isSelected ? "rgba(66, 153, 225, 0.15)" : getChangeBgColor(bgValue)}
|
||||||
|
borderWidth="1px"
|
||||||
|
borderColor={isSelected ? "#4299e1" : COLORS.cardBorderColor}
|
||||||
|
borderRadius="lg"
|
||||||
|
p={3}
|
||||||
|
mb={2}
|
||||||
|
_hover={{
|
||||||
|
bg: isSelected ? "rgba(66, 153, 225, 0.2)" : "rgba(255, 255, 255, 0.06)",
|
||||||
|
borderColor: isSelected ? "#63b3ed" : "#5a6070",
|
||||||
|
transform: "translateY(-1px)",
|
||||||
|
}}
|
||||||
|
transition="all 0.2s ease"
|
||||||
|
>
|
||||||
|
{/* 第一行:时间 */}
|
||||||
|
<Text
|
||||||
|
fontSize="xs"
|
||||||
|
color={COLORS.secondaryTextColor}
|
||||||
|
mb={1.5}
|
||||||
|
>
|
||||||
|
{formatEventTime(event.created_at || event.event_time)}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* 第二行:标题 */}
|
||||||
|
<Text
|
||||||
|
fontSize="sm"
|
||||||
|
color="#e2e8f0"
|
||||||
|
fontWeight="medium"
|
||||||
|
noOfLines={2}
|
||||||
|
lineHeight="1.5"
|
||||||
|
mb={2}
|
||||||
|
_hover={{ textDecoration: "underline", color: "#fff" }}
|
||||||
|
>
|
||||||
|
{event.title}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* 第三行:涨跌幅指标 */}
|
||||||
|
{(hasMaxChange || hasAvgChange) && (
|
||||||
|
<HStack spacing={2} flexWrap="wrap">
|
||||||
|
{/* 最大超额 */}
|
||||||
|
{hasMaxChange && (
|
||||||
|
<Box
|
||||||
|
bg={maxChange > 0 ? "rgba(239, 68, 68, 0.15)" : "rgba(16, 185, 129, 0.15)"}
|
||||||
|
borderWidth="1px"
|
||||||
|
borderColor={maxChange > 0 ? "rgba(239, 68, 68, 0.3)" : "rgba(16, 185, 129, 0.3)"}
|
||||||
|
borderRadius="md"
|
||||||
|
px={2}
|
||||||
|
py={1}
|
||||||
|
>
|
||||||
|
<Text fontSize="xs" color={COLORS.secondaryTextColor} mb={0.5}>
|
||||||
|
最大超额
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
fontSize="sm"
|
||||||
|
fontWeight="bold"
|
||||||
|
color={maxChange > 0 ? "#fc8181" : "#68d391"}
|
||||||
|
>
|
||||||
|
{maxChange > 0 ? "+" : ""}{maxChange.toFixed(2)}%
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 平均超额 */}
|
||||||
|
{hasAvgChange && (
|
||||||
|
<Box
|
||||||
|
bg={avgChange > 0 ? "rgba(239, 68, 68, 0.15)" : "rgba(16, 185, 129, 0.15)"}
|
||||||
|
borderWidth="1px"
|
||||||
|
borderColor={avgChange > 0 ? "rgba(239, 68, 68, 0.3)" : "rgba(16, 185, 129, 0.3)"}
|
||||||
|
borderRadius="md"
|
||||||
|
px={2}
|
||||||
|
py={1}
|
||||||
|
>
|
||||||
|
<Text fontSize="xs" color={COLORS.secondaryTextColor} mb={0.5}>
|
||||||
|
平均超额
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
fontSize="sm"
|
||||||
|
fontWeight="bold"
|
||||||
|
color={avgChange > 0 ? "#fc8181" : "#68d391"}
|
||||||
|
>
|
||||||
|
{avgChange > 0 ? "+" : ""}{avgChange.toFixed(2)}%
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 超预期得分 */}
|
||||||
|
{event.expectation_surprise_score != null && (
|
||||||
|
<Box
|
||||||
|
bg={event.expectation_surprise_score >= 60 ? "rgba(239, 68, 68, 0.15)" :
|
||||||
|
event.expectation_surprise_score >= 40 ? "rgba(237, 137, 54, 0.15)" : "rgba(66, 153, 225, 0.15)"}
|
||||||
|
borderWidth="1px"
|
||||||
|
borderColor={event.expectation_surprise_score >= 60 ? "rgba(239, 68, 68, 0.3)" :
|
||||||
|
event.expectation_surprise_score >= 40 ? "rgba(237, 137, 54, 0.3)" : "rgba(66, 153, 225, 0.3)"}
|
||||||
|
borderRadius="md"
|
||||||
|
px={2}
|
||||||
|
py={1}
|
||||||
|
>
|
||||||
|
<Text fontSize="xs" color={COLORS.secondaryTextColor} mb={0.5}>
|
||||||
|
超预期
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
fontSize="sm"
|
||||||
|
fontWeight="bold"
|
||||||
|
color={event.expectation_surprise_score >= 60 ? "#fc8181" :
|
||||||
|
event.expectation_surprise_score >= 40 ? "#ed8936" : "#63b3ed"}
|
||||||
|
>
|
||||||
|
{Math.round(event.expectation_surprise_score)}分
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
TimelineEventItem.displayName = "TimelineEventItem";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 单个主线卡片组件 - 支持懒加载
|
* 单个主线卡片组件 - 支持懒加载
|
||||||
*/
|
*/
|
||||||
@@ -70,6 +245,23 @@ const MainlineCard = React.memo(
|
|||||||
}
|
}
|
||||||
}, [isExpanded]);
|
}, [isExpanded]);
|
||||||
|
|
||||||
|
// 找出最大超额涨幅最高的事件(HOT 事件)
|
||||||
|
const hotEvent = useMemo(() => {
|
||||||
|
if (!mainline.events || mainline.events.length === 0) return null;
|
||||||
|
let maxChange = -Infinity;
|
||||||
|
let hot = null;
|
||||||
|
mainline.events.forEach((event) => {
|
||||||
|
// 统一使用 related_max_chg(最大超额)
|
||||||
|
const change = event.related_max_chg ?? -Infinity;
|
||||||
|
if (change > maxChange) {
|
||||||
|
maxChange = change;
|
||||||
|
hot = event;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// 只有当最大超额 > 0 时才显示 HOT
|
||||||
|
return maxChange > 0 ? hot : null;
|
||||||
|
}, [mainline.events]);
|
||||||
|
|
||||||
// 当前显示的事件
|
// 当前显示的事件
|
||||||
const displayedEvents = useMemo(() => {
|
const displayedEvents = useMemo(() => {
|
||||||
return mainline.events.slice(0, displayCount);
|
return mainline.events.slice(0, displayCount);
|
||||||
@@ -101,8 +293,8 @@ const MainlineCard = React.memo(
|
|||||||
borderColor={COLORS.cardBorderColor}
|
borderColor={COLORS.cardBorderColor}
|
||||||
borderTopWidth="3px"
|
borderTopWidth="3px"
|
||||||
borderTopColor={`${colorScheme}.500`}
|
borderTopColor={`${colorScheme}.500`}
|
||||||
minW={isExpanded ? "280px" : "200px"}
|
minW={isExpanded ? "320px" : "280px"}
|
||||||
maxW={isExpanded ? "320px" : "240px"}
|
maxW={isExpanded ? "380px" : "320px"}
|
||||||
h="100%"
|
h="100%"
|
||||||
display="flex"
|
display="flex"
|
||||||
flexDirection="column"
|
flexDirection="column"
|
||||||
@@ -114,6 +306,8 @@ const MainlineCard = React.memo(
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* 卡片头部 */}
|
{/* 卡片头部 */}
|
||||||
|
<Box flexShrink={0}>
|
||||||
|
{/* 第一行:概念名称 + 涨跌幅 + 事件数 */}
|
||||||
<Flex
|
<Flex
|
||||||
align="center"
|
align="center"
|
||||||
justify="space-between"
|
justify="space-between"
|
||||||
@@ -123,9 +317,6 @@ const MainlineCard = React.memo(
|
|||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
_hover={{ bg: COLORS.headerHoverBg }}
|
_hover={{ bg: COLORS.headerHoverBg }}
|
||||||
transition="all 0.15s"
|
transition="all 0.15s"
|
||||||
borderBottomWidth="1px"
|
|
||||||
borderBottomColor={COLORS.cardBorderColor}
|
|
||||||
flexShrink={0}
|
|
||||||
>
|
>
|
||||||
<VStack align="start" spacing={0} flex={1} minW={0}>
|
<VStack align="start" spacing={0} flex={1} minW={0}>
|
||||||
<HStack spacing={2} w="100%">
|
<HStack spacing={2} w="100%">
|
||||||
@@ -181,6 +372,67 @@ const MainlineCard = React.memo(
|
|||||||
/>
|
/>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
|
{/* HOT 事件展示区域 */}
|
||||||
|
{hotEvent && (
|
||||||
|
<Box
|
||||||
|
px={3}
|
||||||
|
py={3}
|
||||||
|
bg="rgba(245, 101, 101, 0.1)"
|
||||||
|
borderBottomWidth="1px"
|
||||||
|
borderBottomColor={COLORS.cardBorderColor}
|
||||||
|
cursor="pointer"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onEventSelect?.(hotEvent);
|
||||||
|
}}
|
||||||
|
_hover={{ bg: "rgba(245, 101, 101, 0.18)" }}
|
||||||
|
transition="all 0.15s"
|
||||||
|
>
|
||||||
|
{/* 第一行:HOT 标签 + 最大超额 */}
|
||||||
|
<HStack spacing={2} mb={1.5}>
|
||||||
|
<Badge
|
||||||
|
bg="linear-gradient(135deg, #f56565 0%, #ed8936 100%)"
|
||||||
|
color="white"
|
||||||
|
fontSize="xs"
|
||||||
|
px={2}
|
||||||
|
py={0.5}
|
||||||
|
borderRadius="sm"
|
||||||
|
display="flex"
|
||||||
|
alignItems="center"
|
||||||
|
gap="3px"
|
||||||
|
fontWeight="bold"
|
||||||
|
>
|
||||||
|
<FireOutlined style={{ fontSize: 11 }} />
|
||||||
|
HOT
|
||||||
|
</Badge>
|
||||||
|
{/* 最大超额涨幅 */}
|
||||||
|
{hotEvent.related_max_chg != null && (
|
||||||
|
<Box
|
||||||
|
bg="rgba(239, 68, 68, 0.2)"
|
||||||
|
borderRadius="md"
|
||||||
|
px={2}
|
||||||
|
py={0.5}
|
||||||
|
>
|
||||||
|
<Text fontSize="xs" color="#fc8181" fontWeight="bold">
|
||||||
|
最大超额 +{hotEvent.related_max_chg.toFixed(2)}%
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
{/* 第二行:标题 */}
|
||||||
|
<Text
|
||||||
|
fontSize="sm"
|
||||||
|
color={COLORS.textColor}
|
||||||
|
noOfLines={2}
|
||||||
|
lineHeight="1.5"
|
||||||
|
fontWeight="medium"
|
||||||
|
>
|
||||||
|
{hotEvent.title}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
{/* 事件列表区域 */}
|
{/* 事件列表区域 */}
|
||||||
{isExpanded ? (
|
{isExpanded ? (
|
||||||
<Box
|
<Box
|
||||||
@@ -199,17 +451,15 @@ const MainlineCard = React.memo(
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* 事件列表 - 单列布局 */}
|
{/* 事件列表 - 卡片式 */}
|
||||||
<VStack spacing={2} align="stretch">
|
|
||||||
{displayedEvents.map((event) => (
|
{displayedEvents.map((event) => (
|
||||||
<MiniEventCard
|
<TimelineEventItem
|
||||||
key={event.id}
|
key={event.id}
|
||||||
event={event}
|
event={event}
|
||||||
isSelected={selectedEvent?.id === event.id}
|
isSelected={selectedEvent?.id === event.id}
|
||||||
onEventClick={onEventSelect}
|
onEventClick={onEventSelect}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</VStack>
|
|
||||||
|
|
||||||
{/* 加载更多按钮 */}
|
{/* 加载更多按钮 */}
|
||||||
{hasMore && (
|
{hasMore && (
|
||||||
@@ -221,7 +471,7 @@ const MainlineCard = React.memo(
|
|||||||
isLoading={isLoadingMore}
|
isLoading={isLoadingMore}
|
||||||
loadingText="加载中..."
|
loadingText="加载中..."
|
||||||
w="100%"
|
w="100%"
|
||||||
mt={2}
|
mt={1}
|
||||||
_hover={{ bg: COLORS.headerHoverBg }}
|
_hover={{ bg: COLORS.headerHoverBg }}
|
||||||
>
|
>
|
||||||
加载更多 ({mainline.events.length - displayCount} 条)
|
加载更多 ({mainline.events.length - displayCount} 条)
|
||||||
@@ -229,31 +479,21 @@ const MainlineCard = React.memo(
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
/* 折叠时显示简要信息 */
|
/* 折叠时显示简要信息 - 卡片式 */
|
||||||
<Box px={3} py={2} flex={1} overflow="hidden">
|
<Box px={2} py={2} flex={1} overflow="hidden">
|
||||||
<VStack spacing={1} align="stretch">
|
{mainline.events.slice(0, 3).map((event) => (
|
||||||
{mainline.events.slice(0, 4).map((event) => (
|
<TimelineEventItem
|
||||||
<Text
|
|
||||||
key={event.id}
|
key={event.id}
|
||||||
fontSize="xs"
|
event={event}
|
||||||
color={COLORS.secondaryTextColor}
|
isSelected={selectedEvent?.id === event.id}
|
||||||
noOfLines={1}
|
onEventClick={onEventSelect}
|
||||||
cursor="pointer"
|
/>
|
||||||
_hover={{ color: COLORS.textColor }}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onEventSelect?.(event);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
• {event.title}
|
|
||||||
</Text>
|
|
||||||
))}
|
))}
|
||||||
{mainline.events.length > 4 && (
|
{mainline.events.length > 3 && (
|
||||||
<Text fontSize="xs" color={COLORS.secondaryTextColor}>
|
<Text fontSize="sm" color={COLORS.secondaryTextColor} textAlign="center" pt={1}>
|
||||||
... 还有 {mainline.events.length - 4} 条
|
... 还有 {mainline.events.length - 3} 条
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</VStack>
|
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -288,6 +528,8 @@ const MainlineTimelineViewComponent = forwardRef(
|
|||||||
const [groupBy, setGroupBy] = useState("lv2");
|
const [groupBy, setGroupBy] = useState("lv2");
|
||||||
// 层级选项(从 API 获取)
|
// 层级选项(从 API 获取)
|
||||||
const [hierarchyOptions, setHierarchyOptions] = useState({ lv1: [], lv2: [], lv3: [] });
|
const [hierarchyOptions, setHierarchyOptions] = useState({ lv1: [], lv2: [], lv3: [] });
|
||||||
|
// 排序方式: 'event_count' | 'change_desc' | 'change_asc'
|
||||||
|
const [sortBy, setSortBy] = useState("event_count");
|
||||||
|
|
||||||
// 根据主线类型获取配色
|
// 根据主线类型获取配色
|
||||||
const getColorScheme = useCallback((lv2Name) => {
|
const getColorScheme = useCallback((lv2Name) => {
|
||||||
@@ -372,16 +614,22 @@ const MainlineTimelineViewComponent = forwardRef(
|
|||||||
const apiBase = getApiBase();
|
const apiBase = getApiBase();
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
// 添加筛选参数
|
// 添加筛选参数(主线模式支持时间范围筛选)
|
||||||
if (filters.recent_days)
|
// 优先使用精确时间范围(start_date/end_date),其次使用 recent_days
|
||||||
|
if (filters.start_date) {
|
||||||
|
params.append("start_date", filters.start_date);
|
||||||
|
}
|
||||||
|
if (filters.end_date) {
|
||||||
|
params.append("end_date", filters.end_date);
|
||||||
|
}
|
||||||
|
if (filters.recent_days && !filters.start_date && !filters.end_date) {
|
||||||
|
// 只有在没有精确时间范围时才使用 recent_days
|
||||||
params.append("recent_days", filters.recent_days);
|
params.append("recent_days", filters.recent_days);
|
||||||
if (filters.importance && filters.importance !== "all")
|
}
|
||||||
params.append("importance", filters.importance);
|
|
||||||
// 添加分组方式参数
|
// 添加分组方式参数
|
||||||
params.append("group_by", groupBy);
|
params.append("group_by", groupBy);
|
||||||
|
|
||||||
const url = `${apiBase}/api/events/mainline?${params.toString()}`;
|
const url = `${apiBase}/api/events/mainline?${params.toString()}`;
|
||||||
console.log("[MainlineTimelineView] 🔄 请求主线数据:", url);
|
|
||||||
|
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -389,32 +637,22 @@ const MainlineTimelineViewComponent = forwardRef(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
console.log("[MainlineTimelineView] 📦 响应数据:", {
|
|
||||||
success: result.success,
|
// 兼容两种响应格式:{ success, data: {...} } 或 { success, mainlines, ... }
|
||||||
mainlineCount: result.data?.mainlines?.length,
|
const responseData = result.data || result;
|
||||||
totalEvents: result.data?.total_events,
|
|
||||||
groupBy: result.data?.group_by,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// 按事件数量从多到少排序
|
// 保存原始数据,排序在渲染时根据 sortBy 状态进行
|
||||||
const sortedMainlines = [...(result.data.mainlines || [])].sort(
|
setMainlineData(responseData);
|
||||||
(a, b) => b.event_count - a.event_count
|
|
||||||
);
|
|
||||||
|
|
||||||
setMainlineData({
|
|
||||||
...result.data,
|
|
||||||
mainlines: sortedMainlines,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 保存层级选项供下拉框使用
|
// 保存层级选项供下拉框使用
|
||||||
if (result.data.hierarchy_options) {
|
if (responseData.hierarchy_options) {
|
||||||
setHierarchyOptions(result.data.hierarchy_options);
|
setHierarchyOptions(responseData.hierarchy_options);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 初始化展开状态(默认全部展开)
|
// 初始化展开状态(默认全部展开)
|
||||||
const initialExpanded = {};
|
const initialExpanded = {};
|
||||||
sortedMainlines.forEach((mainline) => {
|
(responseData.mainlines || []).forEach((mainline) => {
|
||||||
const groupId = mainline.group_id || mainline.lv2_id || mainline.lv1_id;
|
const groupId = mainline.group_id || mainline.lv2_id || mainline.lv1_id;
|
||||||
initialExpanded[groupId] = true;
|
initialExpanded[groupId] = true;
|
||||||
});
|
});
|
||||||
@@ -428,7 +666,7 @@ const MainlineTimelineViewComponent = forwardRef(
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [display, filters.recent_days, filters.importance, groupBy]);
|
}, [display, filters.start_date, filters.end_date, filters.recent_days, groupBy]);
|
||||||
|
|
||||||
// 初始加载 & 筛选变化时刷新
|
// 初始加载 & 筛选变化时刷新
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -466,6 +704,25 @@ const MainlineTimelineViewComponent = forwardRef(
|
|||||||
[mainlineData]
|
[mainlineData]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 根据排序方式排序主线列表(必须在条件渲染之前,遵循 Hooks 规则)
|
||||||
|
const sortedMainlines = useMemo(() => {
|
||||||
|
const rawMainlines = mainlineData?.mainlines;
|
||||||
|
if (!rawMainlines) return [];
|
||||||
|
const sorted = [...rawMainlines];
|
||||||
|
switch (sortBy) {
|
||||||
|
case "change_desc":
|
||||||
|
// 按涨跌幅从高到低(涨幅大的在前)
|
||||||
|
return sorted.sort((a, b) => (b.avg_change_pct ?? -999) - (a.avg_change_pct ?? -999));
|
||||||
|
case "change_asc":
|
||||||
|
// 按涨跌幅从低到高(跌幅大的在前)
|
||||||
|
return sorted.sort((a, b) => (a.avg_change_pct ?? 999) - (b.avg_change_pct ?? 999));
|
||||||
|
case "event_count":
|
||||||
|
default:
|
||||||
|
// 按事件数量从多到少
|
||||||
|
return sorted.sort((a, b) => b.event_count - a.event_count);
|
||||||
|
}
|
||||||
|
}, [mainlineData?.mainlines, sortBy]);
|
||||||
|
|
||||||
// 渲染加载状态
|
// 渲染加载状态
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -517,12 +774,14 @@ const MainlineTimelineViewComponent = forwardRef(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
mainlines,
|
|
||||||
total_events,
|
total_events,
|
||||||
mainline_count,
|
mainline_count,
|
||||||
ungrouped_count,
|
ungrouped_count,
|
||||||
} = mainlineData;
|
} = mainlineData;
|
||||||
|
|
||||||
|
// 使用排序后的主线列表
|
||||||
|
const mainlines = sortedMainlines;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
display={display}
|
display={display}
|
||||||
@@ -565,7 +824,7 @@ const MainlineTimelineViewComponent = forwardRef(
|
|||||||
onChange={setGroupBy}
|
onChange={setGroupBy}
|
||||||
size="small"
|
size="small"
|
||||||
style={{
|
style={{
|
||||||
width: 200,
|
width: 180,
|
||||||
backgroundColor: "transparent",
|
backgroundColor: "transparent",
|
||||||
}}
|
}}
|
||||||
popupClassName="dark-select-dropdown"
|
popupClassName="dark-select-dropdown"
|
||||||
@@ -620,6 +879,26 @@ const MainlineTimelineViewComponent = forwardRef(
|
|||||||
: []),
|
: []),
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
{/* 排序方式选择器 */}
|
||||||
|
<Select
|
||||||
|
value={sortBy}
|
||||||
|
onChange={setSortBy}
|
||||||
|
size="small"
|
||||||
|
style={{
|
||||||
|
width: 140,
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
}}
|
||||||
|
popupClassName="dark-select-dropdown"
|
||||||
|
dropdownStyle={{
|
||||||
|
backgroundColor: "#252a34",
|
||||||
|
borderColor: "#3a3f4b",
|
||||||
|
}}
|
||||||
|
options={[
|
||||||
|
{ value: "event_count", label: "按事件数量" },
|
||||||
|
{ value: "change_desc", label: "按涨幅↓" },
|
||||||
|
{ value: "change_asc", label: "按跌幅↓" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
<Tooltip label="全部展开">
|
<Tooltip label="全部展开">
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={<ChevronDownIcon />}
|
icon={<ChevronDownIcon />}
|
||||||
|
|||||||
@@ -1,8 +1,64 @@
|
|||||||
// 事件详情抽屉样式(从底部弹出)
|
// 事件详情抽屉样式(从底部弹出)- 深色毛玻璃风格
|
||||||
// 注意:大部分样式已在 TSX 的 styles 属性中配置,这里只保留必要的覆盖
|
// 整体比背景亮一些,形成层次感
|
||||||
.event-detail-drawer {
|
|
||||||
|
// 深色主题变量 - 提亮以区分背景
|
||||||
|
@glass-bg: #2D3748;
|
||||||
|
@glass-header-bg: #3D4A5C;
|
||||||
|
@glass-border: rgba(255, 255, 255, 0.12);
|
||||||
|
@glass-text: #F7FAFC;
|
||||||
|
@glass-text-secondary: #CBD5E0;
|
||||||
|
|
||||||
|
// 使用 :global 确保样式全局生效
|
||||||
|
:global {
|
||||||
|
// 深色抽屉样式
|
||||||
|
.event-detail-drawer-dark {
|
||||||
|
// 内容包装器 - 移除白边
|
||||||
|
.ant-drawer-content-wrapper {
|
||||||
|
box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.4) !important;
|
||||||
|
border: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内容容器
|
||||||
|
.ant-drawer-content {
|
||||||
|
background: @glass-bg !important;
|
||||||
|
border: none !important;
|
||||||
|
border-radius: 16px 16px 0 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 头部容器
|
||||||
|
.ant-drawer-header {
|
||||||
|
background: @glass-header-bg !important;
|
||||||
|
border-bottom: 1px solid @glass-border !important;
|
||||||
|
padding: 16px 24px !important;
|
||||||
|
border-radius: 16px 16px 0 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
// 标题样式
|
// 标题样式
|
||||||
.ant-drawer-title {
|
.ant-drawer-title {
|
||||||
color: #1A202C;
|
color: @glass-text !important;
|
||||||
|
font-size: 16px !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
line-height: 1.4 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭按钮区域
|
||||||
|
.ant-drawer-extra {
|
||||||
|
.anticon-close {
|
||||||
|
color: @glass-text !important;
|
||||||
|
font-size: 18px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #fff;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内容区域
|
||||||
|
.ant-drawer-body {
|
||||||
|
background: @glass-bg !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import { Drawer } from 'antd';
|
import { Drawer, ConfigProvider, theme } from 'antd';
|
||||||
import { CloseOutlined } from '@ant-design/icons';
|
import { CloseOutlined } from '@ant-design/icons';
|
||||||
import { selectIsMobile } from '@store/slices/deviceSlice';
|
import { selectIsMobile } from '@store/slices/deviceSlice';
|
||||||
import DynamicNewsDetailPanel from '@components/EventDetailPanel';
|
import DynamicNewsDetailPanel from '@components/EventDetailPanel';
|
||||||
import './EventDetailModal.less';
|
|
||||||
|
|
||||||
interface EventDetailModalProps {
|
interface EventDetailModalProps {
|
||||||
/** 是否打开弹窗 */
|
/** 是否打开弹窗 */
|
||||||
@@ -15,8 +14,16 @@ interface EventDetailModalProps {
|
|||||||
event: any; // TODO: 后续可替换为具体的 Event 类型
|
event: any; // TODO: 后续可替换为具体的 Event 类型
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 深色主题颜色 - 比背景亮,形成层次感
|
||||||
|
const THEME = {
|
||||||
|
bg: '#2D3748',
|
||||||
|
headerBg: '#3D4A5C',
|
||||||
|
borderColor: 'rgba(255, 255, 255, 0.12)',
|
||||||
|
textColor: '#F7FAFC',
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 事件详情抽屉组件(从底部弹出)
|
* 事件详情抽屉组件(从底部弹出)- 深色风格
|
||||||
*/
|
*/
|
||||||
const EventDetailModal: React.FC<EventDetailModalProps> = ({
|
const EventDetailModal: React.FC<EventDetailModalProps> = ({
|
||||||
open,
|
open,
|
||||||
@@ -26,6 +33,27 @@ const EventDetailModal: React.FC<EventDetailModalProps> = ({
|
|||||||
const isMobile = useSelector(selectIsMobile);
|
const isMobile = useSelector(selectIsMobile);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<ConfigProvider
|
||||||
|
theme={{
|
||||||
|
algorithm: theme.darkAlgorithm,
|
||||||
|
token: {
|
||||||
|
colorBgElevated: THEME.bg,
|
||||||
|
colorBgContainer: THEME.bg,
|
||||||
|
colorText: THEME.textColor,
|
||||||
|
colorTextHeading: THEME.textColor,
|
||||||
|
colorIcon: THEME.textColor,
|
||||||
|
colorBorderSecondary: THEME.borderColor,
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
Drawer: {
|
||||||
|
colorBgElevated: THEME.bg,
|
||||||
|
colorText: THEME.textColor,
|
||||||
|
colorIcon: THEME.textColor,
|
||||||
|
colorIconHover: '#FFFFFF',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Drawer
|
<Drawer
|
||||||
open={open}
|
open={open}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
@@ -34,27 +62,33 @@ const EventDetailModal: React.FC<EventDetailModalProps> = ({
|
|||||||
width={isMobile ? '100%' : '70vw'}
|
width={isMobile ? '100%' : '70vw'}
|
||||||
title={event?.title || '事件详情'}
|
title={event?.title || '事件详情'}
|
||||||
destroyOnHidden
|
destroyOnHidden
|
||||||
rootClassName="event-detail-drawer"
|
closeIcon={<CloseOutlined />}
|
||||||
closeIcon={null}
|
|
||||||
extra={
|
|
||||||
<CloseOutlined
|
|
||||||
onClick={onClose}
|
|
||||||
style={{ cursor: 'pointer', fontSize: 16, color: '#4A5568' }}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
styles={{
|
styles={{
|
||||||
wrapper: isMobile ? {} : {
|
wrapper: isMobile ? {} : {
|
||||||
maxWidth: 1400,
|
maxWidth: 1400,
|
||||||
margin: '0 auto',
|
margin: '0 auto',
|
||||||
|
},
|
||||||
|
mask: {
|
||||||
|
background: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
borderRadius: '16px 16px 0 0',
|
||||||
|
background: THEME.bg,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
background: THEME.headerBg,
|
||||||
|
borderBottom: `1px solid ${THEME.borderColor}`,
|
||||||
borderRadius: '16px 16px 0 0',
|
borderRadius: '16px 16px 0 0',
|
||||||
},
|
},
|
||||||
content: { borderRadius: '16px 16px 0 0' },
|
body: {
|
||||||
header: { background: '#FFFFFF', borderBottom: '1px solid #E2E8F0', padding: '16px 24px' },
|
padding: 0,
|
||||||
body: { padding: 0, background: '#FFFFFF' },
|
background: THEME.bg,
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{event && <DynamicNewsDetailPanel event={event} showHeader={false} />}
|
{event && <DynamicNewsDetailPanel event={event} showHeader={false} />}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
</ConfigProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -163,13 +163,20 @@ const CompactSearchBox = ({
|
|||||||
stockDisplayValueRef.current = null;
|
stockDisplayValueRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasTimeInFilters = filters.start_date || filters.end_date || filters.recent_days;
|
const hasTimeInFilters = filters.start_date || filters.end_date || filters.recent_days || filters.time_filter_key;
|
||||||
|
|
||||||
if (hasTimeInFilters && (!tradingTimeRange || !tradingTimeRange.key)) {
|
if (hasTimeInFilters && (!tradingTimeRange || !tradingTimeRange.key)) {
|
||||||
let inferredKey = 'custom';
|
// 优先使用 time_filter_key(来自 useEventFilters 的默认值)
|
||||||
|
let inferredKey = filters.time_filter_key || 'custom';
|
||||||
let inferredLabel = '';
|
let inferredLabel = '';
|
||||||
|
|
||||||
if (filters.recent_days) {
|
if (filters.time_filter_key === 'current-trading-day') {
|
||||||
|
inferredKey = 'current-trading-day';
|
||||||
|
inferredLabel = '当前交易日';
|
||||||
|
} else if (filters.time_filter_key === 'all') {
|
||||||
|
inferredKey = 'all';
|
||||||
|
inferredLabel = '全部';
|
||||||
|
} else if (filters.recent_days) {
|
||||||
if (filters.recent_days === '7') {
|
if (filters.recent_days === '7') {
|
||||||
inferredKey = 'week';
|
inferredKey = 'week';
|
||||||
inferredLabel = '近一周';
|
inferredLabel = '近一周';
|
||||||
@@ -377,7 +384,12 @@ const CompactSearchBox = ({
|
|||||||
const { range, type, label, key } = timeConfig;
|
const { range, type, label, key } = timeConfig;
|
||||||
let params = {};
|
let params = {};
|
||||||
|
|
||||||
if (type === 'recent_days') {
|
if (type === 'all') {
|
||||||
|
// "全部"按钮:清除所有时间限制
|
||||||
|
params.start_date = '';
|
||||||
|
params.end_date = '';
|
||||||
|
params.recent_days = '';
|
||||||
|
} else if (type === 'recent_days') {
|
||||||
params.recent_days = range;
|
params.recent_days = range;
|
||||||
params.start_date = '';
|
params.start_date = '';
|
||||||
params.end_date = '';
|
params.end_date = '';
|
||||||
@@ -524,7 +536,8 @@ const CompactSearchBox = ({
|
|||||||
</div>
|
</div>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
{/* 第二行:筛选条件 */}
|
{/* 第二行:筛选条件 - 主线模式下隐藏(主线模式有自己的筛选器) */}
|
||||||
|
{mode !== 'mainline' && (
|
||||||
<Flex justify="space-between" align="center">
|
<Flex justify="space-between" align="center">
|
||||||
{/* 左侧筛选 */}
|
{/* 左侧筛选 */}
|
||||||
<Space size={isMobile ? 4 : 8}>
|
<Space size={isMobile ? 4 : 8}>
|
||||||
@@ -608,6 +621,7 @@ const CompactSearchBox = ({
|
|||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import dayjs from 'dayjs';
|
|||||||
import locale from 'antd/es/date-picker/locale/zh_CN';
|
import locale from 'antd/es/date-picker/locale/zh_CN';
|
||||||
import { logger } from '@utils/logger';
|
import { logger } from '@utils/logger';
|
||||||
import { PROFESSIONAL_COLORS } from '@constants/professionalTheme';
|
import { PROFESSIONAL_COLORS } from '@constants/professionalTheme';
|
||||||
|
import tradingDayUtils from '@utils/tradingDayUtils';
|
||||||
|
|
||||||
const { RangePicker } = DatePicker;
|
const { RangePicker } = DatePicker;
|
||||||
|
|
||||||
@@ -83,28 +84,10 @@ const TradingTimeFilter = ({ value, onChange, compact = false, mobile = false })
|
|||||||
const yesterdayEnd = now.subtract(1, 'day').endOf('day');
|
const yesterdayEnd = now.subtract(1, 'day').endOf('day');
|
||||||
|
|
||||||
// 动态按钮配置(根据时段返回不同按钮数组)
|
// 动态按钮配置(根据时段返回不同按钮数组)
|
||||||
|
// 注意:"当前交易日"已在固定按钮中,这里只放特定时段的快捷按钮
|
||||||
const dynamicButtonsMap = {
|
const dynamicButtonsMap = {
|
||||||
'pre-market': [
|
'pre-market': [], // 盘前:使用"当前交易日"即可
|
||||||
{
|
|
||||||
key: 'latest',
|
|
||||||
label: '最新',
|
|
||||||
range: [yesterday1500, today0930],
|
|
||||||
tooltip: '盘前资讯',
|
|
||||||
timeHint: `昨日 15:00 - 今日 09:30`,
|
|
||||||
color: 'purple',
|
|
||||||
type: 'precise'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
'morning': [
|
'morning': [
|
||||||
{
|
|
||||||
key: 'latest',
|
|
||||||
label: '最新',
|
|
||||||
range: [today0930, now],
|
|
||||||
tooltip: '早盘最新',
|
|
||||||
timeHint: `今日 09:30 - ${now.format('HH:mm')}`,
|
|
||||||
color: 'green',
|
|
||||||
type: 'precise'
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'intraday',
|
key: 'intraday',
|
||||||
label: '盘中',
|
label: '盘中',
|
||||||
@@ -115,27 +98,8 @@ const TradingTimeFilter = ({ value, onChange, compact = false, mobile = false })
|
|||||||
type: 'precise'
|
type: 'precise'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
'lunch': [
|
'lunch': [], // 午休:使用"当前交易日"即可
|
||||||
{
|
|
||||||
key: 'latest',
|
|
||||||
label: '最新',
|
|
||||||
range: [today1130, now],
|
|
||||||
tooltip: '午休时段',
|
|
||||||
timeHint: `今日 11:30 - ${now.format('HH:mm')}`,
|
|
||||||
color: 'orange',
|
|
||||||
type: 'precise'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
'afternoon': [
|
'afternoon': [
|
||||||
{
|
|
||||||
key: 'latest',
|
|
||||||
label: '最新',
|
|
||||||
range: [today1300, now],
|
|
||||||
tooltip: '午盘最新',
|
|
||||||
timeHint: `今日 13:00 - ${now.format('HH:mm')}`,
|
|
||||||
color: 'green',
|
|
||||||
type: 'precise'
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'intraday',
|
key: 'intraday',
|
||||||
label: '盘中',
|
label: '盘中',
|
||||||
@@ -155,21 +119,35 @@ const TradingTimeFilter = ({ value, onChange, compact = false, mobile = false })
|
|||||||
type: 'precise'
|
type: 'precise'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
'after-hours': [
|
'after-hours': [] // 盘后:使用"当前交易日"即可
|
||||||
{
|
|
||||||
key: 'latest',
|
|
||||||
label: '最新',
|
|
||||||
range: [today1500, now],
|
|
||||||
tooltip: '盘后最新',
|
|
||||||
timeHint: `今日 15:00 - ${now.format('HH:mm')}`,
|
|
||||||
color: 'red',
|
|
||||||
type: 'precise'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 获取上一个交易日(使用 tdays.csv 数据)
|
||||||
|
const getPrevTradingDay = () => {
|
||||||
|
try {
|
||||||
|
const prevTradingDay = tradingDayUtils.getPreviousTradingDay(now.toDate());
|
||||||
|
return dayjs(prevTradingDay);
|
||||||
|
} catch (e) {
|
||||||
|
// 降级:简单地减一天(不考虑周末节假日)
|
||||||
|
logger.warn('TradingTimeFilter', '获取上一交易日失败,降级处理', e);
|
||||||
|
return now.subtract(1, 'day');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const prevTradingDay = getPrevTradingDay();
|
||||||
|
const prevTradingDay1500 = prevTradingDay.hour(15).minute(0).second(0);
|
||||||
|
|
||||||
// 固定按钮配置(始终显示)
|
// 固定按钮配置(始终显示)
|
||||||
const fixedButtons = [
|
const fixedButtons = [
|
||||||
|
{
|
||||||
|
key: 'current-trading-day',
|
||||||
|
label: '当前交易日',
|
||||||
|
range: [prevTradingDay1500, now],
|
||||||
|
tooltip: '当前交易日事件',
|
||||||
|
timeHint: `${prevTradingDay.format('MM-DD')} 15:00 - 现在`,
|
||||||
|
color: 'green',
|
||||||
|
type: 'precise'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'morning-fixed',
|
key: 'morning-fixed',
|
||||||
label: '早盘',
|
label: '早盘',
|
||||||
@@ -214,6 +192,15 @@ const TradingTimeFilter = ({ value, onChange, compact = false, mobile = false })
|
|||||||
timeHint: '过去30天',
|
timeHint: '过去30天',
|
||||||
color: 'volcano',
|
color: 'volcano',
|
||||||
type: 'recent_days'
|
type: 'recent_days'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'all',
|
||||||
|
label: '全部',
|
||||||
|
range: null, // 无时间限制
|
||||||
|
tooltip: '显示全部事件',
|
||||||
|
timeHint: '不限时间',
|
||||||
|
color: 'default',
|
||||||
|
type: 'all'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { logger } from '../../../utils/logger';
|
|||||||
import { usePostHogTrack } from '../../../hooks/usePostHogRedux';
|
import { usePostHogTrack } from '../../../hooks/usePostHogRedux';
|
||||||
import { RETENTION_EVENTS } from '../../../lib/constants';
|
import { RETENTION_EVENTS } from '../../../lib/constants';
|
||||||
import { getEventDetailUrl } from '@/utils/idEncoder';
|
import { getEventDetailUrl } from '@/utils/idEncoder';
|
||||||
|
import tradingDayUtils from '@utils/tradingDayUtils';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 事件筛选逻辑 Hook
|
* 事件筛选逻辑 Hook
|
||||||
@@ -22,16 +24,43 @@ export const useEventFilters = ({ navigate, onEventClick, eventTimelineRef } = {
|
|||||||
|
|
||||||
// 筛选参数状态 - 初始化时从URL读取,之后只用本地状态
|
// 筛选参数状态 - 初始化时从URL读取,之后只用本地状态
|
||||||
const [filters, setFilters] = useState(() => {
|
const [filters, setFilters] = useState(() => {
|
||||||
|
// 计算当前交易日的默认时间范围
|
||||||
|
const getDefaultTimeRange = () => {
|
||||||
|
try {
|
||||||
|
const now = dayjs();
|
||||||
|
const prevTradingDay = tradingDayUtils.getPreviousTradingDay(now.toDate());
|
||||||
|
const prevTradingDay1500 = dayjs(prevTradingDay).hour(15).minute(0).second(0);
|
||||||
|
return {
|
||||||
|
start_date: prevTradingDay1500.format('YYYY-MM-DD HH:mm:ss'),
|
||||||
|
end_date: now.format('YYYY-MM-DD HH:mm:ss'),
|
||||||
|
recent_days: '', // 使用精确时间范围,不使用 recent_days
|
||||||
|
time_filter_key: 'current-trading-day' // 标记当前选中的时间按钮
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
// 降级:使用近一周
|
||||||
|
logger.warn('useEventFilters', '获取上一交易日失败,降级为近一周', e);
|
||||||
|
return {
|
||||||
|
start_date: '',
|
||||||
|
end_date: '',
|
||||||
|
recent_days: '7',
|
||||||
|
time_filter_key: 'week'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultTimeRange = getDefaultTimeRange();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sort: searchParams.get('sort') || 'new',
|
sort: searchParams.get('sort') || 'new',
|
||||||
importance: searchParams.get('importance') || 'all',
|
importance: searchParams.get('importance') || 'all',
|
||||||
q: searchParams.get('q') || '',
|
q: searchParams.get('q') || '',
|
||||||
industry_code: searchParams.get('industry_code') || '',
|
industry_code: searchParams.get('industry_code') || '',
|
||||||
// 时间筛选参数(从 TradingTimeFilter 传递)
|
// 时间筛选参数(从 TradingTimeFilter 传递)
|
||||||
// 默认显示近一周数据(recent_days=7)
|
// 默认显示当前交易日数据(上一交易日15:00 - 现在)
|
||||||
start_date: searchParams.get('start_date') || '',
|
start_date: searchParams.get('start_date') || defaultTimeRange.start_date,
|
||||||
end_date: searchParams.get('end_date') || '',
|
end_date: searchParams.get('end_date') || defaultTimeRange.end_date,
|
||||||
recent_days: searchParams.get('recent_days') || '7', // 默认近一周
|
recent_days: searchParams.get('recent_days') || defaultTimeRange.recent_days,
|
||||||
|
time_filter_key: searchParams.get('time_filter_key') || defaultTimeRange.time_filter_key,
|
||||||
page: parseInt(searchParams.get('page') || '1', 10)
|
page: parseInt(searchParams.get('page') || '1', 10)
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ const AnnouncementsPanel: React.FC<AnnouncementsPanelProps> = ({ stockCode, isAc
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <LoadingState message="加载公告数据..." />;
|
return <LoadingState variant="skeleton" skeletonType="list" skeletonCount={5} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const DisclosureSchedulePanel: React.FC<DisclosureSchedulePanelProps> = ({ stock
|
|||||||
const { disclosureSchedule, loading } = useDisclosureData({ stockCode, enabled: isActive });
|
const { disclosureSchedule, loading } = useDisclosureData({ stockCode, enabled: isActive });
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <LoadingState message="加载披露日程..." />;
|
return <LoadingState variant="skeleton" skeletonType="grid" skeletonCount={4} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (disclosureSchedule.length === 0) {
|
if (disclosureSchedule.length === 0) {
|
||||||
|
|||||||
@@ -1,22 +1,110 @@
|
|||||||
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/LoadingState.tsx
|
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/LoadingState.tsx
|
||||||
// 复用的加载状态组件
|
// 复用的加载状态组件 - 支持骨架屏
|
||||||
|
|
||||||
import React from "react";
|
import React, { memo } from "react";
|
||||||
import { Center, VStack, Spinner, Text } from "@chakra-ui/react";
|
import {
|
||||||
|
Center,
|
||||||
|
VStack,
|
||||||
|
Spinner,
|
||||||
|
Text,
|
||||||
|
Box,
|
||||||
|
Skeleton,
|
||||||
|
SimpleGrid,
|
||||||
|
HStack,
|
||||||
|
} from "@chakra-ui/react";
|
||||||
import { THEME } from "../config";
|
import { THEME } from "../config";
|
||||||
|
|
||||||
|
// 骨架屏颜色配置
|
||||||
|
const SKELETON_COLORS = {
|
||||||
|
startColor: "rgba(26, 32, 44, 0.6)",
|
||||||
|
endColor: "rgba(212, 175, 55, 0.2)",
|
||||||
|
};
|
||||||
|
|
||||||
interface LoadingStateProps {
|
interface LoadingStateProps {
|
||||||
message?: string;
|
message?: string;
|
||||||
height?: string;
|
height?: string;
|
||||||
|
/** 使用骨架屏模式(更好的视觉体验) */
|
||||||
|
variant?: "spinner" | "skeleton";
|
||||||
|
/** 骨架屏类型:grid(网格布局)或 list(列表布局) */
|
||||||
|
skeletonType?: "grid" | "list";
|
||||||
|
/** 骨架屏项目数量 */
|
||||||
|
skeletonCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 加载状态组件(黑金主题)
|
* 网格骨架屏(用于披露日程等)
|
||||||
*/
|
*/
|
||||||
const LoadingState: React.FC<LoadingStateProps> = ({
|
const GridSkeleton: React.FC<{ count: number }> = memo(({ count }) => (
|
||||||
|
<SimpleGrid columns={{ base: 2, md: 4 }} spacing={3}>
|
||||||
|
{Array.from({ length: count }).map((_, i) => (
|
||||||
|
<Skeleton
|
||||||
|
key={i}
|
||||||
|
height="80px"
|
||||||
|
borderRadius="md"
|
||||||
|
{...SKELETON_COLORS}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
));
|
||||||
|
|
||||||
|
GridSkeleton.displayName = "GridSkeleton";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列表骨架屏(用于公告列表等)
|
||||||
|
*/
|
||||||
|
const ListSkeleton: React.FC<{ count: number }> = memo(({ count }) => (
|
||||||
|
<VStack spacing={2} align="stretch">
|
||||||
|
{Array.from({ length: count }).map((_, i) => (
|
||||||
|
<Box
|
||||||
|
key={i}
|
||||||
|
p={3}
|
||||||
|
borderRadius="md"
|
||||||
|
bg="rgba(26, 32, 44, 0.4)"
|
||||||
|
border="1px solid"
|
||||||
|
borderColor="rgba(212, 175, 55, 0.2)"
|
||||||
|
>
|
||||||
|
<HStack justify="space-between">
|
||||||
|
<VStack align="start" spacing={2} flex={1}>
|
||||||
|
<HStack>
|
||||||
|
<Skeleton height="20px" width="60px" borderRadius="sm" {...SKELETON_COLORS} />
|
||||||
|
<Skeleton height="16px" width="80px" borderRadius="sm" {...SKELETON_COLORS} />
|
||||||
|
</HStack>
|
||||||
|
<Skeleton height="18px" width="90%" borderRadius="sm" {...SKELETON_COLORS} />
|
||||||
|
</VStack>
|
||||||
|
<Skeleton height="32px" width="32px" borderRadius="md" {...SKELETON_COLORS} />
|
||||||
|
</HStack>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
));
|
||||||
|
|
||||||
|
ListSkeleton.displayName = "ListSkeleton";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载状态组件(黑金主题)
|
||||||
|
*
|
||||||
|
* @param variant - "spinner"(默认)或 "skeleton"(骨架屏)
|
||||||
|
* @param skeletonType - 骨架屏类型:"grid" 或 "list"
|
||||||
|
*/
|
||||||
|
const LoadingState: React.FC<LoadingStateProps> = memo(({
|
||||||
message = "加载中...",
|
message = "加载中...",
|
||||||
height = "200px",
|
height = "200px",
|
||||||
|
variant = "spinner",
|
||||||
|
skeletonType = "list",
|
||||||
|
skeletonCount = 4,
|
||||||
}) => {
|
}) => {
|
||||||
|
if (variant === "skeleton") {
|
||||||
|
return (
|
||||||
|
<Box minH={height} p={4}>
|
||||||
|
{skeletonType === "grid" ? (
|
||||||
|
<GridSkeleton count={skeletonCount} />
|
||||||
|
) : (
|
||||||
|
<ListSkeleton count={skeletonCount} />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Center h={height}>
|
<Center h={height}>
|
||||||
<VStack>
|
<VStack>
|
||||||
@@ -27,6 +115,8 @@ const LoadingState: React.FC<LoadingStateProps> = ({
|
|||||||
</VStack>
|
</VStack>
|
||||||
</Center>
|
</Center>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
|
LoadingState.displayName = "LoadingState";
|
||||||
|
|
||||||
export default LoadingState;
|
export default LoadingState;
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ const DeepAnalysisTab: React.FC<DeepAnalysisTabProps> = ({
|
|||||||
themePreset="blackGold"
|
themePreset="blackGold"
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
<LoadingState message="加载数据中..." height="200px" />
|
<LoadingState variant="skeleton" height="300px" skeletonRows={6} />
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// src/views/Company/components/CompanyOverview/hooks/useAnnouncementsData.ts
|
// src/views/Company/components/CompanyOverview/hooks/useAnnouncementsData.ts
|
||||||
// 公告数据 Hook - 用于公司公告 Tab
|
// 公告数据 Hook - 用于公司公告 Tab
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { logger } from "@utils/logger";
|
import { logger } from "@utils/logger";
|
||||||
import axios from "@utils/axiosConfig";
|
import axios from "@utils/axiosConfig";
|
||||||
import type { Announcement } from "../types";
|
import type { Announcement } from "../types";
|
||||||
@@ -39,7 +39,11 @@ export const useAnnouncementsData = (options: UseAnnouncementsDataOptions): UseA
|
|||||||
const [announcements, setAnnouncements] = useState<Announcement[]>([]);
|
const [announcements, setAnnouncements] = useState<Announcement[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [hasLoaded, setHasLoaded] = useState(false);
|
// 使用 ref 跟踪是否已加载,避免 Tab 切换时重复请求
|
||||||
|
const hasLoadedRef = useRef(false);
|
||||||
|
// 记录上次加载的 stockCode 和 refreshKey
|
||||||
|
const lastStockCodeRef = useRef<string | undefined>(undefined);
|
||||||
|
const lastRefreshKeyRef = useRef<number | undefined>(undefined);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 只有 enabled 且有 stockCode 时才请求
|
// 只有 enabled 且有 stockCode 时才请求
|
||||||
@@ -48,6 +52,26 @@ export const useAnnouncementsData = (options: UseAnnouncementsDataOptions): UseA
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// stockCode 或 refreshKey 变化时重置加载状态
|
||||||
|
if (lastStockCodeRef.current !== stockCode || lastRefreshKeyRef.current !== refreshKey) {
|
||||||
|
// refreshKey 变化时强制重新加载
|
||||||
|
if (lastRefreshKeyRef.current !== refreshKey && lastRefreshKeyRef.current !== undefined) {
|
||||||
|
hasLoadedRef.current = false;
|
||||||
|
}
|
||||||
|
// stockCode 变化时重置
|
||||||
|
if (lastStockCodeRef.current !== stockCode) {
|
||||||
|
hasLoadedRef.current = false;
|
||||||
|
}
|
||||||
|
lastStockCodeRef.current = stockCode;
|
||||||
|
lastRefreshKeyRef.current = refreshKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果已经加载过数据,不再重新请求(Tab 切换回来时保持缓存)
|
||||||
|
if (hasLoadedRef.current) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
@@ -66,7 +90,7 @@ export const useAnnouncementsData = (options: UseAnnouncementsDataOptions): UseA
|
|||||||
setError("加载公告数据失败");
|
setError("加载公告数据失败");
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setHasLoaded(true);
|
hasLoadedRef.current = true;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
// 请求被取消时,不更新任何状态
|
// 请求被取消时,不更新任何状态
|
||||||
if (err.name === "CanceledError") {
|
if (err.name === "CanceledError") {
|
||||||
@@ -75,7 +99,7 @@ export const useAnnouncementsData = (options: UseAnnouncementsDataOptions): UseA
|
|||||||
logger.error("useAnnouncementsData", "loadData", err, { stockCode });
|
logger.error("useAnnouncementsData", "loadData", err, { stockCode });
|
||||||
setError("网络请求失败");
|
setError("网络请求失败");
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setHasLoaded(true);
|
hasLoadedRef.current = true;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -83,7 +107,7 @@ export const useAnnouncementsData = (options: UseAnnouncementsDataOptions): UseA
|
|||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [stockCode, enabled, refreshKey]);
|
}, [stockCode, enabled, refreshKey]);
|
||||||
|
|
||||||
const isLoading = loading || (enabled && !hasLoaded && !error);
|
const isLoading = loading || (enabled && !hasLoadedRef.current && !error);
|
||||||
|
|
||||||
return { announcements, loading: isLoading, error };
|
return { announcements, loading: isLoading, error };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// src/views/Company/components/CompanyOverview/hooks/useBranchesData.ts
|
// src/views/Company/components/CompanyOverview/hooks/useBranchesData.ts
|
||||||
// 分支机构数据 Hook - 用于分支机构 Tab
|
// 分支机构数据 Hook - 用于分支机构 Tab
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { logger } from "@utils/logger";
|
import { logger } from "@utils/logger";
|
||||||
import axios from "@utils/axiosConfig";
|
import axios from "@utils/axiosConfig";
|
||||||
import type { Branch } from "../types";
|
import type { Branch } from "../types";
|
||||||
@@ -36,7 +36,10 @@ export const useBranchesData = (options: UseBranchesDataOptions): UseBranchesDat
|
|||||||
const [branches, setBranches] = useState<Branch[]>([]);
|
const [branches, setBranches] = useState<Branch[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [hasLoaded, setHasLoaded] = useState(false);
|
// 使用 ref 跟踪是否已加载,避免 Tab 切换时重复请求
|
||||||
|
const hasLoadedRef = useRef(false);
|
||||||
|
// 记录上次加载的 stockCode,stockCode 变化时需要重新加载
|
||||||
|
const lastStockCodeRef = useRef<string | undefined>(undefined);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled || !stockCode) {
|
if (!enabled || !stockCode) {
|
||||||
@@ -44,6 +47,18 @@ export const useBranchesData = (options: UseBranchesDataOptions): UseBranchesDat
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// stockCode 变化时重置加载状态
|
||||||
|
if (lastStockCodeRef.current !== stockCode) {
|
||||||
|
hasLoadedRef.current = false;
|
||||||
|
lastStockCodeRef.current = stockCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果已经加载过数据,不再重新请求(Tab 切换回来时保持缓存)
|
||||||
|
if (hasLoadedRef.current) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
@@ -62,7 +77,7 @@ export const useBranchesData = (options: UseBranchesDataOptions): UseBranchesDat
|
|||||||
setError("加载分支机构数据失败");
|
setError("加载分支机构数据失败");
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setHasLoaded(true);
|
hasLoadedRef.current = true;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
// 请求被取消时,不更新任何状态
|
// 请求被取消时,不更新任何状态
|
||||||
if (err.name === "CanceledError") {
|
if (err.name === "CanceledError") {
|
||||||
@@ -71,7 +86,7 @@ export const useBranchesData = (options: UseBranchesDataOptions): UseBranchesDat
|
|||||||
logger.error("useBranchesData", "loadData", err, { stockCode });
|
logger.error("useBranchesData", "loadData", err, { stockCode });
|
||||||
setError("网络请求失败");
|
setError("网络请求失败");
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setHasLoaded(true);
|
hasLoadedRef.current = true;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -79,7 +94,7 @@ export const useBranchesData = (options: UseBranchesDataOptions): UseBranchesDat
|
|||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [stockCode, enabled]);
|
}, [stockCode, enabled]);
|
||||||
|
|
||||||
const isLoading = loading || (enabled && !hasLoaded && !error);
|
const isLoading = loading || (enabled && !hasLoadedRef.current && !error);
|
||||||
|
|
||||||
return { branches, loading: isLoading, error };
|
return { branches, loading: isLoading, error };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// src/views/Company/components/CompanyOverview/hooks/useDisclosureData.ts
|
// src/views/Company/components/CompanyOverview/hooks/useDisclosureData.ts
|
||||||
// 披露日程数据 Hook - 用于工商信息 Tab
|
// 披露日程数据 Hook - 用于工商信息 Tab
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { logger } from "@utils/logger";
|
import { logger } from "@utils/logger";
|
||||||
import axios from "@utils/axiosConfig";
|
import axios from "@utils/axiosConfig";
|
||||||
import type { DisclosureSchedule } from "../types";
|
import type { DisclosureSchedule } from "../types";
|
||||||
@@ -36,7 +36,10 @@ export const useDisclosureData = (options: UseDisclosureDataOptions): UseDisclos
|
|||||||
const [disclosureSchedule, setDisclosureSchedule] = useState<DisclosureSchedule[]>([]);
|
const [disclosureSchedule, setDisclosureSchedule] = useState<DisclosureSchedule[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [hasLoaded, setHasLoaded] = useState(false);
|
// 使用 ref 跟踪是否已加载,避免 Tab 切换时重复请求
|
||||||
|
const hasLoadedRef = useRef(false);
|
||||||
|
// 记录上次加载的 stockCode,stockCode 变化时需要重新加载
|
||||||
|
const lastStockCodeRef = useRef<string | undefined>(undefined);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 只有 enabled 且有 stockCode 时才请求
|
// 只有 enabled 且有 stockCode 时才请求
|
||||||
@@ -45,6 +48,18 @@ export const useDisclosureData = (options: UseDisclosureDataOptions): UseDisclos
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// stockCode 变化时重置加载状态
|
||||||
|
if (lastStockCodeRef.current !== stockCode) {
|
||||||
|
hasLoadedRef.current = false;
|
||||||
|
lastStockCodeRef.current = stockCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果已经加载过数据,不再重新请求(Tab 切换回来时保持缓存)
|
||||||
|
if (hasLoadedRef.current) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
@@ -63,7 +78,7 @@ export const useDisclosureData = (options: UseDisclosureDataOptions): UseDisclos
|
|||||||
setError("加载披露日程数据失败");
|
setError("加载披露日程数据失败");
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setHasLoaded(true);
|
hasLoadedRef.current = true;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
// 请求被取消时,不更新任何状态
|
// 请求被取消时,不更新任何状态
|
||||||
if (err.name === "CanceledError") {
|
if (err.name === "CanceledError") {
|
||||||
@@ -72,7 +87,7 @@ export const useDisclosureData = (options: UseDisclosureDataOptions): UseDisclos
|
|||||||
logger.error("useDisclosureData", "loadData", err, { stockCode });
|
logger.error("useDisclosureData", "loadData", err, { stockCode });
|
||||||
setError("网络请求失败");
|
setError("网络请求失败");
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setHasLoaded(true);
|
hasLoadedRef.current = true;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -80,7 +95,7 @@ export const useDisclosureData = (options: UseDisclosureDataOptions): UseDisclos
|
|||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [stockCode, enabled]);
|
}, [stockCode, enabled]);
|
||||||
|
|
||||||
const isLoading = loading || (enabled && !hasLoaded && !error);
|
const isLoading = loading || (enabled && !hasLoadedRef.current && !error);
|
||||||
|
|
||||||
return { disclosureSchedule, loading: isLoading, error };
|
return { disclosureSchedule, loading: isLoading, error };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// src/views/Company/components/CompanyOverview/hooks/useManagementData.ts
|
// src/views/Company/components/CompanyOverview/hooks/useManagementData.ts
|
||||||
// 管理团队数据 Hook - 用于管理团队 Tab
|
// 管理团队数据 Hook - 用于管理团队 Tab
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { logger } from "@utils/logger";
|
import { logger } from "@utils/logger";
|
||||||
import axios from "@utils/axiosConfig";
|
import axios from "@utils/axiosConfig";
|
||||||
import type { Management } from "../types";
|
import type { Management } from "../types";
|
||||||
@@ -36,7 +36,10 @@ export const useManagementData = (options: UseManagementDataOptions): UseManagem
|
|||||||
const [management, setManagement] = useState<Management[]>([]);
|
const [management, setManagement] = useState<Management[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [hasLoaded, setHasLoaded] = useState(false);
|
// 使用 ref 跟踪是否已加载,避免 Tab 切换时重复请求
|
||||||
|
const hasLoadedRef = useRef(false);
|
||||||
|
// 记录上次加载的 stockCode,stockCode 变化时需要重新加载
|
||||||
|
const lastStockCodeRef = useRef<string | undefined>(undefined);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 只有 enabled 且有 stockCode 时才请求
|
// 只有 enabled 且有 stockCode 时才请求
|
||||||
@@ -45,6 +48,18 @@ export const useManagementData = (options: UseManagementDataOptions): UseManagem
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// stockCode 变化时重置加载状态
|
||||||
|
if (lastStockCodeRef.current !== stockCode) {
|
||||||
|
hasLoadedRef.current = false;
|
||||||
|
lastStockCodeRef.current = stockCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果已经加载过数据,不再重新请求(Tab 切换回来时保持缓存)
|
||||||
|
if (hasLoadedRef.current) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
@@ -63,7 +78,7 @@ export const useManagementData = (options: UseManagementDataOptions): UseManagem
|
|||||||
setError("加载管理团队数据失败");
|
setError("加载管理团队数据失败");
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setHasLoaded(true);
|
hasLoadedRef.current = true;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
// 请求被取消时,不更新任何状态
|
// 请求被取消时,不更新任何状态
|
||||||
if (err.name === "CanceledError") {
|
if (err.name === "CanceledError") {
|
||||||
@@ -72,7 +87,7 @@ export const useManagementData = (options: UseManagementDataOptions): UseManagem
|
|||||||
logger.error("useManagementData", "loadData", err, { stockCode });
|
logger.error("useManagementData", "loadData", err, { stockCode });
|
||||||
setError("网络请求失败");
|
setError("网络请求失败");
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setHasLoaded(true);
|
hasLoadedRef.current = true;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -82,7 +97,7 @@ export const useManagementData = (options: UseManagementDataOptions): UseManagem
|
|||||||
|
|
||||||
// 派生 loading 状态:enabled 但尚未完成首次加载时,视为 loading
|
// 派生 loading 状态:enabled 但尚未完成首次加载时,视为 loading
|
||||||
// 这样可以在渲染时同步判断,避免 useEffect 异步导致的空状态闪烁
|
// 这样可以在渲染时同步判断,避免 useEffect 异步导致的空状态闪烁
|
||||||
const isLoading = loading || (enabled && !hasLoaded && !error);
|
const isLoading = loading || (enabled && !hasLoadedRef.current && !error);
|
||||||
|
|
||||||
return { management, loading: isLoading, error };
|
return { management, loading: isLoading, error };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// src/views/Company/components/CompanyOverview/hooks/useShareholderData.ts
|
// src/views/Company/components/CompanyOverview/hooks/useShareholderData.ts
|
||||||
// 股权结构数据 Hook - 用于股权结构 Tab
|
// 股权结构数据 Hook - 用于股权结构 Tab
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { logger } from "@utils/logger";
|
import { logger } from "@utils/logger";
|
||||||
import axios from "@utils/axiosConfig";
|
import axios from "@utils/axiosConfig";
|
||||||
import type { ActualControl, Concentration, Shareholder } from "../types";
|
import type { ActualControl, Concentration, Shareholder } from "../types";
|
||||||
@@ -42,7 +42,10 @@ export const useShareholderData = (options: UseShareholderDataOptions): UseShare
|
|||||||
const [topCirculationShareholders, setTopCirculationShareholders] = useState<Shareholder[]>([]);
|
const [topCirculationShareholders, setTopCirculationShareholders] = useState<Shareholder[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [hasLoaded, setHasLoaded] = useState(false);
|
// 使用 ref 跟踪是否已加载,避免 Tab 切换时重复请求
|
||||||
|
const hasLoadedRef = useRef(false);
|
||||||
|
// 记录上次加载的 stockCode,stockCode 变化时需要重新加载
|
||||||
|
const lastStockCodeRef = useRef<string | undefined>(undefined);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 只有 enabled 且有 stockCode 时才请求
|
// 只有 enabled 且有 stockCode 时才请求
|
||||||
@@ -51,6 +54,18 @@ export const useShareholderData = (options: UseShareholderDataOptions): UseShare
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// stockCode 变化时重置加载状态
|
||||||
|
if (lastStockCodeRef.current !== stockCode) {
|
||||||
|
hasLoadedRef.current = false;
|
||||||
|
lastStockCodeRef.current = stockCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果已经加载过数据,不再重新请求(Tab 切换回来时保持缓存)
|
||||||
|
if (hasLoadedRef.current) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
@@ -75,7 +90,7 @@ export const useShareholderData = (options: UseShareholderDataOptions): UseShare
|
|||||||
if (shareholdersRes.success) setTopShareholders(shareholdersRes.data);
|
if (shareholdersRes.success) setTopShareholders(shareholdersRes.data);
|
||||||
if (circulationRes.success) setTopCirculationShareholders(circulationRes.data);
|
if (circulationRes.success) setTopCirculationShareholders(circulationRes.data);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setHasLoaded(true);
|
hasLoadedRef.current = true;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
// 请求被取消时,不更新任何状态
|
// 请求被取消时,不更新任何状态
|
||||||
if (err.name === "CanceledError") {
|
if (err.name === "CanceledError") {
|
||||||
@@ -84,7 +99,7 @@ export const useShareholderData = (options: UseShareholderDataOptions): UseShare
|
|||||||
logger.error("useShareholderData", "loadData", err, { stockCode });
|
logger.error("useShareholderData", "loadData", err, { stockCode });
|
||||||
setError("加载股权结构数据失败");
|
setError("加载股权结构数据失败");
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setHasLoaded(true);
|
hasLoadedRef.current = true;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -92,7 +107,7 @@ export const useShareholderData = (options: UseShareholderDataOptions): UseShare
|
|||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [stockCode, enabled]);
|
}, [stockCode, enabled]);
|
||||||
|
|
||||||
const isLoading = loading || (enabled && !hasLoaded && !error);
|
const isLoading = loading || (enabled && !hasLoadedRef.current && !error);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
actualControl,
|
actualControl,
|
||||||
|
|||||||
@@ -136,5 +136,5 @@ const MarketDataSkeleton: React.FC = memo(() => (
|
|||||||
|
|
||||||
MarketDataSkeleton.displayName = 'MarketDataSkeleton';
|
MarketDataSkeleton.displayName = 'MarketDataSkeleton';
|
||||||
|
|
||||||
export { MarketDataSkeleton };
|
export { MarketDataSkeleton, SummaryCardSkeleton };
|
||||||
export default MarketDataSkeleton;
|
export default MarketDataSkeleton;
|
||||||
|
|||||||
@@ -5,4 +5,4 @@ export { default as ThemedCard } from './ThemedCard';
|
|||||||
export { default as MarkdownRenderer } from './MarkdownRenderer';
|
export { default as MarkdownRenderer } from './MarkdownRenderer';
|
||||||
export { default as StockSummaryCard } from './StockSummaryCard';
|
export { default as StockSummaryCard } from './StockSummaryCard';
|
||||||
export { default as AnalysisModal, AnalysisContent } from './AnalysisModal';
|
export { default as AnalysisModal, AnalysisContent } from './AnalysisModal';
|
||||||
export { MarketDataSkeleton } from './MarketDataSkeleton';
|
export { MarketDataSkeleton, SummaryCardSkeleton } from './MarketDataSkeleton';
|
||||||
|
|||||||
@@ -33,8 +33,9 @@ export const useMarketData = (
|
|||||||
period: number = DEFAULT_PERIOD
|
period: number = DEFAULT_PERIOD
|
||||||
): UseMarketDataReturn => {
|
): UseMarketDataReturn => {
|
||||||
// 主数据状态
|
// 主数据状态
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(true);
|
||||||
const [tradeLoading, setTradeLoading] = useState(false);
|
const [tradeLoading, setTradeLoading] = useState(false);
|
||||||
|
const [hasLoaded, setHasLoaded] = useState(false);
|
||||||
const [summary, setSummary] = useState<MarketSummary | null>(null);
|
const [summary, setSummary] = useState<MarketSummary | null>(null);
|
||||||
const [tradeData, setTradeData] = useState<TradeDayData[]>([]);
|
const [tradeData, setTradeData] = useState<TradeDayData[]>([]);
|
||||||
const [fundingData, setFundingData] = useState<FundingDayData[]>([]);
|
const [fundingData, setFundingData] = useState<FundingDayData[]>([]);
|
||||||
@@ -153,15 +154,17 @@ export const useMarketData = (
|
|||||||
if (loadedTradeData.length > 0) {
|
if (loadedTradeData.length > 0) {
|
||||||
loadRiseAnalysis(loadedTradeData);
|
loadRiseAnalysis(loadedTradeData);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
// 取消请求不作为错误处理
|
|
||||||
if (isCancelError(error)) return;
|
|
||||||
logger.error('useMarketData', 'loadCoreData', error, { stockCode, period });
|
|
||||||
} finally {
|
|
||||||
// 只有当前请求没有被取消时才设置 loading 状态
|
|
||||||
if (!controller.signal.aborted) {
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
setHasLoaded(true);
|
||||||
|
} catch (error) {
|
||||||
|
// 请求被取消时,不更新任何状态
|
||||||
|
if (isCancelError(error)) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
logger.error('useMarketData', 'loadCoreData', error, { stockCode, period });
|
||||||
|
setLoading(false);
|
||||||
|
setHasLoaded(true);
|
||||||
}
|
}
|
||||||
}, [stockCode, period, loadRiseAnalysis]);
|
}, [stockCode, period, loadRiseAnalysis]);
|
||||||
|
|
||||||
@@ -363,8 +366,11 @@ export const useMarketData = (
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 派生 loading 状态:stockCode 存在但尚未完成首次加载时,视为 loading
|
||||||
|
const isLoading = loading || (!!stockCode && !hasLoaded);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
loading,
|
loading: isLoading,
|
||||||
tradeLoading,
|
tradeLoading,
|
||||||
summary,
|
summary,
|
||||||
tradeData,
|
tradeData,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { useMarketData } from './hooks/useMarketData';
|
|||||||
import {
|
import {
|
||||||
ThemedCard,
|
ThemedCard,
|
||||||
StockSummaryCard,
|
StockSummaryCard,
|
||||||
|
SummaryCardSkeleton,
|
||||||
AnalysisModal,
|
AnalysisModal,
|
||||||
AnalysisContent,
|
AnalysisContent,
|
||||||
} from './components';
|
} from './components';
|
||||||
@@ -89,13 +90,12 @@ const MarketDataView: React.FC<MarketDataViewProps> = ({ stockCode: propStockCod
|
|||||||
}
|
}
|
||||||
}, [propStockCode, stockCode]);
|
}, [propStockCode, stockCode]);
|
||||||
|
|
||||||
// 首次渲染时加载默认 Tab(融资融券)的数据
|
// 首次挂载时加载默认 Tab(融资融券)的数据
|
||||||
|
// 注意:SubTabContainer 的 onChange 只在切换时触发,首次渲染不会触发
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 默认 Tab 是融资融券(index 0)
|
|
||||||
if (activeTab === 0) {
|
|
||||||
loadDataByType('funding');
|
loadDataByType('funding');
|
||||||
}
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [loadDataByType, activeTab]);
|
}, []); // 只在首次挂载时执行
|
||||||
|
|
||||||
// 处理图表点击事件
|
// 处理图表点击事件
|
||||||
const handleChartClick = useCallback(
|
const handleChartClick = useCallback(
|
||||||
@@ -137,8 +137,8 @@ const MarketDataView: React.FC<MarketDataViewProps> = ({ stockCode: propStockCod
|
|||||||
<Box bg={'#1A202C'} minH="100vh" color={theme.textPrimary}>
|
<Box bg={'#1A202C'} minH="100vh" color={theme.textPrimary}>
|
||||||
<Container maxW="container.xl" py={4}>
|
<Container maxW="container.xl" py={4}>
|
||||||
<VStack align="stretch" spacing={4}>
|
<VStack align="stretch" spacing={4}>
|
||||||
{/* 股票概览 */}
|
{/* 股票概览 - 未加载时显示骨架屏占位,避免布局跳动 */}
|
||||||
{summary && <StockSummaryCard summary={summary} theme={theme} />}
|
{summary ? <StockSummaryCard summary={summary} theme={theme} /> : <SummaryCardSkeleton />}
|
||||||
|
|
||||||
{/* 交易数据 - 日K/分钟K线(独立显示在 Tab 上方) */}
|
{/* 交易数据 - 日K/分钟K线(独立显示在 Tab 上方) */}
|
||||||
<TradeDataPanel
|
<TradeDataPanel
|
||||||
|
|||||||
@@ -2,11 +2,10 @@ import React, { useState, useEffect, useMemo } from 'react';
|
|||||||
import { logger } from '../../utils/logger';
|
import { logger } from '../../utils/logger';
|
||||||
import { useConceptTimelineEvents } from './hooks/useConceptTimelineEvents';
|
import { useConceptTimelineEvents } from './hooks/useConceptTimelineEvents';
|
||||||
import RiskDisclaimer from '../../components/RiskDisclaimer';
|
import RiskDisclaimer from '../../components/RiskDisclaimer';
|
||||||
import FullCalendar from '@fullcalendar/react';
|
|
||||||
import dayGridPlugin from '@fullcalendar/daygrid';
|
|
||||||
import interactionPlugin from '@fullcalendar/interaction';
|
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import 'dayjs/locale/zh-cn';
|
import 'dayjs/locale/zh-cn';
|
||||||
|
// 使用新的公共日历组件
|
||||||
|
import { BaseCalendar } from '@components/Calendar';
|
||||||
import {
|
import {
|
||||||
Modal,
|
Modal,
|
||||||
ModalOverlay,
|
ModalOverlay,
|
||||||
@@ -198,93 +197,19 @@ const ConceptTimelineModal = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 转换时间轴数据为日历事件格式(一天拆分为多个独立事件)
|
// 按日期索引的事件数据(用于日历单元格渲染)
|
||||||
const calendarEvents = useMemo(() => {
|
const eventsByDate = useMemo(() => {
|
||||||
const events = [];
|
const map = {};
|
||||||
|
|
||||||
timelineData.forEach(item => {
|
timelineData.forEach(item => {
|
||||||
const priceInfo = getPriceInfo(item.price);
|
map[item.date] = item;
|
||||||
const newsCount = (item.events || []).filter(e => e.type === 'news').length;
|
|
||||||
const reportCount = (item.events || []).filter(e => e.type === 'report').length;
|
|
||||||
const hasPriceData = item.price && item.price.avg_change_pct !== null;
|
|
||||||
|
|
||||||
// 如果有新闻,添加新闻事件
|
|
||||||
if (newsCount > 0) {
|
|
||||||
events.push({
|
|
||||||
id: `${item.date}-news`,
|
|
||||||
title: `📰 ${newsCount} 条新闻`,
|
|
||||||
date: item.date,
|
|
||||||
start: item.date,
|
|
||||||
backgroundColor: '#9F7AEA',
|
|
||||||
borderColor: '#9F7AEA',
|
|
||||||
extendedProps: {
|
|
||||||
eventType: 'news',
|
|
||||||
count: newsCount,
|
|
||||||
originalData: item,
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
return map;
|
||||||
|
|
||||||
// 如果有研报,添加研报事件
|
|
||||||
if (reportCount > 0) {
|
|
||||||
events.push({
|
|
||||||
id: `${item.date}-report`,
|
|
||||||
title: `📊 ${reportCount} 篇研报`,
|
|
||||||
date: item.date,
|
|
||||||
start: item.date,
|
|
||||||
backgroundColor: '#805AD5',
|
|
||||||
borderColor: '#805AD5',
|
|
||||||
extendedProps: {
|
|
||||||
eventType: 'report',
|
|
||||||
count: reportCount,
|
|
||||||
originalData: item,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果有价格数据,添加价格事件
|
|
||||||
if (hasPriceData) {
|
|
||||||
const changePercent = item.price.avg_change_pct;
|
|
||||||
const isSignificantRise = changePercent >= 3; // 涨幅 >= 3% 为重大利好
|
|
||||||
let bgColor = '#e2e8f0';
|
|
||||||
let title = priceInfo.text;
|
|
||||||
|
|
||||||
if (priceInfo.color === 'red') {
|
|
||||||
if (isSignificantRise) {
|
|
||||||
// 涨幅 >= 3%,使用醒目的橙红色 + 火焰图标
|
|
||||||
bgColor = '#F56565'; // 更深的红色
|
|
||||||
title = `🔥 ${priceInfo.text}`;
|
|
||||||
} else {
|
|
||||||
bgColor = '#FC8181'; // 普通红色(上涨)
|
|
||||||
}
|
|
||||||
} else if (priceInfo.color === 'green') {
|
|
||||||
bgColor = '#68D391'; // 绿色(下跌)
|
|
||||||
}
|
|
||||||
|
|
||||||
events.push({
|
|
||||||
id: `${item.date}-price`,
|
|
||||||
title: title,
|
|
||||||
date: item.date,
|
|
||||||
start: item.date,
|
|
||||||
backgroundColor: bgColor,
|
|
||||||
borderColor: isSignificantRise ? '#C53030' : bgColor, // 深红色边框强调
|
|
||||||
extendedProps: {
|
|
||||||
eventType: 'price',
|
|
||||||
priceInfo,
|
|
||||||
originalData: item,
|
|
||||||
isSignificantRise, // 标记重大涨幅
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return events;
|
|
||||||
}, [timelineData]);
|
}, [timelineData]);
|
||||||
|
|
||||||
// 处理日期点击
|
// 处理日期选择(点击日期单元格)
|
||||||
const handleDateClick = (info) => {
|
const handleDateSelect = (date) => {
|
||||||
const clickedDate = info.dateStr;
|
const clickedDate = date.format('YYYY-MM-DD');
|
||||||
const dateData = timelineData.find(item => item.date === clickedDate);
|
const dateData = eventsByDate[clickedDate];
|
||||||
|
|
||||||
if (dateData) {
|
if (dateData) {
|
||||||
setSelectedDate(clickedDate);
|
setSelectedDate(clickedDate);
|
||||||
@@ -296,16 +221,128 @@ const ConceptTimelineModal = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 处理事件点击
|
// 自定义日期单元格内容渲染
|
||||||
const handleEventClick = (info) => {
|
const renderCellContent = (date) => {
|
||||||
// 从事件的 extendedProps 中获取原始数据
|
const dateStr = date.format('YYYY-MM-DD');
|
||||||
const dateData = info.event.extendedProps?.originalData;
|
const item = eventsByDate[dateStr];
|
||||||
|
|
||||||
if (dateData) {
|
if (!item) return null;
|
||||||
setSelectedDate(dateData.date);
|
|
||||||
setSelectedDateData(dateData);
|
const priceInfo = getPriceInfo(item.price);
|
||||||
onDateDetailOpen();
|
const newsCount = (item.events || []).filter(e => e.type === 'news').length;
|
||||||
|
const reportCount = (item.events || []).filter(e => e.type === 'report').length;
|
||||||
|
const hasPriceData = item.price && item.price.avg_change_pct !== null;
|
||||||
|
|
||||||
|
const events = [];
|
||||||
|
|
||||||
|
// 新闻事件
|
||||||
|
if (newsCount > 0) {
|
||||||
|
events.push(
|
||||||
|
<HStack
|
||||||
|
key="news"
|
||||||
|
spacing={1}
|
||||||
|
fontSize="10px"
|
||||||
|
color="#9F7AEA"
|
||||||
|
cursor="pointer"
|
||||||
|
px={1}
|
||||||
|
py={0.5}
|
||||||
|
borderRadius="sm"
|
||||||
|
bg="rgba(159, 122, 234, 0.2)"
|
||||||
|
_hover={{ bg: 'rgba(159, 122, 234, 0.3)' }}
|
||||||
|
w="100%"
|
||||||
|
overflow="hidden"
|
||||||
|
>
|
||||||
|
<Text fontWeight="600" fontSize="10px" isTruncated flex="1" minW={0}>
|
||||||
|
📰 {newsCount} 条新闻
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 研报事件
|
||||||
|
if (reportCount > 0) {
|
||||||
|
events.push(
|
||||||
|
<HStack
|
||||||
|
key="report"
|
||||||
|
spacing={1}
|
||||||
|
fontSize="10px"
|
||||||
|
color="#805AD5"
|
||||||
|
cursor="pointer"
|
||||||
|
px={1}
|
||||||
|
py={0.5}
|
||||||
|
borderRadius="sm"
|
||||||
|
bg="rgba(128, 90, 213, 0.2)"
|
||||||
|
_hover={{ bg: 'rgba(128, 90, 213, 0.3)' }}
|
||||||
|
w="100%"
|
||||||
|
overflow="hidden"
|
||||||
|
>
|
||||||
|
<Text fontWeight="600" fontSize="10px" isTruncated flex="1" minW={0}>
|
||||||
|
📊 {reportCount} 篇研报
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 涨跌数据
|
||||||
|
if (hasPriceData) {
|
||||||
|
const changePercent = item.price.avg_change_pct;
|
||||||
|
const isSignificantRise = changePercent >= 3;
|
||||||
|
let bgColor = 'rgba(226, 232, 240, 0.2)';
|
||||||
|
let textColor = '#e2e8f0';
|
||||||
|
let title = priceInfo.text;
|
||||||
|
|
||||||
|
if (priceInfo.color === 'red') {
|
||||||
|
if (isSignificantRise) {
|
||||||
|
bgColor = 'rgba(245, 101, 101, 0.3)';
|
||||||
|
textColor = '#F56565';
|
||||||
|
title = `🔥 ${priceInfo.text}`;
|
||||||
|
} else {
|
||||||
|
bgColor = 'rgba(252, 129, 129, 0.2)';
|
||||||
|
textColor = '#FC8181';
|
||||||
|
}
|
||||||
|
} else if (priceInfo.color === 'green') {
|
||||||
|
bgColor = 'rgba(104, 211, 145, 0.2)';
|
||||||
|
textColor = '#68D391';
|
||||||
|
}
|
||||||
|
|
||||||
|
events.push(
|
||||||
|
<HStack
|
||||||
|
key="price"
|
||||||
|
spacing={1}
|
||||||
|
fontSize="10px"
|
||||||
|
color={textColor}
|
||||||
|
cursor="pointer"
|
||||||
|
px={1}
|
||||||
|
py={0.5}
|
||||||
|
borderRadius="sm"
|
||||||
|
bg={bgColor}
|
||||||
|
border={isSignificantRise ? '1px solid #C53030' : 'none'}
|
||||||
|
_hover={{ opacity: 0.8 }}
|
||||||
|
w="100%"
|
||||||
|
overflow="hidden"
|
||||||
|
>
|
||||||
|
<Text fontWeight="bold" fontSize="10px" isTruncated flex="1" minW={0}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 最多显示 3 个事件,超出显示 "更多"
|
||||||
|
const maxDisplay = 3;
|
||||||
|
const displayEvents = events.slice(0, maxDisplay);
|
||||||
|
const remainingCount = events.length - maxDisplay;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VStack spacing={0.5} align="stretch" w="100%" mt={1}>
|
||||||
|
{displayEvents}
|
||||||
|
{remainingCount > 0 && (
|
||||||
|
<Text fontSize="9px" color="whiteAlpha.600" px={1}>
|
||||||
|
+{remainingCount} 更多
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 获取时间轴数据
|
// 获取时间轴数据
|
||||||
@@ -833,7 +870,7 @@ const ConceptTimelineModal = ({
|
|||||||
</HStack>
|
</HStack>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
{/* FullCalendar 日历组件 */}
|
{/* Ant Design 日历组件 */}
|
||||||
<Box
|
<Box
|
||||||
height={{ base: '500px', md: '700px' }}
|
height={{ base: '500px', md: '700px' }}
|
||||||
bg="rgba(15, 23, 42, 0.6)"
|
bg="rgba(15, 23, 42, 0.6)"
|
||||||
@@ -841,129 +878,12 @@ const ConceptTimelineModal = ({
|
|||||||
border="1px solid"
|
border="1px solid"
|
||||||
borderColor="whiteAlpha.100"
|
borderColor="whiteAlpha.100"
|
||||||
p={{ base: 1, md: 4 }}
|
p={{ base: 1, md: 4 }}
|
||||||
sx={{
|
|
||||||
// FullCalendar 深色主题样式定制
|
|
||||||
'.fc': {
|
|
||||||
height: '100%',
|
|
||||||
},
|
|
||||||
'.fc-header-toolbar': {
|
|
||||||
marginBottom: { base: '0.5rem', md: '1.5rem' },
|
|
||||||
padding: { base: '0 4px', md: '0' },
|
|
||||||
flexWrap: 'nowrap',
|
|
||||||
gap: { base: '4px', md: '8px' },
|
|
||||||
},
|
|
||||||
'.fc-toolbar-chunk': {
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
'.fc-toolbar-title': {
|
|
||||||
fontSize: { base: '1rem', md: '1.5rem' },
|
|
||||||
fontWeight: 'bold',
|
|
||||||
color: 'white',
|
|
||||||
},
|
|
||||||
'.fc-button': {
|
|
||||||
backgroundColor: 'rgba(139, 92, 246, 0.6)',
|
|
||||||
borderColor: 'rgba(139, 92, 246, 0.8)',
|
|
||||||
color: 'white',
|
|
||||||
padding: { base: '4px 8px', md: '6px 12px' },
|
|
||||||
fontSize: { base: '12px', md: '14px' },
|
|
||||||
'&:hover': {
|
|
||||||
backgroundColor: 'rgba(139, 92, 246, 0.8)',
|
|
||||||
borderColor: 'rgba(139, 92, 246, 1)',
|
|
||||||
},
|
|
||||||
'&:active, &:focus': {
|
|
||||||
backgroundColor: 'rgba(139, 92, 246, 1)',
|
|
||||||
borderColor: 'rgba(139, 92, 246, 1)',
|
|
||||||
boxShadow: 'none',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'.fc-button-active': {
|
|
||||||
backgroundColor: 'rgba(139, 92, 246, 1)',
|
|
||||||
borderColor: 'rgba(139, 92, 246, 1)',
|
|
||||||
},
|
|
||||||
// 深色主题 - 表格边框和背景
|
|
||||||
'.fc-theme-standard td, .fc-theme-standard th': {
|
|
||||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
|
||||||
},
|
|
||||||
'.fc-theme-standard .fc-scrollgrid': {
|
|
||||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
|
||||||
},
|
|
||||||
'.fc-col-header-cell': {
|
|
||||||
backgroundColor: 'rgba(15, 23, 42, 0.8)',
|
|
||||||
},
|
|
||||||
'.fc-col-header-cell-cushion': {
|
|
||||||
color: 'rgba(255, 255, 255, 0.8)',
|
|
||||||
fontSize: { base: '0.75rem', md: '0.875rem' },
|
|
||||||
padding: { base: '4px 2px', md: '8px' },
|
|
||||||
},
|
|
||||||
'.fc-daygrid-day': {
|
|
||||||
cursor: 'pointer',
|
|
||||||
transition: 'all 0.2s',
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
'&:hover': {
|
|
||||||
backgroundColor: 'rgba(139, 92, 246, 0.2)',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'.fc-daygrid-day-number': {
|
|
||||||
color: 'rgba(255, 255, 255, 0.9)',
|
|
||||||
padding: { base: '2px', md: '4px' },
|
|
||||||
fontSize: { base: '0.75rem', md: '0.875rem' },
|
|
||||||
},
|
|
||||||
'.fc-day-today': {
|
|
||||||
backgroundColor: 'rgba(139, 92, 246, 0.15) !important',
|
|
||||||
},
|
|
||||||
'.fc-day-other .fc-daygrid-day-number': {
|
|
||||||
color: 'rgba(255, 255, 255, 0.4)',
|
|
||||||
},
|
|
||||||
'.fc-event': {
|
|
||||||
cursor: 'pointer',
|
|
||||||
border: 'none',
|
|
||||||
padding: { base: '1px 2px', md: '2px 4px' },
|
|
||||||
fontSize: { base: '0.65rem', md: '0.75rem' },
|
|
||||||
fontWeight: 'bold',
|
|
||||||
borderRadius: '4px',
|
|
||||||
transition: 'all 0.2s',
|
|
||||||
'&:hover': {
|
|
||||||
transform: 'scale(1.05)',
|
|
||||||
boxShadow: '0 2px 8px rgba(0,0,0,0.4)',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'.fc-daygrid-event-harness': {
|
|
||||||
marginBottom: { base: '1px', md: '2px' },
|
|
||||||
},
|
|
||||||
'.fc-more-link': {
|
|
||||||
color: 'rgba(255, 255, 255, 0.8)',
|
|
||||||
},
|
|
||||||
// H5 端隐藏事件文字,只显示色块
|
|
||||||
'@media (max-width: 768px)': {
|
|
||||||
'.fc-event-title': {
|
|
||||||
fontSize: '0.6rem',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<FullCalendar
|
<BaseCalendar
|
||||||
plugins={[dayGridPlugin, interactionPlugin]}
|
onSelect={handleDateSelect}
|
||||||
initialView="dayGridMonth"
|
cellRender={renderCellContent}
|
||||||
locale="zh-cn"
|
|
||||||
headerToolbar={{
|
|
||||||
left: 'prev,next today',
|
|
||||||
center: 'title',
|
|
||||||
right: '',
|
|
||||||
}}
|
|
||||||
events={calendarEvents}
|
|
||||||
dateClick={handleDateClick}
|
|
||||||
eventClick={handleEventClick}
|
|
||||||
height="100%"
|
height="100%"
|
||||||
dayMaxEvents={3}
|
showToolbar={true}
|
||||||
moreLinkText="更多"
|
|
||||||
buttonText={{
|
|
||||||
today: '今天',
|
|
||||||
month: '月',
|
|
||||||
week: '周',
|
|
||||||
}}
|
|
||||||
eventDisplay="block"
|
|
||||||
displayEventTime={false}
|
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,610 +0,0 @@
|
|||||||
// src/views/Dashboard/Center.js
|
|
||||||
import React, { useEffect, useState, useCallback } from 'react';
|
|
||||||
import { logger } from '../../utils/logger';
|
|
||||||
import { getApiBase } from '../../utils/apiConfig';
|
|
||||||
import { useDashboardEvents } from '../../hooks/useDashboardEvents';
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
Flex,
|
|
||||||
Grid,
|
|
||||||
SimpleGrid,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
Badge,
|
|
||||||
Button,
|
|
||||||
VStack,
|
|
||||||
HStack,
|
|
||||||
Card,
|
|
||||||
CardHeader,
|
|
||||||
CardBody,
|
|
||||||
Heading,
|
|
||||||
useColorModeValue,
|
|
||||||
Icon,
|
|
||||||
IconButton,
|
|
||||||
Stat,
|
|
||||||
StatLabel,
|
|
||||||
StatNumber,
|
|
||||||
StatHelpText,
|
|
||||||
StatArrow,
|
|
||||||
Divider,
|
|
||||||
Tag,
|
|
||||||
TagLabel,
|
|
||||||
TagLeftIcon,
|
|
||||||
Wrap,
|
|
||||||
WrapItem,
|
|
||||||
Avatar,
|
|
||||||
Tooltip,
|
|
||||||
Progress,
|
|
||||||
useToast,
|
|
||||||
LinkBox,
|
|
||||||
LinkOverlay,
|
|
||||||
Spinner,
|
|
||||||
Center,
|
|
||||||
Image,
|
|
||||||
} from '@chakra-ui/react';
|
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
|
||||||
import { useLocation, useNavigate, Link } from 'react-router-dom';
|
|
||||||
import {
|
|
||||||
FiTrendingUp,
|
|
||||||
FiEye,
|
|
||||||
FiMessageSquare,
|
|
||||||
FiThumbsUp,
|
|
||||||
FiClock,
|
|
||||||
FiCalendar,
|
|
||||||
FiRefreshCw,
|
|
||||||
FiTrash2,
|
|
||||||
FiExternalLink,
|
|
||||||
FiPlus,
|
|
||||||
FiBarChart2,
|
|
||||||
FiStar,
|
|
||||||
FiActivity,
|
|
||||||
FiAlertCircle,
|
|
||||||
FiUsers,
|
|
||||||
} from 'react-icons/fi';
|
|
||||||
import MyFutureEvents from './components/MyFutureEvents';
|
|
||||||
import InvestmentPlanningCenter from './components/InvestmentPlanningCenter';
|
|
||||||
import { getEventDetailUrl } from '@/utils/idEncoder';
|
|
||||||
|
|
||||||
export default function CenterDashboard() {
|
|
||||||
const { user } = useAuth();
|
|
||||||
const location = useLocation();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const toast = useToast();
|
|
||||||
|
|
||||||
// ⚡ 提取 userId 为独立变量
|
|
||||||
const userId = user?.id;
|
|
||||||
|
|
||||||
// 🎯 初始化Dashboard埋点Hook
|
|
||||||
const dashboardEvents = useDashboardEvents({
|
|
||||||
pageType: 'center',
|
|
||||||
navigate
|
|
||||||
});
|
|
||||||
|
|
||||||
// 颜色主题
|
|
||||||
const textColor = useColorModeValue('gray.700', 'white');
|
|
||||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
|
||||||
const bgColor = useColorModeValue('white', 'gray.800');
|
|
||||||
const hoverBg = useColorModeValue('gray.50', 'gray.700');
|
|
||||||
const secondaryText = useColorModeValue('gray.600', 'gray.400');
|
|
||||||
const cardBg = useColorModeValue('white', 'gray.800');
|
|
||||||
const sectionBg = useColorModeValue('gray.50', 'gray.900');
|
|
||||||
|
|
||||||
const [watchlist, setWatchlist] = useState([]);
|
|
||||||
const [realtimeQuotes, setRealtimeQuotes] = useState({});
|
|
||||||
const [followingEvents, setFollowingEvents] = useState([]);
|
|
||||||
const [eventComments, setEventComments] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [quotesLoading, setQuotesLoading] = useState(false);
|
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const base = getApiBase();
|
|
||||||
const ts = Date.now();
|
|
||||||
|
|
||||||
const [w, e, c] = await Promise.all([
|
|
||||||
fetch(base + `/api/account/watchlist?_=${ts}`, { credentials: 'include', cache: 'no-store' }),
|
|
||||||
fetch(base + `/api/account/events/following?_=${ts}`, { credentials: 'include', cache: 'no-store' }),
|
|
||||||
fetch(base + `/api/account/events/posts?_=${ts}`, { credentials: 'include', cache: 'no-store' }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const jw = await w.json();
|
|
||||||
const je = await e.json();
|
|
||||||
const jc = await c.json();
|
|
||||||
if (jw.success) {
|
|
||||||
const watchlistData = Array.isArray(jw.data) ? jw.data : [];
|
|
||||||
setWatchlist(watchlistData);
|
|
||||||
|
|
||||||
// 🎯 追踪自选股列表查看
|
|
||||||
if (watchlistData.length > 0) {
|
|
||||||
dashboardEvents.trackWatchlistViewed(watchlistData.length, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 加载实时行情
|
|
||||||
if (jw.data && jw.data.length > 0) {
|
|
||||||
loadRealtimeQuotes();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (je.success) {
|
|
||||||
const eventsData = Array.isArray(je.data) ? je.data : [];
|
|
||||||
setFollowingEvents(eventsData);
|
|
||||||
|
|
||||||
// 🎯 追踪关注的事件列表查看
|
|
||||||
dashboardEvents.trackFollowingEventsViewed(eventsData.length);
|
|
||||||
}
|
|
||||||
if (jc.success) {
|
|
||||||
const commentsData = Array.isArray(jc.data) ? jc.data : [];
|
|
||||||
setEventComments(commentsData);
|
|
||||||
|
|
||||||
// 🎯 追踪评论列表查看
|
|
||||||
dashboardEvents.trackCommentsViewed(commentsData.length);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
logger.error('Center', 'loadData', err, {
|
|
||||||
userId,
|
|
||||||
timestamp: new Date().toISOString()
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [userId]); // ⚡ 使用 userId 而不是 user?.id
|
|
||||||
|
|
||||||
// 加载实时行情
|
|
||||||
const loadRealtimeQuotes = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
setQuotesLoading(true);
|
|
||||||
const base = getApiBase();
|
|
||||||
const response = await fetch(base + '/api/account/watchlist/realtime', {
|
|
||||||
credentials: 'include',
|
|
||||||
cache: 'no-store'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
if (data.success) {
|
|
||||||
const quotesMap = {};
|
|
||||||
data.data.forEach(item => {
|
|
||||||
quotesMap[item.stock_code] = item;
|
|
||||||
});
|
|
||||||
setRealtimeQuotes(quotesMap);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Center', 'loadRealtimeQuotes', error, {
|
|
||||||
userId: user?.id,
|
|
||||||
watchlistLength: watchlist.length
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setQuotesLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 格式化日期
|
|
||||||
const formatDate = (dateString) => {
|
|
||||||
if (!dateString) return '';
|
|
||||||
const date = new Date(dateString);
|
|
||||||
const now = new Date();
|
|
||||||
const diffTime = Math.abs(now - date);
|
|
||||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
|
||||||
|
|
||||||
if (diffDays < 1) {
|
|
||||||
const diffHours = Math.ceil(diffTime / (1000 * 60 * 60));
|
|
||||||
if (diffHours < 1) {
|
|
||||||
const diffMinutes = Math.ceil(diffTime / (1000 * 60));
|
|
||||||
return `${diffMinutes}分钟前`;
|
|
||||||
}
|
|
||||||
return `${diffHours}小时前`;
|
|
||||||
} else if (diffDays < 7) {
|
|
||||||
return `${diffDays}天前`;
|
|
||||||
} else {
|
|
||||||
return date.toLocaleDateString('zh-CN');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 格式化数字
|
|
||||||
const formatNumber = (num) => {
|
|
||||||
if (!num) return '0';
|
|
||||||
if (num >= 10000) {
|
|
||||||
return (num / 10000).toFixed(1) + 'w';
|
|
||||||
} else if (num >= 1000) {
|
|
||||||
return (num / 1000).toFixed(1) + 'k';
|
|
||||||
}
|
|
||||||
return num.toString();
|
|
||||||
};
|
|
||||||
|
|
||||||
// 获取事件热度颜色
|
|
||||||
const getHeatColor = (score) => {
|
|
||||||
if (score >= 80) return 'red';
|
|
||||||
if (score >= 60) return 'orange';
|
|
||||||
if (score >= 40) return 'yellow';
|
|
||||||
return 'green';
|
|
||||||
};
|
|
||||||
|
|
||||||
// 🔧 使用 ref 跟踪是否已经加载过数据(首次加载标记)
|
|
||||||
const hasLoadedRef = React.useRef(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const isOnCenterPage = location.pathname.includes('/home/center');
|
|
||||||
|
|
||||||
// 首次进入页面且有用户时加载数据
|
|
||||||
if (user && isOnCenterPage && !hasLoadedRef.current) {
|
|
||||||
console.log('[Center] 🚀 首次加载数据');
|
|
||||||
hasLoadedRef.current = true;
|
|
||||||
loadData();
|
|
||||||
}
|
|
||||||
|
|
||||||
const onVis = () => {
|
|
||||||
if (document.visibilityState === 'visible' && location.pathname.includes('/home/center')) {
|
|
||||||
console.log('[Center] 👁️ visibilitychange 触发 loadData');
|
|
||||||
loadData();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener('visibilitychange', onVis);
|
|
||||||
return () => document.removeEventListener('visibilitychange', onVis);
|
|
||||||
}, [userId, location.pathname, loadData, user]);
|
|
||||||
|
|
||||||
// 当用户登出再登入(userId 变化)时,重置加载标记
|
|
||||||
useEffect(() => {
|
|
||||||
if (!user) {
|
|
||||||
hasLoadedRef.current = false;
|
|
||||||
}
|
|
||||||
}, [user]);
|
|
||||||
|
|
||||||
// 定时刷新实时行情(每分钟一次)
|
|
||||||
useEffect(() => {
|
|
||||||
if (watchlist.length > 0) {
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
loadRealtimeQuotes();
|
|
||||||
}, 60000); // 60秒刷新一次
|
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}
|
|
||||||
}, [watchlist.length, loadRealtimeQuotes]);
|
|
||||||
|
|
||||||
// 渲染加载状态
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<Center h="60vh">
|
|
||||||
<VStack spacing={4}>
|
|
||||||
<Spinner size="xl" color="blue.500" thickness="4px" />
|
|
||||||
<Text color={secondaryText}>加载个人中心数据...</Text>
|
|
||||||
</VStack>
|
|
||||||
</Center>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box bg={sectionBg} minH="100vh" overflowX="hidden">
|
|
||||||
<Box px={{ base: 3, md: 8 }} py={{ base: 4, md: 6 }} maxW="1400px" mx="auto">
|
|
||||||
{/* 主要内容区域 */}
|
|
||||||
<Grid templateColumns={{ base: '1fr', lg: 'repeat(3, 1fr)' }} gap={6} mb={8}>
|
|
||||||
{/* 左列:自选股票 */}
|
|
||||||
<VStack spacing={6} align="stretch" minW={0}>
|
|
||||||
<Card bg={cardBg} shadow="md" maxW="100%" height={{ base: 'auto', md: '600px' }} minH={{ base: '300px', md: '600px' }} display="flex" flexDirection="column">
|
|
||||||
<CardHeader pb={{ base: 2, md: 4 }}>
|
|
||||||
<Flex justify="space-between" align="center">
|
|
||||||
<HStack>
|
|
||||||
<Icon as={FiBarChart2} color="blue.500" boxSize={{ base: 4, md: 5 }} />
|
|
||||||
<Heading size={{ base: 'sm', md: 'md' }}>自选股票</Heading>
|
|
||||||
<Badge colorScheme="blue" variant="subtle">
|
|
||||||
{watchlist.length}
|
|
||||||
</Badge>
|
|
||||||
{quotesLoading && <Spinner size="sm" color="blue.500" />}
|
|
||||||
</HStack>
|
|
||||||
<IconButton
|
|
||||||
icon={<FiPlus />}
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => navigate('/stocks')}
|
|
||||||
aria-label="添加自选股"
|
|
||||||
/>
|
|
||||||
</Flex>
|
|
||||||
</CardHeader>
|
|
||||||
<CardBody pt={0} flex="1" overflowY="auto">
|
|
||||||
{watchlist.length === 0 ? (
|
|
||||||
<Center py={8}>
|
|
||||||
<VStack spacing={3}>
|
|
||||||
<Icon as={FiBarChart2} boxSize={12} color="gray.300" />
|
|
||||||
<Text color={secondaryText} fontSize="sm">
|
|
||||||
暂无自选股
|
|
||||||
</Text>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
colorScheme="blue"
|
|
||||||
onClick={() => navigate('/stocks')}
|
|
||||||
>
|
|
||||||
添加自选股
|
|
||||||
</Button>
|
|
||||||
</VStack>
|
|
||||||
</Center>
|
|
||||||
) : (
|
|
||||||
<VStack align="stretch" spacing={2}>
|
|
||||||
{watchlist.slice(0, 10).map((stock) => (
|
|
||||||
<LinkBox
|
|
||||||
key={stock.stock_code}
|
|
||||||
p={3}
|
|
||||||
borderRadius="md"
|
|
||||||
_hover={{ bg: hoverBg }}
|
|
||||||
transition="all 0.2s"
|
|
||||||
cursor="pointer"
|
|
||||||
>
|
|
||||||
<HStack justify="space-between">
|
|
||||||
<VStack align="start" spacing={0}>
|
|
||||||
<LinkOverlay
|
|
||||||
as={Link}
|
|
||||||
to={`/company/${stock.stock_code}`}
|
|
||||||
>
|
|
||||||
<Text fontWeight="medium" fontSize="sm">
|
|
||||||
{stock.stock_name || stock.stock_code}
|
|
||||||
</Text>
|
|
||||||
</LinkOverlay>
|
|
||||||
<HStack spacing={2}>
|
|
||||||
<Badge variant="subtle" fontSize="xs">
|
|
||||||
{stock.stock_code}
|
|
||||||
</Badge>
|
|
||||||
{realtimeQuotes[stock.stock_code] ? (
|
|
||||||
<Badge
|
|
||||||
colorScheme={realtimeQuotes[stock.stock_code].change_percent > 0 ? 'red' : 'green'}
|
|
||||||
fontSize="xs"
|
|
||||||
>
|
|
||||||
{realtimeQuotes[stock.stock_code].change_percent > 0 ? '+' : ''}
|
|
||||||
{realtimeQuotes[stock.stock_code].change_percent.toFixed(2)}%
|
|
||||||
</Badge>
|
|
||||||
) : stock.change_percent ? (
|
|
||||||
<Badge
|
|
||||||
colorScheme={stock.change_percent > 0 ? 'red' : 'green'}
|
|
||||||
fontSize="xs"
|
|
||||||
>
|
|
||||||
{stock.change_percent > 0 ? '+' : ''}
|
|
||||||
{stock.change_percent}%
|
|
||||||
</Badge>
|
|
||||||
) : null}
|
|
||||||
</HStack>
|
|
||||||
</VStack>
|
|
||||||
<VStack align="end" spacing={0}>
|
|
||||||
<Text fontWeight="bold" fontSize="sm">
|
|
||||||
{realtimeQuotes[stock.stock_code]?.current_price?.toFixed(2) || stock.current_price || '--'}
|
|
||||||
</Text>
|
|
||||||
<Text fontSize="xs" color={secondaryText}>
|
|
||||||
{realtimeQuotes[stock.stock_code]?.update_time || stock.industry || '未分类'}
|
|
||||||
</Text>
|
|
||||||
</VStack>
|
|
||||||
</HStack>
|
|
||||||
</LinkBox>
|
|
||||||
))}
|
|
||||||
{watchlist.length > 10 && (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => navigate('/stocks')}
|
|
||||||
>
|
|
||||||
查看全部 ({watchlist.length})
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</VStack>
|
|
||||||
)}
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
</VStack>
|
|
||||||
|
|
||||||
{/* 中列:关注事件 */}
|
|
||||||
<VStack spacing={6} align="stretch" minW={0}>
|
|
||||||
{/* 关注事件 */}
|
|
||||||
<Card bg={cardBg} shadow="md" maxW="100%" height={{ base: 'auto', md: '600px' }} minH={{ base: '300px', md: '600px' }} display="flex" flexDirection="column">
|
|
||||||
<CardHeader pb={{ base: 2, md: 4 }}>
|
|
||||||
<Flex justify="space-between" align="center">
|
|
||||||
<HStack>
|
|
||||||
<Icon as={FiStar} color="yellow.500" boxSize={{ base: 4, md: 5 }} />
|
|
||||||
<Heading size={{ base: 'sm', md: 'md' }}>关注事件</Heading>
|
|
||||||
<Badge colorScheme="yellow" variant="subtle">
|
|
||||||
{followingEvents.length}
|
|
||||||
</Badge>
|
|
||||||
</HStack>
|
|
||||||
<IconButton
|
|
||||||
icon={<FiPlus />}
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => navigate('/community')}
|
|
||||||
aria-label="添加关注事件"
|
|
||||||
/>
|
|
||||||
</Flex>
|
|
||||||
</CardHeader>
|
|
||||||
<CardBody pt={0} flex="1" overflowY="auto">
|
|
||||||
{followingEvents.length === 0 ? (
|
|
||||||
<Center py={8}>
|
|
||||||
<VStack spacing={3}>
|
|
||||||
<Icon as={FiActivity} boxSize={12} color="gray.300" />
|
|
||||||
<Text color={secondaryText} fontSize="sm">
|
|
||||||
暂无关注事件
|
|
||||||
</Text>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
colorScheme="blue"
|
|
||||||
onClick={() => navigate('/community')}
|
|
||||||
>
|
|
||||||
探索事件
|
|
||||||
</Button>
|
|
||||||
</VStack>
|
|
||||||
</Center>
|
|
||||||
) : (
|
|
||||||
<VStack align="stretch" spacing={3}>
|
|
||||||
{followingEvents.slice(0, 5).map((event) => (
|
|
||||||
<LinkBox
|
|
||||||
key={event.id}
|
|
||||||
p={4}
|
|
||||||
borderRadius="lg"
|
|
||||||
border="1px"
|
|
||||||
borderColor={borderColor}
|
|
||||||
_hover={{ shadow: 'md', transform: 'translateY(-2px)' }}
|
|
||||||
transition="all 0.2s"
|
|
||||||
>
|
|
||||||
<VStack align="stretch" spacing={3}>
|
|
||||||
<LinkOverlay
|
|
||||||
as={Link}
|
|
||||||
to={getEventDetailUrl(event.id)}
|
|
||||||
>
|
|
||||||
<Text fontWeight="medium" fontSize="md" noOfLines={2}>
|
|
||||||
{event.title}
|
|
||||||
</Text>
|
|
||||||
</LinkOverlay>
|
|
||||||
|
|
||||||
{/* 事件标签 */}
|
|
||||||
{event.tags && event.tags.length > 0 && (
|
|
||||||
<Wrap>
|
|
||||||
{event.tags.slice(0, 3).map((tag, idx) => (
|
|
||||||
<WrapItem key={idx}>
|
|
||||||
<Tag size="sm" variant="subtle" colorScheme="blue">
|
|
||||||
<TagLabel>{tag}</TagLabel>
|
|
||||||
</Tag>
|
|
||||||
</WrapItem>
|
|
||||||
))}
|
|
||||||
</Wrap>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 事件统计 */}
|
|
||||||
<HStack spacing={4} fontSize="sm" color={secondaryText}>
|
|
||||||
{event.related_avg_chg !== undefined && event.related_avg_chg !== null && (
|
|
||||||
<Badge
|
|
||||||
colorScheme={event.related_avg_chg > 0 ? 'red' : 'green'}
|
|
||||||
variant="subtle"
|
|
||||||
>
|
|
||||||
平均超额 {event.related_avg_chg > 0 ? '+' : ''}{Number(event.related_avg_chg).toFixed(2)}%
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
<HStack spacing={1}>
|
|
||||||
<Icon as={FiUsers} />
|
|
||||||
<Text>{event.follower_count || 0} 关注</Text>
|
|
||||||
</HStack>
|
|
||||||
</HStack>
|
|
||||||
|
|
||||||
{/* 事件信息 */}
|
|
||||||
<Flex justify="space-between" align="center">
|
|
||||||
<HStack spacing={2} fontSize="xs" color={secondaryText}>
|
|
||||||
<Avatar
|
|
||||||
size="xs"
|
|
||||||
name={event.creator?.username || '系统'}
|
|
||||||
src={event.creator?.avatar_url}
|
|
||||||
/>
|
|
||||||
<Text>{event.creator?.username || '系统'}</Text>
|
|
||||||
<Text>·</Text>
|
|
||||||
<Text>{formatDate(event.created_at)}</Text>
|
|
||||||
</HStack>
|
|
||||||
{event.exceed_expectation_score && (
|
|
||||||
<Badge
|
|
||||||
colorScheme={event.exceed_expectation_score > 70 ? 'red' : 'orange'}
|
|
||||||
variant="solid"
|
|
||||||
fontSize="xs"
|
|
||||||
>
|
|
||||||
超预期 {event.exceed_expectation_score}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</Flex>
|
|
||||||
</VStack>
|
|
||||||
</LinkBox>
|
|
||||||
))}
|
|
||||||
{followingEvents.length > 5 && (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => navigate('/community')}
|
|
||||||
>
|
|
||||||
查看全部 ({followingEvents.length})
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</VStack>
|
|
||||||
)}
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
</VStack>
|
|
||||||
|
|
||||||
{/* 右列:我的评论 */}
|
|
||||||
<VStack spacing={6} align="stretch" minW={0}>
|
|
||||||
{/* 我的评论 */}
|
|
||||||
<Card bg={cardBg} shadow="md" maxW="100%" height={{ base: 'auto', md: '600px' }} minH={{ base: '300px', md: '600px' }} display="flex" flexDirection="column">
|
|
||||||
<CardHeader pb={{ base: 2, md: 4 }}>
|
|
||||||
<Flex justify="space-between" align="center">
|
|
||||||
<HStack>
|
|
||||||
<Icon as={FiMessageSquare} color="purple.500" boxSize={{ base: 4, md: 5 }} />
|
|
||||||
<Heading size={{ base: 'sm', md: 'md' }}>我的评论</Heading>
|
|
||||||
<Badge colorScheme="purple" variant="subtle">
|
|
||||||
{eventComments.length}
|
|
||||||
</Badge>
|
|
||||||
</HStack>
|
|
||||||
</Flex>
|
|
||||||
</CardHeader>
|
|
||||||
<CardBody pt={0} flex="1" overflowY="auto">
|
|
||||||
{eventComments.length === 0 ? (
|
|
||||||
<Center py={8}>
|
|
||||||
<VStack spacing={3}>
|
|
||||||
<Icon as={FiMessageSquare} boxSize={12} color="gray.300" />
|
|
||||||
<Text color={secondaryText} fontSize="sm">
|
|
||||||
暂无评论记录
|
|
||||||
</Text>
|
|
||||||
<Text color={secondaryText} fontSize="xs" textAlign="center">
|
|
||||||
参与事件讨论,分享您的观点
|
|
||||||
</Text>
|
|
||||||
</VStack>
|
|
||||||
</Center>
|
|
||||||
) : (
|
|
||||||
<VStack align="stretch" spacing={3}>
|
|
||||||
{eventComments.slice(0, 5).map((comment) => (
|
|
||||||
<Box
|
|
||||||
key={comment.id}
|
|
||||||
p={3}
|
|
||||||
borderRadius="md"
|
|
||||||
border="1px"
|
|
||||||
borderColor={borderColor}
|
|
||||||
_hover={{ bg: hoverBg }}
|
|
||||||
transition="all 0.2s"
|
|
||||||
>
|
|
||||||
<VStack align="stretch" spacing={2}>
|
|
||||||
<Text fontSize="sm" noOfLines={3}>
|
|
||||||
{comment.content}
|
|
||||||
</Text>
|
|
||||||
<HStack justify="space-between" fontSize="xs" color={secondaryText} spacing={2}>
|
|
||||||
<HStack flexShrink={0}>
|
|
||||||
<Icon as={FiClock} />
|
|
||||||
<Text>{formatDate(comment.created_at)}</Text>
|
|
||||||
</HStack>
|
|
||||||
{comment.event_title && (
|
|
||||||
<Tooltip label={comment.event_title}>
|
|
||||||
<Badge
|
|
||||||
variant="subtle"
|
|
||||||
fontSize="xs"
|
|
||||||
maxW={{ base: '120px', md: '180px' }}
|
|
||||||
overflow="hidden"
|
|
||||||
textOverflow="ellipsis"
|
|
||||||
whiteSpace="nowrap"
|
|
||||||
>
|
|
||||||
{comment.event_title}
|
|
||||||
</Badge>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</HStack>
|
|
||||||
</VStack>
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
{eventComments.length > 5 && (
|
|
||||||
<Text fontSize="sm" color={secondaryText} textAlign="center">
|
|
||||||
共 {eventComments.length} 条评论
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</VStack>
|
|
||||||
)}
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
</VStack>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
{/* 投资规划中心(整合了日历、计划、复盘) */}
|
|
||||||
<Box>
|
|
||||||
<InvestmentPlanningCenter />
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,203 +0,0 @@
|
|||||||
/**
|
|
||||||
* CalendarPanel - 投资日历面板组件
|
|
||||||
* 使用 FullCalendar 展示投资计划、复盘等事件
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useState, lazy, Suspense, useMemo, useCallback } from 'react';
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
Modal,
|
|
||||||
ModalOverlay,
|
|
||||||
ModalContent,
|
|
||||||
ModalHeader,
|
|
||||||
ModalBody,
|
|
||||||
ModalCloseButton,
|
|
||||||
Spinner,
|
|
||||||
Center,
|
|
||||||
} from '@chakra-ui/react';
|
|
||||||
import FullCalendar from '@fullcalendar/react';
|
|
||||||
import dayGridPlugin from '@fullcalendar/daygrid';
|
|
||||||
import interactionPlugin from '@fullcalendar/interaction';
|
|
||||||
import type { DateClickArg } from '@fullcalendar/interaction';
|
|
||||||
import type { EventClickArg } from '@fullcalendar/core';
|
|
||||||
import dayjs, { Dayjs } from 'dayjs';
|
|
||||||
import 'dayjs/locale/zh-cn';
|
|
||||||
|
|
||||||
import { usePlanningData } from './PlanningContext';
|
|
||||||
import { EventDetailModal } from './EventDetailModal';
|
|
||||||
import type { InvestmentEvent } from '@/types';
|
|
||||||
import './InvestmentCalendar.less';
|
|
||||||
|
|
||||||
// 懒加载投资日历组件
|
|
||||||
const InvestmentCalendar = lazy(() => import('@components/InvestmentCalendar'));
|
|
||||||
|
|
||||||
dayjs.locale('zh-cn');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* FullCalendar 事件类型
|
|
||||||
*/
|
|
||||||
interface CalendarEvent {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
start: string;
|
|
||||||
date: string;
|
|
||||||
backgroundColor: string;
|
|
||||||
borderColor: string;
|
|
||||||
extendedProps: InvestmentEvent & {
|
|
||||||
isSystem: boolean;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* CalendarPanel 组件
|
|
||||||
* 日历视图面板,显示所有投资事件
|
|
||||||
*/
|
|
||||||
export const CalendarPanel: React.FC = () => {
|
|
||||||
const {
|
|
||||||
allEvents,
|
|
||||||
borderColor,
|
|
||||||
secondaryText,
|
|
||||||
setViewMode,
|
|
||||||
setListTab,
|
|
||||||
} = usePlanningData();
|
|
||||||
|
|
||||||
// 弹窗状态(统一使用 useState)
|
|
||||||
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false);
|
|
||||||
const [isInvestmentCalendarOpen, setIsInvestmentCalendarOpen] = useState(false);
|
|
||||||
|
|
||||||
const [selectedDate, setSelectedDate] = useState<Dayjs | null>(null);
|
|
||||||
const [selectedDateEvents, setSelectedDateEvents] = useState<InvestmentEvent[]>([]);
|
|
||||||
|
|
||||||
// 转换数据为 FullCalendar 格式(使用 useMemo 缓存)
|
|
||||||
const calendarEvents: CalendarEvent[] = useMemo(() =>
|
|
||||||
allEvents.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' : event.type === 'plan' ? '#8B5CF6' : '#38A169',
|
|
||||||
borderColor: event.source === 'future' ? '#3182CE' : event.type === 'plan' ? '#8B5CF6' : '#38A169',
|
|
||||||
extendedProps: {
|
|
||||||
...event,
|
|
||||||
isSystem: event.source === 'future',
|
|
||||||
}
|
|
||||||
})), [allEvents]);
|
|
||||||
|
|
||||||
// 抽取公共的打开事件详情函数
|
|
||||||
const openEventDetail = useCallback((date: Date | null): void => {
|
|
||||||
if (!date) return;
|
|
||||||
const clickedDate = dayjs(date);
|
|
||||||
setSelectedDate(clickedDate);
|
|
||||||
|
|
||||||
const dayEvents = allEvents.filter(event =>
|
|
||||||
dayjs(event.event_date).isSame(clickedDate, 'day')
|
|
||||||
);
|
|
||||||
setSelectedDateEvents(dayEvents);
|
|
||||||
setIsDetailModalOpen(true);
|
|
||||||
}, [allEvents]);
|
|
||||||
|
|
||||||
// 处理日期点击
|
|
||||||
const handleDateClick = useCallback((info: DateClickArg): void => {
|
|
||||||
openEventDetail(info.date);
|
|
||||||
}, [openEventDetail]);
|
|
||||||
|
|
||||||
// 处理事件点击
|
|
||||||
const handleEventClick = useCallback((info: EventClickArg): void => {
|
|
||||||
openEventDetail(info.event.start);
|
|
||||||
}, [openEventDetail]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<Box
|
|
||||||
height={{ base: '380px', md: '560px' }}
|
|
||||||
sx={{
|
|
||||||
// FullCalendar 按钮样式覆盖(与日历视图按钮颜色一致)
|
|
||||||
'.fc .fc-button': {
|
|
||||||
backgroundColor: '#805AD5 !important',
|
|
||||||
borderColor: '#805AD5 !important',
|
|
||||||
color: '#fff !important',
|
|
||||||
'&:hover': {
|
|
||||||
backgroundColor: '#6B46C1 !important',
|
|
||||||
borderColor: '#6B46C1 !important',
|
|
||||||
},
|
|
||||||
'&:disabled': {
|
|
||||||
backgroundColor: '#6B46C1 !important',
|
|
||||||
borderColor: '#6B46C1 !important',
|
|
||||||
opacity: '1 !important',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// 今天日期高亮边框
|
|
||||||
'.fc-daygrid-day.fc-day-today': {
|
|
||||||
border: '2px solid #805AD5 !important',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FullCalendar
|
|
||||||
plugins={[dayGridPlugin, interactionPlugin]}
|
|
||||||
initialView="dayGridMonth"
|
|
||||||
locale="zh-cn"
|
|
||||||
headerToolbar={{
|
|
||||||
left: 'prev,next today',
|
|
||||||
center: 'title',
|
|
||||||
right: ''
|
|
||||||
}}
|
|
||||||
events={calendarEvents}
|
|
||||||
dateClick={handleDateClick}
|
|
||||||
eventClick={handleEventClick}
|
|
||||||
height="100%"
|
|
||||||
dayMaxEvents={1}
|
|
||||||
moreLinkText="+更多"
|
|
||||||
buttonText={{
|
|
||||||
today: '今天',
|
|
||||||
month: '月',
|
|
||||||
week: '周'
|
|
||||||
}}
|
|
||||||
titleFormat={{ year: 'numeric', month: 'long' }}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* 查看事件详情 Modal */}
|
|
||||||
<EventDetailModal
|
|
||||||
isOpen={isDetailModalOpen}
|
|
||||||
onClose={() => setIsDetailModalOpen(false)}
|
|
||||||
selectedDate={selectedDate}
|
|
||||||
events={selectedDateEvents}
|
|
||||||
borderColor={borderColor}
|
|
||||||
secondaryText={secondaryText}
|
|
||||||
onNavigateToPlan={() => {
|
|
||||||
setViewMode('list');
|
|
||||||
setListTab(0);
|
|
||||||
}}
|
|
||||||
onNavigateToReview={() => {
|
|
||||||
setViewMode('list');
|
|
||||||
setListTab(1);
|
|
||||||
}}
|
|
||||||
onOpenInvestmentCalendar={() => {
|
|
||||||
setIsInvestmentCalendarOpen(true);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 投资日历 Modal */}
|
|
||||||
{isInvestmentCalendarOpen && (
|
|
||||||
<Modal
|
|
||||||
isOpen={isInvestmentCalendarOpen}
|
|
||||||
onClose={() => setIsInvestmentCalendarOpen(false)}
|
|
||||||
size={{ base: 'full', md: '6xl' }}
|
|
||||||
>
|
|
||||||
<ModalOverlay />
|
|
||||||
<ModalContent maxW={{ base: '100%', md: '1200px' }} mx={{ base: 0, md: 4 }}>
|
|
||||||
<ModalHeader fontSize={{ base: 'md', md: 'lg' }} py={{ base: 3, md: 4 }}>投资日历</ModalHeader>
|
|
||||||
<ModalCloseButton />
|
|
||||||
<ModalBody pb={6}>
|
|
||||||
<Suspense fallback={<Center py={{ base: 6, md: 8 }}><Spinner size={{ base: 'lg', md: 'xl' }} color="blue.500" /></Center>}>
|
|
||||||
<InvestmentCalendar />
|
|
||||||
</Suspense>
|
|
||||||
</ModalBody>
|
|
||||||
</ModalContent>
|
|
||||||
</Modal>
|
|
||||||
)}
|
|
||||||
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
/**
|
|
||||||
* EventDetailModal - 事件详情弹窗组件
|
|
||||||
* 用于展示某一天的所有投资事件
|
|
||||||
* 使用 Ant Design 实现
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { Modal, Space } from 'antd';
|
|
||||||
import type { Dayjs } from 'dayjs';
|
|
||||||
|
|
||||||
import { EventCard } from './EventCard';
|
|
||||||
import { EventEmptyState } from './EventEmptyState';
|
|
||||||
import type { InvestmentEvent } from '@/types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* EventDetailModal Props
|
|
||||||
*/
|
|
||||||
export interface EventDetailModalProps {
|
|
||||||
/** 是否打开 */
|
|
||||||
isOpen: boolean;
|
|
||||||
/** 关闭回调 */
|
|
||||||
onClose: () => void;
|
|
||||||
/** 选中的日期 */
|
|
||||||
selectedDate: Dayjs | null;
|
|
||||||
/** 选中日期的事件列表 */
|
|
||||||
events: InvestmentEvent[];
|
|
||||||
/** 边框颜色 */
|
|
||||||
borderColor?: string;
|
|
||||||
/** 次要文字颜色 */
|
|
||||||
secondaryText?: string;
|
|
||||||
/** 导航到计划列表 */
|
|
||||||
onNavigateToPlan?: () => void;
|
|
||||||
/** 导航到复盘列表 */
|
|
||||||
onNavigateToReview?: () => void;
|
|
||||||
/** 打开投资日历 */
|
|
||||||
onOpenInvestmentCalendar?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* EventDetailModal 组件
|
|
||||||
*/
|
|
||||||
export const EventDetailModal: React.FC<EventDetailModalProps> = ({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
selectedDate,
|
|
||||||
events,
|
|
||||||
borderColor,
|
|
||||||
secondaryText,
|
|
||||||
onNavigateToPlan,
|
|
||||||
onNavigateToReview,
|
|
||||||
onOpenInvestmentCalendar,
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
open={isOpen}
|
|
||||||
onCancel={onClose}
|
|
||||||
title={`${selectedDate?.format('YYYY年MM月DD日') || ''} 的事件`}
|
|
||||||
footer={null}
|
|
||||||
width={600}
|
|
||||||
maskClosable={false}
|
|
||||||
keyboard={true}
|
|
||||||
centered
|
|
||||||
styles={{
|
|
||||||
body: { paddingTop: 16, paddingBottom: 24 },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{events.length === 0 ? (
|
|
||||||
<EventEmptyState
|
|
||||||
onNavigateToPlan={() => {
|
|
||||||
onClose();
|
|
||||||
onNavigateToPlan?.();
|
|
||||||
}}
|
|
||||||
onNavigateToReview={() => {
|
|
||||||
onClose();
|
|
||||||
onNavigateToReview?.();
|
|
||||||
}}
|
|
||||||
onOpenInvestmentCalendar={() => {
|
|
||||||
onClose();
|
|
||||||
onOpenInvestmentCalendar?.();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
|
||||||
{events.map((event, idx) => (
|
|
||||||
<EventCard
|
|
||||||
key={idx}
|
|
||||||
event={event}
|
|
||||||
variant="detail"
|
|
||||||
borderColor={borderColor}
|
|
||||||
secondaryText={secondaryText}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Space>
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default EventDetailModal;
|
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
/* EventFormModal.less - 投资计划/复盘弹窗响应式样式 */
|
|
||||||
|
|
||||||
// ==================== 变量定义 ====================
|
|
||||||
@mobile-breakpoint: 768px;
|
|
||||||
@modal-border-radius-mobile: 12px;
|
|
||||||
@modal-border-radius-desktop: 8px;
|
|
||||||
|
|
||||||
// 间距
|
|
||||||
@spacing-xs: 4px;
|
|
||||||
@spacing-sm: 8px;
|
|
||||||
@spacing-md: 12px;
|
|
||||||
@spacing-lg: 16px;
|
|
||||||
@spacing-xl: 20px;
|
|
||||||
@spacing-xxl: 24px;
|
|
||||||
|
|
||||||
// 字体大小
|
|
||||||
@font-size-xs: 12px;
|
|
||||||
@font-size-sm: 14px;
|
|
||||||
@font-size-md: 16px;
|
|
||||||
|
|
||||||
// 颜色
|
|
||||||
@color-border: #f0f0f0;
|
|
||||||
@color-text-secondary: #999;
|
|
||||||
@color-error: #ff4d4f;
|
|
||||||
|
|
||||||
// ==================== 主样式 ====================
|
|
||||||
.event-form-modal {
|
|
||||||
// Modal 整体
|
|
||||||
.ant-modal-content {
|
|
||||||
border-radius: @modal-border-radius-desktop;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Modal 标题放大加粗
|
|
||||||
.ant-modal-title {
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-modal-body {
|
|
||||||
padding: @spacing-xxl;
|
|
||||||
padding-top: 36px; // 增加标题与表单间距
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-form-item {
|
|
||||||
margin-bottom: @spacing-xl;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 表单标签加粗,左对齐
|
|
||||||
.ant-form-item-label {
|
|
||||||
text-align: left !important;
|
|
||||||
|
|
||||||
> label {
|
|
||||||
font-weight: 600 !important;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 字符计数样式
|
|
||||||
.ant-input-textarea-show-count::after {
|
|
||||||
font-size: @font-size-xs;
|
|
||||||
color: @color-text-secondary;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 日期选择器全宽
|
|
||||||
.ant-picker {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 股票标签样式
|
|
||||||
.ant-tag {
|
|
||||||
margin: 2px;
|
|
||||||
border-radius: @spacing-xs;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 模板按钮组
|
|
||||||
.template-buttons {
|
|
||||||
.ant-btn {
|
|
||||||
font-size: @font-size-xs;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 底部操作栏布局
|
|
||||||
.modal-footer {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 加载状态
|
|
||||||
.ant-btn-loading {
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 错误状态动画
|
|
||||||
.ant-form-item-has-error {
|
|
||||||
.ant-input,
|
|
||||||
.ant-picker,
|
|
||||||
.ant-select-selector {
|
|
||||||
animation: shake 0.3s ease-in-out;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 移动端适配 ====================
|
|
||||||
@media (max-width: @mobile-breakpoint) {
|
|
||||||
.event-form-modal {
|
|
||||||
// Modal 整体尺寸
|
|
||||||
.ant-modal {
|
|
||||||
width: calc(100vw - 32px) !important;
|
|
||||||
max-width: 100% !important;
|
|
||||||
margin: @spacing-lg auto;
|
|
||||||
top: 0;
|
|
||||||
padding-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-modal-content {
|
|
||||||
max-height: calc(100vh - 32px);
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
border-radius: @modal-border-radius-mobile;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Modal 头部
|
|
||||||
.ant-modal-header {
|
|
||||||
padding: @spacing-md @spacing-lg;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-modal-title {
|
|
||||||
font-size: @font-size-md;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Modal 内容区域
|
|
||||||
.ant-modal-body {
|
|
||||||
padding: @spacing-lg;
|
|
||||||
overflow-y: auto;
|
|
||||||
flex: 1;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Modal 底部
|
|
||||||
.ant-modal-footer {
|
|
||||||
padding: @spacing-md @spacing-lg;
|
|
||||||
flex-shrink: 0;
|
|
||||||
border-top: 1px solid @color-border;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 表单项间距
|
|
||||||
.ant-form-item {
|
|
||||||
margin-bottom: @spacing-lg;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 表单标签
|
|
||||||
.ant-form-item-label > label {
|
|
||||||
font-size: @font-size-sm;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 输入框字体 - iOS 防止缩放需要 16px
|
|
||||||
.ant-input,
|
|
||||||
.ant-picker-input > input,
|
|
||||||
.ant-select-selection-search-input {
|
|
||||||
font-size: @font-size-md !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 文本域高度
|
|
||||||
.ant-input-textarea textarea {
|
|
||||||
font-size: @font-size-md !important;
|
|
||||||
min-height: 120px;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 模板按钮组
|
|
||||||
.template-buttons .ant-btn {
|
|
||||||
font-size: @font-size-xs;
|
|
||||||
padding: 2px @spacing-sm;
|
|
||||||
height: 26px;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 股票选择器
|
|
||||||
.ant-select-selector {
|
|
||||||
min-height: 40px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 底部按钮
|
|
||||||
.ant-modal-footer .ant-btn {
|
|
||||||
font-size: @font-size-md;
|
|
||||||
height: 40px;
|
|
||||||
border-radius: @spacing-sm;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 动画 ====================
|
|
||||||
@keyframes shake {
|
|
||||||
0%, 100% { transform: translateX(0); }
|
|
||||||
25% { transform: translateX(-4px); }
|
|
||||||
75% { transform: translateX(4px); }
|
|
||||||
}
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
// src/views/Dashboard/components/InvestmentCalendar.less
|
|
||||||
|
|
||||||
// 颜色变量(与日历视图按钮一致的紫色)
|
|
||||||
@primary-color: #805AD5;
|
|
||||||
@primary-hover: #6B46C1;
|
|
||||||
@border-color: #e2e8f0;
|
|
||||||
@text-color: #2d3748;
|
|
||||||
@today-bg: #e6f3ff;
|
|
||||||
|
|
||||||
// 暗色模式颜色
|
|
||||||
@dark-border-color: #4a5568;
|
|
||||||
@dark-text-color: #e2e8f0;
|
|
||||||
@dark-today-bg: #2d3748;
|
|
||||||
|
|
||||||
// FullCalendar 自定义样式
|
|
||||||
.fc {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 工具栏按钮紧密排列(提升优先级)
|
|
||||||
.fc .fc-toolbar.fc-header-toolbar {
|
|
||||||
justify-content: flex-start !important;
|
|
||||||
gap: 8px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fc .fc-toolbar-chunk:first-child {
|
|
||||||
display: flex !important;
|
|
||||||
gap: 4px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fc-theme-standard {
|
|
||||||
td, th {
|
|
||||||
border-color: @border-color;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 按钮样式(针对 fc-button-group 内的按钮)
|
|
||||||
.fc .fc-toolbar .fc-button-group .fc-button {
|
|
||||||
background-color: @primary-color !important;
|
|
||||||
border-color: @primary-color !important;
|
|
||||||
color: #fff !important;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: @primary-hover !important;
|
|
||||||
border-color: @primary-hover !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:not(:disabled):active,
|
|
||||||
&:not(:disabled).fc-button-active {
|
|
||||||
background-color: @primary-hover !important;
|
|
||||||
border-color: @primary-hover !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 今天按钮样式
|
|
||||||
.fc .fc-toolbar .fc-today-button {
|
|
||||||
background-color: @primary-color !important;
|
|
||||||
border-color: @primary-color !important;
|
|
||||||
color: #fff !important;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: @primary-hover !important;
|
|
||||||
border-color: @primary-hover !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 选中状态(disabled 表示当前视图包含今天)
|
|
||||||
&:disabled {
|
|
||||||
background-color: @primary-hover !important;
|
|
||||||
border-color: @primary-hover !important;
|
|
||||||
opacity: 1 !important;
|
|
||||||
color: #fff !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 日期数字
|
|
||||||
.fc-daygrid-day-number {
|
|
||||||
color: @text-color;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 今天高亮
|
|
||||||
.fc-daygrid-day.fc-day-today {
|
|
||||||
background-color: @today-bg !important;
|
|
||||||
border: 2px solid @primary-color !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 事件样式
|
|
||||||
.fc-event {
|
|
||||||
border: none;
|
|
||||||
padding: 2px 4px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fc-daygrid-event-dot {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fc-daygrid-day-events {
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 暗色模式支持
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
.fc-theme-standard {
|
|
||||||
td, th {
|
|
||||||
border-color: @dark-border-color;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.fc-daygrid-day-number {
|
|
||||||
color: @dark-text-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fc-daygrid-day.fc-day-today {
|
|
||||||
background-color: @dark-today-bg !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fc-col-header-cell-cushion,
|
|
||||||
.fc-daygrid-day-number {
|
|
||||||
color: @dark-text-color;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,251 +0,0 @@
|
|||||||
/**
|
|
||||||
* InvestmentPlanningCenter - 投资规划中心主组件 (TypeScript 重构版)
|
|
||||||
*
|
|
||||||
* 性能优化:
|
|
||||||
* - 使用 React.lazy() 懒加载子面板,减少初始加载时间
|
|
||||||
* - 使用 TypeScript 提供类型安全
|
|
||||||
*
|
|
||||||
* 组件架构:
|
|
||||||
* - InvestmentPlanningCenter (主组件)
|
|
||||||
* - CalendarPanel (日历面板,懒加载)
|
|
||||||
* - EventPanel (通用事件面板,用于计划和复盘)
|
|
||||||
* - PlanningContext (数据共享层)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback, Suspense, lazy } from 'react';
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
Card,
|
|
||||||
CardHeader,
|
|
||||||
CardBody,
|
|
||||||
Heading,
|
|
||||||
HStack,
|
|
||||||
Flex,
|
|
||||||
Icon,
|
|
||||||
useColorModeValue,
|
|
||||||
useToast,
|
|
||||||
Tabs,
|
|
||||||
TabList,
|
|
||||||
TabPanels,
|
|
||||||
Tab,
|
|
||||||
TabPanel,
|
|
||||||
Spinner,
|
|
||||||
Center,
|
|
||||||
Button,
|
|
||||||
ButtonGroup,
|
|
||||||
} from '@chakra-ui/react';
|
|
||||||
import {
|
|
||||||
FiCalendar,
|
|
||||||
FiTarget,
|
|
||||||
FiFileText,
|
|
||||||
FiList,
|
|
||||||
FiPlus,
|
|
||||||
} from 'react-icons/fi';
|
|
||||||
|
|
||||||
import { PlanningDataProvider } from './PlanningContext';
|
|
||||||
import type { InvestmentEvent, PlanningContextValue } from '@/types';
|
|
||||||
import { logger } from '@/utils/logger';
|
|
||||||
import { getApiBase } from '@/utils/apiConfig';
|
|
||||||
import './InvestmentCalendar.less';
|
|
||||||
|
|
||||||
// 懒加载子面板组件(实现代码分割)
|
|
||||||
const CalendarPanel = lazy(() =>
|
|
||||||
import('./CalendarPanel').then(module => ({ default: module.CalendarPanel }))
|
|
||||||
);
|
|
||||||
const EventPanel = lazy(() =>
|
|
||||||
import('./EventPanel').then(module => ({ default: module.EventPanel }))
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 面板加载占位符
|
|
||||||
*/
|
|
||||||
const PanelLoadingFallback: React.FC = () => (
|
|
||||||
<Center py={12}>
|
|
||||||
<Spinner size="xl" color="purple.500" thickness="4px" />
|
|
||||||
</Center>
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* InvestmentPlanningCenter 主组件
|
|
||||||
*/
|
|
||||||
const InvestmentPlanningCenter: React.FC = () => {
|
|
||||||
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 [allEvents, setAllEvents] = useState<InvestmentEvent[]>([]);
|
|
||||||
const [loading, setLoading] = useState<boolean>(false);
|
|
||||||
const [viewMode, setViewMode] = useState<'calendar' | 'list'>('list');
|
|
||||||
const [listTab, setListTab] = useState<number>(0); // 0: 我的计划, 1: 我的复盘
|
|
||||||
const [openPlanModalTrigger, setOpenPlanModalTrigger] = useState<number>(0);
|
|
||||||
const [openReviewModalTrigger, setOpenReviewModalTrigger] = useState<number>(0);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 加载所有事件数据(日历事件 + 计划 + 复盘)
|
|
||||||
*/
|
|
||||||
const loadAllData = useCallback(async (): Promise<void> => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const base = getApiBase();
|
|
||||||
|
|
||||||
const response = await fetch(base + '/api/account/calendar/events', {
|
|
||||||
credentials: 'include'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
if (data.success) {
|
|
||||||
setAllEvents(data.data || []);
|
|
||||||
logger.debug('InvestmentPlanningCenter', '数据加载成功', {
|
|
||||||
count: data.data?.length || 0
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('InvestmentPlanningCenter', 'loadAllData', error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 组件挂载时加载数据
|
|
||||||
useEffect(() => {
|
|
||||||
loadAllData();
|
|
||||||
}, [loadAllData]);
|
|
||||||
|
|
||||||
// 提供给子组件的 Context 值
|
|
||||||
const contextValue: PlanningContextValue = {
|
|
||||||
allEvents,
|
|
||||||
setAllEvents,
|
|
||||||
loadAllData,
|
|
||||||
loading,
|
|
||||||
setLoading,
|
|
||||||
openPlanModalTrigger,
|
|
||||||
openReviewModalTrigger,
|
|
||||||
toast,
|
|
||||||
borderColor,
|
|
||||||
textColor,
|
|
||||||
secondaryText,
|
|
||||||
cardBg,
|
|
||||||
setViewMode,
|
|
||||||
setListTab,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 计算各类型事件数量
|
|
||||||
const planCount = allEvents.filter(e => e.type === 'plan').length;
|
|
||||||
const reviewCount = allEvents.filter(e => e.type === 'review').length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PlanningDataProvider value={contextValue}>
|
|
||||||
<Card bg={bgColor} shadow="md">
|
|
||||||
<CardHeader pb={{ base: 2, md: 4 }} px={{ base: 3, md: 5 }}>
|
|
||||||
<Flex justify="space-between" align="center" wrap="wrap" gap={2}>
|
|
||||||
<HStack spacing={{ base: 1, md: 2 }}>
|
|
||||||
<Icon as={FiTarget} color="purple.500" boxSize={{ base: 4, md: 5 }} />
|
|
||||||
<Heading size={{ base: 'sm', md: 'md' }}>投资规划中心</Heading>
|
|
||||||
</HStack>
|
|
||||||
{/* 视图切换按钮组 - H5隐藏 */}
|
|
||||||
<ButtonGroup size="sm" isAttached variant="outline" display={{ base: 'none', md: 'flex' }}>
|
|
||||||
<Button
|
|
||||||
leftIcon={<Icon as={FiList} boxSize={4} />}
|
|
||||||
colorScheme={viewMode === 'list' ? 'purple' : 'gray'}
|
|
||||||
variant={viewMode === 'list' ? 'solid' : 'outline'}
|
|
||||||
onClick={() => setViewMode('list')}
|
|
||||||
>
|
|
||||||
列表视图
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
leftIcon={<Icon as={FiCalendar} boxSize={4} />}
|
|
||||||
colorScheme={viewMode === 'calendar' ? 'purple' : 'gray'}
|
|
||||||
variant={viewMode === 'calendar' ? 'solid' : 'outline'}
|
|
||||||
onClick={() => setViewMode('calendar')}
|
|
||||||
>
|
|
||||||
日历视图
|
|
||||||
</Button>
|
|
||||||
</ButtonGroup>
|
|
||||||
</Flex>
|
|
||||||
</CardHeader>
|
|
||||||
<CardBody pt={0} px={{ base: 3, md: 5 }}>
|
|
||||||
{viewMode === 'calendar' ? (
|
|
||||||
/* 日历视图 */
|
|
||||||
<Suspense fallback={<PanelLoadingFallback />}>
|
|
||||||
<CalendarPanel />
|
|
||||||
</Suspense>
|
|
||||||
) : (
|
|
||||||
/* 列表视图:我的计划 / 我的复盘 切换 */
|
|
||||||
<Tabs
|
|
||||||
index={listTab}
|
|
||||||
onChange={setListTab}
|
|
||||||
variant="enclosed"
|
|
||||||
colorScheme="purple"
|
|
||||||
size={{ base: 'sm', md: 'md' }}
|
|
||||||
>
|
|
||||||
<Flex justify="space-between" align="center" mb={{ base: 2, md: 4 }} flexWrap="nowrap" gap={1}>
|
|
||||||
<TabList mb={0} borderBottom="none" flex="1" minW={0}>
|
|
||||||
<Tab fontSize={{ base: '11px', md: 'sm' }} px={{ base: 1, md: 4 }} whiteSpace="nowrap">
|
|
||||||
<Icon as={FiTarget} mr={1} boxSize={{ base: 3, md: 4 }} />
|
|
||||||
我的计划 ({planCount})
|
|
||||||
</Tab>
|
|
||||||
<Tab fontSize={{ base: '11px', md: 'sm' }} px={{ base: 1, md: 4 }} whiteSpace="nowrap">
|
|
||||||
<Icon as={FiFileText} mr={1} boxSize={{ base: 3, md: 4 }} />
|
|
||||||
我的复盘 ({reviewCount})
|
|
||||||
</Tab>
|
|
||||||
</TabList>
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
colorScheme="purple"
|
|
||||||
leftIcon={<Icon as={FiPlus} boxSize={3} />}
|
|
||||||
fontSize={{ base: '11px', md: 'sm' }}
|
|
||||||
flexShrink={0}
|
|
||||||
onClick={() => {
|
|
||||||
if (listTab === 0) {
|
|
||||||
setOpenPlanModalTrigger(prev => prev + 1);
|
|
||||||
} else {
|
|
||||||
setOpenReviewModalTrigger(prev => prev + 1);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{listTab === 0 ? '新建计划' : '新建复盘'}
|
|
||||||
</Button>
|
|
||||||
</Flex>
|
|
||||||
|
|
||||||
<TabPanels>
|
|
||||||
{/* 计划列表面板 */}
|
|
||||||
<TabPanel px={0}>
|
|
||||||
<Suspense fallback={<PanelLoadingFallback />}>
|
|
||||||
<EventPanel
|
|
||||||
type="plan"
|
|
||||||
colorScheme="purple"
|
|
||||||
label="计划"
|
|
||||||
openModalTrigger={openPlanModalTrigger}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
</TabPanel>
|
|
||||||
|
|
||||||
{/* 复盘列表面板 */}
|
|
||||||
<TabPanel px={0}>
|
|
||||||
<Suspense fallback={<PanelLoadingFallback />}>
|
|
||||||
<EventPanel
|
|
||||||
type="review"
|
|
||||||
colorScheme="green"
|
|
||||||
label="复盘"
|
|
||||||
openModalTrigger={openReviewModalTrigger}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
</TabPanel>
|
|
||||||
</TabPanels>
|
|
||||||
</Tabs>
|
|
||||||
)}
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
</PlanningDataProvider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default InvestmentPlanningCenter;
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -36,7 +36,6 @@ const HomePage: React.FC = () => {
|
|||||||
|
|
||||||
// 响应式配置
|
// 响应式配置
|
||||||
const {
|
const {
|
||||||
heroHeight,
|
|
||||||
headingSize,
|
headingSize,
|
||||||
headingLetterSpacing,
|
headingLetterSpacing,
|
||||||
heroTextSize,
|
heroTextSize,
|
||||||
@@ -85,11 +84,11 @@ const HomePage: React.FC = () => {
|
|||||||
const isMobile = isMobileDevice();
|
const isMobile = isMobileDevice();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box minH="100%">
|
||||||
{/* Hero Section - 深色科技风格 */}
|
{/* Hero Section - 深色科技风格,自适应容器高度 */}
|
||||||
<Box
|
<Box
|
||||||
position="relative"
|
position="relative"
|
||||||
minH={heroHeight}
|
minH="100%"
|
||||||
bg="linear-gradient(135deg, #0E0C15 0%, #15131D 50%, #252134 100%)"
|
bg="linear-gradient(135deg, #0E0C15 0%, #15131D 50%, #252134 100%)"
|
||||||
overflow="hidden"
|
overflow="hidden"
|
||||||
>
|
>
|
||||||
@@ -98,7 +97,7 @@ const HomePage: React.FC = () => {
|
|||||||
<VStack
|
<VStack
|
||||||
spacing={{ base: 5, md: 8, lg: 10 }}
|
spacing={{ base: 5, md: 8, lg: 10 }}
|
||||||
align="stretch"
|
align="stretch"
|
||||||
minH={heroHeight}
|
py={{ base: 8, md: 10, lg: 12 }}
|
||||||
justify="center"
|
justify="center"
|
||||||
>
|
>
|
||||||
{/* 主标题区域 */}
|
{/* 主标题区域 */}
|
||||||
|
|||||||
@@ -1,767 +0,0 @@
|
|||||||
/*!
|
|
||||||
|
|
||||||
=========================================================
|
|
||||||
* Argon Dashboard Chakra PRO - v1.0.0
|
|
||||||
=========================================================
|
|
||||||
|
|
||||||
* Product Page: https://www.creative-tim.com/product/argon-dashboard-chakra-pro
|
|
||||||
* Copyright 2022 Creative Tim (https://www.creative-tim.com/)
|
|
||||||
|
|
||||||
* Designed and Coded by Simmmple & Creative Tim
|
|
||||||
|
|
||||||
=========================================================
|
|
||||||
|
|
||||||
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Chakra imports
|
|
||||||
import {
|
|
||||||
Badge,
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Flex,
|
|
||||||
Grid,
|
|
||||||
Icon,
|
|
||||||
Progress,
|
|
||||||
Spacer,
|
|
||||||
Stack,
|
|
||||||
Stat,
|
|
||||||
StatHelpText,
|
|
||||||
StatLabel,
|
|
||||||
StatNumber,
|
|
||||||
Switch,
|
|
||||||
Text,
|
|
||||||
useColorMode,
|
|
||||||
useColorModeValue,
|
|
||||||
} from '@chakra-ui/react';
|
|
||||||
// Assets
|
|
||||||
import BackgroundCard1 from 'assets/img/BackgroundCard1.png';
|
|
||||||
import BgMusicCard from 'assets/img/BgMusicCard.png';
|
|
||||||
import BgMusicCardDark from 'assets/img/bgMusicCardDark.png';
|
|
||||||
import {
|
|
||||||
ClockIcon,
|
|
||||||
DocumentIcon,
|
|
||||||
RocketIcon,
|
|
||||||
SettingsIcon,
|
|
||||||
WalletIcon,
|
|
||||||
} from 'components/Icons/Icons';
|
|
||||||
import { AiFillBackward, AiFillForward } from 'react-icons/ai';
|
|
||||||
import { BsBatteryCharging, BsMusicNoteBeamed } from 'react-icons/bs';
|
|
||||||
// Custom components
|
|
||||||
import EventCalendar from 'components/Calendars/EventCalendar';
|
|
||||||
import Card from 'components/Card/Card';
|
|
||||||
import CardHeader from 'components/Card/CardHeader';
|
|
||||||
import LineChart from 'components/Charts/LineChart';
|
|
||||||
import IconBox from 'components/Icons/IconBox';
|
|
||||||
import { HSeparator } from 'components/Separator/Separator';
|
|
||||||
import TimelineRow from 'components/Tables/TimelineRow';
|
|
||||||
import React, { useState } from 'react';
|
|
||||||
import {
|
|
||||||
FaCheckCircle,
|
|
||||||
FaLightbulb,
|
|
||||||
FaPaypal,
|
|
||||||
FaPlay,
|
|
||||||
FaRegLightbulb,
|
|
||||||
FaShare,
|
|
||||||
FaUser,
|
|
||||||
FaWallet,
|
|
||||||
} from 'react-icons/fa';
|
|
||||||
import { RiArrowDropRightLine, RiMastercardFill } from 'react-icons/ri';
|
|
||||||
import { calendarDataWidgets } from 'variables/calendar';
|
|
||||||
import {
|
|
||||||
lineChartDataWidgets1,
|
|
||||||
lineChartDataWidgets2,
|
|
||||||
lineChartDataWidgets3,
|
|
||||||
lineChartOptionsWidgets1,
|
|
||||||
lineChartOptionsWidgets2,
|
|
||||||
lineChartOptionsWidgets3,
|
|
||||||
} from 'variables/charts';
|
|
||||||
import { timelineData } from 'variables/general';
|
|
||||||
|
|
||||||
function Widgets() {
|
|
||||||
const [toggleSwitch, setToggleSwitch] = useState(false);
|
|
||||||
const { colorMode } = useColorMode();
|
|
||||||
|
|
||||||
const textColor = useColorModeValue('gray.700', 'white');
|
|
||||||
const iconBlue = useColorModeValue('blue.500', 'white');
|
|
||||||
const secondaryIconBlue = useColorModeValue('gray.100', 'blue.500');
|
|
||||||
const iconBoxInside = useColorModeValue('white', 'blue.500');
|
|
||||||
const bgCard = useColorModeValue(
|
|
||||||
'linear-gradient(81.62deg, #313860 2.25%, #151928 79.87%)',
|
|
||||||
'navy.800'
|
|
||||||
);
|
|
||||||
const iconBoxColor = useColorModeValue(
|
|
||||||
'linear-gradient(81.62deg, #313860 2.25%, #151928 79.87%)',
|
|
||||||
'blue.500'
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Flex direction='column' pt={{ sm: '125px', lg: '75px' }}>
|
|
||||||
<Grid
|
|
||||||
templateColumns={{ sm: '1fr', md: '1fr 1fr', lg: '1fr 1fr 2fr' }}
|
|
||||||
templateRows='1fr'
|
|
||||||
gap='24px'
|
|
||||||
mb='24px'
|
|
||||||
>
|
|
||||||
<Stack direction='column' spacing='24px'>
|
|
||||||
<Card bg={bgCard}>
|
|
||||||
<Flex justify='space-between' w='100%' align='center'>
|
|
||||||
<Flex direction='column'>
|
|
||||||
<Text fontSize='sm' color='#fff' fontWeight='normal' mb='2px'>
|
|
||||||
Battery Health
|
|
||||||
</Text>
|
|
||||||
<Text fontSize='lg' color='#fff' fontWeight='bold'>
|
|
||||||
99%
|
|
||||||
</Text>
|
|
||||||
</Flex>
|
|
||||||
<IconBox h={'45px'} w={'45px'} bg={iconBoxInside}>
|
|
||||||
<Icon
|
|
||||||
as={BsBatteryCharging}
|
|
||||||
h={'24px'}
|
|
||||||
w={'24px'}
|
|
||||||
color={iconBlue}
|
|
||||||
/>
|
|
||||||
</IconBox>
|
|
||||||
</Flex>
|
|
||||||
</Card>
|
|
||||||
<Card bg={bgCard}>
|
|
||||||
<Flex justify='space-between' w='100%' align='center'>
|
|
||||||
<Flex direction='column'>
|
|
||||||
<Text fontSize='sm' color='#fff' fontWeight='normal' mb='2px'>
|
|
||||||
Music Volume
|
|
||||||
</Text>
|
|
||||||
<Text fontSize='lg' color='#fff' fontWeight='bold'>
|
|
||||||
15/100
|
|
||||||
</Text>
|
|
||||||
</Flex>
|
|
||||||
<IconBox h={'45px'} w={'45px'} bg={iconBoxInside}>
|
|
||||||
<Icon
|
|
||||||
as={BsMusicNoteBeamed}
|
|
||||||
h={'24px'}
|
|
||||||
w={'24px'}
|
|
||||||
color={iconBlue}
|
|
||||||
/>
|
|
||||||
</IconBox>
|
|
||||||
</Flex>
|
|
||||||
</Card>
|
|
||||||
</Stack>
|
|
||||||
<Card px='0px' maxH='230px' pb='0px'>
|
|
||||||
<CardHeader px='22px'>
|
|
||||||
<Stat me='auto'>
|
|
||||||
<StatLabel fontSize='xs' color='gray.400' fontWeight='normal'>
|
|
||||||
Income
|
|
||||||
</StatLabel>
|
|
||||||
<Flex>
|
|
||||||
<StatNumber fontSize='lg' color={textColor}>
|
|
||||||
$130,912
|
|
||||||
</StatNumber>
|
|
||||||
<StatHelpText
|
|
||||||
alignSelf='flex-end'
|
|
||||||
justifySelf='flex-end'
|
|
||||||
m='0px'
|
|
||||||
ps='4px'
|
|
||||||
color='green.400'
|
|
||||||
fontWeight='bold'
|
|
||||||
fontSize='sm'
|
|
||||||
>
|
|
||||||
+90%
|
|
||||||
</StatHelpText>
|
|
||||||
</Flex>
|
|
||||||
</Stat>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<Box w='100%'>
|
|
||||||
<LineChart
|
|
||||||
chartData={lineChartDataWidgets1}
|
|
||||||
chartOptions={lineChartOptionsWidgets1}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Card>
|
|
||||||
<Card
|
|
||||||
px='0px'
|
|
||||||
maxH='230px'
|
|
||||||
pb='0px'
|
|
||||||
gridColumn={{ md: '1 / 3', lg: 'auto' }}
|
|
||||||
>
|
|
||||||
<CardHeader px='22px'>
|
|
||||||
<Flex justify='space-between' w='100%'>
|
|
||||||
<Flex align='center'>
|
|
||||||
<IconBox h={'45px'} w={'45px'} bg='blue.500' me='16px'>
|
|
||||||
<Icon
|
|
||||||
as={FaCheckCircle}
|
|
||||||
h={'24px'}
|
|
||||||
w={'24px'}
|
|
||||||
color='white'
|
|
||||||
/>
|
|
||||||
</IconBox>
|
|
||||||
<Flex direction='column'>
|
|
||||||
<Text color='gray.400' fontSize='xs' fontWeight='normal'>
|
|
||||||
Tasks
|
|
||||||
</Text>
|
|
||||||
<Text color={textColor} fontSize='lg' fontWeight='bold'>
|
|
||||||
480
|
|
||||||
</Text>
|
|
||||||
</Flex>
|
|
||||||
</Flex>
|
|
||||||
<Flex direction='column' minW='125px' alignSelf='flex-end'>
|
|
||||||
<Text color='gray.400' fontWeight='normal' fontSize='xs'>
|
|
||||||
60%
|
|
||||||
</Text>
|
|
||||||
<Progress
|
|
||||||
colorScheme='blue'
|
|
||||||
borderRadius='15px'
|
|
||||||
h='6px'
|
|
||||||
value={60}
|
|
||||||
/>
|
|
||||||
</Flex>
|
|
||||||
</Flex>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<Box w='100%'>
|
|
||||||
<LineChart
|
|
||||||
chartData={lineChartDataWidgets2}
|
|
||||||
chartOptions={lineChartOptionsWidgets2}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Card>
|
|
||||||
</Grid>
|
|
||||||
<Grid
|
|
||||||
templateColumns={{
|
|
||||||
sm: '1fr',
|
|
||||||
md: 'repeat(2, 1fr)',
|
|
||||||
lg: 'repeat(3, 1fr)',
|
|
||||||
}}
|
|
||||||
gap='24px'
|
|
||||||
mb='24px'
|
|
||||||
>
|
|
||||||
<Card>
|
|
||||||
<CardHeader mb='16px'>
|
|
||||||
<Flex direction='column'>
|
|
||||||
<Text color={textColor} fontSize='lg' fontWeight='bold' mb='4px'>
|
|
||||||
Upcoming events
|
|
||||||
</Text>
|
|
||||||
<Text color='gray.400' fontSize='sm' fontWeight='bold'>
|
|
||||||
Joined
|
|
||||||
</Text>
|
|
||||||
</Flex>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<Flex direction='column'>
|
|
||||||
<Flex align='center' mb='22px'>
|
|
||||||
<IconBox h={'45px'} w={'45px'} bg={secondaryIconBlue} me='16px'>
|
|
||||||
<Icon as={WalletIcon} h={'24px'} w={'24px'} color={iconBlue} />
|
|
||||||
</IconBox>
|
|
||||||
<Flex direction='column'>
|
|
||||||
<Text color={textColor} fontSize='sm' fontWeight='bold'>
|
|
||||||
Cyber Week
|
|
||||||
</Text>
|
|
||||||
<Text color='gray.400' fontSize='sm' fontWeight='normal'>
|
|
||||||
27 March 2020, at 12:30 PM
|
|
||||||
</Text>
|
|
||||||
</Flex>
|
|
||||||
</Flex>
|
|
||||||
<Flex align='center'>
|
|
||||||
<IconBox h={'45px'} w={'45px'} bg={secondaryIconBlue} me='16px'>
|
|
||||||
<Icon as={ClockIcon} h={'24px'} w={'24px'} color={iconBlue} />
|
|
||||||
</IconBox>
|
|
||||||
<Flex direction='column'>
|
|
||||||
<Text color={textColor} fontSize='sm' fontWeight='bold'>
|
|
||||||
Meeting with Marry
|
|
||||||
</Text>
|
|
||||||
<Text color='gray.400' fontSize='sm' fontWeight='normal'>
|
|
||||||
24 March 2020, at 10:00 PM
|
|
||||||
</Text>
|
|
||||||
</Flex>
|
|
||||||
</Flex>
|
|
||||||
</Flex>
|
|
||||||
</Card>
|
|
||||||
<Stack direction={{ sm: 'column', md: 'row' }} spacing='24px'>
|
|
||||||
<Card p='16px' display='flex' align='center' justify='center'>
|
|
||||||
<Flex direction='column' align='center' w='100%' py='14px'>
|
|
||||||
<IconBox h={'60px'} w={'60px'} bg='blue.500'>
|
|
||||||
<Icon h={'24px'} w={'24px'} color='white' as={FaWallet} />
|
|
||||||
</IconBox>
|
|
||||||
<Flex
|
|
||||||
direction='column'
|
|
||||||
m='14px'
|
|
||||||
justify='center'
|
|
||||||
textAlign='center'
|
|
||||||
align='center'
|
|
||||||
w='100%'
|
|
||||||
>
|
|
||||||
<Text fontSize='lg' color={textColor} fontWeight='bold'>
|
|
||||||
Salary
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
mb='24px'
|
|
||||||
fontSize='xs'
|
|
||||||
color='gray.400'
|
|
||||||
fontWeight='semibold'
|
|
||||||
>
|
|
||||||
Belong Interactive
|
|
||||||
</Text>
|
|
||||||
<HSeparator />
|
|
||||||
</Flex>
|
|
||||||
<Text fontSize='lg' color={textColor} fontWeight='bold'>
|
|
||||||
+$2000
|
|
||||||
</Text>
|
|
||||||
</Flex>
|
|
||||||
</Card>
|
|
||||||
<Card p='16px' display='flex' align='center' justify='center'>
|
|
||||||
<Flex
|
|
||||||
direction='column'
|
|
||||||
align='center'
|
|
||||||
justify='center'
|
|
||||||
w='100%'
|
|
||||||
py='14px'
|
|
||||||
>
|
|
||||||
<IconBox h={'60px'} w={'60px'} bg='blue.500'>
|
|
||||||
<Icon h={'24px'} w={'24px'} color='white' as={FaPaypal} />
|
|
||||||
</IconBox>
|
|
||||||
<Flex
|
|
||||||
direction='column'
|
|
||||||
m='14px'
|
|
||||||
justify='center'
|
|
||||||
textAlign='center'
|
|
||||||
align='center'
|
|
||||||
w='100%'
|
|
||||||
>
|
|
||||||
<Text fontSize='lg' color={textColor} fontWeight='bold'>
|
|
||||||
Paypal
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
mb='24px'
|
|
||||||
fontSize='xs'
|
|
||||||
color='gray.400'
|
|
||||||
fontWeight='semibold'
|
|
||||||
>
|
|
||||||
Freelance Payment
|
|
||||||
</Text>
|
|
||||||
<HSeparator />
|
|
||||||
</Flex>
|
|
||||||
<Text fontSize='lg' color={textColor} fontWeight='bold'>
|
|
||||||
$455.00
|
|
||||||
</Text>
|
|
||||||
</Flex>
|
|
||||||
</Card>
|
|
||||||
</Stack>
|
|
||||||
<Card
|
|
||||||
backgroundImage={
|
|
||||||
colorMode === 'light'
|
|
||||||
? BackgroundCard1
|
|
||||||
: 'linear-gradient(180deg, #3182CE 0%, #63B3ED 100%)'
|
|
||||||
}
|
|
||||||
backgroundRepeat='no-repeat'
|
|
||||||
background='cover'
|
|
||||||
bgPosition='10%'
|
|
||||||
p='16px'
|
|
||||||
h={{ sm: '220px', xl: '100%' }}
|
|
||||||
gridColumn={{ md: '1 / 3', lg: 'auto' }}
|
|
||||||
>
|
|
||||||
<Flex
|
|
||||||
direction='column'
|
|
||||||
color='white'
|
|
||||||
h='100%'
|
|
||||||
p='0px 10px 20px 10px'
|
|
||||||
w='100%'
|
|
||||||
>
|
|
||||||
<Flex justify='space-between' align='center'>
|
|
||||||
<Text fontWeight='bold'>Argon x Chakra UI</Text>
|
|
||||||
<Icon as={RiMastercardFill} w='48px' h='auto' color='gray.400' />
|
|
||||||
</Flex>
|
|
||||||
<Spacer />
|
|
||||||
<Flex direction='column'>
|
|
||||||
<Box>
|
|
||||||
<Text fontSize='xl' letterSpacing='2px' fontWeight='bold'>
|
|
||||||
7812 2139 0823 XXXX
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
<Flex mt='14px'>
|
|
||||||
<Flex direction='column' me='34px'>
|
|
||||||
<Text fontSize='xs'>VALID THRU</Text>
|
|
||||||
<Text fontSize='xs' fontWeight='bold'>
|
|
||||||
05/24
|
|
||||||
</Text>
|
|
||||||
</Flex>
|
|
||||||
<Flex direction='column'>
|
|
||||||
<Text fontSize='xs'>CVV</Text>
|
|
||||||
<Text fontSize='xs' fontWeight='bold'>
|
|
||||||
09X
|
|
||||||
</Text>
|
|
||||||
</Flex>
|
|
||||||
</Flex>
|
|
||||||
</Flex>
|
|
||||||
</Flex>
|
|
||||||
</Card>
|
|
||||||
</Grid>
|
|
||||||
<Grid
|
|
||||||
templateColumns={{
|
|
||||||
sm: '1fr',
|
|
||||||
md: '1fr 1fr',
|
|
||||||
lg: '1.5fr 1fr 1.2fr 1fr 1fr',
|
|
||||||
}}
|
|
||||||
gap='24px'
|
|
||||||
mb='24px'
|
|
||||||
>
|
|
||||||
<Card>
|
|
||||||
<CardHeader mb='24px'>
|
|
||||||
<Flex justify='space-between' w='100%' align='center'>
|
|
||||||
<Text color={textColor} fontWeight='bold' fontSize='lg'>
|
|
||||||
Full Body
|
|
||||||
</Text>
|
|
||||||
<Badge
|
|
||||||
bg={colorMode === 'light' ? 'red.100' : 'red.500'}
|
|
||||||
color={colorMode === 'light' ? 'red.500' : 'white'}
|
|
||||||
w='85px'
|
|
||||||
py='6px'
|
|
||||||
borderRadius='12px'
|
|
||||||
textAlign='center'
|
|
||||||
>
|
|
||||||
MODERATE
|
|
||||||
</Badge>
|
|
||||||
</Flex>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<Text color='gray.400' fontWeight='normal' fontSize='sm'>
|
|
||||||
What matters is the people who are sparked by it. And the people who
|
|
||||||
are liked.
|
|
||||||
</Text>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader mb='22px'>
|
|
||||||
<Flex justify='space-between' align='center' w='100%'>
|
|
||||||
<Text fontSize='xs' color='gray.400' fontWeight='bold'>
|
|
||||||
{toggleSwitch ? 'ON' : 'OFF'}
|
|
||||||
</Text>
|
|
||||||
<Switch
|
|
||||||
colorScheme='blue'
|
|
||||||
onChange={() => setToggleSwitch(!toggleSwitch)}
|
|
||||||
/>
|
|
||||||
</Flex>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<Flex direction='column'>
|
|
||||||
<Icon
|
|
||||||
as={toggleSwitch ? FaLightbulb : FaRegLightbulb}
|
|
||||||
w='52px'
|
|
||||||
h='52px'
|
|
||||||
color='gray.400'
|
|
||||||
mb='16px'
|
|
||||||
/>
|
|
||||||
<Text color={textColor} fontWeight='bold'>
|
|
||||||
Lights
|
|
||||||
</Text>
|
|
||||||
</Flex>
|
|
||||||
</Card>
|
|
||||||
<Card px='0px' pb='0px' gridColumn={{ md: '1 / 3', lg: 'auto' }}>
|
|
||||||
<CardHeader px='22px'>
|
|
||||||
<Stat me='auto'>
|
|
||||||
<StatLabel fontSize='xs' color='gray.400' fontWeight='normal'>
|
|
||||||
Calories
|
|
||||||
</StatLabel>
|
|
||||||
<Flex>
|
|
||||||
<StatNumber fontSize='lg' color={textColor}>
|
|
||||||
187
|
|
||||||
</StatNumber>
|
|
||||||
<StatHelpText
|
|
||||||
alignSelf='flex-end'
|
|
||||||
justifySelf='flex-end'
|
|
||||||
m='0px'
|
|
||||||
ps='4px'
|
|
||||||
color='green.400'
|
|
||||||
fontWeight='bold'
|
|
||||||
fontSize='sm'
|
|
||||||
>
|
|
||||||
+5%
|
|
||||||
</StatHelpText>
|
|
||||||
</Flex>
|
|
||||||
</Stat>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<Box w='100%' maxH='100px'>
|
|
||||||
<LineChart
|
|
||||||
chartData={lineChartDataWidgets3}
|
|
||||||
chartOptions={lineChartOptionsWidgets3}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<Flex direction='column'>
|
|
||||||
<IconBox h={'45px'} w={'45px'} bg='blue.500' mb='24px'>
|
|
||||||
<Icon as={FaShare} h={'24px'} w={'24px'} color='white' />
|
|
||||||
</IconBox>
|
|
||||||
<Flex direction='column'>
|
|
||||||
<Text color={textColor} fontSize='2xl' fontWeight='bold'>
|
|
||||||
754
|
|
||||||
<Text as='span' color='gray.400' fontSize='sm' ms='2px'>
|
|
||||||
m
|
|
||||||
</Text>
|
|
||||||
</Text>
|
|
||||||
<Text fontSize='sm' color='gray.400' fontWeight='normal'>
|
|
||||||
New York City
|
|
||||||
</Text>
|
|
||||||
</Flex>
|
|
||||||
</Flex>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader mb='42px'>
|
|
||||||
<Text color='gray.400' fontSize='xs' fontWeight='normal'>
|
|
||||||
STEPS
|
|
||||||
</Text>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<Stat>
|
|
||||||
<StatNumber
|
|
||||||
color={textColor}
|
|
||||||
fontWeight='bold'
|
|
||||||
fontSize='2xl'
|
|
||||||
mb='6px'
|
|
||||||
>
|
|
||||||
11.4K
|
|
||||||
</StatNumber>
|
|
||||||
<StatHelpText
|
|
||||||
bg='green.100'
|
|
||||||
color='green'
|
|
||||||
w='fit-content'
|
|
||||||
borderRadius='12px'
|
|
||||||
fontSize='10px'
|
|
||||||
p='6px 12px'
|
|
||||||
>
|
|
||||||
+4.3%
|
|
||||||
</StatHelpText>
|
|
||||||
</Stat>
|
|
||||||
</Card>
|
|
||||||
</Grid>
|
|
||||||
<Grid templateColumns={{ sm: '1fr', lg: '1fr .5fr .7fr' }} gap='24px'>
|
|
||||||
<Card minH='550px'>
|
|
||||||
<CardHeader mb='6px'>
|
|
||||||
<Flex direction='column'>
|
|
||||||
<Text color={textColor} fontSize='lg' fontWeight='bold' mb='6px'>
|
|
||||||
Calendar
|
|
||||||
</Text>
|
|
||||||
<Text color='gray.400' fontSize='sm' fontWeight='normal'>
|
|
||||||
Wednesday, 2022
|
|
||||||
</Text>
|
|
||||||
</Flex>
|
|
||||||
</CardHeader>
|
|
||||||
<Box position='relative' display='block' height='100%'>
|
|
||||||
<EventCalendar
|
|
||||||
initialDate='2022-10-01'
|
|
||||||
calendarData={calendarDataWidgets}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Card>
|
|
||||||
<Stack direction='column' spacing='24px'>
|
|
||||||
<Card>
|
|
||||||
<Text fontSize='lg' text={textColor} fontWeight='bold'>
|
|
||||||
Categories
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Stack direction='column' spacing='24px' w='100%' pt='28px'>
|
|
||||||
<Flex align='center' w='100%'>
|
|
||||||
<Flex align='center'>
|
|
||||||
<IconBox h={'40px'} w={'40px'} bg={iconBoxColor} me='18px'>
|
|
||||||
<RocketIcon h={'20px'} w={'20px'} color='white' />
|
|
||||||
</IconBox>
|
|
||||||
<Flex direction='column'>
|
|
||||||
<Text fontSize='sm' fontWeight='bold' color={textColor}>
|
|
||||||
Devices
|
|
||||||
</Text>
|
|
||||||
<Text color='gray.400' fontSize='xs'>
|
|
||||||
250 in stock,{' '}
|
|
||||||
<Text as='span' fontWeight='bold'>
|
|
||||||
346+ sold
|
|
||||||
</Text>
|
|
||||||
</Text>
|
|
||||||
</Flex>
|
|
||||||
</Flex>
|
|
||||||
<Spacer />
|
|
||||||
<Button variant='no-effects' px='0px'>
|
|
||||||
<Icon
|
|
||||||
as={RiArrowDropRightLine}
|
|
||||||
color='gray.400'
|
|
||||||
w='30px'
|
|
||||||
h='30px'
|
|
||||||
cursor='pointer'
|
|
||||||
transition='all .25s ease'
|
|
||||||
_hover={{ transform: 'translateX(25%)' }}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</Flex>
|
|
||||||
<Flex align='center' w='100%'>
|
|
||||||
<Flex align='center'>
|
|
||||||
<IconBox h={'40px'} w={'40px'} bg={iconBoxColor} me='18px'>
|
|
||||||
<SettingsIcon h={'20px'} w={'20px'} color='white' />
|
|
||||||
</IconBox>
|
|
||||||
<Flex direction='column'>
|
|
||||||
<Text fontSize='sm' fontWeight='bold' color={textColor}>
|
|
||||||
Tickets
|
|
||||||
</Text>
|
|
||||||
<Text color='gray.400' fontSize='xs'>
|
|
||||||
123 closed,{' '}
|
|
||||||
<Text as='span' fontWeight='bold'>
|
|
||||||
15 open
|
|
||||||
</Text>
|
|
||||||
</Text>
|
|
||||||
</Flex>
|
|
||||||
</Flex>
|
|
||||||
<Spacer />
|
|
||||||
<Button variant='no-effects' px='0px'>
|
|
||||||
<Icon
|
|
||||||
as={RiArrowDropRightLine}
|
|
||||||
color='gray.400'
|
|
||||||
w='30px'
|
|
||||||
h='30px'
|
|
||||||
cursor='pointer'
|
|
||||||
transition='all .25s ease'
|
|
||||||
_hover={{ transform: 'translateX(25%)' }}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</Flex>
|
|
||||||
<Flex align='center' w='100%'>
|
|
||||||
<Flex align='center'>
|
|
||||||
<IconBox h={'40px'} w={'40px'} bg={iconBoxColor} me='18px'>
|
|
||||||
<DocumentIcon h={'20px'} w={'20px'} color='white' />
|
|
||||||
</IconBox>
|
|
||||||
<Flex direction='column'>
|
|
||||||
<Text fontSize='sm' fontWeight='bold' color={textColor}>
|
|
||||||
Error logs
|
|
||||||
</Text>
|
|
||||||
<Text color='gray.400' fontSize='xs'>
|
|
||||||
1 is active,{' '}
|
|
||||||
<Text as='span' fontWeight='bold'>
|
|
||||||
40 closed
|
|
||||||
</Text>
|
|
||||||
</Text>
|
|
||||||
</Flex>
|
|
||||||
</Flex>
|
|
||||||
<Spacer />
|
|
||||||
<Button variant='no-effects' px='0px'>
|
|
||||||
<Icon
|
|
||||||
as={RiArrowDropRightLine}
|
|
||||||
color='gray.400'
|
|
||||||
w='30px'
|
|
||||||
h='30px'
|
|
||||||
cursor='pointer'
|
|
||||||
transition='all .25s ease'
|
|
||||||
_hover={{ transform: 'translateX(25%)' }}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</Flex>
|
|
||||||
<Flex align='center' w='100%'>
|
|
||||||
<Flex align='center'>
|
|
||||||
<IconBox h={'40px'} w={'40px'} bg={iconBoxColor} me='18px'>
|
|
||||||
<Icon as={FaUser} h={'20px'} w={'20px'} color='white' />
|
|
||||||
</IconBox>
|
|
||||||
<Flex direction='column'>
|
|
||||||
<Text fontSize='sm' fontWeight='bold' color={textColor}>
|
|
||||||
Happy Users
|
|
||||||
</Text>
|
|
||||||
<Text color='gray.400' fontSize='xs'>
|
|
||||||
<Text as='span' fontWeight='bold'>
|
|
||||||
+430
|
|
||||||
</Text>
|
|
||||||
</Text>
|
|
||||||
</Flex>
|
|
||||||
</Flex>
|
|
||||||
<Spacer />
|
|
||||||
<Button variant='no-effects' px='0px'>
|
|
||||||
<Icon
|
|
||||||
as={RiArrowDropRightLine}
|
|
||||||
color='gray.400'
|
|
||||||
w='30px'
|
|
||||||
h='30px'
|
|
||||||
cursor='pointer'
|
|
||||||
transition='all .25s ease'
|
|
||||||
_hover={{ transform: 'translateX(25%)' }}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</Flex>
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
<Card
|
|
||||||
bgImage={colorMode === 'light' ? BgMusicCard : BgMusicCardDark}
|
|
||||||
bgRepeat='no-repeat'
|
|
||||||
>
|
|
||||||
<Flex direction='column' w='100%' mb='60px'>
|
|
||||||
<Text color='#fff' fontWeight='bold' fontSize='lg'>
|
|
||||||
Some Kind of Blues
|
|
||||||
</Text>
|
|
||||||
<Text color='#fff' fontWeight='normal' fontSize='sm'>
|
|
||||||
Deftones
|
|
||||||
</Text>
|
|
||||||
</Flex>
|
|
||||||
|
|
||||||
<Stack direction='row' spacing='18px'>
|
|
||||||
<Button
|
|
||||||
variant='outline'
|
|
||||||
colorScheme='whiteAlpha'
|
|
||||||
borderRadius='50px'
|
|
||||||
w='45px'
|
|
||||||
h='45px'
|
|
||||||
>
|
|
||||||
<Icon as={AiFillBackward} color='#fff' w='26px' h='26px' />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant='outline'
|
|
||||||
colorScheme='whiteAlpha'
|
|
||||||
borderRadius='50px'
|
|
||||||
w='45px'
|
|
||||||
h='45px'
|
|
||||||
>
|
|
||||||
<Icon as={FaPlay} color='#fff' w='18px' h='18px' />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant='outline'
|
|
||||||
colorScheme='whiteAlpha'
|
|
||||||
borderRadius='50px'
|
|
||||||
w='45px'
|
|
||||||
h='45px'
|
|
||||||
>
|
|
||||||
<Icon as={AiFillForward} color='#fff' w='26px' h='26px' />
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
</Stack>
|
|
||||||
<Card pb='0px'>
|
|
||||||
<CardHeader mb='34px'>
|
|
||||||
<Flex direction='column'>
|
|
||||||
<Text
|
|
||||||
fontSize='lg'
|
|
||||||
color={textColor}
|
|
||||||
fontWeight='bold'
|
|
||||||
pb='.5rem'
|
|
||||||
>
|
|
||||||
Orders overview
|
|
||||||
</Text>
|
|
||||||
<Text fontSize='sm' color='gray.400' fontWeight='normal'>
|
|
||||||
<Text fontWeight='bold' as='span' color='green.500'>
|
|
||||||
+30%
|
|
||||||
</Text>{' '}
|
|
||||||
this month.
|
|
||||||
</Text>
|
|
||||||
</Flex>
|
|
||||||
</CardHeader>
|
|
||||||
<Flex direction='column' ms='8px' position='relative'>
|
|
||||||
{timelineData.map((row, index, arr) => {
|
|
||||||
return (
|
|
||||||
<TimelineRow
|
|
||||||
logo={row.logo}
|
|
||||||
title={row.title}
|
|
||||||
date={row.date}
|
|
||||||
color={row.color}
|
|
||||||
index={index}
|
|
||||||
arrLength={arr.length}
|
|
||||||
key={index}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Flex>
|
|
||||||
</Card>
|
|
||||||
</Grid>
|
|
||||||
</Flex>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Widgets;
|
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
// 社区动态卡片
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Box, Text, VStack, HStack, Icon, Button } from '@chakra-ui/react';
|
||||||
|
import { Newspaper, Flame, MessageCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
const CommunityFeedCard = ({
|
||||||
|
myPosts = [
|
||||||
|
{ id: 1, title: '关于新能源车下半场的思考', date: '2025/12/18', replies: 32, isHot: true },
|
||||||
|
{ id: 2, title: '半导体行业深度分析', date: '2025/12/15', replies: 18, isHot: false },
|
||||||
|
],
|
||||||
|
participatedPosts = [
|
||||||
|
{ id: 3, title: 'AI产业链投资机会分析', date: '2025/12/17', replies: 45, isHot: true },
|
||||||
|
{ id: 4, title: '消费板块复苏节奏讨论', date: '2025/12/14', replies: 12, isHot: false },
|
||||||
|
],
|
||||||
|
onPostClick,
|
||||||
|
}) => {
|
||||||
|
const [activeTab, setActiveTab] = useState('my'); // 'my' | 'participated'
|
||||||
|
|
||||||
|
const currentPosts = activeTab === 'my' ? myPosts : participatedPosts;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
bg="rgba(26, 26, 46, 0.7)"
|
||||||
|
borderRadius="lg"
|
||||||
|
overflow="hidden"
|
||||||
|
border="1px solid"
|
||||||
|
borderColor="rgba(212, 175, 55, 0.15)"
|
||||||
|
backdropFilter="blur(8px)"
|
||||||
|
>
|
||||||
|
{/* 标题栏 */}
|
||||||
|
<HStack
|
||||||
|
px={4}
|
||||||
|
py={2}
|
||||||
|
bg="rgba(15, 15, 26, 0.8)"
|
||||||
|
borderBottom="1px solid"
|
||||||
|
borderColor="rgba(212, 175, 55, 0.1)"
|
||||||
|
>
|
||||||
|
<Icon as={Newspaper} boxSize={4} color="#3B82F6" />
|
||||||
|
<Text fontSize="sm" fontWeight="bold" color="rgba(255, 255, 255, 0.95)">
|
||||||
|
社区动态
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{/* 内容区 */}
|
||||||
|
<Box p={3}>
|
||||||
|
<VStack spacing={2} align="stretch">
|
||||||
|
{/* Tab 切换 - 更紧凑 */}
|
||||||
|
<HStack spacing={3} mb={1}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
color={activeTab === 'my' ? 'rgba(212, 175, 55, 0.9)' : 'rgba(255, 255, 255, 0.5)'}
|
||||||
|
fontWeight={activeTab === 'my' ? 'bold' : 'normal'}
|
||||||
|
_hover={{ color: 'rgba(212, 175, 55, 0.9)' }}
|
||||||
|
onClick={() => setActiveTab('my')}
|
||||||
|
px={2}
|
||||||
|
h="24px"
|
||||||
|
>
|
||||||
|
[我发布的]
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
color={activeTab === 'participated' ? 'rgba(212, 175, 55, 0.9)' : 'rgba(255, 255, 255, 0.5)'}
|
||||||
|
fontWeight={activeTab === 'participated' ? 'bold' : 'normal'}
|
||||||
|
_hover={{ color: 'rgba(212, 175, 55, 0.9)' }}
|
||||||
|
onClick={() => setActiveTab('participated')}
|
||||||
|
px={2}
|
||||||
|
h="24px"
|
||||||
|
>
|
||||||
|
[我参与的]
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{/* 帖子列表 */}
|
||||||
|
<VStack spacing={2} align="stretch">
|
||||||
|
{currentPosts.map((post) => (
|
||||||
|
<Box
|
||||||
|
key={post.id}
|
||||||
|
py={2.5}
|
||||||
|
px={2}
|
||||||
|
cursor="pointer"
|
||||||
|
transition="all 0.2s"
|
||||||
|
borderRadius="md"
|
||||||
|
_hover={{
|
||||||
|
bg: 'rgba(212, 175, 55, 0.08)',
|
||||||
|
pl: 3,
|
||||||
|
}}
|
||||||
|
onClick={() => onPostClick?.(post)}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
fontSize="sm"
|
||||||
|
fontWeight="medium"
|
||||||
|
color="rgba(255, 255, 255, 0.9)"
|
||||||
|
noOfLines={1}
|
||||||
|
mb={1}
|
||||||
|
>
|
||||||
|
{post.title}
|
||||||
|
</Text>
|
||||||
|
<HStack spacing={3} fontSize="xs" color="rgba(255, 255, 255, 0.5)">
|
||||||
|
<Text>{post.date}</Text>
|
||||||
|
<Text>·</Text>
|
||||||
|
<HStack spacing={1}>
|
||||||
|
{post.isHot ? (
|
||||||
|
<Icon as={Flame} boxSize={3} color="#F97316" />
|
||||||
|
) : (
|
||||||
|
<Icon as={MessageCircle} boxSize={3} />
|
||||||
|
)}
|
||||||
|
<Text color={post.isHot ? '#F97316' : 'inherit'}>
|
||||||
|
{post.replies}回复
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
</HStack>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CommunityFeedCard;
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
// 我的预测卡片
|
||||||
|
import React from 'react';
|
||||||
|
import { Box, Text, VStack, HStack, Button, Icon } from '@chakra-ui/react';
|
||||||
|
import { Zap, History, TrendingUp, TrendingDown } from 'lucide-react';
|
||||||
|
|
||||||
|
const PredictionCard = ({
|
||||||
|
question = '大A 2025年收盘价?',
|
||||||
|
myBet = { type: '看涨', points: 500 },
|
||||||
|
winRate = 58,
|
||||||
|
odds = 1.8,
|
||||||
|
onBullish,
|
||||||
|
onBearish,
|
||||||
|
onViewHistory,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
bg="rgba(26, 26, 46, 0.7)"
|
||||||
|
borderRadius="lg"
|
||||||
|
overflow="hidden"
|
||||||
|
border="1px solid"
|
||||||
|
borderColor="rgba(212, 175, 55, 0.15)"
|
||||||
|
backdropFilter="blur(8px)"
|
||||||
|
>
|
||||||
|
{/* 标题栏 */}
|
||||||
|
<HStack
|
||||||
|
px={4}
|
||||||
|
py={2}
|
||||||
|
bg="rgba(15, 15, 26, 0.8)"
|
||||||
|
borderBottom="1px solid"
|
||||||
|
borderColor="rgba(212, 175, 55, 0.1)"
|
||||||
|
>
|
||||||
|
<Icon as={Zap} boxSize={4} color="#FBBF24" />
|
||||||
|
<Text fontSize="sm" fontWeight="bold" color="rgba(255, 255, 255, 0.95)">
|
||||||
|
我的预测
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{/* 内容区 - 更紧凑 */}
|
||||||
|
<Box p={3}>
|
||||||
|
<VStack spacing={3} align="stretch">
|
||||||
|
{/* 预测问题 - 带渐变背景 */}
|
||||||
|
<Box
|
||||||
|
bg="linear-gradient(135deg, rgba(30, 30, 50, 0.9) 0%, rgba(20, 20, 35, 0.95) 100%)"
|
||||||
|
borderRadius="md"
|
||||||
|
p={3}
|
||||||
|
textAlign="center"
|
||||||
|
position="relative"
|
||||||
|
overflow="hidden"
|
||||||
|
>
|
||||||
|
{/* 装饰性弧线 */}
|
||||||
|
<Box
|
||||||
|
position="absolute"
|
||||||
|
top="50%"
|
||||||
|
left="50%"
|
||||||
|
transform="translate(-50%, -50%)"
|
||||||
|
w="100px"
|
||||||
|
h="50px"
|
||||||
|
borderRadius="50%"
|
||||||
|
border="1px solid"
|
||||||
|
borderColor="rgba(212, 175, 55, 0.15)"
|
||||||
|
borderBottomColor="transparent"
|
||||||
|
borderLeftColor="transparent"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text
|
||||||
|
fontSize="md"
|
||||||
|
fontWeight="bold"
|
||||||
|
color="rgba(255, 255, 255, 0.95)"
|
||||||
|
position="relative"
|
||||||
|
zIndex={1}
|
||||||
|
>
|
||||||
|
{question}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* 看涨/看跌按钮 - 更紧凑 */}
|
||||||
|
<HStack spacing={2} mt={3} justify="center">
|
||||||
|
<Button
|
||||||
|
flex={1}
|
||||||
|
maxW="110px"
|
||||||
|
h="34px"
|
||||||
|
bg="linear-gradient(135deg, #DC2626 0%, #EF4444 100%)"
|
||||||
|
color="white"
|
||||||
|
fontWeight="bold"
|
||||||
|
fontSize="sm"
|
||||||
|
borderRadius="full"
|
||||||
|
_hover={{
|
||||||
|
bg: 'linear-gradient(135deg, #B91C1C 0%, #DC2626 100%)',
|
||||||
|
transform: 'scale(1.02)',
|
||||||
|
}}
|
||||||
|
_active={{ transform: 'scale(0.98)' }}
|
||||||
|
leftIcon={<Icon as={TrendingUp} boxSize={3.5} />}
|
||||||
|
onClick={onBullish}
|
||||||
|
>
|
||||||
|
看涨
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
flex={1}
|
||||||
|
maxW="110px"
|
||||||
|
h="34px"
|
||||||
|
bg="linear-gradient(135deg, #16A34A 0%, #22C55E 100%)"
|
||||||
|
color="white"
|
||||||
|
fontWeight="bold"
|
||||||
|
fontSize="sm"
|
||||||
|
borderRadius="full"
|
||||||
|
_hover={{
|
||||||
|
bg: 'linear-gradient(135deg, #15803D 0%, #16A34A 100%)',
|
||||||
|
transform: 'scale(1.02)',
|
||||||
|
}}
|
||||||
|
_active={{ transform: 'scale(0.98)' }}
|
||||||
|
leftIcon={<Icon as={TrendingDown} boxSize={3.5} />}
|
||||||
|
onClick={onBearish}
|
||||||
|
>
|
||||||
|
看跌
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 底部信息 - 合并为两行紧凑布局 */}
|
||||||
|
<HStack justify="space-between" fontSize="xs" px={1}>
|
||||||
|
<HStack spacing={1}>
|
||||||
|
<Text color="rgba(255, 255, 255, 0.5)">我的下注:</Text>
|
||||||
|
<Text color="#EF4444" fontWeight="medium">{myBet.type}</Text>
|
||||||
|
<Text color="rgba(212, 175, 55, 0.9)" fontWeight="medium">{myBet.points}积分</Text>
|
||||||
|
</HStack>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="ghost"
|
||||||
|
color="rgba(255, 255, 255, 0.6)"
|
||||||
|
leftIcon={<Icon as={History} boxSize={3} />}
|
||||||
|
_hover={{
|
||||||
|
color: 'rgba(212, 175, 55, 0.9)',
|
||||||
|
bg: 'rgba(212, 175, 55, 0.1)',
|
||||||
|
}}
|
||||||
|
onClick={onViewHistory}
|
||||||
|
px={2}
|
||||||
|
h="22px"
|
||||||
|
>
|
||||||
|
历史战绩
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<HStack fontSize="xs" px={1} spacing={4}>
|
||||||
|
<HStack spacing={1}>
|
||||||
|
<Text color="rgba(255, 255, 255, 0.5)">当前胜率:</Text>
|
||||||
|
<Text color="rgba(255, 255, 255, 0.9)" fontWeight="medium">{winRate}%</Text>
|
||||||
|
</HStack>
|
||||||
|
<HStack spacing={1}>
|
||||||
|
<Text color="rgba(255, 255, 255, 0.5)">赔率:</Text>
|
||||||
|
<Text color="rgba(255, 255, 255, 0.9)" fontWeight="medium">{odds}</Text>
|
||||||
|
</HStack>
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PredictionCard;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// 价值论坛子组件导出
|
||||||
|
export { default as PredictionCard } from './PredictionCard';
|
||||||
|
export { default as CommunityFeedCard } from './CommunityFeedCard';
|
||||||
56
src/views/Profile/components/ForumCenter/index.js
Normal file
56
src/views/Profile/components/ForumCenter/index.js
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
// 价值论坛 / 互动中心组件 (Forum Center)
|
||||||
|
import React from 'react';
|
||||||
|
import { Box, Text, HStack, SimpleGrid, Icon } from '@chakra-ui/react';
|
||||||
|
import { MessageCircle } from 'lucide-react';
|
||||||
|
import GlassCard from '@components/GlassCard';
|
||||||
|
import { PredictionCard, CommunityFeedCard } from './components';
|
||||||
|
|
||||||
|
const ForumCenter = () => {
|
||||||
|
return (
|
||||||
|
<GlassCard
|
||||||
|
variant="transparent"
|
||||||
|
rounded="2xl"
|
||||||
|
padding="md"
|
||||||
|
hoverable={false}
|
||||||
|
cornerDecor
|
||||||
|
>
|
||||||
|
{/* 标题栏 */}
|
||||||
|
<HStack mb={4} spacing={2}>
|
||||||
|
<Icon
|
||||||
|
as={MessageCircle}
|
||||||
|
boxSize={5}
|
||||||
|
color="rgba(212, 175, 55, 0.9)"
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
fontSize="lg"
|
||||||
|
fontWeight="bold"
|
||||||
|
color="rgba(255, 255, 255, 0.95)"
|
||||||
|
letterSpacing="wide"
|
||||||
|
>
|
||||||
|
价值论坛 / 互动中心
|
||||||
|
</Text>
|
||||||
|
<Box
|
||||||
|
h="1px"
|
||||||
|
flex={1}
|
||||||
|
bgGradient="linear(to-r, rgba(212, 175, 55, 0.4), transparent)"
|
||||||
|
ml={2}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{/* 两列布局:预测卡片(2) + 社区动态(3) */}
|
||||||
|
<SimpleGrid
|
||||||
|
columns={{ base: 1, md: 5 }}
|
||||||
|
spacing={4}
|
||||||
|
sx={{
|
||||||
|
'& > *:first-of-type': { gridColumn: { md: 'span 2' } },
|
||||||
|
'& > *:last-of-type': { gridColumn: { md: 'span 3' } },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PredictionCard />
|
||||||
|
<CommunityFeedCard />
|
||||||
|
</SimpleGrid>
|
||||||
|
</GlassCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ForumCenter;
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// 市场概览仪表盘主组件 - 投资仪表盘
|
||||||
|
import React from 'react';
|
||||||
|
import { Box, Text, HStack, Icon } from '@chakra-ui/react';
|
||||||
|
import { TrendingUp } from 'lucide-react';
|
||||||
|
import GlassCard from '@components/GlassCard';
|
||||||
|
import { MarketOverview } from './components';
|
||||||
|
import { MOCK_MARKET_STATS } from './constants';
|
||||||
|
|
||||||
|
const MarketDashboard = ({
|
||||||
|
marketStats = MOCK_MARKET_STATS,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<GlassCard
|
||||||
|
variant="transparent"
|
||||||
|
rounded="2xl"
|
||||||
|
padding="md"
|
||||||
|
hoverable={false}
|
||||||
|
cornerDecor
|
||||||
|
>
|
||||||
|
{/* 标题栏 */}
|
||||||
|
<HStack mb={4} spacing={2}>
|
||||||
|
<Icon
|
||||||
|
as={TrendingUp}
|
||||||
|
boxSize={5}
|
||||||
|
color="rgba(212, 175, 55, 0.9)"
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
fontSize="lg"
|
||||||
|
fontWeight="bold"
|
||||||
|
color="rgba(255, 255, 255, 0.95)"
|
||||||
|
letterSpacing="wide"
|
||||||
|
>
|
||||||
|
投资仪表盘
|
||||||
|
</Text>
|
||||||
|
<Box
|
||||||
|
h="1px"
|
||||||
|
flex={1}
|
||||||
|
bgGradient="linear(to-r, rgba(212, 175, 55, 0.4), transparent)"
|
||||||
|
ml={2}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{/* 市场概况:上证 + 深证 + 创业板指+涨跌 + 热门板块 */}
|
||||||
|
<MarketOverview marketStats={marketStats} />
|
||||||
|
</GlassCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MarketDashboard;
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// 热点概念组件
|
||||||
|
import React from 'react';
|
||||||
|
import { Box, Text, VStack, SimpleGrid, HStack, Icon } from '@chakra-ui/react';
|
||||||
|
import { Flame } from 'lucide-react';
|
||||||
|
import { ConceptItem } from './atoms';
|
||||||
|
import { THEME } from '../constants';
|
||||||
|
|
||||||
|
const HotConcepts = ({ concepts = [], onConceptClick }) => {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
borderRadius="xl"
|
||||||
|
p={4}
|
||||||
|
h="100%"
|
||||||
|
>
|
||||||
|
<VStack align="stretch" spacing={3}>
|
||||||
|
{/* 标题 */}
|
||||||
|
<HStack spacing={2}>
|
||||||
|
<Icon as={Flame} boxSize={4} color={THEME.status.up} />
|
||||||
|
<Text
|
||||||
|
fontSize="sm"
|
||||||
|
fontWeight="bold"
|
||||||
|
color="rgba(255, 255, 255, 0.9)"
|
||||||
|
>
|
||||||
|
热点概念
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{/* 概念列表 */}
|
||||||
|
<SimpleGrid columns={{ base: 1, sm: 2 }} spacing={2}>
|
||||||
|
{concepts.map((concept) => (
|
||||||
|
<ConceptItem
|
||||||
|
key={concept.id}
|
||||||
|
name={concept.name}
|
||||||
|
change={concept.change}
|
||||||
|
trend={concept.trend}
|
||||||
|
onClick={() => onConceptClick?.(concept)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HotConcepts;
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// 市场概况组件 - 三列等宽布局
|
||||||
|
import React from 'react';
|
||||||
|
import { Box, Grid } from '@chakra-ui/react';
|
||||||
|
import {
|
||||||
|
IndexKLineCard,
|
||||||
|
GemIndexCard,
|
||||||
|
} from './atoms';
|
||||||
|
|
||||||
|
const MarketOverview = ({ marketStats = {} }) => {
|
||||||
|
return (
|
||||||
|
<Box borderRadius="xl">
|
||||||
|
{/* 三列等宽网格布局 */}
|
||||||
|
<Grid
|
||||||
|
templateColumns={{ base: '1fr', sm: '1fr 1fr', md: '1fr 1fr 1fr' }}
|
||||||
|
gap={3}
|
||||||
|
>
|
||||||
|
{/* 上证指数 - K线卡片 */}
|
||||||
|
<IndexKLineCard
|
||||||
|
indexCode="sh000001"
|
||||||
|
name="上证指数"
|
||||||
|
height="220px"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 深证成指 - K线卡片 */}
|
||||||
|
<IndexKLineCard
|
||||||
|
indexCode="sz399001"
|
||||||
|
name="深证成指"
|
||||||
|
height="220px"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 创业板指 + 涨跌分布 */}
|
||||||
|
<GemIndexCard
|
||||||
|
indexCode="sz399006"
|
||||||
|
name="创业板指"
|
||||||
|
riseCount={marketStats.riseCount || 2156}
|
||||||
|
fallCount={marketStats.fallCount || 2034}
|
||||||
|
flatCount={marketStats.flatCount || 312}
|
||||||
|
height="220px"
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MarketOverview;
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// AI平台能力统计组件 - 底部横条
|
||||||
|
import React from 'react';
|
||||||
|
import { Box, HStack, Divider } from '@chakra-ui/react';
|
||||||
|
import { StatItem } from './atoms';
|
||||||
|
import { THEME } from '../constants';
|
||||||
|
|
||||||
|
const PlatformStats = ({ stats = [] }) => {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
borderRadius="xl"
|
||||||
|
py={4}
|
||||||
|
px={6}
|
||||||
|
>
|
||||||
|
<HStack justify="space-around" divider={
|
||||||
|
<Divider
|
||||||
|
orientation="vertical"
|
||||||
|
h="40px"
|
||||||
|
borderColor="rgba(212, 175, 55, 0.2)"
|
||||||
|
/>
|
||||||
|
}>
|
||||||
|
{stats.map((stat, index) => (
|
||||||
|
<StatItem
|
||||||
|
key={index}
|
||||||
|
icon={stat.icon}
|
||||||
|
value={stat.value}
|
||||||
|
label={stat.label}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</HStack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PlatformStats;
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
// 交易日历组件
|
||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Text,
|
||||||
|
VStack,
|
||||||
|
HStack,
|
||||||
|
Grid,
|
||||||
|
GridItem,
|
||||||
|
IconButton,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import { ChevronLeft, ChevronRight, Calendar } from 'lucide-react';
|
||||||
|
import { DayCell } from './atoms';
|
||||||
|
import { THEME, WEEKDAY_LABELS } from '../constants';
|
||||||
|
|
||||||
|
const TradingCalendar = ({ tradingDays = [] }) => {
|
||||||
|
const [currentDate, setCurrentDate] = useState(new Date());
|
||||||
|
|
||||||
|
const calendarData = useMemo(() => {
|
||||||
|
const year = currentDate.getFullYear();
|
||||||
|
const month = currentDate.getMonth();
|
||||||
|
|
||||||
|
// 当月第一天
|
||||||
|
const firstDay = new Date(year, month, 1);
|
||||||
|
// 当月最后一天
|
||||||
|
const lastDay = new Date(year, month + 1, 0);
|
||||||
|
// 第一天是星期几
|
||||||
|
const startWeekday = firstDay.getDay();
|
||||||
|
// 当月天数
|
||||||
|
const daysInMonth = lastDay.getDate();
|
||||||
|
|
||||||
|
// 上月最后几天
|
||||||
|
const prevMonthLastDay = new Date(year, month, 0).getDate();
|
||||||
|
|
||||||
|
const days = [];
|
||||||
|
|
||||||
|
// 填充上月日期
|
||||||
|
for (let i = startWeekday - 1; i >= 0; i--) {
|
||||||
|
days.push({
|
||||||
|
day: prevMonthLastDay - i,
|
||||||
|
isCurrentMonth: false,
|
||||||
|
isWeekend: false,
|
||||||
|
isTrading: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 填充当月日期
|
||||||
|
const today = new Date();
|
||||||
|
for (let day = 1; day <= daysInMonth; day++) {
|
||||||
|
const date = new Date(year, month, day);
|
||||||
|
const weekday = date.getDay();
|
||||||
|
const isWeekend = weekday === 0 || weekday === 6;
|
||||||
|
const isToday =
|
||||||
|
day === today.getDate() &&
|
||||||
|
month === today.getMonth() &&
|
||||||
|
year === today.getFullYear();
|
||||||
|
|
||||||
|
// 检查是否为交易日(简化逻辑:非周末即交易日)
|
||||||
|
// 实际应用中应该从 tradingDays 数组判断
|
||||||
|
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||||
|
const isTrading = tradingDays.length > 0
|
||||||
|
? tradingDays.includes(dateStr)
|
||||||
|
: !isWeekend;
|
||||||
|
|
||||||
|
days.push({
|
||||||
|
day,
|
||||||
|
isCurrentMonth: true,
|
||||||
|
isWeekend,
|
||||||
|
isTrading,
|
||||||
|
isToday,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 填充下月日期(补满 6 行 * 7 天 = 42 格)
|
||||||
|
const remaining = 42 - days.length;
|
||||||
|
for (let day = 1; day <= remaining; day++) {
|
||||||
|
days.push({
|
||||||
|
day,
|
||||||
|
isCurrentMonth: false,
|
||||||
|
isWeekend: false,
|
||||||
|
isTrading: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return days;
|
||||||
|
}, [currentDate, tradingDays]);
|
||||||
|
|
||||||
|
const handlePrevMonth = () => {
|
||||||
|
setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNextMonth = () => {
|
||||||
|
setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const monthText = `${currentDate.getFullYear()}年${currentDate.getMonth() + 1}月`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
borderRadius="xl"
|
||||||
|
p={4}
|
||||||
|
h="100%"
|
||||||
|
>
|
||||||
|
<VStack align="stretch" spacing={3}>
|
||||||
|
{/* 日历头部 */}
|
||||||
|
<HStack justify="space-between">
|
||||||
|
<HStack spacing={2}>
|
||||||
|
<Calendar size={16} color={THEME.text.gold} />
|
||||||
|
<Text fontSize="sm" fontWeight="bold" color="rgba(255, 255, 255, 0.9)">
|
||||||
|
交易日历
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
<HStack spacing={1}>
|
||||||
|
<IconButton
|
||||||
|
icon={<ChevronLeft size={16} />}
|
||||||
|
size="xs"
|
||||||
|
variant="ghost"
|
||||||
|
color="rgba(255, 255, 255, 0.6)"
|
||||||
|
onClick={handlePrevMonth}
|
||||||
|
aria-label="上月"
|
||||||
|
_hover={{ bg: 'rgba(212, 175, 55, 0.15)' }}
|
||||||
|
/>
|
||||||
|
<Text fontSize="xs" color="rgba(255, 255, 255, 0.9)" minW="70px" textAlign="center">
|
||||||
|
{monthText}
|
||||||
|
</Text>
|
||||||
|
<IconButton
|
||||||
|
icon={<ChevronRight size={16} />}
|
||||||
|
size="xs"
|
||||||
|
variant="ghost"
|
||||||
|
color="rgba(255, 255, 255, 0.6)"
|
||||||
|
onClick={handleNextMonth}
|
||||||
|
aria-label="下月"
|
||||||
|
_hover={{ bg: 'rgba(212, 175, 55, 0.15)' }}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{/* 星期标题 */}
|
||||||
|
<Grid templateColumns="repeat(7, 1fr)" gap={0}>
|
||||||
|
{WEEKDAY_LABELS.map((label, index) => (
|
||||||
|
<GridItem key={label}>
|
||||||
|
<Text
|
||||||
|
fontSize="xs"
|
||||||
|
color="rgba(255, 255, 255, 0.5)"
|
||||||
|
textAlign="center"
|
||||||
|
py={1}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</GridItem>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* 日期网格 */}
|
||||||
|
<Grid templateColumns="repeat(7, 1fr)" gap={0}>
|
||||||
|
{calendarData.map((dayData, index) => (
|
||||||
|
<GridItem
|
||||||
|
key={index}
|
||||||
|
display="flex"
|
||||||
|
justifyContent="center"
|
||||||
|
alignItems="center"
|
||||||
|
py={0.5}
|
||||||
|
>
|
||||||
|
<DayCell
|
||||||
|
day={dayData.day}
|
||||||
|
isTrading={dayData.isTrading}
|
||||||
|
isToday={dayData.isToday || false}
|
||||||
|
isWeekend={dayData.isWeekend}
|
||||||
|
isCurrentMonth={dayData.isCurrentMonth}
|
||||||
|
/>
|
||||||
|
</GridItem>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TradingCalendar;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user