diff --git a/backend/src/groups/groups.controller.ts b/backend/src/groups/groups.controller.ts new file mode 100644 index 00000000..c1e8c574 --- /dev/null +++ b/backend/src/groups/groups.controller.ts @@ -0,0 +1,79 @@ +import { Controller, Get, Post, Patch, Delete, Body, Param, Query, UseGuards } from '@nestjs/common'; +import { GroupsService } from './groups.service'; +import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; +import { CurrentUser } from '../common/decorators/current-user.decorator'; + +@Controller('groups') +@UseGuards(JwtAuthGuard) +export class GroupsController { + constructor(private groupsService: GroupsService) {} + + @Get() + getMyGroups(@CurrentUser('id') userId: string) { + return this.groupsService.getMyGroups(userId); + } + + @Post() + createGroup( + @CurrentUser('id') userId: string, + @Body() body: { name: string; avatar?: string; description?: string }, + ) { + return this.groupsService.createGroup(userId, body.name, body.avatar, body.description); + } + + @Get(':id') + getGroup(@Param('id') id: string, @CurrentUser('id') userId: string) { + return this.groupsService.getGroup(id, userId); + } + + @Patch(':id') + updateGroup( + @Param('id') id: string, + @CurrentUser('id') userId: string, + @Body() body: { name?: string; avatar?: string; description?: string }, + ) { + return this.groupsService.updateGroup(id, userId, body); + } + + @Delete(':id') + deleteGroup(@Param('id') id: string, @CurrentUser('id') userId: string) { + return this.groupsService.deleteGroup(id, userId); + } + + @Post(':id/members') + addMember( + @Param('id') id: string, + @CurrentUser('id') userId: string, + @Body() body: { userId: string }, + ) { + return this.groupsService.addMember(id, userId, body.userId); + } + + @Delete(':id/members/:userId') + removeMember( + @Param('id') id: string, + @CurrentUser('id') userId: string, + @Param('userId') targetUserId: string, + ) { + return this.groupsService.removeMember(id, userId, targetUserId); + } + + @Get(':id/messages') + getMessages( + @Param('id') id: string, + @CurrentUser('id') userId: string, + @Query('page') page?: string, + @Query('limit') limit?: string, + ) { + return this.groupsService.getMessages(id, userId, page ? +page : 1, limit ? +limit : 50); + } + + @Post(':id/messages') + sendMessage( + @Param('id') id: string, + @CurrentUser('id') userId: string, + @Body() body: { content: string; type?: string }, + ) { + return this.groupsService.sendMessage(id, userId, body.content, body.type); + } +} diff --git a/backend/src/groups/groups.module.ts b/backend/src/groups/groups.module.ts new file mode 100644 index 00000000..3906ad79 --- /dev/null +++ b/backend/src/groups/groups.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { GroupsController } from './groups.controller'; +import { GroupsService } from './groups.service'; +import { PrismaModule } from '../prisma/prisma.module'; + +@Module({ + imports: [PrismaModule], + controllers: [GroupsController], + providers: [GroupsService], +}) +export class GroupsModule {} diff --git a/backend/src/groups/groups.service.ts b/backend/src/groups/groups.service.ts new file mode 100644 index 00000000..fdc8108d --- /dev/null +++ b/backend/src/groups/groups.service.ts @@ -0,0 +1,111 @@ +import { Injectable, ForbiddenException, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { GroupRole } from '@prisma/client'; + +@Injectable() +export class GroupsService { + constructor(private prisma: PrismaService) {} + + async createGroup(userId: string, name: string, avatar?: string, description?: string) { + return this.prisma.group.create({ + data: { + name, + avatar, + description, + members: { + create: { userId, role: GroupRole.CREATOR }, + }, + }, + include: { members: true }, + }); + } + + async getMyGroups(userId: string) { + const members = await this.prisma.groupMember.findMany({ + where: { userId }, + include: { group: true }, + orderBy: { joinedAt: 'desc' }, + }); + return members.map(m => m.group); + } + + async getGroup(groupId: string, userId: string) { + await this.checkMembership(groupId, userId); + return this.prisma.group.findUnique({ + where: { id: groupId }, + include: { members: true }, + }); + } + + async updateGroup(groupId: string, userId: string, data: { name?: string; avatar?: string; description?: string }) { + await this.checkRole(groupId, userId, [GroupRole.CREATOR, GroupRole.ADMIN]); + return this.prisma.group.update({ + where: { id: groupId }, + data, + }); + } + + async deleteGroup(groupId: string, userId: string) { + await this.checkRole(groupId, userId, [GroupRole.CREATOR]); + return this.prisma.group.delete({ where: { id: groupId } }); + } + + async addMember(groupId: string, userId: string, targetUserId: string) { + await this.checkMembership(groupId, userId); + const group = await this.prisma.group.findUnique({ where: { id: groupId } }); + if (!group) throw new NotFoundException('Group not found'); + if (group.memberCount >= group.maxMembers) throw new ForbiddenException('Group is full'); + + const member = await this.prisma.groupMember.create({ + data: { groupId, userId: targetUserId }, + }); + await this.prisma.group.update({ + where: { id: groupId }, + data: { memberCount: { increment: 1 } }, + }); + return member; + } + + async removeMember(groupId: string, userId: string, targetUserId: string) { + await this.checkRole(groupId, userId, [GroupRole.CREATOR, GroupRole.ADMIN]); + await this.prisma.groupMember.delete({ + where: { groupId_userId: { groupId, userId: targetUserId } }, + }); + await this.prisma.group.update({ + where: { id: groupId }, + data: { memberCount: { decrement: 1 } }, + }); + } + + async getMessages(groupId: string, userId: string, page = 1, limit = 50) { + await this.checkMembership(groupId, userId); + const messages = await this.prisma.groupMessage.findMany({ + where: { groupId }, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * limit, + take: limit, + }); + return messages.reverse(); + } + + async sendMessage(groupId: string, userId: string, content: string, type = 'TEXT') { + await this.checkMembership(groupId, userId); + return this.prisma.groupMessage.create({ + data: { groupId, senderId: userId, content, type: type as any }, + }); + } + + private async checkMembership(groupId: string, userId: string) { + const member = await this.prisma.groupMember.findUnique({ + where: { groupId_userId: { groupId, userId } }, + }); + if (!member) throw new ForbiddenException('Not a group member'); + return member; + } + + private async checkRole(groupId: string, userId: string, allowedRoles: GroupRole[]) { + const member = await this.checkMembership(groupId, userId); + if (!allowedRoles.includes(member.role)) throw new ForbiddenException('Insufficient permissions'); + return member; + } +} diff --git a/frontend/api/groups.ts b/frontend/api/groups.ts new file mode 100644 index 00000000..4e9dab9d --- /dev/null +++ b/frontend/api/groups.ts @@ -0,0 +1,70 @@ +import apiClient from './client'; + +export interface GroupMember { + id: number; + userId: number; + username: string; + avatar?: string; + role: 'owner' | 'admin' | 'member'; + joinedAt: string; +} + +export interface GroupMessage { + id: number; + groupId: number; + userId: number; + username: string; + avatar?: string; + content: string; + createdAt: string; +} + +export interface Group { + id: number; + name: string; + avatar?: string; + description?: string; + memberCount: number; + lastMessage?: string; + lastMessageAt?: string; + unreadCount: number; + createdAt: string; +} + +export interface PaginatedMessages { + data: GroupMessage[]; + total: number; + page: number; + limit: number; +} + +export const groupsApi = { + list: () => apiClient.get('/groups'), + + create: (data: { name: string; description?: string }) => + apiClient.post('/groups', data), + + get: (id: number) => apiClient.get(`/groups/${id}`), + + update: (id: number, data: { name?: string; description?: string; avatar?: string }) => + apiClient.patch(`/groups/${id}`, data), + + delete: (id: number) => apiClient.delete(`/groups/${id}`), + + members: (id: number) => apiClient.get(`/groups/${id}/members`), + + addMember: (id: number, userId: number) => + apiClient.post(`/groups/${id}/members`, { userId }), + + removeMember: (id: number, userId: number) => + apiClient.delete(`/groups/${id}/members/${userId}`), + + updateMemberRole: (id: number, userId: number, role: 'admin' | 'member') => + apiClient.patch(`/groups/${id}/members/${userId}`, { role }), + + messages: (id: number, page = 1, limit = 20) => + apiClient.get(`/groups/${id}/messages`, { params: { page, limit } }), + + sendMessage: (id: number, content: string) => + apiClient.post(`/groups/${id}/messages`, { content }), +}; diff --git a/frontend/types.ts b/frontend/types.ts index 9228fcdd..491ee21e 100644 --- a/frontend/types.ts +++ b/frontend/types.ts @@ -21,7 +21,13 @@ export enum View { WeightRecord = 'WEIGHT_RECORD', TodoList = 'TODO_LIST', TrainingLog = 'TRAINING_LOG', - CommunityShare = 'COMMUNITY_SHARE' + CommunityShare = 'COMMUNITY_SHARE', + DraftConfirm = 'DRAFT_CONFIRM', + BuddyRecommend = 'BUDDY_RECOMMEND', + GroupList = 'GROUP_LIST', + GroupChat = 'GROUP_CHAT', + GroupSettings = 'GROUP_SETTINGS', + ManualPost = 'MANUAL_POST' } export interface UserState { diff --git a/frontend/views/GroupChat.tsx b/frontend/views/GroupChat.tsx new file mode 100644 index 00000000..aa1b882c --- /dev/null +++ b/frontend/views/GroupChat.tsx @@ -0,0 +1,148 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { View } from '../types'; +import { groupsApi, Group, GroupMessage, AuthUser } from '../api'; + +interface GroupChatProps { + groupId: number; + authUser: AuthUser; + onNavigate: (view: View, groupId?: number) => void; +} + +const GroupChat: React.FC = ({ groupId, authUser, onNavigate }) => { + const [group, setGroup] = useState(null); + const [messages, setMessages] = useState([]); + const [content, setContent] = useState(''); + const [loading, setLoading] = useState(true); + const [page, setPage] = useState(1); + const [hasMore, setHasMore] = useState(true); + const messagesEndRef = useRef(null); + const containerRef = useRef(null); + + useEffect(() => { + loadGroup(); + loadMessages(1); + }, [groupId]); + + useEffect(() => { + if (page === 1) { + scrollToBottom(); + } + }, [messages]); + + const loadGroup = async () => { + try { + const data = await groupsApi.get(groupId); + setGroup(data); + } catch (err) { + console.error('Failed to load group:', err); + } + }; + + const loadMessages = async (p: number) => { + try { + const data = await groupsApi.messages(groupId, p); + if (p === 1) { + setMessages(data.data.reverse()); + } else { + setMessages(prev => [...data.data.reverse(), ...prev]); + } + setHasMore(data.data.length === 20); + } catch (err) { + console.error('Failed to load messages:', err); + } finally { + setLoading(false); + } + }; + + const handleSend = async () => { + if (!content.trim()) return; + const text = content.trim(); + setContent(''); + try { + const msg = await groupsApi.sendMessage(groupId, text); + setMessages(prev => [...prev, msg]); + } catch (err) { + console.error('Failed to send message:', err); + setContent(text); + } + }; + + const handleScroll = () => { + if (!containerRef.current || !hasMore || loading) return; + if (containerRef.current.scrollTop === 0) { + const nextPage = page + 1; + setPage(nextPage); + loadMessages(nextPage); + } + }; + + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }; + + if (loading && page === 1) { + return ( +
+
+
+ ); + } + + return ( +
+
+
+ +

{group?.name}

+ +
+
+ +
+ {messages.map(msg => { + const isMe = msg.userId === authUser.id; + return ( +
+
+ {!isMe && {msg.username}} +
+ {msg.content} +
+ + {new Date(msg.createdAt).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })} + +
+
+ ); + })} +
+
+ +
+
+ setContent(e.target.value)} + onKeyPress={e => e.key === 'Enter' && handleSend()} + className="flex-1 bg-gray-900 border border-gray-700 rounded-full px-4 py-2" + /> + +
+
+
+ ); +}; + +export default GroupChat; diff --git a/frontend/views/GroupList.tsx b/frontend/views/GroupList.tsx new file mode 100644 index 00000000..c029298c --- /dev/null +++ b/frontend/views/GroupList.tsx @@ -0,0 +1,151 @@ +import React, { useState, useEffect } from 'react'; +import { View } from '../types'; +import { groupsApi, Group } from '../api'; + +interface GroupListProps { + onNavigate: (view: View, groupId?: number) => void; +} + +const GroupList: React.FC = ({ onNavigate }) => { + const [groups, setGroups] = useState([]); + const [loading, setLoading] = useState(true); + const [showCreate, setShowCreate] = useState(false); + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + + useEffect(() => { + loadGroups(); + }, []); + + const loadGroups = async () => { + try { + const data = await groupsApi.list(); + setGroups(data); + } catch (err) { + console.error('Failed to load groups:', err); + } finally { + setLoading(false); + } + }; + + const handleCreate = async () => { + if (!name.trim()) return; + try { + await groupsApi.create({ name: name.trim(), description: description.trim() || undefined }); + setShowCreate(false); + setName(''); + setDescription(''); + loadGroups(); + } catch (err) { + console.error('Failed to create group:', err); + } + }; + + if (loading) { + return ( +
+
+
+ ); + } + + return ( +
+
+
+ +

群组

+ +
+
+ +
+ {groups.map(group => ( + + ))} +
+ + {groups.length === 0 && ( +
+ group +

暂无群组

+
+ )} + + {showCreate && ( +
+
+

创建群组

+ setName(e.target.value)} + className="w-full bg-gray-900 border border-gray-700 rounded px-3 py-2 mb-3" + maxLength={50} + /> +