|
|
@@ -0,0 +1,201 @@
|
|
|
+<template>
|
|
|
+ <div class="modal-overlay" @click="$emit('close')">
|
|
|
+ <div class="modal-card" @click.stop>
|
|
|
+ <div class="modal-header">
|
|
|
+ <div>
|
|
|
+ <h3 class="modal-title">
|
|
|
+ {{ student.name }}{{ titleSuffix }}
|
|
|
+ </h3>
|
|
|
+ <p class="modal-subtitle">
|
|
|
+ <span v-if="student.rawStatus !== 'completed'" class="badge-in-progress">
|
|
|
+ {{ (lang as any).ssSpkInProgressBadge }}
|
|
|
+ </span>
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ <button class="close-btn" @click="$emit('close')">
|
|
|
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
|
+ <line x1="18" y1="6" x2="6" y2="18" />
|
|
|
+ <line x1="6" y1="6" x2="18" y2="18" />
|
|
|
+ </svg>
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="modal-body">
|
|
|
+ <div v-if="loading" class="state-block">
|
|
|
+ <div class="loader" />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-else-if="loadError" class="state-block">
|
|
|
+ <p class="error-msg">{{ (lang as any).ssSpkLoadFailed }}</p>
|
|
|
+ <button class="retry-btn" @click="loadReport">{{ (lang as any).ssSpkRetry }}</button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-else-if="reportPending" class="state-block">
|
|
|
+ <p>{{ (lang as any).ssSpkReportGenerating }}</p>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-else-if="hasNoMessages" class="state-block">
|
|
|
+ <p>{{ (lang as any).ssSpkActiveEmpty }}</p>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <template v-else>
|
|
|
+ <OverallReport
|
|
|
+ v-if="showOverall && report?.evaluation"
|
|
|
+ :evaluation="report.evaluation"
|
|
|
+ :role="role"
|
|
|
+ :scoreDisplayMode="'numeric'"
|
|
|
+ @restart="$emit('close')"
|
|
|
+ @complete="$emit('close')"
|
|
|
+ />
|
|
|
+ <DetailedReport
|
|
|
+ v-if="report?.evaluation?.sentenceEvaluations?.length"
|
|
|
+ :sentenceEvaluations="report.evaluation.sentenceEvaluations"
|
|
|
+ :class="{ 'mt-block': showOverall }"
|
|
|
+ />
|
|
|
+ </template>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script lang="ts" setup>
|
|
|
+import { ref, computed, onMounted, watch } from 'vue'
|
|
|
+import { lang } from '@/main'
|
|
|
+import { createDialogueApi } from '@/views/Editor/EnglishSpeaking/services/llmService'
|
|
|
+import OverallReport from '@/views/Editor/EnglishSpeaking/preview/OverallReport.vue'
|
|
|
+import DetailedReport from '@/views/Editor/EnglishSpeaking/preview/DetailedReport.vue'
|
|
|
+import type { PreviewAIRole, DialogueReport } from '@/types/englishSpeaking'
|
|
|
+import type { ClassStudentSummary } from './types'
|
|
|
+
|
|
|
+const props = defineProps<{
|
|
|
+ student: ClassStudentSummary
|
|
|
+ role: PreviewAIRole
|
|
|
+}>()
|
|
|
+defineEmits<{ close: [] }>()
|
|
|
+
|
|
|
+const loading = ref(false)
|
|
|
+const loadError = ref(false)
|
|
|
+const report = ref<DialogueReport | null>(null)
|
|
|
+
|
|
|
+const titleSuffix = computed(() =>
|
|
|
+ props.student.rawStatus === 'completed'
|
|
|
+ ? (lang as any).ssSpkReportTitleSuffix
|
|
|
+ : (lang as any).ssSpkRecordTitleSuffix,
|
|
|
+)
|
|
|
+
|
|
|
+const reportPending = computed(() =>
|
|
|
+ props.student.rawStatus === 'completed'
|
|
|
+ && !!report.value
|
|
|
+ && report.value.status !== 'ready'
|
|
|
+ && report.value.status !== 'failed',
|
|
|
+)
|
|
|
+
|
|
|
+const showOverall = computed(() =>
|
|
|
+ props.student.rawStatus === 'completed'
|
|
|
+ && report.value?.status === 'ready',
|
|
|
+)
|
|
|
+
|
|
|
+const hasNoMessages = computed(() =>
|
|
|
+ !loading.value
|
|
|
+ && !loadError.value
|
|
|
+ && !!report.value
|
|
|
+ && (!report.value.evaluation
|
|
|
+ || (report.value.evaluation.sentenceEvaluations || []).length === 0)
|
|
|
+ && props.student.rawStatus !== 'completed',
|
|
|
+)
|
|
|
+
|
|
|
+async function loadReport() {
|
|
|
+ if (!props.student.sessionId) return
|
|
|
+ loading.value = true
|
|
|
+ loadError.value = false
|
|
|
+ try {
|
|
|
+ const api = createDialogueApi()
|
|
|
+ report.value = await api.getReport(props.student.sessionId)
|
|
|
+ }
|
|
|
+ catch (e) {
|
|
|
+ console.error('[StudentReportModal] getReport failed:', e)
|
|
|
+ loadError.value = true
|
|
|
+ }
|
|
|
+ finally {
|
|
|
+ loading.value = false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+onMounted(loadReport)
|
|
|
+watch(() => props.student.sessionId, loadReport)
|
|
|
+</script>
|
|
|
+
|
|
|
+<style lang="scss" scoped>
|
|
|
+.modal-overlay {
|
|
|
+ position: fixed; inset: 0;
|
|
|
+ background: rgba(0,0,0,.5);
|
|
|
+ display: flex; align-items: center; justify-content: center;
|
|
|
+ z-index: 50;
|
|
|
+}
|
|
|
+.modal-card {
|
|
|
+ background: #fff;
|
|
|
+ border-radius: 12px;
|
|
|
+ width: 100%; max-width: 720px;
|
|
|
+ max-height: 85vh;
|
|
|
+ margin: 0 16px;
|
|
|
+ display: flex; flex-direction: column;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+.modal-header {
|
|
|
+ display: flex; align-items: center; justify-content: space-between;
|
|
|
+ padding: 16px 24px;
|
|
|
+ border-bottom: 1px solid #e5e7eb;
|
|
|
+}
|
|
|
+.modal-title {
|
|
|
+ font-size: 16px; font-weight: 600; color: #111827; margin: 0;
|
|
|
+}
|
|
|
+.modal-subtitle {
|
|
|
+ font-size: 13px; color: #6b7280; margin: 2px 0 0;
|
|
|
+}
|
|
|
+.badge-in-progress {
|
|
|
+ margin-left: 8px;
|
|
|
+ color: #d97706;
|
|
|
+ font-weight: 500;
|
|
|
+}
|
|
|
+.close-btn {
|
|
|
+ background: transparent; border: none;
|
|
|
+ color: #9ca3af; cursor: pointer;
|
|
|
+ &:hover { color: #4b5563; }
|
|
|
+}
|
|
|
+.modal-body {
|
|
|
+ flex: 1;
|
|
|
+ overflow-y: auto;
|
|
|
+ padding: 16px 24px;
|
|
|
+}
|
|
|
+
|
|
|
+.state-block {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ padding: 40px 0;
|
|
|
+ gap: 12px;
|
|
|
+ color: #6b7280;
|
|
|
+ font-size: 13px;
|
|
|
+}
|
|
|
+.error-msg { color: #dc2626; }
|
|
|
+.retry-btn {
|
|
|
+ padding: 6px 16px;
|
|
|
+ background: #f3f4f6;
|
|
|
+ border: 1px solid #e5e7eb;
|
|
|
+ border-radius: 6px;
|
|
|
+ cursor: pointer;
|
|
|
+ font-size: 13px;
|
|
|
+ &:hover { background: #e5e7eb; }
|
|
|
+}
|
|
|
+.loader {
|
|
|
+ width: 24px; height: 24px;
|
|
|
+ border: 2px solid #e5e7eb;
|
|
|
+ border-top-color: #facc15;
|
|
|
+ border-radius: 50%;
|
|
|
+ animation: spin 1s linear infinite;
|
|
|
+}
|
|
|
+@keyframes spin { from { transform: rotate(0); } to { transform: rotate(360deg); } }
|
|
|
+
|
|
|
+.mt-block { margin-top: 16px; }
|
|
|
+</style>
|