|
|
@@ -0,0 +1,252 @@
|
|
|
+<template>
|
|
|
+ <div class="speaking-class-panel" :style="cardStyle">
|
|
|
+ <div class="content-card">
|
|
|
+ <div v-if="!props.configId" class="state-banner">
|
|
|
+ {{ lang.ssSpkConfigMissing }}
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <template v-else>
|
|
|
+ <!-- 状态筛选 + 排序 -->
|
|
|
+ <div class="filter-row">
|
|
|
+ <div class="status-filter">
|
|
|
+ <button
|
|
|
+ v-for="f in filterTabs"
|
|
|
+ :key="f.key"
|
|
|
+ :class="{ 'active': statusFilter === f.key }"
|
|
|
+ @click="statusFilter = f.key"
|
|
|
+ >
|
|
|
+ {{ f.label }} {{ f.count }}
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ <SortMenu v-model="sortBy" />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 错误条 -->
|
|
|
+ <div v-if="error === 'FETCH_FAILED'" class="error-bar">
|
|
|
+ <span>{{ lang.ssSpkLoadFailed }}</span>
|
|
|
+ <button @click="fetchClassSummary">{{ lang.ssSpkRetry }}</button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 学生网格 -->
|
|
|
+ <div class="grid-scroll">
|
|
|
+ <StudentGrid
|
|
|
+ :students="filteredAndSortedStudents"
|
|
|
+ @click="handleStudentClick"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- AI 总结 -->
|
|
|
+ <AISummary
|
|
|
+ :bullets="aiBullets"
|
|
|
+ :loading="aiLoading"
|
|
|
+ :generatedAt="aiGeneratedAt"
|
|
|
+ @refresh="refreshAISummary"
|
|
|
+ />
|
|
|
+ </template>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <StudentReportModal
|
|
|
+ v-if="reportModalStudent && reportModalStudent.sessionId"
|
|
|
+ :student="reportModalStudent"
|
|
|
+ :role="defaultRole"
|
|
|
+ @close="reportModalStudent = null"
|
|
|
+ />
|
|
|
+
|
|
|
+ <NotStartedTip
|
|
|
+ v-if="notStartedTipStudent"
|
|
|
+ :studentName="notStartedTipStudent.name"
|
|
|
+ @close="notStartedTipStudent = null"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script lang="ts" setup>
|
|
|
+import { ref, computed, toRef } from 'vue'
|
|
|
+import { lang } from '@/main'
|
|
|
+import StudentGrid from './StudentGrid.vue'
|
|
|
+import SortMenu, { type SortKey } from './SortMenu.vue'
|
|
|
+import AISummary from './AISummary.vue'
|
|
|
+import StudentReportModal from './StudentReportModal.vue'
|
|
|
+import NotStartedTip from './NotStartedTip.vue'
|
|
|
+import { useClassSummary } from './useClassSummary'
|
|
|
+import type { ClassStudent, ClassStudentSummary, ClassStudentStatus, Locale } from './types'
|
|
|
+import type { PreviewAIRole } from '@/types/englishSpeaking'
|
|
|
+
|
|
|
+const props = defineProps<{
|
|
|
+ configId: string
|
|
|
+ slideIndex: number
|
|
|
+ studentArray: ClassStudent[]
|
|
|
+ courseId: string
|
|
|
+ slideWidth: number
|
|
|
+ slideHeight: number
|
|
|
+}>()
|
|
|
+
|
|
|
+// locale 派生:简单按 hostname 决定(与 src/i18n/lang.ts 镜像)
|
|
|
+const locale = ref<Locale>(
|
|
|
+ /cocorobo\.com/i.test(window.location.href) ? 'en'
|
|
|
+ : /cocorobo\.hk/i.test(window.location.href) ? 'hk'
|
|
|
+ : 'zh'
|
|
|
+)
|
|
|
+
|
|
|
+const {
|
|
|
+ summaries, loading, error, fetchClassSummary, scheduleRefetch,
|
|
|
+ aiBullets, aiLoading, aiGeneratedAt, refreshAISummary,
|
|
|
+} = useClassSummary({
|
|
|
+ configId: toRef(props, 'configId'),
|
|
|
+ studentArray: toRef(props, 'studentArray'),
|
|
|
+ locale,
|
|
|
+})
|
|
|
+
|
|
|
+// 暴露给父级(Student/index.vue)用 ref 调用
|
|
|
+defineExpose({ scheduleRefetch, fetchClassSummary })
|
|
|
+
|
|
|
+// 状态筛选 / 排序
|
|
|
+type Filter = 'all' | ClassStudentStatus
|
|
|
+const statusFilter = ref<Filter>('all')
|
|
|
+const sortBy = ref<SortKey>('time-asc')
|
|
|
+
|
|
|
+const counts = computed(() => ({
|
|
|
+ all: summaries.value.length,
|
|
|
+ submitted: summaries.value.filter(s => s.status === 'submitted').length,
|
|
|
+ unsubmitted: summaries.value.filter(s => s.status === 'unsubmitted').length,
|
|
|
+ not_started: summaries.value.filter(s => s.status === 'not_started').length,
|
|
|
+}))
|
|
|
+
|
|
|
+const filterTabs = computed(() => [
|
|
|
+ { key: 'all' as Filter, label: (lang as any).ssSpkFilterAll, count: counts.value.all },
|
|
|
+ { key: 'submitted' as Filter, label: (lang as any).ssSpkFilterSubmitted, count: counts.value.submitted },
|
|
|
+ { key: 'unsubmitted' as Filter, label: (lang as any).ssSpkFilterUnsubmitted, count: counts.value.unsubmitted },
|
|
|
+ { key: 'not_started' as Filter, label: (lang as any).ssSpkFilterNotStarted, count: counts.value.not_started },
|
|
|
+])
|
|
|
+
|
|
|
+const filteredAndSortedStudents = computed(() => {
|
|
|
+ let arr = summaries.value
|
|
|
+ if (statusFilter.value !== 'all') arr = arr.filter(s => s.status === statusFilter.value)
|
|
|
+ arr = [...arr]
|
|
|
+ if (sortBy.value === 'name-asc') {
|
|
|
+ arr.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'))
|
|
|
+ } else {
|
|
|
+ arr.sort((a, b) => {
|
|
|
+ const ta = a.completedAt || a.createdAt || ''
|
|
|
+ const tb = b.completedAt || b.createdAt || ''
|
|
|
+ return sortBy.value === 'time-desc' ? tb.localeCompare(ta) : ta.localeCompare(tb)
|
|
|
+ })
|
|
|
+ }
|
|
|
+ return arr
|
|
|
+})
|
|
|
+
|
|
|
+// 点击学生
|
|
|
+const reportModalStudent = ref<ClassStudentSummary | null>(null)
|
|
|
+const notStartedTipStudent = ref<ClassStudentSummary | null>(null)
|
|
|
+
|
|
|
+function handleStudentClick(s: ClassStudentSummary) {
|
|
|
+ if (s.status === 'not_started' || !s.sessionId) {
|
|
|
+ notStartedTipStudent.value = s
|
|
|
+ } else {
|
|
|
+ reportModalStudent.value = s
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 占位 role(MVP):后续可从 config 拉
|
|
|
+const defaultRole: PreviewAIRole = {
|
|
|
+ id: 'amy', name: 'Amy', avatar: '😊',
|
|
|
+ identity: 'Friendly Teacher',
|
|
|
+ personality: 'Patient and encouraging',
|
|
|
+ speakingStyle: 'casual', speed: 'normal',
|
|
|
+ isCustom: false, isRecommended: true,
|
|
|
+}
|
|
|
+
|
|
|
+// 卡片整体尺寸样式
|
|
|
+const cardStyle = computed(() => ({
|
|
|
+ width: props.slideWidth + 'px',
|
|
|
+ height: props.slideHeight + 'px',
|
|
|
+}))
|
|
|
+</script>
|
|
|
+
|
|
|
+<style lang="scss" scoped>
|
|
|
+.speaking-class-panel {
|
|
|
+ margin: 0 auto;
|
|
|
+ background: #f9fafb;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ padding: 24px;
|
|
|
+ box-sizing: border-box;
|
|
|
+}
|
|
|
+
|
|
|
+.content-card {
|
|
|
+ background: #fff;
|
|
|
+ border-radius: 12px;
|
|
|
+ box-shadow: 0 8px 32px rgba(0,0,0,.12);
|
|
|
+ width: 100%;
|
|
|
+ max-width: 900px;
|
|
|
+ height: 100%;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ padding: 32px 48px;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+
|
|
|
+.state-banner {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ flex: 1;
|
|
|
+ color: #9ca3af;
|
|
|
+ font-size: 14px;
|
|
|
+}
|
|
|
+
|
|
|
+.filter-row {
|
|
|
+ display: flex; align-items: center; justify-content: space-between;
|
|
|
+ margin-bottom: 16px;
|
|
|
+}
|
|
|
+.status-filter {
|
|
|
+ display: flex; gap: 4px;
|
|
|
+
|
|
|
+ button {
|
|
|
+ padding: 4px 10px;
|
|
|
+ border-radius: 6px;
|
|
|
+ background: transparent;
|
|
|
+ border: none;
|
|
|
+ font-size: 12px;
|
|
|
+ font-weight: 500;
|
|
|
+ color: #6b7280;
|
|
|
+ cursor: pointer;
|
|
|
+ transition: background .15s;
|
|
|
+ &:hover:not(.active) { background: #f3f4f6; }
|
|
|
+ &.active {
|
|
|
+ background: #facc15;
|
|
|
+ color: #111827;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.error-bar {
|
|
|
+ background: #fef2f2;
|
|
|
+ color: #b91c1c;
|
|
|
+ padding: 8px 12px;
|
|
|
+ border-radius: 8px;
|
|
|
+ font-size: 12px;
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+ margin-bottom: 12px;
|
|
|
+
|
|
|
+ button {
|
|
|
+ padding: 2px 8px;
|
|
|
+ background: #fff;
|
|
|
+ border: 1px solid #fecaca;
|
|
|
+ border-radius: 4px;
|
|
|
+ cursor: pointer;
|
|
|
+ color: #b91c1c;
|
|
|
+ font-size: 12px;
|
|
|
+ &:hover { background: #fee2e2; }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.grid-scroll {
|
|
|
+ flex: 1;
|
|
|
+ overflow-y: auto;
|
|
|
+ margin-bottom: 16px;
|
|
|
+}
|
|
|
+</style>
|