Просмотр исходного кода

feat(speaking): add SpeakingClassPanel main panel

jimmylee 2 месяцев назад
Родитель
Сommit
9d4ad6a7b0
1 измененных файлов с 252 добавлено и 0 удалено
  1. 252 0
      src/views/Student/components/SpeakingClassPanel/index.vue

+ 252 - 0
src/views/Student/components/SpeakingClassPanel/index.vue

@@ -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>