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"
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
2025-12-07 00:11:06 +08:00
// 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'
// 新方案:使用 AAC 录音工具(已验证可用)
import aacRecorder from '@/utils/aac-recorder.js'
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,
2025-12-07 08:40:26 +08:00
pageUnloaded: false, // 页面卸载标记
recordStartTime: 0, // 录音开始时间
2025-12-11 23:28:07 +08:00
recordingFailCount: 0, // 录音失败次数
2025-12-13 13:36:18 +08:00
lastRecordedFilePath: null // ✅ 保存服务器返回的音频路径
2025-12-03 18:58:36 +08:00
}
},
2025-12-07 08:40:26 +08:00
onLoad(options) {
console.log('[Speech] 页面加载 - 开始')
// 调试:显示页面加载信息
uni.showToast({
title: 'Speech页面已加载',
icon: 'none',
duration: 2000
})
2025-12-03 18:58:36 +08:00
if (options.contentId) {
this.loadContentById(options.contentId)
} else {
this.loadContentList()
}
// #ifdef APP-PLUS
2025-12-07 00:11:06 +08:00
// 新方案使用服务器端识别无需UTS插件
console.log('[Speech] 初始化语音服务 - APP环境')
2025-12-07 01:19:40 +08:00
this.initSpeechService()
2025-12-03 18:58:36 +08:00
// #endif
// #ifndef APP-PLUS
console.log('[Speech] 非APP环境')
2025-12-03 18:58:36 +08:00
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-07 08:40:26 +08:00
// 停止录音
2025-12-03 18:58:36 +08:00
if (this.isRecording) {
2025-12-07 08:40:26 +08:00
console.log('[Speech] 页面卸载时停止录音')
this.handleStop()
2025-12-03 18:58:36 +08:00
}
// #endif
2025-12-07 08:40:26 +08:00
2025-12-03 18:58:36 +08:00
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: {
2025-12-07 08:40:26 +08:00
// 初始化语音服务
async initSpeechService() {
try {
console.log('[Speech] 开始初始化语音服务')
// AAC录音工具无需初始化直接标记就绪
this.statusText = '准备就绪'
this.isReady = true
console.log('[Speech] 语音服务初始化成功AAC录音')
2025-12-07 08:40:26 +08:00
} catch (error) {
console.error('[Speech] 初始化失败', error)
this.statusText = '初始化失败'
this.debugInfo = '错误: ' + error.message
uni.showToast({
title: '语音服务初始化失败',
icon: 'none',
duration: 2000
})
}
},
2025-12-03 18:58:36 +08:00
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
2025-12-07 08:40:26 +08:00
async handleStart() {
2025-12-03 18:58:36 +08:00
if (!this.isReady) {
2025-12-07 00:11:06 +08:00
uni.showToast({ title: '未准备好,请稍候', icon: 'none' })
2025-12-03 18:58:36 +08:00
return
}
if (this.isRecording) {
2025-12-07 00:11:06 +08:00
console.log('[Speech] 已在录音中')
2025-12-03 18:58:36 +08:00
return
}
2025-12-07 08:40:26 +08:00
if (!this.selectedContent) {
uni.showToast({ title: '请先选择题目', icon: 'none' })
return
}
// 启动录音
2025-12-03 18:58:36 +08:00
this.isRecording = true
2025-12-07 08:40:26 +08:00
this.recordStartTime = Date.now() // 记录开始时间
console.log('[Speech] 录音开始时间:', this.recordStartTime)
2025-12-07 00:11:06 +08:00
this.statusText = '正在录音...'
2025-12-03 18:58:36 +08:00
this.recognizedText = ''
this.scoreResult = null
2025-12-07 08:40:26 +08:00
this.debugInfo = '最佳时长3-10秒'
2025-12-03 18:58:36 +08:00
2025-12-07 08:40:26 +08:00
try {
await aacRecorder.start(60000)
2025-12-07 08:40:26 +08:00
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'
})
}
2025-12-03 18:58:36 +08:00
},
2025-12-07 00:11:06 +08:00
async handleStop() {
2025-12-07 08:40:26 +08:00
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. 停止录音
2025-12-07 08:40:26 +08:00
console.log('[Speech] 停止录音中...')
const result = await aacRecorder.stop()
const filePath = result.filePath
const fileSize = result.fileSize
2025-12-07 08:40:26 +08:00
console.log('[Speech] 录音文件路径:', filePath)
console.log('[Speech] 文件大小:', fileSize, 'bytes')
console.log('[Speech] 录音时长:', actualDuration, '秒')
2025-12-03 18:58:36 +08:00
2025-12-07 08:40:26 +08:00
if (!filePath) {
throw new Error('录音文件获取失败')
}
2025-12-13 13:36:18 +08:00
// 2. ✅ 上传并评测(使用 uploadAndEvaluate 一次性完成识别+评测)
console.log('[Speech] ========================================')
console.log('[Speech] 🎯 新版本代码 v2.0 - 统一评测模式')
console.log('[Speech] 准备调用 uploadAndEvaluate')
console.log('[Speech] 文件路径:', filePath)
console.log('[Speech] 文件类型:', typeof filePath)
console.log('[Speech] 评测内容:', this.selectedContent?.content)
console.log('[Speech] ========================================')
// 显示版本提示
uni.showToast({
title: '新版本v2.0-统一评测',
icon: 'none',
duration: 1500
})
const { uploadAndEvaluate } = await import('@/api/study/voiceEvaluation.js')
const evalResult = await uploadAndEvaluate(
filePath,
2025-12-13 13:36:18 +08:00
this.selectedContent?.content || '测试文本',
null,
'zh-CN'
2025-12-07 08:40:26 +08:00
)
uni.hideLoading()
2025-12-13 13:36:18 +08:00
// 🔍 调试:输出完整返回数据
console.log('[Speech] 返回结果 code:', evalResult.code)
console.log('[Speech] 返回结果:', JSON.stringify(evalResult))
console.log('[Speech] 是否有evaluation:', !!evalResult.evaluation)
// ✅ 只检查 code === 200不强制要求 evaluation
if (evalResult.code === 200) {
// 获取评测数据(可能在 evaluation 或 data 中)
const evaluation = evalResult.evaluation || evalResult.data || {}
const recognizedText = evalResult.recognizedText || evaluation.recognizedText || ''
// ✅ 设置识别文本
this.recognizedText = recognizedText
console.log('[Speech] 识别文本:', this.recognizedText)
// ✅ 设置评分结果(兼容多种数据格式)
2025-12-07 08:40:26 +08:00
this.scoreResult = {
2025-12-13 13:36:18 +08:00
totalScore: Number(evaluation.score) || Number(evaluation.totalScore) || 0,
accuracy: Number(evaluation.accuracy) || 0,
completeness: Number(evaluation.completeness) || 0,
fluency: Number(evaluation.fluency) || 0,
pronunciation: Number(evaluation.pronunciation) || 0
2025-12-07 08:40:26 +08:00
}
2025-12-13 13:36:18 +08:00
console.log('[Speech] 评分结果:', this.scoreResult)
2025-12-11 23:28:07 +08:00
2025-12-13 13:36:18 +08:00
// ✅ 保存评测ID和提交状态
this.currentEvaluationId = evaluation.id
const submitted = evaluation.isSubmitted
this.isSubmitted = submitted === 1 || submitted === true || submitted === '1'
// ✅ 保存服务器音频路径
if (evaluation.audioPath) {
this.lastRecordedFilePath = evaluation.audioPath
console.log('[Speech] 服务器音频路径:', evaluation.audioPath)
2025-12-11 23:28:07 +08:00
}
2025-12-07 08:40:26 +08:00
this.hasFirstResult = true
this.statusText = '评测完成'
2025-12-13 13:36:18 +08:00
const finalScore = this.scoreResult.totalScore
this.debugInfo = `得分:${finalScore}`
this.recordingFailCount = 0
2025-12-13 13:36:18 +08:00
uni.showToast({ title: `得分:${finalScore}`, icon: 'success' })
2025-12-07 08:40:26 +08:00
} else {
2025-12-13 13:36:18 +08:00
console.error('[Speech] ❌ 评测失败,返回码:', evalResult.code)
console.error('[Speech] evalResult:', evalResult)
throw new Error(evalResult.msg || '评测失败')
2025-12-03 18:58:36 +08:00
}
2025-12-07 08:40:26 +08:00
} 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 })
}
}
},
2025-12-03 18:58:36 +08:00
// #endif
// #ifndef APP-PLUS
handleStart() {
uni.showToast({ title: '语音识别仅支持APP端', icon: 'none' })
},
handleStop() {},
// #endif
// 手动输入文本
showManualInput() {
2025-12-07 08:40:26 +08:00
if (!this.selectedContent) {
uni.showToast({ title: '请先选择题目', icon: 'none' })
return
}
2025-12-03 18:58:36 +08:00
uni.showModal({
title: '手动输入识别文本',
2025-12-07 08:40:26 +08:00
content: this.selectedContent.content || '',
2025-12-03 18:58:36 +08:00
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() {
2025-12-13 13:36:18 +08:00
// ✅ 已经在停止录音时完成评分,这里不需要再次评分
if (this.scoreResult && this.scoreResult.totalScore !== undefined) {
uni.showToast({ title: '已经评分完成', icon: 'none' })
this.$nextTick(() => { uni.pageScrollTo({ selector: '.score-section', duration: 300 }) })
2025-12-03 18:58:36 +08:00
return
}
2025-12-13 13:36:18 +08:00
uni.showToast({ title: '请先录音', icon: 'none' })
2025-12-03 18:58:36 +08:00
},
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,
2025-12-11 23:28:07 +08:00
scoreData.fluency, scoreData.pronunciation, null, resultDetail,
this.lastRecordedFilePath // ✅ 传递录音文件路径
2025-12-03 18:58:36 +08:00
)
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
2025-12-13 13:36:18 +08:00
this.lastRecordedFilePath = null // ✅ 清空服务器路径
2025-12-03 18:58:36 +08:00
},
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>