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:
46
WebUI/src/api/certificates.ts
Normal file
46
WebUI/src/api/certificates.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { get, post, put, del } from './request'
|
||||
import type {
|
||||
Certificate, CertificateFormData,
|
||||
CertificateCategory, CertificateCategoryFormData,
|
||||
} from './types'
|
||||
|
||||
// ============ Categories ============
|
||||
|
||||
export function getCategories(): Promise<CertificateCategory[]> {
|
||||
return get<CertificateCategory[]>('/certificate-categories')
|
||||
}
|
||||
|
||||
export function createCategory(data: CertificateCategoryFormData): Promise<CertificateCategory> {
|
||||
return post<CertificateCategory>('/certificate-categories', data)
|
||||
}
|
||||
|
||||
export function updateCategory(id: number, data: Partial<CertificateCategoryFormData>): Promise<CertificateCategory> {
|
||||
return put<CertificateCategory>(`/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<Certificate[]> {
|
||||
const params = categoryId ? `?category_id=${categoryId}` : ''
|
||||
return get<Certificate[]>(`/certificates${params}`)
|
||||
}
|
||||
|
||||
export function getCertificate(id: number): Promise<Certificate> {
|
||||
return get<Certificate>(`/certificates/${id}`)
|
||||
}
|
||||
|
||||
export function createCertificate(data: CertificateFormData): Promise<Certificate> {
|
||||
return post<Certificate>('/certificates', data)
|
||||
}
|
||||
|
||||
export function updateCertificate(id: number, data: Partial<CertificateFormData>): Promise<Certificate> {
|
||||
return put<Certificate>(`/certificates/${id}`, data)
|
||||
}
|
||||
|
||||
export function deleteCertificate(id: number): Promise<{ message: string }> {
|
||||
return del<{ message: string }>(`/certificates/${id}`)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -103,6 +103,14 @@ const currentRouteName = computed(() => route.name as string)
|
||||
<el-icon><Aim /></el-icon>
|
||||
<span>目标</span>
|
||||
</button>
|
||||
<button
|
||||
class="nav-item"
|
||||
:class="{ active: currentRouteName === 'certificates' }"
|
||||
@click="router.push('/certificates')"
|
||||
>
|
||||
<el-icon><Medal /></el-icon>
|
||||
<span>证书</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="header-right">
|
||||
|
||||
195
WebUI/src/components/CertificateDialog.vue
Normal file
195
WebUI/src/components/CertificateDialog.vue
Normal file
@@ -0,0 +1,195 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useCertificateStore } from '@/stores/useCertificateStore'
|
||||
import type { Certificate, CertificateFormData } from '@/api/types'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
editingCert?: Certificate | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
saved: []
|
||||
}>()
|
||||
|
||||
const store = useCertificateStore()
|
||||
|
||||
const form = ref<CertificateFormData>({
|
||||
title: '',
|
||||
category_id: null,
|
||||
image: null,
|
||||
issuer: null,
|
||||
issue_date: null,
|
||||
expiry_date: null,
|
||||
description: null,
|
||||
})
|
||||
|
||||
const isEditing = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
watch(() => props.visible, (val) => {
|
||||
if (val) {
|
||||
store.fetchCategories()
|
||||
if (props.editingCert) {
|
||||
isEditing.value = true
|
||||
form.value = {
|
||||
title: props.editingCert.title,
|
||||
category_id: props.editingCert.category_id ?? null,
|
||||
image: props.editingCert.image ?? null,
|
||||
issuer: props.editingCert.issuer ?? null,
|
||||
issue_date: props.editingCert.issue_date ?? null,
|
||||
expiry_date: props.editingCert.expiry_date ?? null,
|
||||
description: props.editingCert.description ?? null,
|
||||
}
|
||||
} else {
|
||||
isEditing.value = false
|
||||
form.value = {
|
||||
title: '',
|
||||
category_id: null,
|
||||
image: null,
|
||||
issuer: null,
|
||||
issue_date: null,
|
||||
expiry_date: null,
|
||||
description: null,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function handleImageUpload(e: Event) {
|
||||
const file = (e.target as HTMLInputElement).files?.[0]
|
||||
if (!file) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
form.value.image = reader.result as string
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
function removeImage() {
|
||||
form.value.image = null
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.value.title.trim()) return
|
||||
saving.value = true
|
||||
try {
|
||||
if (isEditing.value && props.editingCert) {
|
||||
await store.updateCertificate(props.editingCert.id, form.value)
|
||||
} else {
|
||||
await store.createCertificate(form.value)
|
||||
}
|
||||
emit('saved')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="visible"
|
||||
:title="isEditing ? '编辑证书' : '添加证书'"
|
||||
width="520px"
|
||||
@close="emit('close')"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form :model="form" label-position="top">
|
||||
<el-form-item label="证书名称" required>
|
||||
<el-input v-model="form.title" maxlength="200" placeholder="例如:CET-6 英语六级证书" />
|
||||
</el-form-item>
|
||||
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="分类">
|
||||
<el-select v-model="form.category_id" style="width:100%" clearable placeholder="选择分类">
|
||||
<el-option v-for="cat in store.categories" :key="cat.id" :label="cat.name" :value="cat.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="颁发机构">
|
||||
<el-input v-model="form.issuer" maxlength="200" placeholder="例如:教育部考试中心" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="获取日期">
|
||||
<el-date-picker v-model="form.issue_date" type="date" placeholder="选择日期" style="width:100%" value-format="YYYY-MM-DD" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="有效期至">
|
||||
<el-date-picker v-model="form.expiry_date" type="date" placeholder="留空表示永久有效" style="width:100%" value-format="YYYY-MM-DD" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item label="证书图片">
|
||||
<div class="image-upload">
|
||||
<div v-if="form.image" class="image-preview">
|
||||
<img :src="form.image" alt="证书预览" />
|
||||
<el-button text type="danger" size="small" class="remove-img-btn" @click="removeImage">移除图片</el-button>
|
||||
</div>
|
||||
<label v-else class="upload-area">
|
||||
<input type="file" accept="image/*" style="display:none" @change="handleImageUpload" />
|
||||
<el-icon :size="32"><Plus /></el-icon>
|
||||
<span>上传证书图片</span>
|
||||
</label>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="补充说明..." />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="emit('close')">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="handleSave">
|
||||
{{ isEditing ? '保存' : '添加' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.image-upload {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.upload-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
height: 120px;
|
||||
border: 2px dashed #ddd;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
color: #999;
|
||||
transition: all 0.2s;
|
||||
&:hover { border-color: var(--primary); color: var(--primary); }
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
img {
|
||||
max-width: 100%;
|
||||
max-height: 200px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #eee;
|
||||
}
|
||||
.remove-img-btn {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
background: rgba(255,255,255,0.9);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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' }
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
105
WebUI/src/stores/useCertificateStore.ts
Normal file
105
WebUI/src/stores/useCertificateStore.ts
Normal file
@@ -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<Certificate[]>([])
|
||||
const categories = ref<CertificateCategory[]>([])
|
||||
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<Certificate | null> {
|
||||
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<CertificateFormData>): Promise<Certificate | null> {
|
||||
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<boolean> {
|
||||
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<CertificateCategory | null> {
|
||||
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<CertificateCategoryFormData>): Promise<CertificateCategory | null> {
|
||||
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<boolean> {
|
||||
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,
|
||||
}
|
||||
})
|
||||
314
WebUI/src/views/CertificatePage.vue
Normal file
314
WebUI/src/views/CertificatePage.vue
Normal file
@@ -0,0 +1,314 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { Plus, Edit, Delete, Medal } from '@element-plus/icons-vue'
|
||||
import { useCertificateStore } from '@/stores/useCertificateStore'
|
||||
import CertificateDialog from '@/components/CertificateDialog.vue'
|
||||
import type { Certificate, CertificateCategory } from '@/api/types'
|
||||
import { ElMessageBox, ElMessage } from 'element-plus'
|
||||
|
||||
const store = useCertificateStore()
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const editingCert = ref<Certificate | null>(null)
|
||||
const selectedCategoryId = ref<number | undefined>()
|
||||
|
||||
const catDialogVisible = ref(false)
|
||||
const editingCat = ref<CertificateCategory | null>(null)
|
||||
const catForm = ref({ name: '', color: '#FFB7C5', icon: 'medal' })
|
||||
|
||||
onMounted(async () => {
|
||||
await store.fetchCategories()
|
||||
await store.fetchCertificates()
|
||||
})
|
||||
|
||||
function openCreate() {
|
||||
editingCert.value = null
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(cert: Certificate) {
|
||||
editingCert.value = cert
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleDelete(cert: Certificate) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要删除证书「${cert.title}」吗?`, '删除确认', {
|
||||
type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消',
|
||||
})
|
||||
await store.deleteCertificate(cert.id)
|
||||
ElMessage.success('证书已删除')
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function onSaved() {
|
||||
dialogVisible.value = false
|
||||
}
|
||||
|
||||
function filterByCategory(catId?: number) {
|
||||
selectedCategoryId.value = catId
|
||||
store.fetchCertificates(catId)
|
||||
}
|
||||
|
||||
function openCatDialog(cat?: CertificateCategory) {
|
||||
editingCat.value = cat || null
|
||||
if (cat) {
|
||||
catForm.value = { name: cat.name, color: cat.color, icon: cat.icon }
|
||||
} else {
|
||||
catForm.value = { name: '', color: '#FFB7C5', icon: 'medal' }
|
||||
}
|
||||
catDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleCatSave() {
|
||||
if (!catForm.value.name.trim()) return
|
||||
if (editingCat.value) {
|
||||
await store.updateCategory(editingCat.value.id, catForm.value)
|
||||
} else {
|
||||
await store.createCategory(catForm.value)
|
||||
}
|
||||
catDialogVisible.value = false
|
||||
}
|
||||
|
||||
async function handleCatDelete() {
|
||||
if (!editingCat.value) return
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要删除分类「${editingCat.value.name}」吗?`, '删除确认', {
|
||||
type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消',
|
||||
})
|
||||
await store.deleteCategory(editingCat.value.id)
|
||||
catDialogVisible.value = false
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const isExpired = (cert: Certificate): boolean => {
|
||||
if (!cert.expiry_date) return false
|
||||
return new Date(cert.expiry_date) < new Date()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cert-page">
|
||||
<div class="page-header">
|
||||
<h2>我的证书</h2>
|
||||
<el-button type="primary" :icon="Plus" @click="openCreate">添加证书</el-button>
|
||||
</div>
|
||||
|
||||
<div class="cert-layout">
|
||||
<aside class="cert-sidebar">
|
||||
<div class="sidebar-section">
|
||||
<div class="section-header">
|
||||
<h3>分类</h3>
|
||||
<el-button text size="small" @click="openCatDialog()">
|
||||
<el-icon><Plus /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
<ul class="cat-list">
|
||||
<li
|
||||
:class="{ active: selectedCategoryId === undefined }"
|
||||
@click="filterByCategory()"
|
||||
>全部</li>
|
||||
<li
|
||||
v-for="cat in store.categories" :key="cat.id"
|
||||
:class="{ active: selectedCategoryId === cat.id }"
|
||||
@click="filterByCategory(cat.id)"
|
||||
>
|
||||
<span class="cat-dot" :style="{ background: cat.color }"></span>
|
||||
{{ cat.name }}
|
||||
<span class="cat-actions">
|
||||
<el-button text size="small" @click.stop="openCatDialog(cat)"><el-icon><Edit /></el-icon></el-button>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="cert-grid">
|
||||
<div v-if="store.loading" class="loading-state">加载中...</div>
|
||||
|
||||
<div v-else-if="store.certificates.length === 0" class="empty-state">
|
||||
<el-icon :size="64" color="#ccc"><Medallion /></el-icon>
|
||||
<p>还没有证书</p>
|
||||
<el-button type="primary" @click="openCreate">添加第一个证书</el-button>
|
||||
</div>
|
||||
|
||||
<div v-for="cert in store.certificates" :key="cert.id" class="cert-card" :class="{ expired: isExpired(cert) }">
|
||||
<div class="card-image" @click="openEdit(cert)">
|
||||
<img v-if="cert.image" :src="cert.image" alt="" />
|
||||
<el-icon v-else :size="48" color="#ddd"><Medallion /></el-icon>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h4 class="card-title">{{ cert.title }}</h4>
|
||||
<div class="card-meta">
|
||||
<span v-if="cert.issuer" class="meta-item">
|
||||
<el-icon :size="14"><OfficeBuilding /></el-icon>
|
||||
{{ cert.issuer }}
|
||||
</span>
|
||||
<span v-if="cert.issue_date" class="meta-item">
|
||||
<el-icon :size="14"><Calendar /></el-icon>
|
||||
{{ cert.issue_date }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<el-tag v-if="cert.expiry_date" :type="isExpired(cert) ? 'danger' : 'success'" size="small">
|
||||
{{ isExpired(cert) ? '已过期' : '有效期至 ' + cert.expiry_date }}
|
||||
</el-tag>
|
||||
<el-tag v-else type="info" size="small">永久有效</el-tag>
|
||||
<span v-if="cert.category" class="cat-badge" :style="{ color: cert.category.color }">
|
||||
{{ cert.category.name }}
|
||||
</span>
|
||||
<div class="card-actions">
|
||||
<el-button text size="small" @click="openEdit(cert)"><el-icon><Edit /></el-icon></el-button>
|
||||
<el-button text size="small" type="danger" @click="handleDelete(cert)"><el-icon><Delete /></el-icon></el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CertificateDialog :visible="dialogVisible" :editing-cert="editingCert" @close="dialogVisible = false" @saved="onSaved" />
|
||||
|
||||
<!-- Category Dialog -->
|
||||
<el-dialog v-model="catDialogVisible" :title="editingCat ? '编辑分类' : '新建分类'" width="400px" destroy-on-close>
|
||||
<el-form :model="catForm" label-position="top">
|
||||
<el-form-item label="分类名称" required>
|
||||
<el-input v-model="catForm.name" maxlength="50" placeholder="例如:比赛证书" />
|
||||
</el-form-item>
|
||||
<el-form-item label="颜色">
|
||||
<div class="color-row">
|
||||
<button v-for="c in ['#FFB7C5','#FF6B81','#FFA502','#7BED9F','#70A1FF','#5352ED','#A29BFE','#FF7979']" :key="c"
|
||||
class="color-dot" :class="{ active: catForm.color === c }" :style="{ background: c }" @click.prevent="catForm.color = c" />
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button v-if="editingCat" type="danger" text @click="handleCatDelete">删除分类</el-button>
|
||||
<el-button @click="catDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleCatSave">{{ editingCat ? '保存' : '创建' }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.cert-page {
|
||||
padding: 24px;
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
h2 { margin: 0; font-size: 22px; }
|
||||
}
|
||||
|
||||
.cert-layout {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.cert-sidebar {
|
||||
width: 180px;
|
||||
flex-shrink: 0;
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
h3 { margin: 0; font-size: 13px; color: #999; text-transform: uppercase; }
|
||||
}
|
||||
.cat-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
transition: all 0.15s;
|
||||
&:hover { background: #f5f5f5; }
|
||||
&.active { background: #fef0f5; color: var(--primary); font-weight: 500; }
|
||||
.cat-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||
.cat-actions { margin-left: auto; opacity: 0; }
|
||||
&:hover .cat-actions { opacity: 1; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cert-grid {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 16px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.cert-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
|
||||
transition: transform 0.15s, box-shadow 0.15s;
|
||||
&.expired { opacity: 0.7; }
|
||||
&:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
|
||||
.card-image {
|
||||
height: 160px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fafafa;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
img { width: 100%; height: 100%; object-fit: cover; }
|
||||
}
|
||||
.card-body {
|
||||
padding: 14px;
|
||||
.card-title { margin: 0 0 8px; font-size: 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.card-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 10px;
|
||||
.meta-item { font-size: 12px; color: #999; display: flex; align-items: center; gap: 4px; }
|
||||
}
|
||||
.card-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
.cat-badge { font-size: 12px; margin-left: auto; }
|
||||
.card-actions { display: flex; gap: 2px; opacity: 0; }
|
||||
}
|
||||
&:hover .card-actions { opacity: 1; }
|
||||
}
|
||||
}
|
||||
|
||||
.loading-state, .empty-state {
|
||||
text-align: center;
|
||||
padding: 80px 0;
|
||||
color: #999;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.color-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
.color-dot {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
&:hover { transform: scale(1.15); }
|
||||
&.active { border-color: #333; }
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -51,6 +51,15 @@ function hasPhaseParent(step: GoalStep): boolean {
|
||||
) ?? false
|
||||
}
|
||||
|
||||
const phaseParentIds = computed(() => {
|
||||
if (!goalStore.currentGoal) return new Set<number>()
|
||||
return new Set(
|
||||
goalStore.currentGoal.steps
|
||||
.filter((s: GoalStep) => s.step_type === 'phase')
|
||||
.map((s: GoalStep) => s.id)
|
||||
)
|
||||
})
|
||||
|
||||
const stepColor: Record<string, string> = {
|
||||
pending: '#909399',
|
||||
in_progress: '#E6A23C',
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
43
api/app/models/certificate.py
Normal file
43
api/app/models/certificate.py
Normal 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")
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
167
api/app/routers/certificates.py
Normal file
167
api/app/routers/certificates.py
Normal 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="删除证书失败")
|
||||
79
api/app/schemas/certificate.py
Normal file
79
api/app/schemas/certificate.py
Normal 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
|
||||
Reference in New Issue
Block a user