个股论坛重做
This commit is contained in:
BIN
__pycache__/community_api.cpython-310.pyc
Normal file
BIN
__pycache__/community_api.cpython-310.pyc
Normal file
Binary file not shown.
12
app.py
12
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():
|
||||
|
||||
759
community_api.py
Normal file
759
community_api.py
Normal file
@@ -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/<channel_id>', 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/<channel_id>/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/<channel_id>/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/<channel_id>/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/<message_id>/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/<message_id>/reactions/<emoji>', 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/<channel_id>/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/<post_id>/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/<post_id>/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/<post_id>/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/<index>/_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 事件已注册')
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
303
src/views/StockCommunity/components/ChannelSidebar/index.tsx
Normal file
303
src/views/StockCommunity/components/ChannelSidebar/index.tsx
Normal file
@@ -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<ChannelSidebarProps> = ({
|
||||
activeChannelId,
|
||||
onChannelSelect,
|
||||
initialChannelId,
|
||||
}) => {
|
||||
const [categories, setCategories] = useState<ChannelCategory[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [collapsedCategories, setCollapsedCategories] = useState<Set<string>>(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 (
|
||||
<Tooltip
|
||||
key={channel.id}
|
||||
label={channel.topic}
|
||||
placement="right"
|
||||
hasArrow
|
||||
isDisabled={!channel.topic}
|
||||
>
|
||||
<Flex
|
||||
align="center"
|
||||
px={3}
|
||||
py={2}
|
||||
mx={2}
|
||||
borderRadius="md"
|
||||
cursor="pointer"
|
||||
bg={isActive ? activeBg : 'transparent'}
|
||||
_hover={{ bg: isActive ? activeBg : hoverBg }}
|
||||
onClick={() => onChannelSelect(channel)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<Icon
|
||||
as={ChannelIcon}
|
||||
boxSize={5}
|
||||
color={isActive ? 'blue.500' : mutedColor}
|
||||
mr={2}
|
||||
/>
|
||||
<Text
|
||||
flex={1}
|
||||
fontSize="sm"
|
||||
fontWeight={isActive ? 'semibold' : 'normal'}
|
||||
color={isActive ? 'blue.500' : textColor}
|
||||
isTruncated
|
||||
>
|
||||
{channel.name}
|
||||
</Text>
|
||||
|
||||
{/* 热门标记 */}
|
||||
{channel.isHot && (
|
||||
<Icon as={MdStar} boxSize={4} color="orange.400" mr={1} />
|
||||
)}
|
||||
|
||||
{/* 概念频道股票数 */}
|
||||
{channel.stockCount && channel.stockCount > 0 && (
|
||||
<Badge
|
||||
size="sm"
|
||||
colorScheme="gray"
|
||||
fontSize="xs"
|
||||
borderRadius="full"
|
||||
>
|
||||
{channel.stockCount}
|
||||
</Badge>
|
||||
)}
|
||||
</Flex>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染分类
|
||||
const renderCategory = (category: ChannelCategory) => {
|
||||
const isCollapsed = collapsedCategories.has(category.id);
|
||||
const filteredChannels = filterChannels(category.channels);
|
||||
|
||||
// 搜索时如果没有匹配的频道,不显示分类
|
||||
if (searchTerm && filteredChannels.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box key={category.id} mb={2}>
|
||||
{/* 分类标题 */}
|
||||
<Flex
|
||||
align="center"
|
||||
px={3}
|
||||
py={1.5}
|
||||
cursor={category.isCollapsible ? 'pointer' : 'default'}
|
||||
_hover={category.isCollapsible ? { bg: hoverBg } : undefined}
|
||||
onClick={() => category.isCollapsible && toggleCategory(category.id)}
|
||||
>
|
||||
{category.isCollapsible && (
|
||||
<Icon
|
||||
as={isCollapsed ? ChevronRightIcon : ChevronDownIcon}
|
||||
boxSize={4}
|
||||
color={mutedColor}
|
||||
mr={1}
|
||||
/>
|
||||
)}
|
||||
<Text
|
||||
fontSize="xs"
|
||||
fontWeight="bold"
|
||||
color={mutedColor}
|
||||
textTransform="uppercase"
|
||||
letterSpacing="wide"
|
||||
>
|
||||
{category.icon} {category.name}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
{/* 频道列表 */}
|
||||
<Collapse in={!isCollapsed} animateOpacity>
|
||||
<VStack spacing={0} align="stretch">
|
||||
{filteredChannels.map(renderChannelItem)}
|
||||
</VStack>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Flex h="full" align="center" justify="center" bg={bgColor}>
|
||||
<Spinner size="lg" color="blue.500" />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box h="full" bg={bgColor} display="flex" flexDirection="column">
|
||||
{/* 头部 */}
|
||||
<Box px={4} py={3} bg={headerBg} borderBottomWidth="1px">
|
||||
<Text fontSize="lg" fontWeight="bold" mb={3}>
|
||||
📊 股票社区
|
||||
</Text>
|
||||
|
||||
{/* 搜索框 */}
|
||||
<InputGroup size="sm">
|
||||
<InputLeftElement>
|
||||
<SearchIcon color={mutedColor} />
|
||||
</InputLeftElement>
|
||||
<Input
|
||||
placeholder="搜索频道..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
borderRadius="md"
|
||||
bg={bgColor}
|
||||
/>
|
||||
</InputGroup>
|
||||
</Box>
|
||||
|
||||
{/* 频道列表 */}
|
||||
<Box flex={1} overflowY="auto" py={2}>
|
||||
{categories.map(renderCategory)}
|
||||
|
||||
{/* 空状态 */}
|
||||
{searchTerm && categories.every(c => filterChannels(c.channels).length === 0) && (
|
||||
<Flex justify="center" py={8}>
|
||||
<Text color={mutedColor} fontSize="sm">
|
||||
未找到匹配的频道
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 底部信息 */}
|
||||
<Box px={4} py={3} borderTopWidth="1px" bg={headerBg}>
|
||||
<Flex align="center" justify="space-between">
|
||||
<Flex align="center">
|
||||
<Icon as={MdTrendingUp} color="green.500" mr={1} />
|
||||
<Text fontSize="xs" color={mutedColor}>
|
||||
在线讨论中
|
||||
</Text>
|
||||
</Flex>
|
||||
<Badge colorScheme="green" fontSize="xs">
|
||||
128 人
|
||||
</Badge>
|
||||
</Flex>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelSidebar;
|
||||
@@ -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<CreatePostModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
channelId,
|
||||
channelName,
|
||||
onPostCreated,
|
||||
}) => {
|
||||
const [title, setTitle] = useState('');
|
||||
const [content, setContent] = useState('');
|
||||
const [tags, setTags] = useState<string[]>([]);
|
||||
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 (
|
||||
<Modal isOpen={isOpen} onClose={handleClose} size="xl">
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
在 #{channelName} 发布帖子
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
|
||||
<ModalBody>
|
||||
{/* 标题 */}
|
||||
<FormControl mb={4} isRequired>
|
||||
<FormLabel>标题</FormLabel>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="输入帖子标题"
|
||||
maxLength={100}
|
||||
/>
|
||||
<FormHelperText textAlign="right">
|
||||
{title.length}/100
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
{/* 内容 */}
|
||||
<FormControl mb={4} isRequired>
|
||||
<FormLabel>内容</FormLabel>
|
||||
<Textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="分享你的观点..."
|
||||
rows={8}
|
||||
maxLength={10000}
|
||||
/>
|
||||
<FormHelperText textAlign="right">
|
||||
{content.length}/10000
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
{/* 标签 */}
|
||||
<FormControl mb={4}>
|
||||
<FormLabel>标签(最多5个)</FormLabel>
|
||||
|
||||
{/* 已选标签 */}
|
||||
{tags.length > 0 && (
|
||||
<HStack spacing={2} mb={2} flexWrap="wrap">
|
||||
{tags.map(tag => (
|
||||
<Tag
|
||||
key={tag}
|
||||
size="md"
|
||||
colorScheme="blue"
|
||||
borderRadius="full"
|
||||
>
|
||||
<TagLabel>{tag}</TagLabel>
|
||||
<TagCloseButton onClick={() => removeTag(tag)} />
|
||||
</Tag>
|
||||
))}
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
{/* 预设标签 */}
|
||||
<Box mb={2}>
|
||||
<Wrap spacing={2}>
|
||||
{PRESET_TAGS.filter(t => !tags.includes(t)).map(tag => (
|
||||
<WrapItem key={tag}>
|
||||
<Tag
|
||||
size="sm"
|
||||
bg={tagBg}
|
||||
cursor="pointer"
|
||||
_hover={{ opacity: 0.8 }}
|
||||
onClick={() => addTag(tag)}
|
||||
>
|
||||
+ {tag}
|
||||
</Tag>
|
||||
</WrapItem>
|
||||
))}
|
||||
</Wrap>
|
||||
</Box>
|
||||
|
||||
{/* 自定义标签 */}
|
||||
{tags.length < 5 && (
|
||||
<HStack>
|
||||
<Input
|
||||
size="sm"
|
||||
value={customTag}
|
||||
onChange={(e) => setCustomTag(e.target.value)}
|
||||
placeholder="输入自定义标签"
|
||||
maxLength={20}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleAddCustomTag();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button size="sm" onClick={handleAddCustomTag}>
|
||||
添加
|
||||
</Button>
|
||||
</HStack>
|
||||
)}
|
||||
</FormControl>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" mr={3} onClick={handleClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
isLoading={submitting}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
发布
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreatePostModal;
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* 帖子卡片组件
|
||||
*/
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
Text,
|
||||
Avatar,
|
||||
HStack,
|
||||
Tag,
|
||||
Icon,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { MdChat, MdVisibility, MdThumbUp, MdPushPin, MdLock } from 'react-icons/md';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { zhCN } from 'date-fns/locale';
|
||||
|
||||
import { ForumPost } from '../../../types';
|
||||
|
||||
interface PostCardProps {
|
||||
post: ForumPost;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const PostCard: React.FC<PostCardProps> = ({ post, onClick }) => {
|
||||
const bgColor = useColorModeValue('white', 'gray.800');
|
||||
const hoverBg = useColorModeValue('gray.50', 'gray.700');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
const textColor = useColorModeValue('gray.800', 'gray.100');
|
||||
const mutedColor = useColorModeValue('gray.500', 'gray.400');
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (dateStr: string) => {
|
||||
return formatDistanceToNow(new Date(dateStr), {
|
||||
addSuffix: true,
|
||||
locale: zhCN,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
bg={bgColor}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
borderRadius="lg"
|
||||
p={4}
|
||||
mb={3}
|
||||
cursor="pointer"
|
||||
transition="all 0.2s"
|
||||
_hover={{ bg: hoverBg, transform: 'translateY(-1px)', shadow: 'sm' }}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Flex>
|
||||
{/* 作者头像 */}
|
||||
<Avatar
|
||||
size="md"
|
||||
name={post.authorName}
|
||||
src={post.authorAvatar}
|
||||
mr={3}
|
||||
/>
|
||||
|
||||
{/* 帖子内容 */}
|
||||
<Box flex={1}>
|
||||
{/* 标题和标记 */}
|
||||
<HStack spacing={2} mb={1}>
|
||||
{post.isPinned && (
|
||||
<Icon as={MdPushPin} color="blue.500" boxSize={4} />
|
||||
)}
|
||||
{post.isLocked && (
|
||||
<Icon as={MdLock} color="orange.500" boxSize={4} />
|
||||
)}
|
||||
<Text
|
||||
fontWeight="bold"
|
||||
fontSize="md"
|
||||
color={textColor}
|
||||
noOfLines={1}
|
||||
>
|
||||
{post.title}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{/* 内容预览 */}
|
||||
<Text
|
||||
color={mutedColor}
|
||||
fontSize="sm"
|
||||
noOfLines={2}
|
||||
mb={2}
|
||||
>
|
||||
{post.content.replace(/<[^>]*>/g, '').slice(0, 150)}
|
||||
</Text>
|
||||
|
||||
{/* 标签 */}
|
||||
{post.tags && post.tags.length > 0 && (
|
||||
<HStack spacing={1} mb={2} flexWrap="wrap">
|
||||
{post.tags.slice(0, 3).map(tag => (
|
||||
<Tag key={tag} size="sm" colorScheme="blue" variant="subtle">
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
{post.tags.length > 3 && (
|
||||
<Text fontSize="xs" color={mutedColor}>
|
||||
+{post.tags.length - 3}
|
||||
</Text>
|
||||
)}
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
{/* 底部信息 */}
|
||||
<Flex align="center" justify="space-between">
|
||||
{/* 作者和时间 */}
|
||||
<HStack spacing={2} fontSize="sm" color={mutedColor}>
|
||||
<Text fontWeight="medium">{post.authorName}</Text>
|
||||
<Text>·</Text>
|
||||
<Text>{formatTime(post.createdAt)}</Text>
|
||||
{post.lastReplyAt && post.lastReplyAt !== post.createdAt && (
|
||||
<>
|
||||
<Text>·</Text>
|
||||
<Text>最后回复 {formatTime(post.lastReplyAt)}</Text>
|
||||
</>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
{/* 统计数据 */}
|
||||
<HStack spacing={4} fontSize="sm" color={mutedColor}>
|
||||
<HStack spacing={1}>
|
||||
<Icon as={MdChat} boxSize={4} />
|
||||
<Text>{post.replyCount}</Text>
|
||||
</HStack>
|
||||
<HStack spacing={1}>
|
||||
<Icon as={MdVisibility} boxSize={4} />
|
||||
<Text>{post.viewCount}</Text>
|
||||
</HStack>
|
||||
<HStack spacing={1}>
|
||||
<Icon as={MdThumbUp} boxSize={4} />
|
||||
<Text>{post.likeCount}</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</Flex>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default PostCard;
|
||||
@@ -0,0 +1,401 @@
|
||||
/**
|
||||
* 帖子详情组件
|
||||
*/
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
Text,
|
||||
Avatar,
|
||||
Button,
|
||||
IconButton,
|
||||
HStack,
|
||||
VStack,
|
||||
Tag,
|
||||
Divider,
|
||||
Textarea,
|
||||
useColorModeValue,
|
||||
Spinner,
|
||||
useToast,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
MdArrowBack,
|
||||
MdThumbUp,
|
||||
MdShare,
|
||||
MdBookmark,
|
||||
MdMoreVert,
|
||||
MdSend,
|
||||
MdLock,
|
||||
} from 'react-icons/md';
|
||||
import { format } from 'date-fns';
|
||||
import { zhCN } from 'date-fns/locale';
|
||||
|
||||
import { ForumPost, ForumReply } from '../../../types';
|
||||
import {
|
||||
getForumReplies,
|
||||
createForumReply,
|
||||
likePost,
|
||||
incrementPostView,
|
||||
} from '../../../services/communityService';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import ReplyItem from './ReplyItem';
|
||||
|
||||
interface PostDetailProps {
|
||||
post: ForumPost;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
const PostDetail: React.FC<PostDetailProps> = ({ post, onBack }) => {
|
||||
const [replies, setReplies] = useState<ForumReply[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
const [replyContent, setReplyContent] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [replyTo, setReplyTo] = useState<ForumReply | null>(null);
|
||||
const [liked, setLiked] = useState(false);
|
||||
const [likeCount, setLikeCount] = useState(post.likeCount);
|
||||
|
||||
const { user } = useAuth();
|
||||
const toast = useToast();
|
||||
|
||||
// 颜色
|
||||
const bgColor = useColorModeValue('white', 'gray.800');
|
||||
const headerBg = useColorModeValue('gray.50', 'gray.900');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
const textColor = useColorModeValue('gray.800', 'gray.100');
|
||||
const mutedColor = useColorModeValue('gray.500', 'gray.400');
|
||||
|
||||
// 加载回复
|
||||
const loadReplies = useCallback(async (pageNum: number = 1, append: boolean = false) => {
|
||||
try {
|
||||
if (append) {
|
||||
setLoadingMore(true);
|
||||
} else {
|
||||
setLoading(true);
|
||||
}
|
||||
|
||||
const response = await getForumReplies(post.id, {
|
||||
page: pageNum,
|
||||
pageSize: 20,
|
||||
});
|
||||
|
||||
if (append) {
|
||||
setReplies(prev => [...prev, ...response.items]);
|
||||
} else {
|
||||
setReplies(response.items);
|
||||
}
|
||||
|
||||
setHasMore(response.hasMore);
|
||||
setPage(pageNum);
|
||||
} catch (error) {
|
||||
console.error('加载回复失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [post.id]);
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
loadReplies(1);
|
||||
// 增加浏览量
|
||||
incrementPostView(post.id);
|
||||
}, [loadReplies, post.id]);
|
||||
|
||||
// 发送回复
|
||||
const handleSubmitReply = async () => {
|
||||
if (!replyContent.trim()) return;
|
||||
|
||||
try {
|
||||
setSubmitting(true);
|
||||
|
||||
const newReply = await createForumReply({
|
||||
postId: post.id,
|
||||
channelId: post.channelId,
|
||||
content: replyContent.trim(),
|
||||
replyTo: replyTo ? {
|
||||
replyId: replyTo.id,
|
||||
authorId: replyTo.authorId,
|
||||
authorName: replyTo.authorName,
|
||||
contentPreview: replyTo.content.slice(0, 50),
|
||||
} : undefined,
|
||||
});
|
||||
|
||||
setReplies(prev => [...prev, newReply]);
|
||||
setReplyContent('');
|
||||
setReplyTo(null);
|
||||
|
||||
toast({
|
||||
title: '回复成功',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('回复失败:', error);
|
||||
toast({
|
||||
title: '回复失败',
|
||||
description: '请稍后重试',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 点赞
|
||||
const handleLike = async () => {
|
||||
try {
|
||||
await likePost(post.id);
|
||||
setLiked(!liked);
|
||||
setLikeCount(prev => liked ? prev - 1 : prev + 1);
|
||||
} catch (error) {
|
||||
console.error('点赞失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (dateStr: string) => {
|
||||
return format(new Date(dateStr), 'yyyy年M月d日 HH:mm', { locale: zhCN });
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex direction="column" h="full">
|
||||
{/* 头部 */}
|
||||
<Flex
|
||||
align="center"
|
||||
px={4}
|
||||
py={3}
|
||||
bg={headerBg}
|
||||
borderBottomWidth="1px"
|
||||
borderColor={borderColor}
|
||||
>
|
||||
<IconButton
|
||||
aria-label="返回"
|
||||
icon={<MdArrowBack />}
|
||||
variant="ghost"
|
||||
mr={2}
|
||||
onClick={onBack}
|
||||
/>
|
||||
<Text fontWeight="bold" flex={1} isTruncated>
|
||||
{post.title}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
{/* 内容区域 */}
|
||||
<Box flex={1} overflowY="auto">
|
||||
{/* 帖子内容 */}
|
||||
<Box p={6} bg={bgColor}>
|
||||
{/* 标题 */}
|
||||
<Text fontSize="2xl" fontWeight="bold" mb={4}>
|
||||
{post.title}
|
||||
</Text>
|
||||
|
||||
{/* 作者信息 */}
|
||||
<Flex align="center" mb={4}>
|
||||
<Avatar
|
||||
size="md"
|
||||
name={post.authorName}
|
||||
src={post.authorAvatar}
|
||||
mr={3}
|
||||
/>
|
||||
<Box>
|
||||
<Text fontWeight="semibold">{post.authorName}</Text>
|
||||
<Text fontSize="sm" color={mutedColor}>
|
||||
发布于 {formatTime(post.createdAt)}
|
||||
{post.updatedAt && post.updatedAt !== post.createdAt && (
|
||||
<> · 编辑于 {formatTime(post.updatedAt)}</>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Flex>
|
||||
|
||||
{/* 标签 */}
|
||||
{post.tags && post.tags.length > 0 && (
|
||||
<HStack spacing={2} mb={4} flexWrap="wrap">
|
||||
{post.tags.map(tag => (
|
||||
<Tag key={tag} colorScheme="blue" size="sm">
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
{/* 正文 */}
|
||||
<Box
|
||||
className="post-content"
|
||||
fontSize="md"
|
||||
lineHeight="1.8"
|
||||
color={textColor}
|
||||
dangerouslySetInnerHTML={{ __html: post.contentHtml || post.content }}
|
||||
sx={{
|
||||
'p': { mb: 4 },
|
||||
'img': { maxW: '100%', borderRadius: 'md', my: 4 },
|
||||
'a': { color: 'blue.500', textDecoration: 'underline' },
|
||||
'blockquote': {
|
||||
borderLeftWidth: '4px',
|
||||
borderLeftColor: 'blue.400',
|
||||
pl: 4,
|
||||
py: 2,
|
||||
my: 4,
|
||||
color: mutedColor,
|
||||
},
|
||||
'code': {
|
||||
bg: 'gray.100',
|
||||
px: 1,
|
||||
borderRadius: 'sm',
|
||||
fontFamily: 'mono',
|
||||
},
|
||||
'pre': {
|
||||
bg: 'gray.100',
|
||||
p: 4,
|
||||
borderRadius: 'md',
|
||||
overflowX: 'auto',
|
||||
my: 4,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 操作栏 */}
|
||||
<Flex
|
||||
align="center"
|
||||
justify="space-between"
|
||||
pt={4}
|
||||
mt={4}
|
||||
borderTopWidth="1px"
|
||||
borderColor={borderColor}
|
||||
>
|
||||
<HStack spacing={4}>
|
||||
<Button
|
||||
leftIcon={<MdThumbUp />}
|
||||
size="sm"
|
||||
variant={liked ? 'solid' : 'ghost'}
|
||||
colorScheme={liked ? 'blue' : 'gray'}
|
||||
onClick={handleLike}
|
||||
>
|
||||
{likeCount}
|
||||
</Button>
|
||||
<Button leftIcon={<MdBookmark />} size="sm" variant="ghost">
|
||||
收藏
|
||||
</Button>
|
||||
<Button leftIcon={<MdShare />} size="sm" variant="ghost">
|
||||
分享
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
<IconButton
|
||||
aria-label="更多"
|
||||
icon={<MdMoreVert />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
/>
|
||||
</Flex>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 回复区域 */}
|
||||
<Box p={4} bg={bgColor}>
|
||||
<Text fontWeight="bold" mb={4}>
|
||||
全部回复 ({post.replyCount})
|
||||
</Text>
|
||||
|
||||
{loading ? (
|
||||
<Flex justify="center" py={8}>
|
||||
<Spinner />
|
||||
</Flex>
|
||||
) : replies.length === 0 ? (
|
||||
<Flex justify="center" py={8}>
|
||||
<Text color={mutedColor}>暂无回复,快来抢沙发!</Text>
|
||||
</Flex>
|
||||
) : (
|
||||
<VStack spacing={0} align="stretch" divider={<Divider />}>
|
||||
{replies.map(reply => (
|
||||
<ReplyItem
|
||||
key={reply.id}
|
||||
reply={reply}
|
||||
onReply={() => setReplyTo(reply)}
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
{/* 加载更多 */}
|
||||
{hasMore && !loading && (
|
||||
<Flex justify="center" py={4}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
isLoading={loadingMore}
|
||||
onClick={() => loadReplies(page + 1, true)}
|
||||
>
|
||||
加载更多
|
||||
</Button>
|
||||
</Flex>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 回复输入框 */}
|
||||
{!post.isLocked ? (
|
||||
<Box p={4} borderTopWidth="1px" borderColor={borderColor} bg={headerBg}>
|
||||
{/* 回复引用 */}
|
||||
{replyTo && (
|
||||
<Flex
|
||||
align="center"
|
||||
mb={2}
|
||||
p={2}
|
||||
bg={bgColor}
|
||||
borderRadius="md"
|
||||
fontSize="sm"
|
||||
>
|
||||
<Text flex={1} isTruncated>
|
||||
回复 <strong>{replyTo.authorName}</strong>: {replyTo.content.slice(0, 50)}
|
||||
</Text>
|
||||
<Button size="xs" variant="ghost" onClick={() => setReplyTo(null)}>
|
||||
取消
|
||||
</Button>
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
<Flex>
|
||||
<Textarea
|
||||
value={replyContent}
|
||||
onChange={(e) => setReplyContent(e.target.value)}
|
||||
placeholder="写下你的回复..."
|
||||
resize="none"
|
||||
rows={2}
|
||||
mr={2}
|
||||
/>
|
||||
<IconButton
|
||||
aria-label="发送"
|
||||
icon={<MdSend />}
|
||||
colorScheme="blue"
|
||||
isLoading={submitting}
|
||||
isDisabled={!replyContent.trim()}
|
||||
onClick={handleSubmitReply}
|
||||
/>
|
||||
</Flex>
|
||||
</Box>
|
||||
) : (
|
||||
<Flex
|
||||
align="center"
|
||||
justify="center"
|
||||
p={4}
|
||||
borderTopWidth="1px"
|
||||
borderColor={borderColor}
|
||||
bg={headerBg}
|
||||
>
|
||||
<MdLock />
|
||||
<Text ml={2} color={mutedColor}>
|
||||
该帖子已锁定,无法回复
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default PostDetail;
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* 回复项组件
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
Text,
|
||||
Avatar,
|
||||
IconButton,
|
||||
HStack,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { MdThumbUp, MdReply, MdMoreVert } from 'react-icons/md';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { zhCN } from 'date-fns/locale';
|
||||
|
||||
import { ForumReply } from '../../../types';
|
||||
|
||||
interface ReplyItemProps {
|
||||
reply: ForumReply;
|
||||
onReply: () => void;
|
||||
}
|
||||
|
||||
const ReplyItem: React.FC<ReplyItemProps> = ({ reply, onReply }) => {
|
||||
const [liked, setLiked] = useState(false);
|
||||
const [likeCount, setLikeCount] = useState(reply.likeCount);
|
||||
|
||||
const textColor = useColorModeValue('gray.800', 'gray.100');
|
||||
const mutedColor = useColorModeValue('gray.500', 'gray.400');
|
||||
const replyBg = useColorModeValue('gray.100', 'gray.700');
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (dateStr: string) => {
|
||||
return formatDistanceToNow(new Date(dateStr), {
|
||||
addSuffix: true,
|
||||
locale: zhCN,
|
||||
});
|
||||
};
|
||||
|
||||
// 点赞
|
||||
const handleLike = () => {
|
||||
setLiked(!liked);
|
||||
setLikeCount(prev => liked ? prev - 1 : prev + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box py={4}>
|
||||
<Flex>
|
||||
{/* 头像 */}
|
||||
<Avatar
|
||||
size="sm"
|
||||
name={reply.authorName}
|
||||
src={reply.authorAvatar}
|
||||
mr={3}
|
||||
/>
|
||||
|
||||
{/* 内容 */}
|
||||
<Box flex={1}>
|
||||
{/* 作者和时间 */}
|
||||
<HStack spacing={2} mb={1}>
|
||||
<Text fontWeight="semibold" fontSize="sm">
|
||||
{reply.authorName}
|
||||
</Text>
|
||||
<Text fontSize="xs" color={mutedColor}>
|
||||
{formatTime(reply.createdAt)}
|
||||
</Text>
|
||||
{reply.isSolution && (
|
||||
<Text
|
||||
fontSize="xs"
|
||||
color="green.500"
|
||||
fontWeight="semibold"
|
||||
bg="green.50"
|
||||
px={2}
|
||||
py={0.5}
|
||||
borderRadius="full"
|
||||
>
|
||||
最佳回答
|
||||
</Text>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
{/* 引用回复 */}
|
||||
{reply.replyTo && (
|
||||
<Box
|
||||
mb={2}
|
||||
p={2}
|
||||
bg={replyBg}
|
||||
borderRadius="md"
|
||||
fontSize="sm"
|
||||
color={mutedColor}
|
||||
>
|
||||
回复 <strong>{reply.replyTo.authorName}</strong>:{' '}
|
||||
{reply.replyTo.contentPreview}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 回复内容 */}
|
||||
<Text
|
||||
color={textColor}
|
||||
fontSize="sm"
|
||||
lineHeight="1.6"
|
||||
dangerouslySetInnerHTML={{ __html: reply.contentHtml || reply.content }}
|
||||
/>
|
||||
|
||||
{/* 操作栏 */}
|
||||
<HStack spacing={4} mt={2}>
|
||||
<IconButton
|
||||
aria-label="点赞"
|
||||
icon={<MdThumbUp />}
|
||||
size="xs"
|
||||
variant={liked ? 'solid' : 'ghost'}
|
||||
colorScheme={liked ? 'blue' : 'gray'}
|
||||
onClick={handleLike}
|
||||
/>
|
||||
<Text fontSize="xs" color={mutedColor}>
|
||||
{likeCount}
|
||||
</Text>
|
||||
|
||||
<IconButton
|
||||
aria-label="回复"
|
||||
icon={<MdReply />}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
onClick={onReply}
|
||||
/>
|
||||
|
||||
<IconButton
|
||||
aria-label="更多"
|
||||
icon={<MdMoreVert />}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
/>
|
||||
</HStack>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReplyItem;
|
||||
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* Forum 帖子频道组件
|
||||
* 帖子列表 + 发帖入口
|
||||
*/
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
Text,
|
||||
Button,
|
||||
Icon,
|
||||
IconButton,
|
||||
HStack,
|
||||
Select,
|
||||
useColorModeValue,
|
||||
Spinner,
|
||||
useDisclosure,
|
||||
} from '@chakra-ui/react';
|
||||
import { MdAdd, MdSort, MdSearch, MdPushPin } from 'react-icons/md';
|
||||
|
||||
import { Channel, ForumPost } from '../../../types';
|
||||
import { getForumPosts } from '../../../services/communityService';
|
||||
import ChannelHeader from '../shared/ChannelHeader';
|
||||
import PostCard from './PostCard';
|
||||
import PostDetail from './PostDetail';
|
||||
import CreatePostModal from './CreatePostModal';
|
||||
|
||||
interface ForumChannelProps {
|
||||
channel: Channel;
|
||||
onOpenRightPanel?: () => void;
|
||||
}
|
||||
|
||||
type SortOption = 'latest' | 'hot' | 'most_replies';
|
||||
|
||||
const ForumChannel: React.FC<ForumChannelProps> = ({
|
||||
channel,
|
||||
onOpenRightPanel,
|
||||
}) => {
|
||||
const [posts, setPosts] = useState<ForumPost[]>([]);
|
||||
const [pinnedPosts, setPinnedPosts] = useState<ForumPost[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [sortBy, setSortBy] = useState<SortOption>('latest');
|
||||
const [selectedPost, setSelectedPost] = useState<ForumPost | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const { isOpen: isCreateOpen, onOpen: onCreateOpen, onClose: onCreateClose } = useDisclosure();
|
||||
|
||||
// 颜色
|
||||
const bgColor = useColorModeValue('white', 'gray.800');
|
||||
const headerBg = useColorModeValue('gray.50', 'gray.900');
|
||||
|
||||
// 加载帖子
|
||||
const loadPosts = useCallback(async (pageNum: number = 1, append: boolean = false) => {
|
||||
try {
|
||||
if (append) {
|
||||
setLoadingMore(true);
|
||||
} else {
|
||||
setLoading(true);
|
||||
}
|
||||
|
||||
const response = await getForumPosts(channel.id, {
|
||||
page: pageNum,
|
||||
pageSize: 20,
|
||||
sortBy,
|
||||
});
|
||||
|
||||
if (append) {
|
||||
setPosts(prev => [...prev, ...response.items.filter(p => !p.isPinned)]);
|
||||
} else {
|
||||
// 分离置顶帖子
|
||||
setPinnedPosts(response.items.filter(p => p.isPinned));
|
||||
setPosts(response.items.filter(p => !p.isPinned));
|
||||
}
|
||||
|
||||
setHasMore(response.hasMore);
|
||||
setPage(pageNum);
|
||||
} catch (error) {
|
||||
console.error('加载帖子失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [channel.id, sortBy]);
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
loadPosts(1);
|
||||
}, [loadPosts]);
|
||||
|
||||
// 排序变化时重新加载
|
||||
useEffect(() => {
|
||||
loadPosts(1);
|
||||
}, [sortBy, loadPosts]);
|
||||
|
||||
// 加载更多
|
||||
const handleLoadMore = () => {
|
||||
if (!loadingMore && hasMore) {
|
||||
loadPosts(page + 1, true);
|
||||
}
|
||||
};
|
||||
|
||||
// 发帖成功
|
||||
const handlePostCreated = (newPost: ForumPost) => {
|
||||
setPosts(prev => [newPost, ...prev]);
|
||||
onCreateClose();
|
||||
};
|
||||
|
||||
// 如果选中了帖子,显示帖子详情
|
||||
if (selectedPost) {
|
||||
return (
|
||||
<PostDetail
|
||||
post={selectedPost}
|
||||
onBack={() => setSelectedPost(null)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex direction="column" h="full">
|
||||
{/* 频道头部 */}
|
||||
<ChannelHeader
|
||||
channel={channel}
|
||||
rightActions={
|
||||
<>
|
||||
<IconButton
|
||||
aria-label="搜索"
|
||||
icon={<Icon as={MdSearch} />}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 工具栏 */}
|
||||
<Flex
|
||||
align="center"
|
||||
justify="space-between"
|
||||
px={4}
|
||||
py={3}
|
||||
bg={headerBg}
|
||||
borderBottomWidth="1px"
|
||||
>
|
||||
<Button
|
||||
leftIcon={<MdAdd />}
|
||||
colorScheme="blue"
|
||||
size="sm"
|
||||
onClick={onCreateOpen}
|
||||
>
|
||||
发布帖子
|
||||
</Button>
|
||||
|
||||
<HStack spacing={2}>
|
||||
<Icon as={MdSort} color="gray.500" />
|
||||
<Select
|
||||
size="sm"
|
||||
w="120px"
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as SortOption)}
|
||||
>
|
||||
<option value="latest">最新发布</option>
|
||||
<option value="hot">最热门</option>
|
||||
<option value="most_replies">最多回复</option>
|
||||
</Select>
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{/* 帖子列表 */}
|
||||
<Box flex={1} overflowY="auto" px={4} py={4}>
|
||||
{loading ? (
|
||||
<Flex justify="center" align="center" h="200px">
|
||||
<Spinner size="lg" />
|
||||
</Flex>
|
||||
) : (
|
||||
<>
|
||||
{/* 置顶帖子 */}
|
||||
{pinnedPosts.length > 0 && (
|
||||
<Box mb={4}>
|
||||
<HStack mb={2}>
|
||||
<Icon as={MdPushPin} color="blue.500" />
|
||||
<Text fontSize="sm" fontWeight="semibold" color="blue.500">
|
||||
置顶帖子
|
||||
</Text>
|
||||
</HStack>
|
||||
{pinnedPosts.map(post => (
|
||||
<PostCard
|
||||
key={post.id}
|
||||
post={post}
|
||||
onClick={() => setSelectedPost(post)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 普通帖子 */}
|
||||
{posts.length === 0 && pinnedPosts.length === 0 ? (
|
||||
<Flex
|
||||
direction="column"
|
||||
align="center"
|
||||
justify="center"
|
||||
h="200px"
|
||||
color="gray.500"
|
||||
>
|
||||
<Text mb={2}>暂无帖子</Text>
|
||||
<Button size="sm" colorScheme="blue" onClick={onCreateOpen}>
|
||||
发布第一篇帖子
|
||||
</Button>
|
||||
</Flex>
|
||||
) : (
|
||||
<>
|
||||
{posts.map(post => (
|
||||
<PostCard
|
||||
key={post.id}
|
||||
post={post}
|
||||
onClick={() => setSelectedPost(post)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 加载更多 */}
|
||||
{hasMore && (
|
||||
<Flex justify="center" py={4}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
isLoading={loadingMore}
|
||||
onClick={handleLoadMore}
|
||||
>
|
||||
加载更多
|
||||
</Button>
|
||||
</Flex>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 发帖弹窗 */}
|
||||
<CreatePostModal
|
||||
isOpen={isCreateOpen}
|
||||
onClose={onCreateClose}
|
||||
channelId={channel.id}
|
||||
channelName={channel.name}
|
||||
onPostCreated={handlePostCreated}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default ForumChannel;
|
||||
@@ -0,0 +1,370 @@
|
||||
/**
|
||||
* 消息输入框组件
|
||||
* 支持 @用户、$股票、表情、附件
|
||||
*/
|
||||
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
Textarea,
|
||||
IconButton,
|
||||
HStack,
|
||||
Text,
|
||||
useColorModeValue,
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
PopoverBody,
|
||||
SimpleGrid,
|
||||
Button,
|
||||
Avatar,
|
||||
CloseButton,
|
||||
Progress,
|
||||
useToast,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
MdAdd,
|
||||
MdEmojiEmotions,
|
||||
MdSend,
|
||||
MdImage,
|
||||
MdAttachFile,
|
||||
MdPoll,
|
||||
} from 'react-icons/md';
|
||||
|
||||
import { Message } from '../../../types';
|
||||
import { sendMessage } from '../../../services/communityService';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
|
||||
interface MessageInputProps {
|
||||
channelId: string;
|
||||
channelName: string;
|
||||
replyTo?: Message | null;
|
||||
onCancelReply?: () => void;
|
||||
onMessageSent?: (message: Message) => void;
|
||||
slowMode?: number;
|
||||
}
|
||||
|
||||
// 常用表情
|
||||
const QUICK_EMOJIS = ['👍', '🔥', '🚀', '💎', '🤔', '😂', '📈', '📉', '💰', '🎯', '⚠️', '❤️'];
|
||||
|
||||
const MessageInput: React.FC<MessageInputProps> = ({
|
||||
channelId,
|
||||
channelName,
|
||||
replyTo,
|
||||
onCancelReply,
|
||||
onMessageSent,
|
||||
slowMode = 0,
|
||||
}) => {
|
||||
const [content, setContent] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const [cooldown, setCooldown] = useState(0);
|
||||
const [attachments, setAttachments] = useState<File[]>([]);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { user } = useAuth();
|
||||
const toast = useToast();
|
||||
|
||||
// 颜色
|
||||
const bgColor = useColorModeValue('gray.100', 'gray.700');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
const replyBg = useColorModeValue('blue.50', 'blue.900');
|
||||
|
||||
// 慢速模式倒计时
|
||||
useEffect(() => {
|
||||
if (cooldown > 0) {
|
||||
const timer = setTimeout(() => setCooldown(cooldown - 1), 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [cooldown]);
|
||||
|
||||
// 自动调整文本框高度
|
||||
useEffect(() => {
|
||||
const textarea = textareaRef.current;
|
||||
if (textarea) {
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
|
||||
}
|
||||
}, [content]);
|
||||
|
||||
// 发送消息
|
||||
const handleSend = useCallback(async () => {
|
||||
if (!content.trim() && attachments.length === 0) return;
|
||||
if (cooldown > 0) return;
|
||||
|
||||
try {
|
||||
setSending(true);
|
||||
|
||||
const messageData = {
|
||||
channelId,
|
||||
content: content.trim(),
|
||||
replyTo: replyTo ? {
|
||||
messageId: replyTo.id,
|
||||
authorId: replyTo.authorId,
|
||||
authorName: replyTo.authorName,
|
||||
contentPreview: replyTo.content.slice(0, 100),
|
||||
} : undefined,
|
||||
// 解析 @用户 和 $股票
|
||||
mentionedUsers: extractMentions(content),
|
||||
mentionedStocks: extractStocks(content),
|
||||
};
|
||||
|
||||
const newMessage = await sendMessage(messageData);
|
||||
|
||||
setContent('');
|
||||
setAttachments([]);
|
||||
onMessageSent?.(newMessage);
|
||||
|
||||
// 启动慢速模式倒计时
|
||||
if (slowMode > 0) {
|
||||
setCooldown(slowMode);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('发送消息失败:', error);
|
||||
toast({
|
||||
title: '发送失败',
|
||||
description: '请稍后重试',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}, [content, attachments, channelId, replyTo, slowMode, onMessageSent, toast, cooldown]);
|
||||
|
||||
// 提取 @用户
|
||||
const extractMentions = (text: string): string[] => {
|
||||
const matches = text.match(/@(\w+)/g);
|
||||
return matches ? matches.map(m => m.slice(1)) : [];
|
||||
};
|
||||
|
||||
// 提取 $股票
|
||||
const extractStocks = (text: string): string[] => {
|
||||
const matches = text.match(/\$(\d{6})/g);
|
||||
return matches ? matches.map(m => m.slice(1)) : [];
|
||||
};
|
||||
|
||||
// 处理键盘事件
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
// Enter 发送,Shift+Enter 换行
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
// 插入表情
|
||||
const insertEmoji = (emoji: string) => {
|
||||
const textarea = textareaRef.current;
|
||||
if (textarea) {
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const newContent = content.slice(0, start) + emoji + content.slice(end);
|
||||
setContent(newContent);
|
||||
|
||||
// 恢复光标位置
|
||||
setTimeout(() => {
|
||||
textarea.selectionStart = textarea.selectionEnd = start + emoji.length;
|
||||
textarea.focus();
|
||||
}, 0);
|
||||
} else {
|
||||
setContent(content + emoji);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理文件选择
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
setAttachments(prev => [...prev, ...files].slice(0, 5)); // 最多 5 个附件
|
||||
};
|
||||
|
||||
// 移除附件
|
||||
const removeAttachment = (index: number) => {
|
||||
setAttachments(prev => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* 回复提示 */}
|
||||
{replyTo && (
|
||||
<Flex
|
||||
align="center"
|
||||
px={4}
|
||||
py={2}
|
||||
bg={replyBg}
|
||||
borderRadius="md"
|
||||
mb={2}
|
||||
>
|
||||
<Text flex={1} fontSize="sm" isTruncated>
|
||||
回复 <strong>{replyTo.authorName}</strong>: {replyTo.content}
|
||||
</Text>
|
||||
<CloseButton size="sm" onClick={onCancelReply} />
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{/* 附件预览 */}
|
||||
{attachments.length > 0 && (
|
||||
<HStack spacing={2} mb={2} flexWrap="wrap">
|
||||
{attachments.map((file, index) => (
|
||||
<Flex
|
||||
key={index}
|
||||
align="center"
|
||||
px={3}
|
||||
py={1}
|
||||
bg={bgColor}
|
||||
borderRadius="md"
|
||||
fontSize="sm"
|
||||
>
|
||||
<Text isTruncated maxW="150px">
|
||||
{file.name}
|
||||
</Text>
|
||||
<CloseButton size="sm" ml={1} onClick={() => removeAttachment(index)} />
|
||||
</Flex>
|
||||
))}
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
{/* 上传进度 */}
|
||||
{uploadProgress > 0 && uploadProgress < 100 && (
|
||||
<Progress value={uploadProgress} size="xs" colorScheme="blue" mb={2} />
|
||||
)}
|
||||
|
||||
{/* 输入框 */}
|
||||
<Flex
|
||||
align="flex-end"
|
||||
bg={bgColor}
|
||||
borderRadius="lg"
|
||||
border="1px solid"
|
||||
borderColor={borderColor}
|
||||
p={2}
|
||||
>
|
||||
{/* 附件按钮 */}
|
||||
<Popover placement="top-start">
|
||||
<PopoverTrigger>
|
||||
<IconButton
|
||||
aria-label="添加"
|
||||
icon={<MdAdd />}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
borderRadius="full"
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent w="200px">
|
||||
<PopoverBody p={2}>
|
||||
<Button
|
||||
leftIcon={<MdImage />}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
w="full"
|
||||
justifyContent="flex-start"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
上传图片
|
||||
</Button>
|
||||
<Button
|
||||
leftIcon={<MdAttachFile />}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
w="full"
|
||||
justifyContent="flex-start"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
上传文件
|
||||
</Button>
|
||||
<Button
|
||||
leftIcon={<MdPoll />}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
w="full"
|
||||
justifyContent="flex-start"
|
||||
>
|
||||
创建投票
|
||||
</Button>
|
||||
</PopoverBody>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* 隐藏的文件输入 */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
hidden
|
||||
onChange={handleFileSelect}
|
||||
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx"
|
||||
/>
|
||||
|
||||
{/* 文本输入 */}
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={cooldown > 0 ? `请等待 ${cooldown} 秒...` : `在 #${channelName} 发送消息`}
|
||||
disabled={cooldown > 0}
|
||||
variant="unstyled"
|
||||
resize="none"
|
||||
minH="40px"
|
||||
maxH="200px"
|
||||
px={2}
|
||||
flex={1}
|
||||
rows={1}
|
||||
/>
|
||||
|
||||
{/* 表情按钮 */}
|
||||
<Popover placement="top-end">
|
||||
<PopoverTrigger>
|
||||
<IconButton
|
||||
aria-label="表情"
|
||||
icon={<MdEmojiEmotions />}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent w="280px">
|
||||
<PopoverBody>
|
||||
<Text fontSize="xs" color="gray.500" mb={2}>
|
||||
常用表情
|
||||
</Text>
|
||||
<SimpleGrid columns={6} spacing={1}>
|
||||
{QUICK_EMOJIS.map(emoji => (
|
||||
<Button
|
||||
key={emoji}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
fontSize="xl"
|
||||
onClick={() => insertEmoji(emoji)}
|
||||
>
|
||||
{emoji}
|
||||
</Button>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</PopoverBody>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* 发送按钮 */}
|
||||
<IconButton
|
||||
aria-label="发送"
|
||||
icon={<MdSend />}
|
||||
colorScheme="blue"
|
||||
size="sm"
|
||||
borderRadius="full"
|
||||
isLoading={sending}
|
||||
isDisabled={(!content.trim() && attachments.length === 0) || cooldown > 0}
|
||||
onClick={handleSend}
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
{/* 提示文字 */}
|
||||
<Text fontSize="xs" color="gray.500" mt={1} textAlign="right">
|
||||
Enter 发送,Shift+Enter 换行 | 输入 @ 提及用户,$ 提及股票
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessageInput;
|
||||
@@ -0,0 +1,394 @@
|
||||
/**
|
||||
* 单条消息组件
|
||||
* Discord 风格的消息展示
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
Text,
|
||||
Avatar,
|
||||
IconButton,
|
||||
HStack,
|
||||
useColorModeValue,
|
||||
Tooltip,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuList,
|
||||
MenuItem,
|
||||
Image,
|
||||
Badge,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
MdReply,
|
||||
MdEmojiEmotions,
|
||||
MdMoreVert,
|
||||
MdPushPin,
|
||||
MdDelete,
|
||||
MdEdit,
|
||||
MdForum,
|
||||
} from 'react-icons/md';
|
||||
import { format } from 'date-fns';
|
||||
import { zhCN } from 'date-fns/locale';
|
||||
|
||||
import { Message, Embed } from '../../../types';
|
||||
import ReactionBar from './ReactionBar';
|
||||
import StockEmbed from '../shared/StockEmbed';
|
||||
|
||||
interface MessageItemProps {
|
||||
message: Message;
|
||||
showAvatar: boolean;
|
||||
onReply: (message: Message) => void;
|
||||
onThreadOpen?: (threadId: string) => void;
|
||||
}
|
||||
|
||||
const MessageItem: React.FC<MessageItemProps> = ({
|
||||
message,
|
||||
showAvatar,
|
||||
onReply,
|
||||
onThreadOpen,
|
||||
}) => {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
// 颜色
|
||||
const hoverBg = useColorModeValue('gray.50', 'gray.700');
|
||||
const textColor = useColorModeValue('gray.800', 'gray.100');
|
||||
const mutedColor = useColorModeValue('gray.500', 'gray.400');
|
||||
const mentionBg = useColorModeValue('blue.50', 'blue.900');
|
||||
const replyBg = useColorModeValue('gray.100', 'gray.700');
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
return format(date, 'HH:mm', { locale: zhCN });
|
||||
};
|
||||
|
||||
const formatFullTime = (dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
return format(date, 'yyyy年M月d日 HH:mm:ss', { locale: zhCN });
|
||||
};
|
||||
|
||||
// 渲染回复引用
|
||||
const renderReplyTo = () => {
|
||||
if (!message.replyTo) return null;
|
||||
|
||||
return (
|
||||
<Flex
|
||||
align="center"
|
||||
ml={showAvatar ? '52px' : 0}
|
||||
mb={1}
|
||||
fontSize="sm"
|
||||
color={mutedColor}
|
||||
>
|
||||
<Box
|
||||
w="2px"
|
||||
h="full"
|
||||
bg="blue.400"
|
||||
borderRadius="full"
|
||||
mr={2}
|
||||
/>
|
||||
<Avatar size="xs" name={message.replyTo.authorName} mr={1} />
|
||||
<Text fontWeight="semibold" mr={2}>
|
||||
{message.replyTo.authorName}
|
||||
</Text>
|
||||
<Text isTruncated maxW="300px">
|
||||
{message.replyTo.contentPreview || '点击查看消息'}
|
||||
</Text>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染消息内容(处理 @用户 和 $股票)
|
||||
const renderContent = () => {
|
||||
let content = message.content;
|
||||
|
||||
// 高亮 @用户
|
||||
if (message.mentionedUsers && message.mentionedUsers.length > 0) {
|
||||
message.mentionedUsers.forEach(userId => {
|
||||
const regex = new RegExp(`@${userId}`, 'g');
|
||||
content = content.replace(regex, `<span class="mention">@${userId}</span>`);
|
||||
});
|
||||
}
|
||||
|
||||
// 高亮 $股票
|
||||
if (message.mentionedStocks && message.mentionedStocks.length > 0) {
|
||||
message.mentionedStocks.forEach(stock => {
|
||||
const regex = new RegExp(`\\$${stock}`, 'g');
|
||||
content = content.replace(regex, `<span class="stock-mention">$${stock}</span>`);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Text
|
||||
color={textColor}
|
||||
whiteSpace="pre-wrap"
|
||||
wordBreak="break-word"
|
||||
sx={{
|
||||
'.mention': {
|
||||
bg: mentionBg,
|
||||
color: 'blue.500',
|
||||
borderRadius: 'sm',
|
||||
px: 1,
|
||||
fontWeight: 'semibold',
|
||||
},
|
||||
'.stock-mention': {
|
||||
bg: 'orange.100',
|
||||
color: 'orange.600',
|
||||
borderRadius: 'sm',
|
||||
px: 1,
|
||||
fontWeight: 'semibold',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: content }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染附件
|
||||
const renderAttachments = () => {
|
||||
if (!message.attachments || message.attachments.length === 0) return null;
|
||||
|
||||
return (
|
||||
<HStack spacing={2} mt={2} flexWrap="wrap">
|
||||
{message.attachments.map(attachment => (
|
||||
<Box key={attachment.id}>
|
||||
{attachment.type === 'image' ? (
|
||||
<Image
|
||||
src={attachment.url}
|
||||
alt={attachment.filename}
|
||||
maxH="300px"
|
||||
maxW="400px"
|
||||
borderRadius="md"
|
||||
cursor="pointer"
|
||||
onClick={() => window.open(attachment.url, '_blank')}
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
p={3}
|
||||
bg={replyBg}
|
||||
borderRadius="md"
|
||||
cursor="pointer"
|
||||
onClick={() => window.open(attachment.url, '_blank')}
|
||||
>
|
||||
<Text fontSize="sm" fontWeight="semibold">
|
||||
📎 {attachment.filename}
|
||||
</Text>
|
||||
<Text fontSize="xs" color={mutedColor}>
|
||||
{(attachment.size / 1024).toFixed(1)} KB
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染嵌入内容(股票卡片等)
|
||||
const renderEmbeds = () => {
|
||||
if (!message.embeds || message.embeds.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Box mt={2}>
|
||||
{message.embeds.map((embed, index) => {
|
||||
if (embed.type === 'stock' && embed.stockSymbol) {
|
||||
return (
|
||||
<StockEmbed
|
||||
key={index}
|
||||
symbol={embed.stockSymbol}
|
||||
name={embed.stockName}
|
||||
price={embed.stockPrice}
|
||||
change={embed.stockChange}
|
||||
changePercent={embed.stockChangePercent}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 链接预览
|
||||
if (embed.type === 'link') {
|
||||
return (
|
||||
<Box
|
||||
key={index}
|
||||
p={3}
|
||||
borderLeftWidth="4px"
|
||||
borderLeftColor="blue.400"
|
||||
bg={replyBg}
|
||||
borderRadius="md"
|
||||
maxW="500px"
|
||||
>
|
||||
{embed.title && (
|
||||
<Text fontWeight="semibold" color="blue.500" mb={1}>
|
||||
{embed.title}
|
||||
</Text>
|
||||
)}
|
||||
{embed.description && (
|
||||
<Text fontSize="sm" color={mutedColor} noOfLines={2}>
|
||||
{embed.description}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染操作栏
|
||||
const renderActions = () => {
|
||||
if (!isHovered) return null;
|
||||
|
||||
return (
|
||||
<HStack
|
||||
position="absolute"
|
||||
top="-10px"
|
||||
right="8px"
|
||||
bg={useColorModeValue('white', 'gray.700')}
|
||||
borderRadius="md"
|
||||
boxShadow="md"
|
||||
p={1}
|
||||
spacing={0}
|
||||
>
|
||||
<Tooltip label="添加表情">
|
||||
<IconButton
|
||||
aria-label="添加表情"
|
||||
icon={<MdEmojiEmotions />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip label="回复">
|
||||
<IconButton
|
||||
aria-label="回复"
|
||||
icon={<MdReply />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => onReply(message)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip label="创建讨论串">
|
||||
<IconButton
|
||||
aria-label="创建讨论串"
|
||||
icon={<MdForum />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => onThreadOpen?.(message.id)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Menu>
|
||||
<MenuButton
|
||||
as={IconButton}
|
||||
aria-label="更多"
|
||||
icon={<MdMoreVert />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
/>
|
||||
<MenuList>
|
||||
<MenuItem icon={<MdPushPin />}>置顶消息</MenuItem>
|
||||
<MenuItem icon={<MdEdit />}>编辑</MenuItem>
|
||||
<MenuItem icon={<MdDelete />} color="red.500">删除</MenuItem>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
position="relative"
|
||||
py={showAvatar ? 2 : 0.5}
|
||||
px={2}
|
||||
mx={-2}
|
||||
borderRadius="md"
|
||||
bg={isHovered ? hoverBg : 'transparent'}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{/* 回复引用 */}
|
||||
{renderReplyTo()}
|
||||
|
||||
<Flex>
|
||||
{/* 头像区域 */}
|
||||
{showAvatar ? (
|
||||
<Avatar
|
||||
size="sm"
|
||||
name={message.authorName}
|
||||
src={message.authorAvatar}
|
||||
mr={3}
|
||||
mt={1}
|
||||
/>
|
||||
) : (
|
||||
<Box w="40px" mr={3} />
|
||||
)}
|
||||
|
||||
{/* 消息内容 */}
|
||||
<Box flex={1}>
|
||||
{/* 用户名和时间(仅在显示头像时) */}
|
||||
{showAvatar && (
|
||||
<HStack spacing={2} mb={1}>
|
||||
<Text fontWeight="semibold" color={textColor}>
|
||||
{message.authorName}
|
||||
</Text>
|
||||
<Tooltip label={formatFullTime(message.createdAt)}>
|
||||
<Text fontSize="xs" color={mutedColor}>
|
||||
{formatTime(message.createdAt)}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
{message.isPinned && (
|
||||
<Badge colorScheme="blue" fontSize="xs">
|
||||
置顶
|
||||
</Badge>
|
||||
)}
|
||||
{message.isEdited && (
|
||||
<Text fontSize="xs" color={mutedColor}>
|
||||
(已编辑)
|
||||
</Text>
|
||||
)}
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
{/* 消息文本 */}
|
||||
{renderContent()}
|
||||
|
||||
{/* 附件 */}
|
||||
{renderAttachments()}
|
||||
|
||||
{/* 嵌入内容 */}
|
||||
{renderEmbeds()}
|
||||
|
||||
{/* 表情反应 */}
|
||||
{message.reactions && Object.keys(message.reactions).length > 0 && (
|
||||
<ReactionBar
|
||||
reactions={message.reactions}
|
||||
messageId={message.id}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 仅时间(不显示头像时) */}
|
||||
{!showAvatar && (
|
||||
<Tooltip label={formatFullTime(message.createdAt)}>
|
||||
<Text
|
||||
fontSize="xs"
|
||||
color={mutedColor}
|
||||
opacity={isHovered ? 1 : 0}
|
||||
transition="opacity 0.2s"
|
||||
ml={2}
|
||||
alignSelf="center"
|
||||
>
|
||||
{formatTime(message.createdAt)}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
{/* 操作栏 */}
|
||||
{renderActions()}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessageItem;
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* 消息列表组件
|
||||
* 按日期分组显示消息
|
||||
*/
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
Text,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { format, isToday, isYesterday, isSameDay } from 'date-fns';
|
||||
import { zhCN } from 'date-fns/locale';
|
||||
|
||||
import { Message } from '../../../types';
|
||||
import MessageItem from './MessageItem';
|
||||
|
||||
interface MessageListProps {
|
||||
messages: Message[];
|
||||
onReply: (message: Message) => void;
|
||||
onThreadOpen?: (threadId: string) => void;
|
||||
}
|
||||
|
||||
interface MessageGroup {
|
||||
date: Date;
|
||||
messages: Message[];
|
||||
}
|
||||
|
||||
const MessageList: React.FC<MessageListProps> = ({
|
||||
messages,
|
||||
onReply,
|
||||
onThreadOpen,
|
||||
}) => {
|
||||
const dividerColor = useColorModeValue('gray.300', 'gray.600');
|
||||
const dividerTextColor = useColorModeValue('gray.500', 'gray.400');
|
||||
const dividerBg = useColorModeValue('white', 'gray.800');
|
||||
|
||||
// 按日期分组消息
|
||||
const messageGroups = useMemo(() => {
|
||||
const groups: MessageGroup[] = [];
|
||||
let currentGroup: MessageGroup | null = null;
|
||||
|
||||
messages.forEach(message => {
|
||||
const messageDate = new Date(message.createdAt);
|
||||
|
||||
if (!currentGroup || !isSameDay(currentGroup.date, messageDate)) {
|
||||
currentGroup = {
|
||||
date: messageDate,
|
||||
messages: [message],
|
||||
};
|
||||
groups.push(currentGroup);
|
||||
} else {
|
||||
currentGroup.messages.push(message);
|
||||
}
|
||||
});
|
||||
|
||||
return groups;
|
||||
}, [messages]);
|
||||
|
||||
// 格式化日期分隔线
|
||||
const formatDateDivider = (date: Date): string => {
|
||||
if (isToday(date)) {
|
||||
return '今天';
|
||||
}
|
||||
if (isYesterday(date)) {
|
||||
return '昨天';
|
||||
}
|
||||
return format(date, 'yyyy年M月d日', { locale: zhCN });
|
||||
};
|
||||
|
||||
// 判断是否需要显示头像(连续消息合并)
|
||||
const shouldShowAvatar = (message: Message, index: number, groupMessages: Message[]): boolean => {
|
||||
if (index === 0) return true;
|
||||
|
||||
const prevMessage = groupMessages[index - 1];
|
||||
if (prevMessage.authorId !== message.authorId) return true;
|
||||
|
||||
// 超过 5 分钟的消息显示头像
|
||||
const prevTime = new Date(prevMessage.createdAt).getTime();
|
||||
const currentTime = new Date(message.createdAt).getTime();
|
||||
if (currentTime - prevTime > 5 * 60 * 1000) return true;
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
if (messages.length === 0) {
|
||||
return (
|
||||
<Flex justify="center" align="center" h="200px">
|
||||
<Text color="gray.500">暂无消息,快来发起讨论吧</Text>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{messageGroups.map((group, groupIndex) => (
|
||||
<Box key={group.date.toISOString()}>
|
||||
{/* 日期分隔线 */}
|
||||
<Flex align="center" my={4}>
|
||||
<Box flex={1} h="1px" bg={dividerColor} />
|
||||
<Text
|
||||
px={3}
|
||||
fontSize="xs"
|
||||
fontWeight="semibold"
|
||||
color={dividerTextColor}
|
||||
bg={dividerBg}
|
||||
>
|
||||
{formatDateDivider(group.date)}
|
||||
</Text>
|
||||
<Box flex={1} h="1px" bg={dividerColor} />
|
||||
</Flex>
|
||||
|
||||
{/* 消息列表 */}
|
||||
{group.messages.map((message, index) => (
|
||||
<MessageItem
|
||||
key={message.id}
|
||||
message={message}
|
||||
showAvatar={shouldShowAvatar(message, index, group.messages)}
|
||||
onReply={onReply}
|
||||
onThreadOpen={onThreadOpen}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessageList;
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* 表情反应栏组件
|
||||
*/
|
||||
import React from 'react';
|
||||
import {
|
||||
HStack,
|
||||
Button,
|
||||
Text,
|
||||
Tooltip,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { MdAdd } from 'react-icons/md';
|
||||
|
||||
import { addReaction, removeReaction } from '../../../services/communityService';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
|
||||
interface ReactionBarProps {
|
||||
reactions: Record<string, string[]>; // emoji -> userIds
|
||||
messageId: string;
|
||||
}
|
||||
|
||||
const ReactionBar: React.FC<ReactionBarProps> = ({ reactions, messageId }) => {
|
||||
const { user } = useAuth();
|
||||
|
||||
const activeBg = useColorModeValue('blue.100', 'blue.800');
|
||||
const hoverBg = useColorModeValue('gray.200', 'gray.600');
|
||||
const defaultBg = useColorModeValue('gray.100', 'gray.700');
|
||||
|
||||
// 处理点击表情
|
||||
const handleReactionClick = async (emoji: string) => {
|
||||
if (!user) return;
|
||||
|
||||
const userIds = reactions[emoji] || [];
|
||||
const hasReacted = userIds.includes(user.id);
|
||||
|
||||
try {
|
||||
if (hasReacted) {
|
||||
await removeReaction(messageId, emoji);
|
||||
} else {
|
||||
await addReaction(messageId, emoji);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('表情操作失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取反应用户列表提示
|
||||
const getTooltipLabel = (emoji: string, userIds: string[]): string => {
|
||||
if (userIds.length === 0) return '';
|
||||
if (userIds.length <= 3) {
|
||||
return userIds.join('、') + ' 做出了反应';
|
||||
}
|
||||
return `${userIds.slice(0, 3).join('、')} 等 ${userIds.length} 人做出了反应`;
|
||||
};
|
||||
|
||||
const reactionEntries = Object.entries(reactions).filter(([_, userIds]) => userIds.length > 0);
|
||||
|
||||
if (reactionEntries.length === 0) return null;
|
||||
|
||||
return (
|
||||
<HStack spacing={1} mt={2} flexWrap="wrap">
|
||||
{reactionEntries.map(([emoji, userIds]) => {
|
||||
const hasReacted = user && userIds.includes(user.id);
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
key={emoji}
|
||||
label={getTooltipLabel(emoji, userIds)}
|
||||
placement="top"
|
||||
hasArrow
|
||||
>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
bg={hasReacted ? activeBg : defaultBg}
|
||||
_hover={{ bg: hasReacted ? activeBg : hoverBg }}
|
||||
borderWidth={hasReacted ? '1px' : '0'}
|
||||
borderColor="blue.400"
|
||||
borderRadius="full"
|
||||
px={2}
|
||||
h="24px"
|
||||
onClick={() => handleReactionClick(emoji)}
|
||||
>
|
||||
<Text fontSize="sm" mr={1}>
|
||||
{emoji}
|
||||
</Text>
|
||||
<Text fontSize="xs" fontWeight="semibold">
|
||||
{userIds.length}
|
||||
</Text>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 添加表情按钮 */}
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
bg={defaultBg}
|
||||
_hover={{ bg: hoverBg }}
|
||||
borderRadius="full"
|
||||
px={2}
|
||||
h="24px"
|
||||
>
|
||||
<MdAdd />
|
||||
</Button>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReactionBar;
|
||||
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* 即时聊天频道组件
|
||||
* Discord 风格的消息列表 + 输入框
|
||||
*/
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
Text,
|
||||
Icon,
|
||||
IconButton,
|
||||
useColorModeValue,
|
||||
Spinner,
|
||||
Divider,
|
||||
} from '@chakra-ui/react';
|
||||
import { MdPushPin, MdPeople, MdSearch } from 'react-icons/md';
|
||||
|
||||
import { Channel, Message } from '../../../types';
|
||||
import { getMessages } from '../../../services/communityService';
|
||||
import { useCommunitySocket } from '../../../hooks/useCommunitySocket';
|
||||
import MessageList from './MessageList';
|
||||
import MessageInput from './MessageInput';
|
||||
import ChannelHeader from '../shared/ChannelHeader';
|
||||
|
||||
interface TextChannelProps {
|
||||
channel: Channel;
|
||||
isReadonly?: boolean;
|
||||
onOpenRightPanel?: () => void;
|
||||
onThreadOpen?: (threadId: string) => void;
|
||||
}
|
||||
|
||||
const TextChannel: React.FC<TextChannelProps> = ({
|
||||
channel,
|
||||
isReadonly = false,
|
||||
onOpenRightPanel,
|
||||
onThreadOpen,
|
||||
}) => {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [replyTo, setReplyTo] = useState<Message | null>(null);
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// WebSocket
|
||||
const { onMessage, offMessage } = useCommunitySocket();
|
||||
|
||||
// 颜色
|
||||
const bgColor = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
|
||||
// 加载消息
|
||||
const loadMessages = useCallback(async (before?: string) => {
|
||||
try {
|
||||
if (before) {
|
||||
setLoadingMore(true);
|
||||
} else {
|
||||
setLoading(true);
|
||||
}
|
||||
|
||||
const response = await getMessages(channel.id, { before, limit: 50 });
|
||||
|
||||
if (before) {
|
||||
setMessages(prev => [...response.items, ...prev]);
|
||||
} else {
|
||||
setMessages(response.items);
|
||||
}
|
||||
|
||||
setHasMore(response.hasMore);
|
||||
} catch (error) {
|
||||
console.error('加载消息失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [channel.id]);
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
loadMessages();
|
||||
}, [loadMessages]);
|
||||
|
||||
// 监听新消息
|
||||
useEffect(() => {
|
||||
const handleNewMessage = (message: Message) => {
|
||||
if (message.channelId === channel.id) {
|
||||
setMessages(prev => [...prev, message]);
|
||||
// 滚动到底部
|
||||
setTimeout(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, 100);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMessageUpdate = (message: Message) => {
|
||||
if (message.channelId === channel.id) {
|
||||
setMessages(prev =>
|
||||
prev.map(m => (m.id === message.id ? message : m))
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMessageDelete = (data: { messageId: string; channelId: string }) => {
|
||||
if (data.channelId === channel.id) {
|
||||
setMessages(prev => prev.filter(m => m.id !== data.messageId));
|
||||
}
|
||||
};
|
||||
|
||||
onMessage('MESSAGE_CREATE', handleNewMessage);
|
||||
onMessage('MESSAGE_UPDATE', handleMessageUpdate);
|
||||
onMessage('MESSAGE_DELETE', handleMessageDelete);
|
||||
|
||||
return () => {
|
||||
offMessage('MESSAGE_CREATE', handleNewMessage);
|
||||
offMessage('MESSAGE_UPDATE', handleMessageUpdate);
|
||||
offMessage('MESSAGE_DELETE', handleMessageDelete);
|
||||
};
|
||||
}, [channel.id, onMessage, offMessage]);
|
||||
|
||||
// 滚动加载更多
|
||||
const handleScroll = useCallback(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container || loadingMore || !hasMore) return;
|
||||
|
||||
// 当滚动到顶部附近时加载更多
|
||||
if (container.scrollTop < 100 && messages.length > 0) {
|
||||
loadMessages(messages[0].id);
|
||||
}
|
||||
}, [loadingMore, hasMore, messages, loadMessages]);
|
||||
|
||||
// 发送消息后
|
||||
const handleMessageSent = useCallback((message: Message) => {
|
||||
setMessages(prev => [...prev, message]);
|
||||
setReplyTo(null);
|
||||
// 滚动到底部
|
||||
setTimeout(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, 100);
|
||||
}, []);
|
||||
|
||||
// 回复消息
|
||||
const handleReply = useCallback((message: Message) => {
|
||||
setReplyTo(message);
|
||||
}, []);
|
||||
|
||||
// 取消回复
|
||||
const handleCancelReply = useCallback(() => {
|
||||
setReplyTo(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Flex direction="column" h="full">
|
||||
{/* 频道头部 */}
|
||||
<ChannelHeader
|
||||
channel={channel}
|
||||
rightActions={
|
||||
<>
|
||||
<IconButton
|
||||
aria-label="置顶消息"
|
||||
icon={<Icon as={MdPushPin} />}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
/>
|
||||
<IconButton
|
||||
aria-label="搜索"
|
||||
icon={<Icon as={MdSearch} />}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
/>
|
||||
<IconButton
|
||||
aria-label="成员"
|
||||
icon={<Icon as={MdPeople} />}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onOpenRightPanel}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 消息列表 */}
|
||||
<Box
|
||||
ref={containerRef}
|
||||
flex={1}
|
||||
overflowY="auto"
|
||||
px={4}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{/* 加载更多指示器 */}
|
||||
{loadingMore && (
|
||||
<Flex justify="center" py={4}>
|
||||
<Spinner size="sm" />
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{/* 频道开始提示 */}
|
||||
{!hasMore && !loading && (
|
||||
<Box textAlign="center" py={8}>
|
||||
<Text fontSize="2xl" mb={2}>
|
||||
{channel.type === 'announcement' ? '📢' : '#'} {channel.name}
|
||||
</Text>
|
||||
<Text color="gray.500" fontSize="sm">
|
||||
这是 #{channel.name} 频道的开始
|
||||
</Text>
|
||||
{channel.topic && (
|
||||
<Text color="gray.500" fontSize="sm" mt={1}>
|
||||
{channel.topic}
|
||||
</Text>
|
||||
)}
|
||||
<Divider my={4} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 加载中 */}
|
||||
{loading ? (
|
||||
<Flex justify="center" align="center" h="full">
|
||||
<Spinner size="lg" />
|
||||
</Flex>
|
||||
) : (
|
||||
<>
|
||||
<MessageList
|
||||
messages={messages}
|
||||
onReply={handleReply}
|
||||
onThreadOpen={onThreadOpen}
|
||||
/>
|
||||
<div ref={messagesEndRef} />
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 输入框 */}
|
||||
{!isReadonly && (
|
||||
<Box px={4} pb={4}>
|
||||
<MessageInput
|
||||
channelId={channel.id}
|
||||
channelName={channel.name}
|
||||
replyTo={replyTo}
|
||||
onCancelReply={handleCancelReply}
|
||||
onMessageSent={handleMessageSent}
|
||||
slowMode={channel.slowMode}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 只读提示 */}
|
||||
{isReadonly && (
|
||||
<Box px={4} py={3} bg={useColorModeValue('gray.50', 'gray.900')} borderTopWidth="1px">
|
||||
<Text textAlign="center" color="gray.500" fontSize="sm">
|
||||
📢 这是公告频道,只有管理员可以发送消息
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default TextChannel;
|
||||
103
src/views/StockCommunity/components/MessageArea/index.tsx
Normal file
103
src/views/StockCommunity/components/MessageArea/index.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* 消息区域组件
|
||||
* 根据频道类型显示不同内容:即时聊天 / Forum 帖子
|
||||
*/
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
Text,
|
||||
Icon,
|
||||
useColorModeValue,
|
||||
VStack,
|
||||
} from '@chakra-ui/react';
|
||||
import { MdForum, MdChat, MdAnnouncement } from 'react-icons/md';
|
||||
|
||||
import { Channel } from '../../types';
|
||||
import TextChannel from './TextChannel';
|
||||
import ForumChannel from './ForumChannel';
|
||||
|
||||
interface MessageAreaProps {
|
||||
channel: Channel | null;
|
||||
onOpenRightPanel?: () => void;
|
||||
onRightPanelContentChange?: (content: 'members' | 'thread' | 'info') => void;
|
||||
}
|
||||
|
||||
const MessageArea: React.FC<MessageAreaProps> = ({
|
||||
channel,
|
||||
onOpenRightPanel,
|
||||
onRightPanelContentChange,
|
||||
}) => {
|
||||
const bgColor = useColorModeValue('white', 'gray.800');
|
||||
const emptyBg = useColorModeValue('gray.50', 'gray.900');
|
||||
const textColor = useColorModeValue('gray.600', 'gray.400');
|
||||
|
||||
// 未选择频道时的空状态
|
||||
if (!channel) {
|
||||
return (
|
||||
<Flex
|
||||
h="full"
|
||||
align="center"
|
||||
justify="center"
|
||||
bg={emptyBg}
|
||||
flexDirection="column"
|
||||
>
|
||||
<Icon as={MdChat} boxSize={16} color="gray.300" mb={4} />
|
||||
<Text fontSize="xl" fontWeight="bold" color={textColor} mb={2}>
|
||||
欢迎来到股票社区
|
||||
</Text>
|
||||
<Text fontSize="sm" color={textColor}>
|
||||
从左侧选择一个频道开始讨论
|
||||
</Text>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
// 根据频道类型渲染不同内容
|
||||
const renderContent = () => {
|
||||
switch (channel.type) {
|
||||
case 'forum':
|
||||
return (
|
||||
<ForumChannel
|
||||
channel={channel}
|
||||
onOpenRightPanel={onOpenRightPanel}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'announcement':
|
||||
return (
|
||||
<TextChannel
|
||||
channel={channel}
|
||||
isReadonly={true}
|
||||
onOpenRightPanel={onOpenRightPanel}
|
||||
onThreadOpen={(threadId) => {
|
||||
onRightPanelContentChange?.('thread');
|
||||
onOpenRightPanel?.();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'text':
|
||||
default:
|
||||
return (
|
||||
<TextChannel
|
||||
channel={channel}
|
||||
isReadonly={false}
|
||||
onOpenRightPanel={onOpenRightPanel}
|
||||
onThreadOpen={(threadId) => {
|
||||
onRightPanelContentChange?.('thread');
|
||||
onOpenRightPanel?.();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box h="full" bg={bgColor} display="flex" flexDirection="column">
|
||||
{renderContent()}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessageArea;
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* 频道头部组件
|
||||
*/
|
||||
import React from 'react';
|
||||
import {
|
||||
Flex,
|
||||
HStack,
|
||||
Text,
|
||||
Icon,
|
||||
useColorModeValue,
|
||||
Tooltip,
|
||||
} from '@chakra-ui/react';
|
||||
import { MdTag, MdAnnouncement, MdForum } from 'react-icons/md';
|
||||
|
||||
import { Channel, ChannelType } from '../../../types';
|
||||
|
||||
interface ChannelHeaderProps {
|
||||
channel: Channel;
|
||||
rightActions?: React.ReactNode;
|
||||
}
|
||||
|
||||
const ChannelHeader: React.FC<ChannelHeaderProps> = ({ channel, rightActions }) => {
|
||||
const bgColor = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
const textColor = useColorModeValue('gray.800', 'gray.100');
|
||||
const mutedColor = useColorModeValue('gray.500', 'gray.400');
|
||||
|
||||
// 获取频道图标
|
||||
const getChannelIcon = (type: ChannelType) => {
|
||||
switch (type) {
|
||||
case 'announcement':
|
||||
return MdAnnouncement;
|
||||
case 'forum':
|
||||
return MdForum;
|
||||
default:
|
||||
return MdTag;
|
||||
}
|
||||
};
|
||||
|
||||
const ChannelIcon = getChannelIcon(channel.type);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
align="center"
|
||||
justify="space-between"
|
||||
px={4}
|
||||
py={3}
|
||||
borderBottomWidth="1px"
|
||||
borderColor={borderColor}
|
||||
bg={bgColor}
|
||||
>
|
||||
{/* 左侧:频道名称和描述 */}
|
||||
<HStack spacing={2}>
|
||||
<Icon as={ChannelIcon} boxSize={5} color={mutedColor} />
|
||||
<Text fontWeight="bold" color={textColor}>
|
||||
{channel.name}
|
||||
</Text>
|
||||
{channel.topic && (
|
||||
<>
|
||||
<Text color={mutedColor}>|</Text>
|
||||
<Tooltip label={channel.topic} placement="bottom">
|
||||
<Text
|
||||
color={mutedColor}
|
||||
fontSize="sm"
|
||||
maxW="400px"
|
||||
isTruncated
|
||||
>
|
||||
{channel.topic}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
{/* 右侧:操作按钮 */}
|
||||
<HStack spacing={1}>
|
||||
{rightActions}
|
||||
</HStack>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelHeader;
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* 股票嵌入卡片组件
|
||||
* 显示股票实时行情
|
||||
*/
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
Text,
|
||||
HStack,
|
||||
Button,
|
||||
useColorModeValue,
|
||||
Icon,
|
||||
} from '@chakra-ui/react';
|
||||
import { MdTrendingUp, MdTrendingDown, MdOpenInNew } from 'react-icons/md';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface StockEmbedProps {
|
||||
symbol?: string;
|
||||
name?: string;
|
||||
price?: number;
|
||||
change?: number;
|
||||
changePercent?: number;
|
||||
}
|
||||
|
||||
const StockEmbed: React.FC<StockEmbedProps> = ({
|
||||
symbol,
|
||||
name,
|
||||
price,
|
||||
change,
|
||||
changePercent,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const bgColor = useColorModeValue('gray.50', 'gray.700');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
|
||||
const isUp = (change || 0) >= 0;
|
||||
const trendColor = isUp ? 'green.500' : 'red.500';
|
||||
const TrendIcon = isUp ? MdTrendingUp : MdTrendingDown;
|
||||
|
||||
// 格式化价格
|
||||
const formatPrice = (p?: number) => {
|
||||
if (p === undefined || p === null) return '--';
|
||||
return p.toFixed(2);
|
||||
};
|
||||
|
||||
// 格式化涨跌幅
|
||||
const formatChange = (c?: number) => {
|
||||
if (c === undefined || c === null) return '--';
|
||||
const prefix = c >= 0 ? '+' : '';
|
||||
return `${prefix}${c.toFixed(2)}`;
|
||||
};
|
||||
|
||||
const formatPercent = (p?: number) => {
|
||||
if (p === undefined || p === null) return '--';
|
||||
const prefix = p >= 0 ? '+' : '';
|
||||
return `${prefix}${p.toFixed(2)}%`;
|
||||
};
|
||||
|
||||
// 跳转到股票详情
|
||||
const handleViewStock = () => {
|
||||
if (symbol) {
|
||||
navigate(`/stock/${symbol}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
bg={bgColor}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
borderLeftWidth="4px"
|
||||
borderLeftColor={trendColor}
|
||||
borderRadius="md"
|
||||
p={3}
|
||||
maxW="350px"
|
||||
>
|
||||
<Flex justify="space-between" align="flex-start">
|
||||
{/* 左侧:股票信息 */}
|
||||
<Box>
|
||||
<HStack spacing={2} mb={1}>
|
||||
<Text fontWeight="bold" fontSize="md">
|
||||
{name || symbol || '未知股票'}
|
||||
</Text>
|
||||
{symbol && (
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
{symbol}
|
||||
</Text>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
<HStack spacing={3}>
|
||||
<Text fontSize="xl" fontWeight="bold">
|
||||
¥{formatPrice(price)}
|
||||
</Text>
|
||||
<HStack spacing={1} color={trendColor}>
|
||||
<Icon as={TrendIcon} />
|
||||
<Text fontWeight="semibold">
|
||||
{formatChange(change)}
|
||||
</Text>
|
||||
<Text fontWeight="semibold">
|
||||
({formatPercent(changePercent)})
|
||||
</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</Box>
|
||||
|
||||
{/* 右侧:操作按钮 */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
rightIcon={<MdOpenInNew />}
|
||||
onClick={handleViewStock}
|
||||
>
|
||||
查看
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
{/* 迷你 K 线图占位(可后续实现) */}
|
||||
{/* <Box mt={2} h="40px" bg="gray.200" borderRadius="sm" /> */}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default StockEmbed;
|
||||
234
src/views/StockCommunity/components/RightPanel/ConceptInfo.tsx
Normal file
234
src/views/StockCommunity/components/RightPanel/ConceptInfo.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* 概念详情组件
|
||||
* 显示概念板块的相关信息和成分股
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Stat,
|
||||
StatLabel,
|
||||
StatNumber,
|
||||
StatHelpText,
|
||||
StatArrow,
|
||||
SimpleGrid,
|
||||
Skeleton,
|
||||
useColorModeValue,
|
||||
Divider,
|
||||
Tag,
|
||||
Button,
|
||||
} from '@chakra-ui/react';
|
||||
import { MdTrendingUp, MdTrendingDown } from 'react-icons/md';
|
||||
|
||||
interface ConceptInfoProps {
|
||||
conceptCode: string;
|
||||
}
|
||||
|
||||
// 模拟概念数据
|
||||
interface ConceptData {
|
||||
code: string;
|
||||
name: string;
|
||||
change: number;
|
||||
changePercent: number;
|
||||
stockCount: number;
|
||||
upCount: number;
|
||||
downCount: number;
|
||||
flatCount: number;
|
||||
leadingStocks: Array<{
|
||||
symbol: string;
|
||||
name: string;
|
||||
change: number;
|
||||
}>;
|
||||
relatedConcepts: string[];
|
||||
}
|
||||
|
||||
const ConceptInfo: React.FC<ConceptInfoProps> = ({ conceptCode }) => {
|
||||
const [conceptData, setConceptData] = useState<ConceptData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const bgColor = useColorModeValue('white', 'gray.800');
|
||||
const cardBg = useColorModeValue('gray.50', 'gray.700');
|
||||
const textColor = useColorModeValue('gray.800', 'gray.100');
|
||||
const mutedColor = useColorModeValue('gray.500', 'gray.400');
|
||||
|
||||
// 加载概念数据
|
||||
useEffect(() => {
|
||||
const loadConcept = async () => {
|
||||
setLoading(true);
|
||||
// 模拟加载
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// 模拟数据
|
||||
setConceptData({
|
||||
code: conceptCode,
|
||||
name: '新能源',
|
||||
change: 28.5,
|
||||
changePercent: 1.25,
|
||||
stockCount: 156,
|
||||
upCount: 98,
|
||||
downCount: 45,
|
||||
flatCount: 13,
|
||||
leadingStocks: [
|
||||
{ symbol: '300750', name: '宁德时代', change: 3.25 },
|
||||
{ symbol: '002594', name: '比亚迪', change: 2.88 },
|
||||
{ symbol: '600438', name: '通威股份', change: 2.15 },
|
||||
{ symbol: '002129', name: '中环股份', change: 1.98 },
|
||||
{ symbol: '601012', name: '隆基绿能', change: 1.65 },
|
||||
],
|
||||
relatedConcepts: ['锂电池', '光伏', '储能', '新能源车'],
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
loadConcept();
|
||||
}, [conceptCode]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box p={4}>
|
||||
<VStack spacing={4} align="stretch">
|
||||
<Skeleton h="60px" />
|
||||
<Skeleton h="100px" />
|
||||
<Skeleton h="150px" />
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!conceptData) {
|
||||
return (
|
||||
<Box p={4} textAlign="center" color={mutedColor}>
|
||||
加载失败
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const isUp = conceptData.changePercent >= 0;
|
||||
|
||||
return (
|
||||
<Box p={4}>
|
||||
<VStack spacing={4} align="stretch">
|
||||
{/* 概念涨跌幅 */}
|
||||
<Box bg={cardBg} p={4} borderRadius="md">
|
||||
<HStack justify="space-between" align="flex-start">
|
||||
<Box>
|
||||
<Text fontSize="lg" fontWeight="bold" color={textColor}>
|
||||
{conceptData.name}
|
||||
</Text>
|
||||
<Text fontSize="xs" color={mutedColor}>
|
||||
{conceptData.stockCount} 只成分股
|
||||
</Text>
|
||||
</Box>
|
||||
<Stat textAlign="right" size="sm">
|
||||
<StatNumber color={isUp ? 'green.500' : 'red.500'}>
|
||||
{isUp ? '+' : ''}{conceptData.changePercent.toFixed(2)}%
|
||||
</StatNumber>
|
||||
<StatHelpText mb={0}>
|
||||
<StatArrow type={isUp ? 'increase' : 'decrease'} />
|
||||
{conceptData.change.toFixed(2)}
|
||||
</StatHelpText>
|
||||
</Stat>
|
||||
</HStack>
|
||||
|
||||
{/* 涨跌分布 */}
|
||||
<HStack mt={3} spacing={4}>
|
||||
<HStack>
|
||||
<Box w="8px" h="8px" bg="green.500" borderRadius="full" />
|
||||
<Text fontSize="xs" color={mutedColor}>
|
||||
上涨 {conceptData.upCount}
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack>
|
||||
<Box w="8px" h="8px" bg="gray.400" borderRadius="full" />
|
||||
<Text fontSize="xs" color={mutedColor}>
|
||||
平盘 {conceptData.flatCount}
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack>
|
||||
<Box w="8px" h="8px" bg="red.500" borderRadius="full" />
|
||||
<Text fontSize="xs" color={mutedColor}>
|
||||
下跌 {conceptData.downCount}
|
||||
</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 领涨股票 */}
|
||||
<Box>
|
||||
<Text fontSize="sm" fontWeight="bold" mb={2} color={textColor}>
|
||||
领涨股票
|
||||
</Text>
|
||||
<VStack spacing={2} align="stretch">
|
||||
{conceptData.leadingStocks.map((stock, index) => (
|
||||
<HStack
|
||||
key={stock.symbol}
|
||||
justify="space-between"
|
||||
py={1}
|
||||
px={2}
|
||||
bg={cardBg}
|
||||
borderRadius="md"
|
||||
cursor="pointer"
|
||||
_hover={{ opacity: 0.8 }}
|
||||
>
|
||||
<HStack>
|
||||
<Text fontSize="xs" color={mutedColor} w="16px">
|
||||
{index + 1}
|
||||
</Text>
|
||||
<Box>
|
||||
<Text fontSize="sm" fontWeight="medium">
|
||||
{stock.name}
|
||||
</Text>
|
||||
<Text fontSize="xs" color={mutedColor}>
|
||||
{stock.symbol}
|
||||
</Text>
|
||||
</Box>
|
||||
</HStack>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="bold"
|
||||
color={stock.change >= 0 ? 'green.500' : 'red.500'}
|
||||
>
|
||||
{stock.change >= 0 ? '+' : ''}{stock.change.toFixed(2)}%
|
||||
</Text>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 相关概念 */}
|
||||
<Box>
|
||||
<Text fontSize="sm" fontWeight="bold" mb={2} color={textColor}>
|
||||
相关概念
|
||||
</Text>
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
{conceptData.relatedConcepts.map(concept => (
|
||||
<Tag
|
||||
key={concept}
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
variant="subtle"
|
||||
cursor="pointer"
|
||||
>
|
||||
{concept}
|
||||
</Tag>
|
||||
))}
|
||||
</HStack>
|
||||
</Box>
|
||||
|
||||
{/* 查看更多 */}
|
||||
<Button size="sm" variant="outline" colorScheme="blue">
|
||||
查看概念详情
|
||||
</Button>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConceptInfo;
|
||||
202
src/views/StockCommunity/components/RightPanel/MemberList.tsx
Normal file
202
src/views/StockCommunity/components/RightPanel/MemberList.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* 成员列表组件
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Avatar,
|
||||
Badge,
|
||||
Skeleton,
|
||||
useColorModeValue,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputLeftElement,
|
||||
} from '@chakra-ui/react';
|
||||
import { SearchIcon } from '@chakra-ui/icons';
|
||||
|
||||
import { CommunityMember } from '../../types';
|
||||
|
||||
interface MemberListProps {
|
||||
channelId: string;
|
||||
}
|
||||
|
||||
// 模拟成员数据(后续替换为 API 调用)
|
||||
const mockMembers: CommunityMember[] = [
|
||||
{ userId: '1', username: '价值投资者', isOnline: true, badge: '大V', level: 5 },
|
||||
{ userId: '2', username: '趋势猎手', isOnline: true, badge: '研究员', level: 4 },
|
||||
{ userId: '3', username: '短线达人', isOnline: true, level: 3 },
|
||||
{ userId: '4', username: '稳健派', isOnline: false, level: 2 },
|
||||
{ userId: '5', username: '新手小白', isOnline: false, level: 1 },
|
||||
{ userId: '6', username: '量化先锋', isOnline: true, badge: '研究员', level: 4 },
|
||||
{ userId: '7', username: '基本面分析师', isOnline: false, level: 3 },
|
||||
{ userId: '8', username: '技术派高手', isOnline: true, level: 3 },
|
||||
];
|
||||
|
||||
const MemberList: React.FC<MemberListProps> = ({ channelId }) => {
|
||||
const [members, setMembers] = useState<CommunityMember[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const headerBg = useColorModeValue('gray.50', 'gray.900');
|
||||
const hoverBg = useColorModeValue('gray.100', 'gray.700');
|
||||
const textColor = useColorModeValue('gray.800', 'gray.100');
|
||||
const mutedColor = useColorModeValue('gray.500', 'gray.400');
|
||||
|
||||
// 加载成员
|
||||
useEffect(() => {
|
||||
const loadMembers = async () => {
|
||||
setLoading(true);
|
||||
// 模拟加载
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
setMembers(mockMembers);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
loadMembers();
|
||||
}, [channelId]);
|
||||
|
||||
// 过滤成员
|
||||
const filteredMembers = searchTerm
|
||||
? members.filter(m => m.username.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
: members;
|
||||
|
||||
// 分组:在线 / 离线
|
||||
const onlineMembers = filteredMembers.filter(m => m.isOnline);
|
||||
const offlineMembers = filteredMembers.filter(m => !m.isOnline);
|
||||
|
||||
// 渲染成员项
|
||||
const renderMember = (member: CommunityMember) => (
|
||||
<HStack
|
||||
key={member.userId}
|
||||
px={3}
|
||||
py={2}
|
||||
cursor="pointer"
|
||||
borderRadius="md"
|
||||
_hover={{ bg: hoverBg }}
|
||||
opacity={member.isOnline ? 1 : 0.6}
|
||||
>
|
||||
<Box position="relative">
|
||||
<Avatar size="sm" name={member.username} src={member.avatar} />
|
||||
{member.isOnline && (
|
||||
<Box
|
||||
position="absolute"
|
||||
bottom={0}
|
||||
right={0}
|
||||
w="10px"
|
||||
h="10px"
|
||||
bg="green.400"
|
||||
borderRadius="full"
|
||||
borderWidth="2px"
|
||||
borderColor={useColorModeValue('white', 'gray.800')}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box flex={1}>
|
||||
<HStack spacing={1}>
|
||||
<Text fontSize="sm" fontWeight="medium" color={textColor}>
|
||||
{member.username}
|
||||
</Text>
|
||||
{member.badge && (
|
||||
<Badge
|
||||
size="sm"
|
||||
colorScheme={member.badge === '大V' ? 'yellow' : 'blue'}
|
||||
fontSize="xs"
|
||||
>
|
||||
{member.badge}
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
{member.level && (
|
||||
<Text fontSize="xs" color={mutedColor}>
|
||||
Lv.{member.level}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</HStack>
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box p={3}>
|
||||
<VStack spacing={3}>
|
||||
{[1, 2, 3, 4, 5].map(i => (
|
||||
<HStack key={i} w="full">
|
||||
<Skeleton borderRadius="full" w="32px" h="32px" />
|
||||
<Skeleton h="20px" flex={1} />
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* 搜索框 */}
|
||||
<Box p={3} bg={headerBg}>
|
||||
<InputGroup size="sm">
|
||||
<InputLeftElement>
|
||||
<SearchIcon color={mutedColor} />
|
||||
</InputLeftElement>
|
||||
<Input
|
||||
placeholder="搜索成员"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
borderRadius="md"
|
||||
/>
|
||||
</InputGroup>
|
||||
</Box>
|
||||
|
||||
{/* 在线成员 */}
|
||||
{onlineMembers.length > 0 && (
|
||||
<Box>
|
||||
<Text
|
||||
px={3}
|
||||
py={2}
|
||||
fontSize="xs"
|
||||
fontWeight="bold"
|
||||
color={mutedColor}
|
||||
textTransform="uppercase"
|
||||
>
|
||||
在线 — {onlineMembers.length}
|
||||
</Text>
|
||||
<VStack spacing={0} align="stretch">
|
||||
{onlineMembers.map(renderMember)}
|
||||
</VStack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 离线成员 */}
|
||||
{offlineMembers.length > 0 && (
|
||||
<Box mt={4}>
|
||||
<Text
|
||||
px={3}
|
||||
py={2}
|
||||
fontSize="xs"
|
||||
fontWeight="bold"
|
||||
color={mutedColor}
|
||||
textTransform="uppercase"
|
||||
>
|
||||
离线 — {offlineMembers.length}
|
||||
</Text>
|
||||
<VStack spacing={0} align="stretch">
|
||||
{offlineMembers.map(renderMember)}
|
||||
</VStack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 空状态 */}
|
||||
{filteredMembers.length === 0 && (
|
||||
<Box textAlign="center" py={8} color={mutedColor}>
|
||||
{searchTerm ? '未找到匹配的成员' : '暂无成员'}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default MemberList;
|
||||
210
src/views/StockCommunity/components/RightPanel/ThreadList.tsx
Normal file
210
src/views/StockCommunity/components/RightPanel/ThreadList.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* 讨论串列表组件
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Badge,
|
||||
Skeleton,
|
||||
useColorModeValue,
|
||||
Icon,
|
||||
} from '@chakra-ui/react';
|
||||
import { MdForum, MdPushPin } from 'react-icons/md';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { zhCN } from 'date-fns/locale';
|
||||
|
||||
import { Thread } from '../../types';
|
||||
|
||||
interface ThreadListProps {
|
||||
channelId: string;
|
||||
}
|
||||
|
||||
// 模拟讨论串数据
|
||||
const mockThreads: Thread[] = [
|
||||
{
|
||||
id: 't1',
|
||||
channelId: 'ch1',
|
||||
parentMessageId: 'm1',
|
||||
name: 'Q3财报讨论',
|
||||
ownerId: 'u1',
|
||||
autoArchiveMinutes: 1440,
|
||||
isLocked: false,
|
||||
isPinned: true,
|
||||
isArchived: false,
|
||||
messageCount: 28,
|
||||
memberCount: 12,
|
||||
lastMessageAt: new Date().toISOString(),
|
||||
createdAt: new Date(Date.now() - 3600000).toISOString(),
|
||||
},
|
||||
{
|
||||
id: 't2',
|
||||
channelId: 'ch1',
|
||||
parentMessageId: 'm2',
|
||||
name: '技术形态分析',
|
||||
ownerId: 'u2',
|
||||
autoArchiveMinutes: 1440,
|
||||
isLocked: false,
|
||||
isPinned: false,
|
||||
isArchived: false,
|
||||
messageCount: 15,
|
||||
memberCount: 8,
|
||||
lastMessageAt: new Date(Date.now() - 1800000).toISOString(),
|
||||
createdAt: new Date(Date.now() - 7200000).toISOString(),
|
||||
},
|
||||
{
|
||||
id: 't3',
|
||||
channelId: 'ch1',
|
||||
parentMessageId: 'm3',
|
||||
name: '明日操作策略',
|
||||
ownerId: 'u3',
|
||||
autoArchiveMinutes: 1440,
|
||||
isLocked: false,
|
||||
isPinned: false,
|
||||
isArchived: false,
|
||||
messageCount: 42,
|
||||
memberCount: 18,
|
||||
lastMessageAt: new Date(Date.now() - 600000).toISOString(),
|
||||
createdAt: new Date(Date.now() - 10800000).toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
const ThreadList: React.FC<ThreadListProps> = ({ channelId }) => {
|
||||
const [threads, setThreads] = useState<Thread[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const hoverBg = useColorModeValue('gray.100', 'gray.700');
|
||||
const textColor = useColorModeValue('gray.800', 'gray.100');
|
||||
const mutedColor = useColorModeValue('gray.500', 'gray.400');
|
||||
|
||||
// 加载讨论串
|
||||
useEffect(() => {
|
||||
const loadThreads = async () => {
|
||||
setLoading(true);
|
||||
// 模拟加载
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
setThreads(mockThreads);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
loadThreads();
|
||||
}, [channelId]);
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (dateStr: string) => {
|
||||
return formatDistanceToNow(new Date(dateStr), {
|
||||
addSuffix: true,
|
||||
locale: zhCN,
|
||||
});
|
||||
};
|
||||
|
||||
// 渲染讨论串项
|
||||
const renderThread = (thread: Thread) => (
|
||||
<Box
|
||||
key={thread.id}
|
||||
px={3}
|
||||
py={3}
|
||||
cursor="pointer"
|
||||
borderRadius="md"
|
||||
_hover={{ bg: hoverBg }}
|
||||
>
|
||||
<HStack spacing={2} mb={1}>
|
||||
{thread.isPinned && (
|
||||
<Icon as={MdPushPin} color="blue.500" boxSize={4} />
|
||||
)}
|
||||
<Icon as={MdForum} color={mutedColor} boxSize={4} />
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="medium"
|
||||
color={textColor}
|
||||
flex={1}
|
||||
isTruncated
|
||||
>
|
||||
{thread.name}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
<HStack spacing={3} pl={6} fontSize="xs" color={mutedColor}>
|
||||
<Text>{thread.messageCount} 条消息</Text>
|
||||
<Text>·</Text>
|
||||
<Text>{formatTime(thread.lastMessageAt || thread.createdAt)}</Text>
|
||||
</HStack>
|
||||
</Box>
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box p={3}>
|
||||
<VStack spacing={3}>
|
||||
{[1, 2, 3].map(i => (
|
||||
<Box key={i} w="full">
|
||||
<Skeleton h="20px" mb={2} />
|
||||
<Skeleton h="14px" w="60%" />
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// 分组:置顶 / 活跃
|
||||
const pinnedThreads = threads.filter(t => t.isPinned);
|
||||
const activeThreads = threads.filter(t => !t.isPinned && !t.isArchived);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* 置顶讨论串 */}
|
||||
{pinnedThreads.length > 0 && (
|
||||
<Box>
|
||||
<Text
|
||||
px={3}
|
||||
py={2}
|
||||
fontSize="xs"
|
||||
fontWeight="bold"
|
||||
color={mutedColor}
|
||||
textTransform="uppercase"
|
||||
>
|
||||
置顶
|
||||
</Text>
|
||||
<VStack spacing={0} align="stretch">
|
||||
{pinnedThreads.map(renderThread)}
|
||||
</VStack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 活跃讨论串 */}
|
||||
{activeThreads.length > 0 && (
|
||||
<Box mt={pinnedThreads.length > 0 ? 2 : 0}>
|
||||
<Text
|
||||
px={3}
|
||||
py={2}
|
||||
fontSize="xs"
|
||||
fontWeight="bold"
|
||||
color={mutedColor}
|
||||
textTransform="uppercase"
|
||||
>
|
||||
活跃讨论串
|
||||
</Text>
|
||||
<VStack spacing={0} align="stretch">
|
||||
{activeThreads.map(renderThread)}
|
||||
</VStack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 空状态 */}
|
||||
{threads.length === 0 && (
|
||||
<Box textAlign="center" py={8} color={mutedColor}>
|
||||
<Icon as={MdForum} boxSize={8} mb={2} />
|
||||
<Text>暂无讨论串</Text>
|
||||
<Text fontSize="xs" mt={1}>
|
||||
在消息上点击"创建讨论串"开始
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThreadList;
|
||||
100
src/views/StockCommunity/components/RightPanel/index.tsx
Normal file
100
src/views/StockCommunity/components/RightPanel/index.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* 右侧面板组件
|
||||
* 显示成员列表 / 概念详情 / 讨论串
|
||||
*/
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Tabs,
|
||||
TabList,
|
||||
TabPanels,
|
||||
Tab,
|
||||
TabPanel,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
import { Channel } from '../../types';
|
||||
import MemberList from './MemberList';
|
||||
import ConceptInfo from './ConceptInfo';
|
||||
import ThreadList from './ThreadList';
|
||||
|
||||
interface RightPanelProps {
|
||||
channel: Channel | null;
|
||||
contentType: 'members' | 'thread' | 'info';
|
||||
onContentTypeChange: (type: 'members' | 'thread' | 'info') => void;
|
||||
}
|
||||
|
||||
const RightPanel: React.FC<RightPanelProps> = ({
|
||||
channel,
|
||||
contentType,
|
||||
onContentTypeChange,
|
||||
}) => {
|
||||
const bgColor = useColorModeValue('white', 'gray.800');
|
||||
const headerBg = useColorModeValue('gray.50', 'gray.900');
|
||||
|
||||
// Tab 索引映射
|
||||
const tabIndexMap = {
|
||||
members: 0,
|
||||
info: 1,
|
||||
thread: 2,
|
||||
};
|
||||
|
||||
const handleTabChange = (index: number) => {
|
||||
const types: Array<'members' | 'info' | 'thread'> = ['members', 'info', 'thread'];
|
||||
onContentTypeChange(types[index]);
|
||||
};
|
||||
|
||||
if (!channel) {
|
||||
return (
|
||||
<Box h="full" bg={bgColor} p={4}>
|
||||
<Box color="gray.500" textAlign="center" mt={10}>
|
||||
选择一个频道查看详情
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// 是否是概念频道
|
||||
const isConceptChannel = !!channel.conceptCode;
|
||||
|
||||
return (
|
||||
<Box h="full" bg={bgColor} display="flex" flexDirection="column">
|
||||
<Tabs
|
||||
index={tabIndexMap[contentType]}
|
||||
onChange={handleTabChange}
|
||||
variant="enclosed"
|
||||
size="sm"
|
||||
h="full"
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
>
|
||||
<TabList bg={headerBg} px={2} pt={2}>
|
||||
<Tab fontSize="xs">成员</Tab>
|
||||
{isConceptChannel && <Tab fontSize="xs">概念</Tab>}
|
||||
<Tab fontSize="xs">讨论串</Tab>
|
||||
</TabList>
|
||||
|
||||
<TabPanels flex={1} overflow="hidden">
|
||||
{/* 成员列表 */}
|
||||
<TabPanel h="full" p={0} overflow="auto">
|
||||
<MemberList channelId={channel.id} />
|
||||
</TabPanel>
|
||||
|
||||
{/* 概念详情(仅概念频道) */}
|
||||
{isConceptChannel && (
|
||||
<TabPanel h="full" p={0} overflow="auto">
|
||||
<ConceptInfo conceptCode={channel.conceptCode!} />
|
||||
</TabPanel>
|
||||
)}
|
||||
|
||||
{/* 讨论串列表 */}
|
||||
<TabPanel h="full" p={0} overflow="auto">
|
||||
<ThreadList channelId={channel.id} />
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default RightPanel;
|
||||
243
src/views/StockCommunity/hooks/useCommunitySocket.ts
Normal file
243
src/views/StockCommunity/hooks/useCommunitySocket.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* 社区 WebSocket Hook
|
||||
* 处理实时消息推送
|
||||
*/
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
import { getApiBase } from '@/utils/apiConfig';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { ServerEventType, Message } from '../types';
|
||||
|
||||
type EventCallback = (data: any) => void;
|
||||
|
||||
interface UseCommunitySocketReturn {
|
||||
isConnected: boolean;
|
||||
subscribe: (channelId: string) => void;
|
||||
unsubscribe: (channelId: string) => void;
|
||||
sendMessage: (channelId: string, content: string, options?: any) => void;
|
||||
startTyping: (channelId: string) => void;
|
||||
stopTyping: (channelId: string) => void;
|
||||
onMessage: (event: ServerEventType, callback: EventCallback) => void;
|
||||
offMessage: (event: ServerEventType, callback: EventCallback) => void;
|
||||
}
|
||||
|
||||
export const useCommunitySocket = (): UseCommunitySocketReturn => {
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const eventListenersRef = useRef<Map<ServerEventType, Set<EventCallback>>>(new Map());
|
||||
const subscribedChannelsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const { user, isAuthenticated } = useAuth();
|
||||
|
||||
// 初始化 Socket 连接
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
// 未登录时断开连接
|
||||
if (socketRef.current) {
|
||||
socketRef.current.disconnect();
|
||||
socketRef.current = null;
|
||||
setIsConnected(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const apiBase = getApiBase();
|
||||
const socket = io(`${apiBase}/community`, {
|
||||
transports: ['websocket', 'polling'],
|
||||
withCredentials: true,
|
||||
autoConnect: true,
|
||||
});
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('[Community Socket] Connected');
|
||||
setIsConnected(true);
|
||||
|
||||
// 重新订阅之前的频道
|
||||
subscribedChannelsRef.current.forEach(channelId => {
|
||||
socket.emit('SUBSCRIBE_CHANNEL', { channelId });
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('disconnect', (reason) => {
|
||||
console.log('[Community Socket] Disconnected:', reason);
|
||||
setIsConnected(false);
|
||||
});
|
||||
|
||||
socket.on('connect_error', (error) => {
|
||||
console.error('[Community Socket] Connection error:', error);
|
||||
});
|
||||
|
||||
// 监听服务器事件
|
||||
const serverEvents: ServerEventType[] = [
|
||||
'MESSAGE_CREATE',
|
||||
'MESSAGE_UPDATE',
|
||||
'MESSAGE_DELETE',
|
||||
'REACTION_ADD',
|
||||
'REACTION_REMOVE',
|
||||
'TYPING_START',
|
||||
'TYPING_STOP',
|
||||
'MEMBER_JOIN',
|
||||
'MEMBER_LEAVE',
|
||||
'CHANNEL_UPDATE',
|
||||
];
|
||||
|
||||
serverEvents.forEach(event => {
|
||||
socket.on(event, (data: any) => {
|
||||
const listeners = eventListenersRef.current.get(event);
|
||||
if (listeners) {
|
||||
listeners.forEach(callback => callback(data));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
socketRef.current = socket;
|
||||
|
||||
return () => {
|
||||
socket.disconnect();
|
||||
socketRef.current = null;
|
||||
};
|
||||
}, [isAuthenticated]);
|
||||
|
||||
// 订阅频道
|
||||
const subscribe = useCallback((channelId: string) => {
|
||||
subscribedChannelsRef.current.add(channelId);
|
||||
|
||||
if (socketRef.current?.connected) {
|
||||
socketRef.current.emit('SUBSCRIBE_CHANNEL', { channelId });
|
||||
console.log('[Community Socket] Subscribed to channel:', channelId);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 取消订阅频道
|
||||
const unsubscribe = useCallback((channelId: string) => {
|
||||
subscribedChannelsRef.current.delete(channelId);
|
||||
|
||||
if (socketRef.current?.connected) {
|
||||
socketRef.current.emit('UNSUBSCRIBE_CHANNEL', { channelId });
|
||||
console.log('[Community Socket] Unsubscribed from channel:', channelId);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 发送消息
|
||||
const sendMessage = useCallback((channelId: string, content: string, options?: any) => {
|
||||
if (socketRef.current?.connected) {
|
||||
socketRef.current.emit('SEND_MESSAGE', {
|
||||
channelId,
|
||||
content,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 开始输入
|
||||
const startTyping = useCallback((channelId: string) => {
|
||||
if (socketRef.current?.connected) {
|
||||
socketRef.current.emit('START_TYPING', { channelId });
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 停止输入
|
||||
const stopTyping = useCallback((channelId: string) => {
|
||||
if (socketRef.current?.connected) {
|
||||
socketRef.current.emit('STOP_TYPING', { channelId });
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 注册事件监听
|
||||
const onMessage = useCallback((event: ServerEventType, callback: EventCallback) => {
|
||||
if (!eventListenersRef.current.has(event)) {
|
||||
eventListenersRef.current.set(event, new Set());
|
||||
}
|
||||
eventListenersRef.current.get(event)!.add(callback);
|
||||
}, []);
|
||||
|
||||
// 移除事件监听
|
||||
const offMessage = useCallback((event: ServerEventType, callback: EventCallback) => {
|
||||
const listeners = eventListenersRef.current.get(event);
|
||||
if (listeners) {
|
||||
listeners.delete(callback);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isConnected,
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
sendMessage,
|
||||
startTyping,
|
||||
stopTyping,
|
||||
onMessage,
|
||||
offMessage,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 输入状态 Hook
|
||||
* 显示"某某正在输入..."
|
||||
*/
|
||||
export const useTypingIndicator = (channelId: string) => {
|
||||
const [typingUsers, setTypingUsers] = useState<Map<string, { name: string; timestamp: number }>>(
|
||||
new Map()
|
||||
);
|
||||
const { onMessage, offMessage } = useCommunitySocket();
|
||||
|
||||
useEffect(() => {
|
||||
const handleTypingStart = (data: { userId: string; userName: string; channelId: string }) => {
|
||||
if (data.channelId !== channelId) return;
|
||||
|
||||
setTypingUsers(prev => {
|
||||
const next = new Map(prev);
|
||||
next.set(data.userId, { name: data.userName, timestamp: Date.now() });
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleTypingStop = (data: { userId: string; channelId: string }) => {
|
||||
if (data.channelId !== channelId) return;
|
||||
|
||||
setTypingUsers(prev => {
|
||||
const next = new Map(prev);
|
||||
next.delete(data.userId);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
onMessage('TYPING_START', handleTypingStart);
|
||||
onMessage('TYPING_STOP', handleTypingStop);
|
||||
|
||||
// 清理超时的输入状态(10秒)
|
||||
const cleanup = setInterval(() => {
|
||||
const now = Date.now();
|
||||
setTypingUsers(prev => {
|
||||
const next = new Map(prev);
|
||||
prev.forEach((value, key) => {
|
||||
if (now - value.timestamp > 10000) {
|
||||
next.delete(key);
|
||||
}
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}, 5000);
|
||||
|
||||
return () => {
|
||||
offMessage('TYPING_START', handleTypingStart);
|
||||
offMessage('TYPING_STOP', handleTypingStop);
|
||||
clearInterval(cleanup);
|
||||
};
|
||||
}, [channelId, onMessage, offMessage]);
|
||||
|
||||
// 格式化输入提示文本
|
||||
const typingText = (() => {
|
||||
const users = Array.from(typingUsers.values());
|
||||
if (users.length === 0) return '';
|
||||
if (users.length === 1) return `${users[0].name} 正在输入...`;
|
||||
if (users.length === 2) return `${users[0].name} 和 ${users[1].name} 正在输入...`;
|
||||
return `${users[0].name} 等 ${users.length} 人正在输入...`;
|
||||
})();
|
||||
|
||||
return {
|
||||
typingUsers: Array.from(typingUsers.values()),
|
||||
typingText,
|
||||
isTyping: typingUsers.size > 0,
|
||||
};
|
||||
};
|
||||
194
src/views/StockCommunity/index.tsx
Normal file
194
src/views/StockCommunity/index.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* 股票社区主页面 - Discord 风格
|
||||
* 三栏布局:频道列表 | 消息区域 | 右侧面板
|
||||
*/
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
useColorModeValue,
|
||||
useBreakpointValue,
|
||||
IconButton,
|
||||
Drawer,
|
||||
DrawerOverlay,
|
||||
DrawerContent,
|
||||
DrawerCloseButton,
|
||||
useDisclosure,
|
||||
} from '@chakra-ui/react';
|
||||
import { HamburgerIcon } from '@chakra-ui/icons';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
import ChannelSidebar from './components/ChannelSidebar';
|
||||
import MessageArea from './components/MessageArea';
|
||||
import RightPanel from './components/RightPanel';
|
||||
import { useCommunitySocket } from './hooks/useCommunitySocket';
|
||||
import { Channel, ChannelType } from './types';
|
||||
|
||||
const StockCommunity: React.FC = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [activeChannel, setActiveChannel] = useState<Channel | null>(null);
|
||||
const [rightPanelContent, setRightPanelContent] = useState<'members' | 'thread' | 'info'>('members');
|
||||
|
||||
// 移动端抽屉
|
||||
const { isOpen: isLeftOpen, onOpen: onLeftOpen, onClose: onLeftClose } = useDisclosure();
|
||||
const { isOpen: isRightOpen, onOpen: onRightOpen, onClose: onRightClose } = useDisclosure();
|
||||
|
||||
// 响应式布局
|
||||
const isMobile = useBreakpointValue({ base: true, lg: false });
|
||||
const showLeftSidebar = useBreakpointValue({ base: false, lg: true });
|
||||
const showRightPanel = useBreakpointValue({ base: false, xl: true });
|
||||
|
||||
// WebSocket 连接
|
||||
const { isConnected, subscribe, unsubscribe } = useCommunitySocket();
|
||||
|
||||
// 颜色
|
||||
const bgColor = useColorModeValue('gray.50', 'gray.900');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
|
||||
// 从 URL 参数获取频道 ID
|
||||
useEffect(() => {
|
||||
const channelId = searchParams.get('channel');
|
||||
if (channelId && !activeChannel) {
|
||||
// 将在 ChannelSidebar 加载频道后设置
|
||||
}
|
||||
}, [searchParams, activeChannel]);
|
||||
|
||||
// 切换频道
|
||||
const handleChannelSelect = useCallback((channel: Channel) => {
|
||||
// 取消订阅旧频道
|
||||
if (activeChannel) {
|
||||
unsubscribe(activeChannel.id);
|
||||
}
|
||||
|
||||
// 订阅新频道
|
||||
subscribe(channel.id);
|
||||
setActiveChannel(channel);
|
||||
|
||||
// 更新 URL
|
||||
setSearchParams({ channel: channel.id });
|
||||
|
||||
// 移动端关闭抽屉
|
||||
if (isMobile) {
|
||||
onLeftClose();
|
||||
}
|
||||
}, [activeChannel, subscribe, unsubscribe, setSearchParams, isMobile, onLeftClose]);
|
||||
|
||||
// 渲染频道侧边栏
|
||||
const renderChannelSidebar = () => (
|
||||
<ChannelSidebar
|
||||
activeChannelId={activeChannel?.id}
|
||||
onChannelSelect={handleChannelSelect}
|
||||
initialChannelId={searchParams.get('channel')}
|
||||
/>
|
||||
);
|
||||
|
||||
// 渲染右侧面板
|
||||
const renderRightPanel = () => (
|
||||
<RightPanel
|
||||
channel={activeChannel}
|
||||
contentType={rightPanelContent}
|
||||
onContentTypeChange={setRightPanelContent}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex h="calc(100vh - 60px)" bg={bgColor} overflow="hidden">
|
||||
{/* 移动端顶部操作栏 */}
|
||||
{isMobile && (
|
||||
<Box
|
||||
position="fixed"
|
||||
top="60px"
|
||||
left={0}
|
||||
right={0}
|
||||
h="50px"
|
||||
bg={bgColor}
|
||||
borderBottomWidth="1px"
|
||||
borderColor={borderColor}
|
||||
px={4}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
zIndex={10}
|
||||
>
|
||||
<IconButton
|
||||
aria-label="打开频道列表"
|
||||
icon={<HamburgerIcon />}
|
||||
variant="ghost"
|
||||
onClick={onLeftOpen}
|
||||
/>
|
||||
<Box fontWeight="bold" fontSize="md">
|
||||
{activeChannel?.name || '股票社区'}
|
||||
</Box>
|
||||
<IconButton
|
||||
aria-label="打开详情面板"
|
||||
icon={<HamburgerIcon />}
|
||||
variant="ghost"
|
||||
onClick={onRightOpen}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 左侧频道列表 - 桌面端 */}
|
||||
{showLeftSidebar && (
|
||||
<Box
|
||||
w="260px"
|
||||
minW="260px"
|
||||
h="full"
|
||||
borderRightWidth="1px"
|
||||
borderColor={borderColor}
|
||||
overflowY="auto"
|
||||
>
|
||||
{renderChannelSidebar()}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 左侧频道列表 - 移动端抽屉 */}
|
||||
<Drawer isOpen={isLeftOpen} placement="left" onClose={onLeftClose}>
|
||||
<DrawerOverlay />
|
||||
<DrawerContent maxW="280px">
|
||||
<DrawerCloseButton />
|
||||
{renderChannelSidebar()}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
{/* 中间消息区域 */}
|
||||
<Box
|
||||
flex={1}
|
||||
h="full"
|
||||
mt={isMobile ? '50px' : 0}
|
||||
overflow="hidden"
|
||||
>
|
||||
<MessageArea
|
||||
channel={activeChannel}
|
||||
onOpenRightPanel={onRightOpen}
|
||||
onRightPanelContentChange={setRightPanelContent}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 右侧面板 - 桌面端 */}
|
||||
{showRightPanel && activeChannel && (
|
||||
<Box
|
||||
w="280px"
|
||||
minW="280px"
|
||||
h="full"
|
||||
borderLeftWidth="1px"
|
||||
borderColor={borderColor}
|
||||
overflowY="auto"
|
||||
>
|
||||
{renderRightPanel()}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 右侧面板 - 移动端抽屉 */}
|
||||
<Drawer isOpen={isRightOpen} placement="right" onClose={onRightClose}>
|
||||
<DrawerOverlay />
|
||||
<DrawerContent maxW="300px">
|
||||
<DrawerCloseButton />
|
||||
{renderRightPanel()}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default StockCommunity;
|
||||
465
src/views/StockCommunity/services/communityService.ts
Normal file
465
src/views/StockCommunity/services/communityService.ts
Normal file
@@ -0,0 +1,465 @@
|
||||
/**
|
||||
* 社区服务层
|
||||
* 处理频道、消息、帖子等 API 调用
|
||||
*/
|
||||
import { getApiBase } from '@/utils/apiConfig';
|
||||
import {
|
||||
Channel,
|
||||
ChannelCategory,
|
||||
Message,
|
||||
ForumPost,
|
||||
ForumReply,
|
||||
PaginatedResponse,
|
||||
} from '../types';
|
||||
|
||||
const API_BASE = getApiBase();
|
||||
const ES_API_BASE = `${API_BASE}/api/community/es`;
|
||||
|
||||
// ============================================================
|
||||
// 频道相关
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 获取频道列表(按分类组织)
|
||||
*/
|
||||
export const getChannels = async (): Promise<ChannelCategory[]> => {
|
||||
const response = await fetch(`${API_BASE}/api/community/channels`);
|
||||
if (!response.ok) throw new Error('获取频道列表失败');
|
||||
const data = await response.json();
|
||||
return data.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取单个频道详情
|
||||
*/
|
||||
export const getChannel = async (channelId: string): Promise<Channel> => {
|
||||
const response = await fetch(`${API_BASE}/api/community/channels/${channelId}`);
|
||||
if (!response.ok) throw new Error('获取频道详情失败');
|
||||
const data = await response.json();
|
||||
return data.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* 订阅频道
|
||||
*/
|
||||
export const subscribeChannel = async (channelId: string): Promise<void> => {
|
||||
const response = await fetch(`${API_BASE}/api/community/channels/${channelId}/subscribe`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!response.ok) throw new Error('订阅频道失败');
|
||||
};
|
||||
|
||||
/**
|
||||
* 取消订阅频道
|
||||
*/
|
||||
export const unsubscribeChannel = async (channelId: string): Promise<void> => {
|
||||
const response = await fetch(`${API_BASE}/api/community/channels/${channelId}/unsubscribe`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!response.ok) throw new Error('取消订阅失败');
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 消息相关(即时聊天)
|
||||
// ============================================================
|
||||
|
||||
interface GetMessagesOptions {
|
||||
before?: string;
|
||||
after?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取频道消息
|
||||
*/
|
||||
export const getMessages = async (
|
||||
channelId: string,
|
||||
options: GetMessagesOptions = {}
|
||||
): Promise<PaginatedResponse<Message>> => {
|
||||
const { before, after, limit = 50 } = options;
|
||||
|
||||
// 构建 ES 查询
|
||||
const query: any = {
|
||||
bool: {
|
||||
must: [
|
||||
{ term: { channel_id: channelId } },
|
||||
{ term: { is_deleted: false } },
|
||||
],
|
||||
must_not: [
|
||||
{ exists: { field: 'thread_id' } }, // 排除讨论串消息
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// 分页游标
|
||||
if (before) {
|
||||
query.bool.must.push({
|
||||
range: { created_at: { lt: before } },
|
||||
});
|
||||
}
|
||||
if (after) {
|
||||
query.bool.must.push({
|
||||
range: { created_at: { gt: after } },
|
||||
});
|
||||
}
|
||||
|
||||
const response = await fetch(`${ES_API_BASE}/community_messages/_search`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
query,
|
||||
sort: [{ created_at: before ? 'desc' : 'asc' }],
|
||||
size: limit,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('获取消息失败');
|
||||
const data = await response.json();
|
||||
|
||||
const items = data.hits.hits.map((hit: any) => ({
|
||||
id: hit._id,
|
||||
...hit._source,
|
||||
}));
|
||||
|
||||
// 如果是向前加载,需要反转顺序
|
||||
if (before) {
|
||||
items.reverse();
|
||||
}
|
||||
|
||||
return {
|
||||
items,
|
||||
total: data.hits.total.value,
|
||||
page: 1,
|
||||
pageSize: limit,
|
||||
hasMore: data.hits.total.value > items.length,
|
||||
};
|
||||
};
|
||||
|
||||
interface SendMessageData {
|
||||
channelId: string;
|
||||
content: string;
|
||||
replyTo?: {
|
||||
messageId: string;
|
||||
authorId: string;
|
||||
authorName: string;
|
||||
contentPreview?: string;
|
||||
};
|
||||
mentionedUsers?: string[];
|
||||
mentionedStocks?: string[];
|
||||
attachments?: any[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息
|
||||
*/
|
||||
export const sendMessage = async (data: SendMessageData): Promise<Message> => {
|
||||
const response = await fetch(`${API_BASE}/api/community/channels/${data.channelId}/messages`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('发送消息失败');
|
||||
const result = await response.json();
|
||||
return result.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* 添加表情反应
|
||||
*/
|
||||
export const addReaction = async (messageId: string, emoji: string): Promise<void> => {
|
||||
const response = await fetch(`${API_BASE}/api/community/messages/${messageId}/reactions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ emoji }),
|
||||
});
|
||||
if (!response.ok) throw new Error('添加表情失败');
|
||||
};
|
||||
|
||||
/**
|
||||
* 移除表情反应
|
||||
*/
|
||||
export const removeReaction = async (messageId: string, emoji: string): Promise<void> => {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/api/community/messages/${messageId}/reactions/${encodeURIComponent(emoji)}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
}
|
||||
);
|
||||
if (!response.ok) throw new Error('移除表情失败');
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Forum 帖子相关
|
||||
// ============================================================
|
||||
|
||||
interface GetForumPostsOptions {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sortBy?: 'latest' | 'hot' | 'most_replies';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Forum 帖子列表
|
||||
*/
|
||||
export const getForumPosts = async (
|
||||
channelId: string,
|
||||
options: GetForumPostsOptions = {}
|
||||
): Promise<PaginatedResponse<ForumPost>> => {
|
||||
const { page = 1, pageSize = 20, sortBy = 'latest' } = options;
|
||||
|
||||
// 排序映射
|
||||
const sortMap: Record<string, any> = {
|
||||
latest: { created_at: 'desc' },
|
||||
hot: { view_count: 'desc' },
|
||||
most_replies: { reply_count: 'desc' },
|
||||
};
|
||||
|
||||
const response = await fetch(`${ES_API_BASE}/community_forum_posts/_search`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
query: {
|
||||
bool: {
|
||||
must: [
|
||||
{ term: { channel_id: channelId } },
|
||||
{ term: { is_deleted: false } },
|
||||
],
|
||||
},
|
||||
},
|
||||
sort: [
|
||||
{ is_pinned: 'desc' }, // 置顶优先
|
||||
sortMap[sortBy],
|
||||
],
|
||||
from: (page - 1) * pageSize,
|
||||
size: pageSize,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('获取帖子列表失败');
|
||||
const data = await response.json();
|
||||
|
||||
const items = data.hits.hits.map((hit: any) => ({
|
||||
id: hit._id,
|
||||
...hit._source,
|
||||
}));
|
||||
|
||||
return {
|
||||
items,
|
||||
total: data.hits.total.value,
|
||||
page,
|
||||
pageSize,
|
||||
hasMore: page * pageSize < data.hits.total.value,
|
||||
};
|
||||
};
|
||||
|
||||
interface CreateForumPostData {
|
||||
channelId: string;
|
||||
title: string;
|
||||
content: string;
|
||||
tags?: string[];
|
||||
stockSymbols?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 Forum 帖子
|
||||
*/
|
||||
export const createForumPost = async (data: CreateForumPostData): Promise<ForumPost> => {
|
||||
const response = await fetch(`${API_BASE}/api/community/channels/${data.channelId}/posts`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('发布帖子失败');
|
||||
const result = await response.json();
|
||||
return result.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* 点赞帖子
|
||||
*/
|
||||
export const likePost = async (postId: string): Promise<void> => {
|
||||
const response = await fetch(`${API_BASE}/api/community/posts/${postId}/like`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!response.ok) throw new Error('点赞失败');
|
||||
};
|
||||
|
||||
/**
|
||||
* 增加帖子浏览量
|
||||
*/
|
||||
export const incrementPostView = async (postId: string): Promise<void> => {
|
||||
await fetch(`${API_BASE}/api/community/posts/${postId}/view`, {
|
||||
method: 'POST',
|
||||
});
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Forum 回复相关
|
||||
// ============================================================
|
||||
|
||||
interface GetForumRepliesOptions {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取帖子回复
|
||||
*/
|
||||
export const getForumReplies = async (
|
||||
postId: string,
|
||||
options: GetForumRepliesOptions = {}
|
||||
): Promise<PaginatedResponse<ForumReply>> => {
|
||||
const { page = 1, pageSize = 20 } = options;
|
||||
|
||||
const response = await fetch(`${ES_API_BASE}/community_forum_replies/_search`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
query: {
|
||||
bool: {
|
||||
must: [
|
||||
{ term: { post_id: postId } },
|
||||
{ term: { is_deleted: false } },
|
||||
],
|
||||
},
|
||||
},
|
||||
sort: [{ created_at: 'asc' }],
|
||||
from: (page - 1) * pageSize,
|
||||
size: pageSize,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('获取回复失败');
|
||||
const data = await response.json();
|
||||
|
||||
const items = data.hits.hits.map((hit: any) => ({
|
||||
id: hit._id,
|
||||
...hit._source,
|
||||
}));
|
||||
|
||||
return {
|
||||
items,
|
||||
total: data.hits.total.value,
|
||||
page,
|
||||
pageSize,
|
||||
hasMore: page * pageSize < data.hits.total.value,
|
||||
};
|
||||
};
|
||||
|
||||
interface CreateForumReplyData {
|
||||
postId: string;
|
||||
channelId: string;
|
||||
content: string;
|
||||
replyTo?: {
|
||||
replyId: string;
|
||||
authorId: string;
|
||||
authorName: string;
|
||||
contentPreview?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建帖子回复
|
||||
*/
|
||||
export const createForumReply = async (data: CreateForumReplyData): Promise<ForumReply> => {
|
||||
const response = await fetch(`${API_BASE}/api/community/posts/${data.postId}/replies`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('回复失败');
|
||||
const result = await response.json();
|
||||
return result.data;
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 搜索
|
||||
// ============================================================
|
||||
|
||||
interface SearchOptions {
|
||||
query: string;
|
||||
type?: 'message' | 'post' | 'all';
|
||||
channelId?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索消息和帖子
|
||||
*/
|
||||
export const search = async (options: SearchOptions): Promise<PaginatedResponse<any>> => {
|
||||
const { query, type = 'all', channelId, page = 1, pageSize = 20 } = options;
|
||||
|
||||
const indices = type === 'all'
|
||||
? 'community_messages,community_forum_posts'
|
||||
: type === 'message'
|
||||
? 'community_messages'
|
||||
: 'community_forum_posts';
|
||||
|
||||
const esQuery: any = {
|
||||
bool: {
|
||||
must: [
|
||||
{
|
||||
multi_match: {
|
||||
query,
|
||||
fields: ['content', 'title'],
|
||||
type: 'best_fields',
|
||||
},
|
||||
},
|
||||
],
|
||||
filter: [{ term: { is_deleted: false } }],
|
||||
},
|
||||
};
|
||||
|
||||
if (channelId) {
|
||||
esQuery.bool.filter.push({ term: { channel_id: channelId } });
|
||||
}
|
||||
|
||||
const response = await fetch(`${ES_API_BASE}/${indices}/_search`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
query: esQuery,
|
||||
sort: [{ _score: 'desc' }, { created_at: 'desc' }],
|
||||
from: (page - 1) * pageSize,
|
||||
size: pageSize,
|
||||
highlight: {
|
||||
fields: {
|
||||
content: {},
|
||||
title: {},
|
||||
},
|
||||
pre_tags: ['<mark>'],
|
||||
post_tags: ['</mark>'],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('搜索失败');
|
||||
const data = await response.json();
|
||||
|
||||
const items = data.hits.hits.map((hit: any) => ({
|
||||
id: hit._id,
|
||||
_index: hit._index,
|
||||
...hit._source,
|
||||
highlight: hit.highlight,
|
||||
}));
|
||||
|
||||
return {
|
||||
items,
|
||||
total: data.hits.total.value,
|
||||
page,
|
||||
pageSize,
|
||||
hasMore: page * pageSize < data.hits.total.value,
|
||||
};
|
||||
};
|
||||
277
src/views/StockCommunity/types/index.ts
Normal file
277
src/views/StockCommunity/types/index.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* 股票社区类型定义
|
||||
*/
|
||||
|
||||
// ============================================================
|
||||
// 频道相关
|
||||
// ============================================================
|
||||
|
||||
export type ChannelType = 'text' | 'forum' | 'announcement';
|
||||
|
||||
export interface ChannelCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
position: number;
|
||||
isCollapsible: boolean;
|
||||
isSystem: boolean;
|
||||
channels: Channel[];
|
||||
}
|
||||
|
||||
export interface Channel {
|
||||
id: string;
|
||||
categoryId: string;
|
||||
name: string;
|
||||
type: ChannelType;
|
||||
topic?: string;
|
||||
position: number;
|
||||
conceptCode?: string;
|
||||
slowMode: number;
|
||||
isReadonly: boolean;
|
||||
isSystem: boolean;
|
||||
subscriberCount: number;
|
||||
messageCount: number;
|
||||
lastMessageAt?: string;
|
||||
// 扩展字段(来自概念关联)
|
||||
conceptName?: string;
|
||||
stockCount?: number;
|
||||
isHot?: boolean;
|
||||
}
|
||||
|
||||
export interface ChannelSubscription {
|
||||
userId: string;
|
||||
channelId: string;
|
||||
notificationLevel: 'all' | 'mentions' | 'none';
|
||||
lastReadMessageId?: string;
|
||||
lastReadAt?: string;
|
||||
subscribedAt: string;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 消息相关
|
||||
// ============================================================
|
||||
|
||||
export type MessageType = 'text' | 'image' | 'file' | 'system';
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
channelId: string;
|
||||
threadId?: string;
|
||||
authorId: string;
|
||||
authorName: string;
|
||||
authorAvatar?: string;
|
||||
content: string;
|
||||
contentHtml?: string;
|
||||
type: MessageType;
|
||||
attachments?: Attachment[];
|
||||
embeds?: Embed[];
|
||||
mentionedUsers?: string[];
|
||||
mentionedStocks?: string[];
|
||||
mentionedEveryone?: boolean;
|
||||
replyTo?: ReplyTo;
|
||||
reactions?: Record<string, string[]>; // emoji -> userIds
|
||||
reactionCount?: number;
|
||||
isPinned: boolean;
|
||||
isEdited: boolean;
|
||||
isDeleted?: boolean;
|
||||
createdAt: string;
|
||||
editedAt?: string;
|
||||
}
|
||||
|
||||
export interface ReplyTo {
|
||||
messageId: string;
|
||||
authorId: string;
|
||||
authorName: string;
|
||||
contentPreview?: string;
|
||||
}
|
||||
|
||||
export interface Attachment {
|
||||
id: string;
|
||||
type: 'image' | 'file' | 'video';
|
||||
filename: string;
|
||||
url: string;
|
||||
size: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export type EmbedType = 'stock' | 'link' | 'image';
|
||||
|
||||
export interface Embed {
|
||||
type: EmbedType;
|
||||
title?: string;
|
||||
description?: string;
|
||||
url?: string;
|
||||
thumbnailUrl?: string;
|
||||
// 股票特有
|
||||
stockSymbol?: string;
|
||||
stockName?: string;
|
||||
stockPrice?: number;
|
||||
stockChange?: number;
|
||||
stockChangePercent?: number;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Forum 帖子相关
|
||||
// ============================================================
|
||||
|
||||
export interface ForumPost {
|
||||
id: string;
|
||||
channelId: string;
|
||||
authorId: string;
|
||||
authorName: string;
|
||||
authorAvatar?: string;
|
||||
title: string;
|
||||
content: string;
|
||||
contentHtml?: string;
|
||||
tags: string[];
|
||||
stockSymbols?: string[];
|
||||
attachments?: Attachment[];
|
||||
isPinned: boolean;
|
||||
isLocked: boolean;
|
||||
isDeleted?: boolean;
|
||||
replyCount: number;
|
||||
viewCount: number;
|
||||
likeCount: number;
|
||||
lastReplyAt?: string;
|
||||
lastReplyBy?: string;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface ForumReply {
|
||||
id: string;
|
||||
postId: string;
|
||||
channelId: string;
|
||||
authorId: string;
|
||||
authorName: string;
|
||||
authorAvatar?: string;
|
||||
content: string;
|
||||
contentHtml?: string;
|
||||
attachments?: Attachment[];
|
||||
replyTo?: {
|
||||
replyId: string;
|
||||
authorId: string;
|
||||
authorName: string;
|
||||
contentPreview?: string;
|
||||
};
|
||||
reactions?: Record<string, string[]>;
|
||||
likeCount: number;
|
||||
isSolution?: boolean;
|
||||
isDeleted?: boolean;
|
||||
createdAt: string;
|
||||
editedAt?: string;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 讨论串相关
|
||||
// ============================================================
|
||||
|
||||
export interface Thread {
|
||||
id: string;
|
||||
channelId: string;
|
||||
parentMessageId: string;
|
||||
name: string;
|
||||
ownerId: string;
|
||||
autoArchiveMinutes: number;
|
||||
isLocked: boolean;
|
||||
isPinned: boolean;
|
||||
isArchived: boolean;
|
||||
messageCount: number;
|
||||
memberCount: number;
|
||||
lastMessageAt?: string;
|
||||
createdAt: string;
|
||||
archivedAt?: string;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 用户相关
|
||||
// ============================================================
|
||||
|
||||
export interface CommunityMember {
|
||||
userId: string;
|
||||
username: string;
|
||||
avatar?: string;
|
||||
isOnline: boolean;
|
||||
lastActiveAt?: string;
|
||||
// 社区特有
|
||||
badge?: string;
|
||||
level?: number;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 通知相关
|
||||
// ============================================================
|
||||
|
||||
export type NotificationType = 'mention' | 'reply' | 'reaction' | 'prediction' | 'system';
|
||||
|
||||
export interface Notification {
|
||||
id: string;
|
||||
userId: string;
|
||||
type: NotificationType;
|
||||
title: string;
|
||||
content: string;
|
||||
icon?: string;
|
||||
source: {
|
||||
type: 'message' | 'post' | 'reply' | 'prediction';
|
||||
id: string;
|
||||
channelId: string;
|
||||
channelName: string;
|
||||
};
|
||||
actor?: {
|
||||
userId: string;
|
||||
userName: string;
|
||||
avatar?: string;
|
||||
};
|
||||
isRead: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// WebSocket 事件
|
||||
// ============================================================
|
||||
|
||||
export type ClientEventType =
|
||||
| 'SUBSCRIBE_CHANNEL'
|
||||
| 'UNSUBSCRIBE_CHANNEL'
|
||||
| 'SEND_MESSAGE'
|
||||
| 'START_TYPING'
|
||||
| 'STOP_TYPING'
|
||||
| 'ADD_REACTION'
|
||||
| 'REMOVE_REACTION';
|
||||
|
||||
export type ServerEventType =
|
||||
| 'MESSAGE_CREATE'
|
||||
| 'MESSAGE_UPDATE'
|
||||
| 'MESSAGE_DELETE'
|
||||
| 'REACTION_ADD'
|
||||
| 'REACTION_REMOVE'
|
||||
| 'TYPING_START'
|
||||
| 'TYPING_STOP'
|
||||
| 'MEMBER_JOIN'
|
||||
| 'MEMBER_LEAVE'
|
||||
| 'CHANNEL_UPDATE';
|
||||
|
||||
export interface TypingUser {
|
||||
userId: string;
|
||||
userName: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// API 响应
|
||||
// ============================================================
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
code: number;
|
||||
message: string;
|
||||
data: T;
|
||||
}
|
||||
Reference in New Issue
Block a user