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

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

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

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

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

188
CHANGELOG-2026-01-29.md Normal file
View 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 表)
- [ ] 数据大屏数据缓存优化
- [ ] 钉钉环境下底部导航适配

View File

@@ -110,5 +110,11 @@ api_router.include_router(system_settings_router, prefix="/settings", tags=["sys
# level_router 等级与奖章路由
from .endpoints.level import router as level_router
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"]

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

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

View File

@@ -27,6 +27,11 @@ from app.models.level import (
BadgeCategory,
ConditionType,
)
from app.models.certificate import (
CertificateTemplate,
UserCertificate,
CertificateType,
)
__all__ = [
"Base",
@@ -64,4 +69,7 @@ __all__ = [
"ExpType",
"BadgeCategory",
"ConditionType",
"CertificateTemplate",
"UserCertificate",
"CertificateType",
]

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

View File

@@ -10,7 +10,7 @@
from datetime import datetime
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 app.core.logger import get_logger
@@ -162,80 +162,102 @@ class BadgeService:
"user_level": 1,
}
# 获取用户等级信息
result = await self.db.execute(
select(UserLevel).where(UserLevel.user_id == user_id)
)
user_level = result.scalar_one_or_none()
if user_level:
stats["login_streak"] = user_level.login_streak
stats["user_level"] = user_level.level
# 获取登录次数(从经验值历史)
result = await self.db.execute(
select(func.count(ExpHistory.id))
.where(
ExpHistory.user_id == user_id,
ExpHistory.exp_type == ExpType.LOGIN
try:
# 获取用户等级信息
result = await self.db.execute(
select(UserLevel).where(UserLevel.user_id == user_id)
)
)
stats["login_count"] = result.scalar() or 0
# 获取考试统计
result = await self.db.execute(
select(
func.count(Exam.id),
func.sum(func.if_(Exam.score >= 100, 1, 0)),
func.sum(func.if_(Exam.score >= 90, 1, 0))
user_level = result.scalar_one_or_none()
if user_level:
stats["login_streak"] = user_level.login_streak or 0
stats["user_level"] = user_level.level or 1
# 获取登录/签到次数(从经验值历史)
result = await self.db.execute(
select(func.count(ExpHistory.id))
.where(
ExpHistory.user_id == user_id,
ExpHistory.exp_type == ExpType.LOGIN
)
)
.where(
Exam.user_id == user_id,
Exam.is_passed == True,
Exam.status == "submitted"
stats["login_count"] = result.scalar() or 0
# 获取考试统计 - 使用 case 语句
# 通过考试数量
result = await self.db.execute(
select(func.count(Exam.id))
.where(
Exam.user_id == user_id,
Exam.is_passed == True,
Exam.status == "submitted"
)
)
)
row = result.first()
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)
# 获取练习统计
result = await self.db.execute(
select(
func.count(PracticeSession.id),
func.sum(PracticeSession.duration_seconds)
stats["exam_passed"] = result.scalar() 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
)
)
.where(
PracticeSession.user_id == user_id,
PracticeSession.status == "completed"
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
)
)
)
row = result.first()
if row:
stats["practice_count"] = row[0] or 0
total_seconds = row[1] or 0
stats["practice_hours"] = total_seconds / 3600
# 获取陪练统计
result = await self.db.execute(
select(func.count(TrainingSession.id))
.where(
TrainingSession.user_id == user_id,
TrainingSession.status == "COMPLETED"
stats["exam_excellent"] = result.scalar() or 0
# 获取练习统计PracticeSession - AI 陪练)
result = await self.db.execute(
select(
func.count(PracticeSession.id),
func.coalesce(func.sum(PracticeSession.duration_seconds), 0)
)
.where(
PracticeSession.user_id == user_id,
PracticeSession.status == "completed"
)
)
)
stats["training_count"] = result.scalar() or 0
# 检查首次高分陪练
result = await self.db.execute(
select(func.count(TrainingReport.id))
.where(
TrainingReport.user_id == user_id,
TrainingReport.overall_score >= 90
row = result.first()
if row:
stats["practice_count"] = row[0] or 0
total_seconds = row[1] or 0
stats["practice_hours"] = float(total_seconds) / 3600.0
# 获取培训/陪练统计TrainingSession
result = await self.db.execute(
select(func.count(TrainingSession.id))
.where(
TrainingSession.user_id == user_id,
TrainingSession.status == "COMPLETED"
)
)
)
stats["first_practice_90"] = 1 if (result.scalar() or 0) > 0 else 0
stats["training_count"] = result.scalar() or 0
# 检查是否有高分陪练90分以上
result = await self.db.execute(
select(func.count(TrainingReport.id))
.where(
TrainingReport.user_id == user_id,
TrainingReport.overall_score >= 90
)
)
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
@@ -465,3 +487,100 @@ class BadgeService:
await self.db.execute(query)
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])

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

View 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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -911,4 +911,128 @@ const loadMore = () => {
}
}
}
// 手机端深度优化
@media (max-width: 480px) {
.course-center-container {
padding: 0 12px;
.page-header {
margin-bottom: 20px;
.page-title {
font-size: 22px;
text-align: center;
}
}
.category-filter {
margin-bottom: 16px;
margin-left: -12px;
margin-right: -12px;
padding: 0 12px;
.el-radio-group {
:deep(.el-radio-button__inner) {
padding: 6px 12px;
font-size: 12px;
white-space: nowrap;
}
}
}
.course-grid {
gap: 12px;
.course-card {
border-radius: 12px;
.card-body {
padding: 14px;
.card-header-info {
margin-bottom: 10px;
.badge {
padding: 3px 8px;
font-size: 10px;
}
.progress-badge .progress-text {
padding: 3px 8px;
font-size: 11px;
}
}
.course-title {
font-size: 16px;
margin-bottom: 6px;
}
.course-description {
font-size: 12px;
margin-bottom: 12px;
-webkit-line-clamp: 2;
}
.course-stats {
gap: 12px;
margin-bottom: 12px;
.stat-item {
font-size: 11px;
.el-icon {
font-size: 12px;
}
}
}
}
.card-footer {
padding: 0 14px 14px;
gap: 10px;
.action-btn.primary-btn {
height: 44px;
font-size: 15px;
border-radius: 8px;
}
.secondary-actions {
grid-template-columns: repeat(3, 1fr);
gap: 6px;
.action-btn {
padding: 10px 4px;
&.secondary-btn,
&.exam-btn,
&.practice-btn {
font-size: 11px;
.el-icon {
font-size: 20px;
}
}
}
}
}
}
}
.empty-state {
padding: 60px 16px;
}
.load-more {
margin-bottom: 40px;
.el-button {
width: 100%;
max-width: 280px;
}
}
}
}
</style>

View File

@@ -1297,4 +1297,178 @@ onUnmounted(() => {
}
}
}
// 手机端深度优化
@media (max-width: 480px) {
.course-detail-container {
padding: 8px;
.course-header {
padding: 14px;
border-radius: 12px;
margin-bottom: 10px;
.header-content {
.breadcrumb {
margin-bottom: 12px;
:deep(.el-breadcrumb) {
font-size: 12px;
}
}
.course-title {
font-size: 18px;
margin-bottom: 8px;
}
.course-desc {
font-size: 13px;
-webkit-line-clamp: 2;
}
.course-meta {
gap: 8px;
.meta-item {
font-size: 12px;
padding: 4px 8px;
}
}
.progress-section {
margin-top: 12px;
.progress-info {
font-size: 13px;
}
}
}
}
.course-content {
gap: 10px;
.content-sidebar {
padding: 10px;
max-height: 200px;
border-radius: 12px;
.sidebar-header {
flex-direction: column;
gap: 10px;
margin-bottom: 10px;
.sidebar-title {
font-size: 15px;
}
.el-input {
width: 100% !important;
}
}
.file-type-filter {
margin-bottom: 10px;
overflow-x: auto;
.el-radio-group {
flex-wrap: nowrap;
:deep(.el-radio-button__inner) {
padding: 4px 10px;
font-size: 12px;
}
}
}
.material-list {
.material-item {
padding: 8px 10px;
.material-info {
.material-name {
font-size: 13px;
}
.material-meta {
font-size: 11px;
}
}
}
}
}
.content-main {
padding: 10px;
border-radius: 12px;
.empty-state {
padding: 40px 20px;
p {
font-size: 13px;
}
}
.preview-container {
min-height: 300px;
.preview-toolbar {
gap: 8px;
margin-bottom: 10px;
.toolbar-left {
.preview-title {
font-size: 14px;
}
}
.toolbar-right {
.el-button {
padding: 8px 12px;
font-size: 12px;
}
}
}
.preview-content {
.pdf-viewer-container {
.pdf-toolbar {
flex-wrap: wrap;
gap: 8px;
padding: 8px;
.page-controls,
.zoom-controls {
gap: 4px;
.page-info,
.zoom-info {
font-size: 12px;
min-width: 50px;
}
}
}
}
// 视频自适应
.video-player {
video {
max-height: 50vh;
}
}
// 图片自适应
.image-preview {
img {
max-height: 60vh;
}
}
}
}
}
}
}
}
</style>

View File

@@ -2199,4 +2199,203 @@ onUnmounted(() => {
}
}
}
// 手机端深度优化
@media (max-width: 480px) {
.growth-path-container {
padding: 12px;
.personal-info {
padding: 16px;
border-radius: 12px;
.info-left {
.el-avatar {
width: 64px !important;
height: 64px !important;
}
.info-content {
.user-name {
font-size: 18px;
}
.user-meta {
font-size: 12px;
gap: 6px;
.separator {
margin: 0 4px;
}
}
}
}
.info-right {
.el-button {
width: 100%;
}
}
}
.main-content {
gap: 16px;
margin-top: 16px;
.card {
padding: 14px;
border-radius: 12px;
.card-header {
flex-direction: column;
gap: 12px;
margin-bottom: 16px;
.card-title {
font-size: 16px;
}
.el-button {
width: 100%;
}
}
}
.ability-radar {
.radar-chart {
height: 260px;
}
.ability-feedback {
.feedback-item {
padding: 12px;
.feedback-header-row {
.dimension-name {
font-size: 14px;
}
.dimension-score {
font-size: 13px;
}
}
.feedback-text {
font-size: 12px;
line-height: 1.6;
}
}
}
}
}
.ai-learning-hub-inner {
padding: 16px;
border-radius: 12px;
.hub-header {
.ai-avatar {
width: 48px;
height: 48px;
}
.header-text {
.hub-title {
font-size: 17px;
}
.hub-subtitle {
font-size: 12px;
}
}
.header-actions {
width: 100%;
.refresh-btn {
width: 100%;
}
}
}
.recommendation-stats {
.stat-card {
padding: 12px;
.stat-content {
.stat-number {
font-size: 20px;
}
.stat-label {
font-size: 11px;
}
}
}
}
.recommendations-section {
.section-header {
.section-title {
font-size: 15px;
}
.filter-tabs {
width: 100%;
overflow-x: auto;
.el-radio-group {
flex-wrap: nowrap;
:deep(.el-radio-button__inner) {
padding: 6px 12px;
font-size: 12px;
}
}
}
}
.course-grid {
gap: 14px;
.smart-course-card {
padding: 14px;
border-radius: 12px;
.card-content {
.course-header {
.course-name {
font-size: 15px;
}
}
.course-reason {
font-size: 12px;
}
.course-meta {
gap: 6px;
.meta-tag {
font-size: 11px;
padding: 3px 8px;
}
}
.action-row {
flex-direction: column;
gap: 10px;
.improvement-badge,
.el-button {
width: 100%;
}
}
}
}
}
}
}
}
}
</style>

View File

@@ -488,4 +488,134 @@ onMounted(() => {
}
}
}
// 手机端深度优化
@media (max-width: 480px) {
.leaderboard-page {
padding: 12px;
.page-header {
margin-bottom: 16px;
h2 {
font-size: 20px;
text-align: center;
}
}
.my-rank-card {
padding: 16px;
border-radius: 12px;
gap: 16px;
.rank-badge {
.rank-number {
font-size: 28px;
}
.rank-label {
font-size: 11px;
}
}
.my-info {
gap: 10px;
.level-badge {
width: 44px;
height: 44px;
.level-number {
font-size: 18px;
}
}
.my-details {
.my-title {
font-size: 15px;
}
.my-exp {
font-size: 12px;
}
}
}
.checkin-section {
width: 100%;
.el-button {
width: 100%;
min-height: 44px;
font-size: 15px;
}
.streak-info {
font-size: 12px;
margin-top: 8px;
}
}
}
.leaderboard-list {
gap: 10px;
margin-bottom: 16px;
.leaderboard-item {
padding: 12px;
border-radius: 10px;
gap: 10px;
.rank-section {
width: 40px;
.rank-icon {
width: 28px;
height: 28px;
font-size: 12px;
}
}
.user-section {
gap: 10px;
.el-avatar {
width: 36px !important;
height: 36px !important;
}
.user-info {
.user-name {
font-size: 14px;
}
.user-title {
font-size: 12px;
}
}
}
.stats-section {
gap: 16px;
padding-top: 10px;
margin-top: 10px;
.stat-item {
.stat-value {
font-size: 14px;
}
.stat-label {
font-size: 11px;
}
}
}
}
}
.load-more {
padding: 12px 0 20px;
}
}
}
</style>

View File

@@ -0,0 +1,735 @@
<template>
<div class="certificates-page">
<!-- 页面标题 -->
<div class="page-header">
<h2>我的证书</h2>
<p class="subtitle">记录您的学习成就与荣誉</p>
</div>
<!-- 统计卡片 -->
<div class="stats-cards">
<div class="stat-card" v-for="stat in stats" :key="stat.type">
<div class="stat-icon" :class="stat.type">
<el-icon><component :is="stat.icon" /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ stat.count }}</div>
<div class="stat-label">{{ stat.label }}</div>
</div>
</div>
</div>
<!-- 筛选区 -->
<div class="filter-bar">
<el-radio-group v-model="filterType" @change="handleFilterChange">
<el-radio-button label="">全部</el-radio-button>
<el-radio-button label="course">课程证书</el-radio-button>
<el-radio-button label="exam">考试证书</el-radio-button>
<el-radio-button label="achievement">成就证书</el-radio-button>
</el-radio-group>
</div>
<!-- 证书列表 -->
<div class="certificates-list" v-loading="loading">
<div
v-for="cert in certificates"
:key="cert.id"
class="certificate-card"
:class="cert.type"
@click="viewCertificate(cert)"
>
<div class="cert-header">
<div class="cert-type-badge" :class="cert.type">
{{ cert.type_name }}
</div>
<div class="cert-date">{{ formatDate(cert.issued_at) }}</div>
</div>
<div class="cert-body">
<h3 class="cert-title">{{ cert.title }}</h3>
<p class="cert-description">{{ cert.description }}</p>
<div class="cert-info" v-if="cert.score || cert.completion_rate">
<span v-if="cert.score" class="info-item">
<el-icon><Trophy /></el-icon>
成绩{{ cert.score }}
</span>
<span v-if="cert.completion_rate" class="info-item">
<el-icon><Select /></el-icon>
完成率{{ cert.completion_rate }}%
</span>
</div>
</div>
<div class="cert-footer">
<span class="cert-no">{{ cert.certificate_no }}</span>
<div class="cert-actions">
<el-button text type="primary" @click.stop="shareCertificate(cert)">
<el-icon><Share /></el-icon>
分享
</el-button>
<el-button text type="primary" @click.stop="downloadCertificate(cert)">
<el-icon><Download /></el-icon>
下载
</el-button>
</div>
</div>
</div>
<!-- 空状态 -->
<el-empty
v-if="!loading && certificates.length === 0"
description="暂无证书"
:image-size="120"
>
<template #description>
<p>完成课程或考试后即可获得证书</p>
</template>
</el-empty>
</div>
<!-- 加载更多 -->
<div class="load-more" v-if="hasMore && certificates.length > 0">
<el-button text @click="loadMore" :loading="loadingMore">
加载更多
</el-button>
</div>
<!-- 证书预览弹窗 -->
<el-dialog
v-model="previewVisible"
:title="currentCert?.title || '证书详情'"
width="600px"
class="certificate-preview-dialog"
>
<div class="preview-content" v-if="currentCert">
<div class="preview-image">
<img
v-if="previewImageUrl"
:src="previewImageUrl"
alt="证书图片"
@error="handleImageError"
/>
<div v-else class="preview-placeholder">
<el-icon :size="48"><Document /></el-icon>
<p>正在生成证书图片...</p>
</div>
</div>
<div class="preview-info">
<div class="info-row">
<span class="label">证书编号</span>
<span class="value">{{ currentCert.certificate_no }}</span>
</div>
<div class="info-row">
<span class="label">证书类型</span>
<span class="value">{{ currentCert.type_name }}</span>
</div>
<div class="info-row">
<span class="label">颁发日期</span>
<span class="value">{{ formatDate(currentCert.issued_at) }}</span>
</div>
<div class="info-row" v-if="currentCert.score">
<span class="label">考试成绩</span>
<span class="value highlight">{{ currentCert.score }}</span>
</div>
<div class="info-row" v-if="currentCert.completion_rate">
<span class="label">完成率</span>
<span class="value highlight">{{ currentCert.completion_rate }}%</span>
</div>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="previewVisible = false">关闭</el-button>
<el-button type="primary" @click="shareCertificate(currentCert!)">
<el-icon><Share /></el-icon>
分享
</el-button>
<el-button type="success" @click="downloadCertificate(currentCert!)">
<el-icon><Download /></el-icon>
下载
</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import {
Trophy, Select, Share, Download, Document,
Reading, Medal, Star
} from '@element-plus/icons-vue'
import {
getMyCertificates,
getCertificateImageUrl,
getCertificateDownloadUrl,
type Certificate,
type CertificateType
} from '@/api/certificate'
// 状态
const loading = ref(false)
const loadingMore = ref(false)
const certificates = ref<Certificate[]>([])
const total = ref(0)
const offset = ref(0)
const limit = 12
const filterType = ref<CertificateType | ''>('')
// 预览弹窗
const previewVisible = ref(false)
const currentCert = ref<Certificate | null>(null)
const previewImageUrl = ref('')
// 统计数据
const stats = computed(() => {
const allCerts = certificates.value
return [
{
type: 'total',
icon: 'Medal',
label: '全部证书',
count: total.value
},
{
type: 'course',
icon: 'Reading',
label: '课程证书',
count: allCerts.filter(c => c.type === 'course').length
},
{
type: 'exam',
icon: 'Trophy',
label: '考试证书',
count: allCerts.filter(c => c.type === 'exam').length
},
{
type: 'achievement',
icon: 'Star',
label: '成就证书',
count: allCerts.filter(c => c.type === 'achievement').length
}
]
})
// 是否有更多
const hasMore = computed(() => offset.value + limit < total.value)
// 获取证书列表
const fetchCertificates = async (append = false) => {
if (append) {
loadingMore.value = true
} else {
loading.value = true
offset.value = 0
}
try {
const res = await getMyCertificates({
cert_type: filterType.value || undefined,
offset: offset.value,
limit
})
if (res.code === 200 && res.data) {
if (append) {
certificates.value.push(...res.data.items)
} else {
certificates.value = res.data.items
}
total.value = res.data.total
}
} catch (error) {
console.error('获取证书列表失败:', error)
ElMessage.error('获取证书列表失败')
} finally {
loading.value = false
loadingMore.value = false
}
}
// 筛选变化
const handleFilterChange = () => {
fetchCertificates()
}
// 加载更多
const loadMore = () => {
offset.value += limit
fetchCertificates(true)
}
// 查看证书
const viewCertificate = (cert: Certificate) => {
currentCert.value = cert
previewImageUrl.value = getCertificateImageUrl(cert.id)
previewVisible.value = true
}
// 分享证书
const shareCertificate = async (cert: Certificate) => {
const shareUrl = `${window.location.origin}/certificates/verify/${cert.certificate_no}`
try {
await navigator.clipboard.writeText(shareUrl)
ElMessage.success('证书链接已复制到剪贴板')
} catch (e) {
// 如果剪贴板API不可用显示链接让用户手动复制
ElMessage({
message: `请手动复制链接: ${shareUrl}`,
type: 'info',
duration: 5000
})
}
}
// 下载证书
const downloadCertificate = (cert: Certificate) => {
const downloadUrl = getCertificateDownloadUrl(cert.id)
// 创建隐藏的 a 标签进行下载
const link = document.createElement('a')
link.href = downloadUrl
link.download = `certificate_${cert.certificate_no}.png`
link.target = '_blank'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
ElMessage.success('证书下载中...')
}
// 图片加载失败
const handleImageError = () => {
previewImageUrl.value = ''
}
// 格式化日期
const formatDate = (dateStr: string) => {
if (!dateStr) return ''
const date = new Date(dateStr)
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
}
// 初始化
onMounted(() => {
fetchCertificates()
})
</script>
<style lang="scss" scoped>
.certificates-page {
max-width: 1200px;
margin: 0 auto;
padding: 24px;
}
.page-header {
margin-bottom: 32px;
h2 {
font-size: 28px;
font-weight: 600;
color: #303133;
margin-bottom: 8px;
}
.subtitle {
color: #909399;
font-size: 14px;
}
}
.stats-cards {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 24px;
.stat-card {
background: #fff;
border-radius: 12px;
padding: 20px;
display: flex;
align-items: center;
gap: 16px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05);
.stat-icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
.el-icon {
font-size: 24px;
color: #fff;
}
&.total {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
&.course {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
}
&.exam {
background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);
}
&.achievement {
background: linear-gradient(135deg, #fa709a 0%, #fee140 100%);
}
}
.stat-info {
.stat-value {
font-size: 24px;
font-weight: 600;
color: #303133;
}
.stat-label {
font-size: 13px;
color: #909399;
margin-top: 4px;
}
}
}
}
.filter-bar {
margin-bottom: 24px;
:deep(.el-radio-button__inner) {
border-radius: 20px;
padding: 8px 20px;
border: none;
background: #f5f7fa;
&:hover {
color: #667eea;
}
}
:deep(.el-radio-button__original-radio:checked + .el-radio-button__inner) {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
}
}
.certificates-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 20px;
min-height: 200px;
}
.certificate-card {
background: #fff;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
cursor: pointer;
transition: all 0.3s ease;
&:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
}
.cert-header {
padding: 16px 20px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #f0f0f0;
.cert-type-badge {
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
&.course {
background: #e6f7ff;
color: #1890ff;
}
&.exam {
background: #f6ffed;
color: #52c41a;
}
&.achievement {
background: #fff7e6;
color: #fa8c16;
}
}
.cert-date {
font-size: 12px;
color: #909399;
}
}
.cert-body {
padding: 20px;
.cert-title {
font-size: 16px;
font-weight: 600;
color: #303133;
margin-bottom: 8px;
line-height: 1.4;
}
.cert-description {
font-size: 13px;
color: #606266;
line-height: 1.5;
margin-bottom: 12px;
}
.cert-info {
display: flex;
gap: 16px;
.info-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
color: #667eea;
font-weight: 500;
.el-icon {
font-size: 14px;
}
}
}
}
.cert-footer {
padding: 12px 20px;
background: #fafafa;
display: flex;
justify-content: space-between;
align-items: center;
.cert-no {
font-size: 11px;
color: #909399;
font-family: monospace;
}
.cert-actions {
display: flex;
gap: 8px;
.el-button {
padding: 4px 8px;
font-size: 12px;
}
}
}
}
.load-more {
text-align: center;
padding: 24px 0;
}
// 预览弹窗样式
.certificate-preview-dialog {
:deep(.el-dialog__body) {
padding: 0;
}
.preview-content {
.preview-image {
background: #f5f7fa;
padding: 20px;
text-align: center;
min-height: 300px;
display: flex;
align-items: center;
justify-content: center;
img {
max-width: 100%;
max-height: 400px;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
}
.preview-placeholder {
color: #909399;
.el-icon {
margin-bottom: 12px;
}
}
}
.preview-info {
padding: 20px;
.info-row {
display: flex;
justify-content: space-between;
padding: 10px 0;
border-bottom: 1px solid #f0f0f0;
&:last-child {
border-bottom: none;
}
.label {
color: #909399;
font-size: 14px;
}
.value {
color: #303133;
font-size: 14px;
font-weight: 500;
&.highlight {
color: #667eea;
}
}
}
}
}
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
// 响应式
@media (max-width: 768px) {
.certificates-page {
padding: 16px;
}
.page-header {
text-align: center;
h2 {
font-size: 22px;
}
}
.stats-cards {
grid-template-columns: repeat(2, 1fr);
gap: 12px;
.stat-card {
padding: 14px;
.stat-icon {
width: 40px;
height: 40px;
.el-icon {
font-size: 20px;
}
}
.stat-info {
.stat-value {
font-size: 20px;
}
.stat-label {
font-size: 12px;
}
}
}
}
.filter-bar {
overflow-x: auto;
.el-radio-group {
display: flex;
flex-wrap: nowrap;
}
:deep(.el-radio-button__inner) {
padding: 6px 14px;
font-size: 13px;
white-space: nowrap;
}
}
.certificates-list {
grid-template-columns: 1fr;
gap: 14px;
}
.certificate-card {
.cert-body {
padding: 16px;
.cert-title {
font-size: 15px;
}
}
.cert-footer {
flex-direction: column;
gap: 10px;
.cert-actions {
width: 100%;
justify-content: flex-end;
}
}
}
}
@media (max-width: 480px) {
.stats-cards {
grid-template-columns: 1fr 1fr;
.stat-card {
flex-direction: column;
text-align: center;
gap: 10px;
}
}
.certificate-preview-dialog {
:deep(.el-dialog) {
width: 95% !important;
margin: 5vh auto !important;
}
.dialog-footer {
flex-direction: column;
.el-button {
width: 100%;
}
}
}
}
</style>
</template>