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'
|
|
|
|
|
|
// #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,
|
|
|
|
|
|
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
|
2025-12-05 18:15:23 +08:00
|
|
|
|
// 检查UTS插件是否可用
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (typeof initVoskModel === 'undefined') {
|
|
|
|
|
|
this.statusText = '语音识别插件未加载'
|
|
|
|
|
|
this.debugInfo = '需要制作自定义基座才能使用语音识别功能。\n\n步骤:\n1. HBuilderX菜单:发行 → 原生App-制作自定义调试基座\n2. 选择Android平台\n3. 等待打包完成\n4. 安装自定义基座到手机'
|
|
|
|
|
|
console.log('[Speech] UTS插件未加载,需要自定义基座')
|
|
|
|
|
|
} else {
|
|
|
|
|
|
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() {
|
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-05 18:15:23 +08:00
|
|
|
|
// 安全地停止语音识别
|
2025-12-03 18:58:36 +08:00
|
|
|
|
if (this.isRecording) {
|
2025-12-05 18:15:23 +08:00
|
|
|
|
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()
|
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: {
|
|
|
|
|
|
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-05 18:15:23 +08:00
|
|
|
|
// 检查模型是否准备好
|
2025-12-03 18:58:36 +08:00
|
|
|
|
if (!this.isReady) {
|
|
|
|
|
|
uni.showToast({ title: '模型未准备好,请稍候', icon: 'none' })
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-05 18:15:23 +08:00
|
|
|
|
// 防止重复点击
|
2025-12-03 18:58:36 +08:00
|
|
|
|
if (this.isRecording) {
|
2025-12-05 18:15:23 +08:00
|
|
|
|
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] ========== 开始语音识别 ==========')
|
2025-12-05 18:15:23 +08:00
|
|
|
|
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) => {
|
2025-12-05 18:15:23 +08:00
|
|
|
|
// 检查页面是否已卸载
|
|
|
|
|
|
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))
|
2025-12-05 18:15:23 +08:00
|
|
|
|
|
|
|
|
|
|
// 检查是否有错误(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)
|
|
|
|
|
|
|
|
|
|
|
|
// Vosk返回的是每句话的完整结果,直接追加
|
|
|
|
|
|
if (text && text.trim()) {
|
|
|
|
|
|
if (!self.hasFirstResult) {
|
|
|
|
|
|
self.recognizedText = text
|
|
|
|
|
|
self.hasFirstResult = true
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 追加新识别的文本,用空格分隔
|
|
|
|
|
|
self.recognizedText += ' ' + text
|
|
|
|
|
|
}
|
|
|
|
|
|
self.debugInfo = '已识别: ' + self.recognizedText.length + '字'
|
|
|
|
|
|
self.$nextTick(() => { self.scrollToBottom() })
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('[Speech] 开始识别错误:', error)
|
|
|
|
|
|
this.isRecording = false
|
|
|
|
|
|
this.statusText = '识别启动失败'
|
|
|
|
|
|
this.debugInfo = '启动失败: ' + (error.message || JSON.stringify(error))
|
|
|
|
|
|
uni.showToast({ title: '识别启动失败', icon: 'none' })
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
handleStop() {
|
|
|
|
|
|
console.log('[Speech] ========== 停止语音识别 ==========')
|
|
|
|
|
|
|
2025-12-05 18:15:23 +08:00
|
|
|
|
// 防止重复调用
|
|
|
|
|
|
if (!this.isRecording) {
|
|
|
|
|
|
console.log('[Speech] 未在录音中,忽略停止请求')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-03 18:58:36 +08:00
|
|
|
|
// 先设置状态为非录音,这样回调会被忽略
|
|
|
|
|
|
this.isRecording = false
|
|
|
|
|
|
this.stopAutoScroll()
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
2025-12-05 18:15:23 +08:00
|
|
|
|
// 安全地停止语音识别
|
|
|
|
|
|
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>
|