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

feat: restore student speaking sessions

jimmylee 1 неделя назад
Родитель
Сommit
675f389f34
1 измененных файлов с 159 добавлено и 4 удалено
  1. 159 4
      src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue

+ 159 - 4
src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue

@@ -1,7 +1,20 @@
 <template>
 <template>
   <div class="topic-discussion-preview">
   <div class="topic-discussion-preview">
     <!-- Ready 阶段:极简首页(参照 enspeak 布局) -->
     <!-- Ready 阶段:极简首页(参照 enspeak 布局) -->
-    <div v-if="dialogueState === 'ready'" class="ready-stage">
+    <div v-if="dialogueState === 'checking-history'" class="ready-stage">
+      <div class="ready-header">
+        <h1 class="ready-title">
+          <span class="ready-title-icon">💬</span>
+          Topic Discussion
+        </h1>
+        <p class="ready-subtitle">正在读取你的练习记录...</p>
+      </div>
+      <div class="ready-body">
+        <span class="start-btn-spinner" />
+      </div>
+    </div>
+
+    <div v-else-if="dialogueState === 'ready'" class="ready-stage">
       <div class="ready-header">
       <div class="ready-header">
         <h1 class="ready-title">
         <h1 class="ready-title">
           <span class="ready-title-icon">💬</span>
           <span class="ready-title-icon">💬</span>
@@ -96,6 +109,19 @@ const dialogueState = ref<PreviewDialogueState>('ready')
 const sessionCreating = ref(false)
 const sessionCreating = ref(false)
 const sessionError = ref<string | null>(null)
 const sessionError = ref<string | null>(null)
 const preparedSession = ref<SessionStartInfo | null>(null)
 const preparedSession = ref<SessionStartInfo | null>(null)
+const historyChecked = ref(false)
+const historyLoadToken = ref(0)
+
+const runtimeParams = computed(() => {
+  const params = new URLSearchParams(window.location.search)
+  return {
+    mode: params.get('mode'),
+    userId: params.get('userid'),
+  }
+})
+
+const isStudentRuntime = computed(() => runtimeParams.value.mode === 'student')
+const runtimeUserId = computed(() => runtimeParams.value.userId || '')
 
 
 const mockRole: PreviewAIRole = {
 const mockRole: PreviewAIRole = {
   id: 'tom',
   id: 'tom',
@@ -215,8 +241,27 @@ const shouldShowOverallReport = computed(() => {
 const overallEvaluationForDisplay = computed(() => shouldShowOverallReport.value ? displayEvaluation.value : null)
 const overallEvaluationForDisplay = computed(() => shouldShowOverallReport.value ? displayEvaluation.value : null)
 const displaySentenceEvaluations = computed(() => displayEvaluation.value?.sentenceEvaluations ?? [])
 const displaySentenceEvaluations = computed(() => displayEvaluation.value?.sentenceEvaluations ?? [])
 
 
+function isHistoryTokenCurrent(token: number) {
+  return token === historyLoadToken.value
+}
+
+function nextHistoryLoadToken() {
+  historyLoadToken.value += 1
+  return historyLoadToken.value
+}
+
 async function startDialogue() {
 async function startDialogue() {
   if (sessionCreating.value) return
   if (sessionCreating.value) return
+  if (isStudentRuntime.value) {
+    if (!props.configId) {
+      sessionError.value = '口语工具配置缺失,请联系老师重新发布。'
+      return
+    }
+    if (!runtimeUserId.value) {
+      sessionError.value = '学生身份缺失,请从课程入口重新进入。'
+      return
+    }
+  }
   sessionCreating.value = true
   sessionCreating.value = true
   sessionError.value = null
   sessionError.value = null
   reportFetchFailed.value = false
   reportFetchFailed.value = false
@@ -230,10 +275,13 @@ async function startDialogue() {
       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,
+      configId: props.configId || null,
+      userId: isStudentRuntime.value ? runtimeUserId.value : null,
     })
     })
     preparedSession.value = {
     preparedSession.value = {
       sessionId: info.sessionId,
       sessionId: info.sessionId,
       expiresAt: info.expiresAt,
       expiresAt: info.expiresAt,
+      currentRound: info.currentRound,
     }
     }
     dialogueState.value = 'chatting'
     dialogueState.value = 'chatting'
   } catch (err: unknown) {
   } catch (err: unknown) {
@@ -247,6 +295,96 @@ async function startDialogue() {
   }
   }
 }
 }
 
 
+async function waitForCompletedHistoryReport(
+  api: ReturnType<typeof createDialogueApi>,
+  sessionId: string,
+  token: number,
+): Promise<DialogueReport | null> {
+  const maxAttempts = 30
+  const intervalMs = 2000
+
+  try {
+    for (let attempt = 1; attempt <= maxAttempts; attempt++) {
+      if (!isHistoryTokenCurrent(token)) return null
+      const report = await api.getReport(sessionId)
+      if (!isHistoryTokenCurrent(token)) return null
+      if (report.status === 'ready' || report.status === 'failed' || report.status === 'incomplete') {
+        return report
+      }
+      await new Promise(resolve => setTimeout(resolve, intervalMs))
+      if (!isHistoryTokenCurrent(token)) return null
+    }
+  } catch (err) {
+    console.error('[speaking] load completed history report failed:', err)
+    return null
+  }
+
+  return null
+}
+
+async function loadLatestStudentSession(token = nextHistoryLoadToken()) {
+  if (historyChecked.value) return
+  historyChecked.value = true
+  if (!isStudentRuntime.value) return
+
+  if (!props.configId) {
+    if (!isHistoryTokenCurrent(token)) return
+    sessionError.value = '口语工具配置缺失,请联系老师重新发布。'
+    return
+  }
+  if (!runtimeUserId.value) {
+    if (!isHistoryTokenCurrent(token)) return
+    sessionError.value = '学生身份缺失,请从课程入口重新进入。'
+    return
+  }
+
+  if (!isHistoryTokenCurrent(token)) return
+  dialogueState.value = 'checking-history'
+  sessionError.value = null
+  try {
+    const api = createDialogueApi()
+    const { session } = await api.getLatestSession(props.configId, runtimeUserId.value)
+    if (!isHistoryTokenCurrent(token)) return
+    if (!session) {
+      preparedSession.value = null
+      dialogueState.value = 'ready'
+      return
+    }
+
+    preparedSession.value = {
+      sessionId: session.sessionId,
+      expiresAt: session.expiresAt,
+      currentRound: session.currentRound,
+      messages: session.messages,
+    }
+
+    if (session.status === 'completed') {
+      const report = await waitForCompletedHistoryReport(api, session.sessionId, token)
+      if (!isHistoryTokenCurrent(token)) return
+      handleDialogueComplete(report)
+      return
+    }
+
+    if (session.status !== 'active') {
+      preparedSession.value = null
+      sessionError.value = '上次练习已失效,请重新开始。'
+      dialogueState.value = 'ready'
+      return
+    }
+
+    dialogueState.value = 'chatting'
+  } catch (err: unknown) {
+    if (!isHistoryTokenCurrent(token)) return
+    console.error('[speaking] load latest session failed:', err)
+    dialogueState.value = 'ready'
+    if (err instanceof DialogueApiError) {
+      sessionError.value = `读取历史会话失败(${err.status}),请刷新重试`
+    } else {
+      sessionError.value = '读取历史会话失败,请刷新重试'
+    }
+  }
+}
+
 function handleDialogueComplete(report: DialogueReport | null) {
 function handleDialogueComplete(report: DialogueReport | null) {
   reportFetchFailed.value = !report
   reportFetchFailed.value = !report
   reportStatus.value = report?.status ?? null
   reportStatus.value = report?.status ?? null
@@ -259,6 +397,7 @@ function handleDialogueComplete(report: DialogueReport | null) {
 }
 }
 
 
 function resetPreview() {
 function resetPreview() {
+  nextHistoryLoadToken()
   dialogueState.value = 'ready'
   dialogueState.value = 'ready'
   realEvaluation.value = null
   realEvaluation.value = null
   reportStatus.value = null
   reportStatus.value = null
@@ -283,22 +422,38 @@ watch(
 
 
 // ── 根据 configId 从后端拉回配置注入 store ──
 // ── 根据 configId 从后端拉回配置注入 store ──
 async function loadConfigFromBackend(id: string) {
 async function loadConfigFromBackend(id: string) {
-  if (!id) return
+  const token = nextHistoryLoadToken()
+  if (!id) {
+    await loadLatestStudentSession(token)
+    return
+  }
   try {
   try {
     const { config } = await getSpeakingConfig(id)
     const { config } = await getSpeakingConfig(id)
+    if (!isHistoryTokenCurrent(token)) return
     speakingStore.$patch({ config })
     speakingStore.$patch({ config })
   } catch (err) {
   } catch (err) {
+    if (!isHistoryTokenCurrent(token)) return
     console.error('[speaking] load config failed:', err)
     console.error('[speaking] load config failed:', err)
+  } finally {
+    if (isHistoryTokenCurrent(token)) {
+      await loadLatestStudentSession(token)
+    }
   }
   }
 }
 }
 
 
-watch(() => props.configId, (id) => { loadConfigFromBackend(id) })
+watch(() => props.configId, (id) => {
+  historyChecked.value = false
+  loadConfigFromBackend(id)
+})
 
 
 onMounted(() => {
 onMounted(() => {
   speakingStore.setPreviewState(dialogueState.value)
   speakingStore.setPreviewState(dialogueState.value)
   loadConfigFromBackend(props.configId)
   loadConfigFromBackend(props.configId)
 })
 })
-onUnmounted(() => { speakingStore.setPreviewState('ready') })
+onUnmounted(() => {
+  nextHistoryLoadToken()
+  speakingStore.setPreviewState('ready')
+})
 </script>
 </script>
 
 
 <style lang="scss" scoped>
 <style lang="scss" scoped>