Some checks failed
continuous-integration/drone/push Build is failing
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 格式
402 lines
9.6 KiB
Vue
402 lines
9.6 KiB
Vue
<template>
|
||
<div class="duo-practice-page">
|
||
<div class="page-header">
|
||
<h1>双人对练</h1>
|
||
<p class="subtitle">与伙伴一起进行角色扮演对练,提升实战能力</p>
|
||
</div>
|
||
|
||
<div class="main-content">
|
||
<!-- 创建/加入房间卡片 -->
|
||
<div class="action-cards">
|
||
<!-- 创建房间 -->
|
||
<div class="action-card create-card" @click="showCreateDialog = true">
|
||
<div class="card-icon">
|
||
<el-icon :size="48"><Plus /></el-icon>
|
||
</div>
|
||
<h3>创建房间</h3>
|
||
<p>创建对练房间,邀请伙伴加入</p>
|
||
</div>
|
||
|
||
<!-- 加入房间 -->
|
||
<div class="action-card join-card" @click="showJoinDialog = true">
|
||
<div class="card-icon">
|
||
<el-icon :size="48"><Connection /></el-icon>
|
||
</div>
|
||
<h3>加入房间</h3>
|
||
<p>输入房间码,加入对练</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 我的房间列表 -->
|
||
<div class="my-rooms" v-if="myRooms.length > 0">
|
||
<h2>我的对练记录</h2>
|
||
<div class="room-list">
|
||
<div
|
||
v-for="room in myRooms"
|
||
:key="room.id"
|
||
class="room-item"
|
||
@click="enterRoom(room)"
|
||
>
|
||
<div class="room-info">
|
||
<span class="room-name">{{ room.room_name || room.scene_name || '双人对练' }}</span>
|
||
<span class="room-code">{{ room.room_code }}</span>
|
||
</div>
|
||
<div class="room-meta">
|
||
<el-tag :type="getStatusType(room.status)" size="small">
|
||
{{ getStatusText(room.status) }}
|
||
</el-tag>
|
||
<span class="room-time">{{ formatTime(room.created_at) }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 创建房间对话框 -->
|
||
<el-dialog
|
||
v-model="showCreateDialog"
|
||
title="创建对练房间"
|
||
width="500px"
|
||
:close-on-click-modal="false"
|
||
>
|
||
<el-form :model="createForm" label-width="100px">
|
||
<el-form-item label="场景名称">
|
||
<el-input v-model="createForm.scene_name" placeholder="如:销售场景对练" />
|
||
</el-form-item>
|
||
<el-form-item label="角色A名称">
|
||
<el-input v-model="createForm.role_a_name" placeholder="如:销售顾问" />
|
||
</el-form-item>
|
||
<el-form-item label="角色B名称">
|
||
<el-input v-model="createForm.role_b_name" placeholder="如:顾客" />
|
||
</el-form-item>
|
||
<el-form-item label="我扮演">
|
||
<el-radio-group v-model="createForm.host_role">
|
||
<el-radio label="A">{{ createForm.role_a_name || '角色A' }}</el-radio>
|
||
<el-radio label="B">{{ createForm.role_b_name || '角色B' }}</el-radio>
|
||
</el-radio-group>
|
||
</el-form-item>
|
||
<el-form-item label="场景背景">
|
||
<el-input
|
||
v-model="createForm.scene_background"
|
||
type="textarea"
|
||
:rows="3"
|
||
placeholder="描述对练场景的背景信息(可选)"
|
||
/>
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="showCreateDialog = false">取消</el-button>
|
||
<el-button type="primary" @click="handleCreateRoom" :loading="isCreating">
|
||
创建房间
|
||
</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 加入房间对话框 -->
|
||
<el-dialog
|
||
v-model="showJoinDialog"
|
||
title="加入对练房间"
|
||
width="400px"
|
||
:close-on-click-modal="false"
|
||
>
|
||
<el-form>
|
||
<el-form-item label="房间码">
|
||
<el-input
|
||
v-model="joinRoomCode"
|
||
placeholder="请输入6位房间码"
|
||
maxlength="6"
|
||
style="font-size: 24px; text-align: center; letter-spacing: 8px;"
|
||
@keyup.enter="handleJoinRoom"
|
||
/>
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="showJoinDialog = false">取消</el-button>
|
||
<el-button type="primary" @click="handleJoinRoom" :loading="isJoining">
|
||
加入房间
|
||
</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, onMounted } from 'vue'
|
||
import { useRouter } from 'vue-router'
|
||
import { ElMessage } from 'element-plus'
|
||
import { Plus, Connection } from '@element-plus/icons-vue'
|
||
import { useDuoPracticeStore } from '@/stores/duoPracticeStore'
|
||
import { getMyRooms, type RoomListItem } from '@/api/duoPractice'
|
||
|
||
const router = useRouter()
|
||
const store = useDuoPracticeStore()
|
||
|
||
// 状态
|
||
const showCreateDialog = ref(false)
|
||
const showJoinDialog = ref(false)
|
||
const isCreating = ref(false)
|
||
const isJoining = ref(false)
|
||
const joinRoomCode = ref('')
|
||
const myRooms = ref<RoomListItem[]>([])
|
||
|
||
// 创建表单
|
||
const createForm = ref({
|
||
scene_name: '',
|
||
role_a_name: '销售顾问',
|
||
role_b_name: '顾客',
|
||
host_role: 'A' as 'A' | 'B',
|
||
scene_background: ''
|
||
})
|
||
|
||
// 加载我的房间列表
|
||
const loadMyRooms = async () => {
|
||
try {
|
||
const res: any = await getMyRooms()
|
||
if (res.code === 200) {
|
||
myRooms.value = res.data.rooms
|
||
}
|
||
} catch (error) {
|
||
console.error('加载房间列表失败:', error)
|
||
}
|
||
}
|
||
|
||
// 创建房间
|
||
const handleCreateRoom = async () => {
|
||
if (!createForm.value.scene_name) {
|
||
ElMessage.warning('请输入场景名称')
|
||
return
|
||
}
|
||
|
||
isCreating.value = true
|
||
try {
|
||
await store.createRoom(createForm.value)
|
||
showCreateDialog.value = false
|
||
|
||
// 跳转到房间页面
|
||
router.push(`/trainee/duo-practice/room/${store.roomCode}`)
|
||
} catch (error) {
|
||
// 错误已在 store 中处理
|
||
} finally {
|
||
isCreating.value = false
|
||
}
|
||
}
|
||
|
||
// 加入房间
|
||
const handleJoinRoom = async () => {
|
||
if (!joinRoomCode.value || joinRoomCode.value.length < 6) {
|
||
ElMessage.warning('请输入6位房间码')
|
||
return
|
||
}
|
||
|
||
isJoining.value = true
|
||
try {
|
||
await store.joinRoom(joinRoomCode.value)
|
||
showJoinDialog.value = false
|
||
|
||
// 跳转到房间页面
|
||
router.push(`/trainee/duo-practice/room/${store.roomCode}`)
|
||
} catch (error) {
|
||
// 错误已在 store 中处理
|
||
} finally {
|
||
isJoining.value = false
|
||
}
|
||
}
|
||
|
||
// 进入房间
|
||
const enterRoom = (room: RoomListItem) => {
|
||
router.push(`/trainee/duo-practice/room/${room.room_code}`)
|
||
}
|
||
|
||
// 获取状态标签类型
|
||
const getStatusType = (status: string) => {
|
||
const map: Record<string, string> = {
|
||
'waiting': 'warning',
|
||
'ready': 'info',
|
||
'practicing': 'success',
|
||
'completed': '',
|
||
'canceled': 'danger'
|
||
}
|
||
return map[status] || ''
|
||
}
|
||
|
||
// 获取状态文本
|
||
const getStatusText = (status: string) => {
|
||
const map: Record<string, string> = {
|
||
'waiting': '等待加入',
|
||
'ready': '准备就绪',
|
||
'practicing': '对练中',
|
||
'completed': '已完成',
|
||
'canceled': '已取消'
|
||
}
|
||
return map[status] || status
|
||
}
|
||
|
||
// 格式化时间
|
||
const formatTime = (time?: string) => {
|
||
if (!time) return ''
|
||
const date = new Date(time)
|
||
return date.toLocaleString('zh-CN', {
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
})
|
||
}
|
||
|
||
onMounted(() => {
|
||
loadMyRooms()
|
||
})
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
.duo-practice-page {
|
||
padding: 24px;
|
||
max-width: 1200px;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
.page-header {
|
||
text-align: center;
|
||
margin-bottom: 40px;
|
||
|
||
h1 {
|
||
font-size: 28px;
|
||
font-weight: 600;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.subtitle {
|
||
color: #666;
|
||
font-size: 16px;
|
||
}
|
||
}
|
||
|
||
.action-cards {
|
||
display: flex;
|
||
gap: 24px;
|
||
justify-content: center;
|
||
margin-bottom: 48px;
|
||
|
||
.action-card {
|
||
width: 280px;
|
||
padding: 40px 24px;
|
||
background: #fff;
|
||
border-radius: 16px;
|
||
text-align: center;
|
||
cursor: pointer;
|
||
transition: all 0.3s ease;
|
||
border: 2px solid transparent;
|
||
|
||
&:hover {
|
||
transform: translateY(-4px);
|
||
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.1);
|
||
}
|
||
|
||
&.create-card {
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
color: #fff;
|
||
|
||
.card-icon {
|
||
background: rgba(255, 255, 255, 0.2);
|
||
}
|
||
|
||
p {
|
||
color: rgba(255, 255, 255, 0.8);
|
||
}
|
||
}
|
||
|
||
&.join-card {
|
||
border-color: #667eea;
|
||
|
||
.card-icon {
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
color: #fff;
|
||
}
|
||
|
||
&:hover {
|
||
border-color: #764ba2;
|
||
}
|
||
}
|
||
|
||
.card-icon {
|
||
width: 80px;
|
||
height: 80px;
|
||
border-radius: 50%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
margin: 0 auto 20px;
|
||
}
|
||
|
||
h3 {
|
||
font-size: 20px;
|
||
font-weight: 600;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
p {
|
||
font-size: 14px;
|
||
color: #666;
|
||
}
|
||
}
|
||
}
|
||
|
||
.my-rooms {
|
||
h2 {
|
||
font-size: 20px;
|
||
font-weight: 600;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.room-list {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||
gap: 16px;
|
||
}
|
||
|
||
.room-item {
|
||
background: #fff;
|
||
border-radius: 12px;
|
||
padding: 16px;
|
||
cursor: pointer;
|
||
transition: all 0.3s ease;
|
||
|
||
&:hover {
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||
}
|
||
|
||
.room-info {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 8px;
|
||
|
||
.room-name {
|
||
font-weight: 500;
|
||
font-size: 16px;
|
||
}
|
||
|
||
.room-code {
|
||
font-family: monospace;
|
||
font-size: 14px;
|
||
color: #667eea;
|
||
background: #f0f2ff;
|
||
padding: 2px 8px;
|
||
border-radius: 4px;
|
||
}
|
||
}
|
||
|
||
.room-meta {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
|
||
.room-time {
|
||
font-size: 12px;
|
||
color: #999;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
</style>
|