feat: 10.10线上最新代码提交

This commit is contained in:
zdl
2025-10-11 16:16:02 +08:00
parent 4d0dc109bc
commit 495ad758ea
3338 changed files with 460147 additions and 152745 deletions

View File

@@ -1,319 +0,0 @@
/* 评论区域样式 */
.comment-section {
background: #fff;
border-radius: 8px;
padding: 16px;
margin-top: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
/* 评论编辑器 */
.comment-editor {
margin-bottom: 24px;
background: #fafafa;
border-radius: 8px;
padding: 16px;
border: 1px solid #e8e8e8;
}
/* 工具栏样式 */
.comment-toolbar {
margin-bottom: 12px;
padding: 8px 12px;
background: #fff;
border: 1px solid #d9d9d9;
border-radius: 6px 6px 0 0;
border-bottom: none;
}
.comment-toolbar .ant-btn {
border: none;
box-shadow: none;
color: #666;
transition: all 0.2s;
}
.comment-toolbar .ant-btn:hover {
color: #ff9500;
background: rgba(255, 149, 0, 0.1);
}
/* 评论输入框 */
.comment-input {
border-radius: 0 0 6px 6px !important;
border-top: none !important;
resize: vertical;
}
.comment-input:focus {
border-color: #ff9500 !important;
box-shadow: 0 0 0 2px rgba(255, 149, 0, 0.2) !important;
}
/* 表情选择器样式 */
.emoji-picker {
width: 280px;
max-height: 200px;
overflow-y: auto;
padding: 8px;
}
.emoji-grid {
display: grid;
grid-template-columns: repeat(10, 1fr);
gap: 4px;
}
.emoji-button {
width: 24px !important;
height: 24px !important;
padding: 0 !important;
font-size: 16px !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
border-radius: 4px !important;
transition: all 0.2s !important;
}
.emoji-button:hover {
background-color: #f0f0f0 !important;
transform: scale(1.1);
}
/* 评论操作区域 */
.comment-actions {
margin-top: 12px;
text-align: right;
}
.comment-actions .ant-btn-primary {
background: #ff9500;
border-color: #ff9500;
}
.comment-actions .ant-btn-primary:hover {
background: #e6860e;
border-color: #e6860e;
}
/* 评论列表头部 */
.comments-header {
display: flex;
justify-content: space-between;
align-items: center;
margin: 20px 0 16px 0;
padding-bottom: 12px;
border-bottom: 1px solid #f0f0f0;
}
.sort-buttons .ant-radio-button-wrapper {
border-color: #d9d9d9;
color: #666;
}
.sort-buttons .ant-radio-button-wrapper-checked {
background: #ff9500;
border-color: #ff9500;
color: #fff;
}
/* 评论项样式 */
.comment-item {
padding: 16px 0;
border-bottom: 1px solid #f0f0f0;
}
.comment-item:last-child {
border-bottom: none;
}
.main-comment {
background: #fff;
}
.reply-item {
background: #fafafa;
margin-left: 48px;
padding: 12px;
border-radius: 6px;
margin-top: 8px;
border: 1px solid #f0f0f0;
}
/* 评论内容 */
.comment-content {
margin-bottom: 8px;
line-height: 1.6;
word-wrap: break-word;
}
/* 格式化文本样式 */
.comment-text strong,
.comment-text b {
font-weight: bold;
color: #262626;
}
.comment-text em,
.comment-text i {
font-style: italic;
color: #595959;
}
.comment-text u {
text-decoration: underline;
color: #722ed1;
}
.comment-text code {
background: #f5f5f5 !important;
border: 1px solid #d9d9d9 !important;
border-radius: 3px !important;
padding: 2px 4px !important;
font-family: 'Courier New', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace !important;
font-size: 0.9em !important;
color: #d63384 !important;
}
.comment-text a {
color: #1890ff !important;
text-decoration: none !important;
transition: color 0.2s;
}
.comment-text a:hover {
color: #40a9ff !important;
text-decoration: underline !important;
}
/* 列表样式 */
.comment-text ol,
.comment-text ul {
margin: 8px 0;
padding-left: 20px;
}
.comment-text li {
margin: 4px 0;
line-height: 1.5;
}
.comment-content a:hover {
text-decoration: underline;
}
/* 评论元信息 */
.comment-meta {
display: flex;
justify-content: space-between;
align-items: center;
color: #8e8e93;
font-size: 12px;
}
.comment-time {
color: #999;
}
/* 评论操作按钮 */
.comment-actions-buttons .ant-btn {
color: #8e8e93;
border: none;
padding: 4px 8px;
height: auto;
font-size: 12px;
}
.comment-actions-buttons .ant-btn:hover {
color: #ff9500;
background: rgba(255, 149, 0, 0.1);
}
/* 点赞按钮特殊样式 */
.comment-actions-buttons .ant-btn.liked {
color: #ff4d4f;
}
/* 回复区域 */
.replies-section {
margin-top: 12px;
border-left: 2px solid #f0f0f0;
padding-left: 16px;
}
/* 空状态样式 */
.ant-empty {
margin: 40px 0;
}
/* 加载状态 */
.ant-spin-container {
min-height: 200px;
}
/* 响应式样式 */
@media (max-width: 768px) {
.comment-section {
padding: 12px;
margin: 12px 0;
}
.comment-editor {
padding: 12px;
}
.comment-toolbar {
padding: 6px 8px;
}
.reply-item {
margin-left: 32px;
padding: 8px;
}
.comments-header {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
}
/* 回复提示样式 */
.reply-to-info {
background: #e6f7ff;
border: 1px solid #91d5ff;
border-radius: 4px;
padding: 8px 12px;
margin-bottom: 8px;
font-size: 12px;
color: #1890ff;
}
/* 动画效果 */
.comment-item {
transition: all 0.3s ease;
}
.comment-item:hover {
background: rgba(0, 0, 0, 0.02);
}
/* 滚动条样式 */
.comment-input::-webkit-scrollbar {
width: 6px;
}
.comment-input::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
.comment-input::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
.comment-input::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}

View File

@@ -1,690 +0,0 @@
// src/views/Community/components/CommentSection.js
import React, { useState, useEffect, useRef } from 'react';
import {
Input,
Button,
List,
Avatar,
Space,
Typography,
Divider,
Empty,
Spin,
Tooltip,
Tag,
Modal,
message,
Popover
} from 'antd';
import { useNavigate } from 'react-router-dom';
import {
BoldOutlined,
ItalicOutlined,
UnderlineOutlined,
CodeOutlined,
LinkOutlined,
OrderedListOutlined,
UnorderedListOutlined,
SmileOutlined,
SendOutlined,
LikeOutlined,
DislikeOutlined,
MessageOutlined,
MoreOutlined,
DeleteOutlined
} from '@ant-design/icons';
import moment from 'moment';
// 直接定义API调用来避免缓存问题
// 判断当前是否是生产环境
const isProduction = process.env.NODE_ENV === 'production';
const API_BASE_URL = isProduction ? "" : process.env.REACT_APP_API_URL;
// 生成唯一的会话ID
const generateSessionId = () => {
let sessionId = localStorage.getItem('user_session_id');
if (!sessionId) {
sessionId = 'user_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
localStorage.setItem('user_session_id', sessionId);
}
return sessionId;
};
const commentAPI = {
getComments: async (eventId, sortType = 'latest') => {
try {
const sessionId = generateSessionId();
const response = await fetch(`${API_BASE_URL}/api/events/${eventId}/comments?sort=${sortType}&session_id=${sessionId}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include'
});
return await response.json();
} catch (error) {
console.error('获取评论失败:', error);
return { success: false, data: [] };
}
},
addComment: async (eventId, commentData) => {
try {
const response = await fetch(`${API_BASE_URL}/api/events/${eventId}/comments`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(commentData)
});
return await response.json();
} catch (error) {
console.error('添加评论失败:', error);
return { success: false, message: '添加评论失败' };
}
},
likeComment: async (commentId) => {
try {
const sessionId = generateSessionId();
const response = await fetch(`${API_BASE_URL}/api/comments/${commentId}/like`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({ session_id: sessionId })
});
return await response.json();
} catch (error) {
console.error('点赞失败:', error);
return { success: false, message: '点赞失败' };
}
},
deleteComment: async (commentId) => {
try {
const response = await fetch(`${API_BASE_URL}/api/comments/${commentId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include'
});
return await response.json();
} catch (error) {
console.error('删除评论失败:', error);
return { success: false, message: '删除评论失败' };
}
}
};
import './CommentSection.css';
const { TextArea } = Input;
const { Text } = Typography;
const CommentSection = ({ eventId, title = "社区讨论" }) => {
const navigate = useNavigate();
const [comments, setComments] = useState([]);
const [loading, setLoading] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [commentText, setCommentText] = useState('');
const [replyTo, setReplyTo] = useState(null);
const [sortType, setSortType] = useState('latest'); // 'latest' or 'hot'
const [expandedComments, setExpandedComments] = useState(new Set());
const [emojiVisible, setEmojiVisible] = useState(false);
const [currentUser, setCurrentUser] = useState(null);
const [userLoading, setUserLoading] = useState(true);
const textareaRef = useRef(null);
// 表情数据
const emojis = [
'😀', '😃', '😄', '😁', '😆', '😅', '🤣', '😂', '🙂', '🙃',
'😉', '😊', '😇', '🥰', '😍', '🤩', '😘', '😗', '😚', '😙',
'😋', '😛', '😜', '🤪', '😝', '🤑', '🤗', '🤭', '🤫', '🤔',
'🤐', '🤨', '😐', '😑', '😶', '😏', '😒', '🙄', '😬', '🤥',
'😔', '😕', '🙁', '😖', '😣', '😞', '😓', '😩', '😫', '🥱',
'😤', '😡', '😠', '🤬', '😈', '👿', '💀', '☠️', '💩', '🤡',
'👍', '👎', '👌', '🤞', '✌️', '🤟', '🤘', '👊', '✊', '🤛',
'🤜', '👏', '🙌', '👐', '🤲', '🤝', '🙏', '✍️', '💪', '🦾',
'❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍', '🤎', '💔',
'❣️', '💕', '💞', '💓', '💗', '💖', '💘', '💝', '💟', '💯'
];
// 加载评论
const loadComments = async () => {
if (!eventId) return;
setLoading(true);
try {
const response = await commentAPI.getComments(eventId, sortType);
if (response.success) {
setComments(response.data || []);
}
} catch (error) {
console.error('加载评论失败:', error);
message.error('加载评论失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadComments();
loadCurrentUser();
}, [eventId, sortType]);
// 加载当前用户信息
const loadCurrentUser = async () => {
try {
const response = await fetch(`${API_BASE_URL}/api/user/current`, {
credentials: 'include'
});
const data = await response.json();
if (data.success && data.data) {
setCurrentUser(data.data);
} else {
setCurrentUser({ is_authenticated: false });
}
} catch (error) {
console.error('获取用户信息失败:', error);
setCurrentUser({ is_authenticated: false });
} finally {
setUserLoading(false);
}
};
// 文本格式化功能
const insertFormatting = (startTag, endTag = null, placeholder = '') => {
const textarea = textareaRef.current;
if (!textarea) return;
// 获取实际的 textarea 元素Ant Design TextArea 的内部元素)
const textareaElement = textarea.resizableTextArea?.textArea || textarea;
const start = textareaElement.selectionStart || 0;
const end = textareaElement.selectionEnd || 0;
const selectedText = commentText.substring(start, end);
if (endTag) {
// 需要结束标签的格式化(如 **bold**
const newText = selectedText || placeholder;
const formatted = startTag + newText + endTag;
setCommentText(prev =>
prev.substring(0, start) + formatted + prev.substring(end)
);
// 设置光标位置
setTimeout(() => {
const newStart = selectedText ? start + formatted.length : start + startTag.length;
if (textareaElement.setSelectionRange) {
textareaElement.setSelectionRange(newStart, newStart);
}
textareaElement.focus();
}, 0);
} else {
// 不需要结束标签的格式化(如链接)
setCommentText(prev =>
prev.substring(0, start) + startTag + prev.substring(end)
);
setTimeout(() => {
const newStart = start + startTag.length;
if (textareaElement.setSelectionRange) {
textareaElement.setSelectionRange(newStart, newStart);
}
textareaElement.focus();
}, 0);
}
};
const formatBold = () => insertFormatting('**', '**', '粗体文本');
const formatItalic = () => insertFormatting('*', '*', '斜体文本');
const formatUnderline = () => insertFormatting('<u>', '</u>', '下划线文本');
const formatCode = () => insertFormatting('`', '`', '代码');
const formatLink = () => {
const url = prompt('请输入链接地址:');
if (url) {
const text = prompt('请输入链接文本:') || url;
insertFormatting(`[${text}](${url})`);
}
};
const formatOrderedList = () => insertFormatting('\n1. ');
const formatUnorderedList = () => insertFormatting('\n- ');
const insertEmoji = (emoji) => {
insertFormatting(emoji);
setEmojiVisible(false);
};
// 表情选择面板
const renderEmojiPicker = () => (
<div className="emoji-picker">
<div className="emoji-grid">
{emojis.map((emoji, index) => (
<Button
key={index}
type="text"
size="small"
className="emoji-button"
onClick={() => insertEmoji(emoji)}
>
{emoji}
</Button>
))}
</div>
</div>
);
// 提交评论
const handleSubmitComment = async () => {
if (!commentText.trim()) {
message.warning('请输入评论内容');
return;
}
setSubmitting(true);
try {
const response = await commentAPI.addComment(eventId, {
content: commentText,
parent_id: replyTo?.id || null
});
if (response.success) {
message.success('评论发布成功');
setCommentText('');
setReplyTo(null);
loadComments(); // 重新加载评论
}
} catch (error) {
console.error('发布评论失败:', error);
message.error('发布评论失败');
} finally {
setSubmitting(false);
}
};
// 处理点赞
const handleLike = async (commentId) => {
try {
const response = await commentAPI.likeComment(commentId);
if (response.success) {
message.success(response.message);
loadComments(); // 重新加载以更新点赞数
} else {
message.error(response.message || '操作失败');
}
} catch (error) {
console.error('点赞失败:', error);
message.error('操作失败');
}
};
// 处理删除评论
const handleDelete = async (commentId) => {
try {
const response = await commentAPI.deleteComment(commentId);
if (response.success) {
message.success('评论删除成功');
loadComments(); // 重新加载评论列表
} else {
message.error(response.message || '删除失败');
}
} catch (error) {
console.error('删除评论失败:', error);
message.error('删除失败');
}
};
// 回复处理
const handleReply = (comment) => {
setReplyTo(comment);
setCommentText(`@${comment.author} `);
if (textareaRef.current) {
const textareaElement = textareaRef.current.resizableTextArea?.textArea || textareaRef.current;
textareaElement.focus();
}
};
// 取消回复
const cancelReply = () => {
setReplyTo(null);
setCommentText('');
};
// 切换回复展开/折叠
const toggleReplies = (commentId) => {
const newExpanded = new Set(expandedComments);
if (newExpanded.has(commentId)) {
newExpanded.delete(commentId);
} else {
newExpanded.add(commentId);
}
setExpandedComments(newExpanded);
};
// 解析和渲染格式化文本
const parseFormattedText = (text) => {
if (!text) return text;
// 替换规则数组
const formatRules = [
// 粗体 **text**
{
regex: /\*\*(.*?)\*\*/g,
replacement: (match, content) => `<strong>${content}</strong>`
},
// 斜体 *text* (但不匹配 **text**)
{
regex: /(?<!\*)\*([^*\n]+?)\*(?!\*)/g,
replacement: (match, content) => `<em>${content}</em>`
},
// 下划线 __text__
{
regex: /__(.*?)__/g,
replacement: (match, content) => `<u>${content}</u>`
},
// 代码 `code`
{
regex: /`([^`]+?)`/g,
replacement: (match, content) => `<code>${content}</code>`
},
// 链接 [text](url)
{
regex: /\[([^\]]+?)\]\(([^)]+?)\)/g,
replacement: (match, text, url) => `<a href="${url}" target="_blank" rel="noopener noreferrer">${text}</a>`
},
// 有序列表 1. item
{
regex: /^(\d+\.\s.+)$/gm,
replacement: (match, content) => `<li>${content.replace(/^\d+\.\s/, '')}</li>`
},
// 无序列表 - item
{
regex: /^(-\s.+)$/gm,
replacement: (match, content) => `<li>${content.replace(/^-\s/, '')}</li>`
},
// 换行
{
regex: /\n/g,
replacement: '<br/>'
}
];
let formattedText = text;
// 应用所有格式化规则
formatRules.forEach(rule => {
formattedText = formattedText.replace(rule.regex, rule.replacement);
});
// 后处理:包装列表项
// 包装有序列表
formattedText = formattedText.replace(
/(<li>.*?<\/li>(?:<br\/>)*)+/g,
(match) => {
// 检查是否包含数字开头的列表项(通过原始文本判断)
const hasOrderedItems = /^\d+\.\s/.test(text);
const listTag = hasOrderedItems ? 'ol' : 'ul';
const cleanedMatch = match.replace(/<br\/>/g, '');
return `<${listTag}>${cleanedMatch}</${listTag}>`;
}
);
return formattedText;
};
// 渲染工具栏
const renderToolbar = () => (
<div className="comment-toolbar">
<Space size={4}>
<Tooltip title="加粗">
<Button type="text" size="small" icon={<BoldOutlined />} onClick={formatBold} />
</Tooltip>
<Tooltip title="斜体">
<Button type="text" size="small" icon={<ItalicOutlined />} onClick={formatItalic} />
</Tooltip>
<Tooltip title="下划线">
<Button type="text" size="small" icon={<UnderlineOutlined />} onClick={formatUnderline} />
</Tooltip>
<Tooltip title="代码">
<Button type="text" size="small" icon={<CodeOutlined />} onClick={formatCode} />
</Tooltip>
<Tooltip title="有序列表">
<Button type="text" size="small" icon={<OrderedListOutlined />} onClick={formatOrderedList} />
</Tooltip>
<Tooltip title="无序列表">
<Button type="text" size="small" icon={<UnorderedListOutlined />} onClick={formatUnorderedList} />
</Tooltip>
<Tooltip title="链接">
<Button type="text" size="small" icon={<LinkOutlined />} onClick={formatLink} />
</Tooltip>
<Popover
content={renderEmojiPicker()}
title="选择表情"
trigger="click"
open={emojiVisible}
onOpenChange={setEmojiVisible}
placement="topLeft"
>
<Tooltip title="表情">
<Button type="text" size="small" icon={<SmileOutlined />} />
</Tooltip>
</Popover>
</Space>
</div>
);
// 渲染评论输入框
const renderCommentEditor = () => (
<div className="comment-editor">
{renderToolbar()}
<TextArea
ref={textareaRef}
placeholder={replyTo ? `回复 @${replyTo.author}` : "写下你的想法..."}
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
rows={4}
className="comment-input"
style={{
border: '1px solid #d9d9d9',
borderRadius: '6px',
fontSize: '14px'
}}
/>
<div className="comment-actions">
<Space>
{replyTo && (
<Button
size="small"
onClick={cancelReply}
>
取消回复
</Button>
)}
<Button
type="primary"
size="small"
icon={<SendOutlined />}
loading={submitting}
onClick={handleSubmitComment}
style={{
backgroundColor: '#ff9500',
borderColor: '#ff9500'
}}
>
发布
</Button>
</Space>
</div>
</div>
);
// 渲染单个评论
const renderComment = (comment, isReply = false) => (
<List.Item
key={comment.id}
className={`comment-item ${isReply ? 'reply-item' : 'main-comment'}`}
>
<List.Item.Meta
avatar={
<Avatar
size={isReply ? 32 : 40}
src={comment.avatar}
style={{ backgroundColor: '#1890ff' }}
>
{comment.author?.charAt(0)?.toUpperCase() || 'U'}
</Avatar>
}
title={
<Space>
<Text strong>{comment.author || '匿名用户'}</Text>
<Text type="secondary" style={{ fontSize: '12px' }}>
{moment(comment.created_at).fromNow()}
</Text>
</Space>
}
description={
<div className="comment-content">
<div
className="comment-text"
dangerouslySetInnerHTML={{
__html: parseFormattedText(comment.content)
}}
/>
<div className="comment-actions-bar">
<Space size={16}>
<Button
type="text"
size="small"
icon={<LikeOutlined />}
onClick={() => handleLike(comment.id)}
style={{
color: comment.user_liked ? '#ff4d4f' : '#8e8e93'
}}
>
{comment.likes || 0}
</Button>
<Button
type="text"
size="small"
icon={<MessageOutlined />}
onClick={() => handleReply(comment)}
>
回复
</Button>
{comment.can_delete && (
<Button
type="text"
size="small"
icon={<DeleteOutlined />}
onClick={() => handleDelete(comment.id)}
style={{ color: '#ff4d4f' }}
>
删除
</Button>
)}
{comment.replies && comment.replies.length > 0 && (
<Button
type="text"
size="small"
onClick={() => toggleReplies(comment.id)}
>
{expandedComments.has(comment.id) ? '收起' : '展开'}
{comment.replies.length}条回复
</Button>
)}
</Space>
</div>
{/* 渲染回复 */}
{comment.replies && comment.replies.length > 0 && expandedComments.has(comment.id) && (
<div className="replies-section">
{comment.replies.map(reply => renderComment(reply, true))}
</div>
)}
</div>
}
/>
</List.Item>
);
return (
<div className="comment-section">
<Divider orientation="left" style={{ margin: '24px 0 16px 0' }}>
<Text strong style={{ fontSize: '16px' }}>{title}</Text>
</Divider>
{/* 评论输入区 */}
{userLoading ? (
<div style={{ textAlign: 'center', padding: '20px' }}>
<Spin />
</div>
) : currentUser?.is_authenticated ? (
renderCommentEditor()
) : (
<div style={{
textAlign: 'center',
padding: '40px 20px',
background: '#fafafa',
borderRadius: '8px',
border: '1px solid #e8e8e8'
}}>
<Text type="secondary">请先登录后再发表评论</Text>
<br />
<Button
type="primary"
style={{ marginTop: '12px', background: '#ff9500', borderColor: '#ff9500' }}
onClick={() => navigate('/auth/sign-in')}
>
立即登录
</Button>
</div>
)}
{/* 评论列表头部 */}
<div className="comments-header">
<Space split={<Divider type="vertical" />}>
<Text strong>全部回复</Text>
<Button
type="text"
size="small"
className={sortType === 'latest' ? 'active' : ''}
onClick={() => setSortType('latest')}
>
最新
</Button>
<Button
type="text"
size="small"
className={sortType === 'hot' ? 'active' : ''}
onClick={() => setSortType('hot')}
>
热门
</Button>
</Space>
</div>
{/* 评论列表 */}
<Spin spinning={loading}>
{comments.length > 0 ? (
<List
className="comments-list"
itemLayout="vertical"
dataSource={comments}
renderItem={(comment) => renderComment(comment)}
/>
) : (
<Empty
description="暂无社区讨论"
image={Empty.PRESENTED_IMAGE_SIMPLE}
style={{ margin: '40px 0' }}
/>
)}
</Spin>
</div>
);
};
export default CommentSection;

View File

@@ -1,12 +1,16 @@
// src/views/Community/components/EventDetailModal.js
import React, { useState, useEffect } from 'react';
import { Modal, Spin, Descriptions, Tag, List, Badge, Empty } from 'antd';
import { Modal, Spin, Descriptions, Tag, List, Badge, Empty, Input, Button, message } from 'antd';
import { eventService } from '../../../services/eventService';
import moment from 'moment';
const EventDetailModal = ({ visible, event, onClose }) => {
const [loading, setLoading] = useState(false);
const [eventDetail, setEventDetail] = useState(null);
const [commentText, setCommentText] = useState('');
const [submitting, setSubmitting] = useState(false);
const [comments, setComments] = useState([]);
const [commentsLoading, setCommentsLoading] = useState(false);
const loadEventDetail = async () => {
if (!event) return;
@@ -24,9 +28,27 @@ const EventDetailModal = ({ visible, event, onClose }) => {
}
};
const loadComments = async () => {
if (!event) return;
setCommentsLoading(true);
try {
// 使用统一的posts API获取评论
const result = await eventService.getPosts(event.id);
if (result.success) {
setComments(result.data || []);
}
} catch (error) {
console.error('Failed to load comments:', error);
} finally {
setCommentsLoading(false);
}
};
useEffect(() => {
if (visible && event) {
loadEventDetail();
loadComments();
}
}, [visible, event]);
@@ -53,6 +75,34 @@ const EventDetailModal = ({ visible, event, onClose }) => {
);
};
const handleSubmitComment = async () => {
if (!commentText.trim()) {
message.warning('请输入评论内容');
return;
}
setSubmitting(true);
try {
// 使用统一的createPost API
const result = await eventService.createPost(event.id, {
content: commentText.trim(),
content_type: 'text'
});
if (result.success) {
message.success('评论发布成功');
setCommentText('');
// 重新加载评论列表
loadComments();
} else {
throw new Error(result.message || '评论失败');
}
} catch (e) {
message.error(e.message || '评论失败');
} finally {
setSubmitting(false);
}
};
return (
<Modal
title={eventDetail?.title || '事件详情'}
@@ -83,7 +133,7 @@ const EventDetailModal = ({ visible, event, onClose }) => {
<Tag>{renderPriceTag(eventDetail.related_week_chg, '周涨幅')}</Tag>
</Descriptions.Item>
<Descriptions.Item label="事件描述" span={2}>
{eventDetail.description}
{eventDetail.description}AI合成
</Descriptions.Item>
</Descriptions>
@@ -105,10 +155,23 @@ const EventDetailModal = ({ visible, event, onClose }) => {
size="small"
dataSource={eventDetail.related_stocks}
renderItem={stock => (
<List.Item>
<List.Item
actions={[
<Button
type="primary"
size="small"
onClick={() => {
const stockCode = stock.stock_code.split('.')[0];
window.open(`https://valuefrontier.cn/company?scode=${stockCode}`, '_blank');
}}
>
股票详情
</Button>
]}
>
<List.Item.Meta
title={`${stock.stock_name} (${stock.stock_code})`}
description={stock.relation_desc}
description={stock.relation_desc ? `${stock.relation_desc}AI合成` : ''}
/>
{stock.change !== null && (
<Tag color={stock.change > 0 ? 'red' : 'green'}>
@@ -120,6 +183,69 @@ const EventDetailModal = ({ visible, event, onClose }) => {
/>
</div>
)}
{/* 讨论区 */}
<div style={{ marginTop: 24 }}>
<h4>讨论区</h4>
{/* 评论列表 */}
<div style={{ marginBottom: 24 }}>
<Spin spinning={commentsLoading}>
{comments.length === 0 ? (
<Empty
description="暂无评论"
style={{ padding: '20px 0' }}
/>
) : (
<List
itemLayout="vertical"
dataSource={comments}
renderItem={comment => (
<List.Item key={comment.id}>
<List.Item.Meta
title={
<div style={{ fontSize: '14px' }}>
<strong>{comment.author?.username || 'Anonymous'}</strong>
<span style={{ marginLeft: 8, color: '#999', fontWeight: 'normal' }}>
{moment(comment.created_at).format('MM-DD HH:mm')}
</span>
</div>
}
description={
<div style={{
fontSize: '14px',
lineHeight: '1.6',
marginTop: 8
}}>
{comment.content}
</div>
}
/>
</List.Item>
)}
/>
)}
</Spin>
</div>
{/* 评论输入框登录后可用未登录后端会返回401 */}
<div>
<h4>发表评论</h4>
<Input.TextArea
placeholder="说点什么..."
rows={3}
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
maxLength={500}
showCount
/>
<div style={{ textAlign: 'right', marginTop: 8 }}>
<Button type="primary" loading={submitting} onClick={handleSubmitComment}>
发布
</Button>
</div>
</div>
</div>
</>
)}
</Spin>

File diff suppressed because it is too large Load Diff

View File

@@ -36,7 +36,7 @@ const HotEvents = ({ events }) => {
};
const handleCardClick = (eventId) => {
navigate(`/admin/event-detail/${eventId}`);
navigate(`/event-detail/${eventId}`);
};
return (
@@ -59,10 +59,10 @@ const HotEvents = ({ events }) => {
<div className="event-cover">
<img
alt={event.title}
src={`/assets/img/podcast/podcast-${index + 1}.jpeg`}
src={`/images/events/${['first', 'second', 'third', 'fourth'][index] || 'first'}.jpg`}
onError={e => {
e.target.onerror = null;
e.target.src = defaultEventImage; // <-- 2. 在这里使用导入的变量
e.target.src = defaultEventImage;
}}
/>
{event.importance && (

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="相关股票分析"
/>
</>
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,466 +0,0 @@
// src/views/Community/components/StockKlineModal.js
import React, { useState, useEffect } from 'react';
import { Modal, Spin, Radio, message } from 'antd';
import ReactECharts from 'echarts-for-react';
import { stockService } from '../../../services/eventService';
import moment from 'moment';
const StockKlineModal = ({ visible, stock, onClose, eventTime }) => {
const [loading, setLoading] = useState(false);
const [klineData, setKlineData] = useState(null);
const [chartType, setChartType] = useState('kline'); // kline or timeline
// 加载K线数据
const loadKlineData = async () => {
if (!stock?.code) return;
setLoading(true);
try {
const response = await stockService.getKlineData(stock.code, chartType, eventTime);
if (response.success) {
setKlineData(response.data);
} else {
message.error('加载K线数据失败');
}
} catch (error) {
console.error('Failed to load kline data:', error);
message.error('加载K线数据失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
if (visible && stock) {
loadKlineData();
}
}, [visible, stock, chartType]);
// 获取K线图配置
const getKlineOption = () => {
if (!klineData || !klineData.data || klineData.data.length === 0) {
return {};
}
const dates = klineData.data.map(item => item[0]);
const data = klineData.data.map(item => [
item[1], // 开盘价
item[2], // 收盘价
item[3], // 最低价
item[4], // 最高价
item[5] // 成交量
]);
const volumes = klineData.data.map((item, index) => ({
value: item[5],
itemStyle: {
color: item[2] >= item[1] ? '#ef5350' : '#26a69a'
}
}));
// 事件发生时间标注
let markLineData = [];
if (eventTime) {
const eventDate = moment(eventTime).format('YYYY-MM-DD');
const idx = dates.indexOf(eventDate);
if (idx !== -1) {
markLineData = [{
name: '事件发生',
xAxis: idx,
label: {
formatter: '事件发生',
position: 'middle',
color: '#ffa500',
fontSize: 12
},
lineStyle: {
color: '#ffa500',
type: 'solid',
width: 2
}
}];
} else if (eventDate < dates[0]) {
markLineData = [{
name: '事件发生',
xAxis: 0,
label: {
formatter: '事件发生\n(历史)',
position: 'start',
offset: [10, 0],
color: '#ffa500',
fontSize: 12
},
lineStyle: {
color: '#ffa500',
type: 'solid',
width: 2
}
}];
} else if (eventDate > dates[dates.length - 1]) {
markLineData = [{
name: '事件发生',
xAxis: dates.length - 1,
label: {
formatter: '事件发生\n(待反映)',
position: 'end',
offset: [-10, 0],
color: '#ffa500',
fontSize: 12
},
lineStyle: {
color: '#ffa500',
type: 'solid',
width: 2
}
}];
}
}
return {
title: {
text: `${stock.name} (${stock.code}) 日K线图`,
left: 'center',
top: 10
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross'
},
formatter: (params) => {
if (!params || params.length === 0) return '';
const data = params[0].data;
const date = params[0].name;
return `
${date}<br/>
开盘: ${data[0]}<br/>
收盘: ${data[1]}<br/>
最低: ${data[2]}<br/>
最高: ${data[3]}<br/>
成交量: ${(data[4] / 10000).toFixed(2)}万手<br/>
涨跌幅: ${((data[1] - data[0]) / data[0] * 100).toFixed(2)}%
`;
}
},
legend: {
data: ['K线', '成交量'],
top: 40
},
grid: [
{
left: '10%',
right: '10%',
top: '15%',
height: '50%'
},
{
left: '10%',
right: '10%',
top: '70%',
height: '15%'
}
],
xAxis: [
{
type: 'category',
data: dates,
scale: true,
boundaryGap: false,
axisLine: { onZero: false },
splitLine: { show: false },
min: 'dataMin',
max: 'dataMax'
},
{
type: 'category',
gridIndex: 1,
data: dates,
scale: true,
boundaryGap: false,
axisLine: { onZero: false },
axisTick: { show: false },
splitLine: { show: false },
axisLabel: { show: false },
min: 'dataMin',
max: 'dataMax'
}
],
yAxis: [
{
scale: true,
splitArea: {
show: true
}
},
{
scale: true,
gridIndex: 1,
splitNumber: 2,
axisLabel: { show: false },
axisLine: { show: false },
axisTick: { show: false },
splitLine: { show: false }
}
],
dataZoom: [
{
type: 'inside',
xAxisIndex: [0, 1],
start: 70,
end: 100
},
{
show: true,
xAxisIndex: [0, 1],
type: 'slider',
bottom: 10,
start: 70,
end: 100
}
],
series: [
{
name: 'K线',
type: 'candlestick',
data: data,
itemStyle: {
color: '#ef5350',
color0: '#26a69a',
borderColor: '#ef5350',
borderColor0: '#26a69a'
},
markLine: markLineData.length ? {
symbol: ['circle', 'none'],
symbolSize: [8, 8],
data: markLineData,
animation: false
} : undefined
},
{
name: '成交量',
type: 'bar',
xAxisIndex: 1,
yAxisIndex: 1,
data: volumes
}
]
};
};
// 获取分时图配置
const getTimelineOption = () => {
if (!klineData || !klineData.data || klineData.data.length === 0) {
return {};
}
const times = klineData.data.map(item => item[0]);
const prices = klineData.data.map(item => item[1]);
const volumes = klineData.data.map(item => item[2]);
const avgPrice = klineData.avgPrice || prices[0];
// 事件发生时间标注
let markLineData = [];
if (eventTime) {
const eventDateTime = moment(eventTime);
const eventDate = eventDateTime.format('YYYY-MM-DD');
const eventHourMin = eventDateTime.format('HH:mm');
if (eventDate === klineData.trade_date) {
if (eventHourMin < times[0]) {
markLineData = [{
name: '事件发生',
xAxis: 0,
label: {
formatter: '事件发生\n(盘前)',
position: 'start',
color: '#ffa500',
fontSize: 12
},
lineStyle: {
color: '#ffa500',
type: 'solid',
width: 2
}
}];
} else if (eventHourMin > times[times.length - 1]) {
markLineData = [{
name: '事件发生',
xAxis: times.length - 1,
label: {
formatter: '事件发生\n(盘后)',
position: 'end',
color: '#ffa500',
fontSize: 12
},
lineStyle: {
color: '#ffa500',
type: 'solid',
width: 2
}
}];
} else {
const nearestIdx = times.findIndex(time => time >= eventHourMin);
markLineData = [{
name: '事件发生',
xAxis: nearestIdx,
label: {
formatter: '事件发生',
position: 'middle',
color: '#ffa500',
fontSize: 12
},
lineStyle: {
color: '#ffa500',
type: 'solid',
width: 2
}
}];
}
}
}
return {
title: {
text: `${stock.name} (${stock.code}) 分时图`,
left: 'center',
top: 10
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross'
}
},
legend: {
data: ['价格', '均价', '成交量'],
top: 40
},
grid: [
{
left: '10%',
right: '10%',
top: '15%',
height: '50%'
},
{
left: '10%',
right: '10%',
top: '70%',
height: '15%'
}
],
xAxis: [
{
type: 'category',
data: times,
boundaryGap: false,
axisLine: { onZero: false }
},
{
type: 'category',
gridIndex: 1,
data: times,
boundaryGap: false,
axisLine: { onZero: false },
axisTick: { show: false },
splitLine: { show: false },
axisLabel: { show: false }
}
],
yAxis: [
{
scale: true,
splitArea: {
show: true
}
},
{
scale: true,
gridIndex: 1,
splitNumber: 2,
axisLabel: { show: false },
axisLine: { show: false },
axisTick: { show: false },
splitLine: { show: false }
}
],
series: [
{
name: '价格',
type: 'line',
data: prices,
smooth: true,
symbol: 'none',
lineStyle: {
width: 2
},
markLine: markLineData.length ? {
symbol: ['circle', 'none'],
symbolSize: [8, 8],
data: markLineData,
animation: false
} : undefined
},
{
name: '均价',
type: 'line',
data: new Array(times.length).fill(avgPrice),
smooth: true,
symbol: 'none',
lineStyle: {
width: 1,
type: 'dashed'
},
itemStyle: {
color: '#ff9800'
}
},
{
name: '成交量',
type: 'bar',
xAxisIndex: 1,
yAxisIndex: 1,
data: volumes,
itemStyle: {
color: '#1890ff'
}
}
]
};
};
return (
<Modal
title={`${stock?.name} (${stock?.code}) - 行情走势`}
open={visible}
onCancel={onClose}
width={1000}
footer={null}
destroyOnClose
>
<div style={{ marginBottom: 16, textAlign: 'center' }}>
<Radio.Group
value={chartType}
onChange={(e) => setChartType(e.target.value)}
buttonStyle="solid"
>
<Radio.Button value="kline">日K线</Radio.Button>
<Radio.Button value="timeline">分时图</Radio.Button>
</Radio.Group>
</div>
<Spin spinning={loading}>
<div style={{ height: 500 }}>
{klineData && (
<ReactECharts
option={chartType === 'kline' ? getKlineOption() : getTimelineOption()}
style={{ height: '100%', width: '100%' }}
notMerge={true}
lazyUpdate={true}
/>
)}
</div>
</Spin>
</Modal>
);
};
export default StockKlineModal;