feat: Implement instructor module with course management pages and API services.

This commit is contained in:
Missez 2026-01-29 09:47:43 +07:00
parent 07ab43a785
commit a24f8c4982
6 changed files with 135 additions and 27 deletions

View file

@ -112,26 +112,36 @@
<h2 class="text-lg font-semibold text-primary-600 mb-4">ปภาพปก</h2> <h2 class="text-lg font-semibold text-primary-600 mb-4">ปภาพปก</h2>
<div class="mb-6"> <div class="mb-6">
<q-input <div class="flex flex-col items-center justify-center border-2 border-dashed border-gray-300 rounded-lg p-6 bg-gray-50 hover:bg-gray-100 transition-colors cursor-pointer" @click="triggerThumbnailUpload">
v-model="form.thumbnail_url" <div v-if="form.thumbnail_url" class="relative group w-full max-w-md aspect-video">
label="URL รูปภาพปก" <img
outlined :src="form.thumbnail_url"
hint="ใส่ URL รูปภาพ (optional)" :key="form.thumbnail_url"
> alt="Thumbnail preview"
<template v-slot:prepend> class="w-full h-full object-cover rounded-lg shadow-sm"
<q-icon name="image" /> @error="form.thumbnail_url = ''"
</template> />
</q-input> <div class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center rounded-lg">
</div> <q-icon name="photo_camera" color="white" size="40px" />
<span class="text-white ml-2">เปลยนรปภาพ</span>
<!-- Preview thumbnail if exists --> </div>
<div v-if="form.thumbnail_url" class="mb-6"> <div v-if="uploadingThumbnail" class="absolute inset-0 bg-white/80 flex items-center justify-center rounded-lg">
<img <q-spinner color="primary" size="2em" />
:src="form.thumbnail_url" </div>
alt="Thumbnail preview" </div>
class="max-w-xs rounded-lg shadow" <div v-else class="text-center py-8">
@error="form.thumbnail_url = ''" <q-icon name="add_photo_alternate" size="48px" class="text-gray-400 mb-2" />
/> <div class="text-gray-600 font-medium">คลกเพออพโหลดรปภาพปก</div>
<div class="text-xs text-gray-400 mt-1">ขนาดแนะนำ 1280x720 (16:9) งส 5MB</div>
</div>
<input
ref="thumbnailInputRef"
type="file"
accept="image/*"
class="hidden"
@change="handleThumbnailUpload"
/>
</div>
</div> </div>
<!-- Actions --> <!-- Actions -->
@ -170,6 +180,8 @@ const route = useRoute();
// Data // Data
const initialLoading = ref(true); const initialLoading = ref(true);
const saving = ref(false); const saving = ref(false);
const uploadingThumbnail = ref(false);
const thumbnailInputRef = ref<HTMLInputElement | null>(null);
const categories = ref<CategoryResponse[]>([]); const categories = ref<CategoryResponse[]>([]);
// Form // Form
@ -259,6 +271,76 @@ const handleSubmit = async () => {
} }
}; };
const triggerThumbnailUpload = () => {
thumbnailInputRef.value?.click();
};
const handleThumbnailUpload = async (event: Event) => {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
// Validate file type
if (!file.type.startsWith('image/')) {
$q.notify({
type: 'warning',
message: 'กรุณาเลือกไฟล์รูปภาพเท่านั้น',
position: 'top'
});
return;
}
// Validate file size (max 5MB)
if (file.size > 5 * 1024 * 1024) {
$q.notify({
type: 'warning',
message: 'ไฟล์มีขนาดใหญ่เกิน 5MB',
position: 'top'
});
return;
}
uploadingThumbnail.value = true;
try {
const courseId = parseInt(route.params.id as string);
// Clear current thumbnail first to force complete re-mount
form.value.thumbnail_url = null;
// Upload the file
await instructorService.uploadCourseThumbnail(courseId, file);
// Wait for Vue to unmount old img
await nextTick();
// Re-fetch course data to get fresh presigned URL from backend
const updatedCourse = await instructorService.getCourseById(courseId);
// Add cache buster to force reload
if (updatedCourse.thumbnail_url) {
// For presigned URLs, we cannot append query parameters as it invalidates the signature
// The presigned URL itself should be unique enough or handle caching differently
form.value.thumbnail_url = updatedCourse.thumbnail_url;
}
$q.notify({
type: 'positive',
message: 'อัพโหลดรูปภาพปกเรียบร้อย',
position: 'top'
});
// Clear input value to allow re-uploading same file if needed
input.value = '';
} catch (error: any) {
$q.notify({
type: 'negative',
message: error.message || 'เกิดข้อผิดพลาดในการอัพโหลดรูปภาพ',
position: 'top'
});
} finally {
uploadingThumbnail.value = false;
}
};
// Lifecycle // Lifecycle
onMounted(() => { onMounted(() => {
fetchData(); fetchData();

View file

@ -15,9 +15,15 @@
<div class="text-xs text-gray-500">{{ authStore.user?.role || 'INSTRUCTOR' }}</div> <div class="text-xs text-gray-500">{{ authStore.user?.role || 'INSTRUCTOR' }}</div>
</div> </div>
<div <div
class="w-12 h-12 bg-primary-100 rounded-full flex items-center justify-center text-2xl cursor-pointer hover:bg-primary-200 transition" class="w-12 h-12 bg-primary-100 rounded-full flex items-center justify-center text-2xl cursor-pointer hover:bg-primary-200 transition overflow-hidden"
> >
<q-icon name="person" /> <img
v-if="authStore.user?.avatarUrl"
:src="authStore.user.avatarUrl"
alt=""
class="w-full h-full object-cover"
/>
<q-icon v-else name="person" />
<q-menu> <q-menu>
<q-list style="min-width: 200px"> <q-list style="min-width: 200px">
<!-- User Info Header --> <!-- User Info Header -->

View file

@ -45,7 +45,7 @@
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<div class="text-sm text-gray-600 mb-1">-นามสก</div> <div class="text-sm text-gray-600 mb-1">-นามสก</div>
<div class="text-lg font-semibold text-gray-900">{{ profile.fullName }}</div> <div class="text-lg text-gray-900">{{ profile.fullName }}</div>
</div> </div>
<div> <div>
@ -65,9 +65,7 @@
<div> <div>
<div class="text-sm text-gray-600 mb-1">ตำแหน</div> <div class="text-sm text-gray-600 mb-1">ตำแหน</div>
<div class="text-lg text-gray-900"> <div class="text-lg text-gray-900">{{ profile.roleName || getRoleLabel(profile.role) }}</div>
<q-badge color="primary">{{ profile.roleName || getRoleLabel(profile.role) }}</q-badge>
</div>
</div> </div>
<div> <div>

View file

@ -44,6 +44,7 @@ export interface LoginResponse {
firstName: string; firstName: string;
lastName: string; lastName: string;
role: string; role: string;
avatarUrl?: string | null;
}; };
} }
@ -132,7 +133,8 @@ export const authService = {
email: response.user.email, email: response.user.email,
firstName: response.user.profile.first_name, firstName: response.user.profile.first_name,
lastName: response.user.profile.last_name, lastName: response.user.profile.last_name,
role: response.user.role.code role: response.user.role.code,
avatarUrl: response.user.profile.avatar_url
} }
}; };
} catch (error: any) { } catch (error: any) {

View file

@ -225,6 +225,25 @@ export const instructorService = {
return response.data; return response.data;
}, },
async uploadCourseThumbnail(courseId: number, file: File): Promise<{ thumbnail_url: string }> {
const config = useRuntimeConfig();
const useMockData = config.public.useMockData as boolean;
if (useMockData) {
await new Promise(resolve => setTimeout(resolve, 500));
return { thumbnail_url: URL.createObjectURL(file) };
}
const formData = new FormData();
formData.append('file', file);
const response = await authRequest<{ code: number; data: { thumbnail_url: string } }>(
`/api/instructors/courses/${courseId}/thumbnail`,
{ method: 'POST', body: formData }
);
return response.data;
},
async deleteCourse(courseId: number): Promise<void> { async deleteCourse(courseId: number): Promise<void> {
const config = useRuntimeConfig(); const config = useRuntimeConfig();
const useMockData = config.public.useMockData as boolean; const useMockData = config.public.useMockData as boolean;

View file

@ -7,6 +7,7 @@ interface User {
firstName: string; firstName: string;
lastName: string; lastName: string;
role: 'INSTRUCTOR' | 'ADMIN' | 'STUDENT'; role: 'INSTRUCTOR' | 'ADMIN' | 'STUDENT';
avatarUrl?: string | null;
} }
export const useAuthStore = defineStore('auth', { export const useAuthStore = defineStore('auth', {