|
@@ -30,8 +30,43 @@
|
|
|
<div class="card-left">
|
|
<div class="card-left">
|
|
|
<!-- 语音条 -->
|
|
<!-- 语音条 -->
|
|
|
<div class="card-voice-bar" :class="sentence.role === 'ai' ? 'bar-ai' : 'bar-student'">
|
|
<div class="card-voice-bar" :class="sentence.role === 'ai' ? 'bar-ai' : 'bar-student'">
|
|
|
- <button class="card-play-btn" :class="sentence.role === 'ai' ? 'play-ai' : 'play-student'" @click.stop>
|
|
|
|
|
- <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
|
|
|
|
|
+ <button
|
|
|
|
|
+ v-if="sentence.role === 'student'"
|
|
|
|
|
+ class="card-play-btn play-student"
|
|
|
|
|
+ :disabled="!sentence.audioUrl"
|
|
|
|
|
+ @click.stop="handleSentencePlay(sentence)"
|
|
|
|
|
+ >
|
|
|
|
|
+ <!-- loading -->
|
|
|
|
|
+ <svg
|
|
|
|
|
+ v-if="player.loadingId.value === sentence.id"
|
|
|
|
|
+ class="play-spinner"
|
|
|
|
|
+ width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
|
|
|
+ stroke-width="2" stroke-linecap="round"
|
|
|
|
|
+ >
|
|
|
|
|
+ <path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ <!-- playing -->
|
|
|
|
|
+ <svg
|
|
|
|
|
+ v-else-if="player.playingId.value === sentence.id"
|
|
|
|
|
+ width="10" height="10" viewBox="0 0 24 24" fill="currentColor"
|
|
|
|
|
+ >
|
|
|
|
|
+ <rect x="6" y="4" width="4" height="16" /><rect x="14" y="4" width="4" height="16" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ <!-- error -->
|
|
|
|
|
+ <svg
|
|
|
|
|
+ v-else-if="player.errorId.value === sentence.id"
|
|
|
|
|
+ width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
|
|
|
+ stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
|
|
|
|
+ >
|
|
|
|
|
+ <path d="M12 9v4" /><path d="M12 17h.01" />
|
|
|
|
|
+ <path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ <!-- idle -->
|
|
|
|
|
+ <svg
|
|
|
|
|
+ v-else
|
|
|
|
|
+ width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
|
|
|
+ stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
|
|
|
|
+ >
|
|
|
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
|
|
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
|
|
|
<path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
|
|
<path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
|
|
|
</svg>
|
|
</svg>
|
|
@@ -45,8 +80,11 @@
|
|
|
:style="{ height: `${Math.sin(i * 0.6) * 4 + 3}px` }"
|
|
:style="{ height: `${Math.sin(i * 0.6) * 4 + 3}px` }"
|
|
|
/>
|
|
/>
|
|
|
</div>
|
|
</div>
|
|
|
- <span class="card-duration" :class="sentence.role === 'ai' ? 'cd-ai' : 'cd-student'">
|
|
|
|
|
- {{ formatDuration(sentence.audioDuration || 3) }}
|
|
|
|
|
|
|
+ <span
|
|
|
|
|
+ v-if="sentence.role === 'student'"
|
|
|
|
|
+ class="card-duration cd-student"
|
|
|
|
|
+ >
|
|
|
|
|
+ {{ formatDuration(sentence.audioDuration) }}
|
|
|
</span>
|
|
</span>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
@@ -111,6 +149,7 @@
|
|
|
<script lang="ts" setup>
|
|
<script lang="ts" setup>
|
|
|
import { ref, computed } from 'vue'
|
|
import { ref, computed } from 'vue'
|
|
|
import type { SentenceEvaluation } from '@/types/englishSpeaking'
|
|
import type { SentenceEvaluation } from '@/types/englishSpeaking'
|
|
|
|
|
+import { useAudioPlayer } from '../composables/useAudioPlayer'
|
|
|
|
|
|
|
|
interface Props {
|
|
interface Props {
|
|
|
sentenceEvaluations: SentenceEvaluation[]
|
|
sentenceEvaluations: SentenceEvaluation[]
|
|
@@ -118,6 +157,8 @@ interface Props {
|
|
|
|
|
|
|
|
const props = defineProps<Props>()
|
|
const props = defineProps<Props>()
|
|
|
|
|
|
|
|
|
|
+const player = useAudioPlayer()
|
|
|
|
|
+
|
|
|
const expandedIds = ref(new Set<string>())
|
|
const expandedIds = ref(new Set<string>())
|
|
|
const allExpanded = ref(false)
|
|
const allExpanded = ref(false)
|
|
|
|
|
|
|
@@ -154,12 +195,24 @@ function hasDetails(sentence: SentenceEvaluation) {
|
|
|
return Boolean(sentence.pronunciation || sentence.feedback)
|
|
return Boolean(sentence.pronunciation || sentence.feedback)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-function formatDuration(seconds: number) {
|
|
|
|
|
- const mins = Math.floor(seconds / 60)
|
|
|
|
|
- const secs = seconds % 60
|
|
|
|
|
|
|
+function formatDuration(seconds: number | null | undefined): string {
|
|
|
|
|
+ if (seconds == null || !Number.isFinite(seconds)) return '--:--'
|
|
|
|
|
+ const total = Math.round(seconds)
|
|
|
|
|
+ const mins = Math.floor(total / 60)
|
|
|
|
|
+ const secs = total % 60
|
|
|
return `${mins}:${String(secs).padStart(2, '0')}`
|
|
return `${mins}:${String(secs).padStart(2, '0')}`
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+function handleSentencePlay(s: SentenceEvaluation) {
|
|
|
|
|
+ if (!s.audioUrl) return
|
|
|
|
|
+ // 同 id 二次点击:toggle 停止;否则播放新源
|
|
|
|
|
+ if (player.playingId.value === s.id || player.loadingId.value === s.id) {
|
|
|
|
|
+ player.stop()
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ player.play(s.id, { kind: 'url', url: s.audioUrl })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
function getScoreClass(score: number) {
|
|
function getScoreClass(score: number) {
|
|
|
if (score >= 90) return 'score-excellent'
|
|
if (score >= 90) return 'score-excellent'
|
|
|
if (score >= 80) return 'score-good'
|
|
if (score >= 80) return 'score-good'
|