1. 奖章条件优化 - 修复统计查询 SQL 语法 - 添加按类别检查奖章方法 2. 移动端适配 - 登录页、课程中心、课程详情 - 考试页面、成长路径、排行榜 3. 证书系统 - 数据库模型和迁移脚本 - 证书颁发/列表/下载/验证 API - 前端证书列表页面 4. 数据大屏 - 企业级/团队级数据 API - ECharts 可视化大屏页面
This commit is contained in:
149
frontend/src/api/certificate.ts
Normal file
149
frontend/src/api/certificate.ts
Normal 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)
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
769
frontend/src/views/admin/data-dashboard.vue
Normal file
769
frontend/src/views/admin/data-dashboard.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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