TopicDiscussionPreview.vue 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411
  1. <template>
  2. <div class="topic-discussion-preview">
  3. <!-- Ready 阶段:极简首页(参照 enspeak 布局) -->
  4. <div v-if="dialogueState === 'ready'" class="ready-stage">
  5. <div class="ready-header">
  6. <h1 class="ready-title">
  7. <span class="ready-title-icon">💬</span>
  8. Topic Discussion
  9. </h1>
  10. <p class="ready-subtitle">话题讨论 · 与 AI 伙伴练习英语对话</p>
  11. </div>
  12. <div class="ready-body">
  13. <span class="topic-emoji">🐼</span>
  14. <h3 class="topic-name">{{ speakingStore.config.topic || topic }}</h3>
  15. </div>
  16. <div class="ready-footer">
  17. <button class="start-btn" :disabled="sessionCreating" @click="startDialogue">
  18. <svg v-if="!sessionCreating" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
  19. stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  20. <path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
  21. <path d="M19 10v2a7 7 0 0 1-14 0v-2" />
  22. <line x1="12" y1="19" x2="12" y2="23" />
  23. <line x1="8" y1="23" x2="16" y2="23" />
  24. </svg>
  25. <span v-else class="start-btn-spinner" />
  26. {{ sessionCreating ? '创建中…' : '开始对话' }}
  27. </button>
  28. <p v-if="sessionError" class="session-error-text">{{ sessionError }}</p>
  29. </div>
  30. </div>
  31. <!-- Chatting 阶段:直接渲染 DialogueChatView -->
  32. <DialogueChatView
  33. v-else-if="dialogueState === 'chatting'"
  34. :topic="speakingStore.config.topic || topic"
  35. :keywords="speakingStore.config.learningGoals.vocabulary.length ? speakingStore.config.learningGoals.vocabulary : keywords"
  36. :ai-name="mockRole.name"
  37. :ai-avatar="mockRole.avatar"
  38. :total-rounds="speakingStore.config.practice.rounds || totalRounds"
  39. :mode="mode"
  40. :session-info="preparedSession"
  41. @complete="handleDialogueComplete"
  42. @restart="resetPreview"
  43. />
  44. <!-- Completed 阶段:直接渲染报告 -->
  45. <div v-else class="report-stage">
  46. <div class="report-scroll">
  47. <OverallReport
  48. :evaluation="displayEvaluation"
  49. :role="mockRole"
  50. :scoreDisplayMode="'numeric'"
  51. @restart="resetPreview"
  52. @complete="resetPreview"
  53. />
  54. <div class="report-divider" />
  55. <DetailedReport :sentenceEvaluations="displayEvaluation.sentenceEvaluations" />
  56. </div>
  57. </div>
  58. </div>
  59. </template>
  60. <script lang="ts" setup>
  61. import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
  62. import type { DialogueReport, OverallEvaluation, PreviewAIRole, PreviewDialogueState, SessionStartInfo } from '@/types/englishSpeaking'
  63. import { useSpeakingStore } from '@/store/speaking'
  64. import { getSpeakingConfig } from '@/services/speaking'
  65. import { createDialogueApi, DialogueApiError } from '../services/llmService'
  66. import DialogueChatView from './DialogueChatView.vue'
  67. import OverallReport from './OverallReport.vue'
  68. import DetailedReport from './DetailedReport.vue'
  69. interface Props {
  70. mode?: 'preview' | 'real'
  71. topic?: string
  72. keywords?: string[]
  73. totalRounds?: number
  74. /** 配置 id(由 FrameElement 从 elementInfo.url 传入) */
  75. configId?: string
  76. }
  77. const props = withDefaults(defineProps<Props>(), {
  78. mode: 'real',
  79. topic: '我最喜欢的动物',
  80. keywords: () => ['favorite', 'adorable', 'bamboo', 'habitat', 'wildlife', 'endangered'],
  81. totalRounds: 3,
  82. configId: '',
  83. })
  84. const dialogueState = ref<PreviewDialogueState>('ready')
  85. const sessionCreating = ref(false)
  86. const sessionError = ref<string | null>(null)
  87. const preparedSession = ref<SessionStartInfo | null>(null)
  88. const mockRole: PreviewAIRole = {
  89. id: 'tom',
  90. name: 'Tom',
  91. avatar: '😊',
  92. identity: 'Friendly Teacher',
  93. personality: 'Patient and encouraging',
  94. speakingStyle: 'casual',
  95. speed: 'normal',
  96. isCustom: false,
  97. isRecommended: true,
  98. }
  99. const mockEvaluation: OverallEvaluation = {
  100. overallScore: 85,
  101. scoreLevel: 'good',
  102. percentile: 78,
  103. dimensions: {
  104. fluency: 82,
  105. interaction: 88,
  106. vocabulary: 76,
  107. grammar: 90,
  108. },
  109. aiComment: 'Great job! You showed good understanding of the topic. Your pronunciation was clear and your responses were relevant. Keep practicing to improve your fluency and try using more advanced vocabulary!',
  110. highlights: [
  111. 'Clear pronunciation of key words',
  112. 'Good use of complete sentences',
  113. 'Natural conversation flow',
  114. ],
  115. improvements: [
  116. 'Try using more descriptive adjectives',
  117. 'Practice linking words for smoother speech',
  118. 'Expand vocabulary range for the topic',
  119. ],
  120. nextChallenge: {
  121. difficulty: 'Medium',
  122. unlockedTopic: 'My Dream Job',
  123. suggestedMode: 'Free Talk',
  124. },
  125. statistics: {
  126. totalRounds: 3,
  127. averageScore: 83,
  128. highestScore: 92,
  129. highestRound: 2,
  130. grammarErrors: 2,
  131. excellentExpressions: 4,
  132. totalDuration: 180,
  133. },
  134. sentenceEvaluations: [
  135. {
  136. id: 'se-1', round: 1, role: 'ai',
  137. content: "Hi! What's your favorite animal?",
  138. audioDuration: 3,
  139. },
  140. {
  141. id: 'se-2', round: 1, role: 'student',
  142. content: 'I like pandas. They are very cute!',
  143. audioDuration: 4, score: 88,
  144. pronunciation: { accuracy: 90, intonation: 85, stress: 82, fluency: 88 },
  145. feedback: {
  146. highlights: ['Good pronunciation of "pandas"', 'Natural intonation'],
  147. corrections: [],
  148. suggestions: ['Try: "I really love pandas because they\'re incredibly cute!"'],
  149. },
  150. },
  151. {
  152. id: 'se-3', round: 2, role: 'ai',
  153. content: 'Pandas are adorable! Have you seen them at the zoo?',
  154. audioDuration: 4,
  155. },
  156. {
  157. id: 'se-4', round: 2, role: 'student',
  158. content: 'Yes, I went to the zoo last month.',
  159. audioDuration: 3, score: 92,
  160. pronunciation: { accuracy: 95, intonation: 90, stress: 88, fluency: 92 },
  161. feedback: {
  162. highlights: ['Excellent fluency', 'Clear past tense usage'],
  163. corrections: [],
  164. suggestions: ['Add more details: "I visited the Beijing Zoo last month and saw giant pandas there."'],
  165. },
  166. },
  167. {
  168. id: 'se-5', round: 3, role: 'ai',
  169. content: "That's great! What do pandas like to eat?",
  170. audioDuration: 3,
  171. },
  172. {
  173. id: 'se-6', round: 3, role: 'student',
  174. content: 'They like to eat bamboo.',
  175. audioDuration: 3, score: 78,
  176. pronunciation: { accuracy: 80, intonation: 75, stress: 76, fluency: 82 },
  177. feedback: {
  178. highlights: ['Correct answer'],
  179. corrections: [{ original: 'like to eat', corrected: 'mainly feed on', explanation: '"feed on" is more natural for describing animal diets' }],
  180. suggestions: ['Expand: "Pandas mainly feed on bamboo, but they also eat fruits and vegetables."'],
  181. },
  182. },
  183. ],
  184. }
  185. const realEvaluation = ref<OverallEvaluation | null>(null)
  186. const displayEvaluation = computed<OverallEvaluation>(() => {
  187. const real = realEvaluation.value
  188. if (real && real.sentenceEvaluations.length > 0) return real
  189. return mockEvaluation
  190. })
  191. async function startDialogue() {
  192. if (sessionCreating.value) return
  193. sessionCreating.value = true
  194. sessionError.value = null
  195. try {
  196. const api = createDialogueApi(props.mode)
  197. const info = await api.createSession({
  198. topic: speakingStore.config.topic || props.topic,
  199. totalRounds: speakingStore.config.practice.rounds || props.totalRounds,
  200. roleId: 'tom',
  201. vocabulary: speakingStore.config.learningGoals.vocabulary,
  202. })
  203. preparedSession.value = {
  204. sessionId: info.sessionId,
  205. expiresAt: info.expiresAt,
  206. }
  207. dialogueState.value = 'chatting'
  208. } catch (err: unknown) {
  209. if (err instanceof DialogueApiError) {
  210. sessionError.value = `创建会话失败(${err.status}),请重试`
  211. } else {
  212. sessionError.value = '创建会话失败,请重试'
  213. }
  214. } finally {
  215. sessionCreating.value = false
  216. }
  217. }
  218. function handleDialogueComplete(report: DialogueReport | null) {
  219. realEvaluation.value = report?.evaluation ?? null
  220. dialogueState.value = 'completed'
  221. }
  222. function resetPreview() {
  223. dialogueState.value = 'ready'
  224. realEvaluation.value = null
  225. preparedSession.value = null
  226. sessionError.value = null
  227. sessionCreating.value = false
  228. }
  229. // ── Sync with speakingStore (让 CanvasTool 可以驱动"重置预览") ──
  230. const speakingStore = useSpeakingStore()
  231. watch(dialogueState, (s) => { speakingStore.setPreviewState(s) }, { immediate: true })
  232. watch(
  233. () => speakingStore.resetSignal,
  234. (val, old) => {
  235. if (val !== old) resetPreview()
  236. },
  237. )
  238. // ── 根据 configId 从后端拉回配置注入 store ──
  239. async function loadConfigFromBackend(id: string) {
  240. if (!id) return
  241. try {
  242. const { config } = await getSpeakingConfig(id)
  243. speakingStore.$patch({ config })
  244. } catch (err) {
  245. console.error('[speaking] load config failed:', err)
  246. }
  247. }
  248. watch(() => props.configId, (id) => { loadConfigFromBackend(id) })
  249. onMounted(() => {
  250. speakingStore.setPreviewState(dialogueState.value)
  251. loadConfigFromBackend(props.configId)
  252. })
  253. onUnmounted(() => { speakingStore.setPreviewState('ready') })
  254. </script>
  255. <style lang="scss" scoped>
  256. .topic-discussion-preview {
  257. width: 100%;
  258. height: 100%;
  259. display: flex;
  260. flex-direction: column;
  261. background: #fff;
  262. position: relative;
  263. overflow: hidden;
  264. // flex 嵌套 overflow 生效的前提:flex 子默认 min-height: auto 会被内容撑大,
  265. // 让 ready-stage / report-stage / DialogueChatView 根节点能 shrink 到父高度
  266. > * {
  267. min-height: 0;
  268. }
  269. }
  270. // ─── Ready 阶段 ───
  271. .ready-stage {
  272. flex: 1;
  273. display: flex;
  274. flex-direction: column;
  275. background: #fff;
  276. }
  277. .ready-header {
  278. padding: 20px 24px 12px;
  279. text-align: center;
  280. border-bottom: 1px solid #f9fafb;
  281. }
  282. .ready-title {
  283. font-size: 20px;
  284. font-weight: 600;
  285. color: #111827;
  286. margin: 0;
  287. display: inline-flex;
  288. align-items: center;
  289. justify-content: center;
  290. gap: 8px;
  291. }
  292. .ready-title-icon { font-size: 22px; }
  293. .ready-subtitle {
  294. font-size: 13px;
  295. color: #9ca3af;
  296. margin: 4px 0 0;
  297. }
  298. .ready-body {
  299. flex: 1;
  300. display: flex;
  301. flex-direction: column;
  302. align-items: center;
  303. justify-content: center;
  304. gap: 16px;
  305. padding: 24px;
  306. }
  307. .topic-emoji { font-size: 56px; line-height: 1; }
  308. .topic-name {
  309. font-size: 18px;
  310. font-weight: 600;
  311. color: #1f2937;
  312. margin: 0;
  313. }
  314. .ready-footer {
  315. padding: 16px 24px 20px;
  316. border-top: 1px solid #f9fafb;
  317. background: #fafbfc;
  318. display: flex;
  319. flex-direction: column;
  320. align-items: center;
  321. }
  322. .start-btn {
  323. display: inline-flex;
  324. align-items: center;
  325. gap: 8px;
  326. padding: 9px 28px;
  327. border-radius: 999px;
  328. background: #f97316;
  329. color: #fff;
  330. border: none;
  331. font-size: 14px;
  332. font-weight: 500;
  333. cursor: pointer;
  334. box-shadow: 0 4px 12px rgba(249, 115, 22, 0.25);
  335. transition: background 0.2s, transform 0.15s;
  336. &:hover { background: #ea580c; }
  337. &:active { transform: scale(0.97); }
  338. }
  339. .start-btn:disabled {
  340. opacity: 0.6;
  341. cursor: wait;
  342. background: #fb923c;
  343. }
  344. .start-btn-spinner {
  345. width: 14px;
  346. height: 14px;
  347. border: 2px solid rgba(255, 255, 255, 0.4);
  348. border-top-color: #fff;
  349. border-radius: 50%;
  350. animation: start-btn-spin 0.8s linear infinite;
  351. display: inline-block;
  352. }
  353. @keyframes start-btn-spin {
  354. to { transform: rotate(360deg); }
  355. }
  356. .session-error-text {
  357. margin: 8px 0 0;
  358. font-size: 12px;
  359. color: #dc2626;
  360. text-align: center;
  361. }
  362. // ─── Completed 阶段 ───
  363. .report-stage {
  364. flex: 1;
  365. display: flex;
  366. flex-direction: column;
  367. overflow: hidden;
  368. background: #fff;
  369. }
  370. .report-scroll {
  371. flex: 1;
  372. overflow-y: auto;
  373. padding: 16px;
  374. display: flex;
  375. flex-direction: column;
  376. gap: 24px;
  377. }
  378. .report-divider {
  379. height: 1px;
  380. background: #e5e7eb;
  381. margin: 0;
  382. }
  383. </style>