feat: 初始化考培练系统项目
- 从服务器拉取完整代码 - 按框架规范整理项目结构 - 配置 Drone CI 测试环境部署 - 包含后端(FastAPI)、前端(Vue3)、管理端 技术栈: Vue3 + TypeScript + FastAPI + MySQL
This commit is contained in:
1151
frontend/src/views/trainee/ai-practice-center.vue
Normal file
1151
frontend/src/views/trainee/ai-practice-center.vue
Normal file
File diff suppressed because it is too large
Load Diff
754
frontend/src/views/trainee/ai-practice-coze.vue
Normal file
754
frontend/src/views/trainee/ai-practice-coze.vue
Normal 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>
|
||||
119
frontend/src/views/trainee/ai-practice.vue
Normal file
119
frontend/src/views/trainee/ai-practice.vue
Normal 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>
|
||||
638
frontend/src/views/trainee/audio-player.vue
Normal file
638
frontend/src/views/trainee/audio-player.vue
Normal 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>
|
||||
314
frontend/src/views/trainee/broadcast-course.vue
Normal file
314
frontend/src/views/trainee/broadcast-course.vue
Normal 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>
|
||||
|
||||
1015
frontend/src/views/trainee/chat-course.vue
Normal file
1015
frontend/src/views/trainee/chat-course.vue
Normal file
File diff suppressed because it is too large
Load Diff
914
frontend/src/views/trainee/course-center.vue
Normal file
914
frontend/src/views/trainee/course-center.vue
Normal 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>
|
||||
1298
frontend/src/views/trainee/course-detail.vue
Normal file
1298
frontend/src/views/trainee/course-detail.vue
Normal file
File diff suppressed because it is too large
Load Diff
810
frontend/src/views/trainee/exam-result.vue
Normal file
810
frontend/src/views/trainee/exam-result.vue
Normal 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>
|
||||
2259
frontend/src/views/trainee/growth-path.vue
Normal file
2259
frontend/src/views/trainee/growth-path.vue
Normal file
File diff suppressed because it is too large
Load Diff
1035
frontend/src/views/trainee/practice-records.vue
Normal file
1035
frontend/src/views/trainee/practice-records.vue
Normal file
File diff suppressed because it is too large
Load Diff
913
frontend/src/views/trainee/practice-report.vue
Normal file
913
frontend/src/views/trainee/practice-report.vue
Normal 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>
|
||||
790
frontend/src/views/trainee/score-query.vue
Normal file
790
frontend/src/views/trainee/score-query.vue
Normal 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>
|
||||
Reference in New Issue
Block a user