guoyu/fronted_uniapp/pages/voice/evaluation.vue

950 lines
30 KiB
Vue
Raw Normal View History

2025-12-03 18:58:36 +08:00
<template>
<view class="voice-evaluation-container">
<!-- 顶部导航栏 -->
<custom-navbar title="语音评测"></custom-navbar>
<!-- 评测内容显示 -->
<view class="content-section">
<view class="section-header">
<text class="section-title">📝 评测内容</text>
<text class="edit-btn" v-if="content" @click="editContent">编辑</text>
</view>
<view class="content-text" v-if="content">
<text>{{ content }}</text>
</view>
<view class="content-input" v-else>
<textarea
v-model="inputContent"
placeholder="请输入要评测的课文或文字内容建议50-200字"
maxlength="500"
class="textarea"
:auto-height="true"
></textarea>
<view class="char-count">{{ inputContent.length }}/500</view>
<button @click="setContent" class="btn-primary" :disabled="!inputContent.trim()">确定</button>
</view>
</view>
<!-- 录音控制 -->
<view class="record-section">
<view class="section-header">
<text class="section-title">🎤 语音录制</text>
</view>
<view class="record-controls">
<!-- 录音波形动画 -->
<view class="wave-container" v-if="isRecording">
<view class="wave-item" v-for="(item, index) in waveData" :key="index"
:style="{ height: item + 'rpx' }"></view>
</view>
<view class="record-time" v-if="isRecording">
<text class="time-text">录制中{{ formatTime(recordTime) }}</text>
</view>
<view class="record-buttons">
<button
v-if="!isRecording && !recordPath"
@click="startRecord"
class="btn-record"
:disabled="!content"
>
<text class="btn-icon">🎤</text>
<text>开始录音</text>
</button>
<button
v-if="isRecording"
@click="stopRecord"
class="btn-stop"
>
<text class="btn-icon"></text>
<text>停止录音</text>
</button>
<button
v-if="recordPath && !isRecording"
@click="playRecord"
class="btn-play"
:disabled="isPlaying"
>
<text class="btn-icon">{{ isPlaying ? '⏸' : '▶' }}</text>
<text>{{ isPlaying ? '播放中' : '播放录音' }}</text>
</button>
<button
v-if="recordPath && !isRecording"
@click="reRecord"
class="btn-rerecord"
>
<text class="btn-icon">🔄</text>
<text>重新录制</text>
</button>
</view>
</view>
</view>
<!-- 评测结果 -->
<view class="result-section" v-if="evaluationResult">
<view class="section-header">
<text class="section-title">📊 评测结果</text>
<view class="header-actions">
<text class="view-detail-btn" @click="viewCurrentDetail">查看详情</text>
<text class="submit-status" v-if="isSubmitted"> 已提交</text>
</view>
</view>
<view class="result-main-score">
<text class="main-score-value">{{ evaluationResult.score || 0 }}</text>
<text class="main-score-label">总分</text>
</view>
<view class="result-scores">
<view class="score-item">
<view class="score-progress">
<view class="score-progress-bar" :style="{ width: (evaluationResult.accuracy || 0) + '%' }"></view>
</view>
<text class="score-label">准确度</text>
<text class="score-value">{{ evaluationResult.accuracy || 0 }}</text>
</view>
<view class="score-item">
<view class="score-progress">
<view class="score-progress-bar" :style="{ width: (evaluationResult.fluency || 0) + '%' }"></view>
</view>
<text class="score-label">流畅度</text>
<text class="score-value">{{ evaluationResult.fluency || 0 }}</text>
</view>
<view class="score-item">
<view class="score-progress">
<view class="score-progress-bar" :style="{ width: (evaluationResult.completeness || 0) + '%' }"></view>
</view>
<text class="score-label">完整度</text>
<text class="score-value">{{ evaluationResult.completeness || 0 }}</text>
</view>
<view class="score-item">
<view class="score-progress">
<view class="score-progress-bar" :style="{ width: (evaluationResult.pronunciation || 0) + '%' }"></view>
</view>
<text class="score-label">发音</text>
<text class="score-value">{{ evaluationResult.pronunciation || 0 }}</text>
</view>
</view>
<!-- 提交按钮 -->
<view class="submit-section" v-if="currentEvaluationId && evaluationResult && !isSubmitted">
<button
@click="submitEvaluation"
class="btn-submit"
:loading="isSubmitting"
>
{{ isSubmitting ? '提交中...' : '提交评测' }}
</button>
</view>
</view>
<!-- 评测按钮 -->
<view class="evaluate-section" v-if="recordPath && !evaluationResult">
<button
@click="evaluate"
class="btn-evaluate"
:loading="isEvaluating"
>
{{ isEvaluating ? '评测中...' : '开始评测' }}
</button>
</view>
<!-- 历史记录 -->
<view class="history-section">
<view class="section-header">
<text class="section-title">📋 历史记录</text>
<text class="refresh-btn" @click="loadHistory">刷新</text>
</view>
<view class="history-list" v-if="historyList.length > 0">
<view
v-for="(item, index) in historyList"
:key="item.id || index"
class="history-item"
@click="viewDetail(item)"
>
<view class="history-left">
<view class="history-content">{{ item.content ? (item.content.length > 30 ? item.content.substring(0, 30) + '...' : item.content) : '无内容' }}</view>
<view class="history-time">{{ formatDateTime(item.evaluationTime) }}</view>
</view>
<view class="history-right">
<view class="history-score">{{ item.score || 0 }}</view>
<text class="arrow"></text>
</view>
</view>
</view>
<view v-else class="empty-tip">
<text class="empty-icon">📭</text>
<text>暂无历史记录</text>
</view>
</view>
</view>
</template>
<script>
import { uploadAndEvaluate, getMyVoiceRecords, submitVoiceEvaluation } from '@/api/study/voiceEvaluation.js'
import CustomNavbar from '@/components/custom-navbar/custom-navbar.vue'
export default {
components: {
CustomNavbar
},
data() {
return {
courseId: null,
courseName: '',
content: '',
inputContent: '',
isRecording: false,
recordTime: 0,
recordTimer: null,
recordPath: '',
recorderManager: null,
audioContext: null,
isPlaying: false,
isEvaluating: false,
evaluationResult: null,
currentEvaluationId: null,
isSubmitted: false, // 是否已提交
isSubmitting: false, // 是否正在提交
currentEvaluationAudioPath: null, // 当前评测记录的音频路径
historyList: [],
waveData: [20, 30, 25, 35, 28, 32, 26, 30], // 波形数据
waveTimer: null
}
},
onLoad(options) {
if (options.courseId) {
this.courseId = parseInt(options.courseId)
}
if (options.courseName) {
this.courseName = decodeURIComponent(options.courseName)
}
if (options.content) {
this.content = decodeURIComponent(options.content)
}
this.initRecorder()
this.loadHistory()
},
onUnload() {
if (this.recordTimer) {
clearInterval(this.recordTimer)
}
if (this.waveTimer) {
clearInterval(this.waveTimer)
}
if (this.recorderManager) {
this.recorderManager.stop()
}
if (this.audioContext) {
this.audioContext.destroy()
}
},
methods: {
// 初始化录音管理器
initRecorder() {
this.recorderManager = uni.getRecorderManager()
this.recorderManager.onStart(() => {
console.log('录音开始')
this.isRecording = true
this.recordTime = 0
this.recordTimer = setInterval(() => {
this.recordTime++
}, 1000)
// 启动波形动画
this.startWaveAnimation()
})
this.recorderManager.onStop((res) => {
console.log('录音结束', res)
this.isRecording = false
if (this.recordTimer) {
clearInterval(this.recordTimer)
}
this.stopWaveAnimation()
if (res.tempFilePath) {
this.recordPath = res.tempFilePath
uni.showToast({
title: '录音完成',
icon: 'success',
duration: 1500
})
}
})
this.recorderManager.onError((err) => {
console.error('录音错误', err)
uni.showToast({
title: '录音失败:' + err.errMsg,
icon: 'none',
duration: 2000
})
this.isRecording = false
if (this.recordTimer) {
clearInterval(this.recordTimer)
}
this.stopWaveAnimation()
})
},
// 启动波形动画
startWaveAnimation() {
this.waveTimer = setInterval(() => {
this.waveData = this.waveData.map(() => Math.random() * 30 + 20)
}, 200)
},
// 停止波形动画
stopWaveAnimation() {
if (this.waveTimer) {
clearInterval(this.waveTimer)
this.waveTimer = null
}
this.waveData = [20, 30, 25, 35, 28, 32, 26, 30]
},
// 设置评测内容
setContent() {
if (!this.inputContent.trim()) {
uni.showToast({
title: '请输入评测内容',
icon: 'none'
})
return
}
this.content = this.inputContent.trim()
this.inputContent = ''
// 重置录音和评测结果
this.recordPath = ''
this.evaluationResult = null
},
// 编辑内容
editContent() {
this.inputContent = this.content
this.content = ''
},
// 开始录音
startRecord() {
if (!this.content) {
uni.showToast({
title: '请先设置评测内容',
icon: 'none'
})
return
}
this.recorderManager.start({
duration: 60000, // 最长60秒
sampleRate: 16000,
numberOfChannels: 1,
encodeBitRate: 96000,
format: 'mp3'
})
},
// 停止录音
stopRecord() {
this.recorderManager.stop()
},
// 播放录音
playRecord() {
if (this.audioContext) {
this.audioContext.destroy()
}
this.audioContext = uni.createInnerAudioContext()
this.audioContext.src = this.recordPath
this.isPlaying = true
this.audioContext.play()
this.audioContext.onEnded(() => {
this.isPlaying = false
this.audioContext.destroy()
this.audioContext = null
})
this.audioContext.onError((err) => {
console.error('播放失败', err)
this.isPlaying = false
uni.showToast({
title: '播放失败',
icon: 'none'
})
if (this.audioContext) {
this.audioContext.destroy()
this.audioContext = null
}
})
},
// 重新录制
reRecord() {
this.recordPath = ''
this.evaluationResult = null
this.currentEvaluationId = null
this.isSubmitted = false
},
// 开始评测
async evaluate() {
if (!this.recordPath) {
uni.showToast({
title: '请先录制语音',
icon: 'none',
duration: 2000
})
return
}
if (!this.content) {
uni.showToast({
title: '请先设置评测内容',
icon: 'none',
duration: 2000
})
return
}
// 检查登录状态
const token = uni.getStorageSync('token')
if (!token) {
uni.showModal({
title: '提示',
content: '请先登录',
showCancel: false,
success: (res) => {
if (res.confirm) {
uni.reLaunch({
url: '/pages/login/login'
})
}
}
})
return
}
this.isEvaluating = true
try {
const result = await uploadAndEvaluate(
this.recordPath,
this.content,
this.courseId,
'zh-CN'
)
if (result.code === 200 && result.data && result.data.evaluation) {
this.evaluationResult = {
score: result.data.evaluation.score,
accuracy: result.data.evaluation.accuracy,
fluency: result.data.evaluation.fluency,
completeness: result.data.evaluation.completeness,
pronunciation: result.data.evaluation.pronunciation
}
this.currentEvaluationId = result.data.evaluation.id
this.currentEvaluationAudioPath = result.data.evaluation.audioPath || null
// 检查是否已提交处理null、0、1、true等情况
const submitted = result.data.evaluation.isSubmitted
this.isSubmitted = submitted === 1 || submitted === true || submitted === '1'
console.log('评测完成评测ID:', this.currentEvaluationId, '提交状态:', this.isSubmitted, '音频路径:', this.currentEvaluationAudioPath)
uni.showToast({
title: '评测完成',
icon: 'success',
duration: 2000
})
// 刷新历史记录
this.loadHistory()
// 滚动到结果区域
this.$nextTick(() => {
uni.pageScrollTo({
selector: '.result-section',
duration: 300
})
})
} else {
throw new Error(result.msg || '评测失败')
}
} catch (error) {
console.error('评测失败', error)
let errorMsg = error.message || '评测失败'
// 如果是登录相关错误,跳转到登录页
if (errorMsg.includes('登录') || errorMsg.includes('401')) {
uni.showModal({
title: '提示',
content: errorMsg,
showCancel: false,
success: (res) => {
if (res.confirm) {
uni.reLaunch({
url: '/pages/login/login'
})
}
}
})
} else {
uni.showToast({
title: errorMsg,
icon: 'none',
duration: 3000
})
}
} finally {
this.isEvaluating = false
}
},
// 加载历史记录
async loadHistory() {
try {
const result = await getMyVoiceRecords(this.courseId)
if (result.code === 200 && result.data) {
this.historyList = result.data || []
// 如果当前评测ID在历史记录中更新提交状态和音频路径
if (this.currentEvaluationId) {
const currentRecord = this.historyList.find(item => item.id === this.currentEvaluationId)
if (currentRecord) {
const submitted = currentRecord.isSubmitted
this.isSubmitted = submitted === 1 || submitted === true || submitted === '1'
this.currentEvaluationAudioPath = currentRecord.audioPath || null
}
}
} else {
console.warn('加载历史记录失败', result.msg)
}
} catch (error) {
console.error('加载历史记录失败', error)
// 如果是401错误不显示错误提示可能是未登录
if (!error.message || !error.message.includes('401')) {
// 静默失败,不打扰用户
}
}
},
// 查看详情
viewDetail(item) {
uni.navigateTo({
url: `/pages/voice/detail?id=${item.id}`
})
},
// 查看当前评测详情
viewCurrentDetail() {
if (this.currentEvaluationId) {
uni.navigateTo({
url: `/pages/voice/detail?id=${this.currentEvaluationId}`
})
} else {
uni.showToast({
title: '暂无评测详情',
icon: 'none'
})
}
},
// 提交评测
async submitEvaluation() {
if (!this.currentEvaluationId) {
uni.showToast({
title: '请先完成评测',
icon: 'none'
})
return
}
if (this.isSubmitted) {
uni.showToast({
title: '该评测已提交',
icon: 'none'
})
return
}
// 确认提交
const hasAudio = this.recordPath || this.currentEvaluationAudioPath
uni.showModal({
title: '确认提交',
content: hasAudio ?
'提交后,管理员将看到您的评测结果和音频文件,是否确认提交?' :
'提交后,管理员将看到您的评测结果,是否确认提交?',
success: async (res) => {
if (res.confirm) {
this.isSubmitting = true
try {
// 如果有录音文件,自动上传音频;否则使用已有的音频路径或不上传
const audioFilePath = this.recordPath || null
const result = await submitVoiceEvaluation(this.currentEvaluationId, audioFilePath)
if (result.code === 200) {
this.isSubmitted = true
uni.showToast({
title: audioFilePath ? '提交成功(已上传音频)' : '提交成功',
icon: 'success',
duration: 2000
})
// 刷新历史记录
this.loadHistory()
} else {
throw new Error(result.msg || '提交失败')
}
} catch (error) {
console.error('提交失败', error)
uni.showToast({
title: error.message || '提交失败',
icon: 'none',
duration: 3000
})
} finally {
this.isSubmitting = false
}
}
}
})
},
// 格式化时间
formatTime(seconds) {
const min = Math.floor(seconds / 60)
const sec = seconds % 60
return `${min.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`
},
// 格式化日期时间
formatDateTime(dateTime) {
if (!dateTime) return ''
const date = new Date(dateTime)
const year = date.getFullYear()
const month = (date.getMonth() + 1).toString().padStart(2, '0')
const day = date.getDate().toString().padStart(2, '0')
const hour = date.getHours().toString().padStart(2, '0')
const minute = date.getMinutes().toString().padStart(2, '0')
return `${year}-${month}-${day} ${hour}:${minute}`
}
}
}
</script>
<style scoped>
.voice-evaluation-container {
padding: 20rpx;
padding-top: calc(20rpx + 88rpx + env(safe-area-inset-top));
background-color: #f5f5f5;
min-height: 100vh;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 40rpx 30rpx;
margin-bottom: 20rpx;
border-radius: 0 0 20rpx 20rpx;
}
.header-content {
display: flex;
flex-direction: column;
}
.title {
font-size: 40rpx;
font-weight: bold;
color: #fff;
display: block;
margin-bottom: 10rpx;
}
.subtitle {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.9);
display: block;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
}
.edit-btn, .refresh-btn, .view-detail-btn {
font-size: 24rpx;
color: #409eff;
padding: 8rpx 16rpx;
border: 1px solid #409eff;
border-radius: 20rpx;
}
.content-section, .record-section, .result-section, .evaluate-section, .history-section {
background-color: #fff;
padding: 30rpx;
margin-bottom: 20rpx;
border-radius: 10rpx;
}
.section-title {
font-size: 32rpx;
font-weight: bold;
color: #333;
margin-bottom: 20rpx;
}
.content-text {
font-size: 30rpx;
line-height: 1.8;
color: #333;
padding: 30rpx;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
border-radius: 12rpx;
min-height: 200rpx;
border-left: 4rpx solid #409eff;
}
.char-count {
text-align: right;
font-size: 24rpx;
color: #999;
margin-bottom: 20rpx;
}
.content-input {
margin-top: 20rpx;
}
.textarea {
width: 100%;
min-height: 200rpx;
padding: 20rpx;
border: 1px solid #ddd;
border-radius: 8rpx;
font-size: 28rpx;
margin-bottom: 20rpx;
}
.btn-primary {
background-color: #409eff;
color: #fff;
border: none;
padding: 20rpx 40rpx;
border-radius: 8rpx;
font-size: 28rpx;
}
.wave-container {
display: flex;
justify-content: center;
align-items: center;
height: 120rpx;
margin: 30rpx 0;
gap: 8rpx;
}
.wave-item {
width: 8rpx;
background: linear-gradient(180deg, #667eea 0%, #764ba2 100%);
border-radius: 4rpx;
transition: height 0.2s;
}
.record-time {
text-align: center;
margin-bottom: 20rpx;
}
.time-text {
font-size: 32rpx;
color: #f56c6c;
font-weight: bold;
}
.record-buttons {
display: flex;
justify-content: center;
gap: 20rpx;
flex-wrap: wrap;
}
.btn-record, .btn-stop, .btn-play, .btn-rerecord {
padding: 24rpx 40rpx;
border-radius: 50rpx;
font-size: 28rpx;
border: none;
display: flex;
align-items: center;
gap: 10rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
}
.btn-icon {
font-size: 32rpx;
}
.btn-record {
background-color: #67c23a;
color: #fff;
}
.btn-stop {
background-color: #f56c6c;
color: #fff;
}
.btn-play {
background-color: #409eff;
color: #fff;
}
.btn-rerecord {
background-color: #909399;
color: #fff;
}
.result-main-score {
text-align: center;
padding: 40rpx 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 12rpx;
margin-bottom: 30rpx;
}
.main-score-value {
display: block;
font-size: 80rpx;
font-weight: bold;
color: #fff;
line-height: 1;
}
.main-score-label {
display: block;
font-size: 28rpx;
color: rgba(255, 255, 255, 0.9);
margin-top: 10rpx;
}
.result-scores {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20rpx;
}
.score-item {
padding: 24rpx;
background-color: #f9f9f9;
border-radius: 12rpx;
border: 1px solid #eee;
}
.score-progress {
width: 100%;
height: 8rpx;
background-color: #e4e7ed;
border-radius: 4rpx;
margin-bottom: 16rpx;
overflow: hidden;
}
.score-progress-bar {
height: 100%;
background: linear-gradient(90deg, #67c23a 0%, #85ce61 100%);
border-radius: 4rpx;
transition: width 0.3s;
}
.score-label {
display: block;
font-size: 24rpx;
color: #666;
margin-bottom: 8rpx;
}
.score-value {
display: block;
font-size: 32rpx;
font-weight: bold;
color: #409eff;
}
.btn-evaluate {
width: 100%;
background-color: #409eff;
color: #fff;
border: none;
padding: 30rpx;
border-radius: 8rpx;
font-size: 32rpx;
}
.history-list {
margin-top: 20rpx;
}
.history-item {
padding: 24rpx;
border-bottom: 1px solid #eee;
display: flex;
justify-content: space-between;
align-items: center;
background-color: #fff;
border-radius: 8rpx;
margin-bottom: 16rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
}
.history-left {
flex: 1;
margin-right: 20rpx;
}
.history-content {
font-size: 28rpx;
color: #333;
margin-bottom: 10rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.history-time {
font-size: 24rpx;
color: #999;
}
.history-right {
display: flex;
align-items: center;
gap: 10rpx;
}
.history-score {
font-size: 32rpx;
font-weight: bold;
color: #409eff;
}
.arrow {
font-size: 40rpx;
color: #ccc;
}
.empty-tip {
text-align: center;
padding: 80rpx 40rpx;
color: #999;
font-size: 28rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 20rpx;
}
.empty-icon {
font-size: 80rpx;
}
.header-actions {
display: flex;
align-items: center;
gap: 20rpx;
}
.submit-status {
font-size: 24rpx;
color: #67c23a;
padding: 8rpx 16rpx;
background: rgba(103, 194, 58, 0.1);
border-radius: 20rpx;
border: 1px solid #67c23a;
}
.submit-section {
margin-top: 30rpx;
padding-top: 30rpx;
border-top: 1px solid #eee;
}
.btn-submit {
width: 100%;
background: linear-gradient(135deg, #67c23a 0%, #85ce61 100%);
color: #fff;
border: none;
padding: 30rpx;
border-radius: 8rpx;
font-size: 32rpx;
font-weight: bold;
box-shadow: 0 4rpx 12rpx rgba(103, 194, 58, 0.3);
}
</style>