From 3bfe500c69984935bd17cca2e46f1d11dfbeb23e Mon Sep 17 00:00:00 2001 From: zzlgreat Date: Thu, 25 Dec 2025 13:15:57 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BA=8B=E4=BB=B6=E6=A0=87=E8=AE=B0=E7=BA=BFbu?= =?UTF-8?q?g=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.py | 105 ++++++++ app_vx.py | 1 + src/mocks/data/events.js | 15 ++ src/mocks/handlers/event.js | 50 ++++ .../HorizontalDynamicNewsEventCard.js | 35 ++- .../EventCard/atoms/EventEngagement.js | 254 ++++++++++++++++++ .../components/EventCard/atoms/index.js | 1 + 7 files changed, 454 insertions(+), 7 deletions(-) create mode 100644 src/views/Community/components/EventCard/atoms/EventEngagement.js diff --git a/app.py b/app.py index 648697fd..58ea9908 100755 --- a/app.py +++ b/app.py @@ -5641,6 +5641,23 @@ class FutureEventFollow(db.Model): __table_args__ = (db.UniqueConstraint('user_id', 'future_event_id'),) +class EventSentimentVote(db.Model): + """事件情绪投票(看多/看空)""" + __tablename__ = 'event_sentiment_vote' + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + event_id = db.Column(db.Integer, db.ForeignKey('event.id'), nullable=False) + vote_type = db.Column(db.String(10), nullable=False) # 'bullish' 或 'bearish' + created_at = db.Column(db.DateTime, default=beijing_now) + updated_at = db.Column(db.DateTime, default=beijing_now, onupdate=beijing_now) + + user = db.relationship('User', backref='sentiment_votes') + event = db.relationship('Event', backref='sentiment_votes') + + __table_args__ = (db.UniqueConstraint('user_id', 'event_id'),) + + # —— 自选股输入统一化与名称补全工具 —— def _normalize_stock_input(raw_input: str): """解析用户输入为标准6位股票代码与可选名称。 @@ -6687,6 +6704,10 @@ class Event(db.Model): post_count = db.Column(db.Integer, default=0) follower_count = db.Column(db.Integer, default=0) + # 看多/看空投票统计 + bullish_count = db.Column(db.Integer, default=0) # 看多数 + bearish_count = db.Column(db.Integer, default=0) # 看空数 + # 关联信息 related_industries = db.Column(db.String(20)) # 申万行业代码,如 "S640701" keywords = db.Column(db.JSON) @@ -7565,6 +7586,74 @@ def toggle_event_follow(event_id): return jsonify({'success': False, 'error': str(e)}), 500 +@app.route('/api/events//sentiment-vote', methods=['POST']) +def vote_event_sentiment(event_id): + """事件情绪投票(看多/看空)""" + if 'user_id' not in session: + return jsonify({'success': False, 'error': '未登录'}), 401 + + try: + event = Event.query.get_or_404(event_id) + user_id = session['user_id'] + data = request.get_json() or {} + vote_type = data.get('vote_type') # 'bullish', 'bearish', 或 null/None(取消投票) + + # 验证投票类型 + if vote_type and vote_type not in ('bullish', 'bearish'): + return jsonify({'success': False, 'error': '无效的投票类型'}), 400 + + existing = EventSentimentVote.query.filter_by(user_id=user_id, event_id=event_id).first() + old_vote_type = existing.vote_type if existing else None + + if vote_type is None or vote_type == '': + # 取消投票 + if existing: + if old_vote_type == 'bullish': + event.bullish_count = max(0, (event.bullish_count or 0) - 1) + elif old_vote_type == 'bearish': + event.bearish_count = max(0, (event.bearish_count or 0) - 1) + db.session.delete(existing) + elif existing: + # 更新投票 + if old_vote_type != vote_type: + # 减少旧投票计数 + if old_vote_type == 'bullish': + event.bullish_count = max(0, (event.bullish_count or 0) - 1) + elif old_vote_type == 'bearish': + event.bearish_count = max(0, (event.bearish_count or 0) - 1) + # 增加新投票计数 + if vote_type == 'bullish': + event.bullish_count = (event.bullish_count or 0) + 1 + elif vote_type == 'bearish': + event.bearish_count = (event.bearish_count or 0) + 1 + existing.vote_type = vote_type + else: + # 新建投票 + vote = EventSentimentVote(user_id=user_id, event_id=event_id, vote_type=vote_type) + db.session.add(vote) + if vote_type == 'bullish': + event.bullish_count = (event.bullish_count or 0) + 1 + elif vote_type == 'bearish': + event.bearish_count = (event.bearish_count or 0) + 1 + + db.session.commit() + + # 查询当前用户的投票状态 + current_vote = EventSentimentVote.query.filter_by(user_id=user_id, event_id=event_id).first() + + return jsonify({ + 'success': True, + 'data': { + 'user_vote': current_vote.vote_type if current_vote else None, + 'bullish_count': event.bullish_count or 0, + 'bearish_count': event.bearish_count or 0, + } + }) + except Exception as e: + db.session.rollback() + return jsonify({'success': False, 'error': str(e)}), 500 + + @app.route('/api/events//transmission', methods=['GET']) def get_transmission_chain(event_id): try: @@ -10968,6 +11057,19 @@ def api_get_events(): query = query.order_by(order_func(Event.view_count)) # 分页 paginated = query.paginate(page=page, per_page=per_page, error_out=False) + + # 查询当前用户的投票状态 + user_vote_map = {} + if 'user_id' in session: + user_id = session['user_id'] + event_ids = [event.id for event in paginated.items] + if event_ids: + user_votes = EventSentimentVote.query.filter( + EventSentimentVote.user_id == user_id, + EventSentimentVote.event_id.in_(event_ids) + ).all() + user_vote_map = {v.event_id: v.vote_type for v in user_votes} + events_data = [] for event in paginated.items: event_dict = { @@ -10988,6 +11090,9 @@ def api_get_events(): 'view_count': event.view_count, 'post_count': event.post_count, 'follower_count': event.follower_count, + 'bullish_count': event.bullish_count or 0, + 'bearish_count': event.bearish_count or 0, + 'user_vote': user_vote_map.get(event.id), # 用户投票状态 'related_avg_chg': event.related_avg_chg, 'related_max_chg': event.related_max_chg, 'related_week_chg': event.related_week_chg, diff --git a/app_vx.py b/app_vx.py index 4be364c1..268cb185 100644 --- a/app_vx.py +++ b/app_vx.py @@ -7392,6 +7392,7 @@ if __name__ == '__main__': logger.info(f"🚀 启动 Flask 应用: port={args.port}, debug={args.debug}, ssl={'enabled' if ssl_context else 'disabled'}") app.run( + host='0.0.0.0', port=args.port, debug=args.debug, diff --git a/src/mocks/data/events.js b/src/mocks/data/events.js index 94a38ccd..a5516b11 100644 --- a/src/mocks/data/events.js +++ b/src/mocks/data/events.js @@ -888,6 +888,10 @@ export function generateMockEvents(params = {}) { const tradingDate = new Date(createdAt); tradingDate.setDate(tradingDate.getDate() + 1); + // 生成投票数据 + const bullishCount = Math.floor(Math.random() * 100) + 5; + const bearishCount = Math.floor(Math.random() * 50) + 3; + allEvents.push({ id: i + 1, title: generateEventTitle(industry, i), @@ -901,6 +905,10 @@ export function generateMockEvents(params = {}) { trading_date: tradingDate.toISOString().split('T')[0], // YYYY-MM-DD 格式 hot_score: hotScore, view_count: Math.floor(Math.random() * 10000), + follower_count: Math.floor(Math.random() * 500) + 10, + bullish_count: bullishCount, + bearish_count: bearishCount, + user_vote: null, // 默认未投票 related_avg_chg: parseFloat(relatedAvgChg), related_max_chg: parseFloat(relatedMaxChg), related_week_chg: parseFloat(relatedWeekChg), @@ -1374,6 +1382,10 @@ export function generateDynamicNewsEvents(timeRange = null, count = 30) { ...generateKeywords(industry, i).slice(0, 2) ]; + // 生成投票数据 + const bullishCount = Math.floor(Math.random() * 80) + 10; + const bearishCount = Math.floor(Math.random() * 40) + 5; + events.push({ id: `dynamic_${i + 1}`, title: eventTitle, @@ -1387,6 +1399,9 @@ export function generateDynamicNewsEvents(timeRange = null, count = 30) { hot_score: hotScore, view_count: Math.floor(Math.random() * 5000) + 1000, // 1000-6000 浏览量 follower_count: Math.floor(Math.random() * 500) + 50, // 50-550 关注数 + bullish_count: bullishCount, // 看多数 + bearish_count: bearishCount, // 看空数 + user_vote: null, // 默认未投票 post_count: Math.floor(Math.random() * 100) + 10, // 10-110 帖子数 related_avg_chg: parseFloat(relatedAvgChg), related_max_chg: parseFloat(relatedMaxChg), diff --git a/src/mocks/handlers/event.js b/src/mocks/handlers/event.js index 500da6fa..2d3a1144 100644 --- a/src/mocks/handlers/event.js +++ b/src/mocks/handlers/event.js @@ -601,6 +601,56 @@ export const eventHandlers = [ } }), + // 事件情绪投票(看多/看空) + http.post('/api/events/:eventId/sentiment-vote', async ({ params, request }) => { + await delay(200); + + const { eventId } = params; + const numericEventId = parseInt(eventId, 10); + + console.log('[Mock] 事件情绪投票, eventId:', numericEventId); + + try { + const body = await request.json(); + const voteType = body.vote_type; // 'bullish', 'bearish', 或 null + + // 使用内存状态管理投票 + // 简单模拟:根据 eventId 生成基础数据 + const baseBullish = (numericEventId * 7) % 50 + 10; + const baseBearish = (numericEventId * 3) % 30 + 5; + + // 根据投票类型调整计数 + let bullishCount = baseBullish; + let bearishCount = baseBearish; + + if (voteType === 'bullish') { + bullishCount += 1; + } else if (voteType === 'bearish') { + bearishCount += 1; + } + + return HttpResponse.json({ + success: true, + data: { + user_vote: voteType || null, + bullish_count: bullishCount, + bearish_count: bearishCount, + }, + message: voteType ? '投票成功' : '取消投票成功' + }); + } catch (error) { + console.error('[Mock] 事件情绪投票失败:', error); + return HttpResponse.json( + { + success: false, + error: '投票失败', + data: null + }, + { status: 500 } + ); + } + }), + // 获取事件传导链分析数据 http.get('/api/events/:eventId/transmission', async ({ params }) => { await delay(500); diff --git a/src/views/Community/components/EventCard/HorizontalDynamicNewsEventCard.js b/src/views/Community/components/EventCard/HorizontalDynamicNewsEventCard.js index bb95f6e6..c182a738 100644 --- a/src/views/Community/components/EventCard/HorizontalDynamicNewsEventCard.js +++ b/src/views/Community/components/EventCard/HorizontalDynamicNewsEventCard.js @@ -23,6 +23,7 @@ import { ImportanceStamp, EventTimeline, EventFollowButton, + EventEngagement, KeywordsCarousel, } from './atoms'; import StockChangeIndicators from '@components/StockChangeIndicators'; @@ -38,10 +39,12 @@ import StockChangeIndicators from '@components/StockChangeIndicators'; * @param {Function} props.onEventClick - 卡片点击事件 * @param {Function} props.onTitleClick - 标题点击事件 * @param {Function} props.onToggleFollow - 切换关注事件 + * @param {Function} props.onVoteChange - 投票变化回调 * @param {Object} props.timelineStyle - 时间轴样式配置 * @param {string} props.borderColor - 边框颜色 * @param {string} props.indicatorSize - 涨幅指标尺寸 ('default' | 'comfortable' | 'large') * @param {string} props.layout - 布局模式 ('vertical' | 'four-row'),影响时间轴竖线高度 + * @param {boolean} props.showEngagement - 是否显示互动指标(浏览/关注/投票) */ const HorizontalDynamicNewsEventCard = React.memo(({ event, @@ -52,10 +55,12 @@ const HorizontalDynamicNewsEventCard = React.memo(({ onEventClick, onTitleClick, onToggleFollow, + onVoteChange, timelineStyle, borderColor: timelineBorderColor, indicatorSize = 'comfortable', layout = 'vertical', + showEngagement = true, }) => { const importance = getImportanceConfig(event.importance); const { isMobile } = useDevice(); @@ -247,13 +252,29 @@ const HorizontalDynamicNewsEventCard = React.memo(({ - {/* 第二行:涨跌幅数据 */} - + {/* 第二行:涨跌幅数据 + 互动指标 */} + + + + {/* 互动指标:浏览量、关注数、看多/看空 */} + {showEngagement && ( + + )} + diff --git a/src/views/Community/components/EventCard/atoms/EventEngagement.js b/src/views/Community/components/EventCard/atoms/EventEngagement.js new file mode 100644 index 00000000..80fefb5d --- /dev/null +++ b/src/views/Community/components/EventCard/atoms/EventEngagement.js @@ -0,0 +1,254 @@ +// src/views/Community/components/EventCard/atoms/EventEngagement.js +// 事件互动组件 - 显示浏览量、关注量和看多/看空投票 + +import React, { useState, useCallback } from 'react'; +import { + HStack, + Box, + Text, + Tooltip, + IconButton, + Badge, + useToast, +} from '@chakra-ui/react'; +import { ViewIcon, StarIcon } from '@chakra-ui/icons'; +import { TbArrowBigUp, TbArrowBigDown, TbArrowBigUpFilled, TbArrowBigDownFilled } from 'react-icons/tb'; +import { getApiBase } from '@utils/apiConfig'; +import { useAuth } from '@contexts/AuthContext'; + +/** + * 格式化数字(大于1000显示为 1k, 1.2k 等) + * @param {number} num - 原始数字 + * @returns {string} 格式化后的字符串 + */ +const formatCount = (num) => { + if (num == null || isNaN(num)) return '0'; + if (num >= 10000) return `${(num / 10000).toFixed(1)}w`; + if (num >= 1000) return `${(num / 1000).toFixed(1)}k`; + return String(num); +}; + +/** + * 事件互动组件 + * @param {Object} props + * @param {number} props.eventId - 事件ID + * @param {number} props.viewCount - 浏览量 + * @param {number} props.followerCount - 关注数 + * @param {number} props.bullishCount - 看多数 + * @param {number} props.bearishCount - 看空数 + * @param {string} props.userVote - 用户当前投票状态 ('bullish' | 'bearish' | null) + * @param {string} props.size - 尺寸 ('xs' | 'sm' | 'md') + * @param {boolean} props.showVoting - 是否显示投票功能 + * @param {Function} props.onVoteChange - 投票变化回调 + */ +const EventEngagement = ({ + eventId, + viewCount = 0, + followerCount = 0, + bullishCount = 0, + bearishCount = 0, + userVote = null, + size = 'sm', + showVoting = true, + onVoteChange, +}) => { + const toast = useToast(); + const { isLoggedIn } = useAuth(); + + // 本地状态管理(乐观更新) + const [localVote, setLocalVote] = useState(userVote); + const [localBullish, setLocalBullish] = useState(bullishCount); + const [localBearish, setLocalBearish] = useState(bearishCount); + const [isVoting, setIsVoting] = useState(false); + + // 尺寸配置 + const sizeConfig = { + xs: { fontSize: '10px', iconSize: '12px', spacing: 1, btnSize: 'xs' }, + sm: { fontSize: 'xs', iconSize: '14px', spacing: 1.5, btnSize: 'xs' }, + md: { fontSize: 'sm', iconSize: '16px', spacing: 2, btnSize: 'sm' }, + }; + const config = sizeConfig[size] || sizeConfig.sm; + + /** + * 处理投票 + * @param {string} voteType - 'bullish' | 'bearish' + */ + const handleVote = useCallback(async (voteType) => { + if (!isLoggedIn) { + toast({ + title: '请先登录', + description: '登录后才能参与投票', + status: 'warning', + duration: 2000, + isClosable: true, + }); + return; + } + + if (isVoting) return; + setIsVoting(true); + + // 计算新的投票状态 + const newVote = localVote === voteType ? null : voteType; + const oldVote = localVote; + + // 乐观更新本地状态 + setLocalVote(newVote); + + // 更新计数 + let newBullish = localBullish; + let newBearish = localBearish; + + // 取消之前的投票 + if (oldVote === 'bullish') newBullish--; + if (oldVote === 'bearish') newBearish--; + + // 添加新投票 + if (newVote === 'bullish') newBullish++; + if (newVote === 'bearish') newBearish++; + + setLocalBullish(Math.max(0, newBullish)); + setLocalBearish(Math.max(0, newBearish)); + + try { + const response = await fetch(`${getApiBase()}/api/events/${eventId}/sentiment-vote`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ vote_type: newVote }), + }); + + const data = await response.json(); + + if (!response.ok || !data.success) { + throw new Error(data.error || '投票失败'); + } + + // 使用服务器返回的准确数据 + if (data.data) { + setLocalBullish(data.data.bullish_count ?? newBullish); + setLocalBearish(data.data.bearish_count ?? newBearish); + setLocalVote(data.data.user_vote); + } + + // 触发回调 + onVoteChange?.({ + eventId, + userVote: data.data?.user_vote, + bullishCount: data.data?.bullish_count, + bearishCount: data.data?.bearish_count, + }); + + } catch (error) { + console.error('投票失败:', error); + // 回滚状态 + setLocalVote(oldVote); + setLocalBullish(bullishCount); + setLocalBearish(bearishCount); + + toast({ + title: '投票失败', + description: error.message, + status: 'error', + duration: 2000, + isClosable: true, + }); + } finally { + setIsVoting(false); + } + }, [eventId, isLoggedIn, isVoting, localVote, localBullish, localBearish, bullishCount, bearishCount, toast, onVoteChange]); + + // 计算看多比例(用于显示) + const totalVotes = localBullish + localBearish; + const bullishRatio = totalVotes > 0 ? Math.round((localBullish / totalVotes) * 100) : 50; + + return ( + e.stopPropagation()}> + {/* 浏览量 */} + + + + {formatCount(viewCount)} + + + + {/* 关注数 */} + + + + {formatCount(followerCount)} + + + + {/* 看多/看空投票 */} + {showVoting && ( + + {/* 看多按钮 */} + + + : + } + onClick={() => handleVote('bullish')} + isLoading={isVoting} + aria-label="看多" + minW="auto" + h="auto" + p={0.5} + color={localVote === 'bullish' ? 'red.400' : 'gray.400'} + _hover={{ color: 'red.400', bg: 'rgba(239, 68, 68, 0.1)' }} + /> + + + {/* 投票比例显示 */} + {totalVotes > 0 && ( + + = 50 ? 'rgba(239, 68, 68, 0.15)' : 'rgba(16, 185, 129, 0.15)'} + color={bullishRatio >= 50 ? 'red.400' : 'green.400'} + fontWeight="medium" + > + {bullishRatio}% + + + )} + + {/* 看空按钮 */} + + + : + } + onClick={() => handleVote('bearish')} + isLoading={isVoting} + aria-label="看空" + minW="auto" + h="auto" + p={0.5} + color={localVote === 'bearish' ? 'green.400' : 'gray.400'} + _hover={{ color: 'green.400', bg: 'rgba(16, 185, 129, 0.1)' }} + /> + + + )} + + ); +}; + +export default EventEngagement; diff --git a/src/views/Community/components/EventCard/atoms/index.js b/src/views/Community/components/EventCard/atoms/index.js index 53026911..a1385a9e 100644 --- a/src/views/Community/components/EventCard/atoms/index.js +++ b/src/views/Community/components/EventCard/atoms/index.js @@ -2,6 +2,7 @@ // 事件卡片原子组件 export { default as EventDescription } from './EventDescription'; +export { default as EventEngagement } from './EventEngagement'; export { default as EventFollowButton } from './EventFollowButton'; export { default as EventHeader } from './EventHeader'; export { default as EventImportanceBadge } from './EventImportanceBadge';