feat: 10.10线上最新代码提交

This commit is contained in:
zdl
2025-10-11 16:16:02 +08:00
parent 4d0dc109bc
commit c1132cd0d6
2750 changed files with 11314 additions and 152745 deletions

View File

@@ -2,22 +2,28 @@
import React, { useState, useEffect } from 'react';
import {
Card, Calendar, Badge, Modal, Table, Tabs, Tag, Button, List, Spin, Empty,
Drawer, Typography, Divider, Space, Tooltip, message
Drawer, Typography, Divider, Space, Tooltip, message, Alert
} from 'antd';
import {
StarFilled, CalendarOutlined, LinkOutlined, StockOutlined,
TagsOutlined, ClockCircleOutlined, InfoCircleOutlined
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 StockKlineModal from './StockKlineModal'; // 需要创建这个组件
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([]);
@@ -33,6 +39,9 @@ const InvestmentCalendar = () => {
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) => {
@@ -67,11 +76,41 @@ const InvestmentCalendar = () => {
}
};
// 获取六位股票代码(去掉后缀)
const getSixDigitCode = (code) => {
if (!code) return code;
// 如果有.SH或.SZ后缀去掉
return code.split('.')[0];
};
// 加载股票行情
const loadStockQuotes = async (stocks, eventTime) => {
try {
const codes = stocks.map(stock => stock[0]); // stock[0] 是股票代码
const quotes = await stockService.getQuotes(codes, eventTime);
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);
@@ -153,24 +192,91 @@ const InvestmentCalendar = () => {
// 显示相关股票
const showRelatedStocks = (stocks, eventTime) => {
// 检查权限
if (!hasFeatureAccess('related_stocks')) {
setUpgradeModalOpen(true);
return;
}
if (!stocks || stocks.length === 0) {
message.info('暂无相关股票');
return;
}
setSelectedStocks(stocks);
// 按相关度排序(限降序)
const sortedStocks = [...stocks].sort((a, b) => (b[3] || 0) - (a[3] || 0));
setSelectedStocks(sortedStocks);
setStockModalVisible(true);
loadStockQuotes(stocks, eventTime);
loadStockQuotes(sortedStocks, eventTime);
};
// 显示K线图
const showKline = (stock) => {
setSelectedStock({
code: stock[0],
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 = [
{
@@ -213,46 +319,43 @@ const InvestmentCalendar = () => {
type="link"
size="small"
icon={<LinkOutlined />}
onClick={() => showContentDetail(text, '事件背景')}
onClick={() => showContentDetail(text + (text ? '\n\n(AI合成)' : ''), '事件背景')}
disabled={!text}
>
查看
</Button>
)
},
{
title: '预测',
dataIndex: 'forecast',
key: 'forecast',
width: 80,
render: (text) => (
<Button
type="link"
size="small"
icon={<InfoCircleOutlined />}
onClick={() => showContentDetail(text, '事件预测')}
disabled={!text}
>
查看
</Button>
)
},
{
title: '相关股票',
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) => (
<Button
type="link"
size="small"
icon={<StockOutlined />}
onClick={() => showRelatedStocks(stocks, record.calendar_time)}
disabled={!stocks || stocks.length === 0}
>
{stocks && stocks.length > 0 ? `${stocks.length}` : '无'}
</Button>
)
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: '相关概念',
@@ -275,6 +378,20 @@ const InvestmentCalendar = () => {
)}
</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)}
/>
)
}
];
@@ -285,14 +402,36 @@ const InvestmentCalendar = () => {
dataIndex: '0',
key: 'code',
width: 100,
render: (code) => <Text code>{code}</Text>
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) => <Text strong>{name}</Text>
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: '现价',
@@ -300,14 +439,14 @@ const InvestmentCalendar = () => {
width: 80,
render: (_, record) => {
const quote = stockQuotes[record[0]];
if (quote) {
if (quote && quote.price !== undefined) {
return (
<Text type={quote.change > 0 ? 'danger' : 'success'}>
{quote.price?.toFixed(2)}
</Text>
);
}
return <Spin size="small" />;
return <Text>-</Text>;
}
},
{
@@ -316,7 +455,7 @@ const InvestmentCalendar = () => {
width: 100,
render: (_, record) => {
const quote = stockQuotes[record[0]];
if (quote) {
if (quote && quote.changePercent !== undefined) {
const changePercent = quote.changePercent || 0;
return (
<Tag color={changePercent > 0 ? 'red' : 'green'}>
@@ -324,46 +463,51 @@ const InvestmentCalendar = () => {
</Tag>
);
}
return <Spin size="small" />;
return <Text>-</Text>;
}
},
{
title: '关联理由',
dataIndex: '2',
key: 'reason',
ellipsis: true,
render: (reason) => (
<Tooltip title={reason}>
<Paragraph ellipsis={{ rows: 2 }} style={{ marginBottom: 0 }}>
{reason}
</Paragraph>
</Tooltip>
)
},
{
title: '相关度',
dataIndex: '3',
key: 'relevance',
width: 100,
render: (relevance) => {
const percent = (relevance * 100).toFixed(0);
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 (
<Tooltip title={`相关度: ${percent}%`}>
<div style={{ width: '100%', height: 20, background: '#f0f0f0', borderRadius: 10 }}>
<div
style={{
width: `${percent}%`,
height: '100%',
background: relevance > 0.8 ? '#52c41a' : '#1890ff',
borderRadius: 10,
transition: 'width 0.3s'
}}
/>
<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>
</Tooltip>
</div>
);
}
},
{
title: 'K线图',
key: 'kline',
@@ -377,6 +521,26 @@ const InvestmentCalendar = () => {
查看
</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>
);
}
}
];
@@ -459,30 +623,71 @@ const InvestmentCalendar = () => {
<Space>
<StockOutlined />
<span>相关股票</span>
{!hasFeatureAccess('related_stocks') && (
<LockOutlined style={{ color: '#faad14' }} />
)}
</Space>
}
visible={stockModalVisible}
onCancel={() => setStockModalVisible(false)}
onCancel={() => {
setStockModalVisible(false);
setExpandedReasons({}); // 清理展开状态
setAddingToWatchlist({}); // 清理加自选状态
}}
width={1000}
footer={null}
footer={
<Button onClick={() => setStockModalVisible(false)}>
关闭
</Button>
}
>
<Table
dataSource={selectedStocks}
columns={stockColumns}
rowKey={(record) => record[0]}
size="middle"
pagination={false}
/>
{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 && (
<StockKlineModal
visible={klineModalVisible}
<StockChartAntdModal
open={klineModalVisible}
stock={selectedStock}
onClose={() => setKlineModalVisible(false)}
onCancel={() => setKlineModalVisible(false)}
/>
)}
{/* 订阅升级模态框 */}
<SubscriptionUpgradeModal
isOpen={upgradeModalOpen}
onClose={() => setUpgradeModalOpen(false)}
requiredLevel="pro"
featureName="相关股票分析"
/>
</>
);
};