guoyu/fronted_uniapp/pages/speech/speech.vue
2025-12-07 08:40:26 +08:00

996 lines
37 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="speech-evaluation-container">
<!-- 顶部导航栏 -->
<view class="top-header">
<view class="header-left" @click="goBack">
<text class="back-icon"></text>
<text class="back-text">返回</text>
</view>
<text class="header-title">语音测评</text>
<view class="header-right"></view>
</view>
<!-- 内容选择区域 -->
<view class="content-select-section" v-if="!selectedContent">
<view class="section-title">📚 选择测评内容</view>
<view class="content-list" v-if="contentList.length > 0">
<view
v-for="(item, index) in contentList"
:key="item.id || index"
class="content-item"
@click="selectContent(item)"
>
<view class="content-item-header">
<text class="content-item-title">{{ item.title || '未命名题目' }}</text>
<text class="content-item-difficulty" :class="'difficulty-' + (item.difficulty || 'medium')">
{{ getDifficultyText(item.difficulty) }}
</text>
</view>
<view class="content-item-preview">
{{ item.content ? (item.content.length > 50 ? item.content.substring(0, 50) + '...' : item.content) : '暂无内容' }}
</view>
</view>
</view>
<view v-else-if="!loadingContent" class="empty-tip">
<text class="empty-icon">📭</text>
<text>暂无测评内容</text>
</view>
<view v-else class="loading-tip">
<text>加载中...</text>
</view>
</view>
<!-- 测评内容显示区域 -->
<view class="content-display-section" v-if="selectedContent">
<view class="section-header">
<text class="section-title">📝 测评内容</text>
<text class="change-btn" @click="changeContent">更换</text>
</view>
<view class="content-text">
<text>{{ selectedContent.content }}</text>
</view>
</view>
<!-- 语音识别状态 -->
<view class="status-section" v-if="selectedContent">
<view class="status-item">
<text class="status-label">识别状态:</text>
<text class="status-value" :class="{
'status-ready': isReady && !isRecording,
'status-recording': isRecording,
'status-loading': isLoading
}">
{{ statusText }}
</text>
</view>
<!-- 调试信息 -->
<view class="debug-info" v-if="debugInfo">
<text class="debug-text">{{ debugInfo }}</text>
</view>
<view class="recording-tip" v-if="isRecording">
<view class="recording-dots">
<view class="dot dot1"></view>
<view class="dot dot2"></view>
<view class="dot dot3"></view>
</view>
<text class="recording-text">正在识别,请清晰朗读,每句话后稍作停顿...</text>
</view>
</view>
<!-- 语音识别操作区域 -->
<view class="action-section" v-if="selectedContent">
<!-- 准备好后显示开始按钮 -->
<button
v-if="isReady"
class="action-btn"
:class="{ 'btn-recording': isRecording, 'btn-ready': !isRecording }"
:disabled="isLoading"
@click="handleStart"
>
<text class="btn-icon">{{ isRecording ? '🎤' : '🎙️' }}</text>
<text class="btn-text">{{ isRecording ? '正在识别...' : '开始说话' }}</text>
</button>
<button v-if="isRecording" class="action-btn btn-stop" @click="handleStop">
<text class="btn-text">停止识别</text>
</button>
<!-- 手动输入按钮 -->
<button class="manual-input-btn" @click="showManualInput">
<text>📝 手动输入文本</text>
</button>
<!-- 工具按钮 -->
<view class="tool-buttons">
<button class="tool-btn" @click="clearModelCache">
<text>🗑️ 清除缓存重新加载</text>
</button>
<button class="tool-btn" @click="testMicrophone">
<text>🎤 测试麦克风</text>
</button>
</view>
</view>
<!-- 识别结果区域 -->
<view class="result-section" v-if="selectedContent && (recognizedText || isRecording)">
<view class="section-title">🎯 识别结果</view>
<view class="recognition-progress" v-if="isRecording">
<view class="progress-dots">
<view class="progress-dot dot1"></view>
<view class="progress-dot dot2"></view>
<view class="progress-dot dot3"></view>
</view>
<text class="progress-text">实时识别中...</text>
</view>
<view class="result-content">
<scroll-view class="result-scroll" scroll-y :scroll-top="scrollTop" scroll-with-animation>
<text class="result-text" v-if="recognizedText">{{ recognizedText }}</text>
<text class="result-text text-loading" v-else-if="isRecording">正在启动语音识别,请稍候...</text>
<view class="typing-cursor" v-if="isRecording && recognizedText">|</view>
</scroll-view>
</view>
<button
class="evaluate-btn"
@click="evaluateScore"
:loading="isEvaluating"
:disabled="!recognizedText || recognizedText.trim() === ''"
v-if="!isRecording"
>
{{ isEvaluating ? '评分中...' : '开始评分' }}
</button>
</view>
<!-- 评分结果区域 -->
<view class="score-section" v-if="scoreResult">
<view class="section-title">📊 评分结果</view>
<view class="score-main">
<text class="score-main-value">{{ scoreResult.totalScore }}</text>
<text class="score-main-label">总分</text>
</view>
<view class="score-details">
<view class="score-item">
<view class="score-item-header">
<text class="score-item-label">准确度</text>
<text class="score-item-value">{{ scoreResult.accuracy }}分</text>
</view>
<view class="score-progress">
<view class="score-progress-bar" :style="{ width: scoreResult.accuracy + '%' }"></view>
</view>
</view>
<view class="score-item">
<view class="score-item-header">
<text class="score-item-label">完整度</text>
<text class="score-item-value">{{ scoreResult.completeness }}分</text>
</view>
<view class="score-progress">
<view class="score-progress-bar" :style="{ width: scoreResult.completeness + '%' }"></view>
</view>
</view>
<view class="score-item">
<view class="score-item-header">
<text class="score-item-label">流畅度</text>
<text class="score-item-value">{{ scoreResult.fluency }}分</text>
</view>
<view class="score-progress">
<view class="score-progress-bar" :style="{ width: scoreResult.fluency + '%' }"></view>
</view>
</view>
<view class="score-item">
<view class="score-item-header">
<text class="score-item-label">发音</text>
<text class="score-item-value">{{ scoreResult.pronunciation }}分</text>
</view>
<view class="score-progress">
<view class="score-progress-bar" :style="{ width: scoreResult.pronunciation + '%' }"></view>
</view>
</view>
</view>
<view class="score-info" v-if="scoreResult.details">
<view class="info-item">
<text class="info-label">相似度:</text>
<text class="info-value">{{ scoreResult.details.similarity }}%</text>
</view>
<view class="info-item">
<text class="info-label">正确字符:</text>
<text class="info-value">{{ scoreResult.details.correctChars }}/{{ scoreResult.details.totalChars }}</text>
</view>
</view>
<view class="submit-section" v-if="currentEvaluationId && !isSubmitted">
<button @click="submitEvaluation" class="btn-submit" :loading="isSubmitting">
{{ isSubmitting ? '提交中...' : '提交评测' }}
</button>
</view>
<view class="submit-status-section" v-if="isSubmitted">
<text class="submit-status-text"> 已提交</text>
</view>
<view class="action-buttons">
<button class="btn-retry" @click="resetEvaluation">重新测评</button>
<button class="btn-change" @click="changeContent">更换内容</button>
</view>
</view>
</view>
</template>
<script>
import { getVoiceEvaluationContents, evaluateSpeechRecognition, saveVoiceScore, submitVoiceEvaluation } from '@/api/study/voiceEvaluation.js'
// #ifdef APP-PLUS
// TODO: 已改用服务器端识别方案,不再使用 UTS 插件
// import { initVoskModel, startSpeechVoice, stopSpeechVoice } from '@/uni_modules/xwq-speech-to-text'
// import { initPerssion } from '@/uni_modules/xwq-speech-to-text/utssdk/app-android/getPermission.uts'
// 新方案:使用 utils/speech-recorder.js
import speechRecorder from '@/utils/speech-recorder.js'
// #endif
export default {
data() {
return {
contentList: [],
loadingContent: false,
selectedContent: null,
modelPath: '',
recognizedText: '',
isReady: false,
isRecording: false,
isLoading: false,
statusText: '准备中...',
debugInfo: '', // 调试信息
scrollTop: 0,
scrollTimer: null,
hasFirstResult: false,
scoreResult: null,
isEvaluating: false,
currentEvaluationId: null,
isSubmitted: false,
isSubmitting: false,
isSaving: false,
pageUnloaded: false, // 页面卸载标记
recordStartTime: 0, // 录音开始时间
recordingFailCount: 0 // 录音失败次数
}
},
onLoad(options) {
if (options.contentId) {
this.loadContentById(options.contentId)
} else {
this.loadContentList()
}
// #ifdef APP-PLUS
// 新方案使用服务器端识别无需UTS插件
this.initSpeechService()
// #endif
// #ifndef APP-PLUS
this.statusText = '语音识别仅支持APP端'
this.debugInfo = '请在APP端使用语音识别功能'
// #endif
},
onUnload() {
console.log('[Speech] 页面卸载,清理资源')
// 标记页面已卸载,防止回调继续执行
this.pageUnloaded = true
// #ifdef APP-PLUS
// 停止录音
if (this.isRecording) {
console.log('[Speech] 页面卸载时停止录音')
this.handleStop()
}
// #endif
this.stopAutoScroll()
// 清理定时器
if (this.scrollTimer) {
clearInterval(this.scrollTimer)
this.scrollTimer = null
}
},
onHide() {
console.log('[Speech] 页面隐藏,暂停识别')
// 页面切换到后台时停止识别
if (this.isRecording) {
this.handleStop()
}
},
methods: {
// 初始化语音服务
async initSpeechService() {
try {
console.log('[Speech] 开始初始化语音服务')
// 请求录音权限
const permissionResult = await this.requestRecordPermission()
if (!permissionResult) {
this.statusText = '需要录音权限'
this.debugInfo = '请在设置中开启录音权限'
return
}
// 初始化录音器
speechRecorder.init()
this.statusText = '准备就绪'
this.isReady = true
console.log('[Speech] 语音服务初始化成功')
} catch (error) {
console.error('[Speech] 初始化失败', error)
this.statusText = '初始化失败'
this.debugInfo = '错误: ' + error.message
uni.showToast({
title: '语音服务初始化失败',
icon: 'none',
duration: 2000
})
}
},
// 请求录音权限
requestRecordPermission() {
return new Promise((resolve) => {
// #ifdef APP-PLUS
const permissions = ['android.permission.RECORD_AUDIO']
plus.android.requestPermissions(
permissions,
(result) => {
console.log('[Speech] 权限请求结果', result)
const granted = result.granted && result.granted.length > 0
if (granted) {
console.log('[Speech] 录音权限已授予')
resolve(true)
} else {
console.log('[Speech] 录音权限被拒绝')
uni.showModal({
title: '需要录音权限',
content: '语音评测需要使用您的麦克风,请在设置中开启录音权限',
showCancel: false
})
resolve(false)
}
},
(error) => {
console.error('[Speech] 权限请求失败', error)
resolve(false)
}
)
// #endif
// #ifndef APP-PLUS
resolve(true)
// #endif
})
},
async loadContentList() {
this.loadingContent = true
try {
const result = await getVoiceEvaluationContents()
if (result.code === 200 && result.data) {
this.contentList = Array.isArray(result.data) ? result.data : result.data.list || []
} else {
this.contentList = []
}
} catch (error) {
console.error('加载内容列表失败', error)
this.contentList = []
} finally {
if (this.contentList.length === 0) {
this.contentList = this.getMockData()
}
this.loadingContent = false
}
},
getMockData() {
return [
{ id: 1, title: '基础拼音练习', content: '春眠不觉晓,处处闻啼鸟。夜来风雨声,花落知多少。', difficulty: 'easy' },
{ id: 2, title: '古诗朗诵', content: '床前明月光,疑是地上霜。举头望明月,低头思故乡。', difficulty: 'medium' },
{ id: 3, title: '绕口令练习', content: '四是四,十是十,十四是十四,四十是四十。', difficulty: 'hard' }
]
},
async loadContentById(contentId) {
this.loadingContent = true
try {
const result = await getVoiceEvaluationContents({ id: contentId })
if (result.code === 200 && result.data) {
const data = Array.isArray(result.data) ? result.data : result.data.list || []
if (data.length > 0) this.selectedContent = data[0]
}
} catch (error) {
console.error('加载内容失败', error)
} finally {
this.loadingContent = false
}
},
selectContent(item) {
this.selectedContent = item
this.recognizedText = ''
this.scoreResult = null
this.currentEvaluationId = null
this.isSubmitted = false
},
changeContent() {
this.selectedContent = null
this.recognizedText = ''
this.scoreResult = null
this.currentEvaluationId = null
this.isSubmitted = false
this.loadContentList()
},
// #ifdef APP-PLUS
async handleStart() {
if (!this.isReady) {
uni.showToast({ title: '未准备好,请稍候', icon: 'none' })
return
}
if (this.isRecording) {
console.log('[Speech] 已在录音中')
return
}
if (!this.selectedContent) {
uni.showToast({ title: '请先选择题目', icon: 'none' })
return
}
// 启动录音
this.isRecording = true
this.recordStartTime = Date.now() // 记录开始时间
console.log('[Speech] 录音开始时间:', this.recordStartTime)
this.statusText = '正在录音...'
this.recognizedText = ''
this.scoreResult = null
this.debugInfo = '最佳时长3-10秒'
try {
speechRecorder.start()
uni.showToast({ title: '开始录音请大声说话推荐3-10秒', icon: 'none', duration: 2500 })
// 3秒后提示可以停止
setTimeout(() => {
if (this.isRecording) {
uni.showToast({ title: '可以停止了最佳时长3-10秒', icon: 'success', duration: 1500 })
}
}, 3000)
// 10秒后提示不要太长
setTimeout(() => {
if (this.isRecording) {
uni.showToast({ title: '建议尽快停止,避免过长', icon: 'none', duration: 2000 })
}
}, 10000)
} catch (error) {
console.error('[Speech] 录音启动失败:', error)
this.isRecording = false
this.statusText = '录音启动失败'
uni.showToast({
title: '录音失败: ' + error.message,
icon: 'none'
})
}
},
async handleStop() {
console.log('[Speech] 停止录音并评测')
if (!this.isRecording) {
console.log('[Speech] 未在录音中')
return
}
// 检查录音时长百度API最佳识别范围2-10秒
const now = Date.now()
const recordDuration = (now - this.recordStartTime) / 1000
console.log('[Speech] 当前时间:', now)
console.log('[Speech] 开始时间:', this.recordStartTime)
console.log('[Speech] 录音时长:', recordDuration, '秒')
// 太短(<1.5秒)
if (!this.recordStartTime || recordDuration < 1.5) {
this.isRecording = true // 继续录音状态
uni.showModal({
title: '录音时长不够',
content: `当前只录了${recordDuration.toFixed(1)}请继续说话至少2秒`,
showCancel: false,
confirmText: '继续录音'
})
return
}
// 太长(>15秒- 提示但允许继续
if (recordDuration > 15) {
uni.showModal({
title: '录音时长过长',
content: `已录${recordDuration.toFixed(1)}秒。百度语音识别最佳时长为3-10秒过长可能只识别部分内容。是否继续`,
cancelText: '继续录音',
confirmText: '立即识别',
success: (res) => {
if (!res.confirm) {
this.isRecording = true // 继续录音
} else {
// 用户选择识别,继续执行
this.processSpeech()
}
}
})
return
}
// 通过检查,执行识别
this.processSpeech()
},
async processSpeech() {
const actualDuration = (Date.now() - this.recordStartTime) / 1000
console.log('[Speech] 实际录音时长:', actualDuration, '秒')
this.isRecording = false
uni.showLoading({ title: `正在处理(${actualDuration.toFixed(1)}秒)...`, mask: true })
try {
// 1. 先停止录音,获取录音文件路径
console.log('[Speech] 停止录音中...')
const filePath = await speechRecorder.stop()
console.log('[Speech] 录音文件路径:', filePath)
console.log('[Speech] 点击停止时的时长:', actualDuration, '秒')
if (!filePath) {
throw new Error('录音文件获取失败')
}
// 验证文件大小(关键!检测录音是否完整)
const fileInfo = await new Promise((resolve, reject) => {
uni.getFileInfo({
filePath: filePath,
success: resolve,
fail: reject
})
})
const fileSize = fileInfo.size
const expectedSize = actualDuration * 32000 // 16kHz单声道16bit = 32000 bytes/s
const sizeRatio = fileSize / expectedSize
console.log('[Speech] 文件大小:', fileSize, 'bytes')
console.log('[Speech] 预期大小:', expectedSize.toFixed(0), 'bytes')
console.log('[Speech] 完整度:', (sizeRatio * 100).toFixed(1), '%')
// 如果文件大小 < 预期的30%,说明严重丢失数据
if (sizeRatio < 0.3) {
uni.hideLoading()
uni.showModal({
title: '录音文件不完整',
content: `录音${actualDuration.toFixed(1)}秒,但文件只有${(fileSize/32000).toFixed(1)}秒。您的设备可能不支持此录音方式。\n\n建议\n1. 重试并说慢一点\n2. 录音时长控制在3-5秒\n3. 或使用手动输入`,
showCancel: true,
cancelText: '重试',
confirmText: '手动输入',
success: (res) => {
if (res.confirm) {
this.showManualInput()
}
}
})
return
}
// 如果文件大小30%-70%,警告但继续
if (sizeRatio < 0.7) {
console.warn('[Speech] 警告:录音文件可能不完整,完整度:', (sizeRatio * 100).toFixed(1), '%')
}
// 2. 上传并评测
uni.showLoading({ title: '评测中...', mask: true })
console.log('[Speech] 开始评测...')
const result = await speechRecorder.evaluateAsync(
this.selectedContent?.content || '测试文本',
this.selectedContent?.id
)
uni.hideLoading()
if (result.success) {
this.recognizedText = result.recognizedText || ''
this.scoreResult = {
score: result.score || 0,
pronunciationScore: result.pronunciationScore || 0,
fluencyScore: result.fluencyScore || 0
}
this.hasFirstResult = true
this.statusText = '评测完成'
this.debugInfo = `得分:${result.score}`
this.recordingFailCount = 0 // 成功后重置失败计数
uni.showToast({ title: `得分:${result.score}`, icon: 'success' })
} else {
this.statusText = '评测失败'
this.debugInfo = result.error || '评测失败'
this.recordingFailCount++
// 连续失败2次建议手动输入
if (this.recordingFailCount >= 2) {
setTimeout(() => {
uni.showModal({
title: '录音功能异常',
content: '您的设备录音功能可能不兼容,已连续失败' + this.recordingFailCount + '次。\n\n建议使用手动输入功能完成练习。',
cancelText: '重试',
confirmText: '手动输入',
success: (res) => {
if (res.confirm) {
this.showManualInput()
} else {
this.recordingFailCount = 0 // 用户选择重试,重置计数
}
}
})
}, 500)
} else {
uni.showToast({ title: '评测失败', icon: 'none' })
}
}
} catch (error) {
uni.hideLoading()
console.error('[Speech] 评测错误:', error)
this.statusText = '评测失败'
this.debugInfo = error.message || '未知错误'
this.recordingFailCount++
// 连续失败2次建议手动输入
if (this.recordingFailCount >= 2) {
uni.showModal({
title: '录音功能异常',
content: '您的设备录音功能可能不兼容,已连续失败' + this.recordingFailCount + '次。\n\n建议使用手动输入功能完成练习。',
cancelText: '重试',
confirmText: '手动输入',
success: (res) => {
if (res.confirm) {
this.showManualInput()
} else {
this.recordingFailCount = 0 // 用户选择重试,重置计数
}
}
})
} else {
uni.showToast({ title: '评测失败: ' + error.message, icon: 'none', duration: 3000 })
}
}
},
// #endif
// #ifndef APP-PLUS
handleStart() {
uni.showToast({ title: '语音识别仅支持APP端', icon: 'none' })
},
handleStop() {},
// #endif
// 手动输入文本
showManualInput() {
if (!this.selectedContent) {
uni.showToast({ title: '请先选择题目', icon: 'none' })
return
}
uni.showModal({
title: '手动输入识别文本',
content: this.selectedContent.content || '',
editable: true,
placeholderText: '请输入您要朗读的内容',
success: (res) => {
if (res.confirm && res.content) {
this.recognizedText = res.content.trim()
this.hasFirstResult = true
this.statusText = '已手动输入文本'
this.debugInfo = '手动输入: ' + this.recognizedText.length + '字'
}
}
})
},
// 清除模型缓存并重新加载
clearModelCache() {
uni.showModal({
title: '清除缓存',
content: '将清除已解压的模型缓存重新从静态资源解压。这可能需要30秒左右确定继续',
success: (res) => {
if (res.confirm) {
// 清除缓存
uni.removeStorageSync('vosk_model_path')
this.modelPath = ''
this.isReady = false
this.debugInfo = '缓存已清除,正在重新加载...'
// 重新初始化
// #ifdef APP-PLUS
this.initFromStatic()
// #endif
}
}
})
},
// 测试麦克风
testMicrophone() {
this.debugInfo = '正在测试麦克风...'
// #ifdef APP-PLUS
const recorderManager = uni.getRecorderManager()
recorderManager.onStart(() => {
this.debugInfo = '麦克风正常,正在录音...'
uni.showToast({ title: '正在录音...', icon: 'none', duration: 3000 })
})
recorderManager.onStop((res) => {
console.log('[Speech] 测试录音结果:', res)
if (res.tempFilePath) {
this.debugInfo = '麦克风测试成功!录音文件: ' + res.tempFilePath.substring(res.tempFilePath.length - 30)
uni.showToast({ title: '麦克风正常', icon: 'success' })
// 播放录音
const innerAudioContext = uni.createInnerAudioContext()
innerAudioContext.src = res.tempFilePath
innerAudioContext.play()
innerAudioContext.onEnded(() => {
this.debugInfo += '\n录音播放完成'
})
} else {
this.debugInfo = '麦克风测试失败:未获取到录音文件'
uni.showToast({ title: '麦克风测试失败', icon: 'none' })
}
})
recorderManager.onError((err) => {
console.error('[Speech] 麦克风测试错误:', err)
this.debugInfo = '麦克风错误: ' + (err.errMsg || JSON.stringify(err))
uni.showToast({ title: '麦克风错误', icon: 'none' })
})
// 开始录音3秒
recorderManager.start({
duration: 3000,
sampleRate: 16000,
numberOfChannels: 1,
format: 'mp3'
})
// #endif
// #ifndef APP-PLUS
this.debugInfo = '麦克风测试仅支持APP端'
// #endif
},
scrollToBottom() { this.scrollTop = 99999 },
startAutoScroll() {
this.stopAutoScroll()
this.scrollTimer = setInterval(() => { if (this.isRecording) this.scrollToBottom() }, 500)
},
stopAutoScroll() {
if (this.scrollTimer) { clearInterval(this.scrollTimer); this.scrollTimer = null }
},
async evaluateScore() {
if (!this.recognizedText || !this.selectedContent) {
uni.showToast({ title: '请先完成语音识别', icon: 'none' })
return
}
this.isEvaluating = true
try {
const result = await evaluateSpeechRecognition(this.selectedContent.content, this.recognizedText)
if (result.code === 200 && result.data) {
this.scoreResult = result.data
await this.saveScoreToBackend(result.data)
uni.showToast({ title: '评分完成', icon: 'success', duration: 2000 })
this.$nextTick(() => { uni.pageScrollTo({ selector: '.score-section', duration: 300 }) })
} else {
throw new Error(result.msg || '评分失败')
}
} catch (error) {
console.error('评分失败', error)
uni.showToast({ title: error.message || '评分失败', icon: 'none', duration: 3000 })
} finally {
this.isEvaluating = false
}
},
async saveScoreToBackend(scoreData) {
if (!this.selectedContent || !this.recognizedText) return
this.isSaving = true
try {
const resultDetail = JSON.stringify({
recognizedText: this.recognizedText,
originalText: this.selectedContent.content,
details: scoreData.details || {}
})
const result = await saveVoiceScore(
this.selectedContent.content, this.recognizedText,
scoreData.totalScore, scoreData.accuracy, scoreData.completeness,
scoreData.fluency, scoreData.pronunciation, null, resultDetail
)
let evaluation = null
if (result.code === 200) {
if (result.data && result.data.evaluation) evaluation = result.data.evaluation
else if (result.evaluation) evaluation = result.evaluation
else if (result.data && result.data.id) evaluation = result.data
}
if (evaluation && evaluation.id) {
this.currentEvaluationId = evaluation.id
const submitted = evaluation.isSubmitted
this.isSubmitted = submitted === 1 || submitted === true || submitted === '1'
}
} catch (error) {
console.error('保存评分到后端失败', error)
} finally {
this.isSaving = false
}
},
async submitEvaluation() {
if (!this.currentEvaluationId) {
uni.showToast({ title: '请先完成评分', icon: 'none' })
return
}
if (this.isSubmitted) {
uni.showToast({ title: '该评测已提交', icon: 'none' })
return
}
uni.showModal({
title: '确认提交',
content: '提交后,管理员将看到您的评测结果,是否确认提交?',
success: async (res) => {
if (res.confirm) {
this.isSubmitting = true
try {
const result = await submitVoiceEvaluation(this.currentEvaluationId)
if (result.code === 200) {
this.isSubmitted = true
uni.showToast({ title: '提交成功', icon: 'success', duration: 2000 })
} else {
throw new Error(result.msg || '提交失败')
}
} catch (error) {
console.error('提交失败', error)
uni.showToast({ title: error.message || '提交失败', icon: 'none', duration: 3000 })
} finally {
this.isSubmitting = false
}
}
}
})
},
resetEvaluation() {
this.recognizedText = ''
this.scoreResult = null
this.currentEvaluationId = null
this.isSubmitted = false
},
getDifficultyText(difficulty) {
const map = { 'easy': '简单', 'medium': '中等', 'hard': '困难' }
return map[difficulty] || '中等'
},
goBack() {
const pages = getCurrentPages()
if (pages.length > 1) {
uni.navigateBack({ delta: 1, fail: () => { uni.switchTab({ url: '/pages/index/index' }) } })
} else {
uni.switchTab({ url: '/pages/index/index' })
}
}
}
}
</script>
<style scoped>
.speech-evaluation-container {
padding: 20rpx;
padding-top: calc(var(--status-bar-height) + 20rpx);
background: linear-gradient(to bottom, #f5f7fa 0%, #c3cfe2 100%);
min-height: 100vh;
}
.top-header {
background: linear-gradient(135deg, rgb(55 140 224) 0%, rgb(45 120 200) 100%);
padding: 30rpx;
padding-top: calc(30rpx + var(--status-bar-height));
margin: -20rpx -20rpx 30rpx -20rpx;
margin-top: calc(-20rpx - var(--status-bar-height));
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
}
.header-left {
display: flex;
align-items: center;
gap: 8rpx;
padding: 10rpx 20rpx;
border-radius: 20rpx;
background: rgba(255, 255, 255, 0.2);
}
.back-icon { font-size: 48rpx; font-weight: bold; color: #fff; line-height: 1; }
.back-text { font-size: 28rpx; color: #fff; }
.header-title { font-size: 36rpx; font-weight: bold; color: #fff; flex: 1; text-align: center; }
.header-right { width: 120rpx; }
.content-select-section, .content-display-section, .status-section, .action-section, .result-section, .score-section {
background: #fff;
border-radius: 20rpx;
padding: 30rpx;
margin-bottom: 30rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.08);
}
.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20rpx; }
.section-title { font-size: 32rpx; font-weight: bold; color: #333; margin-bottom: 20rpx; display: block; }
.change-btn { font-size: 26rpx; color: #409eff; padding: 8rpx 20rpx; border: 1px solid #409eff; border-radius: 20rpx; }
.content-list { display: flex; flex-direction: column; gap: 20rpx; }
.content-item { padding: 24rpx; background: #f8f9fa; border-radius: 12rpx; border-left: 4rpx solid #409eff; }
.content-item-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12rpx; }
.content-item-title { font-size: 30rpx; font-weight: bold; color: #333; }
.content-item-difficulty { font-size: 24rpx; padding: 4rpx 12rpx; border-radius: 12rpx; }
.difficulty-easy { background: #e8f5e9; color: #4caf50; }
.difficulty-medium { background: #fff3e0; color: #ff9800; }
.difficulty-hard { background: #ffebee; color: #f44336; }
.content-item-preview { font-size: 26rpx; color: #666; line-height: 1.6; }
.content-text { font-size: 30rpx; line-height: 2; color: #333; padding: 30rpx; background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%); border-radius: 12rpx; border-left: 4rpx solid #409eff; min-height: 200rpx; }
.status-section { margin-bottom: 20rpx; }
.status-item { display: flex; align-items: center; justify-content: space-between; }
.status-label { font-size: 28rpx; color: #666; }
.status-value { font-size: 28rpx; font-weight: 600; }
.status-ready { color: #52c41a; }
.status-recording { color: #f5576c; animation: pulse 1.5s ease-in-out infinite; }
.status-loading { color: #1890ff; animation: pulse 1.5s ease-in-out infinite; }
/* 调试信息样式 */
.debug-info { margin-top: 16rpx; padding: 16rpx; background: #f0f0f0; border-radius: 8rpx; }
.debug-text { font-size: 24rpx; color: #666; word-break: break-all; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.6; } }
.recording-tip { display: flex; align-items: center; gap: 16rpx; margin-top: 20rpx; padding: 20rpx; background: rgba(245, 87, 108, 0.1); border-radius: 12rpx; border-left: 4rpx solid #f5576c; }
.recording-dots { display: flex; gap: 8rpx; align-items: center; }
.dot { width: 12rpx; height: 12rpx; border-radius: 50%; background: #f5576c; animation: dot-bounce 1.4s ease-in-out infinite; }
.dot1 { animation-delay: 0s; } .dot2 { animation-delay: 0.2s; } .dot3 { animation-delay: 0.4s; }
@keyframes dot-bounce { 0%, 60%, 100% { transform: translateY(0); opacity: 0.7; } 30% { transform: translateY(-10rpx); opacity: 1; } }
.recording-text { font-size: 26rpx; color: #f5576c; flex: 1; }
.action-section { display: flex; flex-direction: column; align-items: center; gap: 20rpx; }
.action-btn { width: 400rpx; height: 400rpx; border-radius: 50%; border: none; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 20rpx; box-shadow: 0 8rpx 30rpx rgba(0, 0, 0, 0.2); transition: all 0.3s; }
.btn-ready { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #fff; }
.btn-recording { background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); color: #fff; animation: pulse-ring 1.5s ease-out infinite; }
.btn-init { background: linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%); color: #333; }
@keyframes pulse-ring { 0% { box-shadow: 0 0 0 0 rgba(245, 87, 108, 0.7); } 50% { box-shadow: 0 0 0 40rpx rgba(245, 87, 108, 0); } 100% { box-shadow: 0 0 0 0 rgba(245, 87, 108, 0); } }
.btn-stop { width: 300rpx; height: 100rpx; border-radius: 50rpx; background: linear-gradient(135deg, #fa709a 0%, #fee140 100%); color: #fff; }
.btn-icon { font-size: 80rpx; }
.btn-text { font-size: 32rpx; font-weight: bold; }
/* 手动输入按钮 */
.manual-input-btn { margin-top: 30rpx; padding: 20rpx 40rpx; background: #f5f5f5; border-radius: 30rpx; border: 1px solid #ddd; font-size: 28rpx; color: #666; }
/* 工具按钮 */
.tool-buttons { display: flex; gap: 20rpx; margin-top: 30rpx; flex-wrap: wrap; justify-content: center; }
.tool-btn { padding: 16rpx 24rpx; background: #fff; border: 1px solid #e0e0e0; border-radius: 20rpx; font-size: 24rpx; color: #666; }
.result-content { margin-bottom: 20rpx; }
.result-scroll { max-height: 400rpx; width: 100%; }
.result-text { font-size: 30rpx; line-height: 1.8; color: #333; word-break: break-all; min-height: 100rpx; }
.typing-cursor { display: inline-block; font-size: 30rpx; color: #409eff; animation: blink 1s infinite; margin-left: 4rpx; }
@keyframes blink { 0%, 50% { opacity: 1; } 51%, 100% { opacity: 0; } }
.recognition-progress { display: flex; align-items: center; gap: 16rpx; margin-bottom: 20rpx; padding: 16rpx 20rpx; background: rgba(64, 158, 255, 0.1); border-radius: 12rpx; border-left: 4rpx solid #409eff; }
.progress-dots { display: flex; gap: 8rpx; align-items: center; }
.progress-dot { width: 12rpx; height: 12rpx; border-radius: 50%; background: #409eff; animation: progress-bounce 1.4s ease-in-out infinite; }
.progress-dot.dot1 { animation-delay: 0s; } .progress-dot.dot2 { animation-delay: 0.2s; } .progress-dot.dot3 { animation-delay: 0.4s; }
@keyframes progress-bounce { 0%, 60%, 100% { transform: translateY(0); opacity: 0.7; } 30% { transform: translateY(-8rpx); opacity: 1; } }
.progress-text { font-size: 26rpx; color: #409eff; font-weight: 500; }
.text-loading { color: #999; font-style: italic; }
.evaluate-btn:disabled { opacity: 0.5; background: #ccc !important; }
.evaluate-btn { width: 100%; background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%); color: #fff; border: none; padding: 24rpx; border-radius: 12rpx; font-size: 32rpx; font-weight: bold; }
.score-main { text-align: center; padding: 40rpx; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 20rpx; margin-bottom: 30rpx; }
.score-main-value { display: block; font-size: 100rpx; font-weight: bold; color: #fff; line-height: 1; }
.score-main-label { display: block; font-size: 28rpx; color: rgba(255, 255, 255, 0.9); margin-top: 10rpx; }
.score-details { display: flex; flex-direction: column; gap: 24rpx; margin-bottom: 30rpx; }
.score-item { padding: 20rpx; background: #f8f9fa; border-radius: 12rpx; }
.score-item-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12rpx; }
.score-item-label { font-size: 28rpx; color: #333; font-weight: 500; }
.score-item-value { font-size: 32rpx; font-weight: bold; color: #409eff; }
.score-progress { width: 100%; height: 12rpx; background: #e4e7ed; border-radius: 6rpx; overflow: hidden; }
.score-progress-bar { height: 100%; background: linear-gradient(90deg, #67c23a 0%, #85ce61 100%); border-radius: 6rpx; transition: width 0.5s; }
.score-info { padding: 20rpx; background: #f8f9fa; border-radius: 12rpx; margin-bottom: 30rpx; }
.info-item { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12rpx; }
.info-item:last-child { margin-bottom: 0; }
.info-label { font-size: 26rpx; color: #666; }
.info-value { font-size: 28rpx; font-weight: bold; color: #333; }
.action-buttons { display: flex; gap: 20rpx; }
.btn-retry, .btn-change { flex: 1; padding: 24rpx; border-radius: 12rpx; font-size: 28rpx; font-weight: bold; border: none; }
.btn-retry { background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%); color: #fff; }
.btn-change { background: #f0f0f0; color: #333; }
.submit-section { margin-top: 30rpx; margin-bottom: 20rpx; }
.btn-submit { width: 100%; background: linear-gradient(135deg, #67c23a 0%, #85ce61 100%); color: #fff; border: none; padding: 30rpx; border-radius: 12rpx; font-size: 32rpx; font-weight: bold; box-shadow: 0 4rpx 12rpx rgba(103, 194, 58, 0.3); }
.submit-status-section { margin-top: 20rpx; margin-bottom: 20rpx; text-align: center; }
.submit-status-text { font-size: 28rpx; color: #67c23a; padding: 16rpx 32rpx; background: rgba(103, 194, 58, 0.1); border-radius: 20rpx; border: 1px solid #67c23a; display: inline-block; }
.empty-tip, .loading-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; }
</style>