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'
|
|
|
|
|
|
// 新方案:使用 utils/speech-recorder.js
|
|
|
|
|
|
import speechRecorder from '@/utils/speech-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,
|
2025-12-05 18:15:23 +08:00
|
|
|
|
isSaving: false,
|
2025-12-07 08:40:26 +08:00
|
|
|
|
pageUnloaded: false, // 页面卸载标记
|
|
|
|
|
|
recordStartTime: 0, // 录音开始时间
|
|
|
|
|
|
recordingFailCount: 0 // 录音失败次数
|
2025-12-03 18:58:36 +08:00
|
|
|
|
}
|
|
|
|
|
|
},
|
2025-12-07 08:40:26 +08:00
|
|
|
|
onLoad(options) {
|
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插件
|
2025-12-07 01:19:40 +08:00
|
|
|
|
this.initSpeechService()
|
2025-12-03 18:58:36 +08:00
|
|
|
|
// #endif
|
|
|
|
|
|
// #ifndef APP-PLUS
|
|
|
|
|
|
this.statusText = '语音识别仅支持APP端'
|
|
|
|
|
|
this.debugInfo = '请在APP端使用语音识别功能'
|
|
|
|
|
|
// #endif
|
|
|
|
|
|
},
|
|
|
|
|
|
onUnload() {
|
2025-12-05 18:15:23 +08:00
|
|
|
|
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()
|
2025-12-05 18:15:23 +08:00
|
|
|
|
// 清理定时器
|
|
|
|
|
|
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] 开始初始化语音服务')
|
|
|
|
|
|
|
|
|
|
|
|
// 请求录音权限
|
|
|
|
|
|
const permissionResult = await this.requestRecordPermission()
|
|
|
|
|
|
if (!permissionResult) {
|
|
|
|
|
|
this.statusText = '需要录音权限'
|
|
|
|
|
|
this.debugInfo = '请在设置中开启录音权限'
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 初始化录音器
|
|
|
|
|
|
speechRecorder.init()
|
|
|
|
|
|
this.statusText = '准备就绪'
|
|
|
|
|
|
this.isReady = true
|
|
|
|
|
|
console.log('[Speech] 语音服务初始化成功')
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('[Speech] 初始化失败', error)
|
|
|
|
|
|
this.statusText = '初始化失败'
|
|
|
|
|
|
this.debugInfo = '错误: ' + error.message
|
|
|
|
|
|
|
|
|
|
|
|
uni.showToast({
|
|
|
|
|
|
title: '语音服务初始化失败',
|
|
|
|
|
|
icon: 'none',
|
|
|
|
|
|
duration: 2000
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// 请求录音权限
|
|
|
|
|
|
requestRecordPermission() {
|
|
|
|
|
|
return new Promise((resolve) => {
|
|
|
|
|
|
// #ifdef APP-PLUS
|
|
|
|
|
|
const permissions = ['android.permission.RECORD_AUDIO']
|
|
|
|
|
|
|
|
|
|
|
|
plus.android.requestPermissions(
|
|
|
|
|
|
permissions,
|
|
|
|
|
|
(result) => {
|
|
|
|
|
|
console.log('[Speech] 权限请求结果', result)
|
|
|
|
|
|
const granted = result.granted && result.granted.length > 0
|
|
|
|
|
|
if (granted) {
|
|
|
|
|
|
console.log('[Speech] 录音权限已授予')
|
|
|
|
|
|
resolve(true)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
console.log('[Speech] 录音权限被拒绝')
|
|
|
|
|
|
uni.showModal({
|
|
|
|
|
|
title: '需要录音权限',
|
|
|
|
|
|
content: '语音评测需要使用您的麦克风,请在设置中开启录音权限',
|
|
|
|
|
|
showCancel: false
|
|
|
|
|
|
})
|
|
|
|
|
|
resolve(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
(error) => {
|
|
|
|
|
|
console.error('[Speech] 权限请求失败', error)
|
|
|
|
|
|
resolve(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
// #endif
|
|
|
|
|
|
// #ifndef APP-PLUS
|
|
|
|
|
|
resolve(true)
|
|
|
|
|
|
// #endif
|
|
|
|
|
|
})
|
|
|
|
|
|
},
|
|
|
|
|
|
|
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 {
|
|
|
|
|
|
speechRecorder.start()
|
|
|
|
|
|
uni.showToast({ title: '开始录音!请大声说话(推荐3-10秒)', icon: 'none', duration: 2500 })
|
|
|
|
|
|
|
|
|
|
|
|
// 3秒后提示可以停止
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
if (this.isRecording) {
|
|
|
|
|
|
uni.showToast({ title: '可以停止了(最佳时长3-10秒)', icon: 'success', duration: 1500 })
|
|
|
|
|
|
}
|
|
|
|
|
|
}, 3000)
|
|
|
|
|
|
|
|
|
|
|
|
// 10秒后提示不要太长
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
if (this.isRecording) {
|
|
|
|
|
|
uni.showToast({ title: '建议尽快停止,避免过长', icon: 'none', duration: 2000 })
|
|
|
|
|
|
}
|
|
|
|
|
|
}, 10000)
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('[Speech] 录音启动失败:', error)
|
|
|
|
|
|
this.isRecording = false
|
|
|
|
|
|
this.statusText = '录音启动失败'
|
|
|
|
|
|
uni.showToast({
|
|
|
|
|
|
title: '录音失败: ' + error.message,
|
|
|
|
|
|
icon: 'none'
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
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. 先停止录音,获取录音文件路径
|
|
|
|
|
|
console.log('[Speech] 停止录音中...')
|
|
|
|
|
|
const filePath = await speechRecorder.stop()
|
|
|
|
|
|
console.log('[Speech] 录音文件路径:', filePath)
|
|
|
|
|
|
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('录音文件获取失败')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 验证文件大小(关键!检测录音是否完整)
|
|
|
|
|
|
const fileInfo = await new Promise((resolve, reject) => {
|
|
|
|
|
|
uni.getFileInfo({
|
|
|
|
|
|
filePath: filePath,
|
|
|
|
|
|
success: resolve,
|
|
|
|
|
|
fail: reject
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const fileSize = fileInfo.size
|
|
|
|
|
|
const expectedSize = actualDuration * 32000 // 16kHz单声道16bit = 32000 bytes/s
|
|
|
|
|
|
const sizeRatio = fileSize / expectedSize
|
|
|
|
|
|
|
|
|
|
|
|
console.log('[Speech] 文件大小:', fileSize, 'bytes')
|
|
|
|
|
|
console.log('[Speech] 预期大小:', expectedSize.toFixed(0), 'bytes')
|
|
|
|
|
|
console.log('[Speech] 完整度:', (sizeRatio * 100).toFixed(1), '%')
|
|
|
|
|
|
|
|
|
|
|
|
// 如果文件大小 < 预期的30%,说明严重丢失数据
|
|
|
|
|
|
if (sizeRatio < 0.3) {
|
|
|
|
|
|
uni.hideLoading()
|
|
|
|
|
|
uni.showModal({
|
|
|
|
|
|
title: '录音文件不完整',
|
|
|
|
|
|
content: `录音${actualDuration.toFixed(1)}秒,但文件只有${(fileSize/32000).toFixed(1)}秒。您的设备可能不支持此录音方式。\n\n建议:\n1. 重试并说慢一点\n2. 录音时长控制在3-5秒\n3. 或使用手动输入`,
|
|
|
|
|
|
showCancel: true,
|
|
|
|
|
|
cancelText: '重试',
|
|
|
|
|
|
confirmText: '手动输入',
|
|
|
|
|
|
success: (res) => {
|
|
|
|
|
|
if (res.confirm) {
|
|
|
|
|
|
this.showManualInput()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
2025-12-05 18:15:23 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-07 08:40:26 +08:00
|
|
|
|
// 如果文件大小30%-70%,警告但继续
|
|
|
|
|
|
if (sizeRatio < 0.7) {
|
|
|
|
|
|
console.warn('[Speech] 警告:录音文件可能不完整,完整度:', (sizeRatio * 100).toFixed(1), '%')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 2. 上传并评测
|
2025-12-07 00:11:06 +08:00
|
|
|
|
uni.showLoading({ title: '评测中...', mask: true })
|
2025-12-07 08:40:26 +08:00
|
|
|
|
console.log('[Speech] 开始评测...')
|
2025-12-03 18:58:36 +08:00
|
|
|
|
|
2025-12-07 08:40:26 +08:00
|
|
|
|
const result = await speechRecorder.evaluateAsync(
|
|
|
|
|
|
this.selectedContent?.content || '测试文本',
|
|
|
|
|
|
this.selectedContent?.id
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
uni.hideLoading()
|
|
|
|
|
|
|
|
|
|
|
|
if (result.success) {
|
|
|
|
|
|
this.recognizedText = result.recognizedText || ''
|
|
|
|
|
|
this.scoreResult = {
|
|
|
|
|
|
score: result.score || 0,
|
|
|
|
|
|
pronunciationScore: result.pronunciationScore || 0,
|
|
|
|
|
|
fluencyScore: result.fluencyScore || 0
|
|
|
|
|
|
}
|
|
|
|
|
|
this.hasFirstResult = true
|
|
|
|
|
|
this.statusText = '评测完成'
|
|
|
|
|
|
this.debugInfo = `得分:${result.score}分`
|
|
|
|
|
|
this.recordingFailCount = 0 // 成功后重置失败计数
|
|
|
|
|
|
uni.showToast({ title: `得分:${result.score}分`, icon: 'success' })
|
|
|
|
|
|
} else {
|
|
|
|
|
|
this.statusText = '评测失败'
|
|
|
|
|
|
this.debugInfo = result.error || '评测失败'
|
|
|
|
|
|
this.recordingFailCount++
|
2025-12-07 00:11:06 +08:00
|
|
|
|
|
2025-12-07 08:40:26 +08:00
|
|
|
|
// 连续失败2次,建议手动输入
|
|
|
|
|
|
if (this.recordingFailCount >= 2) {
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
uni.showModal({
|
|
|
|
|
|
title: '录音功能异常',
|
|
|
|
|
|
content: '您的设备录音功能可能不兼容,已连续失败' + this.recordingFailCount + '次。\n\n建议使用手动输入功能完成练习。',
|
|
|
|
|
|
cancelText: '重试',
|
|
|
|
|
|
confirmText: '手动输入',
|
|
|
|
|
|
success: (res) => {
|
|
|
|
|
|
if (res.confirm) {
|
|
|
|
|
|
this.showManualInput()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
this.recordingFailCount = 0 // 用户选择重试,重置计数
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}, 500)
|
2025-12-07 00:11:06 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
uni.showToast({ title: '评测失败', icon: 'none' })
|
|
|
|
|
|
}
|
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() {
|
|
|
|
|
|
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>
|