个股论坛重做

This commit is contained in:
2026-01-06 16:25:06 +08:00
parent 2c46beb58a
commit 7ffd288665
5 changed files with 162 additions and 23 deletions

View File

@@ -274,6 +274,17 @@ def api_error(message, code=400):
}), code if code >= 400 else 400
# ============================================================
# 在线成员 API
# ============================================================
@community_bp.route('/members/online', methods=['GET'])
def get_online_members_api():
"""获取当前在线成员列表"""
members = get_online_members()
return api_response(members)
# ============================================================
# 用户管理员状态 API
# ============================================================
@@ -1371,6 +1382,28 @@ def es_search_proxy(index):
# ============================================================
# WebSocket 事件处理(需要在 app.py 中注册)
# ============================================================
# 在线用户管理
# ============================================================
# 在线用户字典: {user_id: {sid: socket_id, username: str, avatar: str, connected_at: datetime}}
online_users = {}
# Socket ID 到用户 ID 的映射: {socket_id: user_id}
sid_to_user = {}
def get_online_members():
"""获取当前在线用户列表"""
return [
{
'userId': str(user_id),
'username': info['username'],
'avatar': info.get('avatar', ''),
'isOnline': True,
'connectedAt': info['connected_at'].isoformat() if info.get('connected_at') else None,
}
for user_id, info in online_users.items()
]
def register_community_socketio(socketio):
"""
@@ -1381,14 +1414,51 @@ def register_community_socketio(socketio):
socketio_instance = socketio
from flask_socketio import join_room, leave_room, emit
from flask import request
@socketio.on('connect', namespace='/community')
def handle_connect():
print('[Community Socket] Client connected')
user_id = session.get('user_id')
username = session.get('username', '匿名用户')
avatar = session.get('avatar', '')
sid = request.sid
if user_id:
# 记录在线用户
online_users[user_id] = {
'sid': sid,
'username': username,
'avatar': avatar,
'connected_at': datetime.utcnow()
}
sid_to_user[sid] = user_id
# 广播用户上线事件
emit('MEMBER_ONLINE', {
'userId': str(user_id),
'username': username,
'avatar': avatar,
}, broadcast=True, include_self=False)
print(f'[Community Socket] User {username}({user_id}) connected, online: {len(online_users)}')
else:
print('[Community Socket] Anonymous client connected')
@socketio.on('disconnect', namespace='/community')
def handle_disconnect():
print('[Community Socket] Client disconnected')
sid = request.sid
user_id = sid_to_user.pop(sid, None)
if user_id and user_id in online_users:
username = online_users[user_id].get('username', '匿名')
del online_users[user_id]
# 广播用户下线事件
emit('MEMBER_OFFLINE', {
'userId': str(user_id),
}, broadcast=True)
print(f'[Community Socket] User {username}({user_id}) disconnected, online: {len(online_users)}')
@socketio.on('SUBSCRIBE_CHANNEL', namespace='/community')
def handle_subscribe(data):

View File

@@ -1,7 +1,7 @@
/**
* 成员列表组件 - HeroUI 深色风格
*/
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import {
Box,
VStack,
@@ -20,7 +20,8 @@ import { motion } from 'framer-motion';
import { CommunityMember, AdminRole, ChannelAdmin } from '../../types';
import { useAdmin } from '../../contexts/AdminContext';
import { getChannelAdmins } from '../../services/communityService';
import { getChannelAdmins, getOnlineMembers, OnlineMember } from '../../services/communityService';
import { useCommunitySocket } from '../../hooks/useCommunitySocket';
// 角色配置
const ROLE_CONFIG: Record<AdminRole, { icon: any; label: string; color: string; bg: string }> = {
@@ -34,18 +35,6 @@ 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);
@@ -55,21 +44,73 @@ const MemberList: React.FC<MemberListProps> = ({ channelId }) => {
// 获取管理员上下文
const { isSuperAdmin, channelAdminList } = useAdmin();
// WebSocket 事件监听
const { onMessage, offMessage } = useCommunitySocket();
// 处理用户上线
const handleMemberOnline = useCallback((data: { userId: string; username: string; avatar?: string }) => {
setMembers(prev => {
// 检查用户是否已在列表中
const existingIndex = prev.findIndex(m => m.userId === data.userId);
if (existingIndex >= 0) {
// 更新现有用户为在线状态
const updated = [...prev];
updated[existingIndex] = { ...updated[existingIndex], isOnline: true };
return updated;
}
// 添加新用户
return [...prev, {
userId: data.userId,
username: data.username,
avatar: data.avatar,
isOnline: true,
}];
});
}, []);
// 处理用户下线
const handleMemberOffline = useCallback((data: { userId: string }) => {
setMembers(prev => prev.map(m =>
m.userId === data.userId ? { ...m, isOnline: false } : m
));
}, []);
// 监听在线状态变化
useEffect(() => {
onMessage('MEMBER_ONLINE', handleMemberOnline);
onMessage('MEMBER_OFFLINE', handleMemberOffline);
return () => {
offMessage('MEMBER_ONLINE', handleMemberOnline);
offMessage('MEMBER_OFFLINE', handleMemberOffline);
};
}, [onMessage, offMessage, handleMemberOnline, handleMemberOffline]);
// 加载成员和管理员列表
useEffect(() => {
const loadData = async () => {
setLoading(true);
try {
// 加载频道管理员列表
const admins = await getChannelAdmins(channelId);
// 并行加载频道管理员和在线成员
const [admins, onlineMembers] = await Promise.all([
getChannelAdmins(channelId),
getOnlineMembers(),
]);
setChannelAdmins(admins);
// 将在线成员转换为 CommunityMember 格式
const memberList: CommunityMember[] = onlineMembers.map((m: OnlineMember) => ({
userId: m.userId,
username: m.username,
avatar: m.avatar,
isOnline: m.isOnline,
}));
setMembers(memberList);
} catch (error) {
console.error('加载频道管理员失败:', error);
console.error('加载成员数据失败:', error);
} finally {
setLoading(false);
}
// 模拟加载成员
await new Promise(resolve => setTimeout(resolve, 500));
setMembers(mockMembers);
setLoading(false);
};
loadData();

View File

@@ -78,6 +78,8 @@ export const useCommunitySocket = (): UseCommunitySocketReturn => {
'TYPING_STOP',
'MEMBER_JOIN',
'MEMBER_LEAVE',
'MEMBER_ONLINE',
'MEMBER_OFFLINE',
'CHANNEL_UPDATE',
];

View File

@@ -650,6 +650,30 @@ export const togglePinMessage = async (messageId: string, isPinned: boolean): Pr
if (!response.ok) throw new Error(isPinned ? '取消置顶失败' : '置顶失败');
};
// ============================================================
// 在线成员相关
// ============================================================
export interface OnlineMember {
userId: string;
username: string;
avatar?: string;
isOnline: boolean;
connectedAt?: string;
}
/**
* 获取在线成员列表
*/
export const getOnlineMembers = async (): Promise<OnlineMember[]> => {
const response = await fetch(`${API_BASE}/api/community/members/online`, {
credentials: 'include',
});
if (!response.ok) throw new Error('获取在线成员失败');
const data = await response.json();
return data.data;
};
// ============================================================
// 文件上传相关
// ============================================================

View File

@@ -250,6 +250,8 @@ export type ServerEventType =
| 'TYPING_STOP'
| 'MEMBER_JOIN'
| 'MEMBER_LEAVE'
| 'MEMBER_ONLINE'
| 'MEMBER_OFFLINE'
| 'CHANNEL_UPDATE';
export interface TypingUser {