个股论坛重做
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -78,6 +78,8 @@ export const useCommunitySocket = (): UseCommunitySocketReturn => {
|
||||
'TYPING_STOP',
|
||||
'MEMBER_JOIN',
|
||||
'MEMBER_LEAVE',
|
||||
'MEMBER_ONLINE',
|
||||
'MEMBER_OFFLINE',
|
||||
'CHANNEL_UPDATE',
|
||||
];
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 文件上传相关
|
||||
// ============================================================
|
||||
|
||||
@@ -250,6 +250,8 @@ export type ServerEventType =
|
||||
| 'TYPING_STOP'
|
||||
| 'MEMBER_JOIN'
|
||||
| 'MEMBER_LEAVE'
|
||||
| 'MEMBER_ONLINE'
|
||||
| 'MEMBER_OFFLINE'
|
||||
| 'CHANNEL_UPDATE';
|
||||
|
||||
export interface TypingUser {
|
||||
|
||||
Reference in New Issue
Block a user