refactor(speaking-evaluation): 重构听读评测页面代码结构与逻辑

- 重构了组件的脚本部分,优化状态管理与函数组织
- 统一管理TTS合成与播放逻辑,提升代码可维护性
- 优化ASR语音识别流程,增加错误处理和状态反馈
- 改进AI评测接口调用及结果解析逻辑,确保稳定性
- 清理录音和播放资源,防止内存泄漏
- 重新编排模板结构,提升交互体验和代码可读性
- 优化样式细节,增强界面一致性和响应式表现
This commit is contained in:
cc 2026-04-16 15:57:21 +08:00
parent 63056170cc
commit 533507e4ae
1 changed files with 359 additions and 12 deletions

View File

@ -1,5 +1,5 @@
<script setup> <script setup>
import { ref, computed, onUnmounted, nextTick } from "vue"; import { ref, computed, watch, onUnmounted } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { import {
DOUBAO_APP_ID, DOUBAO_APP_ID,
@ -40,10 +40,37 @@ let asrAudioContext = null;
let asrScriptProcessor = null; let asrScriptProcessor = null;
let asrMediaStream = null; let asrMediaStream = null;
let recognizedText = ref(""); let recognizedText = ref("");
const interimText = ref("");
//
let recordingTimer = null;
const recordingSeconds = ref(0);
// //
const evaluationResult = ref(null); const evaluationResult = ref(null);
const isEvaluating = ref(false); const isEvaluating = ref(false);
const evaluationHistory = ref([]); // 3
//
const scoreLevel = computed(() => {
const s = evaluationResult.value?.overallScore ?? 0;
if (s >= 90) return { label: "优秀", cls: "score-excellent" };
if (s >= 70) return { label: "良好", cls: "score-good" };
return { label: "继续加油", cls: "score-poor" };
});
//
const diffWords = computed(() => {
if (!evaluationResult.value || !currentWord.value) return [];
const standard = currentWord.value.toLowerCase().trim().split(/\s+/);
const actual = (recognizedText.value || "").toLowerCase().trim().split(/\s+/);
return standard.map((word, i) => {
const actualWord = actual[i] || "";
if (!actualWord) return { word, status: "missing", actual: "" };
if (actualWord === word) return { word, status: "correct", actual: actualWord };
return { word, status: "wrong", actual: actualWord };
});
});
// //
const selectedVoice = ref("en_female_dacey_uranus_bigtts"); const selectedVoice = ref("en_female_dacey_uranus_bigtts");
@ -92,9 +119,21 @@ const parseText = () => {
wordList.value = [...new Set(items)]; wordList.value = [...new Set(items)];
}; };
// TTS
const audioCache = new Map(); // key: `${text}_${voice}`
watch(selectedVoice, () => {
audioCache.clear();
});
// TTS // TTS
const synthesizeAndPlay = async (text) => { const synthesizeAndPlay = async (text) => {
if (!text || !text.trim()) return; if (!text || !text.trim()) return;
const cacheKey = `${text}_${selectedVoice.value}`;
if (audioCache.has(cacheKey)) {
playAudio(audioCache.get(cacheKey));
return;
}
const payload = { const payload = {
user: { uid: "eval_" + Date.now() }, user: { uid: "eval_" + Date.now() },
@ -162,6 +201,8 @@ const synthesizeAndPlay = async (text) => {
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
blobUrls.push(url); blobUrls.push(url);
const cacheKey2 = `${text}_${selectedVoice.value}`;
audioCache.set(cacheKey2, url);
playAudio(url); playAudio(url);
} catch (err) { } catch (err) {
console.error("TTS error:", err); console.error("TTS error:", err);
@ -200,6 +241,9 @@ const handleWordClick = (index) => {
currentWordIndex.value = index; currentWordIndex.value = index;
playingIndex.value = index; playingIndex.value = index;
evaluationResult.value = null; // evaluationResult.value = null; //
evaluationHistory.value = []; //
recognizedText.value = "";
interimText.value = "";
synthesizeAndPlay(wordList.value[index]); synthesizeAndPlay(wordList.value[index]);
}; };
@ -269,6 +313,12 @@ const parseServerFrame = (buffer) => {
const stopRecording = async () => { const stopRecording = async () => {
isRecording.value = false; isRecording.value = false;
asrStatus.value = ""; asrStatus.value = "";
//
if (recordingTimer) {
clearInterval(recordingTimer);
recordingTimer = null;
}
recordingSeconds.value = 0;
if (asrScriptProcessor) { if (asrScriptProcessor) {
asrScriptProcessor.disconnect(); asrScriptProcessor.disconnect();
@ -304,16 +354,21 @@ const stopRecording = async () => {
// //
if (recognizedText.value && currentWord.value) { if (recognizedText.value && currentWord.value) {
interimText.value = "";
await evaluatePronunciation(); await evaluatePronunciation();
} }
}; };
const startRecording = async () => { //
if (isRecording.value) { const toggleRecording = async () => {
if (isRecording.value || asrStatus.value === "connecting") {
await stopRecording(); await stopRecording();
return; return;
} }
await startRecording();
};
const startRecording = async () => {
if (!currentWord.value) { if (!currentWord.value) {
alert("请先点击要评测的单词或句子"); alert("请先点击要评测的单词或句子");
return; return;
@ -381,6 +436,9 @@ const startRecording = async () => {
const hasFinal = result.utterances?.some((u) => u.definite === true); const hasFinal = result.utterances?.some((u) => u.definite === true);
if (hasFinal) { if (hasFinal) {
recognizedText.value = text; recognizedText.value = text;
interimText.value = "";
} else {
interimText.value = text;
} }
} catch (e) { } catch (e) {
console.error("ASR parse error:", e); console.error("ASR parse error:", e);
@ -432,6 +490,9 @@ const startRecording = async () => {
isRecording.value = true; isRecording.value = true;
asrStatus.value = "recording"; asrStatus.value = "recording";
//
recordingSeconds.value = 0;
recordingTimer = setInterval(() => recordingSeconds.value++, 1000);
} catch (err) { } catch (err) {
console.error("ASR start error:", err); console.error("ASR start error:", err);
stopRecording(); stopRecording();
@ -485,7 +546,16 @@ const evaluatePronunciation = async () => {
// JSON // JSON
const jsonMatch = resultText.match(/\{[\s\S]*\}/); const jsonMatch = resultText.match(/\{[\s\S]*\}/);
if (jsonMatch) { if (jsonMatch) {
evaluationResult.value = JSON.parse(jsonMatch[0]); const parsed = JSON.parse(jsonMatch[0]);
evaluationResult.value = parsed;
//
evaluationHistory.value.unshift({
overallScore: parsed.overallScore,
accuracy: parsed.accuracy,
fluency: parsed.fluency,
time: new Date().toLocaleTimeString(),
});
if (evaluationHistory.value.length > 3) evaluationHistory.value.pop();
} else { } else {
throw new Error("无法解析评测结果"); throw new Error("无法解析评测结果");
} }
@ -511,6 +581,14 @@ onUnmounted(() => {
} }
blobUrls.forEach((url) => URL.revokeObjectURL(url)); blobUrls.forEach((url) => URL.revokeObjectURL(url));
blobUrls.length = 0; blobUrls.length = 0;
//
audioCache.forEach((url) => URL.revokeObjectURL(url));
audioCache.clear();
//
if (recordingTimer) {
clearInterval(recordingTimer);
recordingTimer = null;
}
stopRecording(); stopRecording();
}); });
</script> </script>
@ -638,11 +716,7 @@ onUnmounted(() => {
error: asrStatus === 'error', error: asrStatus === 'error',
}" }"
:disabled="!currentWord || isEvaluating" :disabled="!currentWord || isEvaluating"
@mousedown="startRecording" @click="toggleRecording"
@mouseup="stopRecording"
@mouseleave="isRecording && stopRecording()"
@touchstart.prevent="startRecording"
@touchend.prevent="stopRecording"
> >
<div v-if="asrStatus === 'connecting'" class="btn-spinner"></div> <div v-if="asrStatus === 'connecting'" class="btn-spinner"></div>
<div v-else-if="isRecording" class="recording-content"> <div v-else-if="isRecording" class="recording-content">
@ -656,7 +730,7 @@ onUnmounted(() => {
d="M12 1a4 4 0 0 1 4 4v6a4 4 0 0 1-8 0V5a4 4 0 0 1 4-4zm0 2a2 2 0 0 0-2 2v6a2 2 0 0 0 4 0V5a2 2 0 0 0-2-2zm-1 14.93V20H9v2h6v-2h-2v-2.07A7.001 7.001 0 0 0 19 11h-2a5 5 0 0 1-10 0H5a7.001 7.001 0 0 0 6 6.93z" d="M12 1a4 4 0 0 1 4 4v6a4 4 0 0 1-8 0V5a4 4 0 0 1 4-4zm0 2a2 2 0 0 0-2 2v6a2 2 0 0 0 4 0V5a2 2 0 0 0-2-2zm-1 14.93V20H9v2h6v-2h-2v-2.07A7.001 7.001 0 0 0 19 11h-2a5 5 0 0 1-10 0H5a7.001 7.001 0 0 0 6 6.93z"
/> />
</svg> </svg>
<span>录音中...</span> <span>录音中 {{ recordingSeconds }}s 点击停止</span>
</div> </div>
<template v-else> <template v-else>
<svg <svg
@ -668,10 +742,19 @@ onUnmounted(() => {
d="M12 1a4 4 0 0 1 4 4v6a4 4 0 0 1-8 0V5a4 4 0 0 1 4-4zm0 2a2 2 0 0 0-2 2v6a2 2 0 0 0 4 0V5a2 2 0 0 0-2-2zm-1 14.93V20H9v2h6v-2h-2v-2.07A7.001 7.001 0 0 0 19 11h-2a5 5 0 0 1-10 0H5a7.001 7.001 0 0 0 6 6.93z" d="M12 1a4 4 0 0 1 4 4v6a4 4 0 0 1-8 0V5a4 4 0 0 1 4-4zm0 2a2 2 0 0 0-2 2v6a2 2 0 0 0 4 0V5a2 2 0 0 0-2-2zm-1 14.93V20H9v2h6v-2h-2v-2.07A7.001 7.001 0 0 0 19 11h-2a5 5 0 0 1-10 0H5a7.001 7.001 0 0 0 6 6.93z"
/> />
</svg> </svg>
<span>按住跟读评测</span> <span>点击开始跟读评测</span>
</template> </template>
</button> </button>
<!-- ASR 识别文本展示 -->
<div v-if="recognizedText || interimText" class="recognized-text-card">
<span class="recognized-label">识别结果</span>
<span
class="recognized-content"
:class="{ interim: !recognizedText && interimText }"
>{{ recognizedText || interimText }}</span>
</div>
<!-- 评测结果 --> <!-- 评测结果 -->
<div v-if="isEvaluating" class="result-loading"> <div v-if="isEvaluating" class="result-loading">
<div class="loading-spinner"></div> <div class="loading-spinner"></div>
@ -679,6 +762,26 @@ onUnmounted(() => {
</div> </div>
<div v-else-if="evaluationResult" class="result-section"> <div v-else-if="evaluationResult" class="result-section">
<!-- 历史评分趋势 -->
<div v-if="evaluationHistory.length > 1" class="history-trend">
<span class="history-label">近期记录</span>
<div class="history-dots">
<div
v-for="(h, i) in evaluationHistory"
:key="i"
class="history-dot"
:class="{
'dot-excellent': h.overallScore >= 90,
'dot-good': h.overallScore >= 70 && h.overallScore < 90,
'dot-poor': h.overallScore < 70,
}"
>
<span class="dot-score">{{ h.overallScore }}</span>
<span class="dot-time">{{ h.time }}</span>
</div>
</div>
</div>
<div class="score-display"> <div class="score-display">
<div class="score-circle"> <div class="score-circle">
<svg class="score-svg" viewBox="0 0 100 100"> <svg class="score-svg" viewBox="0 0 100 100">
@ -688,17 +791,21 @@ onUnmounted(() => {
cy="50" cy="50"
r="45" r="45"
class="score-progress" class="score-progress"
:class="scoreLevel.cls"
:style="{ :style="{
strokeDashoffset: strokeDashoffset:
283 - (283 * evaluationResult.overallScore) / 100, 283 - (283 * evaluationResult.overallScore) / 100,
}" }"
></circle> ></circle>
</svg> </svg>
<div class="score-value"> <div class="score-value" :class="scoreLevel.cls">
{{ evaluationResult.overallScore }} {{ evaluationResult.overallScore }}
</div> </div>
</div> </div>
<div class="score-label">综合评分</div> <div class="score-label">综合评分</div>
<div class="score-level-badge" :class="scoreLevel.cls">
{{ scoreLevel.label }}
</div>
</div> </div>
<div class="detail-scores"> <div class="detail-scores">
@ -726,6 +833,44 @@ onUnmounted(() => {
</div> </div>
</div> </div>
<!-- 差异对比多词时展示 -->
<div
v-if="diffWords.length > 1"
class="diff-section"
>
<h4>对比分析</h4>
<div class="diff-row">
<span class="diff-meta">标准</span>
<div class="diff-words">
<span
v-for="(d, i) in diffWords"
:key="i"
class="diff-word"
:class="{
'diff-correct': d.status === 'correct',
'diff-wrong': d.status === 'wrong',
'diff-missing': d.status === 'missing',
}"
>{{ d.word }}</span>
</div>
</div>
<div class="diff-row" v-if="recognizedText">
<span class="diff-meta">实际</span>
<div class="diff-words">
<span
v-for="(d, i) in diffWords"
:key="i"
class="diff-word"
:class="{
'diff-correct': d.status === 'correct',
'diff-wrong': d.status === 'wrong',
'diff-missing': d.status === 'missing',
}"
>{{ d.actual || '—' }}</span>
</div>
</div>
</div>
<div <div
v-if=" v-if="
evaluationResult.errors && evaluationResult.errors.length > 0 evaluationResult.errors && evaluationResult.errors.length > 0
@ -1390,6 +1535,208 @@ onUnmounted(() => {
color: var(--text-secondary); color: var(--text-secondary);
} }
/* 识别文本卡片 */
.recognized-text-card {
margin-top: 1rem;
padding: 0.875rem 1rem;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.5rem;
}
.recognized-label {
font-size: 0.8125rem;
color: var(--text-secondary);
white-space: nowrap;
}
.recognized-content {
font-size: 1rem;
font-weight: 500;
color: var(--text-primary);
line-height: 1.5;
}
.recognized-content.interim {
color: var(--text-secondary);
font-style: italic;
}
/* 历史评分趋势 */
.history-trend {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1.25rem;
padding: 0.875rem 1rem;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 10px;
flex-wrap: wrap;
}
.history-label {
font-size: 0.8125rem;
color: var(--text-secondary);
white-space: nowrap;
}
.history-dots {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.history-dot {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 0.4rem 0.75rem;
border-radius: 8px;
border: 1px solid transparent;
}
.history-dot.dot-excellent {
background: rgba(34, 197, 94, 0.12);
border-color: rgba(34, 197, 94, 0.3);
}
.history-dot.dot-good {
background: rgba(234, 179, 8, 0.12);
border-color: rgba(234, 179, 8, 0.3);
}
.history-dot.dot-poor {
background: rgba(239, 68, 68, 0.12);
border-color: rgba(239, 68, 68, 0.3);
}
.dot-score {
font-size: 1rem;
font-weight: 700;
line-height: 1;
}
.dot-excellent .dot-score { color: #22c55e; }
.dot-good .dot-score { color: #eab308; }
.dot-poor .dot-score { color: #ef4444; }
.dot-time {
font-size: 0.6875rem;
color: var(--text-secondary);
}
/* 评分等级应用 */
.score-level-badge {
margin-top: 0.5rem;
font-size: 0.875rem;
font-weight: 600;
padding: 0.25rem 0.875rem;
border-radius: 20px;
}
.score-excellent .score-level-badge,
.score-level-badge.score-excellent {
background: rgba(34, 197, 94, 0.15);
color: #22c55e;
}
.score-good .score-level-badge,
.score-level-badge.score-good {
background: rgba(234, 179, 8, 0.15);
color: #eab308;
}
.score-poor .score-level-badge,
.score-level-badge.score-poor {
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
}
/* 评分进度条着色 */
.score-progress.score-excellent { stroke: #22c55e; }
.score-progress.score-good { stroke: #eab308; }
.score-progress.score-poor { stroke: #ef4444; }
/* 分数数字着色 */
.score-value.score-excellent { color: #22c55e; }
.score-value.score-good { color: #eab308; }
.score-value.score-poor { color: #ef4444; }
/* 差异对比區 */
.diff-section {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
padding: 1rem;
margin-bottom: 1rem;
}
.diff-section h4 {
margin: 0 0 0.75rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--text-primary);
}
.diff-row {
display: flex;
align-items: flex-start;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.diff-row:last-child {
margin-bottom: 0;
}
.diff-meta {
font-size: 0.75rem;
color: var(--text-secondary);
white-space: nowrap;
min-width: 36px;
padding-top: 2px;
}
.diff-words {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.diff-word {
font-size: 0.9375rem;
font-weight: 500;
padding: 0.2rem 0.5rem;
border-radius: 6px;
border: 1px solid transparent;
}
.diff-correct {
color: #22c55e;
background: rgba(34, 197, 94, 0.1);
border-color: rgba(34, 197, 94, 0.25);
}
.diff-wrong {
color: #ef4444;
background: rgba(239, 68, 68, 0.1);
border-color: rgba(239, 68, 68, 0.25);
text-decoration: line-through;
}
.diff-missing {
color: #f97316;
background: rgba(249, 115, 22, 0.1);
border-color: rgba(249, 115, 22, 0.25);
font-style: italic;
}
/* 响应式 */ /* 响应式 */
@media (max-width: 1024px) { @media (max-width: 1024px) {
.main-content { .main-content {