useClassSummary.ts 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173
  1. import { ref, computed, onMounted, onUnmounted, type Ref } from 'vue'
  2. import { lang } from '@/main'
  3. import {
  4. listSpeakingSessionsByConfig,
  5. generateClassSummary,
  6. type ClassSessionSummary,
  7. type ClassSummaryResponse,
  8. } from '@/services/speaking'
  9. import type { ClassStudent, ClassStudentSummary, Locale } from './types'
  10. interface UseClassSummaryOpts {
  11. configId: Ref<string>
  12. studentArray: Ref<ClassStudent[]>
  13. locale: Ref<Locale>
  14. }
  15. /** 把 i18n 模板里的 {key} 替换成实际值 */
  16. function formatTpl(tpl: string, vars: Record<string, string | number>): string {
  17. return tpl.replace(/\{(\w+)\}/g, (_, k) => String(vars[k] ?? ''))
  18. }
  19. function mergeWithRoster(
  20. serverSummaries: ClassSessionSummary[],
  21. roster: ClassStudent[],
  22. ): ClassStudentSummary[] {
  23. const byUser = new Map(serverSummaries.map(s => [s.userId, s]))
  24. return roster.map(stu => {
  25. const found = byUser.get(stu.userid)
  26. if (!found) {
  27. return {
  28. userId: stu.userid, name: stu.name,
  29. status: 'not_started',
  30. overallScore: null, sessionId: null,
  31. createdAt: null, completedAt: null,
  32. rawStatus: null, overallStatus: null,
  33. }
  34. }
  35. const status = found.status === 'completed' ? 'submitted' : 'unsubmitted'
  36. return {
  37. userId: stu.userid, name: stu.name,
  38. status,
  39. overallScore: found.overallScore,
  40. sessionId: found.sessionId,
  41. createdAt: found.createdAt,
  42. completedAt: found.completedAt,
  43. rawStatus: found.status,
  44. overallStatus: found.overallStatus,
  45. }
  46. })
  47. }
  48. // 前端 fallback 规则版本(必须与后端 class_summary_rules.py 保持镜像)
  49. function frontendRuleBullet2(summaries: ClassStudentSummary[]): string {
  50. const submitted = summaries.filter(s => s.status === 'submitted')
  51. if (submitted.length === 0) return (lang as any).ssSpkWaitingFirst
  52. const scores = submitted.map(s => s.overallScore).filter((x): x is number => x != null)
  53. if (!scores.length) return (lang as any).ssSpkWaitingFirst
  54. const avg = Math.round(scores.reduce((a, b) => a + b, 0) / scores.length)
  55. const max = Math.max(...scores)
  56. return formatTpl((lang as any).ssSpkAvgScoreTpl, { avg, max })
  57. }
  58. function frontendRuleBullet3(summaries: ClassStudentSummary[]): string {
  59. const total = summaries.length
  60. const submitted = summaries.filter(s => s.status === 'submitted').length
  61. const unfinished = total - submitted
  62. const rate = total ? Math.round(submitted / total * 100) : 0
  63. if (rate === 0) return (lang as any).ssSpkWaitingStart
  64. if (rate < 50) return formatTpl((lang as any).ssSpkRemindUnfinishedTpl, { n: unfinished })
  65. if (rate < 100) return (lang as any).ssSpkProgressHalf
  66. return (lang as any).ssSpkAllDone
  67. }
  68. export function useClassSummary(opts: UseClassSummaryOpts) {
  69. // ─── list 数据 ─────────────────────────────────────────
  70. const summaries = ref<ClassStudentSummary[]>([])
  71. const loading = ref(false)
  72. const error = ref<string | null>(null)
  73. let fetchToken = 0
  74. let refetchTimer: number | null = null
  75. async function fetchClassSummary() {
  76. if (!opts.configId.value) {
  77. error.value = 'CONFIG_MISSING'
  78. summaries.value = []
  79. return
  80. }
  81. if (!opts.studentArray.value.length) return
  82. const token = ++fetchToken
  83. loading.value = true
  84. try {
  85. const userIds = opts.studentArray.value.map(s => s.userid)
  86. const data = await listSpeakingSessionsByConfig(opts.configId.value, userIds)
  87. if (token !== fetchToken) return
  88. summaries.value = mergeWithRoster(data.summaries, opts.studentArray.value)
  89. error.value = null
  90. } catch (e) {
  91. if (token !== fetchToken) return
  92. error.value = 'FETCH_FAILED'
  93. } finally {
  94. if (token === fetchToken) loading.value = false
  95. }
  96. }
  97. function scheduleRefetch() {
  98. if (refetchTimer) clearTimeout(refetchTimer)
  99. refetchTimer = window.setTimeout(fetchClassSummary, 1000)
  100. }
  101. // ─── AI 总结(混合模式) ───────────────────────────────
  102. const aiBackendBullets = ref<[string, string, string] | null>(null)
  103. const aiLoading = ref(false)
  104. const aiGeneratedAt = ref<string | null>(null)
  105. let aiToken = 0
  106. // bullet 1 永远 live(数字派生)
  107. const liveBullet1 = computed(() => {
  108. const total = summaries.value.length
  109. const done = summaries.value.filter(s => s.status === 'submitted').length
  110. const rate = total ? Math.round(done / total * 100) : 0
  111. return formatTpl((lang as any).ssSpkCompletedCountTpl, { done, total, rate })
  112. })
  113. // 暴露的 3 条 bullet:第 1 条永远 live,2/3 跟随后端或 fallback
  114. const aiBullets = computed<[string, string, string]>(() => {
  115. const b1 = liveBullet1.value
  116. if (aiBackendBullets.value) {
  117. return [b1, aiBackendBullets.value[1], aiBackendBullets.value[2]]
  118. }
  119. return [b1, frontendRuleBullet2(summaries.value), frontendRuleBullet3(summaries.value)]
  120. })
  121. async function refreshAISummary() {
  122. if (!opts.configId.value) return
  123. if (!opts.studentArray.value.length) return
  124. const token = ++aiToken
  125. aiLoading.value = true
  126. try {
  127. const userIds = opts.studentArray.value.map(s => s.userid)
  128. const data: ClassSummaryResponse = await generateClassSummary(
  129. opts.configId.value, userIds, opts.locale.value,
  130. )
  131. if (token !== aiToken) return
  132. aiBackendBullets.value = data.bullets
  133. aiGeneratedAt.value = data.generatedAt
  134. } catch (e) {
  135. if (token !== aiToken) return
  136. // 失败保持上次值;若从未成功,aiBullets 自动用前端 fallback
  137. } finally {
  138. if (token === aiToken) aiLoading.value = false
  139. }
  140. }
  141. // ─── 生命周期 ─────────────────────────────────────────
  142. onMounted(() => {
  143. fetchClassSummary()
  144. refreshAISummary()
  145. })
  146. onUnmounted(() => {
  147. if (refetchTimer) clearTimeout(refetchTimer)
  148. fetchToken++
  149. aiToken++
  150. })
  151. return {
  152. // list 数据
  153. summaries, loading, error, fetchClassSummary, scheduleRefetch,
  154. // AI 总结
  155. aiBullets, aiLoading, aiGeneratedAt, refreshAISummary,
  156. }
  157. }