TopicDiscussionPreview.vue 20 KB

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