Files
012-kaopeilian/frontend/src/views/trainee/duo-practice.vue
yuliang_guo 64f5d567fa
Some checks failed
continuous-integration/drone/push Build is failing
feat: 实现 KPL 系统功能改进计划
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 格式
2026-01-30 14:22:35 +08:00

402 lines
9.6 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>