1. 奖章条件优化 - 修复统计查询 SQL 语法 - 添加按类别检查奖章方法 2. 移动端适配 - 登录页、课程中心、课程详情 - 考试页面、成长路径、排行榜 3. 证书系统 - 数据库模型和迁移脚本 - 证书颁发/列表/下载/验证 API - 前端证书列表页面 4. 数据大屏 - 企业级/团队级数据 API - ECharts 可视化大屏页面
This commit is contained in:
188
CHANGELOG-2026-01-29.md
Normal file
188
CHANGELOG-2026-01-29.md
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
# KPL 考培练系统 功能迭代更新日志
|
||||||
|
|
||||||
|
**日期**: 2026-01-29
|
||||||
|
**版本**: v1.5.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、奖章条件优化
|
||||||
|
|
||||||
|
### 修复内容
|
||||||
|
- 修复 `badge_service.py` 中统计查询的 SQL 语法问题
|
||||||
|
- 将非标准 `func.if_` 替换为 SQLAlchemy 标准 `case` 语句
|
||||||
|
- 优化考试统计逻辑:通过数、满分数、优秀数分开查询
|
||||||
|
- 添加 `func.coalesce` 处理空值
|
||||||
|
|
||||||
|
### 新增功能
|
||||||
|
- `check_badges_by_category()` - 按类别检查奖章
|
||||||
|
- `check_exam_badges()` - 考试后触发
|
||||||
|
- `check_practice_badges()` - 练习后触发
|
||||||
|
- `check_streak_badges()` - 签到后触发
|
||||||
|
- `check_level_badges()` - 等级变化后触发
|
||||||
|
|
||||||
|
### 文件变更
|
||||||
|
- `backend/app/services/badge_service.py`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、移动端适配
|
||||||
|
|
||||||
|
### 适配页面
|
||||||
|
| 页面 | 文件 | 适配要点 |
|
||||||
|
|------|------|----------|
|
||||||
|
| 登录页 | `views/login/index.vue` | 表单全宽、背景动画隐藏、钉钉安全区域 |
|
||||||
|
| 课程中心 | `views/trainee/course-center.vue` | 单列卡片、分类横向滚动、操作按钮网格化 |
|
||||||
|
| 课程详情 | `views/trainee/course-detail.vue` | 侧边栏折叠、视频自适应、工具栏响应式 |
|
||||||
|
| 考试页面 | `views/exam/practice.vue` | 选项垂直排列、按钮放大、弹窗全屏 |
|
||||||
|
| 成长路径 | `views/trainee/growth-path.vue` | 雷达图缩放、卡片堆叠、统计区折叠 |
|
||||||
|
| 排行榜 | `views/trainee/leaderboard.vue` | 列表简化、签到按钮全宽 |
|
||||||
|
|
||||||
|
### 技术方案
|
||||||
|
- 使用 `@media (max-width: 768px)` 和 `@media (max-width: 480px)` 断点
|
||||||
|
- 钉钉环境使用 `env(safe-area-inset-*)` 适配安全区域
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、证书系统
|
||||||
|
|
||||||
|
### 数据库设计
|
||||||
|
```sql
|
||||||
|
-- 证书模板表
|
||||||
|
CREATE TABLE certificate_templates (
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
type ENUM('course', 'exam', 'achievement') NOT NULL,
|
||||||
|
background_url VARCHAR(500),
|
||||||
|
template_html TEXT,
|
||||||
|
template_style TEXT,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
...
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 用户证书表
|
||||||
|
CREATE TABLE user_certificates (
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
template_id INT NOT NULL,
|
||||||
|
certificate_no VARCHAR(50) UNIQUE NOT NULL,
|
||||||
|
title VARCHAR(200) NOT NULL,
|
||||||
|
...
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 后端实现
|
||||||
|
- **模型**: `CertificateTemplate`, `UserCertificate`, `CertificateType`
|
||||||
|
- **服务**: `CertificateService`
|
||||||
|
- `issue_course_certificate()` - 颁发课程证书
|
||||||
|
- `issue_exam_certificate()` - 颁发考试证书
|
||||||
|
- `issue_achievement_certificate()` - 颁发成就证书
|
||||||
|
- `generate_certificate_image()` - 生成分享图片
|
||||||
|
- `get_certificate_by_no()` - 验证证书
|
||||||
|
|
||||||
|
### API 端点
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| GET | `/certificates/me` | 我的证书列表 |
|
||||||
|
| GET | `/certificates/{id}` | 证书详情 |
|
||||||
|
| GET | `/certificates/{id}/image` | 获取分享图片 |
|
||||||
|
| GET | `/certificates/{id}/download` | 下载证书 |
|
||||||
|
| GET | `/certificates/verify/{no}` | 验证证书(无需登录) |
|
||||||
|
| POST | `/certificates/issue/course` | 颁发课程证书 |
|
||||||
|
| POST | `/certificates/issue/exam` | 颁发考试证书 |
|
||||||
|
|
||||||
|
### 前端实现
|
||||||
|
- **API**: `frontend/src/api/certificate.ts`
|
||||||
|
- **页面**: `frontend/src/views/trainee/my-certificates.vue`
|
||||||
|
- **功能**: 证书列表、分类筛选、预览、分享、下载
|
||||||
|
|
||||||
|
### 文件变更
|
||||||
|
- `backend/migrations/add_certificate_system.sql` (新增)
|
||||||
|
- `backend/app/models/certificate.py` (新增)
|
||||||
|
- `backend/app/services/certificate_service.py` (新增)
|
||||||
|
- `backend/app/api/v1/endpoints/certificate.py` (新增)
|
||||||
|
- `backend/app/models/__init__.py` (修改)
|
||||||
|
- `backend/app/api/v1/__init__.py` (修改)
|
||||||
|
- `frontend/src/api/certificate.ts` (新增)
|
||||||
|
- `frontend/src/views/trainee/my-certificates.vue` (新增)
|
||||||
|
- `frontend/src/router/index.ts` (修改)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、数据大屏
|
||||||
|
|
||||||
|
### 数据指标
|
||||||
|
| 类别 | 指标 |
|
||||||
|
|------|------|
|
||||||
|
| 概览 | 总学员数、今日活跃、周活跃、月活跃、总学习时长、签到率 |
|
||||||
|
| 考试 | 总次数、通过率、平均分、满分人数 |
|
||||||
|
| 部门 | 成员数、通过率、平均学习时长、平均等级 |
|
||||||
|
| 趋势 | 近7天活跃用户、学习时长、考试次数 |
|
||||||
|
| 分布 | 1-10级用户数量分布 |
|
||||||
|
| 动态 | 最新学习活动实时滚动 |
|
||||||
|
|
||||||
|
### 后端实现
|
||||||
|
- **服务**: `DashboardService`
|
||||||
|
- `get_enterprise_overview()` - 企业级概览
|
||||||
|
- `get_department_comparison()` - 部门对比
|
||||||
|
- `get_learning_trend()` - 学习趋势
|
||||||
|
- `get_level_distribution()` - 等级分布
|
||||||
|
- `get_realtime_activities()` - 实时动态
|
||||||
|
- `get_team_dashboard()` - 团队级数据
|
||||||
|
- `get_course_ranking()` - 课程热度排行
|
||||||
|
|
||||||
|
### API 端点
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| GET | `/dashboard/enterprise/overview` | 企业概览 |
|
||||||
|
| GET | `/dashboard/enterprise/departments` | 部门对比 |
|
||||||
|
| GET | `/dashboard/enterprise/trend` | 学习趋势 |
|
||||||
|
| GET | `/dashboard/enterprise/level-distribution` | 等级分布 |
|
||||||
|
| GET | `/dashboard/enterprise/activities` | 实时动态 |
|
||||||
|
| GET | `/dashboard/enterprise/course-ranking` | 课程排行 |
|
||||||
|
| GET | `/dashboard/team` | 团队数据 |
|
||||||
|
| GET | `/dashboard/all` | 完整数据(一次性) |
|
||||||
|
|
||||||
|
### 前端实现
|
||||||
|
- **API**: `frontend/src/api/dashboard.ts`
|
||||||
|
- **页面**: `frontend/src/views/admin/data-dashboard.vue`
|
||||||
|
- **图表**: ECharts (横向柱状图、折线图、仪表盘、饼图)
|
||||||
|
- **功能**: 全屏模式、5分钟自动刷新、响应式布局
|
||||||
|
|
||||||
|
### 文件变更
|
||||||
|
- `backend/app/services/dashboard_service.py` (新增)
|
||||||
|
- `backend/app/api/v1/endpoints/dashboard.py` (新增)
|
||||||
|
- `backend/app/api/v1/__init__.py` (修改)
|
||||||
|
- `frontend/src/api/dashboard.ts` (新增)
|
||||||
|
- `frontend/src/views/admin/data-dashboard.vue` (新增)
|
||||||
|
- `frontend/src/router/index.ts` (修改)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 部署说明
|
||||||
|
|
||||||
|
### 数据库迁移
|
||||||
|
需执行以下 SQL 脚本:
|
||||||
|
```bash
|
||||||
|
# 证书系统迁移
|
||||||
|
mysql -u root -p kaopeilian < backend/migrations/add_certificate_system.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### 依赖安装
|
||||||
|
后端新增依赖(用于证书图片生成):
|
||||||
|
```bash
|
||||||
|
pip install Pillow qrcode
|
||||||
|
```
|
||||||
|
|
||||||
|
### 路由变更
|
||||||
|
新增前端路由:
|
||||||
|
- `/trainee/my-certificates` - 我的证书
|
||||||
|
- `/manager/data-dashboard` - 数据大屏
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 待办事项
|
||||||
|
|
||||||
|
- [ ] 证书 PDF 生成(需安装 weasyprint)
|
||||||
|
- [ ] 课程完成进度追踪(user_course_progress 表)
|
||||||
|
- [ ] 数据大屏数据缓存优化
|
||||||
|
- [ ] 钉钉环境下底部导航适配
|
||||||
@@ -110,5 +110,11 @@ api_router.include_router(system_settings_router, prefix="/settings", tags=["sys
|
|||||||
# level_router 等级与奖章路由
|
# level_router 等级与奖章路由
|
||||||
from .endpoints.level import router as level_router
|
from .endpoints.level import router as level_router
|
||||||
api_router.include_router(level_router, prefix="/level", tags=["level"])
|
api_router.include_router(level_router, prefix="/level", tags=["level"])
|
||||||
|
# certificate_router 证书管理路由
|
||||||
|
from .endpoints.certificate import router as certificate_router
|
||||||
|
api_router.include_router(certificate_router, prefix="/certificates", tags=["certificates"])
|
||||||
|
# dashboard_router 数据大屏路由
|
||||||
|
from .endpoints.dashboard import router as dashboard_router
|
||||||
|
api_router.include_router(dashboard_router, prefix="/dashboard", tags=["dashboard"])
|
||||||
|
|
||||||
__all__ = ["api_router"]
|
__all__ = ["api_router"]
|
||||||
|
|||||||
305
backend/app/api/v1/endpoints/certificate.py
Normal file
305
backend/app/api/v1/endpoints/certificate.py
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
"""
|
||||||
|
证书管理 API 端点
|
||||||
|
|
||||||
|
提供证书相关的 RESTful API:
|
||||||
|
- 获取证书列表
|
||||||
|
- 获取证书详情
|
||||||
|
- 下载证书
|
||||||
|
- 验证证书
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional, List
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, Response, Query
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
import io
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import get_current_user
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.certificate_service import CertificateService
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/templates")
|
||||||
|
async def get_certificate_templates(
|
||||||
|
cert_type: Optional[str] = Query(None, description="证书类型: course/exam/achievement"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""获取证书模板列表"""
|
||||||
|
service = CertificateService(db)
|
||||||
|
templates = await service.get_templates(cert_type)
|
||||||
|
return {
|
||||||
|
"code": 200,
|
||||||
|
"message": "success",
|
||||||
|
"data": templates
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me")
|
||||||
|
async def get_my_certificates(
|
||||||
|
cert_type: Optional[str] = Query(None, description="证书类型过滤"),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(20, ge=1, le=100),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""获取当前用户的证书列表"""
|
||||||
|
service = CertificateService(db)
|
||||||
|
result = await service.get_user_certificates(
|
||||||
|
user_id=current_user.id,
|
||||||
|
cert_type=cert_type,
|
||||||
|
offset=offset,
|
||||||
|
limit=limit
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"code": 200,
|
||||||
|
"message": "success",
|
||||||
|
"data": result
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/user/{user_id}")
|
||||||
|
async def get_user_certificates(
|
||||||
|
user_id: int,
|
||||||
|
cert_type: Optional[str] = Query(None),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(20, ge=1, le=100),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""获取指定用户的证书列表(需要管理员权限)"""
|
||||||
|
# 只允许查看自己的证书或管理员查看
|
||||||
|
if current_user.id != user_id and current_user.role not in ["admin", "enterprise_admin"]:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="无权查看其他用户的证书"
|
||||||
|
)
|
||||||
|
|
||||||
|
service = CertificateService(db)
|
||||||
|
result = await service.get_user_certificates(
|
||||||
|
user_id=user_id,
|
||||||
|
cert_type=cert_type,
|
||||||
|
offset=offset,
|
||||||
|
limit=limit
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"code": 200,
|
||||||
|
"message": "success",
|
||||||
|
"data": result
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{cert_id}")
|
||||||
|
async def get_certificate_detail(
|
||||||
|
cert_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""获取证书详情"""
|
||||||
|
service = CertificateService(db)
|
||||||
|
cert = await service.get_certificate_by_id(cert_id)
|
||||||
|
|
||||||
|
if not cert:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="证书不存在"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"code": 200,
|
||||||
|
"message": "success",
|
||||||
|
"data": cert
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{cert_id}/image")
|
||||||
|
async def get_certificate_image(
|
||||||
|
cert_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""获取证书分享图片"""
|
||||||
|
service = CertificateService(db)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 获取基础URL
|
||||||
|
base_url = "https://kpl.example.com/certificates" # 可从配置读取
|
||||||
|
|
||||||
|
image_bytes = await service.generate_certificate_image(cert_id, base_url)
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
io.BytesIO(image_bytes),
|
||||||
|
media_type="image/png",
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": f"inline; filename=certificate_{cert_id}.png"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=str(e)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"生成证书图片失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{cert_id}/download")
|
||||||
|
async def download_certificate_pdf(
|
||||||
|
cert_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""下载证书PDF"""
|
||||||
|
service = CertificateService(db)
|
||||||
|
cert = await service.get_certificate_by_id(cert_id)
|
||||||
|
|
||||||
|
if not cert:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="证书不存在"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 如果已有PDF URL则重定向
|
||||||
|
if cert.get("pdf_url"):
|
||||||
|
return {
|
||||||
|
"code": 200,
|
||||||
|
"message": "success",
|
||||||
|
"data": {
|
||||||
|
"download_url": cert["pdf_url"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 否则返回图片作为替代
|
||||||
|
try:
|
||||||
|
base_url = "https://kpl.example.com/certificates"
|
||||||
|
image_bytes = await service.generate_certificate_image(cert_id, base_url)
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
io.BytesIO(image_bytes),
|
||||||
|
media_type="image/png",
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": f"attachment; filename=certificate_{cert['certificate_no']}.png"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"下载失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/verify/{cert_no}")
|
||||||
|
async def verify_certificate(
|
||||||
|
cert_no: str,
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
验证证书真伪
|
||||||
|
|
||||||
|
此接口无需登录,可用于公开验证证书
|
||||||
|
"""
|
||||||
|
service = CertificateService(db)
|
||||||
|
cert = await service.get_certificate_by_no(cert_no)
|
||||||
|
|
||||||
|
if not cert:
|
||||||
|
return {
|
||||||
|
"code": 404,
|
||||||
|
"message": "证书不存在或编号错误",
|
||||||
|
"data": {
|
||||||
|
"valid": False,
|
||||||
|
"certificate_no": cert_no
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"code": 200,
|
||||||
|
"message": "证书验证通过",
|
||||||
|
"data": {
|
||||||
|
"valid": True,
|
||||||
|
"certificate_no": cert_no,
|
||||||
|
"title": cert.get("title"),
|
||||||
|
"type_name": cert.get("type_name"),
|
||||||
|
"issued_at": cert.get("issued_at"),
|
||||||
|
"user": cert.get("user", {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/issue/course")
|
||||||
|
async def issue_course_certificate(
|
||||||
|
course_id: int,
|
||||||
|
course_name: str,
|
||||||
|
completion_rate: float = 100.0,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
颁发课程结业证书
|
||||||
|
|
||||||
|
通常由系统在用户完成课程时自动调用
|
||||||
|
"""
|
||||||
|
service = CertificateService(db)
|
||||||
|
|
||||||
|
try:
|
||||||
|
cert = await service.issue_course_certificate(
|
||||||
|
user_id=current_user.id,
|
||||||
|
course_id=course_id,
|
||||||
|
course_name=course_name,
|
||||||
|
completion_rate=completion_rate,
|
||||||
|
user_name=current_user.full_name or current_user.username
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"code": 200,
|
||||||
|
"message": "证书颁发成功",
|
||||||
|
"data": cert
|
||||||
|
}
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=str(e)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/issue/exam")
|
||||||
|
async def issue_exam_certificate(
|
||||||
|
exam_id: int,
|
||||||
|
exam_name: str,
|
||||||
|
score: float,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
颁发考试合格证书
|
||||||
|
|
||||||
|
通常由系统在用户考试通过时自动调用
|
||||||
|
"""
|
||||||
|
service = CertificateService(db)
|
||||||
|
|
||||||
|
try:
|
||||||
|
cert = await service.issue_exam_certificate(
|
||||||
|
user_id=current_user.id,
|
||||||
|
exam_id=exam_id,
|
||||||
|
exam_name=exam_name,
|
||||||
|
score=score,
|
||||||
|
user_name=current_user.full_name or current_user.username
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"code": 200,
|
||||||
|
"message": "证书颁发成功",
|
||||||
|
"data": cert
|
||||||
|
}
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=str(e)
|
||||||
|
)
|
||||||
231
backend/app/api/v1/endpoints/dashboard.py
Normal file
231
backend/app/api/v1/endpoints/dashboard.py
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
"""
|
||||||
|
数据大屏 API 端点
|
||||||
|
|
||||||
|
提供企业级和团队级数据大屏接口
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import get_current_user
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.dashboard_service import DashboardService
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/enterprise/overview")
|
||||||
|
async def get_enterprise_overview(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取企业级数据概览
|
||||||
|
|
||||||
|
需要管理员或企业管理员权限
|
||||||
|
"""
|
||||||
|
if current_user.role not in ["admin", "enterprise_admin", "manager"]:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="需要管理员权限"
|
||||||
|
)
|
||||||
|
|
||||||
|
service = DashboardService(db)
|
||||||
|
data = await service.get_enterprise_overview()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"code": 200,
|
||||||
|
"message": "success",
|
||||||
|
"data": data
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/enterprise/departments")
|
||||||
|
async def get_department_comparison(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取部门/团队学习对比数据
|
||||||
|
"""
|
||||||
|
if current_user.role not in ["admin", "enterprise_admin", "manager"]:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="需要管理员权限"
|
||||||
|
)
|
||||||
|
|
||||||
|
service = DashboardService(db)
|
||||||
|
data = await service.get_department_comparison()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"code": 200,
|
||||||
|
"message": "success",
|
||||||
|
"data": data
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/enterprise/trend")
|
||||||
|
async def get_learning_trend(
|
||||||
|
days: int = Query(7, ge=1, le=30),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取学习趋势数据
|
||||||
|
"""
|
||||||
|
if current_user.role not in ["admin", "enterprise_admin", "manager"]:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="需要管理员权限"
|
||||||
|
)
|
||||||
|
|
||||||
|
service = DashboardService(db)
|
||||||
|
data = await service.get_learning_trend(days)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"code": 200,
|
||||||
|
"message": "success",
|
||||||
|
"data": data
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/enterprise/level-distribution")
|
||||||
|
async def get_level_distribution(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取等级分布数据
|
||||||
|
"""
|
||||||
|
if current_user.role not in ["admin", "enterprise_admin", "manager"]:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="需要管理员权限"
|
||||||
|
)
|
||||||
|
|
||||||
|
service = DashboardService(db)
|
||||||
|
data = await service.get_level_distribution()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"code": 200,
|
||||||
|
"message": "success",
|
||||||
|
"data": data
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/enterprise/activities")
|
||||||
|
async def get_realtime_activities(
|
||||||
|
limit: int = Query(20, ge=1, le=100),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取实时动态
|
||||||
|
"""
|
||||||
|
if current_user.role not in ["admin", "enterprise_admin", "manager"]:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="需要管理员权限"
|
||||||
|
)
|
||||||
|
|
||||||
|
service = DashboardService(db)
|
||||||
|
data = await service.get_realtime_activities(limit)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"code": 200,
|
||||||
|
"message": "success",
|
||||||
|
"data": data
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/enterprise/course-ranking")
|
||||||
|
async def get_course_ranking(
|
||||||
|
limit: int = Query(10, ge=1, le=50),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取课程热度排行
|
||||||
|
"""
|
||||||
|
if current_user.role not in ["admin", "enterprise_admin", "manager"]:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="需要管理员权限"
|
||||||
|
)
|
||||||
|
|
||||||
|
service = DashboardService(db)
|
||||||
|
data = await service.get_course_ranking(limit)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"code": 200,
|
||||||
|
"message": "success",
|
||||||
|
"data": data
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/team")
|
||||||
|
async def get_team_dashboard(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取团队级数据大屏
|
||||||
|
|
||||||
|
面向团队负责人,显示其管理团队的数据
|
||||||
|
"""
|
||||||
|
if current_user.role not in ["admin", "enterprise_admin", "manager"]:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="需要团队负责人权限"
|
||||||
|
)
|
||||||
|
|
||||||
|
service = DashboardService(db)
|
||||||
|
data = await service.get_team_dashboard(current_user.id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"code": 200,
|
||||||
|
"message": "success",
|
||||||
|
"data": data
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/all")
|
||||||
|
async def get_all_dashboard_data(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取完整的大屏数据(一次性获取所有数据)
|
||||||
|
|
||||||
|
用于大屏初始化加载
|
||||||
|
"""
|
||||||
|
if current_user.role not in ["admin", "enterprise_admin", "manager"]:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="需要管理员权限"
|
||||||
|
)
|
||||||
|
|
||||||
|
service = DashboardService(db)
|
||||||
|
|
||||||
|
# 并行获取所有数据
|
||||||
|
overview = await service.get_enterprise_overview()
|
||||||
|
departments = await service.get_department_comparison()
|
||||||
|
trend = await service.get_learning_trend(7)
|
||||||
|
level_dist = await service.get_level_distribution()
|
||||||
|
activities = await service.get_realtime_activities(20)
|
||||||
|
course_ranking = await service.get_course_ranking(10)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"code": 200,
|
||||||
|
"message": "success",
|
||||||
|
"data": {
|
||||||
|
"overview": overview,
|
||||||
|
"departments": departments,
|
||||||
|
"trend": trend,
|
||||||
|
"level_distribution": level_dist,
|
||||||
|
"activities": activities,
|
||||||
|
"course_ranking": course_ranking,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,11 @@ from app.models.level import (
|
|||||||
BadgeCategory,
|
BadgeCategory,
|
||||||
ConditionType,
|
ConditionType,
|
||||||
)
|
)
|
||||||
|
from app.models.certificate import (
|
||||||
|
CertificateTemplate,
|
||||||
|
UserCertificate,
|
||||||
|
CertificateType,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Base",
|
"Base",
|
||||||
@@ -64,4 +69,7 @@ __all__ = [
|
|||||||
"ExpType",
|
"ExpType",
|
||||||
"BadgeCategory",
|
"BadgeCategory",
|
||||||
"ConditionType",
|
"ConditionType",
|
||||||
|
"CertificateTemplate",
|
||||||
|
"UserCertificate",
|
||||||
|
"CertificateType",
|
||||||
]
|
]
|
||||||
|
|||||||
76
backend/app/models/certificate.py
Normal file
76
backend/app/models/certificate.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
"""
|
||||||
|
证书系统数据模型
|
||||||
|
|
||||||
|
定义证书模板和用户证书的数据结构
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Optional
|
||||||
|
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, Enum as SQLEnum, DECIMAL, JSON
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class CertificateType(str, Enum):
|
||||||
|
"""证书类型枚举"""
|
||||||
|
COURSE = "course" # 课程结业证书
|
||||||
|
EXAM = "exam" # 考试合格证书
|
||||||
|
ACHIEVEMENT = "achievement" # 成就证书
|
||||||
|
|
||||||
|
|
||||||
|
class CertificateTemplate(Base):
|
||||||
|
"""证书模板表"""
|
||||||
|
__tablename__ = "certificate_templates"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
name = Column(String(100), nullable=False, comment="模板名称")
|
||||||
|
type = Column(SQLEnum(CertificateType), nullable=False, comment="证书类型")
|
||||||
|
background_url = Column(String(500), comment="证书背景图URL")
|
||||||
|
template_html = Column(Text, comment="HTML模板内容")
|
||||||
|
template_style = Column(Text, comment="CSS样式")
|
||||||
|
is_active = Column(Boolean, default=True, comment="是否启用")
|
||||||
|
sort_order = Column(Integer, default=0, comment="排序顺序")
|
||||||
|
created_at = Column(DateTime, nullable=False, default=datetime.now)
|
||||||
|
updated_at = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now)
|
||||||
|
|
||||||
|
# 关联
|
||||||
|
certificates = relationship("UserCertificate", back_populates="template")
|
||||||
|
|
||||||
|
|
||||||
|
class UserCertificate(Base):
|
||||||
|
"""用户证书表"""
|
||||||
|
__tablename__ = "user_certificates"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, comment="用户ID")
|
||||||
|
template_id = Column(Integer, ForeignKey("certificate_templates.id"), nullable=False, comment="模板ID")
|
||||||
|
certificate_no = Column(String(50), unique=True, nullable=False, comment="证书编号")
|
||||||
|
title = Column(String(200), nullable=False, comment="证书标题")
|
||||||
|
description = Column(Text, comment="证书描述")
|
||||||
|
issued_at = Column(DateTime, default=datetime.now, comment="颁发时间")
|
||||||
|
valid_until = Column(DateTime, comment="有效期至")
|
||||||
|
|
||||||
|
# 关联信息
|
||||||
|
course_id = Column(Integer, comment="关联课程ID")
|
||||||
|
exam_id = Column(Integer, comment="关联考试ID")
|
||||||
|
badge_id = Column(Integer, comment="关联奖章ID")
|
||||||
|
|
||||||
|
# 成绩信息
|
||||||
|
score = Column(DECIMAL(5, 2), comment="考试分数")
|
||||||
|
completion_rate = Column(DECIMAL(5, 2), comment="完成率")
|
||||||
|
|
||||||
|
# 生成的文件
|
||||||
|
pdf_url = Column(String(500), comment="PDF文件URL")
|
||||||
|
image_url = Column(String(500), comment="分享图片URL")
|
||||||
|
|
||||||
|
# 元数据
|
||||||
|
meta_data = Column(JSON, comment="扩展元数据")
|
||||||
|
|
||||||
|
created_at = Column(DateTime, nullable=False, default=datetime.now)
|
||||||
|
updated_at = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now)
|
||||||
|
|
||||||
|
# 关联
|
||||||
|
template = relationship("CertificateTemplate", back_populates="certificates")
|
||||||
|
user = relationship("User", backref="certificates")
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional, List, Dict, Any, Tuple
|
from typing import Optional, List, Dict, Any, Tuple
|
||||||
from sqlalchemy import select, func, and_, or_
|
from sqlalchemy import select, func, and_, or_, case
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.logger import get_logger
|
from app.core.logger import get_logger
|
||||||
@@ -162,16 +162,17 @@ class BadgeService:
|
|||||||
"user_level": 1,
|
"user_level": 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
# 获取用户等级信息
|
# 获取用户等级信息
|
||||||
result = await self.db.execute(
|
result = await self.db.execute(
|
||||||
select(UserLevel).where(UserLevel.user_id == user_id)
|
select(UserLevel).where(UserLevel.user_id == user_id)
|
||||||
)
|
)
|
||||||
user_level = result.scalar_one_or_none()
|
user_level = result.scalar_one_or_none()
|
||||||
if user_level:
|
if user_level:
|
||||||
stats["login_streak"] = user_level.login_streak
|
stats["login_streak"] = user_level.login_streak or 0
|
||||||
stats["user_level"] = user_level.level
|
stats["user_level"] = user_level.level or 1
|
||||||
|
|
||||||
# 获取登录次数(从经验值历史)
|
# 获取登录/签到次数(从经验值历史)
|
||||||
result = await self.db.execute(
|
result = await self.db.execute(
|
||||||
select(func.count(ExpHistory.id))
|
select(func.count(ExpHistory.id))
|
||||||
.where(
|
.where(
|
||||||
@@ -181,30 +182,45 @@ class BadgeService:
|
|||||||
)
|
)
|
||||||
stats["login_count"] = result.scalar() or 0
|
stats["login_count"] = result.scalar() or 0
|
||||||
|
|
||||||
# 获取考试统计
|
# 获取考试统计 - 使用 case 语句
|
||||||
|
# 通过考试数量
|
||||||
result = await self.db.execute(
|
result = await self.db.execute(
|
||||||
select(
|
select(func.count(Exam.id))
|
||||||
func.count(Exam.id),
|
|
||||||
func.sum(func.if_(Exam.score >= 100, 1, 0)),
|
|
||||||
func.sum(func.if_(Exam.score >= 90, 1, 0))
|
|
||||||
)
|
|
||||||
.where(
|
.where(
|
||||||
Exam.user_id == user_id,
|
Exam.user_id == user_id,
|
||||||
Exam.is_passed == True,
|
Exam.is_passed == True,
|
||||||
Exam.status == "submitted"
|
Exam.status == "submitted"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
row = result.first()
|
stats["exam_passed"] = result.scalar() or 0
|
||||||
if row:
|
|
||||||
stats["exam_passed"] = row[0] or 0
|
|
||||||
stats["exam_perfect_count"] = int(row[1] or 0)
|
|
||||||
stats["exam_excellent"] = int(row[2] or 0)
|
|
||||||
|
|
||||||
# 获取练习统计
|
# 满分考试数量(score >= 总分,通常是 100)
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(func.count(Exam.id))
|
||||||
|
.where(
|
||||||
|
Exam.user_id == user_id,
|
||||||
|
Exam.status == "submitted",
|
||||||
|
Exam.score >= Exam.total_score
|
||||||
|
)
|
||||||
|
)
|
||||||
|
stats["exam_perfect_count"] = result.scalar() or 0
|
||||||
|
|
||||||
|
# 优秀考试数量(90分以上)
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(func.count(Exam.id))
|
||||||
|
.where(
|
||||||
|
Exam.user_id == user_id,
|
||||||
|
Exam.status == "submitted",
|
||||||
|
Exam.score >= 90
|
||||||
|
)
|
||||||
|
)
|
||||||
|
stats["exam_excellent"] = result.scalar() or 0
|
||||||
|
|
||||||
|
# 获取练习统计(PracticeSession - AI 陪练)
|
||||||
result = await self.db.execute(
|
result = await self.db.execute(
|
||||||
select(
|
select(
|
||||||
func.count(PracticeSession.id),
|
func.count(PracticeSession.id),
|
||||||
func.sum(PracticeSession.duration_seconds)
|
func.coalesce(func.sum(PracticeSession.duration_seconds), 0)
|
||||||
)
|
)
|
||||||
.where(
|
.where(
|
||||||
PracticeSession.user_id == user_id,
|
PracticeSession.user_id == user_id,
|
||||||
@@ -215,9 +231,9 @@ class BadgeService:
|
|||||||
if row:
|
if row:
|
||||||
stats["practice_count"] = row[0] or 0
|
stats["practice_count"] = row[0] or 0
|
||||||
total_seconds = row[1] or 0
|
total_seconds = row[1] or 0
|
||||||
stats["practice_hours"] = total_seconds / 3600
|
stats["practice_hours"] = float(total_seconds) / 3600.0
|
||||||
|
|
||||||
# 获取陪练统计
|
# 获取培训/陪练统计(TrainingSession)
|
||||||
result = await self.db.execute(
|
result = await self.db.execute(
|
||||||
select(func.count(TrainingSession.id))
|
select(func.count(TrainingSession.id))
|
||||||
.where(
|
.where(
|
||||||
@@ -227,7 +243,7 @@ class BadgeService:
|
|||||||
)
|
)
|
||||||
stats["training_count"] = result.scalar() or 0
|
stats["training_count"] = result.scalar() or 0
|
||||||
|
|
||||||
# 检查首次高分陪练
|
# 检查是否有高分陪练(90分以上)
|
||||||
result = await self.db.execute(
|
result = await self.db.execute(
|
||||||
select(func.count(TrainingReport.id))
|
select(func.count(TrainingReport.id))
|
||||||
.where(
|
.where(
|
||||||
@@ -235,7 +251,13 @@ class BadgeService:
|
|||||||
TrainingReport.overall_score >= 90
|
TrainingReport.overall_score >= 90
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
stats["first_practice_90"] = 1 if (result.scalar() or 0) > 0 else 0
|
high_score_count = result.scalar() or 0
|
||||||
|
stats["first_practice_90"] = 1 if high_score_count > 0 else 0
|
||||||
|
|
||||||
|
logger.debug(f"用户 {user_id} 奖章统计数据: {stats}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取用户统计数据失败: {e}")
|
||||||
|
|
||||||
return stats
|
return stats
|
||||||
|
|
||||||
@@ -465,3 +487,100 @@ class BadgeService:
|
|||||||
|
|
||||||
await self.db.execute(query)
|
await self.db.execute(query)
|
||||||
await self.db.flush()
|
await self.db.flush()
|
||||||
|
|
||||||
|
async def check_badges_by_category(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
categories: List[str]
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
按类别检查并授予奖章(优化触发时机)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: 用户ID
|
||||||
|
categories: 要检查的奖章类别列表
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
新获得的奖章列表
|
||||||
|
"""
|
||||||
|
# 获取用户统计数据
|
||||||
|
stats = await self._get_user_stats(user_id)
|
||||||
|
|
||||||
|
# 获取指定类别的奖章定义
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(BadgeDefinition)
|
||||||
|
.where(
|
||||||
|
BadgeDefinition.is_active == True,
|
||||||
|
BadgeDefinition.category.in_(categories)
|
||||||
|
)
|
||||||
|
.order_by(BadgeDefinition.sort_order)
|
||||||
|
)
|
||||||
|
category_badges = list(result.scalars().all())
|
||||||
|
|
||||||
|
# 获取用户已有的奖章
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(UserBadge.badge_id).where(UserBadge.user_id == user_id)
|
||||||
|
)
|
||||||
|
owned_badge_ids = {row[0] for row in result.all()}
|
||||||
|
|
||||||
|
# 检查每个奖章的解锁条件
|
||||||
|
newly_awarded = []
|
||||||
|
for badge in category_badges:
|
||||||
|
if badge.id in owned_badge_ids:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 检查条件
|
||||||
|
condition_met = self._check_badge_condition(badge, stats)
|
||||||
|
|
||||||
|
if condition_met:
|
||||||
|
# 授予奖章
|
||||||
|
user_badge = UserBadge(
|
||||||
|
user_id=user_id,
|
||||||
|
badge_id=badge.id,
|
||||||
|
unlocked_at=datetime.now(),
|
||||||
|
is_notified=False
|
||||||
|
)
|
||||||
|
self.db.add(user_badge)
|
||||||
|
|
||||||
|
# 如果有经验奖励,添加经验值
|
||||||
|
if badge.exp_reward > 0:
|
||||||
|
from app.services.level_service import LevelService
|
||||||
|
level_service = LevelService(self.db)
|
||||||
|
await level_service.add_exp(
|
||||||
|
user_id=user_id,
|
||||||
|
exp_amount=badge.exp_reward,
|
||||||
|
exp_type=ExpType.BADGE,
|
||||||
|
description=f"解锁奖章「{badge.name}」"
|
||||||
|
)
|
||||||
|
|
||||||
|
newly_awarded.append({
|
||||||
|
"badge_id": badge.id,
|
||||||
|
"code": badge.code,
|
||||||
|
"name": badge.name,
|
||||||
|
"description": badge.description,
|
||||||
|
"icon": badge.icon,
|
||||||
|
"exp_reward": badge.exp_reward,
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info(f"用户 {user_id} 解锁奖章: {badge.name}")
|
||||||
|
|
||||||
|
if newly_awarded:
|
||||||
|
await self.db.flush()
|
||||||
|
|
||||||
|
return newly_awarded
|
||||||
|
|
||||||
|
async def check_exam_badges(self, user_id: int) -> List[Dict[str, Any]]:
|
||||||
|
"""考试后检查考试类奖章"""
|
||||||
|
return await self.check_badges_by_category(user_id, [BadgeCategory.EXAM])
|
||||||
|
|
||||||
|
async def check_practice_badges(self, user_id: int) -> List[Dict[str, Any]]:
|
||||||
|
"""练习后检查练习类奖章"""
|
||||||
|
return await self.check_badges_by_category(user_id, [BadgeCategory.PRACTICE])
|
||||||
|
|
||||||
|
async def check_streak_badges(self, user_id: int) -> List[Dict[str, Any]]:
|
||||||
|
"""签到后检查连续打卡类奖章"""
|
||||||
|
return await self.check_badges_by_category(user_id, [BadgeCategory.STREAK, BadgeCategory.LEARNING])
|
||||||
|
|
||||||
|
async def check_level_badges(self, user_id: int) -> List[Dict[str, Any]]:
|
||||||
|
"""等级变化后检查等级类奖章"""
|
||||||
|
return await self.check_badges_by_category(user_id, [BadgeCategory.SPECIAL])
|
||||||
|
|||||||
516
backend/app/services/certificate_service.py
Normal file
516
backend/app/services/certificate_service.py
Normal file
@@ -0,0 +1,516 @@
|
|||||||
|
"""
|
||||||
|
证书服务
|
||||||
|
|
||||||
|
提供证书管理功能:
|
||||||
|
- 颁发证书
|
||||||
|
- 获取证书列表
|
||||||
|
- 生成证书PDF/图片
|
||||||
|
- 验证证书
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import io
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, List, Dict, Any
|
||||||
|
from sqlalchemy import select, func, and_
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
import qrcode
|
||||||
|
|
||||||
|
from app.core.logger import get_logger
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.models.certificate import CertificateTemplate, UserCertificate, CertificateType
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class CertificateService:
|
||||||
|
"""证书服务"""
|
||||||
|
|
||||||
|
# 证书编号前缀
|
||||||
|
CERT_NO_PREFIX = "KPL"
|
||||||
|
|
||||||
|
def __init__(self, db: AsyncSession):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
async def get_templates(self, cert_type: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
获取证书模板列表
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cert_type: 证书类型过滤
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
模板列表
|
||||||
|
"""
|
||||||
|
query = select(CertificateTemplate).where(CertificateTemplate.is_active == True)
|
||||||
|
|
||||||
|
if cert_type:
|
||||||
|
query = query.where(CertificateTemplate.type == cert_type)
|
||||||
|
|
||||||
|
query = query.order_by(CertificateTemplate.sort_order)
|
||||||
|
|
||||||
|
result = await self.db.execute(query)
|
||||||
|
templates = result.scalars().all()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": t.id,
|
||||||
|
"name": t.name,
|
||||||
|
"type": t.type.value if isinstance(t.type, CertificateType) else t.type,
|
||||||
|
"background_url": t.background_url,
|
||||||
|
"is_active": t.is_active,
|
||||||
|
}
|
||||||
|
for t in templates
|
||||||
|
]
|
||||||
|
|
||||||
|
async def _generate_certificate_no(self) -> str:
|
||||||
|
"""生成唯一证书编号"""
|
||||||
|
year = datetime.now().year
|
||||||
|
|
||||||
|
# 获取当年的证书数量
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(func.count(UserCertificate.id))
|
||||||
|
.where(UserCertificate.certificate_no.like(f"{self.CERT_NO_PREFIX}-{year}-%"))
|
||||||
|
)
|
||||||
|
count = result.scalar() or 0
|
||||||
|
|
||||||
|
# 生成编号:KPL-年份-6位序号
|
||||||
|
cert_no = f"{self.CERT_NO_PREFIX}-{year}-{str(count + 1).zfill(6)}"
|
||||||
|
return cert_no
|
||||||
|
|
||||||
|
async def issue_course_certificate(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
course_id: int,
|
||||||
|
course_name: str,
|
||||||
|
completion_rate: float,
|
||||||
|
user_name: str
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
颁发课程结业证书
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: 用户ID
|
||||||
|
course_id: 课程ID
|
||||||
|
course_name: 课程名称
|
||||||
|
completion_rate: 完成率
|
||||||
|
user_name: 用户姓名
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
证书信息
|
||||||
|
"""
|
||||||
|
# 检查是否已颁发
|
||||||
|
existing = await self.db.execute(
|
||||||
|
select(UserCertificate).where(
|
||||||
|
UserCertificate.user_id == user_id,
|
||||||
|
UserCertificate.course_id == course_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing.scalar_one_or_none():
|
||||||
|
raise ValueError("该课程证书已颁发")
|
||||||
|
|
||||||
|
# 获取课程证书模板
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(CertificateTemplate).where(
|
||||||
|
CertificateTemplate.type == CertificateType.COURSE,
|
||||||
|
CertificateTemplate.is_active == True
|
||||||
|
)
|
||||||
|
)
|
||||||
|
template = result.scalar_one_or_none()
|
||||||
|
if not template:
|
||||||
|
raise ValueError("证书模板不存在")
|
||||||
|
|
||||||
|
# 生成证书编号
|
||||||
|
cert_no = await self._generate_certificate_no()
|
||||||
|
|
||||||
|
# 创建证书
|
||||||
|
certificate = UserCertificate(
|
||||||
|
user_id=user_id,
|
||||||
|
template_id=template.id,
|
||||||
|
certificate_no=cert_no,
|
||||||
|
title=f"《{course_name}》课程结业证书",
|
||||||
|
description=f"完成课程《{course_name}》的全部学习内容",
|
||||||
|
course_id=course_id,
|
||||||
|
completion_rate=completion_rate,
|
||||||
|
meta_data={
|
||||||
|
"course_name": course_name,
|
||||||
|
"user_name": user_name,
|
||||||
|
"completion_rate": completion_rate
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.db.add(certificate)
|
||||||
|
await self.db.flush()
|
||||||
|
|
||||||
|
logger.info(f"颁发课程证书: user_id={user_id}, course_id={course_id}, cert_no={cert_no}")
|
||||||
|
|
||||||
|
return await self._format_certificate(certificate, template)
|
||||||
|
|
||||||
|
async def issue_exam_certificate(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
exam_id: int,
|
||||||
|
exam_name: str,
|
||||||
|
score: float,
|
||||||
|
user_name: str
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
颁发考试合格证书
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: 用户ID
|
||||||
|
exam_id: 考试ID
|
||||||
|
exam_name: 考试名称
|
||||||
|
score: 分数
|
||||||
|
user_name: 用户姓名
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
证书信息
|
||||||
|
"""
|
||||||
|
# 检查是否已颁发
|
||||||
|
existing = await self.db.execute(
|
||||||
|
select(UserCertificate).where(
|
||||||
|
UserCertificate.user_id == user_id,
|
||||||
|
UserCertificate.exam_id == exam_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing.scalar_one_or_none():
|
||||||
|
raise ValueError("该考试证书已颁发")
|
||||||
|
|
||||||
|
# 获取考试证书模板
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(CertificateTemplate).where(
|
||||||
|
CertificateTemplate.type == CertificateType.EXAM,
|
||||||
|
CertificateTemplate.is_active == True
|
||||||
|
)
|
||||||
|
)
|
||||||
|
template = result.scalar_one_or_none()
|
||||||
|
if not template:
|
||||||
|
raise ValueError("证书模板不存在")
|
||||||
|
|
||||||
|
# 生成证书编号
|
||||||
|
cert_no = await self._generate_certificate_no()
|
||||||
|
|
||||||
|
# 创建证书
|
||||||
|
certificate = UserCertificate(
|
||||||
|
user_id=user_id,
|
||||||
|
template_id=template.id,
|
||||||
|
certificate_no=cert_no,
|
||||||
|
title=f"《{exam_name}》考试合格证书",
|
||||||
|
description=f"在《{exam_name}》考试中成绩合格",
|
||||||
|
exam_id=exam_id,
|
||||||
|
score=score,
|
||||||
|
meta_data={
|
||||||
|
"exam_name": exam_name,
|
||||||
|
"user_name": user_name,
|
||||||
|
"score": score
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.db.add(certificate)
|
||||||
|
await self.db.flush()
|
||||||
|
|
||||||
|
logger.info(f"颁发考试证书: user_id={user_id}, exam_id={exam_id}, cert_no={cert_no}")
|
||||||
|
|
||||||
|
return await self._format_certificate(certificate, template)
|
||||||
|
|
||||||
|
async def issue_achievement_certificate(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
badge_id: int,
|
||||||
|
badge_name: str,
|
||||||
|
badge_description: str,
|
||||||
|
user_name: str
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
颁发成就证书
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: 用户ID
|
||||||
|
badge_id: 奖章ID
|
||||||
|
badge_name: 奖章名称
|
||||||
|
badge_description: 奖章描述
|
||||||
|
user_name: 用户姓名
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
证书信息
|
||||||
|
"""
|
||||||
|
# 检查是否已颁发
|
||||||
|
existing = await self.db.execute(
|
||||||
|
select(UserCertificate).where(
|
||||||
|
UserCertificate.user_id == user_id,
|
||||||
|
UserCertificate.badge_id == badge_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing.scalar_one_or_none():
|
||||||
|
raise ValueError("该成就证书已颁发")
|
||||||
|
|
||||||
|
# 获取成就证书模板
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(CertificateTemplate).where(
|
||||||
|
CertificateTemplate.type == CertificateType.ACHIEVEMENT,
|
||||||
|
CertificateTemplate.is_active == True
|
||||||
|
)
|
||||||
|
)
|
||||||
|
template = result.scalar_one_or_none()
|
||||||
|
if not template:
|
||||||
|
raise ValueError("证书模板不存在")
|
||||||
|
|
||||||
|
# 生成证书编号
|
||||||
|
cert_no = await self._generate_certificate_no()
|
||||||
|
|
||||||
|
# 创建证书
|
||||||
|
certificate = UserCertificate(
|
||||||
|
user_id=user_id,
|
||||||
|
template_id=template.id,
|
||||||
|
certificate_no=cert_no,
|
||||||
|
title=f"「{badge_name}」成就证书",
|
||||||
|
description=badge_description,
|
||||||
|
badge_id=badge_id,
|
||||||
|
meta_data={
|
||||||
|
"badge_name": badge_name,
|
||||||
|
"badge_description": badge_description,
|
||||||
|
"user_name": user_name
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.db.add(certificate)
|
||||||
|
await self.db.flush()
|
||||||
|
|
||||||
|
logger.info(f"颁发成就证书: user_id={user_id}, badge_id={badge_id}, cert_no={cert_no}")
|
||||||
|
|
||||||
|
return await self._format_certificate(certificate, template)
|
||||||
|
|
||||||
|
async def get_user_certificates(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
cert_type: Optional[str] = None,
|
||||||
|
offset: int = 0,
|
||||||
|
limit: int = 20
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
获取用户证书列表
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: 用户ID
|
||||||
|
cert_type: 证书类型过滤
|
||||||
|
offset: 偏移量
|
||||||
|
limit: 数量限制
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
证书列表和分页信息
|
||||||
|
"""
|
||||||
|
query = (
|
||||||
|
select(UserCertificate, CertificateTemplate)
|
||||||
|
.join(CertificateTemplate, UserCertificate.template_id == CertificateTemplate.id)
|
||||||
|
.where(UserCertificate.user_id == user_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
if cert_type:
|
||||||
|
query = query.where(CertificateTemplate.type == cert_type)
|
||||||
|
|
||||||
|
# 获取总数
|
||||||
|
count_query = select(func.count()).select_from(query.subquery())
|
||||||
|
total_result = await self.db.execute(count_query)
|
||||||
|
total = total_result.scalar() or 0
|
||||||
|
|
||||||
|
# 分页查询
|
||||||
|
query = query.order_by(UserCertificate.issued_at.desc()).offset(offset).limit(limit)
|
||||||
|
result = await self.db.execute(query)
|
||||||
|
rows = result.all()
|
||||||
|
|
||||||
|
certificates = [
|
||||||
|
await self._format_certificate(cert, template)
|
||||||
|
for cert, template in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"items": certificates,
|
||||||
|
"total": total,
|
||||||
|
"offset": offset,
|
||||||
|
"limit": limit
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_certificate_by_id(self, cert_id: int) -> Optional[Dict[str, Any]]:
|
||||||
|
"""根据ID获取证书"""
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(UserCertificate, CertificateTemplate)
|
||||||
|
.join(CertificateTemplate, UserCertificate.template_id == CertificateTemplate.id)
|
||||||
|
.where(UserCertificate.id == cert_id)
|
||||||
|
)
|
||||||
|
row = result.first()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
|
||||||
|
cert, template = row
|
||||||
|
return await self._format_certificate(cert, template)
|
||||||
|
|
||||||
|
async def get_certificate_by_no(self, cert_no: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""根据编号获取证书(用于验证)"""
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(UserCertificate, CertificateTemplate)
|
||||||
|
.join(CertificateTemplate, UserCertificate.template_id == CertificateTemplate.id)
|
||||||
|
.where(UserCertificate.certificate_no == cert_no)
|
||||||
|
)
|
||||||
|
row = result.first()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
|
||||||
|
cert, template = row
|
||||||
|
return await self._format_certificate(cert, template, include_user=True)
|
||||||
|
|
||||||
|
async def _format_certificate(
|
||||||
|
self,
|
||||||
|
cert: UserCertificate,
|
||||||
|
template: CertificateTemplate,
|
||||||
|
include_user: bool = False
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""格式化证书数据"""
|
||||||
|
data = {
|
||||||
|
"id": cert.id,
|
||||||
|
"certificate_no": cert.certificate_no,
|
||||||
|
"title": cert.title,
|
||||||
|
"description": cert.description,
|
||||||
|
"type": template.type.value if isinstance(template.type, CertificateType) else template.type,
|
||||||
|
"type_name": self._get_type_name(template.type),
|
||||||
|
"issued_at": cert.issued_at.isoformat() if cert.issued_at else None,
|
||||||
|
"valid_until": cert.valid_until.isoformat() if cert.valid_until else None,
|
||||||
|
"score": float(cert.score) if cert.score else None,
|
||||||
|
"completion_rate": float(cert.completion_rate) if cert.completion_rate else None,
|
||||||
|
"pdf_url": cert.pdf_url,
|
||||||
|
"image_url": cert.image_url,
|
||||||
|
"course_id": cert.course_id,
|
||||||
|
"exam_id": cert.exam_id,
|
||||||
|
"badge_id": cert.badge_id,
|
||||||
|
"meta_data": cert.meta_data,
|
||||||
|
"template": {
|
||||||
|
"id": template.id,
|
||||||
|
"name": template.name,
|
||||||
|
"background_url": template.background_url,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if include_user and cert.user:
|
||||||
|
data["user"] = {
|
||||||
|
"id": cert.user.id,
|
||||||
|
"username": cert.user.username,
|
||||||
|
"full_name": cert.user.full_name,
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
def _get_type_name(self, cert_type) -> str:
|
||||||
|
"""获取证书类型名称"""
|
||||||
|
type_names = {
|
||||||
|
CertificateType.COURSE: "课程结业证书",
|
||||||
|
CertificateType.EXAM: "考试合格证书",
|
||||||
|
CertificateType.ACHIEVEMENT: "成就证书",
|
||||||
|
"course": "课程结业证书",
|
||||||
|
"exam": "考试合格证书",
|
||||||
|
"achievement": "成就证书",
|
||||||
|
}
|
||||||
|
return type_names.get(cert_type, "证书")
|
||||||
|
|
||||||
|
async def generate_certificate_image(
|
||||||
|
self,
|
||||||
|
cert_id: int,
|
||||||
|
base_url: str = ""
|
||||||
|
) -> bytes:
|
||||||
|
"""
|
||||||
|
生成证书分享图片
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cert_id: 证书ID
|
||||||
|
base_url: 基础URL(用于生成二维码链接)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
图片二进制数据
|
||||||
|
"""
|
||||||
|
# 获取证书信息
|
||||||
|
cert_data = await self.get_certificate_by_id(cert_id)
|
||||||
|
if not cert_data:
|
||||||
|
raise ValueError("证书不存在")
|
||||||
|
|
||||||
|
# 创建图片
|
||||||
|
width, height = 800, 600
|
||||||
|
img = Image.new('RGB', (width, height), color='#f5f7fa')
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
# 尝试加载字体,如果失败则使用默认字体
|
||||||
|
try:
|
||||||
|
title_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 36)
|
||||||
|
text_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 20)
|
||||||
|
small_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 14)
|
||||||
|
except:
|
||||||
|
title_font = ImageFont.load_default()
|
||||||
|
text_font = ImageFont.load_default()
|
||||||
|
small_font = ImageFont.load_default()
|
||||||
|
|
||||||
|
# 绘制标题
|
||||||
|
title = cert_data.get("type_name", "证书")
|
||||||
|
draw.text((width // 2, 60), title, font=title_font, fill='#333333', anchor='mm')
|
||||||
|
|
||||||
|
# 绘制证书标题
|
||||||
|
cert_title = cert_data.get("title", "")
|
||||||
|
draw.text((width // 2, 140), cert_title, font=text_font, fill='#666666', anchor='mm')
|
||||||
|
|
||||||
|
# 绘制描述
|
||||||
|
description = cert_data.get("description", "")
|
||||||
|
draw.text((width // 2, 200), description, font=text_font, fill='#666666', anchor='mm')
|
||||||
|
|
||||||
|
# 绘制分数/完成率(如果有)
|
||||||
|
if cert_data.get("score"):
|
||||||
|
score_text = f"成绩:{cert_data['score']}分"
|
||||||
|
draw.text((width // 2, 280), score_text, font=text_font, fill='#667eea', anchor='mm')
|
||||||
|
elif cert_data.get("completion_rate"):
|
||||||
|
rate_text = f"完成率:{cert_data['completion_rate']}%"
|
||||||
|
draw.text((width // 2, 280), rate_text, font=text_font, fill='#667eea', anchor='mm')
|
||||||
|
|
||||||
|
# 绘制颁发日期
|
||||||
|
if cert_data.get("issued_at"):
|
||||||
|
date_text = f"颁发日期:{cert_data['issued_at'][:10]}"
|
||||||
|
draw.text((width // 2, 360), date_text, font=small_font, fill='#999999', anchor='mm')
|
||||||
|
|
||||||
|
# 绘制证书编号
|
||||||
|
cert_no = cert_data.get("certificate_no", "")
|
||||||
|
draw.text((width // 2, 520), f"证书编号:{cert_no}", font=small_font, fill='#999999', anchor='mm')
|
||||||
|
|
||||||
|
# 生成验证二维码
|
||||||
|
if base_url and cert_no:
|
||||||
|
verify_url = f"{base_url}/verify/{cert_no}"
|
||||||
|
qr = qrcode.QRCode(version=1, box_size=3, border=2)
|
||||||
|
qr.add_data(verify_url)
|
||||||
|
qr.make(fit=True)
|
||||||
|
qr_img = qr.make_image(fill_color="black", back_color="white")
|
||||||
|
qr_img = qr_img.resize((80, 80))
|
||||||
|
img.paste(qr_img, (width - 100, height - 100))
|
||||||
|
|
||||||
|
# 转换为字节
|
||||||
|
img_bytes = io.BytesIO()
|
||||||
|
img.save(img_bytes, format='PNG')
|
||||||
|
img_bytes.seek(0)
|
||||||
|
|
||||||
|
return img_bytes.getvalue()
|
||||||
|
|
||||||
|
async def update_certificate_files(
|
||||||
|
self,
|
||||||
|
cert_id: int,
|
||||||
|
pdf_url: Optional[str] = None,
|
||||||
|
image_url: Optional[str] = None
|
||||||
|
):
|
||||||
|
"""更新证书文件URL"""
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(UserCertificate).where(UserCertificate.id == cert_id)
|
||||||
|
)
|
||||||
|
cert = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if cert:
|
||||||
|
if pdf_url:
|
||||||
|
cert.pdf_url = pdf_url
|
||||||
|
if image_url:
|
||||||
|
cert.image_url = image_url
|
||||||
|
await self.db.flush()
|
||||||
489
backend/app/services/dashboard_service.py
Normal file
489
backend/app/services/dashboard_service.py
Normal file
@@ -0,0 +1,489 @@
|
|||||||
|
"""
|
||||||
|
数据大屏服务
|
||||||
|
|
||||||
|
提供企业级和团队级数据大屏功能:
|
||||||
|
- 学习数据概览
|
||||||
|
- 部门/团队对比
|
||||||
|
- 趋势分析
|
||||||
|
- 实时动态
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, date
|
||||||
|
from typing import Optional, List, Dict, Any
|
||||||
|
from sqlalchemy import select, func, and_, or_, desc, case
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.logger import get_logger
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.course import Course, CourseMaterial
|
||||||
|
from app.models.exam import Exam
|
||||||
|
from app.models.practice import PracticeSession
|
||||||
|
from app.models.training import TrainingSession, TrainingReport
|
||||||
|
from app.models.level import UserLevel, ExpHistory, UserBadge
|
||||||
|
from app.models.position import Position
|
||||||
|
from app.models.position_member import PositionMember
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardService:
|
||||||
|
"""数据大屏服务"""
|
||||||
|
|
||||||
|
def __init__(self, db: AsyncSession):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
async def get_enterprise_overview(self, enterprise_id: Optional[int] = None) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
获取企业级数据概览
|
||||||
|
|
||||||
|
Args:
|
||||||
|
enterprise_id: 企业ID(可选,用于多租户)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
企业级数据概览
|
||||||
|
"""
|
||||||
|
today = date.today()
|
||||||
|
week_ago = today - timedelta(days=7)
|
||||||
|
month_ago = today - timedelta(days=30)
|
||||||
|
|
||||||
|
# 基础统计
|
||||||
|
# 1. 总学员数
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(func.count(User.id))
|
||||||
|
.where(User.is_deleted == False, User.role == 'trainee')
|
||||||
|
)
|
||||||
|
total_users = result.scalar() or 0
|
||||||
|
|
||||||
|
# 2. 今日活跃用户(有经验值记录)
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(func.count(func.distinct(ExpHistory.user_id)))
|
||||||
|
.where(func.date(ExpHistory.created_at) == today)
|
||||||
|
)
|
||||||
|
today_active = result.scalar() or 0
|
||||||
|
|
||||||
|
# 3. 本周活跃用户
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(func.count(func.distinct(ExpHistory.user_id)))
|
||||||
|
.where(ExpHistory.created_at >= datetime.combine(week_ago, datetime.min.time()))
|
||||||
|
)
|
||||||
|
week_active = result.scalar() or 0
|
||||||
|
|
||||||
|
# 4. 本月活跃用户
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(func.count(func.distinct(ExpHistory.user_id)))
|
||||||
|
.where(ExpHistory.created_at >= datetime.combine(month_ago, datetime.min.time()))
|
||||||
|
)
|
||||||
|
month_active = result.scalar() or 0
|
||||||
|
|
||||||
|
# 5. 总学习时长(小时)
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(func.coalesce(func.sum(PracticeSession.duration_seconds), 0))
|
||||||
|
.where(PracticeSession.status == 'completed')
|
||||||
|
)
|
||||||
|
practice_hours = (result.scalar() or 0) / 3600
|
||||||
|
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(func.coalesce(func.sum(TrainingSession.duration_seconds), 0))
|
||||||
|
.where(TrainingSession.status == 'COMPLETED')
|
||||||
|
)
|
||||||
|
training_hours = (result.scalar() or 0) / 3600
|
||||||
|
|
||||||
|
total_hours = round(practice_hours + training_hours, 1)
|
||||||
|
|
||||||
|
# 6. 考试统计
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(
|
||||||
|
func.count(Exam.id),
|
||||||
|
func.count(case((Exam.is_passed == True, 1))),
|
||||||
|
func.avg(Exam.score)
|
||||||
|
)
|
||||||
|
.where(Exam.status == 'submitted')
|
||||||
|
)
|
||||||
|
exam_row = result.first()
|
||||||
|
exam_count = exam_row[0] or 0
|
||||||
|
exam_passed = exam_row[1] or 0
|
||||||
|
exam_avg_score = round(exam_row[2] or 0, 1)
|
||||||
|
exam_pass_rate = round(exam_passed / exam_count * 100, 1) if exam_count > 0 else 0
|
||||||
|
|
||||||
|
# 7. 满分人数
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(func.count(func.distinct(Exam.user_id)))
|
||||||
|
.where(Exam.status == 'submitted', Exam.score >= Exam.total_score)
|
||||||
|
)
|
||||||
|
perfect_users = result.scalar() or 0
|
||||||
|
|
||||||
|
# 8. 签到率(今日签到人数/总用户数)
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(func.count(UserLevel.id))
|
||||||
|
.where(func.date(UserLevel.last_login_date) == today)
|
||||||
|
)
|
||||||
|
today_checkin = result.scalar() or 0
|
||||||
|
checkin_rate = round(today_checkin / total_users * 100, 1) if total_users > 0 else 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"overview": {
|
||||||
|
"total_users": total_users,
|
||||||
|
"today_active": today_active,
|
||||||
|
"week_active": week_active,
|
||||||
|
"month_active": month_active,
|
||||||
|
"total_hours": total_hours,
|
||||||
|
"checkin_rate": checkin_rate,
|
||||||
|
},
|
||||||
|
"exam": {
|
||||||
|
"total_count": exam_count,
|
||||||
|
"pass_rate": exam_pass_rate,
|
||||||
|
"avg_score": exam_avg_score,
|
||||||
|
"perfect_users": perfect_users,
|
||||||
|
},
|
||||||
|
"updated_at": datetime.now().isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_department_comparison(self) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
获取部门/团队学习对比数据
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
部门对比列表
|
||||||
|
"""
|
||||||
|
# 获取所有岗位及其成员的学习数据
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(Position)
|
||||||
|
.where(Position.is_deleted == False)
|
||||||
|
.order_by(Position.name)
|
||||||
|
)
|
||||||
|
positions = result.scalars().all()
|
||||||
|
|
||||||
|
departments = []
|
||||||
|
for pos in positions:
|
||||||
|
# 获取该岗位的成员数
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(func.count(PositionMember.id))
|
||||||
|
.where(PositionMember.position_id == pos.id)
|
||||||
|
)
|
||||||
|
member_count = result.scalar() or 0
|
||||||
|
|
||||||
|
if member_count == 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 获取成员ID列表
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(PositionMember.user_id)
|
||||||
|
.where(PositionMember.position_id == pos.id)
|
||||||
|
)
|
||||||
|
member_ids = [row[0] for row in result.all()]
|
||||||
|
|
||||||
|
# 统计该岗位成员的学习数据
|
||||||
|
# 考试通过率
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(
|
||||||
|
func.count(Exam.id),
|
||||||
|
func.count(case((Exam.is_passed == True, 1)))
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
Exam.user_id.in_(member_ids),
|
||||||
|
Exam.status == 'submitted'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
exam_row = result.first()
|
||||||
|
exam_total = exam_row[0] or 0
|
||||||
|
exam_passed = exam_row[1] or 0
|
||||||
|
pass_rate = round(exam_passed / exam_total * 100, 1) if exam_total > 0 else 0
|
||||||
|
|
||||||
|
# 平均学习时长
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(func.coalesce(func.sum(PracticeSession.duration_seconds), 0))
|
||||||
|
.where(
|
||||||
|
PracticeSession.user_id.in_(member_ids),
|
||||||
|
PracticeSession.status == 'completed'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
total_seconds = result.scalar() or 0
|
||||||
|
avg_hours = round(total_seconds / 3600 / member_count, 1) if member_count > 0 else 0
|
||||||
|
|
||||||
|
# 平均等级
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(func.avg(UserLevel.level))
|
||||||
|
.where(UserLevel.user_id.in_(member_ids))
|
||||||
|
)
|
||||||
|
avg_level = round(result.scalar() or 1, 1)
|
||||||
|
|
||||||
|
departments.append({
|
||||||
|
"id": pos.id,
|
||||||
|
"name": pos.name,
|
||||||
|
"member_count": member_count,
|
||||||
|
"pass_rate": pass_rate,
|
||||||
|
"avg_hours": avg_hours,
|
||||||
|
"avg_level": avg_level,
|
||||||
|
})
|
||||||
|
|
||||||
|
# 按通过率排序
|
||||||
|
departments.sort(key=lambda x: x["pass_rate"], reverse=True)
|
||||||
|
|
||||||
|
return departments
|
||||||
|
|
||||||
|
async def get_learning_trend(self, days: int = 7) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
获取学习趋势数据
|
||||||
|
|
||||||
|
Args:
|
||||||
|
days: 统计天数
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
趋势数据
|
||||||
|
"""
|
||||||
|
today = date.today()
|
||||||
|
dates = [(today - timedelta(days=i)) for i in range(days-1, -1, -1)]
|
||||||
|
|
||||||
|
trend_data = []
|
||||||
|
for d in dates:
|
||||||
|
# 当日活跃用户
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(func.count(func.distinct(ExpHistory.user_id)))
|
||||||
|
.where(func.date(ExpHistory.created_at) == d)
|
||||||
|
)
|
||||||
|
active_users = result.scalar() or 0
|
||||||
|
|
||||||
|
# 当日新增学习时长
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(func.coalesce(func.sum(PracticeSession.duration_seconds), 0))
|
||||||
|
.where(
|
||||||
|
func.date(PracticeSession.created_at) == d,
|
||||||
|
PracticeSession.status == 'completed'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
hours = round((result.scalar() or 0) / 3600, 1)
|
||||||
|
|
||||||
|
# 当日考试次数
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(func.count(Exam.id))
|
||||||
|
.where(
|
||||||
|
func.date(Exam.created_at) == d,
|
||||||
|
Exam.status == 'submitted'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
exams = result.scalar() or 0
|
||||||
|
|
||||||
|
trend_data.append({
|
||||||
|
"date": d.isoformat(),
|
||||||
|
"active_users": active_users,
|
||||||
|
"learning_hours": hours,
|
||||||
|
"exam_count": exams,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"dates": [d.isoformat() for d in dates],
|
||||||
|
"trend": trend_data
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_level_distribution(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
获取等级分布数据
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
等级分布
|
||||||
|
"""
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(UserLevel.level, func.count(UserLevel.id))
|
||||||
|
.group_by(UserLevel.level)
|
||||||
|
.order_by(UserLevel.level)
|
||||||
|
)
|
||||||
|
rows = result.all()
|
||||||
|
|
||||||
|
distribution = {row[0]: row[1] for row in rows}
|
||||||
|
|
||||||
|
# 补全1-10级
|
||||||
|
for i in range(1, 11):
|
||||||
|
if i not in distribution:
|
||||||
|
distribution[i] = 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"levels": list(range(1, 11)),
|
||||||
|
"counts": [distribution.get(i, 0) for i in range(1, 11)]
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_realtime_activities(self, limit: int = 20) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
获取实时动态
|
||||||
|
|
||||||
|
Args:
|
||||||
|
limit: 数量限制
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
实时动态列表
|
||||||
|
"""
|
||||||
|
activities = []
|
||||||
|
|
||||||
|
# 获取最近的经验值记录
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(ExpHistory, User)
|
||||||
|
.join(User, ExpHistory.user_id == User.id)
|
||||||
|
.order_by(ExpHistory.created_at.desc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
rows = result.all()
|
||||||
|
|
||||||
|
for exp, user in rows:
|
||||||
|
activity_type = "学习"
|
||||||
|
if "考试" in (exp.description or ""):
|
||||||
|
activity_type = "考试"
|
||||||
|
elif "签到" in (exp.description or ""):
|
||||||
|
activity_type = "签到"
|
||||||
|
elif "陪练" in (exp.description or ""):
|
||||||
|
activity_type = "陪练"
|
||||||
|
elif "奖章" in (exp.description or ""):
|
||||||
|
activity_type = "奖章"
|
||||||
|
|
||||||
|
activities.append({
|
||||||
|
"id": exp.id,
|
||||||
|
"user_id": user.id,
|
||||||
|
"user_name": user.full_name or user.username,
|
||||||
|
"type": activity_type,
|
||||||
|
"description": exp.description,
|
||||||
|
"exp_amount": exp.exp_amount,
|
||||||
|
"created_at": exp.created_at.isoformat() if exp.created_at else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
return activities
|
||||||
|
|
||||||
|
async def get_team_dashboard(self, team_leader_id: int) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
获取团队级数据大屏
|
||||||
|
|
||||||
|
Args:
|
||||||
|
team_leader_id: 团队负责人ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
团队数据
|
||||||
|
"""
|
||||||
|
# 获取团队负责人管理的岗位
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(Position)
|
||||||
|
.where(
|
||||||
|
Position.is_deleted == False,
|
||||||
|
or_(
|
||||||
|
Position.manager_id == team_leader_id,
|
||||||
|
Position.created_by == team_leader_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
positions = result.scalars().all()
|
||||||
|
position_ids = [p.id for p in positions]
|
||||||
|
|
||||||
|
if not position_ids:
|
||||||
|
return {
|
||||||
|
"members": [],
|
||||||
|
"overview": {
|
||||||
|
"total_members": 0,
|
||||||
|
"avg_level": 0,
|
||||||
|
"avg_exp": 0,
|
||||||
|
"total_badges": 0,
|
||||||
|
},
|
||||||
|
"pending_tasks": []
|
||||||
|
}
|
||||||
|
|
||||||
|
# 获取团队成员
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(PositionMember.user_id)
|
||||||
|
.where(PositionMember.position_id.in_(position_ids))
|
||||||
|
)
|
||||||
|
member_ids = [row[0] for row in result.all()]
|
||||||
|
|
||||||
|
if not member_ids:
|
||||||
|
return {
|
||||||
|
"members": [],
|
||||||
|
"overview": {
|
||||||
|
"total_members": 0,
|
||||||
|
"avg_level": 0,
|
||||||
|
"avg_exp": 0,
|
||||||
|
"total_badges": 0,
|
||||||
|
},
|
||||||
|
"pending_tasks": []
|
||||||
|
}
|
||||||
|
|
||||||
|
# 获取成员详细信息
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(User, UserLevel)
|
||||||
|
.outerjoin(UserLevel, User.id == UserLevel.user_id)
|
||||||
|
.where(User.id.in_(member_ids))
|
||||||
|
.order_by(UserLevel.total_exp.desc().nullslast())
|
||||||
|
)
|
||||||
|
rows = result.all()
|
||||||
|
|
||||||
|
members = []
|
||||||
|
total_exp = 0
|
||||||
|
total_level = 0
|
||||||
|
|
||||||
|
for user, level in rows:
|
||||||
|
user_level = level.level if level else 1
|
||||||
|
user_exp = level.total_exp if level else 0
|
||||||
|
total_level += user_level
|
||||||
|
total_exp += user_exp
|
||||||
|
|
||||||
|
# 获取用户奖章数
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(func.count(UserBadge.id))
|
||||||
|
.where(UserBadge.user_id == user.id)
|
||||||
|
)
|
||||||
|
badge_count = result.scalar() or 0
|
||||||
|
|
||||||
|
members.append({
|
||||||
|
"id": user.id,
|
||||||
|
"username": user.username,
|
||||||
|
"full_name": user.full_name,
|
||||||
|
"avatar_url": user.avatar_url,
|
||||||
|
"level": user_level,
|
||||||
|
"total_exp": user_exp,
|
||||||
|
"badge_count": badge_count,
|
||||||
|
})
|
||||||
|
|
||||||
|
total_members = len(members)
|
||||||
|
|
||||||
|
# 获取团队总奖章数
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(func.count(UserBadge.id))
|
||||||
|
.where(UserBadge.user_id.in_(member_ids))
|
||||||
|
)
|
||||||
|
total_badges = result.scalar() or 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"members": members,
|
||||||
|
"overview": {
|
||||||
|
"total_members": total_members,
|
||||||
|
"avg_level": round(total_level / total_members, 1) if total_members > 0 else 0,
|
||||||
|
"avg_exp": round(total_exp / total_members) if total_members > 0 else 0,
|
||||||
|
"total_badges": total_badges,
|
||||||
|
},
|
||||||
|
"positions": [{"id": p.id, "name": p.name} for p in positions]
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_course_ranking(self, limit: int = 10) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
获取课程热度排行
|
||||||
|
|
||||||
|
Args:
|
||||||
|
limit: 数量限制
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
课程排行列表
|
||||||
|
"""
|
||||||
|
# 这里简化实现,实际应该统计课程学习次数
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(Course)
|
||||||
|
.where(Course.is_deleted == False, Course.is_published == True)
|
||||||
|
.order_by(Course.created_at.desc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
courses = result.scalars().all()
|
||||||
|
|
||||||
|
ranking = []
|
||||||
|
for i, course in enumerate(courses, 1):
|
||||||
|
ranking.append({
|
||||||
|
"rank": i,
|
||||||
|
"id": course.id,
|
||||||
|
"name": course.name,
|
||||||
|
"description": course.description,
|
||||||
|
# 这里可以添加实际的学习人数统计
|
||||||
|
"learners": 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
return ranking
|
||||||
166
backend/migrations/add_certificate_system.sql
Normal file
166
backend/migrations/add_certificate_system.sql
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
-- ================================================================
|
||||||
|
-- 证书系统数据库迁移脚本
|
||||||
|
-- 创建日期: 2026-01-29
|
||||||
|
-- 功能: 添加证书模板表和用户证书表
|
||||||
|
-- ================================================================
|
||||||
|
|
||||||
|
-- 事务开始
|
||||||
|
START TRANSACTION;
|
||||||
|
|
||||||
|
-- ================================================================
|
||||||
|
-- 1. 创建证书模板表
|
||||||
|
-- ================================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS certificate_templates (
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
name VARCHAR(100) NOT NULL COMMENT '模板名称',
|
||||||
|
type ENUM('course', 'exam', 'achievement') NOT NULL COMMENT '证书类型: course=课程结业, exam=考试合格, achievement=成就证书',
|
||||||
|
background_url VARCHAR(500) COMMENT '证书背景图URL',
|
||||||
|
template_html TEXT COMMENT 'HTML模板内容',
|
||||||
|
template_style TEXT COMMENT 'CSS样式',
|
||||||
|
is_active BOOLEAN DEFAULT TRUE COMMENT '是否启用',
|
||||||
|
sort_order INT DEFAULT 0 COMMENT '排序顺序',
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_type (type),
|
||||||
|
INDEX idx_is_active (is_active)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='证书模板表';
|
||||||
|
|
||||||
|
-- ================================================================
|
||||||
|
-- 2. 创建用户证书表
|
||||||
|
-- ================================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS user_certificates (
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
user_id INT NOT NULL COMMENT '用户ID',
|
||||||
|
template_id INT NOT NULL COMMENT '模板ID',
|
||||||
|
certificate_no VARCHAR(50) UNIQUE NOT NULL COMMENT '证书编号 KPL-年份-序号',
|
||||||
|
title VARCHAR(200) NOT NULL COMMENT '证书标题',
|
||||||
|
description TEXT COMMENT '证书描述/成就说明',
|
||||||
|
issued_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '颁发时间',
|
||||||
|
valid_until DATETIME COMMENT '有效期至(NULL表示永久)',
|
||||||
|
|
||||||
|
-- 关联信息
|
||||||
|
course_id INT COMMENT '关联课程ID',
|
||||||
|
exam_id INT COMMENT '关联考试ID',
|
||||||
|
badge_id INT COMMENT '关联奖章ID',
|
||||||
|
|
||||||
|
-- 成绩信息
|
||||||
|
score DECIMAL(5,2) COMMENT '考试分数',
|
||||||
|
completion_rate DECIMAL(5,2) COMMENT '完成率',
|
||||||
|
|
||||||
|
-- 生成的文件
|
||||||
|
pdf_url VARCHAR(500) COMMENT 'PDF文件URL',
|
||||||
|
image_url VARCHAR(500) COMMENT '分享图片URL',
|
||||||
|
|
||||||
|
-- 元数据
|
||||||
|
meta_data JSON COMMENT '扩展元数据',
|
||||||
|
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (template_id) REFERENCES certificate_templates(id),
|
||||||
|
INDEX idx_user_id (user_id),
|
||||||
|
INDEX idx_certificate_no (certificate_no),
|
||||||
|
INDEX idx_course_id (course_id),
|
||||||
|
INDEX idx_exam_id (exam_id),
|
||||||
|
INDEX idx_issued_at (issued_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户证书表';
|
||||||
|
|
||||||
|
-- ================================================================
|
||||||
|
-- 3. 插入默认证书模板
|
||||||
|
-- ================================================================
|
||||||
|
INSERT INTO certificate_templates (name, type, template_html, template_style, is_active, sort_order) VALUES
|
||||||
|
-- 课程结业证书模板
|
||||||
|
('课程结业证书', 'course',
|
||||||
|
'<div class="certificate">
|
||||||
|
<div class="header">
|
||||||
|
<div class="logo">考培练系统</div>
|
||||||
|
<h1>结业证书</h1>
|
||||||
|
</div>
|
||||||
|
<div class="body">
|
||||||
|
<p class="recipient">兹证明 <strong>{{user_name}}</strong></p>
|
||||||
|
<p class="content">已完成课程《{{course_name}}》的全部学习内容</p>
|
||||||
|
<p class="completion">完成率:{{completion_rate}}%</p>
|
||||||
|
<p class="date">颁发日期:{{issue_date}}</p>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<div class="cert-no">证书编号:{{certificate_no}}</div>
|
||||||
|
<div class="qrcode">{{qrcode}}</div>
|
||||||
|
</div>
|
||||||
|
</div>',
|
||||||
|
'.certificate { width: 800px; height: 600px; padding: 40px; background: linear-gradient(135deg, #f5f7fa 0%, #e4e9f2 100%); font-family: "Microsoft YaHei", sans-serif; }
|
||||||
|
.header { text-align: center; margin-bottom: 30px; }
|
||||||
|
.header .logo { font-size: 24px; color: #667eea; font-weight: bold; }
|
||||||
|
.header h1 { font-size: 36px; color: #333; margin: 20px 0; }
|
||||||
|
.body { text-align: center; padding: 30px 60px; }
|
||||||
|
.body .recipient { font-size: 20px; margin-bottom: 20px; }
|
||||||
|
.body .content { font-size: 18px; color: #555; margin-bottom: 15px; }
|
||||||
|
.body .completion { font-size: 16px; color: #667eea; }
|
||||||
|
.body .date { font-size: 14px; color: #888; margin-top: 30px; }
|
||||||
|
.footer { display: flex; justify-content: space-between; align-items: center; margin-top: 40px; }
|
||||||
|
.cert-no { font-size: 12px; color: #999; }
|
||||||
|
.qrcode { width: 80px; height: 80px; }',
|
||||||
|
TRUE, 1),
|
||||||
|
|
||||||
|
-- 考试合格证书模板
|
||||||
|
('考试合格证书', 'exam',
|
||||||
|
'<div class="certificate exam-cert">
|
||||||
|
<div class="header">
|
||||||
|
<div class="logo">考培练系统</div>
|
||||||
|
<h1>考试合格证书</h1>
|
||||||
|
</div>
|
||||||
|
<div class="body">
|
||||||
|
<p class="recipient">兹证明 <strong>{{user_name}}</strong></p>
|
||||||
|
<p class="content">在《{{exam_name}}》考试中成绩合格</p>
|
||||||
|
<div class="score-badge">
|
||||||
|
<span class="score">{{score}}</span>
|
||||||
|
<span class="unit">分</span>
|
||||||
|
</div>
|
||||||
|
<p class="date">考试日期:{{exam_date}}</p>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<div class="cert-no">证书编号:{{certificate_no}}</div>
|
||||||
|
<div class="qrcode">{{qrcode}}</div>
|
||||||
|
</div>
|
||||||
|
</div>',
|
||||||
|
'.certificate.exam-cert { width: 800px; height: 600px; padding: 40px; background: linear-gradient(135deg, #e8f5e9 0%, #c8e6c9 100%); font-family: "Microsoft YaHei", sans-serif; }
|
||||||
|
.exam-cert .header h1 { color: #2e7d32; }
|
||||||
|
.score-badge { display: inline-block; padding: 20px 40px; background: #fff; border-radius: 50%; box-shadow: 0 4px 20px rgba(0,0,0,0.1); margin: 20px 0; }
|
||||||
|
.score-badge .score { font-size: 48px; font-weight: bold; color: #2e7d32; }
|
||||||
|
.score-badge .unit { font-size: 18px; color: #666; }',
|
||||||
|
TRUE, 2),
|
||||||
|
|
||||||
|
-- 成就证书模板
|
||||||
|
('成就证书', 'achievement',
|
||||||
|
'<div class="certificate achievement-cert">
|
||||||
|
<div class="header">
|
||||||
|
<div class="logo">考培练系统</div>
|
||||||
|
<h1>成就证书</h1>
|
||||||
|
</div>
|
||||||
|
<div class="body">
|
||||||
|
<p class="recipient">兹证明 <strong>{{user_name}}</strong></p>
|
||||||
|
<div class="achievement-icon">{{badge_icon}}</div>
|
||||||
|
<p class="achievement-name">{{badge_name}}</p>
|
||||||
|
<p class="achievement-desc">{{badge_description}}</p>
|
||||||
|
<p class="date">获得日期:{{achieve_date}}</p>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<div class="cert-no">证书编号:{{certificate_no}}</div>
|
||||||
|
<div class="qrcode">{{qrcode}}</div>
|
||||||
|
</div>
|
||||||
|
</div>',
|
||||||
|
'.certificate.achievement-cert { width: 800px; height: 600px; padding: 40px; background: linear-gradient(135deg, #fff3e0 0%, #ffe0b2 100%); font-family: "Microsoft YaHei", sans-serif; }
|
||||||
|
.achievement-cert .header h1 { color: #e65100; }
|
||||||
|
.achievement-icon { font-size: 64px; margin: 20px 0; }
|
||||||
|
.achievement-name { font-size: 24px; font-weight: bold; color: #e65100; margin: 10px 0; }
|
||||||
|
.achievement-desc { font-size: 16px; color: #666; }',
|
||||||
|
TRUE, 3);
|
||||||
|
|
||||||
|
-- 提交事务
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
-- ================================================================
|
||||||
|
-- 验证脚本
|
||||||
|
-- ================================================================
|
||||||
|
-- SELECT * FROM certificate_templates;
|
||||||
|
-- SELECT COUNT(*) AS template_count FROM certificate_templates;
|
||||||
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 interface DashboardOverview {
|
||||||
*/
|
overview: {
|
||||||
export function getUserStatistics() {
|
total_users: number
|
||||||
return request.get('/api/v1/users/me/statistics')
|
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) {
|
export function getEnterpriseOverview() {
|
||||||
return request.get('/api/v1/users/me/recent-exams', { limit })
|
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'),
|
component: () => import('@/views/trainee/leaderboard.vue'),
|
||||||
meta: { title: '等级排行榜', icon: 'Trophy' }
|
meta: { title: '等级排行榜', icon: 'Trophy' }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'my-certificates',
|
||||||
|
name: 'MyCertificates',
|
||||||
|
component: () => import('@/views/trainee/my-certificates.vue'),
|
||||||
|
meta: { title: '我的证书', icon: 'Medal' }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'course-center',
|
path: 'course-center',
|
||||||
name: 'CourseCenter',
|
name: 'CourseCenter',
|
||||||
@@ -165,6 +171,12 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import('@/views/manager/team-dashboard.vue'),
|
component: () => import('@/views/manager/team-dashboard.vue'),
|
||||||
meta: { title: '团队看板', icon: 'DataLine' }
|
meta: { title: '团队看板', icon: 'DataLine' }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'data-dashboard',
|
||||||
|
name: 'DataDashboard',
|
||||||
|
component: () => import('@/views/admin/data-dashboard.vue'),
|
||||||
|
meta: { title: '数据大屏', icon: 'Monitor' }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'team-management',
|
path: 'team-management',
|
||||||
name: 'TeamManagement',
|
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>
|
</style>
|
||||||
@@ -532,7 +532,7 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 响应式
|
// 响应式 - 平板
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.login-container {
|
.login-container {
|
||||||
padding: 20px;
|
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>
|
</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>
|
</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>
|
</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>
|
</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>
|
</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