Merge branch 'feature_2025/251117_pref' of https://git.valuefrontier.cn/vf/vf_react into feature_2025/251117_pref
This commit is contained in:
@@ -1,283 +0,0 @@
|
||||
// src/views/Community/components/EventDetailModal.js
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal, Spin, Descriptions, Tag, List, Badge, Empty, Input, Button, message } from 'antd';
|
||||
import { eventService } from '../../../services/eventService';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
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;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await eventService.getEventDetail(event.id);
|
||||
if (response.success) {
|
||||
setEventDetail(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('EventDetailModal', 'loadEventDetail', error, {
|
||||
eventId: event?.id
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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) {
|
||||
logger.error('EventDetailModal', 'loadComments', error, {
|
||||
eventId: event?.id
|
||||
});
|
||||
} finally {
|
||||
setCommentsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && event) {
|
||||
loadEventDetail();
|
||||
loadComments();
|
||||
}
|
||||
}, [visible, event]);
|
||||
|
||||
const getImportanceColor = (importance) => {
|
||||
const colors = {
|
||||
S: 'red',
|
||||
A: 'orange',
|
||||
B: 'blue',
|
||||
C: 'green'
|
||||
};
|
||||
return colors[importance] || 'default';
|
||||
};
|
||||
|
||||
const getRelationDesc = (relationDesc) => {
|
||||
// 处理空值
|
||||
if (!relationDesc) return '';
|
||||
|
||||
// 如果是字符串,直接返回
|
||||
if (typeof relationDesc === 'string') {
|
||||
return relationDesc;
|
||||
}
|
||||
|
||||
// 如果是对象且包含data数组
|
||||
if (typeof relationDesc === 'object' && relationDesc.data && Array.isArray(relationDesc.data)) {
|
||||
const firstItem = relationDesc.data[0];
|
||||
if (firstItem) {
|
||||
// 优先使用 query_part,其次使用 sentences
|
||||
return firstItem.query_part || firstItem.sentences || '';
|
||||
}
|
||||
}
|
||||
|
||||
// 其他情况返回空字符串
|
||||
return '';
|
||||
};
|
||||
|
||||
const renderPriceTag = (value, label) => {
|
||||
if (value === null || value === undefined) return `${label}: --`;
|
||||
|
||||
const color = value > 0 ? '#ff4d4f' : '#52c41a';
|
||||
const prefix = value > 0 ? '+' : '';
|
||||
|
||||
return (
|
||||
<span>
|
||||
{label}: <span style={{ color }}>{prefix}{value.toFixed(2)}%</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
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 || '事件详情'}
|
||||
open={visible}
|
||||
onCancel={onClose}
|
||||
width={800}
|
||||
footer={null}
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
{eventDetail && (
|
||||
<>
|
||||
<Descriptions bordered column={2} style={{ marginBottom: 24 }}>
|
||||
<Descriptions.Item label="创建时间">
|
||||
{dayjs(eventDetail.created_at).format('YYYY-MM-DD HH:mm:ss')}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="创建者">
|
||||
{eventDetail.creator?.username || 'Anonymous'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="重要性">
|
||||
<Badge color={getImportanceColor(eventDetail.importance)} text={`${eventDetail.importance}级`} />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="浏览数">
|
||||
{eventDetail.view_count || 0}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="涨幅统计" span={2}>
|
||||
<Tag>{renderPriceTag(eventDetail.related_avg_chg, '平均涨幅')}</Tag>
|
||||
<Tag>{renderPriceTag(eventDetail.related_max_chg, '最大涨幅')}</Tag>
|
||||
<Tag>{renderPriceTag(eventDetail.related_week_chg, '周涨幅')}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="事件描述" span={2}>
|
||||
{eventDetail.description}(AI合成)
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
{eventDetail.keywords && eventDetail.keywords.length > 0 && (
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<h4>相关概念</h4>
|
||||
{eventDetail.keywords.map((keyword, index) => (
|
||||
<Tag key={index} color="blue" style={{ marginBottom: 8 }}>
|
||||
{keyword}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{eventDetail.related_stocks && eventDetail.related_stocks.length > 0 && (
|
||||
<div>
|
||||
<h4>相关股票</h4>
|
||||
<List
|
||||
size="small"
|
||||
dataSource={eventDetail.related_stocks}
|
||||
renderItem={stock => (
|
||||
<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={getRelationDesc(stock.relation_desc) ? `${getRelationDesc(stock.relation_desc)}(AI合成)` : ''}
|
||||
/>
|
||||
{stock.change !== null && (
|
||||
<Tag color={stock.change > 0 ? 'red' : 'green'}>
|
||||
{stock.change > 0 ? '+' : ''}{stock.change.toFixed(2)}%
|
||||
</Tag>
|
||||
)}
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</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' }}>
|
||||
{dayjs(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>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventDetailModal;
|
||||
@@ -1,387 +0,0 @@
|
||||
/* src/views/Community/components/EventList.css */
|
||||
|
||||
/* 时间轴容器样式 */
|
||||
.event-timeline {
|
||||
padding: 0 0 0 24px;
|
||||
}
|
||||
|
||||
/* 时间轴圆点样式 */
|
||||
.timeline-dot {
|
||||
border: none !important;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
/* 时间轴事件卡片 */
|
||||
.timeline-event-card {
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #f0f0f0;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.timeline-event-card:hover {
|
||||
transform: translateX(8px);
|
||||
box-shadow: 0 6px 20px rgba(0,0,0,0.08);
|
||||
border-color: #d9d9d9;
|
||||
}
|
||||
|
||||
/* 重要性标记线 */
|
||||
.importance-marker {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 4px;
|
||||
height: 100%;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
.timeline-event-card:hover .importance-marker {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
/* 事件标题 */
|
||||
.event-title {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.event-title a {
|
||||
color: #1890ff;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.event-title a:hover {
|
||||
color: #40a9ff;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* 事件元信息 */
|
||||
.event-meta {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.event-meta .anticon {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.event-meta .separator {
|
||||
margin: 0;
|
||||
color: #e8e8e8;
|
||||
}
|
||||
|
||||
/* 事件描述 */
|
||||
.event-description {
|
||||
margin: 0 0 12px;
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 事件统计标签 */
|
||||
.event-stats {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.event-stats .ant-tag {
|
||||
border-radius: 4px;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 事件操作区域 */
|
||||
.event-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.event-actions > span {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.event-actions .anticon {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
/* 事件按钮 */
|
||||
.event-buttons {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.event-buttons .ant-btn {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.event-buttons .ant-btn-sm {
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
/* 重要性指示器 */
|
||||
.importance-indicator {
|
||||
text-align: right;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.importance-indicator .ant-badge {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.importance-indicator .ant-avatar {
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.timeline-event-card:hover .importance-indicator .ant-avatar {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.importance-label {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 分页容器 */
|
||||
.pagination-container {
|
||||
margin-top: 32px;
|
||||
text-align: center;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 0;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.event-timeline {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.timeline-event-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.event-title a {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.event-description {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.event-actions {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.event-buttons {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.event-buttons .ant-space {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.event-buttons .ant-btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.importance-indicator {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
}
|
||||
|
||||
.importance-label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* 深色主题支持(可选) */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.timeline-event-card {
|
||||
background: #1f1f1f;
|
||||
border-color: #303030;
|
||||
}
|
||||
|
||||
.timeline-event-card:hover {
|
||||
border-color: #434343;
|
||||
}
|
||||
|
||||
.event-title a {
|
||||
color: #4096ff;
|
||||
}
|
||||
|
||||
.event-description {
|
||||
color: #bfbfbf;
|
||||
}
|
||||
|
||||
.event-meta {
|
||||
color: #8c8c8c;
|
||||
}
|
||||
}
|
||||
|
||||
/* 动画效果 */
|
||||
@keyframes fadeInLeft {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 时间轴项目动画 */
|
||||
.ant-timeline-item {
|
||||
animation: fadeInLeft 0.5s ease-out forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.ant-timeline-item:nth-child(1) { animation-delay: 0.1s; }
|
||||
.ant-timeline-item:nth-child(2) { animation-delay: 0.2s; }
|
||||
.ant-timeline-item:nth-child(3) { animation-delay: 0.3s; }
|
||||
.ant-timeline-item:nth-child(4) { animation-delay: 0.4s; }
|
||||
.ant-timeline-item:nth-child(5) { animation-delay: 0.5s; }
|
||||
.ant-timeline-item:nth-child(6) { animation-delay: 0.6s; }
|
||||
.ant-timeline-item:nth-child(7) { animation-delay: 0.7s; }
|
||||
.ant-timeline-item:nth-child(8) { animation-delay: 0.8s; }
|
||||
.ant-timeline-item:nth-child(9) { animation-delay: 0.9s; }
|
||||
.ant-timeline-item:nth-child(10) { animation-delay: 1s; }
|
||||
|
||||
/* 时间轴连接线样式 */
|
||||
.ant-timeline-item-tail {
|
||||
border-left-style: dashed;
|
||||
border-left-width: 2px;
|
||||
}
|
||||
|
||||
/* 涨跌幅标签特殊样式 */
|
||||
.event-stats .ant-tag[color="#ff4d4f"] {
|
||||
background-color: #fff1f0;
|
||||
border-color: #ffccc7;
|
||||
}
|
||||
|
||||
.event-stats .ant-tag[color="#52c41a"] {
|
||||
background-color: #f6ffed;
|
||||
border-color: #b7eb8f;
|
||||
}
|
||||
|
||||
/* 快速查看和详细信息按钮悬停效果 */
|
||||
.event-buttons .ant-btn-default:hover {
|
||||
color: #40a9ff;
|
||||
border-color: #40a9ff;
|
||||
}
|
||||
|
||||
.event-buttons .ant-btn-primary {
|
||||
background: #1890ff;
|
||||
border-color: #1890ff;
|
||||
}
|
||||
|
||||
.event-buttons .ant-btn-primary:hover {
|
||||
background: #40a9ff;
|
||||
border-color: #40a9ff;
|
||||
}
|
||||
|
||||
/* 工具提示样式 */
|
||||
.ant-tooltip-inner {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 徽章计数样式 */
|
||||
.importance-indicator .ant-badge-count {
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
line-height: 20px;
|
||||
border-radius: 10px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
/* 加载状态动画 */
|
||||
.timeline-event-card.loading {
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: loading 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes loading {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 特殊重要性等级样式增强 */
|
||||
.timeline-event-card[data-importance="S"] {
|
||||
border-left: 4px solid #722ed1;
|
||||
}
|
||||
|
||||
.timeline-event-card[data-importance="A"] {
|
||||
border-left: 4px solid #ff4d4f;
|
||||
}
|
||||
|
||||
.timeline-event-card[data-importance="B"] {
|
||||
border-left: 4px solid #faad14;
|
||||
}
|
||||
|
||||
.timeline-event-card[data-importance="C"] {
|
||||
border-left: 4px solid #52c41a;
|
||||
}
|
||||
|
||||
/* 时间轴左侧内容区域优化 */
|
||||
.ant-timeline-item-content {
|
||||
padding-bottom: 0;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
/* 确保最后一个时间轴项目没有连接线 */
|
||||
.ant-timeline-item:last-child .ant-timeline-item-tail {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 打印样式优化 */
|
||||
@media print {
|
||||
.timeline-event-card {
|
||||
page-break-inside: avoid;
|
||||
border: 1px solid #000;
|
||||
}
|
||||
|
||||
.event-buttons {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.importance-marker {
|
||||
width: 2px !important;
|
||||
background: #000 !important;
|
||||
}
|
||||
}
|
||||
@@ -1,490 +0,0 @@
|
||||
// src/views/Community/components/EventList.js
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Button,
|
||||
Badge,
|
||||
Flex,
|
||||
Container,
|
||||
useColorModeValue,
|
||||
Switch,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
useToast,
|
||||
Center,
|
||||
Tooltip,
|
||||
} from '@chakra-ui/react';
|
||||
import { InfoIcon } from '@chakra-ui/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
// 导入工具函数和常量
|
||||
import { logger } from '../../../utils/logger';
|
||||
import { getApiBase } from '../../../utils/apiConfig';
|
||||
import { useEventNotifications } from '../../../hooks/useEventNotifications';
|
||||
import { browserNotificationService } from '../../../services/browserNotificationService';
|
||||
import { useNotification } from '../../../contexts/NotificationContext';
|
||||
import { getImportanceConfig } from '../../../constants/importanceLevels';
|
||||
|
||||
// 导入子组件
|
||||
import EventCard from './EventCard';
|
||||
|
||||
// ========== 主组件 ==========
|
||||
const EventList = ({ events, pagination, onPageChange, onEventClick, onViewDetail }) => {
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
const [isCompactMode, setIsCompactMode] = useState(false); // 新增:紧凑模式状态
|
||||
const [followingMap, setFollowingMap] = useState({});
|
||||
const [followCountMap, setFollowCountMap] = useState({});
|
||||
const [localEvents, setLocalEvents] = useState(events); // 用于实时更新的本地事件列表
|
||||
|
||||
// 从 NotificationContext 获取推送权限相关状态和方法
|
||||
const { browserPermission, requestBrowserPermission } = useNotification();
|
||||
|
||||
// 实时事件推送集成
|
||||
const { isConnected } = useEventNotifications({
|
||||
eventType: 'all',
|
||||
importance: 'all',
|
||||
enabled: true,
|
||||
onNewEvent: (event) => {
|
||||
console.log('\n[EventList DEBUG] ========== EventList 收到新事件 ==========');
|
||||
console.log('[EventList DEBUG] 事件数据:', event);
|
||||
console.log('[EventList DEBUG] 事件 ID:', event?.id);
|
||||
console.log('[EventList DEBUG] 事件标题:', event?.title);
|
||||
logger.info('EventList', '收到新事件推送', event);
|
||||
|
||||
// 发送浏览器原生通知
|
||||
console.log('[EventList DEBUG] 准备发送浏览器原生通知');
|
||||
console.log('[EventList DEBUG] 通知权限状态:', browserPermission);
|
||||
if (browserPermission === 'granted') {
|
||||
const importance = getImportanceConfig(event.importance);
|
||||
const notification = browserNotificationService.sendNotification({
|
||||
title: `🔔 ${importance.label}级事件`,
|
||||
body: event.title,
|
||||
tag: `event_${event.id}`,
|
||||
data: {
|
||||
link: `/event-detail/${event.id}`,
|
||||
eventId: event.id,
|
||||
},
|
||||
autoClose: 10000, // 10秒后自动关闭
|
||||
});
|
||||
|
||||
if (notification) {
|
||||
browserNotificationService.setupClickHandler(notification, navigate);
|
||||
console.log('[EventList DEBUG] ✓ 浏览器原生通知已发送');
|
||||
} else {
|
||||
console.log('[EventList DEBUG] ⚠️ 浏览器原生通知发送失败');
|
||||
}
|
||||
} else {
|
||||
console.log('[EventList DEBUG] ⚠️ 浏览器通知权限未授予,跳过原生通知');
|
||||
}
|
||||
|
||||
console.log('[EventList DEBUG] 准备更新事件列表');
|
||||
// 将新事件添加到列表顶部(防止重复)
|
||||
setLocalEvents((prevEvents) => {
|
||||
console.log('[EventList DEBUG] 当前事件列表数量:', prevEvents.length);
|
||||
const exists = prevEvents.some(e => e.id === event.id);
|
||||
console.log('[EventList DEBUG] 事件是否已存在:', exists);
|
||||
if (exists) {
|
||||
logger.debug('EventList', '事件已存在,跳过添加', { eventId: event.id });
|
||||
console.log('[EventList DEBUG] ⚠️ 事件已存在,跳过添加');
|
||||
return prevEvents;
|
||||
}
|
||||
logger.info('EventList', '新事件添加到列表顶部', { eventId: event.id });
|
||||
console.log('[EventList DEBUG] ✓ 新事件添加到列表顶部');
|
||||
// 添加到顶部,最多保留 100 个
|
||||
const updatedEvents = [event, ...prevEvents].slice(0, 100);
|
||||
console.log('[EventList DEBUG] 更新后事件列表数量:', updatedEvents.length);
|
||||
return updatedEvents;
|
||||
});
|
||||
console.log('[EventList DEBUG] ✓ 事件列表更新完成');
|
||||
console.log('[EventList DEBUG] ========== EventList 处理完成 ==========\n');
|
||||
}
|
||||
});
|
||||
|
||||
// 同步外部 events 到 localEvents
|
||||
useEffect(() => {
|
||||
setLocalEvents(events);
|
||||
}, [events]);
|
||||
|
||||
// 初始化关注状态与计数
|
||||
useEffect(() => {
|
||||
// 初始化计数映射
|
||||
const initCounts = {};
|
||||
localEvents.forEach(ev => {
|
||||
initCounts[ev.id] = ev.follower_count || 0;
|
||||
});
|
||||
setFollowCountMap(initCounts);
|
||||
|
||||
const loadFollowing = async () => {
|
||||
try {
|
||||
const base = getApiBase();
|
||||
const res = await fetch(base + '/api/account/events/following', { credentials: 'include' });
|
||||
const data = await res.json();
|
||||
if (res.ok && data.success) {
|
||||
const map = {};
|
||||
(data.data || []).forEach(ev => { map[ev.id] = true; });
|
||||
setFollowingMap(map);
|
||||
logger.debug('EventList', '关注状态加载成功', {
|
||||
followingCount: Object.keys(map).length
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('EventList', '加载关注状态失败', { error: e.message });
|
||||
}
|
||||
};
|
||||
loadFollowing();
|
||||
// 仅在 localEvents 更新时重跑
|
||||
}, [localEvents]);
|
||||
|
||||
const toggleFollow = async (eventId) => {
|
||||
try {
|
||||
const base = getApiBase();
|
||||
const res = await fetch(base + `/api/events/${eventId}/follow`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include'
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data.success) throw new Error(data.error || '操作失败');
|
||||
const isFollowing = data.data?.is_following;
|
||||
const count = data.data?.follower_count ?? 0;
|
||||
setFollowingMap(prev => ({ ...prev, [eventId]: isFollowing }));
|
||||
setFollowCountMap(prev => ({ ...prev, [eventId]: count }));
|
||||
logger.debug('EventList', '关注状态切换成功', {
|
||||
eventId,
|
||||
isFollowing,
|
||||
followerCount: count
|
||||
});
|
||||
} catch (e) {
|
||||
logger.warn('EventList', '关注操作失败', {
|
||||
eventId,
|
||||
error: e.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 处理推送开关切换
|
||||
const handlePushToggle = async (e) => {
|
||||
const isChecked = e.target.checked;
|
||||
|
||||
if (isChecked) {
|
||||
// 用户想开启推送
|
||||
logger.info('EventList', '用户请求开启推送');
|
||||
const permission = await requestBrowserPermission();
|
||||
|
||||
if (permission === 'denied') {
|
||||
// 权限被拒绝,显示设置指引
|
||||
logger.warn('EventList', '用户拒绝了推送权限');
|
||||
toast({
|
||||
title: '推送权限被拒绝',
|
||||
description: '如需开启推送,请在浏览器设置中允许通知权限',
|
||||
status: 'warning',
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
position: 'top',
|
||||
});
|
||||
} else if (permission === 'granted') {
|
||||
logger.info('EventList', '推送权限已授予');
|
||||
}
|
||||
} else {
|
||||
// 用户想关闭推送 - 提示需在浏览器设置中操作
|
||||
logger.info('EventList', '用户尝试关闭推送');
|
||||
toast({
|
||||
title: '关闭推送通知',
|
||||
description: '如需关闭,请在浏览器设置中撤销通知权限',
|
||||
status: 'info',
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
position: 'top',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 专业的金融配色方案
|
||||
const bgColor = useColorModeValue('gray.50', 'gray.900');
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
const textColor = useColorModeValue('gray.700', 'gray.200');
|
||||
const mutedColor = useColorModeValue('gray.500', 'gray.400');
|
||||
const linkColor = useColorModeValue('blue.600', 'blue.400');
|
||||
const hoverBg = useColorModeValue('gray.50', 'gray.700');
|
||||
|
||||
|
||||
const handleTitleClick = (e, event) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onEventClick(event);
|
||||
};
|
||||
|
||||
const handleViewDetailClick = (e, eventId) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/event-detail/${eventId}`);
|
||||
};
|
||||
|
||||
// 时间轴样式配置(固定使用轻量卡片样式)
|
||||
const getTimelineBoxStyle = () => {
|
||||
return {
|
||||
bg: useColorModeValue('gray.50', 'gray.700'),
|
||||
borderColor: useColorModeValue('gray.400', 'gray.500'),
|
||||
borderWidth: '2px',
|
||||
textColor: useColorModeValue('blue.600', 'blue.400'),
|
||||
boxShadow: 'sm',
|
||||
};
|
||||
};
|
||||
|
||||
// 分页组件
|
||||
const Pagination = ({ current, total, pageSize, onChange }) => {
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
// 计算要显示的页码数组(智能分页)
|
||||
const getPageNumbers = () => {
|
||||
const delta = 2; // 当前页左右各显示2个页码
|
||||
const range = [];
|
||||
const rangeWithDots = [];
|
||||
|
||||
// 始终显示第1页
|
||||
range.push(1);
|
||||
|
||||
// 显示当前页附近的页码
|
||||
for (let i = current - delta; i <= current + delta; i++) {
|
||||
if (i > 1 && i < totalPages) {
|
||||
range.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
// 始终显示最后一页(如果总页数>1)
|
||||
if (totalPages > 1) {
|
||||
range.push(totalPages);
|
||||
}
|
||||
|
||||
// 去重并排序
|
||||
const uniqueRange = [...new Set(range)].sort((a, b) => a - b);
|
||||
|
||||
// 添加省略号
|
||||
let prev = 0;
|
||||
for (const page of uniqueRange) {
|
||||
if (page - prev === 2) {
|
||||
// 如果只差一个页码,直接显示
|
||||
rangeWithDots.push(prev + 1);
|
||||
} else if (page - prev > 2) {
|
||||
// 如果差距大于2,显示省略号
|
||||
rangeWithDots.push('...');
|
||||
}
|
||||
rangeWithDots.push(page);
|
||||
prev = page;
|
||||
}
|
||||
|
||||
return rangeWithDots;
|
||||
};
|
||||
|
||||
const pageNumbers = getPageNumbers();
|
||||
|
||||
return (
|
||||
<Flex justify="center" align="center" mt={8} gap={2}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onChange(current - 1)}
|
||||
isDisabled={current === 1}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
|
||||
<HStack spacing={1}>
|
||||
{pageNumbers.map((page, index) => {
|
||||
if (page === '...') {
|
||||
return (
|
||||
<Text key={`ellipsis-${index}`} px={2} color="gray.500">
|
||||
...
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
key={page}
|
||||
size="sm"
|
||||
variant={current === page ? 'solid' : 'ghost'}
|
||||
colorScheme={current === page ? 'blue' : 'gray'}
|
||||
onClick={() => onChange(page)}
|
||||
>
|
||||
{page}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</HStack>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onChange(current + 1)}
|
||||
isDisabled={current === totalPages}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
|
||||
<Text fontSize="sm" color={mutedColor} ml={4}>
|
||||
共 {total} 条
|
||||
</Text>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box bg={bgColor} minH="100vh" pb={8}>
|
||||
{/* 顶部控制栏:左空白 + 中间分页器 + 右侧控制(固定sticky) - 铺满全宽 */}
|
||||
<Box
|
||||
position="sticky"
|
||||
top={0}
|
||||
zIndex={10}
|
||||
bg={useColorModeValue('rgba(255, 255, 255, 0.9)', 'rgba(26, 32, 44, 0.9)')}
|
||||
backdropFilter="blur(10px)"
|
||||
boxShadow="sm"
|
||||
mb={4}
|
||||
py={2}
|
||||
w="100%"
|
||||
>
|
||||
<Container maxW="container.xl">
|
||||
<Flex justify="space-between" align="center">
|
||||
{/* 左侧占位 */}
|
||||
<Box key="left-spacer" flex="1" />
|
||||
|
||||
{/* 中间:分页器 */}
|
||||
{pagination.total > 0 && localEvents.length > 0 ? (
|
||||
<Flex key="pagination-controls" align="center" gap={2}>
|
||||
<Button
|
||||
key="prev-page"
|
||||
size="xs"
|
||||
variant="outline"
|
||||
onClick={() => onPageChange(pagination.current - 1)}
|
||||
isDisabled={pagination.current === 1}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<Text key="page-info" fontSize="xs" color={mutedColor} px={2} whiteSpace="nowrap">
|
||||
第 {pagination.current} / {Math.ceil(pagination.total / pagination.pageSize)} 页
|
||||
</Text>
|
||||
<Button
|
||||
key="next-page"
|
||||
size="xs"
|
||||
variant="outline"
|
||||
onClick={() => onPageChange(pagination.current + 1)}
|
||||
isDisabled={pagination.current === Math.ceil(pagination.total / pagination.pageSize)}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
<Text key="total-count" fontSize="xs" color={mutedColor} ml={2} whiteSpace="nowrap">
|
||||
共 {pagination.total} 条
|
||||
</Text>
|
||||
</Flex>
|
||||
) : (
|
||||
<Box key="center-spacer" flex="1" />
|
||||
)}
|
||||
|
||||
{/* 右侧:控制按钮 */}
|
||||
<Flex key="right-controls" align="center" gap={3} flex="1" justify="flex-end">
|
||||
{/* WebSocket 连接状态 */}
|
||||
<Badge
|
||||
key="websocket-status"
|
||||
colorScheme={isConnected ? 'green' : 'red'}
|
||||
fontSize="xs"
|
||||
px={2}
|
||||
py={1}
|
||||
borderRadius="full"
|
||||
>
|
||||
{isConnected ? '🟢 实时' : '🔴 离线'}
|
||||
</Badge>
|
||||
|
||||
{/* 桌面推送开关 */}
|
||||
<FormControl key="push-notification" display="flex" alignItems="center" w="auto">
|
||||
<FormLabel htmlFor="push-notification" mb="0" fontSize="xs" color={textColor} mr={2}>
|
||||
推送
|
||||
</FormLabel>
|
||||
<Tooltip
|
||||
label={
|
||||
browserPermission === 'granted'
|
||||
? '桌面推送已开启'
|
||||
: browserPermission === 'denied'
|
||||
? '推送权限被拒绝,请在浏览器设置中允许通知权限'
|
||||
: '点击开启桌面推送通知'
|
||||
}
|
||||
placement="top"
|
||||
>
|
||||
<Switch
|
||||
id="push-notification"
|
||||
size="sm"
|
||||
isChecked={browserPermission === 'granted'}
|
||||
onChange={handlePushToggle}
|
||||
colorScheme="green"
|
||||
/>
|
||||
</Tooltip>
|
||||
</FormControl>
|
||||
|
||||
{/* 视图切换控制 */}
|
||||
<FormControl key="compact-mode" display="flex" alignItems="center" w="auto">
|
||||
<FormLabel htmlFor="compact-mode" mb="0" fontSize="xs" color={textColor} mr={2}>
|
||||
精简
|
||||
</FormLabel>
|
||||
<Switch
|
||||
id="compact-mode"
|
||||
size="sm"
|
||||
isChecked={isCompactMode}
|
||||
onChange={(e) => setIsCompactMode(e.target.checked)}
|
||||
colorScheme="blue"
|
||||
/>
|
||||
</FormControl>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
{/* 事件列表内容 */}
|
||||
<Container maxW="container.xl">
|
||||
{localEvents.length > 0 ? (
|
||||
<VStack key="event-list" align="stretch" spacing={0}>
|
||||
{localEvents.map((event, index) => (
|
||||
<Box key={event.id} position="relative">
|
||||
<EventCard
|
||||
event={event}
|
||||
index={index}
|
||||
isCompactMode={isCompactMode}
|
||||
isFollowing={!!followingMap[event.id]}
|
||||
followerCount={followCountMap[event.id] ?? (event.follower_count || 0)}
|
||||
onEventClick={onEventClick}
|
||||
onTitleClick={handleTitleClick}
|
||||
onViewDetail={handleViewDetailClick}
|
||||
onToggleFollow={toggleFollow}
|
||||
timelineStyle={getTimelineBoxStyle()}
|
||||
borderColor={borderColor}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
) : (
|
||||
<Center key="empty-state" h="300px">
|
||||
<VStack spacing={4}>
|
||||
<InfoIcon key="empty-icon" boxSize={12} color={mutedColor} />
|
||||
<Text key="empty-text" color={mutedColor} fontSize="lg">
|
||||
暂无事件数据
|
||||
</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{pagination.total > 0 && (
|
||||
<Pagination
|
||||
key="bottom-pagination"
|
||||
current={pagination.current}
|
||||
total={pagination.total}
|
||||
pageSize={pagination.pageSize}
|
||||
onChange={onPageChange}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventList;
|
||||
@@ -1,818 +0,0 @@
|
||||
// src/views/Community/components/EventList.js
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Button,
|
||||
Badge,
|
||||
Tag,
|
||||
TagLabel,
|
||||
TagLeftIcon,
|
||||
Flex,
|
||||
Avatar,
|
||||
Tooltip,
|
||||
IconButton,
|
||||
Divider,
|
||||
Container,
|
||||
useColorModeValue,
|
||||
Circle,
|
||||
Stat,
|
||||
StatNumber,
|
||||
StatHelpText,
|
||||
StatArrow,
|
||||
ButtonGroup,
|
||||
Heading,
|
||||
SimpleGrid,
|
||||
Card,
|
||||
CardBody,
|
||||
Center,
|
||||
Link,
|
||||
Spacer,
|
||||
Switch,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
ViewIcon,
|
||||
ChatIcon,
|
||||
StarIcon,
|
||||
TimeIcon,
|
||||
InfoIcon,
|
||||
WarningIcon,
|
||||
WarningTwoIcon,
|
||||
CheckCircleIcon,
|
||||
TriangleUpIcon,
|
||||
TriangleDownIcon,
|
||||
ArrowForwardIcon,
|
||||
ExternalLinkIcon,
|
||||
ViewOffIcon,
|
||||
} from '@chakra-ui/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import moment from 'moment';
|
||||
import { logger } from '../../../utils/logger';
|
||||
|
||||
// ========== 工具函数定义在组件外部 ==========
|
||||
// 涨跌颜色配置(中国A股配色:红涨绿跌)- 分档次显示
|
||||
const getPriceChangeColor = (value) => {
|
||||
if (value === null || value === undefined) return 'gray.500';
|
||||
|
||||
const absValue = Math.abs(value);
|
||||
|
||||
if (value > 0) {
|
||||
// 上涨用红色,根据涨幅大小使用不同深浅
|
||||
if (absValue >= 3) return 'red.600'; // 深红色:3%以上
|
||||
if (absValue >= 1) return 'red.500'; // 中红色:1-3%
|
||||
return 'red.400'; // 浅红色:0-1%
|
||||
} else if (value < 0) {
|
||||
// 下跌用绿色,根据跌幅大小使用不同深浅
|
||||
if (absValue >= 3) return 'green.600'; // 深绿色:3%以上
|
||||
if (absValue >= 1) return 'green.500'; // 中绿色:1-3%
|
||||
return 'green.400'; // 浅绿色:0-1%
|
||||
}
|
||||
return 'gray.500';
|
||||
};
|
||||
|
||||
const getPriceChangeBg = (value) => {
|
||||
if (value === null || value === undefined) return 'gray.50';
|
||||
|
||||
const absValue = Math.abs(value);
|
||||
|
||||
if (value > 0) {
|
||||
// 上涨背景色
|
||||
if (absValue >= 3) return 'red.100'; // 深色背景:3%以上
|
||||
if (absValue >= 1) return 'red.50'; // 中色背景:1-3%
|
||||
return 'red.50'; // 浅色背景:0-1%
|
||||
} else if (value < 0) {
|
||||
// 下跌背景色
|
||||
if (absValue >= 3) return 'green.100'; // 深色背景:3%以上
|
||||
if (absValue >= 1) return 'green.50'; // 中色背景:1-3%
|
||||
return 'green.50'; // 浅色背景:0-1%
|
||||
}
|
||||
return 'gray.50';
|
||||
};
|
||||
|
||||
const getPriceChangeBorderColor = (value) => {
|
||||
if (value === null || value === undefined) return 'gray.300';
|
||||
|
||||
const absValue = Math.abs(value);
|
||||
|
||||
if (value > 0) {
|
||||
// 上涨边框色
|
||||
if (absValue >= 3) return 'red.500'; // 深边框:3%以上
|
||||
if (absValue >= 1) return 'red.400'; // 中边框:1-3%
|
||||
return 'red.300'; // 浅边框:0-1%
|
||||
} else if (value < 0) {
|
||||
// 下跌边框色
|
||||
if (absValue >= 3) return 'green.500'; // 深边框:3%以上
|
||||
if (absValue >= 1) return 'green.400'; // 中边框:1-3%
|
||||
return 'green.300'; // 浅边框:0-1%
|
||||
}
|
||||
return 'gray.300';
|
||||
};
|
||||
|
||||
// 重要性等级配置 - 金融配色方案
|
||||
const importanceLevels = {
|
||||
'S': {
|
||||
color: 'purple.600',
|
||||
bgColor: 'purple.50',
|
||||
borderColor: 'purple.200',
|
||||
icon: WarningIcon,
|
||||
label: '极高',
|
||||
dotBg: 'purple.500',
|
||||
},
|
||||
'A': {
|
||||
color: 'red.600',
|
||||
bgColor: 'red.50',
|
||||
borderColor: 'red.200',
|
||||
icon: WarningTwoIcon,
|
||||
label: '高',
|
||||
dotBg: 'red.500',
|
||||
},
|
||||
'B': {
|
||||
color: 'orange.600',
|
||||
bgColor: 'orange.50',
|
||||
borderColor: 'orange.200',
|
||||
icon: InfoIcon,
|
||||
label: '中',
|
||||
dotBg: 'orange.500',
|
||||
},
|
||||
'C': {
|
||||
color: 'green.600',
|
||||
bgColor: 'green.50',
|
||||
borderColor: 'green.200',
|
||||
icon: CheckCircleIcon,
|
||||
label: '低',
|
||||
dotBg: 'green.500',
|
||||
}
|
||||
};
|
||||
|
||||
const getImportanceConfig = (importance) => {
|
||||
return importanceLevels[importance] || importanceLevels['C'];
|
||||
};
|
||||
|
||||
// 自定义的涨跌箭头组件(修复颜色问题)
|
||||
const PriceArrow = ({ value }) => {
|
||||
if (value === null || value === undefined) return null;
|
||||
|
||||
const Icon = value > 0 ? TriangleUpIcon : TriangleDownIcon;
|
||||
const color = value > 0 ? 'red.500' : 'green.500';
|
||||
|
||||
return <Icon color={color} boxSize="16px" />;
|
||||
};
|
||||
|
||||
// ========== 主组件 ==========
|
||||
const EventList = ({ events, pagination, onPageChange, onEventClick, onViewDetail }) => {
|
||||
const navigate = useNavigate();
|
||||
const [isCompactMode, setIsCompactMode] = useState(false); // 新增:紧凑模式状态
|
||||
const [followingMap, setFollowingMap] = useState({});
|
||||
const [followCountMap, setFollowCountMap] = useState({});
|
||||
|
||||
// 初始化关注状态与计数
|
||||
useEffect(() => {
|
||||
// 初始化计数映射
|
||||
const initCounts = {};
|
||||
events.forEach(ev => {
|
||||
initCounts[ev.id] = ev.follower_count || 0;
|
||||
});
|
||||
setFollowCountMap(initCounts);
|
||||
|
||||
const loadFollowing = async () => {
|
||||
try {
|
||||
const base = (process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
|
||||
const res = await fetch(base + '/api/account/events/following', { credentials: 'include' });
|
||||
const data = await res.json();
|
||||
if (res.ok && data.success) {
|
||||
const map = {};
|
||||
(data.data || []).forEach(ev => { map[ev.id] = true; });
|
||||
setFollowingMap(map);
|
||||
logger.debug('EventList', '关注状态加载成功', {
|
||||
followingCount: Object.keys(map).length
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('EventList', '加载关注状态失败', { error: e.message });
|
||||
}
|
||||
};
|
||||
loadFollowing();
|
||||
// 仅在 events 更新时重跑
|
||||
}, [events]);
|
||||
|
||||
const toggleFollow = async (eventId) => {
|
||||
try {
|
||||
const base = (process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
|
||||
const res = await fetch(base + `/api/events/${eventId}/follow`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include'
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data.success) throw new Error(data.error || '操作失败');
|
||||
const isFollowing = data.data?.is_following;
|
||||
const count = data.data?.follower_count ?? 0;
|
||||
setFollowingMap(prev => ({ ...prev, [eventId]: isFollowing }));
|
||||
setFollowCountMap(prev => ({ ...prev, [eventId]: count }));
|
||||
logger.debug('EventList', '关注状态切换成功', {
|
||||
eventId,
|
||||
isFollowing,
|
||||
followerCount: count
|
||||
});
|
||||
} catch (e) {
|
||||
logger.warn('EventList', '关注操作失败', {
|
||||
eventId,
|
||||
error: e.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 专业的金融配色方案
|
||||
const bgColor = useColorModeValue('gray.50', 'gray.900');
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
const textColor = useColorModeValue('gray.700', 'gray.200');
|
||||
const mutedColor = useColorModeValue('gray.500', 'gray.400');
|
||||
const linkColor = useColorModeValue('blue.600', 'blue.400');
|
||||
const hoverBg = useColorModeValue('gray.50', 'gray.700');
|
||||
|
||||
const renderPriceChange = (value, label) => {
|
||||
if (value === null || value === undefined) {
|
||||
return (
|
||||
<Tag size="lg" colorScheme="gray" borderRadius="full" variant="subtle">
|
||||
<TagLabel fontSize="sm" fontWeight="medium">{label}: --</TagLabel>
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
const absValue = Math.abs(value);
|
||||
const isPositive = value > 0;
|
||||
|
||||
// 根据涨跌幅大小选择不同的颜色深浅
|
||||
let colorScheme = 'gray';
|
||||
let variant = 'solid';
|
||||
|
||||
if (isPositive) {
|
||||
// 上涨用红色系
|
||||
if (absValue >= 3) {
|
||||
colorScheme = 'red';
|
||||
variant = 'solid'; // 深色
|
||||
} else if (absValue >= 1) {
|
||||
colorScheme = 'red';
|
||||
variant = 'subtle'; // 中等
|
||||
} else {
|
||||
colorScheme = 'red';
|
||||
variant = 'outline'; // 浅色
|
||||
}
|
||||
} else {
|
||||
// 下跌用绿色系
|
||||
if (absValue >= 3) {
|
||||
colorScheme = 'green';
|
||||
variant = 'solid'; // 深色
|
||||
} else if (absValue >= 1) {
|
||||
colorScheme = 'green';
|
||||
variant = 'subtle'; // 中等
|
||||
} else {
|
||||
colorScheme = 'green';
|
||||
variant = 'outline'; // 浅色
|
||||
}
|
||||
}
|
||||
|
||||
const Icon = isPositive ? TriangleUpIcon : TriangleDownIcon;
|
||||
|
||||
return (
|
||||
<Tag
|
||||
size="lg"
|
||||
colorScheme={colorScheme}
|
||||
borderRadius="full"
|
||||
variant={variant}
|
||||
boxShadow="sm"
|
||||
_hover={{ transform: 'scale(1.05)', boxShadow: 'md' }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<TagLeftIcon as={Icon} boxSize="16px" />
|
||||
<TagLabel fontSize="sm" fontWeight="bold">
|
||||
{label}: {isPositive ? '+' : ''}{value.toFixed(2)}%
|
||||
</TagLabel>
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
|
||||
const handleTitleClick = (e, event) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onEventClick(event);
|
||||
};
|
||||
|
||||
const handleViewDetailClick = (e, eventId) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/event-detail/${eventId}`);
|
||||
};
|
||||
|
||||
// 精简模式的事件渲染
|
||||
const renderCompactEvent = (event) => {
|
||||
const importance = getImportanceConfig(event.importance);
|
||||
const isFollowing = !!followingMap[event.id];
|
||||
const followerCount = followCountMap[event.id] ?? (event.follower_count || 0);
|
||||
|
||||
return (
|
||||
<HStack align="stretch" spacing={4} w="full">
|
||||
{/* 时间线和重要性标记 */}
|
||||
<VStack spacing={0} align="center">
|
||||
<Circle
|
||||
size="32px"
|
||||
bg={importance.dotBg}
|
||||
color="white"
|
||||
fontWeight="bold"
|
||||
fontSize="sm"
|
||||
boxShadow="sm"
|
||||
border="2px solid"
|
||||
borderColor={cardBg}
|
||||
>
|
||||
{event.importance || 'C'}
|
||||
</Circle>
|
||||
<Box
|
||||
w="2px"
|
||||
flex="1"
|
||||
bg={borderColor}
|
||||
minH="60px"
|
||||
/>
|
||||
</VStack>
|
||||
|
||||
{/* 精简事件卡片 */}
|
||||
<Card
|
||||
flex="1"
|
||||
bg={cardBg}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
borderRadius="lg"
|
||||
boxShadow="sm"
|
||||
_hover={{
|
||||
boxShadow: 'md',
|
||||
transform: 'translateY(-1px)',
|
||||
borderColor: importance.color,
|
||||
}}
|
||||
transition="all 0.2s"
|
||||
cursor="pointer"
|
||||
onClick={() => onEventClick(event)}
|
||||
mb={3}
|
||||
>
|
||||
<CardBody p={4}>
|
||||
<Flex align="center" justify="space-between" wrap="wrap" gap={3}>
|
||||
{/* 左侧:标题和时间 */}
|
||||
<VStack align="start" spacing={2} flex="1" minW="200px">
|
||||
<Heading
|
||||
size="sm"
|
||||
color={linkColor}
|
||||
_hover={{ textDecoration: 'underline' }}
|
||||
onClick={(e) => handleTitleClick(e, event)}
|
||||
cursor="pointer"
|
||||
noOfLines={1}
|
||||
>
|
||||
{event.title}
|
||||
</Heading>
|
||||
<HStack spacing={2} fontSize="xs" color={mutedColor}>
|
||||
<TimeIcon />
|
||||
<Text>{moment(event.created_at).format('MM-DD HH:mm')}</Text>
|
||||
<Text>•</Text>
|
||||
<Text>{event.creator?.username || 'Anonymous'}</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
{/* 右侧:涨跌幅指标 */}
|
||||
<HStack spacing={3}>
|
||||
<Tooltip label="平均涨幅" placement="top">
|
||||
<Box
|
||||
px={3}
|
||||
py={1}
|
||||
borderRadius="md"
|
||||
bg={getPriceChangeBg(event.related_avg_chg)}
|
||||
borderWidth="1px"
|
||||
borderColor={getPriceChangeBorderColor(event.related_avg_chg)}
|
||||
>
|
||||
<HStack spacing={1}>
|
||||
<PriceArrow value={event.related_avg_chg} />
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="bold"
|
||||
color={getPriceChangeColor(event.related_avg_chg)}
|
||||
>
|
||||
{event.related_avg_chg != null
|
||||
? `${event.related_avg_chg > 0 ? '+' : ''}${event.related_avg_chg.toFixed(2)}%`
|
||||
: '--'}
|
||||
</Text>
|
||||
</HStack>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="blue"
|
||||
onClick={(e) => handleViewDetailClick(e, event.id)}
|
||||
>
|
||||
详情
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={isFollowing ? 'solid' : 'outline'}
|
||||
colorScheme="yellow"
|
||||
leftIcon={<StarIcon />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleFollow(event.id);
|
||||
}}
|
||||
>
|
||||
{isFollowing ? '已关注' : '关注'} {followerCount ? `(${followerCount})` : ''}
|
||||
</Button>
|
||||
</HStack>
|
||||
</Flex>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
// 详细模式的事件渲染(原有的渲染方式,但修复了箭头颜色)
|
||||
const renderDetailedEvent = (event) => {
|
||||
const importance = getImportanceConfig(event.importance);
|
||||
const isFollowing = !!followingMap[event.id];
|
||||
const followerCount = followCountMap[event.id] ?? (event.follower_count || 0);
|
||||
|
||||
return (
|
||||
<HStack align="stretch" spacing={4} w="full">
|
||||
{/* 时间线和重要性标记 */}
|
||||
<VStack spacing={0} align="center">
|
||||
<Circle
|
||||
size="40px"
|
||||
bg={importance.dotBg}
|
||||
color="white"
|
||||
fontWeight="bold"
|
||||
fontSize="lg"
|
||||
boxShadow="md"
|
||||
border="3px solid"
|
||||
borderColor={cardBg}
|
||||
>
|
||||
{event.importance || 'C'}
|
||||
</Circle>
|
||||
<Box
|
||||
w="2px"
|
||||
flex="1"
|
||||
bg={borderColor}
|
||||
minH="100px"
|
||||
/>
|
||||
</VStack>
|
||||
|
||||
{/* 事件卡片 */}
|
||||
<Card
|
||||
flex="1"
|
||||
bg={cardBg}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
borderRadius="lg"
|
||||
boxShadow="sm"
|
||||
_hover={{
|
||||
boxShadow: 'md',
|
||||
transform: 'translateY(-2px)',
|
||||
borderColor: importance.color,
|
||||
}}
|
||||
transition="all 0.2s"
|
||||
cursor="pointer"
|
||||
onClick={() => onEventClick(event)}
|
||||
mb={4}
|
||||
>
|
||||
<CardBody p={5}>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
{/* 标题和重要性标签 */}
|
||||
<Flex align="center" justify="space-between">
|
||||
<Tooltip
|
||||
label="点击查看事件详情"
|
||||
placement="top"
|
||||
hasArrow
|
||||
openDelay={500}
|
||||
>
|
||||
<Heading
|
||||
size="md"
|
||||
color={linkColor}
|
||||
_hover={{ textDecoration: 'underline', color: 'blue.500' }}
|
||||
onClick={(e) => handleTitleClick(e, event)}
|
||||
cursor="pointer"
|
||||
>
|
||||
{event.title}
|
||||
</Heading>
|
||||
</Tooltip>
|
||||
<Badge
|
||||
colorScheme={importance.color.split('.')[0]}
|
||||
px={3}
|
||||
py={1}
|
||||
borderRadius="full"
|
||||
fontSize="sm"
|
||||
>
|
||||
{importance.label}优先级
|
||||
</Badge>
|
||||
</Flex>
|
||||
|
||||
{/* 元信息 */}
|
||||
<HStack spacing={4} fontSize="sm">
|
||||
<HStack
|
||||
bg="blue.50"
|
||||
px={3}
|
||||
py={1}
|
||||
borderRadius="full"
|
||||
color="blue.700"
|
||||
fontWeight="medium"
|
||||
>
|
||||
<TimeIcon />
|
||||
<Text>{moment(event.created_at).format('YYYY-MM-DD HH:mm')}</Text>
|
||||
</HStack>
|
||||
<Text color={mutedColor}>•</Text>
|
||||
<Text color={mutedColor}>{event.creator?.username || 'Anonymous'}</Text>
|
||||
</HStack>
|
||||
|
||||
{/* 描述 */}
|
||||
<Text color={textColor} fontSize="sm" lineHeight="tall" noOfLines={3}>
|
||||
{event.description}
|
||||
</Text>
|
||||
|
||||
{/* 价格变化指标 */}
|
||||
<Box
|
||||
bg={useColorModeValue('gradient.subtle', 'gray.700')}
|
||||
bgGradient="linear(to-r, gray.50, white)"
|
||||
p={4}
|
||||
borderRadius="lg"
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
boxShadow="sm"
|
||||
>
|
||||
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={3}>
|
||||
<Tooltip label="点击查看相关股票" placement="top" hasArrow>
|
||||
<Box
|
||||
cursor="pointer"
|
||||
p={2}
|
||||
borderRadius="md"
|
||||
bg={getPriceChangeBg(event.related_avg_chg)}
|
||||
borderWidth="2px"
|
||||
borderColor={getPriceChangeBorderColor(event.related_avg_chg)}
|
||||
_hover={{ transform: 'scale(1.02)', boxShadow: 'md' }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<Stat size="sm">
|
||||
<StatHelpText mb={1} fontWeight="semibold" color="gray.600" fontSize="xs">
|
||||
平均涨幅
|
||||
</StatHelpText>
|
||||
<StatNumber fontSize="xl" color={getPriceChangeColor(event.related_avg_chg)}>
|
||||
{event.related_avg_chg != null ? (
|
||||
<HStack spacing={1}>
|
||||
<PriceArrow value={event.related_avg_chg} />
|
||||
<Text fontWeight="bold">
|
||||
{event.related_avg_chg > 0 ? '+' : ''}{event.related_avg_chg.toFixed(2)}%
|
||||
</Text>
|
||||
</HStack>
|
||||
) : (
|
||||
<Text color="gray.400">--</Text>
|
||||
)}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="点击查看相关股票" placement="top" hasArrow>
|
||||
<Box
|
||||
cursor="pointer"
|
||||
p={2}
|
||||
borderRadius="md"
|
||||
bg={getPriceChangeBg(event.related_max_chg)}
|
||||
borderWidth="2px"
|
||||
borderColor={getPriceChangeBorderColor(event.related_max_chg)}
|
||||
_hover={{ transform: 'scale(1.02)', boxShadow: 'md' }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<Stat size="sm">
|
||||
<StatHelpText mb={1} fontWeight="semibold" color="gray.600" fontSize="xs">
|
||||
最大涨幅
|
||||
</StatHelpText>
|
||||
<StatNumber fontSize="xl" color={getPriceChangeColor(event.related_max_chg)}>
|
||||
{event.related_max_chg != null ? (
|
||||
<HStack spacing={1}>
|
||||
<PriceArrow value={event.related_max_chg} />
|
||||
<Text fontWeight="bold">
|
||||
{event.related_max_chg > 0 ? '+' : ''}{event.related_max_chg.toFixed(2)}%
|
||||
</Text>
|
||||
</HStack>
|
||||
) : (
|
||||
<Text color="gray.400">--</Text>
|
||||
)}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="点击查看相关股票" placement="top" hasArrow>
|
||||
<Box
|
||||
cursor="pointer"
|
||||
p={2}
|
||||
borderRadius="md"
|
||||
bg={getPriceChangeBg(event.related_week_chg)}
|
||||
borderWidth="2px"
|
||||
borderColor={getPriceChangeBorderColor(event.related_week_chg)}
|
||||
_hover={{ transform: 'scale(1.02)', boxShadow: 'md' }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<Stat size="sm">
|
||||
<StatHelpText mb={1} fontWeight="semibold" color="gray.600" fontSize="xs">
|
||||
周涨幅
|
||||
</StatHelpText>
|
||||
<StatNumber fontSize="xl" color={getPriceChangeColor(event.related_week_chg)}>
|
||||
{event.related_week_chg != null ? (
|
||||
<HStack spacing={1}>
|
||||
<PriceArrow value={event.related_week_chg} />
|
||||
<Text fontWeight="bold">
|
||||
{event.related_week_chg > 0 ? '+' : ''}{event.related_week_chg.toFixed(2)}%
|
||||
</Text>
|
||||
</HStack>
|
||||
) : (
|
||||
<Text color="gray.400">--</Text>
|
||||
)}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 统计信息和操作按钮 */}
|
||||
<Flex justify="space-between" align="center" wrap="wrap" gap={3}>
|
||||
<HStack spacing={6}>
|
||||
<Tooltip label="浏览量" placement="top">
|
||||
<HStack spacing={1} color={mutedColor}>
|
||||
<ViewIcon />
|
||||
<Text fontSize="sm">{event.view_count || 0}</Text>
|
||||
</HStack>
|
||||
</Tooltip>
|
||||
<Tooltip label="帖子数" placement="top">
|
||||
<HStack spacing={1} color={mutedColor}>
|
||||
<ChatIcon />
|
||||
<Text fontSize="sm">{event.post_count || 0}</Text>
|
||||
</HStack>
|
||||
</Tooltip>
|
||||
<Tooltip label="关注数" placement="top">
|
||||
<HStack spacing={1} color={mutedColor}>
|
||||
<StarIcon />
|
||||
<Text fontSize="sm">{followerCount}</Text>
|
||||
</HStack>
|
||||
</Tooltip>
|
||||
</HStack>
|
||||
|
||||
<ButtonGroup size="sm" spacing={2}>
|
||||
<Button
|
||||
variant="outline"
|
||||
colorScheme="gray"
|
||||
leftIcon={<ViewIcon />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEventClick(event);
|
||||
}}
|
||||
>
|
||||
快速查看
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
leftIcon={<ExternalLinkIcon />}
|
||||
onClick={(e) => handleViewDetailClick(e, event.id)}
|
||||
>
|
||||
详细信息
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme="yellow"
|
||||
variant={isFollowing ? 'solid' : 'outline'}
|
||||
leftIcon={<StarIcon />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleFollow(event.id);
|
||||
}}
|
||||
>
|
||||
{isFollowing ? '已关注' : '关注'}
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</Flex>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
// 分页组件
|
||||
const Pagination = ({ current, total, pageSize, onChange }) => {
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
return (
|
||||
<Flex justify="center" align="center" mt={8} gap={2}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onChange(current - 1)}
|
||||
isDisabled={current === 1}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
|
||||
<HStack spacing={1}>
|
||||
{[...Array(Math.min(5, totalPages))].map((_, i) => {
|
||||
const pageNum = i + 1;
|
||||
return (
|
||||
<Button
|
||||
key={pageNum}
|
||||
size="sm"
|
||||
variant={current === pageNum ? 'solid' : 'ghost'}
|
||||
colorScheme={current === pageNum ? 'blue' : 'gray'}
|
||||
onClick={() => onChange(pageNum)}
|
||||
>
|
||||
{pageNum}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
{totalPages > 5 && <Text>...</Text>}
|
||||
{totalPages > 5 && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={current === totalPages ? 'solid' : 'ghost'}
|
||||
colorScheme={current === totalPages ? 'blue' : 'gray'}
|
||||
onClick={() => onChange(totalPages)}
|
||||
>
|
||||
{totalPages}
|
||||
</Button>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onChange(current + 1)}
|
||||
isDisabled={current === totalPages}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
|
||||
<Text fontSize="sm" color={mutedColor} ml={4}>
|
||||
共 {total} 条
|
||||
</Text>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box bg={bgColor} minH="100vh" py={8}>
|
||||
<Container maxW="container.xl">
|
||||
{/* 视图切换控制 */}
|
||||
<Flex justify="flex-end" mb={6}>
|
||||
<FormControl display="flex" alignItems="center" w="auto">
|
||||
<FormLabel htmlFor="compact-mode" mb="0" fontSize="sm" color={textColor}>
|
||||
精简模式
|
||||
</FormLabel>
|
||||
<Switch
|
||||
id="compact-mode"
|
||||
isChecked={isCompactMode}
|
||||
onChange={(e) => setIsCompactMode(e.target.checked)}
|
||||
colorScheme="blue"
|
||||
/>
|
||||
</FormControl>
|
||||
</Flex>
|
||||
|
||||
{events.length > 0 ? (
|
||||
<VStack align="stretch" spacing={0}>
|
||||
{events.map((event, index) => (
|
||||
<Box key={event.id} position="relative">
|
||||
{isCompactMode
|
||||
? renderCompactEvent(event)
|
||||
: renderDetailedEvent(event)
|
||||
}
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
) : (
|
||||
<Center h="300px">
|
||||
<VStack spacing={4}>
|
||||
<InfoIcon boxSize={12} color={mutedColor} />
|
||||
<Text color={mutedColor} fontSize="lg">
|
||||
暂无事件数据
|
||||
</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{pagination.total > 0 && (
|
||||
<Pagination
|
||||
current={pagination.current}
|
||||
total={pagination.total}
|
||||
pageSize={pagination.pageSize}
|
||||
onChange={onPageChange}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventList;
|
||||
@@ -1,75 +0,0 @@
|
||||
// src/views/Community/components/EventListSection.js
|
||||
// 事件列表区域组件(包含Loading、Empty、List三种状态)
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Center,
|
||||
VStack,
|
||||
Spinner,
|
||||
Text
|
||||
} from '@chakra-ui/react';
|
||||
import EventList from './EventList';
|
||||
|
||||
/**
|
||||
* 事件列表区域组件
|
||||
* @param {boolean} loading - 加载状态
|
||||
* @param {Array} events - 事件列表
|
||||
* @param {Object} pagination - 分页信息
|
||||
* @param {Function} onPageChange - 分页变化回调
|
||||
* @param {Function} onEventClick - 事件点击回调
|
||||
* @param {Function} onViewDetail - 查看详情回调
|
||||
*/
|
||||
const EventListSection = ({
|
||||
loading,
|
||||
events,
|
||||
pagination,
|
||||
onPageChange,
|
||||
onEventClick,
|
||||
onViewDetail
|
||||
}) => {
|
||||
// ✅ 最小高度,避免加载后高度突变
|
||||
const minHeight = '600px';
|
||||
|
||||
// Loading 状态
|
||||
if (loading) {
|
||||
return (
|
||||
<Box minH={minHeight}>
|
||||
<Center py={10}>
|
||||
<VStack>
|
||||
<Spinner size="xl" color="blue.500" thickness="4px" />
|
||||
<Text color="gray.500">正在加载最新事件...</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Empty 状态
|
||||
if (!events || events.length === 0) {
|
||||
return (
|
||||
<Box minH={minHeight}>
|
||||
<Center py={10}>
|
||||
<VStack>
|
||||
<Text fontSize="lg" color="gray.500">暂无事件数据</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// List 状态
|
||||
return (
|
||||
<Box minH={minHeight}>
|
||||
<EventList
|
||||
events={events}
|
||||
pagination={pagination}
|
||||
onPageChange={onPageChange}
|
||||
onEventClick={onEventClick}
|
||||
onViewDetail={onViewDetail}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventListSection;
|
||||
@@ -1,42 +0,0 @@
|
||||
// src/views/Community/components/EventTimelineHeader.js
|
||||
// 事件时间轴标题组件
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Flex,
|
||||
VStack,
|
||||
HStack,
|
||||
Heading,
|
||||
Text,
|
||||
Badge
|
||||
} from '@chakra-ui/react';
|
||||
import { TimeIcon } from '@chakra-ui/icons';
|
||||
|
||||
/**
|
||||
* 事件时间轴标题组件
|
||||
* @param {Date} lastUpdateTime - 最后更新时间
|
||||
*/
|
||||
const EventTimelineHeader = ({ lastUpdateTime }) => {
|
||||
return (
|
||||
<Flex justify="space-between" align="center">
|
||||
<VStack align="start" spacing={1}>
|
||||
<Heading size="md">
|
||||
<HStack>
|
||||
<TimeIcon />
|
||||
<Text>实时事件</Text>
|
||||
</HStack>
|
||||
</Heading>
|
||||
<HStack fontSize="sm" color="gray.500">
|
||||
<Badge colorScheme="green">全网监控</Badge>
|
||||
<Badge colorScheme="orange">智能捕获</Badge>
|
||||
<Badge colorScheme="purple">深度分析</Badge>
|
||||
</HStack>
|
||||
</VStack>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
最后更新: {lastUpdateTime.toLocaleTimeString()}
|
||||
</Text>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventTimelineHeader;
|
||||
@@ -1,34 +0,0 @@
|
||||
// src/views/Community/components/ImportanceLegend.js
|
||||
import React from 'react';
|
||||
import { Card, Space, Badge } from 'antd';
|
||||
|
||||
const ImportanceLegend = () => {
|
||||
const levels = [
|
||||
{ level: 'S', color: '#ff4d4f', description: '重大事件,市场影响深远' },
|
||||
{ level: 'A', color: '#faad14', description: '重要事件,影响较大' },
|
||||
{ level: 'B', color: '#1890ff', description: '普通事件,有一定影响' },
|
||||
{ level: 'C', color: '#52c41a', description: '参考事件,影响有限' }
|
||||
];
|
||||
|
||||
return (
|
||||
<Card title="重要性等级说明" className="importance-legend">
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{levels.map(item => (
|
||||
<div key={item.level} style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Badge
|
||||
color={item.color}
|
||||
text={
|
||||
<span>
|
||||
<strong style={{ marginRight: 8 }}>{item.level}级</strong>
|
||||
{item.description}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImportanceLegend;
|
||||
@@ -1,78 +0,0 @@
|
||||
// src/views/Community/components/IndustryCascader.js
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Card, Form, Cascader } from 'antd';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { fetchIndustryData, selectIndustryData, selectIndustryLoading } from '../../../store/slices/industrySlice';
|
||||
import { logger } from '../../../utils/logger';
|
||||
|
||||
const IndustryCascader = ({ onFilterChange, loading }) => {
|
||||
const [industryCascaderValue, setIndustryCascaderValue] = useState([]);
|
||||
|
||||
// 使用 Redux 获取行业数据
|
||||
const dispatch = useDispatch();
|
||||
const industryData = useSelector(selectIndustryData);
|
||||
const industryLoading = useSelector(selectIndustryLoading);
|
||||
|
||||
// Cascader 获得焦点时加载数据
|
||||
const handleCascaderFocus = useCallback(async () => {
|
||||
if (!industryData || industryData.length === 0) {
|
||||
logger.debug('IndustryCascader', 'Cascader 获得焦点,开始加载行业数据');
|
||||
await dispatch(fetchIndustryData());
|
||||
}
|
||||
}, [dispatch, industryData]);
|
||||
|
||||
// Cascader 选择变化
|
||||
const handleIndustryCascaderChange = (value, selectedOptions) => {
|
||||
setIndustryCascaderValue(value);
|
||||
|
||||
if (value && value.length > 0) {
|
||||
// value[0] = 分类体系名称
|
||||
// value[1...n] = 行业代码(一级~四级)
|
||||
const industryCode = value[value.length - 1]; // 最后一级的 code
|
||||
const classification = value[0]; // 分类体系名称
|
||||
|
||||
onFilterChange('industry_classification', classification);
|
||||
onFilterChange('industry_code', industryCode);
|
||||
|
||||
logger.debug('IndustryCascader', 'Cascader 选择变化', {
|
||||
value,
|
||||
classification,
|
||||
industryCode,
|
||||
path: selectedOptions.map(o => o.label).join(' > ')
|
||||
});
|
||||
} else {
|
||||
// 清空
|
||||
onFilterChange('industry_classification', '');
|
||||
onFilterChange('industry_code', '');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="industry-cascader" title="行业分类" style={{ marginBottom: 16 }}>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="选择行业分类体系和具体行业">
|
||||
<Cascader
|
||||
options={industryData || []}
|
||||
value={industryCascaderValue}
|
||||
onChange={handleIndustryCascaderChange}
|
||||
onFocus={handleCascaderFocus}
|
||||
changeOnSelect
|
||||
placeholder={industryLoading ? "加载中..." : "请选择行业分类体系和具体行业"}
|
||||
disabled={loading || industryLoading}
|
||||
loading={industryLoading}
|
||||
allowClear
|
||||
expandTrigger="hover"
|
||||
displayRender={(labels) => labels.join(' > ')}
|
||||
showSearch={{
|
||||
filter: (inputValue, path) =>
|
||||
path.some(option => option.label.toLowerCase().includes(inputValue.toLowerCase()))
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default IndustryCascader;
|
||||
@@ -1,300 +0,0 @@
|
||||
// src/views/Community/components/MarketReviewCard.js
|
||||
// 市场复盘组件(左右布局:事件列表 | 事件详情)
|
||||
|
||||
import React, { forwardRef, useState } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardBody,
|
||||
Box,
|
||||
Flex,
|
||||
VStack,
|
||||
HStack,
|
||||
Heading,
|
||||
Text,
|
||||
Badge,
|
||||
Center,
|
||||
Spinner,
|
||||
useColorModeValue,
|
||||
Grid,
|
||||
GridItem,
|
||||
} from '@chakra-ui/react';
|
||||
import { TimeIcon, InfoIcon } from '@chakra-ui/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import CompactEventCard from './EventCard/CompactEventCard';
|
||||
import EventHeader from './EventCard/EventHeader';
|
||||
import EventStats from './EventCard/EventStats';
|
||||
import EventFollowButton from './EventCard/EventFollowButton';
|
||||
import EventPriceDisplay from './EventCard/EventPriceDisplay';
|
||||
import EventDescription from './EventCard/EventDescription';
|
||||
import { getImportanceConfig } from '../../../constants/importanceLevels';
|
||||
|
||||
/**
|
||||
* 市场复盘 - 左右布局卡片组件
|
||||
* @param {Array} events - 事件列表
|
||||
* @param {boolean} loading - 加载状态
|
||||
* @param {Date} lastUpdateTime - 最后更新时间
|
||||
* @param {Function} onEventClick - 事件点击回调
|
||||
* @param {Function} onViewDetail - 查看详情回调
|
||||
* @param {Function} onToggleFollow - 切换关注回调
|
||||
* @param {Object} ref - 用于滚动的ref
|
||||
*/
|
||||
const MarketReviewCard = forwardRef(({
|
||||
events,
|
||||
loading,
|
||||
lastUpdateTime,
|
||||
onEventClick,
|
||||
onViewDetail,
|
||||
onToggleFollow,
|
||||
...rest
|
||||
}, ref) => {
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
const linkColor = useColorModeValue('blue.600', 'blue.400');
|
||||
const mutedColor = useColorModeValue('gray.500', 'gray.400');
|
||||
const textColor = useColorModeValue('gray.700', 'gray.200');
|
||||
const selectedBg = useColorModeValue('blue.50', 'blue.900');
|
||||
|
||||
// 选中的事件
|
||||
const [selectedEvent, setSelectedEvent] = useState(null);
|
||||
|
||||
// 时间轴样式配置
|
||||
const getTimelineBoxStyle = () => {
|
||||
return {
|
||||
bg: useColorModeValue('gray.50', 'gray.700'),
|
||||
borderColor: useColorModeValue('gray.400', 'gray.500'),
|
||||
borderWidth: '2px',
|
||||
textColor: useColorModeValue('blue.600', 'blue.400'),
|
||||
boxShadow: 'sm',
|
||||
};
|
||||
};
|
||||
|
||||
// 处理事件点击
|
||||
const handleEventClick = (event) => {
|
||||
setSelectedEvent(event);
|
||||
if (onEventClick) {
|
||||
onEventClick(event);
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染右侧事件详情
|
||||
const renderEventDetail = () => {
|
||||
if (!selectedEvent) {
|
||||
return (
|
||||
<Center h="full" minH="400px">
|
||||
<VStack spacing={4}>
|
||||
<InfoIcon boxSize={12} color={mutedColor} />
|
||||
<Text color={mutedColor} fontSize="lg">
|
||||
请从左侧选择事件查看详情
|
||||
</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
const importance = getImportanceConfig(selectedEvent.importance);
|
||||
|
||||
return (
|
||||
<Card
|
||||
bg={cardBg}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
borderRadius="md"
|
||||
boxShadow="md"
|
||||
h="full"
|
||||
>
|
||||
<CardBody p={6}>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
{/* 第一行:标题+优先级 | 统计+关注 */}
|
||||
<Flex align="center" justify="space-between" gap={3}>
|
||||
{/* 左侧:标题 + 优先级标签 */}
|
||||
<EventHeader
|
||||
title={selectedEvent.title}
|
||||
importance={selectedEvent.importance}
|
||||
onTitleClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (onViewDetail) {
|
||||
onViewDetail(e, selectedEvent.id);
|
||||
}
|
||||
}}
|
||||
linkColor={linkColor}
|
||||
compact={false}
|
||||
size="lg"
|
||||
/>
|
||||
|
||||
{/* 右侧:统计数据 + 关注按钮 */}
|
||||
<HStack spacing={4} flexShrink={0}>
|
||||
{/* 统计数据 */}
|
||||
<EventStats
|
||||
viewCount={selectedEvent.view_count}
|
||||
postCount={selectedEvent.post_count}
|
||||
followerCount={selectedEvent.follower_count}
|
||||
size="md"
|
||||
spacing={4}
|
||||
display="flex"
|
||||
mutedColor={mutedColor}
|
||||
/>
|
||||
|
||||
{/* 关注按钮 */}
|
||||
<EventFollowButton
|
||||
isFollowing={false}
|
||||
followerCount={selectedEvent.follower_count}
|
||||
onToggle={() => onToggleFollow && onToggleFollow(selectedEvent.id)}
|
||||
size="sm"
|
||||
showCount={false}
|
||||
/>
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{/* 第二行:价格标签 | 时间+作者 */}
|
||||
<Flex justify="space-between" align="center" wrap="wrap" gap={3}>
|
||||
{/* 左侧:价格标签 */}
|
||||
<EventPriceDisplay
|
||||
avgChange={selectedEvent.related_avg_chg}
|
||||
maxChange={selectedEvent.related_max_chg}
|
||||
weekChange={selectedEvent.related_week_chg}
|
||||
compact={false}
|
||||
/>
|
||||
|
||||
{/* 右侧:时间 + 作者 */}
|
||||
<HStack spacing={2} fontSize="sm" flexShrink={0}>
|
||||
<Text fontWeight="bold" color={linkColor}>
|
||||
{dayjs(selectedEvent.created_at).format('YYYY-MM-DD HH:mm')}
|
||||
</Text>
|
||||
<Text color={mutedColor}>•</Text>
|
||||
<Text color={mutedColor}>@{selectedEvent.creator?.username || 'Anonymous'}</Text>
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{/* 第三行:描述文字 */}
|
||||
<EventDescription
|
||||
description={selectedEvent.description}
|
||||
textColor={textColor}
|
||||
minLength={200}
|
||||
noOfLines={10}
|
||||
/>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card ref={ref} {...rest} bg={cardBg} borderColor={borderColor} mb={4}>
|
||||
{/* 标题部分 */}
|
||||
<CardHeader>
|
||||
<Flex justify="space-between" align="center">
|
||||
<VStack align="start" spacing={1}>
|
||||
<Heading size="md">
|
||||
<HStack>
|
||||
<TimeIcon />
|
||||
<Text>市场复盘</Text>
|
||||
</HStack>
|
||||
</Heading>
|
||||
<HStack fontSize="sm" color="gray.500">
|
||||
<Badge colorScheme="orange">复盘</Badge>
|
||||
<Badge colorScheme="purple">总结</Badge>
|
||||
<Badge colorScheme="gray">完整</Badge>
|
||||
</HStack>
|
||||
</VStack>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
最后更新: {lastUpdateTime?.toLocaleTimeString() || '未知'}
|
||||
</Text>
|
||||
</Flex>
|
||||
</CardHeader>
|
||||
|
||||
{/* 主体内容 */}
|
||||
<CardBody>
|
||||
{/* Loading 状态 */}
|
||||
{loading && (
|
||||
<Center py={10}>
|
||||
<VStack>
|
||||
<Spinner size="xl" color="blue.500" thickness="4px" />
|
||||
<Text color="gray.500">正在加载复盘数据...</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{/* Empty 状态 */}
|
||||
{!loading && (!events || events.length === 0) && (
|
||||
<Center py={10}>
|
||||
<VStack>
|
||||
<Text fontSize="lg" color="gray.500">暂无复盘数据</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{/* 左右布局:事件列表 | 事件详情 */}
|
||||
{!loading && events && events.length > 0 && (
|
||||
<Grid templateColumns="1fr 2fr" gap={6} minH="500px">
|
||||
{/* 左侧:事件列表 (33.3%) */}
|
||||
<GridItem>
|
||||
<Box
|
||||
overflowY="auto"
|
||||
maxH="600px"
|
||||
pr={2}
|
||||
css={{
|
||||
'&::-webkit-scrollbar': {
|
||||
width: '6px',
|
||||
},
|
||||
'&::-webkit-scrollbar-track': {
|
||||
background: useColorModeValue('#f1f1f1', '#2D3748'),
|
||||
borderRadius: '10px',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
background: useColorModeValue('#888', '#4A5568'),
|
||||
borderRadius: '10px',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb:hover': {
|
||||
background: useColorModeValue('#555', '#718096'),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<VStack align="stretch" spacing={2}>
|
||||
{events.map((event, index) => (
|
||||
<Box
|
||||
key={event.id}
|
||||
onClick={() => handleEventClick(event)}
|
||||
cursor="pointer"
|
||||
bg={selectedEvent?.id === event.id ? selectedBg : 'transparent'}
|
||||
borderRadius="md"
|
||||
transition="all 0.2s"
|
||||
_hover={{ bg: selectedBg }}
|
||||
>
|
||||
<CompactEventCard
|
||||
event={event}
|
||||
index={index}
|
||||
isFollowing={false}
|
||||
followerCount={event.follower_count || 0}
|
||||
onEventClick={() => handleEventClick(event)}
|
||||
onTitleClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleEventClick(event);
|
||||
}}
|
||||
onViewDetail={onViewDetail}
|
||||
onToggleFollow={() => {}}
|
||||
timelineStyle={getTimelineBoxStyle()}
|
||||
borderColor={borderColor}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
</Box>
|
||||
</GridItem>
|
||||
|
||||
{/* 右侧:事件详情 (66.7%) */}
|
||||
<GridItem>
|
||||
{renderEventDetail()}
|
||||
</GridItem>
|
||||
</Grid>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
|
||||
MarketReviewCard.displayName = 'MarketReviewCard';
|
||||
|
||||
export default MarketReviewCard;
|
||||
@@ -1,898 +0,0 @@
|
||||
// src/views/Community/components/UnifiedSearchBox.js
|
||||
// 搜索组件:三行布局(主搜索 + 热门概念 + 筛选区)
|
||||
import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Card, Input, Cascader, Button, Space, Tag, AutoComplete, Select as AntSelect
|
||||
} from 'antd';
|
||||
import {
|
||||
SearchOutlined, CloseCircleOutlined, StockOutlined
|
||||
} from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { fetchIndustryData, selectIndustryData, selectIndustryLoading } from '../../../store/slices/industrySlice';
|
||||
import { stockService } from '../../../services/stockService';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import PopularKeywords from './PopularKeywords';
|
||||
import TradingTimeFilter from './TradingTimeFilter';
|
||||
|
||||
const { Option } = AntSelect;
|
||||
|
||||
const UnifiedSearchBox = ({
|
||||
onSearch,
|
||||
onSearchFocus,
|
||||
popularKeywords = [],
|
||||
filters = {},
|
||||
mode, // 显示模式(如:vertical, horizontal 等)
|
||||
pageSize, // 每页显示数量
|
||||
trackingFunctions = {} // PostHog 追踪函数集合
|
||||
}) => {
|
||||
|
||||
// 其他状态
|
||||
const [stockOptions, setStockOptions] = useState([]); // 股票下拉选项列表
|
||||
const [allStocks, setAllStocks] = useState([]); // 所有股票数据
|
||||
const [industryValue, setIndustryValue] = useState([]);
|
||||
|
||||
// 筛选条件状态
|
||||
const [sort, setSort] = useState('new'); // 排序方式
|
||||
const [importance, setImportance] = useState([]); // 重要性(数组,支持多选)
|
||||
const [tradingTimeRange, setTradingTimeRange] = useState(null); // 交易时段筛选
|
||||
|
||||
// ✅ 本地输入状态 - 管理用户的实时输入
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
|
||||
// 使用 Redux 获取行业数据
|
||||
const dispatch = useDispatch();
|
||||
const industryData = useSelector(selectIndustryData);
|
||||
const industryLoading = useSelector(selectIndustryLoading);
|
||||
|
||||
// 加载行业数据函数
|
||||
const loadIndustryData = useCallback(() => {
|
||||
if (!industryData) {
|
||||
dispatch(fetchIndustryData());
|
||||
}
|
||||
}, [dispatch, industryData]);
|
||||
|
||||
// 搜索触发函数
|
||||
const triggerSearch = useCallback((params) => {
|
||||
logger.debug('UnifiedSearchBox', '【5/5】✅ 最终触发搜索 - 调用onSearch回调', {
|
||||
params: params,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
onSearch(params);
|
||||
}, [onSearch]);
|
||||
|
||||
// ✅ 创建防抖的搜索函数(300ms 延迟)
|
||||
const debouncedSearchRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
// 创建防抖函数,使用 triggerSearch 而不是直接调用 onSearch
|
||||
debouncedSearchRef.current = debounce((params) => {
|
||||
logger.debug('UnifiedSearchBox', '⏱️ 防抖延迟结束,执行搜索', {
|
||||
params: params,
|
||||
delayMs: 300
|
||||
});
|
||||
triggerSearch(params);
|
||||
}, 300);
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
if (debouncedSearchRef.current) {
|
||||
debouncedSearchRef.current.cancel();
|
||||
}
|
||||
};
|
||||
}, [triggerSearch]);
|
||||
|
||||
// 加载所有股票数据
|
||||
useEffect(() => {
|
||||
const loadStocks = async () => {
|
||||
const response = await stockService.getAllStocks();
|
||||
if (response.success && response.data) {
|
||||
setAllStocks(response.data);
|
||||
logger.debug('UnifiedSearchBox', '股票数据加载成功', {
|
||||
count: response.data.length
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
loadStocks();
|
||||
}, []);
|
||||
|
||||
// Cascader 获得焦点时加载数据
|
||||
const handleCascaderFocus = async () => {
|
||||
if (!industryData || industryData.length === 0) {
|
||||
logger.debug('UnifiedSearchBox', 'Cascader 获得焦点,开始加载行业数据');
|
||||
await loadIndustryData();
|
||||
}
|
||||
};
|
||||
|
||||
// 从 props.filters 初始化所有内部状态 (只在组件首次挂载时执行)
|
||||
// 辅助函数:递归查找行业代码的完整路径
|
||||
const findIndustryPath = React.useCallback((targetCode, data, currentPath = []) => {
|
||||
if (!data || data.length === 0) return null;
|
||||
|
||||
for (const item of data) {
|
||||
const newPath = [...currentPath, item.value];
|
||||
|
||||
if (item.value === targetCode) {
|
||||
return newPath;
|
||||
}
|
||||
|
||||
if (item.children && item.children.length > 0) {
|
||||
const found = findIndustryPath(targetCode, item.children, newPath);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
// ✅ 从 props.filters 初始化筛选条件和输入框值
|
||||
useEffect(() => {
|
||||
if (!filters) return;
|
||||
|
||||
// 初始化排序
|
||||
if (filters.sort) setSort(filters.sort);
|
||||
|
||||
// 初始化重要性(字符串解析为数组)
|
||||
if (filters.importance) {
|
||||
const importanceArray = filters.importance === 'all'
|
||||
? [] // 'all' 对应空数组(不显示任何选中)
|
||||
: filters.importance.split(',').map(v => v.trim()).filter(Boolean);
|
||||
setImportance(importanceArray);
|
||||
logger.debug('UnifiedSearchBox', '初始化重要性', {
|
||||
filters_importance: filters.importance,
|
||||
importanceArray
|
||||
});
|
||||
} else {
|
||||
setImportance([]);
|
||||
}
|
||||
|
||||
// ✅ 初始化行业分类(需要 industryData 加载完成)
|
||||
// ⚠️ 只在 industryValue 为空时才从 filters 初始化,避免用户选择后被覆盖
|
||||
if (filters.industry_code && industryData && industryData.length > 0 && (!industryValue || industryValue.length === 0)) {
|
||||
const path = findIndustryPath(filters.industry_code, industryData);
|
||||
if (path) {
|
||||
setIndustryValue(path);
|
||||
logger.debug('UnifiedSearchBox', '初始化行业分类', {
|
||||
industry_code: filters.industry_code,
|
||||
path
|
||||
});
|
||||
}
|
||||
} else if (!filters.industry_code && industryValue && industryValue.length > 0) {
|
||||
// 如果 filters 中没有行业代码,但本地有值,清空本地值
|
||||
setIndustryValue([]);
|
||||
logger.debug('UnifiedSearchBox', '清空行业分类(filters中无值)');
|
||||
}
|
||||
|
||||
// ✅ 同步 filters.q 到输入框显示值
|
||||
if (filters.q) {
|
||||
setInputValue(filters.q);
|
||||
} else if (!filters.q) {
|
||||
// 如果 filters 中没有搜索关键词,清空输入框
|
||||
setInputValue('');
|
||||
}
|
||||
|
||||
// ✅ 初始化时间筛选(从 filters 中恢复)
|
||||
// ⚠️ 只在 tradingTimeRange 为空时才从 filters 初始化,避免用户选择后被覆盖
|
||||
const hasTimeInFilters = filters.start_date || filters.end_date || filters.recent_days;
|
||||
|
||||
if (hasTimeInFilters && (!tradingTimeRange || !tradingTimeRange.key)) {
|
||||
// 根据参数推断按钮 key
|
||||
let inferredKey = 'custom';
|
||||
let inferredLabel = '';
|
||||
|
||||
if (filters.recent_days) {
|
||||
// 推断是否是预设按钮
|
||||
if (filters.recent_days === '7') {
|
||||
inferredKey = 'week';
|
||||
inferredLabel = '近一周';
|
||||
} else if (filters.recent_days === '30') {
|
||||
inferredKey = 'month';
|
||||
inferredLabel = '近一月';
|
||||
} else {
|
||||
inferredLabel = `近${filters.recent_days}天`;
|
||||
}
|
||||
} else if (filters.start_date && filters.end_date) {
|
||||
inferredLabel = `${dayjs(filters.start_date).format('MM-DD HH:mm')} - ${dayjs(filters.end_date).format('MM-DD HH:mm')}`;
|
||||
}
|
||||
|
||||
// 从 filters 重建 tradingTimeRange 状态
|
||||
const timeRange = {
|
||||
start_date: filters.start_date || '',
|
||||
end_date: filters.end_date || '',
|
||||
recent_days: filters.recent_days || '',
|
||||
label: inferredLabel,
|
||||
key: inferredKey
|
||||
};
|
||||
setTradingTimeRange(timeRange);
|
||||
logger.debug('UnifiedSearchBox', '初始化时间筛选', {
|
||||
filters_time: {
|
||||
start_date: filters.start_date,
|
||||
end_date: filters.end_date,
|
||||
recent_days: filters.recent_days
|
||||
},
|
||||
tradingTimeRange: timeRange
|
||||
});
|
||||
} else if (!hasTimeInFilters && tradingTimeRange) {
|
||||
// 如果 filters 中没有时间参数,但本地有值,清空本地值
|
||||
setTradingTimeRange(null);
|
||||
logger.debug('UnifiedSearchBox', '清空时间筛选(filters中无值)');
|
||||
}
|
||||
}, [filters.sort, filters.importance, filters.industry_code, filters.q, filters.start_date, filters.end_date, filters.recent_days, industryData, findIndustryPath, industryValue, tradingTimeRange]);
|
||||
|
||||
// AutoComplete 搜索股票(模糊匹配 code 或 name)
|
||||
const handleSearch = (value) => {
|
||||
if (!value || !allStocks || allStocks.length === 0) {
|
||||
setStockOptions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// 使用 stockService 进行模糊搜索
|
||||
const results = stockService.fuzzySearch(value, allStocks, 10);
|
||||
|
||||
// 转换为 AutoComplete 选项格式
|
||||
const options = results.map(stock => ({
|
||||
value: stock.code,
|
||||
label: (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<StockOutlined style={{ color: '#1890ff' }} />
|
||||
<span style={{ fontWeight: 500, color: '#333' }}>{stock.code}</span>
|
||||
<span style={{ color: '#666' }}>{stock.name}</span>
|
||||
</div>
|
||||
),
|
||||
// 保存完整的股票信息,用于选中后显示
|
||||
stockInfo: stock
|
||||
}));
|
||||
|
||||
setStockOptions(options);
|
||||
logger.debug('UnifiedSearchBox', '股票模糊搜索', {
|
||||
query: value,
|
||||
resultCount: options.length
|
||||
});
|
||||
};
|
||||
|
||||
// ✅ 选中股票(从下拉选择) - 更新输入框并触发搜索
|
||||
const handleStockSelect = (_value, option) => {
|
||||
const stockInfo = option.stockInfo;
|
||||
if (stockInfo) {
|
||||
logger.debug('UnifiedSearchBox', '选中股票', {
|
||||
code: stockInfo.code,
|
||||
name: stockInfo.name
|
||||
});
|
||||
|
||||
// 🎯 追踪股票点击
|
||||
if (trackingFunctions.trackRelatedStockClicked) {
|
||||
trackingFunctions.trackRelatedStockClicked({
|
||||
stockCode: stockInfo.code,
|
||||
stockName: stockInfo.name,
|
||||
source: 'search_box_autocomplete',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// 更新输入框显示
|
||||
setInputValue(`${stockInfo.code} ${stockInfo.name}`);
|
||||
|
||||
// 直接构建参数并触发搜索 - 使用股票代码作为 q 参数
|
||||
const params = buildFilterParams({
|
||||
q: stockInfo.code, // 使用股票代码作为搜索关键词
|
||||
industry_code: ''
|
||||
});
|
||||
logger.debug('UnifiedSearchBox', '自动触发股票搜索', params);
|
||||
triggerSearch(params);
|
||||
}
|
||||
};
|
||||
|
||||
// ✅ 重要性变化(立即执行)- 支持多选
|
||||
const handleImportanceChange = (value) => {
|
||||
logger.debug('UnifiedSearchBox', '重要性值改变', {
|
||||
oldValue: importance,
|
||||
newValue: value
|
||||
});
|
||||
|
||||
setImportance(value);
|
||||
|
||||
// 取消之前的防抖搜索
|
||||
if (debouncedSearchRef.current) {
|
||||
debouncedSearchRef.current.cancel();
|
||||
}
|
||||
|
||||
// 转换为逗号分隔字符串传给后端(空数组表示"全部")
|
||||
const importanceStr = value.length === 0 ? 'all' : value.join(',');
|
||||
|
||||
// 🎯 追踪筛选操作
|
||||
if (trackingFunctions.trackNewsFilterApplied) {
|
||||
trackingFunctions.trackNewsFilterApplied({
|
||||
filterType: 'importance',
|
||||
filterValue: importanceStr,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// 立即触发搜索
|
||||
const params = buildFilterParams({ importance: importanceStr });
|
||||
logger.debug('UnifiedSearchBox', '重要性改变,立即触发搜索', params);
|
||||
|
||||
triggerSearch(params);
|
||||
};
|
||||
|
||||
// ✅ 排序变化(立即触发搜索)
|
||||
const handleSortChange = (value) => {
|
||||
logger.debug('UnifiedSearchBox', '排序值改变', {
|
||||
oldValue: sort,
|
||||
newValue: value
|
||||
});
|
||||
setSort(value);
|
||||
|
||||
// 取消之前的防抖搜索
|
||||
if (debouncedSearchRef.current) {
|
||||
debouncedSearchRef.current.cancel();
|
||||
}
|
||||
|
||||
// 🎯 追踪排序操作
|
||||
if (trackingFunctions.trackNewsSorted) {
|
||||
trackingFunctions.trackNewsSorted({
|
||||
sortBy: value,
|
||||
previousSortBy: sort,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// 立即触发搜索
|
||||
const params = buildFilterParams({ sort: value });
|
||||
logger.debug('UnifiedSearchBox', '排序改变,立即触发搜索', params);
|
||||
triggerSearch(params);
|
||||
};
|
||||
|
||||
// ✅ 行业分类变化(立即触发搜索)
|
||||
const handleIndustryChange = (value) => {
|
||||
logger.debug('UnifiedSearchBox', '行业分类值改变', {
|
||||
oldValue: industryValue,
|
||||
newValue: value
|
||||
});
|
||||
setIndustryValue(value);
|
||||
|
||||
// 取消之前的防抖搜索
|
||||
if (debouncedSearchRef.current) {
|
||||
debouncedSearchRef.current.cancel();
|
||||
}
|
||||
|
||||
// 🎯 追踪行业筛选
|
||||
if (trackingFunctions.trackNewsFilterApplied) {
|
||||
trackingFunctions.trackNewsFilterApplied({
|
||||
filterType: 'industry',
|
||||
filterValue: value?.[value.length - 1] || '',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// 立即触发搜索
|
||||
const params = buildFilterParams({
|
||||
industry_code: value?.[value.length - 1] || ''
|
||||
});
|
||||
logger.debug('UnifiedSearchBox', '行业改变,立即触发搜索', params);
|
||||
|
||||
triggerSearch(params);
|
||||
};
|
||||
|
||||
// ✅ 热门概念点击处理(立即搜索,不使用防抖) - 更新输入框并触发搜索
|
||||
const handleKeywordClick = (keyword) => {
|
||||
// 更新输入框显示
|
||||
setInputValue(keyword);
|
||||
|
||||
// 立即触发搜索(取消之前的防抖)
|
||||
if (debouncedSearchRef.current) {
|
||||
debouncedSearchRef.current.cancel();
|
||||
}
|
||||
|
||||
// 🎯 追踪热门关键词点击
|
||||
if (trackingFunctions.trackNewsSearched) {
|
||||
trackingFunctions.trackNewsSearched({
|
||||
searchQuery: keyword,
|
||||
searchType: 'popular_keyword',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
const params = buildFilterParams({
|
||||
q: keyword,
|
||||
industry_code: ''
|
||||
});
|
||||
logger.debug('UnifiedSearchBox', '热门概念点击,立即触发搜索', {
|
||||
keyword,
|
||||
params
|
||||
});
|
||||
triggerSearch(params);
|
||||
};
|
||||
|
||||
// ✅ 交易时段筛选变化(立即触发搜索)
|
||||
const handleTradingTimeChange = (timeConfig) => {
|
||||
if (!timeConfig) {
|
||||
// 清空筛选
|
||||
setTradingTimeRange(null);
|
||||
|
||||
// 🎯 追踪时间筛选清空
|
||||
if (trackingFunctions.trackNewsFilterApplied) {
|
||||
trackingFunctions.trackNewsFilterApplied({
|
||||
filterType: 'time_range',
|
||||
filterValue: 'cleared',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
const params = buildFilterParams({
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
recent_days: ''
|
||||
});
|
||||
triggerSearch(params);
|
||||
return;
|
||||
}
|
||||
|
||||
const { range, type, label, key } = timeConfig;
|
||||
let params = {};
|
||||
|
||||
if (type === 'recent_days') {
|
||||
// 近一周/近一月使用 recent_days
|
||||
params.recent_days = range;
|
||||
params.start_date = '';
|
||||
params.end_date = '';
|
||||
} else {
|
||||
// 其他使用 start_date + end_date
|
||||
params.start_date = range[0].format('YYYY-MM-DD HH:mm:ss');
|
||||
params.end_date = range[1].format('YYYY-MM-DD HH:mm:ss');
|
||||
params.recent_days = '';
|
||||
}
|
||||
|
||||
setTradingTimeRange({ ...params, label, key });
|
||||
|
||||
// 🎯 追踪时间筛选
|
||||
if (trackingFunctions.trackNewsFilterApplied) {
|
||||
trackingFunctions.trackNewsFilterApplied({
|
||||
filterType: 'time_range',
|
||||
filterValue: label,
|
||||
timeRangeType: type,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// 立即触发搜索
|
||||
const searchParams = buildFilterParams({ ...params, mode });
|
||||
logger.debug('UnifiedSearchBox', '交易时段筛选变化,立即触发搜索', {
|
||||
timeConfig,
|
||||
params: searchParams
|
||||
});
|
||||
triggerSearch(searchParams);
|
||||
};
|
||||
|
||||
// 主搜索(点击搜索按钮或回车)
|
||||
const handleMainSearch = () => {
|
||||
// 取消之前的防抖
|
||||
if (debouncedSearchRef.current) {
|
||||
debouncedSearchRef.current.cancel();
|
||||
}
|
||||
|
||||
// 构建参数并触发搜索 - 使用用户输入作为 q 参数
|
||||
const params = buildFilterParams({
|
||||
q: inputValue, // 使用用户输入(可能是话题、股票代码、股票名称等)
|
||||
industry_code: ''
|
||||
});
|
||||
|
||||
// 🎯 追踪搜索操作
|
||||
if (trackingFunctions.trackNewsSearched && inputValue) {
|
||||
trackingFunctions.trackNewsSearched({
|
||||
searchQuery: inputValue,
|
||||
searchType: 'main_search',
|
||||
filters: params,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
logger.debug('UnifiedSearchBox', '主搜索触发', {
|
||||
inputValue,
|
||||
params
|
||||
});
|
||||
triggerSearch(params);
|
||||
};
|
||||
|
||||
// ✅ 处理输入变化 - 更新本地输入状态
|
||||
const handleInputChange = (value) => {
|
||||
logger.debug('UnifiedSearchBox', '输入变化', { value });
|
||||
setInputValue(value);
|
||||
};
|
||||
|
||||
// ✅ 生成完整的筛选参数对象 - 直接从 filters 和本地筛选器状态构建
|
||||
const buildFilterParams = useCallback((overrides = {}) => {
|
||||
logger.debug('UnifiedSearchBox', '🔧 buildFilterParams - 输入参数', {
|
||||
overrides: overrides,
|
||||
currentState: {
|
||||
sort,
|
||||
importance,
|
||||
industryValue,
|
||||
'filters.q': filters.q,
|
||||
mode,
|
||||
pageSize
|
||||
}
|
||||
});
|
||||
|
||||
// 处理排序参数 - 将 returns_avg/returns_week 转换为 sort=returns + return_type
|
||||
const sortValue = overrides.sort ?? sort;
|
||||
let actualSort = sortValue;
|
||||
let returnType;
|
||||
|
||||
if (sortValue === 'returns_avg') {
|
||||
actualSort = 'returns';
|
||||
returnType = 'avg';
|
||||
} else if (sortValue === 'returns_week') {
|
||||
actualSort = 'returns';
|
||||
returnType = 'week';
|
||||
}
|
||||
|
||||
// 处理重要性参数:数组转换为逗号分隔字符串
|
||||
let importanceValue = overrides.importance ?? importance;
|
||||
if (Array.isArray(importanceValue)) {
|
||||
importanceValue = importanceValue.length === 0
|
||||
? 'all'
|
||||
: importanceValue.join(',');
|
||||
}
|
||||
|
||||
const result = {
|
||||
// 基础参数(overrides 优先级高于本地状态)
|
||||
sort: actualSort,
|
||||
importance: importanceValue,
|
||||
|
||||
|
||||
// 搜索参数: 统一使用 q 参数进行搜索(话题/股票/关键词)
|
||||
q: (overrides.q ?? filters.q) ?? '',
|
||||
// 行业代码: 取选中路径的最后一级(最具体的行业代码)
|
||||
industry_code: overrides.industry_code ?? (industryValue?.[industryValue.length - 1] || ''),
|
||||
|
||||
// 交易时段筛选参数
|
||||
start_date: overrides.start_date ?? (tradingTimeRange?.start_date || ''),
|
||||
end_date: overrides.end_date ?? (tradingTimeRange?.end_date || ''),
|
||||
recent_days: overrides.recent_days ?? (tradingTimeRange?.recent_days || ''),
|
||||
|
||||
// 最终 overrides 具有最高优先级
|
||||
...overrides,
|
||||
page: 1,
|
||||
per_page: overrides.mode === 'four-row' ? 30: 10
|
||||
};
|
||||
|
||||
// 删除可能来自 overrides 的旧 per_page 值(将由 pageSize 重新设置)
|
||||
delete result.per_page;
|
||||
|
||||
// 添加 return_type 参数(如果需要)
|
||||
if (returnType) {
|
||||
result.return_type = returnType;
|
||||
}
|
||||
|
||||
// 添加 mode 和 per_page 参数(如果提供了的话)
|
||||
if (mode !== undefined && mode !== null) {
|
||||
result.mode = mode;
|
||||
}
|
||||
if (pageSize !== undefined && pageSize !== null) {
|
||||
result.per_page = pageSize; // 后端实际使用的参数
|
||||
}
|
||||
|
||||
logger.debug('UnifiedSearchBox', '🔧 buildFilterParams - 输出结果', result);
|
||||
return result;
|
||||
}, [sort, importance, filters.q, industryValue, tradingTimeRange, mode, pageSize]);
|
||||
|
||||
// ✅ 重置筛选 - 清空所有筛选器并触发搜索
|
||||
const handleReset = () => {
|
||||
console.log('%c🔄 [重置] 开始重置筛选条件', 'color: #FF4D4F; font-weight: bold;');
|
||||
|
||||
// 重置所有筛选器状态
|
||||
setInputValue(''); // 清空输入框
|
||||
setStockOptions([]);
|
||||
setIndustryValue([]);
|
||||
setSort('new');
|
||||
setImportance([]); // 改为空数组
|
||||
setTradingTimeRange(null); // 清空交易时段筛选
|
||||
|
||||
// 🎯 追踪筛选重置
|
||||
if (trackingFunctions.trackNewsFilterApplied) {
|
||||
trackingFunctions.trackNewsFilterApplied({
|
||||
filterType: 'reset',
|
||||
filterValue: 'all_filters_cleared',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// 输出重置后的完整参数
|
||||
const resetParams = {
|
||||
q: '',
|
||||
industry_code: '',
|
||||
sort: 'new',
|
||||
importance: 'all', // 传给后端时转为'all'
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
recent_days: '',
|
||||
page: 1,
|
||||
_forceRefresh: Date.now() // 添加强制刷新标志,确保每次重置都触发更新
|
||||
};
|
||||
|
||||
console.log('%c🔄 [重置] 重置参数', 'color: #FF4D4F;', resetParams);
|
||||
logger.debug('UnifiedSearchBox', '重置筛选', resetParams);
|
||||
|
||||
console.log('%c🔄 [重置] 调用 onSearch', 'color: #FF4D4F;', typeof onSearch);
|
||||
onSearch(resetParams);
|
||||
|
||||
console.log('%c✅ [重置] 重置完成', 'color: #52C41A; font-weight: bold;');
|
||||
};
|
||||
|
||||
// 生成已选条件标签(包含所有筛选条件) - 从 filters 和本地状态读取
|
||||
const filterTags = useMemo(() => {
|
||||
const tags = [];
|
||||
|
||||
// 搜索关键词标签 - 从 filters.q 读取
|
||||
if (filters.q) {
|
||||
tags.push({ key: 'search', label: `搜索: ${filters.q}` });
|
||||
}
|
||||
|
||||
// 行业标签
|
||||
if (industryValue && industryValue.length > 0 && industryData) {
|
||||
// 递归查找每个层级的 label
|
||||
const findLabel = (code, data) => {
|
||||
for (const item of data) {
|
||||
if (code.startsWith(item.value)) {
|
||||
if (item.value === code) {
|
||||
return item.label;
|
||||
} else {
|
||||
return findLabel(code, item.children);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// 只显示最后一级的 label
|
||||
const lastLevelCode = industryValue[industryValue.length - 1];
|
||||
const lastLevelLabel = findLabel(lastLevelCode, industryData);
|
||||
|
||||
tags.push({
|
||||
key: 'industry',
|
||||
label: `行业: ${lastLevelLabel}`
|
||||
});
|
||||
}
|
||||
|
||||
// 交易时段筛选标签
|
||||
if (tradingTimeRange?.label) {
|
||||
tags.push({
|
||||
key: 'trading_time',
|
||||
label: `时间: ${tradingTimeRange.label}`
|
||||
});
|
||||
}
|
||||
|
||||
// 重要性标签(多选合并显示为单个标签)
|
||||
if (importance && importance.length > 0) {
|
||||
const importanceMap = { 'S': '极高', 'A': '高', 'B': '中', 'C': '低' };
|
||||
const importanceLabel = importance.map(imp => importanceMap[imp] || imp).join(', ');
|
||||
tags.push({ key: 'importance', label: `重要性: ${importanceLabel}` });
|
||||
}
|
||||
|
||||
// 排序标签(排除默认值 'new')
|
||||
if (sort && sort !== 'new') {
|
||||
let sortLabel;
|
||||
if (sort === 'hot') sortLabel = '最热';
|
||||
else if (sort === 'importance') sortLabel = '重要性';
|
||||
else if (sort === 'returns_avg') sortLabel = '平均收益率';
|
||||
else if (sort === 'returns_week') sortLabel = '周收益率';
|
||||
else sortLabel = sort;
|
||||
tags.push({ key: 'sort', label: `排序: ${sortLabel}` });
|
||||
}
|
||||
|
||||
return tags;
|
||||
}, [filters.q, industryValue, importance, sort, tradingTimeRange]);
|
||||
|
||||
// ✅ 移除单个标签 - 构建新参数并触发搜索
|
||||
const handleRemoveTag = (key) => {
|
||||
logger.debug('UnifiedSearchBox', '移除标签', { key });
|
||||
|
||||
// 取消所有待执行的防抖搜索(避免旧的防抖覆盖删除操作)
|
||||
if (debouncedSearchRef.current) {
|
||||
debouncedSearchRef.current.cancel();
|
||||
}
|
||||
|
||||
if (key === 'search') {
|
||||
// 清除搜索关键词和输入框,立即触发搜索
|
||||
setInputValue(''); // 清空输入框
|
||||
const params = buildFilterParams({ q: '' });
|
||||
logger.debug('UnifiedSearchBox', '移除搜索标签后触发搜索', { key, params });
|
||||
triggerSearch(params);
|
||||
} else if (key === 'industry') {
|
||||
// 清除行业选择
|
||||
setIndustryValue([]);
|
||||
const params = buildFilterParams({ industry_code: '' });
|
||||
triggerSearch(params);
|
||||
} else if (key === 'trading_time') {
|
||||
// 清除交易时段筛选
|
||||
setTradingTimeRange(null);
|
||||
const params = buildFilterParams({
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
recent_days: ''
|
||||
});
|
||||
triggerSearch(params);
|
||||
} else if (key === 'importance') {
|
||||
// 重置重要性为空数组(传给后端为'all')
|
||||
setImportance([]);
|
||||
const params = buildFilterParams({ importance: 'all' });
|
||||
triggerSearch(params);
|
||||
} else if (key === 'sort') {
|
||||
// 重置排序为默认值
|
||||
setSort('new');
|
||||
const params = buildFilterParams({ sort: 'new' });
|
||||
triggerSearch(params);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{padding: '8px'}}>
|
||||
{/* 第三行:行业 + 重要性 + 排序 */}
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between' }} size="middle">
|
||||
{/* 左侧:筛选器组 */}
|
||||
<Space size="small" wrap>
|
||||
<span style={{ fontSize: 12, color: '#666', fontWeight: 'bold' }}>筛选:</span>
|
||||
{/* 行业分类 */}
|
||||
<Cascader
|
||||
value={industryValue}
|
||||
onChange={handleIndustryChange}
|
||||
onFocus={handleCascaderFocus}
|
||||
options={industryData || []}
|
||||
placeholder="行业分类"
|
||||
changeOnSelect
|
||||
showSearch={{
|
||||
filter: (inputValue, path) =>
|
||||
path.some(option =>
|
||||
option.label.toLowerCase().includes(inputValue.toLowerCase())
|
||||
)
|
||||
}}
|
||||
allowClear
|
||||
expandTrigger="hover"
|
||||
displayRender={(labels) => labels.join(' > ')}
|
||||
disabled={industryLoading}
|
||||
style={{ width: 160 }}
|
||||
size="small"
|
||||
/>
|
||||
|
||||
{/* 重要性 */}
|
||||
<Space size="small">
|
||||
<span style={{ fontSize: 12, color: '#666' }}>重要性:</span>
|
||||
<AntSelect
|
||||
mode="multiple"
|
||||
value={importance}
|
||||
onChange={handleImportanceChange}
|
||||
style={{ width: 120 }}
|
||||
size="small"
|
||||
placeholder="全部"
|
||||
maxTagCount={3}
|
||||
>
|
||||
<Option value="S">极高</Option>
|
||||
<Option value="A">高</Option>
|
||||
<Option value="B">中</Option>
|
||||
<Option value="C">低</Option>
|
||||
</AntSelect>
|
||||
</Space>
|
||||
|
||||
{/* 搜索图标(可点击) + 搜索框 */}
|
||||
<Space.Compact style={{ flex: 1, minWidth: 250 }}>
|
||||
<SearchOutlined
|
||||
onClick={handleMainSearch}
|
||||
style={{
|
||||
fontSize: 14,
|
||||
padding: '5px 8px',
|
||||
background: '#e6f7ff',
|
||||
borderRadius: '6px 0 0 6px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
color: '#1890ff',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = '#096dd9';
|
||||
e.currentTarget.style.background = '#bae7ff';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = '#1890ff';
|
||||
e.currentTarget.style.background = '#e6f7ff';
|
||||
}}
|
||||
/>
|
||||
<AutoComplete
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
onSearch={handleSearch}
|
||||
onSelect={handleStockSelect}
|
||||
onFocus={onSearchFocus}
|
||||
options={stockOptions}
|
||||
placeholder="请输入股票代码/股票名称/相关话题"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleMainSearch();
|
||||
}
|
||||
}}
|
||||
style={{ flex: 1 }}
|
||||
size="small"
|
||||
notFoundContent={inputValue && stockOptions.length === 0 ? "未找到匹配的股票" : null}
|
||||
/>
|
||||
</Space.Compact>
|
||||
|
||||
{/* 重置按钮 - 现代化设计 */}
|
||||
<Button
|
||||
icon={<CloseCircleOutlined />}
|
||||
onClick={handleReset}
|
||||
size="small"
|
||||
style={{
|
||||
borderRadius: 6,
|
||||
border: '1px solid #d9d9d9',
|
||||
backgroundColor: '#fff',
|
||||
color: '#666',
|
||||
fontWeight: 500,
|
||||
padding: '4px 10px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.05)'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#ff4d4f';
|
||||
e.currentTarget.style.color = '#ff4d4f';
|
||||
e.currentTarget.style.backgroundColor = '#fff1f0';
|
||||
e.currentTarget.style.boxShadow = '0 2px 8px rgba(255, 77, 79, 0.15)';
|
||||
e.currentTarget.style.transform = 'translateY(-1px)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = '#d9d9d9';
|
||||
e.currentTarget.style.color = '#666';
|
||||
e.currentTarget.style.backgroundColor = '#fff';
|
||||
e.currentTarget.style.boxShadow = '0 1px 2px rgba(0, 0, 0, 0.05)';
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
{/* 右侧:排序 */}
|
||||
<Space size="small">
|
||||
<span style={{ fontSize: 12, color: '#666' }}>排序:</span>
|
||||
<AntSelect
|
||||
value={sort}
|
||||
onChange={handleSortChange}
|
||||
style={{ width: 100 }}
|
||||
size="small"
|
||||
>
|
||||
<Option value="new">最新</Option>
|
||||
<Option value="hot">最热</Option>
|
||||
<Option value="importance">重要性</Option>
|
||||
<Option value="returns_avg">平均收益率</Option>
|
||||
<Option value="returns_week">周收益率</Option>
|
||||
</AntSelect>
|
||||
</Space>
|
||||
</Space>
|
||||
|
||||
{/* 第一行:筛选 + 时间按钮 + 搜索图标 + 搜索框 */}
|
||||
<Space wrap style={{ width: '100%', marginBottom: 4, marginTop: 6 }} size="middle">
|
||||
<span style={{ fontSize: 14, color: '#666', fontWeight: 'bold' }}>时间筛选:</span>
|
||||
|
||||
{/* 交易时段筛选 */}
|
||||
<TradingTimeFilter
|
||||
value={tradingTimeRange?.key || null}
|
||||
onChange={handleTradingTimeChange}
|
||||
/>
|
||||
</Space>
|
||||
|
||||
{/* 第二行:热门概念 */}
|
||||
<div style={{ marginTop: 2 }}>
|
||||
<PopularKeywords
|
||||
keywords={popularKeywords}
|
||||
onKeywordClick={handleKeywordClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UnifiedSearchBox;
|
||||
Reference in New Issue
Block a user