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:
@@ -183,6 +183,11 @@ import {
|
||||
type CozeSession,
|
||||
type StreamEvent
|
||||
} from '@/api/coze'
|
||||
import {
|
||||
SpeechRecognitionManager,
|
||||
isSpeechRecognitionSupported,
|
||||
type SpeechRecognitionResult
|
||||
} from '@/utils/speechRecognition'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -205,6 +210,11 @@ const voiceStatusText = ref('点击开始按钮进行语音陪练')
|
||||
const mediaRecorder = ref<MediaRecorder | null>(null)
|
||||
const audioChunks = ref<Blob[]>([])
|
||||
|
||||
// 语音识别相关
|
||||
const speechRecognition = ref<SpeechRecognitionManager | null>(null)
|
||||
const recognizedText = ref('')
|
||||
const isSpeechSupported = isSpeechRecognitionSupported()
|
||||
|
||||
// DOM引用
|
||||
const messageContainer = ref<HTMLElement>()
|
||||
|
||||
@@ -380,9 +390,21 @@ const toggleRecording = async () => {
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始录音
|
||||
* 开始录音(同时启动语音识别)
|
||||
*/
|
||||
const startRecording = async () => {
|
||||
if (!cozeSession.value) {
|
||||
ElMessage.warning('请先开始陪练会话')
|
||||
return
|
||||
}
|
||||
|
||||
// 优先使用 Web Speech API 进行实时语音识别
|
||||
if (isSpeechSupported) {
|
||||
startSpeechRecognition()
|
||||
return
|
||||
}
|
||||
|
||||
// 降级到录音模式(需要后端语音识别服务)
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
|
||||
@@ -400,7 +422,7 @@ const startRecording = async () => {
|
||||
|
||||
mediaRecorder.value.start()
|
||||
isRecording.value = true
|
||||
voiceStatusText.value = '正在录音...'
|
||||
voiceStatusText.value = '正在录音(浏览器不支持实时识别,录音结束后将发送到服务器识别)...'
|
||||
} catch (error) {
|
||||
ElMessage.error('无法访问麦克风')
|
||||
}
|
||||
@@ -410,6 +432,13 @@ const startRecording = async () => {
|
||||
* 停止录音
|
||||
*/
|
||||
const stopRecording = () => {
|
||||
// 如果使用的是 Web Speech API
|
||||
if (speechRecognition.value) {
|
||||
stopSpeechRecognition()
|
||||
return
|
||||
}
|
||||
|
||||
// 如果使用的是录音模式
|
||||
if (mediaRecorder.value && isRecording.value) {
|
||||
mediaRecorder.value.stop()
|
||||
mediaRecorder.value.stream.getTracks().forEach(track => track.stop())
|
||||
@@ -420,13 +449,116 @@ const stopRecording = () => {
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理录音
|
||||
* 处理录音(使用 Web Speech API 已识别的文本)
|
||||
*/
|
||||
const processAudio = async (_audioBlob: Blob) => {
|
||||
// TODO: 实现音频转文本和发送逻辑
|
||||
isProcessing.value = false
|
||||
voiceStatusText.value = '点击开始按钮进行语音陪练'
|
||||
ElMessage.info('语音功能正在开发中')
|
||||
try {
|
||||
// 检查是否有识别结果
|
||||
const text = recognizedText.value.trim()
|
||||
if (!text) {
|
||||
ElMessage.warning('没有检测到语音内容')
|
||||
return
|
||||
}
|
||||
|
||||
// 清空识别结果
|
||||
recognizedText.value = ''
|
||||
|
||||
// 发送识别的文本消息
|
||||
if (cozeSession.value) {
|
||||
// 添加用户消息
|
||||
messages.value.push({
|
||||
role: 'user',
|
||||
content: text,
|
||||
timestamp: new Date()
|
||||
})
|
||||
|
||||
await scrollToBottom()
|
||||
isLoading.value = true
|
||||
|
||||
// 创建AI回复消息占位
|
||||
const assistantMessage = {
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: new Date()
|
||||
}
|
||||
messages.value.push(assistantMessage)
|
||||
|
||||
// 流式发送消息
|
||||
await sendCozeMessage(
|
||||
cozeSession.value.sessionId,
|
||||
text,
|
||||
(event: StreamEvent) => {
|
||||
if (event.type === 'message.delta') {
|
||||
assistantMessage.content += event.content
|
||||
scrollToBottom()
|
||||
} else if (event.type === 'message.completed') {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
} catch (error: any) {
|
||||
ElMessage.error('发送消息失败:' + (error.message || '未知错误'))
|
||||
} finally {
|
||||
isProcessing.value = false
|
||||
voiceStatusText.value = '点击开始按钮进行语音陪练'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始语音识别
|
||||
*/
|
||||
const startSpeechRecognition = () => {
|
||||
if (!isSpeechSupported) {
|
||||
ElMessage.warning('您的浏览器不支持语音识别,请使用 Chrome 或 Edge 浏览器')
|
||||
return
|
||||
}
|
||||
|
||||
// 创建语音识别管理器
|
||||
speechRecognition.value = new SpeechRecognitionManager({
|
||||
continuous: true,
|
||||
interimResults: true,
|
||||
lang: 'zh-CN'
|
||||
})
|
||||
|
||||
speechRecognition.value.setCallbacks({
|
||||
onResult: (result: SpeechRecognitionResult) => {
|
||||
recognizedText.value = result.transcript
|
||||
voiceStatusText.value = result.isFinal
|
||||
? `识别结果: ${result.transcript}`
|
||||
: `正在识别: ${result.transcript}`
|
||||
},
|
||||
onError: (error: string) => {
|
||||
ElMessage.error(error)
|
||||
stopSpeechRecognition()
|
||||
},
|
||||
onStart: () => {
|
||||
isRecording.value = true
|
||||
voiceStatusText.value = '正在监听,请说话...'
|
||||
},
|
||||
onEnd: () => {
|
||||
// 识别结束后自动处理
|
||||
if (recognizedText.value.trim()) {
|
||||
processAudio(new Blob())
|
||||
} else {
|
||||
isRecording.value = false
|
||||
voiceStatusText.value = '点击开始按钮进行语音陪练'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
speechRecognition.value.start()
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止语音识别
|
||||
*/
|
||||
const stopSpeechRecognition = () => {
|
||||
if (speechRecognition.value) {
|
||||
speechRecognition.value.stop()
|
||||
speechRecognition.value = null
|
||||
}
|
||||
isRecording.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -456,6 +588,10 @@ onUnmounted(() => {
|
||||
if (mediaRecorder.value && isRecording.value) {
|
||||
stopRecording()
|
||||
}
|
||||
if (speechRecognition.value) {
|
||||
speechRecognition.value.destroy()
|
||||
speechRecognition.value = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,401 +1,401 @@
|
||||
<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>
|
||||
<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>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user