feat: Implement instructor module with course management pages and API services.
This commit is contained in:
parent
07ab43a785
commit
a24f8c4982
6 changed files with 135 additions and 27 deletions
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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 -->
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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', {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue