950 lines
30 KiB
Vue
950 lines
30 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="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>
|
|||
|
|
|