guoyu/fronted_uniapp/pages/voice/evaluation.vue
2025-12-03 18:58:36 +08:00

950 lines
30 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<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>