|
@@ -1,62 +1,49 @@
|
|
|
<template>
|
|
<template>
|
|
|
<div class="topic-discussion-preview">
|
|
<div class="topic-discussion-preview">
|
|
|
- <!-- Ready 阶段 -->
|
|
|
|
|
- <StudentPreview
|
|
|
|
|
- v-if="dialogueState === 'ready'"
|
|
|
|
|
- title="Talk About Animals"
|
|
|
|
|
- subtitle="Topic Discussion"
|
|
|
|
|
- titleIcon="🗣️"
|
|
|
|
|
- >
|
|
|
|
|
- <div class="ready-content">
|
|
|
|
|
- <div class="topic-icon-large">🐼</div>
|
|
|
|
|
- <h2 class="topic-name">我最喜欢的动物</h2>
|
|
|
|
|
- <p class="topic-desc">和 AI 伙伴 {{ mockRole.name }} 一起练习关于动物的英语对话</p>
|
|
|
|
|
- <div class="topic-meta">
|
|
|
|
|
- <span class="meta-item">🔄 {{ totalRounds }} 轮对话</span>
|
|
|
|
|
- <span class="meta-item">⏱️ 约 {{ totalRounds * 2 }} 分钟</span>
|
|
|
|
|
- </div>
|
|
|
|
|
- <div class="vocab-preview">
|
|
|
|
|
- <span v-for="word in previewVocab" :key="word" class="vocab-chip">{{ word }}</span>
|
|
|
|
|
- </div>
|
|
|
|
|
|
|
+ <!-- Ready 阶段:极简首页(参照 enspeak 布局) -->
|
|
|
|
|
+ <div v-if="dialogueState === 'ready'" class="ready-stage">
|
|
|
|
|
+ <div class="ready-header">
|
|
|
|
|
+ <h1 class="ready-title">
|
|
|
|
|
+ <span class="ready-title-icon">💬</span>
|
|
|
|
|
+ Topic Discussion
|
|
|
|
|
+ </h1>
|
|
|
|
|
+ <p class="ready-subtitle">话题讨论 · 与 AI 伙伴练习英语对话</p>
|
|
|
</div>
|
|
</div>
|
|
|
- <template #action>
|
|
|
|
|
- <div class="action-center">
|
|
|
|
|
- <button class="start-btn" @click="startDialogue">
|
|
|
|
|
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
|
|
|
- <polygon points="5 3 19 12 5 21 5 3" />
|
|
|
|
|
- </svg>
|
|
|
|
|
- 开始对话
|
|
|
|
|
- </button>
|
|
|
|
|
- </div>
|
|
|
|
|
- </template>
|
|
|
|
|
- </StudentPreview>
|
|
|
|
|
|
|
|
|
|
- <!-- Chatting 阶段 -->
|
|
|
|
|
- <StudentPreview
|
|
|
|
|
|
|
+ <div class="ready-body">
|
|
|
|
|
+ <span class="topic-emoji">🐼</span>
|
|
|
|
|
+ <h3 class="topic-name">{{ topic }}</h3>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="ready-footer">
|
|
|
|
|
+ <button class="start-btn" @click="startDialogue">
|
|
|
|
|
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
|
|
|
+ stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
|
|
|
+ <path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
|
|
|
|
|
+ <path d="M19 10v2a7 7 0 0 1-14 0v-2" />
|
|
|
|
|
+ <line x1="12" y1="19" x2="12" y2="23" />
|
|
|
|
|
+ <line x1="8" y1="23" x2="16" y2="23" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ 开始对话
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- Chatting 阶段:直接渲染 DialogueChatView -->
|
|
|
|
|
+ <DialogueChatView
|
|
|
v-else-if="dialogueState === 'chatting'"
|
|
v-else-if="dialogueState === 'chatting'"
|
|
|
- :title="`与 ${mockRole.name} 对话中`"
|
|
|
|
|
- titleIcon="💬"
|
|
|
|
|
- :progress="`Round ${currentRound}/${totalRounds}`"
|
|
|
|
|
- :fullscreen="true"
|
|
|
|
|
- >
|
|
|
|
|
- <DialogueChatView
|
|
|
|
|
- :topic="'我最喜欢的动物'"
|
|
|
|
|
- :ai-name="mockRole.name"
|
|
|
|
|
- :ai-avatar="mockRole.avatar"
|
|
|
|
|
- :total-rounds="totalRounds"
|
|
|
|
|
- :mode="mode"
|
|
|
|
|
- @complete="handleDialogueComplete"
|
|
|
|
|
- />
|
|
|
|
|
- </StudentPreview>
|
|
|
|
|
|
|
+ :topic="topic"
|
|
|
|
|
+ :keywords="keywords"
|
|
|
|
|
+ :ai-name="mockRole.name"
|
|
|
|
|
+ :ai-avatar="mockRole.avatar"
|
|
|
|
|
+ :total-rounds="totalRounds"
|
|
|
|
|
+ :mode="mode"
|
|
|
|
|
+ @complete="handleDialogueComplete"
|
|
|
|
|
+ />
|
|
|
|
|
|
|
|
- <!-- Completed 阶段 -->
|
|
|
|
|
- <StudentPreview
|
|
|
|
|
- v-else
|
|
|
|
|
- title="学习报告"
|
|
|
|
|
- titleIcon="📊"
|
|
|
|
|
- :fullscreen="true"
|
|
|
|
|
- >
|
|
|
|
|
- <div class="report-container">
|
|
|
|
|
|
|
+ <!-- Completed 阶段:直接渲染报告 -->
|
|
|
|
|
+ <div v-else class="report-stage">
|
|
|
|
|
+ <div class="report-scroll">
|
|
|
<OverallReport
|
|
<OverallReport
|
|
|
:evaluation="mockEvaluation"
|
|
:evaluation="mockEvaluation"
|
|
|
:role="mockRole"
|
|
:role="mockRole"
|
|
@@ -65,44 +52,37 @@
|
|
|
@complete="resetPreview"
|
|
@complete="resetPreview"
|
|
|
/>
|
|
/>
|
|
|
<div class="report-divider" />
|
|
<div class="report-divider" />
|
|
|
- <DetailedReport
|
|
|
|
|
- :sentenceEvaluations="mockEvaluation.sentenceEvaluations"
|
|
|
|
|
- />
|
|
|
|
|
|
|
+ <DetailedReport :sentenceEvaluations="mockEvaluation.sentenceEvaluations" />
|
|
|
</div>
|
|
</div>
|
|
|
- </StudentPreview>
|
|
|
|
|
|
|
+ </div>
|
|
|
|
|
|
|
|
- <!-- 重置按钮 -->
|
|
|
|
|
- <button v-if="dialogueState !== 'ready'" class="reset-btn" @click="resetPreview">
|
|
|
|
|
- <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
|
|
|
- <polyline points="23 4 23 10 17 10" /><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
|
|
|
|
|
- </svg>
|
|
|
|
|
- 重置预览
|
|
|
|
|
- </button>
|
|
|
|
|
</div>
|
|
</div>
|
|
|
</template>
|
|
</template>
|
|
|
|
|
|
|
|
<script lang="ts" setup>
|
|
<script lang="ts" setup>
|
|
|
-import { ref } from 'vue'
|
|
|
|
|
|
|
+import { ref, watch, onMounted, onUnmounted } from 'vue'
|
|
|
import type { OverallEvaluation, PreviewAIRole, PreviewDialogueState } from '@/types/englishSpeaking'
|
|
import type { OverallEvaluation, PreviewAIRole, PreviewDialogueState } from '@/types/englishSpeaking'
|
|
|
|
|
+import { useSpeakingStore } from '@/store/speaking'
|
|
|
|
|
|
|
|
-import StudentPreview from './StudentPreview.vue'
|
|
|
|
|
import DialogueChatView from './DialogueChatView.vue'
|
|
import DialogueChatView from './DialogueChatView.vue'
|
|
|
import OverallReport from './OverallReport.vue'
|
|
import OverallReport from './OverallReport.vue'
|
|
|
import DetailedReport from './DetailedReport.vue'
|
|
import DetailedReport from './DetailedReport.vue'
|
|
|
|
|
|
|
|
interface Props {
|
|
interface Props {
|
|
|
mode?: 'preview' | 'real'
|
|
mode?: 'preview' | 'real'
|
|
|
|
|
+ topic?: string
|
|
|
|
|
+ keywords?: string[]
|
|
|
|
|
+ totalRounds?: number
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
|
mode: 'real',
|
|
mode: 'real',
|
|
|
|
|
+ topic: '我最喜欢的动物',
|
|
|
|
|
+ keywords: () => ['favorite', 'adorable', 'bamboo', 'habitat', 'wildlife', 'endangered'],
|
|
|
|
|
+ totalRounds: 3,
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
const dialogueState = ref<PreviewDialogueState>('ready')
|
|
const dialogueState = ref<PreviewDialogueState>('ready')
|
|
|
-const currentRound = ref(1)
|
|
|
|
|
-const totalRounds = 3
|
|
|
|
|
-
|
|
|
|
|
-const previewVocab = ['favorite', 'adorable', 'bamboo', 'habitat', 'wildlife', 'endangered']
|
|
|
|
|
|
|
|
|
|
const mockRole: PreviewAIRole = {
|
|
const mockRole: PreviewAIRole = {
|
|
|
id: 'tom',
|
|
id: 'tom',
|
|
@@ -213,8 +193,22 @@ function handleDialogueComplete() {
|
|
|
|
|
|
|
|
function resetPreview() {
|
|
function resetPreview() {
|
|
|
dialogueState.value = 'ready'
|
|
dialogueState.value = 'ready'
|
|
|
- currentRound.value = 1
|
|
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+// ── Sync with speakingStore (让 CanvasTool 可以驱动"重置预览") ──
|
|
|
|
|
+const speakingStore = useSpeakingStore()
|
|
|
|
|
+
|
|
|
|
|
+watch(dialogueState, (s) => { speakingStore.setPreviewState(s) }, { immediate: true })
|
|
|
|
|
+
|
|
|
|
|
+watch(
|
|
|
|
|
+ () => speakingStore.resetSignal,
|
|
|
|
|
+ (val, old) => {
|
|
|
|
|
+ if (val !== old) resetPreview()
|
|
|
|
|
+ },
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+onMounted(() => { speakingStore.setPreviewState(dialogueState.value) })
|
|
|
|
|
+onUnmounted(() => { speakingStore.setPreviewState('ready') })
|
|
|
</script>
|
|
</script>
|
|
|
|
|
|
|
|
<style lang="scss" scoped>
|
|
<style lang="scss" scoped>
|
|
@@ -223,70 +217,92 @@ function resetPreview() {
|
|
|
height: 100%;
|
|
height: 100%;
|
|
|
display: flex;
|
|
display: flex;
|
|
|
flex-direction: column;
|
|
flex-direction: column;
|
|
|
- background: #f9fafb;
|
|
|
|
|
|
|
+ background: #fff;
|
|
|
position: relative;
|
|
position: relative;
|
|
|
overflow: hidden;
|
|
overflow: hidden;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// Ready 阶段
|
|
|
|
|
-.ready-content {
|
|
|
|
|
|
|
+// ─── Ready 阶段 ───
|
|
|
|
|
+.ready-stage {
|
|
|
|
|
+ flex: 1;
|
|
|
display: flex;
|
|
display: flex;
|
|
|
flex-direction: column;
|
|
flex-direction: column;
|
|
|
- align-items: center;
|
|
|
|
|
|
|
+ background: #fff;
|
|
|
|
|
+}
|
|
|
|
|
+.ready-header {
|
|
|
|
|
+ padding: 20px 24px 12px;
|
|
|
text-align: center;
|
|
text-align: center;
|
|
|
|
|
+ border-bottom: 1px solid #f9fafb;
|
|
|
|
|
+}
|
|
|
|
|
+.ready-title {
|
|
|
|
|
+ font-size: 20px;
|
|
|
|
|
+ font-weight: 600;
|
|
|
|
|
+ color: #111827;
|
|
|
|
|
+ margin: 0;
|
|
|
|
|
+ display: inline-flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ justify-content: center;
|
|
|
gap: 8px;
|
|
gap: 8px;
|
|
|
}
|
|
}
|
|
|
-.topic-icon-large { font-size: 48px; }
|
|
|
|
|
-.topic-name { font-size: 18px; font-weight: 600; color: #111827; margin: 0; }
|
|
|
|
|
-.topic-desc { font-size: 13px; color: #6b7280; margin: 0; }
|
|
|
|
|
-.topic-meta {
|
|
|
|
|
|
|
+.ready-title-icon { font-size: 22px; }
|
|
|
|
|
+.ready-subtitle {
|
|
|
|
|
+ font-size: 13px;
|
|
|
|
|
+ color: #9ca3af;
|
|
|
|
|
+ margin: 4px 0 0;
|
|
|
|
|
+}
|
|
|
|
|
+.ready-body {
|
|
|
|
|
+ flex: 1;
|
|
|
display: flex;
|
|
display: flex;
|
|
|
|
|
+ flex-direction: column;
|
|
|
align-items: center;
|
|
align-items: center;
|
|
|
|
|
+ justify-content: center;
|
|
|
gap: 16px;
|
|
gap: 16px;
|
|
|
- margin-top: 4px;
|
|
|
|
|
|
|
+ padding: 24px;
|
|
|
}
|
|
}
|
|
|
-.meta-item { font-size: 12px; color: #9ca3af; }
|
|
|
|
|
-
|
|
|
|
|
-.vocab-preview {
|
|
|
|
|
|
|
+.topic-emoji { font-size: 56px; line-height: 1; }
|
|
|
|
|
+.topic-name {
|
|
|
|
|
+ font-size: 18px;
|
|
|
|
|
+ font-weight: 600;
|
|
|
|
|
+ color: #1f2937;
|
|
|
|
|
+ margin: 0;
|
|
|
|
|
+}
|
|
|
|
|
+.ready-footer {
|
|
|
|
|
+ padding: 16px 24px 20px;
|
|
|
|
|
+ border-top: 1px solid #f9fafb;
|
|
|
|
|
+ background: #fafbfc;
|
|
|
display: flex;
|
|
display: flex;
|
|
|
- flex-wrap: wrap;
|
|
|
|
|
justify-content: center;
|
|
justify-content: center;
|
|
|
- gap: 6px;
|
|
|
|
|
- margin-top: 8px;
|
|
|
|
|
-}
|
|
|
|
|
-.vocab-chip {
|
|
|
|
|
- padding: 4px 10px;
|
|
|
|
|
- background: #fff7ed;
|
|
|
|
|
- color: #ea580c;
|
|
|
|
|
- font-size: 11px;
|
|
|
|
|
- border-radius: 999px;
|
|
|
|
|
- border: 1px solid #fed7aa;
|
|
|
|
|
}
|
|
}
|
|
|
-
|
|
|
|
|
-// 开始按钮
|
|
|
|
|
-.action-center { display: flex; justify-content: center; }
|
|
|
|
|
.start-btn {
|
|
.start-btn {
|
|
|
- display: flex;
|
|
|
|
|
|
|
+ display: inline-flex;
|
|
|
align-items: center;
|
|
align-items: center;
|
|
|
gap: 8px;
|
|
gap: 8px;
|
|
|
- padding: 10px 28px;
|
|
|
|
|
|
|
+ padding: 9px 28px;
|
|
|
|
|
+ border-radius: 999px;
|
|
|
background: #f97316;
|
|
background: #f97316;
|
|
|
color: #fff;
|
|
color: #fff;
|
|
|
border: none;
|
|
border: none;
|
|
|
- border-radius: 999px;
|
|
|
|
|
font-size: 14px;
|
|
font-size: 14px;
|
|
|
font-weight: 500;
|
|
font-weight: 500;
|
|
|
cursor: pointer;
|
|
cursor: pointer;
|
|
|
- transition: background 0.2s;
|
|
|
|
|
- box-shadow: 0 4px 12px rgba(249, 115, 22, 0.3);
|
|
|
|
|
|
|
+ box-shadow: 0 4px 12px rgba(249, 115, 22, 0.25);
|
|
|
|
|
+ transition: background 0.2s, transform 0.15s;
|
|
|
&:hover { background: #ea580c; }
|
|
&:hover { background: #ea580c; }
|
|
|
|
|
+ &:active { transform: scale(0.97); }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// 报告容器
|
|
|
|
|
-.report-container {
|
|
|
|
|
- width: 100%;
|
|
|
|
|
- padding: 16px;
|
|
|
|
|
|
|
+// ─── Completed 阶段 ───
|
|
|
|
|
+.report-stage {
|
|
|
|
|
+ flex: 1;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-direction: column;
|
|
|
|
|
+ overflow: hidden;
|
|
|
|
|
+ background: #fff;
|
|
|
|
|
+}
|
|
|
|
|
+.report-scroll {
|
|
|
|
|
+ flex: 1;
|
|
|
overflow-y: auto;
|
|
overflow-y: auto;
|
|
|
|
|
+ padding: 16px;
|
|
|
display: flex;
|
|
display: flex;
|
|
|
flex-direction: column;
|
|
flex-direction: column;
|
|
|
gap: 24px;
|
|
gap: 24px;
|
|
@@ -294,26 +310,7 @@ function resetPreview() {
|
|
|
.report-divider {
|
|
.report-divider {
|
|
|
height: 1px;
|
|
height: 1px;
|
|
|
background: #e5e7eb;
|
|
background: #e5e7eb;
|
|
|
- margin: 0 16px;
|
|
|
|
|
|
|
+ margin: 0;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// 重置按钮
|
|
|
|
|
-.reset-btn {
|
|
|
|
|
- position: absolute;
|
|
|
|
|
- top: 8px;
|
|
|
|
|
- right: 8px;
|
|
|
|
|
- display: flex;
|
|
|
|
|
- align-items: center;
|
|
|
|
|
- gap: 4px;
|
|
|
|
|
- padding: 4px 10px;
|
|
|
|
|
- border-radius: 999px;
|
|
|
|
|
- background: rgba(255,255,255,0.9);
|
|
|
|
|
- backdrop-filter: blur(4px);
|
|
|
|
|
- border: 1px solid #e5e7eb;
|
|
|
|
|
- color: #6b7280;
|
|
|
|
|
- font-size: 10px;
|
|
|
|
|
- cursor: pointer;
|
|
|
|
|
- z-index: 10;
|
|
|
|
|
- &:hover { background: #f3f4f6; color: #374151; }
|
|
|
|
|
-}
|
|
|
|
|
</style>
|
|
</style>
|