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