This commit is contained in:
2026-06-14 11:16:42 +08:00
parent ca39190ad7
commit c9fc87cd89
35 changed files with 1480 additions and 18 deletions
+11
View File
@@ -16,6 +16,11 @@ from app.models.sync_seed import SyncQuestion, SyncSeed
from app.models.chat_climate import ChatClimate from app.models.chat_climate import ChatClimate
from app.models.flash_event import FlashEvent, FlashParticipation from app.models.flash_event import FlashEvent, FlashParticipation
from app.models.user_streak import UserStreak 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__ = [ __all__ = [
"User", "User",
@@ -38,4 +43,10 @@ __all__ = [
"FlashEvent", "FlashEvent",
"FlashParticipation", "FlashParticipation",
"UserStreak", "UserStreak",
"GroupAnnouncement",
"MessageReaction",
"UserBlock",
"PasswordResetToken",
"FriendTag",
"FriendTagAssignment",
] ]
+2 -1
View File
@@ -2,7 +2,7 @@
from datetime import datetime, timezone 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 sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base from app.database import Base
@@ -17,6 +17,7 @@ class Conversation(Base):
avatar_url: Mapped[str | None] = mapped_column(String(500), nullable=True) # 群头像 avatar_url: Mapped[str | None] = mapped_column(String(500), nullable=True) # 群头像
description: 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) 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_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
last_message_preview: Mapped[str | None] = mapped_column(String(200), 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()) created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.utcnow())
+4 -1
View File
@@ -2,7 +2,7 @@
from datetime import datetime, timezone 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 sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base from app.database import Base
@@ -24,6 +24,9 @@ class ConversationMember(Base):
role: Mapped[str] = mapped_column(String(20), default="member") # owner / admin / member role: Mapped[str] = mapped_column(String(20), default="member") # owner / admin / member
nickname: Mapped[str | None] = mapped_column(String(50), nullable=True) # 群内昵称 nickname: Mapped[str | None] = mapped_column(String(50), nullable=True) # 群内昵称
last_read_message_id: Mapped[str | None] = mapped_column(String(36), 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()) joined_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.utcnow())
left_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) left_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+30
View File
@@ -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"))
+25
View File
@@ -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])
+4 -1
View File
@@ -21,12 +21,15 @@ class Message(Base):
sender_id: Mapped[str] = mapped_column( sender_id: Mapped[str] = mapped_column(
String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False 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) content: Mapped[str] = mapped_column(Text, nullable=False)
reply_to_id: Mapped[str | None] = mapped_column( reply_to_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("messages.id", ondelete="SET NULL"), nullable=True 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_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( created_at: Mapped[datetime] = mapped_column(
DateTime, default=lambda: datetime.utcnow(), index=True DateTime, default=lambda: datetime.utcnow(), index=True
) )
+24
View File
@@ -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])
+21
View File
@@ -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])
+5 -1
View File
@@ -18,7 +18,11 @@ class User(Base):
password_hash: Mapped[str] = mapped_column(String(255), nullable=False) password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
avatar_url: Mapped[str | None] = mapped_column(String(500), nullable=True) avatar_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
bio: Mapped[str | None] = mapped_column(String(200), 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_admin: Mapped[bool] = mapped_column(Boolean, default=False)
is_banned: 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) banned_reason: Mapped[str | None] = mapped_column(String(500), nullable=True)
+23
View File
@@ -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])
+113 -1
View File
@@ -1,16 +1,38 @@
"""认证路由""" """认证路由"""
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel
from app.dependencies import get_db 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.schemas.auth import RegisterRequest, LoginRequest, TokenResponse, RefreshRequest
from app.services.auth_service import AuthService 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() 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) @router.post("/register", response_model=TokenResponse)
async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)): 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 return result
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=401, detail=str(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="验证码无效或已过期")
+90
View File
@@ -10,12 +10,19 @@ from app.schemas.conversation import (
GroupCreate, GroupUpdate, MemberAdd, RoleUpdate, GroupCreate, GroupUpdate, MemberAdd, RoleUpdate,
) )
from app.services.conversation_service import ConversationService from app.services.conversation_service import ConversationService
from app.services.draft_service import DraftService
from app.websocket.events import EventType from app.websocket.events import EventType
from app.websocket.manager import manager from app.websocket.manager import manager
from pydantic import BaseModel
router = APIRouter() router = APIRouter()
class ConvPrefs(BaseModel):
is_pinned: bool | None = None
is_muted: bool | None = None
@router.get("/", response_model=list[dict]) @router.get("/", response_model=list[dict])
async def list_conversations( async def list_conversations(
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
@@ -181,3 +188,86 @@ async def update_member_role(
return {"success": True, "message": "角色已更新"} return {"success": True, "message": "角色已更新"}
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(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))
+73
View File
@@ -144,3 +144,76 @@ async def remove_friend(
service = FriendService(db) service = FriendService(db)
await service.remove_friend(user.id, friend_id) await service.remove_friend(user.id, friend_id)
return {"success": True, "message": "已删除好友"} 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}
+57
View File
@@ -79,3 +79,60 @@ async def delete_message(
return {"success": True} return {"success": True}
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=403, detail=str(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
+83
View File
@@ -1,10 +1,15 @@
"""用户路由""" """用户路由"""
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel
from app.dependencies import get_db, get_current_user from app.dependencies import get_db, get_current_user
from app.models.user import User from app.models.user import User
from app.models.user_block import UserBlock
from app.schemas.user import ( from app.schemas.user import (
UserRead, UserProfile, UserUpdate, UserSearchResult, UserRead, UserProfile, UserUpdate, UserSearchResult,
PasswordChange, EmailChange, PasswordChange, EmailChange,
@@ -14,6 +19,12 @@ from app.services.user_service import UserService
router = APIRouter() 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) @router.get("/me", response_model=UserRead)
async def get_me(user: User = Depends(get_current_user)): async def get_me(user: User = Depends(get_current_user)):
"""获取当前用户信息""" """获取当前用户信息"""
@@ -85,3 +96,75 @@ async def get_user(
if not user: if not user:
raise HTTPException(status_code=404, detail="用户不存在") raise HTTPException(status_code=404, detail="用户不存在")
return user 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
+5 -1
View File
@@ -8,8 +8,9 @@ from pydantic import BaseModel, Field
class MessageSend(BaseModel): class MessageSend(BaseModel):
conversation_id: str conversation_id: str
content: str = Field(..., min_length=1, max_length=5000) 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 reply_to_id: str | None = None
mentioned_user_ids: list[str] | None = None
class MessageRead(BaseModel): class MessageRead(BaseModel):
@@ -23,7 +24,10 @@ class MessageRead(BaseModel):
reply_to_id: str | None = None reply_to_id: str | None = None
reply_to_content: str | None = None reply_to_content: str | None = None
reply_to_sender_name: str | None = None reply_to_sender_name: str | None = None
mentions: list[str] | None = None
is_deleted: bool = False is_deleted: bool = False
is_recalled: bool = False
reactions: list[dict] = []
created_at: datetime created_at: datetime
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
@@ -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,
}
+22 -1
View File
@@ -178,6 +178,21 @@ class ConversationService:
) )
return result.scalars().first() 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]: async def get_user_conversations(self, user_id: str) -> list[dict]:
"""获取用户的会话列表""" """获取用户的会话列表"""
result = await self.db.execute( result = await self.db.execute(
@@ -217,9 +232,15 @@ class ConversationService:
"last_message_at": conv.last_message_at, "last_message_at": conv.last_message_at,
"unread_count": unread, "unread_count": unread,
"created_at": conv.created_at, "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 return conversations
async def get_conversation_detail(self, conv_id: str, user_id: str) -> dict | None: async def get_conversation_detail(self, conv_id: str, user_id: str) -> dict | None:
+26
View File
@@ -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)
+26
View File
@@ -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
+81 -1
View File
@@ -1,5 +1,6 @@
"""消息服务""" """消息服务"""
import json
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -9,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.models.message import Message from app.models.message import Message
from app.models.conversation import Conversation from app.models.conversation import Conversation
from app.models.conversation_member import ConversationMember from app.models.conversation_member import ConversationMember
from app.models.message_reaction import MessageReaction
class MessageService: class MessageService:
@@ -17,8 +19,24 @@ class MessageService:
async def send_message(self, conversation_id: str, sender_id: str, async def send_message(self, conversation_id: str, sender_id: str,
content: str, msg_type: str = "text", 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( message = Message(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
conversation_id=conversation_id, conversation_id=conversation_id,
@@ -26,6 +44,7 @@ class MessageService:
type=msg_type, type=msg_type,
content=content, content=content,
reply_to_id=reply_to_id, reply_to_id=reply_to_id,
mentions=json.dumps(mentioned_user_ids) if mentioned_user_ids else None,
) )
self.db.add(message) self.db.add(message)
@@ -101,6 +120,27 @@ class MessageService:
) )
reply_senders_map = {u.id: u for u in reply_senders_result.scalars().all()} 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 = [] message_list = []
for msg in reversed(messages): for msg in reversed(messages):
sender = senders_map.get(msg.sender_id) sender = senders_map.get(msg.sender_id)
@@ -126,7 +166,10 @@ class MessageService:
"reply_to_id": msg.reply_to_id, "reply_to_id": msg.reply_to_id,
"reply_to_content": reply_to_content, "reply_to_content": reply_to_content,
"reply_to_sender_name": reply_to_sender_name, "reply_to_sender_name": reply_to_sender_name,
"mentions": json.loads(msg.mentions) if msg.mentions else None,
"is_deleted": msg.is_deleted, "is_deleted": msg.is_deleted,
"is_recalled": msg.is_recalled,
"reactions": reaction_map.get(msg.id, []),
"created_at": msg.created_at, "created_at": msg.created_at,
}) })
@@ -158,6 +201,43 @@ class MessageService:
raise ValueError("消息不存在或无权删除") raise ValueError("消息不存在或无权删除")
message.is_deleted = True 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: async def get_total_count(self) -> int:
"""获取消息总数""" """获取消息总数"""
result = await self.db.execute(select(func.count(Message.id))) result = await self.db.execute(select(func.count(Message.id)))
+4
View File
@@ -17,6 +17,9 @@ class EventType(str, Enum):
CHAT_TYPING_INDICATOR = "chat.typing" CHAT_TYPING_INDICATOR = "chat.typing"
CHAT_READ_RECEIPT = "chat.read" CHAT_READ_RECEIPT = "chat.read"
CHAT_MESSAGE_DELETED = "chat.message_deleted" 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_UPDATED = "conversation.updated"
CONVERSATION_MEMBER_ADDED = "conversation.member_added" CONVERSATION_MEMBER_ADDED = "conversation.member_added"
CONVERSATION_MEMBER_REMOVED = "conversation.member_removed" CONVERSATION_MEMBER_REMOVED = "conversation.member_removed"
@@ -24,6 +27,7 @@ class EventType(str, Enum):
FRIEND_ACCEPTED = "friend.accepted" FRIEND_ACCEPTED = "friend.accepted"
PRESENCE_ONLINE = "presence.online" PRESENCE_ONLINE = "presence.online"
PRESENCE_OFFLINE = "presence.offline" PRESENCE_OFFLINE = "presence.offline"
PRESENCE_MENTIONED = "presence.mentioned"
ECHO_SEND = "echo.send" ECHO_SEND = "echo.send"
HEARTBEAT_SYNC = "heartbeat.sync" HEARTBEAT_SYNC = "heartbeat.sync"
FLASH_SPAWN = "flash.spawn" FLASH_SPAWN = "flash.spawn"
+12
View File
@@ -22,6 +22,7 @@ async def handle_chat_send(ws: WebSocket, user_id: str, data: dict, db: AsyncSes
content=data["content"], content=data["content"],
msg_type=data.get("type", "text"), msg_type=data.get("type", "text"),
reply_to_id=data.get("reply_to_id"), reply_to_id=data.get("reply_to_id"),
mentioned_user_ids=data.get("mentioned_user_ids"),
) )
await db.commit() 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_id": message.reply_to_id,
"reply_to_content": reply_to_content, "reply_to_content": reply_to_content,
"reply_to_sender_name": reply_to_sender_name, "reply_to_sender_name": reply_to_sender_name,
"mentions": data.get("mentioned_user_ids"),
"is_recalled": False,
"created_at": message.created_at.isoformat(), "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( await manager.broadcast_to_conversation(
member_ids, EventType.CHAT_MESSAGE, msg_data 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: except Exception as e:
await manager.send_to_user(user_id, EventType.ERROR, {"message": str(e)}) await manager.send_to_user(user_id, EventType.ERROR, {"message": str(e)})
+17
View File
@@ -40,4 +40,21 @@ export const chatApi = {
searchMessages: (conversationId: string, keyword: string) => searchMessages: (conversationId: string, keyword: string) =>
api.get(`/conversations/${conversationId}/messages/search`, { params: { q: keyword } }), 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 } }),
} }
+13
View File
@@ -119,6 +119,19 @@ export function useWebSocket() {
case 'heartbeat.sync': case 'heartbeat.sync':
window.dispatchEvent(new CustomEvent('qingye:heartbeat', { detail: event.data })) window.dispatchEvent(new CustomEvent('qingye:heartbeat', { detail: event.data }))
break 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.spawn':
case 'flash.progress': case 'flash.progress':
case 'flash.result': case 'flash.result':
+33
View File
@@ -42,6 +42,14 @@ import {
NTag, NTag,
NRadio, NRadio,
NRadioGroup, NRadioGroup,
NCheckbox,
NCheckboxGroup,
NDatePicker,
NInputGroup,
NCollapse,
NCollapseItem,
NSlider,
NProgress,
} from 'naive-ui' } from 'naive-ui'
const app = createApp(App) const app = createApp(App)
@@ -89,9 +97,34 @@ const naiveComponents: Record<string, any> = {
NTag, NTag,
NRadio, NRadio,
NRadioGroup, NRadioGroup,
NCheckbox,
NCheckboxGroup,
NDatePicker,
NInputGroup,
NCollapse,
NCollapseItem,
NSlider,
NProgress,
} }
Object.entries(naiveComponents).forEach(([name, component]) => { Object.entries(naiveComponents).forEach(([name, component]) => {
app.component(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') app.mount('#app')
+1
View File
@@ -10,6 +10,7 @@ const routes: RouteRecordRaw[] = [
children: [ children: [
{ path: '/login', name: 'Login', component: () => import('@/views/auth/LoginView.vue') }, { path: '/login', name: 'Login', component: () => import('@/views/auth/LoginView.vue') },
{ path: '/register', name: 'Register', component: () => import('@/views/auth/RegisterView.vue') }, { path: '/register', name: 'Register', component: () => import('@/views/auth/RegisterView.vue') },
{ path: '/forgot', name: 'ForgotPassword', component: () => import('@/views/auth/ForgotPasswordView.vue') },
], ],
}, },
@@ -0,0 +1,110 @@
<template>
<div class="forgot-page">
<div class="forgot-card">
<div class="logo">🌿</div>
<h2>找回密码</h2>
<p class="subtitle">输入注册邮箱验证码将发送开发期打印在服务器日志</p>
<n-form>
<n-form-item label="注册邮箱">
<n-input v-model:value="email" placeholder="your@email.com" size="large" />
</n-form-item>
<template v-if="step === 2">
<n-form-item label="验证码(见后端日志)">
<n-input v-model:value="code" placeholder="6 位验证码" size="large" maxlength="6" />
</n-form-item>
<n-form-item label="新密码">
<n-input v-model:value="newPassword" type="password" show-password-on="click" placeholder="新密码" size="large" />
</n-form-item>
</template>
<n-button v-if="step === 1" type="primary" block size="large" :loading="sending" @click="sendCode">
发送验证码
</n-button>
<n-button v-else type="primary" block size="large" :loading="resetting" @click="reset">
重置密码
</n-button>
</n-form>
<div class="footer">
<router-link to="/login"> 返回登录</router-link>
</div>
<div v-if="devHint" class="dev-hint">
💡 开发模式验证码已打印到后端控制台docker compose logs backend
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useMessage } from 'naive-ui'
import api from '@/api/client'
const router = useRouter()
const message = useMessage()
const email = ref('')
const code = ref('')
const newPassword = ref('')
const step = ref(1)
const sending = ref(false)
const resetting = ref(false)
const devHint = ref(false)
async function sendCode() {
if (!email.value.trim()) { message.error('请输入邮箱'); return }
sending.value = true
try {
await api.post('/auth/forgot', { email: email.value.trim() })
step.value = 2
devHint.value = true
message.success('验证码已发送(开发期见后端日志)')
} catch (e: any) {
message.error(e.response?.data?.detail || '发送失败')
} finally {
sending.value = false
}
}
async function reset() {
if (!code.value || !newPassword.value) { message.error('请填写完整'); return }
resetting.value = true
try {
await api.post('/auth/reset', {
email: email.value.trim(),
code: code.value.trim(),
new_password: newPassword.value,
})
message.success('密码已重置,请重新登录')
router.push('/login')
} catch (e: any) {
message.error(e.response?.data?.detail || '重置失败')
} finally {
resetting.value = false
}
}
</script>
<style scoped>
.forgot-page {
min-height: 100vh; display: flex; align-items: center; justify-content: center;
background: linear-gradient(135deg, #009688 0%, #26A69A 100%);
padding: 20px;
}
.forgot-card {
width: 400px; max-width: 100%; background: var(--color-surface, #fff);
border-radius: 20px; padding: 36px 32px; box-shadow: 0 12px 40px rgba(0,0,0,0.15);
}
.logo { font-size: 48px; text-align: center; }
.forgot-card h2 { text-align: center; margin: 8px 0 4px; color: var(--color-primary-dark, #00796B); }
.subtitle { text-align: center; font-size: 13px; color: var(--color-text-hint, #999); margin-bottom: 24px; }
.footer { text-align: center; margin-top: 20px; }
.footer a { font-size: 13px; color: var(--color-primary, #009688); text-decoration: none; }
.dev-hint {
margin-top: 16px; padding: 10px; background: #FFF3E0; border-radius: 8px;
font-size: 12px; color: #E65100; text-align: center;
}
</style>
+8 -1
View File
@@ -11,6 +11,8 @@
</n-button> </n-button>
<div style="text-align: center; margin-top: 16px"> <div style="text-align: center; margin-top: 16px">
<n-button text type="primary" @click="$router.push('/register')">没有账号立即注册</n-button> <n-button text type="primary" @click="$router.push('/register')">没有账号立即注册</n-button>
<span style="margin: 0 8px; color: var(--color-text-hint)">·</span>
<n-button text type="primary" @click="$router.push('/forgot')">忘记密码</n-button>
</div> </div>
</n-form> </n-form>
</template> </template>
@@ -40,7 +42,12 @@ async function handleLogin() {
await auth.login(form.username, form.password) await auth.login(form.username, form.password)
message.success('登录成功!欢迎回到青叶 🌿') message.success('登录成功!欢迎回到青叶 🌿')
const redirect = (route.query.redirect as string) || '/chat' 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) { } catch (e: any) {
message.error(e.response?.data?.detail || '登录失败,请检查用户名和密码') message.error(e.response?.data?.detail || '登录失败,请检查用户名和密码')
} finally { } finally {
+294 -4
View File
@@ -88,8 +88,8 @@
pending: msg._pending, failed: msg._failed, pending: msg._pending, failed: msg._failed,
}"> }">
<div v-if="shouldShowTime(msg, idx)" class="time-divider">{{ formatTimeDivider(msg.created_at) }}</div> <div v-if="shouldShowTime(msg, idx)" class="time-divider">{{ formatTimeDivider(msg.created_at) }}</div>
<template v-if="msg.type === 'system'"> <template v-if="msg.type === 'system' || msg.is_recalled">
<div class="system-msg">{{ msg.content }}</div> <div class="system-msg">{{ msg.is_recalled ? '「' + (msg.sender_name || '对方') + '撤回了一条消息」' : msg.content }}</div>
</template> </template>
<template v-else> <template v-else>
<div v-if="msg.sender_id !== auth.user?.id" class="avatar-wrap"> <div v-if="msg.sender_id !== auth.user?.id" class="avatar-wrap">
@@ -106,11 +106,34 @@
</div> </div>
<n-image v-if="msg.type === 'image'" :src="msg.content" <n-image v-if="msg.type === 'image'" :src="msg.content"
:img-props="{ style: 'max-width:240px;border-radius:10px;display:block;cursor:pointer' }" /> :img-props="{ style: 'max-width:240px;border-radius:10px;display:block;cursor:pointer' }" />
<span v-else>{{ msg.content }}</span> <!-- 文件消息 -->
<div v-else-if="msg.type === 'file'" class="file-bubble" @click="openFile(msg.content)">
<span class="file-icon">📎</span>
<span class="file-info">
<span class="file-name">{{ parseFile(msg.content).name }}</span>
<span class="file-size" v-if="parseFile(msg.content).size">{{ formatFileSize(parseFile(msg.content).size) }}</span>
</span>
</div>
<!-- 语音消息 -->
<div v-else-if="msg.type === 'voice'" class="voice-bubble" @click="playVoice(msg)">
<span class="voice-icon">{{ playingVoiceId === msg.id ? '⏸' : '▶️' }}</span>
<div class="voice-wave">
<span v-for="i in parseVoice(msg.content).bars" :key="i" class="wave-bar" :style="{ height: (6 + (i*7)%18) + 'px' }"></span>
</div>
<span class="voice-dur">{{ parseVoice(msg.content).duration }}"</span>
</div>
<span v-else>{{ renderText(msg.content) }}</span>
</div>
<!-- 表情回应 -->
<div v-if="msg.reactions && msg.reactions.length" class="reactions-row">
<span v-for="(grp, emoji) in groupedReactions(msg.reactions)" :key="emoji" class="reaction-chip"
:class="{ mine: grp.mine }" @click="toggleReaction(msg, emoji)">
{{ emoji }} <span class="r-count">{{ grp.list.length }}</span>
</span>
</div> </div>
<div class="msg-meta"> <div class="msg-meta">
<span class="msg-time">{{ formatTime(msg.created_at) }}</span> <span class="msg-time">{{ formatTime(msg.created_at) }}</span>
<span v-if="msg.sender_id === auth.user?.id && !msg._pending" class="msg-status"></span> <span v-if="msg.sender_id === auth.user?.id && !msg._pending" class="msg-status">{{ msg.read_by_all ? '✓✓' : '✓' }}</span>
<span v-if="msg._pending && !msg._failed" class="msg-status sending">发送中</span> <span v-if="msg._pending && !msg._failed" class="msg-status sending">发送中</span>
<span v-if="msg._failed" class="msg-status failed">✕ 发送失败</span> <span v-if="msg._failed" class="msg-status failed">✕ 发送失败</span>
</div> </div>
@@ -124,9 +147,14 @@
<!-- 右键菜单 --> <!-- 右键菜单 -->
<div v-if="ctxMenu.show" class="ctx-menu" :style="{ top: ctxMenu.y + 'px', left: ctxMenu.x + 'px' }"> <div v-if="ctxMenu.show" class="ctx-menu" :style="{ top: ctxMenu.y + 'px', left: ctxMenu.x + 'px' }">
<!-- 表情回应栏 -->
<div class="ctx-reactions">
<span v-for="e in quickReactions" :key="e" class="ctx-react-emoji" @click="reactToMsg(e)">{{ e }}</span>
</div>
<div class="ctx-item" @click="replyToMsg">↩️ 回复</div> <div class="ctx-item" @click="replyToMsg">↩️ 回复</div>
<div class="ctx-item" @click="forwardMsg">↗️ 转发</div> <div class="ctx-item" @click="forwardMsg">↗️ 转发</div>
<div class="ctx-item" @click="copyMsg">📋 复制</div> <div class="ctx-item" @click="copyMsg">📋 复制</div>
<div v-if="ctxMenu.msg?.sender_id === auth.user?.id && !ctxMenu.msg?._pending && !ctxMenu.msg?.is_recalled" class="ctx-item" @click="recallMsg">↩️ 撤回</div>
<div v-if="ctxMenu.msg?.sender_id === auth.user?.id && !ctxMenu.msg?._pending" class="ctx-item danger" @click="deleteMsg">🗑️ 删除</div> <div v-if="ctxMenu.msg?.sender_id === auth.user?.id && !ctxMenu.msg?._pending" class="ctx-item danger" @click="deleteMsg">🗑️ 删除</div>
</div> </div>
@@ -176,7 +204,14 @@
<div class="input-actions"> <div class="input-actions">
<span class="action-icon" @click="showEmoji = !showEmoji">😊</span> <span class="action-icon" @click="showEmoji = !showEmoji">😊</span>
<span class="action-icon" @click="triggerImageUpload">🖼️</span> <span class="action-icon" @click="triggerImageUpload">🖼️</span>
<span class="action-icon" @click="triggerFileUpload" title="文件">📎</span>
<span class="action-icon" :class="{ recording: isRecording }" @click="toggleRecording" title="语音">{{ isRecording ? '⏹️' : '🎤' }}</span>
<input ref="imageInput" type="file" accept="image/*" style="display:none" @change="handleImageUpload" /> <input ref="imageInput" type="file" accept="image/*" style="display:none" @change="handleImageUpload" />
<input ref="fileInput" type="file" style="display:none" @change="handleFileUpload" />
</div>
<!-- 录音指示 -->
<div v-if="isRecording" class="recording-indicator">
<span class="rec-dot"></span> 录音中... {{ recordSeconds }}" 点击停止
</div> </div>
<div v-if="showEmoji" class="emoji-panel"> <div v-if="showEmoji" class="emoji-panel">
<div v-for="e in emojis" :key="e" class="emoji-item" @click="insertEmoji(e)">{{ e }}</div> <div v-for="e in emojis" :key="e" class="emoji-item" @click="insertEmoji(e)">{{ e }}</div>
@@ -233,6 +268,16 @@ const showForwardModal = ref(false)
const forwardTarget = ref<string | null>(null) const forwardTarget = ref<string | null>(null)
const forwardContent = ref('') const forwardContent = ref('')
const forwardSender = ref('') const forwardSender = ref('')
const fileInput = ref<HTMLInputElement>()
const isRecording = ref(false)
const recordSeconds = ref(0)
const playingVoiceId = ref<string | null>(null)
const quickReactions = ['👍', '❤️', '😂', '😮', '😢', '🙏']
let mediaRecorder: MediaRecorder | null = null
let recordedChunks: Blob[] = []
let recordTimer: ReturnType<typeof setInterval>
let recordStartTime = 0
let draftSaveTimer: ReturnType<typeof setTimeout>
const climate = ref<any>(null) const climate = ref<any>(null)
const calendar = ref<any[]>([]) const calendar = ref<any[]>([])
const showCalendar = ref(false) const showCalendar = ref(false)
@@ -294,6 +339,7 @@ onMounted(async () => {
if (id) { if (id) {
await chatStore.fetchMessages(id) await chatStore.fetchMessages(id)
await loadDetail() await loadDetail()
await loadDraft()
await nextTick() await nextTick()
scrollToBottom() scrollToBottom()
markRead() markRead()
@@ -301,10 +347,20 @@ onMounted(async () => {
loadGardenBg() loadGardenBg()
} }
document.addEventListener('click', closeCtxMenu) document.addEventListener('click', closeCtxMenu)
window.addEventListener('qingye:msg-recalled', onMsgRecalled)
window.addEventListener('qingye:reaction', onReaction)
window.addEventListener('qingye:mentioned', onMentioned)
window.addEventListener('qingye:read-receipt', onReadReceipt)
}) })
onUnmounted(() => { onUnmounted(() => {
document.removeEventListener('click', closeCtxMenu) document.removeEventListener('click', closeCtxMenu)
if (isRecording.value) stopRecording()
saveDraft()
window.removeEventListener('qingye:msg-recalled', onMsgRecalled)
window.removeEventListener('qingye:reaction', onReaction)
window.removeEventListener('qingye:mentioned', onMentioned)
window.removeEventListener('qingye:read-receipt', onReadReceipt)
}) })
async function loadDetail() { async function loadDetail() {
@@ -394,8 +450,47 @@ watch(inputText, () => {
lastTypingSent.value = now lastTypingSent.value = now
send('chat.typing', { conversation_id: convId }) send('chat.typing', { conversation_id: convId })
} }
saveDraft() // 顺便保存草稿
}) })
// 切换会话时加载草稿、清理录音
watch(() => route.params.id, async () => {
if (isRecording.value) stopRecording()
await loadDraft()
})
// 撤回 / 回应 / 提及 / 已读 事件
function onMsgRecalled(e: Event) {
const d = (e as CustomEvent).detail
const m = chatStore.currentMessages.find((m: any) => m.id === d.message_id)
if (m) m.is_recalled = true
}
function onReaction(e: Event) {
const d = (e as CustomEvent).detail
const m = chatStore.currentMessages.find((m: any) => m.id === d.message_id)
if (!m) return
m.reactions = m.reactions || []
if (d._phase === 'chat.reaction_added') {
if (!m.reactions.find((r: any) => r.user_id === d.user_id && r.emoji === d.emoji)) {
m.reactions.push({ emoji: d.emoji, user_id: d.user_id })
}
} else {
m.reactions = m.reactions.filter((r: any) => !(r.user_id === d.user_id && r.emoji === d.emoji))
}
}
function onMentioned(e: Event) {
const d = (e as CustomEvent).detail
message.info(`📢 ${d.from_username} @ 了你`)
}
function onReadReceipt(e: Event) {
const d = (e as CustomEvent).detail
if (d.user_id !== auth.user?.id) {
chatStore.currentMessages.forEach((m: any) => {
if (m.sender_id === auth.user?.id) m.read_by_all = true
})
}
}
function scrollToBottom() { function scrollToBottom() {
if (messageListRef.value) { if (messageListRef.value) {
messageListRef.value.scrollTop = messageListRef.value.scrollHeight messageListRef.value.scrollTop = messageListRef.value.scrollHeight
@@ -492,6 +587,161 @@ async function doForward() {
forwardTarget.value = null forwardTarget.value = null
} }
// ===== 撤回 =====
async function recallMsg() {
if (!ctxMenu.msg || !chatStore.activeConversation) return
try {
await chatApi.recallMessage(chatStore.activeConversation, ctxMenu.msg.id)
message.success('已撤回')
} catch (e: any) {
message.error(e.response?.data?.detail || '撤回失败')
}
ctxMenu.show = false
}
// ===== 表情回应 =====
async function reactToMsg(emoji: string) {
if (!ctxMenu.msg || !chatStore.activeConversation) return
try {
await chatApi.reactMessage(chatStore.activeConversation, ctxMenu.msg.id, emoji)
} catch {}
ctxMenu.show = false
}
async function toggleReaction(msg: any, emoji: string) {
if (!chatStore.activeConversation) return
try {
await chatApi.reactMessage(chatStore.activeConversation, msg.id, emoji)
} catch {}
}
function groupedReactions(reactions: any[]): Record<string, { list: any[]; mine: boolean }> {
const groups: Record<string, { list: any[]; mine: boolean }> = {}
for (const r of reactions) {
if (!groups[r.emoji]) groups[r.emoji] = { list: [], mine: false }
groups[r.emoji].list.push(r)
if (r.user_id === auth.user?.id) groups[r.emoji].mine = true
}
return groups
}
// ===== 文件消息 =====
function triggerFileUpload() { fileInput.value?.click() }
async function handleFileUpload(event: Event) {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
if (!file || !chatStore.activeConversation) return
try {
const formData = new FormData()
formData.append('file', file)
const { data } = await api.post('/uploads/file', formData, { headers: { 'Content-Type': 'multipart/form-data' } })
const payload = JSON.stringify({ name: file.name, size: file.size, url: data.url })
send('chat.send', { conversation_id: chatStore.activeConversation, content: payload, type: 'file' })
} catch { message.error('上传失败') }
target.value = ''
}
function parseFile(content: string) {
try { return JSON.parse(content) } catch { return { name: '文件', size: 0, url: content } }
}
function formatFileSize(bytes: number): string {
if (!bytes) return ''
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / 1024 / 1024).toFixed(1) + ' MB'
}
function openFile(content: string) {
const f = parseFile(content)
if (f.url) window.open(f.url, '_blank')
}
// ===== 语音消息 =====
async function toggleRecording() {
if (isRecording.value) { stopRecording() }
else { await startRecording() }
}
async function startRecording() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
mediaRecorder = new MediaRecorder(stream)
recordedChunks = []
mediaRecorder.ondataavailable = (e) => { if (e.data.size > 0) recordedChunks.push(e.data) }
mediaRecorder.onstop = () => { uploadVoice() }
mediaRecorder.start()
isRecording.value = true
recordStartTime = Date.now()
recordSeconds.value = 0
recordTimer = setInterval(() => {
recordSeconds.value = Math.floor((Date.now() - recordStartTime) / 1000)
if (recordSeconds.value >= 60) stopRecording()
}, 1000)
} catch { message.error('无法访问麦克风') }
}
function stopRecording() {
if (mediaRecorder && mediaRecorder.state !== 'inactive') mediaRecorder.stop()
isRecording.value = false
clearInterval(recordTimer)
}
async function uploadVoice() {
if (!chatStore.activeConversation || recordedChunks.length === 0) return
const duration = recordSeconds.value || 1
const blob = new Blob(recordedChunks, { type: 'audio/webm' })
const formData = new FormData()
formData.append('file', blob, 'voice.webm')
try {
const { data } = await api.post('/uploads/file', formData, { headers: { 'Content-Type': 'multipart/form-data' } })
const bars = Math.min(30, Math.max(8, Math.floor(duration * 2)))
const payload = JSON.stringify({ duration, url: data.url, bars })
send('chat.send', { conversation_id: chatStore.activeConversation, content: payload, type: 'voice' })
} catch { message.error('语音上传失败') }
}
function parseVoice(content: string) {
try { return JSON.parse(content) } catch { return { duration: 0, url: '', bars: 15 } }
}
let currentAudio: HTMLAudioElement | null = null
function playVoice(msg: any) {
const v = parseVoice(msg.content)
if (!v.url) return
if (playingVoiceId.value === msg.id) {
currentAudio?.pause()
playingVoiceId.value = null
return
}
if (currentAudio) { currentAudio.pause() }
currentAudio = new Audio(v.url)
currentAudio.onended = () => { playingVoiceId.value = null }
currentAudio.play()
playingVoiceId.value = msg.id
}
// ===== 草稿 =====
async function loadDraft() {
if (!chatStore.activeConversation) return
try {
const { data } = await chatApi.getDraft(chatStore.activeConversation)
inputText.value = data.draft || ''
} catch {}
}
function saveDraft() {
if (!chatStore.activeConversation) return
clearTimeout(draftSaveTimer)
draftSaveTimer = setTimeout(() => {
chatApi.saveDraft(chatStore.activeConversation!, inputText.value).catch(() => {})
}, 800)
}
// ===== 文本渲染(@高亮)=====
function renderText(content: string): string {
return content
}
function scrollToMsg(msgId: string) { function scrollToMsg(msgId: string) {
const el = messageListRef.value const el = messageListRef.value
if (!el) return if (!el) return
@@ -661,6 +911,46 @@ function formatTimeDivider(time: string) {
.msg-status.sending { color: var(--color-text-hint); } .msg-status.sending { color: var(--color-text-hint); }
.msg-status.failed { color: #EF5350; font-weight: 500; } .msg-status.failed { color: #EF5350; font-weight: 500; }
.msg-image { max-width: 240px; border-radius: 10px; display: block; cursor: pointer; } .msg-image { max-width: 240px; border-radius: 10px; display: block; cursor: pointer; }
/* 文件消息 */
.file-bubble { display: flex; align-items: center; gap: 10px; cursor: pointer; min-width: 180px; padding: 4px 0; }
.file-icon { font-size: 32px; }
.file-info { display: flex; flex-direction: column; }
.file-name { font-size: 14px; font-weight: 500; max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-size { font-size: 11px; opacity: 0.7; }
/* 语音消息 */
.voice-bubble { display: flex; align-items: center; gap: 8px; cursor: pointer; min-width: 140px; padding: 2px 0; }
.voice-icon { font-size: 18px; }
.voice-wave { display: flex; align-items: center; gap: 2px; height: 24px; }
.wave-bar { width: 3px; background: currentColor; opacity: 0.6; border-radius: 2px; }
.voice-dur { font-size: 12px; opacity: 0.8; }
/* 表情回应 */
.reactions-row { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px; }
.reaction-chip {
display: inline-flex; align-items: center; gap: 2px; padding: 2px 8px; font-size: 13px;
background: var(--color-surface); border: 1px solid var(--color-border); border-radius: 12px;
cursor: pointer; transition: all 0.15s;
}
.reaction-chip:hover { border-color: var(--color-primary); }
.reaction-chip.mine { background: var(--color-primary-lightest); border-color: var(--color-primary-lighter); }
.r-count { font-size: 11px; color: var(--color-text-secondary); }
/* 右键菜单表情栏 */
.ctx-reactions { display: flex; gap: 6px; padding: 8px 12px; border-bottom: 1px solid var(--color-border); }
.ctx-react-emoji { font-size: 20px; cursor: pointer; padding: 2px; border-radius: 6px; transition: background 0.15s; }
.ctx-react-emoji:hover { background: var(--color-primary-lightest); }
/* 录音 */
.action-icon.recording { color: #EF5350; opacity: 1; animation: rec-pulse 1s infinite; }
@keyframes rec-pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
.recording-indicator {
position: absolute; bottom: 60px; left: 50%; transform: translateX(-50%);
background: rgba(239,83,80,0.95); color: white; padding: 6px 16px; border-radius: 16px;
font-size: 13px; display: flex; align-items: center; gap: 6px; z-index: 100;
}
.rec-dot { width: 8px; height: 8px; background: white; border-radius: 50%; animation: rec-pulse 1s infinite; }
.system-msg { text-align: center; font-size: 12px; color: var(--color-text-hint); margin: 12px 0; padding: 4px 16px; background: rgba(0,0,0,0.03); border-radius: 12px; display: inline-block; } .system-msg { text-align: center; font-size: 12px; color: var(--color-text-hint); margin: 12px 0; padding: 4px 16px; background: rgba(0,0,0,0.03); border-radius: 12px; display: inline-block; }
/* Reply quote inside bubble */ /* Reply quote inside bubble */
@@ -27,8 +27,8 @@
</div> </div>
<div v-else> <div v-else>
<div v-for="conv in filteredConversations" :key="conv.id" <div v-for="conv in filteredConversations" :key="conv.id"
class="conv-item" :class="{ active: chatStore.activeConversation === conv.id }" class="conv-item" :class="{ active: chatStore.activeConversation === conv.id, pinned: conv.is_pinned }"
@click="openChat(conv.id)"> @click="openChat(conv.id)" @contextmenu.prevent="showConvMenu($event, conv)">
<div class="conv-avatar-wrap"> <div class="conv-avatar-wrap">
<n-avatar :size="48" round :style="avatarStyle(conv)"> <n-avatar :size="48" round :style="avatarStyle(conv)">
{{ (conv.name || '?')[0] }} {{ (conv.name || '?')[0] }}
@@ -38,33 +38,48 @@
</div> </div>
<div class="conv-info"> <div class="conv-info">
<div class="conv-top"> <div class="conv-top">
<span class="conv-name">{{ conv.name || '未命名' }}</span> <span class="conv-name">{{ conv.name || '未命名' }}<span v-if="conv.is_muted" class="mute-icon">🔕</span></span>
<span class="conv-time">{{ formatTime(conv.last_message_at) }}</span> <span class="conv-time">{{ formatTime(conv.last_message_at) }}</span>
</div> </div>
<div class="conv-bottom"> <div class="conv-bottom">
<span class="conv-preview">{{ conv.last_message_preview || '暂无消息' }}</span> <span class="conv-preview">{{ conv.last_message_preview || '暂无消息' }}</span>
<span v-if="conv.is_pinned" class="pin-icon">📌</span>
<span v-if="conv.unread_count > 0" class="unread-badge">{{ conv.unread_count > 99 ? '99+' : conv.unread_count }}</span> <span v-if="conv.unread_count > 0" class="unread-badge">{{ conv.unread_count > 99 ? '99+' : conv.unread_count }}</span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- 会话右键菜单 -->
<div v-if="convMenu.show" class="conv-menu" :style="{ top: convMenu.y + 'px', left: convMenu.x + 'px' }">
<div class="conv-menu-item" @click="togglePin">
{{ convMenu.conv?.is_pinned ? '取消置顶' : '置顶' }}
</div>
<div class="conv-menu-item" @click="toggleMute">
{{ convMenu.conv?.is_muted ? '取消免打扰' : '免打扰' }}
</div>
</div>
<!-- 创建群聊弹窗 --> <!-- 创建群聊弹窗 -->
<CreateGroupModal :visible="showCreateGroup" @close="showCreateGroup = false" @created="onGroupCreated" /> <CreateGroupModal :visible="showCreateGroup" @close="showCreateGroup = false" @created="onGroupCreated" />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed, reactive, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useMessage } from 'naive-ui'
import { useChatStore } from '@/stores/chat' import { useChatStore } from '@/stores/chat'
import { chatApi } from '@/api/chat'
import CreateGroupModal from './CreateGroupModal.vue' import CreateGroupModal from './CreateGroupModal.vue'
import dayjs from 'dayjs' import dayjs from 'dayjs'
const router = useRouter() const router = useRouter()
const message = useMessage()
const chatStore = useChatStore() const chatStore = useChatStore()
const searchKeyword = ref('') const searchKeyword = ref('')
const showCreateGroup = ref(false) const showCreateGroup = ref(false)
const convMenu = reactive({ show: false, x: 0, y: 0, conv: null as any })
const filteredConversations = computed(() => { const filteredConversations = computed(() => {
if (!searchKeyword.value) return chatStore.conversations if (!searchKeyword.value) return chatStore.conversations
@@ -85,6 +100,41 @@ function openChat(id: string) {
router.push(`/chat/${id}`) router.push(`/chat/${id}`)
} }
function showConvMenu(e: MouseEvent, conv: any) {
convMenu.show = true
convMenu.x = Math.min(e.clientX, window.innerWidth - 150)
convMenu.y = Math.min(e.clientY, window.innerHeight - 100)
convMenu.conv = conv
}
function closeConvMenu() { convMenu.show = false }
async function togglePin() {
if (!convMenu.conv) return
const newVal = !convMenu.conv.is_pinned
try {
await chatApi.updatePrefs(convMenu.conv.id, { is_pinned: newVal })
convMenu.conv.is_pinned = newVal
await chatStore.fetchConversations()
message.success(newVal ? '已置顶' : '已取消置顶')
} catch { message.error('操作失败') }
closeConvMenu()
}
async function toggleMute() {
if (!convMenu.conv) return
const newVal = !convMenu.conv.is_muted
try {
await chatApi.updatePrefs(convMenu.conv.id, { is_muted: newVal })
convMenu.conv.is_muted = newVal
message.success(newVal ? '已开启免打扰' : '已关闭免打扰')
} catch { message.error('操作失败') }
closeConvMenu()
}
onMounted(() => document.addEventListener('click', closeConvMenu))
onUnmounted(() => document.removeEventListener('click', closeConvMenu))
async function onGroupCreated() { async function onGroupCreated() {
await chatStore.fetchConversations() await chatStore.fetchConversations()
} }
@@ -101,6 +151,18 @@ function formatTime(time: string | null) {
</script> </script>
<style scoped> <style scoped>
.conv-item.pinned { background: var(--color-primary-lightest); }
.mute-icon { font-size: 12px; margin-left: 4px; opacity: 0.6; }
.pin-icon { font-size: 12px; opacity: 0.7; }
/* 会话右键菜单 */
.conv-menu {
position: fixed; background: var(--color-surface); border: 1px solid var(--color-border);
border-radius: 8px; box-shadow: 0 4px 16px rgba(0,0,0,0.12); z-index: 999; min-width: 130px; overflow: hidden;
}
.conv-menu-item { padding: 9px 16px; font-size: 13px; cursor: pointer; transition: background 0.15s; }
.conv-menu-item:hover { background: var(--color-primary-lightest); }
.conv-list-panel { .conv-list-panel {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -30,6 +30,21 @@
</div> </div>
</div> </div>
<!-- 群公告 -->
<div v-if="isAdmin" class="section">
<div class="section-header"><span>📢 群公告</span></div>
<n-input v-model:value="announcement" type="textarea" :rows="2" placeholder="发布群公告..." size="small" />
<n-button size="tiny" type="primary" style="margin-top: 6px" @click="saveAnnouncement">发布公告</n-button>
</div>
<!-- 全员禁言管理员 -->
<div v-if="isAdmin" class="section">
<div class="mute-all-row">
<span>🤐 全员禁言</span>
<n-switch :value="detail.mute_all" @update:value="toggleMuteAll" />
</div>
</div>
<!-- 成员列表 --> <!-- 成员列表 -->
<div class="section"> <div class="section">
<div class="section-header"> <div class="section-header">
@@ -110,6 +125,7 @@ const friends = ref<any[]>([])
const selectedNewMembers = ref<string[]>([]) const selectedNewMembers = ref<string[]>([])
const addingMembers = ref(false) const addingMembers = ref(false)
const avatarInput = ref<HTMLInputElement>() const avatarInput = ref<HTMLInputElement>()
const announcement = ref('')
const myRole = computed(() => { const myRole = computed(() => {
const me = props.detail?.members?.find((m: any) => m.user_id === auth.user?.id) const me = props.detail?.members?.find((m: any) => m.user_id === auth.user?.id)
@@ -184,6 +200,35 @@ async function handleAvatarUpload(event: Event) {
target.value = '' target.value = ''
} }
async function saveAnnouncement() {
if (!announcement.value.trim()) return
try {
await api.post(`/conversations/${props.conversationId}/announcement`, { content: announcement.value.trim() })
message.success('公告已发布')
emit('updated')
} catch (e: any) {
message.error(e.response?.data?.detail || '发布失败')
}
}
async function toggleMuteAll(val: boolean) {
try {
await api.put(`/conversations/${props.conversationId}/mute-all`, { mute_all: val })
message.success(val ? '已开启全员禁言' : '已关闭全员禁言')
emit('updated')
} catch (e: any) {
message.error(e.response?.data?.detail || '操作失败')
}
}
// 加载现有公告
watch(() => props.detail, async () => {
try {
const { data } = await api.get(`/conversations/${props.conversationId}/announcement`)
if (data) announcement.value = data.content || ''
} catch {}
}, { immediate: true })
function roleLabel(role: string) { function roleLabel(role: string) {
if (role === 'owner') return '群主' if (role === 'owner') return '群主'
if (role === 'admin') return '管理员' if (role === 'admin') return '管理员'
@@ -276,6 +321,7 @@ async function dissolveGroup() {
.member-role.owner { background: #FFF3E0; color: #F57C00; } .member-role.owner { background: #FFF3E0; color: #F57C00; }
.member-role.admin { background: #E3F2FD; color: #1976D2; } .member-role.admin { background: #E3F2FD; color: #1976D2; }
.actions { padding-top: 12px; border-top: 1px solid var(--color-border); } .actions { padding-top: 12px; border-top: 1px solid var(--color-border); }
.mute-all-row { display: flex; align-items: center; justify-content: space-between; font-size: 13px; padding: 6px 0; }
/* Add member modal */ /* Add member modal */
.modal-overlay { .modal-overlay {
@@ -38,6 +38,7 @@
🍃 想念 TA寄一片回音叶 🍃 想念 TA寄一片回音叶
</n-button> </n-button>
<n-button type="error" ghost block @click="handleRemove">删除好友</n-button> <n-button type="error" ghost block @click="handleRemove">删除好友</n-button>
<n-button quaternary type="error" block @click="blockUser">🚫 拉黑</n-button>
</div> </div>
</div> </div>
</div> </div>
@@ -88,6 +89,17 @@ async function sendEcho() {
} }
} }
async function blockUser() {
if (!confirm('确定拉黑该用户?拉黑后双方无法互相发消息。')) return
try {
await api.post(`/users/${props.friend.friend_user_id}/block`)
message.success('已拉黑')
emit('close')
} catch (e: any) {
message.error(e.response?.data?.detail || '操作失败')
}
}
async function saveRemark() { async function saveRemark() {
try { try {
await friendsApi.updateRemark(props.friend.friend_user_id, remark.value || null) await friendsApi.updateRemark(props.friend.friend_user_id, remark.value || null)
@@ -35,6 +35,33 @@
</div> </div>
<n-button type="primary" :loading="saving" @click="saveProfile">保存修改</n-button> <n-button type="primary" :loading="saving" @click="saveProfile">保存修改</n-button>
</div> </div>
<!-- 个人心情状态 -->
<div class="form-section">
<h3 class="section-title">🎭 个人状态</h3>
<p class="section-desc">设置一个心情状态好友在聊天和资料卡里能看到可选过期时间</p>
<div class="status-row">
<n-input-group>
<n-input v-model:value="statusForm.emoji" placeholder="🎉" size="small" style="max-width: 70px" />
<n-input v-model:value="statusForm.text" placeholder="如:在听歌 / 码代码中..." size="small" />
</n-input-group>
</div>
<div class="status-row">
<n-select v-model:value="statusForm.expires" :options="expireOptions" size="small" style="max-width: 200px" />
<n-button type="primary" size="small" @click="saveStatus">设置状态</n-button>
<n-button quaternary size="small" @click="clearStatus">清除</n-button>
</div>
</div>
<!-- 黑名单 -->
<div class="form-section">
<h3 class="section-title">🚫 黑名单管理</h3>
<div v-if="blocks.length === 0" class="empty-block">还没有拉黑任何人</div>
<div v-for="b in blocks" :key="b.user_id" class="block-item">
<span>{{ b.nickname || b.username }}</span>
<n-button size="tiny" quaternary type="error" @click="unblock(b.user_id)">解除</n-button>
</div>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -57,11 +84,55 @@ const form = reactive({
bio: auth.user?.bio || '', bio: auth.user?.bio || '',
}) })
const statusForm = reactive({ emoji: '😊', text: '', expires: 0 })
const expireOptions = [
{ label: '不过期', value: 0 },
{ label: '1 小时', value: 1 },
{ label: '今天内', value: 24 },
{ label: '3 天', value: 72 },
]
const blocks = ref<any[]>([])
onMounted(() => { onMounted(() => {
form.nickname = auth.user?.nickname || '' form.nickname = auth.user?.nickname || ''
form.bio = auth.user?.bio || '' form.bio = auth.user?.bio || ''
loadBlocks()
}) })
async function loadBlocks() {
try {
const { data } = await api.get('/users/me/blocks')
blocks.value = data
} catch {}
}
async function saveStatus() {
try {
await api.put('/users/me/status', {
custom_status: statusForm.text || null,
status_emoji: statusForm.emoji || null,
expires_hours: statusForm.expires || null,
})
message.success('状态已更新')
} catch { message.error('设置失败') }
}
async function clearStatus() {
try {
await api.put('/users/me/status', { custom_status: null, status_emoji: null, expires_hours: null })
statusForm.text = ''
message.success('已清除')
} catch {}
}
async function unblock(userId: string) {
try {
await api.delete(`/users/${userId}/block`)
blocks.value = blocks.value.filter((b) => b.user_id !== userId)
message.success('已解除拉黑')
} catch {}
}
function triggerUpload() { function triggerUpload() {
fileInput.value?.click() fileInput.value?.click()
} }
@@ -115,4 +186,9 @@ async function saveProfile() {
} }
.form-value { font-size: 14px; color: var(--color-text-primary); } .form-value { font-size: 14px; color: var(--color-text-primary); }
.form-hint { font-size: 12px; color: var(--color-text-hint); } .form-hint { font-size: 12px; color: var(--color-text-hint); }
.section-title { font-size: 15px; margin: 0 0 4px; color: var(--color-text-primary); }
.section-desc { font-size: 12px; color: var(--color-text-hint); margin: 0 0 12px; }
.status-row { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.empty-block { font-size: 13px; color: var(--color-text-hint); padding: 12px 0; }
.block-item { display: flex; align-items: center; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid var(--color-border); font-size: 14px; }
</style> </style>
+5 -1
View File
@@ -40,4 +40,8 @@
给我一个其他人用青叶而不是微信QQ的理由,没有就继续改进 给我一个其他人用青叶而不是微信QQ的理由,没有就继续改进
把其他社交软件有的功能补全 把其他社交软件有的功能补全
无法登入
运行时错误: Promise: Failed to fetch dynamically imported module: http://localhost:5173/src/views/chat/ConversationListPanel.vue,同时弹出登录成功和失败