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

@@ -0,0 +1,149 @@
/**
* 证书系统 API
*/
import request from '@/utils/request'
// 证书类型
export type CertificateType = 'course' | 'exam' | 'achievement'
// 证书模板
export interface CertificateTemplate {
id: number
name: string
type: CertificateType
background_url?: string
is_active: boolean
}
// 证书信息
export interface Certificate {
id: number
certificate_no: string
title: string
description?: string
type: CertificateType
type_name: string
issued_at: string
valid_until?: string
score?: number
completion_rate?: number
pdf_url?: string
image_url?: string
course_id?: number
exam_id?: number
badge_id?: number
meta_data?: Record<string, any>
template?: {
id: number
name: string
background_url?: string
}
user?: {
id: number
username: string
full_name?: string
}
}
// 证书列表响应
export interface CertificateListResponse {
items: Certificate[]
total: number
offset: number
limit: number
}
// 验证结果
export interface VerifyResult {
valid: boolean
certificate_no: string
title?: string
type_name?: string
issued_at?: string
user?: {
id: number
username: string
full_name?: string
}
}
/**
* 获取证书模板列表
*/
export function getCertificateTemplates(type?: CertificateType) {
return request.get<CertificateTemplate[]>('/certificates/templates', {
params: { cert_type: type }
})
}
/**
* 获取我的证书列表
*/
export function getMyCertificates(params?: {
cert_type?: CertificateType
offset?: number
limit?: number
}) {
return request.get<CertificateListResponse>('/certificates/me', { params })
}
/**
* 获取指定用户的证书列表
*/
export function getUserCertificates(userId: number, params?: {
cert_type?: CertificateType
offset?: number
limit?: number
}) {
return request.get<CertificateListResponse>(`/certificates/user/${userId}`, { params })
}
/**
* 获取证书详情
*/
export function getCertificateDetail(certId: number) {
return request.get<Certificate>(`/certificates/${certId}`)
}
/**
* 获取证书分享图片URL
*/
export function getCertificateImageUrl(certId: number): string {
return `/api/v1/certificates/${certId}/image`
}
/**
* 获取证书下载URL
*/
export function getCertificateDownloadUrl(certId: number): string {
return `/api/v1/certificates/${certId}/download`
}
/**
* 验证证书
*/
export function verifyCertificate(certNo: string) {
return request.get<VerifyResult>(`/certificates/verify/${certNo}`)
}
/**
* 颁发课程证书
*/
export function issueCoursCertificate(data: {
course_id: number
course_name: string
completion_rate?: number
}) {
return request.post<Certificate>('/certificates/issue/course', data)
}
/**
* 颁发考试证书
*/
export function issueExamCertificate(data: {
exam_id: number
exam_name: string
score: number
}) {
return request.post<Certificate>('/certificates/issue/exam', data)
}

View File

@@ -1,20 +1,159 @@
/**
* 首页数据API
* 数据大屏 API
*/
import request from './request'
import request from '@/utils/request'
/**
* 获取用户统计数据
*/
export function getUserStatistics() {
return request.get('/api/v1/users/me/statistics')
// 数据概览
export interface DashboardOverview {
overview: {
total_users: number
today_active: number
week_active: number
month_active: number
total_hours: number
checkin_rate: number
}
exam: {
total_count: number
pass_rate: number
avg_score: number
perfect_users: number
}
updated_at: string
}
// 部门对比
export interface DepartmentData {
id: number
name: string
member_count: number
pass_rate: number
avg_hours: number
avg_level: number
}
// 学习趋势
export interface TrendData {
dates: string[]
trend: Array<{
date: string
active_users: number
learning_hours: number
exam_count: number
}>
}
// 等级分布
export interface LevelDistribution {
levels: number[]
counts: number[]
}
// 实时动态
export interface ActivityItem {
id: number
user_id: number
user_name: string
type: string
description: string
exp_amount: number
created_at: string
}
// 课程排行
export interface CourseRanking {
rank: number
id: number
name: string
description: string
learners: number
}
// 团队数据
export interface TeamDashboard {
members: Array<{
id: number
username: string
full_name: string
avatar_url?: string
level: number
total_exp: number
badge_count: number
}>
overview: {
total_members: number
avg_level: number
avg_exp: number
total_badges: number
}
positions: Array<{
id: number
name: string
}>
}
// 完整大屏数据
export interface FullDashboardData {
overview: DashboardOverview
departments: DepartmentData[]
trend: TrendData
level_distribution: LevelDistribution
activities: ActivityItem[]
course_ranking: CourseRanking[]
}
/**
* 获取最近考试列表
* @param limit 返回数量默认5条
* 获取企业级数据概览
*/
export function getRecentExams(limit: number = 5) {
return request.get('/api/v1/users/me/recent-exams', { limit })
export function getEnterpriseOverview() {
return request.get<DashboardOverview>('/dashboard/enterprise/overview')
}
/**
* 获取部门对比数据
*/
export function getDepartmentComparison() {
return request.get<DepartmentData[]>('/dashboard/enterprise/departments')
}
/**
* 获取学习趋势数据
*/
export function getLearningTrend(days = 7) {
return request.get<TrendData>('/dashboard/enterprise/trend', { params: { days } })
}
/**
* 获取等级分布数据
*/
export function getLevelDistribution() {
return request.get<LevelDistribution>('/dashboard/enterprise/level-distribution')
}
/**
* 获取实时动态
*/
export function getRealtimeActivities(limit = 20) {
return request.get<ActivityItem[]>('/dashboard/enterprise/activities', { params: { limit } })
}
/**
* 获取课程热度排行
*/
export function getCourseRanking(limit = 10) {
return request.get<CourseRanking[]>('/dashboard/enterprise/course-ranking', { params: { limit } })
}
/**
* 获取团队数据大屏
*/
export function getTeamDashboard() {
return request.get<TeamDashboard>('/dashboard/team')
}
/**
* 获取完整大屏数据(一次性加载)
*/
export function getFullDashboardData() {
return request.get<FullDashboardData>('/dashboard/all')
}

View File

@@ -37,6 +37,12 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/trainee/leaderboard.vue'),
meta: { title: '等级排行榜', icon: 'Trophy' }
},
{
path: 'my-certificates',
name: 'MyCertificates',
component: () => import('@/views/trainee/my-certificates.vue'),
meta: { title: '我的证书', icon: 'Medal' }
},
{
path: 'course-center',
name: 'CourseCenter',
@@ -165,6 +171,12 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/manager/team-dashboard.vue'),
meta: { title: '团队看板', icon: 'DataLine' }
},
{
path: 'data-dashboard',
name: 'DataDashboard',
component: () => import('@/views/admin/data-dashboard.vue'),
meta: { title: '数据大屏', icon: 'Monitor' }
},
{
path: 'team-management',
name: 'TeamManagement',

View File

@@ -0,0 +1,769 @@
<template>
<div class="dashboard-screen" :class="{ 'fullscreen': isFullscreen }">
<!-- 头部 -->
<header class="dashboard-header">
<h1 class="title">企业培训数据大屏</h1>
<div class="header-right">
<span class="current-time">{{ currentTime }}</span>
<el-button text @click="toggleFullscreen">
<el-icon><FullScreen /></el-icon>
</el-button>
<el-button text @click="refreshData" :loading="loading">
<el-icon><Refresh /></el-icon>
</el-button>
</div>
</header>
<!-- 主内容区 -->
<main class="dashboard-main" v-loading="loading">
<!-- 顶部数据卡片 -->
<section class="top-cards">
<div class="stat-card" v-for="stat in topStats" :key="stat.key">
<div class="stat-icon" :style="{ background: stat.color }">
<el-icon><component :is="stat.icon" /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ stat.value }}</div>
<div class="stat-label">{{ stat.label }}</div>
</div>
</div>
</section>
<!-- 中间图表区 -->
<section class="middle-charts">
<!-- 左侧部门排行 -->
<div class="chart-card department-ranking">
<div class="card-header">
<h3>部门学习排行</h3>
</div>
<div class="chart-content" ref="departmentChartRef"></div>
</div>
<!-- 中间学习趋势 -->
<div class="chart-card trend-chart">
<div class="card-header">
<h3>学习趋势近7天</h3>
</div>
<div class="chart-content" ref="trendChartRef"></div>
</div>
<!-- 右侧考试通过率 -->
<div class="chart-card exam-stats">
<div class="card-header">
<h3>考试通过率</h3>
</div>
<div class="chart-content" ref="examChartRef"></div>
</div>
</section>
<!-- 底部区域 -->
<section class="bottom-section">
<!-- 左侧等级分布 -->
<div class="chart-card level-distribution">
<div class="card-header">
<h3>等级分布</h3>
</div>
<div class="chart-content" ref="levelChartRef"></div>
</div>
<!-- 右侧实时动态 -->
<div class="chart-card realtime-activities">
<div class="card-header">
<h3>实时动态</h3>
</div>
<div class="activities-list">
<div
v-for="activity in activities"
:key="activity.id"
class="activity-item"
>
<span class="activity-type" :class="getActivityClass(activity.type)">
{{ activity.type }}
</span>
<span class="activity-user">{{ activity.user_name }}</span>
<span class="activity-desc">{{ activity.description }}</span>
<span class="activity-exp" v-if="activity.exp_amount > 0">
+{{ activity.exp_amount }}
</span>
</div>
</div>
</div>
</section>
</main>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { FullScreen, Refresh, User, TrendCharts, Trophy, Clock, Reading, Medal } from '@element-plus/icons-vue'
import * as echarts from 'echarts'
import {
getFullDashboardData,
type FullDashboardData,
type ActivityItem,
type DepartmentData
} from '@/api/dashboard'
// 状态
const loading = ref(false)
const isFullscreen = ref(false)
const currentTime = ref('')
const dashboardData = ref<FullDashboardData | null>(null)
const activities = ref<ActivityItem[]>([])
// 图表引用
const departmentChartRef = ref<HTMLElement | null>(null)
const trendChartRef = ref<HTMLElement | null>(null)
const examChartRef = ref<HTMLElement | null>(null)
const levelChartRef = ref<HTMLElement | null>(null)
// 图表实例
let departmentChart: echarts.ECharts | null = null
let trendChart: echarts.ECharts | null = null
let examChart: echarts.ECharts | null = null
let levelChart: echarts.ECharts | null = null
// 定时器
let timeTimer: number | null = null
let refreshTimer: number | null = null
// 顶部统计数据
const topStats = computed(() => {
const overview = dashboardData.value?.overview?.overview
const exam = dashboardData.value?.overview?.exam
return [
{
key: 'total_users',
label: '总学员数',
value: overview?.total_users || 0,
icon: 'User',
color: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'
},
{
key: 'today_active',
label: '今日活跃',
value: overview?.today_active || 0,
icon: 'TrendCharts',
color: 'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)'
},
{
key: 'total_hours',
label: '总学习时长',
value: `${overview?.total_hours || 0}h`,
icon: 'Clock',
color: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)'
},
{
key: 'pass_rate',
label: '考试通过率',
value: `${exam?.pass_rate || 0}%`,
icon: 'Trophy',
color: 'linear-gradient(135deg, #fa709a 0%, #fee140 100%)'
},
{
key: 'checkin_rate',
label: '今日签到率',
value: `${overview?.checkin_rate || 0}%`,
icon: 'Medal',
color: 'linear-gradient(135deg, #a18cd1 0%, #fbc2eb 100%)'
}
]
})
// 更新时间
const updateTime = () => {
const now = new Date()
currentTime.value = now.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
// 切换全屏
const toggleFullscreen = () => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen()
isFullscreen.value = true
} else {
document.exitFullscreen()
isFullscreen.value = false
}
}
// 获取活动样式类
const getActivityClass = (type: string) => {
const classMap: Record<string, string> = {
'签到': 'checkin',
'考试': 'exam',
'陪练': 'practice',
'奖章': 'badge',
'学习': 'study'
}
return classMap[type] || 'default'
}
// 初始化部门排行图表
const initDepartmentChart = (departments: DepartmentData[]) => {
if (!departmentChartRef.value) return
if (departmentChart) {
departmentChart.dispose()
}
departmentChart = echarts.init(departmentChartRef.value)
const top10 = departments.slice(0, 10)
departmentChart.setOption({
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' }
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'value',
axisLabel: { color: '#a0a0a0' },
axisLine: { lineStyle: { color: '#333' } },
splitLine: { lineStyle: { color: '#333' } }
},
yAxis: {
type: 'category',
data: top10.map(d => d.name).reverse(),
axisLabel: { color: '#fff' },
axisLine: { lineStyle: { color: '#333' } }
},
series: [{
name: '考试通过率',
type: 'bar',
data: top10.map(d => d.pass_rate).reverse(),
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{ offset: 0, color: '#667eea' },
{ offset: 1, color: '#764ba2' }
])
},
label: {
show: true,
position: 'right',
formatter: '{c}%',
color: '#fff'
}
}]
})
}
// 初始化趋势图表
const initTrendChart = () => {
if (!trendChartRef.value || !dashboardData.value?.trend) return
if (trendChart) {
trendChart.dispose()
}
trendChart = echarts.init(trendChartRef.value)
const trend = dashboardData.value.trend
trendChart.setOption({
tooltip: {
trigger: 'axis'
},
legend: {
data: ['活跃用户', '学习时长', '考试次数'],
textStyle: { color: '#a0a0a0' }
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'category',
boundaryGap: false,
data: trend.dates.map(d => d.slice(5)),
axisLabel: { color: '#a0a0a0' },
axisLine: { lineStyle: { color: '#333' } }
},
yAxis: {
type: 'value',
axisLabel: { color: '#a0a0a0' },
axisLine: { lineStyle: { color: '#333' } },
splitLine: { lineStyle: { color: '#333' } }
},
series: [
{
name: '活跃用户',
type: 'line',
smooth: true,
data: trend.trend.map(t => t.active_users),
itemStyle: { color: '#667eea' },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(102, 126, 234, 0.3)' },
{ offset: 1, color: 'rgba(102, 126, 234, 0.05)' }
])
}
},
{
name: '学习时长',
type: 'line',
smooth: true,
data: trend.trend.map(t => t.learning_hours),
itemStyle: { color: '#43e97b' }
},
{
name: '考试次数',
type: 'line',
smooth: true,
data: trend.trend.map(t => t.exam_count),
itemStyle: { color: '#fa709a' }
}
]
})
}
// 初始化考试通过率图表
const initExamChart = () => {
if (!examChartRef.value || !dashboardData.value) return
if (examChart) {
examChart.dispose()
}
examChart = echarts.init(examChartRef.value)
const exam = dashboardData.value.overview.exam
examChart.setOption({
tooltip: {
trigger: 'item'
},
series: [{
name: '考试统计',
type: 'gauge',
radius: '85%',
startAngle: 180,
endAngle: 0,
min: 0,
max: 100,
splitNumber: 10,
axisLine: {
lineStyle: {
width: 20,
color: [
[0.6, '#f56c6c'],
[0.8, '#e6a23c'],
[1, '#67c23a']
]
}
},
pointer: {
itemStyle: {
color: 'auto'
}
},
axisTick: { show: false },
splitLine: { show: false },
axisLabel: {
color: '#a0a0a0',
fontSize: 10,
distance: -30
},
title: {
offsetCenter: [0, '30%'],
color: '#fff',
fontSize: 14
},
detail: {
fontSize: 24,
offsetCenter: [0, '-10%'],
valueAnimation: true,
formatter: '{value}%',
color: '#fff'
},
data: [{
value: exam.pass_rate,
name: '通过率'
}]
}]
})
}
// 初始化等级分布图表
const initLevelChart = () => {
if (!levelChartRef.value || !dashboardData.value) return
if (levelChart) {
levelChart.dispose()
}
levelChart = echarts.init(levelChartRef.value)
const dist = dashboardData.value.level_distribution
levelChart.setOption({
tooltip: {
trigger: 'item'
},
series: [{
name: '等级分布',
type: 'pie',
radius: ['40%', '70%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 4,
borderColor: '#1a1a2e',
borderWidth: 2
},
label: {
show: true,
formatter: 'Lv.{b}\n{c}人',
color: '#fff'
},
data: dist.levels.map((level, i) => ({
value: dist.counts[i],
name: level.toString(),
itemStyle: {
color: `hsl(${(level - 1) * 36}, 70%, 50%)`
}
})).filter(d => d.value > 0)
}]
})
}
// 获取数据
const fetchData = async () => {
loading.value = true
try {
const res = await getFullDashboardData()
if (res.code === 200 && res.data) {
dashboardData.value = res.data
activities.value = res.data.activities || []
await nextTick()
// 初始化图表
initDepartmentChart(res.data.departments)
initTrendChart()
initExamChart()
initLevelChart()
}
} catch (error) {
console.error('获取大屏数据失败:', error)
} finally {
loading.value = false
}
}
// 刷新数据
const refreshData = () => {
fetchData()
}
// 处理窗口大小变化
const handleResize = () => {
departmentChart?.resize()
trendChart?.resize()
examChart?.resize()
levelChart?.resize()
}
// 初始化
onMounted(() => {
updateTime()
timeTimer = window.setInterval(updateTime, 1000)
fetchData()
// 每5分钟自动刷新
refreshTimer = window.setInterval(refreshData, 5 * 60 * 1000)
window.addEventListener('resize', handleResize)
})
// 清理
onUnmounted(() => {
if (timeTimer) clearInterval(timeTimer)
if (refreshTimer) clearInterval(refreshTimer)
departmentChart?.dispose()
trendChart?.dispose()
examChart?.dispose()
levelChart?.dispose()
window.removeEventListener('resize', handleResize)
})
</script>
<style lang="scss" scoped>
.dashboard-screen {
min-height: 100vh;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
color: #fff;
padding: 20px;
&.fullscreen {
padding: 24px;
}
}
.dashboard-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
.title {
font-size: 28px;
font-weight: 600;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.header-right {
display: flex;
align-items: center;
gap: 16px;
.current-time {
font-size: 14px;
color: #a0a0a0;
}
.el-button {
color: #a0a0a0;
&:hover {
color: #667eea;
}
}
}
}
.dashboard-main {
display: flex;
flex-direction: column;
gap: 20px;
}
.top-cards {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 16px;
.stat-card {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
border-radius: 12px;
padding: 20px;
display: flex;
align-items: center;
gap: 16px;
border: 1px solid rgba(255, 255, 255, 0.1);
.stat-icon {
width: 56px;
height: 56px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
.el-icon {
font-size: 28px;
color: #fff;
}
}
.stat-info {
.stat-value {
font-size: 28px;
font-weight: 600;
}
.stat-label {
font-size: 13px;
color: #a0a0a0;
margin-top: 4px;
}
}
}
}
.middle-charts {
display: grid;
grid-template-columns: 1fr 2fr 1fr;
gap: 20px;
}
.bottom-section {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.chart-card {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
overflow: hidden;
.card-header {
padding: 16px 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
h3 {
font-size: 16px;
font-weight: 500;
margin: 0;
}
}
.chart-content {
height: 280px;
padding: 16px;
}
}
.realtime-activities {
.activities-list {
height: 280px;
overflow-y: auto;
padding: 8px 16px;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 2px;
}
.activity-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
font-size: 13px;
&:last-child {
border-bottom: none;
}
.activity-type {
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
&.checkin { background: #67c23a; }
&.exam { background: #e6a23c; }
&.practice { background: #409eff; }
&.badge { background: #f56c6c; }
&.study { background: #909399; }
&.default { background: #606266; }
}
.activity-user {
color: #fff;
font-weight: 500;
min-width: 80px;
}
.activity-desc {
color: #a0a0a0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.activity-exp {
color: #67c23a;
font-weight: 600;
}
}
}
}
// 响应式
@media (max-width: 1400px) {
.top-cards {
grid-template-columns: repeat(3, 1fr);
}
.middle-charts {
grid-template-columns: 1fr;
}
}
@media (max-width: 1024px) {
.top-cards {
grid-template-columns: repeat(2, 1fr);
}
.bottom-section {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.dashboard-screen {
padding: 12px;
}
.dashboard-header {
flex-direction: column;
gap: 12px;
text-align: center;
.title {
font-size: 22px;
}
}
.top-cards {
grid-template-columns: 1fr;
.stat-card {
padding: 16px;
.stat-icon {
width: 48px;
height: 48px;
.el-icon {
font-size: 24px;
}
}
.stat-info .stat-value {
font-size: 24px;
}
}
}
.chart-card .chart-content {
height: 220px;
}
}
</style>
</template>

View File

@@ -1941,4 +1941,204 @@ onUnmounted(() => {
}
}
}
// 手机端深度优化
@media (max-width: 480px) {
.practice-container {
padding: 0 8px;
.loading-container {
padding: 40px 20px;
h3 {
font-size: 18px;
}
p {
font-size: 13px;
}
}
.practice-header {
padding: 10px 12px;
border-radius: 12px;
.header-info {
h2 {
font-size: 18px;
margin-bottom: 6px;
}
.progress-info {
font-size: 12px;
flex-wrap: wrap;
gap: 4px;
.separator {
display: none;
}
span {
background: rgba(102, 126, 234, 0.1);
padding: 2px 8px;
border-radius: 10px;
}
}
}
.el-button {
min-height: 44px;
font-size: 15px;
}
}
.question-section {
padding: 14px !important;
border-radius: 12px;
.question-type {
margin-bottom: 12px;
.el-tag {
font-size: 12px;
}
.question-score {
font-size: 13px;
}
}
.question-content {
h3 {
font-size: 15px;
line-height: 1.6;
margin-bottom: 16px;
}
.option-item {
padding: 10px 12px !important;
border-radius: 10px;
margin-bottom: 10px;
.option-label {
width: 26px !important;
height: 26px !important;
font-size: 13px;
flex-shrink: 0;
}
.option-content {
font-size: 13px !important;
line-height: 1.5;
}
}
.judge-cards {
gap: 12px;
.judge-card {
padding: 16px 12px;
border-radius: 12px;
.judge-icon {
margin-bottom: 8px;
}
.judge-text {
font-size: 14px;
}
}
}
.blank-input-wrapper {
.el-input {
font-size: 15px;
}
.blank-hint {
font-size: 12px;
}
}
.essay-answer {
.el-textarea {
:deep(.el-textarea__inner) {
font-size: 14px;
min-height: 120px !important;
}
}
}
}
.answer-section {
.correct-feedback,
.answer-title {
font-size: 14px;
}
.explanation-text {
font-size: 13px;
line-height: 1.6;
}
}
}
.submit-section {
padding: 0 4px 20px;
padding-top: 16px;
.el-button {
min-height: 50px;
font-size: 17px;
}
}
}
// 结果卡片手机端优化
.result-card {
padding: 24px 16px !important;
border-radius: 16px;
.result-icon {
font-size: 48px;
}
.result-title {
font-size: 20px;
}
.score-display {
.score-value {
font-size: 48px;
}
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
gap: 12px;
.stat-item {
padding: 12px;
.stat-value {
font-size: 20px;
}
.stat-label {
font-size: 12px;
}
}
}
.action-buttons {
flex-direction: column;
gap: 10px;
.el-button {
width: 100%;
min-height: 44px;
}
}
}
}
</style>

View File

@@ -532,7 +532,7 @@ onMounted(async () => {
}
}
// 响应式
// 响应式 - 平板
@media (max-width: 768px) {
.login-container {
padding: 20px;
@@ -544,4 +544,96 @@ onMounted(async () => {
}
}
}
// 响应式 - 手机
@media (max-width: 480px) {
.login-container {
padding: 16px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
// 手机端隐藏背景动画以提升性能
.login-bg {
display: none;
}
.login-card {
width: 100%;
max-width: none;
padding: 32px 20px;
border-radius: 16px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.15);
.login-header {
margin-bottom: 32px;
.logo {
margin-bottom: 12px;
:deep(.el-icon) {
font-size: 40px !important;
}
}
.title {
font-size: 24px;
}
.subtitle {
font-size: 13px;
}
}
.login-form {
.el-form-item {
margin-bottom: 20px;
}
:deep(.el-input__wrapper) {
padding: 8px 12px;
}
.login-options {
flex-wrap: wrap;
gap: 8px;
.el-checkbox {
margin-right: 0;
}
}
.login-btn {
height: 48px;
font-size: 17px;
}
.other-login {
margin-top: 24px;
.social-icons {
gap: 20px;
margin-top: 20px;
.social-icon {
width: 48px;
height: 48px;
}
}
}
.register-link {
margin-top: 20px;
}
}
}
}
}
// 钉钉环境特殊样式
.is-dingtalk {
.login-container {
// 钉钉内嵌页面适配
padding-top: env(safe-area-inset-top);
padding-bottom: env(safe-area-inset-bottom);
}
}
</style>

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>