guoyu/fronted_uniapp/pages/speech/speech.vue

939 lines
36 KiB
Vue
Raw Normal View History

2025-12-03 18:58:36 +08:00
<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'
2025-12-03 18:58:36 +08:00
// #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 // 页面卸载标记
2025-12-03 18:58:36 +08:00
}
},
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)
}
2025-12-03 18:58:36 +08:00
// #endif
// #ifndef APP-PLUS
this.statusText = '语音识别仅支持APP端'
this.debugInfo = '请在APP端使用语音识别功能'
// #endif
},
onUnload() {
console.log('[Speech] 页面卸载,清理资源')
// 标记页面已卸载,防止回调继续执行
this.pageUnloaded = true
2025-12-03 18:58:36 +08:00
// #ifdef APP-PLUS
// 安全地停止语音识别
2025-12-03 18:58:36 +08:00
if (this.isRecording) {
this.isRecording = false
try {
if (typeof stopSpeechVoice === 'function') {
stopSpeechVoice()
}
} catch(e) {
console.error('[Speech] 停止识别时出错:', e)
}
2025-12-03 18:58:36 +08:00
}
// #endif
this.stopAutoScroll()
// 清理定时器
if (this.scrollTimer) {
clearInterval(this.scrollTimer)
this.scrollTimer = null
}
},
onHide() {
console.log('[Speech] 页面隐藏,暂停识别')
// 页面切换到后台时停止识别
if (this.isRecording) {
this.handleStop()
}
2025-12-03 18:58:36 +08:00
},
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() {
// 检查模型是否准备好
2025-12-03 18:58:36 +08:00
if (!this.isReady) {
uni.showToast({ title: '模型未准备好,请稍候', icon: 'none' })
return
}
// 防止重复点击
2025-12-03 18:58:36 +08:00
if (this.isRecording) {
console.log('[Speech] 已在录音中,忽略重复点击')
return
}
// 检查模型路径是否有效
if (!this.modelPath) {
uni.showToast({ title: '模型路径无效,请重新初始化', icon: 'none' })
this.isReady = false
2025-12-03 18:58:36 +08:00
return
}
console.log('[Speech] ========== 开始语音识别 ==========')
console.log('[Speech] 模型路径:', this.modelPath)
2025-12-03 18:58:36 +08:00
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
}
2025-12-03 18:58:36 +08:00
// 检查是否仍在录音状态,如果已停止则忽略回调
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
}
2025-12-03 18:58:36 +08:00
if (res && res.data && res.data.text) {
const text = res.data.text
console.log('[Speech] 识别到文本:', text, '长度:', text.length)
2025-12-03 18:58:36 +08:00
// 实时显示识别结果vosk会持续返回实时结果
2025-12-03 18:58:36 +08:00
if (text && text.trim()) {
self.recognizedText = text
self.hasFirstResult = true
self.debugInfo = '实时识别中: ' + text.length + '字'
2025-12-03 18:58:36 +08:00
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
}
2025-12-03 18:58:36 +08:00
// 先设置状态为非录音,这样回调会被忽略
this.isRecording = false
this.stopAutoScroll()
try {
// 安全地停止语音识别
if (typeof stopSpeechVoice === 'function') {
stopSpeechVoice()
}
2025-12-03 18:58:36 +08:00
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>