1. 课程学习进度追踪
- 新增 UserCourseProgress 和 UserMaterialProgress 模型
- 新增 /api/v1/progress/* 进度追踪 API
- 更新 admin.py 使用真实课程完成率数据
2. 路由权限检查完善
- 新增前端 permissionChecker.ts 权限检查工具
- 更新 router/guard.ts 实现团队和课程权限验证
- 新增后端 permission_service.py
3. AI 陪练音频转文本
- 新增 speech_recognition.py 语音识别服务
- 新增 /api/v1/speech/* API
- 更新 ai-practice-coze.vue 支持语音输入
4. 双人对练报告生成
- 更新 practice_room_service.py 添加报告生成功能
- 新增 /rooms/{room_code}/report API
- 更新 duo-practice-report.vue 调用真实 API
5. 学习提醒推送
- 新增 notification_service.py 通知服务
- 新增 scheduler_service.py 定时任务服务
- 支持钉钉、企微、站内消息推送
6. 智能学习推荐
- 新增 recommendation_service.py 推荐服务
- 新增 /api/v1/recommendations/* API
- 支持错题、能力、进度、热门多维度推荐
7. 安全问题修复
- DEBUG 默认值改为 False
- 添加 SECRET_KEY 安全警告
- 新增 check_security_settings() 检查函数
8. 证书 PDF 生成
- 更新 certificate_service.py 添加 PDF 生成
- 添加 weasyprint、Pillow、qrcode 依赖
- 更新下载 API 支持 PDF 和 PNG 格式
This commit is contained in:
@@ -1,297 +1,297 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
:show-close="false"
|
||||
:close-on-click-modal="false"
|
||||
width="360px"
|
||||
center
|
||||
class="level-up-dialog"
|
||||
>
|
||||
<div class="dialog-content">
|
||||
<!-- 等级升级 -->
|
||||
<div class="level-up-section" v-if="leveledUp">
|
||||
<div class="celebration">
|
||||
<div class="firework"></div>
|
||||
<div class="firework"></div>
|
||||
<div class="firework"></div>
|
||||
</div>
|
||||
<div class="level-badge-large">
|
||||
<span class="level-number">{{ newLevel }}</span>
|
||||
</div>
|
||||
<div class="congrats-text">恭喜升级!</div>
|
||||
<div class="level-title" :style="{ color: levelColor }">{{ levelTitle }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 经验值获得 -->
|
||||
<div class="exp-section" v-if="expGained > 0 && !leveledUp">
|
||||
<el-icon class="exp-icon" :size="48"><TrendCharts /></el-icon>
|
||||
<div class="exp-text">经验值 +{{ expGained }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 新获得奖章 -->
|
||||
<div class="badges-section" v-if="newBadges && newBadges.length > 0">
|
||||
<div class="section-title">新解锁奖章</div>
|
||||
<div class="badges-list">
|
||||
<div
|
||||
class="badge-item"
|
||||
v-for="badge in newBadges"
|
||||
:key="badge.code"
|
||||
>
|
||||
<div class="badge-icon">
|
||||
<el-icon :size="24">
|
||||
<component :is="getIconComponent(badge.icon)" />
|
||||
</el-icon>
|
||||
</div>
|
||||
<div class="badge-name">{{ badge.name }}</div>
|
||||
<div class="badge-reward" v-if="badge.exp_reward > 0">
|
||||
+{{ badge.exp_reward }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button type="primary" @click="handleClose" size="large" round>
|
||||
太棒了!
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import {
|
||||
TrendCharts, Medal, Star, Reading, Collection, Files,
|
||||
Select, Finished, Trophy, Clock, Timer, Stopwatch,
|
||||
Operation, Calendar, Rank, Headset, StarFilled
|
||||
} from '@element-plus/icons-vue'
|
||||
|
||||
interface Badge {
|
||||
code: string
|
||||
name: string
|
||||
icon: string
|
||||
exp_reward?: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
leveledUp?: boolean
|
||||
newLevel?: number | null
|
||||
levelTitle?: string
|
||||
levelColor?: string
|
||||
expGained?: number
|
||||
newBadges?: Badge[]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
leveledUp: false,
|
||||
newLevel: null,
|
||||
levelTitle: '',
|
||||
levelColor: '#409EFF',
|
||||
expGained: 0,
|
||||
newBadges: () => []
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'close'): void
|
||||
}>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value)
|
||||
})
|
||||
|
||||
// 图标映射
|
||||
const iconMap: Record<string, any> = {
|
||||
Medal, Star, Reading, Collection, Files, Select,
|
||||
Finished, Trophy, TrendCharts, Clock, Timer,
|
||||
Stopwatch, Operation, Calendar, Rank,
|
||||
Headset, StarFilled
|
||||
}
|
||||
|
||||
const getIconComponent = (icon: string) => {
|
||||
return iconMap[icon] || Medal
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
visible.value = false
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.level-up-dialog {
|
||||
:deep(.el-dialog__header) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:deep(.el-dialog__body) {
|
||||
padding: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
text-align: center;
|
||||
|
||||
.level-up-section {
|
||||
position: relative;
|
||||
padding: 20px 0;
|
||||
|
||||
.celebration {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
|
||||
.firework {
|
||||
position: absolute;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
animation: firework 1s ease-out infinite;
|
||||
|
||||
&:nth-child(1) {
|
||||
left: 20%;
|
||||
top: 30%;
|
||||
background-color: #FFD700;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
left: 50%;
|
||||
top: 20%;
|
||||
background-color: #FF6B6B;
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
left: 80%;
|
||||
top: 40%;
|
||||
background-color: #4ECDC4;
|
||||
animation-delay: 0.6s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.level-badge-large {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin: 0 auto 16px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #FFD700 0%, #FFA500 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 8px 24px rgba(255, 165, 0, 0.4);
|
||||
animation: bounce 0.6s ease;
|
||||
|
||||
.level-number {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.congrats-text {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #303133;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.level-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.exp-section {
|
||||
padding: 24px 0;
|
||||
|
||||
.exp-icon {
|
||||
color: #E6A23C;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.exp-text {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #E6A23C;
|
||||
}
|
||||
}
|
||||
|
||||
.badges-section {
|
||||
margin-top: 20px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #EBEEF5;
|
||||
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.badges-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
|
||||
.badge-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
.badge-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #FFD700 0%, #FFA500 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.badge-name {
|
||||
font-size: 12px;
|
||||
color: #303133;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.badge-reward {
|
||||
font-size: 12px;
|
||||
color: #E6A23C;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes firework {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(20);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
:show-close="false"
|
||||
:close-on-click-modal="false"
|
||||
width="360px"
|
||||
center
|
||||
class="level-up-dialog"
|
||||
>
|
||||
<div class="dialog-content">
|
||||
<!-- 等级升级 -->
|
||||
<div class="level-up-section" v-if="leveledUp">
|
||||
<div class="celebration">
|
||||
<div class="firework"></div>
|
||||
<div class="firework"></div>
|
||||
<div class="firework"></div>
|
||||
</div>
|
||||
<div class="level-badge-large">
|
||||
<span class="level-number">{{ newLevel }}</span>
|
||||
</div>
|
||||
<div class="congrats-text">恭喜升级!</div>
|
||||
<div class="level-title" :style="{ color: levelColor }">{{ levelTitle }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 经验值获得 -->
|
||||
<div class="exp-section" v-if="expGained > 0 && !leveledUp">
|
||||
<el-icon class="exp-icon" :size="48"><TrendCharts /></el-icon>
|
||||
<div class="exp-text">经验值 +{{ expGained }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 新获得奖章 -->
|
||||
<div class="badges-section" v-if="newBadges && newBadges.length > 0">
|
||||
<div class="section-title">新解锁奖章</div>
|
||||
<div class="badges-list">
|
||||
<div
|
||||
class="badge-item"
|
||||
v-for="badge in newBadges"
|
||||
:key="badge.code"
|
||||
>
|
||||
<div class="badge-icon">
|
||||
<el-icon :size="24">
|
||||
<component :is="getIconComponent(badge.icon)" />
|
||||
</el-icon>
|
||||
</div>
|
||||
<div class="badge-name">{{ badge.name }}</div>
|
||||
<div class="badge-reward" v-if="badge.exp_reward > 0">
|
||||
+{{ badge.exp_reward }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button type="primary" @click="handleClose" size="large" round>
|
||||
太棒了!
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import {
|
||||
TrendCharts, Medal, Star, Reading, Collection, Files,
|
||||
Select, Finished, Trophy, Clock, Timer, Stopwatch,
|
||||
Operation, Calendar, Rank, Headset, StarFilled
|
||||
} from '@element-plus/icons-vue'
|
||||
|
||||
interface Badge {
|
||||
code: string
|
||||
name: string
|
||||
icon: string
|
||||
exp_reward?: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
leveledUp?: boolean
|
||||
newLevel?: number | null
|
||||
levelTitle?: string
|
||||
levelColor?: string
|
||||
expGained?: number
|
||||
newBadges?: Badge[]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
leveledUp: false,
|
||||
newLevel: null,
|
||||
levelTitle: '',
|
||||
levelColor: '#409EFF',
|
||||
expGained: 0,
|
||||
newBadges: () => []
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'close'): void
|
||||
}>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value)
|
||||
})
|
||||
|
||||
// 图标映射
|
||||
const iconMap: Record<string, any> = {
|
||||
Medal, Star, Reading, Collection, Files, Select,
|
||||
Finished, Trophy, TrendCharts, Clock, Timer,
|
||||
Stopwatch, Operation, Calendar, Rank,
|
||||
Headset, StarFilled
|
||||
}
|
||||
|
||||
const getIconComponent = (icon: string) => {
|
||||
return iconMap[icon] || Medal
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
visible.value = false
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.level-up-dialog {
|
||||
:deep(.el-dialog__header) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:deep(.el-dialog__body) {
|
||||
padding: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
text-align: center;
|
||||
|
||||
.level-up-section {
|
||||
position: relative;
|
||||
padding: 20px 0;
|
||||
|
||||
.celebration {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
|
||||
.firework {
|
||||
position: absolute;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
animation: firework 1s ease-out infinite;
|
||||
|
||||
&:nth-child(1) {
|
||||
left: 20%;
|
||||
top: 30%;
|
||||
background-color: #FFD700;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
left: 50%;
|
||||
top: 20%;
|
||||
background-color: #FF6B6B;
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
left: 80%;
|
||||
top: 40%;
|
||||
background-color: #4ECDC4;
|
||||
animation-delay: 0.6s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.level-badge-large {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin: 0 auto 16px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #FFD700 0%, #FFA500 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 8px 24px rgba(255, 165, 0, 0.4);
|
||||
animation: bounce 0.6s ease;
|
||||
|
||||
.level-number {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.congrats-text {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #303133;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.level-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.exp-section {
|
||||
padding: 24px 0;
|
||||
|
||||
.exp-icon {
|
||||
color: #E6A23C;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.exp-text {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #E6A23C;
|
||||
}
|
||||
}
|
||||
|
||||
.badges-section {
|
||||
margin-top: 20px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #EBEEF5;
|
||||
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.badges-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
|
||||
.badge-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
.badge-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #FFD700 0%, #FFA500 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.badge-name {
|
||||
font-size: 12px;
|
||||
color: #303133;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.badge-reward {
|
||||
font-size: 12px;
|
||||
color: #E6A23C;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes firework {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(20);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user