1307 lines
45 KiB
Vue
1307 lines
45 KiB
Vue
<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="format-selector" v-if="!isRecording">
|
||
<view class="format-header">
|
||
<view class="format-label">测试格式:</view>
|
||
<text class="format-tip">👇 点击切换格式测试</text>
|
||
</view>
|
||
<view class="format-pickers">
|
||
<picker mode="selector" :range="formatOptions" range-key="label" @change="onFormatChange" :value="currentFormatIndex">
|
||
<view class="picker-value">
|
||
{{ currentFormatLabel }}
|
||
</view>
|
||
</picker>
|
||
</view>
|
||
<!-- 测试结果提示 -->
|
||
<view class="test-results" v-if="Object.keys(testResults).length > 0">
|
||
<text class="results-title">测试记录:</text>
|
||
<view class="result-item" v-for="(result, key) in testResults" :key="key">
|
||
<text :class="result.success ? 'success' : 'failed'">
|
||
{{ key }}: {{ result.success ? '✓ 成功' : '✗ 失败' }}
|
||
({{ (result.bitrate/1024).toFixed(1) }}KB/秒)
|
||
</text>
|
||
</view>
|
||
</view>
|
||
</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'
|
||
import aacRecorder from '@/utils/aac-recorder.js'
|
||
|
||
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,
|
||
recordDiagnostic: null, // 录音诊断信息
|
||
evaluationResult: null,
|
||
currentEvaluationId: null,
|
||
isSubmitted: false, // 是否已提交
|
||
isSubmitting: false, // 是否正在提交
|
||
currentEvaluationAudioPath: null, // 当前评测记录的音频路径
|
||
historyList: [],
|
||
waveData: [20, 30, 25, 35, 28, 32, 26, 30], // 波形数据
|
||
waveTimer: null,
|
||
// 录音格式测试配置
|
||
selectedFormat: 'wav', // 默认wav(最兼容)
|
||
selectedSampleRate: 8000, // 默认8000(最兼容)
|
||
formatOptions: [
|
||
{ value: 'wav', label: 'WAV 8K(最兼容)', rates: [8000] },
|
||
{ value: 'wav', label: 'WAV 16K(推荐)', rates: [16000] },
|
||
{ value: 'pcm', label: 'PCM 8K', rates: [8000] },
|
||
{ value: 'pcm', label: 'PCM 16K', rates: [16000] },
|
||
{ value: 'm4a', label: 'M4A(自适应)', rates: [] },
|
||
{ value: 'mp3', label: 'MP3 16K', rates: [16000] }
|
||
],
|
||
showFormatPicker: false,
|
||
testResults: {}, // 记录每种格式的测试结果
|
||
recordFileSize: 0, // 录音文件大小
|
||
recordDuration: 0 // 录音时长
|
||
}
|
||
},
|
||
computed: {
|
||
// 当前选中格式的索引
|
||
currentFormatIndex() {
|
||
// 查找匹配格式和采样率的选项
|
||
return this.formatOptions.findIndex(item => {
|
||
if (item.rates.length === 0) {
|
||
// m4a等自适应格式,只匹配格式
|
||
return item.value === this.selectedFormat
|
||
} else {
|
||
// 需要同时匹配格式和采样率
|
||
return item.value === this.selectedFormat && item.rates[0] === this.selectedSampleRate
|
||
}
|
||
})
|
||
},
|
||
// 当前选中格式的标签
|
||
currentFormatLabel() {
|
||
const index = this.currentFormatIndex
|
||
return index >= 0 ? this.formatOptions[index].label : 'WAV 8K(最兼容)'
|
||
}
|
||
},
|
||
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: {
|
||
// 检查录音权限
|
||
checkRecordPermission() {
|
||
return new Promise((resolve) => {
|
||
// #ifdef APP-PLUS
|
||
const permissions = ['android.permission.RECORD_AUDIO']
|
||
const hasPermission = plus.android.checkPermission(permissions[0])
|
||
if (hasPermission === 0) {
|
||
plus.android.requestPermissions(
|
||
permissions,
|
||
(result) => {
|
||
const granted = result.granted && result.granted.length > 0
|
||
if (!granted) {
|
||
uni.showModal({
|
||
title: '需要录音权限',
|
||
content: '请在设置中开启录音权限',
|
||
showCancel: false
|
||
})
|
||
}
|
||
resolve(granted)
|
||
},
|
||
(error) => {
|
||
console.error('权限请求失败', error)
|
||
resolve(false)
|
||
}
|
||
)
|
||
} else {
|
||
resolve(true)
|
||
}
|
||
// #endif
|
||
// #ifndef APP-PLUS
|
||
resolve(true)
|
||
// #endif
|
||
})
|
||
},
|
||
// 初始化录音管理器
|
||
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) {
|
||
// 关键修复:等待音频缓冲完全写入文件
|
||
console.log('等待录音文件完全保存...')
|
||
|
||
// 先等待1500ms让音频数据完全写入(MP3编码需要时间)
|
||
setTimeout(() => {
|
||
// 验证文件大小
|
||
uni.getFileInfo({
|
||
filePath: res.tempFilePath,
|
||
success: (fileInfo) => {
|
||
// 保存诊断信息
|
||
this.recordDiagnostic = {
|
||
path: res.tempFilePath,
|
||
actualSize: fileInfo.size,
|
||
actualSizeKB: (fileInfo.size / 1024).toFixed(2),
|
||
duration: res.duration || 0,
|
||
durationSec: res.duration ? (res.duration / 1000).toFixed(1) : '未知',
|
||
expectedSize: res.duration ? res.duration * 16 : 45000,
|
||
timestamp: new Date().toLocaleString()
|
||
}
|
||
|
||
console.log('录音文件信息:', this.recordDiagnostic)
|
||
|
||
// 检查文件大小是否合理
|
||
// 128kbps = 16KB/秒,3秒至少应该有 45KB
|
||
const minExpectedSize = Math.max(45000, res.duration ? res.duration * 16 : 45000)
|
||
this.recordDiagnostic.expectedSizeKB = (minExpectedSize / 1024).toFixed(2)
|
||
this.recordDiagnostic.sizeRatio = ((fileInfo.size / minExpectedSize) * 100).toFixed(1) + '%'
|
||
|
||
// 保存文件信息用于诊断
|
||
this.recordFileSize = fileInfo.size
|
||
this.recordDuration = res.duration || this.recordTime * 1000
|
||
|
||
// 计算实际码率(字节/秒)
|
||
const actualBitrate = this.recordDuration > 0 ? (fileInfo.size / (this.recordDuration / 1000)) : 0
|
||
const expectedBitrate = 32000 // 正常应该是32KB/秒
|
||
|
||
console.log(`[录音检测] 格式:${this.selectedFormat} 采样率:${this.selectedSampleRate}Hz`)
|
||
console.log(`[录音检测] 文件大小:${(fileInfo.size/1024).toFixed(2)}KB 时长:${(this.recordDuration/1000).toFixed(1)}秒`)
|
||
console.log(`[录音检测] 实际码率:${(actualBitrate/1024).toFixed(2)}KB/秒 预期:${(expectedBitrate/1024).toFixed(2)}KB/秒`)
|
||
|
||
if (actualBitrate < expectedBitrate * 0.15) {
|
||
// 码率低于正常的15%,说明格式不兼容
|
||
console.error('录音格式不兼容!码率过低!')
|
||
|
||
// 记录失败的格式
|
||
const formatKey = `${this.selectedFormat}_${this.selectedSampleRate || 'auto'}`
|
||
this.testResults[formatKey] = {
|
||
success: false,
|
||
bitrate: actualBitrate,
|
||
size: fileInfo.size,
|
||
duration: this.recordDuration
|
||
}
|
||
|
||
uni.showModal({
|
||
title: '录音文件不完整',
|
||
content: `录音${(this.recordDuration/1000).toFixed(1)}秒,文件大小${(fileInfo.size/1024).toFixed(2)}KB(约${(actualBitrate/1024).toFixed(2)}KB/秒)。\n\n正常应为32KB/秒。您的设备可能不支持此录音方式。\n\n建议:\n1. 重试并详细讲一点\n2. 录音时长控制在3-5秒\n3. 或使用手动输入文本`,
|
||
showCancel: true,
|
||
confirmText: '手动输入',
|
||
cancelText: '重试',
|
||
success: (modalRes) => {
|
||
if (modalRes.confirm) {
|
||
// 跳转到手动输入
|
||
this.recordPath = ''
|
||
// 可以添加手动输入逻辑
|
||
} else {
|
||
// 清空准备重试
|
||
this.recordPath = ''
|
||
}
|
||
}
|
||
})
|
||
} else if (fileInfo.size < minExpectedSize * 0.5) {
|
||
// 文件偏小但可能可用
|
||
console.warn('警告:录音文件偏小')
|
||
this.recordPath = res.tempFilePath
|
||
uni.showToast({
|
||
title: `录音完成(${(fileInfo.size/1024).toFixed(1)}KB,偏小)`,
|
||
icon: 'none',
|
||
duration: 2000
|
||
})
|
||
} else {
|
||
// 文件大小正常,记录成功
|
||
const formatKey = `${this.selectedFormat}_${this.selectedSampleRate || 'auto'}`
|
||
this.testResults[formatKey] = {
|
||
success: true,
|
||
bitrate: actualBitrate,
|
||
size: fileInfo.size,
|
||
duration: this.recordDuration
|
||
}
|
||
|
||
this.recordPath = res.tempFilePath
|
||
uni.showToast({
|
||
title: `录音完成 ✓`,
|
||
icon: 'success',
|
||
duration: 1500
|
||
})
|
||
}
|
||
},
|
||
fail: (err) => {
|
||
console.error('获取文件信息失败', err)
|
||
// 即使获取失败也使用文件
|
||
this.recordPath = res.tempFilePath
|
||
uni.showToast({
|
||
title: '录音完成',
|
||
icon: 'success',
|
||
duration: 1500
|
||
})
|
||
}
|
||
})
|
||
}, 1500) // 增加到1500ms,确保MP3编码完成
|
||
}
|
||
})
|
||
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 = ''
|
||
},
|
||
// 开始录音
|
||
async startRecord() {
|
||
if (!this.content) {
|
||
uni.showToast({
|
||
title: '请先设置评测内容',
|
||
icon: 'none'
|
||
})
|
||
return
|
||
}
|
||
|
||
try {
|
||
console.log('[录音] 使用AAC格式开始录音...')
|
||
|
||
// 使用AAC录音工具(已验证可用)
|
||
await aacRecorder.start(60000) // 最长60秒
|
||
|
||
this.isRecording = true
|
||
this.recordTime = 0
|
||
|
||
// 开始计时
|
||
this.recordTimer = setInterval(() => {
|
||
this.recordTime++
|
||
}, 1000)
|
||
|
||
// 开始波形动画
|
||
this.startWaveAnimation()
|
||
|
||
console.log('[录音] AAC录音已开始')
|
||
|
||
} catch (error) {
|
||
console.error('[录音] 开始失败:', error)
|
||
uni.showToast({
|
||
title: '录音失败: ' + error.message,
|
||
icon: 'none'
|
||
})
|
||
}
|
||
},
|
||
// 停止录音
|
||
async stopRecord() {
|
||
if (!this.isRecording) {
|
||
return
|
||
}
|
||
|
||
try {
|
||
console.log('[录音] 停止录音...')
|
||
|
||
// 停止AAC录音
|
||
const result = await aacRecorder.stop()
|
||
|
||
// 停止计时和波形动画
|
||
if (this.recordTimer) {
|
||
clearInterval(this.recordTimer)
|
||
this.recordTimer = null
|
||
}
|
||
this.stopWaveAnimation()
|
||
|
||
this.isRecording = false
|
||
this.recordPath = result.filePath
|
||
this.recordFileSize = result.fileSize
|
||
this.recordDuration = this.recordTime
|
||
|
||
console.log('[录音] 录音完成')
|
||
console.log('[录音] 文件:', result.filePath)
|
||
console.log('[录音] 大小:', result.fileSize, 'bytes')
|
||
console.log('[录音] 时长:', this.recordTime, '秒')
|
||
|
||
uni.showToast({
|
||
title: '录音完成',
|
||
icon: 'success',
|
||
duration: 1500
|
||
})
|
||
|
||
} catch (error) {
|
||
console.error('[录音] 停止失败:', error)
|
||
this.isRecording = false
|
||
if (this.recordTimer) {
|
||
clearInterval(this.recordTimer)
|
||
this.recordTimer = null
|
||
}
|
||
this.stopWaveAnimation()
|
||
|
||
uni.showToast({
|
||
title: '停止录音失败',
|
||
icon: 'none'
|
||
})
|
||
}
|
||
},
|
||
// 播放录音
|
||
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.currentEvaluationAudioPath || this.recordPath
|
||
uni.showModal({
|
||
title: '确认提交',
|
||
content: hasAudio ?
|
||
'提交后,管理员将看到您的评测结果和音频文件,是否确认提交?' :
|
||
'提交后,管理员将看到您的评测结果,是否确认提交?',
|
||
success: async (res) => {
|
||
if (res.confirm) {
|
||
this.isSubmitting = true
|
||
try {
|
||
// 评测时已经上传过音频了,提交时不需要再上传(传null即可)
|
||
// 这样可以避免临时文件路径失效的问题
|
||
const result = await submitVoiceEvaluation(this.currentEvaluationId, null)
|
||
if (result.code === 200) {
|
||
this.isSubmitted = true
|
||
uni.showToast({
|
||
title: hasAudio ? '提交成功' : '提交成功',
|
||
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
|
||
}
|
||
}
|
||
}
|
||
})
|
||
},
|
||
// 格式选择器事件
|
||
onFormatChange(e) {
|
||
const index = e.detail.value
|
||
const option = this.formatOptions[index]
|
||
this.selectedFormat = option.value
|
||
// 设置对应的采样率
|
||
const rates = option.rates
|
||
if (rates.length > 0) {
|
||
this.selectedSampleRate = rates[0]
|
||
} else {
|
||
this.selectedSampleRate = null // m4a等自适应格式
|
||
}
|
||
console.log('[格式选择] 选择格式:', this.selectedFormat, '采样率:', this.selectedSampleRate || '自适应')
|
||
|
||
uni.showToast({
|
||
title: `已切换到 ${option.label}`,
|
||
icon: 'none',
|
||
duration: 1500
|
||
})
|
||
},
|
||
// 格式化时间
|
||
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;
|
||
}
|
||
|
||
.format-selector {
|
||
padding: 20rpx;
|
||
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
||
border-radius: 12rpx;
|
||
margin-bottom: 20rpx;
|
||
border: 2rpx dashed #409eff;
|
||
}
|
||
|
||
.format-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 16rpx;
|
||
}
|
||
|
||
.format-label {
|
||
font-size: 28rpx;
|
||
color: #333;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.format-tip {
|
||
font-size: 22rpx;
|
||
color: #999;
|
||
}
|
||
|
||
.format-pickers {
|
||
display: flex;
|
||
gap: 16rpx;
|
||
}
|
||
|
||
.picker-value {
|
||
flex: 1;
|
||
padding: 16rpx 24rpx;
|
||
background: #fff;
|
||
border-radius: 24rpx;
|
||
font-size: 26rpx;
|
||
color: #409eff;
|
||
border: 2rpx solid #409eff;
|
||
text-align: center;
|
||
box-shadow: 0 2rpx 8rpx rgba(64, 158, 255, 0.15);
|
||
}
|
||
|
||
.test-results {
|
||
margin-top: 20rpx;
|
||
padding-top: 20rpx;
|
||
border-top: 1rpx solid #e0e0e0;
|
||
}
|
||
|
||
.results-title {
|
||
font-size: 24rpx;
|
||
color: #666;
|
||
display: block;
|
||
margin-bottom: 12rpx;
|
||
}
|
||
|
||
.result-item {
|
||
margin-bottom: 8rpx;
|
||
}
|
||
|
||
.result-item .success {
|
||
font-size: 22rpx;
|
||
color: #67c23a;
|
||
}
|
||
|
||
.result-item .failed {
|
||
font-size: 22rpx;
|
||
color: #f56c6c;
|
||
}
|
||
|
||
.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>
|
||
|