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 studentArray: Ref locale: Ref } /** 把 i18n 模板里的 {key} 替换成实际值 */ function formatTpl(tpl: string, vars: Record): 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([]) const loading = ref(false) const error = ref(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(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, } }