feat: Implement instructor course management, including student progress tracking and course content views, alongside admin user management.
This commit is contained in:
parent
c9381b9385
commit
be5b9756be
11 changed files with 2116 additions and 1386 deletions
470
frontend_management/components/course/QuizResultsTab.vue
Normal file
470
frontend_management/components/course/QuizResultsTab.vue
Normal file
|
|
@ -0,0 +1,470 @@
|
|||
<template>
|
||||
<div>
|
||||
<!-- No Quizzes State -->
|
||||
<div v-if="quizzes.length === 0" class="text-center py-10 text-gray-500">
|
||||
<q-icon name="quiz" size="60px" color="grey-4" class="mb-4" />
|
||||
<p>หลักสูตรนี้ยังไม่มีแบบทดสอบ</p>
|
||||
</div>
|
||||
|
||||
<!-- Quiz Selection -->
|
||||
<div v-else class="space-y-6">
|
||||
<div class="flex flex-col md:flex-row items-center gap-4 bg-white p-4 rounded-lg border border-gray-200">
|
||||
<div class="flex-1 w-full">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">เลือกแบบทดสอบ</label>
|
||||
<q-select
|
||||
v-model="selectedQuiz"
|
||||
:options="quizzes"
|
||||
option-label="label"
|
||||
outlined
|
||||
dense
|
||||
emit-value
|
||||
map-options
|
||||
class="bg-white"
|
||||
@update:model-value="handleQuizChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search and Filter -->
|
||||
<q-card flat bordered class="rounded-lg mb-6" v-if="selectedQuiz">
|
||||
<q-card-section>
|
||||
<div class="flex flex-col md:flex-row gap-4">
|
||||
<q-input
|
||||
v-model="search"
|
||||
outlined
|
||||
dense
|
||||
placeholder="ค้นหาผู้เรียน..."
|
||||
class="flex-1"
|
||||
debounce="600"
|
||||
@update:model-value="handleSearch"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<q-icon name="search" />
|
||||
</template>
|
||||
<template v-slot:append v-if="search">
|
||||
<q-icon name="close" class="cursor-pointer" @click="search = ''; handleSearch()" />
|
||||
</template>
|
||||
</q-input>
|
||||
|
||||
<q-select
|
||||
v-model="statusFilter"
|
||||
outlined
|
||||
dense
|
||||
:options="statusFilterOptions"
|
||||
option-value="value"
|
||||
option-label="label"
|
||||
emit-value
|
||||
map-options
|
||||
class="w-full md:w-40"
|
||||
@update:model-value="handleSearch"
|
||||
/>
|
||||
|
||||
<q-select
|
||||
v-model="pagination.rowsPerPage"
|
||||
:options="limitOptions"
|
||||
option-value="value"
|
||||
option-label="label"
|
||||
outlined
|
||||
dense
|
||||
emit-value
|
||||
map-options
|
||||
label="จำนวนต่อหน้า"
|
||||
class="w-full md:w-32"
|
||||
@update:model-value="handleLimitChange"
|
||||
/>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div v-if="stats" class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<q-card flat bordered class="bg-blue-50 border-blue-100">
|
||||
<q-card-section>
|
||||
<div class="text-blue-800 text-sm font-medium">คะแนนเฉลี่ย</div>
|
||||
<div class="text-2xl font-bold text-blue-900 mt-1">
|
||||
{{ stats.averageScore.toFixed(1) }} / {{ stats.totalScore }}
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<q-card flat bordered class="bg-green-50 border-green-100">
|
||||
<q-card-section>
|
||||
<div class="text-green-800 text-sm font-medium">ผู้ที่สอบผ่าน</div>
|
||||
<div class="text-2xl font-bold text-green-900 mt-1">
|
||||
{{ stats.passCount }} คน
|
||||
<span class="text-sm font-normal text-green-700">({{ stats.passRate.toFixed(1) }}%)</span>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<q-card flat bordered class="bg-purple-50 border-purple-100">
|
||||
<q-card-section>
|
||||
<div class="text-purple-800 text-sm font-medium">จำนวนผู้เข้าสอบ</div>
|
||||
<div class="text-2xl font-bold text-purple-900 mt-1">
|
||||
{{ stats.totalStudents }} คน
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<!-- Students Table -->
|
||||
<div v-if="selectedQuiz">
|
||||
<q-table
|
||||
:rows="students"
|
||||
:columns="columns"
|
||||
row-key="user_id"
|
||||
:loading="loading"
|
||||
flat
|
||||
bordered
|
||||
hide-pagination
|
||||
:pagination="pagination"
|
||||
no-data-label="ยังไม่มีข้อมูล"
|
||||
>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props" @click="openStudentDetail(props.row.user_id)" class="cursor-pointer hover:bg-gray-50">
|
||||
<q-td key="student" :props="props">
|
||||
<div class="flex items-center gap-3">
|
||||
<q-avatar size="32px">
|
||||
<img :src="props.row.avatar_url || 'https://cdn.quasar.dev/img/boy-avatar.png'" />
|
||||
</q-avatar>
|
||||
<div>
|
||||
<div class="font-medium">{{ props.row.first_name }} {{ props.row.last_name }}</div>
|
||||
<div class="text-xs text-gray-500">{{ props.row.email }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-td>
|
||||
<q-td key="score" :props="props" class="text-center">
|
||||
<div class="font-medium">
|
||||
{{ props.row.best_score }} / {{ currentQuizData?.total_score }}
|
||||
</div>
|
||||
</q-td>
|
||||
<q-td key="status" :props="props" class="text-center">
|
||||
<q-chip
|
||||
:color="props.row.is_passed ? 'green-1' : 'red-1'"
|
||||
:text-color="props.row.is_passed ? 'green-9' : 'red-9'"
|
||||
size="md"
|
||||
>
|
||||
{{ props.row.is_passed ? 'ผ่าน' : 'ไม่ผ่าน' }}
|
||||
</q-chip>
|
||||
</q-td>
|
||||
<q-td key="attempts" :props="props" class="text-center">
|
||||
{{ props.row.total_attempts }} ครั้ง
|
||||
</q-td>
|
||||
<q-td key="last_attempt" :props="props">
|
||||
<div v-if="props.row.latest_attempt">
|
||||
{{ formatDate(props.row.latest_attempt.completed_at) }}
|
||||
</div>
|
||||
<div v-else class="text-gray-400">-</div>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
</q-table>
|
||||
|
||||
<!-- Custom Pagination -->
|
||||
<div v-if="pagination.rowsNumber > pagination.rowsPerPage" class="flex justify-center mt-4">
|
||||
<q-pagination
|
||||
v-model="pagination.page"
|
||||
:max="Math.ceil(pagination.rowsNumber / pagination.rowsPerPage)"
|
||||
:max-pages="6"
|
||||
direction-links
|
||||
boundary-links
|
||||
@update:model-value="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Student Quiz Detail Dialog -->
|
||||
<q-dialog v-model="showDetailDialog" maximized transition-show="slide-up" transition-hide="slide-down">
|
||||
<q-card class="bg-gray-50">
|
||||
<q-card-section class="bg-white shadow-sm sticky top-0 z-10">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex items-center gap-4" v-if="attemptDetail">
|
||||
<q-avatar size="48px" color="primary" text-color="white">
|
||||
<img v-if="attemptDetail.student.user_id" :src="`https://ui-avatars.com/api/?name=${attemptDetail.student.first_name}+${attemptDetail.student.last_name}`" />
|
||||
<span v-else>{{ attemptDetail.student.username.charAt(0).toUpperCase() }}</span>
|
||||
</q-avatar>
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">{{ attemptDetail.student.first_name }} {{ attemptDetail.student.last_name }}</h2>
|
||||
<div class="text-gray-500 text-sm">
|
||||
{{ attemptDetail.student.email }} · ครั้งที่ {{ attemptDetail.attempt_number }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="h-12 w-48 bg-gray-200 animate-pulse rounded"></div>
|
||||
<q-btn icon="close" flat round dense v-close-popup />
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section class="p-6 max-w-4xl mx-auto w-full">
|
||||
<div v-if="loadingDetail" class="flex justify-center py-20">
|
||||
<q-spinner color="primary" size="50px" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="attemptDetail" class="space-y-6">
|
||||
<!-- Summary Stats -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<q-card flat bordered class="text-center p-4">
|
||||
<div class="text-gray-500 text-sm mb-1">คะแนนที่ได้</div>
|
||||
<div class="text-3xl font-bold" :class="attemptDetail.is_passed ? 'text-green-600' : 'text-red-600'">
|
||||
{{ attemptDetail.score }} / {{ attemptDetail.total_score }}
|
||||
</div>
|
||||
</q-card>
|
||||
<q-card flat bordered class="text-center p-4">
|
||||
<div class="text-gray-500 text-sm mb-1">สถานะ</div>
|
||||
<q-chip
|
||||
:color="attemptDetail.is_passed ? 'green-1' : 'red-1'"
|
||||
:text-color="attemptDetail.is_passed ? 'green-9' : 'red-9'"
|
||||
class="font-bold"
|
||||
>
|
||||
{{ attemptDetail.is_passed ? 'ผ่าน' : 'ไม่ผ่าน' }}
|
||||
</q-chip>
|
||||
</q-card>
|
||||
<q-card flat bordered class="text-center p-4">
|
||||
<div class="text-gray-500 text-sm mb-1">ตอบถูก</div>
|
||||
<div class="text-xl font-semibold">
|
||||
{{ attemptDetail.correct_answers }} / {{ attemptDetail.total_questions }} ข้อ
|
||||
</div>
|
||||
</q-card>
|
||||
<q-card flat bordered class="text-center p-4">
|
||||
<div class="text-gray-500 text-sm mb-1">วันที่ทำ</div>
|
||||
<div class="text-xl font-semibold">
|
||||
{{ formatDate(attemptDetail.completed_at) }}
|
||||
<!-- Note: Ideally calculate duration if started_at is available -->
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<!-- Questions Review -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div class="p-4 border-b bg-gray-50 font-semibold text-gray-700">
|
||||
รายละเอียดการตอบคำถาม
|
||||
</div>
|
||||
<div class="divide-y">
|
||||
<div
|
||||
v-for="(answer, index) in attemptDetail.answers_review"
|
||||
:key="answer.question_id"
|
||||
class="p-6"
|
||||
>
|
||||
<div class="flex gap-4">
|
||||
<div class="flex-none">
|
||||
<div class="w-8 h-8 rounded-full flex items-center justify-center font-bold text-white"
|
||||
:class="answer.is_correct ? 'bg-green-500' : 'bg-red-500'"
|
||||
>
|
||||
{{ index + 1 }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="font-medium text-lg mb-3">{{ answer.question_text?.th || 'ไม่สามารถโหลดคำถามได้' }}</div>
|
||||
|
||||
<div class="p-3 rounded-lg border-l-4"
|
||||
:class="answer.is_correct ? 'bg-green-50 border-green-500' : 'bg-red-50 border-red-500'"
|
||||
>
|
||||
<div class="text-sm text-gray-500 mb-1">คำตอบที่เลือก:</div>
|
||||
<div class="font-medium" :class="answer.is_correct ? 'text-green-800' : 'text-red-800'">
|
||||
{{ answer.selected_choice_text?.th || '-' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 text-right text-sm text-gray-500">
|
||||
คะแนน: {{ answer.score }} / {{ answer.question_score }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useQuasar } from 'quasar';
|
||||
import {
|
||||
instructorService,
|
||||
type ChapterResponse,
|
||||
type QuizScoreStudentResponse,
|
||||
type QuizScoresData,
|
||||
type QuizAttemptDetailData
|
||||
} from '~/services/instructor.service';
|
||||
|
||||
interface Props {
|
||||
courseId: number;
|
||||
chapters: ChapterResponse[];
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const $q = useQuasar();
|
||||
|
||||
// Columns
|
||||
const columns = [
|
||||
{ name: 'student', label: 'ผู้เรียน', align: 'left' as const, field: 'first_name' },
|
||||
{ name: 'score', label: 'คะแนนที่ดีที่สุด', align: 'center' as const, field: 'best_score' },
|
||||
{ name: 'status', label: 'สถานะ', align: 'center' as const, field: 'is_passed' },
|
||||
{ name: 'attempts', label: 'จำนวนครั้งที่สอบ', align: 'center' as const, field: 'total_attempts' },
|
||||
{ name: 'last_attempt', label: 'สอบล่าสุดเมื่อ', align: 'left' as const, field: 'latest_attempt' },
|
||||
];
|
||||
|
||||
// State
|
||||
const selectedQuiz = ref<any>(null); // Option object
|
||||
const loading = ref(false);
|
||||
const students = ref<QuizScoreStudentResponse[]>([]);
|
||||
const currentQuizData = ref<QuizScoresData | null>(null);
|
||||
const search = ref('');
|
||||
const statusFilter = ref('all');
|
||||
|
||||
// Student Detail Dialog
|
||||
const showDetailDialog = ref(false);
|
||||
const loadingDetail = ref(false);
|
||||
const attemptDetail = ref<QuizAttemptDetailData | null>(null);
|
||||
|
||||
const pagination = ref({
|
||||
page: 1,
|
||||
rowsPerPage: 20,
|
||||
rowsNumber: 0
|
||||
});
|
||||
|
||||
const limitOptions = [
|
||||
{ label: '5 รายการ', value: 5 },
|
||||
{ label: '10 รายการ', value: 10 },
|
||||
{ label: '20 รายการ', value: 20 },
|
||||
{ label: '50 รายการ', value: 50 },
|
||||
];
|
||||
|
||||
const statusFilterOptions = [
|
||||
{ label: 'ทั้งหมด', value: 'all' },
|
||||
{ label: 'ผ่าน', value: 'passed' },
|
||||
{ label: 'ไม่ผ่าน', value: 'failed' }
|
||||
];
|
||||
|
||||
// Computed
|
||||
const quizzes = computed(() => {
|
||||
const list: any[] = [];
|
||||
props.chapters.forEach(chapter => {
|
||||
chapter.lessons.forEach(lesson => {
|
||||
// Assuming lesson.type === 'QUIZ' identifies a quiz
|
||||
// Also checking if quiz object exists might be safer if type is not strictly enforcing it
|
||||
if (lesson.type === 'QUIZ') {
|
||||
list.push({
|
||||
label: `${chapter.title.th} - ${lesson.title.th}`,
|
||||
value: lesson.id,
|
||||
total_score: lesson.quiz?.questions ? lesson.quiz.questions.reduce((sum, q) => sum + (q.score || 0), 0) : 0
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
return list;
|
||||
});
|
||||
|
||||
const stats = computed(() => {
|
||||
if (!currentQuizData.value || !students.value.length) return null;
|
||||
|
||||
// Basic stats logic from current page data or explicit API data if available
|
||||
const passed = students.value.filter(s => s.is_passed).length;
|
||||
const avg = students.value.reduce((sum, s) => sum + s.best_score, 0) / students.value.length;
|
||||
|
||||
return {
|
||||
totalStudents: pagination.value.rowsNumber, // Total from API
|
||||
passCount: passed,
|
||||
averageScore: avg || 0,
|
||||
totalScore: currentQuizData.value.total_score,
|
||||
passRate: (passed / students.value.length) * 100 || 0
|
||||
};
|
||||
});
|
||||
|
||||
// Methods
|
||||
const handleQuizChange = () => {
|
||||
pagination.value.page = 1;
|
||||
search.value = '';
|
||||
statusFilter.value = 'all';
|
||||
fetchScores();
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.value.page = 1;
|
||||
fetchScores();
|
||||
};
|
||||
|
||||
const handleLimitChange = () => {
|
||||
pagination.value.page = 1;
|
||||
fetchScores();
|
||||
};
|
||||
|
||||
const handlePageChange = (vals:any) => {
|
||||
fetchScores();
|
||||
}
|
||||
|
||||
const fetchScores = async () => {
|
||||
if (!selectedQuiz.value) return;
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
let isPassed: boolean | undefined = undefined;
|
||||
if (statusFilter.value === 'passed') isPassed = true;
|
||||
if (statusFilter.value === 'failed') isPassed = false;
|
||||
|
||||
const response = await instructorService.getLessonQuizScores(
|
||||
props.courseId,
|
||||
selectedQuiz.value,
|
||||
pagination.value.page,
|
||||
pagination.value.rowsPerPage,
|
||||
search.value,
|
||||
isPassed
|
||||
);
|
||||
|
||||
currentQuizData.value = response.data;
|
||||
students.value = response.data.students;
|
||||
|
||||
pagination.value.rowsNumber = response.total;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch quiz scores:', error);
|
||||
$q.notify({ type: 'negative', message: 'ไม่สามารถโหลดผลสอบได้', position: 'top' });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openStudentDetail = async (studentId: number) => {
|
||||
showDetailDialog.value = true;
|
||||
loadingDetail.value = true;
|
||||
attemptDetail.value = null;
|
||||
|
||||
try {
|
||||
// Assuming we want the detail for the current lesson/quiz
|
||||
const response = await instructorService.getStudentQuizAttemptDetail(
|
||||
props.courseId,
|
||||
selectedQuiz.value,
|
||||
studentId
|
||||
);
|
||||
attemptDetail.value = response.data;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch student quiz detail:', error);
|
||||
$q.notify({ type: 'negative', message: 'ไม่สามารถโหลดรายละเอียดการสอบได้', position: 'top' });
|
||||
showDetailDialog.value = false;
|
||||
} finally {
|
||||
loadingDetail.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
if (!dateStr) return '-';
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('th-TH', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
if (quizzes.value.length > 0) {
|
||||
selectedQuiz.value = quizzes.value[0].value;
|
||||
fetchScores();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
Loading…
Add table
Add a link
Reference in a new issue