From c9fc87cd89696a0f616b66f708fa5490a0bbae90 Mon Sep 17 00:00:00 2001 From: hefanyang Date: Sun, 14 Jun 2026 11:16:42 +0800 Subject: [PATCH] 1.9 --- backend/app/models/__init__.py | 11 + backend/app/models/conversation.py | 3 +- backend/app/models/conversation_member.py | 5 +- backend/app/models/friend_tag.py | 30 ++ backend/app/models/group_announcement.py | 25 ++ backend/app/models/message.py | 5 +- backend/app/models/message_reaction.py | 24 ++ backend/app/models/password_reset.py | 21 ++ backend/app/models/user.py | 6 +- backend/app/models/user_block.py | 23 ++ backend/app/routers/auth.py | 114 ++++++- backend/app/routers/conversations.py | 90 ++++++ backend/app/routers/friends.py | 73 +++++ backend/app/routers/messages.py | 57 ++++ backend/app/routers/users.py | 83 +++++ backend/app/schemas/message.py | 6 +- backend/app/services/announcement_service.py | 58 ++++ backend/app/services/conversation_service.py | 23 +- backend/app/services/draft_service.py | 26 ++ backend/app/services/email_service.py | 26 ++ backend/app/services/message_service.py | 82 ++++- backend/app/websocket/events.py | 4 + backend/app/websocket/handlers.py | 12 + frontend/src/api/chat.ts | 17 + frontend/src/composables/useWebSocket.ts | 13 + frontend/src/main.ts | 33 ++ frontend/src/router/index.ts | 1 + .../src/views/auth/ForgotPasswordView.vue | 110 +++++++ frontend/src/views/auth/LoginView.vue | 9 +- frontend/src/views/chat/ChatRoomView.vue | 298 +++++++++++++++++- .../src/views/chat/ConversationListPanel.vue | 70 +++- frontend/src/views/chat/GroupInfoPanel.vue | 46 +++ .../src/views/contacts/FriendProfileCard.vue | 12 + .../views/settings/ProfileSettingsView.vue | 76 +++++ 提示词.md | 6 +- 35 files changed, 1480 insertions(+), 18 deletions(-) create mode 100644 backend/app/models/friend_tag.py create mode 100644 backend/app/models/group_announcement.py create mode 100644 backend/app/models/message_reaction.py create mode 100644 backend/app/models/password_reset.py create mode 100644 backend/app/models/user_block.py create mode 100644 backend/app/services/announcement_service.py create mode 100644 backend/app/services/draft_service.py create mode 100644 backend/app/services/email_service.py create mode 100644 frontend/src/views/auth/ForgotPasswordView.vue diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 42a7a2b..4976ec5 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -16,6 +16,11 @@ from app.models.sync_seed import SyncQuestion, SyncSeed from app.models.chat_climate import ChatClimate from app.models.flash_event import FlashEvent, FlashParticipation from app.models.user_streak import UserStreak +from app.models.group_announcement import GroupAnnouncement +from app.models.message_reaction import MessageReaction +from app.models.user_block import UserBlock +from app.models.password_reset import PasswordResetToken +from app.models.friend_tag import FriendTag, FriendTagAssignment __all__ = [ "User", @@ -38,4 +43,10 @@ __all__ = [ "FlashEvent", "FlashParticipation", "UserStreak", + "GroupAnnouncement", + "MessageReaction", + "UserBlock", + "PasswordResetToken", + "FriendTag", + "FriendTagAssignment", ] diff --git a/backend/app/models/conversation.py b/backend/app/models/conversation.py index 4f979de..c973653 100644 --- a/backend/app/models/conversation.py +++ b/backend/app/models/conversation.py @@ -2,7 +2,7 @@ from datetime import datetime, timezone -from sqlalchemy import String, DateTime, ForeignKey, Text +from sqlalchemy import String, DateTime, ForeignKey, Text, Boolean from sqlalchemy.orm import Mapped, mapped_column, relationship from app.database import Base @@ -17,6 +17,7 @@ class Conversation(Base): avatar_url: Mapped[str | None] = mapped_column(String(500), nullable=True) # 群头像 description: Mapped[str | None] = mapped_column(String(500), nullable=True) # 群描述 creator_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + mute_all: Mapped[bool] = mapped_column(Boolean, default=False) # 全员禁言(仅成员不能发,管理员可以) last_message_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) last_message_preview: Mapped[str | None] = mapped_column(String(200), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.utcnow()) diff --git a/backend/app/models/conversation_member.py b/backend/app/models/conversation_member.py index 5ad80c2..2e68b38 100644 --- a/backend/app/models/conversation_member.py +++ b/backend/app/models/conversation_member.py @@ -2,7 +2,7 @@ from datetime import datetime, timezone -from sqlalchemy import String, DateTime, ForeignKey, UniqueConstraint +from sqlalchemy import String, DateTime, Boolean, ForeignKey, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column, relationship from app.database import Base @@ -24,6 +24,9 @@ class ConversationMember(Base): role: Mapped[str] = mapped_column(String(20), default="member") # owner / admin / member nickname: Mapped[str | None] = mapped_column(String(50), nullable=True) # 群内昵称 last_read_message_id: Mapped[str | None] = mapped_column(String(36), nullable=True) + is_pinned: Mapped[bool] = mapped_column(Boolean, default=False) # 置顶 + pinned_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + is_muted: Mapped[bool] = mapped_column(Boolean, default=False) # 免打扰 joined_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.utcnow()) left_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) diff --git a/backend/app/models/friend_tag.py b/backend/app/models/friend_tag.py new file mode 100644 index 0000000..8db2a94 --- /dev/null +++ b/backend/app/models/friend_tag.py @@ -0,0 +1,30 @@ +"""好友分组/标签模型""" + +from datetime import datetime + +from sqlalchemy import String, DateTime, ForeignKey, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class FriendTag(Base): + """好友标签(一个好友可有多个标签)""" + __tablename__ = "friend_tags" + + id: Mapped[str] = mapped_column(String(36), primary_key=True) + user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE")) # 标签所有者 + name: Mapped[str] = mapped_column(String(30), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.utcnow()) + + +class FriendTagAssignment(Base): + """好友-标签 关联""" + __tablename__ = "friend_tag_assignments" + __table_args__ = ( + UniqueConstraint("friend_id", "tag_id", name="uq_friend_tag"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True) + friend_id: Mapped[str] = mapped_column(String(36), ForeignKey("friends.id", ondelete="CASCADE")) + tag_id: Mapped[str] = mapped_column(String(36), ForeignKey("friend_tags.id", ondelete="CASCADE")) diff --git a/backend/app/models/group_announcement.py b/backend/app/models/group_announcement.py new file mode 100644 index 0000000..2f6f71c --- /dev/null +++ b/backend/app/models/group_announcement.py @@ -0,0 +1,25 @@ +"""群公告模型""" + +from datetime import datetime + +from sqlalchemy import String, DateTime, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class GroupAnnouncement(Base): + __tablename__ = "group_announcements" + + id: Mapped[str] = mapped_column(String(36), primary_key=True) + conversation_id: Mapped[str] = mapped_column( + String(36), ForeignKey("conversations.id", ondelete="CASCADE"), nullable=False + ) + author_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + content: Mapped[str] = mapped_column(String(1000), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.utcnow()) + updated_at: Mapped[datetime] = mapped_column( + DateTime, default=lambda: datetime.utcnow(), onupdate=lambda: datetime.utcnow() + ) + + author = relationship("User", foreign_keys=[author_id]) diff --git a/backend/app/models/message.py b/backend/app/models/message.py index 3dac8a3..3d6f975 100644 --- a/backend/app/models/message.py +++ b/backend/app/models/message.py @@ -21,12 +21,15 @@ class Message(Base): sender_id: Mapped[str] = mapped_column( String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False ) - type: Mapped[str] = mapped_column(String(20), default="text") # text / image / file / system + type: Mapped[str] = mapped_column(String(20), default="text") # text / image / file / voice / system content: Mapped[str] = mapped_column(Text, nullable=False) reply_to_id: Mapped[str | None] = mapped_column( String(36), ForeignKey("messages.id", ondelete="SET NULL"), nullable=True ) + mentions: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON: 被@的用户ID列表 is_deleted: Mapped[bool] = mapped_column(Boolean, default=False) + is_recalled: Mapped[bool] = mapped_column(Boolean, default=False) # 撤回 + recalled_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) created_at: Mapped[datetime] = mapped_column( DateTime, default=lambda: datetime.utcnow(), index=True ) diff --git a/backend/app/models/message_reaction.py b/backend/app/models/message_reaction.py new file mode 100644 index 0000000..a76c469 --- /dev/null +++ b/backend/app/models/message_reaction.py @@ -0,0 +1,24 @@ +"""消息表情回应模型""" + +from datetime import datetime + +from sqlalchemy import String, DateTime, ForeignKey, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class MessageReaction(Base): + __tablename__ = "message_reactions" + __table_args__ = ( + UniqueConstraint("message_id", "user_id", "emoji", name="uq_msg_reaction"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True) + message_id: Mapped[str] = mapped_column(String(36), ForeignKey("messages.id", ondelete="CASCADE")) + user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE")) + emoji: Mapped[str] = mapped_column(String(10), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.utcnow()) + + message = relationship("Message") + user = relationship("User", foreign_keys=[user_id]) diff --git a/backend/app/models/password_reset.py b/backend/app/models/password_reset.py new file mode 100644 index 0000000..2e40fe7 --- /dev/null +++ b/backend/app/models/password_reset.py @@ -0,0 +1,21 @@ +"""密码重置令牌模型""" + +from datetime import datetime + +from sqlalchemy import String, DateTime, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class PasswordResetToken(Base): + __tablename__ = "password_reset_tokens" + + id: Mapped[str] = mapped_column(String(36), primary_key=True) + user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE")) + token_hash: Mapped[str] = mapped_column(String(64), nullable=False) # 验证码的 hash + expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + used: Mapped[bool] = mapped_column(default=False) + created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.utcnow()) + + user = relationship("User", foreign_keys=[user_id]) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index d8c5fa5..5b5b1e0 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -18,7 +18,11 @@ class User(Base): password_hash: Mapped[str] = mapped_column(String(255), nullable=False) avatar_url: Mapped[str | None] = mapped_column(String(500), nullable=True) bio: Mapped[str | None] = mapped_column(String(200), nullable=True) - status: Mapped[str] = mapped_column(String(20), default="offline") # online/offline/away + status: Mapped[str] = mapped_column(String(20), default="offline") # online/offline/away (presence) + custom_status: Mapped[str | None] = mapped_column(String(50), nullable=True) # 个人心情状态文字 + status_emoji: Mapped[str | None] = mapped_column(String(10), nullable=True) # 心情 emoji + status_expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) # 状态到期自动清空 + email_verified: Mapped[bool] = mapped_column(Boolean, default=False) # 邮箱是否已验证 is_admin: Mapped[bool] = mapped_column(Boolean, default=False) is_banned: Mapped[bool] = mapped_column(Boolean, default=False) banned_reason: Mapped[str | None] = mapped_column(String(500), nullable=True) diff --git a/backend/app/models/user_block.py b/backend/app/models/user_block.py new file mode 100644 index 0000000..9519bb0 --- /dev/null +++ b/backend/app/models/user_block.py @@ -0,0 +1,23 @@ +"""用户拉黑模型""" + +from datetime import datetime + +from sqlalchemy import String, DateTime, ForeignKey, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class UserBlock(Base): + __tablename__ = "user_blocks" + __table_args__ = ( + UniqueConstraint("blocker_id", "blocked_id", name="uq_user_block"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True) + blocker_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE")) + blocked_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE")) + created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.utcnow()) + + blocker = relationship("User", foreign_keys=[blocker_id]) + blocked = relationship("User", foreign_keys=[blocked_id]) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index c8187a5..be9e438 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -1,16 +1,38 @@ """认证路由""" +from datetime import datetime, timedelta + from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from pydantic import BaseModel from app.dependencies import get_db +from app.models.user import User +from app.models.password_reset import PasswordResetToken from app.schemas.auth import RegisterRequest, LoginRequest, TokenResponse, RefreshRequest from app.services.auth_service import AuthService -from app.utils.security import decode_refresh_token +from app.services.email_service import generate_code, hash_code, send_verification_email +from app.utils.security import decode_refresh_token, hash_password router = APIRouter() +class ForgotRequest(BaseModel): + email: str + + +class ResetRequest(BaseModel): + email: str + code: str + new_password: str + + +class VerifyEmailRequest(BaseModel): + email: str + code: str + + @router.post("/register", response_model=TokenResponse) async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)): """用户注册""" @@ -54,3 +76,93 @@ async def refresh_token(req: RefreshRequest, db: AsyncSession = Depends(get_db)) return result except ValueError as e: raise HTTPException(status_code=401, detail=str(e)) + + +@router.post("/forgot") +async def forgot_password(req: ForgotRequest, db: AsyncSession = Depends(get_db)): + """找回密码:生成验证码(开发期打印到日志)""" + result = await db.execute(select(User).where(User.email == req.email)) + user = result.scalars().first() + # 出于安全,无论用户是否存在都返回成功 + if user: + import uuid + code = generate_code() + token = PasswordResetToken( + id=str(uuid.uuid4()), + user_id=user.id, + token_hash=hash_code(code), + expires_at=datetime.utcnow() + timedelta(minutes=15), + ) + db.add(token) + await db.flush() + await send_verification_email(req.email, code, "找回密码") + return {"success": True, "message": "若该邮箱已注册,验证码已发送(开发期见后端日志)"} + + +@router.post("/reset") +async def reset_password(req: ResetRequest, db: AsyncSession = Depends(get_db)): + """用验证码重置密码""" + result = await db.execute(select(User).where(User.email == req.email)) + user = result.scalars().first() + if not user: + raise HTTPException(status_code=400, detail="用户不存在") + # 找匹配且未过期的 token + tokens = await db.execute( + select(PasswordResetToken).where( + PasswordResetToken.user_id == user.id, + PasswordResetToken.used == False, + ).order_by(PasswordResetToken.created_at.desc()) + ) + matched = None + for t in tokens.scalars().all(): + if t.token_hash == hash_code(req.code) and t.expires_at > datetime.utcnow(): + matched = t + break + if not matched: + raise HTTPException(status_code=400, detail="验证码无效或已过期") + user.password_hash = hash_password(req.new_password) + matched.used = True + await db.flush() + return {"success": True, "message": "密码已重置"} + + +@router.post("/send-verify-email") +async def send_verify_email(req: ForgotRequest, db: AsyncSession = Depends(get_db)): + """发送邮箱验证码""" + result = await db.execute(select(User).where(User.email == req.email)) + user = result.scalars().first() + if user: + code = generate_code() + import uuid + token = PasswordResetToken( + id=str(uuid.uuid4()), + user_id=user.id, + token_hash=hash_code(code), + expires_at=datetime.utcnow() + timedelta(minutes=15), + ) + db.add(token) + await db.flush() + await send_verification_email(req.email, code, "邮箱验证") + return {"success": True, "message": "验证码已发送(开发期见后端日志)"} + + +@router.post("/verify-email") +async def verify_email(req: VerifyEmailRequest, db: AsyncSession = Depends(get_db)): + """验证邮箱""" + result = await db.execute(select(User).where(User.email == req.email)) + user = result.scalars().first() + if not user: + raise HTTPException(status_code=400, detail="用户不存在") + tokens = await db.execute( + select(PasswordResetToken).where( + PasswordResetToken.user_id == user.id, + PasswordResetToken.used == False, + ).order_by(PasswordResetToken.created_at.desc()) + ) + for t in tokens.scalars().all(): + if t.token_hash == hash_code(req.code) and t.expires_at > datetime.utcnow(): + t.used = True + user.email_verified = True + await db.flush() + return {"success": True, "email_verified": True} + raise HTTPException(status_code=400, detail="验证码无效或已过期") diff --git a/backend/app/routers/conversations.py b/backend/app/routers/conversations.py index 0c5c7ba..cf00cc8 100644 --- a/backend/app/routers/conversations.py +++ b/backend/app/routers/conversations.py @@ -10,12 +10,19 @@ from app.schemas.conversation import ( GroupCreate, GroupUpdate, MemberAdd, RoleUpdate, ) from app.services.conversation_service import ConversationService +from app.services.draft_service import DraftService from app.websocket.events import EventType from app.websocket.manager import manager +from pydantic import BaseModel router = APIRouter() +class ConvPrefs(BaseModel): + is_pinned: bool | None = None + is_muted: bool | None = None + + @router.get("/", response_model=list[dict]) async def list_conversations( user: User = Depends(get_current_user), @@ -181,3 +188,86 @@ async def update_member_role( return {"success": True, "message": "角色已更新"} except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) + + +@router.put("/{conversation_id}/prefs") +async def update_prefs( + conversation_id: str, + req: ConvPrefs, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """更新会话个人偏好(置顶/免打扰)""" + service = ConversationService(db) + try: + return await service.update_prefs(conversation_id, user.id, req.is_pinned, req.is_muted) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.get("/{conversation_id}/draft") +async def get_draft( + conversation_id: str, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """获取草稿""" + service = DraftService() + return {"draft": await service.get(user.id, conversation_id)} + + +@router.put("/{conversation_id}/draft") +async def save_draft( + conversation_id: str, + body: dict, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """保存草稿""" + service = DraftService() + await service.set(user.id, conversation_id, body.get("draft", "")) + return {"success": True} + + +@router.get("/{conversation_id}/announcement") +async def get_announcement( + conversation_id: str, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """获取群公告""" + from app.services.announcement_service import AnnouncementService + service = AnnouncementService(db) + return await service.get(conversation_id) + + +@router.post("/{conversation_id}/announcement") +async def set_announcement( + conversation_id: str, + body: dict, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """设置群公告(管理员)""" + from app.services.announcement_service import AnnouncementService + service = AnnouncementService(db) + try: + return await service.upsert(conversation_id, user.id, body.get("content", "")) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.put("/{conversation_id}/mute-all") +async def toggle_mute_all( + conversation_id: str, + body: dict, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """全员禁言开关(群主/管理员)""" + service = ConversationService(db) + try: + await service.update_group(conversation_id, user.id, mute_all=body.get("mute_all")) + return {"success": True, "mute_all": body.get("mute_all")} + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) diff --git a/backend/app/routers/friends.py b/backend/app/routers/friends.py index 8a48ec1..2f834a7 100644 --- a/backend/app/routers/friends.py +++ b/backend/app/routers/friends.py @@ -144,3 +144,76 @@ async def remove_friend( service = FriendService(db) await service.remove_friend(user.id, friend_id) return {"success": True, "message": "已删除好友"} + + +# ============ 好友分组/标签 ============ + +@router.get("/tags/list") +async def list_tags( + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """获取我的好友标签""" + from app.models.friend_tag import FriendTag + result = await db.execute( + select(FriendTag).where(FriendTag.user_id == user.id).order_by(FriendTag.created_at) + ) + return [{"id": t.id, "name": t.name} for t in result.scalars().all()] + + +@router.post("/tags/create") +async def create_tag( + body: dict, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """创建好友标签""" + from app.models.friend_tag import FriendTag + import uuid + tag = FriendTag(id=str(uuid.uuid4()), user_id=user.id, name=body.get("name", "")[:30]) + db.add(tag) + await db.flush() + return {"id": tag.id, "name": tag.name} + + +@router.delete("/tags/{tag_id}") +async def delete_tag( + tag_id: str, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """删除好友标签""" + from app.models.friend_tag import FriendTag + from sqlalchemy import delete as sql_delete + await db.execute(sql_delete(FriendTag).where(FriendTag.id == tag_id, FriendTag.user_id == user.id)) + return {"success": True} + + +@router.put("/{friend_id}/tags") +async def assign_tags( + friend_id: str, + body: dict, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """给好友分配标签(覆盖式)""" + from app.models.friend_tag import FriendTag, FriendTagAssignment + from app.models.friend import Friend + from sqlalchemy import delete as sql_delete + # 找到该好友关系记录 + fr = await db.execute( + select(Friend).where(Friend.user_id == user.id, Friend.friend_user_id == friend_id) + ) + friendship = fr.scalars().first() + if not friendship: + raise HTTPException(status_code=404, detail="好友不存在") + # 清空旧分配 + await db.execute( + sql_delete(FriendTagAssignment).where(FriendTagAssignment.friend_id == friendship.id) + ) + # 新分配 + tag_ids = body.get("tag_ids", []) + import uuid + for tid in tag_ids: + db.add(FriendTagAssignment(id=str(uuid.uuid4()), friend_id=friendship.id, tag_id=tid)) + return {"success": True} diff --git a/backend/app/routers/messages.py b/backend/app/routers/messages.py index 967d592..3ae10bb 100644 --- a/backend/app/routers/messages.py +++ b/backend/app/routers/messages.py @@ -79,3 +79,60 @@ async def delete_message( return {"success": True} except ValueError as e: raise HTTPException(status_code=403, detail=str(e)) + + +@router.post("/{conversation_id}/messages/{message_id}/recall") +async def recall_message( + conversation_id: str, + message_id: str, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """撤回消息(2 分钟内)""" + service = MessageService(db) + try: + await service.recall_message(message_id, user.id) + # 广播撤回事件 + from app.services.conversation_service import ConversationService + conv_service = ConversationService(db) + detail = await conv_service.get_conversation_detail(conversation_id, user.id) + if detail and "members" in detail: + from app.websocket.events import EventType + from app.websocket.manager import manager + member_ids = [m["user_id"] for m in detail["members"]] + for uid in member_ids: + await manager.send_to_user(uid, EventType.CHAT_MESSAGE_RECALLED, { + "conversation_id": conversation_id, "message_id": message_id, + "recalled_by": user.id, + }) + return {"success": True} + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.post("/{conversation_id}/messages/{message_id}/reactions") +async def add_reaction( + conversation_id: str, + message_id: str, + body: dict, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """添加/取消表情回应(toggle)""" + service = MessageService(db) + result = await service.react(message_id, user.id, body.get("emoji", "")) + # 广播回应变化 + from app.services.conversation_service import ConversationService + from app.websocket.events import EventType + from app.websocket.manager import manager + conv_service = ConversationService(db) + detail = await conv_service.get_conversation_detail(conversation_id, user.id) + if detail and "members" in detail: + member_ids = [m["user_id"] for m in detail["members"]] + etype = EventType.CHAT_REACTION_ADDED if result["action"] == "added" else EventType.CHAT_REACTION_REMOVED + for uid in member_ids: + await manager.send_to_user(uid, etype, { + "conversation_id": conversation_id, "message_id": message_id, + "emoji": result["emoji"], "user_id": user.id, + }) + return result diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index 3a08d57..d775303 100644 --- a/backend/app/routers/users.py +++ b/backend/app/routers/users.py @@ -1,10 +1,15 @@ """用户路由""" +from datetime import datetime + from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from pydantic import BaseModel from app.dependencies import get_db, get_current_user from app.models.user import User +from app.models.user_block import UserBlock from app.schemas.user import ( UserRead, UserProfile, UserUpdate, UserSearchResult, PasswordChange, EmailChange, @@ -14,6 +19,12 @@ from app.services.user_service import UserService router = APIRouter() +class StatusUpdate(BaseModel): + custom_status: str | None = None + status_emoji: str | None = None + expires_hours: int | None = None + + @router.get("/me", response_model=UserRead) async def get_me(user: User = Depends(get_current_user)): """获取当前用户信息""" @@ -85,3 +96,75 @@ async def get_user( if not user: raise HTTPException(status_code=404, detail="用户不存在") return user + + +@router.put("/me/status") +async def update_status( + req: StatusUpdate, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """设置个人心情状态(对外可见,可设过期)""" + user.custom_status = req.custom_status or None + user.status_emoji = req.status_emoji or None + if req.expires_hours: + from datetime import timedelta + user.status_expires_at = datetime.utcnow() + timedelta(hours=req.expires_hours) + else: + user.status_expires_at = None + await db.flush() + return { + "custom_status": user.custom_status, + "status_emoji": user.status_emoji, + "status_expires_at": user.status_expires_at.isoformat() if user.status_expires_at else None, + } + + +@router.post("/{user_id}/block") +async def block_user( + user_id: str, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """拉黑用户""" + if user_id == user.id: + raise HTTPException(status_code=400, detail="不能拉黑自己") + existing = await db.execute( + select(UserBlock).where(UserBlock.blocker_id == user.id, UserBlock.blocked_id == user_id) + ) + if not existing.scalars().first(): + import uuid + db.add(UserBlock(id=str(uuid.uuid4()), blocker_id=user.id, blocked_id=user_id)) + return {"success": True} + + +@router.delete("/{user_id}/block") +async def unblock_user( + user_id: str, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """取消拉黑""" + from sqlalchemy import delete as sql_delete + await db.execute( + sql_delete(UserBlock).where(UserBlock.blocker_id == user.id, UserBlock.blocked_id == user_id) + ) + return {"success": True} + + +@router.get("/me/blocks") +async def list_blocks( + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """我的黑名单""" + result = await db.execute( + select(UserBlock).where(UserBlock.blocker_id == user.id) + ) + blocks = [] + for b in result.scalars().all(): + u = await db.execute(select(User).where(User.id == b.blocked_id)) + bu = u.scalars().first() + if bu: + blocks.append({"user_id": bu.id, "username": bu.username, "nickname": bu.nickname}) + return blocks diff --git a/backend/app/schemas/message.py b/backend/app/schemas/message.py index bc18e18..faaca73 100644 --- a/backend/app/schemas/message.py +++ b/backend/app/schemas/message.py @@ -8,8 +8,9 @@ from pydantic import BaseModel, Field class MessageSend(BaseModel): conversation_id: str content: str = Field(..., min_length=1, max_length=5000) - type: str = Field(default="text", pattern="^(text|image|file)$") + type: str = Field(default="text", pattern="^(text|image|file|voice)$") reply_to_id: str | None = None + mentioned_user_ids: list[str] | None = None class MessageRead(BaseModel): @@ -23,7 +24,10 @@ class MessageRead(BaseModel): reply_to_id: str | None = None reply_to_content: str | None = None reply_to_sender_name: str | None = None + mentions: list[str] | None = None is_deleted: bool = False + is_recalled: bool = False + reactions: list[dict] = [] created_at: datetime model_config = {"from_attributes": True} diff --git a/backend/app/services/announcement_service.py b/backend/app/services/announcement_service.py new file mode 100644 index 0000000..750bffe --- /dev/null +++ b/backend/app/services/announcement_service.py @@ -0,0 +1,58 @@ +"""群公告服务""" + +import uuid +from datetime import datetime + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.group_announcement import GroupAnnouncement +from app.services.conversation_service import ConversationService + + +class AnnouncementService: + def __init__(self, db: AsyncSession): + self.db = db + + async def get(self, conversation_id: str) -> dict | None: + result = await self.db.execute( + select(GroupAnnouncement).where( + GroupAnnouncement.conversation_id == conversation_id + ).order_by(GroupAnnouncement.updated_at.desc()) + ) + ann = result.scalars().first() + if not ann: + return None + return self._to_dict(ann) + + async def upsert(self, conversation_id: str, author_id: str, content: str) -> dict: + # 校验管理员 + conv_service = ConversationService(self.db) + await conv_service._get_conv_if_admin(conversation_id, author_id) + + result = await self.db.execute( + select(GroupAnnouncement).where(GroupAnnouncement.conversation_id == conversation_id) + ) + ann = result.scalars().first() + if ann: + ann.content = content + ann.author_id = author_id + ann.updated_at = datetime.utcnow() + else: + ann = GroupAnnouncement( + id=str(uuid.uuid4()), + conversation_id=conversation_id, + author_id=author_id, content=content, + ) + self.db.add(ann) + await self.db.flush() + return self._to_dict(ann) + + def _to_dict(self, ann: GroupAnnouncement) -> dict: + return { + "id": ann.id, + "conversation_id": ann.conversation_id, + "author_id": ann.author_id, + "content": ann.content, + "updated_at": ann.updated_at.isoformat() if ann.updated_at else None, + } diff --git a/backend/app/services/conversation_service.py b/backend/app/services/conversation_service.py index d423cdf..5558cd4 100644 --- a/backend/app/services/conversation_service.py +++ b/backend/app/services/conversation_service.py @@ -178,6 +178,21 @@ class ConversationService: ) return result.scalars().first() + async def update_prefs(self, conv_id: str, user_id: str, + is_pinned: bool | None = None, + is_muted: bool | None = None) -> dict: + """更新会话个人偏好(置顶/免打扰)""" + member = await self._get_member(conv_id, user_id) + if not member: + raise ValueError("无权访问该会话") + if is_pinned is not None: + member.is_pinned = is_pinned + member.pinned_at = datetime.utcnow() if is_pinned else None + if is_muted is not None: + member.is_muted = is_muted + await self.db.flush() + return {"is_pinned": member.is_pinned, "is_muted": member.is_muted} + async def get_user_conversations(self, user_id: str) -> list[dict]: """获取用户的会话列表""" result = await self.db.execute( @@ -217,9 +232,15 @@ class ConversationService: "last_message_at": conv.last_message_at, "unread_count": unread, "created_at": conv.created_at, + "is_pinned": member.is_pinned, + "is_muted": member.is_muted, }) - conversations.sort(key=lambda x: x["last_message_at"] or x["created_at"], reverse=True) + # 排序:置顶优先,组内按最后消息时间倒序 + conversations.sort(key=lambda x: ( + x["is_pinned"], + x["last_message_at"] or x["created_at"], + ), reverse=True) return conversations async def get_conversation_detail(self, conv_id: str, user_id: str) -> dict | None: diff --git a/backend/app/services/draft_service.py b/backend/app/services/draft_service.py new file mode 100644 index 0000000..22816f8 --- /dev/null +++ b/backend/app/services/draft_service.py @@ -0,0 +1,26 @@ +"""草稿服务(Redis 存储,零迁移)""" + +import redis.asyncio as aioredis +from app.config import settings + + +class DraftService: + def __init__(self): + self._redis = None + + async def _get_redis(self): + if self._redis is None: + self._redis = aioredis.from_url(settings.REDIS_URL, decode_responses=True) + return self._redis + + async def get(self, user_id: str, conv_id: str) -> str: + r = await self._get_redis() + return await r.get(f"draft:{user_id}:{conv_id}") or "" + + async def set(self, user_id: str, conv_id: str, text: str): + r = await self._get_redis() + key = f"draft:{user_id}:{conv_id}" + if text: + await r.set(key, text, ex=30 * 86400) # 30 天 TTL + else: + await r.delete(key) diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py new file mode 100644 index 0000000..c0a1d86 --- /dev/null +++ b/backend/app/services/email_service.py @@ -0,0 +1,26 @@ +"""邮件服务(假实现:验证码打印到日志;以后替换为 SMTP)""" + +import hashlib +import secrets + + +def generate_code() -> str: + """生成 6 位数字验证码""" + return f"{secrets.randbelow(1000000):06d}" + + +def hash_code(code: str) -> str: + return hashlib.sha256(code.encode()).hexdigest() + + +async def send_verification_email(to_email: str, code: str, purpose: str = "验证"): + """发送验证邮件(开发期假实现:打印到日志) + + 生产环境替换此函数为真实 SMTP 发送即可,调用方无需改动。 + """ + print(f"\n{'=' * 50}") + print(f"📧 [邮件服务-开发模式] 收件人: {to_email}") + print(f"📋 用途: {purpose}") + print(f"🔢 验证码: {code}") + print(f"{'=' * 50}\n") + return True diff --git a/backend/app/services/message_service.py b/backend/app/services/message_service.py index 8ab682a..bf66144 100644 --- a/backend/app/services/message_service.py +++ b/backend/app/services/message_service.py @@ -1,5 +1,6 @@ """消息服务""" +import json import uuid from datetime import datetime, timezone @@ -9,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.models.message import Message from app.models.conversation import Conversation from app.models.conversation_member import ConversationMember +from app.models.message_reaction import MessageReaction class MessageService: @@ -17,8 +19,24 @@ class MessageService: async def send_message(self, conversation_id: str, sender_id: str, content: str, msg_type: str = "text", - reply_to_id: str | None = None) -> Message: + reply_to_id: str | None = None, + mentioned_user_ids: list[str] | None = None) -> Message: """发送消息""" + # 全员禁言检查(群聊,非管理员不能发) + conv_result = await self.db.execute( + select(Conversation).where(Conversation.id == conversation_id) + ) + conv = conv_result.scalars().first() + if conv and conv.type == "group" and conv.mute_all: + from app.models.conversation_member import ConversationMember as CM + m_result = await self.db.execute( + select(CM).where(CM.conversation_id == conversation_id, CM.user_id == sender_id) + ) + m = m_result.scalars().first() + if m and m.role == "member": + raise ValueError("群已开启全员禁言") + + import json message = Message( id=str(uuid.uuid4()), conversation_id=conversation_id, @@ -26,6 +44,7 @@ class MessageService: type=msg_type, content=content, reply_to_id=reply_to_id, + mentions=json.dumps(mentioned_user_ids) if mentioned_user_ids else None, ) self.db.add(message) @@ -101,6 +120,27 @@ class MessageService: ) reply_senders_map = {u.id: u for u in reply_senders_result.scalars().all()} + # 批量预加载这些消息的表情回应 + msg_ids = [m.id for m in messages] + reaction_result = await self.db.execute( + select(MessageReaction).where(MessageReaction.message_id.in_(msg_ids)) + ) + from app.models.user import User as U + reactions_raw = reaction_result.scalars().all() + reaction_user_ids = list(set(r.user_id for r in reactions_raw)) + reaction_users_map: dict[str, U] = {} + if reaction_user_ids: + ru_result = await self.db.execute(select(U).where(U.id.in_(reaction_user_ids))) + reaction_users_map = {u.id: u for u in ru_result.scalars().all()} + reaction_map: dict[str, list[dict]] = {} + for r in reactions_raw: + u = reaction_users_map.get(r.user_id) + reaction_map.setdefault(r.message_id, []).append({ + "emoji": r.emoji, + "user_id": r.user_id, + "username": u.username if u else None, + }) + message_list = [] for msg in reversed(messages): sender = senders_map.get(msg.sender_id) @@ -126,7 +166,10 @@ class MessageService: "reply_to_id": msg.reply_to_id, "reply_to_content": reply_to_content, "reply_to_sender_name": reply_to_sender_name, + "mentions": json.loads(msg.mentions) if msg.mentions else None, "is_deleted": msg.is_deleted, + "is_recalled": msg.is_recalled, + "reactions": reaction_map.get(msg.id, []), "created_at": msg.created_at, }) @@ -158,6 +201,43 @@ class MessageService: raise ValueError("消息不存在或无权删除") message.is_deleted = True + async def recall_message(self, message_id: str, user_id: str): + """撤回消息(仅本人,2 分钟内)""" + result = await self.db.execute( + select(Message).where(Message.id == message_id, Message.sender_id == user_id) + ) + message = result.scalars().first() + if not message: + raise ValueError("消息不存在或无权撤回") + if message.is_recalled: + raise ValueError("消息已撤回") + elapsed = (datetime.utcnow() - message.created_at).total_seconds() + if elapsed > 120: + raise ValueError("超过 2 分钟,无法撤回") + message.is_recalled = True + message.recalled_at = datetime.utcnow() + + async def react(self, message_id: str, user_id: str, emoji: str): + """添加表情回应(toggle:已存在则取消)""" + result = await self.db.execute( + select(MessageReaction).where( + MessageReaction.message_id == message_id, + MessageReaction.user_id == user_id, + MessageReaction.emoji == emoji, + ) + ) + existing = result.scalars().first() + if existing: + await self.db.delete(existing) + return {"action": "removed", "emoji": emoji} + reaction = MessageReaction( + id=str(uuid.uuid4()), + message_id=message_id, user_id=user_id, emoji=emoji, + ) + self.db.add(reaction) + await self.db.flush() + return {"action": "added", "emoji": emoji} + async def get_total_count(self) -> int: """获取消息总数""" result = await self.db.execute(select(func.count(Message.id))) diff --git a/backend/app/websocket/events.py b/backend/app/websocket/events.py index c98368d..fb531ec 100644 --- a/backend/app/websocket/events.py +++ b/backend/app/websocket/events.py @@ -17,6 +17,9 @@ class EventType(str, Enum): CHAT_TYPING_INDICATOR = "chat.typing" CHAT_READ_RECEIPT = "chat.read" CHAT_MESSAGE_DELETED = "chat.message_deleted" + CHAT_MESSAGE_RECALLED = "chat.message_recalled" + CHAT_REACTION_ADDED = "chat.reaction_added" + CHAT_REACTION_REMOVED = "chat.reaction_removed" CONVERSATION_UPDATED = "conversation.updated" CONVERSATION_MEMBER_ADDED = "conversation.member_added" CONVERSATION_MEMBER_REMOVED = "conversation.member_removed" @@ -24,6 +27,7 @@ class EventType(str, Enum): FRIEND_ACCEPTED = "friend.accepted" PRESENCE_ONLINE = "presence.online" PRESENCE_OFFLINE = "presence.offline" + PRESENCE_MENTIONED = "presence.mentioned" ECHO_SEND = "echo.send" HEARTBEAT_SYNC = "heartbeat.sync" FLASH_SPAWN = "flash.spawn" diff --git a/backend/app/websocket/handlers.py b/backend/app/websocket/handlers.py index 980d80f..b0f35ea 100644 --- a/backend/app/websocket/handlers.py +++ b/backend/app/websocket/handlers.py @@ -22,6 +22,7 @@ async def handle_chat_send(ws: WebSocket, user_id: str, data: dict, db: AsyncSes content=data["content"], msg_type=data.get("type", "text"), reply_to_id=data.get("reply_to_id"), + mentioned_user_ids=data.get("mentioned_user_ids"), ) await db.commit() @@ -60,6 +61,8 @@ async def handle_chat_send(ws: WebSocket, user_id: str, data: dict, db: AsyncSes "reply_to_id": message.reply_to_id, "reply_to_content": reply_to_content, "reply_to_sender_name": reply_to_sender_name, + "mentions": data.get("mentioned_user_ids"), + "is_recalled": False, "created_at": message.created_at.isoformat(), } @@ -69,6 +72,15 @@ async def handle_chat_send(ws: WebSocket, user_id: str, data: dict, db: AsyncSes await manager.broadcast_to_conversation( member_ids, EventType.CHAT_MESSAGE, msg_data ) + # 被@的人:单独推送提及通知(即使免打扰也提醒) + mentioned = data.get("mentioned_user_ids") or [] + for mid in mentioned: + if mid in member_ids and mid != user_id: + await manager.send_to_user(mid, EventType.PRESENCE_MENTIONED, { + "conversation_id": message.conversation_id, + "from_user_id": user_id, + "from_username": sender.username if sender else "未知", + }) except Exception as e: await manager.send_to_user(user_id, EventType.ERROR, {"message": str(e)}) diff --git a/frontend/src/api/chat.ts b/frontend/src/api/chat.ts index 74d1f56..77f40f6 100644 --- a/frontend/src/api/chat.ts +++ b/frontend/src/api/chat.ts @@ -40,4 +40,21 @@ export const chatApi = { searchMessages: (conversationId: string, keyword: string) => api.get(`/conversations/${conversationId}/messages/search`, { params: { q: keyword } }), + + updatePrefs: (convId: string, data: { is_pinned?: boolean; is_muted?: boolean }) => + api.put(`/conversations/${convId}/prefs`, data), + + getDraft: (convId: string) => api.get(`/conversations/${convId}/draft`), + + saveDraft: (convId: string, draft: string) => + api.put(`/conversations/${convId}/draft`, { draft }), + + recallMessage: (conversationId: string, messageId: string) => + api.post(`/conversations/${conversationId}/messages/${messageId}/recall`), + + reactMessage: (conversationId: string, messageId: string, emoji: string) => + api.post(`/conversations/${conversationId}/messages/${messageId}/reactions`, { emoji }), + + removeReaction: (conversationId: string, messageId: string, emoji: string) => + api.delete(`/conversations/${conversationId}/messages/${messageId}/reactions`, { params: { emoji } }), } diff --git a/frontend/src/composables/useWebSocket.ts b/frontend/src/composables/useWebSocket.ts index d8a7636..f705a0c 100644 --- a/frontend/src/composables/useWebSocket.ts +++ b/frontend/src/composables/useWebSocket.ts @@ -119,6 +119,19 @@ export function useWebSocket() { case 'heartbeat.sync': window.dispatchEvent(new CustomEvent('qingye:heartbeat', { detail: event.data })) break + case 'chat.message_recalled': + window.dispatchEvent(new CustomEvent('qingye:msg-recalled', { detail: event.data })) + break + case 'chat.reaction_added': + case 'chat.reaction_removed': + window.dispatchEvent(new CustomEvent('qingye:reaction', { detail: { ...event.data, _phase: event.type } })) + break + case 'presence.mentioned': + window.dispatchEvent(new CustomEvent('qingye:mentioned', { detail: event.data })) + break + case 'chat.read': + window.dispatchEvent(new CustomEvent('qingye:read-receipt', { detail: event.data })) + break case 'flash.spawn': case 'flash.progress': case 'flash.result': diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 6df4b0b..40f834f 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -42,6 +42,14 @@ import { NTag, NRadio, NRadioGroup, + NCheckbox, + NCheckboxGroup, + NDatePicker, + NInputGroup, + NCollapse, + NCollapseItem, + NSlider, + NProgress, } from 'naive-ui' const app = createApp(App) @@ -89,9 +97,34 @@ const naiveComponents: Record = { NTag, NRadio, NRadioGroup, + NCheckbox, + NCheckboxGroup, + NDatePicker, + NInputGroup, + NCollapse, + NCollapseItem, + NSlider, + NProgress, } Object.entries(naiveComponents).forEach(([name, component]) => { app.component(name, component) }) +// 全局错误捕获:把运行时错误显示在屏幕顶部(便于无控制台时诊断) +function showErrorBanner(msg: string) { + if (typeof document === 'undefined') return + const div = document.createElement('div') + div.textContent = '⚠️ 运行时错误: ' + msg + div.style.cssText = 'position:fixed;top:0;left:0;right:0;background:#d32f2f;color:#fff;padding:10px 14px;z-index:99999;font-size:13px;font-family:monospace;white-space:pre-wrap;word-break:break-all;max-height:40vh;overflow:auto;' + document.body.appendChild(div) +} +app.config.errorHandler = (err: any, _instance, info) => { + console.error('Vue error:', err, info) + showErrorBanner((err?.message || String(err)) + ' 【' + info + '】') +} +if (typeof window !== 'undefined') { + window.addEventListener('error', (e) => showErrorBanner(e.message + ' @ ' + (e.filename || '') + ':' + (e.lineno || ''))) + window.addEventListener('unhandledrejection', (e) => showErrorBanner('Promise: ' + (e.reason?.message || e.reason))) +} + app.mount('#app') diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 4c5fb4b..9225fb3 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -10,6 +10,7 @@ const routes: RouteRecordRaw[] = [ children: [ { path: '/login', name: 'Login', component: () => import('@/views/auth/LoginView.vue') }, { path: '/register', name: 'Register', component: () => import('@/views/auth/RegisterView.vue') }, + { path: '/forgot', name: 'ForgotPassword', component: () => import('@/views/auth/ForgotPasswordView.vue') }, ], }, diff --git a/frontend/src/views/auth/ForgotPasswordView.vue b/frontend/src/views/auth/ForgotPasswordView.vue new file mode 100644 index 0000000..80a336a --- /dev/null +++ b/frontend/src/views/auth/ForgotPasswordView.vue @@ -0,0 +1,110 @@ + + + + + diff --git a/frontend/src/views/auth/LoginView.vue b/frontend/src/views/auth/LoginView.vue index 2bf0e47..d27aa50 100644 --- a/frontend/src/views/auth/LoginView.vue +++ b/frontend/src/views/auth/LoginView.vue @@ -11,6 +11,8 @@
没有账号?立即注册 + · + 忘记密码
@@ -40,7 +42,12 @@ async function handleLogin() { await auth.login(form.username, form.password) message.success('登录成功!欢迎回到青叶 🌿') const redirect = (route.query.redirect as string) || '/chat' - router.push(redirect) + // 诊断:若导航失败立即显示原因 + const failure = await router.push(redirect) + if (failure) { + console.error('导航失败:', failure) + message.error(`跳转失败(${failure.name || '未知'}),已登录但未能进入主页。请截图此提示。`) + } } catch (e: any) { message.error(e.response?.data?.detail || '登录失败,请检查用户名和密码') } finally { diff --git a/frontend/src/views/chat/ChatRoomView.vue b/frontend/src/views/chat/ChatRoomView.vue index 7335b04..e1f3fbb 100644 --- a/frontend/src/views/chat/ChatRoomView.vue +++ b/frontend/src/views/chat/ChatRoomView.vue @@ -88,8 +88,8 @@ pending: msg._pending, failed: msg._failed, }">
{{ formatTimeDivider(msg.created_at) }}
-