Ver Fonte

剩余时间修复

jimmylee há 2 meses atrás
pai
commit
49d9334157

+ 1 - 1
src/store/speaking.ts

@@ -24,7 +24,7 @@ const DEFAULT_CONFIG: TopicDiscussionConfig = {
   },
   },
   practice: {
   practice: {
     mode: 'time',
     mode: 'time',
-    rounds: 20,
+    rounds: 2,
     duration: 5,
     duration: 5,
     showEnglish: true,
     showEnglish: true,
     taskHint: true,
     taskHint: true,

+ 4 - 0
src/types/englishSpeaking.ts

@@ -238,6 +238,8 @@ export interface SessionConfig {
   grade: string
   grade: string
   roleId: string
   roleId: string
   totalRounds: number
   totalRounds: number
+  /** 时长(分钟)。与 totalRounds 是并存的两个结束条件,后端据此计算 expiresAt */
+  durationMinutes: number
   vocabulary?: string[]
   vocabulary?: string[]
   sentences?: string[]
   sentences?: string[]
 }
 }
@@ -256,6 +258,8 @@ export interface GreetingInfo {
 }
 }
 
 
 // 用于启动 chat view 的最小 session 信息(父组件 createSession 后传给 DialogueChatView)
 // 用于启动 chat view 的最小 session 信息(父组件 createSession 后传给 DialogueChatView)
+// expiresAt 由后端基于 createSession 时传入的 durationMinutes 计算下发 —— 后端权威,
+// 关页面/刷新/换设备重进都接续同一个截止时刻,确保倒计时不会因任何原因暂停。
 export interface SessionStartInfo {
 export interface SessionStartInfo {
   sessionId: string
   sessionId: string
   expiresAt: string | null
   expiresAt: string | null

+ 7 - 3
src/views/Editor/EnglishSpeaking/composables/useDialogueEngine.ts

@@ -2,7 +2,7 @@ import { ref, reactive, computed, onUnmounted } from 'vue'
 import type { PreviewChatMessage, DialogueAPI, DialogueReport } from '@/types/englishSpeaking'
 import type { PreviewChatMessage, DialogueAPI, DialogueReport } from '@/types/englishSpeaking'
 import { createDialogueApi, DialogueApiError } from '../services/llmService'
 import { createDialogueApi, DialogueApiError } from '../services/llmService'
 
 
-export function useDialogueEngine(mode: 'preview' | 'real' = 'preview') {
+export function useDialogueEngine() {
   const messages = ref<PreviewChatMessage[]>([])
   const messages = ref<PreviewChatMessage[]>([])
   const sessionId = ref<string | null>(null)
   const sessionId = ref<string | null>(null)
   const expiresAt = ref<string | null>(null)
   const expiresAt = ref<string | null>(null)
@@ -10,7 +10,7 @@ export function useDialogueEngine(mode: 'preview' | 'real' = 'preview') {
   const isComplete = ref(false)
   const isComplete = ref(false)
   const countdownSeconds = ref<number | null>(null)
   const countdownSeconds = ref<number | null>(null)
 
 
-  const api: DialogueAPI = createDialogueApi(mode)
+  const api: DialogueAPI = createDialogueApi()
   let currentAbortController: AbortController | null = null
   let currentAbortController: AbortController | null = null
   let countdownTimer: ReturnType<typeof setInterval> | null = null
   let countdownTimer: ReturnType<typeof setInterval> | null = null
   let ttsUtterance: SpeechSynthesisUtterance | null = null
   let ttsUtterance: SpeechSynthesisUtterance | null = null
@@ -23,7 +23,11 @@ export function useDialogueEngine(mode: 'preview' | 'real' = 'preview') {
   const greetingInflight = ref(false)
   const greetingInflight = ref(false)
   let greetingAbortController: AbortController | null = null
   let greetingAbortController: AbortController | null = null
 
 
-  /** 仅附加已建好的 session 到 engine,不发任何网络请求。 */
+  /** 仅附加已建好的 session 到 engine,不发任何网络请求。
+   *  时间和轮次是两个并存的结束条件,先满足谁就结束。
+   *  expiresAt 由后端在 createSession 时基于 durationMinutes 计算下发,前端只读 ——
+   *  保证关页面/刷新/换设备重进都能接续同一个截止时刻,倒计时不暂停。
+   */
   function attachSession(info: { sessionId: string; expiresAt?: string | null }) {
   function attachSession(info: { sessionId: string; expiresAt?: string | null }) {
     sessionId.value = info.sessionId
     sessionId.value = info.sessionId
     expiresAt.value = info.expiresAt ?? null
     expiresAt.value = info.expiresAt ?? null

+ 4 - 16
src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue

@@ -21,10 +21,8 @@
 
 
       <div class="header-right">
       <div class="header-right">
         <span class="round-indicator">{{ currentRound }} / {{ totalRounds }} 轮</span>
         <span class="round-indicator">{{ currentRound }} / {{ totalRounds }} 轮</span>
-        <span class="total-time">
-          {{ engine.countdownSeconds.value != null
-            ? formatSeconds(engine.countdownSeconds.value)
-            : formatSeconds(totalSeconds) }}
+        <span v-if="engine.countdownSeconds.value != null" class="total-time">
+          {{ formatSeconds(engine.countdownSeconds.value) }}
         </span>
         </span>
         <button class="icon-btn" title="更多操作" @click="showExitConfirm = true">
         <button class="icon-btn" title="更多操作" @click="showExitConfirm = true">
           <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
           <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
@@ -442,7 +440,6 @@ interface Props {
   aiName?: string
   aiName?: string
   aiAvatar?: string
   aiAvatar?: string
   totalRounds?: number
   totalRounds?: number
-  mode?: 'preview' | 'real'
   showEnglishText?: boolean
   showEnglishText?: boolean
   showChineseText?: boolean
   showChineseText?: boolean
   sessionInfo?: SessionStartInfo | null
   sessionInfo?: SessionStartInfo | null
@@ -454,7 +451,6 @@ const props = withDefaults(defineProps<Props>(), {
   aiName: 'Tom',
   aiName: 'Tom',
   aiAvatar: '😊',
   aiAvatar: '😊',
   totalRounds: 3,
   totalRounds: 3,
-  mode: 'preview',
   showEnglishText: true,
   showEnglishText: true,
   showChineseText: false,
   showChineseText: false,
   sessionInfo: null,
   sessionInfo: null,
@@ -487,7 +483,7 @@ const BADGE_CONFIG: Record<string, BadgeAchievement> = {
 // Composables
 // Composables
 // ─────────────────────────────────────────────
 // ─────────────────────────────────────────────
 
 
-const engine = useDialogueEngine(props.mode)
+const engine = useDialogueEngine()
 const recorder = useAudioRecorder()
 const recorder = useAudioRecorder()
 
 
 // ─────────────────────────────────────────────
 // ─────────────────────────────────────────────
@@ -501,7 +497,7 @@ const showHintModal = ref(false)
 const taskHint = ref<TaskHint | null>(null)
 const taskHint = ref<TaskHint | null>(null)
 const taskHintLoading = ref(false)
 const taskHintLoading = ref(false)
 const taskHintError = ref<string | null>(null)
 const taskHintError = ref<string | null>(null)
-const taskHintApi = createDialogueApi(props.mode)
+const taskHintApi = createDialogueApi()
 const showExitConfirm = ref(false)
 const showExitConfirm = ref(false)
 const phonemeDetail = ref<{
 const phonemeDetail = ref<{
   word: string
   word: string
@@ -516,9 +512,6 @@ let idleHintTimer: ReturnType<typeof setTimeout> | null = null
 let currentAudio: HTMLAudioElement | null = null
 let currentAudio: HTMLAudioElement | null = null
 let currentAudioUrl: string | null = null
 let currentAudioUrl: string | null = null
 
 
-// 总用时计数(独立 UI 计时,与 engine.countdownSeconds 互斥显示)
-const totalSeconds = ref(0)
-let totalTimer: ReturnType<typeof setInterval> | null = null
 
 
 // 徽章
 // 徽章
 const showBadge = ref<BadgeAchievement | null>(null)
 const showBadge = ref<BadgeAchievement | null>(null)
@@ -929,14 +922,9 @@ onMounted(() => {
     console.warn('[DialogueChatView] mounted without sessionInfo; chat is inert. Parent must createSession before mounting.')
     console.warn('[DialogueChatView] mounted without sessionInfo; chat is inert. Parent must createSession before mounting.')
   }
   }
   // 无 sessionInfo 时聊天区保持空(父组件应当先创建 session 再挂载本组件)
   // 无 sessionInfo 时聊天区保持空(父组件应当先创建 session 再挂载本组件)
-
-  totalTimer = setInterval(() => {
-    if (engine.countdownSeconds.value == null) totalSeconds.value += 1
-  }, 1000)
 })
 })
 
 
 onUnmounted(() => {
 onUnmounted(() => {
-  if (totalTimer) clearInterval(totalTimer)
   if (idleHintTimer) clearTimeout(idleHintTimer)
   if (idleHintTimer) clearTimeout(idleHintTimer)
   if (badgeTimer) clearTimeout(badgeTimer)
   if (badgeTimer) clearTimeout(badgeTimer)
   stopCurrentPlayback()
   stopCurrentPlayback()

+ 2 - 5
src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue

@@ -39,7 +39,6 @@
       :ai-name="mockRole.name"
       :ai-name="mockRole.name"
       :ai-avatar="mockRole.avatar"
       :ai-avatar="mockRole.avatar"
       :total-rounds="speakingStore.config.practice.rounds || totalRounds"
       :total-rounds="speakingStore.config.practice.rounds || totalRounds"
-      :mode="mode"
       :session-info="preparedSession"
       :session-info="preparedSession"
       @complete="handleDialogueComplete"
       @complete="handleDialogueComplete"
       @restart="resetPreview"
       @restart="resetPreview"
@@ -79,7 +78,6 @@ import OverallReport from './OverallReport.vue'
 import DetailedReport from './DetailedReport.vue'
 import DetailedReport from './DetailedReport.vue'
 
 
 interface Props {
 interface Props {
-  mode?: 'preview' | 'real'
   topic?: string
   topic?: string
   keywords?: string[]
   keywords?: string[]
   totalRounds?: number
   totalRounds?: number
@@ -88,7 +86,6 @@ interface Props {
 }
 }
 
 
 const props = withDefaults(defineProps<Props>(), {
 const props = withDefaults(defineProps<Props>(), {
-  mode: 'real',
   topic: '我最喜欢的动物',
   topic: '我最喜欢的动物',
   keywords: () => ['favorite', 'adorable', 'bamboo', 'habitat', 'wildlife', 'endangered'],
   keywords: () => ['favorite', 'adorable', 'bamboo', 'habitat', 'wildlife', 'endangered'],
   totalRounds: 3,
   totalRounds: 3,
@@ -224,12 +221,12 @@ async function startDialogue() {
   sessionError.value = null
   sessionError.value = null
   reportFetchFailed.value = false
   reportFetchFailed.value = false
   try {
   try {
-    // Fresh instance per attempt; MockDialogueAPI resets roundIndex in createSession.
-    const api = createDialogueApi(props.mode)
+    const api = createDialogueApi()
     const info = await api.createSession({
     const info = await api.createSession({
       topic: speakingStore.config.topic || props.topic,
       topic: speakingStore.config.topic || props.topic,
       grade: speakingStore.config.grade,
       grade: speakingStore.config.grade,
       totalRounds: speakingStore.config.practice.rounds ?? props.totalRounds,
       totalRounds: speakingStore.config.practice.rounds ?? props.totalRounds,
+      durationMinutes: speakingStore.config.practice.duration,
       roleId: mockRole.id,
       roleId: mockRole.id,
       vocabulary: speakingStore.config.learningGoals.vocabulary,
       vocabulary: speakingStore.config.learningGoals.vocabulary,
       sentences: speakingStore.config.learningGoals.sentences,
       sentences: speakingStore.config.learningGoals.sentences,

+ 3 - 108
src/views/Editor/EnglishSpeaking/services/llmService.ts

@@ -206,6 +206,7 @@ export class RealDialogueAPI implements DialogueAPI {
         vocabulary: config.vocabulary ?? [],
         vocabulary: config.vocabulary ?? [],
         sentences: config.sentences ?? [],
         sentences: config.sentences ?? [],
         totalRounds: config.totalRounds,
         totalRounds: config.totalRounds,
+        durationMinutes: config.durationMinutes,
         roleId: config.roleId,
         roleId: config.roleId,
       }),
       }),
     })
     })
@@ -286,112 +287,6 @@ export class RealDialogueAPI implements DialogueAPI {
   }
   }
 }
 }
 
 
-// ==================== Mock API ====================
-
-const MOCK_AI_REPLIES = [
-  'Pandas are adorable! Have you seen them at the zoo?',
-  "That's great! What do pandas like to eat?",
-  "Bamboo is their favorite! You did a wonderful job talking about animals today!",
-]
-
-export class MockDialogueAPI implements DialogueAPI {
-  private roundIndex = 0
-
-  async createSession(_config: SessionConfig): Promise<SessionInfo> {
-    this.roundIndex = 0
-    return {
-      sessionId: 'mock-session-' + Date.now(),
-      totalRounds: _config.totalRounds,
-      currentRound: 1,
-      expiresAt: null,
-    }
-  }
-
-  async completeSession(_sessionId: string): Promise<void> {
-    return undefined
-  }
-
-  async generateGreeting(_sessionId: string, signal?: AbortSignal): Promise<GreetingInfo> {
-    await delay(300, signal)
-    return { aiMessage: "Hi! What's your favorite animal?" }
-  }
-
-  async generateTaskHint(_sessionId: string): Promise<TaskHint> {
-    await delay(250)
-    return {
-      practice_level: 'grade5-1',
-      conversation_topic: '我最喜欢的动物',
-      current_question: '和 Tom 聊一聊「我最喜欢的动物」,试着用今天的重点词汇和句型表达你的想法。',
-      example_sentences: [
-        { english: 'I like pandas best because they are very cute.', chinese: '我最喜欢大熊猫,因为它们非常可爱。' },
-        { english: "My favorite animal is the elephant. It's really smart!", chinese: '我最喜欢的动物是大象,它真的很聪明!' },
-        { english: 'I enjoy watching animals at the zoo with my family.', chinese: '我喜欢和家人一起在动物园看动物。' },
-      ],
-      key_vocabulary: [
-        { word: 'favorite', meaning: '最喜欢的' },
-        { word: 'adorable', meaning: '可爱的' },
-        { word: 'bamboo', meaning: '竹子' },
-        { word: 'habitat', meaning: '栖息地' },
-      ],
-    }
-  }
-
-  async *speak(_sessionId: string, _audioBlob: Blob, signal: AbortSignal): AsyncGenerator<SSEEvent> {
-    const mockStudentTexts = [
-      'I like pandas. They are very cute!',
-      'Yes, I went to the zoo last month.',
-      'They like to eat bamboo.',
-    ]
-
-    // Simulate transcript
-    await delay(300, signal)
-    yield { type: 'transcript', text: mockStudentTexts[this.roundIndex] || 'I think so.' }
-
-    // Simulate token streaming
-    const aiReply = MOCK_AI_REPLIES[this.roundIndex] || 'That is very interesting!'
-    const words = aiReply.split(' ')
-    for (const word of words) {
-      await delay(80, signal)
-      yield { type: 'token', text: word + ' ' }
-    }
-
-    this.roundIndex++
-    const isComplete = this.roundIndex >= MOCK_AI_REPLIES.length
-
-    await delay(100, signal)
-    yield { type: 'done', isComplete }
-  }
-
-  async getReport(_sessionId: string): Promise<DialogueReport> {
-    return {
-      status: 'ready',
-      evaluation: {
-        overallScore: 85,
-        scoreLevel: 'good',
-        percentile: 78,
-        dimensions: { fluency: 82, interaction: 88, vocabulary: 76, grammar: 90 },
-        aiComment: 'Great job! Your pronunciation was clear and your responses were relevant.',
-        highlights: ['Clear pronunciation', 'Good use of complete sentences', 'Natural flow'],
-        improvements: ['Try more adjectives', 'Practice linking words', 'Expand vocabulary'],
-        nextChallenge: { difficulty: 'Medium', unlockedTopic: 'My Dream Job' },
-        statistics: {
-          totalRounds: 3, averageScore: 83, highestScore: 92,
-          highestRound: 2, grammarErrors: 2, excellentExpressions: 4, totalDuration: 180,
-        },
-        sentenceEvaluations: [],
-      },
-    }
-  }
-}
-
-export function createDialogueApi(mode: 'preview' | 'real'): DialogueAPI {
-  return mode === 'real' ? new RealDialogueAPI() : new MockDialogueAPI()
-}
-
-function delay(ms: number, signal?: AbortSignal): Promise<void> {
-  return new Promise((resolve, reject) => {
-    if (signal?.aborted) { reject(new DOMException('Aborted', 'AbortError')); return }
-    const timer = setTimeout(resolve, ms)
-    signal?.addEventListener('abort', () => { clearTimeout(timer); reject(new DOMException('Aborted', 'AbortError')) }, { once: true })
-  })
+export function createDialogueApi(): DialogueAPI {
+  return new RealDialogueAPI()
 }
 }