1.9
This commit is contained in:
@@ -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,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())
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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"))
|
||||||
@@ -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])
|
||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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])
|
||||||
@@ -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])
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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="验证码无效或已过期")
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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)))
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)})
|
||||||
|
|
||||||
|
|||||||
@@ -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 } }),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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':
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user