refactor(speaking-evaluation): 重构听读评测页面代码结构与逻辑
- 重构了组件的脚本部分,优化状态管理与函数组织 - 统一管理TTS合成与播放逻辑,提升代码可维护性 - 优化ASR语音识别流程,增加错误处理和状态反馈 - 改进AI评测接口调用及结果解析逻辑,确保稳定性 - 清理录音和播放资源,防止内存泄漏 - 重新编排模板结构,提升交互体验和代码可读性 - 优化样式细节,增强界面一致性和响应式表现
This commit is contained in:
parent
63056170cc
commit
533507e4ae
|
|
@ -1,5 +1,5 @@
|
|||
<script setup>
|
||||
import { ref, computed, onUnmounted, nextTick } from "vue";
|
||||
import { ref, computed, watch, onUnmounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import {
|
||||
DOUBAO_APP_ID,
|
||||
|
|
@ -40,10 +40,37 @@ let asrAudioContext = null;
|
|||
let asrScriptProcessor = null;
|
||||
let asrMediaStream = null;
|
||||
let recognizedText = ref("");
|
||||
const interimText = ref("");
|
||||
|
||||
// ── 录音计时器 ──
|
||||
let recordingTimer = null;
|
||||
const recordingSeconds = ref(0);
|
||||
|
||||
// ── 评测结果 ──
|
||||
const evaluationResult = ref(null);
|
||||
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");
|
||||
|
|
@ -92,9 +119,21 @@ const parseText = () => {
|
|||
wordList.value = [...new Set(items)];
|
||||
};
|
||||
|
||||
// ── TTS 音频缓存 ──
|
||||
const audioCache = new Map(); // key: `${text}_${voice}`
|
||||
|
||||
watch(selectedVoice, () => {
|
||||
audioCache.clear();
|
||||
});
|
||||
|
||||
// ── TTS 语音合成 ──
|
||||
const synthesizeAndPlay = async (text) => {
|
||||
if (!text || !text.trim()) return;
|
||||
const cacheKey = `${text}_${selectedVoice.value}`;
|
||||
if (audioCache.has(cacheKey)) {
|
||||
playAudio(audioCache.get(cacheKey));
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
user: { uid: "eval_" + Date.now() },
|
||||
|
|
@ -162,6 +201,8 @@ const synthesizeAndPlay = async (text) => {
|
|||
const url = URL.createObjectURL(blob);
|
||||
blobUrls.push(url);
|
||||
|
||||
const cacheKey2 = `${text}_${selectedVoice.value}`;
|
||||
audioCache.set(cacheKey2, url);
|
||||
playAudio(url);
|
||||
} catch (err) {
|
||||
console.error("TTS error:", err);
|
||||
|
|
@ -200,6 +241,9 @@ const handleWordClick = (index) => {
|
|||
currentWordIndex.value = index;
|
||||
playingIndex.value = index;
|
||||
evaluationResult.value = null; // 清空之前的评测结果
|
||||
evaluationHistory.value = []; // 切换单词时清空历史
|
||||
recognizedText.value = "";
|
||||
interimText.value = "";
|
||||
synthesizeAndPlay(wordList.value[index]);
|
||||
};
|
||||
|
||||
|
|
@ -269,6 +313,12 @@ const parseServerFrame = (buffer) => {
|
|||
const stopRecording = async () => {
|
||||
isRecording.value = false;
|
||||
asrStatus.value = "";
|
||||
// 清除录音计时器
|
||||
if (recordingTimer) {
|
||||
clearInterval(recordingTimer);
|
||||
recordingTimer = null;
|
||||
}
|
||||
recordingSeconds.value = 0;
|
||||
|
||||
if (asrScriptProcessor) {
|
||||
asrScriptProcessor.disconnect();
|
||||
|
|
@ -304,16 +354,21 @@ const stopRecording = async () => {
|
|||
|
||||
// 识别完成后进行评测
|
||||
if (recognizedText.value && currentWord.value) {
|
||||
interimText.value = "";
|
||||
await evaluatePronunciation();
|
||||
}
|
||||
};
|
||||
|
||||
const startRecording = async () => {
|
||||
if (isRecording.value) {
|
||||
// 点击切换录音
|
||||
const toggleRecording = async () => {
|
||||
if (isRecording.value || asrStatus.value === "connecting") {
|
||||
await stopRecording();
|
||||
return;
|
||||
}
|
||||
await startRecording();
|
||||
};
|
||||
|
||||
const startRecording = async () => {
|
||||
if (!currentWord.value) {
|
||||
alert("请先点击要评测的单词或句子");
|
||||
return;
|
||||
|
|
@ -381,6 +436,9 @@ const startRecording = async () => {
|
|||
const hasFinal = result.utterances?.some((u) => u.definite === true);
|
||||
if (hasFinal) {
|
||||
recognizedText.value = text;
|
||||
interimText.value = "";
|
||||
} else {
|
||||
interimText.value = text;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("ASR parse error:", e);
|
||||
|
|
@ -432,6 +490,9 @@ const startRecording = async () => {
|
|||
|
||||
isRecording.value = true;
|
||||
asrStatus.value = "recording";
|
||||
// 启动计时器
|
||||
recordingSeconds.value = 0;
|
||||
recordingTimer = setInterval(() => recordingSeconds.value++, 1000);
|
||||
} catch (err) {
|
||||
console.error("ASR start error:", err);
|
||||
stopRecording();
|
||||
|
|
@ -485,7 +546,16 @@ const evaluatePronunciation = async () => {
|
|||
// 提取 JSON
|
||||
const jsonMatch = resultText.match(/\{[\s\S]*\}/);
|
||||
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 {
|
||||
throw new Error("无法解析评测结果");
|
||||
}
|
||||
|
|
@ -511,6 +581,14 @@ onUnmounted(() => {
|
|||
}
|
||||
blobUrls.forEach((url) => URL.revokeObjectURL(url));
|
||||
blobUrls.length = 0;
|
||||
// 清理音频缓存
|
||||
audioCache.forEach((url) => URL.revokeObjectURL(url));
|
||||
audioCache.clear();
|
||||
// 清理计时器
|
||||
if (recordingTimer) {
|
||||
clearInterval(recordingTimer);
|
||||
recordingTimer = null;
|
||||
}
|
||||
stopRecording();
|
||||
});
|
||||
</script>
|
||||
|
|
@ -638,11 +716,7 @@ onUnmounted(() => {
|
|||
error: asrStatus === 'error',
|
||||
}"
|
||||
:disabled="!currentWord || isEvaluating"
|
||||
@mousedown="startRecording"
|
||||
@mouseup="stopRecording"
|
||||
@mouseleave="isRecording && stopRecording()"
|
||||
@touchstart.prevent="startRecording"
|
||||
@touchend.prevent="stopRecording"
|
||||
@click="toggleRecording"
|
||||
>
|
||||
<div v-if="asrStatus === 'connecting'" class="btn-spinner"></div>
|
||||
<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"
|
||||
/>
|
||||
</svg>
|
||||
<span>录音中...</span>
|
||||
<span>录音中 {{ recordingSeconds }}s 点击停止</span>
|
||||
</div>
|
||||
<template v-else>
|
||||
<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"
|
||||
/>
|
||||
</svg>
|
||||
<span>按住跟读评测</span>
|
||||
<span>点击开始跟读评测</span>
|
||||
</template>
|
||||
</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 class="loading-spinner"></div>
|
||||
|
|
@ -679,6 +762,26 @@ onUnmounted(() => {
|
|||
</div>
|
||||
|
||||
<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-circle">
|
||||
<svg class="score-svg" viewBox="0 0 100 100">
|
||||
|
|
@ -688,17 +791,21 @@ onUnmounted(() => {
|
|||
cy="50"
|
||||
r="45"
|
||||
class="score-progress"
|
||||
:class="scoreLevel.cls"
|
||||
:style="{
|
||||
strokeDashoffset:
|
||||
283 - (283 * evaluationResult.overallScore) / 100,
|
||||
}"
|
||||
></circle>
|
||||
</svg>
|
||||
<div class="score-value">
|
||||
<div class="score-value" :class="scoreLevel.cls">
|
||||
{{ evaluationResult.overallScore }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="score-label">综合评分</div>
|
||||
<div class="score-level-badge" :class="scoreLevel.cls">
|
||||
{{ scoreLevel.label }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-scores">
|
||||
|
|
@ -726,6 +833,44 @@ onUnmounted(() => {
|
|||
</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
|
||||
v-if="
|
||||
evaluationResult.errors && evaluationResult.errors.length > 0
|
||||
|
|
@ -1390,6 +1535,208 @@ onUnmounted(() => {
|
|||
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) {
|
||||
.main-content {
|
||||
|
|
|
|||
Loading…
Reference in New Issue