feat: Add initial e-learning frontend setup including admin and instructor services, layouts, and pages.
All checks were successful
Build and Deploy Frontend Management to Dev Server / Build Frontend Management Docker Image (push) Successful in 46s
Build and Deploy Frontend Management to Dev Server / Deploy E-learning Frontend Management to Dev Server (push) Successful in 6s
Build and Deploy Frontend Management to Dev Server / Notify Deployment Status (push) Successful in 1s

This commit is contained in:
Missez 2026-02-24 09:25:02 +07:00
parent 01d249c19a
commit 031ca5c984
12 changed files with 135 additions and 36 deletions

View file

@ -83,6 +83,28 @@
</div> </div>
</div> </div>
<!-- Quiz Settings -->
<div class="mt-4 p-4 bg-white rounded-lg border border-blue-100">
<div class="font-semibold text-gray-700 mb-3">การตงคาเพมเต</div>
<div class="flex flex-wrap gap-2">
<q-chip :color="lessonDetail.quiz.shuffle_questions ? 'positive' : 'grey-4'" :text-color="lessonDetail.quiz.shuffle_questions ? 'white' : 'grey-8'" size="m" :icon="lessonDetail.quiz.shuffle_questions ? 'check' : 'close'">
มคำถาม
</q-chip>
<q-chip :color="lessonDetail.quiz.shuffle_choices ? 'positive' : 'grey-4'" :text-color="lessonDetail.quiz.shuffle_choices ? 'white' : 'grey-8'" size="m" :icon="lessonDetail.quiz.shuffle_choices ? 'check' : 'close'">
มตวเลอก
</q-chip>
<q-chip :color="lessonDetail.quiz.show_answers_after_completion ? 'positive' : 'grey-4'" :text-color="lessonDetail.quiz.show_answers_after_completion ? 'white' : 'grey-8'" size="m" :icon="lessonDetail.quiz.show_answers_after_completion ? 'check' : 'close'">
เฉลยหลงทำเสร
</q-chip>
<q-chip :color="lessonDetail.quiz.is_skippable ? 'positive' : 'grey-4'" :text-color="lessonDetail.quiz.is_skippable ? 'white' : 'grey-8'" size="m" :icon="lessonDetail.quiz.is_skippable ? 'check' : 'close'">
ามขอได
</q-chip>
<q-chip :color="lessonDetail.quiz.allow_multiple_attempts ? 'positive' : 'grey-4'" :text-color="lessonDetail.quiz.allow_multiple_attempts ? 'white' : 'grey-8'" size="m" :icon="lessonDetail.quiz.allow_multiple_attempts ? 'check' : 'close'">
ทำซำได
</q-chip>
</div>
</div>
<!-- Questions List --> <!-- Questions List -->
<div v-if="lessonDetail.quiz.questions && lessonDetail.quiz.questions.length > 0" class="mt-6 space-y-6"> <div v-if="lessonDetail.quiz.questions && lessonDetail.quiz.questions.length > 0" class="mt-6 space-y-6">
<!-- ... (questions rendering code unchanged) ... --> <!-- ... (questions rendering code unchanged) ... -->

View file

@ -63,7 +63,7 @@
</NuxtLink> </NuxtLink>
<NuxtLink <NuxtLink
to="/admin/audit-logs" to="/admin/audit-log"
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-primary-100 transition mb-2" class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-primary-100 transition mb-2"
active-class="bg-primary-500 text-white hover:bg-primary-600" active-class="bg-primary-500 text-white hover:bg-primary-600"
> >
@ -91,11 +91,29 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useQuasar } from 'quasar';
const $q = useQuasar();
const authStore = useAuthStore(); const authStore = useAuthStore();
const router = useRouter(); const router = useRouter();
const handleLogout = () => { const handleLogout = () => {
$q.dialog({
title: 'ยืนยันออกจากระบบ',
message: 'คุณต้องการออกจากระบบหรือไม่?',
persistent: true,
ok: {
label: 'ออกจากระบบ',
color: 'negative',
flat: false
},
cancel: {
label: 'ยกเลิก',
flat: true
}
}).onOk(() => {
authStore.logout(); authStore.logout();
router.push('/login'); router.push('/login');
});
}; };
</script> </script>

View file

@ -2,7 +2,7 @@
<div class="min-h-screen bg-gray-50"> <div class="min-h-screen bg-gray-50">
<!-- Sidebar --> <!-- Sidebar -->
<aside class="fixed left-0 top-0 h-screen w-64 bg-white shadow-lg"> <aside class="fixed left-0 top-0 h-screen w-64 bg-white shadow-lg">
<div class="p-6"> <div class="py-6 px-8">
<h2 class="text-xl font-bold text-primary-600">E-Learning</h2> <h2 class="text-xl font-bold text-primary-600">E-Learning</h2>
<p class="text-sm text-gray-500">Instructor Panel</p> <p class="text-sm text-gray-500">Instructor Panel</p>
</div> </div>
@ -46,11 +46,29 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useQuasar } from 'quasar';
const $q = useQuasar();
const authStore = useAuthStore(); const authStore = useAuthStore();
const router = useRouter(); const router = useRouter();
const handleLogout = () => { const handleLogout = () => {
$q.dialog({
title: 'ยืนยันออกจากระบบ',
message: 'คุณต้องการออกจากระบบหรือไม่?',
persistent: true,
ok: {
label: 'ออกจากระบบ',
color: 'negative',
flat: false
},
cancel: {
label: 'ยกเลิก',
flat: true
}
}).onOk(() => {
authStore.logout(); authStore.logout();
router.push('/login'); router.push('/login');
});
}; };
</script> </script>

View file

@ -35,10 +35,13 @@ export default defineNuxtConfig({
devtools: { enabled: true }, devtools: { enabled: true },
app: { app: {
head: { head: {
title: 'E-Learning System', title: 'E-Learning-Management',
meta: [ meta: [
{ charset: 'utf-8' }, { charset: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' } { name: 'viewport', content: 'width=device-width, initial-scale=1' }
],
link: [
{ rel: 'icon', type: 'image/png', href: '/icon.png' }
] ]
} }
} }

View file

@ -218,7 +218,7 @@
<p>เลอกระยะเวลาทองการเกบไว (ลบขอมลทเกากวากำหนด):</p> <p>เลอกระยะเวลาทองการเกบไว (ลบขอมลทเกากวากำหนด):</p>
<q-select <q-select
v-model="cleanupDays" v-model="cleanupDays"
:options="[30, 60, 90, 180, 365]" :options="[7, 15, 30, 60, 90, 180, 365]"
label="จำนวนวัน" label="จำนวนวัน"
suffix="วัน" suffix="วัน"
outlined outlined

View file

@ -75,7 +75,7 @@
<div class="space-y-4"> <div class="space-y-4">
<!-- Stats --> <!-- Stats -->
<div class="bg-white rounded-xl shadow-sm p-6"> <div class="bg-white rounded-xl shadow-sm p-6">
<h3 class="font-semibold text-gray-700 mb-4">สถ</h3> <h3 class="font-semibold text-gray-700 mb-4">รายละเอยด</h3>
<div class="space-y-3"> <div class="space-y-3">
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-gray-500">จำนวนบท</span> <span class="text-gray-500">จำนวนบท</span>
@ -93,6 +93,14 @@
<span class="text-gray-500">แบบทดสอบ</span> <span class="text-gray-500">แบบทดสอบ</span>
<span class="font-medium">{{ quizCount }}</span> <span class="font-medium">{{ quizCount }}</span>
</div> </div>
<div class="flex justify-between">
<span class="text-gray-500">สรางเม</span>
<span>{{ formatDate(course.created_at) }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">พเดทลาส</span>
<span>{{ formatDate(course.updated_at) }}</span>
</div>
</div> </div>
</div> </div>
@ -116,21 +124,6 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Timeline -->
<div class="bg-white rounded-xl shadow-sm p-6">
<h3 class="font-semibold text-gray-700 mb-4">อมลระบบ</h3>
<div class="space-y-3 text-sm">
<div class="flex justify-between">
<span class="text-gray-500">สรางเม</span>
<span>{{ formatDate(course.created_at) }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">พเดทลาส</span>
<span>{{ formatDate(course.updated_at) }}</span>
</div>
</div>
</div>
</div> </div>
</div> </div>

View file

@ -146,7 +146,7 @@
<div class="card bg-white rounded-lg shadow-sm"> <div class="card bg-white rounded-lg shadow-sm">
<div class="p-6 border-b flex justify-between items-center"> <div class="p-6 border-b flex justify-between items-center">
<h2 class="text-lg font-semibold text-gray-900">จกรรมลาส</h2> <h2 class="text-lg font-semibold text-gray-900">จกรรมลาส</h2>
<q-btn flat color="primary" label="ดูทั้งหมด" to="/admin/audit-logs" size="sm" /> <q-btn flat color="primary" label="ดูทั้งหมด" to="/admin/audit-log" size="sm" />
</div> </div>
<div class="divide-y"> <div class="divide-y">
<div v-if="loading" class="p-8 text-center text-gray-500"> <div v-if="loading" class="p-8 text-center text-gray-500">

View file

@ -183,6 +183,34 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Course Structure -->
<div v-if="selectedCourse.chapters && selectedCourse.chapters.length > 0" class="mt-6">
<div class="font-bold text-lg mb-3">โครงสรางหลกสตร (Course Structure)</div>
<div class="space-y-3">
<q-expansion-item
v-for="(chapter, index) in selectedCourse.chapters"
:key="chapter.id"
:label="`บทที่ ${index + 1}: ${chapter.title.th}`"
:caption="`${chapter.lessons.length} บทเรียน`"
header-class="bg-gray-50 rounded-lg"
expand-icon-class="text-primary"
>
<div class="pl-4 pt-2">
<div
v-for="(lesson, lessonIndex) in chapter.lessons"
:key="lesson.id"
class="flex items-center gap-3 py-2 border-b last:border-b-0"
>
<q-icon name="article" color="primary" size="20px" />
<span class="text-gray-700">{{ lessonIndex + 1 }}. {{ lesson.title.th }}</span>
<span v-if="lesson.title.en" class="text-gray-400 text-xs ml-auto">{{ lesson.title.en }}</span>
</div>
</div>
</q-expansion-item>
</div>
</div>
</q-card-section> </q-card-section>
<!-- Inner Loading --> <!-- Inner Loading -->

View file

@ -263,7 +263,8 @@ const statusOptions = [
{ label: 'เผยแพร่แล้ว', value: 'APPROVED' }, { label: 'เผยแพร่แล้ว', value: 'APPROVED' },
{ label: 'รอตรวจสอบ', value: 'PENDING' }, { label: 'รอตรวจสอบ', value: 'PENDING' },
{ label: 'แบบร่าง', value: 'DRAFT' }, { label: 'แบบร่าง', value: 'DRAFT' },
{ label: 'ถูกปฏิเสธ', value: 'REJECTED' } { label: 'ถูกปฏิเสธ', value: 'REJECTED' },
//{ label: '', value: 'ARCHIVED' }
]; ];
// Stats // Stats
@ -275,7 +276,7 @@ const stats = computed(() => ({
rejected: courses.value.filter(c => c.status === 'REJECTED').length rejected: courses.value.filter(c => c.status === 'REJECTED').length
})); }));
// Filtered courses // Filtered courses (search only, status is handled server-side)
const filteredCourses = computed(() => { const filteredCourses = computed(() => {
let result = courses.value; let result = courses.value;
@ -287,10 +288,6 @@ const filteredCourses = computed(() => {
); );
} }
if (filterStatus.value) {
result = result.filter(course => course.status === filterStatus.value);
}
return result; return result;
}); });
@ -298,7 +295,7 @@ const filteredCourses = computed(() => {
const fetchCourses = async () => { const fetchCourses = async () => {
loading.value = true; loading.value = true;
try { try {
courses.value = await instructorService.getCourses(); courses.value = await instructorService.getCourses(filterStatus.value || undefined);
} catch (error) { } catch (error) {
$q.notify({ $q.notify({
type: 'negative', type: 'negative',
@ -310,12 +307,18 @@ const fetchCourses = async () => {
} }
}; };
// Re-fetch when status filter changes
watch(filterStatus, () => {
fetchCourses();
});
const getStatusColor = (status: string) => { const getStatusColor = (status: string) => {
const colors: Record<string, string> = { const colors: Record<string, string> = {
APPROVED: 'green', APPROVED: 'green',
PENDING: 'orange', PENDING: 'orange',
DRAFT: 'grey', DRAFT: 'grey',
REJECTED: 'red' REJECTED: 'red',
ARCHIVED: 'blue-grey'
}; };
return colors[status] || 'grey'; return colors[status] || 'grey';
}; };
@ -325,7 +328,8 @@ const getStatusLabel = (status: string) => {
APPROVED: 'เผยแพร่แล้ว', APPROVED: 'เผยแพร่แล้ว',
PENDING: 'รอตรวจสอบ', PENDING: 'รอตรวจสอบ',
DRAFT: 'แบบร่าง', DRAFT: 'แบบร่าง',
REJECTED: 'ถูกปฏิเสธ' REJECTED: 'ถูกปฏิเสธ',
ARCHIVED: 'เก็บถาวร'
}; };
return labels[status] || status; return labels[status] || status;
}; };

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

View file

@ -296,6 +296,15 @@ export interface RecommendedCourse {
}; };
chapters_count: number; chapters_count: number;
lessons_count: number; lessons_count: number;
chapters?: {
id: number;
title: { th: string; en: string };
sort_order: number;
lessons: {
id: number;
title: { th: string; en: string };
}[];
}[];
} }
export interface RecommendedCoursesListResponse { export interface RecommendedCoursesListResponse {

View file

@ -208,8 +208,12 @@ const authRequest = async <T>(
}; };
export const instructorService = { export const instructorService = {
async getCourses(): Promise<CourseResponse[]> { async getCourses(status?: string): Promise<CourseResponse[]> {
const response = await authRequest<CoursesListResponse>('/api/instructors/courses'); let url = '/api/instructors/courses';
if (status) {
url += `?status=${status}`;
}
const response = await authRequest<CoursesListResponse>(url);
return response.data; return response.data;
}, },