diff --git a/WebUI/src/api/certificates.ts b/WebUI/src/api/certificates.ts new file mode 100644 index 0000000..4619a17 --- /dev/null +++ b/WebUI/src/api/certificates.ts @@ -0,0 +1,46 @@ +import { get, post, put, del } from './request' +import type { + Certificate, CertificateFormData, + CertificateCategory, CertificateCategoryFormData, +} from './types' + +// ============ Categories ============ + +export function getCategories(): Promise { + return get('/certificate-categories') +} + +export function createCategory(data: CertificateCategoryFormData): Promise { + return post('/certificate-categories', data) +} + +export function updateCategory(id: number, data: Partial): Promise { + return put(`/certificate-categories/${id}`, data) +} + +export function deleteCategory(id: number): Promise<{ message: string }> { + return del<{ message: string }>(`/certificate-categories/${id}`) +} + +// ============ Certificates ============ + +export function getCertificates(categoryId?: number): Promise { + const params = categoryId ? `?category_id=${categoryId}` : '' + return get(`/certificates${params}`) +} + +export function getCertificate(id: number): Promise { + return get(`/certificates/${id}`) +} + +export function createCertificate(data: CertificateFormData): Promise { + return post('/certificates', data) +} + +export function updateCertificate(id: number, data: Partial): Promise { + return put(`/certificates/${id}`, data) +} + +export function deleteCertificate(id: number): Promise<{ message: string }> { + return del<{ message: string }>(`/certificates/${id}`) +} diff --git a/WebUI/src/api/types.ts b/WebUI/src/api/types.ts index f2f2026..51233e2 100644 --- a/WebUI/src/api/types.ts +++ b/WebUI/src/api/types.ts @@ -275,3 +275,48 @@ export interface GoalReviewFormData { content: string rating?: number | null } + +// ============ 证书相关 ============ + +export interface CertificateCategory { + id: number + uuid?: string + name: string + icon: string + color: string + sort_order: number +} + +export interface CertificateCategoryFormData { + name: string + icon: string + color: string + sort_order?: number +} + +export interface Certificate { + id: number + uuid?: string + title: string + category_id?: number | null + image?: string | null + issuer?: string | null + issue_date?: string | null + expiry_date?: string | null + description?: string | null + sort_order: number + created_at: string + updated_at: string + category?: CertificateCategory | null +} + +export interface CertificateFormData { + title: string + category_id?: number | null + image?: string | null + issuer?: string | null + issue_date?: string | null + expiry_date?: string | null + description?: string | null + sort_order?: number +} diff --git a/WebUI/src/components/AppHeader.vue b/WebUI/src/components/AppHeader.vue index eb8e8d2..256efc9 100644 --- a/WebUI/src/components/AppHeader.vue +++ b/WebUI/src/components/AppHeader.vue @@ -103,6 +103,14 @@ const currentRouteName = computed(() => route.name as string) 目标 +
diff --git a/WebUI/src/components/CertificateDialog.vue b/WebUI/src/components/CertificateDialog.vue new file mode 100644 index 0000000..52c8e87 --- /dev/null +++ b/WebUI/src/components/CertificateDialog.vue @@ -0,0 +1,195 @@ + + + + + diff --git a/WebUI/src/router/index.ts b/WebUI/src/router/index.ts index aeaebe7..d86c901 100644 --- a/WebUI/src/router/index.ts +++ b/WebUI/src/router/index.ts @@ -73,6 +73,12 @@ const routes: RouteRecordRaw[] = [ name: 'goalDetail', component: () => import('@/views/GoalDetailPage.vue'), meta: { title: '目标详情', view: 'goals' } + }, + { + path: '/certificates', + name: 'certificates', + component: () => import('@/views/CertificatePage.vue'), + meta: { title: '证书管理', view: 'certificates' } } ] diff --git a/WebUI/src/stores/useCertificateStore.ts b/WebUI/src/stores/useCertificateStore.ts new file mode 100644 index 0000000..3378c28 --- /dev/null +++ b/WebUI/src/stores/useCertificateStore.ts @@ -0,0 +1,105 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import type { Certificate, CertificateCategory, CertificateFormData, CertificateCategoryFormData } from '@/api/types' +import * as certApi from '@/api/certificates' + +export const useCertificateStore = defineStore('certificate', () => { + const certificates = ref([]) + const categories = ref([]) + const loading = ref(false) + const error = ref('') + + async function fetchCertificates(categoryId?: number) { + loading.value = true + error.value = '' + try { + certificates.value = await certApi.getCertificates(categoryId) + } catch (e: any) { + error.value = e?.response?.data?.detail || '获取证书列表失败' + } finally { + loading.value = false + } + } + + async function fetchCategories() { + try { + categories.value = await certApi.getCategories() + } catch (e: any) { + error.value = e?.response?.data?.detail || '获取证书分类失败' + } + } + + async function createCertificate(data: CertificateFormData): Promise { + try { + const cert = await certApi.createCertificate(data) + await fetchCertificates() + return cert + } catch (e: any) { + error.value = e?.response?.data?.detail || '创建证书失败' + return null + } + } + + async function updateCertificate(id: number, data: Partial): Promise { + try { + const cert = await certApi.updateCertificate(id, data) + await fetchCertificates() + return cert + } catch (e: any) { + error.value = e?.response?.data?.detail || '更新证书失败' + return null + } + } + + async function deleteCertificate(id: number): Promise { + try { + await certApi.deleteCertificate(id) + await fetchCertificates() + return true + } catch (e: any) { + error.value = e?.response?.data?.detail || '删除证书失败' + return false + } + } + + async function createCategory(data: CertificateCategoryFormData): Promise { + try { + const cat = await certApi.createCategory(data) + categories.value.push(cat) + return cat + } catch (e: any) { + error.value = e?.response?.data?.detail || '创建证书分类失败' + return null + } + } + + async function updateCategory(id: number, data: Partial): Promise { + try { + const cat = await certApi.updateCategory(id, data) + const idx = categories.value.findIndex(c => c.id === id) + if (idx !== -1) categories.value[idx] = cat + return cat + } catch (e: any) { + error.value = e?.response?.data?.detail || '更新证书分类失败' + return null + } + } + + async function deleteCategory(id: number): Promise { + try { + await certApi.deleteCategory(id) + categories.value = categories.value.filter(c => c.id !== id) + return true + } catch (e: any) { + error.value = e?.response?.data?.detail || '删除证书分类失败' + return false + } + } + + return { + certificates, categories, loading, error, + fetchCertificates, fetchCategories, + createCertificate, updateCertificate, deleteCertificate, + createCategory, updateCategory, deleteCategory, + } +}) diff --git a/WebUI/src/views/CertificatePage.vue b/WebUI/src/views/CertificatePage.vue new file mode 100644 index 0000000..d2e08ac --- /dev/null +++ b/WebUI/src/views/CertificatePage.vue @@ -0,0 +1,314 @@ + + + + + diff --git a/WebUI/src/views/GoalDetailPage.vue b/WebUI/src/views/GoalDetailPage.vue index 7ab7cc0..109d64c 100644 --- a/WebUI/src/views/GoalDetailPage.vue +++ b/WebUI/src/views/GoalDetailPage.vue @@ -51,6 +51,15 @@ function hasPhaseParent(step: GoalStep): boolean { ) ?? false } +const phaseParentIds = computed(() => { + if (!goalStore.currentGoal) return new Set() + return new Set( + goalStore.currentGoal.steps + .filter((s: GoalStep) => s.step_type === 'phase') + .map((s: GoalStep) => s.id) + ) +}) + const stepColor: Record = { pending: '#909399', in_progress: '#E6A23C', diff --git a/api/app/models/__init__.py b/api/app/models/__init__.py index 2a3a03f..b4737f4 100644 --- a/api/app/models/__init__.py +++ b/api/app/models/__init__.py @@ -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", ] diff --git a/api/app/models/certificate.py b/api/app/models/certificate.py new file mode 100644 index 0000000..b481739 --- /dev/null +++ b/api/app/models/certificate.py @@ -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") diff --git a/api/app/routers/__init__.py b/api/app/routers/__init__.py index 6ebf9a7..3407ce1 100644 --- a/api/app/routers/__init__.py +++ b/api/app/routers/__init__.py @@ -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) diff --git a/api/app/routers/backup.py b/api/app/routers/backup.py index 8ef5ff7..2545008 100644 --- a/api/app/routers/backup.py +++ b/api/app/routers/backup.py @@ -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", ] diff --git a/api/app/routers/certificates.py b/api/app/routers/certificates.py new file mode 100644 index 0000000..87d5c7d --- /dev/null +++ b/api/app/routers/certificates.py @@ -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="删除证书失败") diff --git a/api/app/schemas/certificate.py b/api/app/schemas/certificate.py new file mode 100644 index 0000000..c29711e --- /dev/null +++ b/api/app/schemas/certificate.py @@ -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