feat: 10.10线上最新代码提交
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
@@ -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 && (
|
||||
|
||||
@@ -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
@@ -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;
|
||||
Reference in New Issue
Block a user