diff --git a/backend/app/routers/conversations.py b/backend/app/routers/conversations.py index 7f22be3..0c5c7ba 100644 --- a/backend/app/routers/conversations.py +++ b/backend/app/routers/conversations.py @@ -10,6 +10,8 @@ from app.schemas.conversation import ( GroupCreate, GroupUpdate, MemberAdd, RoleUpdate, ) from app.services.conversation_service import ConversationService +from app.websocket.events import EventType +from app.websocket.manager import manager router = APIRouter() @@ -97,6 +99,16 @@ async def add_members( service = ConversationService(db) try: await service.add_members(conversation_id, user.id, req.user_ids) + # 通知新成员被加入群聊 + detail = await service.get_conversation_detail(conversation_id, user.id) + group_name = detail.get("name", "群聊") if detail else "群聊" + for mid in req.user_ids: + await manager.send_to_user(mid, EventType.CONVERSATION_MEMBER_ADDED, { + "conversation_id": conversation_id, + "group_name": group_name, + "added_by_user_id": user.id, + "added_by_username": user.username, + }) return {"success": True, "message": "成员已添加"} except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) @@ -113,6 +125,12 @@ async def remove_member( service = ConversationService(db) try: await service.remove_member(conversation_id, user.id, target_user_id) + # 通知被移除的用户 + await manager.send_to_user(target_user_id, EventType.CONVERSATION_MEMBER_REMOVED, { + "conversation_id": conversation_id, + "removed_by_user_id": user.id, + "removed_by_username": user.username, + }) return {"success": True, "message": "成员已移除"} except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) @@ -133,6 +151,21 @@ async def leave_group( raise HTTPException(status_code=400, detail=str(e)) +@router.post("/{conversation_id}/dissolve") +async def dissolve_group( + conversation_id: str, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """解散群聊(仅群主)""" + service = ConversationService(db) + try: + await service.dissolve_group(conversation_id, user.id) + return {"success": True, "message": "群聊已解散"} + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + @router.put("/{conversation_id}/members/{target_user_id}/role") async def update_member_role( conversation_id: str, diff --git a/backend/app/routers/friends.py b/backend/app/routers/friends.py index 160faa0..8a48ec1 100644 --- a/backend/app/routers/friends.py +++ b/backend/app/routers/friends.py @@ -7,6 +7,8 @@ from app.dependencies import get_db, get_current_user from app.models.user import User from app.schemas.friend import FriendRequestCreate, FriendRead, FriendRequestRead, RemarkUpdate from app.services.friend_service import FriendService +from app.websocket.events import EventType +from app.websocket.manager import manager router = APIRouter() @@ -41,6 +43,14 @@ async def send_friend_request( service = FriendService(db) try: await service.send_request(user.id, req.to_user_id, req.message) + # 通知接收者 + await manager.send_to_user(req.to_user_id, EventType.FRIEND_REQUEST, { + "from_user_id": user.id, + "from_username": user.username, + "from_nickname": user.nickname, + "from_avatar": user.avatar_url, + "message": req.message, + }) return {"success": True, "message": "好友请求已发送"} except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) @@ -86,7 +96,24 @@ async def accept_friend_request( """接受好友请求""" service = FriendService(db) try: + # Get request details before accepting to know who sent it + request = await service.get_pending_requests(user.id) + from_user_id = None + for r in request: + if r["id"] == request_id: + from_user_id = r["from_user_id"] + break + await service.accept_request(request_id, user.id) + + # Notify the requester that their request was accepted + if from_user_id: + await manager.send_to_user(from_user_id, EventType.FRIEND_ACCEPTED, { + "accepted_by_user_id": user.id, + "accepted_by_username": user.username, + "accepted_by_nickname": user.nickname, + }) + return {"success": True, "message": "已添加好友"} except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) diff --git a/backend/app/schemas/message.py b/backend/app/schemas/message.py index d636d20..bc18e18 100644 --- a/backend/app/schemas/message.py +++ b/backend/app/schemas/message.py @@ -21,6 +21,8 @@ class MessageRead(BaseModel): type: str content: str reply_to_id: str | None = None + reply_to_content: str | None = None + reply_to_sender_name: str | None = None is_deleted: bool = False created_at: datetime diff --git a/backend/app/services/conversation_service.py b/backend/app/services/conversation_service.py index ce40d9c..c482daf 100644 --- a/backend/app/services/conversation_service.py +++ b/backend/app/services/conversation_service.py @@ -115,6 +115,33 @@ class ConversationService: raise ValueError("群主不能退出,请先转让群主身份") member.left_at = datetime.utcnow() + async def dissolve_group(self, conv_id: str, user_id: str): + """解散群聊(仅群主)""" + member = await self._get_member(conv_id, user_id) + if not member: + raise ValueError("你不在该群中") + if member.role != "owner": + raise ValueError("只有群主可以解散群聊") + + # 验证会话存在且为群聊 + conv_result = await self.db.execute( + select(Conversation).where(Conversation.id == conv_id) + ) + conv = conv_result.scalars().first() + if not conv or conv.type != "group": + raise ValueError("群聊不存在") + + # 软删除所有成员(设置 left_at) + members_result = await self.db.execute( + select(ConversationMember).where( + ConversationMember.conversation_id == conv_id, + ConversationMember.left_at.is_(None), + ) + ) + now = datetime.utcnow() + for m in members_result.scalars().all(): + m.left_at = now + async def update_member_role(self, conv_id: str, user_id: str, target_user_id: str, role: str): """修改成员角色(仅群主)""" member = await self._get_member(conv_id, user_id) diff --git a/backend/app/services/message_service.py b/backend/app/services/message_service.py index fa95d91..0110e29 100644 --- a/backend/app/services/message_service.py +++ b/backend/app/services/message_service.py @@ -85,6 +85,23 @@ class MessageService: select(User).where(User.id == msg.sender_id) ) sender = sender_result.scalars().first() + + # 获取被引用消息的信息 + reply_to_content = None + reply_to_sender_name = None + if msg.reply_to_id: + reply_msg_result = await self.db.execute( + select(Message).where(Message.id == msg.reply_to_id) + ) + reply_msg = reply_msg_result.scalars().first() + if reply_msg: + reply_to_content = reply_msg.content[:200] if reply_msg.content else None + reply_sender_result = await self.db.execute( + select(User).where(User.id == reply_msg.sender_id) + ) + reply_sender = reply_sender_result.scalars().first() + reply_to_sender_name = reply_sender.username if reply_sender else None + message_list.append({ "id": msg.id, "conversation_id": msg.conversation_id, @@ -94,6 +111,8 @@ class MessageService: "type": msg.type, "content": msg.content, "reply_to_id": msg.reply_to_id, + "reply_to_content": reply_to_content, + "reply_to_sender_name": reply_to_sender_name, "is_deleted": msg.is_deleted, "created_at": msg.created_at, }) diff --git a/backend/app/websocket/handlers.py b/backend/app/websocket/handlers.py index 4fd0163..980d80f 100644 --- a/backend/app/websocket/handlers.py +++ b/backend/app/websocket/handlers.py @@ -4,6 +4,7 @@ import json from datetime import datetime, timezone from fastapi import WebSocket +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.websocket.events import EventType @@ -34,6 +35,20 @@ async def handle_chat_send(ws: WebSocket, user_id: str, data: dict, db: AsyncSes conv_service = ConversationService(db) detail = await conv_service.get_conversation_detail(data["conversation_id"], user_id) + # 获取被引用消息的信息 + reply_to_content = None + reply_to_sender_name = None + if message.reply_to_id: + from app.models.message import Message + reply_msg_result = await db.execute( + select(Message).where(Message.id == message.reply_to_id) + ) + reply_msg = reply_msg_result.scalars().first() + if reply_msg: + reply_to_content = reply_msg.content[:200] if reply_msg.content else None + reply_sender = await user_service.get_by_id(reply_msg.sender_id) + reply_to_sender_name = reply_sender.username if reply_sender else None + msg_data = { "id": message.id, "conversation_id": message.conversation_id, @@ -42,6 +57,9 @@ async def handle_chat_send(ws: WebSocket, user_id: str, data: dict, db: AsyncSes "sender_avatar": sender.avatar_url if sender else None, "type": message.type, "content": message.content, + "reply_to_id": message.reply_to_id, + "reply_to_content": reply_to_content, + "reply_to_sender_name": reply_to_sender_name, "created_at": message.created_at.isoformat(), } diff --git a/frontend/src/api/chat.ts b/frontend/src/api/chat.ts index 46e7924..bd96f00 100644 --- a/frontend/src/api/chat.ts +++ b/frontend/src/api/chat.ts @@ -11,7 +11,7 @@ export const chatApi = { getConversationDetail: (id: string) => api.get(`/conversations/${id}`), - updateGroup: (id: string, data: { name?: string; description?: string }) => + updateGroup: (id: string, data: { name?: string; description?: string; avatar_url?: string }) => api.put(`/conversations/${id}`, data), addMembers: (convId: string, userIds: string[]) => @@ -23,6 +23,9 @@ export const chatApi = { leaveGroup: (convId: string) => api.post(`/conversations/${convId}/leave`), + dissolveGroup: (convId: string) => + api.post(`/conversations/${convId}/dissolve`), + getMessages: (conversationId: string, before?: string, limit = 50) => { const params: Record = { limit } if (before) params.before = before diff --git a/frontend/src/composables/useWebSocket.ts b/frontend/src/composables/useWebSocket.ts index b191234..d711541 100644 --- a/frontend/src/composables/useWebSocket.ts +++ b/frontend/src/composables/useWebSocket.ts @@ -11,6 +11,7 @@ const maxReconnectAttempts = 10 // 共享状态 const connected = ref(false) const onlineUsers = ref(new Map()) +const typingUsers = ref(new Map }>()) export function useWebSocket() { @@ -80,11 +81,37 @@ export function useWebSocket() { onlineUsers.value.set(event.data.user_id, false) console.log(`用户 ${event.data.user_id} 下线`) break + case 'chat.typing': + if (event.data.conversation_id && event.data.username) { + const key = `${event.data.conversation_id}:${event.data.user_id}` + const existing = typingUsers.value.get(key) + if (existing) clearTimeout(existing.timeout) + typingUsers.value.set(key, { + username: event.data.username, + timeout: setTimeout(() => typingUsers.value.delete(key), 3000), + }) + } + break case 'friend.request': console.log('收到好友请求:', event.data) // 通过自定义事件通知全局 window.dispatchEvent(new CustomEvent('qingye:friend-request', { detail: event.data })) break + case 'friend.accepted': + console.log('好友请求已被接受:', event.data) + window.dispatchEvent(new CustomEvent('qingye:friend-accepted', { detail: event.data })) + break + case 'conversation.member_added': + console.log('被加入群聊:', event.data) + window.dispatchEvent(new CustomEvent('qingye:member-added', { detail: event.data })) + // 刷新会话列表 + chatStore.fetchConversations() + break + case 'conversation.member_removed': + console.log('被移出群聊:', event.data) + window.dispatchEvent(new CustomEvent('qingye:member-removed', { detail: event.data })) + chatStore.fetchConversations() + break case 'error': console.error('服务端错误:', event.data.message) break @@ -95,5 +122,5 @@ export function useWebSocket() { return onlineUsers.value.get(userId) === true } - return { connected, onlineUsers, connect, disconnect, send, isUserOnline } + return { connected, onlineUsers, typingUsers, connect, disconnect, send, isUserOnline } } diff --git a/frontend/src/layouts/UnifiedLayout.vue b/frontend/src/layouts/UnifiedLayout.vue index 02a87fc..10998a4 100644 --- a/frontend/src/layouts/UnifiedLayout.vue +++ b/frontend/src/layouts/UnifiedLayout.vue @@ -49,6 +49,7 @@ diff --git a/frontend/src/views/moments/MomentCard.vue b/frontend/src/views/moments/MomentCard.vue index d143ba6..edebe91 100644 --- a/frontend/src/views/moments/MomentCard.vue +++ b/frontend/src/views/moments/MomentCard.vue @@ -1,6 +1,6 @@