| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173 |
- 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,
- }
- }
|