ArticleDetailedReport.vue 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186
  1. <template>
  2. <div class="article-report">
  3. <div class="article-report__scroll">
  4. <!-- 1. 整篇总评(还原 enspeak OverallReport scoreLayout="compactStars") -->
  5. <ArticleOverallReport
  6. :report="report"
  7. :show-restart="showRestart"
  8. @restart="emit('restart')"
  9. />
  10. <!-- 2. 逐段详情 -->
  11. <div class="report-paragraphs">
  12. <div class="report-paragraphs__head">
  13. <span class="report-paragraphs__title">{{ lang.ssArticleParagraphsTitle }}</span>
  14. <button
  15. v-if="recordedIdxs.length"
  16. class="report-paragraphs__toggle"
  17. @click="toggleAll"
  18. >
  19. {{ allExpanded ? lang.ssArticleCollapseAll : lang.ssArticleExpandAll }}
  20. </button>
  21. </div>
  22. <div
  23. v-for="(paragraph, index) in sortedParagraphs"
  24. :key="paragraph.idx"
  25. class="paragraph-block"
  26. >
  27. <!-- 段落分隔线 -->
  28. <div v-if="index > 0" class="paragraph-divider">
  29. <span class="paragraph-divider__line"></span>
  30. <span class="paragraph-divider__label">{{ paragraphLabel(index) }}</span>
  31. <span class="paragraph-divider__line"></span>
  32. </div>
  33. <div class="paragraph-shell">
  34. <ArticleParagraphCard
  35. :paragraph="paragraph"
  36. :expanded="expandedIdxs.has(paragraph.idx)"
  37. :demo-audio-loading="loadingId === `demo-${paragraph.idx}`"
  38. :demo-audio-playing="playingId === `demo-${paragraph.idx}`"
  39. :own-audio-loading="loadingId === `own-${paragraph.idx}`"
  40. :own-audio-playing="playingId === `own-${paragraph.idx}`"
  41. :own-audio-available="Boolean(paragraph.audioUrl)"
  42. @toggle="toggleOne(paragraph.idx)"
  43. @play-demo="emit('play-demo', paragraph.idx)"
  44. @play-own="emit('play-own', paragraph.idx)"
  45. />
  46. </div>
  47. </div>
  48. </div>
  49. </div>
  50. </div>
  51. </template>
  52. <script lang="ts" setup>
  53. import { computed, ref, watch } from 'vue'
  54. import { lang } from '@/main'
  55. import ArticleOverallReport from './ArticleOverallReport.vue'
  56. import ArticleParagraphCard from './ArticleParagraphCard.vue'
  57. import type { ArticleReadingReport } from '@/types/articleReading'
  58. const props = withDefaults(defineProps<{
  59. report: ArticleReadingReport
  60. playingId: string | null
  61. loadingId: string | null
  62. /** 学生自己的报告页给「再来一次」;老师端 StudentReportModal 不给。 */
  63. showRestart?: boolean
  64. }>(), { showRestart: false })
  65. const emit = defineEmits<{
  66. (event: 'play-demo', paragraphIdx: number): void
  67. (event: 'play-own', paragraphIdx: number): void
  68. (event: 'restart'): void
  69. }>()
  70. const sortedParagraphs = computed(() => [...props.report.paragraphs].sort((a, b) => a.idx - b.idx))
  71. // 只有已出分的段落有评价区可展开(原型同款)
  72. const recordedIdxs = computed(() =>
  73. sortedParagraphs.value.filter(paragraph => paragraph.status === 'completed').map(p => p.idx),
  74. )
  75. const expandedIdxs = ref<Set<number>>(new Set())
  76. // 默认展开全部已录段落;换一份报告(老师逐个学生看)时重新初始化
  77. watch(
  78. () => `${props.report.sessionId}|${recordedIdxs.value.join(',')}`,
  79. () => {
  80. expandedIdxs.value = new Set(recordedIdxs.value)
  81. },
  82. { immediate: true },
  83. )
  84. const allExpanded = computed(() =>
  85. recordedIdxs.value.length > 0 && recordedIdxs.value.every(idx => expandedIdxs.value.has(idx)),
  86. )
  87. function toggleAll() {
  88. expandedIdxs.value = allExpanded.value ? new Set() : new Set(recordedIdxs.value)
  89. }
  90. function toggleOne(idx: number) {
  91. const next = new Set(expandedIdxs.value)
  92. if (next.has(idx)) next.delete(idx)
  93. else next.add(idx)
  94. expandedIdxs.value = next
  95. }
  96. function paragraphLabel(index: number): string {
  97. return String(lang.ssArticleParagraphIndex).replace('{n}', String(index + 1))
  98. }
  99. </script>
  100. <style lang="scss" scoped>
  101. .article-report {
  102. width: 100%;
  103. height: 100%;
  104. background: #fff;
  105. overflow: hidden;
  106. }
  107. .article-report__scroll {
  108. height: 100%;
  109. overflow-y: auto;
  110. padding: 24px;
  111. max-width: 720px;
  112. margin: 0 auto;
  113. display: flex;
  114. flex-direction: column;
  115. gap: 16px;
  116. }
  117. .report-paragraphs__head {
  118. display: flex;
  119. align-items: center;
  120. justify-content: space-between;
  121. gap: 8px;
  122. margin-bottom: 8px;
  123. }
  124. .report-paragraphs__title {
  125. font-size: 12px;
  126. font-weight: 500;
  127. color: #6b7280;
  128. }
  129. .report-paragraphs__toggle {
  130. border: none;
  131. background: transparent;
  132. padding: 0;
  133. font-size: 10px;
  134. color: #f97316;
  135. cursor: pointer;
  136. transition: color 0.2s;
  137. &:hover { color: #ea580c; }
  138. }
  139. // 段落之间的「第 N 段」分隔线
  140. .paragraph-divider {
  141. display: flex;
  142. align-items: center;
  143. gap: 8px;
  144. margin: 12px 0;
  145. }
  146. .paragraph-divider__line {
  147. flex: 1;
  148. height: 1px;
  149. background: #e5e7eb;
  150. }
  151. .paragraph-divider__label {
  152. font-size: 9px;
  153. color: #9ca3af;
  154. white-space: nowrap;
  155. }
  156. .paragraph-shell {
  157. padding: 16px;
  158. border: 1px solid #e5e7eb;
  159. border-radius: 12px;
  160. background: #fff;
  161. }
  162. </style>