Просмотр исходного кода

feat(speaking): DetailedReport student replay + real duration

Wires DetailedReport student cards to useAudioPlayer (url mode) so the
play button actually plays the persisted S3 audio. Replaces the
audioDuration || 3 fallback with a null-safe formatter that shows
'--:--' on legacy data. AI cards drop the play button and duration span
entirely — TTS audio has no persisted url/duration to surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
jimmylee 1 день назад
Родитель
Сommit
2ad1adfc14
1 измененных файлов с 60 добавлено и 7 удалено
  1. 60 7
      src/views/Editor/EnglishSpeaking/preview/DetailedReport.vue

+ 60 - 7
src/views/Editor/EnglishSpeaking/preview/DetailedReport.vue

@@ -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'