Initial commit

This commit is contained in:
2025-10-11 11:55:25 +08:00
parent 467dad8449
commit 8107dee8d3
2879 changed files with 610575 additions and 0 deletions

View File

@@ -0,0 +1,695 @@
// src/views/Community/components/InvestmentCalendar.js
import React, { useState, useEffect } from 'react';
import {
Card, Calendar, Badge, Modal, Table, Tabs, Tag, Button, List, Spin, Empty,
Drawer, Typography, Divider, Space, Tooltip, message, Alert
} from 'antd';
import {
StarFilled, StarOutlined, CalendarOutlined, LinkOutlined, StockOutlined,
TagsOutlined, ClockCircleOutlined, InfoCircleOutlined, LockOutlined
} from '@ant-design/icons';
import moment from 'moment';
import ReactMarkdown from 'react-markdown';
import { eventService, stockService } from '../../../services/eventService';
import StockChartAntdModal from '../../../components/StockChart/StockChartAntdModal';
import { useSubscription } from '../../../hooks/useSubscription';
import SubscriptionUpgradeModal from '../../../components/SubscriptionUpgradeModal';
import './InvestmentCalendar.css';
const { TabPane } = Tabs;
const { Text, Title, Paragraph } = Typography;
const InvestmentCalendar = () => {
// 权限控制
const { hasFeatureAccess, getUpgradeRecommendation } = useSubscription();
const [upgradeModalOpen, setUpgradeModalOpen] = useState(false);
const [eventCounts, setEventCounts] = useState([]);
const [selectedDate, setSelectedDate] = useState(null);
const [selectedDateEvents, setSelectedDateEvents] = useState([]);
const [modalVisible, setModalVisible] = useState(false);
const [loading, setLoading] = useState(false);
const [currentMonth, setCurrentMonth] = useState(moment());
// 新增状态
const [detailDrawerVisible, setDetailDrawerVisible] = useState(false);
const [selectedDetail, setSelectedDetail] = useState(null);
const [stockModalVisible, setStockModalVisible] = useState(false);
const [selectedStocks, setSelectedStocks] = useState([]);
const [stockQuotes, setStockQuotes] = useState({});
const [klineModalVisible, setKlineModalVisible] = useState(false);
const [selectedStock, setSelectedStock] = useState(null);
const [followingIds, setFollowingIds] = useState([]); // 正在处理关注的事件ID列表
const [addingToWatchlist, setAddingToWatchlist] = useState({}); // 正在添加到自选的股票代码
const [expandedReasons, setExpandedReasons] = useState({}); // 跟踪每个股票关联理由的展开状态
// 加载月度事件统计
const loadEventCounts = async (date) => {
try {
const year = date.year();
const month = date.month() + 1;
const response = await eventService.calendar.getEventCounts(year, month);
if (response.success) {
setEventCounts(response.data);
}
} catch (error) {
console.error('Failed to load calendar event counts:', error);
}
};
// 加载指定日期的事件
const loadDateEvents = async (date) => {
setLoading(true);
try {
const dateStr = date.format('YYYY-MM-DD');
const response = await eventService.calendar.getEventsForDate(dateStr);
if (response.success) {
setSelectedDateEvents(response.data);
}
} catch (error) {
console.error('Failed to load date events:', error);
setSelectedDateEvents([]);
} finally {
setLoading(false);
}
};
// 获取六位股票代码(去掉后缀)
const getSixDigitCode = (code) => {
if (!code) return code;
// 如果有.SH或.SZ后缀去掉
return code.split('.')[0];
};
// 加载股票行情
const loadStockQuotes = async (stocks, eventTime) => {
try {
const codes = stocks.map(stock => getSixDigitCode(stock[0])); // 确保使用六位代码
const quotes = {};
// 使用市场API获取最新行情数据
for (let i = 0; i < codes.length; i++) {
const code = codes[i];
const originalCode = stocks[i][0]; // 保持原始代码作为key
try {
const response = await fetch(`/api/market/trade/${code}?days=1`);
if (response.ok) {
const data = await response.json();
if (data.success && data.data && data.data.length > 0) {
const latest = data.data[data.data.length - 1]; // 最新数据
quotes[originalCode] = {
price: latest.close,
change: latest.change_amount,
changePercent: latest.change_percent
};
}
}
} catch (err) {
console.error(`Failed to load quote for ${code}:`, err);
}
}
setStockQuotes(quotes);
} catch (error) {
console.error('Failed to load stock quotes:', error);
message.error('加载股票行情失败');
}
};
useEffect(() => {
loadEventCounts(currentMonth);
}, [currentMonth]);
// 自定义日期单元格渲染
const dateCellRender = (value) => {
const dateStr = value.format('YYYY-MM-DD');
const dayEvents = eventCounts.find(item => item.date === dateStr);
if (dayEvents && dayEvents.count > 0) {
return (
<div className="calendar-events">
{/* 使用小圆点指示器,不遮挡日期数字 */}
<div className="event-indicators">
<div
className="event-dot"
style={{ backgroundColor: getEventCountColor(dayEvents.count) }}
title={`${dayEvents.count}个事件`}
/>
<span
className={`event-count ${dayEvents.count >= 10 ? 'many-events' : ''}`}
style={{ color: getEventCountColor(dayEvents.count) }}
>
{dayEvents.count > 99 ? '99+' : dayEvents.count}
</span>
</div>
</div>
);
}
return null;
};
// 根据事件数量获取颜色 - 更丰富的渐进色彩
const getEventCountColor = (count) => {
if (count >= 15) return '#f5222d'; // 深红色 - 非常多
if (count >= 10) return '#fa541c'; // 橙红色 - 很多
if (count >= 8) return '#fa8c16'; // 橙色 - 较多
if (count >= 5) return '#faad14'; // 金黄色 - 中等
if (count >= 3) return '#52c41a'; // 绿色 - 少量
return '#1890ff'; // 蓝色 - 很少
};
// 处理日期选择
const handleDateSelect = (value) => {
setSelectedDate(value);
loadDateEvents(value);
setModalVisible(true);
};
// 渲染重要性星级
const renderStars = (star) => {
const stars = [];
for (let i = 1; i <= 5; i++) {
stars.push(
<StarFilled
key={i}
style={{
color: i <= star ? '#faad14' : '#d9d9d9',
fontSize: '14px'
}}
/>
);
}
return <span>{stars}</span>;
};
// 显示内容详情
const showContentDetail = (content, title) => {
setSelectedDetail({ content, title });
setDetailDrawerVisible(true);
};
// 显示相关股票
const showRelatedStocks = (stocks, eventTime) => {
// 检查权限
if (!hasFeatureAccess('related_stocks')) {
setUpgradeModalOpen(true);
return;
}
if (!stocks || stocks.length === 0) {
message.info('暂无相关股票');
return;
}
// 按相关度排序(限降序)
const sortedStocks = [...stocks].sort((a, b) => (b[3] || 0) - (a[3] || 0));
setSelectedStocks(sortedStocks);
setStockModalVisible(true);
loadStockQuotes(sortedStocks, eventTime);
};
// 显示K线图
const showKline = (stock) => {
setSelectedStock({
code: getSixDigitCode(stock[0]), // 确保使用六位代码
name: stock[1]
});
setKlineModalVisible(true);
};
// 处理关注切换
const handleFollowToggle = async (eventId) => {
setFollowingIds(prev => [...prev, eventId]);
try {
const response = await eventService.calendar.toggleFollow(eventId);
if (response.success) {
// 更新本地事件列表的关注状态
setSelectedDateEvents(prev =>
prev.map(event =>
event.id === eventId
? { ...event, is_following: response.data.is_following }
: event
)
);
message.success(response.data.is_following ? '关注成功' : '取消关注成功');
} else {
message.error(response.error || '操作失败');
}
} catch (error) {
console.error('关注操作失败:', error);
message.error('操作失败,请重试');
} finally {
setFollowingIds(prev => prev.filter(id => id !== eventId));
}
};
// 添加单只股票到自选
const addSingleToWatchlist = async (stock) => {
const stockCode = getSixDigitCode(stock[0]);
setAddingToWatchlist(prev => ({ ...prev, [stockCode]: true }));
try {
const response = await fetch('/api/account/watchlist', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
stock_code: stockCode, // 使用六位代码
stock_name: stock[1] // 股票名称
})
});
const data = await response.json();
if (data.success) {
message.success(`已将 ${stock[1]}(${stockCode}) 添加到自选`);
} else {
message.error(data.error || '添加失败');
}
} catch (error) {
console.error(`添加${stock[1]}(${stockCode})到自选失败:`, error);
message.error('添加失败,请重试');
} finally {
setAddingToWatchlist(prev => ({ ...prev, [stockCode]: false }));
}
};
// 事件表格列定义
const eventColumns = [
{
title: '时间',
dataIndex: 'calendar_time',
key: 'time',
width: 80,
render: (time) => (
<Space>
<ClockCircleOutlined />
<Text>{moment(time).format('HH:mm')}</Text>
</Space>
)
},
{
title: '重要度',
dataIndex: 'star',
key: 'star',
width: 120,
render: renderStars
},
{
title: '标题',
dataIndex: 'title',
key: 'title',
ellipsis: true,
render: (text) => (
<Tooltip title={text}>
<Text strong>{text}</Text>
</Tooltip>
)
},
{
title: '背景',
dataIndex: 'former',
key: 'former',
width: 80,
render: (text) => (
<Button
type="link"
size="small"
icon={<LinkOutlined />}
onClick={() => showContentDetail(text + (text ? '\n\n(AI合成)' : ''), '事件背景')}
disabled={!text}
>
查看
</Button>
)
},
{
title: (
<span>
相关股票
{!hasFeatureAccess('related_stocks') && (
<LockOutlined style={{ marginLeft: '4px', fontSize: '12px', opacity: 0.6, color: '#faad14' }} />
)}
</span>
),
dataIndex: 'related_stocks',
key: 'stocks',
width: 100,
render: (stocks, record) => {
const hasStocks = stocks && stocks.length > 0;
const hasAccess = hasFeatureAccess('related_stocks');
return (
<Button
type="link"
size="small"
icon={hasAccess ? <StockOutlined /> : <LockOutlined />}
onClick={() => showRelatedStocks(stocks, record.calendar_time)}
disabled={!hasStocks}
style={!hasAccess ? { color: '#faad14' } : {}}
>
{hasStocks ? (hasAccess ? `${stocks.length}` : '🔒需Pro') : '无'}
</Button>
);
}
},
{
title: '相关概念',
dataIndex: 'concepts',
key: 'concepts',
width: 200,
render: (concepts) => (
<Space wrap>
{concepts && concepts.length > 0 ? (
concepts.slice(0, 3).map((concept, index) => (
<Tag key={index} icon={<TagsOutlined />}>
{Array.isArray(concept) ? concept[0] : concept}
</Tag>
))
) : (
<Text type="secondary"></Text>
)}
{concepts && concepts.length > 3 && (
<Tag>+{concepts.length - 3}</Tag>
)}
</Space>
)
},
{
title: '关注',
key: 'follow',
width: 60,
render: (_, record) => (
<Button
type={record.is_following ? "primary" : "default"}
icon={record.is_following ? <StarFilled /> : <StarOutlined />}
size="small"
onClick={() => handleFollowToggle(record.id)}
loading={followingIds.includes(record.id)}
/>
)
}
];
// 股票表格列定义
const stockColumns = [
{
title: '代码',
dataIndex: '0',
key: 'code',
width: 100,
render: (code) => {
const sixDigitCode = getSixDigitCode(code);
return (
<a
href={`https://valuefrontier.cn/company?scode=${sixDigitCode}`}
target="_blank"
rel="noopener noreferrer"
>
<Text code>{sixDigitCode}</Text>
</a>
);
}
},
{
title: '名称',
dataIndex: '1',
key: 'name',
width: 100,
render: (name, record) => {
const sixDigitCode = getSixDigitCode(record[0]);
return (
<a
href={`https://valuefrontier.cn/company?scode=${sixDigitCode}`}
target="_blank"
rel="noopener noreferrer"
>
<Text strong>{name}</Text>
</a>
);
}
},
{
title: '现价',
key: 'price',
width: 80,
render: (_, record) => {
const quote = stockQuotes[record[0]];
if (quote && quote.price !== undefined) {
return (
<Text type={quote.change > 0 ? 'danger' : 'success'}>
{quote.price?.toFixed(2)}
</Text>
);
}
return <Text>-</Text>;
}
},
{
title: '涨跌幅',
key: 'change',
width: 100,
render: (_, record) => {
const quote = stockQuotes[record[0]];
if (quote && quote.changePercent !== undefined) {
const changePercent = quote.changePercent || 0;
return (
<Tag color={changePercent > 0 ? 'red' : 'green'}>
{changePercent > 0 ? '+' : ''}{changePercent.toFixed(2)}%
</Tag>
);
}
return <Text>-</Text>;
}
},
{
title: '关联理由',
dataIndex: '2',
key: 'reason',
render: (reason, record) => {
const stockCode = record[0];
const isExpanded = expandedReasons[stockCode] || false;
const shouldTruncate = reason && reason.length > 100;
const toggleExpanded = () => {
setExpandedReasons(prev => ({
...prev,
[stockCode]: !prev[stockCode]
}));
};
return (
<div>
<Text>
{isExpanded || !shouldTruncate
? reason
: `${reason?.slice(0, 100)}...`
}
</Text>
{shouldTruncate && (
<Button
type="link"
size="small"
onClick={toggleExpanded}
style={{ padding: 0, marginLeft: 4 }}
>
({isExpanded ? '收起' : '展开'})
</Button>
)}
<div style={{ marginTop: 4 }}>
<Text type="secondary" style={{ fontSize: '12px' }}>(AI合成)</Text>
</div>
</div>
);
}
},
{
title: 'K线图',
key: 'kline',
width: 80,
render: (_, record) => (
<Button
type="primary"
size="small"
onClick={() => showKline(record)}
>
查看
</Button>
)
},
{
title: '操作',
key: 'action',
width: 100,
render: (_, record) => {
const stockCode = getSixDigitCode(record[0]);
const isAdding = addingToWatchlist[stockCode] || false;
return (
<Button
type="default"
size="small"
loading={isAdding}
onClick={() => addSingleToWatchlist(record)}
>
加自选
</Button>
);
}
}
];
return (
<>
<Card
title={
<span>
<CalendarOutlined style={{ marginRight: 8 }} />
投资日历
</span>
}
className="investment-calendar"
style={{ marginBottom: 16 }}
>
<Calendar
fullscreen={false}
dateCellRender={dateCellRender}
onSelect={handleDateSelect}
onPanelChange={(date) => setCurrentMonth(date)}
/>
</Card>
{/* 事件列表模态框 */}
<Modal
title={
<Space>
<CalendarOutlined />
<span>{selectedDate?.format('YYYY年MM月DD日')} 投资事件</span>
</Space>
}
visible={modalVisible}
onCancel={() => setModalVisible(false)}
width={1200}
footer={null}
bodyStyle={{ padding: '24px' }}
>
<Spin spinning={loading}>
<Tabs defaultActiveKey="event">
<TabPane tab={`事件 (${selectedDateEvents.filter(e => e.type === 'event').length})`} key="event">
<Table
dataSource={selectedDateEvents.filter(e => e.type === 'event')}
columns={eventColumns}
rowKey="id"
size="middle"
pagination={false}
scroll={{ x: 1000 }}
/>
</TabPane>
<TabPane tab={`数据 (${selectedDateEvents.filter(e => e.type === 'data').length})`} key="data">
<Table
dataSource={selectedDateEvents.filter(e => e.type === 'data')}
columns={eventColumns}
rowKey="id"
size="middle"
pagination={false}
scroll={{ x: 1000 }}
/>
</TabPane>
</Tabs>
</Spin>
</Modal>
{/* 内容详情抽屉 */}
<Drawer
title={selectedDetail?.title}
placement="right"
width={600}
onClose={() => setDetailDrawerVisible(false)}
visible={detailDrawerVisible}
>
<div className="markdown-content">
<ReactMarkdown>{selectedDetail?.content || '暂无内容'}</ReactMarkdown>
</div>
</Drawer>
{/* 相关股票模态框 */}
<Modal
title={
<Space>
<StockOutlined />
<span>相关股票</span>
{!hasFeatureAccess('related_stocks') && (
<LockOutlined style={{ color: '#faad14' }} />
)}
</Space>
}
visible={stockModalVisible}
onCancel={() => {
setStockModalVisible(false);
setExpandedReasons({}); // 清理展开状态
setAddingToWatchlist({}); // 清理加自选状态
}}
width={1000}
footer={
<Button onClick={() => setStockModalVisible(false)}>
关闭
</Button>
}
>
{hasFeatureAccess('related_stocks') ? (
<Table
dataSource={selectedStocks}
columns={stockColumns}
rowKey={(record) => record[0]}
size="middle"
pagination={false}
/>
) : (
<div style={{ padding: '40px', textAlign: 'center' }}>
<div style={{ fontSize: '48px', marginBottom: '16px', opacity: 0.3 }}>
<LockOutlined />
</div>
<Alert
message="相关股票功能已锁定"
description="此功能需要Pro版订阅才能使用"
type="warning"
showIcon
style={{ maxWidth: '400px', margin: '0 auto', marginBottom: '24px' }}
/>
<Button
type="primary"
size="large"
onClick={() => setUpgradeModalOpen(true)}
>
升级到 Pro版
</Button>
</div>
)}
</Modal>
{/* K线图模态框 */}
{klineModalVisible && selectedStock && (
<StockChartAntdModal
open={klineModalVisible}
stock={selectedStock}
onCancel={() => setKlineModalVisible(false)}
/>
)}
{/* 订阅升级模态框 */}
<SubscriptionUpgradeModal
isOpen={upgradeModalOpen}
onClose={() => setUpgradeModalOpen(false)}
requiredLevel="pro"
featureName="相关股票分析"
/>
</>
);
};
export default InvestmentCalendar;