feat: 初始化考培练系统项目

- 从服务器拉取完整代码
- 按框架规范整理项目结构
- 配置 Drone CI 测试环境部署
- 包含后端(FastAPI)、前端(Vue3)、管理端

技术栈: Vue3 + TypeScript + FastAPI + MySQL
This commit is contained in:
111
2026-01-24 19:33:28 +08:00
commit 998211c483
1197 changed files with 228429 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,754 @@
<template>
<div class="ai-practice-container">
<!-- 页面头部 -->
<div class="practice-header">
<el-page-header @back="goBack">
<template #content>
<span class="page-title">AI陪练会话</span>
</template>
<template #extra>
<el-tag v-if="cozeSession" type="success">会话已连接</el-tag>
<el-tag v-else type="info">未连接</el-tag>
</template>
</el-page-header>
</div>
<!-- 主要内容区 -->
<div class="practice-main">
<!-- 左侧陪练模式选择 -->
<div class="practice-sidebar">
<div class="mode-selector">
<h3>陪练模式</h3>
<el-radio-group v-model="practiceMode" @change="handleModeChange">
<el-radio-button value="text">文本对话</el-radio-button>
<el-radio-button value="voice">语音对话</el-radio-button>
</el-radio-group>
</div>
<div class="topic-selector">
<h3>陪练主题</h3>
<el-input
v-model="trainingTopic"
placeholder="请输入陪练主题(可选)"
:disabled="!!cozeSession"
/>
<el-button
type="primary"
@click="startSession"
:loading="isCreatingSession"
:disabled="!!cozeSession"
style="margin-top: 12px; width: 100%"
>
{{ cozeSession ? '会话进行中' : '开始陪练' }}
</el-button>
<el-button
v-if="cozeSession"
@click="endSession"
style="margin-top: 8px; width: 100%"
>
结束陪练
</el-button>
</div>
<div class="practice-tips">
<h3>陪练提示</h3>
<ul>
<li>保持专注认真倾听</li>
<li>积极思考主动表达</li>
<li>不要害怕犯错</li>
<li>及时总结经验</li>
</ul>
</div>
</div>
<!-- 右侧对话区域 -->
<div class="practice-content">
<!-- 文本对话模式 -->
<div v-if="practiceMode === 'text'" class="text-chat-area">
<!-- 消息列表 -->
<div class="message-list" ref="messageContainer">
<div v-if="messages.length === 0" class="empty-state">
<el-empty description="点击开始陪练开始您的AI陪练之旅">
<el-icon :size="64"><ChatDotRound /></el-icon>
</el-empty>
</div>
<div
v-for="(message, index) in messages"
:key="index"
class="message-item"
:class="message.role"
>
<div class="message-avatar">
<el-avatar :size="36">
{{ message.role === 'user' ? '我' : 'AI' }}
</el-avatar>
</div>
<div class="message-content">
<div class="message-text" v-if="!message.loading">
{{ message.content }}
</div>
<div class="message-loading" v-else>
<span class="dot"></span>
<span class="dot"></span>
<span class="dot"></span>
</div>
<div class="message-time">
{{ formatTime(message.timestamp) }}
</div>
</div>
</div>
</div>
<!-- 输入区域 -->
<div class="input-area" v-if="cozeSession">
<el-input
v-model="inputMessage"
type="textarea"
:autosize="{ minRows: 2, maxRows: 4 }"
placeholder="输入您的消息..."
@keyup.enter.ctrl="sendTextMessage"
:disabled="isLoading"
/>
<el-button
type="primary"
@click="sendTextMessage"
:loading="isLoading"
:disabled="!inputMessage.trim()"
style="margin-top: 10px"
>
发送
</el-button>
<el-button
v-if="isLoading"
@click="abortChat"
style="margin-top: 10px"
>
中断
</el-button>
</div>
</div>
<!-- 语音对话模式 -->
<div v-else class="voice-chat-area">
<div class="voice-visualization">
<div class="voice-status">
<el-icon :size="120" :class="{ recording: isRecording }">
<Microphone />
</el-icon>
<p class="status-text">{{ voiceStatusText }}</p>
</div>
<div class="voice-controls" v-if="cozeSession">
<el-button
type="primary"
size="large"
circle
@click="toggleRecording"
:disabled="isProcessing"
>
<el-icon :size="32">
<component :is="isRecording ? 'VideoPause' : 'VideoPlay'" />
</el-icon>
</el-button>
</div>
</div>
<!-- 语音对话历史 -->
<div class="voice-history">
<div
v-for="(message, index) in messages"
:key="index"
class="voice-message"
:class="message.role"
>
<span class="speaker">{{ message.role === 'user' ? '您' : 'AI陪练' }}</span>
<span class="content">{{ message.content }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onUnmounted, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
createTrainingSession,
endTrainingSession,
sendMessage as sendCozeMessage,
type CozeSession,
type StreamEvent
} from '@/api/coze'
const router = useRouter()
// 陪练模式
const practiceMode = ref<'text' | 'voice'>('text')
const trainingTopic = ref('')
const cozeSession = ref<CozeSession | null>(null)
const isCreatingSession = ref(false)
// 消息相关
const messages = ref<any[]>([])
const inputMessage = ref('')
const isLoading = ref(false)
const abortController = ref<AbortController | null>(null)
// 语音相关
const isRecording = ref(false)
const isProcessing = ref(false)
const voiceStatusText = ref('点击开始按钮进行语音陪练')
const mediaRecorder = ref<MediaRecorder | null>(null)
const audioChunks = ref<Blob[]>([])
// DOM引用
const messageContainer = ref<HTMLElement>()
/**
* 返回上一页
*/
const goBack = () => {
if (cozeSession.value) {
ElMessageBox.confirm('确定要离开吗?这将结束当前陪练会话。', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
endSession()
router.back()
})
} else {
router.back()
}
}
/**
* 切换模式
*/
const handleModeChange = (_mode: string) => {
if (cozeSession.value) {
ElMessage.warning('请先结束当前会话再切换模式')
practiceMode.value = practiceMode.value === 'text' ? 'voice' : 'text'
}
}
/**
* 开始会话
*/
const startSession = async () => {
isCreatingSession.value = true
try {
const response = await createTrainingSession(trainingTopic.value || undefined)
if (response.data) {
cozeSession.value = response.data as unknown as CozeSession
ElMessage.success('陪练会话已创建')
// 添加欢迎消息
messages.value.push({
role: 'assistant',
content: '您好我是您的AI陪练助手。让我们开始今天的练习吧',
timestamp: new Date()
})
}
} catch (error: any) {
ElMessage.error('创建会话失败:' + (error.message || '未知错误'))
} finally {
isCreatingSession.value = false
}
}
/**
* 结束会话
*/
const endSession = async () => {
if (!cozeSession.value) return
try {
await endTrainingSession(cozeSession.value.sessionId, {
reason: '用户主动结束',
feedback: {
rating: 5,
comment: '陪练体验良好'
}
})
cozeSession.value = null
messages.value = []
inputMessage.value = ''
trainingTopic.value = ''
ElMessage.success('陪练会话已结束')
} catch (error: any) {
ElMessage.error('结束会话失败:' + (error.message || '未知错误'))
}
}
/**
* 发送文本消息
*/
const sendTextMessage = async () => {
const content = inputMessage.value.trim()
if (!content || !cozeSession.value) return
// 添加用户消息
messages.value.push({
role: 'user',
content,
timestamp: new Date()
})
// 清空输入
inputMessage.value = ''
// 添加AI加载消息
const aiMessageIndex = messages.value.length
messages.value.push({
role: 'assistant',
content: '',
loading: true,
timestamp: new Date()
})
// 滚动到底部
await scrollToBottom()
// 发送消息
isLoading.value = true
let fullContent = ''
try {
abortController.value = new AbortController()
await sendCozeMessage(
cozeSession.value.sessionId,
content,
true,
(event: StreamEvent) => {
if (event.event === 'message_delta') {
fullContent += event.data.content || ''
messages.value[aiMessageIndex] = {
role: 'assistant',
content: fullContent,
loading: false,
timestamp: new Date()
}
scrollToBottom()
} else if (event.event === 'message_completed') {
isLoading.value = false
} else if (event.event === 'error') {
throw new Error(event.data.error || '发送消息失败')
}
}
)
} catch (error: any) {
isLoading.value = false
messages.value[aiMessageIndex] = {
role: 'assistant',
content: '抱歉,发送消息时出现错误。请稍后重试。',
loading: false,
timestamp: new Date(),
isError: true
}
ElMessage.error(error.message || '发送消息失败')
}
}
/**
* 中断对话
*/
const abortChat = () => {
if (abortController.value) {
abortController.value.abort()
abortController.value = null
isLoading.value = false
ElMessage.info('对话已中断')
}
}
/**
* 切换录音状态
*/
const toggleRecording = async () => {
if (isRecording.value) {
stopRecording()
} else {
await startRecording()
}
}
/**
* 开始录音
*/
const startRecording = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
mediaRecorder.value = new MediaRecorder(stream)
audioChunks.value = []
mediaRecorder.value.ondataavailable = (event) => {
audioChunks.value.push(event.data)
}
mediaRecorder.value.onstop = async () => {
const audioBlob = new Blob(audioChunks.value, { type: 'audio/wav' })
await processAudio(audioBlob)
}
mediaRecorder.value.start()
isRecording.value = true
voiceStatusText.value = '正在录音...'
} catch (error) {
ElMessage.error('无法访问麦克风')
}
}
/**
* 停止录音
*/
const stopRecording = () => {
if (mediaRecorder.value && isRecording.value) {
mediaRecorder.value.stop()
mediaRecorder.value.stream.getTracks().forEach(track => track.stop())
isRecording.value = false
voiceStatusText.value = '正在处理...'
isProcessing.value = true
}
}
/**
* 处理录音
*/
const processAudio = async (_audioBlob: Blob) => {
// TODO: 实现音频转文本和发送逻辑
isProcessing.value = false
voiceStatusText.value = '点击开始按钮进行语音陪练'
ElMessage.info('语音功能正在开发中')
}
/**
* 滚动到底部
*/
const scrollToBottom = async () => {
await nextTick()
if (messageContainer.value) {
messageContainer.value.scrollTop = messageContainer.value.scrollHeight
}
}
/**
* 格式化时间
*/
const formatTime = (date: Date) => {
const hours = date.getHours().toString().padStart(2, '0')
const minutes = date.getMinutes().toString().padStart(2, '0')
return `${hours}:${minutes}`
}
// 清理
onUnmounted(() => {
if (abortController.value) {
abortController.value.abort()
}
if (mediaRecorder.value && isRecording.value) {
stopRecording()
}
})
</script>
<style lang="scss" scoped>
.ai-practice-container {
height: 100vh;
display: flex;
flex-direction: column;
background: #f5f7fa;
.practice-header {
background: white;
padding: 16px 24px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
.page-title {
font-size: 18px;
font-weight: 600;
color: #333;
}
}
.practice-main {
flex: 1;
display: flex;
overflow: hidden;
.practice-sidebar {
width: 300px;
background: white;
border-right: 1px solid #ebeef5;
padding: 24px;
overflow-y: auto;
h3 {
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 16px;
}
.mode-selector {
margin-bottom: 32px;
.el-radio-group {
width: 100%;
display: flex;
.el-radio-button {
flex: 1;
:deep(.el-radio-button__inner) {
width: 100%;
}
}
}
}
.topic-selector {
margin-bottom: 32px;
}
.practice-tips {
ul {
list-style: none;
padding: 0;
margin: 0;
li {
padding: 8px 0;
color: #666;
font-size: 14px;
display: flex;
align-items: center;
&::before {
content: '•';
margin-right: 8px;
color: #667eea;
font-weight: bold;
}
}
}
}
}
.practice-content {
flex: 1;
display: flex;
flex-direction: column;
background: white;
.text-chat-area {
height: 100%;
display: flex;
flex-direction: column;
.message-list {
flex: 1;
overflow-y: auto;
padding: 24px;
.empty-state {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
.el-empty {
padding: 40px;
}
}
.message-item {
display: flex;
gap: 12px;
margin-bottom: 24px;
&.user {
flex-direction: row-reverse;
.message-content {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
.message-time {
color: rgba(255, 255, 255, 0.8);
}
}
}
&.assistant {
.message-content {
background: #f5f7fa;
color: #333;
}
}
.message-content {
max-width: 70%;
padding: 12px 16px;
border-radius: 12px;
.message-text {
font-size: 15px;
line-height: 1.6;
white-space: pre-wrap;
}
.message-loading {
display: flex;
gap: 4px;
padding: 8px 0;
.dot {
width: 8px;
height: 8px;
background: #909399;
border-radius: 50%;
animation: loading 1.4s ease-in-out infinite;
&:nth-child(2) {
animation-delay: 0.2s;
}
&:nth-child(3) {
animation-delay: 0.4s;
}
}
}
.message-time {
font-size: 12px;
color: #909399;
margin-top: 4px;
}
}
}
}
.input-area {
border-top: 1px solid #ebeef5;
padding: 16px 24px;
background: white;
}
}
.voice-chat-area {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px;
.voice-visualization {
text-align: center;
.voice-status {
margin-bottom: 48px;
.el-icon {
color: #909399;
transition: all 0.3s;
&.recording {
color: #f56c6c;
animation: pulse 1.5s ease-in-out infinite;
}
}
.status-text {
font-size: 18px;
color: #666;
margin-top: 24px;
}
}
}
.voice-history {
width: 100%;
max-width: 600px;
max-height: 300px;
overflow-y: auto;
margin-top: 48px;
padding: 24px;
background: #f5f7fa;
border-radius: 12px;
.voice-message {
margin-bottom: 16px;
font-size: 14px;
line-height: 1.6;
.speaker {
font-weight: 600;
color: #333;
}
.content {
color: #666;
}
&.user .speaker {
color: #667eea;
}
&.assistant .speaker {
color: #764ba2;
}
}
}
}
}
}
}
@keyframes loading {
0%, 60%, 100% {
transform: scale(1);
opacity: 1;
}
30% {
transform: scale(1.3);
opacity: 0.5;
}
}
@keyframes pulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
}
}
// 响应式
@media (max-width: 768px) {
.ai-practice-container {
.practice-main {
flex-direction: column;
.practice-sidebar {
width: 100%;
border-right: none;
border-bottom: 1px solid #ebeef5;
padding: 16px;
}
}
}
}
</style>

View File

@@ -0,0 +1,119 @@
<template>
<div class="ai-practice-page">
<!-- 语音对话模式默认 -->
<VoiceChat v-if="chatModel === 'voice'" />
<!-- 文本对话模式 -->
<TextChat v-else />
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { storeToRefs } from 'pinia'
import { usePracticeStore } from '@/stores/practiceStore'
import { practiceApi } from '@/api/practice'
import { ElMessage } from 'element-plus'
import VoiceChat from '@/components/VoiceChat.vue'
import TextChat from '@/components/TextChat.vue'
const route = useRoute()
const router = useRouter()
const practiceStore = usePracticeStore()
const { chatModel } = storeToRefs(practiceStore)
// ==================== 生命周期 ====================
onMounted(async () => {
// 检查陪练模式自由对话mode=free、直接模式sceneId或课程模式sceneData
const mode = route.query.mode as string
const sceneId = route.query.sceneId ? Number(route.query.sceneId) : null
const sceneData = route.query.sceneData as string
if (mode === 'free') {
// 自由对话模式创建默认场景id为null表示无需数据库场景
const defaultScene = {
id: null,
name: 'AI自由对话陪练',
description: '与AI进行自由对话提升您的沟通能力',
background: '这是一个自由对话场景您可以与AI进行任何话题的交流练习。',
ai_role: '我是您的AI陪练助手我会根据您的话题提供专业的指导和反馈。',
objectives: [
'提升沟通表达能力',
'增强语言组织能力',
'培养自信心'
],
type: 'free',
difficulty: 'beginner',
status: 'active'
}
practiceStore.setCurrentScene(defaultScene)
console.log('自由对话模式启动')
} else if (mode === 'course' && sceneData) {
// 课程模式从URL解析场景数据
try {
const scene = JSON.parse(sceneData)
practiceStore.setCurrentScene(scene)
console.log('课程模式场景加载成功:', scene.name)
} catch (error) {
console.error('解析场景数据失败:', error)
ElMessage.error('场景数据格式错误')
router.push('/trainee/ai-practice-center')
return
}
} else if (sceneId) {
// 直接模式:从数据库加载场景详情
await loadSceneDetail(sceneId)
} else {
// 缺少必要参数
ElMessage.error('缺少场景参数')
router.push('/trainee/ai-practice-center')
return
}
// 默认设置为语音模式
practiceStore.setChatModel('voice')
})
onUnmounted(() => {
// 页面卸载时重置状态
practiceStore.resetChat()
// 如果是语音模式,断开连接
if (chatModel.value === 'voice') {
practiceStore.disconnectVoice()
}
})
// ==================== 方法 ====================
/**
* 加载场景详情
*/
const loadSceneDetail = async (sceneId: number) => {
try {
const response: any = await practiceApi.getSceneDetail(sceneId)
// http.ts响应拦截器已经返回了response.data所以response直接是业务数据
if (response.code === 200 && response.data) {
practiceStore.setCurrentScene(response.data)
} else {
ElMessage.error(response.message || '加载场景失败')
router.push('/trainee/ai-practice-center')
}
} catch (error) {
console.error('加载场景失败:', error)
ElMessage.error('加载场景失败')
router.push('/trainee/ai-practice-center')
}
}
</script>
<style lang="scss" scoped>
.ai-practice-page {
width: 100%;
height: 100%;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,638 @@
<template>
<div class="audio-player-container">
<div class="player-bg">
<div class="wave wave-1"></div>
<div class="wave wave-2"></div>
<div class="wave wave-3"></div>
</div>
<div class="player-content">
<div class="player-header">
<el-button link @click="goBack">
<el-icon :size="20"><ArrowLeft /></el-icon>
返回
</el-button>
</div>
<div class="player-main">
<div class="course-cover">
<img :src="courseInfo.cover" :alt="courseInfo.title" />
<div class="cover-overlay">
<el-icon :size="80" color="white"><Headset /></el-icon>
</div>
</div>
<div class="course-info">
<h1 class="course-title">{{ courseInfo.title }}</h1>
<p class="course-instructor">讲师{{ courseInfo.instructor }}</p>
</div>
<div class="audio-controls">
<div class="progress-bar">
<div class="time-display">{{ formatTime(currentTime) }}</div>
<el-slider
v-model="progress"
:show-tooltip="false"
@change="handleProgressChange"
class="audio-slider"
/>
<div class="time-display">{{ formatTime(duration) }}</div>
</div>
<div class="control-buttons">
<el-button circle size="large" @click="backward">
<el-icon :size="24"><DArrowLeft /></el-icon>
</el-button>
<el-button
type="primary"
circle
size="large"
class="play-button"
@click="togglePlay"
>
<el-icon :size="32">
<component :is="isPlaying ? 'VideoPause' : 'VideoPlay'" />
</el-icon>
</el-button>
<el-button circle size="large" @click="forward">
<el-icon :size="24"><DArrowRight /></el-icon>
</el-button>
</div>
<div class="extra-controls">
<div class="speed-control">
<span>倍速</span>
<el-select v-model="playbackRate" @change="handleSpeedChange">
<el-option label="0.5x" :value="0.5" />
<el-option label="0.75x" :value="0.75" />
<el-option label="1.0x" :value="1" />
<el-option label="1.25x" :value="1.25" />
<el-option label="1.5x" :value="1.5" />
<el-option label="2.0x" :value="2" />
</el-select>
</div>
<div class="volume-control">
<el-icon :size="20" @click="toggleMute">
<component :is="isMuted ? 'Mute' : 'Microphone'" />
</el-icon>
<el-slider
v-model="volume"
:show-tooltip="false"
@change="handleVolumeChange"
style="width: 100px"
/>
</div>
</div>
</div>
<div class="playlist">
<h3 class="playlist-title">播放列表</h3>
<div class="playlist-items">
<div
class="playlist-item"
:class="{ active: item.id === currentTrack.id }"
v-for="item in playlist"
:key="item.id"
@click="selectTrack(item)"
>
<div class="item-index">{{ item.index }}</div>
<div class="item-info">
<div class="item-title">{{ item.title }}</div>
<div class="item-duration">{{ item.duration }}</div>
</div>
<el-icon v-if="item.id === currentTrack.id" class="playing-icon">
<VideoPlay />
</el-icon>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
const router = useRouter()
// 课程信息
const courseInfo = ref({
title: '销售技巧基础训练',
instructor: '王老师',
cover: 'https://via.placeholder.com/400x400/667eea/ffffff?text=音频课程'
})
// 播放状态
const isPlaying = ref(false)
const isMuted = ref(false)
const currentTime = ref(0)
const duration = ref(3600) // 60分钟
const progress = ref(0)
const volume = ref(80)
const playbackRate = ref(1)
// 当前播放的音频
const currentTrack = ref({
id: 1,
title: '第一章:销售基础认知',
url: '/audio/chapter1.mp3'
})
// 播放列表
const playlist = ref([
{
id: 1,
index: '01',
title: '第一章:销售基础认知',
duration: '15:32',
url: '/audio/chapter1.mp3'
},
{
id: 2,
index: '02',
title: '第二章:客户沟通技巧',
duration: '22:18',
url: '/audio/chapter2.mp3'
},
{
id: 3,
index: '03',
title: '第三章:需求挖掘方法',
duration: '18:45',
url: '/audio/chapter3.mp3'
},
{
id: 4,
index: '04',
title: '第四章:产品介绍技巧',
duration: '20:12',
url: '/audio/chapter4.mp3'
},
{
id: 5,
index: '05',
title: '第五章:异议处理',
duration: '16:38',
url: '/audio/chapter5.mp3'
}
])
// 音频元素
let audioElement: HTMLAudioElement | null = null
/**
* 返回
*/
const goBack = () => {
router.back()
}
/**
* 格式化时间
*/
const formatTime = (seconds: number) => {
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = Math.floor(seconds % 60)
if (h > 0) {
return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
}
return `${m}:${s.toString().padStart(2, '0')}`
}
/**
* 播放/暂停
*/
const togglePlay = () => {
isPlaying.value = !isPlaying.value
if (isPlaying.value) {
audioElement?.play()
ElMessage.success('开始播放')
} else {
audioElement?.pause()
}
}
/**
* 快退10秒
*/
const backward = () => {
if (audioElement) {
audioElement.currentTime = Math.max(0, audioElement.currentTime - 10)
}
}
/**
* 快进10秒
*/
const forward = () => {
if (audioElement) {
audioElement.currentTime = Math.min(duration.value, audioElement.currentTime + 10)
}
}
/**
* 进度改变
*/
const handleProgressChange = (val: number) => {
if (audioElement) {
audioElement.currentTime = (val / 100) * duration.value
}
}
/**
* 速度改变
*/
const handleSpeedChange = (val: number) => {
if (audioElement) {
audioElement.playbackRate = val
}
}
/**
* 静音切换
*/
const toggleMute = () => {
isMuted.value = !isMuted.value
if (audioElement) {
audioElement.muted = isMuted.value
}
}
/**
* 音量改变
*/
const handleVolumeChange = (val: number) => {
if (audioElement) {
audioElement.volume = val / 100
}
}
/**
* 选择音轨
*/
const selectTrack = (track: any) => {
currentTrack.value = track
ElMessage.info(`切换到:${track.title}`)
// 重置播放状态
isPlaying.value = false
currentTime.value = 0
progress.value = 0
}
// 初始化音频
onMounted(() => {
audioElement = new Audio(currentTrack.value.url)
// 监听播放进度
audioElement.addEventListener('timeupdate', () => {
if (audioElement) {
currentTime.value = audioElement.currentTime
progress.value = (audioElement.currentTime / duration.value) * 100
}
})
// 监听播放结束
audioElement.addEventListener('ended', () => {
isPlaying.value = false
// 自动播放下一首
const currentIndex = playlist.value.findIndex(item => item.id === currentTrack.value.id)
if (currentIndex < playlist.value.length - 1) {
selectTrack(playlist.value[currentIndex + 1])
setTimeout(() => togglePlay(), 500)
}
})
})
// 清理
onUnmounted(() => {
if (audioElement) {
audioElement.pause()
audioElement = null
}
})
</script>
<style lang="scss" scoped>
.audio-player-container {
min-height: 100vh;
background: linear-gradient(135deg, #1e1e2e 0%, #2d2d44 100%);
position: relative;
overflow: hidden;
.player-bg {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
opacity: 0.1;
.wave {
position: absolute;
width: 200%;
height: 200%;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
&-1 {
top: -50%;
left: -50%;
animation: wave 20s linear infinite;
}
&-2 {
bottom: -50%;
right: -50%;
animation: wave 15s linear infinite reverse;
}
&-3 {
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
animation: wave 25s linear infinite;
}
}
}
.player-content {
position: relative;
z-index: 1;
max-width: 800px;
margin: 0 auto;
padding: 24px;
.player-header {
margin-bottom: 32px;
.el-button {
color: white;
&:hover {
color: #667eea;
}
}
}
.player-main {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(20px);
border-radius: 24px;
padding: 40px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
.course-cover {
width: 240px;
height: 240px;
margin: 0 auto 32px;
position: relative;
border-radius: 20px;
overflow: hidden;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.cover-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
}
}
.course-info {
text-align: center;
margin-bottom: 40px;
.course-title {
font-size: 28px;
font-weight: 600;
color: white;
margin-bottom: 8px;
}
.course-instructor {
font-size: 16px;
color: rgba(255, 255, 255, 0.7);
}
}
.audio-controls {
.progress-bar {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 32px;
.time-display {
font-size: 14px;
color: rgba(255, 255, 255, 0.8);
min-width: 50px;
}
.audio-slider {
flex: 1;
:deep(.el-slider__runway) {
background-color: rgba(255, 255, 255, 0.2);
}
:deep(.el-slider__bar) {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
:deep(.el-slider__button) {
border: 2px solid #667eea;
}
}
}
.control-buttons {
display: flex;
align-items: center;
justify-content: center;
gap: 24px;
margin-bottom: 32px;
.el-button {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
color: white;
&:hover {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.3);
}
&.play-button {
width: 80px;
height: 80px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
&:hover {
transform: scale(1.05);
}
}
}
}
.extra-controls {
display: flex;
justify-content: space-between;
align-items: center;
.speed-control,
.volume-control {
display: flex;
align-items: center;
gap: 12px;
color: rgba(255, 255, 255, 0.8);
span {
font-size: 14px;
}
.el-select {
width: 100px;
:deep(.el-input__inner) {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.2);
color: white;
}
}
.el-icon {
cursor: pointer;
&:hover {
color: #667eea;
}
}
.el-slider {
:deep(.el-slider__runway) {
background-color: rgba(255, 255, 255, 0.2);
}
:deep(.el-slider__bar) {
background: #667eea;
}
}
}
}
}
.playlist {
margin-top: 40px;
padding-top: 32px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
.playlist-title {
font-size: 18px;
font-weight: 600;
color: white;
margin-bottom: 20px;
}
.playlist-items {
.playlist-item {
display: flex;
align-items: center;
padding: 12px 16px;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s;
&:hover {
background: rgba(255, 255, 255, 0.1);
}
&.active {
background: rgba(102, 126, 234, 0.2);
.item-title {
color: #667eea;
}
}
.item-index {
width: 30px;
font-size: 14px;
color: rgba(255, 255, 255, 0.5);
}
.item-info {
flex: 1;
margin-left: 12px;
.item-title {
font-size: 14px;
color: white;
margin-bottom: 4px;
}
.item-duration {
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
}
}
.playing-icon {
color: #667eea;
}
}
}
}
}
}
}
@keyframes wave {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
// 响应式
@media (max-width: 768px) {
.audio-player-container {
.player-main {
padding: 24px !important;
.course-cover {
width: 180px;
height: 180px;
}
.extra-controls {
flex-direction: column;
gap: 16px;
}
}
}
}
</style>

View File

@@ -0,0 +1,314 @@
<template>
<div class="broadcast-wrapper">
<div class="broadcast-header">
<el-button @click="goBack" circle>
<el-icon><Back /></el-icon>
</el-button>
<h2 class="course-title">{{ courseName }}</h2>
<div class="header-spacer"></div>
</div>
<div class="player-container">
<div class="player-card">
<div class="player-icon">
<el-icon :size="80">
<Microphone />
</el-icon>
</div>
<div class="player-info">
<h3>播课音频</h3>
<p class="time-display">{{ formatTime(currentTime) }} / {{ formatTime(duration) }}</p>
</div>
<!-- HTML5 Audio 元素 -->
<audio
ref="audioPlayer"
:src="mp3Url"
@timeupdate="onTimeUpdate"
@loadedmetadata="onLoadedMetadata"
@ended="onEnded"
@error="onError"
/>
<!-- 进度条 -->
<div class="progress-container">
<el-slider
v-model="sliderValue"
:max="100"
:show-tooltip="false"
@change="seekTo"
/>
</div>
<!-- 播放控制栏 -->
<div class="controls">
<el-button circle @click="togglePlay" :disabled="!audioReady">
<el-icon :size="32">
<VideoPause v-if="isPlaying" />
<VideoPlay v-else />
</el-icon>
</el-button>
<div class="speed-control">
<span class="speed-label">播放速度</span>
<el-button-group>
<el-button
v-for="speed in speedOptions"
:key="speed"
:type="playbackRate === speed ? 'primary' : ''"
size="small"
@click="setSpeed(speed)"
>
{{ speed }}x
</el-button>
</el-button-group>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Back, Microphone, VideoPlay, VideoPause } from '@element-plus/icons-vue'
const router = useRouter()
const route = useRoute()
// 从路由参数获取数据
const courseName = computed(() => route.query.courseName as string || '播课')
const mp3Url = computed(() => route.query.mp3Url as string)
// 音频播放器引用
const audioPlayer = ref<HTMLAudioElement>()
// 播放状态
const isPlaying = ref(false)
const audioReady = ref(false)
const currentTime = ref(0)
const duration = ref(0)
const playbackRate = ref(1.0)
const sliderValue = ref(0)
// 播放速度选项
const speedOptions = [1.0, 1.25, 1.5, 2.0]
/**
* 返回
*/
const goBack = () => {
router.back()
}
/**
* 切换播放/暂停
*/
const togglePlay = async () => {
if (!audioPlayer.value) return
try {
if (isPlaying.value) {
audioPlayer.value.pause()
isPlaying.value = false
} else {
await audioPlayer.value.play()
isPlaying.value = true
}
} catch (error) {
console.error('播放控制失败:', error)
ElMessage.error('播放失败')
}
}
/**
* 设置播放速度
*/
const setSpeed = (speed: number) => {
if (!audioPlayer.value) return
audioPlayer.value.playbackRate = speed
playbackRate.value = speed
}
/**
* 跳转到指定位置
*/
const seekTo = (value: number) => {
if (!audioPlayer.value || !duration.value) return
const newTime = (value / 100) * duration.value
audioPlayer.value.currentTime = newTime
}
/**
* 音频时间更新
*/
const onTimeUpdate = () => {
if (!audioPlayer.value) return
currentTime.value = audioPlayer.value.currentTime
if (duration.value > 0) {
sliderValue.value = (currentTime.value / duration.value) * 100
}
}
/**
* 音频元数据加载完成
*/
const onLoadedMetadata = () => {
if (!audioPlayer.value) return
duration.value = audioPlayer.value.duration
audioReady.value = true
}
/**
* 播放结束
*/
const onEnded = () => {
isPlaying.value = false
currentTime.value = 0
sliderValue.value = 0
}
/**
* 音频加载错误
*/
const onError = (event: Event) => {
console.error('音频加载失败:', event)
ElMessage.error('音频加载失败,请检查网络或稍后重试')
audioReady.value = false
}
/**
* 格式化时间
*/
const formatTime = (seconds: number): string => {
if (isNaN(seconds) || seconds === 0) {
return '00:00'
}
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const secs = Math.floor(seconds % 60)
if (hours > 0) {
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
} else {
return `${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
}
// 验证必要参数
onMounted(() => {
if (!mp3Url.value) {
ElMessage.error('缺少播课音频地址')
goBack()
}
})
// 清理
onBeforeUnmount(() => {
if (audioPlayer.value) {
audioPlayer.value.pause()
}
})
</script>
<style scoped lang="scss">
.broadcast-wrapper {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.broadcast-header {
display: flex;
align-items: center;
gap: 20px;
padding: 20px 30px;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
.course-title {
margin: 0;
color: #fff;
font-size: 24px;
font-weight: 600;
}
.header-spacer {
flex: 1;
}
}
.player-container {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 40px 20px;
}
.player-card {
width: 100%;
max-width: 600px;
background: #fff;
border-radius: 20px;
padding: 40px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
.player-icon {
text-align: center;
margin-bottom: 30px;
color: #667eea;
}
.player-info {
text-align: center;
margin-bottom: 30px;
h3 {
margin: 0 0 10px 0;
font-size: 20px;
color: #333;
}
.time-display {
margin: 0;
font-size: 14px;
color: #999;
}
}
.progress-container {
margin-bottom: 30px;
}
.controls {
display: flex;
flex-direction: column;
gap: 20px;
align-items: center;
.speed-control {
display: flex;
align-items: center;
gap: 10px;
.speed-label {
font-size: 14px;
color: #666;
}
}
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,914 @@
<template>
<div class="course-center-wrapper">
<!-- 全屏Loading遮罩Teleport到body -->
<Teleport to="body">
<div v-if="loading" class="fullscreen-loading-mask">
<div class="loading-content">
<el-icon class="loading-icon is-loading">
<Loading />
</el-icon>
<div class="loading-text">{{ loadingText }}</div>
</div>
</div>
</Teleport>
<div class="course-center-container">
<div class="page-header">
<h1 class="page-title">课程中心</h1>
<div class="header-actions">
<el-input
v-model="searchKeyword"
placeholder="搜索课程"
clearable
style="width: 300px"
@keyup.enter="handleSearch"
@input="handleRealTimeSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</div>
</div>
<!-- 分类筛选 -->
<div class="category-filter">
<el-radio-group v-model="selectedCategory" @change="handleCategoryChange">
<el-radio-button label="">全部课程</el-radio-button>
<el-radio-button label="required">必修课程</el-radio-button>
<el-radio-button label="optional">选修课程</el-radio-button>
<el-radio-button label="completed">已完成</el-radio-button>
<el-radio-button label="ongoing">学习中</el-radio-button>
</el-radio-group>
</div>
<!-- 课程列表 -->
<div v-if="filteredCourses.length > 0" class="course-grid">
<div class="course-card" v-for="course in filteredCourses" :key="course.id">
<!-- 卡片内容区域 -->
<div class="card-body">
<!-- 顶部徽章和进度 -->
<div class="card-header-info">
<div class="card-badges">
<span class="badge badge-new" v-if="course.isNew">
<el-icon><Star /></el-icon>
NEW
</span>
<span class="badge" :class="course.type === '必修' ? 'badge-required' : 'badge-optional'">
{{ course.type }}
</span>
</div>
<div class="progress-badge" v-if="course.progress > 0">
<span class="progress-text">{{ course.progress }}%</span>
</div>
</div>
<h3 class="course-title">{{ course.title }}</h3>
<p class="course-description">{{ course.description }}</p>
<div class="course-stats">
<div class="stat-item">
<el-icon><Clock /></el-icon>
<span>{{ course.duration }}</span>
</div>
<div class="stat-item">
<el-icon><Collection /></el-icon>
<span>{{ course.lessons }} 课时</span>
</div>
<div class="stat-item">
<el-icon><User /></el-icon>
<span>{{ course.students }}</span>
</div>
</div>
</div>
<!-- 卡片底部操作区域 -->
<div class="card-footer">
<!-- 主要操作按钮 -->
<button class="action-btn primary-btn" @click="enterCourse(course)">
<el-icon><VideoPlay /></el-icon>
<span>{{ course.progress > 0 ? '继续学习' : '开始学习' }}</span>
</button>
<!-- 次要操作按钮组 -->
<div class="secondary-actions">
<!-- 播课功能暂时关闭 -->
<button v-if="false" class="action-btn secondary-btn" @click="playAudio(course)">
<el-icon><Headset /></el-icon>
<span>播课</span>
</button>
<button class="action-btn secondary-btn" @click="chatWithCourse(course)">
<el-icon><ChatDotRound /></el-icon>
<span>对话</span>
</button>
<button
class="action-btn exam-btn"
@click="startExam(course)"
>
<el-icon><DocumentChecked /></el-icon>
<span>考试</span>
</button>
<button
class="action-btn practice-btn"
@click="startPractice(course)"
>
<el-icon><Microphone /></el-icon>
<span>陪练</span>
</button>
</div>
</div>
</div>
</div>
<!-- 空状态显示 -->
<div v-else class="empty-state">
<el-empty
:description="(searchKeyword || selectedCategory) ? '没有符合条件的课程' : '暂无课程数据'"
:image-size="120"
>
<el-button v-if="searchKeyword || selectedCategory" type="primary" @click="clearAllFilters">
清空筛选条件
</el-button>
<el-button v-else type="primary" disabled>
敬请期待更多课程
</el-button>
</el-empty>
</div>
<!-- 加载更多 -->
<div class="load-more" v-if="hasMore && filteredCourses.length > 0">
<el-button @click="loadMore">加载更多</el-button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
Search, Clock, User, Headset, ChatDotRound,
Microphone, Star, VideoPlay, Collection, DocumentChecked,
Loading
} from '@element-plus/icons-vue'
import { getCourseList, type CourseInfo } from '@/api/trainee'
import { practiceApi } from '@/api/practice'
import { broadcastApi } from '@/api/broadcast'
const router = useRouter()
// 搜索关键词
const searchKeyword = ref('')
// 选中的分类
const selectedCategory = ref('')
// 是否有更多
const hasMore = ref(true)
// 课程列表(展示用)
const courseList = ref<any[]>([])
// Loading状态
const loading = ref(false)
const loadingText = ref('正在加载...')
/**
* 从后端获取课程列表并映射为页面展示结构
*/
async function fetchCourses(): Promise<void> {
try {
const res = await getCourseList({ page: 1, size: 12 })
const items = res?.data?.items || []
// 将后端数据映射到当前页面使用的展示结构
courseList.value = (items as CourseInfo[]).map((c: any, idx) => {
// 根据后端返回的course_type字段判断课程类型
// required=必修, optional=选修, 无值表示该课程未分配给用户岗位
let courseType = '必修' // 默认为必修
if (c.course_type === 'optional') {
courseType = '选修'
} else if (c.course_type === 'required') {
courseType = '必修'
}
return {
id: c.id,
title: c.name || c.title || '', // 优先使用name数据库字段兼容title
description: c.description || '',
type: courseType,
isNew: idx < 2,
duration: formatDuration(c.duration),
lessons: c.materialCount ?? 0,
students: 0,
progress: c.progress ?? 0,
examPassed: (c.progress ?? 0) >= 80
}
})
} catch (error) {
ElMessage.error('课程加载失败,请稍后重试')
// 失败时保持空列表UI 会显示空状态
courseList.value = []
}
}
/**
* 将分钟数格式化为“X小时Y分”
*/
function formatDuration(minutes?: number): string {
if (!minutes || minutes <= 0) return '—'
const h = Math.floor(minutes / 60)
const m = minutes % 60
if (h > 0 && m > 0) return `${h}小时${m}`
if (h > 0) return `${h}小时`
return `${m}`
}
// 过滤后的课程数据
const filteredCourses = computed(() => {
let filtered = courseList.value
// 关键词搜索
if (searchKeyword.value) {
const keyword = searchKeyword.value.toLowerCase()
filtered = filtered.filter(course =>
course.title.toLowerCase().includes(keyword) ||
course.description.toLowerCase().includes(keyword)
)
}
// 分类筛选
if (selectedCategory.value) {
switch (selectedCategory.value) {
case 'required':
filtered = filtered.filter(course => course.type === '必修')
break
case 'optional':
filtered = filtered.filter(course => course.type === '选修')
break
case 'completed':
filtered = filtered.filter(course => course.progress === 100)
break
case 'ongoing':
filtered = filtered.filter(course => course.progress > 0 && course.progress < 100)
break
}
}
return filtered
})
onMounted(() => {
fetchCourses()
})
/**
* 搜索课程
*/
const handleSearch = () => {
const count = filteredCourses.value.length
ElMessage.success('找到 ' + count + ' 门课程')
}
/**
* 分类切换
*/
const handleCategoryChange = () => {
const categoryText = getCategoryText(selectedCategory.value)
const count = filteredCourses.value.length
ElMessage.success('切换到:' + categoryText + ',找到 ' + count + ' 门课程')
}
/**
* 获取分类文本
*/
const getCategoryText = (category: string) => {
const map: Record<string, string> = {
'': '全部课程',
'required': '必修课程',
'optional': '选修课程',
'completed': '已完成',
'ongoing': '学习中'
}
return map[category] || '全部课程'
}
/**
* 实时搜索处理
*/
const handleRealTimeSearch = () => {
// 筛选逻辑在计算属性中处理
}
/**
* 清空所有筛选条件
*/
const clearAllFilters = () => {
searchKeyword.value = ''
selectedCategory.value = ''
ElMessage.success('已清空所有筛选条件')
}
/**
* 进入课程
*/
const enterCourse = (course: any) => {
router.push('/trainee/course-detail?id=' + course.id)
}
/**
* 播放音频
*/
const playAudio = async (course: any) => {
try {
const res: any = await broadcastApi.getInfo(course.id)
// http.ts 返回 AxiosResponse需要访问 res.data.data
const code = res.data?.code || res.code
const data = res.data?.data || res.data
if (code === 200 && data?.has_broadcast && data.mp3_url) {
router.push({
name: 'BroadcastCourse',
query: {
courseId: course.id,
courseName: course.title, // 使用映射后的title字段
mp3Url: data.mp3_url
}
})
} else {
ElMessage.warning('该课程暂无播课内容')
}
} catch (error) {
console.error('查询播课信息失败:', error)
ElMessage.error('查询播课信息失败')
}
}
/**
* 与课程对话
*/
const chatWithCourse = (course: any) => {
router.push('/trainee/chat-course?courseId=' + course.id)
}
/**
* 开始考试
*/
const startExam = (course: any) => {
router.push('/trainee/exam?courseId=' + course.id)
}
/**
* 开始陪练(课程模式)
*
* 流程:
* 1. 调用Dify工作流提取场景
* 2. 跳转到陪练对话页面,携带场景数据
*/
const startPractice = async (course: any) => {
try {
// 显示全屏Loading
loading.value = true
loadingText.value = '正在分析课程内容,生成陪练场景...\n预计需要10-30秒请稍候'
console.log('🚀 开始提取场景, course_id:', course.id)
// 调用Dify提取场景
const response: any = await practiceApi.extractScene({
course_id: course.id
})
console.log('📦 完整响应:', response)
console.log('📦 response.data:', response.data)
// http.ts返回AxiosResponse数据在response.data.data中
// response -> AxiosResponse
// response.data -> { code: 200, message: "success", data: {...} }
// response.data.data -> { scene: {...}, workflow_run_id, task_id }
const businessData = response.data?.data || response.data || response
console.log('📦 业务数据:', businessData)
console.log('📦 场景数据:', businessData?.scene)
if (!businessData || !businessData.scene) {
console.error('❌ 场景数据格式错误:', { response, businessData })
throw new Error('场景数据格式错误请查看Console')
}
// 更新Loading文字
loadingText.value = '场景生成完成,正在跳转...'
console.log('✅ 场景提取成功:', businessData.scene.name)
// 跳转到陪练对话页面,携带场景数据
await router.push({
name: 'AIPractice',
query: {
mode: 'course',
courseId: String(course.id),
sceneData: JSON.stringify(businessData.scene)
}
})
console.log('✅ 跳转完成')
} catch (error: any) {
console.error('❌ 场景提取失败:', error)
ElMessage.error(error.message || '场景提取失败,请稍后重试')
} finally {
// 关闭Loading
loading.value = false
loadingText.value = '正在加载...'
}
}
/**
* 加载更多
*/
const loadMore = () => {
ElMessage.info('加载更多课程...')
}
</script>
<style lang="scss" scoped>
// 全屏Loading遮罩样式Teleport到body
.fullscreen-loading-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.8);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
.loading-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
.loading-icon {
font-size: 60px;
color: #ffffff;
animation: rotating 2s linear infinite;
}
.loading-text {
color: #ffffff;
font-size: 18px;
font-weight: 600;
line-height: 1.8;
white-space: pre-line;
text-align: center;
max-width: 400px;
}
}
}
@keyframes rotating {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.course-center-wrapper {
// 包裹容器,无样式
}
.course-center-container {
max-width: 1400px;
margin: 0 auto;
padding: 0 20px;
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 32px;
.page-title {
font-size: 28px;
font-weight: 700;
color: #1a1a1a;
letter-spacing: -0.5px;
}
.header-actions {
:deep(.el-input) {
border-radius: 25px;
.el-input__wrapper {
border-radius: 25px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
border: 1px solid #e4e7ed;
transition: all 0.3s;
&:hover {
border-color: #667eea;
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.15);
}
}
}
}
}
.category-filter {
margin-bottom: 32px;
.el-radio-group {
display: flex;
gap: 12px;
:deep(.el-radio-button__inner) {
border-radius: 25px;
padding: 10px 24px;
font-weight: 500;
border: 2px solid #e4e7ed;
background: #fff;
transition: all 0.3s;
&:hover {
color: #667eea;
border-color: #667eea;
background: rgba(102, 126, 234, 0.05);
}
}
:deep(.el-radio-button__original-radio:checked + .el-radio-button__inner) {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-color: transparent;
color: #fff;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
}
}
}
.course-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
gap: 28px;
margin-bottom: 48px;
.course-card {
background: #fff;
border-radius: 20px;
overflow: hidden;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
&:hover {
transform: translateY(-8px);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15);
.primary-btn {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.4);
}
}
.card-body {
padding: 20px;
.card-header-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
.card-badges {
display: flex;
gap: 6px;
flex-wrap: wrap;
.badge {
padding: 4px 10px;
border-radius: 16px;
font-size: 11px;
font-weight: 600;
display: flex;
align-items: center;
gap: 3px;
&.badge-new {
background: linear-gradient(135deg, #ff4757 0%, #ff6348 100%);
color: #fff;
box-shadow: 0 2px 8px rgba(255, 71, 87, 0.3);
}
&.badge-required {
background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
color: #fff;
}
&.badge-optional {
background: #f4f4f5;
color: #606266;
}
}
}
.progress-badge {
display: flex;
align-items: center;
.progress-text {
padding: 4px 12px;
border-radius: 16px;
font-size: 12px;
font-weight: 600;
background: linear-gradient(135deg, #67c23a 0%, #85ce61 100%);
color: #fff;
box-shadow: 0 2px 8px rgba(103, 194, 58, 0.3);
}
}
}
.course-title {
font-size: 18px;
font-weight: 700;
color: #1a1a1a;
margin-bottom: 8px;
line-height: 1.3;
}
.course-description {
font-size: 13px;
color: #606266;
line-height: 1.6;
margin-bottom: 16px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.course-stats {
display: flex;
gap: 16px;
margin-bottom: 16px;
.stat-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #909399;
.el-icon {
font-size: 14px;
color: #667eea;
}
}
}
}
.card-footer {
padding: 0 20px 20px;
display: flex;
flex-direction: column;
gap: 10px;
.action-btn {
height: 38px;
border: none;
border-radius: 10px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
&.primary-btn {
width: 100%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
box-shadow: 0 3px 10px rgba(102, 126, 234, 0.25);
&:hover {
background: linear-gradient(135deg, #5a67d8 0%, #6b46c1 100%);
transform: translateY(-1px);
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.35);
}
&:active {
transform: translateY(0);
}
}
&.secondary-btn {
flex: 1;
background: #fff;
border: 1.5px solid #e4e7ed;
color: #606266;
font-size: 13px;
font-weight: 500;
&:hover {
border-color: #667eea;
color: #667eea;
background: rgba(102, 126, 234, 0.05);
transform: translateY(-1px);
}
}
&.exam-btn {
flex: 1;
background: #fff;
border: 1.5px solid #e6a23c;
color: #e6a23c;
font-size: 13px;
font-weight: 500;
&:hover {
background: rgba(230, 162, 60, 0.08);
transform: translateY(-1px);
}
}
&.practice-btn {
flex: 1;
background: #fff;
border: 1.5px solid #67c23a;
color: #67c23a;
font-size: 13px;
font-weight: 500;
&:hover {
background: rgba(103, 194, 58, 0.08);
transform: translateY(-1px);
}
}
.el-icon {
font-size: 16px;
}
span {
white-space: nowrap;
}
}
.secondary-actions {
display: flex;
gap: 8px;
width: 100%;
}
}
}
}
.empty-state {
padding: 80px 20px;
text-align: center;
:deep(.el-empty) {
.el-empty__description {
font-size: 16px;
color: #909399;
}
}
}
.load-more {
text-align: center;
margin-bottom: 60px;
.el-button {
min-width: 200px;
height: 44px;
border-radius: 22px;
font-size: 15px;
font-weight: 500;
}
}
}
// 响应式设计
@media (max-width: 1200px) {
.course-center-container {
.course-grid {
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 20px;
}
}
}
@media (max-width: 768px) {
.course-center-container {
padding: 0 12px;
.page-header {
flex-direction: column;
align-items: stretch;
gap: 16px;
margin-bottom: 24px;
.page-title {
font-size: 24px;
}
.header-actions {
.el-input {
width: 100% !important;
}
}
}
.category-filter {
margin-bottom: 24px;
.el-radio-group {
width: 100%;
overflow-x: auto;
flex-wrap: nowrap;
padding-bottom: 8px;
-webkit-overflow-scrolling: touch;
gap: 8px;
&::-webkit-scrollbar {
display: none; // Hide scrollbar for cleaner look
}
:deep(.el-radio-button__inner) {
padding: 8px 16px;
font-size: 13px;
}
}
}
.course-grid {
grid-template-columns: 1fr;
gap: 16px;
.course-card {
border-radius: 16px;
.card-body {
padding: 16px;
.course-title {
font-size: 17px;
}
}
.card-footer {
padding: 0 16px 16px;
gap: 12px;
.action-btn {
height: 40px;
font-size: 14px;
.el-icon {
font-size: 16px;
}
}
.secondary-actions {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
.action-btn {
flex-direction: column;
height: auto;
padding: 8px 2px;
gap: 4px;
border-radius: 8px;
&.secondary-btn,
&.exam-btn,
&.practice-btn {
font-size: 11px;
span {
display: block !important;
line-height: 1;
transform: scale(0.95);
}
.el-icon {
margin: 0;
font-size: 18px;
margin-bottom: 2px;
}
}
}
}
}
}
}
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,810 @@
<template>
<div class="exam-result-container">
<div class="result-card card">
<!-- 成绩展示 -->
<div class="result-header">
<div class="score-circle" :class="scoreLevel">
<div class="score-value">{{ examResult.score }}</div>
<div class="score-label"></div>
</div>
<div class="result-info">
<h1 class="exam-title">{{ examResult.examTitle }}</h1>
<div class="result-meta">
<span class="meta-item">
<el-icon><Clock /></el-icon>
用时{{ formatDuration(examResult.duration) }}
</span>
<span class="meta-item">
<el-icon><Document /></el-icon>
题数{{ examResult.totalQuestions }}
</span>
<span class="meta-item">
<el-icon><CircleCheck /></el-icon>
正确{{ examResult.correctCount }}
</span>
<span class="meta-item">
<el-icon><CircleClose /></el-icon>
错误{{ examResult.wrongCount }}
</span>
</div>
<div class="result-status">
<el-tag :type="examResult.passed ? 'success' : 'danger'" size="large">
{{ examResult.passed ? '考试通过' : '未通过' }}
</el-tag>
<span class="pass-line">及格线{{ examResult.passScore }}</span>
</div>
</div>
</div>
<!-- 统计分析 -->
<div class="statistics-section">
<h2 class="section-title">答题统计</h2>
<div class="stats-grid">
<div class="stat-item">
<div class="stat-label">正确率</div>
<div class="stat-value">{{ examResult.accuracy }}%</div>
<el-progress :percentage="examResult.accuracy" :color="getProgressColor(examResult.accuracy)" />
</div>
<div class="stat-item">
<div class="stat-label">排名</div>
<div class="stat-value">{{ examResult.rank }}/{{ examResult.totalParticipants }}</div>
<div class="stat-desc">超过了{{ examResult.surpassRate }}%的人</div>
</div>
<div class="stat-item">
<div class="stat-label">平均用时</div>
<div class="stat-value">{{ examResult.avgTimePerQuestion }}/</div>
<div class="stat-desc">{{ examResult.timeEfficiency }}</div>
</div>
</div>
</div>
<!-- 各轮次得分 -->
<div class="round-scores-section">
<h2 class="section-title">各轮次得分</h2>
<div class="round-scores">
<div class="round-item" v-for="round in examResult.roundScores" :key="round.round">
<div class="round-header">
<span class="round-label">{{ round.round }}</span>
<span class="round-score" :class="getScoreClass(round.score)">{{ round.score }}</span>
</div>
<div class="round-details">
<div class="detail-item">
<span class="detail-label">题数</span>
<span class="detail-value">{{ round.questions }}</span>
</div>
<div class="detail-item">
<span class="detail-label">正确</span>
<span class="detail-value correct">{{ round.correct }}</span>
</div>
<div class="detail-item">
<span class="detail-label">错误</span>
<span class="detail-value wrong">{{ round.wrong }}</span>
</div>
<div class="detail-item">
<span class="detail-label">正确率</span>
<span class="detail-value">{{ Math.round(round.correct / round.questions * 100) }}%</span>
</div>
</div>
<el-progress
:percentage="Math.round(round.correct / round.questions * 100)"
:color="getProgressColor(Math.round(round.correct / round.questions * 100))"
:show-text="false"
/>
</div>
</div>
</div>
<!-- 题型分析 -->
<div class="type-analysis-section">
<h2 class="section-title">题型分析</h2>
<div class="type-stats">
<div class="type-item" v-for="type in questionTypeStats" :key="type.type">
<div class="type-header">
<span class="type-name">{{ type.name }}</span>
<span class="type-accuracy">正确率{{ type.accuracy }}%</span>
</div>
<el-progress :percentage="type.accuracy" :color="getProgressColor(type.accuracy)" />
<div class="type-detail">
<span>{{ type.total }}</span>
<span>正确{{ type.correct }}</span>
<span>错误{{ type.wrong }}</span>
</div>
</div>
</div>
</div>
<!-- 错题记录 -->
<div class="mistakes-section" v-if="mistakes.length > 0">
<h2 class="section-title">错题记录</h2>
<div class="mistakes-list">
<div class="mistake-item" v-for="(mistake, _index) in mistakes" :key="mistake.id">
<div class="mistake-header">
<span class="question-number">{{ mistake.number }}</span>
<el-tag :type="getQuestionTypeTag(mistake.type)" size="small">
{{ getQuestionTypeText(mistake.type) }}
</el-tag>
</div>
<div class="mistake-content">
<h4 class="question-title">{{ mistake.title }}</h4>
<div class="answer-comparison">
<div class="answer-item your-answer">
<span class="answer-label">你的答案</span>
<span class="answer-value wrong">{{ mistake.yourAnswer }}</span>
</div>
<div class="answer-item correct-answer">
<span class="answer-label">正确答案</span>
<span class="answer-value correct">{{ mistake.correctAnswer }}</span>
</div>
</div>
<div class="explanation">
<div class="explanation-title">
<el-icon><InfoFilled /></el-icon>
答案解析
</div>
<p class="explanation-content">{{ mistake.explanation }}</p>
</div>
</div>
</div>
</div>
</div>
<!-- 操作按钮 -->
<div class="action-buttons">
<el-button @click="viewReport">查看详细报告</el-button>
<el-button @click="retakeExam">重新考试</el-button>
<el-button v-if="examResult.passed" type="primary" @click="startPractice">
开始陪练
</el-button>
<el-button type="primary" @click="backToCourse">
返回课程
</el-button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
const router = useRouter()
// 考试结果数据
const examResult = ref({
examTitle: '销售技巧基础训练 - 期末考试',
score: 85,
passScore: 60,
passed: true,
totalQuestions: 50,
correctCount: 42,
wrongCount: 8,
duration: 3600,
accuracy: 84,
rank: 12,
totalParticipants: 156,
surpassRate: 92.3,
avgTimePerQuestion: 72,
timeEfficiency: '答题速度适中',
// 三轮考试得分
roundScores: [
{ round: 1, score: 75, questions: 50, correct: 38, wrong: 12 },
{ round: 2, score: 82, questions: 12, correct: 10, wrong: 2 },
{ round: 3, score: 100, questions: 2, correct: 2, wrong: 0 }
]
})
// 分数等级
const scoreLevel = computed(() => {
const score = examResult.value.score
if (score >= 90) return 'excellent'
if (score >= 80) return 'good'
if (score >= 60) return 'pass'
return 'fail'
})
// 题型统计
const questionTypeStats = ref([
{
type: 'single',
name: '单选题',
total: 20,
correct: 18,
wrong: 2,
accuracy: 90
},
{
type: 'multiple',
name: '多选题',
total: 15,
correct: 12,
wrong: 3,
accuracy: 80
},
{
type: 'judge',
name: '判断题',
total: 10,
correct: 8,
wrong: 2,
accuracy: 80
},
{
type: 'blank',
name: '填空题',
total: 5,
correct: 4,
wrong: 1,
accuracy: 80
}
])
// 错题列表
const mistakes = ref([
{
id: 1,
number: 12,
type: 'single',
title: '以下哪个不是有效的客户沟通技巧?',
yourAnswer: 'B',
correctAnswer: 'C',
explanation: '在客户沟通中倾听比说话更重要。选项C中的"打断客户发言"违背了有效沟通的基本原则。'
},
{
id: 2,
number: 25,
type: 'multiple',
title: '成功的销售人员应该具备哪些特质?',
yourAnswer: 'A, B, D',
correctAnswer: 'A, B, C, D',
explanation: '成功的销售人员需要具备多方面的特质包括良好的沟通能力、同理心、专业知识和持续学习的态度。选项C"抗压能力"也是必不可少的。'
},
{
id: 3,
number: 38,
type: 'judge',
title: '销售过程中,价格是决定客户购买的唯一因素。',
yourAnswer: '正确',
correctAnswer: '错误',
explanation: '价格只是影响客户购买决策的因素之一。产品质量、服务、品牌信誉、售后支持等都是重要的考虑因素。'
}
])
/**
* 格式化时长
*/
const formatDuration = (seconds: number) => {
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = seconds % 60
if (h > 0) {
return `${h}小时${m}分钟${s}`
}
return `${m}分钟${s}`
}
/**
* 获取进度条颜色
*/
const getProgressColor = (percentage: number) => {
if (percentage >= 90) return '#67c23a'
if (percentage >= 75) return '#409eff'
if (percentage >= 60) return '#e6a23c'
return '#f56c6c'
}
/**
* 获取分数样式类
*/
const getScoreClass = (score: number) => {
if (score >= 90) return 'excellent'
if (score >= 80) return 'good'
if (score >= 60) return 'pass'
return 'fail'
}
/**
* 获取题型标签类型
*/
const getQuestionTypeTag = (type: string) => {
const typeMap: Record<string, string> = {
single: '',
multiple: 'success',
judge: 'warning',
blank: 'danger'
}
return typeMap[type] || ''
}
/**
* 获取题型文本
*/
const getQuestionTypeText = (type: string) => {
const typeMap: Record<string, string> = {
single: '单选题',
multiple: '多选题',
judge: '判断题',
blank: '填空题'
}
return typeMap[type] || ''
}
/**
* 查看详细报告
*/
const viewReport = () => {
ElMessage.info('生成详细分析报告中...')
}
/**
* 重新考试
*/
const retakeExam = () => {
router.push('/trainee/exam')
}
/**
* 开始陪练
*/
const startPractice = () => {
router.push('/trainee/ai-practice')
}
/**
* 返回课程
*/
const backToCourse = () => {
router.push('/trainee/course-center')
}
</script>
<style lang="scss" scoped>
.exam-result-container {
max-width: 1000px;
margin: 0 auto;
padding: 24px;
.result-card {
padding: 32px;
.result-header {
display: flex;
gap: 48px;
padding-bottom: 32px;
border-bottom: 1px solid #ebeef5;
margin-bottom: 32px;
.score-circle {
width: 160px;
height: 160px;
border-radius: 50%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
background: #f5f7fa;
&::before {
content: '';
position: absolute;
inset: -4px;
border-radius: 50%;
padding: 4px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
}
&.excellent::before {
background: linear-gradient(135deg, #67c23a 0%, #409eff 100%);
}
&.good::before {
background: linear-gradient(135deg, #409eff 0%, #667eea 100%);
}
&.pass::before {
background: linear-gradient(135deg, #e6a23c 0%, #f56c6c 100%);
}
&.fail::before {
background: linear-gradient(135deg, #f56c6c 0%, #909399 100%);
}
.score-value {
font-size: 48px;
font-weight: 700;
color: #333;
}
.score-label {
font-size: 16px;
color: #666;
}
}
.result-info {
flex: 1;
.exam-title {
font-size: 24px;
font-weight: 600;
color: #333;
margin-bottom: 16px;
}
.result-meta {
display: flex;
gap: 24px;
margin-bottom: 20px;
.meta-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
color: #666;
.el-icon {
color: #909399;
}
}
}
.result-status {
display: flex;
align-items: center;
gap: 16px;
.pass-line {
font-size: 14px;
color: #909399;
}
}
}
}
.section-title {
font-size: 18px;
font-weight: 600;
color: #333;
margin-bottom: 20px;
}
.statistics-section {
margin-bottom: 32px;
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
.stat-item {
text-align: center;
padding: 20px;
background: #f5f7fa;
border-radius: 12px;
.stat-label {
font-size: 14px;
color: #666;
margin-bottom: 8px;
}
.stat-value {
font-size: 28px;
font-weight: 600;
color: #333;
margin-bottom: 12px;
}
.stat-desc {
font-size: 13px;
color: #909399;
margin-top: 8px;
}
}
}
}
.round-scores-section {
margin-bottom: 32px;
.round-scores {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
.round-item {
padding: 20px;
background: #f9fafb;
border-radius: 12px;
border: 1px solid #ebeef5;
transition: all 0.3s;
&:hover {
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}
.round-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
.round-label {
font-size: 16px;
font-weight: 500;
color: #333;
}
.round-score {
font-size: 24px;
font-weight: 700;
&.excellent {
color: #67c23a;
}
&.good {
color: #409eff;
}
&.pass {
color: #e6a23c;
}
&.fail {
color: #f56c6c;
}
}
}
.round-details {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
margin-bottom: 16px;
.detail-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
.detail-label {
color: #909399;
}
.detail-value {
color: #606266;
font-weight: 500;
&.correct {
color: #67c23a;
}
&.wrong {
color: #f56c6c;
}
}
}
}
.el-progress {
height: 6px;
:deep(.el-progress-bar) {
border-radius: 3px;
}
}
}
}
}
.type-analysis-section {
margin-bottom: 32px;
.type-stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20px;
.type-item {
padding: 16px;
background: #f9fafb;
border-radius: 8px;
.type-header {
display: flex;
justify-content: space-between;
margin-bottom: 12px;
.type-name {
font-size: 15px;
font-weight: 500;
color: #333;
}
.type-accuracy {
font-size: 14px;
color: #666;
}
}
.type-detail {
display: flex;
gap: 16px;
margin-top: 12px;
font-size: 13px;
color: #909399;
}
}
}
}
.mistakes-section {
margin-bottom: 32px;
.mistakes-list {
.mistake-item {
padding: 20px;
background: #fff;
border: 1px solid #ebeef5;
border-radius: 8px;
margin-bottom: 16px;
&:last-child {
margin-bottom: 0;
}
.mistake-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
.question-number {
font-size: 14px;
font-weight: 500;
color: #666;
}
}
.mistake-content {
.question-title {
font-size: 16px;
font-weight: 500;
color: #333;
margin-bottom: 16px;
line-height: 1.5;
}
.answer-comparison {
display: flex;
gap: 32px;
margin-bottom: 20px;
padding: 16px;
background: #f5f7fa;
border-radius: 8px;
.answer-item {
display: flex;
align-items: center;
gap: 8px;
.answer-label {
font-size: 14px;
color: #666;
}
.answer-value {
font-size: 15px;
font-weight: 500;
&.wrong {
color: #f56c6c;
}
&.correct {
color: #67c23a;
}
}
}
}
.explanation {
.explanation-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: 500;
color: #667eea;
margin-bottom: 8px;
}
.explanation-content {
font-size: 14px;
color: #666;
line-height: 1.6;
padding-left: 22px;
}
}
}
}
}
}
.action-buttons {
display: flex;
justify-content: center;
gap: 16px;
padding-top: 32px;
border-top: 1px solid #ebeef5;
.el-button {
min-width: 120px;
&--primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
}
}
}
}
}
// 响应式
@media (max-width: 768px) {
.exam-result-container {
.result-header {
flex-direction: column;
align-items: center;
text-align: center;
}
.statistics-section {
.stats-grid {
grid-template-columns: 1fr;
}
}
.round-scores-section {
.round-scores {
grid-template-columns: 1fr;
}
}
.type-analysis-section {
.type-stats {
grid-template-columns: 1fr;
}
}
.mistakes-section {
.answer-comparison {
flex-direction: column;
gap: 12px !important;
}
}
.action-buttons {
flex-wrap: wrap;
.el-button {
flex: 1;
min-width: 0;
}
}
}
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,913 @@
<template>
<div class="practice-report-container">
<div class="report-header card">
<div class="header-content">
<h1 class="report-title">陪练分析报告</h1>
<div class="report-meta">
<span>
<el-icon><Calendar /></el-icon>
{{ reportData.date }}
</span>
<span>
<el-icon><Clock /></el-icon>
通话时长{{ reportData.duration }}
</span>
<span>
<el-icon><ChatDotRound /></el-icon>
对话轮次{{ reportData.turns }}
</span>
</div>
</div>
<div class="header-actions">
<el-button @click="downloadReport">
<el-icon class="el-icon--left"><Download /></el-icon>
下载报告
</el-button>
<el-button type="primary" @click="startNewPractice">
再练一次
</el-button>
</div>
</div>
<!-- 综合评分 -->
<div class="score-section card">
<h2 class="section-title">综合评分</h2>
<div class="score-content">
<div class="total-score">
<div class="score-circle">
<el-progress
type="circle"
:percentage="reportData.totalScore"
:width="160"
:stroke-width="12"
:color="getScoreColor(reportData.totalScore)"
>
<template #default="{ percentage }">
<div class="score-text">
<div class="score-value">{{ percentage }}</div>
<div class="score-label">综合得分</div>
</div>
</template>
</el-progress>
</div>
<div class="score-level">
<el-tag :type="getScoreLevel(reportData.totalScore).type" size="large">
{{ getScoreLevel(reportData.totalScore).text }}
</el-tag>
</div>
</div>
<div class="score-breakdown">
<div class="breakdown-item" v-for="item in scoreBreakdown" :key="item.name">
<div class="item-header">
<span class="item-name">{{ item.name }}</span>
<span class="item-score">{{ item.score }}</span>
</div>
<el-progress :percentage="item.score" :color="getScoreColor(item.score)" />
<div class="item-desc">{{ item.description }}</div>
</div>
</div>
</div>
</div>
<!-- 能力维度雷达图 -->
<div class="radar-section card">
<h2 class="section-title">能力维度分析</h2>
<div class="radar-content">
<div class="radar-chart" ref="radarChartRef"></div>
<div class="radar-legend">
<div class="legend-item" v-for="dimension in abilityDimensions" :key="dimension.name">
<div class="legend-header">
<span class="legend-name">{{ dimension.name }}</span>
<span class="legend-score">{{ dimension.score }}</span>
</div>
<el-progress :percentage="dimension.score" :show-text="false" />
<div class="legend-feedback">{{ dimension.feedback }}</div>
</div>
</div>
</div>
</div>
<!-- 对话复盘 -->
<div class="dialogue-section card">
<h2 class="section-title">完整对话复盘</h2>
<div class="dialogue-controls">
<el-button-group>
<el-button
:type="dialogueFilter === 'all' ? 'primary' : ''"
@click="dialogueFilter = 'all'"
>
全部对话
</el-button>
<el-button
:type="dialogueFilter === 'highlight' ? 'primary' : ''"
@click="dialogueFilter = 'highlight'"
>
亮点话术
</el-button>
<el-button
:type="dialogueFilter === 'golden' ? 'primary' : ''"
@click="dialogueFilter = 'golden'"
>
金牌话术
</el-button>
</el-button-group>
</div>
<div class="dialogue-timeline">
<div
class="dialogue-item"
v-for="(item, index) in filteredDialogue"
:key="index"
:class="item.speaker"
>
<div class="speaker-avatar">
<el-avatar :size="36">
{{ item.speaker === 'user' ? 'ME' : 'AI' }}
</el-avatar>
</div>
<div class="dialogue-content">
<div class="content-header">
<span class="speaker-name">{{ item.speaker === 'user' ? '你' : 'AI客户' }}</span>
<span class="dialogue-time">{{ item.time }}</span>
</div>
<div class="content-text">{{ item.content }}</div>
<div class="content-tags" v-if="item.tags && item.tags.length">
<el-tag
v-for="tag in item.tags"
:key="tag"
:type="getTagType(tag)"
size="small"
>
{{ tag }}
</el-tag>
</div>
<div class="content-comment" v-if="item.comment">
<el-icon><InfoFilled /></el-icon>
<span>{{ item.comment }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- 改进建议 -->
<div class="suggestions-section card">
<h2 class="section-title">改进建议</h2>
<div class="suggestions-list">
<div class="suggestion-item" v-for="(suggestion, index) in suggestions" :key="index">
<div class="suggestion-icon">
<el-icon :size="24" :color="suggestion.color">
<component :is="suggestion.icon" />
</el-icon>
</div>
<div class="suggestion-content">
<h3>{{ suggestion.title }}</h3>
<p>{{ suggestion.content }}</p>
<div class="suggestion-example" v-if="suggestion.example">
<span class="example-label">示例</span>
<span class="example-text">{{ suggestion.example }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { practiceApi } from '@/api/practice'
import * as echarts from 'echarts'
const router = useRouter()
const route = useRoute()
// 雷达图引用
const radarChartRef = ref()
let radarChart: any = null
// 报告数据
const reportData = ref({
date: '2024-03-20 14:30',
duration: '12分35秒',
turns: 16,
totalScore: 85
})
// 分数细分
const scoreBreakdown = ref([
{
name: '开场技巧',
score: 90,
description: '开场自然,快速建立信任'
},
{
name: '需求挖掘',
score: 85,
description: '能够有效识别客户需求'
},
{
name: '产品介绍',
score: 88,
description: '产品介绍清晰,重点突出'
},
{
name: '异议处理',
score: 78,
description: '处理客户异议还需加强'
},
{
name: '成交技巧',
score: 82,
description: '成交话术运用良好'
}
])
// 能力维度
const abilityDimensions = ref([
{
name: '沟通表达',
score: 88,
feedback: '语言流畅,表达清晰,但语速可以适当放慢'
},
{
name: '倾听理解',
score: 92,
feedback: '能够准确理解客户意图,给予恰当回应'
},
{
name: '情绪控制',
score: 85,
feedback: '整体情绪稳定,面对异议时保持专业'
},
{
name: '专业知识',
score: 90,
feedback: '产品知识扎实,能够准确回答客户问题'
},
{
name: '销售技巧',
score: 83,
feedback: '基本销售流程掌握良好,细节还可优化'
},
{
name: '应变能力',
score: 80,
feedback: '面对突发问题反应较快,但处理方式可更灵活'
}
])
// 对话记录
const dialogueRecords = ref([
{
speaker: 'user',
time: '00:05',
content: '您好王女士我是XX保险公司的销售顾问小张今天打电话给您是想了解一下您对保险产品的需求。',
tags: ['亮点话术'],
comment: '开场专业,身份介绍清晰'
},
{
speaker: 'ai',
time: '00:12',
content: '哦,保险啊,我现在已经有社保了,不需要额外的保险。',
tags: [],
comment: ''
},
{
speaker: 'user',
time: '00:18',
content: '王女士,社保确实是基础保障,但您知道吗,社保的报销比例和范围是有限的。比如很多进口药品和先进治疗手段都不在社保范围内。',
tags: ['金牌话术'],
comment: '巧妙引导,从客户角度出发'
},
{
speaker: 'ai',
time: '00:28',
content: '这个我倒是听说过,但是保险费用会不会很贵?',
tags: [],
comment: ''
},
{
speaker: 'user',
time: '00:35',
content: '王女士,其实保险的费用是根据您的年龄和保额来定的。像您这个年龄,每月只需要几百元,就相当于每天一杯咖啡的钱,却能获得几十万的保障。',
tags: ['亮点话术'],
comment: '类比生动,让客户容易理解'
}
])
// 对话筛选
const dialogueFilter = ref('all')
// 筛选后的对话
const filteredDialogue = computed(() => {
if (dialogueFilter.value === 'all') {
return dialogueRecords.value
}
return dialogueRecords.value.filter(item =>
item.tags && item.tags.includes(
dialogueFilter.value === 'highlight' ? '亮点话术' : '金牌话术'
)
)
})
// 改进建议
const suggestions = ref([
{
icon: 'Clock',
color: '#e6a23c',
title: '控制语速',
content: '您的语速偏快,建议适当放慢,给客户更多思考时间',
example: '说完产品优势后停顿2-3秒观察客户反应'
},
{
icon: 'QuestionFilled',
color: '#667eea',
title: '多用开放式问题',
content: '增加开放式问题的使用,更深入了解客户需求',
example: '"您对未来的保障有什么期望?"而不是"您需要保险吗?"'
},
{
icon: 'Trophy',
color: '#67c23a',
title: '强化成交信号识别',
content: '客户已经表现出兴趣时,要及时推进成交',
example: '当客户问"费用多少"时,这是购买信号,应该立即报价并促成'
}
])
/**
* 获取分数颜色
*/
const getScoreColor = (score: number) => {
if (score >= 90) return '#67c23a'
if (score >= 80) return '#409eff'
if (score >= 70) return '#e6a23c'
return '#f56c6c'
}
/**
* 获取分数等级
*/
const getScoreLevel = (score: number) => {
if (score >= 90) return { type: 'success', text: '优秀' }
if (score >= 80) return { type: '', text: '良好' }
if (score >= 70) return { type: 'warning', text: '及格' }
return { type: 'danger', text: '待提升' }
}
/**
* 获取标签类型
*/
const getTagType = (tag: string) => {
if (tag === '金牌话术') return 'warning'
if (tag === '亮点话术') return 'success'
return ''
}
/**
* 下载报告
*/
const downloadReport = () => {
ElMessage.success('报告下载中...')
}
/**
* 初始化雷达图
*/
const initRadarChart = () => {
if (!radarChartRef.value) return
radarChart = echarts.init(radarChartRef.value)
const option = {
title: {
text: '能力维度雷达图',
left: 'center',
top: 10,
textStyle: {
fontSize: 16,
fontWeight: 600,
color: '#333'
}
},
tooltip: {
trigger: 'item',
formatter: function(params: any) {
return `${params.name}: ${params.value}`
}
},
radar: {
indicator: abilityDimensions.value.map(item => ({
name: item.name,
max: 100
})),
center: ['50%', '55%'],
radius: '70%',
startAngle: 90,
splitNumber: 5,
shape: 'polygon',
name: {
formatter: '{value}',
textStyle: {
color: '#666',
fontSize: 13
}
},
splitArea: {
areaStyle: {
color: ['rgba(102, 126, 234, 0.05)', 'rgba(102, 126, 234, 0.1)', 'rgba(102, 126, 234, 0.15)', 'rgba(102, 126, 234, 0.2)', 'rgba(102, 126, 234, 0.25)']
}
},
axisLine: {
lineStyle: {
color: 'rgba(102, 126, 234, 0.3)'
}
},
splitLine: {
lineStyle: {
color: 'rgba(102, 126, 234, 0.3)'
}
}
},
series: [{
name: '能力评估',
type: 'radar',
data: [{
value: abilityDimensions.value.map(item => item.score),
name: '当前表现',
areaStyle: {
color: 'rgba(102, 126, 234, 0.2)'
},
lineStyle: {
color: '#667eea',
width: 3
},
itemStyle: {
color: '#667eea',
borderWidth: 2,
borderColor: '#fff'
}
}]
}]
}
radarChart.setOption(option)
// 响应式处理
window.addEventListener('resize', () => {
radarChart?.resize()
})
}
/**
* 开始新的练习
*/
const startNewPractice = () => {
router.push('/trainee/ai-practice')
}
/**
* 组件挂载时初始化
*/
onMounted(async () => {
// 1. 从URL获取sessionId从路径参数
const sessionId = route.params.id as string
if (!sessionId) {
ElMessage.error('缺少会话ID')
router.push('/trainee/ai-practice-center')
return
}
console.log('[PracticeReport] 加载报告sessionId=', sessionId)
// 2. 加载报告数据
try {
const response: any = await practiceApi.getPracticeReport(sessionId)
if (response.code === 200 && response.data) {
const data = response.data
// 3. 填充会话信息
const startTime = new Date(data.session_info.start_time)
const durationMin = Math.floor(data.session_info.duration_seconds / 60)
const durationSec = data.session_info.duration_seconds % 60
reportData.value = {
date: startTime.toLocaleString('zh-CN'),
duration: `${durationMin}${durationSec}`,
turns: data.session_info.turns,
totalScore: data.analysis.total_score
}
// 4. 填充分析数据
scoreBreakdown.value = data.analysis.score_breakdown
abilityDimensions.value = data.analysis.ability_dimensions
dialogueRecords.value = data.analysis.dialogue_review
suggestions.value = data.analysis.suggestions
// 5. 初始化图表
nextTick(() => {
initRadarChart()
})
} else {
ElMessage.error('加载报告失败')
router.push('/trainee/ai-practice-center')
}
} catch (error: any) {
console.error('加载报告失败:', error)
ElMessage.error(error.message || '加载报告失败')
router.push('/trainee/ai-practice-center')
}
// 6. 响应式处理
window.addEventListener('resize', handleResize)
})
/**
* 组件卸载时清理资源
*/
onUnmounted(() => {
// 销毁图表实例
radarChart?.dispose()
// 移除事件监听
window.removeEventListener('resize', handleResize)
})
/**
* 窗口大小改变处理
*/
const handleResize = () => {
radarChart?.resize()
}
</script>
<style lang="scss" scoped>
.practice-report-container {
max-width: 1200px;
margin: 0 auto;
.report-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24px;
margin-bottom: 24px;
.header-content {
.report-title {
font-size: 28px;
font-weight: 600;
color: #333;
margin-bottom: 12px;
}
.report-meta {
display: flex;
gap: 24px;
font-size: 14px;
color: #666;
span {
display: flex;
align-items: center;
gap: 6px;
.el-icon {
color: #909399;
}
}
}
}
.header-actions {
display: flex;
gap: 12px;
.el-button--primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
}
}
}
.section-title {
font-size: 20px;
font-weight: 600;
color: #333;
margin-bottom: 24px;
}
.score-section {
padding: 32px;
margin-bottom: 24px;
.score-content {
display: grid;
grid-template-columns: 300px 1fr;
gap: 48px;
.total-score {
text-align: center;
.score-circle {
margin-bottom: 24px;
.score-text {
.score-value {
font-size: 48px;
font-weight: 700;
color: #333;
}
.score-label {
font-size: 14px;
color: #666;
margin-top: 4px;
}
}
}
}
.score-breakdown {
display: grid;
gap: 20px;
.breakdown-item {
.item-header {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
.item-name {
font-size: 15px;
font-weight: 500;
color: #333;
}
.item-score {
font-size: 14px;
color: #667eea;
font-weight: 600;
}
}
.item-desc {
font-size: 13px;
color: #909399;
margin-top: 6px;
}
}
}
}
}
.radar-section {
padding: 32px;
margin-bottom: 24px;
.radar-content {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 48px;
.radar-chart {
height: 400px;
width: 100%;
}
.radar-legend {
.legend-item {
margin-bottom: 24px;
.legend-header {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
.legend-name {
font-size: 15px;
font-weight: 500;
color: #333;
}
.legend-score {
font-size: 14px;
color: #667eea;
font-weight: 600;
}
}
.legend-feedback {
font-size: 13px;
color: #666;
margin-top: 8px;
line-height: 1.5;
}
}
}
}
}
.dialogue-section {
padding: 32px;
margin-bottom: 24px;
.dialogue-controls {
margin-bottom: 24px;
.el-button-group {
.el-button--primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-color: #667eea;
}
}
}
.dialogue-timeline {
.dialogue-item {
display: flex;
gap: 16px;
margin-bottom: 24px;
&.ai {
flex-direction: row-reverse;
.dialogue-content {
background: #f5f7fa;
}
}
.speaker-avatar {
flex-shrink: 0;
}
.dialogue-content {
flex: 1;
max-width: 70%;
background: #fff;
border: 1px solid #ebeef5;
border-radius: 12px;
padding: 16px;
.content-header {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
.speaker-name {
font-size: 14px;
font-weight: 500;
color: #333;
}
.dialogue-time {
font-size: 12px;
color: #909399;
}
}
.content-text {
font-size: 15px;
color: #333;
line-height: 1.6;
}
.content-tags {
margin-top: 12px;
display: flex;
gap: 8px;
}
.content-comment {
margin-top: 12px;
padding: 8px 12px;
background: #f5f7fa;
border-radius: 6px;
font-size: 13px;
color: #666;
display: flex;
align-items: flex-start;
gap: 6px;
.el-icon {
color: #667eea;
margin-top: 2px;
flex-shrink: 0;
}
}
}
}
}
}
.suggestions-section {
padding: 32px;
.suggestions-list {
.suggestion-item {
display: flex;
gap: 20px;
margin-bottom: 24px;
padding: 20px;
background: #f9fafb;
border-radius: 12px;
&:last-child {
margin-bottom: 0;
}
.suggestion-icon {
width: 48px;
height: 48px;
background: white;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.suggestion-content {
flex: 1;
h3 {
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
p {
font-size: 14px;
color: #666;
line-height: 1.6;
margin-bottom: 12px;
}
.suggestion-example {
padding: 12px;
background: white;
border-radius: 8px;
font-size: 13px;
.example-label {
color: #909399;
margin-right: 8px;
}
.example-text {
color: #333;
font-style: italic;
}
}
}
}
}
}
}
// 响应式
@media (max-width: 1024px) {
.practice-report-container {
.report-header {
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
.score-content {
grid-template-columns: 1fr !important;
}
.radar-content {
grid-template-columns: 1fr !important;
}
}
}
@media (max-width: 768px) {
.practice-report-container {
.dialogue-item {
.dialogue-content {
max-width: 85% !important;
}
}
.suggestion-item {
flex-direction: column;
text-align: center;
.suggestion-icon {
margin: 0 auto;
}
}
}
}
</style>

View File

@@ -0,0 +1,790 @@
<template>
<div class="score-query-container">
<div class="page-header">
<h1 class="page-title">查分中心</h1>
<div class="header-actions">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
@change="handleDateChange"
/>
<el-button type="primary" @click="refreshData">
<el-icon class="el-icon--left"><Refresh /></el-icon>
刷新数据
</el-button>
</div>
</div>
<!-- 成绩概览 -->
<div class="score-overview">
<div class="overview-card card">
<div class="card-icon" style="background-color: rgba(102, 126, 234, 0.1)">
<el-icon :size="32" color="#667eea">
<Document />
</el-icon>
</div>
<div class="card-content">
<div class="card-value">{{ overviewData.total_exams || 0 }}</div>
<div class="card-label">总考试次数</div>
</div>
</div>
<div class="overview-card card">
<div class="card-icon" style="background-color: rgba(103, 194, 58, 0.1)">
<el-icon :size="32" color="#67c23a">
<TrendCharts />
</el-icon>
</div>
<div class="card-content">
<div class="card-value">{{ overviewData.avg_score?.toFixed(1) || '0.0' }}</div>
<div class="card-label">平均分</div>
</div>
</div>
<div class="overview-card card">
<div class="card-icon" style="background-color: rgba(230, 162, 60, 0.1)">
<el-icon :size="32" color="#e6a23c">
<CircleCheck />
</el-icon>
</div>
<div class="card-content">
<div class="card-value">{{ (overviewData.pass_rate || 0).toFixed(1) }}%</div>
<div class="card-label">通过率</div>
</div>
</div>
<div class="overview-card card">
<div class="card-icon" style="background-color: rgba(64, 158, 255, 0.1)">
<el-icon :size="32" color="#409eff">
<EditPen />
</el-icon>
</div>
<div class="card-content">
<div class="card-value">{{ overviewData.total_questions || 0 }}</div>
<div class="card-label">答题总数</div>
</div>
</div>
</div>
<!-- 筛选和搜索 -->
<div class="filter-section card">
<el-form :inline="true" :model="filterForm" class="filter-form">
<el-form-item label="课程">
<el-select v-model="filterForm.courseId" placeholder="请选择课程" clearable style="width: 200px">
<el-option label="全部课程" :value="null" />
<el-option
v-for="course in courseList"
:key="course.id"
:label="course.name"
:value="course.id"
/>
</el-select>
</el-form-item>
<el-form-item label="成绩范围">
<el-select v-model="filterForm.scoreRange" placeholder="请选择" clearable style="width: 180px">
<el-option label="全部成绩" value="" />
<el-option label="优秀 (90-100)" value="excellent" />
<el-option label="良好 (80-89)" value="good" />
<el-option label="及格 (60-79)" value="pass" />
<el-option label="不及格 (0-59)" value="fail" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">
<el-icon class="el-icon--left"><Search /></el-icon>
搜索
</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</div>
<!-- 成绩列表 -->
<div class="score-list card">
<el-table
:data="filteredScoreList"
style="width: 100%"
v-loading="loading"
@row-click="viewScoreDetail"
>
<el-table-column prop="exam_name" label="考试名称" min-width="200" />
<el-table-column prop="course_name" label="课程" width="150" />
<el-table-column prop="score" label="得分" width="100" sortable>
<template #default="scope">
<span :class="getScoreClass(scope.row.score)" class="score-text">
{{ scope.row.score ?? '-' }}
</span>
</template>
</el-table-column>
<el-table-column prop="total_score" label="总分" width="80">
<template #default="scope">
<span class="total-score">{{ scope.row.total_score }}</span>
</template>
</el-table-column>
<el-table-column prop="accuracy" label="正确率" width="100" sortable>
<template #default="scope">
<el-progress
v-if="scope.row.accuracy !== null"
:percentage="scope.row.accuracy"
:color="getAccuracyColor(scope.row.accuracy)"
:stroke-width="8"
/>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="duration_seconds" label="用时" width="100">
<template #default="scope">
<span>{{ formatDuration(scope.row.duration_seconds) }}</span>
</template>
</el-table-column>
<el-table-column prop="start_time" label="考试时间" width="180" sortable>
<template #default="scope">
{{ formatDateTime(scope.row.start_time) }}
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="scope">
<el-tag :type="getStatusTag(scope.row.status)">
{{ getStatusText(scope.row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="scope">
<el-button link type="primary" size="small" @click.stop="viewScoreDetail(scope.row)">
<el-icon><View /></el-icon>
查看详情
</el-button>
<el-button link type="danger" size="small" @click.stop="viewMistakes(scope.row)">
<el-icon><Warning /></el-icon>
错题本
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-wrapper">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</div>
<!-- 成绩详情弹窗 -->
<el-dialog
v-model="detailDialogVisible"
:title="`${currentScore?.exam_name} - 成绩详情`"
width="800px"
>
<div class="score-detail" v-if="currentScore">
<!-- 基本信息 -->
<div class="detail-section">
<h3>基本信息</h3>
<el-descriptions :column="2" border>
<el-descriptions-item label="考试名称">{{ currentScore.exam_name }}</el-descriptions-item>
<el-descriptions-item label="课程">{{ currentScore.course_name }}</el-descriptions-item>
<el-descriptions-item label="考试时间">{{ formatDateTime(currentScore.start_time) }}</el-descriptions-item>
<el-descriptions-item label="用时">{{ formatDuration(currentScore.duration_seconds) }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="getStatusTag(currentScore.status)">
{{ getStatusText(currentScore.status) }}
</el-tag>
</el-descriptions-item>
</el-descriptions>
</div>
<!-- 成绩统计 -->
<div class="detail-section">
<h3>成绩统计</h3>
<div class="score-stats">
<div class="stat-item">
<div class="stat-label">总得分</div>
<div class="stat-value score" :class="getScoreClass(currentScore.score)">
{{ currentScore.score ?? '-' }}/{{ currentScore.total_score }}
</div>
</div>
<div class="stat-item">
<div class="stat-label">正确率</div>
<div class="stat-value">
<el-progress
v-if="currentScore.accuracy !== null"
:percentage="currentScore.accuracy"
:color="getAccuracyColor(currentScore.accuracy)"
:stroke-width="12"
/>
<span v-else>-</span>
</div>
</div>
<div class="stat-item">
<div class="stat-label">题目统计</div>
<div class="stat-value">
<div class="question-stats">
<span class="correct">正确: {{ currentScore.correct_count ?? 0 }}</span>
<span class="wrong">错误: {{ currentScore.wrong_count ?? 0 }}</span>
<span class="total">总计: {{ currentScore.question_count }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- 分题型统计 -->
<div class="detail-section" v-if="currentScore.question_type_stats && currentScore.question_type_stats.length > 0">
<h3>分题型统计</h3>
<el-table :data="currentScore.question_type_stats" size="small">
<el-table-column prop="type" label="题型" width="120">
<template #default="scope">
<el-tag size="small">{{ scope.row.type }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="total" label="总题数" width="100" />
<el-table-column prop="correct" label="正确数" width="100" />
<el-table-column prop="wrong" label="错误数" width="100" />
<el-table-column prop="accuracy" label="正确率" width="150">
<template #default="scope">
<el-progress
:percentage="scope.row.accuracy"
:stroke-width="6"
:show-text="false"
/>
<span style="margin-left: 8px;">{{ scope.row.accuracy }}%</span>
</template>
</el-table-column>
</el-table>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
getExamRecords,
getExamStatistics,
getCourseList,
type ExamRecord,
type ExamStatistics as ExamStatsType
} from '@/api/score'
const router = useRouter()
// 加载状态
const loading = ref(false)
// 日期范围
const dateRange = ref<[Date, Date]>([
new Date(new Date().setDate(new Date().getDate() - 30)),
new Date()
])
// 分页
const currentPage = ref(1)
const pageSize = ref(20)
const total = ref(0)
// 弹窗状态
const detailDialogVisible = ref(false)
const currentScore = ref<ExamRecord | null>(null)
// 筛选表单
const filterForm = reactive({
courseId: null as number | null,
scoreRange: ''
})
// 概览数据
const overviewData = ref<ExamStatsType>({
total_exams: 0,
avg_score: 0,
pass_rate: 0,
total_questions: 0
})
// 课程列表
const courseList = ref<any[]>([])
// 成绩列表数据
const scoreList = ref<ExamRecord[]>([])
// 筛选后的成绩列表(前端筛选成绩范围)
const filteredScoreList = computed(() => {
let filtered = scoreList.value
// 按成绩范围筛选(前端过滤)
if (filterForm.scoreRange) {
filtered = filtered.filter(item => {
const score = item.score
if (score === null) return false
switch (filterForm.scoreRange) {
case 'excellent':
return score >= 90
case 'good':
return score >= 80 && score < 90
case 'pass':
return score >= 60 && score < 80
case 'fail':
return score < 60
default:
return true
}
})
}
return filtered
})
/**
* 获取分数样式类
*/
const getScoreClass = (score: number | null) => {
if (score === null) return ''
if (score >= 90) return 'score-excellent'
if (score >= 80) return 'score-good'
if (score >= 60) return 'score-pass'
return 'score-fail'
}
/**
* 获取正确率颜色
*/
const getAccuracyColor = (accuracy: number) => {
if (accuracy >= 90) return '#67c23a'
if (accuracy >= 80) return '#409eff'
if (accuracy >= 60) return '#e6a23c'
return '#f56c6c'
}
/**
* 获取状态标签样式
*/
const getStatusTag = (status: string) => {
const tagMap: Record<string, string> = {
completed: 'success',
submitted: 'success',
in_progress: 'warning',
started: 'warning',
timeout: 'info',
failed: 'danger'
}
return tagMap[status] || ''
}
/**
* 获取状态文本
*/
const getStatusText = (status: string) => {
const textMap: Record<string, string> = {
completed: '已完成',
submitted: '已提交',
in_progress: '进行中',
started: '进行中',
timeout: '超时',
failed: '已失败'
}
return textMap[status] || status
}
/**
* 格式化时长
*/
const formatDuration = (seconds: number | null) => {
if (!seconds) return '-'
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const secs = seconds % 60
if (hours > 0) {
return `${hours}小时${minutes}分钟`
} else if (minutes > 0) {
return `${minutes}分钟${secs}`
} else {
return `${secs}`
}
}
/**
* 格式化日期时间
*/
const formatDateTime = (dateStr: string | null) => {
if (!dateStr) return '-'
return dateStr.replace('T', ' ').substring(0, 19)
}
/**
* 格式化日期为字符串
*/
const formatDate = (date: Date) => {
return date.toISOString().split('T')[0]
}
/**
* 加载概览数据
*/
const loadOverviewData = async () => {
try {
const params: any = {}
if (filterForm.courseId) {
params.course_id = filterForm.courseId
}
const res = await getExamStatistics(params)
if (res.code === 200) {
overviewData.value = res.data
}
} catch (error) {
console.error('获取概览数据失败:', error)
}
}
/**
* 加载成绩列表
*/
const loadScoreList = async () => {
loading.value = true
try {
const params: any = {
page: currentPage.value,
size: pageSize.value
}
if (filterForm.courseId) {
params.course_id = filterForm.courseId
}
if (dateRange.value && dateRange.value.length === 2) {
params.start_date = formatDate(dateRange.value[0])
params.end_date = formatDate(dateRange.value[1])
}
const res = await getExamRecords(params)
if (res.code === 200) {
scoreList.value = res.data.items
total.value = res.data.total
}
} catch (error) {
console.error('获取成绩列表失败:', error)
ElMessage.error('获取成绩列表失败')
} finally {
loading.value = false
}
}
/**
* 加载课程列表
*/
const loadCourseList = async () => {
try {
const res = await getCourseList()
if (res.code === 200) {
courseList.value = res.data.items || []
}
} catch (error) {
console.error('获取课程列表失败:', error)
}
}
/**
* 日期变化处理
*/
const handleDateChange = () => {
handleSearch()
}
/**
* 刷新数据
*/
const refreshData = async () => {
await Promise.all([loadOverviewData(), loadScoreList()])
ElMessage.success('数据已刷新')
}
/**
* 搜索处理
*/
const handleSearch = () => {
currentPage.value = 1
loadOverviewData()
loadScoreList()
}
/**
* 重置筛选
*/
const handleReset = () => {
filterForm.courseId = null
filterForm.scoreRange = ''
dateRange.value = [
new Date(new Date().setDate(new Date().getDate() - 30)),
new Date()
]
handleSearch()
}
/**
* 分页大小改变
*/
const handleSizeChange = (val: number) => {
pageSize.value = val
currentPage.value = 1
loadScoreList()
}
/**
* 当前页改变
*/
const handleCurrentChange = (val: number) => {
currentPage.value = val
loadScoreList()
}
/**
* 查看成绩详情
*/
const viewScoreDetail = (score: ExamRecord) => {
currentScore.value = score
detailDialogVisible.value = true
}
/**
* 查看错题本
*/
const viewMistakes = (exam: ExamRecord) => {
// 跳转到错题本页面并带上课程ID和考试ID筛选
router.push({
path: '/analysis/mistakes',
query: {
course_id: exam.course_id,
exam_id: exam.id
}
})
}
// 组件挂载时初始化数据
onMounted(() => {
loadCourseList()
loadOverviewData()
loadScoreList()
})
</script>
<style lang="scss" scoped>
.score-query-container {
max-width: 1400px;
margin: 0 auto;
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
.page-title {
font-size: 24px;
font-weight: 600;
color: #333;
}
.header-actions {
display: flex;
gap: 12px;
}
}
.card {
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.score-overview {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
margin-bottom: 24px;
.overview-card {
padding: 24px;
display: flex;
align-items: center;
gap: 20px;
.card-icon {
width: 64px;
height: 64px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.card-content {
flex: 1;
.card-value {
font-size: 32px;
font-weight: 700;
color: #333;
line-height: 1;
margin-bottom: 8px;
}
.card-label {
font-size: 14px;
color: #666;
}
}
}
}
.filter-section {
padding: 20px;
margin-bottom: 20px;
.filter-form {
.el-form-item {
margin-bottom: 0;
}
}
}
.score-list {
padding: 24px;
.score-text {
font-weight: 600;
font-size: 16px;
&.score-excellent {
color: #67c23a;
}
&.score-good {
color: #409eff;
}
&.score-pass {
color: #e6a23c;
}
&.score-fail {
color: #f56c6c;
}
}
.total-score {
color: #666;
font-size: 14px;
}
.pagination-wrapper {
display: flex;
justify-content: center;
margin-top: 24px;
}
}
.score-detail {
.detail-section {
margin-bottom: 24px;
h3 {
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 16px;
padding-bottom: 8px;
border-bottom: 2px solid #f0f0f0;
}
}
.score-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
.stat-item {
text-align: center;
padding: 20px;
background: #f8f9fa;
border-radius: 8px;
.stat-label {
font-size: 14px;
color: #666;
margin-bottom: 12px;
}
.stat-value {
&.score {
font-size: 24px;
font-weight: 700;
}
.question-stats {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 14px;
.correct {
color: #67c23a;
}
.wrong {
color: #f56c6c;
}
.total {
color: #333;
font-weight: 500;
}
}
}
}
}
}
}
// 响应式设计
@media (max-width: 768px) {
.score-query-container {
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 16px;
.header-actions {
width: 100%;
justify-content: flex-end;
}
}
.score-overview {
grid-template-columns: 1fr;
}
.filter-form {
.el-form-item {
display: block;
margin-bottom: 16px !important;
}
}
.score-stats {
grid-template-columns: 1fr;
}
}
}
</style>