1. 奖章条件优化 - 修复统计查询 SQL 语法 - 添加按类别检查奖章方法 2. 移动端适配 - 登录页、课程中心、课程详情 - 考试页面、成长路径、排行榜 3. 证书系统 - 数据库模型和迁移脚本 - 证书颁发/列表/下载/验证 API - 前端证书列表页面 4. 数据大屏 - 企业级/团队级数据 API - ECharts 可视化大屏页面
This commit is contained in:
@@ -911,4 +911,128 @@ const loadMore = () => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 手机端深度优化
|
||||
@media (max-width: 480px) {
|
||||
.course-center-container {
|
||||
padding: 0 12px;
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 20px;
|
||||
|
||||
.page-title {
|
||||
font-size: 22px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.category-filter {
|
||||
margin-bottom: 16px;
|
||||
margin-left: -12px;
|
||||
margin-right: -12px;
|
||||
padding: 0 12px;
|
||||
|
||||
.el-radio-group {
|
||||
:deep(.el-radio-button__inner) {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.course-grid {
|
||||
gap: 12px;
|
||||
|
||||
.course-card {
|
||||
border-radius: 12px;
|
||||
|
||||
.card-body {
|
||||
padding: 14px;
|
||||
|
||||
.card-header-info {
|
||||
margin-bottom: 10px;
|
||||
|
||||
.badge {
|
||||
padding: 3px 8px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.progress-badge .progress-text {
|
||||
padding: 3px 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
.course-title {
|
||||
font-size: 16px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.course-description {
|
||||
font-size: 12px;
|
||||
margin-bottom: 12px;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.course-stats {
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.stat-item {
|
||||
font-size: 11px;
|
||||
|
||||
.el-icon {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
padding: 0 14px 14px;
|
||||
gap: 10px;
|
||||
|
||||
.action-btn.primary-btn {
|
||||
height: 44px;
|
||||
font-size: 15px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.secondary-actions {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 6px;
|
||||
|
||||
.action-btn {
|
||||
padding: 10px 4px;
|
||||
|
||||
&.secondary-btn,
|
||||
&.exam-btn,
|
||||
&.practice-btn {
|
||||
font-size: 11px;
|
||||
|
||||
.el-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 60px 16px;
|
||||
}
|
||||
|
||||
.load-more {
|
||||
margin-bottom: 40px;
|
||||
|
||||
.el-button {
|
||||
width: 100%;
|
||||
max-width: 280px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1297,4 +1297,178 @@ onUnmounted(() => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 手机端深度优化
|
||||
@media (max-width: 480px) {
|
||||
.course-detail-container {
|
||||
padding: 8px;
|
||||
|
||||
.course-header {
|
||||
padding: 14px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 10px;
|
||||
|
||||
.header-content {
|
||||
.breadcrumb {
|
||||
margin-bottom: 12px;
|
||||
|
||||
:deep(.el-breadcrumb) {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.course-title {
|
||||
font-size: 18px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.course-desc {
|
||||
font-size: 13px;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.course-meta {
|
||||
gap: 8px;
|
||||
|
||||
.meta-item {
|
||||
font-size: 12px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.progress-section {
|
||||
margin-top: 12px;
|
||||
|
||||
.progress-info {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.course-content {
|
||||
gap: 10px;
|
||||
|
||||
.content-sidebar {
|
||||
padding: 10px;
|
||||
max-height: 200px;
|
||||
border-radius: 12px;
|
||||
|
||||
.sidebar-header {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
|
||||
.sidebar-title {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.el-input {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
|
||||
.file-type-filter {
|
||||
margin-bottom: 10px;
|
||||
overflow-x: auto;
|
||||
|
||||
.el-radio-group {
|
||||
flex-wrap: nowrap;
|
||||
|
||||
:deep(.el-radio-button__inner) {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.material-list {
|
||||
.material-item {
|
||||
padding: 8px 10px;
|
||||
|
||||
.material-info {
|
||||
.material-name {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.material-meta {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content-main {
|
||||
padding: 10px;
|
||||
border-radius: 12px;
|
||||
|
||||
.empty-state {
|
||||
padding: 40px 20px;
|
||||
|
||||
p {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-container {
|
||||
min-height: 300px;
|
||||
|
||||
.preview-toolbar {
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
|
||||
.toolbar-left {
|
||||
.preview-title {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar-right {
|
||||
.el-button {
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
.pdf-viewer-container {
|
||||
.pdf-toolbar {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
|
||||
.page-controls,
|
||||
.zoom-controls {
|
||||
gap: 4px;
|
||||
|
||||
.page-info,
|
||||
.zoom-info {
|
||||
font-size: 12px;
|
||||
min-width: 50px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 视频自适应
|
||||
.video-player {
|
||||
video {
|
||||
max-height: 50vh;
|
||||
}
|
||||
}
|
||||
|
||||
// 图片自适应
|
||||
.image-preview {
|
||||
img {
|
||||
max-height: 60vh;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2199,4 +2199,203 @@ onUnmounted(() => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 手机端深度优化
|
||||
@media (max-width: 480px) {
|
||||
.growth-path-container {
|
||||
padding: 12px;
|
||||
|
||||
.personal-info {
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
|
||||
.info-left {
|
||||
.el-avatar {
|
||||
width: 64px !important;
|
||||
height: 64px !important;
|
||||
}
|
||||
|
||||
.info-content {
|
||||
.user-name {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.user-meta {
|
||||
font-size: 12px;
|
||||
gap: 6px;
|
||||
|
||||
.separator {
|
||||
margin: 0 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.info-right {
|
||||
.el-button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.main-content {
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
|
||||
.card {
|
||||
padding: 14px;
|
||||
border-radius: 12px;
|
||||
|
||||
.card-header {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.el-button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ability-radar {
|
||||
.radar-chart {
|
||||
height: 260px;
|
||||
}
|
||||
|
||||
.ability-feedback {
|
||||
.feedback-item {
|
||||
padding: 12px;
|
||||
|
||||
.feedback-header-row {
|
||||
.dimension-name {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.dimension-score {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.feedback-text {
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ai-learning-hub-inner {
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
|
||||
.hub-header {
|
||||
.ai-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.header-text {
|
||||
.hub-title {
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.hub-subtitle {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
width: 100%;
|
||||
|
||||
.refresh-btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.recommendation-stats {
|
||||
.stat-card {
|
||||
padding: 12px;
|
||||
|
||||
.stat-content {
|
||||
.stat-number {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.recommendations-section {
|
||||
.section-header {
|
||||
.section-title {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.filter-tabs {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
|
||||
.el-radio-group {
|
||||
flex-wrap: nowrap;
|
||||
|
||||
:deep(.el-radio-button__inner) {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.course-grid {
|
||||
gap: 14px;
|
||||
|
||||
.smart-course-card {
|
||||
padding: 14px;
|
||||
border-radius: 12px;
|
||||
|
||||
.card-content {
|
||||
.course-header {
|
||||
.course-name {
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.course-reason {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.course-meta {
|
||||
gap: 6px;
|
||||
|
||||
.meta-tag {
|
||||
font-size: 11px;
|
||||
padding: 3px 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.action-row {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
|
||||
.improvement-badge,
|
||||
.el-button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -488,4 +488,134 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 手机端深度优化
|
||||
@media (max-width: 480px) {
|
||||
.leaderboard-page {
|
||||
padding: 12px;
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 16px;
|
||||
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.my-rank-card {
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
gap: 16px;
|
||||
|
||||
.rank-badge {
|
||||
.rank-number {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.rank-label {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
.my-info {
|
||||
gap: 10px;
|
||||
|
||||
.level-badge {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
|
||||
.level-number {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.my-details {
|
||||
.my-title {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.my-exp {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.checkin-section {
|
||||
width: 100%;
|
||||
|
||||
.el-button {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.streak-info {
|
||||
font-size: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.leaderboard-list {
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.leaderboard-item {
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
gap: 10px;
|
||||
|
||||
.rank-section {
|
||||
width: 40px;
|
||||
|
||||
.rank-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.user-section {
|
||||
gap: 10px;
|
||||
|
||||
.el-avatar {
|
||||
width: 36px !important;
|
||||
height: 36px !important;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
.user-name {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.user-title {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.stats-section {
|
||||
gap: 16px;
|
||||
padding-top: 10px;
|
||||
margin-top: 10px;
|
||||
|
||||
.stat-item {
|
||||
.stat-value {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.load-more {
|
||||
padding: 12px 0 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
735
frontend/src/views/trainee/my-certificates.vue
Normal file
735
frontend/src/views/trainee/my-certificates.vue
Normal file
@@ -0,0 +1,735 @@
|
||||
<template>
|
||||
<div class="certificates-page">
|
||||
<!-- 页面标题 -->
|
||||
<div class="page-header">
|
||||
<h2>我的证书</h2>
|
||||
<p class="subtitle">记录您的学习成就与荣誉</p>
|
||||
</div>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<div class="stats-cards">
|
||||
<div class="stat-card" v-for="stat in stats" :key="stat.type">
|
||||
<div class="stat-icon" :class="stat.type">
|
||||
<el-icon><component :is="stat.icon" /></el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ stat.count }}</div>
|
||||
<div class="stat-label">{{ stat.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 筛选区 -->
|
||||
<div class="filter-bar">
|
||||
<el-radio-group v-model="filterType" @change="handleFilterChange">
|
||||
<el-radio-button label="">全部</el-radio-button>
|
||||
<el-radio-button label="course">课程证书</el-radio-button>
|
||||
<el-radio-button label="exam">考试证书</el-radio-button>
|
||||
<el-radio-button label="achievement">成就证书</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
|
||||
<!-- 证书列表 -->
|
||||
<div class="certificates-list" v-loading="loading">
|
||||
<div
|
||||
v-for="cert in certificates"
|
||||
:key="cert.id"
|
||||
class="certificate-card"
|
||||
:class="cert.type"
|
||||
@click="viewCertificate(cert)"
|
||||
>
|
||||
<div class="cert-header">
|
||||
<div class="cert-type-badge" :class="cert.type">
|
||||
{{ cert.type_name }}
|
||||
</div>
|
||||
<div class="cert-date">{{ formatDate(cert.issued_at) }}</div>
|
||||
</div>
|
||||
|
||||
<div class="cert-body">
|
||||
<h3 class="cert-title">{{ cert.title }}</h3>
|
||||
<p class="cert-description">{{ cert.description }}</p>
|
||||
|
||||
<div class="cert-info" v-if="cert.score || cert.completion_rate">
|
||||
<span v-if="cert.score" class="info-item">
|
||||
<el-icon><Trophy /></el-icon>
|
||||
成绩:{{ cert.score }}分
|
||||
</span>
|
||||
<span v-if="cert.completion_rate" class="info-item">
|
||||
<el-icon><Select /></el-icon>
|
||||
完成率:{{ cert.completion_rate }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cert-footer">
|
||||
<span class="cert-no">{{ cert.certificate_no }}</span>
|
||||
<div class="cert-actions">
|
||||
<el-button text type="primary" @click.stop="shareCertificate(cert)">
|
||||
<el-icon><Share /></el-icon>
|
||||
分享
|
||||
</el-button>
|
||||
<el-button text type="primary" @click.stop="downloadCertificate(cert)">
|
||||
<el-icon><Download /></el-icon>
|
||||
下载
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<el-empty
|
||||
v-if="!loading && certificates.length === 0"
|
||||
description="暂无证书"
|
||||
:image-size="120"
|
||||
>
|
||||
<template #description>
|
||||
<p>完成课程或考试后即可获得证书</p>
|
||||
</template>
|
||||
</el-empty>
|
||||
</div>
|
||||
|
||||
<!-- 加载更多 -->
|
||||
<div class="load-more" v-if="hasMore && certificates.length > 0">
|
||||
<el-button text @click="loadMore" :loading="loadingMore">
|
||||
加载更多
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 证书预览弹窗 -->
|
||||
<el-dialog
|
||||
v-model="previewVisible"
|
||||
:title="currentCert?.title || '证书详情'"
|
||||
width="600px"
|
||||
class="certificate-preview-dialog"
|
||||
>
|
||||
<div class="preview-content" v-if="currentCert">
|
||||
<div class="preview-image">
|
||||
<img
|
||||
v-if="previewImageUrl"
|
||||
:src="previewImageUrl"
|
||||
alt="证书图片"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<div v-else class="preview-placeholder">
|
||||
<el-icon :size="48"><Document /></el-icon>
|
||||
<p>正在生成证书图片...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="preview-info">
|
||||
<div class="info-row">
|
||||
<span class="label">证书编号</span>
|
||||
<span class="value">{{ currentCert.certificate_no }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">证书类型</span>
|
||||
<span class="value">{{ currentCert.type_name }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">颁发日期</span>
|
||||
<span class="value">{{ formatDate(currentCert.issued_at) }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="currentCert.score">
|
||||
<span class="label">考试成绩</span>
|
||||
<span class="value highlight">{{ currentCert.score }}分</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="currentCert.completion_rate">
|
||||
<span class="label">完成率</span>
|
||||
<span class="value highlight">{{ currentCert.completion_rate }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="previewVisible = false">关闭</el-button>
|
||||
<el-button type="primary" @click="shareCertificate(currentCert!)">
|
||||
<el-icon><Share /></el-icon>
|
||||
分享
|
||||
</el-button>
|
||||
<el-button type="success" @click="downloadCertificate(currentCert!)">
|
||||
<el-icon><Download /></el-icon>
|
||||
下载
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
Trophy, Select, Share, Download, Document,
|
||||
Reading, Medal, Star
|
||||
} from '@element-plus/icons-vue'
|
||||
import {
|
||||
getMyCertificates,
|
||||
getCertificateImageUrl,
|
||||
getCertificateDownloadUrl,
|
||||
type Certificate,
|
||||
type CertificateType
|
||||
} from '@/api/certificate'
|
||||
|
||||
// 状态
|
||||
const loading = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
const certificates = ref<Certificate[]>([])
|
||||
const total = ref(0)
|
||||
const offset = ref(0)
|
||||
const limit = 12
|
||||
const filterType = ref<CertificateType | ''>('')
|
||||
|
||||
// 预览弹窗
|
||||
const previewVisible = ref(false)
|
||||
const currentCert = ref<Certificate | null>(null)
|
||||
const previewImageUrl = ref('')
|
||||
|
||||
// 统计数据
|
||||
const stats = computed(() => {
|
||||
const allCerts = certificates.value
|
||||
return [
|
||||
{
|
||||
type: 'total',
|
||||
icon: 'Medal',
|
||||
label: '全部证书',
|
||||
count: total.value
|
||||
},
|
||||
{
|
||||
type: 'course',
|
||||
icon: 'Reading',
|
||||
label: '课程证书',
|
||||
count: allCerts.filter(c => c.type === 'course').length
|
||||
},
|
||||
{
|
||||
type: 'exam',
|
||||
icon: 'Trophy',
|
||||
label: '考试证书',
|
||||
count: allCerts.filter(c => c.type === 'exam').length
|
||||
},
|
||||
{
|
||||
type: 'achievement',
|
||||
icon: 'Star',
|
||||
label: '成就证书',
|
||||
count: allCerts.filter(c => c.type === 'achievement').length
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
// 是否有更多
|
||||
const hasMore = computed(() => offset.value + limit < total.value)
|
||||
|
||||
// 获取证书列表
|
||||
const fetchCertificates = async (append = false) => {
|
||||
if (append) {
|
||||
loadingMore.value = true
|
||||
} else {
|
||||
loading.value = true
|
||||
offset.value = 0
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await getMyCertificates({
|
||||
cert_type: filterType.value || undefined,
|
||||
offset: offset.value,
|
||||
limit
|
||||
})
|
||||
|
||||
if (res.code === 200 && res.data) {
|
||||
if (append) {
|
||||
certificates.value.push(...res.data.items)
|
||||
} else {
|
||||
certificates.value = res.data.items
|
||||
}
|
||||
total.value = res.data.total
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取证书列表失败:', error)
|
||||
ElMessage.error('获取证书列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
loadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 筛选变化
|
||||
const handleFilterChange = () => {
|
||||
fetchCertificates()
|
||||
}
|
||||
|
||||
// 加载更多
|
||||
const loadMore = () => {
|
||||
offset.value += limit
|
||||
fetchCertificates(true)
|
||||
}
|
||||
|
||||
// 查看证书
|
||||
const viewCertificate = (cert: Certificate) => {
|
||||
currentCert.value = cert
|
||||
previewImageUrl.value = getCertificateImageUrl(cert.id)
|
||||
previewVisible.value = true
|
||||
}
|
||||
|
||||
// 分享证书
|
||||
const shareCertificate = async (cert: Certificate) => {
|
||||
const shareUrl = `${window.location.origin}/certificates/verify/${cert.certificate_no}`
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareUrl)
|
||||
ElMessage.success('证书链接已复制到剪贴板')
|
||||
} catch (e) {
|
||||
// 如果剪贴板API不可用,显示链接让用户手动复制
|
||||
ElMessage({
|
||||
message: `请手动复制链接: ${shareUrl}`,
|
||||
type: 'info',
|
||||
duration: 5000
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 下载证书
|
||||
const downloadCertificate = (cert: Certificate) => {
|
||||
const downloadUrl = getCertificateDownloadUrl(cert.id)
|
||||
|
||||
// 创建隐藏的 a 标签进行下载
|
||||
const link = document.createElement('a')
|
||||
link.href = downloadUrl
|
||||
link.download = `certificate_${cert.certificate_no}.png`
|
||||
link.target = '_blank'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
|
||||
ElMessage.success('证书下载中...')
|
||||
}
|
||||
|
||||
// 图片加载失败
|
||||
const handleImageError = () => {
|
||||
previewImageUrl.value = ''
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateStr: string) => {
|
||||
if (!dateStr) return ''
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
fetchCertificates()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.certificates-page {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 32px;
|
||||
|
||||
h2 {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.stats-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.stat-card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05);
|
||||
|
||||
.stat-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.el-icon {
|
||||
font-size: 24px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&.total {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
&.course {
|
||||
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
||||
}
|
||||
|
||||
&.exam {
|
||||
background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);
|
||||
}
|
||||
|
||||
&.achievement {
|
||||
background: linear-gradient(135deg, #fa709a 0%, #fee140 100%);
|
||||
}
|
||||
}
|
||||
|
||||
.stat-info {
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
margin-bottom: 24px;
|
||||
|
||||
:deep(.el-radio-button__inner) {
|
||||
border-radius: 20px;
|
||||
padding: 8px 20px;
|
||||
border: none;
|
||||
background: #f5f7fa;
|
||||
|
||||
&:hover {
|
||||
color: #667eea;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-radio-button__original-radio:checked + .el-radio-button__inner) {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.certificates-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||
gap: 20px;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.certificate-card {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.cert-header {
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
|
||||
.cert-type-badge {
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
|
||||
&.course {
|
||||
background: #e6f7ff;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
&.exam {
|
||||
background: #f6ffed;
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
&.achievement {
|
||||
background: #fff7e6;
|
||||
color: #fa8c16;
|
||||
}
|
||||
}
|
||||
|
||||
.cert-date {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
|
||||
.cert-body {
|
||||
padding: 20px;
|
||||
|
||||
.cert-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.cert-description {
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.cert-info {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
color: #667eea;
|
||||
font-weight: 500;
|
||||
|
||||
.el-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cert-footer {
|
||||
padding: 12px 20px;
|
||||
background: #fafafa;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.cert-no {
|
||||
font-size: 11px;
|
||||
color: #909399;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.cert-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
.el-button {
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.load-more {
|
||||
text-align: center;
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
// 预览弹窗样式
|
||||
.certificate-preview-dialog {
|
||||
:deep(.el-dialog__body) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
.preview-image {
|
||||
background: #f5f7fa;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
min-height: 300px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
max-height: 400px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.preview-placeholder {
|
||||
color: #909399;
|
||||
|
||||
.el-icon {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.preview-info {
|
||||
padding: 20px;
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: #303133;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
|
||||
&.highlight {
|
||||
color: #667eea;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
// 响应式
|
||||
@media (max-width: 768px) {
|
||||
.certificates-page {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
text-align: center;
|
||||
|
||||
h2 {
|
||||
font-size: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
.stats-cards {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
|
||||
.stat-card {
|
||||
padding: 14px;
|
||||
|
||||
.stat-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
|
||||
.el-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-info {
|
||||
.stat-value {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
overflow-x: auto;
|
||||
|
||||
.el-radio-group {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
:deep(.el-radio-button__inner) {
|
||||
padding: 6px 14px;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.certificates-list {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.certificate-card {
|
||||
.cert-body {
|
||||
padding: 16px;
|
||||
|
||||
.cert-title {
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.cert-footer {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
|
||||
.cert-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.stats-cards {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
|
||||
.stat-card {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.certificate-preview-dialog {
|
||||
:deep(.el-dialog) {
|
||||
width: 95% !important;
|
||||
margin: 5vh auto !important;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
flex-direction: column;
|
||||
|
||||
.el-button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</template>
|
||||
Reference in New Issue
Block a user