个股论坛重做

This commit is contained in:
2026-01-06 08:13:01 +08:00
parent 11db27d58d
commit 12bf4c2f87
27 changed files with 5796 additions and 2 deletions

Binary file not shown.

12
app.py
View File

@@ -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
View 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 事件已注册')

View File

@@ -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;

View File

@@ -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,

View 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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View 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;

View File

@@ -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;

View File

@@ -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;

View 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;

View 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;

View 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;

View 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;

View 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,
};
};

View 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;

View 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,
};
};

View 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;
}