首个可运行的版本

This commit is contained in:
2026-06-12 23:14:12 +08:00
commit b3d90c65f8
86 changed files with 4808 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
# ============================================
# 青叶 (QingYe) - 环境变量配置
# 复制此文件为 .env 并根据实际情况修改
# ============================================
# 后端
DATABASE_URL=postgresql+asyncpg://qingye:qingye_secret@postgres:5432/qingye
REDIS_URL=redis://redis:6379/0
JWT_SECRET_KEY=qingye-jwt-secret-change-in-production
JWT_REFRESH_SECRET_KEY=qingye-refresh-secret-change-in-production
CORS_ORIGINS=http://localhost:5173
ADMIN_PASSWORD=admin123
# 前端
VITE_API_BASE_URL=http://localhost:8000
VITE_WS_BASE_URL=ws://localhost:8000
+111
View File
@@ -0,0 +1,111 @@
# ============================================
# 青叶 (QingYe) .gitignore
# 技术栈: Python FastAPI + Vue 3 + Docker
# ============================================
# ---------------------
# 依赖目录
# ---------------------
node_modules/
__pycache__/
*.pyc
*.pyo
*.pyd
# ---------------------
# 构建产物
# ---------------------
dist/
build/
*.egg-info/
*.egg
# ---------------------
# 环境变量
# ---------------------
.env
.env.local
.env.*.local
.env.production
# ---------------------
# Python 虚拟环境
# ---------------------
.venv/
venv/
env/
.Python
# ---------------------
# Python 工具
# ---------------------
*.pyc
*.pyo
.mypy_cache/
.ruff_cache/
.pytest_cache/
htmlcov/
.coverage
coverage/
# ---------------------
# Node.js 工具
# ---------------------
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# ---------------------
# 前端产物
# ---------------------
frontend/dist/
frontend/node_modules/
# ---------------------
# 后端产物
# ---------------------
backend/dist/
backend/uploads/
# ---------------------
# Alembic 迁移(按需取消注释)
# ---------------------
# backend/alembic/versions/
# ---------------------
# Docker 数据卷
# ---------------------
postgres_data/
redis_data/
# ---------------------
# IDE / 编辑器
# ---------------------
.vscode/
.idea/
*.swp
*.swo
*~
.project
.classpath
.settings/
# ---------------------
# 操作系统
# ---------------------
.DS_Store
Thumbs.db
Desktop.ini
ehthumbs.db
# ---------------------
# 日志
# ---------------------
*.log
logs/
# ---------------------
# Claude Code
# ---------------------
.claude/
+27
View File
@@ -0,0 +1,27 @@
FROM python:3.12-slim
WORKDIR /app
# 使用国内 Debian 镜像源加速
RUN sed -i 's|deb.debian.org|mirrors.tuna.tsinghua.edu.cn|g' /etc/apt/sources.list.d/debian.sources
# 安装系统依赖
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
# 安装 Python 依赖
COPY requirements.txt .
RUN pip install --no-cache-dir -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt
# 复制项目代码
COPY . .
# 创建上传目录
RUN mkdir -p /app/uploads
EXPOSE 8000
# 开发模式:热重载
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
View File
+32
View File
@@ -0,0 +1,32 @@
"""应用配置 - 通过环境变量读取"""
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
# 数据库
DATABASE_URL: str = "postgresql+asyncpg://qingye:qingye_secret@postgres:5432/qingye"
# Redis
REDIS_URL: str = "redis://redis:6379/0"
# JWT
JWT_SECRET_KEY: str = "qingye-jwt-secret-change-in-production"
JWT_REFRESH_SECRET_KEY: str = "qingye-refresh-secret-change-in-production"
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = 7
# CORS
CORS_ORIGINS: str = "http://localhost:5173"
# 管理员默认密码
ADMIN_PASSWORD: str = "admin123"
# 上传
MAX_UPLOAD_SIZE_MB: int = 10
UPLOAD_DIR: str = "/app/uploads"
model_config = {"env_file": ".env", "extra": "ignore"}
settings = Settings()
+26
View File
@@ -0,0 +1,26 @@
"""异步数据库引擎和会话管理"""
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from app.config import settings
# 异步引擎
engine = create_async_engine(
settings.DATABASE_URL,
echo=False,
pool_size=20,
max_overflow=10,
)
# 异步会话工厂
async_session = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
# 声明式基类
class Base(DeclarativeBase):
pass
+96
View File
@@ -0,0 +1,96 @@
"""FastAPI 公共依赖"""
from typing import AsyncGenerator
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.ext.asyncio import AsyncSession
import redis.asyncio as redis
from app.config import settings
from app.database import async_session
from app.utils.security import decode_access_token
# HTTP Bearer 认证
security = HTTPBearer()
# Redis 连接池
_redis_pool: redis.Redis | None = None
async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""获取数据库会话"""
async with async_session() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
async def get_redis() -> redis.Redis:
"""获取 Redis 连接"""
global _redis_pool
if _redis_pool is None:
_redis_pool = redis.from_url(settings.REDIS_URL, decode_responses=True)
return _redis_pool
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: AsyncSession = Depends(get_db),
):
"""获取当前认证用户"""
token = credentials.credentials
payload = decode_access_token(token)
if payload is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="无效或过期的 Token",
)
user_id = payload.get("sub")
if user_id is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="无效的 Token 格式",
)
from app.services.user_service import UserService
user_service = UserService(db)
user = await user_service.get_by_id(user_id)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户不存在",
)
if user.is_banned:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="账号已被封禁",
)
return user
async def get_admin_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: AsyncSession = Depends(get_db),
):
"""获取管理员用户(需要 is_admin=True"""
token = credentials.credentials
payload = decode_access_token(token)
if payload is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="无效或过期的 Token",
)
if not payload.get("is_admin"):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="需要管理员权限",
)
return payload
+84
View File
@@ -0,0 +1,84 @@
"""青叶 - FastAPI 应用入口"""
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from app.config import settings
import os
@asynccontextmanager
async def lifespan(app: FastAPI):
"""应用生命周期:启动和关闭"""
# 启动时
print("🌿 青叶后端启动中...")
# 确保上传目录存在
os.makedirs(settings.UPLOAD_DIR, exist_ok=True)
# 初始化数据库表(开发阶段用,生产用 Alembic 迁移)
from app.database import engine, Base
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
print("📦 数据库表已创建")
# 初始化系统配置
from app.database import async_session
async with async_session() as db:
from app.services.admin_service import AdminService
admin_service = AdminService(db)
await admin_service.init_system_config()
await db.commit()
print("⚙️ 系统配置已初始化")
print("🚀 青叶后端启动完成!")
yield
# 关闭时
print("🌿 青叶后端关闭")
from app.database import engine
await engine.dispose()
app = FastAPI(
title="青叶 - QingYe",
description="青叶社交聊天应用后端 API",
version="0.1.0",
lifespan=lifespan,
)
# CORS 中间件
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS.split(","),
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 静态文件(上传的文件)
app.mount("/uploads", StaticFiles(directory=settings.UPLOAD_DIR), name="uploads")
# 注册路由
from app.routers import auth, users, conversations, messages, friends, admin, uploads
app.include_router(auth.router, prefix="/api/v1/auth", tags=["认证"])
app.include_router(users.router, prefix="/api/v1/users", tags=["用户"])
app.include_router(conversations.router, prefix="/api/v1/conversations", tags=["会话"])
app.include_router(messages.router, prefix="/api/v1/conversations", tags=["消息"])
app.include_router(friends.router, prefix="/api/v1/friends", tags=["好友"])
app.include_router(admin.router, prefix="/api/v1/admin", tags=["管理"])
app.include_router(uploads.router, prefix="/api/v1/uploads", tags=["上传"])
# WebSocket
from app.websocket.router import websocket_router
app.include_router(websocket_router)
@app.get("/")
async def root():
return {"name": "青叶 QingYe", "version": "0.1.0", "status": "running"}
+19
View File
@@ -0,0 +1,19 @@
"""SQLAlchemy 模型包 - 导入所有模型供 Alembic 自动检测"""
from app.models.user import User
from app.models.conversation import Conversation
from app.models.conversation_member import ConversationMember
from app.models.message import Message
from app.models.friend import Friend
from app.models.friend_request import FriendRequest
from app.models.system_config import SystemConfig
__all__ = [
"User",
"Conversation",
"ConversationMember",
"Message",
"Friend",
"FriendRequest",
"SystemConfig",
]
+32
View File
@@ -0,0 +1,32 @@
"""会话模型(私聊 + 群聊)"""
from datetime import datetime, timezone
from sqlalchemy import String, DateTime, ForeignKey, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class Conversation(Base):
__tablename__ = "conversations"
id: Mapped[str] = mapped_column(String(36), primary_key=True)
type: Mapped[str] = mapped_column(String(20), nullable=False) # private / group
name: Mapped[str | None] = mapped_column(String(100), nullable=True) # 群聊名称
avatar_url: Mapped[str | None] = mapped_column(String(500), nullable=True) # 群头像
description: Mapped[str | None] = mapped_column(String(500), nullable=True) # 群描述
creator_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
last_message_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
last_message_preview: Mapped[str | None] = mapped_column(String(200), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.utcnow())
updated_at: Mapped[datetime] = mapped_column(
DateTime,
default=lambda: datetime.utcnow(),
onupdate=lambda: datetime.utcnow(),
)
# 关系
members = relationship("ConversationMember", back_populates="conversation", cascade="all, delete-orphan")
messages = relationship("Message", back_populates="conversation", cascade="all, delete-orphan")
creator = relationship("User", foreign_keys=[creator_id])
+32
View File
@@ -0,0 +1,32 @@
"""会话成员模型"""
from datetime import datetime, timezone
from sqlalchemy import String, DateTime, ForeignKey, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class ConversationMember(Base):
__tablename__ = "conversation_members"
__table_args__ = (
UniqueConstraint("conversation_id", "user_id", name="uq_conv_user"),
)
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
)
user_id: Mapped[str] = mapped_column(
String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
role: Mapped[str] = mapped_column(String(20), default="member") # owner / admin / member
nickname: Mapped[str | None] = mapped_column(String(50), nullable=True) # 群内昵称
last_read_message_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
joined_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.utcnow())
left_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
# 关系
conversation = relationship("Conversation", back_populates="members")
user = relationship("User", back_populates="conversations")
+29
View File
@@ -0,0 +1,29 @@
"""好友关系模型"""
from datetime import datetime, timezone
from sqlalchemy import String, DateTime, ForeignKey, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class Friend(Base):
__tablename__ = "friends"
__table_args__ = (
UniqueConstraint("user_id", "friend_user_id", name="uq_friendship"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True)
user_id: Mapped[str] = mapped_column(
String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
friend_user_id: Mapped[str] = mapped_column(
String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
remark: Mapped[str | None] = mapped_column(String(50), nullable=True) # 好友备注
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.utcnow())
# 关系
user = relationship("User", foreign_keys=[user_id], back_populates="friends")
friend_user = relationship("User", foreign_keys=[friend_user_id], back_populates="friend_of")
+28
View File
@@ -0,0 +1,28 @@
"""好友请求模型"""
from datetime import datetime, timezone
from sqlalchemy import String, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class FriendRequest(Base):
__tablename__ = "friend_requests"
id: Mapped[str] = mapped_column(String(36), primary_key=True)
from_user_id: Mapped[str] = mapped_column(
String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
to_user_id: Mapped[str] = mapped_column(
String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
message: Mapped[str | None] = mapped_column(String(200), nullable=True) # 验证消息
status: Mapped[str] = mapped_column(String(20), default="pending") # pending / accepted / rejected
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.utcnow())
responded_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
# 关系
from_user = relationship("User", foreign_keys=[from_user_id], back_populates="sent_requests")
to_user = relationship("User", foreign_keys=[to_user_id], back_populates="received_requests")
+37
View File
@@ -0,0 +1,37 @@
"""消息模型"""
from datetime import datetime, timezone
from sqlalchemy import String, Text, Boolean, DateTime, ForeignKey, Index
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class Message(Base):
__tablename__ = "messages"
__table_args__ = (
Index("ix_messages_conv_created", "conversation_id", "created_at"),
)
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
)
sender_id: Mapped[str] = mapped_column(
String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
type: Mapped[str] = mapped_column(String(20), default="text") # text / image / file / system
content: Mapped[str] = mapped_column(Text, nullable=False)
reply_to_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("messages.id", ondelete="SET NULL"), nullable=True
)
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(
DateTime, default=lambda: datetime.utcnow(), index=True
)
# 关系
conversation = relationship("Conversation", back_populates="messages")
sender = relationship("User", back_populates="sent_messages", foreign_keys=[sender_id])
reply_to = relationship("Message", remote_side="Message.id")
+24
View File
@@ -0,0 +1,24 @@
"""系统配置模型"""
from datetime import datetime, timezone
from sqlalchemy import String, Text, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class SystemConfig(Base):
__tablename__ = "system_configs"
id: Mapped[str] = mapped_column(String(36), primary_key=True)
key: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
value: Mapped[str] = mapped_column(Text, nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime,
default=lambda: datetime.utcnow(),
onupdate=lambda: datetime.utcnow(),
)
updated_by: Mapped[str | None] = mapped_column(
String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
+38
View File
@@ -0,0 +1,38 @@
"""用户模型"""
from datetime import datetime, timezone
from sqlalchemy import String, Boolean, DateTime, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class User(Base):
__tablename__ = "users"
id: Mapped[str] = mapped_column(String(36), primary_key=True)
username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
avatar_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
bio: Mapped[str | None] = mapped_column(String(200), nullable=True)
status: Mapped[str] = mapped_column(String(20), default="offline") # online/offline/away
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
is_banned: Mapped[bool] = mapped_column(Boolean, default=False)
banned_reason: Mapped[str | None] = mapped_column(String(500), nullable=True)
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
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(),
)
# 关系
sent_messages = relationship("Message", back_populates="sender", foreign_keys="Message.sender_id")
conversations = relationship("ConversationMember", back_populates="user")
friends = relationship("Friend", foreign_keys="Friend.user_id", back_populates="user")
friend_of = relationship("Friend", foreign_keys="Friend.friend_user_id", back_populates="friend_user")
sent_requests = relationship("FriendRequest", foreign_keys="FriendRequest.from_user_id", back_populates="from_user")
received_requests = relationship("FriendRequest", foreign_keys="FriendRequest.to_user_id", back_populates="to_user")
+1
View File
@@ -0,0 +1 @@
"""路由包"""
+130
View File
@@ -0,0 +1,130 @@
"""管理后台路由"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, get_admin_user
from app.schemas.admin import (
AdminLoginRequest, AdminLoginResponse, DashboardStats,
UserBanRequest, SystemConfigUpdate,
)
from app.services.admin_service import AdminService
from app.services.message_service import MessageService
router = APIRouter()
@router.post("/login", response_model=AdminLoginResponse)
async def admin_login(
req: AdminLoginRequest,
db: AsyncSession = Depends(get_db),
):
"""管理员登录(仅密码)"""
service = AdminService(db)
token = await service.login(req.password)
if not token:
raise HTTPException(status_code=401, detail="管理员密码错误")
return AdminLoginResponse(access_token=token)
@router.get("/dashboard", response_model=DashboardStats)
async def admin_dashboard(
_=Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""仪表盘统计数据"""
service = AdminService(db)
return await service.get_dashboard_stats()
@router.get("/stats/{metric}")
async def admin_stats(
metric: str,
days: int = Query(7, ge=1, le=90),
_=Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""获取趋势数据 (online/messages/registrations)"""
if metric not in ("online", "messages", "registrations"):
raise HTTPException(status_code=400, detail="无效的指标类型")
service = AdminService(db)
return await service.get_trend_data(metric, days)
@router.get("/users")
async def admin_users(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
search: str | None = Query(None),
status: str | None = Query(None),
_=Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""用户管理列表"""
service = AdminService(db)
return await service.get_users_list(page, page_size, search, status)
@router.put("/users/{user_id}/ban")
async def admin_ban_user(
user_id: str,
req: UserBanRequest,
_=Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""封禁/解封用户"""
service = AdminService(db)
try:
await service.ban_user(user_id, req.is_banned, req.reason)
return {"success": True}
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
@router.delete("/users/{user_id}")
async def admin_delete_user(
user_id: str,
_=Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""删除用户"""
service = AdminService(db)
await service.delete_user(user_id)
return {"success": True}
@router.get("/messages")
async def admin_messages(
user_id: str | None = Query(None),
conversation_id: str | None = Query(None),
keyword: str | None = Query(None),
date_from: str | None = Query(None),
date_to: str | None = Query(None),
_=Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""搜索消息(管理审查)"""
service = MessageService(db)
return await service.search_messages(user_id, conversation_id, keyword, date_from, date_to)
@router.get("/config")
async def admin_get_config(
_=Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""获取系统配置"""
service = AdminService(db)
return await service.get_all_configs()
@router.put("/config")
async def admin_update_config(
req: SystemConfigUpdate,
_=Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""更新系统配置"""
service = AdminService(db)
await service.update_configs(req.configs)
return {"success": True}
+56
View File
@@ -0,0 +1,56 @@
"""认证路由"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db
from app.schemas.auth import RegisterRequest, LoginRequest, TokenResponse, RefreshRequest
from app.services.auth_service import AuthService
from app.utils.security import decode_refresh_token
router = APIRouter()
@router.post("/register", response_model=TokenResponse)
async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
"""用户注册"""
service = AuthService(db)
try:
result = await service.register(req.username, req.email, req.password)
return TokenResponse(
access_token=result["access_token"],
refresh_token=result["refresh_token"],
user=result["user"],
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/login", response_model=TokenResponse)
async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)):
"""用户登录"""
service = AuthService(db)
try:
result = await service.login(req.username, req.password)
return TokenResponse(
access_token=result["access_token"],
refresh_token=result["refresh_token"],
user=result["user"],
)
except ValueError as e:
raise HTTPException(status_code=401, detail=str(e))
@router.post("/refresh", response_model=dict)
async def refresh_token(req: RefreshRequest, db: AsyncSession = Depends(get_db)):
"""刷新 Token"""
payload = decode_refresh_token(req.refresh_token)
if not payload:
raise HTTPException(status_code=401, detail="无效的 Refresh Token")
service = AuthService(db)
try:
result = await service.refresh_token(payload.get("sub"))
return result
except ValueError as e:
raise HTTPException(status_code=401, detail=str(e))
+67
View File
@@ -0,0 +1,67 @@
"""会话路由"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, get_current_user
from app.models.user import User
from app.schemas.conversation import ConversationCreate, ConversationRead, ConversationDetail, GroupCreate
from app.services.conversation_service import ConversationService
router = APIRouter()
@router.get("/", response_model=list[dict])
async def list_conversations(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""获取会话列表"""
service = ConversationService(db)
return await service.get_user_conversations(user.id)
@router.post("/", response_model=dict)
async def create_conversation(
req: ConversationCreate,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""创建会话(私聊或群聊)"""
service = ConversationService(db)
if req.type == "private":
if len(req.member_ids) != 1:
raise HTTPException(status_code=400, detail="私聊只能选择一个用户")
conv = await service.get_or_create_private(user.id, req.member_ids[0])
else:
conv = await service.create_group(user.id, req.name or "群聊", req.member_ids)
detail = await service.get_conversation_detail(conv.id, user.id)
return detail
@router.post("/group", response_model=dict)
async def create_group(
req: GroupCreate,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""创建群聊"""
service = ConversationService(db)
conv = await service.create_group(user.id, req.name, req.member_ids, req.description)
detail = await service.get_conversation_detail(conv.id, user.id)
return detail
@router.get("/{conversation_id}", response_model=dict)
async def get_conversation(
conversation_id: str,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""获取会话详情"""
service = ConversationService(db)
detail = await service.get_conversation_detail(conversation_id, user.id)
if not detail:
raise HTTPException(status_code=404, detail="会话不存在或无权访问")
return detail
+88
View File
@@ -0,0 +1,88 @@
"""好友路由"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, get_current_user
from app.models.user import User
from app.schemas.friend import FriendRequestCreate, FriendRead, FriendRequestRead
from app.services.friend_service import FriendService
router = APIRouter()
@router.get("/", response_model=list[dict])
async def list_friends(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""获取好友列表"""
service = FriendService(db)
return await service.get_friends(user.id)
@router.get("/requests", response_model=list[dict])
async def list_requests(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""获取待处理的好友请求"""
service = FriendService(db)
return await service.get_pending_requests(user.id)
@router.post("/request")
async def send_friend_request(
req: FriendRequestCreate,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""发送好友请求"""
service = FriendService(db)
try:
await service.send_request(user.id, req.to_user_id, req.message)
return {"success": True, "message": "好友请求已发送"}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.put("/request/{request_id}/accept")
async def accept_friend_request(
request_id: str,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""接受好友请求"""
service = FriendService(db)
try:
await service.accept_request(request_id, user.id)
return {"success": True, "message": "已添加好友"}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.put("/request/{request_id}/reject")
async def reject_friend_request(
request_id: str,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""拒绝好友请求"""
service = FriendService(db)
try:
await service.reject_request(request_id, user.id)
return {"success": True, "message": "已拒绝"}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.delete("/{friend_id}")
async def remove_friend(
friend_id: str,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""删除好友"""
service = FriendService(db)
await service.remove_friend(user.id, friend_id)
return {"success": True, "message": "已删除好友"}
+56
View File
@@ -0,0 +1,56 @@
"""消息路由"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, get_current_user
from app.models.user import User
from app.schemas.message import MessageSend, MessagePage, MarkReadRequest
from app.services.message_service import MessageService
router = APIRouter()
@router.get("/{conversation_id}/messages", response_model=dict)
async def get_messages(
conversation_id: str,
before: str | None = Query(None),
limit: int = Query(50, ge=1, le=100),
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""获取消息列表(游标分页)"""
service = MessageService(db)
try:
return await service.get_messages(conversation_id, user.id, before, limit)
except ValueError as e:
raise HTTPException(status_code=403, detail=str(e))
@router.put("/{conversation_id}/messages/{message_id}/read")
async def mark_as_read(
conversation_id: str,
message_id: str,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""标记消息已读"""
service = MessageService(db)
await service.mark_as_read(conversation_id, user.id, message_id)
return {"success": True}
@router.delete("/{conversation_id}/messages/{message_id}")
async def delete_message(
conversation_id: str,
message_id: str,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""删除消息"""
service = MessageService(db)
try:
await service.soft_delete(message_id, user.id)
return {"success": True}
except ValueError as e:
raise HTTPException(status_code=403, detail=str(e))
+63
View File
@@ -0,0 +1,63 @@
"""文件上传路由"""
import os
import uuid
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from app.config import settings
from app.dependencies import get_current_user
from app.models.user import User
router = APIRouter()
@router.post("/avatar")
async def upload_avatar(
file: UploadFile = File(...),
user: User = Depends(get_current_user),
):
"""上传头像"""
if not file.content_type or not file.content_type.startswith("image/"):
raise HTTPException(status_code=400, detail="只能上传图片文件")
# 检查文件大小
contents = await file.read()
max_size = settings.MAX_UPLOAD_SIZE_MB * 1024 * 1024
if len(contents) > max_size:
raise HTTPException(status_code=400, detail=f"文件大小超过 {settings.MAX_UPLOAD_SIZE_MB}MB")
# 保存文件
ext = os.path.splitext(file.filename or "image.jpg")[1]
filename = f"avatar_{user.id}{ext}"
filepath = os.path.join(settings.UPLOAD_DIR, filename)
with open(filepath, "wb") as f:
f.write(contents)
return {"url": f"/uploads/{filename}"}
@router.post("/file")
async def upload_file(
file: UploadFile = File(...),
user: User = Depends(get_current_user),
):
"""上传文件(聊天中使用)"""
contents = await file.read()
max_size = settings.MAX_UPLOAD_SIZE_MB * 1024 * 1024
if len(contents) > max_size:
raise HTTPException(status_code=400, detail=f"文件大小超过 {settings.MAX_UPLOAD_SIZE_MB}MB")
ext = os.path.splitext(file.filename or "file")[1]
filename = f"{uuid.uuid4().hex}{ext}"
filepath = os.path.join(settings.UPLOAD_DIR, filename)
with open(filepath, "wb") as f:
f.write(contents)
return {
"url": f"/uploads/{filename}",
"filename": file.filename,
"size": len(contents),
"content_type": file.content_type,
}
+54
View File
@@ -0,0 +1,54 @@
"""用户路由"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, get_current_user
from app.models.user import User
from app.schemas.user import UserRead, UserProfile, UserUpdate, UserSearchResult
from app.services.user_service import UserService
router = APIRouter()
@router.get("/me", response_model=UserRead)
async def get_me(user: User = Depends(get_current_user)):
"""获取当前用户信息"""
return user
@router.put("/me", response_model=UserRead)
async def update_me(
req: UserUpdate,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""更新当前用户信息"""
service = UserService(db)
updated = await service.update_profile(user.id, **req.model_dump(exclude_none=True))
return updated
@router.get("/search", response_model=list[UserSearchResult])
async def search_users(
q: str = Query(..., min_length=1),
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""搜索用户"""
service = UserService(db)
users = await service.search_users(q, user.id)
return users
@router.get("/{user_id}", response_model=UserProfile)
async def get_user(
user_id: str,
db: AsyncSession = Depends(get_db),
):
"""获取用户公开信息"""
service = UserService(db)
user = await service.get_by_id(user_id)
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
return user
+1
View File
@@ -0,0 +1 @@
"""Pydantic Schema 包"""
+66
View File
@@ -0,0 +1,66 @@
"""管理后台 Schema"""
from datetime import datetime
from pydantic import BaseModel
class AdminLoginRequest(BaseModel):
password: str
class AdminLoginResponse(BaseModel):
access_token: str
token_type: str = "bearer"
class DashboardStats(BaseModel):
total_users: int
online_users: int
total_messages: int
today_messages: int
total_conversations: int
new_users_7d: int
class TrendDataPoint(BaseModel):
date: str
value: int
class UserAdminRead(BaseModel):
id: str
username: str
email: str
avatar_url: str | None = None
status: str
is_banned: bool
banned_reason: str | None = None
last_seen_at: datetime | None = None
created_at: datetime
model_config = {"from_attributes": True}
class UserBanRequest(BaseModel):
is_banned: bool
reason: str | None = None
class SystemConfigRead(BaseModel):
key: str
value: str
model_config = {"from_attributes": True}
class SystemConfigUpdate(BaseModel):
configs: dict[str, str]
class AdminMessageFilter(BaseModel):
user_id: str | None = None
conversation_id: str | None = None
keyword: str | None = None
date_from: str | None = None
date_to: str | None = None
+34
View File
@@ -0,0 +1,34 @@
"""认证相关 Schema"""
from pydantic import BaseModel, EmailStr, Field
class RegisterRequest(BaseModel):
username: str = Field(..., min_length=2, max_length=50)
email: EmailStr
password: str = Field(..., min_length=6, max_length=100)
class LoginRequest(BaseModel):
username: str
password: str
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
user: "UserBrief"
class RefreshRequest(BaseModel):
refresh_token: str
class UserBrief(BaseModel):
id: str
username: str
avatar_url: str | None = None
is_admin: bool = False
model_config = {"from_attributes": True}
+21
View File
@@ -0,0 +1,21 @@
"""通用 Schema"""
from pydantic import BaseModel
class SuccessResponse(BaseModel):
success: bool = True
message: str = "操作成功"
class PageParams(BaseModel):
page: int = 1
page_size: int = 20
class PageResult(BaseModel):
items: list
total: int
page: int
page_size: int
has_more: bool
+53
View File
@@ -0,0 +1,53 @@
"""会话相关 Schema"""
from datetime import datetime
from pydantic import BaseModel, Field
class ConversationCreate(BaseModel):
type: str = Field(..., pattern="^(private|group)$")
name: str | None = Field(None, max_length=100)
member_ids: list[str] = Field(..., min_length=1)
class ConversationRead(BaseModel):
id: str
type: str
name: str | None = None
avatar_url: str | None = None
description: str | None = None
last_message_preview: str | None = None
last_message_at: datetime | None = None
created_at: datetime
unread_count: int = 0
model_config = {"from_attributes": True}
class ConversationDetail(ConversationRead):
members: list["MemberRead"] = []
class MemberRead(BaseModel):
id: str
user_id: str
username: str
nickname: str | None = None
avatar_url: str | None = None
role: str = "member"
joined_at: datetime
model_config = {"from_attributes": True}
class ConversationUpdate(BaseModel):
name: str | None = Field(None, max_length=100)
description: str | None = Field(None, max_length=500)
avatar_url: str | None = None
class GroupCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
description: str | None = Field(None, max_length=500)
member_ids: list[str] = Field(..., min_length=1)
+36
View File
@@ -0,0 +1,36 @@
"""好友相关 Schema"""
from datetime import datetime
from pydantic import BaseModel
class FriendRequestCreate(BaseModel):
to_user_id: str
message: str | None = None
class FriendRequestRead(BaseModel):
id: str
from_user_id: str
from_username: str | None = None
from_avatar: str | None = None
to_user_id: str
to_username: str | None = None
message: str | None = None
status: str
created_at: datetime
model_config = {"from_attributes": True}
class FriendRead(BaseModel):
id: str
friend_user_id: str
username: str
nickname: str | None = None
avatar_url: str | None = None
remark: str | None = None
status: str = "offline"
model_config = {"from_attributes": True}
+37
View File
@@ -0,0 +1,37 @@
"""消息相关 Schema"""
from datetime import datetime
from pydantic import BaseModel, Field
class MessageSend(BaseModel):
conversation_id: str
content: str = Field(..., min_length=1, max_length=5000)
type: str = Field(default="text", pattern="^(text|image|file)$")
reply_to_id: str | None = None
class MessageRead(BaseModel):
id: str
conversation_id: str
sender_id: str
sender_name: str | None = None
sender_avatar: str | None = None
type: str
content: str
reply_to_id: str | None = None
is_deleted: bool = False
created_at: datetime
model_config = {"from_attributes": True}
class MessagePage(BaseModel):
messages: list[MessageRead]
has_more: bool = False
next_cursor: str | None = None
class MarkReadRequest(BaseModel):
message_id: str
+49
View File
@@ -0,0 +1,49 @@
"""用户相关 Schema"""
from datetime import datetime
from pydantic import BaseModel, Field
class UserRead(BaseModel):
id: str
username: str
email: str
avatar_url: str | None = None
bio: str | None = None
status: str = "offline"
created_at: datetime
model_config = {"from_attributes": True}
class UserProfile(BaseModel):
"""他人可见的公开信息"""
id: str
username: str
avatar_url: str | None = None
bio: str | None = None
status: str = "offline"
model_config = {"from_attributes": True}
class UserUpdate(BaseModel):
username: str | None = Field(None, min_length=2, max_length=50)
bio: str | None = Field(None, max_length=200)
avatar_url: str | None = None
class PasswordChange(BaseModel):
old_password: str
new_password: str = Field(..., min_length=6, max_length=100)
class UserSearchResult(BaseModel):
id: str
username: str
avatar_url: str | None = None
bio: str | None = None
status: str = "offline"
model_config = {"from_attributes": True}
+1
View File
@@ -0,0 +1 @@
"""服务层包"""
+189
View File
@@ -0,0 +1,189 @@
"""管理后台服务"""
import uuid
from datetime import datetime, timezone
from sqlalchemy import select, func, delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.models.user import User
from app.models.message import Message
from app.models.conversation import Conversation
from app.models.system_config import SystemConfig
from app.utils.security import verify_password, hash_password, create_access_token
class AdminService:
def __init__(self, db: AsyncSession = None):
self.db = db
async def init_system_config(self):
"""初始化系统默认配置"""
if not self.db:
return
defaults = {
"platform_name": "青叶",
"announcement": "",
"max_upload_size_mb": "10",
"allow_registration": "true",
"admin_password_hash": hash_password(settings.ADMIN_PASSWORD),
}
for key, value in defaults.items():
result = await self.db.execute(
select(SystemConfig).where(SystemConfig.key == key)
)
if not result.scalars().first():
self.db.add(SystemConfig(id=str(uuid.uuid4()), key=key, value=value))
async def login(self, password: str) -> str | None:
"""管理员登录(仅密码)"""
result = await self.db.execute(
select(SystemConfig).where(SystemConfig.key == "admin_password_hash")
)
config = result.scalars().first()
if not config:
return None
if not verify_password(password, config.value):
return None
# 生成管理员 Token
token = create_access_token({
"sub": "admin",
"username": "admin",
"is_admin": True,
})
return token
async def get_dashboard_stats(self) -> dict:
"""获取仪表盘统计数据"""
total_users = await self.db.execute(select(func.count(User.id)))
online_users = await self.db.execute(
select(func.count(User.id)).where(User.status == "online")
)
total_messages = await self.db.execute(select(func.count(Message.id)))
total_conversations = await self.db.execute(select(func.count(Conversation.id)))
today = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
today_messages = await self.db.execute(
select(func.count(Message.id)).where(Message.created_at >= today)
)
seven_days_ago = datetime.utcnow() - __import__("datetime").timedelta(days=7)
new_users_7d = await self.db.execute(
select(func.count(User.id)).where(User.created_at >= seven_days_ago)
)
return {
"total_users": total_users.scalar() or 0,
"online_users": online_users.scalar() or 0,
"total_messages": total_messages.scalar() or 0,
"today_messages": today_messages.scalar() or 0,
"total_conversations": total_conversations.scalar() or 0,
"new_users_7d": new_users_7d.scalar() or 0,
}
async def get_trend_data(self, metric: str, days: int = 7) -> list[dict]:
"""获取趋势数据"""
from sqlalchemy import cast, Date
trends = []
for i in range(days - 1, -1, -1):
day = (datetime.utcnow() - __import__("datetime").timedelta(days=i)).date()
day_start = datetime.combine(day, __import__("datetime").time.min)
day_end = datetime.combine(day, __import__("datetime").time.max)
if metric == "online":
# 简化:使用当前在线数
count = await self.db.execute(
select(func.count(User.id)).where(User.status == "online")
)
value = count.scalar() or 0
elif metric == "messages":
count = await self.db.execute(
select(func.count(Message.id)).where(
Message.created_at >= day_start,
Message.created_at <= day_end,
)
)
value = count.scalar() or 0
elif metric == "registrations":
count = await self.db.execute(
select(func.count(User.id)).where(
User.created_at >= day_start,
User.created_at <= day_end,
)
)
value = count.scalar() or 0
else:
value = 0
trends.append({"date": day.isoformat(), "value": value})
return trends
async def get_users_list(self, page: int = 1, page_size: int = 20,
search: str | None = None, status: str | None = None) -> dict:
"""获取用户列表(管理后台)"""
query = select(User)
count_query = select(func.count(User.id))
if search:
query = query.where(User.username.ilike(f"%{search}%"))
count_query = count_query.where(User.username.ilike(f"%{search}%"))
if status == "online":
query = query.where(User.status == "online")
count_query = count_query.where(User.status == "online")
elif status == "banned":
query = query.where(User.is_banned == True)
count_query = count_query.where(User.is_banned == True)
total = (await self.db.execute(count_query)).scalar() or 0
result = await self.db.execute(
query.order_by(User.created_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
users = []
for u in result.scalars().all():
users.append({
"id": u.id,
"username": u.username,
"email": u.email,
"avatar_url": u.avatar_url,
"status": u.status,
"is_banned": u.is_banned,
"banned_reason": u.banned_reason,
"last_seen_at": u.last_seen_at,
"created_at": u.created_at,
})
return {"items": users, "total": total, "page": page, "page_size": page_size}
async def ban_user(self, user_id: str, is_banned: bool, reason: str | None = None):
"""封禁/解封用户"""
result = await self.db.execute(select(User).where(User.id == user_id))
user = result.scalars().first()
if not user:
raise ValueError("用户不存在")
user.is_banned = is_banned
user.banned_reason = reason if is_banned else None
async def delete_user(self, user_id: str):
"""删除用户"""
await self.db.execute(delete(User).where(User.id == user_id))
async def get_all_configs(self) -> list[dict]:
"""获取所有系统配置"""
result = await self.db.execute(select(SystemConfig))
return [{"key": c.key, "value": c.value} for c in result.scalars().all()]
async def update_configs(self, configs: dict[str, str]):
"""更新系统配置"""
for key, value in configs.items():
result = await self.db.execute(
select(SystemConfig).where(SystemConfig.key == key)
)
config = result.scalars().first()
if config:
config.value = value
config.updated_at = datetime.utcnow()
+79
View File
@@ -0,0 +1,79 @@
"""认证服务"""
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.user import User
from app.utils.security import hash_password, verify_password, create_access_token, create_refresh_token
class AuthService:
def __init__(self, db: AsyncSession):
self.db = db
async def register(self, username: str, email: str, password: str) -> dict:
"""用户注册"""
# 检查用户名是否已存在
result = await self.db.execute(select(User).where(User.username == username))
if result.scalars().first():
raise ValueError("用户名已存在")
# 检查邮箱是否已存在
result = await self.db.execute(select(User).where(User.email == email))
if result.scalars().first():
raise ValueError("邮箱已被注册")
# 创建用户
user = User(
id=str(uuid.uuid4()),
username=username,
email=email,
password_hash=hash_password(password),
)
self.db.add(user)
await self.db.flush()
# 生成 Token
tokens = self._generate_tokens(user)
return {**tokens, "user": user}
async def login(self, username: str, password: str) -> dict:
"""用户登录"""
result = await self.db.execute(
select(User).where(
(User.username == username) | (User.email == username)
)
)
user = result.scalars().first()
if not user or not verify_password(password, user.password_hash):
raise ValueError("用户名或密码错误")
if user.is_banned:
raise ValueError("账号已被封禁")
# 更新在线状态
user.status = "online"
from datetime import datetime, timezone
user.last_seen_at = datetime.utcnow()
tokens = self._generate_tokens(user)
return {**tokens, "user": user}
async def refresh_token(self, user_id: str) -> dict:
"""刷新 Token"""
result = await self.db.execute(select(User).where(User.id == user_id))
user = result.scalars().first()
if not user:
raise ValueError("用户不存在")
return self._generate_tokens(user)
def _generate_tokens(self, user: User) -> dict:
"""生成 JWT Token 对"""
data = {"sub": user.id, "username": user.username}
return {
"access_token": create_access_token(data),
"refresh_token": create_refresh_token(data),
}
@@ -0,0 +1,210 @@
"""会话服务"""
import uuid
from datetime import datetime, timezone
from sqlalchemy import select, and_
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.conversation import Conversation
from app.models.conversation_member import ConversationMember
from app.models.user import User
class ConversationService:
def __init__(self, db: AsyncSession):
self.db = db
async def get_or_create_private(self, user1_id: str, user2_id: str) -> Conversation:
"""获取或创建私聊会话"""
# 查找已有的私聊
result = await self.db.execute(
select(Conversation).join(ConversationMember)
.where(
Conversation.type == "private",
ConversationMember.user_id == user1_id,
)
)
for conv in result.scalars().all():
member_result = await self.db.execute(
select(ConversationMember).where(
ConversationMember.conversation_id == conv.id,
ConversationMember.user_id == user2_id,
)
)
if member_result.scalars().first():
return conv
# 创建新私聊
conv = Conversation(id=str(uuid.uuid4()), type="private")
self.db.add(conv)
await self.db.flush()
# 添加两个成员
self.db.add(ConversationMember(
id=str(uuid.uuid4()), conversation_id=conv.id, user_id=user1_id, role="member"
))
self.db.add(ConversationMember(
id=str(uuid.uuid4()), conversation_id=conv.id, user_id=user2_id, role="member"
))
return conv
async def create_group(self, creator_id: str, name: str, member_ids: list[str],
description: str | None = None) -> Conversation:
"""创建群聊"""
conv = Conversation(
id=str(uuid.uuid4()),
type="group",
name=name,
description=description,
creator_id=creator_id,
)
self.db.add(conv)
await self.db.flush()
# 创建者为 owner
self.db.add(ConversationMember(
id=str(uuid.uuid4()), conversation_id=conv.id,
user_id=creator_id, role="owner"
))
# 其他成员
for mid in member_ids:
if mid != creator_id:
self.db.add(ConversationMember(
id=str(uuid.uuid4()), conversation_id=conv.id,
user_id=mid, role="member"
))
return conv
async def get_user_conversations(self, user_id: str) -> list[dict]:
"""获取用户的会话列表"""
result = await self.db.execute(
select(ConversationMember)
.where(ConversationMember.user_id == user_id, ConversationMember.left_at.is_(None))
.order_by(ConversationMember.joined_at.desc())
)
members = result.scalars().all()
conversations = []
for member in members:
conv_result = await self.db.execute(
select(Conversation).where(Conversation.id == member.conversation_id)
)
conv = conv_result.scalars().first()
if not conv:
continue
# 获取未读数
unread = await self._get_unread_count(conv.id, member.last_read_message_id)
# 获取显示信息
display_name = conv.name
display_avatar = conv.avatar_url
if conv.type == "private":
other = await self._get_other_member(conv.id, user_id)
if other:
display_name = other.username
display_avatar = other.avatar_url
conversations.append({
"id": conv.id,
"type": conv.type,
"name": display_name,
"avatar_url": display_avatar,
"description": conv.description,
"last_message_preview": conv.last_message_preview,
"last_message_at": conv.last_message_at,
"unread_count": unread,
"created_at": conv.created_at,
})
# 按最后消息时间排序
conversations.sort(key=lambda x: x["last_message_at"] or x["created_at"], reverse=True)
return conversations
async def get_conversation_detail(self, conv_id: str, user_id: str) -> dict | None:
"""获取会话详情"""
conv_result = await self.db.execute(
select(Conversation).where(Conversation.id == conv_id)
)
conv = conv_result.scalars().first()
if not conv:
return None
# 验证成员身份
member_result = await self.db.execute(
select(ConversationMember).where(
ConversationMember.conversation_id == conv_id,
ConversationMember.user_id == user_id,
ConversationMember.left_at.is_(None),
)
)
if not member_result.scalars().first():
return None
# 获取所有成员
members_result = await self.db.execute(
select(ConversationMember).where(
ConversationMember.conversation_id == conv_id,
ConversationMember.left_at.is_(None),
)
)
members = []
for m in members_result.scalars().all():
user_result = await self.db.execute(select(User).where(User.id == m.user_id))
user = user_result.scalars().first()
if user:
members.append({
"id": m.id,
"user_id": user.id,
"username": user.username,
"nickname": user.bio,
"avatar_url": user.avatar_url,
"role": m.role,
"joined_at": m.joined_at,
})
return {
"id": conv.id,
"type": conv.type,
"name": conv.name,
"avatar_url": conv.avatar_url,
"description": conv.description,
"last_message_preview": conv.last_message_preview,
"last_message_at": conv.last_message_at,
"created_at": conv.created_at,
"members": members,
"unread_count": await self._get_unread_count(conv.id, None),
}
async def _get_other_member(self, conv_id: str, user_id: str) -> User | None:
"""获取私聊的对方用户"""
result = await self.db.execute(
select(ConversationMember).where(
ConversationMember.conversation_id == conv_id,
ConversationMember.user_id != user_id,
)
)
member = result.scalars().first()
if member:
user_result = await self.db.execute(select(User).where(User.id == member.user_id))
return user_result.scalars().first()
return None
async def _get_unread_count(self, conv_id: str, last_read_id: str | None) -> int:
"""计算未读消息数"""
from app.models.message import Message
query = select(func := __import__("sqlalchemy").func).count(Message.id).where(
Message.conversation_id == conv_id,
Message.is_deleted == False,
)
if last_read_id:
# 获取 last_read 消息的时间
lr = await self.db.execute(select(Message).where(Message.id == last_read_id))
lr_msg = lr.scalars().first()
if lr_msg:
query = query.where(Message.created_at > lr_msg.created_at)
result = await self.db.execute(query)
return result.scalar() or 0
+162
View File
@@ -0,0 +1,162 @@
"""好友服务"""
import uuid
from datetime import datetime, timezone
from sqlalchemy import select, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.user import User
from app.models.friend import Friend
from app.models.friend_request import FriendRequest
class FriendService:
def __init__(self, db: AsyncSession):
self.db = db
async def send_request(self, from_user_id: str, to_user_id: str,
message: str | None = None) -> FriendRequest:
"""发送好友请求"""
if from_user_id == to_user_id:
raise ValueError("不能添加自己为好友")
# 检查目标用户是否存在
target = await self.db.execute(select(User).where(User.id == to_user_id))
if not target.scalars().first():
raise ValueError("目标用户不存在")
# 检查是否已是好友
existing = await self.db.execute(
select(Friend).where(
Friend.user_id == from_user_id,
Friend.friend_user_id == to_user_id,
)
)
if existing.scalars().first():
raise ValueError("已经是好友了")
# 检查是否有待处理的请求
pending = await self.db.execute(
select(FriendRequest).where(
FriendRequest.from_user_id == from_user_id,
FriendRequest.to_user_id == to_user_id,
FriendRequest.status == "pending",
)
)
if pending.scalars().first():
raise ValueError("已发送过好友请求")
request = FriendRequest(
id=str(uuid.uuid4()),
from_user_id=from_user_id,
to_user_id=to_user_id,
message=message,
status="pending",
)
self.db.add(request)
return request
async def accept_request(self, request_id: str, user_id: str):
"""接受好友请求"""
result = await self.db.execute(
select(FriendRequest).where(FriendRequest.id == request_id)
)
request = result.scalars().first()
if not request:
raise ValueError("请求不存在")
if request.to_user_id != user_id:
raise ValueError("无权操作此请求")
if request.status != "pending":
raise ValueError("该请求已处理")
request.status = "accepted"
request.responded_at = datetime.utcnow()
# 创建双向好友关系
self.db.add(Friend(
id=str(uuid.uuid4()), user_id=request.from_user_id,
friend_user_id=request.to_user_id,
))
self.db.add(Friend(
id=str(uuid.uuid4()), user_id=request.to_user_id,
friend_user_id=request.from_user_id,
))
async def reject_request(self, request_id: str, user_id: str):
"""拒绝好友请求"""
result = await self.db.execute(
select(FriendRequest).where(FriendRequest.id == request_id)
)
request = result.scalars().first()
if not request:
raise ValueError("请求不存在")
if request.to_user_id != user_id:
raise ValueError("无权操作此请求")
request.status = "rejected"
request.responded_at = datetime.utcnow()
async def get_friends(self, user_id: str) -> list[dict]:
"""获取好友列表"""
result = await self.db.execute(
select(Friend).where(Friend.user_id == user_id)
)
friends = []
for friendship in result.scalars().all():
user_result = await self.db.execute(
select(User).where(User.id == friendship.friend_user_id)
)
user = user_result.scalars().first()
if user:
friends.append({
"id": friendship.id,
"friend_user_id": user.id,
"username": user.username,
"nickname": user.bio,
"avatar_url": user.avatar_url,
"remark": friendship.remark,
"status": user.status,
})
return friends
async def get_pending_requests(self, user_id: str) -> list[dict]:
"""获取待处理的好友请求"""
result = await self.db.execute(
select(FriendRequest).where(
FriendRequest.to_user_id == user_id,
FriendRequest.status == "pending",
).order_by(FriendRequest.created_at.desc())
)
requests = []
for req in result.scalars().all():
from_user = await self.db.execute(select(User).where(User.id == req.from_user_id))
fu = from_user.scalars().first()
requests.append({
"id": req.id,
"from_user_id": req.from_user_id,
"from_username": fu.username if fu else "未知",
"from_avatar": fu.avatar_url if fu else None,
"to_user_id": req.to_user_id,
"message": req.message,
"status": req.status,
"created_at": req.created_at,
})
return requests
async def remove_friend(self, user_id: str, friend_id: str):
"""删除好友"""
await self.db.execute(
select(Friend).where(
Friend.user_id == user_id,
Friend.friend_user_id == friend_id,
)
)
# 删除双向关系
from sqlalchemy import delete
await self.db.execute(
delete(Friend).where(
(Friend.user_id == user_id) & (Friend.friend_user_id == friend_id) |
(Friend.user_id == friend_id) & (Friend.friend_user_id == user_id)
)
)
+179
View File
@@ -0,0 +1,179 @@
"""消息服务"""
import uuid
from datetime import datetime, timezone
from sqlalchemy import select, func, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.message import Message
from app.models.conversation import Conversation
from app.models.conversation_member import ConversationMember
class MessageService:
def __init__(self, db: AsyncSession):
self.db = db
async def send_message(self, conversation_id: str, sender_id: str,
content: str, msg_type: str = "text",
reply_to_id: str | None = None) -> Message:
"""发送消息"""
message = Message(
id=str(uuid.uuid4()),
conversation_id=conversation_id,
sender_id=sender_id,
type=msg_type,
content=content,
reply_to_id=reply_to_id,
)
self.db.add(message)
# 更新会话的最后消息
conv_result = await self.db.execute(
select(Conversation).where(Conversation.id == conversation_id)
)
conv = conv_result.scalars().first()
if conv:
conv.last_message_at = datetime.utcnow()
preview = content[:200] if len(content) > 200 else content
conv.last_message_preview = preview
conv.updated_at = datetime.utcnow()
return message
async def get_messages(self, conversation_id: str, user_id: str,
before: str | None = None, limit: int = 50) -> dict:
"""获取消息列表(游标分页)"""
# 验证成员身份
member_result = await self.db.execute(
select(ConversationMember).where(
ConversationMember.conversation_id == conversation_id,
ConversationMember.user_id == user_id,
)
)
if not member_result.scalars().first():
raise ValueError("无权访问该会话")
query = (
select(Message)
.where(Message.conversation_id == conversation_id, Message.is_deleted == False)
.order_by(Message.created_at.desc())
)
# 游标分页
if before:
before_msg = await self.db.execute(
select(Message).where(Message.id == before)
)
before_msg_obj = before_msg.scalars().first()
if before_msg_obj:
query = query.where(Message.created_at < before_msg_obj.created_at)
query = query.limit(limit + 1)
result = await self.db.execute(query)
messages = list(result.scalars().all())
has_more = len(messages) > limit
messages = messages[:limit]
# 获取发送者信息
from app.models.user import User
message_list = []
for msg in reversed(messages):
sender_result = await self.db.execute(
select(User).where(User.id == msg.sender_id)
)
sender = sender_result.scalars().first()
message_list.append({
"id": msg.id,
"conversation_id": msg.conversation_id,
"sender_id": msg.sender_id,
"sender_name": sender.username if sender else "未知",
"sender_avatar": sender.avatar_url if sender else None,
"type": msg.type,
"content": msg.content,
"reply_to_id": msg.reply_to_id,
"is_deleted": msg.is_deleted,
"created_at": msg.created_at,
})
return {
"messages": message_list,
"has_more": has_more,
"next_cursor": messages[-1].id if has_more and messages else None,
}
async def mark_as_read(self, conversation_id: str, user_id: str, message_id: str):
"""标记消息已读"""
result = await self.db.execute(
select(ConversationMember).where(
ConversationMember.conversation_id == conversation_id,
ConversationMember.user_id == user_id,
)
)
member = result.scalars().first()
if member:
member.last_read_message_id = message_id
async def soft_delete(self, message_id: str, user_id: str):
"""软删除消息(仅能删除自己的)"""
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("消息不存在或无权删除")
message.is_deleted = True
async def get_total_count(self) -> int:
"""获取消息总数"""
result = await self.db.execute(select(func.count(Message.id)))
return result.scalar() or 0
async def get_today_count(self) -> int:
"""获取今日消息数"""
today = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
result = await self.db.execute(
select(func.count(Message.id)).where(Message.created_at >= today)
)
return result.scalar() or 0
async def search_messages(self, user_id: str | None = None,
conversation_id: str | None = None,
keyword: str | None = None,
date_from: str | None = None,
date_to: str | None = None,
limit: int = 50) -> list[dict]:
"""管理后台搜索消息"""
query = select(Message).where(Message.is_deleted == False)
if user_id:
query = query.where(Message.sender_id == user_id)
if conversation_id:
query = query.where(Message.conversation_id == conversation_id)
if keyword:
query = query.where(Message.content.ilike(f"%{keyword}%"))
if date_from:
query = query.where(Message.created_at >= date_from)
if date_to:
query = query.where(Message.created_at <= date_to)
query = query.order_by(Message.created_at.desc()).limit(limit)
result = await self.db.execute(query)
from app.models.user import User
messages = []
for msg in result.scalars().all():
sender = await self.db.execute(select(User).where(User.id == msg.sender_id))
s = sender.scalars().first()
messages.append({
"id": msg.id,
"conversation_id": msg.conversation_id,
"sender_id": msg.sender_id,
"sender_name": s.username if s else "未知",
"type": msg.type,
"content": msg.content[:200],
"created_at": msg.created_at,
})
return messages
+69
View File
@@ -0,0 +1,69 @@
"""用户服务"""
from datetime import datetime, timezone
from sqlalchemy import select, or_, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.user import User
class UserService:
def __init__(self, db: AsyncSession):
self.db = db
async def get_by_id(self, user_id: str) -> User | None:
"""根据 ID 获取用户"""
result = await self.db.execute(select(User).where(User.id == user_id))
return result.scalars().first()
async def get_by_username(self, username: str) -> User | None:
"""根据用户名获取用户"""
result = await self.db.execute(select(User).where(User.username == username))
return result.scalars().first()
async def search_users(self, query: str, current_user_id: str, limit: int = 20) -> list[User]:
"""搜索用户"""
result = await self.db.execute(
select(User).where(
or_(
User.username.ilike(f"%{query}%"),
User.email.ilike(f"%{query}%"),
),
User.id != current_user_id,
User.is_banned == False,
).limit(limit)
)
return list(result.scalars().all())
async def update_profile(self, user_id: str, **kwargs) -> User:
"""更新用户资料"""
user = await self.get_by_id(user_id)
if not user:
raise ValueError("用户不存在")
for key, value in kwargs.items():
if value is not None and hasattr(user, key):
setattr(user, key, value)
user.updated_at = datetime.utcnow()
return user
async def update_status(self, user_id: str, status: str):
"""更新用户在线状态"""
user = await self.get_by_id(user_id)
if user:
user.status = status
user.last_seen_at = datetime.utcnow()
async def get_total_count(self) -> int:
"""获取用户总数"""
result = await self.db.execute(select(func.count(User.id)))
return result.scalar() or 0
async def get_online_count(self) -> int:
"""获取在线用户数"""
result = await self.db.execute(
select(func.count(User.id)).where(User.status == "online")
)
return result.scalar() or 0
View File
+14
View File
@@ -0,0 +1,14 @@
"""通用工具函数"""
import uuid
from datetime import datetime, timezone
def generate_uuid() -> str:
"""生成 UUID"""
return str(uuid.uuid4())
def now_utc() -> datetime:
"""获取当前 UTC 时间"""
return datetime.now(timezone.utc)
+67
View File
@@ -0,0 +1,67 @@
"""安全工具:JWT Token 和密码哈希"""
from datetime import datetime, timedelta, timezone
from jose import JWTError, jwt
from passlib.context import CryptContext
from app.config import settings
# 密码哈希上下文
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
"""密码加密"""
return pwd_context.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""验证密码"""
return pwd_context.verify(plain_password, hashed_password)
def create_access_token(data: dict) -> str:
"""创建 Access Token"""
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(
minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES
)
to_encode.update({"exp": expire, "type": "access"})
return jwt.encode(to_encode, settings.JWT_SECRET_KEY, algorithm="HS256")
def create_refresh_token(data: dict) -> str:
"""创建 Refresh Token"""
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(
days=settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS
)
to_encode.update({"exp": expire, "type": "refresh"})
return jwt.encode(
to_encode, settings.JWT_REFRESH_SECRET_KEY, algorithm="HS256"
)
def decode_access_token(token: str) -> dict | None:
"""解码 Access Token"""
try:
payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=["HS256"])
if payload.get("type") != "access":
return None
return payload
except JWTError:
return None
def decode_refresh_token(token: str) -> dict | None:
"""解码 Refresh Token"""
try:
payload = jwt.decode(
token, settings.JWT_REFRESH_SECRET_KEY, algorithms=["HS256"]
)
if payload.get("type") != "refresh":
return None
return payload
except JWTError:
return None
+1
View File
@@ -0,0 +1 @@
"""WebSocket 包"""
+58
View File
@@ -0,0 +1,58 @@
"""WebSocket 事件类型定义"""
from enum import Enum
from pydantic import BaseModel
from datetime import datetime
class EventType(str, Enum):
# 客户端 -> 服务端
CHAT_SEND = "chat.send"
CHAT_TYPING = "chat.typing"
CHAT_READ = "chat.read"
PRESENCE_UPDATE = "presence.update"
# 服务端 -> 客户端
CHAT_MESSAGE = "chat.message"
CHAT_TYPING_INDICATOR = "chat.typing"
CHAT_READ_RECEIPT = "chat.read"
CHAT_MESSAGE_DELETED = "chat.message_deleted"
CONVERSATION_UPDATED = "conversation.updated"
CONVERSATION_MEMBER_ADDED = "conversation.member_added"
CONVERSATION_MEMBER_REMOVED = "conversation.member_removed"
FRIEND_REQUEST = "friend.request"
FRIEND_ACCEPTED = "friend.accepted"
PRESENCE_ONLINE = "presence.online"
PRESENCE_OFFLINE = "presence.offline"
ERROR = "error"
class WSEvent(BaseModel):
"""WebSocket 事件信封"""
type: str
data: dict
timestamp: str | None = None
class ChatSendData(BaseModel):
"""发送消息数据"""
conversation_id: str
content: str
type: str = "text"
reply_to_id: str | None = None
class ChatTypingData(BaseModel):
"""输入中数据"""
conversation_id: str
class ChatReadData(BaseModel):
"""已读数据"""
conversation_id: str
message_id: str
class PresenceUpdateData(BaseModel):
"""在线状态更新"""
status: str # online / offline / away
+106
View File
@@ -0,0 +1,106 @@
"""WebSocket 事件处理器"""
import json
from datetime import datetime, timezone
from fastapi import WebSocket
from sqlalchemy.ext.asyncio import AsyncSession
from app.websocket.events import EventType
from app.websocket.manager import manager
async def handle_chat_send(ws: WebSocket, user_id: str, data: dict, db: AsyncSession):
"""处理发送消息事件"""
from app.services.message_service import MessageService
service = MessageService(db)
try:
message = await service.send_message(
conversation_id=data["conversation_id"],
sender_id=user_id,
content=data["content"],
msg_type=data.get("type", "text"),
reply_to_id=data.get("reply_to_id"),
)
await db.commit()
# 获取发送者信息
from app.services.user_service import UserService
user_service = UserService(db)
sender = await user_service.get_by_id(user_id)
# 获取会话成员列表
from app.services.conversation_service import ConversationService
conv_service = ConversationService(db)
detail = await conv_service.get_conversation_detail(data["conversation_id"], user_id)
msg_data = {
"id": message.id,
"conversation_id": message.conversation_id,
"sender_id": user_id,
"sender_name": sender.username if sender else "未知",
"sender_avatar": sender.avatar_url if sender else None,
"type": message.type,
"content": message.content,
"created_at": message.created_at.isoformat(),
}
# 广播给会话中的所有成员
if detail and "members" in detail:
member_ids = [m["user_id"] for m in detail["members"]]
await manager.broadcast_to_conversation(
member_ids, EventType.CHAT_MESSAGE, msg_data
)
except Exception as e:
await manager.send_to_user(user_id, EventType.ERROR, {"message": str(e)})
async def handle_chat_typing(ws: WebSocket, user_id: str, data: dict, db: AsyncSession):
"""处理输入中事件"""
from app.services.user_service import UserService
user_service = UserService(db)
user = await user_service.get_by_id(user_id)
from app.services.conversation_service import ConversationService
conv_service = ConversationService(db)
detail = await conv_service.get_conversation_detail(data["conversation_id"], user_id)
if detail and "members" in detail:
member_ids = [m["user_id"] for m in detail["members"]]
await manager.broadcast_to_conversation(
member_ids, EventType.CHAT_TYPING_INDICATOR,
{"conversation_id": data["conversation_id"], "user_id": user_id,
"username": user.username if user else "未知"},
exclude_user=user_id,
)
async def handle_chat_read(ws: WebSocket, user_id: str, data: dict, db: AsyncSession):
"""处理已读事件"""
from app.services.message_service import MessageService
service = MessageService(db)
await service.mark_as_read(data["conversation_id"], user_id, data["message_id"])
await db.commit()
from app.services.conversation_service import ConversationService
conv_service = ConversationService(db)
detail = await conv_service.get_conversation_detail(data["conversation_id"], user_id)
if detail and "members" in detail:
member_ids = [m["user_id"] for m in detail["members"]]
await manager.broadcast_to_conversation(
member_ids, EventType.CHAT_READ_RECEIPT,
{"conversation_id": data["conversation_id"], "user_id": user_id,
"read_up_to": data["message_id"]},
)
async def handle_presence_update(ws: WebSocket, user_id: str, data: dict, db: AsyncSession):
"""处理在线状态更新"""
from app.services.user_service import UserService
user_service = UserService(db)
await user_service.update_status(user_id, data["status"])
await db.commit()
event = EventType.PRESENCE_ONLINE if data["status"] == "online" else EventType.PRESENCE_OFFLINE
await manager.broadcast(event, {"user_id": user_id})
+76
View File
@@ -0,0 +1,76 @@
"""WebSocket 连接管理器"""
import json
from datetime import datetime, timezone
from typing import Dict, Set
from fastapi import WebSocket
class ConnectionManager:
"""管理所有 WebSocket 连接"""
def __init__(self):
# user_id -> set of WebSocket connections (一个用户可能有多个标签页)
self.active_connections: Dict[str, Set[WebSocket]] = {}
async def connect(self, websocket: WebSocket, user_id: str):
"""接受新连接"""
await websocket.accept()
if user_id not in self.active_connections:
self.active_connections[user_id] = set()
self.active_connections[user_id].add(websocket)
def disconnect(self, websocket: WebSocket, user_id: str):
"""断开连接"""
if user_id in self.active_connections:
self.active_connections[user_id].discard(websocket)
if not self.active_connections[user_id]:
del self.active_connections[user_id]
async def send_to_user(self, user_id: str, event_type: str, data: dict):
"""向指定用户发送事件"""
if user_id not in self.active_connections:
return
message = json.dumps({
"type": event_type,
"data": data,
"timestamp": datetime.utcnow().isoformat(),
}, ensure_ascii=False, default=str)
disconnected = set()
for ws in self.active_connections[user_id]:
try:
await ws.send_text(message)
except Exception:
disconnected.add(ws)
# 清理断开的连接
for ws in disconnected:
self.active_connections[user_id].discard(ws)
async def broadcast_to_conversation(self, user_ids: list[str],
event_type: str, data: dict,
exclude_user: str | None = None):
"""向会话中所有用户广播事件"""
for uid in user_ids:
if uid != exclude_user:
await self.send_to_user(uid, event_type, data)
async def broadcast(self, event_type: str, data: dict):
"""向所有在线用户广播"""
for user_id in list(self.active_connections.keys()):
await self.send_to_user(user_id, event_type, data)
def is_online(self, user_id: str) -> bool:
"""检查用户是否在线"""
return user_id in self.active_connections and len(self.active_connections[user_id]) > 0
def get_online_user_ids(self) -> list[str]:
"""获取所有在线用户 ID"""
return list(self.active_connections.keys())
# 全局单例
manager = ConnectionManager()
+101
View File
@@ -0,0 +1,101 @@
"""WebSocket 路由"""
import json
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import async_session
from app.utils.security import decode_access_token
from app.websocket.manager import manager
from app.websocket.events import EventType
from app.websocket.handlers import (
handle_chat_send, handle_chat_typing,
handle_chat_read, handle_presence_update,
)
websocket_router = APIRouter()
@websocket_router.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket, token: str = Query(None)):
"""WebSocket 连接端点"""
# 验证 Token
if not token:
await websocket.close(code=4001, reason="Missing token")
return
payload = decode_access_token(token)
if not payload:
await websocket.close(code=4001, reason="Invalid token")
return
user_id = payload.get("sub")
if not user_id:
await websocket.close(code=4001, reason="Invalid token payload")
return
# 接受连接
await manager.connect(websocket, user_id)
# 更新在线状态
async with async_session() as db:
from app.services.user_service import UserService
user_service = UserService(db)
await user_service.update_status(user_id, "online")
await db.commit()
# 广播上线通知
await manager.broadcast(EventType.PRESENCE_ONLINE, {"user_id": user_id})
print(f"🌿 用户 {user_id} 已连接 WebSocket")
try:
while True:
raw = await websocket.receive_text()
try:
event = json.loads(raw)
except json.JSONDecodeError:
await manager.send_to_user(user_id, EventType.ERROR, {"message": "无效的 JSON"})
continue
event_type = event.get("type")
data = event.get("data", {})
# 创建新的数据库会话处理事件
async with async_session() as db:
handler_map = {
EventType.CHAT_SEND: handle_chat_send,
EventType.CHAT_TYPING: handle_chat_typing,
EventType.CHAT_READ: handle_chat_read,
EventType.PRESENCE_UPDATE: handle_presence_update,
}
handler = handler_map.get(event_type)
if handler:
try:
await handler(websocket, user_id, data, db)
await db.commit()
except Exception as e:
await manager.send_to_user(
user_id, EventType.ERROR, {"message": str(e)}
)
await db.rollback()
else:
await manager.send_to_user(
user_id, EventType.ERROR,
{"message": f"未知事件类型: {event_type}"}
)
except WebSocketDisconnect:
manager.disconnect(websocket, user_id)
# 更新离线状态
async with async_session() as db:
from app.services.user_service import UserService
user_service = UserService(db)
await user_service.update_status(user_id, "offline")
await db.commit()
await manager.broadcast(EventType.PRESENCE_OFFLINE, {"user_id": user_id})
print(f"🌿 用户 {user_id} 已断开 WebSocket")
+33
View File
@@ -0,0 +1,33 @@
# FastAPI 核心
fastapi==0.115.*
uvicorn[standard]==0.34.*
# 数据库
sqlalchemy[asyncio]==2.0.*
asyncpg==0.30.*
alembic==1.14.*
# 数据验证
pydantic==2.10.*
pydantic-settings==2.7.*
email-validator==2.2.*
# 认证
python-jose[cryptography]==3.3.*
passlib[bcrypt]==1.7.*
bcrypt==4.2.*
# 文件上传
python-multipart==0.0.*
Pillow==11.1.*
# Redis
redis==5.2.*
# 工具
python-dotenv==1.0.*
# 测试
pytest==8.3.*
pytest-asyncio==0.24.*
httpx==0.28.*
+93
View File
@@ -0,0 +1,93 @@
services:
# ==================== PostgreSQL ====================
postgres:
image: postgres:16-alpine
container_name: qingye-postgres
restart: unless-stopped
environment:
POSTGRES_DB: qingye
POSTGRES_USER: qingye
POSTGRES_PASSWORD: qingye_secret
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U qingye"]
interval: 5s
timeout: 5s
retries: 5
networks:
- qingye-network
# ==================== Redis ====================
redis:
image: redis:7-alpine
container_name: qingye-redis
restart: unless-stopped
command: redis-server --appendonly yes
volumes:
- redisdata:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 5
networks:
- qingye-network
# ==================== Backend (FastAPI) ====================
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: qingye-backend
restart: unless-stopped
ports:
- "8000:8000"
environment:
DATABASE_URL: postgresql+asyncpg://qingye:qingye_secret@postgres:5432/qingye
REDIS_URL: redis://redis:6379/0
JWT_SECRET_KEY: qingye-jwt-secret-change-in-production
JWT_REFRESH_SECRET_KEY: qingye-refresh-secret-change-in-production
CORS_ORIGINS: http://localhost:5173
ADMIN_PASSWORD: admin123
volumes:
- ./backend:/app
- upload_data:/app/uploads
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
networks:
- qingye-network
# ==================== Frontend (Vue 3 + Vite) ====================
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: qingye-frontend
restart: unless-stopped
ports:
- "5173:5173"
environment:
VITE_API_BASE_URL: http://localhost:8000
VITE_WS_BASE_URL: ws://localhost:8000
volumes:
- ./frontend:/app
- /app/node_modules
depends_on:
- backend
networks:
- qingye-network
# ==================== Volumes ====================
volumes:
pgdata:
redisdata:
upload_data:
# ==================== Network ====================
networks:
qingye-network:
driver: bridge
+17
View File
@@ -0,0 +1,17 @@
FROM node:20-alpine
WORKDIR /app
# 复制 package 文件
COPY package.json package-lock.json* ./
# 安装依赖(使用国内镜像源)
RUN npm config set registry https://registry.npmmirror.com && npm install
# 复制项目代码(开发时通过 volume 挂载覆盖)
COPY . .
EXPOSE 5173
# 开发模式:Vite dev server,允许外部访问
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
+16
View File
@@ -0,0 +1,16 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string
readonly VITE_WS_BASE_URL: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>青叶 - QingYe</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+29
View File
@@ -0,0 +1,29 @@
{
"name": "qingye-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview",
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"vue": "^3.5.0",
"vue-router": "^4.5.0",
"pinia": "^3.0.0",
"naive-ui": "^2.40.0",
"axios": "^1.7.0",
"dayjs": "^1.11.0",
"echarts": "^5.5.0",
"vue-echarts": "^7.0.0",
"@vicons/ionicons5": "^0.12.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.0",
"typescript": "^5.7.0",
"vite": "^6.0.0",
"vue-tsc": "^2.2.0"
}
}
+68
View File
@@ -0,0 +1,68 @@
<template>
<n-config-provider :theme-overrides="themeOverrides">
<n-message-provider>
<n-dialog-provider>
<router-view />
</n-dialog-provider>
</n-message-provider>
</n-config-provider>
</template>
<script setup lang="ts">
import type { GlobalThemeOverrides } from 'naive-ui'
/** 青绿色主题配置 */
const themeOverrides: GlobalThemeOverrides = {
common: {
primaryColor: '#009688',
primaryColorHover: '#26A69A',
primaryColorPressed: '#00796B',
primaryColorSuppl: '#00897B',
successColor: '#4CAF50',
borderRadius: '8px',
borderRadiusSmall: '6px',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif',
},
Button: {
colorPrimary: '#009688',
colorHoverPrimary: '#26A69A',
colorPressedPrimary: '#00796B',
textColorPrimary: '#FFFFFF',
borderRadiusMedium: '8px',
},
Input: {
borderColorFocus: '#009688',
boxShadowFocus: '0 0 0 2px rgba(0, 150, 136, 0.2)',
caretColor: '#009688',
borderRadius: '8px',
},
Card: {
borderColor: '#D4E8E3',
borderRadius: '12px',
},
Menu: {
itemTextColorActive: '#009688',
itemTextColorActiveHover: '#26A69A',
itemIconColorActive: '#009688',
borderRadius: '8px',
},
Tabs: {
tabTextColorActiveLine: '#009688',
tabTextColorHoverLine: '#26A69A',
barColor: '#009688',
},
Tag: {
borderRadius: '6px',
},
DataTable: {
thColor: '#E0F2F1',
},
}
</script>
<style>
body {
margin: 0;
background-color: #F5FBF9;
}
</style>
+28
View File
@@ -0,0 +1,28 @@
import api from './client'
export const adminApi = {
login: (password: string) =>
api.post('/admin/login', { password }),
getDashboard: () => api.get('/admin/dashboard'),
getStats: (metric: string, days = 7) =>
api.get(`/admin/stats/${metric}`, { params: { days } }),
getUsers: (params?: { page?: number; page_size?: number; search?: string; status?: string }) =>
api.get('/admin/users', { params }),
banUser: (userId: string, isBanned: boolean, reason?: string) =>
api.put(`/admin/users/${userId}/ban`, { is_banned: isBanned, reason }),
deleteUser: (userId: string) =>
api.delete(`/admin/users/${userId}`),
getMessages: (params?: Record<string, string>) =>
api.get('/admin/messages', { params }),
getConfig: () => api.get('/admin/config'),
updateConfig: (configs: Record<string, string>) =>
api.put('/admin/config', { configs }),
}
+14
View File
@@ -0,0 +1,14 @@
import api from './client'
export const authApi = {
login: (username: string, password: string) =>
api.post('/auth/login', { username, password }),
register: (username: string, email: string, password: string) =>
api.post('/auth/register', { username, email, password }),
refresh: (refreshToken: string) =>
api.post('/auth/refresh', { refresh_token: refreshToken }),
getProfile: () => api.get('/users/me'),
}
+25
View File
@@ -0,0 +1,25 @@
import api from './client'
export const chatApi = {
getConversations: () => api.get('/conversations/'),
createPrivateConversation: (userId: string) =>
api.post('/conversations/', { type: 'private', member_ids: [userId] }),
createGroup: (name: string, memberIds: string[], description?: string) =>
api.post('/conversations/group', { name, member_ids: memberIds, description }),
getConversationDetail: (id: string) => api.get(`/conversations/${id}`),
getMessages: (conversationId: string, before?: string, limit = 50) => {
const params: Record<string, any> = { limit }
if (before) params.before = before
return api.get(`/conversations/${conversationId}/messages`, { params })
},
markAsRead: (conversationId: string, messageId: string) =>
api.put(`/conversations/${conversationId}/messages/${messageId}/read`),
deleteMessage: (conversationId: string, messageId: string) =>
api.delete(`/conversations/${conversationId}/messages/${messageId}`),
}
+49
View File
@@ -0,0 +1,49 @@
import axios from 'axios'
import type { AxiosInstance } from 'axios'
const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000'
const api: AxiosInstance = axios.create({
baseURL: `${API_BASE}/api/v1`,
timeout: 15000,
headers: { 'Content-Type': 'application/json' },
})
// 请求拦截器:附加 Token
api.interceptors.request.use((config) => {
const token = localStorage.getItem('access_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// 响应拦截器:处理 401 自动刷新
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true
const refreshToken = localStorage.getItem('refresh_token')
if (refreshToken) {
try {
const { data } = await axios.post(`${API_BASE}/api/v1/auth/refresh`, {
refresh_token: refreshToken,
})
localStorage.setItem('access_token', data.access_token)
localStorage.setItem('refresh_token', data.refresh_token)
originalRequest.headers.Authorization = `Bearer ${data.access_token}`
return api(originalRequest)
} catch {
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
window.location.href = '/login'
}
}
}
return Promise.reject(error)
},
)
export default api
+19
View File
@@ -0,0 +1,19 @@
import api from './client'
export const friendsApi = {
getFriends: () => api.get('/friends/'),
getPendingRequests: () => api.get('/friends/requests'),
sendRequest: (toUserId: string, message?: string) =>
api.post('/friends/request', { to_user_id: toUserId, message }),
acceptRequest: (requestId: string) =>
api.put(`/friends/request/${requestId}/accept`),
rejectRequest: (requestId: string) =>
api.put(`/friends/request/${requestId}/reject`),
removeFriend: (friendId: string) =>
api.delete(`/friends/${friendId}`),
}
+93
View File
@@ -0,0 +1,93 @@
/* 青叶全局样式 */
:root {
/* 青绿色主题色 */
--color-primary: #009688;
--color-primary-light: #26A69A;
--color-primary-lighter: #80CBC4;
--color-primary-lightest: #E0F2F1;
--color-primary-dark: #00796B;
--color-primary-darker: #004D40;
/* 背景 */
--color-bg: #F5FBF9;
--color-surface: #FFFFFF;
--color-surface-elevated: #FAFFFE;
/* 文字 */
--color-text-primary: #1A2E2A;
--color-text-secondary: #5F7A74;
--color-text-hint: #9DB5AE;
/* 边框 */
--color-border: #D4E8E3;
/* 状态色 */
--color-success: #4CAF50;
--color-warning: #FF9800;
--color-error: #EF5350;
--color-unread: #FF6B6B;
/* 聊天气泡 */
--color-bubble-self: #009688;
--color-bubble-self-text: #FFFFFF;
--color-bubble-other: #E8F5F1;
--color-bubble-other-text: #1A2E2A;
}
* {
box-sizing: border-box;
}
html, body {
margin: 0;
padding: 0;
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
color: var(--color-text-primary);
background-color: var(--color-bg);
}
#app {
height: 100%;
}
/* 自定义滚动条 */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-thumb {
background: var(--color-primary-lighter);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-primary-light);
}
::-webkit-scrollbar-track {
background: transparent;
}
/* 淡入动画 */
.fade-enter-active, .fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from, .fade-leave-to {
opacity: 0;
}
/* 滑入动画 */
.slide-enter-active, .slide-leave-active {
transition: transform 0.3s ease, opacity 0.3s ease;
}
.slide-enter-from {
transform: translateX(-20px);
opacity: 0;
}
.slide-leave-to {
transform: translateX(20px);
opacity: 0;
}
+88
View File
@@ -0,0 +1,88 @@
import { ref } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { useChatStore } from '@/stores/chat'
const WS_BASE = import.meta.env.VITE_WS_BASE_URL || 'ws://localhost:8000'
let ws: WebSocket | null = null
let reconnectAttempts = 0
const maxReconnectAttempts = 10
export function useWebSocket() {
const connected = ref(false)
function connect() {
const auth = useAuthStore()
if (!auth.accessToken) return
const url = `${WS_BASE}/ws?token=${auth.accessToken}`
ws = new WebSocket(url)
ws.onopen = () => {
connected.value = true
reconnectAttempts = 0
console.log('🌿 WebSocket 已连接')
}
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data)
handleEvent(msg)
} catch (e) {
console.error('WebSocket 消息解析失败:', e)
}
}
ws.onclose = () => {
connected.value = false
console.log('🌿 WebSocket 已断开')
// 自动重连
if (reconnectAttempts < maxReconnectAttempts) {
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000)
reconnectAttempts++
setTimeout(connect, delay)
}
}
ws.onerror = (error) => {
console.error('WebSocket 错误:', error)
}
}
function disconnect() {
if (ws) {
ws.close()
ws = null
}
connected.value = false
}
function send(type: string, data: any) {
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type, data }))
}
}
function handleEvent(event: { type: string; data: any }) {
const chatStore = useChatStore()
switch (event.type) {
case 'chat.message':
chatStore.addMessage(event.data)
break
case 'presence.online':
case 'presence.offline':
// 可以在这里更新联系人在线状态
console.log(`用户 ${event.data.user_id} ${event.type === 'presence.online' ? '上线' : '下线'}`)
break
case 'friend.request':
console.log('收到好友请求:', event.data)
break
case 'error':
console.error('服务端错误:', event.data.message)
break
}
}
return { connected, connect, disconnect, send }
}
+40
View File
@@ -0,0 +1,40 @@
<template>
<div class="admin-layout">
<aside class="admin-sidebar">
<div class="admin-logo">
<span style="font-size: 24px">🌿</span>
<span style="font-weight: 600; color: var(--color-primary-dark)">管理后台</span>
</div>
<nav class="admin-nav">
<router-link to="/admin/dashboard" class="admin-nav-item" active-class="active">📊 仪表盘</router-link>
<router-link to="/admin/users" class="admin-nav-item" active-class="active">👥 用户管理</router-link>
<router-link to="/admin/messages" class="admin-nav-item" active-class="active">💬 消息审查</router-link>
<router-link to="/admin/config" class="admin-nav-item" active-class="active"> 系统配置</router-link>
</nav>
<div style="margin-top: auto; padding: 16px">
<n-button text @click="$router.push('/chat')"> 返回青叶</n-button>
</div>
</aside>
<main class="admin-content">
<router-view />
</main>
</div>
</template>
<style scoped>
.admin-layout { display: flex; height: 100vh; }
.admin-sidebar {
width: 220px; background: var(--color-surface); border-right: 1px solid var(--color-border);
display: flex; flex-direction: column; padding-top: 16px;
}
.admin-logo { display: flex; align-items: center; gap: 8px; padding: 16px 20px; margin-bottom: 16px; }
.admin-nav { display: flex; flex-direction: column; gap: 2px; padding: 0 8px; }
.admin-nav-item {
display: flex; align-items: center; gap: 10px; padding: 12px 16px;
border-radius: 8px; text-decoration: none; color: var(--color-text-primary);
font-size: 14px; transition: all 0.2s;
}
.admin-nav-item:hover { background: var(--color-primary-lightest); }
.admin-nav-item.active { background: var(--color-primary-lightest); color: var(--color-primary); font-weight: 500; }
.admin-content { flex: 1; overflow-y: auto; padding: 24px; background: var(--color-bg); }
</style>
+48
View File
@@ -0,0 +1,48 @@
<template>
<div class="auth-layout">
<div class="auth-card">
<div class="auth-header">
<div class="logo">🌿</div>
<h1 class="title">青叶</h1>
<p class="subtitle">QingYe 清新社交</p>
</div>
<router-view />
</div>
</div>
</template>
<style scoped>
.auth-layout {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #E0F2F1 0%, #B2DFDB 50%, #80CBC4 100%);
}
.auth-card {
width: 420px;
background: #fff;
border-radius: 16px;
padding: 48px 40px;
box-shadow: 0 8px 32px rgba(0, 150, 136, 0.12);
}
.auth-header {
text-align: center;
margin-bottom: 36px;
}
.logo {
font-size: 48px;
margin-bottom: 8px;
}
.title {
font-size: 28px;
font-weight: 700;
color: var(--color-primary-dark);
margin: 0;
}
.subtitle {
color: var(--color-text-secondary);
font-size: 14px;
margin-top: 4px;
}
</style>
+203
View File
@@ -0,0 +1,203 @@
<template>
<div class="chat-layout">
<!-- 左侧会话列表面板 -->
<div class="left-panel" :class="{ collapsed: uiStore.sidebarCollapsed && uiStore.isMobile }">
<div class="panel-header">
<div class="user-info" @click="$router.push('/profile')">
<n-avatar v-if="auth.user?.avatar_url" :src="auth.user.avatar_url" :size="36" round />
<n-avatar v-else :size="36" round style="background: var(--color-primary)">
{{ (auth.user?.username || '?')[0].toUpperCase() }}
</n-avatar>
<span class="username">{{ auth.user?.username || '青叶用户' }}</span>
</div>
<div class="header-actions">
<n-button quaternary circle @click="$router.push('/contacts')">
<template #icon>👥</template>
</n-button>
</div>
</div>
<!-- 布局模式切换 -->
<div class="layout-switch">
<n-button-group size="small">
<n-button :type="uiStore.layoutMode === 'list' ? 'primary' : 'default'" @click="uiStore.setLayoutMode('list')">列表</n-button>
<n-button :type="uiStore.layoutMode === 'card' ? 'primary' : 'default'" @click="uiStore.setLayoutMode('card')">卡片</n-button>
<n-button :type="uiStore.layoutMode === 'waterfall' ? 'primary' : 'default'" @click="uiStore.setLayoutMode('waterfall')">瀑布流</n-button>
</n-button-group>
</div>
<router-view name="left" />
<div class="conversation-list">
<div v-if="chatStore.isLoading" style="text-align: center; padding: 40px; color: var(--color-text-hint)">加载中...</div>
<div v-else-if="chatStore.conversations.length === 0" class="empty-state">
<div style="font-size: 48px">💬</div>
<p>暂无消息</p>
<p style="font-size: 13px; color: var(--color-text-hint)">去通讯录找朋友聊天吧</p>
</div>
<!-- 列表模式 -->
<template v-if="uiStore.layoutMode === 'list'">
<div v-for="conv in chatStore.conversations" :key="conv.id"
class="conv-item" :class="{ active: chatStore.activeConversation === conv.id }"
@click="openChat(conv.id)">
<n-avatar :size="46" round :style="{ background: 'var(--color-primary)' }">
{{ (conv.name || '?')[0] }}
</n-avatar>
<div class="conv-info">
<div class="conv-top">
<span class="conv-name">{{ conv.name || '未命名' }}</span>
<span class="conv-time">{{ formatTime(conv.last_message_at) }}</span>
</div>
<div class="conv-bottom">
<span class="conv-preview">{{ conv.last_message_preview || '' }}</span>
<n-badge v-if="conv.unread_count > 0" :value="conv.unread_count" :max="99" />
</div>
</div>
</div>
</template>
<!-- 卡片模式 -->
<template v-else-if="uiStore.layoutMode === 'card'">
<div class="card-grid">
<div v-for="conv in chatStore.conversations" :key="conv.id"
class="conv-card" :class="{ active: chatStore.activeConversation === conv.id }"
@click="openChat(conv.id)">
<n-avatar :size="56" round :style="{ background: 'var(--color-primary)' }">
{{ (conv.name || '?')[0] }}
</n-avatar>
<div class="conv-card-name">{{ conv.name || '未命名' }}</div>
<div class="conv-card-preview">{{ conv.last_message_preview?.substring(0, 30) || '' }}</div>
<n-badge v-if="conv.unread_count > 0" :value="conv.unread_count" :max="99" style="position: absolute; top: 8px; right: 8px" />
</div>
</div>
</template>
<!-- 瀑布流模式 -->
<template v-else>
<div class="waterfall-grid">
<div v-for="conv in chatStore.conversations" :key="conv.id"
class="conv-waterfall" @click="openChat(conv.id)">
<div class="wf-header">
<n-avatar :size="32" round :style="{ background: 'var(--color-primary)' }">
{{ (conv.name || '?')[0] }}
</n-avatar>
<span class="wf-name">{{ conv.name }}</span>
</div>
<div class="wf-content">{{ conv.last_message_preview || '暂无消息' }}</div>
<div class="wf-footer">
<span>{{ formatTime(conv.last_message_at) }}</span>
<n-badge v-if="conv.unread_count > 0" :value="conv.unread_count" :max="99" />
</div>
</div>
</div>
</template>
</div>
</div>
<!-- 右侧聊天窗口 -->
<div class="right-panel">
<router-view />
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useChatStore } from '@/stores/chat'
import { useUiStore } from '@/stores/ui'
import { useWebSocket } from '@/composables/useWebSocket'
import dayjs from 'dayjs'
const router = useRouter()
const auth = useAuthStore()
const chatStore = useChatStore()
const uiStore = useUiStore()
const { connect } = useWebSocket()
onMounted(async () => {
await auth.fetchProfile()
await chatStore.fetchConversations()
connect()
})
function openChat(id: string) {
router.push(`/chat/${id}`)
}
function formatTime(time: string | null) {
if (!time) return ''
const d = dayjs(time)
const now = dayjs()
if (d.isSame(now, 'day')) return d.format('HH:mm')
if (d.isSame(now.subtract(1, 'day'), 'day')) return '昨天'
return d.format('MM/DD')
}
</script>
<style scoped>
.chat-layout { display: flex; height: 100vh; background: var(--color-bg); }
.left-panel {
width: 340px; background: var(--color-surface);
border-right: 1px solid var(--color-border);
display: flex; flex-direction: column;
transition: width 0.3s;
}
.left-panel.collapsed { width: 0; overflow: hidden; }
.panel-header {
display: flex; align-items: center; justify-content: space-between;
padding: 16px; border-bottom: 1px solid var(--color-border);
}
.user-info { display: flex; align-items: center; gap: 10px; cursor: pointer; }
.username { font-weight: 500; font-size: 15px; }
.layout-switch { padding: 8px 16px; border-bottom: 1px solid var(--color-border); text-align: center; }
.conversation-list { flex: 1; overflow-y: auto; }
.empty-state { text-align: center; padding: 80px 20px; color: var(--color-text-secondary); }
/* 列表模式 */
.conv-item {
display: flex; align-items: center; padding: 12px 16px; gap: 12px;
cursor: pointer; transition: background 0.15s; border-bottom: 0.5px solid var(--color-border);
}
.conv-item:hover { background: var(--color-primary-lightest); }
.conv-item.active { background: var(--color-primary-lightest); border-left: 3px solid var(--color-primary); }
.conv-info { flex: 1; min-width: 0; }
.conv-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; }
.conv-name { font-weight: 500; font-size: 15px; }
.conv-time { font-size: 12px; color: var(--color-text-hint); }
.conv-bottom { display: flex; justify-content: space-between; align-items: center; }
.conv-preview { font-size: 13px; color: var(--color-text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
/* 卡片模式 */
.card-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; padding: 12px; }
.conv-card {
background: var(--color-surface-elevated); border-radius: 12px; padding: 16px;
text-align: center; cursor: pointer; border: 1px solid var(--color-border);
transition: all 0.2s; position: relative;
}
.conv-card:hover { border-color: var(--color-primary-lighter); box-shadow: 0 2px 12px rgba(0,150,136,0.1); }
.conv-card.active { border-color: var(--color-primary); background: var(--color-primary-lightest); }
.conv-card-name { font-weight: 500; margin-top: 8px; font-size: 14px; }
.conv-card-preview { font-size: 12px; color: var(--color-text-hint); margin-top: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* 瀑布流模式 */
.waterfall-grid { columns: 2; column-gap: 10px; padding: 12px; }
.conv-waterfall {
break-inside: avoid; background: var(--color-surface-elevated); border-radius: 12px;
padding: 14px; margin-bottom: 10px; cursor: pointer; border: 1px solid var(--color-border);
transition: all 0.2s;
}
.conv-waterfall:hover { border-color: var(--color-primary-lighter); }
.wf-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.wf-name { font-weight: 500; font-size: 14px; }
.wf-content { font-size: 13px; color: var(--color-text-secondary); line-height: 1.5; margin-bottom: 8px; }
.wf-footer { display: flex; justify-content: space-between; align-items: center; font-size: 12px; color: var(--color-text-hint); }
.right-panel { flex: 1; display: flex; flex-direction: column; min-width: 0; }
@media (max-width: 768px) {
.left-panel { width: 100%; }
.right-panel { display: none; }
}
</style>
+38
View File
@@ -0,0 +1,38 @@
<template>
<div class="main-layout">
<aside class="sidebar">
<div class="sidebar-logo">🌿</div>
<nav class="sidebar-nav">
<router-link to="/chat" class="nav-item" active-class="active" title="消息">
<span class="nav-icon">💬</span>
</router-link>
<router-link to="/contacts" class="nav-item" active-class="active" title="通讯录">
<span class="nav-icon">👥</span>
</router-link>
<router-link to="/profile" class="nav-item" active-class="active" title="我的">
<span class="nav-icon">👤</span>
</router-link>
</nav>
</aside>
<main class="main-content">
<router-view />
</main>
</div>
</template>
<style scoped>
.main-layout { display: flex; height: 100vh; }
.sidebar {
width: 64px; background: var(--color-surface); border-right: 1px solid var(--color-border);
display: flex; flex-direction: column; align-items: center; padding-top: 16px;
}
.sidebar-logo { font-size: 28px; margin-bottom: 24px; }
.sidebar-nav { display: flex; flex-direction: column; gap: 8px; }
.nav-item {
width: 44px; height: 44px; display: flex; align-items: center; justify-content: center;
border-radius: 10px; text-decoration: none; font-size: 20px; transition: all 0.2s;
}
.nav-item:hover { background: var(--color-primary-lightest); }
.nav-item.active { background: var(--color-primary-lightest); color: var(--color-primary); }
.main-content { flex: 1; overflow-y: auto; background: var(--color-bg); }
</style>
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import router from './router'
import App from './App.vue'
import './assets/styles/global.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
+99
View File
@@ -0,0 +1,99 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const routes: RouteRecordRaw[] = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/auth/LoginView.vue'),
meta: { requiresAuth: false, layout: 'auth' },
},
{
path: '/register',
name: 'Register',
component: () => import('@/views/auth/RegisterView.vue'),
meta: { requiresAuth: false, layout: 'auth' },
},
{
path: '/',
component: () => import('@/layouts/ChatLayout.vue'),
meta: { requiresAuth: true },
children: [
{ path: '', redirect: '/chat' },
{
path: 'chat',
name: 'ChatList',
component: () => import('@/views/chat/ChatListView.vue'),
},
{
path: 'chat/:id',
name: 'ChatRoom',
component: () => import('@/views/chat/ChatRoomView.vue'),
},
],
},
{
path: '/contacts',
component: () => import('@/layouts/MainLayout.vue'),
meta: { requiresAuth: true },
children: [
{ path: '', name: 'Contacts', component: () => import('@/views/contacts/ContactsView.vue') },
{ path: 'search', name: 'Search', component: () => import('@/views/contacts/SearchView.vue') },
],
},
{
path: '/profile',
component: () => import('@/layouts/MainLayout.vue'),
meta: { requiresAuth: true },
children: [
{ path: '', name: 'Profile', component: () => import('@/views/profile/ProfileView.vue') },
],
},
{
path: '/admin',
children: [
{
path: 'login',
name: 'AdminLogin',
component: () => import('@/views/admin/AdminLoginView.vue'),
meta: { layout: 'auth' },
},
{
path: '',
component: () => import('@/layouts/AdminLayout.vue'),
meta: { requiresAdmin: true },
children: [
{ path: '', redirect: '/admin/dashboard' },
{ path: 'dashboard', name: 'AdminDashboard', component: () => import('@/views/admin/AdminDashboardView.vue') },
{ path: 'users', name: 'AdminUsers', component: () => import('@/views/admin/AdminUsersView.vue') },
{ path: 'messages', name: 'AdminMessages', component: () => import('@/views/admin/AdminMessagesView.vue') },
{ path: 'config', name: 'AdminConfig', component: () => import('@/views/admin/AdminConfigView.vue') },
],
},
],
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
// 路由守卫
router.beforeEach((to, _from, next) => {
const authStore = useAuthStore()
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
next({ name: 'Login', query: { redirect: to.fullPath } })
return
}
if (to.meta.requiresAuth === false && authStore.isAuthenticated) {
next({ name: 'ChatList' })
return
}
next()
})
export default router
+44
View File
@@ -0,0 +1,44 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { authApi } from '@/api/auth'
export const useAuthStore = defineStore('auth', () => {
const user = ref<any>(null)
const accessToken = ref(localStorage.getItem('access_token') || '')
const isAuthenticated = computed(() => !!accessToken.value && !!user.value)
async function login(username: string, password: string) {
const { data } = await authApi.login(username, password)
accessToken.value = data.access_token
localStorage.setItem('access_token', data.access_token)
localStorage.setItem('refresh_token', data.refresh_token)
user.value = data.user
}
async function register(username: string, email: string, password: string) {
const { data } = await authApi.register(username, email, password)
accessToken.value = data.access_token
localStorage.setItem('access_token', data.access_token)
localStorage.setItem('refresh_token', data.refresh_token)
user.value = data.user
}
async function fetchProfile() {
try {
const { data } = await authApi.getProfile()
user.value = data
} catch {
logout()
}
}
function logout() {
user.value = null
accessToken.value = ''
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
}
return { user, accessToken, isAuthenticated, login, register, fetchProfile, logout }
})
+49
View File
@@ -0,0 +1,49 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { chatApi } from '@/api/chat'
export const useChatStore = defineStore('chat', () => {
const conversations = ref<any[]>([])
const currentMessages = ref<any[]>([])
const activeConversation = ref<string | null>(null)
const isLoading = ref(false)
async function fetchConversations() {
isLoading.value = true
try {
const { data } = await chatApi.getConversations()
conversations.value = data
} finally {
isLoading.value = false
}
}
async function fetchMessages(conversationId: string, before?: string) {
const { data } = await chatApi.getMessages(conversationId, before)
if (before) {
currentMessages.value = [...data.messages, ...currentMessages.value]
} else {
currentMessages.value = data.messages
}
activeConversation.value = conversationId
}
function addMessage(message: any) {
currentMessages.value.push(message)
// 更新会话列表中的最后消息
const conv = conversations.value.find((c) => c.id === message.conversation_id)
if (conv) {
conv.last_message_preview = message.content?.substring(0, 50)
conv.last_message_at = message.created_at
}
}
async function markAsRead(conversationId: string, messageId: string) {
await chatApi.markAsRead(conversationId, messageId)
}
return {
conversations, currentMessages, activeConversation, isLoading,
fetchConversations, fetchMessages, addMessage, markAsRead,
}
})
+36
View File
@@ -0,0 +1,36 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export type LayoutMode = 'list' | 'card' | 'waterfall'
export type ChatStyle = 'classic' | 'compact' | 'bubble'
export const useUiStore = defineStore('ui', () => {
const layoutMode = ref<LayoutMode>((localStorage.getItem('layoutMode') as LayoutMode) || 'list')
const chatStyle = ref<ChatStyle>((localStorage.getItem('chatStyle') as ChatStyle) || 'bubble')
const sidebarCollapsed = ref(false)
const isMobile = ref(window.innerWidth < 768)
function setLayoutMode(mode: LayoutMode) {
layoutMode.value = mode
localStorage.setItem('layoutMode', mode)
}
function setChatStyle(style: ChatStyle) {
chatStyle.value = style
localStorage.setItem('chatStyle', style)
}
function toggleSidebar() {
sidebarCollapsed.value = !sidebarCollapsed.value
}
// 监听窗口大小
window.addEventListener('resize', () => {
isMobile.value = window.innerWidth < 768
})
return {
layoutMode, chatStyle, sidebarCollapsed, isMobile,
setLayoutMode, setChatStyle, toggleSidebar,
}
})
@@ -0,0 +1,48 @@
<template>
<div>
<h2 style="margin-top: 0; color: var(--color-primary-dark)"> 系统配置</h2>
<n-card>
<n-form label-placement="left" label-width="120">
<n-form-item v-for="config in configs" :key="config.key" :label="configLabels[config.key] || config.key">
<n-input v-if="config.key === 'announcement'" v-model:value="config.value" type="textarea" :rows="3" />
<n-switch v-else-if="config.key === 'allow_registration'" :value="config.value === 'true'"
@update:value="config.value = $event ? 'true' : 'false'" />
<n-input v-else v-model:value="config.value" />
</n-form-item>
<n-button type="primary" @click="saveConfigs" :loading="saving">保存配置</n-button>
</n-form>
</n-card>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useMessage } from 'naive-ui'
import { adminApi } from '@/api/admin'
const message = useMessage()
const configs = ref<{ key: string; value: string }[]>([])
const saving = ref(false)
const configLabels: Record<string, string> = {
platform_name: '平台名称',
announcement: '公告内容',
max_upload_size_mb: '最大上传大小 (MB)',
allow_registration: '允许新用户注册',
}
onMounted(async () => {
try { const { data } = await adminApi.getConfig(); configs.value = data } catch {}
})
async function saveConfigs() {
saving.value = true
try {
const map: Record<string, string> = {}
configs.value.forEach(c => { map[c.key] = c.value })
await adminApi.updateConfig(map)
message.success('配置已保存')
} catch { message.error('保存失败') }
finally { saving.value = false }
}
</script>
@@ -0,0 +1,88 @@
<template>
<div>
<h2 style="margin-top: 0; color: var(--color-primary-dark)">📊 数据仪表盘</h2>
<!-- 统计卡片 -->
<div class="stats-grid">
<n-card v-for="stat in statsCards" :key="stat.label" class="stat-card">
<div class="stat-icon">{{ stat.icon }}</div>
<div class="stat-value">{{ stat.value }}</div>
<div class="stat-label">{{ stat.label }}</div>
</n-card>
</div>
<!-- 图表区域 -->
<div class="charts-grid">
<n-card title="消息量趋势" style="height: 320px">
<v-chart :option="messageChartOption" autoresize style="height: 240px" />
</n-card>
<n-card title="用户注册趋势" style="height: 320px">
<v-chart :option="registrationChartOption" autoresize style="height: 240px" />
</n-card>
<n-card title="在线用户趋势" style="height: 320px">
<v-chart :option="onlineChartOption" autoresize style="height: 240px" />
</n-card>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import VChart from 'vue-echarts'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { LineChart, BarChart } from 'echarts/charts'
import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components'
import { adminApi } from '@/api/admin'
use([CanvasRenderer, LineChart, BarChart, GridComponent, TooltipComponent, LegendComponent])
const stats = ref<any>({})
const statsCards = computed(() => [
{ icon: '👥', label: '总用户数', value: stats.value.total_users || 0 },
{ icon: '🟢', label: '当前在线', value: stats.value.online_users || 0 },
{ icon: '💬', label: '消息总量', value: stats.value.total_messages || 0 },
{ icon: '📅', label: '今日消息', value: stats.value.today_messages || 0 },
])
const messageData = ref<{ date: string; value: number }[]>([])
const regData = ref<{ date: string; value: number }[]>([])
const onlineData = ref<{ date: string; value: number }[]>([])
const makeChartOption = (data: typeof messageData.value, color: string, type: string) => ({
tooltip: { trigger: 'axis' as const },
grid: { left: 40, right: 16, top: 16, bottom: 30 },
xAxis: { type: 'category' as const, data: data.value.map(d => d.date.slice(5)), axisLabel: { fontSize: 11 } },
yAxis: { type: 'value' as const, axisLabel: { fontSize: 11 } },
series: [{ data: data.value.map(d => d.value), type, smooth: true, itemStyle: { color }, areaStyle: type === 'line' ? { color: color + '33' } : undefined }],
})
const messageChartOption = computed(() => makeChartOption(messageData, '#009688', 'bar'))
const registrationChartOption = computed(() => makeChartOption(regData, '#26A69A', 'line'))
const onlineChartOption = computed(() => makeChartOption(onlineData, '#4CAF50', 'line'))
onMounted(async () => {
try {
const [dashRes, msgRes, regRes, onlRes] = await Promise.all([
adminApi.getDashboard(),
adminApi.getStats('messages', 7),
adminApi.getStats('registrations', 7),
adminApi.getStats('online', 7),
])
stats.value = dashRes.data
messageData.value = msgRes.data
regData.value = regRes.data
onlineData.value = onlRes.data
} catch {}
})
</script>
<style scoped>
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px; }
.stat-card { text-align: center; }
.stat-icon { font-size: 32px; margin-bottom: 8px; }
.stat-value { font-size: 28px; font-weight: 700; color: var(--color-primary); }
.stat-label { font-size: 14px; color: var(--color-text-secondary); margin-top: 4px; }
.charts-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(380px, 1fr)); gap: 16px; }
</style>
@@ -0,0 +1,54 @@
<template>
<div class="auth-layout">
<div class="auth-card">
<div class="auth-header">
<div class="logo">🌿</div>
<h1 class="title">管理后台</h1>
<p class="subtitle">青叶平台管理系统</p>
</div>
<n-form @submit.prevent="handleLogin">
<n-form-item label="管理员密码">
<n-input v-model:value="password" type="password" show-password-on="click" placeholder="请输入管理员密码" />
</n-form-item>
<n-button type="primary" block attr-type="submit" :loading="loading">登录</n-button>
<div style="text-align: center; margin-top: 16px">
<n-button text @click="$router.push('/login')"> 返回用户登录</n-button>
</div>
</n-form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useMessage } from 'naive-ui'
import { adminApi } from '@/api/admin'
const router = useRouter()
const message = useMessage()
const password = ref('')
const loading = ref(false)
async function handleLogin() {
if (!password.value) { message.warning('请输入密码'); return }
loading.value = true
try {
const { data } = await adminApi.login(password.value)
localStorage.setItem('admin_token', data.access_token)
message.success('登录成功')
router.push('/admin/dashboard')
} catch {
message.error('密码错误')
} finally { loading.value = false }
}
</script>
<style scoped>
.auth-layout { min-height: 100vh; display: flex; align-items: center; justify-content: center; background: linear-gradient(135deg, #004D40, #00796B, #009688); }
.auth-card { width: 400px; background: #fff; border-radius: 16px; padding: 48px 40px; box-shadow: 0 8px 32px rgba(0,0,0,0.15); }
.auth-header { text-align: center; margin-bottom: 36px; }
.logo { font-size: 48px; margin-bottom: 8px; }
.title { font-size: 24px; font-weight: 700; color: var(--color-primary-dark); margin: 0; }
.subtitle { color: var(--color-text-secondary); font-size: 14px; margin-top: 4px; }
</style>
@@ -0,0 +1,47 @@
<template>
<div>
<h2 style="margin-top: 0; color: var(--color-primary-dark)">💬 消息审查</h2>
<div class="toolbar">
<n-input v-model:value="filters.keyword" placeholder="搜索关键词..." style="width: 200px" />
<n-input v-model:value="filters.user_id" placeholder="用户ID..." style="width: 160px" />
<n-input v-model:value="filters.conversation_id" placeholder="会话ID..." style="width: 160px" />
<n-button type="primary" @click="searchMessages">搜索</n-button>
</div>
<n-data-table :columns="columns" :data="messages" :loading="loading" striped />
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { adminApi } from '@/api/admin'
const messages = ref<any[]>([])
const loading = ref(false)
const filters = reactive({ keyword: '', user_id: '', conversation_id: '' })
const columns = [
{ title: '发送者', key: 'sender_name', width: 100 },
{ title: '会话ID', key: 'conversation_id', width: 100, ellipsis: { tooltip: true } },
{ title: '内容', key: 'content', ellipsis: { tooltip: true } },
{ title: '类型', key: 'type', width: 80 },
{ title: '时间', key: 'created_at', width: 160, render: (row: any) => new Date(row.created_at).toLocaleString('zh-CN') },
]
async function searchMessages() {
loading.value = true
try {
const params: Record<string, string> = {}
if (filters.keyword) params.keyword = filters.keyword
if (filters.user_id) params.user_id = filters.user_id
if (filters.conversation_id) params.conversation_id = filters.conversation_id
const { data } = await adminApi.getMessages(params)
messages.value = data
} finally { loading.value = false }
}
onMounted(searchMessages)
</script>
<style scoped>
.toolbar { display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
</style>
@@ -0,0 +1,94 @@
<template>
<div>
<h2 style="margin-top: 0; color: var(--color-primary-dark)">👥 用户管理</h2>
<div class="toolbar">
<n-input v-model:value="search" placeholder="搜索用户名..." style="width: 240px" @update:value="loadUsers" />
<n-select v-model:value="statusFilter" :options="statusOptions" style="width: 140px" @update:value="loadUsers" />
</div>
<n-data-table :columns="columns" :data="users" :pagination="{ pageSize: 20 }" :loading="loading" striped />
</div>
</template>
<script setup lang="ts">
import { ref, h, onMounted } from 'vue'
import { NButton, useMessage, useDialog } from 'naive-ui'
import { adminApi } from '@/api/admin'
const message = useMessage()
const dialog = useDialog()
const users = ref<any[]>([])
const loading = ref(false)
const search = ref('')
const statusFilter = ref<string | null>(null)
const statusOptions = [
{ label: '全部', value: null },
{ label: '在线', value: 'online' },
{ label: '已封禁', value: 'banned' },
]
const columns = [
{ title: '用户名', key: 'username', width: 120 },
{ title: '邮箱', key: 'email', width: 180 },
{ title: '状态', key: 'status', width: 80, render: (row: any) => row.is_banned ? '🚫 已封禁' : row.status === 'online' ? '🟢 在线' : '⚫ 离线' },
{ title: '注册时间', key: 'created_at', width: 160, render: (row: any) => new Date(row.created_at).toLocaleString('zh-CN') },
{
title: '操作', key: 'actions', width: 160,
render: (row: any) => [
h(NButton, {
size: 'small', type: row.is_banned ? 'success' : 'warning',
onClick: () => toggleBan(row),
}, { default: () => row.is_banned ? '解封' : '封禁' }),
h(NButton, {
size: 'small', type: 'error', style: 'margin-left: 8px',
onClick: () => deleteUser(row),
}, { default: () => '删除' }),
],
},
]
async function loadUsers() {
loading.value = true
try {
const { data } = await adminApi.getUsers({
search: search.value || undefined,
status: statusFilter.value || undefined,
})
users.value = data.items
} finally { loading.value = false }
}
function toggleBan(user: any) {
dialog.warning({
title: user.is_banned ? '解封用户' : '封禁用户',
content: `确定要${user.is_banned ? '解封' : '封禁'} ${user.username} 吗?`,
positiveText: '确定',
negativeText: '取消',
onPositiveClick: async () => {
await adminApi.banUser(user.id, !user.is_banned)
message.success('操作成功')
await loadUsers()
},
})
}
function deleteUser(user: any) {
dialog.error({
title: '⚠️ 删除用户',
content: `确定要永久删除 ${user.username} 吗?此操作不可撤销!`,
positiveText: '确定删除',
negativeText: '取消',
onPositiveClick: async () => {
await adminApi.deleteUser(user.id)
message.success('已删除')
await loadUsers()
},
})
}
onMounted(loadUsers)
</script>
<style scoped>
.toolbar { display: flex; gap: 12px; margin-bottom: 16px; }
</style>
+50
View File
@@ -0,0 +1,50 @@
<template>
<n-form ref="formRef" :model="form" :rules="rules" @submit.prevent="handleLogin">
<n-form-item path="username" label="用户名">
<n-input v-model:value="form.username" placeholder="请输入用户名" />
</n-form-item>
<n-form-item path="password" label="密码">
<n-input v-model:value="form.password" type="password" show-password-on="click" placeholder="请输入密码" />
</n-form-item>
<n-button type="primary" block attr-type="submit" :loading="loading" style="margin-top: 8px">
登录
</n-button>
<div style="text-align: center; margin-top: 16px">
<n-button text type="primary" @click="$router.push('/register')">没有账号立即注册</n-button>
</div>
</n-form>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useMessage } from 'naive-ui'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const route = useRoute()
const message = useMessage()
const auth = useAuthStore()
const loading = ref(false)
const form = reactive({ username: '', password: '' })
const rules = {
username: { required: true, message: '请输入用户名', trigger: 'blur' },
password: { required: true, message: '请输入密码', trigger: 'blur' },
}
async function handleLogin() {
if (!form.username || !form.password) return
loading.value = true
try {
await auth.login(form.username, form.password)
message.success('登录成功!欢迎回到青叶 🌿')
const redirect = (route.query.redirect as string) || '/chat'
router.push(redirect)
} catch (e: any) {
message.error(e.response?.data?.detail || '登录失败,请检查用户名和密码')
} finally {
loading.value = false
}
}
</script>
+58
View File
@@ -0,0 +1,58 @@
<template>
<n-form :model="form" :rules="rules" @submit.prevent="handleRegister">
<n-form-item path="username" label="用户名">
<n-input v-model:value="form.username" placeholder="2-50个字符" />
</n-form-item>
<n-form-item path="email" label="邮箱">
<n-input v-model:value="form.email" placeholder="your@email.com" />
</n-form-item>
<n-form-item path="password" label="密码">
<n-input v-model:value="form.password" type="password" show-password-on="click" placeholder="至少6位" />
</n-form-item>
<n-form-item path="confirmPassword" label="确认密码">
<n-input v-model:value="form.confirmPassword" type="password" placeholder="再次输入密码" />
</n-form-item>
<n-button type="primary" block attr-type="submit" :loading="loading" style="margin-top: 8px">
注册
</n-button>
<div style="text-align: center; margin-top: 16px">
<n-button text type="primary" @click="$router.push('/login')">已有账号去登录</n-button>
</div>
</n-form>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { useMessage } from 'naive-ui'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const message = useMessage()
const auth = useAuthStore()
const loading = ref(false)
const form = reactive({ username: '', email: '', password: '', confirmPassword: '' })
const rules = {
username: { required: true, message: '请输入用户名', trigger: 'blur' },
email: { required: true, message: '请输入邮箱', trigger: 'blur' },
password: { required: true, min: 6, message: '密码至少6位', trigger: 'blur' },
}
async function handleRegister() {
if (form.password !== form.confirmPassword) {
message.error('两次密码不一致')
return
}
loading.value = true
try {
await auth.register(form.username, form.email, form.password)
message.success('注册成功!欢迎加入青叶 🌿')
router.push('/chat')
} catch (e: any) {
message.error(e.response?.data?.detail || '注册失败,请稍后重试')
} finally {
loading.value = false
}
}
</script>
+14
View File
@@ -0,0 +1,14 @@
<template>
<div class="chat-list-placeholder">
<div style="font-size: 64px">🌿</div>
<h2 style="color: var(--color-primary-dark)">青叶</h2>
<p style="color: var(--color-text-secondary)">选择一个会话开始聊天</p>
</div>
</template>
<style scoped>
.chat-list-placeholder {
flex: 1; display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 12px;
}
</style>
+155
View File
@@ -0,0 +1,155 @@
<template>
<div class="chat-room">
<!-- 聊天头部 -->
<div class="chat-header">
<n-button v-if="uiStore.isMobile" quaternary circle @click="$router.push('/chat')"></n-button>
<span class="room-name">{{ conversationName }}</span>
<!-- 聊天风格切换 -->
<n-button-group size="tiny" style="margin-left: auto">
<n-button :type="uiStore.chatStyle === 'classic' ? 'primary' : 'default'" @click="uiStore.setChatStyle('classic')">经典</n-button>
<n-button :type="uiStore.chatStyle === 'compact' ? 'primary' : 'default'" @click="uiStore.setChatStyle('compact')">紧凑</n-button>
<n-button :type="uiStore.chatStyle === 'bubble' ? 'primary' : 'default'" @click="uiStore.setChatStyle('bubble')">气泡</n-button>
</n-button-group>
</div>
<!-- 消息列表 -->
<div class="message-list" ref="messageListRef">
<div v-if="chatStore.currentMessages.length === 0" class="no-messages">
<p>开始聊天吧 🌿</p>
</div>
<div
v-for="msg in chatStore.currentMessages" :key="msg.id"
class="message-row" :class="{
'own': msg.sender_id === auth.user?.id,
'other': msg.sender_id !== auth.user?.id,
[`style-${uiStore.chatStyle}`]: true,
}"
>
<template v-if="msg.type === 'system'">
<div class="system-msg">{{ msg.content }}</div>
</template>
<template v-else>
<div v-if="msg.sender_id !== auth.user?.id" class="avatar">
<n-avatar :size="uiStore.chatStyle === 'compact' ? 28 : 34" round
:style="{ background: 'var(--color-primary)' }">
{{ (msg.sender_name || '?')[0] }}
</n-avatar>
</div>
<div class="bubble-area">
<div v-if="uiStore.chatStyle !== 'compact' && msg.sender_id !== auth.user?.id" class="sender-name">
{{ msg.sender_name }}
</div>
<div class="bubble" :class="{ 'bubble-self': msg.sender_id === auth.user?.id, 'bubble-other': msg.sender_id !== auth.user?.id }">
<img v-if="msg.type === 'image'" :src="msg.content" class="msg-image" />
<span v-else>{{ msg.content }}</span>
</div>
<div v-if="uiStore.chatStyle === 'classic'" class="msg-time">
{{ formatTime(msg.created_at) }}
</div>
</div>
<div v-if="msg.sender_id === auth.user?.id" class="avatar">
<n-avatar :size="uiStore.chatStyle === 'compact' ? 28 : 34" round
style="background: var(--color-primary-dark)"></n-avatar>
</div>
</template>
</div>
</div>
<!-- 输入框 -->
<div class="input-bar">
<n-input v-model:value="inputText" type="textarea" :autosize="{ minRows: 1, maxRows: 4 }"
placeholder="输入消息..." @keydown.enter.exact.prevent="sendMessage" />
<n-button type="primary" :disabled="!inputText.trim()" @click="sendMessage" circle>
</n-button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, nextTick, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useChatStore } from '@/stores/chat'
import { useAuthStore } from '@/stores/auth'
import { useUiStore } from '@/stores/ui'
import { useWebSocket } from '@/composables/useWebSocket'
import dayjs from 'dayjs'
const route = useRoute()
const chatStore = useChatStore()
const auth = useAuthStore()
const uiStore = useUiStore()
const { send } = useWebSocket()
const inputText = ref('')
const messageListRef = ref<HTMLElement>()
const conversationName = ref('')
onMounted(async () => {
const id = route.params.id as string
if (id) {
await chatStore.fetchMessages(id)
await nextTick()
scrollToBottom()
}
})
watch(() => chatStore.currentMessages.length, () => {
nextTick(scrollToBottom)
})
function scrollToBottom() {
if (messageListRef.value) {
messageListRef.value.scrollTop = messageListRef.value.scrollHeight
}
}
function sendMessage() {
const text = inputText.value.trim()
if (!text || !chatStore.activeConversation) return
inputText.value = ''
send('chat.send', {
conversation_id: chatStore.activeConversation,
content: text,
type: 'text',
})
}
function formatTime(time: string) {
return dayjs(time).format('HH:mm')
}
</script>
<style scoped>
.chat-room { display: flex; flex-direction: column; height: 100%; }
.chat-header {
display: flex; align-items: center; gap: 12px;
padding: 12px 20px; border-bottom: 1px solid var(--color-border);
background: var(--color-surface);
}
.room-name { font-weight: 600; font-size: 16px; }
.message-list { flex: 1; overflow-y: auto; padding: 16px 20px; }
.no-messages { text-align: center; padding-top: 120px; color: var(--color-text-hint); }
.message-row { display: flex; align-items: flex-start; margin-bottom: 12px; gap: 8px; }
.message-row.own { justify-content: flex-end; }
.message-row.other { justify-content: flex-start; }
.message-row.style-compact { margin-bottom: 4px; }
.bubble-area { max-width: 65%; }
.sender-name { font-size: 12px; color: var(--color-text-hint); margin-bottom: 2px; margin-left: 4px; }
.bubble { padding: 10px 14px; border-radius: 12px; font-size: 15px; line-height: 1.5; word-break: break-word; }
.bubble-self { background: var(--color-bubble-self); color: var(--color-bubble-self-text); border-top-right-radius: 4px; }
.bubble-other { background: var(--color-bubble-other); color: var(--color-bubble-other-text); border-top-left-radius: 4px; }
.msg-time { font-size: 11px; color: var(--color-text-hint); margin-top: 2px; }
.msg-image { max-width: 200px; border-radius: 8px; }
.system-msg { text-align: center; font-size: 12px; color: var(--color-text-hint); margin: 8px 0; padding: 4px 12px; }
.input-bar {
display: flex; align-items: flex-end; gap: 8px;
padding: 12px 20px; border-top: 1px solid var(--color-border);
background: var(--color-surface);
}
</style>
@@ -0,0 +1,107 @@
<template>
<div class="contacts-page">
<div class="page-header">
<h2>通讯录</h2>
<n-button type="primary" size="small" @click="$router.push('/contacts/search')">🔍 搜索添加</n-button>
</div>
<!-- 好友请求 -->
<div v-if="pendingRequests.length > 0" class="section">
<h3 class="section-title">好友请求 ({{ pendingRequests.length }})</h3>
<div v-for="req in pendingRequests" :key="req.id" class="request-item">
<n-avatar :size="40" round :style="{ background: 'var(--color-primary)' }">
{{ (req.from_username || '?')[0] }}
</n-avatar>
<div class="request-info">
<span class="request-name">{{ req.from_username }}</span>
<span class="request-msg">{{ req.message || '请求添加你为好友' }}</span>
</div>
<div class="request-actions">
<n-button type="primary" size="small" @click="acceptRequest(req.id)">接受</n-button>
<n-button size="small" @click="rejectRequest(req.id)">拒绝</n-button>
</div>
</div>
</div>
<!-- 好友列表 -->
<div class="section">
<h3 class="section-title">我的好友 ({{ friends.length }})</h3>
<div v-if="friends.length === 0" class="empty">
<p style="color: var(--color-text-hint)">还没有好友去搜索添加吧</p>
</div>
<div v-for="friend in friends" :key="friend.id" class="friend-item" @click="startChat(friend)">
<n-avatar :size="44" round :style="{ background: 'var(--color-primary)' }">
{{ (friend.username || '?')[0] }}
</n-avatar>
<div class="friend-info">
<span class="friend-name">{{ friend.remark || friend.username }}</span>
<span class="friend-status" :class="friend.status">{{ friend.status === 'online' ? '在线' : '离线' }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useMessage } from 'naive-ui'
import { friendsApi } from '@/api/friends'
import { chatApi } from '@/api/chat'
const router = useRouter()
const message = useMessage()
const friends = ref<any[]>([])
const pendingRequests = ref<any[]>([])
onMounted(async () => {
await Promise.all([loadFriends(), loadRequests()])
})
async function loadFriends() {
try { const { data } = await friendsApi.getFriends(); friends.value = data } catch {}
}
async function loadRequests() {
try { const { data } = await friendsApi.getPendingRequests(); pendingRequests.value = data } catch {}
}
async function acceptRequest(id: string) {
try {
await friendsApi.acceptRequest(id)
message.success('已添加好友')
await Promise.all([loadFriends(), loadRequests()])
} catch { message.error('操作失败') }
}
async function rejectRequest(id: string) {
try { await friendsApi.rejectRequest(id); await loadRequests() } catch {}
}
async function startChat(friend: any) {
try {
const { data } = await chatApi.createPrivateConversation(friend.friend_user_id)
router.push(`/chat/${data.id}`)
} catch { message.error('创建会话失败') }
}
</script>
<style scoped>
.contacts-page { max-width: 800px; margin: 0 auto; padding: 24px; }
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
.page-header h2 { margin: 0; color: var(--color-primary-dark); }
.section { margin-bottom: 24px; }
.section-title { font-size: 14px; color: var(--color-text-secondary); margin-bottom: 12px; padding-left: 4px; }
.request-item { display: flex; align-items: center; gap: 12px; padding: 12px; background: var(--color-surface); border-radius: 10px; margin-bottom: 8px; }
.request-info { flex: 1; }
.request-name { font-weight: 500; display: block; }
.request-msg { font-size: 13px; color: var(--color-text-hint); }
.request-actions { display: flex; gap: 8px; }
.friend-item { display: flex; align-items: center; gap: 12px; padding: 12px; background: var(--color-surface); border-radius: 10px; margin-bottom: 8px; cursor: pointer; transition: background 0.15s; }
.friend-item:hover { background: var(--color-primary-lightest); }
.friend-info { flex: 1; }
.friend-name { font-weight: 500; }
.friend-status { font-size: 12px; margin-left: 8px; }
.friend-status.online { color: var(--color-success); }
.friend-status.offline { color: var(--color-text-hint); }
.empty { text-align: center; padding: 40px; }
</style>
@@ -0,0 +1,58 @@
<template>
<div class="search-page">
<h2>搜索用户</h2>
<n-input v-model:value="keyword" placeholder="输入用户名或邮箱搜索..." size="large" @input="debouncedSearch" style="margin: 16px 0" />
<div v-if="results.length > 0">
<div v-for="user in results" :key="user.id" class="search-result">
<n-avatar :size="44" round :style="{ background: 'var(--color-primary)' }">{{ (user.username)[0] }}</n-avatar>
<div class="result-info">
<span class="result-name">{{ user.username }}</span>
<span class="result-bio">{{ user.bio || '这个人很懒,什么都没写' }}</span>
</div>
<n-button type="primary" size="small" @click="addFriend(user.id)">添加好友</n-button>
</div>
</div>
<div v-else-if="searched" class="empty">没有找到匹配的用户</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useMessage } from 'naive-ui'
import api from '@/api/client'
const message = useMessage()
const keyword = ref('')
const results = ref<any[]>([])
const searched = ref(false)
let timer: any = null
const debouncedSearch = () => {
clearTimeout(timer)
timer = setTimeout(async () => {
if (!keyword.value.trim()) { results.value = []; return }
try {
const { data } = await api.get('/users/search', { params: { q: keyword.value } })
results.value = data; searched.value = true
} catch {}
}, 400)
}
async function addFriend(userId: string) {
try {
await api.post('/friends/request', { to_user_id: userId })
message.success('好友请求已发送')
} catch (e: any) {
message.error(e.response?.data?.detail || '发送失败')
}
}
</script>
<style scoped>
.search-page { max-width: 600px; margin: 0 auto; padding: 24px; }
.search-result { display: flex; align-items: center; gap: 12px; padding: 12px; background: var(--color-surface); border-radius: 10px; margin-bottom: 8px; }
.result-info { flex: 1; }
.result-name { font-weight: 500; display: block; }
.result-bio { font-size: 13px; color: var(--color-text-hint); }
.empty { text-align: center; padding: 40px; color: var(--color-text-hint); }
</style>
@@ -0,0 +1,70 @@
<template>
<div class="profile-page">
<div class="profile-card">
<n-avatar :size="80" round :style="{ background: 'var(--color-primary)', fontSize: '32px' }">
{{ (auth.user?.username || '?')[0].toUpperCase() }}
</n-avatar>
<div class="profile-info">
<h2>{{ auth.user?.username }}</h2>
<p>{{ auth.user?.email }}</p>
<p class="bio">{{ auth.user?.bio || '这个人很懒,什么都没写' }}</p>
</div>
</div>
<n-card title="个人设置" style="margin-top: 16px">
<n-form :model="form" label-placement="left" label-width="80">
<n-form-item label="用户名">
<n-input v-model:value="form.username" />
</n-form-item>
<n-form-item label="个性签名">
<n-input v-model:value="form.bio" type="textarea" :rows="2" />
</n-form-item>
<n-button type="primary" @click="saveProfile">保存修改</n-button>
</n-form>
</n-card>
<n-card style="margin-top: 16px">
<n-button type="error" ghost block @click="handleLogout">退出登录</n-button>
</n-card>
</div>
</template>
<script setup lang="ts">
import { reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useMessage } from 'naive-ui'
import { useAuthStore } from '@/stores/auth'
import api from '@/api/client'
const router = useRouter()
const message = useMessage()
const auth = useAuthStore()
const form = reactive({ username: '', bio: '' })
onMounted(() => {
form.username = auth.user?.username || ''
form.bio = auth.user?.bio || ''
})
async function saveProfile() {
try {
await api.put('/users/me', form)
await auth.fetchProfile()
message.success('保存成功')
} catch { message.error('保存失败') }
}
function handleLogout() {
auth.logout()
router.push('/login')
}
</script>
<style scoped>
.profile-page { max-width: 600px; margin: 0 auto; padding: 24px; }
.profile-card { display: flex; align-items: center; gap: 20px; padding: 24px; background: var(--color-surface); border-radius: 16px; }
.profile-info h2 { margin: 0 0 4px; color: var(--color-primary-dark); }
.profile-info p { margin: 0; color: var(--color-text-secondary); font-size: 14px; }
.bio { margin-top: 8px !important; }
</style>
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"paths": {
"@/*": ["./src/*"]
},
"baseUrl": "."
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "env.d.ts"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+19
View File
@@ -0,0 +1,19 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
server: {
host: '0.0.0.0',
port: 5173,
watch: {
usePolling: true,
},
},
})
+7
View File
@@ -0,0 +1,7 @@
请帮我做一个社交软件,叫青叶,整体是青绿色,主要是聊天和群聊功能,界面简洁清新,有多种页面布局模式,功能丰富,有管理模式,要输入密码,可以查看平台详情。
以上系统采用前后端分离的方式实现。在当前文件夹分别创建frontend和backend。技术路线采用目前主流的路线,后端使用python。以上配置都通过docker实现,不要在宿主机里安装任何其他依赖,全部在docker运行。
基于当前的技术栈和项目环境,在.gitignore里补充适当的内容,忽视不必要追踪的文件
前端的用户和管理员界面,都看不到登录界面。用户界面目前一片空白。请检查。此外,需要实现热挂载,即修改了前端或后端代码后,不需要重启docker(除非必要),刷新就能看到效果。