feat: Implement My Courses page with course cards, filtering, and certificate download functionality.
This commit is contained in:
parent
9ed2347843
commit
c4f68eb927
4 changed files with 107 additions and 157 deletions
|
|
@ -37,6 +37,8 @@ interface CourseCardProps {
|
||||||
showStudyAgain?: boolean
|
showStudyAgain?: boolean
|
||||||
/** URL for course thumbnail image */
|
/** URL for course thumbnail image */
|
||||||
image?: string
|
image?: string
|
||||||
|
/** Loading state for actions */
|
||||||
|
loading?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<CourseCardProps>()
|
const props = defineProps<CourseCardProps>()
|
||||||
|
|
@ -156,6 +158,7 @@ const displayDescription = computed(() => getLocalizedText(props.description))
|
||||||
class="w-full font-black shadow-lg shadow-emerald-600/20"
|
class="w-full font-black shadow-lg shadow-emerald-600/20"
|
||||||
style="background: linear-gradient(135deg, #10b981 0%, #059669 100%); color: white;"
|
style="background: linear-gradient(135deg, #10b981 0%, #059669 100%); color: white;"
|
||||||
:label="$t('course.downloadCertificate')"
|
:label="$t('course.downloadCertificate')"
|
||||||
|
:loading="loading"
|
||||||
@click="emit('viewCertificate')"
|
@click="emit('viewCertificate')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,18 @@ export interface QuizResult {
|
||||||
// - ดึงข้อมูลคอร์ส (Public & Protected)
|
// - ดึงข้อมูลคอร์ส (Public & Protected)
|
||||||
// - ลงทะเบียนเรียน (Enroll)
|
// - ลงทะเบียนเรียน (Enroll)
|
||||||
// - ติดตามความคืบหน้าการเรียน (Progress tracking)
|
// - ติดตามความคืบหน้าการเรียน (Progress tracking)
|
||||||
|
// Interface สำหรับ Certificate
|
||||||
|
export interface Certificate {
|
||||||
|
certificate_id: number
|
||||||
|
course_id: number
|
||||||
|
course_title: {
|
||||||
|
en: string
|
||||||
|
th: string
|
||||||
|
}
|
||||||
|
issued_at: string
|
||||||
|
download_url: string
|
||||||
|
}
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
export const useCourse = () => {
|
export const useCourse = () => {
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
|
|
@ -522,6 +534,59 @@ export const useCourse = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// ฟังก์ชันสร้างใบ Certificate (Create/Generate)
|
||||||
|
// Endpoint: POST /certificates/:courseId/generate
|
||||||
|
const generateCertificate = async (courseId: number) => {
|
||||||
|
try {
|
||||||
|
const data = await $fetch<{ code: number; message: string; data: Certificate }>(`${API_BASE_URL}/certificates/${courseId}/generate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: token.value ? {
|
||||||
|
Authorization: `Bearer ${token.value}`
|
||||||
|
} : {}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: data.data
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Generate certificate failed:', err)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: err.data?.message || err.message || 'Error generating certificate',
|
||||||
|
code: err.data?.code,
|
||||||
|
status: err.status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ฟังก์ชันดึงใบ Certificate ของคอร์สที่ระบุ (แบบเดี่ยว - ใหม่)
|
||||||
|
// Endpoint: GET /certificates/:courseId
|
||||||
|
const getCertificate = async (courseId: number) => {
|
||||||
|
try {
|
||||||
|
const data = await $fetch<{ code: number; message: string; data: Certificate }>(`${API_BASE_URL}/certificates/${courseId}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: token.value ? {
|
||||||
|
Authorization: `Bearer ${token.value}`
|
||||||
|
} : {}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: data.data // Return single certificate object
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Get certificate failed:', err)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: err.data?.message || err.message || 'Error getting certificate',
|
||||||
|
code: err.data?.code,
|
||||||
|
status: err.status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
fetchCourses,
|
fetchCourses,
|
||||||
fetchCourseById,
|
fetchCourseById,
|
||||||
|
|
@ -533,7 +598,9 @@ export const useCourse = () => {
|
||||||
saveVideoProgress,
|
saveVideoProgress,
|
||||||
fetchVideoProgress,
|
fetchVideoProgress,
|
||||||
markLessonComplete,
|
markLessonComplete,
|
||||||
submitQuiz
|
submitQuiz,
|
||||||
|
generateCertificate,
|
||||||
|
getCertificate
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@ useHead({
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const showEnrollModal = ref(false)
|
const showEnrollModal = ref(false)
|
||||||
const showCertModal = ref(false)
|
|
||||||
const activeFilter = ref<'all' | 'progress' | 'completed'>('all')
|
const activeFilter = ref<'all' | 'progress' | 'completed'>('all')
|
||||||
const { currentUser } = useAuth()
|
const { currentUser } = useAuth()
|
||||||
|
|
||||||
|
|
@ -36,9 +35,10 @@ const getLocalizedText = (text: string | { th: string; en: string } | undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Data Handling
|
// Data Handling
|
||||||
const { fetchEnrolledCourses } = useCourse()
|
const { fetchEnrolledCourses, getCertificate, generateCertificate } = useCourse()
|
||||||
const enrolledCourses = ref<any[]>([])
|
const enrolledCourses = ref<any[]>([])
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
|
const isDownloadingCert = ref(false)
|
||||||
|
|
||||||
const loadEnrolledCourses = async () => {
|
const loadEnrolledCourses = async () => {
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
|
|
@ -85,17 +85,40 @@ onMounted(() => {
|
||||||
})
|
})
|
||||||
|
|
||||||
// Certificate Handling
|
// Certificate Handling
|
||||||
const selectedCertCourse = ref<any>(null)
|
const downloadingCourseId = ref<number | null>(null)
|
||||||
|
// Certificate Handling
|
||||||
|
// Certificate Handling
|
||||||
|
const downloadCertificate = async (course: any) => {
|
||||||
|
if (!course) return
|
||||||
|
downloadingCourseId.value = course.id
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Try to GET existing certificate
|
||||||
|
let res = await getCertificate(course.id)
|
||||||
|
|
||||||
const openCertModal = (course: any) => {
|
// 2. If not found (or error), try to GENERATE new one
|
||||||
selectedCertCourse.value = course
|
if (!res.success) {
|
||||||
showCertModal.value = true
|
res = await generateCertificate(course.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mock certificate download action
|
// 3. Handle Result
|
||||||
const downloadCertificate = () => {
|
if (res.success && res.data) {
|
||||||
showCertModal.value = false
|
const cert = res.data
|
||||||
alert(`Start downloading certificate for ${selectedCertCourse.value?.title || 'course'}...`)
|
if (cert.download_url) {
|
||||||
|
window.open(cert.download_url, '_blank')
|
||||||
|
} else {
|
||||||
|
// Fallback if no URL but success (maybe show message)
|
||||||
|
console.warn('Certificate ready but no URL')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Silent fail or minimal log, or maybe use a toast if available, but avoid $q if undefined
|
||||||
|
console.error(res.error || 'Failed to get certificate')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
} finally {
|
||||||
|
downloadingCourseId.value = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -148,7 +171,8 @@ const downloadCertificate = () => {
|
||||||
:completed="true"
|
:completed="true"
|
||||||
show-certificate
|
show-certificate
|
||||||
show-study-again
|
show-study-again
|
||||||
@view-certificate="openCertModal(course)"
|
:loading="downloadingCourseId === course.id"
|
||||||
|
@view-certificate="downloadCertificate(course)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -190,50 +214,7 @@ const downloadCertificate = () => {
|
||||||
</q-card>
|
</q-card>
|
||||||
</q-dialog>
|
</q-dialog>
|
||||||
|
|
||||||
<!-- MODAL: Certificate Preview -->
|
|
||||||
<q-dialog v-model="showCertModal" backdrop-filter="blur(8px)" full-width full-height>
|
|
||||||
<div class="relative bg-white text-slate-900 w-full max-w-5xl aspect-[1.414/1] shadow-2xl rounded-sm p-12 flex flex-col items-center text-center overflow-hidden m-auto">
|
|
||||||
<!-- Close Button -->
|
|
||||||
<q-btn icon="close" flat round dense class="absolute top-4 right-4 text-slate-400" @click="showCertModal = false" />
|
|
||||||
|
|
||||||
<!-- Border Decoration -->
|
|
||||||
<div class="absolute inset-4 border-4 border-double border-slate-200 pointer-events-none"></div>
|
|
||||||
|
|
||||||
<div class="relative z-10 w-full h-full flex flex-col justify-center">
|
|
||||||
<div class="w-16 h-16 mx-auto mb-6 bg-blue-600 text-white flex items-center justify-center rounded-full font-serif font-bold text-3xl">E</div>
|
|
||||||
|
|
||||||
<h1 class="text-4xl font-serif font-bold mb-2 uppercase tracking-widest text-slate-800">{{ $t('certificate.title') }}</h1>
|
|
||||||
<div class="w-32 h-1 bg-amber-400 mx-auto mb-8"></div>
|
|
||||||
|
|
||||||
<p class="text-slate-500 mb-4 text-lg italic font-serif">{{ $t('certificate.presentedTo') }}</p>
|
|
||||||
|
|
||||||
<h2 class="text-5xl font-serif font-bold text-blue-900 mb-6 font-handwriting">{{ currentUser?.firstName }} {{ currentUser?.lastName }}</h2>
|
|
||||||
|
|
||||||
<p class="text-slate-500 mb-6 text-lg italic font-serif">{{ $t('certificate.completedDesc') }}</p>
|
|
||||||
|
|
||||||
<h3 class="text-2xl font-bold text-slate-800 mb-12">{{ selectedCertCourse?.title || 'Course Title' }}</h3>
|
|
||||||
|
|
||||||
<div class="flex justify-end items-end px-12 mt-auto">
|
|
||||||
<div class="text-center">
|
|
||||||
<div class="w-48 border-b border-slate-800 mb-2 pb-2">{{ new Date().toLocaleDateString('th-TH', { year: 'numeric', month: '2-digit', day: '2-digit' }) }}</div>
|
|
||||||
<div class="text-xs text-slate-500 uppercase tracking-wider">{{ $t('certificate.issueDate') }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="absolute bottom-6 left-0 w-full text-center">
|
|
||||||
<q-btn
|
|
||||||
unelevated
|
|
||||||
rounded
|
|
||||||
color="primary"
|
|
||||||
icon="download"
|
|
||||||
:label="$t('certificate.downloadPDF')"
|
|
||||||
class="font-bold px-6"
|
|
||||||
@click="downloadCertificate"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</q-dialog>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
# 📚 ระบบห้องเรียน (Classroom Module)
|
|
||||||
|
|
||||||
> **Location:** `pages/classroom/`
|
|
||||||
|
|
||||||
โมดูลนี้เป็นหัวใจหลักของระบบ e-Learning ทำหน้าที่จัดการประสบการณ์การเรียนรู้ทั้งหมด ตั้งแต่การรับชมวิดีโอ การทำแบบฝึกหัด ไปจนถึงการวัดผลสอบ ออกแบบโดยเน้น **"ผู้เรียนเป็นศูนย์กลาง" (Learner-Centric)** และมีความปลอดภัยสูง
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🗺️ User Flow (เส้นทางการเรียนรู้)
|
|
||||||
|
|
||||||
1. **เข้าคลาสเรียน**: ผู้ใช้เลือกคอร์สจาก "คอร์สของฉัน" ระบบจะพาเข้าสู่หน้า `learning.vue`
|
|
||||||
2. **เรียนรู้เนื้อหา**:
|
|
||||||
- ระบบเช็คสิทธิ์ (Locked/Unlocked) อัตโนมัติ
|
|
||||||
- ดูวิดีโอ (Video Player) พร้อมระบบจำเวลาเดิม (Resume)
|
|
||||||
- ดาวน์โหลดเอกสารประกอบ (Attachments)
|
|
||||||
3. **บันทึกผล**: เมื่อดูจบหรือทำ Progress ระบบจะบันทึกให้อัตโนมัติแบบ Real-time
|
|
||||||
4. **วัดผลสอบ**: เมื่อเรียนครบ เข้าสู่หน้า `quiz.vue` เพื่อทำข้อสอบจับเวลา
|
|
||||||
5. **ประเมินผล**: ทราบผลสอบทันที (Pass/Fail) พร้อมเฉลยคะแนน
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 📺 หน้าเรียน (`learning.vue`)
|
|
||||||
|
|
||||||
**หน้าที่:** พื้นที่หลักสำหรับการเรียนรู้ รองรับ Video Streaming และเอกสารการสอน
|
|
||||||
|
|
||||||
### 🎨 การออกแบบ (Design Concept)
|
|
||||||
|
|
||||||
- **Focus Mode**: ใช้ Layout พิเศษ (`layout: false`) ตัดเมนูรบกวนออก ให้พื้นที่วิดีโอใหญ่ที่สุด
|
|
||||||
- **Smart Sidebar**: แถบเมนูบทเรียนทางซ้าย
|
|
||||||
- _Desktop_: กางออกเสมอ เพื่อให้เห็นภาพรวมเนื้อหา
|
|
||||||
- _Mobile_: ซ่อนอัตโนมัติ (Drawer) เรียกดูได้เมื่อต้องการ
|
|
||||||
- **Responsive Player**: เครื่องเล่นวิดีโอที่ปรับขนาดตามหน้าจอ และมี Custom Controls ที่สวยงาม
|
|
||||||
|
|
||||||
### ⚙️ ระบบเบื้องหลัง (Technical Logic)
|
|
||||||
|
|
||||||
- **Video Resume**: จำวินาทีล่าสุดที่ดูค้างไว้ กลับมาดูต่อได้ทันที (ดึงจาก Hybrid Storage)
|
|
||||||
- **Hybrid Saving System**:
|
|
||||||
1. **Local Save**: บันทึกทุก 5 วินาทีลงเครื่องผู้ใช้ (กันเน็ตหลุด)
|
|
||||||
2. **Server Save**: บันทึกทุก 15 วินาทีลงฐานข้อมูล (Sync ข้ามอุปกรณ์)
|
|
||||||
3. **Anti-Cheat**: ระบบป้องกันการลากข้าม (Seek Forward) ไปยังจุดที่ยังดูไม่ถึง
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 📝 หน้าสอบ (`quiz.vue`)
|
|
||||||
|
|
||||||
**หน้าที่:** ระบบสอบวัดผลมาตรฐาน พร้อมตัวจับเวลาและการคำนวณคะแนน
|
|
||||||
|
|
||||||
### <20> รูปแบบการทำงาน (State Machine)
|
|
||||||
|
|
||||||
หน้าจอนี้ทำงานแบบ **Single Page Application** เปลี่ยนสถานะโดยไม่ต้องโหลดหน้าใหม่ แบ่งเป็น 3 States:
|
|
||||||
|
|
||||||
1. **🟢 Start**: หน้าเตรียมตัว บอกกฎกติกา จำนวนข้อ และเวลาที่ให้
|
|
||||||
2. **🟡 Taking**: หน้าทำข้อสอบ
|
|
||||||
- มี **Countdown Timer** นับถอยหลัง
|
|
||||||
- โจทย์ 1 ข้อต่อ 1 หน้า (Pagination) เพื่อโฟกัสทีละข้อ
|
|
||||||
3. **🔴 Result**: หน้าสรุปผล แจ้งคะแนนและสถานะ (ผ่าน/ไม่ผ่าน) ทันที
|
|
||||||
|
|
||||||
### 🛡️ ความปลอดภัย (Security)
|
|
||||||
|
|
||||||
- **Randomization**: สุ่มลำดับข้อ (Shuffle Questions) และสลับตัวเลือก (Shuffle Choices) ทุกครั้งที่สอบ
|
|
||||||
- **Backend Grading**: ส่งคำตอบไปตรวจที่ Server เท่านั้น ไม่มีการเฉลยค้างไว้ใน Code ฝั่งหน้าบ้าน (ป้องกันการแอบดูเฉลย)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🛠️ โครงสร้างทางเทคนิค (Technical Stack)
|
|
||||||
|
|
||||||
การทำงานของระบบขับเคลื่อนด้วยการเชื่อมต่อข้อมูลและ Component ที่ทันสมัย:
|
|
||||||
|
|
||||||
### 📥 1. แหล่งข้อมูล (Data Sources & Composables)
|
|
||||||
|
|
||||||
- **`useCourse` (Core Logic)**:
|
|
||||||
- `fetchCourseLearningInfo`: ดึงผังบทเรียน
|
|
||||||
- `fetchLessonContent`: ดึงวิดีโอ/โจทย์
|
|
||||||
- `saveVideoProgress`: บันทึกเวลาเรียน
|
|
||||||
- `submitQuiz`: ส่งคำตอบสอบ
|
|
||||||
- **`useAuth`**: ระบุตัวตนผู้เรียนเพื่อบันทึกข้อมูลได้ถูกต้อง
|
|
||||||
- **`useMediaPrefs`**: ระบบจำค่าเสียง (Volume Memory) ไม่ต้องปรับเสียงใหม่ทุกครั้งที่เปลี่ยนบท
|
|
||||||
|
|
||||||
### 🧩 2. ชิ้นส่วนหน้าจอ (Quasar Components)
|
|
||||||
|
|
||||||
- **Layout**: `q-layout`, `q-header`, `q-drawer` (จัดโครงสร้าง Responsive)
|
|
||||||
- **Interaction**: `q-btn` (ปุ่ม), `q-dialog` (Popup แจ้งเตือน)
|
|
||||||
- **Feedback**: `q-spinner` (โหลด), `q-icon` (สัญลักษณ์สถานะ)
|
|
||||||
- **Data**: `q-list`, `q-item` (รายการบทเรียน)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✨ จุดเด่นสำหรับนำเสนอ (Key Highlights)
|
|
||||||
|
|
||||||
1. **Seamless Experience**: เรียนต่อได้ทันทีไม่ต้องจำว่าถึงไหน (Resume Playback)
|
|
||||||
2. **Robust Data Saving**: เน็ตหลุดวิก็ไม่หาย ด้วยระบบบันทึก 2 ชั้น (Hybrid Save)
|
|
||||||
3. **Modern & Clean UI**: ดีไซน์สบายตา ลดสิ่งรบกวน เหมาะกับการเรียนรู้นานๆ
|
|
||||||
4. **Secure Testing**: ระบบสอบที่เชื่อถือได้ ป้องกันการลอกและแอบดูเฉลย
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 แผนพัฒนาต่อยอด (Future Roadmap)
|
|
||||||
|
|
||||||
- [ ] **Comment & Q&A**: ระบบถาม-ตอบ ใต้คลิปวิดีโอ
|
|
||||||
- [ ] **Video Bookmarks**: ปักหมุดช่วงเวลาสำคัญในวิดีโอ
|
|
||||||
- [ ] **Interactive Video**: คำถามเด้งขึ้นมาระหว่างดูวิดีโอ (Pop-up Quiz)
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue