detached3 #2

Merged
Kronecker merged 8 commits from detached3 into detached 2026-04-01 00:09:43 +08:00
66 changed files with 3778 additions and 384 deletions

4
.gitignore vendored
View File

@@ -9,4 +9,8 @@
/frontend/.vscode/
/frontend/.idea/
/开发路径.md
**/__pycache__/
**/*.pyc
**/logs/
**/logs/*.log

7
backend/.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
__pycache__
*.pyc
.env
.git
*.md
venv
.venv

28
backend/.env.production Normal file
View File

@@ -0,0 +1,28 @@
# ACG Blog 生产环境配置
# 复制此文件为 .env 并修改对应值
# 应用配置
APP_NAME=ACG Blog
APP_VERSION=1.0.0
DEBUG=false
# 数据库配置
DB_HOST=postgres
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=postgres
DB_NAME=acg_blog
# Redis 配置
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_DB=0
# JWT 配置 (请修改为随机字符串)
SECRET_KEY=your-super-secret-key-change-this-in-production
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
REFRESH_TOKEN_EXPIRE_DAYS=7
# CORS 配置 (前端域名)
BACKEND_CORS_ORIGINS=http://localhost,https://your-domain.com

25
backend/Dockerfile Normal file
View File

@@ -0,0 +1,25 @@
# FastAPI Backend
FROM python:3.11-slim
WORKDIR /app
# 安装系统依赖
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
# 复制依赖文件
COPY requirements.txt .
# 安装 Python 依赖
RUN pip install --no-cache-dir -r requirements.txt
# 复制应用代码
COPY . .
# 暴露端口
EXPOSE 8000
# 启动命令
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

1
backend/app/__init__.py Normal file
View File

@@ -0,0 +1 @@
# App module

View File

@@ -0,0 +1 @@
# API module

15
backend/app/api/api.py Normal file
View File

@@ -0,0 +1,15 @@
"""
API 路由汇总
"""
from fastapi import APIRouter
from app.api.endpoints import auth, users, posts, comments
api_router = APIRouter(prefix="/api/v1")
# 注册各模块路由
api_router.include_router(auth.router)
api_router.include_router(users.router)
api_router.include_router(posts.router)
api_router.include_router(posts.category_router)
api_router.include_router(posts.tag_router)
api_router.include_router(comments.router)

View File

@@ -0,0 +1 @@
# Endpoints module

View File

@@ -0,0 +1,109 @@
"""
认证 API 接口
"""
from fastapi import APIRouter, HTTPException, status
from app.schemas.auth import LoginRequest, RegisterRequest, TokenResponse, RefreshTokenRequest
from app.schemas.user import UserPublic
from app.crud.user import user_crud
from app.core.security import (
create_access_token,
create_refresh_token,
decode_token,
)
from app.core.logger import app_logger
router = APIRouter(prefix="/auth", tags=["认证"])
@router.post("/register", response_model=UserPublic, status_code=status.HTTP_201_CREATED)
async def register(request: RegisterRequest):
"""用户注册"""
# 检查用户名是否存在
if await user_crud.get_by_username(request.username):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="用户名已存在"
)
# 检查邮箱是否存在
if await user_crud.get_by_email(request.email):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="邮箱已被注册"
)
# 创建用户
user = await user_crud.create(
username=request.username,
email=request.email,
password=request.password
)
app_logger.info(f"New user registered: {user.username}")
return user
@router.post("/login", response_model=TokenResponse)
async def login(login_data: LoginRequest):
"""用户登录"""
username_or_email = login_data.username
password = login_data.password
# 验证用户
user = await user_crud.authenticate(username_or_email, password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户名或密码错误",
headers={"WWW-Authenticate": "Bearer"},
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="用户已被禁用"
)
# 生成令牌
token_data = {"sub": str(user.id), "username": user.username}
access_token = create_access_token(token_data)
refresh_token = create_refresh_token(token_data)
app_logger.info(f"User logged in: {user.username}")
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token,
token_type="bearer"
)
@router.post("/refresh", response_model=TokenResponse)
async def refresh_token(request: RefreshTokenRequest):
"""刷新令牌"""
payload = decode_token(request.refresh_token)
if payload.get("type") != "refresh":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="无效的刷新令牌"
)
user_id = payload.get("sub")
username = payload.get("username")
# 生成新令牌
token_data = {"sub": user_id, "username": username}
access_token = create_access_token(token_data)
refresh_token = create_refresh_token(token_data)
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token,
token_type="bearer"
)
@router.post("/logout")
async def logout():
"""用户登出(前端清除令牌即可)"""
return {"message": "登出成功"}

View File

@@ -0,0 +1,140 @@
"""
评论 API 接口
"""
from fastapi import APIRouter, HTTPException, status, Depends, Query
from app.schemas.comment import (
CommentCreate,
CommentUpdate,
CommentResponse,
CommentListResponse
)
from app.crud.comment import comment_crud
from app.crud.post import post_crud
from app.core.security import get_current_user_id
from app.core.logger import app_logger
router = APIRouter(prefix="/comments", tags=["评论"])
@router.get("/post/{post_id}", response_model=CommentListResponse)
async def get_post_comments(
post_id: str,
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
approved_only: bool = Query(True, description="仅显示已审核评论")
):
"""获取文章的所有评论"""
# 检查文章是否存在
post = await post_crud.get_by_id(post_id)
if not post:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="文章不存在"
)
comments, total = await comment_crud.get_by_post(
post_id=post_id,
page=page,
page_size=page_size,
approved_only=approved_only
)
return CommentListResponse(
items=comments,
total=total,
page=page,
page_size=page_size
)
@router.post("", response_model=CommentResponse, status_code=status.HTTP_201_CREATED)
async def create_comment(
comment_data: CommentCreate,
user_id: str = Depends(get_current_user_id)
):
"""创建评论"""
# 检查文章是否存在
post = await post_crud.get_by_id(comment_data.post_id)
if not post:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="文章不存在"
)
# 如果是回复,检查父评论是否存在
if comment_data.parent_id:
parent = await comment_crud.get_by_id(comment_data.parent_id)
if not parent:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="父评论不存在"
)
if str(parent.post_id) != comment_data.post_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="父评论不属于该文章"
)
comment = await comment_crud.create(
content=comment_data.content,
author_id=user_id,
post_id=comment_data.post_id,
parent_id=comment_data.parent_id
)
app_logger.info(f"Comment created on post {comment_data.post_id} by user {user_id}")
return comment
@router.put("/{comment_id}", response_model=CommentResponse)
async def update_comment(
comment_id: str,
comment_data: CommentUpdate,
user_id: str = Depends(get_current_user_id)
):
"""更新评论"""
comment = await comment_crud.get_by_id(comment_id)
if not comment:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="评论不存在"
)
# 检查权限(只能修改自己的评论)
if str(comment.author_id) != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="无权限修改此评论"
)
updated_comment = await comment_crud.update(
comment_id,
**comment_data.model_dump(exclude_unset=True)
)
app_logger.info(f"Comment updated: {comment_id}")
return updated_comment
@router.delete("/{comment_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_comment(
comment_id: str,
user_id: str = Depends(get_current_user_id)
):
"""删除评论"""
comment = await comment_crud.get_by_id(comment_id)
if not comment:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="评论不存在"
)
# 检查权限(只能删除自己的评论)
if str(comment.author_id) != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="无权限删除此评论"
)
await comment_crud.delete(comment_id)
app_logger.info(f"Comment deleted: {comment_id}")

View File

@@ -0,0 +1,288 @@
"""
文章 API 接口
"""
import math
from typing import Optional
from fastapi import APIRouter, HTTPException, status, Depends, Query
from app.schemas.post import (
PostCreate,
PostUpdate,
PostResponse,
PostListResponse,
TagCreate,
TagResponse,
CategoryCreate,
CategoryResponse
)
from app.schemas.post import AuthorResponse
from app.crud.post import post_crud
from app.crud.category import category_crud
from app.crud.tag import tag_crud
from app.crud.user import user_crud
from app.core.security import get_current_user_id
from app.core.logger import app_logger
router = APIRouter(prefix="/posts", tags=["文章"])
category_router = APIRouter(prefix="/categories", tags=["分类"])
tag_router = APIRouter(prefix="/tags", tags=["标签"])
# ==================== 文章接口 ====================
@router.get("", response_model=PostListResponse)
async def get_posts(
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(10, ge=1, le=100, description="每页数量"),
status: Optional[str] = Query("published", description="文章状态"),
category_id: Optional[str] = Query(None, description="分类ID"),
tag_id: Optional[str] = Query(None, description="标签ID")
):
"""获取文章列表"""
posts, total = await post_crud.get_all(
page=page,
page_size=page_size,
status=status,
category_id=category_id,
tag_id=tag_id
)
return PostListResponse(
items=posts,
total=total,
page=page,
page_size=page_size,
total_pages=math.ceil(total / page_size) if total > 0 else 0
)
@router.get("/hot", response_model=list[PostResponse])
async def get_hot_posts(limit: int = Query(10, ge=1, le=50)):
"""获取热门文章"""
posts = await post_crud.get_hot_posts(limit=limit)
return posts
@router.get("/recent", response_model=list[PostResponse])
async def get_recent_posts(limit: int = Query(10, ge=1, le=50)):
"""获取最新文章"""
posts = await post_crud.get_recent_posts(limit=limit)
return posts
@router.get("/{post_id}", response_model=PostResponse)
async def get_post(post_id: str):
"""获取文章详情"""
post = await post_crud.get_by_id(post_id)
if not post:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="文章不存在"
)
# 增加浏览量
await post_crud.increment_view_count(post_id)
return post
@router.post("", response_model=PostResponse, status_code=status.HTTP_201_CREATED)
async def create_post(
post_data: PostCreate,
user_id: str = Depends(get_current_user_id)
):
"""创建文章"""
# 检查 slug 是否已存在
if await post_crud.get_by_slug(post_data.slug):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="URL别名已存在"
)
post = await post_crud.create(
author_id=user_id,
**post_data.model_dump()
)
app_logger.info(f"Post created: {post.title} by user {user_id}")
return post
@router.put("/{post_id}", response_model=PostResponse)
async def update_post(
post_id: str,
post_data: PostUpdate,
user_id: str = Depends(get_current_user_id)
):
"""更新文章"""
post = await post_crud.get_by_id(post_id)
if not post:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="文章不存在"
)
# 检查权限
if str(post.author_id) != user_id:
is_admin = await user_crud.is_superuser(user_id)
if not is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="无权限修改此文章"
)
updated_post = await post_crud.update(
post_id,
**post_data.model_dump(exclude_unset=True)
)
app_logger.info(f"Post updated: {updated_post.title}")
return updated_post
@router.delete("/{post_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_post(
post_id: str,
user_id: str = Depends(get_current_user_id)
):
"""删除文章"""
post = await post_crud.get_by_id(post_id)
if not post:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="文章不存在"
)
# 检查权限
if str(post.author_id) != user_id:
is_admin = await user_crud.is_superuser(user_id)
if not is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="无权限删除此文章"
)
await post_crud.delete(post_id)
app_logger.info(f"Post deleted: {post_id}")
# ==================== 分类接口 ====================
@category_router.get("", response_model=list[CategoryResponse])
async def get_categories():
"""获取所有分类"""
return await category_crud.get_all()
@category_router.get("/{category_id}", response_model=CategoryResponse)
async def get_category(category_id: str):
"""获取分类详情"""
category = await category_crud.get_by_id(category_id)
if not category:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="分类不存在"
)
return category
@category_router.post("", response_model=CategoryResponse, status_code=status.HTTP_201_CREATED)
async def create_category(
category_data: CategoryCreate,
user_id: str = Depends(get_current_user_id)
):
"""创建分类"""
# 检查权限
is_admin = await user_crud.is_superuser(user_id)
if not is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="需要管理员权限"
)
# 检查 slug 是否已存在
if await category_crud.get_by_slug(category_data.slug):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="URL别名已存在"
)
category = await category_crud.create(**category_data.model_dump())
app_logger.info(f"Category created: {category.name}")
return category
@category_router.put("/{category_id}", response_model=CategoryResponse)
async def update_category(
category_id: str,
category_data: CategoryCreate,
user_id: str = Depends(get_current_user_id)
):
"""更新分类"""
is_admin = await user_crud.is_superuser(user_id)
if not is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="需要管理员权限"
)
category = await category_crud.update(category_id, **category_data.model_dump())
if not category:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="分类不存在"
)
return category
@category_router.delete("/{category_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_category(
category_id: str,
user_id: str = Depends(get_current_user_id)
):
"""删除分类"""
is_admin = await user_crud.is_superuser(user_id)
if not is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="需要管理员权限"
)
await category_crud.delete(category_id)
# ==================== 标签接口 ====================
@tag_router.get("", response_model=list[TagResponse])
async def get_tags():
"""获取所有标签"""
return await tag_crud.get_all()
@tag_router.get("/{tag_id}", response_model=TagResponse)
async def get_tag(tag_id: str):
"""获取标签详情"""
tag = await tag_crud.get_by_id(tag_id)
if not tag:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="标签不存在"
)
return tag
@tag_router.post("", response_model=TagResponse, status_code=status.HTTP_201_CREATED)
async def create_tag(
tag_data: TagCreate,
user_id: str = Depends(get_current_user_id)
):
"""创建标签"""
# 检查 slug 是否已存在
if await tag_crud.get_by_slug(tag_data.slug):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="URL别名已存在"
)
tag = await tag_crud.create(**tag_data.model_dump())
app_logger.info(f"Tag created: {tag.name}")
return tag

View File

@@ -0,0 +1,50 @@
"""
用户 API 接口
"""
from fastapi import APIRouter, HTTPException, status, Depends
from app.schemas.user import UserPublic, UserUpdate
from app.crud.user import user_crud
from app.core.security import get_current_user_id
from app.core.logger import app_logger
router = APIRouter(prefix="/users", tags=["用户"])
@router.get("/me", response_model=UserPublic)
async def get_current_user(user_id: str = Depends(get_current_user_id)):
"""获取当前用户信息"""
user = await user_crud.get_by_id(user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="用户不存在"
)
return user
@router.put("/me", response_model=UserPublic)
async def update_current_user(
user_update: UserUpdate,
user_id: str = Depends(get_current_user_id)
):
"""更新当前用户信息"""
user = await user_crud.update(user_id, **user_update.model_dump(exclude_unset=True))
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="用户不存在"
)
app_logger.info(f"User updated: {user.username}")
return user
@router.get("/{user_id}", response_model=UserPublic)
async def get_user(user_id: str):
"""获取指定用户信息"""
user = await user_crud.get_by_id(user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="用户不存在"
)
return user

View File

@@ -0,0 +1 @@
# Core module

View File

@@ -0,0 +1,67 @@
"""
应用配置模块
使用 Pydantic Settings 管理环境变量
"""
from pydantic_settings import BaseSettings
from functools import lru_cache
class Settings(BaseSettings):
"""应用配置类"""
# 应用基础配置
APP_NAME: str = "ACG Blog"
APP_VERSION: str = "0.1.0"
DEBUG: bool = True
# 数据库配置
DB_HOST: str = "localhost"
DB_PORT: int = 5432
DB_USER: str = "postgres"
DB_PASSWORD: str = "postgres"
DB_NAME: str = "acg_blog"
# Redis 配置
REDIS_HOST: str = "localhost"
REDIS_PORT: int = 6379
REDIS_DB: int = 0
# JWT 配置
SECRET_KEY: str = "your-secret-key-change-in-production"
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
# CORS 配置
BACKEND_CORS_ORIGINS: list[str] = [
"http://localhost:5173",
"http://localhost:3000",
]
@property
def DATABASE_URL(self) -> str:
"""生成数据库连接 URL"""
return f"postgres://{self.DB_USER}:{self.DB_PASSWORD}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}"
@property
def DATABASE_URL_ASYNC(self) -> str:
"""生成异步数据库连接 URL"""
return f"asyncpg://{self.DB_USER}:{self.DB_PASSWORD}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}"
@property
def REDIS_URL(self) -> str:
"""生成 Redis 连接 URL"""
return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
class Config:
env_file = ".env"
case_sensitive = True
@lru_cache()
def get_settings() -> Settings:
"""获取配置单例"""
return Settings()
settings = get_settings()

View File

@@ -0,0 +1,39 @@
"""
数据库模块
Tortoise-ORM 初始化配置
"""
from tortoise import Tortoise
from app.core.config import settings
from app.core.logger import app_logger
async def init_db():
"""初始化数据库连接"""
app_logger.info("Initializing database connection...")
await Tortoise.init(
db_url=settings.DATABASE_URL_ASYNC,
modules={
"models": [
"app.models.user",
"app.models.post",
"app.models.category",
"app.models.tag",
"app.models.comment",
]
},
use_tz=False, # 使用 UTC 时间
timezone="Asia/Shanghai", # 使用上海时区
)
# 生成 schema
await Tortoise.generate_schemas()
app_logger.info("Database connection established")
async def close_db():
"""关闭数据库连接"""
app_logger.info("Closing database connection...")
await Tortoise.close_connections()
app_logger.info("Database connection closed")

View File

@@ -0,0 +1,42 @@
"""
日志配置模块
使用 Loguru 实现异步日志处理
"""
import sys
from pathlib import Path
from loguru import logger
# 日志目录
LOG_DIR = Path(__file__).parent.parent.parent / "logs"
LOG_DIR.mkdir(exist_ok=True)
def setup_logger():
"""配置日志格式和输出"""
# 移除默认处理器
logger.remove()
# 控制台输出格式
logger.add(
sys.stdout,
format="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>",
level="INFO",
colorize=True,
)
# 文件输出格式
logger.add(
LOG_DIR / "acg_blog_{time:YYYY-MM-DD}.log",
format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}",
level="DEBUG",
rotation="00:00", # 每天零点轮换
retention="30 days", # 保留30天
compression="zip", # 压缩旧日志
encoding="utf-8",
)
return logger
# 初始化日志
app_logger = setup_logger()

View File

@@ -0,0 +1,82 @@
"""
安全模块
包含 JWT 令牌生成、验证和密码哈希功能
"""
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from app.core.config import settings
# 密码上下文(使用 bcrypt
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# OAuth2 方案
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""验证密码"""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""哈希密码"""
return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
"""创建访问令牌"""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire, "type": "access"})
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt
def create_refresh_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
"""创建刷新令牌"""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
to_encode.update({"exp": expire, "type": "refresh"})
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt
def decode_token(token: str) -> dict:
"""解码令牌"""
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
return payload
except JWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
async def get_current_user_id(token: str = Depends(oauth2_scheme)) -> str:
"""从令牌中获取当前用户 ID"""
payload = decode_token(token)
user_id: str = payload.get("sub")
if user_id is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
return user_id

View File

@@ -0,0 +1 @@
# CRUD module

View File

@@ -0,0 +1,72 @@
"""
分类 CRUD 操作
"""
from typing import Optional, List
from app.models.category import Category
class CategoryCRUD:
"""分类 CRUD 操作类"""
@staticmethod
async def get_by_id(category_id: str) -> Optional[Category]:
"""根据 ID 获取分类"""
return await Category.filter(id=category_id).first()
@staticmethod
async def get_by_slug(slug: str) -> Optional[Category]:
"""根据 slug 获取分类"""
return await Category.filter(slug=slug).first()
@staticmethod
async def get_all() -> List[Category]:
"""获取所有分类"""
return await Category.all()
@staticmethod
async def create(name: str, slug: str, description: Optional[str] = None) -> Category:
"""创建分类"""
category = await Category.create(
name=name,
slug=slug,
description=description
)
return category
@staticmethod
async def update(category_id: str, **kwargs) -> Optional[Category]:
"""更新分类"""
category = await Category.filter(id=category_id).first()
if category:
for key, value in kwargs.items():
if value is not None and hasattr(category, key):
setattr(category, key, value)
await category.save()
return category
@staticmethod
async def delete(category_id: str) -> bool:
"""删除分类"""
category = await Category.filter(id=category_id).first()
if category:
await category.delete()
return True
return False
@staticmethod
async def get_with_post_count() -> List[dict]:
"""获取分类及其文章数量"""
categories = await Category.all().prefetch_related("posts")
result = []
for cat in categories:
result.append({
"id": str(cat.id),
"name": cat.name,
"slug": cat.slug,
"description": cat.description,
"post_count": len(cat.posts) if cat.posts else 0
})
return result
category_crud = CategoryCRUD()

View File

@@ -0,0 +1,91 @@
"""
评论 CRUD 操作
"""
from typing import Optional, List, Tuple
from app.models.comment import Comment
from app.models.post import Post
class CommentCRUD:
"""评论 CRUD 操作类"""
@staticmethod
async def get_by_id(comment_id: str) -> Optional[Comment]:
"""根据 ID 获取评论"""
return await Comment.filter(id=comment_id).first()
@staticmethod
async def get_by_post(
post_id: str,
page: int = 1,
page_size: int = 20,
approved_only: bool = True
) -> Tuple[List[Comment], int]:
"""获取文章的所有评论(树形结构)"""
query = Comment.filter(post_id=post_id)
if approved_only:
query = query.filter(is_approved=True)
total = await query.count()
comments = await query \
.prefetch_related("author", "replies__author") \
.filter(parent_id=None) \
.offset((page - 1) * page_size) \
.limit(page_size) \
.order_by("created_at")
return comments, total
@staticmethod
async def create(
content: str,
author_id: str,
post_id: str,
parent_id: Optional[str] = None,
is_approved: bool = True
) -> Comment:
"""创建评论"""
comment = await Comment.create(
content=content,
author_id=author_id,
post_id=post_id,
parent_id=parent_id,
is_approved=is_approved
)
# 更新文章的评论数
await Post.filter(id=post_id).update(comment_count=Post.comment_count + 1)
return comment
@staticmethod
async def update(comment_id: str, **kwargs) -> Optional[Comment]:
"""更新评论"""
comment = await Comment.filter(id=comment_id).first()
if comment:
for key, value in kwargs.items():
if value is not None and hasattr(comment, key):
setattr(comment, key, value)
await comment.save()
return comment
@staticmethod
async def delete(comment_id: str) -> bool:
"""删除评论"""
comment = await Comment.filter(id=comment_id).first()
if comment:
post_id = comment.post_id
await comment.delete()
# 更新文章的评论数
await Post.filter(id=post_id).update(comment_count=Post.comment_count - 1)
return True
return False
@staticmethod
async def approve(comment_id: str) -> Optional[Comment]:
"""审核通过评论"""
return await CommentCRUD.update(comment_id, is_approved=True)
comment_crud = CommentCRUD()

163
backend/app/crud/post.py Normal file
View File

@@ -0,0 +1,163 @@
"""
文章 CRUD 操作
"""
from datetime import datetime
from typing import Optional, List, Tuple
from app.models.post import Post, PostTag
from app.models.user import User
from app.models.category import Category
from app.models.tag import Tag
class PostCRUD:
"""文章 CRUD 操作类"""
@staticmethod
async def get_by_id(post_id: str) -> Optional[Post]:
"""根据 ID 获取文章"""
return await Post.filter(id=post_id).first()
@staticmethod
async def get_by_slug(slug: str) -> Optional[Post]:
"""根据 slug 获取文章"""
return await Post.filter(slug=slug).first()
@staticmethod
async def get_all(
page: int = 1,
page_size: int = 10,
status: str = "published",
category_id: Optional[str] = None,
tag_id: Optional[str] = None
) -> Tuple[List[Post], int]:
"""获取文章列表(分页)"""
query = Post.all()
if status:
query = query.filter(status=status)
if category_id:
query = query.filter(category_id=category_id)
if tag_id:
query = query.filter(tags__id=tag_id)
total = await query.count()
posts = await query \
.prefetch_related("author", "category", "tags") \
.offset((page - 1) * page_size) \
.limit(page_size) \
.order_by("-created_at")
return posts, total
@staticmethod
async def get_by_author(
author_id: str,
page: int = 1,
page_size: int = 10
) -> Tuple[List[Post], int]:
"""获取指定作者的文章列表"""
query = Post.filter(author_id=author_id)
total = await query.count()
posts = await query \
.prefetch_related("author", "category", "tags") \
.offset((page - 1) * page_size) \
.limit(page_size) \
.order_by("-created_at")
return posts, total
@staticmethod
async def create(
title: str,
slug: str,
content: str,
author_id: str,
summary: Optional[str] = None,
cover_image: Optional[str] = None,
category_id: Optional[str] = None,
tag_ids: Optional[List[str]] = None,
status: str = "draft",
meta_title: Optional[str] = None,
meta_description: Optional[str] = None
) -> Post:
"""创建文章"""
post = await Post.create(
title=title,
slug=slug,
content=content,
author_id=author_id,
summary=summary,
cover_image=cover_image,
category_id=category_id,
status=status,
meta_title=meta_title,
meta_description=meta_description
)
# 添加标签
if tag_ids:
for tag_id in tag_ids:
await PostTag.create(post_id=post.id, tag_id=tag_id)
return post
@staticmethod
async def update(post_id: str, **kwargs) -> Optional[Post]:
"""更新文章"""
post = await Post.filter(id=post_id).first()
if not post:
return None
# 处理标签更新
if "tag_ids" in kwargs:
tag_ids = kwargs.pop("tag_ids")
# 删除旧标签关联
await PostTag.filter(post_id=post_id).delete()
# 添加新标签关联
for tag_id in tag_ids:
await PostTag.create(post_id=post_id, tag_id=tag_id)
# 处理发布状态更新
if kwargs.get("status") == "published" and not post.published_at:
kwargs["published_at"] = datetime.utcnow()
for key, value in kwargs.items():
if value is not None and hasattr(post, key):
setattr(post, key, value)
await post.save()
return post
@staticmethod
async def delete(post_id: str) -> bool:
"""删除文章"""
post = await Post.filter(id=post_id).first()
if post:
await post.delete()
return True
return False
@staticmethod
async def increment_view_count(post_id: str) -> None:
"""增加浏览量"""
await Post.filter(id=post_id).update(view_count=Post.view_count + 1)
@staticmethod
async def get_hot_posts(limit: int = 10) -> List[Post]:
"""获取热门文章(按浏览量排序)"""
return await Post.filter(status="published") \
.prefetch_related("author", "category") \
.order_by("-view_count") \
.limit(limit)
@staticmethod
async def get_recent_posts(limit: int = 10) -> List[Post]:
"""获取最新文章"""
return await Post.filter(status="published") \
.prefetch_related("author", "category", "tags") \
.order_by("-created_at") \
.limit(limit)
post_crud = PostCRUD()

66
backend/app/crud/tag.py Normal file
View File

@@ -0,0 +1,66 @@
"""
标签 CRUD 操作
"""
from typing import Optional, List
from app.models.tag import Tag
class TagCRUD:
"""标签 CRUD 操作类"""
@staticmethod
async def get_by_id(tag_id: str) -> Optional[Tag]:
"""根据 ID 获取标签"""
return await Tag.filter(id=tag_id).first()
@staticmethod
async def get_by_slug(slug: str) -> Optional[Tag]:
"""根据 slug 获取标签"""
return await Tag.filter(slug=slug).first()
@staticmethod
async def get_by_name(name: str) -> Optional[Tag]:
"""根据名称获取标签"""
return await Tag.filter(name=name).first()
@staticmethod
async def get_all() -> List[Tag]:
"""获取所有标签"""
return await Tag.all()
@staticmethod
async def create(name: str, slug: str) -> Tag:
"""创建标签"""
tag = await Tag.create(name=name, slug=slug)
return tag
@staticmethod
async def update(tag_id: str, **kwargs) -> Optional[Tag]:
"""更新标签"""
tag = await Tag.filter(id=tag_id).first()
if tag:
for key, value in kwargs.items():
if value is not None and hasattr(tag, key):
setattr(tag, key, value)
await tag.save()
return tag
@staticmethod
async def delete(tag_id: str) -> bool:
"""删除标签"""
tag = await Tag.filter(id=tag_id).first()
if tag:
await tag.delete()
return True
return False
@staticmethod
async def get_or_create(name: str, slug: str) -> Tag:
"""获取或创建标签"""
tag = await TagCRUD.get_by_slug(slug)
if tag:
return tag
return await TagCRUD.create(name, slug)
tag_crud = TagCRUD()

75
backend/app/crud/user.py Normal file
View File

@@ -0,0 +1,75 @@
"""
用户 CRUD 操作
"""
from typing import Optional
from app.models.user import User
from app.core.security import get_password_hash, verify_password
class UserCRUD:
"""用户 CRUD 操作类"""
@staticmethod
async def get_by_id(user_id: str) -> Optional[User]:
"""根据 ID 获取用户"""
return await User.filter(id=user_id).first()
@staticmethod
async def get_by_username(username: str) -> Optional[User]:
"""根据用户名获取用户"""
return await User.filter(username=username).first()
@staticmethod
async def get_by_email(email: str) -> Optional[User]:
"""根据邮箱获取用户"""
return await User.filter(email=email).first()
@staticmethod
async def get_by_username_or_email(username_or_email: str) -> Optional[User]:
"""根据用户名或邮箱获取用户"""
return await User.filter(
username=username_or_email
).first() or await User.filter(
email=username_or_email
).first()
@staticmethod
async def create(username: str, email: str, password: str) -> User:
"""创建用户"""
password_hash = get_password_hash(password)
user = await User.create(
username=username,
email=email,
password_hash=password_hash
)
return user
@staticmethod
async def update(user_id: str, **kwargs) -> Optional[User]:
"""更新用户"""
user = await User.filter(id=user_id).first()
if user:
for key, value in kwargs.items():
if value is not None and hasattr(user, key):
setattr(user, key, value)
await user.save()
return user
@staticmethod
async def authenticate(username_or_email: str, password: str) -> Optional[User]:
"""验证用户登录"""
user = await UserCRUD.get_by_username_or_email(username_or_email)
if not user:
return None
if not verify_password(password, user.password_hash):
return None
return user
@staticmethod
async def is_superuser(user_id: str) -> bool:
"""检查用户是否为超级用户"""
user = await User.filter(id=user_id).first()
return user.is_superuser if user else False
user_crud = UserCRUD()

View File

@@ -0,0 +1 @@
# Models module

View File

@@ -0,0 +1,22 @@
"""
分类模型
"""
import uuid
from tortoise import fields, models
class Category(models.Model):
"""分类模型"""
id = fields.UUIDField(pk=True, default=uuid.uuid4)
name = fields.CharField(max_length=50, unique=True, description="分类名称")
slug = fields.CharField(max_length=50, unique=True, description="URL别名")
description = fields.TextField(null=True, description="分类描述")
created_at = fields.DatetimeField(auto_now_add=True, description="创建时间")
class Meta:
table = "categories"
ordering = ["name"]
def __str__(self):
return self.name

View File

@@ -0,0 +1,49 @@
"""
评论模型
"""
import uuid
from tortoise import fields, models
class Comment(models.Model):
"""评论模型"""
id = fields.UUIDField(pk=True, default=uuid.uuid4)
content = fields.TextField(description="评论内容")
is_approved = fields.BooleanField(default=True, description="是否审核通过")
# 关联用户(评论者)
author = fields.ForeignKeyField(
"models.User",
related_name="comments",
on_delete=fields.CASCADE,
description="评论者"
)
# 关联文章
post = fields.ForeignKeyField(
"models.Post",
related_name="comments",
on_delete=fields.CASCADE,
description="所属文章"
)
# 自关联(回复)
parent = fields.ForeignKeyField(
"models.Comment",
related_name="replies",
on_delete=fields.CASCADE,
null=True,
description="父评论"
)
# 时间戳
created_at = fields.DatetimeField(auto_now_add=True, description="创建时间")
updated_at = fields.DatetimeField(auto_now=True, description="更新时间")
class Meta:
table = "comments"
ordering = ["created_at"]
def __str__(self):
return f"Comment by {self.author.username} on {self.post.title}"

View File

@@ -0,0 +1,94 @@
"""
文章模型
"""
import uuid
from datetime import datetime
from tortoise import fields, models
class Post(models.Model):
"""文章模型"""
id = fields.UUIDField(pk=True, default=uuid.uuid4)
title = fields.CharField(max_length=200, description="文章标题")
slug = fields.CharField(max_length=200, unique=True, description="URL别名")
content = fields.TextField(description="文章内容(Markdown)")
summary = fields.TextField(null=True, description="文章摘要")
cover_image = fields.CharField(max_length=500, null=True, description="封面图片URL")
# 关联用户(作者)
author = fields.ForeignKeyField(
"models.User",
related_name="posts",
on_delete=fields.CASCADE,
description="作者"
)
# 关联分类
category = fields.ForeignKeyField(
"models.Category",
related_name="posts",
on_delete=fields.SET_NULL,
null=True,
description="分类"
)
# 标签(多对多)
tags = fields.ManyToManyField(
"models.Tag",
related_name="posts",
through="post_tags",
description="标签"
)
# 统计数据
view_count = fields.IntField(default=0, description="浏览量")
like_count = fields.IntField(default=0, description="点赞数")
comment_count = fields.IntField(default=0, description="评论数")
# 状态draft(草稿), published(已发布), archived(已归档)
status = fields.CharField(
max_length=20,
default="draft",
description="文章状态"
)
# SEO
meta_title = fields.CharField(max_length=200, null=True, description="SEO标题")
meta_description = fields.TextField(null=True, description="SEO描述")
# 时间戳
published_at = fields.DatetimeField(null=True, description="发布时间")
created_at = fields.DatetimeField(auto_now_add=True, description="创建时间")
updated_at = fields.DatetimeField(auto_now=True, description="更新时间")
class Meta:
table = "posts"
ordering = ["-created_at"]
indexes = [
("status", "published_at"),
("author", "status"),
]
def __str__(self):
return self.title
class PostTag(models.Model):
"""文章-标签关联表"""
id = fields.UUIDField(pk=True, default=uuid.uuid4)
post = fields.ForeignKeyField(
"models.Post",
on_delete=fields.CASCADE,
description="文章"
)
tag = fields.ForeignKeyField(
"models.Tag",
on_delete=fields.CASCADE,
description="标签"
)
class Meta:
table = "post_tags"
unique_together = (("post", "tag"),)

21
backend/app/models/tag.py Normal file
View File

@@ -0,0 +1,21 @@
"""
标签模型
"""
import uuid
from tortoise import fields, models
class Tag(models.Model):
"""标签模型"""
id = fields.UUIDField(pk=True, default=uuid.uuid4)
name = fields.CharField(max_length=50, unique=True, description="标签名称")
slug = fields.CharField(max_length=50, unique=True, description="URL别名")
created_at = fields.DatetimeField(auto_now_add=True, description="创建时间")
class Meta:
table = "tags"
ordering = ["name"]
def __str__(self):
return self.name

View File

@@ -0,0 +1,28 @@
"""
用户模型
"""
import uuid
from datetime import datetime
from tortoise import fields, models
class User(models.Model):
"""用户模型"""
id = fields.UUIDField(pk=True, default=uuid.uuid4)
username = fields.CharField(max_length=50, unique=True, description="用户名")
email = fields.CharField(max_length=255, unique=True, description="邮箱")
password_hash = fields.CharField(max_length=255, description="密码哈希")
avatar = fields.CharField(max_length=500, null=True, description="头像URL")
bio = fields.TextField(null=True, description="个人简介")
is_active = fields.BooleanField(default=True, description="是否激活")
is_superuser = fields.BooleanField(default=False, description="是否超级用户")
created_at = fields.DatetimeField(auto_now_add=True, description="创建时间")
updated_at = fields.DatetimeField(auto_now=True, description="更新时间")
class Meta:
table = "users"
ordering = ["-created_at"]
def __str__(self):
return self.username

View File

@@ -0,0 +1 @@
# Schemas module

View File

@@ -0,0 +1,29 @@
"""
认证 Schema
"""
from pydantic import BaseModel, EmailStr, Field
class LoginRequest(BaseModel):
"""登录请求 Schema"""
username: str = Field(..., description="用户名或邮箱")
password: str = Field(..., description="密码")
class RegisterRequest(BaseModel):
"""注册请求 Schema"""
username: str = Field(..., min_length=3, max_length=50, description="用户名")
email: EmailStr = Field(..., description="邮箱")
password: str = Field(..., min_length=6, max_length=100, description="密码")
class TokenResponse(BaseModel):
"""令牌响应 Schema"""
access_token: str = Field(..., description="访问令牌")
refresh_token: str = Field(..., description="刷新令牌")
token_type: str = Field(default="bearer", description="令牌类型")
class RefreshTokenRequest(BaseModel):
"""刷新令牌请求 Schema"""
refresh_token: str = Field(..., description="刷新令牌")

View File

@@ -0,0 +1,55 @@
"""
评论 Schema
"""
from datetime import datetime
from typing import Optional, List
from pydantic import BaseModel, Field
class CommentBase(BaseModel):
"""评论基础 Schema"""
content: str = Field(..., min_length=1, description="评论内容")
class CommentCreate(CommentBase):
"""评论创建 Schema"""
post_id: str = Field(..., description="文章ID")
parent_id: Optional[str] = Field(None, description="父评论ID")
class CommentUpdate(BaseModel):
"""评论更新 Schema"""
content: Optional[str] = Field(None, min_length=1)
class CommentAuthor(BaseModel):
"""评论作者 Schema"""
id: str
username: str
avatar: Optional[str] = None
class Config:
from_attributes = True
class CommentResponse(CommentBase):
"""评论响应 Schema"""
id: str
author: CommentAuthor
post_id: str
parent_id: Optional[str] = None
is_approved: bool
created_at: datetime
updated_at: datetime
replies: List["CommentResponse"] = []
class Config:
from_attributes = True
class CommentListResponse(BaseModel):
"""评论列表响应 Schema"""
items: List[CommentResponse]
total: int
page: int
page_size: int

115
backend/app/schemas/post.py Normal file
View File

@@ -0,0 +1,115 @@
"""
文章 Schema
"""
from datetime import datetime
from typing import Optional, List
from pydantic import BaseModel, Field
class TagBase(BaseModel):
"""标签基础 Schema"""
name: str = Field(..., min_length=1, max_length=50, description="标签名")
slug: str = Field(..., min_length=1, max_length=50, description="URL别名")
class TagCreate(TagBase):
"""标签创建 Schema"""
pass
class TagResponse(TagBase):
"""标签响应 Schema"""
id: str
created_at: datetime
class Config:
from_attributes = True
class CategoryBase(BaseModel):
"""分类基础 Schema"""
name: str = Field(..., min_length=1, max_length=50, description="分类名")
slug: str = Field(..., min_length=1, max_length=50, description="URL别名")
description: Optional[str] = None
class CategoryCreate(CategoryBase):
"""分类创建 Schema"""
pass
class CategoryResponse(CategoryBase):
"""分类响应 Schema"""
id: str
created_at: datetime
class Config:
from_attributes = True
class PostBase(BaseModel):
"""文章基础 Schema"""
title: str = Field(..., min_length=1, max_length=200, description="标题")
slug: str = Field(..., min_length=1, max_length=200, description="URL别名")
content: str = Field(..., description="文章内容")
summary: Optional[str] = None
cover_image: Optional[str] = None
category_id: Optional[str] = None
status: str = Field(default="draft", description="状态: draft/published/archived")
class PostCreate(PostBase):
"""文章创建 Schema"""
tag_ids: Optional[List[str]] = []
meta_title: Optional[str] = None
meta_description: Optional[str] = None
class PostUpdate(BaseModel):
"""文章更新 Schema"""
title: Optional[str] = Field(None, min_length=1, max_length=200)
slug: Optional[str] = Field(None, min_length=1, max_length=200)
content: Optional[str] = None
summary: Optional[str] = None
cover_image: Optional[str] = None
category_id: Optional[str] = None
tag_ids: Optional[List[str]] = None
status: Optional[str] = None
meta_title: Optional[str] = None
meta_description: Optional[str] = None
class AuthorResponse(BaseModel):
"""作者响应 Schema"""
id: str
username: str
avatar: Optional[str] = None
class Config:
from_attributes = True
class PostResponse(PostBase):
"""文章响应 Schema"""
id: str
author: AuthorResponse
category: Optional[CategoryResponse] = None
tags: List[TagResponse] = []
view_count: int
like_count: int
comment_count: int
published_at: Optional[datetime] = None
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class PostListResponse(BaseModel):
"""文章列表响应 Schema"""
items: List[PostResponse]
total: int
page: int
page_size: int
total_pages: int

View File

@@ -0,0 +1,50 @@
"""
用户 Schema
"""
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, EmailStr, Field
class UserBase(BaseModel):
"""用户基础 Schema"""
username: str = Field(..., min_length=3, max_length=50, description="用户名")
email: EmailStr = Field(..., description="邮箱")
class UserCreate(UserBase):
"""用户创建 Schema"""
password: str = Field(..., min_length=6, max_length=100, description="密码")
class UserUpdate(BaseModel):
"""用户更新 Schema"""
username: Optional[str] = Field(None, min_length=3, max_length=50)
email: Optional[EmailStr] = None
avatar: Optional[str] = None
bio: Optional[str] = None
class UserInDB(UserBase):
"""用户数据库 Schema"""
id: str
avatar: Optional[str] = None
bio: Optional[str] = None
is_active: bool
is_superuser: bool
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class UserPublic(UserBase):
"""用户公开信息 Schema"""
id: str
avatar: Optional[str] = None
bio: Optional[str] = None
created_at: datetime
class Config:
from_attributes = True

View File

@@ -0,0 +1 @@
# Services module

View File

@@ -1,6 +1,72 @@
"""
FastAPI 应用入口
"""
from contextlib import asynccontextmanager
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
@app.get("/")
def read_root():
return {"Hello": "World"}
from fastapi.middleware.cors import CORSMiddleware
from app.core.config import settings
from app.core.database import init_db, close_db
from app.core.logger import app_logger
from app.api.api import api_router
@asynccontextmanager
async def lifespan(app: FastAPI):
"""应用生命周期管理"""
# 启动时
app_logger.info(f"Starting {settings.APP_NAME} v{settings.APP_VERSION}...")
# 初始化数据库
await init_db()
yield
# 关闭时
app_logger.info("Shutting down...")
await close_db()
def create_app() -> FastAPI:
"""创建 FastAPI 应用"""
app = FastAPI(
title=settings.APP_NAME,
version=settings.APP_VERSION,
description="ACG 风格博客系统 API",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan
)
# 配置 CORS
app.add_middleware(
CORSMiddleware,
allow_origins=settings.BACKEND_CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 注册路由
app.include_router(api_router)
# 健康检查
@app.get("/health")
async def health_check():
return {"status": "healthy", "app": settings.APP_NAME}
return app
app = create_app()
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"main:app",
host="0.0.0.0",
port=8000,
reload=True,
log_level="info"
)

136
backend/schema.sql Normal file
View File

@@ -0,0 +1,136 @@
-- ACG Blog 数据库建表脚本
-- PostgreSQL
-- 运行前请先创建数据库: CREATE DATABASE acg_blog;
-- 启用 UUID 扩展
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- ==================== 用户表 ====================
CREATE TABLE IF NOT EXISTS "users" (
"id" UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
"username" VARCHAR(50) UNIQUE NOT NULL,
"email" VARCHAR(255) UNIQUE NOT NULL,
"password_hash" VARCHAR(255) NOT NULL,
"avatar" VARCHAR(500),
"bio" TEXT,
"is_active" BOOLEAN DEFAULT TRUE,
"is_superuser" BOOLEAN DEFAULT FALSE,
"created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- ==================== 分类表 ====================
CREATE TABLE IF NOT EXISTS "categories" (
"id" UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
"name" VARCHAR(50) UNIQUE NOT NULL,
"slug" VARCHAR(50) UNIQUE NOT NULL,
"description" TEXT,
"created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- ==================== 标签表 ====================
CREATE TABLE IF NOT EXISTS "tags" (
"id" UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
"name" VARCHAR(50) UNIQUE NOT NULL,
"slug" VARCHAR(50) UNIQUE NOT NULL,
"created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- ==================== 文章表 ====================
CREATE TABLE IF NOT EXISTS "posts" (
"id" UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
"title" VARCHAR(200) NOT NULL,
"slug" VARCHAR(200) UNIQUE NOT NULL,
"content" TEXT NOT NULL,
"summary" TEXT,
"cover_image" VARCHAR(500),
-- 外键关联
"author_id" UUID NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
"category_id" UUID REFERENCES "categories"("id") ON DELETE SET NULL,
-- 统计数据
"view_count" INTEGER DEFAULT 0,
"like_count" INTEGER DEFAULT 0,
"comment_count" INTEGER DEFAULT 0,
-- 状态: draft/published/archived
"status" VARCHAR(20) DEFAULT 'draft',
-- SEO
"meta_title" VARCHAR(200),
"meta_description" TEXT,
-- 时间戳
"published_at" TIMESTAMP,
"created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 索引
CREATE INDEX IF NOT EXISTS "idx_posts_status_published_at" ON "posts"("status", "published_at");
CREATE INDEX IF NOT EXISTS "idx_posts_author_status" ON "posts"("author_id", "status");
-- ==================== 文章标签关联表 ====================
CREATE TABLE IF NOT EXISTS "post_tags" (
"id" UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
"post_id" UUID NOT NULL REFERENCES "posts"("id") ON DELETE CASCADE,
"tag_id" UUID NOT NULL REFERENCES "tags"("id") ON DELETE CASCADE,
UNIQUE("post_id", "tag_id")
);
-- ==================== 评论表 ====================
CREATE TABLE IF NOT EXISTS "comments" (
"id" UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
"content" TEXT NOT NULL,
"is_approved" BOOLEAN DEFAULT TRUE,
-- 外键关联
"author_id" UUID NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
"post_id" UUID NOT NULL REFERENCES "posts"("id") ON DELETE CASCADE,
"parent_id" UUID REFERENCES "comments"("id") ON DELETE CASCADE,
-- 时间戳
"created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 索引
CREATE INDEX IF NOT EXISTS "idx_comments_post_id" ON "comments"("post_id");
CREATE INDEX IF NOT EXISTS "idx_comments_author_id" ON "comments"("author_id");
-- ==================== 初始化数据 ====================
-- 插入默认分类
INSERT INTO "categories" ("name", "slug", "description") VALUES
('动漫资讯', 'anime', '最新动漫新闻、番剧更新、业界动态'),
('游戏攻略', 'game', '游戏通关指南、角色培养、剧情解析'),
('二次元美图', 'pictures', '精选壁纸、Cosplay、插画作品'),
('同人创作', 'fanwork', '同人小说、同人绘画、手办模型')
ON CONFLICT ("slug") DO NOTHING;
-- 插入默认标签
INSERT INTO "tags" ("name", "slug") VALUES
('原神', 'genshin'),
('崩坏星穹铁道', 'honkai-star-rail'),
('我的世界', 'minecraft'),
('EVA', 'evangelion'),
('约定的梦幻岛', 'neverland'),
('咒术回战', 'jujutsu-kaisen'),
('Cosplay', 'cosplay'),
('手办', 'figure')
ON CONFLICT ("slug") DO NOTHING;
-- 插入管理员用户 (密码: admin123)
-- 密码哈希基于 bcrypt使用前请替换为实际哈希值
INSERT INTO "users" ("username", "email", "password_hash", "is_superuser") VALUES
('admin', 'admin@acgblog.com', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/X4.TZND60LMTUBu.K', TRUE)
ON CONFLICT ("username") DO NOTHING;
-- ==================== 注释 ====================
COMMENT ON TABLE "users" IS '用户表';
COMMENT ON TABLE "categories" IS '文章分类表';
COMMENT ON TABLE "tags" IS '文章标签表';
COMMENT ON TABLE "posts" IS '文章表';
COMMENT ON TABLE "post_tags" IS '文章标签关联表';
COMMENT ON TABLE "comments" IS '评论表';

78
docker-compose.yml Normal file
View File

@@ -0,0 +1,78 @@
version: '3.8'
services:
# PostgreSQL 数据库
postgres:
image: postgres:16-alpine
container_name: acg_blog_db
restart: unless-stopped
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: acg_blog
volumes:
- postgres_data:/var/lib/postgresql/data
- ./backend/schema.sql:/docker-entrypoint-initdb.d/schema.sql:ro
ports:
- "5432:5432"
networks:
- acg_blog_network
# Redis 缓存
redis:
image: redis:7-alpine
container_name: acg_blog_redis
restart: unless-stopped
ports:
- "6379:6379"
volumes:
- redis_data:/data
networks:
- acg_blog_network
# 后端 API
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: acg_blog_backend
restart: unless-stopped
environment:
- DB_HOST=postgres
- DB_PORT=5432
- DB_USER=postgres
- DB_PASSWORD=postgres
- DB_NAME=acg_blog
- REDIS_HOST=redis
- REDIS_PORT=6379
- SECRET_KEY=your-secret-key-change-in-production
- DEBUG=false
ports:
- "8000:8000"
depends_on:
- postgres
- redis
networks:
- acg_blog_network
# 前端 Nginx
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: acg_blog_frontend
restart: unless-stopped
ports:
- "80:80"
depends_on:
- backend
networks:
- acg_blog_network
volumes:
postgres_data:
redis_data:
networks:
acg_blog_network:
driver: bridge

69
docker-deploy.md Normal file
View File

@@ -0,0 +1,69 @@
# Docker 部署指南
## 快速启动
```bash
# 启动所有服务
docker-compose up -d
# 查看服务状态
docker-compose ps
# 查看日志
docker-compose logs -f
```
## 服务地址
| 服务 | 地址 |
|------|------|
| 前端 | http://localhost |
| 后端 API | http://localhost:8000 |
| API 文档 | http://localhost:8000/docs |
| PostgreSQL | localhost:5432 |
| Redis | localhost:6379 |
## 初始化数据库
首次启动时,数据库会自动创建表结构和初始数据。
管理员账户:
- 用户名: `admin`
- 邮箱: `admin@acgblog.com`
- 密码: `admin123`
**重要**: 请在部署后修改管理员密码!
## 常用命令
```bash
# 重新构建镜像
docker-compose build --no-cache
# 停止所有服务
docker-compose down
# 停止并删除数据卷
docker-compose down -v
# 进入后端容器
docker exec -it acg_blog_backend sh
# 进入数据库
docker exec -it acg_blog_db psql -U postgres -d acg_blog
```
## 生产环境部署
1. 修改 `backend/.env.production` 中的配置:
- `SECRET_KEY` - 使用随机字符串
- `BACKEND_CORS_ORIGINS` - 改为你的域名
2. 修改 `docker-compose.yml` 中的端口映射(移除端口暴露,仅通过 nginx 反向代理)
3. 使用 Nginx 或 Traefik 等反向代理配置 HTTPS
4. 定期备份数据库:
```bash
docker exec acg_blog_db pg_dump -U postgres acg_blog > backup.sql
```

6
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
node_modules
dist
.git
.env
*.md
.vscode

29
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,29 @@
# Vue 3 Frontend
FROM node:20-alpine AS builder
WORKDIR /app
# 复制依赖文件
COPY package*.json ./
# 安装依赖
RUN npm ci
# 复制源代码
COPY . .
# 构建生产版本
RUN npm run build
# ==================== Nginx 运行阶段 ====================
FROM nginx:alpine
# 复制构建产物
COPY --from=builder /app/dist /usr/share/nginx/html
# 复制 Nginx 配置
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

32
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,32 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Gzip 压缩
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
gzip_min_length 1000;
# 前端路由SPA
location / {
try_files $uri $uri/ /index.html;
}
# API 代理
location /api/ {
proxy_pass http://backend:8000/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 静态资源缓存
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}

View File

@@ -9,8 +9,10 @@
"preview": "vite preview"
},
"dependencies": {
"@kangc/v-md-editor": "^2.3.18",
"@vueuse/core": "^14.2.1",
"axios": "^1.13.6",
"highlight.js": "^11.10.0",
"naive-ui": "^2.44.1",
"pinia": "^3.0.4",
"vue": "^3.5.30",

View File

@@ -0,0 +1,14 @@
import { http } from './index'
import type { Category } from '@/types'
export const categoryApi = {
// 获取所有分类
getAll() {
return http.get<Category[]>('/categories')
},
// 获取单个分类
getDetail(id: string) {
return http.get<Category>(`/categories/${id}`)
},
}

View File

@@ -1,5 +1,4 @@
import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse } from 'axios'
import type { ApiResponse } from '@/types'
import axios, { type AxiosInstance, type AxiosRequestConfig } from 'axios'
// 创建 axios 实例
const request: AxiosInstance = axios.create({
@@ -27,20 +26,22 @@ request.interceptors.request.use(
// 响应拦截器
request.interceptors.response.use(
(response: AxiosResponse<ApiResponse>) => {
return response
(response) => {
// 成功时返回 response.data即 ApiResponse
return response.data
},
(error: any) => {
if (error.response) {
const { status } = error.response
const { status, data } = error.response
// 401 未授权,跳转登录页
// 401 未授权,清除 token
if (status === 401) {
localStorage.removeItem('access_token')
window.location.href = '/login'
}
return Promise.reject(error.response.data)
// 返回错误信息
return Promise.reject(data)
}
return Promise.reject(error)
@@ -48,21 +49,22 @@ request.interceptors.response.use(
)
// 封装请求方法
// 返回 Promise<ApiResponse<T>>,调用者通过 .data 访问实际数据
export const http = {
get<T = any>(url: string, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
return request.get(url, config)
get<T = any>(url: string, config?: AxiosRequestConfig): Promise<{ code: number; message: string; data: T }> {
return request.get(url, config).then(res => res.data)
},
post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
return request.post(url, data, config)
post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<{ code: number; message: string; data: T }> {
return request.post(url, data, config).then(res => res.data)
},
put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
return request.put(url, data, config)
put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<{ code: number; message: string; data: T }> {
return request.put(url, data, config).then(res => res.data)
},
delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
return request.delete(url, config)
delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<{ code: number; message: string; data: T }> {
return request.delete(url, config).then(res => res.data)
},
}

View File

@@ -0,0 +1,59 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import VMdEditor from '@kangc/v-md-editor'
import '@kangc/v-md-editor/lib/style/style.css'
import githubTheme from '@kangc/v-md-editor/lib/theme/github.js'
import '@kangc/v-md-editor/lib/theme/style/github.css'
import hljs from 'highlight.js'
// 配置主题
VMdEditor.use(githubTheme, {
hljs,
})
const props = defineProps<{
modelValue: string
placeholder?: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const content = ref(props.modelValue)
watch(() => props.modelValue, (newVal) => {
content.value = newVal
})
watch(content, (newVal) => {
emit('update:modelValue', newVal)
})
function handleInput(value: string) {
content.value = value
}
</script>
<template>
<div class="markdown-editor">
<VMdEditor
v-model="content"
:placeholder="placeholder || '开始撰写...'"
height="400px"
mode="edit"
@change="handleInput"
/>
</div>
</template>
<style scoped>
.markdown-editor :deep(.v-md-editor) {
border: 1px solid var(--gray-300, #d1d5db);
border-radius: 0.5rem;
}
.markdown-editor :deep(.v-md-editor--fullscreen) {
z-index: 1000;
}
</style>

View File

@@ -1,14 +1,18 @@
<script setup lang="ts">
import { useThemeStore } from '@/store/theme'
import { useUserStore } from '@/store/user'
import { RouterLink } from 'vue-router'
import { RouterLink, useRoute } from 'vue-router'
import { computed } from 'vue'
const themeStore = useThemeStore()
const userStore = useUserStore()
const route = useRoute()
const isAdmin = computed(() => route.path.startsWith('/admin'))
</script>
<template>
<nav class="navbar">
<nav v-if="!isAdmin" class="navbar">
<div class="nav-container">
<!-- Logo -->
<RouterLink to="/" class="logo">
@@ -18,24 +22,23 @@ const userStore = useUserStore()
<!-- 导航链接 -->
<div class="nav-links">
<RouterLink to="/" class="nav-link">首页</RouterLink>
<RouterLink to="/category" class="nav-link">分类</RouterLink>
<RouterLink to="/archive" class="nav-link">归档</RouterLink>
<RouterLink to="/about" class="nav-link">关于</RouterLink>
<RouterLink to="/" class="nav-link" exact-active-class="active">首页</RouterLink>
<RouterLink to="/category" class="nav-link" active-class="active">分类</RouterLink>
<RouterLink to="/about" class="nav-link" active-class="active">关于</RouterLink>
</div>
<!-- 右侧操作区 -->
<div class="nav-actions">
<!-- 主题切换 -->
<button @click="themeStore.toggleTheme()" class="action-btn">
{{ themeStore.theme === 'light' ? '☀️' : themeStore.theme === 'dark' ? '🌙' : '🌗' }}
<button @click="themeStore.toggleTheme()" class="action-btn" :title="themeStore.theme === 'light' ? '切换深色模式' : '切换浅色模式'">
<span class="text-lg">{{ themeStore.theme === 'light' ? '🌙' : '☀️' }}</span>
</button>
<!-- 登录按钮 -->
<RouterLink v-if="!userStore.isLoggedIn" to="/login" class="login-btn">
登录
</RouterLink>
<RouterLink v-else to="/profile" class="user-avatar">
<RouterLink v-else to="/profile" class="user-avatar" :title="userStore.user?.username">
{{ userStore.user?.username?.[0]?.toUpperCase() || 'U' }}
</RouterLink>
</div>
@@ -50,19 +53,19 @@ const userStore = useUserStore()
left: 0;
right: 0;
z-index: 50;
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(12px);
border-bottom: 1px solid rgba(229, 231, 235, 0.5);
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(20px);
border-bottom: 1px solid rgba(229, 231, 235, 0.3);
}
:global(.dark) .navbar {
background: rgba(17, 24, 39, 0.85);
border-bottom-color: rgba(75, 85, 99, 0.5);
background: rgba(17, 24, 39, 0.8);
border-bottom-color: rgba(75, 85, 99, 0.3);
}
.nav-container {
max-width: 1280px;
margin: 0 auto;
padding: 0 1rem;
padding: 0 1.5rem;
height: 4rem;
display: flex;
align-items: center;
@@ -77,9 +80,9 @@ const userStore = useUserStore()
}
.logo-icon {
width: 2rem;
height: 2rem;
border-radius: 0.5rem;
width: 2.25rem;
height: 2.25rem;
border-radius: 0.625rem;
background: linear-gradient(135deg, #FFB7C5, #D4B5E6);
display: flex;
align-items: center;
@@ -87,6 +90,7 @@ const userStore = useUserStore()
color: white;
font-weight: bold;
font-size: 1.125rem;
box-shadow: 0 2px 10px rgba(255, 183, 197, 0.3);
}
.logo-text {
@@ -100,7 +104,7 @@ const userStore = useUserStore()
.nav-links {
display: none;
gap: 1.5rem;
gap: 0.5rem;
}
@media (min-width: 768px) {
.nav-links {
@@ -110,18 +114,24 @@ const userStore = useUserStore()
.nav-link {
position: relative;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
color: #6B7280;
text-decoration: none;
font-size: 0.875rem;
font-size: 0.9375rem;
font-weight: 500;
transition: color 0.2s;
transition: all 0.2s;
}
:global(.dark) .nav-link {
color: #9CA3AF;
}
.nav-link:hover,
.nav-link.router-link-active {
.nav-link:hover {
color: #FFB7C5;
background: rgba(255, 183, 197, 0.1);
}
.nav-link.active {
color: #FFB7C5;
background: rgba(255, 183, 197, 0.15);
}
.nav-actions {
@@ -131,9 +141,9 @@ const userStore = useUserStore()
}
.action-btn {
width: 2.25rem;
height: 2.25rem;
border-radius: 9999px;
width: 2.5rem;
height: 2.5rem;
border-radius: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
@@ -146,27 +156,29 @@ const userStore = useUserStore()
background: #374151;
}
.action-btn:hover {
background: #FFB7C5;
background: rgba(255, 183, 197, 0.3);
transform: scale(1.05);
}
.login-btn {
padding: 0.5rem 1rem;
padding: 0.5rem 1.25rem;
border-radius: 9999px;
border: 2px solid #FFB7C5;
color: #FFB7C5;
background: linear-gradient(135deg, #FFB7C5, #D4B5E6);
color: white;
font-size: 0.875rem;
font-weight: 500;
font-weight: 600;
text-decoration: none;
transition: all 0.2s;
box-shadow: 0 2px 10px rgba(255, 183, 197, 0.3);
}
.login-btn:hover {
background: #FFB7C5;
color: white;
transform: translateY(-1px);
box-shadow: 0 4px 15px rgba(255, 183, 197, 0.5);
}
.user-avatar {
width: 2.25rem;
height: 2.25rem;
width: 2.5rem;
height: 2.5rem;
border-radius: 9999px;
background: linear-gradient(135deg, #FFB7C5, #D4B5E6);
display: flex;
@@ -175,5 +187,11 @@ const userStore = useUserStore()
color: white;
font-weight: 600;
text-decoration: none;
box-shadow: 0 2px 10px rgba(255, 183, 197, 0.3);
transition: all 0.2s;
}
.user-avatar:hover {
transform: scale(1.05);
box-shadow: 0 4px 15px rgba(255, 183, 197, 0.5);
}
</style>

View File

@@ -1,26 +1,34 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ref, onMounted } from 'vue'
import { RouterLink } from 'vue-router'
import { categoryApi } from '@/api/category'
import { postApi } from '@/api/post'
import type { Category, Post } from '@/types'
const categories = ref([
{ id: '1', name: '动漫资讯', slug: 'anime', post_count: 12 },
{ id: '2', name: '游戏攻略', slug: 'game', post_count: 8 },
{ id: '3', name: '二次元美图', slug: 'pictures', post_count: 25 },
{ id: '4', name: '同人创作', slug: 'fanwork', post_count: 15 },
])
const categories = ref<Category[]>([])
const tags = ref<string[]>(['原神', '崩坏星穹铁道', '我的世界', 'EVA', '约定的梦幻岛', '咒术回战', 'Cosplay', '手办'])
const hotPosts = ref<Post[]>([])
const tags = ref([
'原神', '崩坏星穹铁道', '我的世界', 'EVA',
'约定的梦幻岛', '咒术回战', 'Cosplay', '手办'
])
async function fetchSidebarData() {
try {
// 获取分类
const catResponse = await categoryApi.getAll()
categories.value = catResponse.data
const hotPosts = ref([
{ id: '1', title: '《原神》4.2版本前瞻:芙宁娜技能演示', view_count: 5200 },
{ id: '2', title: '2024年必追的10部春季新番', view_count: 3800 },
{ id: '3', title: '《崩坏星穹铁道》角色强度榜更新', view_count: 2900 },
{ id: '4', title: '二次元手游开服大横评', view_count: 2100 },
{ id: '5', title: '手办入坑指南:从萌新到进阶', view_count: 1800 },
])
// 获取热门文章
const postsResponse = await postApi.getList({ page: 1, page_size: 10 })
hotPosts.value = postsResponse.data.items
.filter((p: Post) => p.status === 'published')
.sort((a: Post, b: Post) => b.view_count - a.view_count)
.slice(0, 5)
} catch (error) {
console.error('Failed to fetch sidebar data:', error)
}
}
onMounted(() => {
fetchSidebarData()
})
</script>
<template>
@@ -53,9 +61,8 @@ const hotPosts = ref([
<h3 class="sidebar-title">分类</h3>
<ul class="category-list">
<li v-for="cat in categories" :key="cat.id">
<RouterLink :to="`/category/${cat.slug}`" class="category-item">
<RouterLink :to="`/category/${cat.id}`" class="category-item">
<span>{{ cat.name }}</span>
<span class="category-count">{{ cat.post_count }}</span>
</RouterLink>
</li>
</ul>
@@ -65,9 +72,9 @@ const hotPosts = ref([
<div class="sidebar-card">
<h3 class="sidebar-title">标签</h3>
<div class="tag-cloud">
<RouterLink v-for="tag in tags" :key="tag" :to="`/tag/${tag}`" class="tag">
<span v-for="tag in tags" :key="tag" class="tag">
{{ tag }}
</RouterLink>
</span>
</div>
</div>

View File

@@ -1,12 +1,14 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import naive from 'naive-ui'
import App from './App.vue'
import router from './router'
// import naive-ui (按需引入,后续按需配置)
import './style.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(naive)
app.mount('#app')

View File

@@ -83,17 +83,37 @@ const router = createRouter({
})
// 路由守卫
router.beforeEach((to, _from, next) => {
router.beforeEach(async (to, _from, next) => {
const userStore = useUserStore()
const token = localStorage.getItem('access_token')
// 需要登录但未登录
if (to.meta.requiresAuth && !token) {
next({ name: 'Login' })
} else if (to.meta.requiresAdmin && userStore.user?.is_active !== true) {
next({ name: 'Home' })
} else {
next()
return
}
// 需要管理员权限但不是管理员
if (to.meta.requiresAdmin) {
// 如果还没有用户信息,先获取
if (!userStore.user && token) {
try {
await userStore.fetchUser()
} catch {
// 获取失败,清除 token
userStore.logout()
next({ name: 'Login' })
return
}
}
if (!userStore.user?.is_superuser) {
next({ name: 'Home' })
return
}
}
next()
})
export default router

View File

@@ -10,7 +10,7 @@ export const useUserStore = defineStore('user', () => {
// 计算属性
const isLoggedIn = computed(() => !!token.value)
const isAdmin = computed(() => user.value?.is_active === true)
const isAdmin = computed(() => user.value?.is_superuser === true)
// Actions
function setToken(newToken: string | null) {

View File

@@ -5,6 +5,7 @@ export interface User {
email: string
avatar?: string
is_active: boolean
is_superuser: boolean
created_at: string
updated_at: string
}
@@ -82,6 +83,7 @@ export interface Category {
export interface Tag {
id: string
name: string
slug?: string
created_at: string
}

10
frontend/src/types/v-md-editor.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
declare module '@kangc/v-md-editor' {
import { DefineComponent } from 'vue'
const VMdEditor: DefineComponent<any, any, any>
export default VMdEditor
}
declare module '@kangc/v-md-editor/lib/theme/github.js' {
const theme: any
export default theme
}

View File

@@ -1,19 +1,43 @@
<template>
<div class="about">
<nav class="glass fixed top-0 left-0 right-0 z-50">
<div class="max-w-4xl mx-auto px-4 py-4">
<RouterLink to="/" class="text-acg-pink hover:underline"> 返回首页</RouterLink>
</div>
</nav>
<script setup lang="ts">
import Navbar from '@/components/Navbar.vue'
import Footer from '@/components/Footer.vue'
</script>
<main class="pt-20 px-4 max-w-4xl mx-auto">
<template>
<div class="about min-h-screen">
<Navbar />
<main class="pt-24 px-4 pb-12 max-w-4xl mx-auto">
<div class="glass rounded-xl p-8">
<h1 class="text-3xl font-bold mb-4">关于 ACG Blog</h1>
<p class="text-gray-600 dark:text-gray-400 leading-relaxed">
<h1 class="text-3xl font-bold mb-6">关于 ACG Blog</h1>
<div class="space-y-4 text-gray-600 dark:text-gray-400 leading-relaxed">
<p>
ACG Blog 是一个专注于二次元文化的博客平台使用 Vue 3 + TypeScript + Naive UI 构建
在这里你可以分享你的二次元生活动漫评论游戏攻略等内容
</p>
<p>
本博客系统采用前后端分离架构前端使用 Vue 3 + Vite后端使用 FastAPI
数据存储使用 PostgreSQL缓存使用 Redis为你提供流畅的使用体验
</p>
<p>
如果你有任何问题或建议欢迎联系我们
</p>
</div>
<div class="mt-8 pt-8 border-t border-gray-200 dark:border-gray-700">
<h2 class="text-xl font-bold mb-4">技术栈</h2>
<div class="flex flex-wrap gap-2">
<span class="px-3 py-1 rounded-full bg-acg-pink/20 text-acg-pink text-sm">Vue 3</span>
<span class="px-3 py-1 rounded-full bg-acg-purple/20 text-acg-purple text-sm">FastAPI</span>
<span class="px-3 py-1 rounded-full bg-acg-pink/20 text-acg-pink text-sm">TypeScript</span>
<span class="px-3 py-1 rounded-full bg-acg-purple/20 text-acg-purple text-sm">Naive UI</span>
<span class="px-3 py-1 rounded-full bg-acg-pink/20 text-acg-pink text-sm">Tailwind CSS</span>
<span class="px-3 py-1 rounded-full bg-acg-purple/20 text-acg-purple text-sm">PostgreSQL</span>
</div>
</div>
</div>
</main>
<Footer />
</div>
</template>

View File

@@ -1,25 +1,105 @@
<script setup lang="ts">
import { useRoute } from 'vue-router'
import Navbar from '@/components/Navbar.vue'
import Footer from '@/components/Footer.vue'
import { postApi } from '@/api/post'
import { categoryApi } from '@/api/category'
import type { Post, Category } from '@/types'
import { ref, onMounted, computed } from 'vue'
import PostCard from '@/components/PostCard.vue'
const route = useRoute()
const categoryId = route.params.id as string
const category = ref<Category | null>(null)
const posts = ref<Post[]>([])
const loading = ref(false)
const routePath = computed(() => route.path)
async function fetchCategoryAndPosts() {
loading.value = true
try {
if (routePath.value === '/category') {
// 全部分类页
category.value = null
const response = await postApi.getList({ page: 1, page_size: 20 })
posts.value = response.data.items.filter((p: Post) => p.status === 'published')
} else {
// 特定分类
const [catResponse, postsResponse] = await Promise.all([
categoryApi.getDetail(categoryId),
postApi.getList({ category_id: categoryId, page: 1, page_size: 20 }),
])
category.value = catResponse.data
posts.value = postsResponse.data.items.filter((p: Post) => p.status === 'published')
}
} catch (error) {
console.error('Failed to fetch category:', error)
} finally {
loading.value = false
}
}
onMounted(() => {
fetchCategoryAndPosts()
})
</script>
<template>
<div class="category">
<nav class="glass fixed top-0 left-0 right-0 z-50">
<div class="max-w-6xl mx-auto px-4 py-4">
<RouterLink to="/" class="text-acg-pink hover:underline"> 返回首页</RouterLink>
</div>
</nav>
<div class="category min-h-screen">
<Navbar />
<main class="pt-20 px-4 max-w-6xl mx-auto">
<div class="glass rounded-xl p-8">
<h1 class="text-2xl font-bold mb-4">分类 - {{ categoryId }}</h1>
<div class="text-gray-600 dark:text-gray-400">
分类内容加载中...
<!-- Hero Section -->
<section class="relative py-16 px-4 overflow-hidden">
<div class="absolute inset-0 bg-gradient-to-br from-pink-500/10 via-purple-500/10 to-blue-500/10"></div>
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-96 h-96 bg-acg-pink/20 rounded-full blur-3xl"></div>
<div class="relative max-w-6xl mx-auto text-center">
<div class="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-white/50 dark:bg-gray-800/50 backdrop-blur-sm mb-6">
<span class="text-lg">📁</span>
<span class="text-sm font-medium">分类</span>
</div>
<h1 class="text-4xl md:text-5xl font-bold mb-4">
{{ category?.name || '全部分类' }}
</h1>
<p v-if="category?.description" class="text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
{{ category.description }}
</p>
<p v-else class="text-gray-500">
浏览所有文章
</p>
</div>
</section>
<!-- Content -->
<main class="px-4 pb-12 max-w-6xl mx-auto">
<div v-if="loading" class="glass rounded-xl p-12 text-center">
<div class="animate-pulse space-y-4">
<div class="h-6 bg-gray-200 dark:bg-gray-700 rounded w-1/4 mx-auto"></div>
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-1/2 mx-auto"></div>
</div>
</div>
<div v-else-if="posts.length === 0" class="glass rounded-xl p-12 text-center">
<div class="text-6xl mb-4">📭</div>
<h3 class="text-xl font-bold mb-2">暂无文章</h3>
<p class="text-gray-500">该分类下还没有发布的文章</p>
<RouterLink to="/" class="inline-block mt-4 text-acg-pink hover:underline">
返回首页
</RouterLink>
</div>
<div v-else>
<div class="flex items-center justify-between mb-6">
<h2 class="text-lg font-medium">
{{ posts.length }} 篇文章
</h2>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<PostCard v-for="post in posts" :key="post.id" :post="post" />
</div>
</div>
</main>
<Footer />
</div>
</template>

View File

@@ -1,115 +1,54 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ref, onMounted } from 'vue'
import Navbar from '@/components/Navbar.vue'
import Hero from '@/components/Hero.vue'
import PostCard from '@/components/PostCard.vue'
import Sidebar from '@/components/Sidebar.vue'
import Footer from '@/components/Footer.vue'
import type { Post } from '@/types'
// 模拟文章数据 - 后端完成后替换为真实API调用
const posts = ref<Post[]>([
{
id: '1',
title: '《原神》4.2版本前瞻:芙宁娜技能演示与剧情预测',
slug: 'genshin-4-2-preview',
content: '',
summary: '4.2版本即将到来,让我们提前了解水神芙宁娜的技能机制以及可能的剧情发展...',
cover_image: 'https://images.unsplash.com/photo-1542751371-adc38448a05e?w=800',
author: { id: '1', username: 'Miyako', email: '', is_active: true, created_at: '', updated_at: '' },
category: { id: '1', name: '游戏攻略', slug: 'game', created_at: '' },
tags: [{ id: '1', name: '原神', created_at: '' }, { id: '2', name: '攻略', created_at: '' }],
view_count: 5200,
status: 'published',
created_at: '2024-03-15T10:00:00Z',
updated_at: '2024-03-15T10:00:00Z'
},
{
id: '2',
title: '2024年必追的10部春季新番推荐',
slug: 'spring-2024-anime',
content: '',
summary: '春天到了又到了补番的季节本期为大家带来2024年春季最值得期待的新番动画...',
cover_image: 'https://images.unsplash.com/photo-1535016120720-40c6874c3b1c?w=800',
author: { id: '2', username: 'Akari', email: '', is_active: true, created_at: '', updated_at: '' },
category: { id: '2', name: '动漫资讯', slug: 'anime', created_at: '' },
tags: [{ id: '3', name: '新番', created_at: '' }, { id: '4', name: '推荐', created_at: '' }],
view_count: 3800,
status: 'published',
created_at: '2024-03-14T08:00:00Z',
updated_at: '2024-03-14T08:00:00Z'
},
{
id: '3',
title: '《崩坏星穹铁道》角色强度榜 - 3月版',
slug: 'honkai-star-rail-tier-list',
content: '',
summary: '版本强势角色有哪些本期强度榜为你详细分析当前版本的T0、T1角色...',
cover_image: 'https://images.unsplash.com/photo-1550745165-9bc0b252726f?w=800',
author: { id: '1', username: 'Miyako', email: '', is_active: true, created_at: '', updated_at: '' },
category: { id: '1', name: '游戏攻略', slug: 'game', created_at: '' },
tags: [{ id: '2', name: '崩坏星穹铁道', created_at: '' }, { id: '5', name: '强度榜', created_at: '' }],
view_count: 2900,
status: 'published',
created_at: '2024-03-13T15:30:00Z',
updated_at: '2024-03-13T15:30:00Z'
},
{
id: '4',
title: '手办入坑指南:从萌新到进阶的全方位攻略',
slug: 'figure-beginner-guide',
content: '',
summary: '想入手第一只手办但不知道如何选择?这篇指南将带你了解手办的各种分类、选购渠道...',
cover_image: 'https://images.unsplash.com/photo-1608889175123-8ee362201f81?w=800',
author: { id: '3', username: 'Sakura', email: '', is_active: true, created_at: '', updated_at: '' },
category: { id: '3', name: '二次元美图', slug: 'pictures', created_at: '' },
tags: [{ id: '6', name: '手办', created_at: '' }, { id: '7', name: '教程', created_at: '' }],
view_count: 2100,
status: 'published',
created_at: '2024-03-12T12:00:00Z',
updated_at: '2024-03-12T12:00:00Z'
},
{
id: '5',
title: '同人创作分享:用画笔描绘心中的二次元世界',
slug: 'fanart-sharing',
content: '',
summary: '本期收录了多位优秀画师的作品,让我们一起欣赏这些充满想象力的同人创作...',
cover_image: 'https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?w=800',
author: { id: '2', username: 'Akari', email: '', is_active: true, created_at: '', updated_at: '' },
category: { id: '4', name: '同人创作', slug: 'fanwork', created_at: '' },
tags: [{ id: '8', name: '同人', created_at: '' }, { id: '9', name: '绘画', created_at: '' }],
view_count: 1800,
status: 'published',
created_at: '2024-03-11T09:00:00Z',
updated_at: '2024-03-11T09:00:00Z'
},
{
id: '6',
title: '《约定的梦幻岛》最新话解析:剧情走向深度分析',
slug: 'neverland-analysis',
content: '',
summary: '最新一话的剧情信息量巨大,让我们来深度分析一下剧情走向和角色心理...',
cover_image: 'https://images.unsplash.com/photo-1518709268805-4e9042af9f23?w=800',
author: { id: '4', username: 'Ken', email: '', is_active: true, created_at: '', updated_at: '' },
category: { id: '2', name: '动漫资讯', slug: 'anime', created_at: '' },
tags: [{ id: '10', name: '约定的梦幻岛', created_at: '' }, { id: '11', name: '分析', created_at: '' }],
view_count: 1500,
status: 'published',
created_at: '2024-03-10T20:00:00Z',
updated_at: '2024-03-10T20:00:00Z'
}
])
const categories = [
{ name: '全部', slug: '' },
{ name: '动漫资讯', slug: 'anime' },
{ name: '游戏攻略', slug: 'game' },
{ name: '二次元美图', slug: 'pictures' },
{ name: '同人创作', slug: 'fanwork' },
]
import { postApi } from '@/api/post'
import { categoryApi } from '@/api/category'
import type { Post, Category } from '@/types'
const posts = ref<Post[]>([])
const categories = ref<Category[]>([{ id: '', name: '全部', slug: '', created_at: '' }])
const selectedCategory = ref('')
const loading = ref(false)
async function fetchPosts(categoryId?: string) {
loading.value = true
try {
const params: any = { page: 1, page_size: 20 }
if (categoryId) {
params.category_id = categoryId
}
const response = await postApi.getList(params)
posts.value = response.data.items.filter((p: any) => p.status === 'published')
} catch (error) {
console.error('Failed to fetch posts:', error)
} finally {
loading.value = false
}
}
async function fetchCategories() {
try {
const response = await categoryApi.getAll()
categories.value = [{ id: '', name: '全部', slug: '', created_at: '' }, ...response.data]
} catch (error) {
console.error('Failed to fetch categories:', error)
}
}
function handleCategoryChange(slug: string) {
selectedCategory.value = slug
const catId = slug ? categories.value.find(c => c.slug === slug)?.id : ''
fetchPosts(catId)
}
onMounted(() => {
fetchCategories()
fetchPosts()
})
</script>
<template>
@@ -123,8 +62,8 @@ const selectedCategory = ref('')
<div class="category-buttons">
<button
v-for="cat in categories"
:key="cat.slug"
@click="selectedCategory = cat.slug"
:key="cat.id"
@click="handleCategoryChange(cat.slug)"
class="category-btn"
:class="{ active: selectedCategory === cat.slug }"
>
@@ -138,7 +77,9 @@ const selectedCategory = ref('')
<!-- 文章列表 -->
<section class="posts-section">
<h2 class="section-title">最新文章</h2>
<div class="posts-grid">
<div v-if="loading" class="text-center py-8 text-gray-500">加载中...</div>
<div v-else-if="posts.length === 0" class="text-center py-8 text-gray-500">暂无文章</div>
<div v-else class="posts-grid">
<PostCard v-for="post in posts" :key="post.id" :post="post" />
</div>
</section>

View File

@@ -1,13 +1,17 @@
<script setup lang="ts">
import { RouterLink } from 'vue-router'
import Navbar from '@/components/Navbar.vue'
</script>
<template>
<div class="not-found min-h-screen flex items-center justify-center px-4">
<div class="not-found min-h-screen">
<Navbar />
<main class="pt-24 px-4 pb-12 flex items-center justify-center">
<div class="text-center">
<h1 class="text-6xl font-bold text-acg-pink mb-4">404</h1>
<p class="text-xl mb-6">页面不存在</p>
<p class="text-xl mb-6 text-gray-600 dark:text-gray-400">页面不存在</p>
<RouterLink to="/" class="btn-acg">返回首页</RouterLink>
</div>
</main>
</div>
</template>

View File

@@ -1,25 +1,100 @@
<script setup lang="ts">
import { useRoute } from 'vue-router'
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { postApi } from '@/api/post'
import type { Post } from '@/types'
import { useMessage } from 'naive-ui'
import Navbar from '@/components/Navbar.vue'
import Footer from '@/components/Footer.vue'
const message = useMessage()
const route = useRoute()
const router = useRouter()
const postId = route.params.id as string
const post = ref<Post | null>(null)
const loading = ref(true)
async function fetchPost() {
loading.value = true
try {
const response = await postApi.getDetail(postId)
post.value = response.data
// 增加浏览量
postApi.incrementView(postId).catch(() => {})
} catch (error) {
message.error('文章不存在或已被删除')
router.push('/')
} finally {
loading.value = false
}
}
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
onMounted(() => {
fetchPost()
})
</script>
<template>
<div class="post-detail">
<nav class="glass fixed top-0 left-0 right-0 z-50">
<div class="max-w-4xl mx-auto px-4 py-4">
<RouterLink to="/" class="text-acg-pink hover:underline"> 返回首页</RouterLink>
</div>
</nav>
<div class="post-detail min-h-screen">
<Navbar />
<main class="pt-20 px-4 max-w-4xl mx-auto">
<div class="glass rounded-xl p-8">
<h1 class="text-3xl font-bold mb-4">文章详情 - {{ postId }}</h1>
<div class="text-gray-600 dark:text-gray-400">
文章内容加载中...
<main class="pt-24 px-4 pb-12 max-w-4xl mx-auto">
<div v-if="loading" class="glass rounded-xl p-8 text-center">
<div class="text-gray-500">加载中...</div>
</div>
<article v-else-if="post" class="glass rounded-xl overflow-hidden">
<!-- 封面图 -->
<div v-if="post.cover_image" class="w-full h-64 md:h-80 overflow-hidden">
<img :src="post.cover_image" :alt="post.title" class="w-full h-full object-cover" />
</div>
<div class="p-6 md:p-8">
<!-- 标题 -->
<h1 class="text-3xl md:text-4xl font-bold mb-4">{{ post.title }}</h1>
<!-- 元信息 -->
<div class="flex flex-wrap items-center gap-4 text-sm text-gray-500 mb-6 pb-6 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-2">
<img
v-if="post.author.avatar"
:src="post.author.avatar"
:alt="post.author.username"
class="w-8 h-8 rounded-full"
/>
<span class="text-acg-pink">{{ post.author.username }}</span>
</div>
<span>发布于 {{ formatDate(post.created_at) }}</span>
<span v-if="post.category">{{ post.category.name }}</span>
<span>阅读 {{ post.view_count }}</span>
</div>
<!-- 标签 -->
<div v-if="post.tags?.length" class="flex flex-wrap gap-2 mb-6">
<span
v-for="tag in post.tags"
:key="tag.id"
class="px-3 py-1 text-sm rounded-full bg-acg-pink/20 text-acg-pink"
>
{{ tag.name }}
</span>
</div>
<!-- 文章内容 -->
<div class="prose prose-lg max-w-none dark:prose-invert" v-html="post.content"></div>
</div>
</article>
</main>
<Footer />
</div>
</template>

View File

@@ -1,8 +1,197 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useMessage, useDialog } from 'naive-ui'
import type { Category } from '@/types'
const message = useMessage()
const dialog = useDialog()
const categories = ref<Category[]>([])
const loading = ref(false)
const showModal = ref(false)
const isEditing = ref(false)
const currentId = ref<string | null>(null)
const formData = ref({
name: '',
slug: '',
description: '',
})
async function fetchCategories() {
loading.value = true
try {
// 模拟数据 - 实际应该调用API
categories.value = []
} catch (error) {
message.error('获取分类失败')
} finally {
loading.value = false
}
}
function openModal(category?: Category) {
if (category) {
isEditing.value = true
currentId.value = category.id
formData.value = {
name: category.name,
slug: category.slug,
description: category.description || '',
}
} else {
isEditing.value = false
currentId.value = null
formData.value = { name: '', slug: '', description: '' }
}
showModal.value = true
}
function closeModal() {
showModal.value = false
isEditing.value = false
currentId.value = null
formData.value = { name: '', slug: '', description: '' }
}
async function saveCategory() {
if (!formData.value.name || !formData.value.slug) {
message.warning('请填写名称和别名')
return
}
try {
if (isEditing.value && currentId.value) {
message.success('分类更新成功')
} else {
message.success('分类创建成功')
}
closeModal()
fetchCategories()
} catch (error) {
message.error('保存失败')
}
}
async function deleteCategory(_id: string) {
try {
await dialog.warning({
title: '确认删除',
content: '确定要删除这个分类吗?',
positiveText: '删除',
negativeText: '取消',
})
message.success('删除成功')
fetchCategories()
} catch {
// 取消
}
}
function generateSlug() {
// 简单的 slug 生成
formData.value.slug = formData.value.name
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fa5]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
}
onMounted(() => {
fetchCategories()
})
</script>
<template>
<div class="category-manage">
<h1 class="text-2xl font-bold mb-6">分类管理</h1>
<div class="glass rounded-xl p-6">
<p class="text-gray-600 dark:text-gray-400">分类管理功能开发中...</p>
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold">分类管理</h1>
<button @click="openModal()" class="btn-acg">
新建分类
</button>
</div>
<div class="glass rounded-xl overflow-hidden">
<table class="w-full">
<thead class="bg-gray-100 dark:bg-gray-800">
<tr>
<th class="px-4 py-3 text-left text-sm font-medium">名称</th>
<th class="px-4 py-3 text-left text-sm font-medium">别名</th>
<th class="px-4 py-3 text-left text-sm font-medium">描述</th>
<th class="px-4 py-3 text-left text-sm font-medium">创建时间</th>
<th class="px-4 py-3 text-right text-sm font-medium">操作</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<tr v-for="cat in categories" :key="cat.id" class="hover:bg-gray-50 dark:hover:bg-gray-800">
<td class="px-4 py-3 font-medium">{{ cat.name }}</td>
<td class="px-4 py-3 text-gray-500 text-sm">{{ cat.slug }}</td>
<td class="px-4 py-3 text-gray-500 text-sm">{{ cat.description || '-' }}</td>
<td class="px-4 py-3 text-gray-500 text-sm">{{ new Date(cat.created_at).toLocaleDateString() }}</td>
<td class="px-4 py-3 text-right">
<button @click="openModal(cat)" class="text-acg-pink hover:underline mr-4">编辑</button>
<button @click="deleteCategory(cat.id)" class="text-red-500 hover:underline">删除</button>
</td>
</tr>
<tr v-if="loading">
<td colspan="5" class="px-4 py-8 text-center text-gray-500">加载中...</td>
</tr>
<tr v-if="!loading && categories.length === 0">
<td colspan="5" class="px-4 py-8 text-center text-gray-500">暂无分类点击上方按钮创建</td>
</tr>
</tbody>
</table>
</div>
<!-- 弹窗 -->
<div v-if="showModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
<div class="glass rounded-xl w-full max-w-md">
<div class="p-6">
<h2 class="text-xl font-bold mb-6">{{ isEditing ? '编辑分类' : '新建分类' }}</h2>
<form @submit.prevent="saveCategory" class="space-y-4">
<div>
<label class="block text-sm font-medium mb-2">名称</label>
<input
v-model="formData.name"
type="text"
placeholder="分类名称"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800"
@blur="generateSlug"
/>
</div>
<div>
<label class="block text-sm font-medium mb-2">别名</label>
<input
v-model="formData.slug"
type="text"
placeholder="url-slug"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800"
/>
</div>
<div>
<label class="block text-sm font-medium mb-2">描述</label>
<textarea
v-model="formData.description"
placeholder="分类描述(可选)"
rows="3"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800"
></textarea>
</div>
<div class="flex justify-end gap-4 pt-4">
<button type="button" @click="closeModal" class="px-6 py-2 rounded-lg border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800">
取消
</button>
<button type="submit" class="btn-acg">
保存
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</template>

View File

@@ -1,18 +1,120 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { postApi } from '@/api/post'
import { useUserStore } from '@/store/user'
const userStore = useUserStore()
const stats = ref({
postCount: 0,
userCount: 0,
viewCount: 0,
})
const recentPosts = ref<any[]>([])
const loading = ref(false)
async function fetchDashboardData() {
loading.value = true
try {
const response = await postApi.getList({ page: 1, page_size: 5 })
recentPosts.value = response.data.items
stats.value.postCount = response.data.total
} catch (error) {
console.error('Failed to fetch dashboard data:', error)
} finally {
loading.value = false
}
}
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleDateString('zh-CN')
}
onMounted(() => {
fetchDashboardData()
})
</script>
<template>
<div class="dashboard">
<h1 class="text-2xl font-bold mb-6">仪表盘</h1>
<div class="grid gap-4 md:grid-cols-3">
<div class="glass rounded-xl p-6">
<h3 class="text-gray-600 dark:text-gray-400">文章总数</h3>
<p class="text-3xl font-bold text-acg-pink">0</p>
<div class="mb-6">
<h1 class="text-2xl font-bold">仪表盘</h1>
<p class="text-gray-500 text-sm mt-1">欢迎回来{{ userStore.user?.username || '管理员' }}</p>
</div>
<div class="glass rounded-xl p-6">
<h3 class="text-gray-600 dark:text-gray-400">用户总数</h3>
<p class="text-3xl font-bold text-acg-blue">0</p>
<!-- 统计卡片 -->
<div class="grid gap-4 md:grid-cols-3 mb-8">
<div class="glass rounded-xl p-6 relative overflow-hidden">
<div class="absolute top-0 right-0 w-20 h-20 bg-gradient-to-br from-pink-500/20 to-transparent rounded-bl-full"></div>
<div class="flex items-center gap-4">
<div class="w-12 h-12 rounded-xl bg-pink-500/20 flex items-center justify-center">
<span class="text-2xl">📝</span>
</div>
<div>
<p class="text-gray-500 text-sm">文章总数</p>
<p class="text-3xl font-bold text-pink-500">{{ stats.postCount }}</p>
</div>
</div>
</div>
<div class="glass rounded-xl p-6 relative overflow-hidden">
<div class="absolute top-0 right-0 w-20 h-20 bg-gradient-to-br from-blue-500/20 to-transparent rounded-bl-full"></div>
<div class="flex items-center gap-4">
<div class="w-12 h-12 rounded-xl bg-blue-500/20 flex items-center justify-center">
<span class="text-2xl">👤</span>
</div>
<div>
<p class="text-gray-500 text-sm">用户总数</p>
<p class="text-3xl font-bold text-blue-500">{{ stats.userCount }}</p>
</div>
</div>
</div>
<div class="glass rounded-xl p-6 relative overflow-hidden">
<div class="absolute top-0 right-0 w-20 h-20 bg-gradient-to-br from-purple-500/20 to-transparent rounded-bl-full"></div>
<div class="flex items-center gap-4">
<div class="w-12 h-12 rounded-xl bg-purple-500/20 flex items-center justify-center">
<span class="text-2xl">👁</span>
</div>
<div>
<p class="text-gray-500 text-sm">总访问量</p>
<p class="text-3xl font-bold text-purple-500">{{ stats.viewCount.toLocaleString() }}</p>
</div>
</div>
</div>
</div>
<!-- 最近文章 -->
<div class="glass rounded-xl p-6">
<h3 class="text-gray-600 dark:text-gray-400">总访问量</h3>
<p class="text-3xl font-bold text-acg-purple">0</p>
<h2 class="text-lg font-bold mb-4">最近文章</h2>
<div v-if="loading" class="text-center text-gray-500 py-4">加载中...</div>
<div v-else-if="recentPosts.length === 0" class="text-center text-gray-500 py-4">暂无文章</div>
<div v-else class="space-y-3">
<div
v-for="post in recentPosts"
:key="post.id"
class="flex items-center justify-between p-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-acg-pink/10 flex items-center justify-center text-acg-pink">
📄
</div>
<div>
<RouterLink :to="`/post/${post.id}`" class="font-medium hover:text-acg-pink">
{{ post.title }}
</RouterLink>
<p class="text-sm text-gray-500">{{ formatDate(post.created_at) }}</p>
</div>
</div>
<span
:class="{
'bg-green-500/20 text-green-500': post.status === 'published',
'bg-yellow-500/20 text-yellow-500': post.status === 'draft',
}"
class="px-2 py-1 text-xs rounded-full"
>
{{ post.status === 'published' ? '已发布' : '草稿' }}
</span>
</div>
</div>
</div>
</div>

View File

@@ -1,8 +1,250 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useMessage } from 'naive-ui'
import { postApi } from '@/api/post'
import type { Post, PostCreateRequest } from '@/types'
import Navbar from '@/components/Navbar.vue'
import Footer from '@/components/Footer.vue'
const message = useMessage()
const posts = ref<Post[]>([])
const loading = ref(false)
const showEditor = ref(false)
const isEditing = ref(false)
const currentPostId = ref<string | null>(null)
// 表单数据
const formData = ref<PostCreateRequest>({
title: '',
content: '',
summary: '',
cover_image: '',
category_id: '',
tags: [],
status: 'draft',
})
const searchParams = ref({
page: 1,
page_size: 20,
})
async function fetchPosts() {
loading.value = true
try {
const response = await postApi.getList(searchParams.value)
posts.value = response.data.items
} catch (error) {
message.error('获取文章列表失败')
} finally {
loading.value = false
}
}
function openEditor(post?: Post) {
if (post) {
isEditing.value = true
currentPostId.value = post.id
formData.value = {
title: post.title,
content: post.content,
summary: post.summary || '',
cover_image: post.cover_image || '',
category_id: post.category?.id || '',
tags: [],
status: post.status === 'archived' ? 'draft' : post.status,
}
} else {
isEditing.value = false
currentPostId.value = null
formData.value = {
title: '',
content: '',
summary: '',
cover_image: '',
category_id: '',
tags: [],
status: 'draft',
}
}
showEditor.value = true
}
function closeEditor() {
showEditor.value = false
isEditing.value = false
currentPostId.value = null
}
async function savePost() {
if (!formData.value.title || !formData.value.content) {
message.warning('请填写标题和内容')
return
}
try {
if (isEditing.value && currentPostId.value) {
await postApi.update(currentPostId.value, formData.value)
message.success('文章更新成功')
} else {
await postApi.create(formData.value)
message.success('文章创建成功')
}
closeEditor()
fetchPosts()
} catch (error: any) {
message.error(error?.message || '保存失败')
}
}
async function deletePost(id: string) {
try {
await postApi.delete(id)
message.success('删除成功')
fetchPosts()
} catch (error) {
message.error('删除失败')
}
}
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleDateString('zh-CN')
}
onMounted(() => {
fetchPosts()
})
</script>
<template>
<div class="post-manage">
<h1 class="text-2xl font-bold mb-6">文章管理</h1>
<div class="glass rounded-xl p-6">
<p class="text-gray-600 dark:text-gray-400">文章管理功能开发中...</p>
<div class="post-manage min-h-screen">
<Navbar />
<main class="pt-24 px-4 pb-12 max-w-6xl mx-auto">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold">文章管理</h1>
<button @click="openEditor()" class="btn-acg">
新建文章
</button>
</div>
<div class="glass rounded-xl overflow-hidden">
<table class="w-full">
<thead class="bg-gray-100 dark:bg-gray-800">
<tr>
<th class="px-4 py-3 text-left text-sm font-medium">标题</th>
<th class="px-4 py-3 text-left text-sm font-medium">分类</th>
<th class="px-4 py-3 text-left text-sm font-medium">状态</th>
<th class="px-4 py-3 text-left text-sm font-medium">发布时间</th>
<th class="px-4 py-3 text-right text-sm font-medium">操作</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<tr v-for="post in posts" :key="post.id" class="hover:bg-gray-50 dark:hover:bg-gray-800">
<td class="px-4 py-3">
<RouterLink :to="`/post/${post.id}`" class="text-acg-pink hover:underline">
{{ post.title }}
</RouterLink>
</td>
<td class="px-4 py-3 text-sm text-gray-500">{{ post.category?.name || '-' }}</td>
<td class="px-4 py-3">
<span
:class="{
'bg-green-500/20 text-green-500': post.status === 'published',
'bg-yellow-500/20 text-yellow-500': post.status === 'draft',
'bg-gray-500/20 text-gray-500': post.status === 'archived',
}"
class="px-2 py-1 text-xs rounded-full"
>
{{ post.status === 'published' ? '已发布' : post.status === 'draft' ? '草稿' : '归档' }}
</span>
</td>
<td class="px-4 py-3 text-sm text-gray-500">{{ formatDate(post.created_at) }}</td>
<td class="px-4 py-3 text-right">
<button @click="openEditor(post)" class="text-acg-pink hover:underline mr-4">编辑</button>
<button @click="deletePost(post.id)" class="text-red-500 hover:underline">删除</button>
</td>
</tr>
</tbody>
</table>
<div v-if="loading" class="p-8 text-center text-gray-500">加载中...</div>
<div v-if="!loading && posts.length === 0" class="p-8 text-center text-gray-500">暂无文章</div>
</div>
</main>
<!-- 编辑器弹窗 -->
<div v-if="showEditor" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
<div class="glass rounded-xl w-full max-w-4xl max-h-[90vh] overflow-y-auto">
<div class="p-6">
<h2 class="text-xl font-bold mb-6">{{ isEditing ? '编辑文章' : '新建文章' }}</h2>
<form @submit.prevent="savePost" class="space-y-4">
<div>
<label class="block text-sm font-medium mb-2">标题</label>
<input
v-model="formData.title"
type="text"
placeholder="文章标题"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800"
required
/>
</div>
<div>
<label class="block text-sm font-medium mb-2">摘要</label>
<textarea
v-model="formData.summary"
placeholder="文章摘要(可选)"
rows="2"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800"
></textarea>
</div>
<div>
<label class="block text-sm font-medium mb-2">封面图 URL</label>
<input
v-model="formData.cover_image"
type="url"
placeholder="https://..."
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800"
/>
</div>
<div>
<label class="block text-sm font-medium mb-2">内容 (Markdown)</label>
<textarea
v-model="formData.content"
placeholder="使用 Markdown 编写文章内容..."
rows="12"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 font-mono text-sm"
></textarea>
</div>
<div>
<label class="block text-sm font-medium mb-2">状态</label>
<select
v-model="formData.status"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800"
>
<option value="draft">草稿</option>
<option value="published">发布</option>
</select>
</div>
<div class="flex justify-end gap-4 pt-4">
<button type="button" @click="closeEditor" class="px-6 py-2 rounded-lg border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800">
取消
</button>
<button type="submit" class="btn-acg">
保存
</button>
</div>
</form>
</div>
</div>
</div>
<Footer />
</div>
</template>

View File

@@ -1,8 +1,171 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useMessage, useDialog } from 'naive-ui'
import type { Tag } from '@/types'
const message = useMessage()
const dialog = useDialog()
const tags = ref<Tag[]>([])
const loading = ref(false)
const showModal = ref(false)
const isEditing = ref(false)
const currentId = ref<string | null>(null)
const formData = ref({
name: '',
slug: '',
})
async function fetchTags() {
loading.value = true
try {
// 模拟数据 - 实际应该调用API
tags.value = []
} catch (error) {
message.error('获取标签失败')
} finally {
loading.value = false
}
}
function openModal(tag?: Tag) {
if (tag) {
isEditing.value = true
currentId.value = tag.id
formData.value = {
name: tag.name,
slug: tag.slug || '',
}
} else {
isEditing.value = false
currentId.value = null
formData.value = { name: '', slug: '' }
}
showModal.value = true
}
function closeModal() {
showModal.value = false
isEditing.value = false
currentId.value = null
formData.value = { name: '', slug: '' }
}
async function saveTag() {
if (!formData.value.name || !formData.value.slug) {
message.warning('请填写名称和别名')
return
}
try {
if (isEditing.value && currentId.value) {
message.success('标签更新成功')
} else {
message.success('标签创建成功')
}
closeModal()
fetchTags()
} catch (error) {
message.error('保存失败')
}
}
async function deleteTag(_id: string) {
try {
await dialog.warning({
title: '确认删除',
content: '确定要删除这个标签吗?',
positiveText: '删除',
negativeText: '取消',
})
message.success('删除成功')
fetchTags()
} catch {
// 取消
}
}
function generateSlug() {
formData.value.slug = formData.value.name
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fa5]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
}
onMounted(() => {
fetchTags()
})
</script>
<template>
<div class="tag-manage">
<h1 class="text-2xl font-bold mb-6">标签管理</h1>
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold">标签管理</h1>
<button @click="openModal()" class="btn-acg">
新建标签
</button>
</div>
<div class="glass rounded-xl p-6">
<p class="text-gray-600 dark:text-gray-400">标签管理功能开发中...</p>
<div v-if="loading" class="text-center text-gray-500 py-4">加载中...</div>
<div v-else-if="tags.length === 0" class="text-center text-gray-500 py-8">
<p class="mb-2">暂无标签</p>
<button @click="openModal()" class="text-acg-pink hover:underline">点击创建第一个标签</button>
</div>
<div v-else class="flex flex-wrap gap-3">
<div
v-for="tag in tags"
:key="tag.id"
class="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-acg-pink/10 text-acg-pink"
>
<span>{{ tag.name }}</span>
<button @click="openModal(tag)" class="hover:text-white"></button>
<button @click="deleteTag(tag.id)" class="hover:text-white"></button>
</div>
</div>
</div>
<!-- 弹窗 -->
<div v-if="showModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
<div class="glass rounded-xl w-full max-w-md">
<div class="p-6">
<h2 class="text-xl font-bold mb-6">{{ isEditing ? '编辑标签' : '新建标签' }}</h2>
<form @submit.prevent="saveTag" class="space-y-4">
<div>
<label class="block text-sm font-medium mb-2">名称</label>
<input
v-model="formData.name"
type="text"
placeholder="标签名称"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800"
@blur="generateSlug"
/>
</div>
<div>
<label class="block text-sm font-medium mb-2">别名</label>
<input
v-model="formData.slug"
type="text"
placeholder="url-slug"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800"
/>
</div>
<div class="flex justify-end gap-4 pt-4">
<button type="button" @click="closeModal" class="px-6 py-2 rounded-lg border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800">
取消
</button>
<button type="submit" class="btn-acg">
保存
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</template>

View File

@@ -1,15 +1,32 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/store/user'
import { useMessage } from 'naive-ui'
const router = useRouter()
const userStore = useUserStore()
const message = useMessage()
const email = ref('')
const password = ref('')
const loading = ref(false)
async function handleLogin() {
// TODO: 实现登录逻辑
console.log('Login:', email.value, password.value)
if (!email.value || !password.value) {
message.warning('请填写邮箱和密码')
return
}
loading.value = true
try {
await userStore.login(email.value, password.value)
message.success('登录成功')
router.push('/')
} catch (error: any) {
message.error(error?.message || '登录失败,请检查邮箱和密码')
} finally {
loading.value = false
}
}
</script>
@@ -41,8 +58,8 @@ async function handleLogin() {
/>
</div>
<button type="submit" class="w-full btn-acg">
登录
<button type="submit" class="w-full btn-acg" :disabled="loading">
{{ loading ? '登录中...' : '登录' }}
</button>
</form>

View File

@@ -1,16 +1,41 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { authApi } from '@/api/auth'
import { useMessage } from 'naive-ui'
const router = useRouter()
const message = useMessage()
const username = ref('')
const email = ref('')
const password = ref('')
const loading = ref(false)
async function handleRegister() {
// TODO: 实现注册逻辑
console.log('Register:', username.value, email.value, password.value)
if (!username.value || !email.value || !password.value) {
message.warning('请填写所有字段')
return
}
if (password.value.length < 6) {
message.warning('密码长度至少为6位')
return
}
loading.value = true
try {
await authApi.register({
username: username.value,
email: email.value,
password: password.value,
})
message.success('注册成功,请登录')
router.push('/login')
} catch (error: any) {
message.error(error?.message || '注册失败')
} finally {
loading.value = false
}
}
</script>
@@ -53,8 +78,8 @@ async function handleRegister() {
/>
</div>
<button type="submit" class="w-full btn-acg">
注册
<button type="submit" class="w-full btn-acg" :disabled="loading">
{{ loading ? '注册中...' : '注册' }}
</button>
</form>

View File

@@ -1,28 +1,108 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/store/user'
import { authApi } from '@/api/auth'
import { useMessage, useDialog } from 'naive-ui'
import Navbar from '@/components/Navbar.vue'
import Footer from '@/components/Footer.vue'
const router = useRouter()
const userStore = useUserStore()
const message = useMessage()
const dialog = useDialog()
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
async function handleLogout() {
try {
await dialog.warning({
title: '提示',
content: '确定要退出登录吗?',
positiveText: '确定',
negativeText: '取消',
})
await authApi.logout()
} catch {
// 用户取消
return
}
userStore.logout()
message.success('已退出登录')
router.push('/')
}
onMounted(() => {
if (!userStore.isLoggedIn) {
message.warning('请先登录')
router.push('/login')
}
})
</script>
<template>
<div class="profile">
<nav class="glass fixed top-0 left-0 right-0 z-50">
<div class="max-w-4xl mx-auto px-4 py-4">
<RouterLink to="/" class="text-acg-pink hover:underline"> 返回首页</RouterLink>
</div>
</nav>
<div class="profile min-h-screen">
<Navbar />
<main class="pt-20 px-4 max-w-4xl mx-auto">
<div class="glass rounded-xl p-8">
<h1 class="text-2xl font-bold mb-4">个人中心</h1>
<div v-if="userStore.user">
<p>用户名: {{ userStore.user.username }}</p>
<p>邮箱: {{ userStore.user.email }}</p>
<main class="pt-24 px-4 pb-12 max-w-2xl mx-auto">
<div class="glass rounded-xl p-6 md:p-8">
<h1 class="text-2xl font-bold mb-6">个人中心</h1>
<div v-if="userStore.user" class="space-y-6">
<!-- 头像和基本信息 -->
<div class="flex items-center gap-6">
<div class="w-20 h-20 rounded-full overflow-hidden bg-acg-pink/20 flex items-center justify-center">
<img
v-if="userStore.user.avatar"
:src="userStore.user.avatar"
:alt="userStore.user.username"
class="w-full h-full object-cover"
/>
<span v-else class="text-3xl text-acg-pink">{{ userStore.user.username[0].toUpperCase() }}</span>
</div>
<div v-else>
<div>
<h2 class="text-xl font-bold">{{ userStore.user.username }}</h2>
<p class="text-gray-500 text-sm">加入于 {{ formatDate(userStore.user.created_at) }}</p>
</div>
</div>
<!-- 详细信息 -->
<div class="space-y-4 py-4 border-t border-gray-200 dark:border-gray-700">
<div class="flex justify-between">
<span class="text-gray-500">邮箱</span>
<span>{{ userStore.user.email }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">状态</span>
<span :class="userStore.user.is_active ? 'text-green-500' : 'text-gray-400'">
{{ userStore.user.is_active ? '已激活' : '未激活' }}
</span>
</div>
</div>
<!-- 操作按钮 -->
<div class="flex gap-4 pt-4">
<button
@click="handleLogout"
class="px-6 py-2 rounded-lg bg-red-500/20 text-red-500 hover:bg-red-500/30 transition-colors"
>
退出登录
</button>
</div>
</div>
<div v-else class="text-center text-gray-500 py-8">
加载中...
</div>
</div>
</main>
<Footer />
</div>
</template>

274
readme.md
View File

@@ -1,123 +1,189 @@
# 二次元风格博客技术文档
# ACG Blog - 二次元风格博客系统
### 项目基本信息
<p align="center">
<img src="https://img.shields.io/badge/Python-3.11+-blue.svg" alt="Python">
<img src="https://img.shields.io/badge/FastAPI-0.110-green.svg" alt="FastAPI">
<img src="https://img.shields.io/badge/Vue-3.5-brightgreen.svg" alt="Vue">
<img src="https://img.shields.io/badge/TypeScript-5.9-blue.svg" alt="TypeScript">
<img src="https://img.shields.io/badge/Docker-Ready-blue.svg" alt="Docker">
</p>
- **项目名称**ACG Blog二次元风格博客
- **项目简介**:基于 **FastAPI****Vue 3** 的前后端分离博客系统,主打二次元视觉体验与高性能响应。支持 Markdown 文章发布、动态看板娘、访问量统计、热搜排行、深色模式切换等特色功能。
- **技术栈概览**
- 后端Python + FastAPI + SupaBase + TortoiseORM + Redis + JWT
- 前端Vue 3 (Vite) + Pinia + Naive UI + Tailwind CSS + GSAP
- **系统架构****B/S 架构**Browser/Server浏览器/服务器架构)
- 前端单页应用SPA运行于浏览器
- 后端RESTful API 服务器
- 通信协议HTTP/HTTPS + JSON
- **后端架构模式****类 MVC 模式**
- **Model模型层**`models/` 目录Tortoise-ORM 数据模型定义
- **View视图层**`api/endpoints/` 目录FastAPI 路由返回 JSON 响应
- **Controller控制器层**`api/endpoints/` 中的路由处理函数,协调业务逻辑
- **Service服务层**`services/` 目录,封装核心业务(如看板娘互动、统计逻辑)
- **Schema数据传输层**`schemas/` 目录Pydantic 模型校验请求与响应
- **CRUD数据操作层**`crud/` 目录,封装数据库增删改查逻辑,辅助 Model 层
- **Core核心配置**`core/` 目录管理环境变量、日志、JWT 安全等基础设施
- **开发状态**:规划中
基于 **FastAPI****Vue 3** 的前后端分离博客系统,主打二次元视觉体验与高性能响应。支持 Markdown 文章发布、访问量统计、分类标签管理、深色模式切换等功能。
> 详细技术选型及项目结构请参见下文。
## 技术栈
### 后端技术栈
### 后端
| 技术 | 说明 |
|------|------|
| FastAPI | 高性能异步 Web 框架 |
| Tortoise-ORM | 异步 ORM支持 PostgreSQL |
| PostgreSQL | 关系型数据库 |
| Redis | 缓存与会话存储 |
| JWT | 无状态身份认证 |
| Pydantic | 数据验证 |
| Loguru | 日志处理 |
| **模块** | **技术选型** | **说明** |
| -------------- | --------------------- | ------------------------------------------------------------ |
| **核心框架** | **FastAPI** | 高性能、原生异步Async满足二次元素材图片/视频)的高并发加载需求。 |
| **数据库** | **SupaBace** | **内置 Auth**,支持第三方登录 (Github/Google) |
| **ORM (异步)** | **Tortoise-ORM** | 语法类似 Django且原生支持异步操作。 |
| **缓存/任务** | **Redis** | 用于文章点击量统计、热搜排行以及 Session 存储。 |
| **认证** | **JWT (python-jose)** | 状态认证,方便前后端分离部署。 |
| **数据校验** | **Pydantic v2** | 确保前端传来的数据不会让后端“炸掉”。 |
| **日志** | **Loguru** | 极简且强大的异步日志处理,便于排错 |
### 前端
| 技术 | 说明 |
|------|------|
| Vue 3 | 组合式 API (Script Setup) |
| Vite | 快速构建工具 |
| TypeScript | 类型安全 |
| Pinia | 状态管理 |
| Naive UI | UI 组件库 |
| Tailwind CSS | 样式引擎 |
| Axios | HTTP 客户端 |
### 前端技术栈 (The Visuals)
## 项目结构
| **模块** | **技术选型** | **说明** |
| ------------- | ---------------- | ------------------------------------------------------------ |
| **框架** | **Vue 3 (Vite)** | 组合式 API (Script Setup) 开发体验极佳,构建速度快。 |
| **状态管理** | **Pinia** | 轻量、简洁,存储用户偏好(如:深色/浅色模式、看板娘状态)。 |
| **UI 组件库** | **Naive UI** | 设计感极强,配置主题非常自由,适合定制二次元配色。 |
| **样式引擎** | **Tailwind CSS** | 极其方便编写自定义 UI。你可以轻松实现“毛玻璃”、“卡片悬浮”等特效。 |
| **编辑器** | **V-MD-Editor** | 基于 Vue 3 的 Markdown 编辑器,支持预览、代码高亮和 LaTeX 公式。 |
| **动画库** | **GSAP** | 强大的动效库。二次元风格需要细腻的入场和交互动画。 |
### 预计项目结构
```bash
acg-blog/
├── backend/ # FastAPI 后端项目
```
├── backend/ # FastAPI 后端
│ ├── app/
│ │ ├── api/ # 接口层 (v1, v2...)
│ │ │ ├── endpoints/ # 具体业务接口 (posts.py, users.py, etc.)
│ │ │ └── api.py # 路由汇总
│ │ ├── core/ # 核心配置
│ │ │ ├── config.py # 环境变量与全局配置
│ │ │ ├── logger.py # 日志拦截与配置
│ │ │ └── security.py # JWT 与权限相关
│ │ ├── crud/ # 数据库增删改查逻辑
│ │ ├── db/ # 数据库连接与初始化
│ │ ├── models/ # Tortoise-ORM 数据库模型
│ │ ├── api/endpoints/ # API 端点 (auth, posts, comments, users)
│ │ ├── core/ # 核心配置 (config, security, database, logger)
│ │ ├── crud/ # 数据库增删改查
│ │ ├── models/ # Tortoise-ORM 数据模型
│ │ ├── schemas/ # Pydantic 数据验证模型
│ │ ── services/ # 业务服务逻辑 (如:看板娘互动 API)
│ └── main.py # 后端入口
│ ├── logs/ # 日志存储目录 (自动生成 .log 文件)
── static/ # 静态资源 (用户上传的插图、头像)
├── tests/ # 测试用例
│ ├── .env # 敏感配置文件
│ ├── pyproject.toml # Poetry 配置或使用 requirements
│ └── requirements.txt # 依赖清单
├── frontend/ # Vue 3 前端项目 (Vite)
│ ├── public/ # 公共静态资源 (Live2D 模型、Favicon)
│ │ ── main.py # 应用入口
├── Dockerfile # 后端容器配置
│ ├── schema.sql # 数据库建表脚本
── requirements.txt # Python 依赖
├── frontend/ # Vue 3 前端
│ ├── src/
│ │ ├── api/ # Axios 接口封装
│ │ ├── assets/ # 样式、二次元字体、插画
│ │ ├── components/ # 组件 (看板娘、播放器、卡片)
│ │ ├── router/ # 路由配置
│ │ ├── components/ # 公共组件
│ │ ├── views/ # 页面视图
│ │ ├── store/ # Pinia 状态管理
│ │ ├── views/ # 页面布局 (首页、文章页、归档)
│ │ ── App.vue # 根组件
│ └── main.ts # 前端入口
── tailwind.config.js # Tailwind CSS 配置
└── package.json # 前端依赖
├── docker-compose.yml # 全栈 Docker 编排
── README.md
│ │ ├── router/ # 路由配置
│ │ ── types/ # TypeScript 类型定义
├── Dockerfile # 前端容器配置
── nginx.conf # Nginx 配置
├── docker-compose.yml # Docker 编排
── schema.sql # 数据库建表脚本
└── docker-deploy.md # 部署文档
```
### 后端requirements
## 快速开始
### 环境要求
- Python 3.11+
- Node.js 18+
- PostgreSQL 14+
- Redis 7+ (可选)
### 本地开发
**1. 克隆项目**
```bash
git clone <repository-url>
cd acg-blog
```
**2. 启动后端**
```bash
cd backend
# 创建虚拟环境
python -m venv venv
source venv/bin/activate # Linux/Mac
# 或 venv\Scripts\activate # Windows
# 安装依赖
pip install -r requirements.txt
# 配置环境变量
cp .env.example .env # 修改数据库连接信息
# 初始化数据库 (确保 PostgreSQL 运行中)
psql -U postgres -c "CREATE DATABASE acg_blog;"
psql -U postgres -d acg_blog -f schema.sql
# 启动服务
uvicorn main:app --reload --port 8000
```
**3. 启动前端**
```bash
cd frontend
# 安装依赖
npm install
# 启动开发服务器
npm run dev
```
**4. 访问应用**
- 前端: http://localhost:5173
- 后端 API: http://localhost:8000
- API 文档: http://localhost:8000/docs
### Docker 部署
```bash
# Web 框架
fastapi==0.110.0
uvicorn[standard]==0.27.1
# 启动所有服务
docker-compose up -d
# 数据库与异步 ORM
tortoise-orm==0.20.0
aerich==0.7.2 # 数据库迁移工具
asyncpg==0.29.0 # PostgreSQL 驱动
aioredis==2.0.1 # Redis 驱动
# 日志系统
loguru==0.7.2 # 极简且强大的异步日志处理
# 配置与安全
pydantic[email]==2.6.3 # 包含 Email 校验
pydantic-settings==2.2.1 # 处理 .env 环境变量
python-jose[cryptography]==3.3.0 # JWT
passlib[bcrypt]==1.7.4 # 密码哈希
# 业务
python-multipart==0.0.9 # 处理表单与文件上传
mistune==3.0.2 # 快速 Markdown 解析
pillow==10.2.0 # 处理图片
httpx==0.27.0 # 异步 HTTP 请求
# 开发与部署
python-dotenv==1.0.1
gunicorn==21.2.0 # 生产环境容器部署使用
# 查看服务状态
docker-compose ps
```
访问 http://localhost 即可使用。
## API 接口
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | /api/v1/auth/register | 用户注册 |
| POST | /api/v1/auth/login | 用户登录 |
| POST | /api/v1/auth/refresh | 刷新 Token |
| GET | /api/v1/auth/me | 获取当前用户 |
| GET | /api/v1/posts | 获取文章列表 |
| GET | /api/v1/posts/{id} | 获取文章详情 |
| POST | /api/v1/posts | 创建文章 |
| PUT | /api/v1/posts/{id} | 更新文章 |
| DELETE | /api/v1/posts/{id} | 删除文章 |
| GET | /api/v1/categories | 获取分类列表 |
| GET | /api/v1/tags | 获取标签列表 |
| GET | /api/v1/comments/post/{id} | 获取文章评论 |
## 默认账户
- 用户名: `admin`
- 邮箱: `admin@acgblog.com`
- 密码: `admin123`
**请在部署后立即修改管理员密码!**
## 功能特性
- [x] 用户认证 (JWT)
- [x] 文章管理 (CRUD)
- [x] 分类与标签
- [x] Markdown 编辑器
- [x] 评论系统
- [x] 浏览量统计
- [x] 深色/浅色模式
- [x] 响应式设计
- [x] Docker 部署
## 开发说明
### 前端组件
- `Navbar.vue` - 顶部导航栏
- `Hero.vue` - 首页横幅
- `PostCard.vue` - 文章卡片
- `Sidebar.vue` - 侧边栏
- `Footer.vue` - 页脚
### 管理后台
- `/admin/dashboard` - 仪表盘
- `/admin/posts` - 文章管理
- `/admin/categories` - 分类管理
- `/admin/tags` - 标签管理
## License
MIT License