Просмотр исходного кода

feat(speaking): add useClassSummary composable with list+AI hybrid

jimmylee 2 месяцев назад
Родитель
Сommit
b421d99d4a
1 измененных файлов с 173 добавлено и 0 удалено
  1. 173 0
      src/views/Student/components/SpeakingClassPanel/useClassSummary.ts

+ 173 - 0
src/views/Student/components/SpeakingClassPanel/useClassSummary.ts

@@ -0,0 +1,173 @@
+import { ref, computed, onMounted, onUnmounted, type Ref } from 'vue'
+import { lang } from '@/main'
+import {
+  listSpeakingSessionsByConfig,
+  generateClassSummary,
+  type ClassSessionSummary,
+  type ClassSummaryResponse,
+} from '@/services/speaking'
+import type { ClassStudent, ClassStudentSummary, Locale } from './types'
+
+interface UseClassSummaryOpts {
+  configId: Ref<string>
+  studentArray: Ref<ClassStudent[]>
+  locale: Ref<Locale>
+}
+
+/** 把 i18n 模板里的 {key} 替换成实际值 */
+function formatTpl(tpl: string, vars: Record<string, string | number>): string {
+  return tpl.replace(/\{(\w+)\}/g, (_, k) => String(vars[k] ?? ''))
+}
+
+function mergeWithRoster(
+  serverSummaries: ClassSessionSummary[],
+  roster: ClassStudent[],
+): ClassStudentSummary[] {
+  const byUser = new Map(serverSummaries.map(s => [s.userId, s]))
+  return roster.map(stu => {
+    const found = byUser.get(stu.userid)
+    if (!found) {
+      return {
+        userId: stu.userid, name: stu.name,
+        status: 'not_started',
+        overallScore: null, sessionId: null,
+        createdAt: null, completedAt: null,
+        rawStatus: null, overallStatus: null,
+      }
+    }
+    const status = found.status === 'completed' ? 'submitted' : 'unsubmitted'
+    return {
+      userId: stu.userid, name: stu.name,
+      status,
+      overallScore: found.overallScore,
+      sessionId: found.sessionId,
+      createdAt: found.createdAt,
+      completedAt: found.completedAt,
+      rawStatus: found.status,
+      overallStatus: found.overallStatus,
+    }
+  })
+}
+
+// 前端 fallback 规则版本(必须与后端 class_summary_rules.py 保持镜像)
+function frontendRuleBullet2(summaries: ClassStudentSummary[]): string {
+  const submitted = summaries.filter(s => s.status === 'submitted')
+  if (submitted.length === 0) return (lang as any).ssSpkWaitingFirst
+  const scores = submitted.map(s => s.overallScore).filter((x): x is number => x != null)
+  if (!scores.length) return (lang as any).ssSpkWaitingFirst
+  const avg = Math.round(scores.reduce((a, b) => a + b, 0) / scores.length)
+  const max = Math.max(...scores)
+  return formatTpl((lang as any).ssSpkAvgScoreTpl, { avg, max })
+}
+
+function frontendRuleBullet3(summaries: ClassStudentSummary[]): string {
+  const total = summaries.length
+  const submitted = summaries.filter(s => s.status === 'submitted').length
+  const unfinished = total - submitted
+  const rate = total ? Math.round(submitted / total * 100) : 0
+  if (rate === 0)   return (lang as any).ssSpkWaitingStart
+  if (rate < 50)    return formatTpl((lang as any).ssSpkRemindUnfinishedTpl, { n: unfinished })
+  if (rate < 100)   return (lang as any).ssSpkProgressHalf
+  return (lang as any).ssSpkAllDone
+}
+
+export function useClassSummary(opts: UseClassSummaryOpts) {
+  // ─── list 数据 ─────────────────────────────────────────
+  const summaries = ref<ClassStudentSummary[]>([])
+  const loading = ref(false)
+  const error = ref<string | null>(null)
+  let fetchToken = 0
+  let refetchTimer: number | null = null
+
+  async function fetchClassSummary() {
+    if (!opts.configId.value) {
+      error.value = 'CONFIG_MISSING'
+      summaries.value = []
+      return
+    }
+    if (!opts.studentArray.value.length) return
+
+    const token = ++fetchToken
+    loading.value = true
+    try {
+      const userIds = opts.studentArray.value.map(s => s.userid)
+      const data = await listSpeakingSessionsByConfig(opts.configId.value, userIds)
+      if (token !== fetchToken) return
+      summaries.value = mergeWithRoster(data.summaries, opts.studentArray.value)
+      error.value = null
+    } catch (e) {
+      if (token !== fetchToken) return
+      error.value = 'FETCH_FAILED'
+    } finally {
+      if (token === fetchToken) loading.value = false
+    }
+  }
+
+  function scheduleRefetch() {
+    if (refetchTimer) clearTimeout(refetchTimer)
+    refetchTimer = window.setTimeout(fetchClassSummary, 1000)
+  }
+
+  // ─── AI 总结(混合模式) ───────────────────────────────
+  const aiBackendBullets = ref<[string, string, string] | null>(null)
+  const aiLoading = ref(false)
+  const aiGeneratedAt = ref<string | null>(null)
+  let aiToken = 0
+
+  // bullet 1 永远 live(数字派生)
+  const liveBullet1 = computed(() => {
+    const total = summaries.value.length
+    const done = summaries.value.filter(s => s.status === 'submitted').length
+    const rate = total ? Math.round(done / total * 100) : 0
+    return formatTpl((lang as any).ssSpkCompletedCountTpl, { done, total, rate })
+  })
+
+  // 暴露的 3 条 bullet:第 1 条永远 live,2/3 跟随后端或 fallback
+  const aiBullets = computed<[string, string, string]>(() => {
+    const b1 = liveBullet1.value
+    if (aiBackendBullets.value) {
+      return [b1, aiBackendBullets.value[1], aiBackendBullets.value[2]]
+    }
+    return [b1, frontendRuleBullet2(summaries.value), frontendRuleBullet3(summaries.value)]
+  })
+
+  async function refreshAISummary() {
+    if (!opts.configId.value) return
+    if (!opts.studentArray.value.length) return
+
+    const token = ++aiToken
+    aiLoading.value = true
+    try {
+      const userIds = opts.studentArray.value.map(s => s.userid)
+      const data: ClassSummaryResponse = await generateClassSummary(
+        opts.configId.value, userIds, opts.locale.value,
+      )
+      if (token !== aiToken) return
+      aiBackendBullets.value = data.bullets
+      aiGeneratedAt.value = data.generatedAt
+    } catch (e) {
+      if (token !== aiToken) return
+      // 失败保持上次值;若从未成功,aiBullets 自动用前端 fallback
+    } finally {
+      if (token === aiToken) aiLoading.value = false
+    }
+  }
+
+  // ─── 生命周期 ─────────────────────────────────────────
+  onMounted(() => {
+    fetchClassSummary()
+    refreshAISummary()
+  })
+  onUnmounted(() => {
+    if (refetchTimer) clearTimeout(refetchTimer)
+    fetchToken++
+    aiToken++
+  })
+
+  return {
+    // list 数据
+    summaries, loading, error, fetchClassSummary, scheduleRefetch,
+    // AI 总结
+    aiBullets, aiLoading, aiGeneratedAt, refreshAISummary,
+  }
+}