feat: KPL v1.5.0 功能迭代
Some checks failed
continuous-integration/drone/push Build is failing

1. 奖章条件优化
- 修复统计查询 SQL 语法
- 添加按类别检查奖章方法

2. 移动端适配
- 登录页、课程中心、课程详情
- 考试页面、成长路径、排行榜

3. 证书系统
- 数据库模型和迁移脚本
- 证书颁发/列表/下载/验证 API
- 前端证书列表页面

4. 数据大屏
- 企业级/团队级数据 API
- ECharts 可视化大屏页面
This commit is contained in:
yuliang_guo
2026-01-29 16:51:17 +08:00
parent 813ba2c295
commit 6f0f2e6363
21 changed files with 4907 additions and 80 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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>