From 12bf4c2f870549495ba97992ddd46047cfc3b31b Mon Sep 17 00:00:00 2001 From: zzlgreat Date: Tue, 6 Jan 2026 08:13:01 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=AA=E8=82=A1=E8=AE=BA=E5=9D=9B=E9=87=8D?= =?UTF-8?q?=E5=81=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- __pycache__/community_api.cpython-310.pyc | Bin 0 -> 18143 bytes app.py | 12 + community_api.py | 759 ++++++++++++++++++ src/routes/lazy-components.js | 6 +- src/routes/routeConfig.js | 14 +- .../components/ChannelSidebar/index.tsx | 303 +++++++ .../ForumChannel/CreatePostModal.tsx | 264 ++++++ .../MessageArea/ForumChannel/PostCard.tsx | 146 ++++ .../MessageArea/ForumChannel/PostDetail.tsx | 401 +++++++++ .../MessageArea/ForumChannel/ReplyItem.tsx | 141 ++++ .../MessageArea/ForumChannel/index.tsx | 252 ++++++ .../MessageArea/TextChannel/MessageInput.tsx | 370 +++++++++ .../MessageArea/TextChannel/MessageItem.tsx | 394 +++++++++ .../MessageArea/TextChannel/MessageList.tsx | 129 +++ .../MessageArea/TextChannel/ReactionBar.tsx | 111 +++ .../MessageArea/TextChannel/index.tsx | 259 ++++++ .../components/MessageArea/index.tsx | 103 +++ .../MessageArea/shared/ChannelHeader.tsx | 83 ++ .../MessageArea/shared/StockEmbed.tsx | 126 +++ .../components/RightPanel/ConceptInfo.tsx | 234 ++++++ .../components/RightPanel/MemberList.tsx | 202 +++++ .../components/RightPanel/ThreadList.tsx | 210 +++++ .../components/RightPanel/index.tsx | 100 +++ .../hooks/useCommunitySocket.ts | 243 ++++++ src/views/StockCommunity/index.tsx | 194 +++++ .../services/communityService.ts | 465 +++++++++++ src/views/StockCommunity/types/index.ts | 277 +++++++ 27 files changed, 5796 insertions(+), 2 deletions(-) create mode 100644 __pycache__/community_api.cpython-310.pyc create mode 100644 community_api.py create mode 100644 src/views/StockCommunity/components/ChannelSidebar/index.tsx create mode 100644 src/views/StockCommunity/components/MessageArea/ForumChannel/CreatePostModal.tsx create mode 100644 src/views/StockCommunity/components/MessageArea/ForumChannel/PostCard.tsx create mode 100644 src/views/StockCommunity/components/MessageArea/ForumChannel/PostDetail.tsx create mode 100644 src/views/StockCommunity/components/MessageArea/ForumChannel/ReplyItem.tsx create mode 100644 src/views/StockCommunity/components/MessageArea/ForumChannel/index.tsx create mode 100644 src/views/StockCommunity/components/MessageArea/TextChannel/MessageInput.tsx create mode 100644 src/views/StockCommunity/components/MessageArea/TextChannel/MessageItem.tsx create mode 100644 src/views/StockCommunity/components/MessageArea/TextChannel/MessageList.tsx create mode 100644 src/views/StockCommunity/components/MessageArea/TextChannel/ReactionBar.tsx create mode 100644 src/views/StockCommunity/components/MessageArea/TextChannel/index.tsx create mode 100644 src/views/StockCommunity/components/MessageArea/index.tsx create mode 100644 src/views/StockCommunity/components/MessageArea/shared/ChannelHeader.tsx create mode 100644 src/views/StockCommunity/components/MessageArea/shared/StockEmbed.tsx create mode 100644 src/views/StockCommunity/components/RightPanel/ConceptInfo.tsx create mode 100644 src/views/StockCommunity/components/RightPanel/MemberList.tsx create mode 100644 src/views/StockCommunity/components/RightPanel/ThreadList.tsx create mode 100644 src/views/StockCommunity/components/RightPanel/index.tsx create mode 100644 src/views/StockCommunity/hooks/useCommunitySocket.ts create mode 100644 src/views/StockCommunity/index.tsx create mode 100644 src/views/StockCommunity/services/communityService.ts create mode 100644 src/views/StockCommunity/types/index.ts diff --git a/__pycache__/community_api.cpython-310.pyc b/__pycache__/community_api.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..41066c4a9873db7a2ad17becb94e6e83877e446c GIT binary patch literal 18143 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 K?mMx5tNstbZ1nH| literal 0 HcmV?d00001 diff --git a/app.py b/app.py index b2da41c2..478dda39 100755 --- a/app.py +++ b/app.py @@ -19715,6 +19715,18 @@ def settle_time_capsule(topic_id): return jsonify({'success': False, 'error': str(e)}), 500 +# ============================================================ +# 注册股票社区 Blueprint 和 WebSocket 事件 +# ============================================================ +from community_api import community_bp, register_community_socketio + +# 注册 Blueprint +app.register_blueprint(community_bp) + +# 注册 WebSocket 事件(必须在 socketio 初始化之后) +register_community_socketio(socketio) + + if __name__ == '__main__': # 创建数据库表 with app.app_context(): diff --git a/community_api.py b/community_api.py new file mode 100644 index 00000000..730e88c1 --- /dev/null +++ b/community_api.py @@ -0,0 +1,759 @@ +# -*- coding: utf-8 -*- +""" +股票社区 API - Discord 风格 +包含:频道、消息、帖子、回复、表情反应等接口 +""" + +import uuid +from datetime import datetime +from functools import wraps + +from flask import Blueprint, request, jsonify, session, g +from elasticsearch import Elasticsearch +from sqlalchemy import create_engine, text + +# ============================================================ +# Blueprint 和数据库连接 +# ============================================================ + +community_bp = Blueprint('community', __name__, url_prefix='/api/community') + +# ES 客户端(与 app.py 共享配置) +es_client = Elasticsearch( + hosts=["http://222.128.1.157:19200"], + request_timeout=30, + max_retries=3, + retry_on_timeout=True +) + +# MySQL 连接(与 app.py 共享配置) +DATABASE_URL = "mysql+pymysql://root:wangzhe66@222.128.1.157:3307/stock_analysis?charset=utf8mb4" +engine = create_engine(DATABASE_URL, pool_pre_ping=True, pool_recycle=3600) + + +# ============================================================ +# 工具函数 +# ============================================================ + +def generate_id(): + """生成唯一 ID""" + return str(uuid.uuid4()).replace('-', '')[:16] + + +def get_current_user(): + """获取当前登录用户""" + user_id = session.get('user_id') + if not user_id: + return None + return { + 'id': str(user_id), + 'username': session.get('username', '匿名用户'), + 'avatar': session.get('avatar', ''), + } + + +def login_required(f): + """登录验证装饰器""" + @wraps(f) + def decorated_function(*args, **kwargs): + user = get_current_user() + if not user: + return jsonify({'code': 401, 'message': '请先登录'}), 401 + g.current_user = user + return f(*args, **kwargs) + return decorated_function + + +def api_response(data=None, message='success', code=200): + """统一 API 响应格式""" + return jsonify({ + 'code': code, + 'message': message, + 'data': data + }) + + +def api_error(message, code=400): + """API 错误响应""" + return jsonify({ + 'code': code, + 'message': message + }), code if code >= 400 else 400 + + +# ============================================================ +# 频道相关 API +# ============================================================ + +@community_bp.route('/channels', methods=['GET']) +def get_channels(): + """ + 获取频道列表(按分类组织) + 返回格式:[{ id, name, icon, channels: [...] }, ...] + """ + try: + with engine.connect() as conn: + # 查询分类 + categories_sql = text(""" + SELECT id, name, icon, position, is_collapsible, is_system + FROM community_categories + ORDER BY position + """) + categories_result = conn.execute(categories_sql).fetchall() + + # 查询频道 + channels_sql = text(""" + SELECT + c.id, c.category_id, c.name, c.type, c.topic, c.position, + c.concept_code, c.slow_mode, c.is_readonly, c.is_system, + c.subscriber_count, c.message_count, c.last_message_at, + cc.concept_name, cc.stock_count, cc.is_hot + FROM community_channels c + LEFT JOIN community_concept_channels cc ON c.concept_code = cc.concept_code + WHERE c.is_visible = 1 + ORDER BY c.position + """) + channels_result = conn.execute(channels_sql).fetchall() + + # 组装数据 + channels_by_category = {} + for ch in channels_result: + cat_id = ch.category_id + if cat_id not in channels_by_category: + channels_by_category[cat_id] = [] + channels_by_category[cat_id].append({ + 'id': ch.id, + 'categoryId': ch.category_id, + 'name': ch.name, + 'type': ch.type, + 'topic': ch.topic, + 'position': ch.position, + 'conceptCode': ch.concept_code, + 'slowMode': ch.slow_mode or 0, + 'isReadonly': bool(ch.is_readonly), + 'isSystem': bool(ch.is_system), + 'subscriberCount': ch.subscriber_count or 0, + 'messageCount': ch.message_count or 0, + 'lastMessageAt': ch.last_message_at.isoformat() if ch.last_message_at else None, + 'conceptName': ch.concept_name, + 'stockCount': ch.stock_count, + 'isHot': bool(ch.is_hot) if ch.is_hot is not None else False, + }) + + result = [] + for cat in categories_result: + result.append({ + 'id': cat.id, + 'name': cat.name, + 'icon': cat.icon or '', + 'position': cat.position, + 'isCollapsible': bool(cat.is_collapsible), + 'isSystem': bool(cat.is_system), + 'channels': channels_by_category.get(cat.id, []) + }) + + return api_response(result) + + except Exception as e: + print(f"[Community API] 获取频道列表失败: {e}") + return api_error(f'获取频道列表失败: {str(e)}', 500) + + +@community_bp.route('/channels/', methods=['GET']) +def get_channel(channel_id): + """获取单个频道详情""" + try: + with engine.connect() as conn: + sql = text(""" + SELECT + c.*, cc.concept_name, cc.stock_count, cc.is_hot + FROM community_channels c + LEFT JOIN community_concept_channels cc ON c.concept_code = cc.concept_code + WHERE c.id = :channel_id + """) + result = conn.execute(sql, {'channel_id': channel_id}).fetchone() + + if not result: + return api_error('频道不存在', 404) + + return api_response({ + 'id': result.id, + 'categoryId': result.category_id, + 'name': result.name, + 'type': result.type, + 'topic': result.topic, + 'conceptCode': result.concept_code, + 'slowMode': result.slow_mode or 0, + 'isReadonly': bool(result.is_readonly), + 'subscriberCount': result.subscriber_count or 0, + 'messageCount': result.message_count or 0, + 'conceptName': result.concept_name, + 'stockCount': result.stock_count, + 'isHot': bool(result.is_hot) if result.is_hot is not None else False, + }) + + except Exception as e: + return api_error(f'获取频道失败: {str(e)}', 500) + + +@community_bp.route('/channels//subscribe', methods=['POST']) +@login_required +def subscribe_channel(channel_id): + """订阅频道""" + try: + user = g.current_user + subscription_id = generate_id() + + with engine.connect() as conn: + # 检查是否已订阅 + check_sql = text(""" + SELECT id FROM community_subscriptions + WHERE user_id = :user_id AND channel_id = :channel_id + """) + existing = conn.execute(check_sql, { + 'user_id': user['id'], + 'channel_id': channel_id + }).fetchone() + + if existing: + return api_response(message='已订阅') + + # 创建订阅 + insert_sql = text(""" + INSERT INTO community_subscriptions (id, user_id, channel_id, notification_level) + VALUES (:id, :user_id, :channel_id, 'all') + """) + conn.execute(insert_sql, { + 'id': subscription_id, + 'user_id': user['id'], + 'channel_id': channel_id + }) + + # 更新订阅数 + update_sql = text(""" + UPDATE community_channels + SET subscriber_count = subscriber_count + 1 + WHERE id = :channel_id + """) + conn.execute(update_sql, {'channel_id': channel_id}) + conn.commit() + + return api_response(message='订阅成功') + + except Exception as e: + return api_error(f'订阅失败: {str(e)}', 500) + + +@community_bp.route('/channels//unsubscribe', methods=['POST']) +@login_required +def unsubscribe_channel(channel_id): + """取消订阅频道""" + try: + user = g.current_user + + with engine.connect() as conn: + # 删除订阅 + delete_sql = text(""" + DELETE FROM community_subscriptions + WHERE user_id = :user_id AND channel_id = :channel_id + """) + result = conn.execute(delete_sql, { + 'user_id': user['id'], + 'channel_id': channel_id + }) + + if result.rowcount > 0: + # 更新订阅数 + update_sql = text(""" + UPDATE community_channels + SET subscriber_count = GREATEST(subscriber_count - 1, 0) + WHERE id = :channel_id + """) + conn.execute(update_sql, {'channel_id': channel_id}) + + conn.commit() + + return api_response(message='取消订阅成功') + + except Exception as e: + return api_error(f'取消订阅失败: {str(e)}', 500) + + +# ============================================================ +# 消息相关 API(即时聊天) +# ============================================================ + +@community_bp.route('/channels//messages', methods=['POST']) +@login_required +def send_message(channel_id): + """发送消息""" + try: + user = g.current_user + data = request.get_json() + + content = data.get('content', '').strip() + if not content: + return api_error('消息内容不能为空') + + message_id = generate_id() + now = datetime.utcnow() + + # 构建消息文档 + message_doc = { + 'id': message_id, + 'channel_id': channel_id, + 'thread_id': data.get('threadId'), + 'author_id': user['id'], + 'author_name': user['username'], + 'author_avatar': user.get('avatar', ''), + 'content': content, + 'type': 'text', + 'mentioned_users': data.get('mentionedUsers', []), + 'mentioned_stocks': data.get('mentionedStocks', []), + 'mentioned_everyone': data.get('mentionedEveryone', False), + 'reply_to': data.get('replyTo'), + 'reactions': {}, + 'reaction_count': 0, + 'is_pinned': False, + 'is_edited': False, + 'is_deleted': False, + 'created_at': now.isoformat(), + } + + # 写入 ES + es_client.index( + index='community_messages', + id=message_id, + document=message_doc, + refresh=True + ) + + # 更新频道最后消息时间和消息数 + with engine.connect() as conn: + update_sql = text(""" + UPDATE community_channels + SET message_count = message_count + 1, + last_message_id = :message_id, + last_message_at = :now + WHERE id = :channel_id + """) + conn.execute(update_sql, { + 'message_id': message_id, + 'now': now, + 'channel_id': channel_id + }) + conn.commit() + + # 转换字段名为 camelCase + response_data = { + 'id': message_doc['id'], + 'channelId': message_doc['channel_id'], + 'threadId': message_doc['thread_id'], + 'authorId': message_doc['author_id'], + 'authorName': message_doc['author_name'], + 'authorAvatar': message_doc['author_avatar'], + 'content': message_doc['content'], + 'type': message_doc['type'], + 'mentionedUsers': message_doc['mentioned_users'], + 'mentionedStocks': message_doc['mentioned_stocks'], + 'replyTo': message_doc['reply_to'], + 'reactions': message_doc['reactions'], + 'reactionCount': message_doc['reaction_count'], + 'isPinned': message_doc['is_pinned'], + 'isEdited': message_doc['is_edited'], + 'createdAt': message_doc['created_at'], + } + + return api_response(response_data) + + except Exception as e: + print(f"[Community API] 发送消息失败: {e}") + return api_error(f'发送消息失败: {str(e)}', 500) + + +@community_bp.route('/messages//reactions', methods=['POST']) +@login_required +def add_reaction(message_id): + """添加表情反应""" + try: + user = g.current_user + data = request.get_json() + emoji = data.get('emoji') + + if not emoji: + return api_error('请选择表情') + + # 获取当前消息 + result = es_client.get(index='community_messages', id=message_id) + message = result['_source'] + + # 更新 reactions + reactions = message.get('reactions', {}) + if emoji not in reactions: + reactions[emoji] = [] + + if user['id'] not in reactions[emoji]: + reactions[emoji].append(user['id']) + + # 计算总数 + reaction_count = sum(len(users) for users in reactions.values()) + + # 更新 ES + es_client.update( + index='community_messages', + id=message_id, + doc={'reactions': reactions, 'reaction_count': reaction_count}, + refresh=True + ) + + return api_response(message='添加成功') + + except Exception as e: + return api_error(f'添加表情失败: {str(e)}', 500) + + +@community_bp.route('/messages//reactions/', methods=['DELETE']) +@login_required +def remove_reaction(message_id, emoji): + """移除表情反应""" + try: + user = g.current_user + + # 获取当前消息 + result = es_client.get(index='community_messages', id=message_id) + message = result['_source'] + + # 更新 reactions + reactions = message.get('reactions', {}) + if emoji in reactions and user['id'] in reactions[emoji]: + reactions[emoji].remove(user['id']) + if not reactions[emoji]: + del reactions[emoji] + + # 计算总数 + reaction_count = sum(len(users) for users in reactions.values()) + + # 更新 ES + es_client.update( + index='community_messages', + id=message_id, + doc={'reactions': reactions, 'reaction_count': reaction_count}, + refresh=True + ) + + return api_response(message='移除成功') + + except Exception as e: + return api_error(f'移除表情失败: {str(e)}', 500) + + +# ============================================================ +# Forum 帖子相关 API +# ============================================================ + +@community_bp.route('/channels//posts', methods=['POST']) +@login_required +def create_post(channel_id): + """创建帖子""" + try: + user = g.current_user + data = request.get_json() + + title = data.get('title', '').strip() + content = data.get('content', '').strip() + + if not title: + return api_error('标题不能为空') + if not content: + return api_error('内容不能为空') + + post_id = generate_id() + now = datetime.utcnow() + + # 构建帖子文档 + post_doc = { + 'id': post_id, + 'channel_id': channel_id, + 'author_id': user['id'], + 'author_name': user['username'], + 'author_avatar': user.get('avatar', ''), + 'title': title, + 'content': content, + 'content_html': content, # 可以后续添加 Markdown 渲染 + 'tags': data.get('tags', []), + 'stock_symbols': data.get('stockSymbols', []), + 'is_pinned': False, + 'is_locked': False, + 'is_deleted': False, + 'reply_count': 0, + 'view_count': 0, + 'like_count': 0, + 'last_reply_at': None, + 'last_reply_by': None, + 'created_at': now.isoformat(), + 'updated_at': now.isoformat(), + } + + # 写入 ES + es_client.index( + index='community_forum_posts', + id=post_id, + document=post_doc, + refresh=True + ) + + # 更新频道消息数 + with engine.connect() as conn: + update_sql = text(""" + UPDATE community_channels + SET message_count = message_count + 1, + last_message_at = :now + WHERE id = :channel_id + """) + conn.execute(update_sql, {'now': now, 'channel_id': channel_id}) + conn.commit() + + # 转换字段名 + response_data = { + 'id': post_doc['id'], + 'channelId': post_doc['channel_id'], + 'authorId': post_doc['author_id'], + 'authorName': post_doc['author_name'], + 'authorAvatar': post_doc['author_avatar'], + 'title': post_doc['title'], + 'content': post_doc['content'], + 'tags': post_doc['tags'], + 'stockSymbols': post_doc['stock_symbols'], + 'isPinned': post_doc['is_pinned'], + 'isLocked': post_doc['is_locked'], + 'replyCount': post_doc['reply_count'], + 'viewCount': post_doc['view_count'], + 'likeCount': post_doc['like_count'], + 'createdAt': post_doc['created_at'], + } + + return api_response(response_data) + + except Exception as e: + print(f"[Community API] 创建帖子失败: {e}") + return api_error(f'创建帖子失败: {str(e)}', 500) + + +@community_bp.route('/posts//like', methods=['POST']) +@login_required +def like_post(post_id): + """点赞帖子""" + try: + # 更新 ES(简单实现,生产环境应该用单独的点赞表防止重复) + es_client.update( + index='community_forum_posts', + id=post_id, + script={ + 'source': 'ctx._source.like_count += 1', + 'lang': 'painless' + }, + refresh=True + ) + + return api_response(message='点赞成功') + + except Exception as e: + return api_error(f'点赞失败: {str(e)}', 500) + + +@community_bp.route('/posts//view', methods=['POST']) +def increment_view(post_id): + """增加帖子浏览量""" + try: + es_client.update( + index='community_forum_posts', + id=post_id, + script={ + 'source': 'ctx._source.view_count += 1', + 'lang': 'painless' + } + ) + return api_response(message='success') + + except Exception as e: + # 浏览量统计失败不影响主流程 + return api_response(message='success') + + +@community_bp.route('/posts//replies', methods=['POST']) +@login_required +def create_reply(post_id): + """创建帖子回复""" + try: + user = g.current_user + data = request.get_json() + + content = data.get('content', '').strip() + if not content: + return api_error('回复内容不能为空') + + reply_id = generate_id() + now = datetime.utcnow() + + # 获取帖子信息 + post_result = es_client.get(index='community_forum_posts', id=post_id) + post = post_result['_source'] + + # 构建回复文档 + reply_doc = { + 'id': reply_id, + 'post_id': post_id, + 'channel_id': post['channel_id'], + 'author_id': user['id'], + 'author_name': user['username'], + 'author_avatar': user.get('avatar', ''), + 'content': content, + 'content_html': content, + 'reply_to': data.get('replyTo'), + 'reactions': {}, + 'like_count': 0, + 'is_solution': False, + 'is_deleted': False, + 'created_at': now.isoformat(), + } + + # 写入 ES + es_client.index( + index='community_forum_replies', + id=reply_id, + document=reply_doc, + refresh=True + ) + + # 更新帖子的回复数和最后回复时间 + es_client.update( + index='community_forum_posts', + id=post_id, + script={ + 'source': ''' + ctx._source.reply_count += 1; + ctx._source.last_reply_at = params.now; + ctx._source.last_reply_by = params.author_name; + ''', + 'lang': 'painless', + 'params': { + 'now': now.isoformat(), + 'author_name': user['username'] + } + }, + refresh=True + ) + + # 转换字段名 + response_data = { + 'id': reply_doc['id'], + 'postId': reply_doc['post_id'], + 'channelId': reply_doc['channel_id'], + 'authorId': reply_doc['author_id'], + 'authorName': reply_doc['author_name'], + 'authorAvatar': reply_doc['author_avatar'], + 'content': reply_doc['content'], + 'replyTo': reply_doc['reply_to'], + 'likeCount': reply_doc['like_count'], + 'createdAt': reply_doc['created_at'], + } + + return api_response(response_data) + + except Exception as e: + print(f"[Community API] 创建回复失败: {e}") + return api_error(f'创建回复失败: {str(e)}', 500) + + +# ============================================================ +# ES 代理接口(前端直接查询 ES) +# ============================================================ + +@community_bp.route('/es//_search', methods=['POST']) +def es_search_proxy(index): + """ + ES 搜索代理 + 允许的索引:community_messages, community_forum_posts, community_forum_replies + """ + allowed_indices = [ + 'community_messages', + 'community_forum_posts', + 'community_forum_replies', + 'community_notifications' + ] + + if index not in allowed_indices: + return api_error('不允许访问该索引', 403) + + try: + body = request.get_json() + result = es_client.search(index=index, body=body) + + return jsonify(result) + + except Exception as e: + print(f"[Community API] ES 搜索失败: {e}") + return api_error(f'搜索失败: {str(e)}', 500) + + +# ============================================================ +# WebSocket 事件处理(需要在 app.py 中注册) +# ============================================================ + +def register_community_socketio(socketio): + """ + 注册社区 WebSocket 事件 + 在 app.py 中调用:register_community_socketio(socketio) + """ + from flask_socketio import join_room, leave_room, emit + + @socketio.on('connect', namespace='/community') + def handle_connect(): + print('[Community Socket] Client connected') + + @socketio.on('disconnect', namespace='/community') + def handle_disconnect(): + print('[Community Socket] Client disconnected') + + @socketio.on('SUBSCRIBE_CHANNEL', namespace='/community') + def handle_subscribe(data): + channel_id = data.get('channelId') + if channel_id: + join_room(channel_id) + print(f'[Community Socket] Joined room: {channel_id}') + + @socketio.on('UNSUBSCRIBE_CHANNEL', namespace='/community') + def handle_unsubscribe(data): + channel_id = data.get('channelId') + if channel_id: + leave_room(channel_id) + print(f'[Community Socket] Left room: {channel_id}') + + @socketio.on('SEND_MESSAGE', namespace='/community') + def handle_send_message(data): + """通过 WebSocket 发送消息(实时广播)""" + channel_id = data.get('channelId') + # 广播给频道内所有用户 + emit('MESSAGE_CREATE', data, room=channel_id, include_self=False) + + @socketio.on('START_TYPING', namespace='/community') + def handle_start_typing(data): + channel_id = data.get('channelId') + user_id = session.get('user_id') + user_name = session.get('username', '匿名') + emit('TYPING_START', { + 'channelId': channel_id, + 'userId': user_id, + 'userName': user_name + }, room=channel_id, include_self=False) + + @socketio.on('STOP_TYPING', namespace='/community') + def handle_stop_typing(data): + channel_id = data.get('channelId') + user_id = session.get('user_id') + emit('TYPING_STOP', { + 'channelId': channel_id, + 'userId': user_id + }, room=channel_id, include_self=False) + + print('✅ Community WebSocket 事件已注册') diff --git a/src/routes/lazy-components.js b/src/routes/lazy-components.js index 664e464b..6f281c06 100644 --- a/src/routes/lazy-components.js +++ b/src/routes/lazy-components.js @@ -42,11 +42,14 @@ export const lazyComponents = { // Agent模块 AgentChat: React.lazy(() => import('@views/AgentChat')), - // 价值论坛模块 + // 价值论坛模块(旧版) ValueForum: React.lazy(() => import('@views/ValueForum')), ForumPostDetail: React.lazy(() => import('@views/ValueForum/PostDetail')), PredictionTopicDetail: React.lazy(() => import('@views/ValueForum/PredictionTopicDetail')), + // 股票社区(Discord 风格,新版) + StockCommunity: React.lazy(() => import('@views/StockCommunity')), + // 数据浏览器模块 DataBrowser: React.lazy(() => import('@views/DataBrowser')), }; @@ -77,5 +80,6 @@ export const { AgentChat, ValueForum, ForumPostDetail, + StockCommunity, DataBrowser, } = lazyComponents; diff --git a/src/routes/routeConfig.js b/src/routes/routeConfig.js index 220e11d2..c8d02ebd 100644 --- a/src/routes/routeConfig.js +++ b/src/routes/routeConfig.js @@ -160,7 +160,19 @@ export const routeConfig = [ } }, - // ==================== 价值论坛模块 ==================== + // ==================== 股票社区(Discord 风格,新版) ==================== + { + path: 'stock-community', + component: lazyComponents.StockCommunity, + protection: PROTECTION_MODES.MODAL, + layout: 'main', + meta: { + title: '股票社区', + description: 'Discord 风格股票社区' + } + }, + + // ==================== 价值论坛模块(旧版,保留兼容) ==================== { path: 'value-forum', component: lazyComponents.ValueForum, diff --git a/src/views/StockCommunity/components/ChannelSidebar/index.tsx b/src/views/StockCommunity/components/ChannelSidebar/index.tsx new file mode 100644 index 00000000..1a52fb9d --- /dev/null +++ b/src/views/StockCommunity/components/ChannelSidebar/index.tsx @@ -0,0 +1,303 @@ +/** + * 频道侧边栏组件 + * 显示频道分类和频道列表 + */ +import React, { useState, useEffect, useCallback } from 'react'; +import { + Box, + VStack, + Text, + Flex, + Icon, + Collapse, + useColorModeValue, + Spinner, + Input, + InputGroup, + InputLeftElement, + Badge, + Tooltip, +} from '@chakra-ui/react'; +import { + ChevronDownIcon, + ChevronRightIcon, + SearchIcon, +} from '@chakra-ui/icons'; +import { + MdAnnouncement, + MdChat, + MdForum, + MdTrendingUp, + MdStar, +} from 'react-icons/md'; + +import { Channel, ChannelCategory, ChannelType } from '../../types'; +import { getChannels } from '../../services/communityService'; + +interface ChannelSidebarProps { + activeChannelId?: string; + onChannelSelect: (channel: Channel) => void; + initialChannelId?: string | null; +} + +const ChannelSidebar: React.FC = ({ + activeChannelId, + onChannelSelect, + initialChannelId, +}) => { + const [categories, setCategories] = useState([]); + const [loading, setLoading] = useState(true); + const [searchTerm, setSearchTerm] = useState(''); + const [collapsedCategories, setCollapsedCategories] = useState>(new Set()); + + // 颜色 + const bgColor = useColorModeValue('white', 'gray.800'); + const headerBg = useColorModeValue('gray.100', 'gray.900'); + const hoverBg = useColorModeValue('gray.100', 'gray.700'); + const activeBg = useColorModeValue('blue.50', 'blue.900'); + const textColor = useColorModeValue('gray.700', 'gray.200'); + const mutedColor = useColorModeValue('gray.500', 'gray.400'); + + // 加载频道列表 + useEffect(() => { + const loadChannels = async () => { + try { + setLoading(true); + const data = await getChannels(); + setCategories(data); + + // 如果有初始频道 ID,自动选中 + if (initialChannelId) { + for (const category of data) { + const channel = category.channels.find(c => c.id === initialChannelId); + if (channel) { + onChannelSelect(channel); + break; + } + } + } else if (data.length > 0 && data[0].channels.length > 0) { + // 默认选中第一个频道 + onChannelSelect(data[0].channels[0]); + } + } catch (error) { + console.error('加载频道失败:', error); + } finally { + setLoading(false); + } + }; + + loadChannels(); + }, [initialChannelId, onChannelSelect]); + + // 切换分类折叠状态 + const toggleCategory = useCallback((categoryId: string) => { + setCollapsedCategories(prev => { + const next = new Set(prev); + if (next.has(categoryId)) { + next.delete(categoryId); + } else { + next.add(categoryId); + } + return next; + }); + }, []); + + // 获取频道图标 + const getChannelIcon = (type: ChannelType) => { + switch (type) { + case 'announcement': + return MdAnnouncement; + case 'forum': + return MdForum; + default: + return MdChat; + } + }; + + // 过滤频道 + const filterChannels = useCallback((channels: Channel[]) => { + if (!searchTerm) return channels; + const term = searchTerm.toLowerCase(); + return channels.filter( + c => c.name.toLowerCase().includes(term) || + c.conceptName?.toLowerCase().includes(term) + ); + }, [searchTerm]); + + // 渲染频道项 + const renderChannelItem = (channel: Channel) => { + const isActive = channel.id === activeChannelId; + const ChannelIcon = getChannelIcon(channel.type); + + return ( + + onChannelSelect(channel)} + role="button" + tabIndex={0} + > + + + {channel.name} + + + {/* 热门标记 */} + {channel.isHot && ( + + )} + + {/* 概念频道股票数 */} + {channel.stockCount && channel.stockCount > 0 && ( + + {channel.stockCount} + + )} + + + ); + }; + + // 渲染分类 + const renderCategory = (category: ChannelCategory) => { + const isCollapsed = collapsedCategories.has(category.id); + const filteredChannels = filterChannels(category.channels); + + // 搜索时如果没有匹配的频道,不显示分类 + if (searchTerm && filteredChannels.length === 0) { + return null; + } + + return ( + + {/* 分类标题 */} + category.isCollapsible && toggleCategory(category.id)} + > + {category.isCollapsible && ( + + )} + + {category.icon} {category.name} + + + + {/* 频道列表 */} + + + {filteredChannels.map(renderChannelItem)} + + + + ); + }; + + if (loading) { + return ( + + + + ); + } + + return ( + + {/* 头部 */} + + + 📊 股票社区 + + + {/* 搜索框 */} + + + + + setSearchTerm(e.target.value)} + borderRadius="md" + bg={bgColor} + /> + + + + {/* 频道列表 */} + + {categories.map(renderCategory)} + + {/* 空状态 */} + {searchTerm && categories.every(c => filterChannels(c.channels).length === 0) && ( + + + 未找到匹配的频道 + + + )} + + + {/* 底部信息 */} + + + + + + 在线讨论中 + + + + 128 人 + + + + + ); +}; + +export default ChannelSidebar; diff --git a/src/views/StockCommunity/components/MessageArea/ForumChannel/CreatePostModal.tsx b/src/views/StockCommunity/components/MessageArea/ForumChannel/CreatePostModal.tsx new file mode 100644 index 00000000..a577c460 --- /dev/null +++ b/src/views/StockCommunity/components/MessageArea/ForumChannel/CreatePostModal.tsx @@ -0,0 +1,264 @@ +/** + * 创建帖子弹窗 + */ +import React, { useState } from 'react'; +import { + Modal, + ModalOverlay, + ModalContent, + ModalHeader, + ModalBody, + ModalFooter, + ModalCloseButton, + Button, + Input, + Textarea, + FormControl, + FormLabel, + FormHelperText, + HStack, + Tag, + TagLabel, + TagCloseButton, + useColorModeValue, + useToast, + Box, + Wrap, + WrapItem, +} from '@chakra-ui/react'; + +import { ForumPost } from '../../../types'; +import { createForumPost } from '../../../services/communityService'; + +interface CreatePostModalProps { + isOpen: boolean; + onClose: () => void; + channelId: string; + channelName: string; + onPostCreated: (post: ForumPost) => void; +} + +// 预设标签 +const PRESET_TAGS = ['分析', '策略', '新闻', '提问', '讨论', '复盘', '干货', '观点']; + +const CreatePostModal: React.FC = ({ + isOpen, + onClose, + channelId, + channelName, + onPostCreated, +}) => { + const [title, setTitle] = useState(''); + const [content, setContent] = useState(''); + const [tags, setTags] = useState([]); + const [customTag, setCustomTag] = useState(''); + const [submitting, setSubmitting] = useState(false); + + const toast = useToast(); + const tagBg = useColorModeValue('gray.100', 'gray.700'); + + // 添加标签 + const addTag = (tag: string) => { + if (tag && !tags.includes(tag) && tags.length < 5) { + setTags([...tags, tag]); + } + }; + + // 移除标签 + const removeTag = (tag: string) => { + setTags(tags.filter(t => t !== tag)); + }; + + // 添加自定义标签 + const handleAddCustomTag = () => { + if (customTag.trim()) { + addTag(customTag.trim()); + setCustomTag(''); + } + }; + + // 提交帖子 + const handleSubmit = async () => { + if (!title.trim()) { + toast({ + title: '请输入标题', + status: 'warning', + duration: 2000, + }); + return; + } + + if (!content.trim()) { + toast({ + title: '请输入内容', + status: 'warning', + duration: 2000, + }); + return; + } + + try { + setSubmitting(true); + + const newPost = await createForumPost({ + channelId, + title: title.trim(), + content: content.trim(), + tags, + }); + + toast({ + title: '发布成功', + status: 'success', + duration: 2000, + }); + + // 重置表单 + setTitle(''); + setContent(''); + setTags([]); + + onPostCreated(newPost); + } catch (error) { + console.error('发布失败:', error); + toast({ + title: '发布失败', + description: '请稍后重试', + status: 'error', + duration: 3000, + }); + } finally { + setSubmitting(false); + } + }; + + // 关闭时重置 + const handleClose = () => { + setTitle(''); + setContent(''); + setTags([]); + setCustomTag(''); + onClose(); + }; + + return ( + + + + + 在 #{channelName} 发布帖子 + + + + + {/* 标题 */} + + 标题 + setTitle(e.target.value)} + placeholder="输入帖子标题" + maxLength={100} + /> + + {title.length}/100 + + + + {/* 内容 */} + + 内容 +