|
@@ -2,9 +2,8 @@ import { ref, computed, onMounted, onUnmounted, type Ref } from 'vue'
|
|
|
import { lang } from '@/main'
|
|
import { lang } from '@/main'
|
|
|
import {
|
|
import {
|
|
|
listSpeakingSessionsByConfig,
|
|
listSpeakingSessionsByConfig,
|
|
|
- generateClassSummary,
|
|
|
|
|
|
|
+ streamClassReport,
|
|
|
type ClassSessionSummary,
|
|
type ClassSessionSummary,
|
|
|
- type ClassSummaryResponse,
|
|
|
|
|
} from '@/services/speaking'
|
|
} from '@/services/speaking'
|
|
|
import type { ClassStudent, ClassStudentSummary, Locale } from './types'
|
|
import type { ClassStudent, ClassStudentSummary, Locale } from './types'
|
|
|
|
|
|
|
@@ -49,28 +48,6 @@ function mergeWithRoster(
|
|
|
})
|
|
})
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// 前端 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) {
|
|
export function useClassSummary(opts: UseClassSummaryOpts) {
|
|
|
// ─── list 数据 ─────────────────────────────────────────
|
|
// ─── list 数据 ─────────────────────────────────────────
|
|
|
const summaries = ref<ClassStudentSummary[]>([])
|
|
const summaries = ref<ClassStudentSummary[]>([])
|
|
@@ -108,66 +85,63 @@ export function useClassSummary(opts: UseClassSummaryOpts) {
|
|
|
refetchTimer = window.setTimeout(fetchClassSummary, 1000)
|
|
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
|
|
|
|
|
|
|
+ // ─── AI 报告(流式 Markdown) ───────────────────────────
|
|
|
|
|
+ const reportMarkdown = ref('')
|
|
|
|
|
+ const reportStreaming = ref(false)
|
|
|
|
|
+ const reportGeneratedAt = ref<string | null>(null)
|
|
|
|
|
+ const reportError = ref<string | null>(null)
|
|
|
|
|
+ let reportToken = 0
|
|
|
|
|
|
|
|
- // bullet 1 永远 live(数字派生)
|
|
|
|
|
- const liveBullet1 = computed(() => {
|
|
|
|
|
|
|
+ // 折叠态常驻行:完成人数/比例(纯前端派生,不需要 LLM)
|
|
|
|
|
+ const completionLine = computed(() => {
|
|
|
const total = summaries.value.length
|
|
const total = summaries.value.length
|
|
|
const done = summaries.value.filter(s => s.status === 'submitted').length
|
|
const done = summaries.value.filter(s => s.status === 'submitted').length
|
|
|
const rate = total ? Math.round(done / total * 100) : 0
|
|
const rate = total ? Math.round(done / total * 100) : 0
|
|
|
return formatTpl((lang as any).ssSpkCompletedCountTpl, { done, total, rate })
|
|
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() {
|
|
|
|
|
|
|
+ async function refreshReport() {
|
|
|
if (!opts.configId.value) return
|
|
if (!opts.configId.value) return
|
|
|
if (!opts.studentArray.value.length) return
|
|
if (!opts.studentArray.value.length) return
|
|
|
|
|
|
|
|
- const token = ++aiToken
|
|
|
|
|
- aiLoading.value = true
|
|
|
|
|
|
|
+ const token = ++reportToken
|
|
|
|
|
+ reportStreaming.value = true
|
|
|
|
|
+ reportError.value = null
|
|
|
|
|
+ reportMarkdown.value = ''
|
|
|
try {
|
|
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
|
|
|
|
|
|
|
+ const students = opts.studentArray.value.map(s => ({ userId: s.userid, name: s.name }))
|
|
|
|
|
+ for await (const chunk of streamClassReport(
|
|
|
|
|
+ opts.configId.value, students, opts.locale.value,
|
|
|
|
|
+ )) {
|
|
|
|
|
+ if (token !== reportToken) return
|
|
|
|
|
+ reportMarkdown.value += chunk
|
|
|
|
|
+ }
|
|
|
|
|
+ if (token !== reportToken) return
|
|
|
|
|
+ reportGeneratedAt.value = new Date().toISOString()
|
|
|
} catch (e) {
|
|
} catch (e) {
|
|
|
- if (token !== aiToken) return
|
|
|
|
|
- // 失败保持上次值;若从未成功,aiBullets 自动用前端 fallback
|
|
|
|
|
|
|
+ if (token !== reportToken) return
|
|
|
|
|
+ reportError.value = 'REPORT_FAILED'
|
|
|
} finally {
|
|
} finally {
|
|
|
- if (token === aiToken) aiLoading.value = false
|
|
|
|
|
|
|
+ if (token === reportToken) reportStreaming.value = false
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// ─── 生命周期 ─────────────────────────────────────────
|
|
// ─── 生命周期 ─────────────────────────────────────────
|
|
|
onMounted(() => {
|
|
onMounted(() => {
|
|
|
fetchClassSummary()
|
|
fetchClassSummary()
|
|
|
- refreshAISummary()
|
|
|
|
|
|
|
+ refreshReport()
|
|
|
})
|
|
})
|
|
|
onUnmounted(() => {
|
|
onUnmounted(() => {
|
|
|
if (refetchTimer) clearTimeout(refetchTimer)
|
|
if (refetchTimer) clearTimeout(refetchTimer)
|
|
|
fetchToken++
|
|
fetchToken++
|
|
|
- aiToken++
|
|
|
|
|
|
|
+ reportToken++
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
return {
|
|
return {
|
|
|
// list 数据
|
|
// list 数据
|
|
|
summaries, loading, error, fetchClassSummary, scheduleRefetch,
|
|
summaries, loading, error, fetchClassSummary, scheduleRefetch,
|
|
|
- // AI 总结
|
|
|
|
|
- aiBullets, aiLoading, aiGeneratedAt, refreshAISummary,
|
|
|
|
|
|
|
+ // AI 报告
|
|
|
|
|
+ completionLine, reportMarkdown, reportStreaming, reportGeneratedAt, reportError,
|
|
|
|
|
+ refreshReport,
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|