feat: add certificate management module with image upload

- Add Certificate + CertificateCategory models with full CRUD API
- Image upload via base64 data URL stored in Text column
- Certificate fields: title, issuer, issue_date, expiry_date, image, description
- Frontend: card grid with category sidebar filter, create/edit dialog
- Include certificates in data backup/export
- Fix hasPhaseParent optimization in GoalDetailPage

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
祀梦
2026-05-18 00:25:58 +08:00
parent 5048de4fa1
commit 4ee1e39454
14 changed files with 1027 additions and 7 deletions

View File

@@ -6,6 +6,7 @@ from app.models.habit import HabitGroup, Habit, HabitCheckin
from app.models.anniversary import AnniversaryCategory, Anniversary
from app.models.goal import Goal, GoalStep, GoalReview, goal_tasks
from app.models.sync_settings import SyncSettings
from app.models.certificate import Certificate, CertificateCategory
__all__ = [
"Task", "Category", "Tag", "task_tags", "UserSettings",
@@ -13,4 +14,5 @@ __all__ = [
"AnniversaryCategory", "Anniversary",
"Goal", "GoalStep", "GoalReview", "goal_tasks",
"SyncSettings",
"Certificate", "CertificateCategory",
]

View File

@@ -0,0 +1,43 @@
import uuid as _uuid
from sqlalchemy import Column, Integer, String, Text, Date, Boolean, DateTime, ForeignKey
from sqlalchemy.orm import relationship
from app.database import Base
from app.utils.datetime import utcnow
class CertificateCategory(Base):
"""证书分类模型"""
__tablename__ = "certificate_categories"
id = Column(Integer, primary_key=True, index=True)
uuid = Column(String(36), default=lambda: str(_uuid.uuid4()), unique=True, index=True)
name = Column(String(50), nullable=False)
icon = Column(String(50), default="medal")
color = Column(String(20), default="#FFB7C5")
sort_order = Column(Integer, default=0)
is_deleted = Column(Boolean, default=False)
sync_version = Column(Integer, default=1)
certificates = relationship("Certificate", back_populates="category")
class Certificate(Base):
"""证书模型"""
__tablename__ = "certificates"
id = Column(Integer, primary_key=True, index=True)
uuid = Column(String(36), default=lambda: str(_uuid.uuid4()), unique=True, index=True)
title = Column(String(200), nullable=False)
category_id = Column(Integer, ForeignKey("certificate_categories.id"), nullable=True)
image = Column(Text, nullable=True) # base64 data URL
issuer = Column(String(200), nullable=True) # 来源/颁发机构
issue_date = Column(Date, nullable=True)
expiry_date = Column(Date, nullable=True) # null = 永久有效
description = Column(Text, nullable=True)
sort_order = Column(Integer, default=0)
is_deleted = Column(Boolean, default=False)
sync_version = Column(Integer, default=1)
created_at = Column(DateTime, default=utcnow)
updated_at = Column(DateTime, default=utcnow, onupdate=utcnow)
category = relationship("CertificateCategory", back_populates="certificates")

View File

@@ -1,5 +1,5 @@
from fastapi import APIRouter
from app.routers import tasks, categories, tags, user_settings, habits, anniversaries, auth, goals, sync, backup
from app.routers import tasks, categories, tags, user_settings, habits, anniversaries, auth, goals, sync, backup, certificates
api_router = APIRouter()
@@ -13,3 +13,4 @@ api_router.include_router(anniversaries.router)
api_router.include_router(goals.router)
api_router.include_router(sync.router)
api_router.include_router(backup.router)
api_router.include_router(certificates.router)

View File

@@ -16,8 +16,8 @@ router = APIRouter(prefix="/api/backup", tags=["备份"])
# 导出顺序:按依赖关系(无 FK 的先导出)
EXPORT_TABLES = [
"categories", "tags", "user_settings", "sync_settings",
"habit_groups", "anniversary_categories",
"goals", "tasks", "habits", "anniversaries",
"habit_groups", "anniversary_categories", "certificate_categories",
"goals", "tasks", "habits", "anniversaries", "certificates",
"goal_steps", "goal_reviews", "habit_checkins",
"task_tags", "goal_tasks",
]
@@ -27,17 +27,17 @@ TRUNCATE_ORDER = [
"task_tags", "goal_tasks",
"habit_checkins",
"goal_reviews", "goal_steps",
"tasks", "habits", "anniversaries",
"tasks", "habits", "anniversaries", "certificates",
"goals", "categories", "tags",
"habit_groups", "anniversary_categories",
"habit_groups", "anniversary_categories", "certificate_categories",
"user_settings", "sync_settings",
]
# 导入时的插入顺序:父表先插
INSERT_ORDER = [
"categories", "tags", "user_settings", "sync_settings",
"habit_groups", "anniversary_categories",
"goals", "tasks", "habits", "anniversaries",
"habit_groups", "anniversary_categories", "certificate_categories",
"goals", "tasks", "habits", "anniversaries", "certificates",
"goal_steps", "goal_reviews", "habit_checkins",
"task_tags", "goal_tasks",
]

View File

@@ -0,0 +1,167 @@
"""证书路由"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from typing import Optional, List
from app.database import get_db
from app.models.certificate import Certificate, CertificateCategory
from app.schemas.certificate import (
CertificateCreate, CertificateUpdate,
CertificateListResponse, CertificateDetailResponse,
CertificateCategoryCreate, CertificateCategoryUpdate, CertificateCategoryResponse,
)
from app.schemas.common import DeleteResponse
from app.utils.crud import get_or_404
from app.utils.datetime import utcnow
from app.utils.logger import logger
router = APIRouter(prefix="/api", tags=["证书"])
# ============ 证书分类 API ============
@router.get("/certificate-categories", response_model=List[CertificateCategoryResponse])
def get_categories(db: Session = Depends(get_db)):
try:
categories = db.query(CertificateCategory).order_by(
CertificateCategory.sort_order.asc(),
CertificateCategory.id.asc()
).all()
return categories
except Exception as e:
logger.error(f"获取证书分类列表失败: {str(e)}")
raise HTTPException(status_code=500, detail="获取证书分类列表失败")
@router.post("/certificate-categories", response_model=CertificateCategoryResponse, status_code=201)
def create_category(data: CertificateCategoryCreate, db: Session = Depends(get_db)):
try:
cat = CertificateCategory(**data.model_dump())
db.add(cat)
db.commit()
db.refresh(cat)
logger.info(f"创建证书分类成功: id={cat.id}, name={cat.name}")
return cat
except Exception as e:
db.rollback()
logger.error(f"创建证书分类失败: {str(e)}")
raise HTTPException(status_code=500, detail="创建证书分类失败")
@router.put("/certificate-categories/{category_id}", response_model=CertificateCategoryResponse)
def update_category(category_id: int, data: CertificateCategoryUpdate, db: Session = Depends(get_db)):
try:
cat = get_or_404(db, CertificateCategory, category_id, "证书分类")
update_data = data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(cat, field, value)
db.commit()
db.refresh(cat)
logger.info(f"更新证书分类成功: id={category_id}")
return cat
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"更新证书分类失败: {str(e)}")
raise HTTPException(status_code=500, detail="更新证书分类失败")
@router.delete("/certificate-categories/{category_id}")
def delete_category(category_id: int, db: Session = Depends(get_db)):
try:
cat = get_or_404(db, CertificateCategory, category_id, "证书分类")
certs = db.query(Certificate).filter(Certificate.category_id == category_id).all()
for c in certs:
c.category_id = None
db.delete(cat)
db.commit()
logger.info(f"删除证书分类成功: id={category_id}")
return DeleteResponse(message="证书分类删除成功")
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"删除证书分类失败: {str(e)}")
raise HTTPException(status_code=500, detail="删除证书分类失败")
# ============ 证书 API ============
@router.get("/certificates", response_model=List[CertificateListResponse])
def get_certificates(
category_id: Optional[int] = Query(None),
db: Session = Depends(get_db),
):
try:
query = db.query(Certificate)
if category_id is not None:
query = query.filter(Certificate.category_id == category_id)
certs = query.order_by(Certificate.sort_order.asc(), Certificate.created_at.desc()).all()
return certs
except Exception as e:
logger.error(f"获取证书列表失败: {str(e)}")
raise HTTPException(status_code=500, detail="获取证书列表失败")
@router.post("/certificates", response_model=CertificateDetailResponse, status_code=201)
def create_certificate(data: CertificateCreate, db: Session = Depends(get_db)):
try:
cert = Certificate(**data.model_dump())
db.add(cert)
db.commit()
db.refresh(cert)
logger.info(f"创建证书成功: id={cert.id}, title={cert.title}")
return cert
except Exception as e:
db.rollback()
logger.error(f"创建证书失败: {str(e)}")
raise HTTPException(status_code=500, detail="创建证书失败")
@router.get("/certificates/{cert_id}", response_model=CertificateDetailResponse)
def get_certificate(cert_id: int, db: Session = Depends(get_db)):
try:
return get_or_404(db, Certificate, cert_id, "证书")
except HTTPException:
raise
except Exception as e:
logger.error(f"获取证书失败: {str(e)}")
raise HTTPException(status_code=500, detail="获取证书失败")
@router.put("/certificates/{cert_id}", response_model=CertificateDetailResponse)
def update_certificate(cert_id: int, data: CertificateUpdate, db: Session = Depends(get_db)):
try:
cert = get_or_404(db, Certificate, cert_id, "证书")
update_data = data.model_dump(exclude_unset=True)
for field, value in update_data.items():
if value is not None or field in data.clearable_fields:
setattr(cert, field, value)
cert.updated_at = utcnow()
db.commit()
db.refresh(cert)
logger.info(f"更新证书成功: id={cert_id}")
return cert
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"更新证书失败: {str(e)}")
raise HTTPException(status_code=500, detail="更新证书失败")
@router.delete("/certificates/{cert_id}")
def delete_certificate(cert_id: int, db: Session = Depends(get_db)):
try:
cert = get_or_404(db, Certificate, cert_id, "证书")
db.delete(cert)
db.commit()
logger.info(f"删除证书成功: id={cert_id}")
return DeleteResponse(message="证书删除成功")
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"删除证书失败: {str(e)}")
raise HTTPException(status_code=500, detail="删除证书失败")

View File

@@ -0,0 +1,79 @@
"""证书 Schema"""
from pydantic import BaseModel, Field, field_validator
from datetime import date, datetime
from typing import Optional, List
# ============ 证书分类 Schema ============
class CertificateCategoryBase(BaseModel):
name: str = Field(..., max_length=50)
icon: str = Field(default="medal", max_length=50)
color: str = Field(default="#FFB7C5", max_length=20)
sort_order: int = Field(default=0)
class CertificateCategoryCreate(CertificateCategoryBase):
pass
class CertificateCategoryUpdate(BaseModel):
name: Optional[str] = Field(None, max_length=50)
icon: Optional[str] = Field(None, max_length=50)
color: Optional[str] = Field(None, max_length=20)
sort_order: Optional[int] = None
class CertificateCategoryResponse(CertificateCategoryBase):
id: int
uuid: Optional[str] = None
class Config:
from_attributes = True
# ============ 证书 Schema ============
class CertificateBase(BaseModel):
title: str = Field(..., max_length=200)
category_id: Optional[int] = None
image: Optional[str] = None
issuer: Optional[str] = Field(None, max_length=200)
issue_date: Optional[date] = None
expiry_date: Optional[date] = None
description: Optional[str] = None
sort_order: int = Field(default=0)
class CertificateCreate(CertificateBase):
pass
class CertificateUpdate(BaseModel):
title: Optional[str] = Field(None, max_length=200)
category_id: Optional[int] = None
image: Optional[str] = None
issuer: Optional[str] = Field(None, max_length=200)
issue_date: Optional[date] = None
expiry_date: Optional[date] = None
description: Optional[str] = None
sort_order: Optional[int] = None
@property
def clearable_fields(self) -> set:
return {"description", "category_id", "image", "issuer", "issue_date", "expiry_date"}
class CertificateListResponse(CertificateBase):
id: int
uuid: Optional[str] = None
created_at: datetime
updated_at: datetime
category: Optional[CertificateCategoryResponse] = None
class Config:
from_attributes = True
class CertificateDetailResponse(CertificateListResponse):
pass