|
|
@@ -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,
|
|
|
+ }
|
|
|
+}
|