TopicDiscussionPreview.vue 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709
  1. <template>
  2. <div class="topic-discussion-fit-wrapper" ref="fitWrapperRef">
  3. <div class="topic-discussion-preview" :style="fitStyle">
  4. <!-- Ready 阶段:极简首页(参照 enspeak 布局) -->
  5. <div v-if="dialogueState === 'checking-history'" class="ready-stage">
  6. <div class="ready-header">
  7. <h1 class="ready-title">
  8. <!-- <span class="ready-title-icon">💬</span> -->
  9. Topic Discussion
  10. </h1>
  11. <p class="ready-subtitle">正在读取你的练习记录...</p>
  12. </div>
  13. <div class="ready-body">
  14. <span class="start-btn-spinner" />
  15. </div>
  16. </div>
  17. <div v-else-if="dialogueState === 'ready'" class="ready-stage">
  18. <div class="ready-header">
  19. <h1 class="ready-title">
  20. <!-- <span class="ready-title-icon">💬</span> -->
  21. Topic Discussion
  22. </h1>
  23. <p class="ready-subtitle">话题讨论 · 与 AI 伙伴练习英语对话</p>
  24. </div>
  25. <div class="ready-body">
  26. <!-- <span class="topic-emoji">🐼</span> -->
  27. <h3 class="topic-name">{{ speakingStore.config.topic || topic }}</h3>
  28. <VideoSceneGrid
  29. v-if="isVideoSceneMode"
  30. :videos="videos"
  31. :selected-id="selectedVideo?.id ?? null"
  32. @select="selectedVideo = $event"
  33. @play="playingVideo = $event"
  34. />
  35. </div>
  36. <div class="ready-footer">
  37. <button class="start-btn" :disabled="sessionCreating || startBlocked" @click="startDialogue">
  38. <svg v-if="!sessionCreating" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
  39. stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  40. <path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
  41. <path d="M19 10v2a7 7 0 0 1-14 0v-2" />
  42. <line x1="12" y1="19" x2="12" y2="23" />
  43. <line x1="8" y1="23" x2="16" y2="23" />
  44. </svg>
  45. <span v-else class="start-btn-spinner" />
  46. {{ sessionCreating ? '创建中…' : '开始对话' }}
  47. </button>
  48. <p v-if="startBlockReason" class="session-error-text">{{ startBlockReason }}</p>
  49. <p v-else-if="sessionError" class="session-error-text">{{ sessionError }}</p>
  50. </div>
  51. </div>
  52. <!-- Chatting 阶段:直接渲染 DialogueChatView -->
  53. <DialogueChatView
  54. v-else-if="dialogueState === 'chatting'"
  55. :topic="speakingStore.config.topic || topic"
  56. :keywords="speakingStore.config.learningGoals.vocabulary.length ? speakingStore.config.learningGoals.vocabulary : keywords"
  57. :ai-name="mockRole.name"
  58. :ai-avatar="mockRole.avatar"
  59. :total-rounds="speakingStore.config.practice.rounds || totalRounds"
  60. :session-info="preparedSession"
  61. @complete="handleDialogueComplete"
  62. @restart="resetPreview"
  63. />
  64. <!-- Completed 阶段:直接渲染报告 -->
  65. <div v-else class="report-stage">
  66. <div class="report-scroll">
  67. <div v-if="reportError" class="report-error">
  68. {{ reportError }}
  69. </div>
  70. <OverallReport
  71. :evaluation="overallEvaluationForDisplay"
  72. :role="mockRole"
  73. :scoreDisplayMode="'numeric'"
  74. @restart="resetPreview"
  75. @complete="resetPreview"
  76. />
  77. <div class="report-divider" />
  78. <DetailedReport :sentenceEvaluations="displaySentenceEvaluations" />
  79. </div>
  80. </div>
  81. </div>
  82. <VideoPlayerModal
  83. v-if="playingVideo"
  84. :title="playingVideo.title"
  85. :video-url="playingVideo.videoUrl"
  86. @close="playingVideo = null"
  87. />
  88. </div>
  89. </template>
  90. <script lang="ts" setup>
  91. import { ref, computed, watch, onMounted, onUnmounted, inject } from 'vue'
  92. import type { DialogueReport, OverallEvaluation, PreviewAIRole, PreviewDialogueState, SessionStartInfo, VideoMeta } from '@/types/englishSpeaking'
  93. import { useSpeakingStore } from '@/store/speaking'
  94. import { getSpeakingConfig } from '@/services/speaking'
  95. import { createDialogueApi, DialogueApiError } from '../services/llmService'
  96. import DialogueChatView from './DialogueChatView.vue'
  97. import OverallReport from './OverallReport.vue'
  98. import DetailedReport from './DetailedReport.vue'
  99. import VideoSceneGrid from './VideoSceneGrid.vue'
  100. import VideoPlayerModal from './VideoPlayerModal.vue'
  101. import topicDiscussionVideos from '../data/topicDiscussionVideos.json'
  102. interface Props {
  103. topic?: string
  104. keywords?: string[]
  105. totalRounds?: number
  106. /** 配置 id(由 FrameElement 从 elementInfo.url 传入) */
  107. configId?: string
  108. }
  109. const props = withDefaults(defineProps<Props>(), {
  110. topic: '',
  111. keywords: () => ['favorite', 'adorable', 'bamboo', 'habitat', 'wildlife', 'endangered'],
  112. totalRounds: 3,
  113. configId: '',
  114. })
  115. type SpeakingNotify = (
  116. status: 'active' | 'completed',
  117. payload: { configId: string; sessionId: string },
  118. ) => void
  119. const notifySpeakingProgress = inject<SpeakingNotify>('notifySpeakingProgress', () => {})
  120. const recordSpeakingStart = inject<(sessionId: string) => void>('recordSpeakingStart', () => {})
  121. const speakingStore = useSpeakingStore()
  122. // 让组件按 DESIGN_WIDTH 设计宽度等比放大到任意槽位尺寸:
  123. // 槽位(type 77 的 frame 默认为整张 slide ~1000×562)若直接装下按手机尺寸设计的 chat
  124. // 会显得字小、留白大、布局像桌面端。这里用 ResizeObserver + transform: scale 让内部
  125. // 始终按 DESIGN_WIDTH px 布局,再视觉缩放到槽位实际宽度。
  126. // 调小 → 内部元素更大;调大 → 内部元素更小(接近槽位实际像素)。
  127. const DESIGN_WIDTH = 1000
  128. const fitWrapperRef = ref<HTMLDivElement>()
  129. const fitScale = ref(1)
  130. const fitInnerHeight = ref(0)
  131. const fitStyle = computed(() => {
  132. if (fitInnerHeight.value === 0) {
  133. return { width: '100%', height: '100%' }
  134. }
  135. return {
  136. width: DESIGN_WIDTH + 'px',
  137. height: fitInnerHeight.value + 'px',
  138. transform: `scale(${fitScale.value})`,
  139. transformOrigin: 'top left',
  140. }
  141. })
  142. let fitResizeObserver: ResizeObserver | null = null
  143. function updateFit() {
  144. const el = fitWrapperRef.value
  145. if (!el) return
  146. const w = el.clientWidth
  147. const h = el.clientHeight
  148. if (w === 0 || h === 0) return
  149. const scale = w / DESIGN_WIDTH
  150. fitScale.value = scale
  151. fitInnerHeight.value = h / scale
  152. }
  153. const dialogueState = ref<PreviewDialogueState>('ready')
  154. const sessionCreating = ref(false)
  155. const sessionError = ref<string | null>(null)
  156. const preparedSession = ref<SessionStartInfo | null>(null)
  157. const historyChecked = ref(false)
  158. const historyLoadToken = ref(0)
  159. const videos = topicDiscussionVideos as VideoMeta[]
  160. const selectedVideo = ref<VideoMeta | null>(null)
  161. const playingVideo = ref<VideoMeta | null>(null)
  162. const isVideoSceneMode = computed(() => speakingStore.config.videoScene?.enabled === true)
  163. const startBlockReason = computed<string | null>(() => {
  164. if (!(speakingStore.config.topic ?? '').trim()) return '至少设置topic'
  165. if (isVideoSceneMode.value && !selectedVideo.value) return '请先选择一个影片'
  166. return null
  167. })
  168. const startBlocked = computed(() => startBlockReason.value !== null)
  169. const runtimeParams = computed(() => {
  170. const params = new URLSearchParams(window.location.search)
  171. return {
  172. mode: params.get('mode'),
  173. userId: params.get('userid'),
  174. }
  175. })
  176. const isStudentRuntime = computed(() => runtimeParams.value.mode === 'student')
  177. const runtimeUserId = computed(() => runtimeParams.value.userId || '')
  178. const mockRole: PreviewAIRole = {
  179. id: 'tom',
  180. name: 'Amy',
  181. avatar: '😊',
  182. identity: 'Friendly Teacher',
  183. personality: 'Patient and encouraging',
  184. speakingStyle: 'casual',
  185. speed: 'normal',
  186. isCustom: false,
  187. isRecommended: true,
  188. }
  189. const mockEvaluation: OverallEvaluation = {
  190. overallScore: 85,
  191. scoreLevel: 'good',
  192. percentile: 78,
  193. dimensions: {
  194. fluency: 82,
  195. interaction: 88,
  196. vocabulary: 76,
  197. grammar: 90,
  198. },
  199. 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!',
  200. highlights: [
  201. 'Clear pronunciation of key words',
  202. 'Good use of complete sentences',
  203. 'Natural conversation flow',
  204. ],
  205. improvements: [
  206. 'Try using more descriptive adjectives',
  207. 'Practice linking words for smoother speech',
  208. 'Expand vocabulary range for the topic',
  209. ],
  210. nextChallenge: {
  211. difficulty: 'Medium',
  212. unlockedTopic: 'My Dream Job',
  213. suggestedMode: 'Free Talk',
  214. },
  215. statistics: {
  216. totalRounds: 3,
  217. averageScore: 83,
  218. highestScore: 92,
  219. highestRound: 2,
  220. grammarErrors: 2,
  221. excellentExpressions: 4,
  222. totalDuration: 180,
  223. },
  224. sentenceEvaluations: [
  225. {
  226. id: 'se-1', round: 1, role: 'ai',
  227. content: "Hi! What's your favorite animal?",
  228. audioDuration: 3,
  229. },
  230. {
  231. id: 'se-2', round: 1, role: 'student',
  232. content: 'I like pandas. They are very cute!',
  233. audioDuration: 4, score: 88,
  234. pronunciation: { accuracy: 90, intonation: 85, stress: 82, fluency: 88 },
  235. feedback: {
  236. comment: 'Good pronunciation of "pandas" with natural intonation.',
  237. betterExpression: 'I really love pandas because they are incredibly cute!',
  238. },
  239. },
  240. {
  241. id: 'se-3', round: 2, role: 'ai',
  242. content: 'Pandas are adorable! Have you seen them at the zoo?',
  243. audioDuration: 4,
  244. },
  245. {
  246. id: 'se-4', round: 2, role: 'student',
  247. content: 'Yes, I went to the zoo last month.',
  248. audioDuration: 3, score: 92,
  249. pronunciation: { accuracy: 95, intonation: 90, stress: 88, fluency: 92 },
  250. feedback: {
  251. comment: 'Excellent fluency and clear past tense usage.',
  252. betterExpression: 'I visited the Beijing Zoo last month and saw giant pandas there.',
  253. },
  254. },
  255. {
  256. id: 'se-5', round: 3, role: 'ai',
  257. content: "That's great! What do pandas like to eat?",
  258. audioDuration: 3,
  259. },
  260. {
  261. id: 'se-6', round: 3, role: 'student',
  262. content: 'They like to eat bamboo.',
  263. audioDuration: 3, score: 78,
  264. pronunciation: { accuracy: 80, intonation: 75, stress: 76, fluency: 82 },
  265. feedback: {
  266. comment: 'Correct answer. Try using a more natural phrase for animal diets.',
  267. betterExpression: 'Pandas mainly feed on bamboo, but they also eat fruits and vegetables.',
  268. },
  269. },
  270. ],
  271. }
  272. const realEvaluation = ref<OverallEvaluation | null>(null)
  273. const reportStatus = ref<DialogueReport['status'] | null>(null)
  274. const reportError = ref('')
  275. const reportFetchFailed = ref(false)
  276. const displayEvaluation = computed<OverallEvaluation | null>(() => {
  277. const real = realEvaluation.value
  278. if (real) return real
  279. if (reportFetchFailed.value) return null
  280. return mockEvaluation
  281. })
  282. const shouldShowOverallReport = computed(() => {
  283. return !!displayEvaluation.value
  284. && !reportError.value
  285. && reportStatus.value !== 'failed'
  286. && reportStatus.value !== 'incomplete'
  287. })
  288. const overallEvaluationForDisplay = computed(() => shouldShowOverallReport.value ? displayEvaluation.value : null)
  289. const displaySentenceEvaluations = computed(() => displayEvaluation.value?.sentenceEvaluations ?? [])
  290. function isHistoryTokenCurrent(token: number) {
  291. return token === historyLoadToken.value
  292. }
  293. function nextHistoryLoadToken() {
  294. historyLoadToken.value += 1
  295. return historyLoadToken.value
  296. }
  297. async function startDialogue() {
  298. if (sessionCreating.value) return
  299. if (isStudentRuntime.value) {
  300. if (!props.configId) {
  301. sessionError.value = '口语工具配置缺失,请联系老师重新发布。'
  302. return
  303. }
  304. if (!runtimeUserId.value) {
  305. sessionError.value = '学生身份缺失,请从课程入口重新进入。'
  306. return
  307. }
  308. }
  309. sessionCreating.value = true
  310. sessionError.value = null
  311. reportFetchFailed.value = false
  312. try {
  313. const api = createDialogueApi()
  314. const info = await api.createSession({
  315. topic: speakingStore.config.topic || props.topic,
  316. grade: speakingStore.config.grade,
  317. totalRounds: speakingStore.config.practice.rounds ?? props.totalRounds,
  318. durationMinutes: speakingStore.config.practice.duration,
  319. roleId: mockRole.id,
  320. vocabulary: speakingStore.config.learningGoals.vocabulary,
  321. sentences: speakingStore.config.learningGoals.sentences,
  322. configId: props.configId || null,
  323. userId: isStudentRuntime.value ? runtimeUserId.value : null,
  324. mode: isVideoSceneMode.value ? 'video_scene' : 'normal',
  325. selectedVideo: selectedVideo.value ?? undefined,
  326. })
  327. preparedSession.value = {
  328. sessionId: info.sessionId,
  329. expiresAt: info.expiresAt,
  330. currentRound: info.currentRound,
  331. }
  332. dialogueState.value = 'chatting'
  333. notifySpeakingProgress('active', {
  334. configId: props.configId || '',
  335. sessionId: preparedSession.value?.sessionId || '',
  336. })
  337. recordSpeakingStart(preparedSession.value?.sessionId || '')
  338. } catch (err: unknown) {
  339. if (err instanceof DialogueApiError) {
  340. sessionError.value = `创建会话失败(${err.status}),请重试`
  341. } else {
  342. sessionError.value = '创建会话失败,请重试'
  343. }
  344. } finally {
  345. sessionCreating.value = false
  346. }
  347. }
  348. async function waitForCompletedHistoryReport(
  349. api: ReturnType<typeof createDialogueApi>,
  350. sessionId: string,
  351. token: number,
  352. ): Promise<DialogueReport | null> {
  353. const maxAttempts = 30
  354. const intervalMs = 2000
  355. try {
  356. for (let attempt = 1; attempt <= maxAttempts; attempt++) {
  357. if (!isHistoryTokenCurrent(token)) return null
  358. const report = await api.getReport(sessionId)
  359. if (!isHistoryTokenCurrent(token)) return null
  360. if (report.status === 'ready' || report.status === 'failed' || report.status === 'incomplete') {
  361. return report
  362. }
  363. await new Promise(resolve => setTimeout(resolve, intervalMs))
  364. if (!isHistoryTokenCurrent(token)) return null
  365. }
  366. } catch (err) {
  367. console.error('[speaking] load completed history report failed:', err)
  368. return null
  369. }
  370. return null
  371. }
  372. async function loadLatestStudentSession(token = nextHistoryLoadToken()) {
  373. if (historyChecked.value) return
  374. historyChecked.value = true
  375. if (!isStudentRuntime.value) return
  376. if (!props.configId) {
  377. if (!isHistoryTokenCurrent(token)) return
  378. sessionError.value = '口语工具配置缺失,请联系老师重新发布。'
  379. return
  380. }
  381. if (!runtimeUserId.value) {
  382. if (!isHistoryTokenCurrent(token)) return
  383. sessionError.value = '学生身份缺失,请从课程入口重新进入。'
  384. return
  385. }
  386. if (!isHistoryTokenCurrent(token)) return
  387. dialogueState.value = 'checking-history'
  388. sessionError.value = null
  389. try {
  390. const api = createDialogueApi()
  391. const { session } = await api.getLatestSession(props.configId, runtimeUserId.value)
  392. if (!isHistoryTokenCurrent(token)) return
  393. if (!session) {
  394. preparedSession.value = null
  395. dialogueState.value = 'ready'
  396. return
  397. }
  398. preparedSession.value = {
  399. sessionId: session.sessionId,
  400. expiresAt: session.expiresAt,
  401. currentRound: session.currentRound,
  402. messages: session.messages,
  403. }
  404. if (session.status === 'completed') {
  405. const report = await waitForCompletedHistoryReport(api, session.sessionId, token)
  406. if (!isHistoryTokenCurrent(token)) return
  407. handleDialogueComplete(report)
  408. return
  409. }
  410. if (session.status !== 'active') {
  411. preparedSession.value = null
  412. sessionError.value = '上次练习已失效,请重新开始。'
  413. dialogueState.value = 'ready'
  414. return
  415. }
  416. dialogueState.value = 'chatting'
  417. notifySpeakingProgress('active', {
  418. configId: props.configId || '',
  419. sessionId: preparedSession.value?.sessionId || '',
  420. })
  421. } catch (err: unknown) {
  422. if (!isHistoryTokenCurrent(token)) return
  423. console.error('[speaking] load latest session failed:', err)
  424. dialogueState.value = 'ready'
  425. if (err instanceof DialogueApiError) {
  426. sessionError.value = `读取历史会话失败(${err.status}),请刷新重试`
  427. } else {
  428. sessionError.value = '读取历史会话失败,请刷新重试'
  429. }
  430. }
  431. }
  432. function handleDialogueComplete(report: DialogueReport | null) {
  433. reportFetchFailed.value = !report
  434. reportStatus.value = report?.status ?? null
  435. realEvaluation.value = report?.evaluation ?? null
  436. if (!report) reportError.value = '报告生成超时或获取失败,请稍后重试。'
  437. else if (report.status === 'failed') reportError.value = '报告生成失败,部分语音评分未完成。'
  438. else if (report?.status === 'incomplete') reportError.value = '本次练习没有足够的有效回答生成报告。'
  439. else reportError.value = ''
  440. dialogueState.value = 'completed'
  441. if (preparedSession.value?.sessionId) {
  442. notifySpeakingProgress('completed', {
  443. configId: props.configId || '',
  444. sessionId: preparedSession.value.sessionId,
  445. })
  446. }
  447. }
  448. function resetPreview() {
  449. nextHistoryLoadToken()
  450. dialogueState.value = 'ready'
  451. realEvaluation.value = null
  452. reportStatus.value = null
  453. reportError.value = ''
  454. reportFetchFailed.value = false
  455. preparedSession.value = null
  456. sessionError.value = null
  457. sessionCreating.value = false
  458. selectedVideo.value = null
  459. playingVideo.value = null
  460. }
  461. // ── Sync with speakingStore (让 CanvasTool 可以驱动"重置预览") ──
  462. watch(dialogueState, (s) => { speakingStore.setPreviewState(s) }, { immediate: true })
  463. watch(
  464. () => speakingStore.resetSignal,
  465. (val, old) => {
  466. if (val !== old) resetPreview()
  467. },
  468. )
  469. // ── 根据 configId 从后端拉回配置注入 store ──
  470. async function loadConfigFromBackend(id: string) {
  471. const token = nextHistoryLoadToken()
  472. if (!id) {
  473. await loadLatestStudentSession(token)
  474. return
  475. }
  476. try {
  477. const { config } = await getSpeakingConfig(id)
  478. if (!isHistoryTokenCurrent(token)) return
  479. speakingStore.$patch({ config })
  480. } catch (err) {
  481. if (!isHistoryTokenCurrent(token)) return
  482. console.error('[speaking] load config failed:', err)
  483. } finally {
  484. if (isHistoryTokenCurrent(token)) {
  485. await loadLatestStudentSession(token)
  486. }
  487. }
  488. }
  489. watch(() => props.configId, (id) => {
  490. historyChecked.value = false
  491. loadConfigFromBackend(id)
  492. })
  493. onMounted(() => {
  494. speakingStore.setPreviewState(dialogueState.value)
  495. loadConfigFromBackend(props.configId)
  496. if (fitWrapperRef.value) {
  497. fitResizeObserver = new ResizeObserver(updateFit)
  498. fitResizeObserver.observe(fitWrapperRef.value)
  499. updateFit()
  500. }
  501. })
  502. onUnmounted(() => {
  503. nextHistoryLoadToken()
  504. speakingStore.setPreviewState('ready')
  505. fitResizeObserver?.disconnect()
  506. fitResizeObserver = null
  507. })
  508. </script>
  509. <style lang="scss" scoped>
  510. // 外层 fit-wrapper 始终撑满槽位(type 77 的 frame 默认 ~1000×562 设计 CSS-px)。
  511. // ResizeObserver 观察它的尺寸,再用 transform: scale() 把内层按 DESIGN_WIDTH 设计尺寸放大填满。
  512. .topic-discussion-fit-wrapper {
  513. width: 100%;
  514. height: 100%;
  515. overflow: hidden;
  516. position: relative;
  517. }
  518. .topic-discussion-preview {
  519. width: 100%;
  520. height: 100%;
  521. display: flex;
  522. flex-direction: column;
  523. background: #fff;
  524. position: relative;
  525. overflow: hidden;
  526. // flex 嵌套 overflow 生效的前提:flex 子默认 min-height: auto 会被内容撑大,
  527. // 让 ready-stage / report-stage / DialogueChatView 根节点能 shrink 到父高度
  528. > * {
  529. min-height: 0;
  530. }
  531. }
  532. // ─── Ready 阶段 ───
  533. .ready-stage {
  534. flex: 1;
  535. display: flex;
  536. flex-direction: column;
  537. background: #fff;
  538. }
  539. .ready-header {
  540. padding: 20px 24px 12px;
  541. text-align: center;
  542. border-bottom: 1px solid #f9fafb;
  543. }
  544. .ready-title {
  545. font-size: 20px;
  546. font-weight: 600;
  547. color: #111827;
  548. margin: 0;
  549. display: inline-flex;
  550. align-items: center;
  551. justify-content: center;
  552. gap: 8px;
  553. }
  554. .ready-title-icon { font-size: 22px; }
  555. .ready-subtitle {
  556. font-size: 13px;
  557. color: #9ca3af;
  558. margin: 4px 0 0;
  559. }
  560. .ready-body {
  561. flex: 1;
  562. display: flex;
  563. flex-direction: column;
  564. align-items: center;
  565. justify-content: center;
  566. gap: 16px;
  567. padding: 24px;
  568. }
  569. .topic-emoji { font-size: 56px; line-height: 1; }
  570. .topic-name {
  571. font-size: 18px;
  572. font-weight: 600;
  573. color: #1f2937;
  574. margin: 0;
  575. }
  576. .ready-footer {
  577. padding: 16px 24px 20px;
  578. border-top: 1px solid #f9fafb;
  579. background: #fafbfc;
  580. display: flex;
  581. flex-direction: column;
  582. align-items: center;
  583. }
  584. .start-btn {
  585. display: inline-flex;
  586. align-items: center;
  587. gap: 8px;
  588. padding: 9px 28px;
  589. border-radius: 999px;
  590. background: #f97316;
  591. color: #fff;
  592. border: none;
  593. font-size: 14px;
  594. font-weight: 500;
  595. cursor: pointer;
  596. box-shadow: 0 4px 12px rgba(249, 115, 22, 0.25);
  597. transition: background 0.2s, transform 0.15s;
  598. &:hover { background: #ea580c; }
  599. &:active { transform: scale(0.97); }
  600. }
  601. .start-btn:disabled {
  602. opacity: 0.6;
  603. cursor: wait;
  604. background: #fb923c;
  605. }
  606. .start-btn-spinner {
  607. width: 14px;
  608. height: 14px;
  609. border: 2px solid rgba(255, 255, 255, 0.4);
  610. border-top-color: #fff;
  611. border-radius: 50%;
  612. animation: start-btn-spin 0.8s linear infinite;
  613. display: inline-block;
  614. }
  615. @keyframes start-btn-spin {
  616. to { transform: rotate(360deg); }
  617. }
  618. .session-error-text {
  619. margin: 8px 0 0;
  620. font-size: 12px;
  621. color: #dc2626;
  622. text-align: center;
  623. }
  624. // ─── Completed 阶段 ───
  625. .report-stage {
  626. flex: 1;
  627. display: flex;
  628. flex-direction: column;
  629. overflow: hidden;
  630. background: #fff;
  631. }
  632. .report-scroll {
  633. flex: 1;
  634. overflow-y: auto;
  635. padding: 16px;
  636. display: flex;
  637. flex-direction: column;
  638. gap: 24px;
  639. }
  640. .report-divider {
  641. height: 1px;
  642. background: #e5e7eb;
  643. margin: 0;
  644. }
  645. .report-error {
  646. max-width: 448px;
  647. margin: 0 auto 12px;
  648. padding: 10px 12px;
  649. border: 1px solid #fed7aa;
  650. border-radius: 8px;
  651. background: #fff7ed;
  652. color: #c2410c;
  653. font-size: 12px;
  654. line-height: 1.5;
  655. }
  656. </style>