guoyu/fronted_uniapp/pages/speech/speech.vue
2025-12-03 18:58:36 +08:00

852 lines
33 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view class="speech-evaluation-container">
<!-- 顶部导航栏 -->
<view class="top-header">
<view class="header-left" @click="goBack">
<text class="back-icon"></text>
<text class="back-text">返回</text>
</view>
<text class="header-title">语音测评</text>
<view class="header-right"></view>
</view>
<!-- 内容选择区域 -->
<view class="content-select-section" v-if="!selectedContent">
<view class="section-title">📚 选择测评内容</view>
<view class="content-list" v-if="contentList.length > 0">
<view
v-for="(item, index) in contentList"
:key="item.id || index"
class="content-item"
@click="selectContent(item)"
>
<view class="content-item-header">
<text class="content-item-title">{{ item.title || '未命名题目' }}</text>
<text class="content-item-difficulty" :class="'difficulty-' + (item.difficulty || 'medium')">
{{ getDifficultyText(item.difficulty) }}
</text>
</view>
<view class="content-item-preview">
{{ item.content ? (item.content.length > 50 ? item.content.substring(0, 50) + '...' : item.content) : '暂无内容' }}
</view>
</view>
</view>
<view v-else-if="!loadingContent" class="empty-tip">
<text class="empty-icon">📭</text>
<text>暂无测评内容</text>
</view>
<view v-else class="loading-tip">
<text>加载中...</text>
</view>
</view>
<!-- 测评内容显示区域 -->
<view class="content-display-section" v-if="selectedContent">
<view class="section-header">
<text class="section-title">📝 测评内容</text>
<text class="change-btn" @click="changeContent">更换</text>
</view>
<view class="content-text">
<text>{{ selectedContent.content }}</text>
</view>
</view>
<!-- 语音识别状态 -->
<view class="status-section" v-if="selectedContent">
<view class="status-item">
<text class="status-label">识别状态:</text>
<text class="status-value" :class="{
'status-ready': isReady && !isRecording,
'status-recording': isRecording,
'status-loading': isLoading
}">
{{ statusText }}
</text>
</view>
<!-- 调试信息 -->
<view class="debug-info" v-if="debugInfo">
<text class="debug-text">{{ debugInfo }}</text>
</view>
<view class="recording-tip" v-if="isRecording">
<view class="recording-dots">
<view class="dot dot1"></view>
<view class="dot dot2"></view>
<view class="dot dot3"></view>
</view>
<text class="recording-text">正在识别,请清晰朗读,每句话后稍作停顿...</text>
</view>
</view>
<!-- 语音识别操作区域 -->
<view class="action-section" v-if="selectedContent">
<!-- 未准备好时显示初始化按钮 -->
<button
v-if="!isReady && !isLoading"
class="action-btn btn-init"
@click="initSpeechModel"
>
<text class="btn-icon">🔄</text>
<text class="btn-text">重新初始化</text>
</button>
<!-- 准备好后显示开始按钮 -->
<button
v-if="isReady"
class="action-btn"
:class="{ 'btn-recording': isRecording, 'btn-ready': !isRecording }"
:disabled="isLoading"
@click="handleStart"
>
<text class="btn-icon">{{ isRecording ? '🎤' : '🎙️' }}</text>
<text class="btn-text">{{ isRecording ? '正在识别...' : '开始说话' }}</text>
</button>
<button v-if="isRecording" class="action-btn btn-stop" @click="handleStop">
<text class="btn-text">停止识别</text>
</button>
<!-- 手动输入按钮 -->
<button class="manual-input-btn" @click="showManualInput">
<text>📝 手动输入文本</text>
</button>
<!-- 工具按钮 -->
<view class="tool-buttons">
<button class="tool-btn" @click="clearModelCache">
<text>🗑️ 清除缓存重新加载</text>
</button>
<button class="tool-btn" @click="testMicrophone">
<text>🎤 测试麦克风</text>
</button>
</view>
</view>
<!-- 识别结果区域 -->
<view class="result-section" v-if="selectedContent && (recognizedText || isRecording)">
<view class="section-title">🎯 识别结果</view>
<view class="recognition-progress" v-if="isRecording">
<view class="progress-dots">
<view class="progress-dot dot1"></view>
<view class="progress-dot dot2"></view>
<view class="progress-dot dot3"></view>
</view>
<text class="progress-text">实时识别中...</text>
</view>
<view class="result-content">
<scroll-view class="result-scroll" scroll-y :scroll-top="scrollTop" scroll-with-animation>
<text class="result-text" v-if="recognizedText">{{ recognizedText }}</text>
<text class="result-text text-loading" v-else-if="isRecording">正在启动语音识别,请稍候...</text>
<view class="typing-cursor" v-if="isRecording && recognizedText">|</view>
</scroll-view>
</view>
<button
class="evaluate-btn"
@click="evaluateScore"
:loading="isEvaluating"
:disabled="!recognizedText || recognizedText.trim() === ''"
v-if="!isRecording"
>
{{ isEvaluating ? '评分中...' : '开始评分' }}
</button>
</view>
<!-- 评分结果区域 -->
<view class="score-section" v-if="scoreResult">
<view class="section-title">📊 评分结果</view>
<view class="score-main">
<text class="score-main-value">{{ scoreResult.totalScore }}</text>
<text class="score-main-label">总分</text>
</view>
<view class="score-details">
<view class="score-item">
<view class="score-item-header">
<text class="score-item-label">准确度</text>
<text class="score-item-value">{{ scoreResult.accuracy }}分</text>
</view>
<view class="score-progress">
<view class="score-progress-bar" :style="{ width: scoreResult.accuracy + '%' }"></view>
</view>
</view>
<view class="score-item">
<view class="score-item-header">
<text class="score-item-label">完整度</text>
<text class="score-item-value">{{ scoreResult.completeness }}分</text>
</view>
<view class="score-progress">
<view class="score-progress-bar" :style="{ width: scoreResult.completeness + '%' }"></view>
</view>
</view>
<view class="score-item">
<view class="score-item-header">
<text class="score-item-label">流畅度</text>
<text class="score-item-value">{{ scoreResult.fluency }}分</text>
</view>
<view class="score-progress">
<view class="score-progress-bar" :style="{ width: scoreResult.fluency + '%' }"></view>
</view>
</view>
<view class="score-item">
<view class="score-item-header">
<text class="score-item-label">发音</text>
<text class="score-item-value">{{ scoreResult.pronunciation }}分</text>
</view>
<view class="score-progress">
<view class="score-progress-bar" :style="{ width: scoreResult.pronunciation + '%' }"></view>
</view>
</view>
</view>
<view class="score-info" v-if="scoreResult.details">
<view class="info-item">
<text class="info-label">相似度:</text>
<text class="info-value">{{ scoreResult.details.similarity }}%</text>
</view>
<view class="info-item">
<text class="info-label">正确字符:</text>
<text class="info-value">{{ scoreResult.details.correctChars }}/{{ scoreResult.details.totalChars }}</text>
</view>
</view>
<view class="submit-section" v-if="currentEvaluationId && !isSubmitted">
<button @click="submitEvaluation" class="btn-submit" :loading="isSubmitting">
{{ isSubmitting ? '提交中...' : '提交评测' }}
</button>
</view>
<view class="submit-status-section" v-if="isSubmitted">
<text class="submit-status-text"> 已提交</text>
</view>
<view class="action-buttons">
<button class="btn-retry" @click="resetEvaluation">重新测评</button>
<button class="btn-change" @click="changeContent">更换内容</button>
</view>
</view>
</view>
</template>
<script>
import { getVoiceEvaluationContents, evaluateSpeechRecognition, saveVoiceScore, submitVoiceEvaluation } from '@/api/study/voiceEvaluation.js'
// #ifdef APP-PLUS
import { initVoskModel, startSpeechVoice, stopSpeechVoice } from '@/uni_modules/xwq-speech-to-text'
// #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
}
},
onLoad(options) {
if (options.contentId) {
this.loadContentById(options.contentId)
} else {
this.loadContentList()
}
// #ifdef APP-PLUS
this.initSpeechModel()
// #endif
// #ifndef APP-PLUS
this.statusText = '语音识别仅支持APP端'
this.debugInfo = '请在APP端使用语音识别功能'
// #endif
},
onUnload() {
// #ifdef APP-PLUS
if (this.isRecording) {
try { stopSpeechVoice() } catch(e) {}
}
// #endif
this.stopAutoScroll()
},
methods: {
async loadContentList() {
this.loadingContent = true
try {
const result = await getVoiceEvaluationContents()
if (result.code === 200 && result.data) {
this.contentList = Array.isArray(result.data) ? result.data : result.data.list || []
} else {
this.contentList = []
}
} catch (error) {
console.error('加载内容列表失败', error)
this.contentList = []
} finally {
if (this.contentList.length === 0) {
this.contentList = this.getMockData()
}
this.loadingContent = false
}
},
getMockData() {
return [
{ id: 1, title: '基础拼音练习', content: '春眠不觉晓,处处闻啼鸟。夜来风雨声,花落知多少。', difficulty: 'easy' },
{ id: 2, title: '古诗朗诵', content: '床前明月光,疑是地上霜。举头望明月,低头思故乡。', difficulty: 'medium' },
{ id: 3, title: '绕口令练习', content: '四是四,十是十,十四是十四,四十是四十。', difficulty: 'hard' }
]
},
async loadContentById(contentId) {
this.loadingContent = true
try {
const result = await getVoiceEvaluationContents({ id: contentId })
if (result.code === 200 && result.data) {
const data = Array.isArray(result.data) ? result.data : result.data.list || []
if (data.length > 0) this.selectedContent = data[0]
}
} catch (error) {
console.error('加载内容失败', error)
} finally {
this.loadingContent = false
}
},
selectContent(item) {
this.selectedContent = item
this.recognizedText = ''
this.scoreResult = null
this.currentEvaluationId = null
this.isSubmitted = false
},
changeContent() {
this.selectedContent = null
this.recognizedText = ''
this.scoreResult = null
this.currentEvaluationId = null
this.isSubmitted = false
this.loadContentList()
},
// #ifdef APP-PLUS
initSpeechModel() {
console.log('[Speech] ========== 开始初始化语音模型 ==========')
this.isLoading = true
this.statusText = '正在初始化模型...'
this.debugInfo = '检查模型缓存...'
try {
// 先检查是否有已保存的模型路径
const savedModelPath = uni.getStorageSync('vosk_model_path')
console.log('[Speech] 已保存的模型路径:', savedModelPath)
if (savedModelPath) {
this.debugInfo = '加载已解压模型: ' + savedModelPath.substring(savedModelPath.length - 30)
this.statusText = '正在加载已解压模型...'
initVoskModel({
modelPath: savedModelPath,
zipModelPath: ''
}, (result) => {
console.log('[Speech] 加载已保存模型结果:', JSON.stringify(result))
if (result && result.data && result.data.modelPath) {
this.modelPath = result.data.modelPath
this.isReady = true
this.isLoading = false
this.statusText = '准备就绪,可以开始说话'
this.debugInfo = '模型已加载: ' + this.modelPath.substring(this.modelPath.length - 20)
console.log('[Speech] 模型加载成功:', this.modelPath)
} else {
console.log('[Speech] 已保存模型加载失败,尝试从静态资源加载')
// 清除无效的缓存
uni.removeStorageSync('vosk_model_path')
this.initFromStatic()
}
})
} else {
this.initFromStatic()
}
} catch (error) {
console.error('[Speech] 初始化错误:', error)
this.isLoading = false
this.statusText = '初始化失败'
this.debugInfo = '错误: ' + (error.message || JSON.stringify(error))
uni.showToast({ title: '模型初始化失败', icon: 'none' })
}
},
initFromStatic() {
console.log('[Speech] ========== 从静态资源加载模型 ==========')
this.statusText = '正在解压模型文件...'
this.debugInfo = '首次加载正在解压模型约需30秒...'
try {
// 模型文件路径
const staticZipPath = '/static/vosk-model-small-cn-0.22.zip'
let resolvedPath = staticZipPath
// 转换路径
if (typeof plus !== 'undefined' && plus.io && typeof plus.io.convertLocalFileSystemURL === 'function') {
resolvedPath = plus.io.convertLocalFileSystemURL(staticZipPath)
console.log('[Speech] 转换后的路径:', resolvedPath)
}
this.debugInfo = '模型路径: ' + resolvedPath
initVoskModel({
zipModelPath: resolvedPath
}, (result) => {
console.log('[Speech] 静态资源加载结果:', JSON.stringify(result))
if (result && result.data && result.data.modelPath) {
this.modelPath = result.data.modelPath
// 保存模型路径,下次直接加载
uni.setStorageSync('vosk_model_path', result.data.modelPath)
this.isReady = true
this.isLoading = false
this.statusText = '准备就绪,可以开始说话'
this.debugInfo = '模型解压成功'
console.log('[Speech] 模型解压成功:', this.modelPath)
uni.showToast({ title: '模型加载成功', icon: 'success', duration: 2000 })
} else {
console.error('[Speech] 模型加载失败,结果:', result)
this.isLoading = false
this.statusText = '模型加载失败'
this.debugInfo = '加载失败: ' + JSON.stringify(result)
uni.showToast({ title: '模型加载失败,请检查模型文件', icon: 'none', duration: 3000 })
}
})
} catch (error) {
console.error('[Speech] 加载静态资源失败:', error)
this.isLoading = false
this.statusText = '模型加载失败'
this.debugInfo = '异常: ' + (error.message || JSON.stringify(error))
}
},
handleStart() {
if (!this.isReady) {
uni.showToast({ title: '模型未准备好,请稍候', icon: 'none' })
return
}
// 如果已经在录音,先停止
if (this.isRecording) {
this.handleStop()
return
}
console.log('[Speech] ========== 开始语音识别 ==========')
this.isRecording = true
this.statusText = '正在识别中...'
this.recognizedText = ''
this.scoreResult = null
this.hasFirstResult = false
this.debugInfo = '识别中...'
this.startAutoScroll()
try {
const self = this
startSpeechVoice((res) => {
// 检查是否仍在录音状态,如果已停止则忽略回调
if (!self.isRecording) {
console.log('[Speech] 已停止录音,忽略回调')
return
}
console.log('[Speech] 识别回调:', JSON.stringify(res))
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] ========== 停止语音识别 ==========')
// 先设置状态为非录音,这样回调会被忽略
this.isRecording = false
this.stopAutoScroll()
try {
stopSpeechVoice()
this.statusText = '识别完成'
// 延迟检查结果,等待最后的回调处理完成
setTimeout(() => {
if (!this.hasFirstResult || !this.recognizedText || this.recognizedText.trim() === '') {
this.debugInfo = '未识别到有效语音'
uni.showToast({ title: '未识别到有效语音,请重试或手动输入', icon: 'none', duration: 2000 })
} else {
this.debugInfo = '识别完成,共' + this.recognizedText.length + '字'
uni.showToast({ title: '识别完成', icon: 'success', duration: 1500 })
}
}, 300)
} catch (error) {
console.error('[Speech] 停止识别错误:', error)
this.statusText = '已停止识别'
this.debugInfo = '停止时出错: ' + (error.message || '')
}
},
// #endif
// #ifndef APP-PLUS
handleStart() {
uni.showToast({ title: '语音识别仅支持APP端', icon: 'none' })
},
handleStop() {},
initSpeechModel() {
this.statusText = '语音识别仅支持APP端'
},
// #endif
// 手动输入文本
showManualInput() {
uni.showModal({
title: '手动输入识别文本',
editable: true,
placeholderText: '请输入您要朗读的内容',
success: (res) => {
if (res.confirm && res.content) {
this.recognizedText = res.content.trim()
this.hasFirstResult = true
this.statusText = '已手动输入文本'
this.debugInfo = '手动输入: ' + this.recognizedText.length + '字'
}
}
})
},
// 清除模型缓存并重新加载
clearModelCache() {
uni.showModal({
title: '清除缓存',
content: '将清除已解压的模型缓存重新从静态资源解压。这可能需要30秒左右确定继续',
success: (res) => {
if (res.confirm) {
// 清除缓存
uni.removeStorageSync('vosk_model_path')
this.modelPath = ''
this.isReady = false
this.debugInfo = '缓存已清除,正在重新加载...'
// 重新初始化
// #ifdef APP-PLUS
this.initFromStatic()
// #endif
}
}
})
},
// 测试麦克风
testMicrophone() {
this.debugInfo = '正在测试麦克风...'
// #ifdef APP-PLUS
const recorderManager = uni.getRecorderManager()
recorderManager.onStart(() => {
this.debugInfo = '麦克风正常,正在录音...'
uni.showToast({ title: '正在录音...', icon: 'none', duration: 3000 })
})
recorderManager.onStop((res) => {
console.log('[Speech] 测试录音结果:', res)
if (res.tempFilePath) {
this.debugInfo = '麦克风测试成功!录音文件: ' + res.tempFilePath.substring(res.tempFilePath.length - 30)
uni.showToast({ title: '麦克风正常', icon: 'success' })
// 播放录音
const innerAudioContext = uni.createInnerAudioContext()
innerAudioContext.src = res.tempFilePath
innerAudioContext.play()
innerAudioContext.onEnded(() => {
this.debugInfo += '\n录音播放完成'
})
} else {
this.debugInfo = '麦克风测试失败:未获取到录音文件'
uni.showToast({ title: '麦克风测试失败', icon: 'none' })
}
})
recorderManager.onError((err) => {
console.error('[Speech] 麦克风测试错误:', err)
this.debugInfo = '麦克风错误: ' + (err.errMsg || JSON.stringify(err))
uni.showToast({ title: '麦克风错误', icon: 'none' })
})
// 开始录音3秒
recorderManager.start({
duration: 3000,
sampleRate: 16000,
numberOfChannels: 1,
format: 'mp3'
})
// #endif
// #ifndef APP-PLUS
this.debugInfo = '麦克风测试仅支持APP端'
// #endif
},
scrollToBottom() { this.scrollTop = 99999 },
startAutoScroll() {
this.stopAutoScroll()
this.scrollTimer = setInterval(() => { if (this.isRecording) this.scrollToBottom() }, 500)
},
stopAutoScroll() {
if (this.scrollTimer) { clearInterval(this.scrollTimer); this.scrollTimer = null }
},
async evaluateScore() {
if (!this.recognizedText || !this.selectedContent) {
uni.showToast({ title: '请先完成语音识别', icon: 'none' })
return
}
this.isEvaluating = true
try {
const result = await evaluateSpeechRecognition(this.selectedContent.content, this.recognizedText)
if (result.code === 200 && result.data) {
this.scoreResult = result.data
await this.saveScoreToBackend(result.data)
uni.showToast({ title: '评分完成', icon: 'success', duration: 2000 })
this.$nextTick(() => { uni.pageScrollTo({ selector: '.score-section', duration: 300 }) })
} else {
throw new Error(result.msg || '评分失败')
}
} catch (error) {
console.error('评分失败', error)
uni.showToast({ title: error.message || '评分失败', icon: 'none', duration: 3000 })
} finally {
this.isEvaluating = false
}
},
async saveScoreToBackend(scoreData) {
if (!this.selectedContent || !this.recognizedText) return
this.isSaving = true
try {
const resultDetail = JSON.stringify({
recognizedText: this.recognizedText,
originalText: this.selectedContent.content,
details: scoreData.details || {}
})
const result = await saveVoiceScore(
this.selectedContent.content, this.recognizedText,
scoreData.totalScore, scoreData.accuracy, scoreData.completeness,
scoreData.fluency, scoreData.pronunciation, null, resultDetail
)
let evaluation = null
if (result.code === 200) {
if (result.data && result.data.evaluation) evaluation = result.data.evaluation
else if (result.evaluation) evaluation = result.evaluation
else if (result.data && result.data.id) evaluation = result.data
}
if (evaluation && evaluation.id) {
this.currentEvaluationId = evaluation.id
const submitted = evaluation.isSubmitted
this.isSubmitted = submitted === 1 || submitted === true || submitted === '1'
}
} catch (error) {
console.error('保存评分到后端失败', error)
} finally {
this.isSaving = false
}
},
async submitEvaluation() {
if (!this.currentEvaluationId) {
uni.showToast({ title: '请先完成评分', icon: 'none' })
return
}
if (this.isSubmitted) {
uni.showToast({ title: '该评测已提交', icon: 'none' })
return
}
uni.showModal({
title: '确认提交',
content: '提交后,管理员将看到您的评测结果,是否确认提交?',
success: async (res) => {
if (res.confirm) {
this.isSubmitting = true
try {
const result = await submitVoiceEvaluation(this.currentEvaluationId)
if (result.code === 200) {
this.isSubmitted = true
uni.showToast({ title: '提交成功', icon: 'success', duration: 2000 })
} else {
throw new Error(result.msg || '提交失败')
}
} catch (error) {
console.error('提交失败', error)
uni.showToast({ title: error.message || '提交失败', icon: 'none', duration: 3000 })
} finally {
this.isSubmitting = false
}
}
}
})
},
resetEvaluation() {
this.recognizedText = ''
this.scoreResult = null
this.currentEvaluationId = null
this.isSubmitted = false
},
getDifficultyText(difficulty) {
const map = { 'easy': '简单', 'medium': '中等', 'hard': '困难' }
return map[difficulty] || '中等'
},
goBack() {
const pages = getCurrentPages()
if (pages.length > 1) {
uni.navigateBack({ delta: 1, fail: () => { uni.switchTab({ url: '/pages/index/index' }) } })
} else {
uni.switchTab({ url: '/pages/index/index' })
}
}
}
}
</script>
<style scoped>
.speech-evaluation-container {
padding: 20rpx;
padding-top: calc(var(--status-bar-height) + 20rpx);
background: linear-gradient(to bottom, #f5f7fa 0%, #c3cfe2 100%);
min-height: 100vh;
}
.top-header {
background: linear-gradient(135deg, rgb(55 140 224) 0%, rgb(45 120 200) 100%);
padding: 30rpx;
padding-top: calc(30rpx + var(--status-bar-height));
margin: -20rpx -20rpx 30rpx -20rpx;
margin-top: calc(-20rpx - var(--status-bar-height));
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
}
.header-left {
display: flex;
align-items: center;
gap: 8rpx;
padding: 10rpx 20rpx;
border-radius: 20rpx;
background: rgba(255, 255, 255, 0.2);
}
.back-icon { font-size: 48rpx; font-weight: bold; color: #fff; line-height: 1; }
.back-text { font-size: 28rpx; color: #fff; }
.header-title { font-size: 36rpx; font-weight: bold; color: #fff; flex: 1; text-align: center; }
.header-right { width: 120rpx; }
.content-select-section, .content-display-section, .status-section, .action-section, .result-section, .score-section {
background: #fff;
border-radius: 20rpx;
padding: 30rpx;
margin-bottom: 30rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.08);
}
.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20rpx; }
.section-title { font-size: 32rpx; font-weight: bold; color: #333; margin-bottom: 20rpx; display: block; }
.change-btn { font-size: 26rpx; color: #409eff; padding: 8rpx 20rpx; border: 1px solid #409eff; border-radius: 20rpx; }
.content-list { display: flex; flex-direction: column; gap: 20rpx; }
.content-item { padding: 24rpx; background: #f8f9fa; border-radius: 12rpx; border-left: 4rpx solid #409eff; }
.content-item-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12rpx; }
.content-item-title { font-size: 30rpx; font-weight: bold; color: #333; }
.content-item-difficulty { font-size: 24rpx; padding: 4rpx 12rpx; border-radius: 12rpx; }
.difficulty-easy { background: #e8f5e9; color: #4caf50; }
.difficulty-medium { background: #fff3e0; color: #ff9800; }
.difficulty-hard { background: #ffebee; color: #f44336; }
.content-item-preview { font-size: 26rpx; color: #666; line-height: 1.6; }
.content-text { font-size: 30rpx; line-height: 2; color: #333; padding: 30rpx; background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%); border-radius: 12rpx; border-left: 4rpx solid #409eff; min-height: 200rpx; }
.status-section { margin-bottom: 20rpx; }
.status-item { display: flex; align-items: center; justify-content: space-between; }
.status-label { font-size: 28rpx; color: #666; }
.status-value { font-size: 28rpx; font-weight: 600; }
.status-ready { color: #52c41a; }
.status-recording { color: #f5576c; animation: pulse 1.5s ease-in-out infinite; }
.status-loading { color: #1890ff; animation: pulse 1.5s ease-in-out infinite; }
/* 调试信息样式 */
.debug-info { margin-top: 16rpx; padding: 16rpx; background: #f0f0f0; border-radius: 8rpx; }
.debug-text { font-size: 24rpx; color: #666; word-break: break-all; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.6; } }
.recording-tip { display: flex; align-items: center; gap: 16rpx; margin-top: 20rpx; padding: 20rpx; background: rgba(245, 87, 108, 0.1); border-radius: 12rpx; border-left: 4rpx solid #f5576c; }
.recording-dots { display: flex; gap: 8rpx; align-items: center; }
.dot { width: 12rpx; height: 12rpx; border-radius: 50%; background: #f5576c; animation: dot-bounce 1.4s ease-in-out infinite; }
.dot1 { animation-delay: 0s; } .dot2 { animation-delay: 0.2s; } .dot3 { animation-delay: 0.4s; }
@keyframes dot-bounce { 0%, 60%, 100% { transform: translateY(0); opacity: 0.7; } 30% { transform: translateY(-10rpx); opacity: 1; } }
.recording-text { font-size: 26rpx; color: #f5576c; flex: 1; }
.action-section { display: flex; flex-direction: column; align-items: center; gap: 20rpx; }
.action-btn { width: 400rpx; height: 400rpx; border-radius: 50%; border: none; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 20rpx; box-shadow: 0 8rpx 30rpx rgba(0, 0, 0, 0.2); transition: all 0.3s; }
.btn-ready { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #fff; }
.btn-recording { background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); color: #fff; animation: pulse-ring 1.5s ease-out infinite; }
.btn-init { background: linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%); color: #333; }
@keyframes pulse-ring { 0% { box-shadow: 0 0 0 0 rgba(245, 87, 108, 0.7); } 50% { box-shadow: 0 0 0 40rpx rgba(245, 87, 108, 0); } 100% { box-shadow: 0 0 0 0 rgba(245, 87, 108, 0); } }
.btn-stop { width: 300rpx; height: 100rpx; border-radius: 50rpx; background: linear-gradient(135deg, #fa709a 0%, #fee140 100%); color: #fff; }
.btn-icon { font-size: 80rpx; }
.btn-text { font-size: 32rpx; font-weight: bold; }
/* 手动输入按钮 */
.manual-input-btn { margin-top: 30rpx; padding: 20rpx 40rpx; background: #f5f5f5; border-radius: 30rpx; border: 1px solid #ddd; font-size: 28rpx; color: #666; }
/* 工具按钮 */
.tool-buttons { display: flex; gap: 20rpx; margin-top: 30rpx; flex-wrap: wrap; justify-content: center; }
.tool-btn { padding: 16rpx 24rpx; background: #fff; border: 1px solid #e0e0e0; border-radius: 20rpx; font-size: 24rpx; color: #666; }
.result-content { margin-bottom: 20rpx; }
.result-scroll { max-height: 400rpx; width: 100%; }
.result-text { font-size: 30rpx; line-height: 1.8; color: #333; word-break: break-all; min-height: 100rpx; }
.typing-cursor { display: inline-block; font-size: 30rpx; color: #409eff; animation: blink 1s infinite; margin-left: 4rpx; }
@keyframes blink { 0%, 50% { opacity: 1; } 51%, 100% { opacity: 0; } }
.recognition-progress { display: flex; align-items: center; gap: 16rpx; margin-bottom: 20rpx; padding: 16rpx 20rpx; background: rgba(64, 158, 255, 0.1); border-radius: 12rpx; border-left: 4rpx solid #409eff; }
.progress-dots { display: flex; gap: 8rpx; align-items: center; }
.progress-dot { width: 12rpx; height: 12rpx; border-radius: 50%; background: #409eff; animation: progress-bounce 1.4s ease-in-out infinite; }
.progress-dot.dot1 { animation-delay: 0s; } .progress-dot.dot2 { animation-delay: 0.2s; } .progress-dot.dot3 { animation-delay: 0.4s; }
@keyframes progress-bounce { 0%, 60%, 100% { transform: translateY(0); opacity: 0.7; } 30% { transform: translateY(-8rpx); opacity: 1; } }
.progress-text { font-size: 26rpx; color: #409eff; font-weight: 500; }
.text-loading { color: #999; font-style: italic; }
.evaluate-btn:disabled { opacity: 0.5; background: #ccc !important; }
.evaluate-btn { width: 100%; background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%); color: #fff; border: none; padding: 24rpx; border-radius: 12rpx; font-size: 32rpx; font-weight: bold; }
.score-main { text-align: center; padding: 40rpx; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 20rpx; margin-bottom: 30rpx; }
.score-main-value { display: block; font-size: 100rpx; font-weight: bold; color: #fff; line-height: 1; }
.score-main-label { display: block; font-size: 28rpx; color: rgba(255, 255, 255, 0.9); margin-top: 10rpx; }
.score-details { display: flex; flex-direction: column; gap: 24rpx; margin-bottom: 30rpx; }
.score-item { padding: 20rpx; background: #f8f9fa; border-radius: 12rpx; }
.score-item-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12rpx; }
.score-item-label { font-size: 28rpx; color: #333; font-weight: 500; }
.score-item-value { font-size: 32rpx; font-weight: bold; color: #409eff; }
.score-progress { width: 100%; height: 12rpx; background: #e4e7ed; border-radius: 6rpx; overflow: hidden; }
.score-progress-bar { height: 100%; background: linear-gradient(90deg, #67c23a 0%, #85ce61 100%); border-radius: 6rpx; transition: width 0.5s; }
.score-info { padding: 20rpx; background: #f8f9fa; border-radius: 12rpx; margin-bottom: 30rpx; }
.info-item { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12rpx; }
.info-item:last-child { margin-bottom: 0; }
.info-label { font-size: 26rpx; color: #666; }
.info-value { font-size: 28rpx; font-weight: bold; color: #333; }
.action-buttons { display: flex; gap: 20rpx; }
.btn-retry, .btn-change { flex: 1; padding: 24rpx; border-radius: 12rpx; font-size: 28rpx; font-weight: bold; border: none; }
.btn-retry { background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%); color: #fff; }
.btn-change { background: #f0f0f0; color: #333; }
.submit-section { margin-top: 30rpx; margin-bottom: 20rpx; }
.btn-submit { width: 100%; background: linear-gradient(135deg, #67c23a 0%, #85ce61 100%); color: #fff; border: none; padding: 30rpx; border-radius: 12rpx; font-size: 32rpx; font-weight: bold; box-shadow: 0 4rpx 12rpx rgba(103, 194, 58, 0.3); }
.submit-status-section { margin-top: 20rpx; margin-bottom: 20rpx; text-align: center; }
.submit-status-text { font-size: 28rpx; color: #67c23a; padding: 16rpx 32rpx; background: rgba(103, 194, 58, 0.1); border-radius: 20rpx; border: 1px solid #67c23a; display: inline-block; }
.empty-tip, .loading-tip { text-align: center; padding: 80rpx 40rpx; color: #999; font-size: 28rpx; display: flex; flex-direction: column; align-items: center; gap: 20rpx; }
.empty-icon { font-size: 80rpx; }
</style>