refactor(speaking-evaluation): 重构听读评测页面代码结构与逻辑
- 重构了组件的脚本部分,优化状态管理与函数组织 - 统一管理TTS合成与播放逻辑,提升代码可维护性 - 优化ASR语音识别流程,增加错误处理和状态反馈 - 改进AI评测接口调用及结果解析逻辑,确保稳定性 - 清理录音和播放资源,防止内存泄漏 - 重新编排模板结构,提升交互体验和代码可读性 - 优化样式细节,增强界面一致性和响应式表现
This commit is contained in:
parent
63056170cc
commit
533507e4ae
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue