guoyu/fronted_uniapp/pages/voice/evaluation.vue
2025-12-10 22:53:20 +08:00

1307 lines
45 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="voice-evaluation-container">
<!-- 顶部导航栏 -->
<custom-navbar title="语音评测"></custom-navbar>
<!-- 评测内容显示 -->
<view class="content-section">
<view class="section-header">
<text class="section-title">📝 评测内容</text>
<text class="edit-btn" v-if="content" @click="editContent">编辑</text>
</view>
<view class="content-text" v-if="content">
<text>{{ content }}</text>
</view>
<view class="content-input" v-else>
<textarea
v-model="inputContent"
placeholder="请输入要评测的课文或文字内容建议50-200字"
maxlength="500"
class="textarea"
:auto-height="true"
></textarea>
<view class="char-count">{{ inputContent.length }}/500</view>
<button @click="setContent" class="btn-primary" :disabled="!inputContent.trim()">确定</button>
</view>
</view>
<!-- 录音控制 -->
<view class="record-section">
<view class="section-header">
<text class="section-title">🎤 语音录制</text>
</view>
<!-- 格式选择器(测试用) -->
<view class="format-selector" v-if="!isRecording">
<view class="format-header">
<view class="format-label">测试格式:</view>
<text class="format-tip">👇 点击切换格式测试</text>
</view>
<view class="format-pickers">
<picker mode="selector" :range="formatOptions" range-key="label" @change="onFormatChange" :value="currentFormatIndex">
<view class="picker-value">
{{ currentFormatLabel }}
</view>
</picker>
</view>
<!-- 测试结果提示 -->
<view class="test-results" v-if="Object.keys(testResults).length > 0">
<text class="results-title">测试记录:</text>
<view class="result-item" v-for="(result, key) in testResults" :key="key">
<text :class="result.success ? 'success' : 'failed'">
{{ key }}: {{ result.success ? '✓ 成功' : '✗ 失败' }}
({{ (result.bitrate/1024).toFixed(1) }}KB/)
</text>
</view>
</view>
</view>
<view class="record-controls">
<!-- 录音波形动画 -->
<view class="wave-container" v-if="isRecording">
<view class="wave-item" v-for="(item, index) in waveData" :key="index"
:style="{ height: item + 'rpx' }"></view>
</view>
<view class="record-time" v-if="isRecording">
<text class="time-text">录制中:{{ formatTime(recordTime) }}</text>
</view>
<view class="record-buttons">
<button
v-if="!isRecording && !recordPath"
@click="startRecord"
class="btn-record"
:disabled="!content"
>
<text class="btn-icon">🎤</text>
<text>开始录音</text>
</button>
<button
v-if="isRecording"
@click="stopRecord"
class="btn-stop"
>
<text class="btn-icon">⏹</text>
<text>停止录音</text>
</button>
<button
v-if="recordPath && !isRecording"
@click="playRecord"
class="btn-play"
:disabled="isPlaying"
>
<text class="btn-icon">{{ isPlaying ? '⏸' : '▶' }}</text>
<text>{{ isPlaying ? '播放中' : '播放录音' }}</text>
</button>
<button
v-if="recordPath && !isRecording"
@click="reRecord"
class="btn-rerecord"
>
<text class="btn-icon">🔄</text>
<text>重新录制</text>
</button>
</view>
</view>
</view>
<!-- 评测结果 -->
<view class="result-section" v-if="evaluationResult">
<view class="section-header">
<text class="section-title">📊 评测结果</text>
<view class="header-actions">
<text class="view-detail-btn" @click="viewCurrentDetail">查看详情</text>
<text class="submit-status" v-if="isSubmitted">✓ 已提交</text>
</view>
</view>
<view class="result-main-score">
<text class="main-score-value">{{ evaluationResult.score || 0 }}</text>
<text class="main-score-label">总分</text>
</view>
<view class="result-scores">
<view class="score-item">
<view class="score-progress">
<view class="score-progress-bar" :style="{ width: (evaluationResult.accuracy || 0) + '%' }"></view>
</view>
<text class="score-label">准确度</text>
<text class="score-value">{{ evaluationResult.accuracy || 0 }}分</text>
</view>
<view class="score-item">
<view class="score-progress">
<view class="score-progress-bar" :style="{ width: (evaluationResult.fluency || 0) + '%' }"></view>
</view>
<text class="score-label">流畅度</text>
<text class="score-value">{{ evaluationResult.fluency || 0 }}分</text>
</view>
<view class="score-item">
<view class="score-progress">
<view class="score-progress-bar" :style="{ width: (evaluationResult.completeness || 0) + '%' }"></view>
</view>
<text class="score-label">完整度</text>
<text class="score-value">{{ evaluationResult.completeness || 0 }}分</text>
</view>
<view class="score-item">
<view class="score-progress">
<view class="score-progress-bar" :style="{ width: (evaluationResult.pronunciation || 0) + '%' }"></view>
</view>
<text class="score-label">发音</text>
<text class="score-value">{{ evaluationResult.pronunciation || 0 }}分</text>
</view>
</view>
<!-- 提交按钮 -->
<view class="submit-section" v-if="currentEvaluationId && evaluationResult && !isSubmitted">
<button
@click="submitEvaluation"
class="btn-submit"
:loading="isSubmitting"
>
{{ isSubmitting ? '提交中...' : '提交评测' }}
</button>
</view>
</view>
<!-- 评测按钮 -->
<view class="evaluate-section" v-if="recordPath && !evaluationResult">
<button
@click="evaluate"
class="btn-evaluate"
:loading="isEvaluating"
>
{{ isEvaluating ? '评测中...' : '开始评测' }}
</button>
</view>
<!-- 历史记录 -->
<view class="history-section">
<view class="section-header">
<text class="section-title">📋 历史记录</text>
<text class="refresh-btn" @click="loadHistory">刷新</text>
</view>
<view class="history-list" v-if="historyList.length > 0">
<view
v-for="(item, index) in historyList"
:key="item.id || index"
class="history-item"
@click="viewDetail(item)"
>
<view class="history-left">
<view class="history-content">{{ item.content ? (item.content.length > 30 ? item.content.substring(0, 30) + '...' : item.content) : '无内容' }}</view>
<view class="history-time">{{ formatDateTime(item.evaluationTime) }}</view>
</view>
<view class="history-right">
<view class="history-score">{{ item.score || 0 }}分</view>
<text class="arrow"></text>
</view>
</view>
</view>
<view v-else class="empty-tip">
<text class="empty-icon">📭</text>
<text>暂无历史记录</text>
</view>
</view>
</view>
</template>
<script>
import { uploadAndEvaluate, getMyVoiceRecords, submitVoiceEvaluation } from '@/api/study/voiceEvaluation.js'
import CustomNavbar from '@/components/custom-navbar/custom-navbar.vue'
import aacRecorder from '@/utils/aac-recorder.js'
export default {
components: {
CustomNavbar
},
data() {
return {
courseId: null,
courseName: '',
content: '',
inputContent: '',
isRecording: false,
recordTime: 0,
recordTimer: null,
recordPath: '',
recorderManager: null,
audioContext: null,
isPlaying: false,
isEvaluating: false,
recordDiagnostic: null, // 录音诊断信息
evaluationResult: null,
currentEvaluationId: null,
isSubmitted: false, // 是否已提交
isSubmitting: false, // 是否正在提交
currentEvaluationAudioPath: null, // 当前评测记录的音频路径
historyList: [],
waveData: [20, 30, 25, 35, 28, 32, 26, 30], // 波形数据
waveTimer: null,
// 录音格式测试配置
selectedFormat: 'wav', // 默认wav最兼容
selectedSampleRate: 8000, // 默认8000最兼容
formatOptions: [
{ value: 'wav', label: 'WAV 8K最兼容', rates: [8000] },
{ value: 'wav', label: 'WAV 16K推荐', rates: [16000] },
{ value: 'pcm', label: 'PCM 8K', rates: [8000] },
{ value: 'pcm', label: 'PCM 16K', rates: [16000] },
{ value: 'm4a', label: 'M4A自适应', rates: [] },
{ value: 'mp3', label: 'MP3 16K', rates: [16000] }
],
showFormatPicker: false,
testResults: {}, // 记录每种格式的测试结果
recordFileSize: 0, // 录音文件大小
recordDuration: 0 // 录音时长
}
},
computed: {
// 当前选中格式的索引
currentFormatIndex() {
// 查找匹配格式和采样率的选项
return this.formatOptions.findIndex(item => {
if (item.rates.length === 0) {
// m4a等自适应格式只匹配格式
return item.value === this.selectedFormat
} else {
// 需要同时匹配格式和采样率
return item.value === this.selectedFormat && item.rates[0] === this.selectedSampleRate
}
})
},
// 当前选中格式的标签
currentFormatLabel() {
const index = this.currentFormatIndex
return index >= 0 ? this.formatOptions[index].label : 'WAV 8K最兼容'
}
},
onLoad(options) {
if (options.courseId) {
this.courseId = parseInt(options.courseId)
}
if (options.courseName) {
this.courseName = decodeURIComponent(options.courseName)
}
if (options.content) {
this.content = decodeURIComponent(options.content)
}
this.initRecorder()
this.loadHistory()
},
onUnload() {
if (this.recordTimer) {
clearInterval(this.recordTimer)
}
if (this.waveTimer) {
clearInterval(this.waveTimer)
}
if (this.recorderManager) {
this.recorderManager.stop()
}
if (this.audioContext) {
this.audioContext.destroy()
}
},
methods: {
// 检查录音权限
checkRecordPermission() {
return new Promise((resolve) => {
// #ifdef APP-PLUS
const permissions = ['android.permission.RECORD_AUDIO']
const hasPermission = plus.android.checkPermission(permissions[0])
if (hasPermission === 0) {
plus.android.requestPermissions(
permissions,
(result) => {
const granted = result.granted && result.granted.length > 0
if (!granted) {
uni.showModal({
title: '需要录音权限',
content: '请在设置中开启录音权限',
showCancel: false
})
}
resolve(granted)
},
(error) => {
console.error('权限请求失败', error)
resolve(false)
}
)
} else {
resolve(true)
}
// #endif
// #ifndef APP-PLUS
resolve(true)
// #endif
})
},
// 初始化录音管理器
initRecorder() {
this.recorderManager = uni.getRecorderManager()
this.recorderManager.onStart(() => {
console.log('录音开始')
this.isRecording = true
this.recordTime = 0
this.recordTimer = setInterval(() => {
this.recordTime++
}, 1000)
// 启动波形动画
this.startWaveAnimation()
})
this.recorderManager.onStop((res) => {
console.log('录音结束(原始)', res)
this.isRecording = false
if (this.recordTimer) {
clearInterval(this.recordTimer)
}
this.stopWaveAnimation()
if (res.tempFilePath) {
// 关键修复:等待音频缓冲完全写入文件
console.log('等待录音文件完全保存...')
// 先等待1500ms让音频数据完全写入MP3编码需要时间
setTimeout(() => {
// 验证文件大小
uni.getFileInfo({
filePath: res.tempFilePath,
success: (fileInfo) => {
// 保存诊断信息
this.recordDiagnostic = {
path: res.tempFilePath,
actualSize: fileInfo.size,
actualSizeKB: (fileInfo.size / 1024).toFixed(2),
duration: res.duration || 0,
durationSec: res.duration ? (res.duration / 1000).toFixed(1) : '未知',
expectedSize: res.duration ? res.duration * 16 : 45000,
timestamp: new Date().toLocaleString()
}
console.log('录音文件信息:', this.recordDiagnostic)
// 检查文件大小是否合理
// 128kbps = 16KB/秒3秒至少应该有 45KB
const minExpectedSize = Math.max(45000, res.duration ? res.duration * 16 : 45000)
this.recordDiagnostic.expectedSizeKB = (minExpectedSize / 1024).toFixed(2)
this.recordDiagnostic.sizeRatio = ((fileInfo.size / minExpectedSize) * 100).toFixed(1) + '%'
// 保存文件信息用于诊断
this.recordFileSize = fileInfo.size
this.recordDuration = res.duration || this.recordTime * 1000
// 计算实际码率(字节/秒)
const actualBitrate = this.recordDuration > 0 ? (fileInfo.size / (this.recordDuration / 1000)) : 0
const expectedBitrate = 32000 // 正常应该是32KB/秒
console.log(`[录音检测] 格式:${this.selectedFormat} 采样率:${this.selectedSampleRate}Hz`)
console.log(`[录音检测] 文件大小:${(fileInfo.size/1024).toFixed(2)}KB 时长:${(this.recordDuration/1000).toFixed(1)}`)
console.log(`[录音检测] 实际码率:${(actualBitrate/1024).toFixed(2)}KB/秒 预期:${(expectedBitrate/1024).toFixed(2)}KB/秒`)
if (actualBitrate < expectedBitrate * 0.15) {
// 码率低于正常的15%,说明格式不兼容
console.error('录音格式不兼容!码率过低!')
// 记录失败的格式
const formatKey = `${this.selectedFormat}_${this.selectedSampleRate || 'auto'}`
this.testResults[formatKey] = {
success: false,
bitrate: actualBitrate,
size: fileInfo.size,
duration: this.recordDuration
}
uni.showModal({
title: '录音文件不完整',
content: `录音${(this.recordDuration/1000).toFixed(1)}秒,文件大小${(fileInfo.size/1024).toFixed(2)}KB${(actualBitrate/1024).toFixed(2)}KB/秒)。\n\n正常应为32KB/秒。您的设备可能不支持此录音方式。\n\n建议\n1. 重试并详细讲一点\n2. 录音时长控制在3-5秒\n3. 或使用手动输入文本`,
showCancel: true,
confirmText: '手动输入',
cancelText: '重试',
success: (modalRes) => {
if (modalRes.confirm) {
// 跳转到手动输入
this.recordPath = ''
// 可以添加手动输入逻辑
} else {
// 清空准备重试
this.recordPath = ''
}
}
})
} else if (fileInfo.size < minExpectedSize * 0.5) {
// 文件偏小但可能可用
console.warn('警告:录音文件偏小')
this.recordPath = res.tempFilePath
uni.showToast({
title: `录音完成(${(fileInfo.size/1024).toFixed(1)}KB偏小`,
icon: 'none',
duration: 2000
})
} else {
// 文件大小正常,记录成功
const formatKey = `${this.selectedFormat}_${this.selectedSampleRate || 'auto'}`
this.testResults[formatKey] = {
success: true,
bitrate: actualBitrate,
size: fileInfo.size,
duration: this.recordDuration
}
this.recordPath = res.tempFilePath
uni.showToast({
title: `录音完成 ✓`,
icon: 'success',
duration: 1500
})
}
},
fail: (err) => {
console.error('获取文件信息失败', err)
// 即使获取失败也使用文件
this.recordPath = res.tempFilePath
uni.showToast({
title: '录音完成',
icon: 'success',
duration: 1500
})
}
})
}, 1500) // 增加到1500ms确保MP3编码完成
}
})
this.recorderManager.onError((err) => {
console.error('录音错误', err)
uni.showToast({
title: '录音失败:' + err.errMsg,
icon: 'none',
duration: 2000
})
this.isRecording = false
if (this.recordTimer) {
clearInterval(this.recordTimer)
}
this.stopWaveAnimation()
})
},
// 启动波形动画
startWaveAnimation() {
this.waveTimer = setInterval(() => {
this.waveData = this.waveData.map(() => Math.random() * 30 + 20)
}, 200)
},
// 停止波形动画
stopWaveAnimation() {
if (this.waveTimer) {
clearInterval(this.waveTimer)
this.waveTimer = null
}
this.waveData = [20, 30, 25, 35, 28, 32, 26, 30]
},
// 设置评测内容
setContent() {
if (!this.inputContent.trim()) {
uni.showToast({
title: '请输入评测内容',
icon: 'none'
})
return
}
this.content = this.inputContent.trim()
this.inputContent = ''
// 重置录音和评测结果
this.recordPath = ''
this.evaluationResult = null
},
// 编辑内容
editContent() {
this.inputContent = this.content
this.content = ''
},
// 开始录音
async startRecord() {
if (!this.content) {
uni.showToast({
title: '请先设置评测内容',
icon: 'none'
})
return
}
try {
console.log('[录音] 使用AAC格式开始录音...')
// 使用AAC录音工具已验证可用
await aacRecorder.start(60000) // 最长60秒
this.isRecording = true
this.recordTime = 0
// 开始计时
this.recordTimer = setInterval(() => {
this.recordTime++
}, 1000)
// 开始波形动画
this.startWaveAnimation()
console.log('[录音] AAC录音已开始')
} catch (error) {
console.error('[录音] 开始失败:', error)
uni.showToast({
title: '录音失败: ' + error.message,
icon: 'none'
})
}
},
// 停止录音
async stopRecord() {
if (!this.isRecording) {
return
}
try {
console.log('[录音] 停止录音...')
// 停止AAC录音
const result = await aacRecorder.stop()
// 停止计时和波形动画
if (this.recordTimer) {
clearInterval(this.recordTimer)
this.recordTimer = null
}
this.stopWaveAnimation()
this.isRecording = false
this.recordPath = result.filePath
this.recordFileSize = result.fileSize
this.recordDuration = this.recordTime
console.log('[录音] 录音完成')
console.log('[录音] 文件:', result.filePath)
console.log('[录音] 大小:', result.fileSize, 'bytes')
console.log('[录音] 时长:', this.recordTime, '秒')
uni.showToast({
title: '录音完成',
icon: 'success',
duration: 1500
})
} catch (error) {
console.error('[录音] 停止失败:', error)
this.isRecording = false
if (this.recordTimer) {
clearInterval(this.recordTimer)
this.recordTimer = null
}
this.stopWaveAnimation()
uni.showToast({
title: '停止录音失败',
icon: 'none'
})
}
},
// 播放录音
playRecord() {
if (this.audioContext) {
this.audioContext.destroy()
}
this.audioContext = uni.createInnerAudioContext()
this.audioContext.src = this.recordPath
this.isPlaying = true
this.audioContext.play()
this.audioContext.onEnded(() => {
this.isPlaying = false
this.audioContext.destroy()
this.audioContext = null
})
this.audioContext.onError((err) => {
console.error('播放失败', err)
this.isPlaying = false
uni.showToast({
title: '播放失败',
icon: 'none'
})
if (this.audioContext) {
this.audioContext.destroy()
this.audioContext = null
}
})
},
// 重新录制
reRecord() {
this.recordPath = ''
this.evaluationResult = null
this.currentEvaluationId = null
this.isSubmitted = false
},
// 开始评测
async evaluate() {
if (!this.recordPath) {
uni.showToast({
title: '请先录制语音',
icon: 'none',
duration: 2000
})
return
}
if (!this.content) {
uni.showToast({
title: '请先设置评测内容',
icon: 'none',
duration: 2000
})
return
}
// 检查登录状态
const token = uni.getStorageSync('token')
if (!token) {
uni.showModal({
title: '提示',
content: '请先登录',
showCancel: false,
success: (res) => {
if (res.confirm) {
uni.reLaunch({
url: '/pages/login/login'
})
}
}
})
return
}
this.isEvaluating = true
try {
const result = await uploadAndEvaluate(
this.recordPath,
this.content,
this.courseId,
'zh-CN'
)
if (result.code === 200 && result.data && result.data.evaluation) {
this.evaluationResult = {
score: result.data.evaluation.score,
accuracy: result.data.evaluation.accuracy,
fluency: result.data.evaluation.fluency,
completeness: result.data.evaluation.completeness,
pronunciation: result.data.evaluation.pronunciation
}
this.currentEvaluationId = result.data.evaluation.id
this.currentEvaluationAudioPath = result.data.evaluation.audioPath || null
// 检查是否已提交处理null、0、1、true等情况
const submitted = result.data.evaluation.isSubmitted
this.isSubmitted = submitted === 1 || submitted === true || submitted === '1'
console.log('评测完成评测ID:', this.currentEvaluationId, '提交状态:', this.isSubmitted, '音频路径:', this.currentEvaluationAudioPath)
uni.showToast({
title: '评测完成',
icon: 'success',
duration: 2000
})
// 刷新历史记录
this.loadHistory()
// 滚动到结果区域
this.$nextTick(() => {
uni.pageScrollTo({
selector: '.result-section',
duration: 300
})
})
} else {
throw new Error(result.msg || '评测失败')
}
} catch (error) {
console.error('评测失败', error)
let errorMsg = error.message || '评测失败'
// 如果是登录相关错误,跳转到登录页
if (errorMsg.includes('登录') || errorMsg.includes('401')) {
uni.showModal({
title: '提示',
content: errorMsg,
showCancel: false,
success: (res) => {
if (res.confirm) {
uni.reLaunch({
url: '/pages/login/login'
})
}
}
})
} else {
uni.showToast({
title: errorMsg,
icon: 'none',
duration: 3000
})
}
} finally {
this.isEvaluating = false
}
},
// 加载历史记录
async loadHistory() {
try {
const result = await getMyVoiceRecords(this.courseId)
if (result.code === 200 && result.data) {
this.historyList = result.data || []
// 如果当前评测ID在历史记录中更新提交状态和音频路径
if (this.currentEvaluationId) {
const currentRecord = this.historyList.find(item => item.id === this.currentEvaluationId)
if (currentRecord) {
const submitted = currentRecord.isSubmitted
this.isSubmitted = submitted === 1 || submitted === true || submitted === '1'
this.currentEvaluationAudioPath = currentRecord.audioPath || null
}
}
} else {
console.warn('加载历史记录失败', result.msg)
}
} catch (error) {
console.error('加载历史记录失败', error)
// 如果是401错误不显示错误提示可能是未登录
if (!error.message || !error.message.includes('401')) {
// 静默失败,不打扰用户
}
}
},
// 查看详情
viewDetail(item) {
uni.navigateTo({
url: `/pages/voice/detail?id=${item.id}`
})
},
// 查看当前评测详情
viewCurrentDetail() {
if (this.currentEvaluationId) {
uni.navigateTo({
url: `/pages/voice/detail?id=${this.currentEvaluationId}`
})
} else {
uni.showToast({
title: '暂无评测详情',
icon: 'none'
})
}
},
// 提交评测
async submitEvaluation() {
if (!this.currentEvaluationId) {
uni.showToast({
title: '请先完成评测',
icon: 'none'
})
return
}
if (this.isSubmitted) {
uni.showToast({
title: '该评测已提交',
icon: 'none'
})
return
}
// 确认提交
const hasAudio = this.currentEvaluationAudioPath || this.recordPath
uni.showModal({
title: '确认提交',
content: hasAudio ?
'提交后,管理员将看到您的评测结果和音频文件,是否确认提交?' :
'提交后,管理员将看到您的评测结果,是否确认提交?',
success: async (res) => {
if (res.confirm) {
this.isSubmitting = true
try {
// 评测时已经上传过音频了提交时不需要再上传传null即可
// 这样可以避免临时文件路径失效的问题
const result = await submitVoiceEvaluation(this.currentEvaluationId, null)
if (result.code === 200) {
this.isSubmitted = true
uni.showToast({
title: hasAudio ? '提交成功' : '提交成功',
icon: 'success',
duration: 2000
})
// 刷新历史记录
this.loadHistory()
} else {
throw new Error(result.msg || '提交失败')
}
} catch (error) {
console.error('提交失败', error)
uni.showToast({
title: error.message || '提交失败',
icon: 'none',
duration: 3000
})
} finally {
this.isSubmitting = false
}
}
}
})
},
// 格式选择器事件
onFormatChange(e) {
const index = e.detail.value
const option = this.formatOptions[index]
this.selectedFormat = option.value
// 设置对应的采样率
const rates = option.rates
if (rates.length > 0) {
this.selectedSampleRate = rates[0]
} else {
this.selectedSampleRate = null // m4a等自适应格式
}
console.log('[格式选择] 选择格式:', this.selectedFormat, '采样率:', this.selectedSampleRate || '自适应')
uni.showToast({
title: `已切换到 ${option.label}`,
icon: 'none',
duration: 1500
})
},
// 格式化时间
formatTime(seconds) {
const min = Math.floor(seconds / 60)
const sec = seconds % 60
return `${min.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`
},
// 格式化日期时间
formatDateTime(dateTime) {
if (!dateTime) return ''
const date = new Date(dateTime)
const year = date.getFullYear()
const month = (date.getMonth() + 1).toString().padStart(2, '0')
const day = date.getDate().toString().padStart(2, '0')
const hour = date.getHours().toString().padStart(2, '0')
const minute = date.getMinutes().toString().padStart(2, '0')
return `${year}-${month}-${day} ${hour}:${minute}`
}
}
}
</script>
<style scoped>
.voice-evaluation-container {
padding: 20rpx;
padding-top: calc(20rpx + 88rpx + env(safe-area-inset-top));
background-color: #f5f5f5;
min-height: 100vh;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 40rpx 30rpx;
margin-bottom: 20rpx;
border-radius: 0 0 20rpx 20rpx;
}
.header-content {
display: flex;
flex-direction: column;
}
.title {
font-size: 40rpx;
font-weight: bold;
color: #fff;
display: block;
margin-bottom: 10rpx;
}
.subtitle {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.9);
display: block;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
}
.edit-btn, .refresh-btn, .view-detail-btn {
font-size: 24rpx;
color: #409eff;
padding: 8rpx 16rpx;
border: 1px solid #409eff;
border-radius: 20rpx;
}
.content-section, .record-section, .result-section, .evaluate-section, .history-section {
background-color: #fff;
padding: 30rpx;
margin-bottom: 20rpx;
border-radius: 10rpx;
}
.section-title {
font-size: 32rpx;
font-weight: bold;
color: #333;
margin-bottom: 20rpx;
}
.content-text {
font-size: 30rpx;
line-height: 1.8;
color: #333;
padding: 30rpx;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
border-radius: 12rpx;
min-height: 200rpx;
border-left: 4rpx solid #409eff;
}
.char-count {
text-align: right;
font-size: 24rpx;
color: #999;
margin-bottom: 20rpx;
}
.content-input {
margin-top: 20rpx;
}
.textarea {
width: 100%;
min-height: 200rpx;
padding: 20rpx;
border: 1px solid #ddd;
border-radius: 8rpx;
font-size: 28rpx;
margin-bottom: 20rpx;
}
.btn-primary {
background-color: #409eff;
color: #fff;
border: none;
padding: 20rpx 40rpx;
border-radius: 8rpx;
font-size: 28rpx;
}
.format-selector {
padding: 20rpx;
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
border-radius: 12rpx;
margin-bottom: 20rpx;
border: 2rpx dashed #409eff;
}
.format-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16rpx;
}
.format-label {
font-size: 28rpx;
color: #333;
font-weight: bold;
}
.format-tip {
font-size: 22rpx;
color: #999;
}
.format-pickers {
display: flex;
gap: 16rpx;
}
.picker-value {
flex: 1;
padding: 16rpx 24rpx;
background: #fff;
border-radius: 24rpx;
font-size: 26rpx;
color: #409eff;
border: 2rpx solid #409eff;
text-align: center;
box-shadow: 0 2rpx 8rpx rgba(64, 158, 255, 0.15);
}
.test-results {
margin-top: 20rpx;
padding-top: 20rpx;
border-top: 1rpx solid #e0e0e0;
}
.results-title {
font-size: 24rpx;
color: #666;
display: block;
margin-bottom: 12rpx;
}
.result-item {
margin-bottom: 8rpx;
}
.result-item .success {
font-size: 22rpx;
color: #67c23a;
}
.result-item .failed {
font-size: 22rpx;
color: #f56c6c;
}
.wave-container {
display: flex;
justify-content: center;
align-items: center;
height: 120rpx;
margin: 30rpx 0;
gap: 8rpx;
}
.wave-item {
width: 8rpx;
background: linear-gradient(180deg, #667eea 0%, #764ba2 100%);
border-radius: 4rpx;
transition: height 0.2s;
}
.record-time {
text-align: center;
margin-bottom: 20rpx;
}
.time-text {
font-size: 32rpx;
color: #f56c6c;
font-weight: bold;
}
.record-buttons {
display: flex;
justify-content: center;
gap: 20rpx;
flex-wrap: wrap;
}
.btn-record, .btn-stop, .btn-play, .btn-rerecord {
padding: 24rpx 40rpx;
border-radius: 50rpx;
font-size: 28rpx;
border: none;
display: flex;
align-items: center;
gap: 10rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
}
.btn-icon {
font-size: 32rpx;
}
.btn-record {
background-color: #67c23a;
color: #fff;
}
.btn-stop {
background-color: #f56c6c;
color: #fff;
}
.btn-play {
background-color: #409eff;
color: #fff;
}
.btn-rerecord {
background-color: #909399;
color: #fff;
}
.result-main-score {
text-align: center;
padding: 40rpx 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 12rpx;
margin-bottom: 30rpx;
}
.main-score-value {
display: block;
font-size: 80rpx;
font-weight: bold;
color: #fff;
line-height: 1;
}
.main-score-label {
display: block;
font-size: 28rpx;
color: rgba(255, 255, 255, 0.9);
margin-top: 10rpx;
}
.result-scores {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20rpx;
}
.score-item {
padding: 24rpx;
background-color: #f9f9f9;
border-radius: 12rpx;
border: 1px solid #eee;
}
.score-progress {
width: 100%;
height: 8rpx;
background-color: #e4e7ed;
border-radius: 4rpx;
margin-bottom: 16rpx;
overflow: hidden;
}
.score-progress-bar {
height: 100%;
background: linear-gradient(90deg, #67c23a 0%, #85ce61 100%);
border-radius: 4rpx;
transition: width 0.3s;
}
.score-label {
display: block;
font-size: 24rpx;
color: #666;
margin-bottom: 8rpx;
}
.score-value {
display: block;
font-size: 32rpx;
font-weight: bold;
color: #409eff;
}
.btn-evaluate {
width: 100%;
background-color: #409eff;
color: #fff;
border: none;
padding: 30rpx;
border-radius: 8rpx;
font-size: 32rpx;
}
.history-list {
margin-top: 20rpx;
}
.history-item {
padding: 24rpx;
border-bottom: 1px solid #eee;
display: flex;
justify-content: space-between;
align-items: center;
background-color: #fff;
border-radius: 8rpx;
margin-bottom: 16rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
}
.history-left {
flex: 1;
margin-right: 20rpx;
}
.history-content {
font-size: 28rpx;
color: #333;
margin-bottom: 10rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.history-time {
font-size: 24rpx;
color: #999;
}
.history-right {
display: flex;
align-items: center;
gap: 10rpx;
}
.history-score {
font-size: 32rpx;
font-weight: bold;
color: #409eff;
}
.arrow {
font-size: 40rpx;
color: #ccc;
}
.empty-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;
}
.header-actions {
display: flex;
align-items: center;
gap: 20rpx;
}
.submit-status {
font-size: 24rpx;
color: #67c23a;
padding: 8rpx 16rpx;
background: rgba(103, 194, 58, 0.1);
border-radius: 20rpx;
border: 1px solid #67c23a;
}
.submit-section {
margin-top: 30rpx;
padding-top: 30rpx;
border-top: 1px solid #eee;
}
.btn-submit {
width: 100%;
background: linear-gradient(135deg, #67c23a 0%, #85ce61 100%);
color: #fff;
border: none;
padding: 30rpx;
border-radius: 8rpx;
font-size: 32rpx;
font-weight: bold;
box-shadow: 0 4rpx 12rpx rgba(103, 194, 58, 0.3);
}
</style>