guoyu/fronted_uniapp/pages/speech/speech.vue
2025-12-06 14:53:35 +08:00

939 lines
36 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 && !isLoading"
class="action-btn btn-init"
@click="initSpeechModel"
>
<text class="btn-icon">🔄</text>
<text class="btn-text">重新初始化</text>
</button>
<!-- 准备好后显示开始按钮 -->
<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
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'
// #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 // 页面卸载标记
}
},
onLoad(options) {
if (options.contentId) {
this.loadContentById(options.contentId)
} else {
this.loadContentList()
}
// #ifdef APP-PLUS
// 检查UTS插件是否可用
try {
if (typeof initVoskModel === 'undefined') {
this.statusText = '语音识别插件未加载'
this.debugInfo = '需要制作自定义基座才能使用语音识别功能。\n\n步骤\n1. HBuilderX菜单发行 → 原生App-制作自定义调试基座\n2. 选择Android平台\n3. 等待打包完成\n4. 安装自定义基座到手机'
console.log('[Speech] UTS插件未加载需要自定义基座')
} else {
// 请求麦克风权限
initPerssion(['android.permission.RECORD_AUDIO']).then(res => {
console.log('[Speech] 麦克风权限结果:', res)
if (res.isPass) {
console.log('[Speech] 麦克风权限已授予')
this.initSpeechModel()
} else {
console.error('[Speech] 麦克风权限被拒绝')
this.statusText = '需要麦克风权限'
this.debugInfo = '语音识别需要麦克风权限,请在设置中授予权限'
uni.showToast({ title: '请授予麦克风权限', icon: 'none', duration: 3000 })
}
}).catch(err => {
console.error('[Speech] 权限请求失败:', err)
// 即使权限请求失败,也尝试初始化(可能已有权限)
this.initSpeechModel()
})
}
} catch(e) {
this.statusText = '语音识别插件加载失败'
this.debugInfo = '错误:' + (e.message || JSON.stringify(e)) + '\n\n需要制作自定义基座才能使用语音识别功能。'
console.error('[Speech] 插件加载失败:', e)
}
// #endif
// #ifndef APP-PLUS
this.statusText = '语音识别仅支持APP端'
this.debugInfo = '请在APP端使用语音识别功能'
// #endif
},
onUnload() {
console.log('[Speech] 页面卸载,清理资源')
// 标记页面已卸载,防止回调继续执行
this.pageUnloaded = true
// #ifdef APP-PLUS
// 安全地停止语音识别
if (this.isRecording) {
this.isRecording = false
try {
if (typeof stopSpeechVoice === 'function') {
stopSpeechVoice()
}
} catch(e) {
console.error('[Speech] 停止识别时出错:', e)
}
}
// #endif
this.stopAutoScroll()
// 清理定时器
if (this.scrollTimer) {
clearInterval(this.scrollTimer)
this.scrollTimer = null
}
},
onHide() {
console.log('[Speech] 页面隐藏,暂停识别')
// 页面切换到后台时停止识别
if (this.isRecording) {
this.handleStop()
}
},
methods: {
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
initSpeechModel() {
console.log('[Speech] ========== 开始初始化语音模型 ==========')
this.isLoading = true
this.statusText = '正在初始化模型...'
this.debugInfo = '检查模型缓存...'
try {
// 先检查是否有已保存的模型路径
const savedModelPath = uni.getStorageSync('vosk_model_path')
console.log('[Speech] 已保存的模型路径:', savedModelPath)
if (savedModelPath) {
this.debugInfo = '加载已解压模型: ' + savedModelPath.substring(savedModelPath.length - 30)
this.statusText = '正在加载已解压模型...'
initVoskModel({
modelPath: savedModelPath,
zipModelPath: ''
}, (result) => {
console.log('[Speech] 加载已保存模型结果:', JSON.stringify(result))
if (result && result.data && result.data.modelPath) {
this.modelPath = result.data.modelPath
this.isReady = true
this.isLoading = false
this.statusText = '准备就绪,可以开始说话'
this.debugInfo = '模型已加载: ' + this.modelPath.substring(this.modelPath.length - 20)
console.log('[Speech] 模型加载成功:', this.modelPath)
} else {
console.log('[Speech] 已保存模型加载失败,尝试从静态资源加载')
// 清除无效的缓存
uni.removeStorageSync('vosk_model_path')
this.initFromStatic()
}
})
} else {
this.initFromStatic()
}
} catch (error) {
console.error('[Speech] 初始化错误:', error)
this.isLoading = false
this.statusText = '初始化失败'
this.debugInfo = '错误: ' + (error.message || JSON.stringify(error))
uni.showToast({ title: '模型初始化失败', icon: 'none' })
}
},
initFromStatic() {
console.log('[Speech] ========== 从静态资源加载模型 ==========')
this.statusText = '正在解压模型文件...'
this.debugInfo = '首次加载正在解压模型约需30秒...'
try {
// 模型文件路径
const staticZipPath = '/static/vosk-model-small-cn-0.22.zip'
let resolvedPath = staticZipPath
// 转换路径
if (typeof plus !== 'undefined' && plus.io && typeof plus.io.convertLocalFileSystemURL === 'function') {
resolvedPath = plus.io.convertLocalFileSystemURL(staticZipPath)
console.log('[Speech] 转换后的路径:', resolvedPath)
}
this.debugInfo = '模型路径: ' + resolvedPath
initVoskModel({
zipModelPath: resolvedPath
}, (result) => {
console.log('[Speech] 静态资源加载结果:', JSON.stringify(result))
if (result && result.data && result.data.modelPath) {
this.modelPath = result.data.modelPath
// 保存模型路径,下次直接加载
uni.setStorageSync('vosk_model_path', result.data.modelPath)
this.isReady = true
this.isLoading = false
this.statusText = '准备就绪,可以开始说话'
this.debugInfo = '模型解压成功'
console.log('[Speech] 模型解压成功:', this.modelPath)
uni.showToast({ title: '模型加载成功', icon: 'success', duration: 2000 })
} else {
console.error('[Speech] 模型加载失败,结果:', result)
this.isLoading = false
this.statusText = '模型加载失败'
this.debugInfo = '加载失败: ' + JSON.stringify(result)
uni.showToast({ title: '模型加载失败,请检查模型文件', icon: 'none', duration: 3000 })
}
})
} catch (error) {
console.error('[Speech] 加载静态资源失败:', error)
this.isLoading = false
this.statusText = '模型加载失败'
this.debugInfo = '异常: ' + (error.message || JSON.stringify(error))
}
},
handleStart() {
// 检查模型是否准备好
if (!this.isReady) {
uni.showToast({ title: '模型未准备好,请稍候', icon: 'none' })
return
}
// 防止重复点击
if (this.isRecording) {
console.log('[Speech] 已在录音中,忽略重复点击')
return
}
// 检查模型路径是否有效
if (!this.modelPath) {
uni.showToast({ title: '模型路径无效,请重新初始化', icon: 'none' })
this.isReady = false
return
}
console.log('[Speech] ========== 开始语音识别 ==========')
console.log('[Speech] 模型路径:', this.modelPath)
this.isRecording = true
this.statusText = '正在识别中...'
this.recognizedText = ''
this.scoreResult = null
this.hasFirstResult = false
this.debugInfo = '识别中...'
this.startAutoScroll()
try {
const self = this
startSpeechVoice((res) => {
// 检查页面是否已卸载
if (self.pageUnloaded) {
console.log('[Speech] 页面已卸载,忽略回调')
return
}
// 检查是否仍在录音状态,如果已停止则忽略回调
if (!self.isRecording) {
console.log('[Speech] 已停止录音,忽略回调')
return
}
console.log('[Speech] 识别回调:', JSON.stringify(res))
// 检查是否有错误code === -1 表示错误)
if (res && res.code === -1) {
console.error('[Speech] 识别出错:', res.data?.errorMsg)
self.isRecording = false
self.statusText = '识别出错'
self.debugInfo = '错误: ' + (res.data?.errorMsg || '未知错误')
self.stopAutoScroll()
uni.showToast({ title: res.data?.errorMsg || '语音识别出错', icon: 'none', duration: 2000 })
return
}
if (res && res.data && res.data.text) {
const text = res.data.text
console.log('[Speech] 识别到文本:', text, '长度:', text.length)
// 实时显示识别结果vosk会持续返回实时结果
if (text && text.trim()) {
self.recognizedText = text
self.hasFirstResult = true
self.debugInfo = '实时识别中: ' + text.length + '字'
self.$nextTick(() => { self.scrollToBottom() })
}
}
})
} catch (error) {
console.error('[Speech] 开始识别错误:', error)
this.isRecording = false
this.statusText = '识别启动失败'
this.debugInfo = '启动失败: ' + (error.message || JSON.stringify(error))
uni.showToast({ title: '识别启动失败', icon: 'none' })
}
},
handleStop() {
console.log('[Speech] ========== 停止语音识别 ==========')
// 防止重复调用
if (!this.isRecording) {
console.log('[Speech] 未在录音中,忽略停止请求')
return
}
// 先设置状态为非录音,这样回调会被忽略
this.isRecording = false
this.stopAutoScroll()
try {
// 安全地停止语音识别
if (typeof stopSpeechVoice === 'function') {
stopSpeechVoice()
}
this.statusText = '识别完成'
// 延迟检查结果,等待最后的回调处理完成
setTimeout(() => {
if (!this.hasFirstResult || !this.recognizedText || this.recognizedText.trim() === '') {
this.debugInfo = '未识别到有效语音'
uni.showToast({ title: '未识别到有效语音,请重试或手动输入', icon: 'none', duration: 2000 })
} else {
this.debugInfo = '识别完成,共' + this.recognizedText.length + '字'
uni.showToast({ title: '识别完成', icon: 'success', duration: 1500 })
}
}, 300)
} catch (error) {
console.error('[Speech] 停止识别错误:', error)
this.statusText = '已停止识别'
this.debugInfo = '停止时出错: ' + (error.message || '')
}
},
// #endif
// #ifndef APP-PLUS
handleStart() {
uni.showToast({ title: '语音识别仅支持APP端', icon: 'none' })
},
handleStop() {},
initSpeechModel() {
this.statusText = '语音识别仅支持APP端'
},
// #endif
// 手动输入文本
showManualInput() {
uni.showModal({
title: '手动输入识别文本',
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>