feat: Add admin user and category management services and instructor course creation and listing pages.
This commit is contained in:
parent
715d94fbf9
commit
9fa70efaf6
4 changed files with 299 additions and 3 deletions
241
frontend_management/pages/instructor/courses/create.vue
Normal file
241
frontend_management/pages/instructor/courses/create.vue
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
<template>
|
||||
<div>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<q-btn
|
||||
flat
|
||||
round
|
||||
icon="arrow_back"
|
||||
@click="navigateTo('/instructor/courses')"
|
||||
/>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-primary-600">สร้างหลักสูตรใหม่</h1>
|
||||
<p class="text-gray-600 mt-1">กรอกข้อมูลหลักสูตรของคุณ</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form -->
|
||||
<div class="bg-white rounded-xl shadow-sm p-6">
|
||||
<q-form @submit="handleSubmit">
|
||||
<!-- Basic Info -->
|
||||
<h2 class="text-lg font-semibold text-primary-600 mb-4">ข้อมูลพื้นฐาน</h2>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
<q-input
|
||||
v-model="form.title.th"
|
||||
label="ชื่อหลักสูตร (ภาษาไทย) *"
|
||||
outlined
|
||||
:rules="[val => !!val || 'กรุณากรอกชื่อหลักสูตร']"
|
||||
/>
|
||||
<q-input
|
||||
v-model="form.title.en"
|
||||
label="ชื่อหลักสูตร (English) *"
|
||||
outlined
|
||||
:rules="[val => !!val || 'Please enter course title']"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
<q-input
|
||||
v-model="form.slug"
|
||||
label="Slug (URL) *"
|
||||
outlined
|
||||
hint="ใช้สำหรับ URL เช่น javascript-basics"
|
||||
:rules="[val => !!val || 'กรุณากรอก slug']"
|
||||
/>
|
||||
<q-select
|
||||
v-model="form.category_id"
|
||||
:options="categoryOptions"
|
||||
label="หมวดหมู่ *"
|
||||
outlined
|
||||
emit-value
|
||||
map-options
|
||||
:rules="[val => !!val || 'กรุณาเลือกหมวดหมู่']"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<q-input
|
||||
v-model="form.description.th"
|
||||
label="คำอธิบาย (ภาษาไทย)"
|
||||
type="textarea"
|
||||
outlined
|
||||
autogrow
|
||||
rows="3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<q-input
|
||||
v-model="form.description.en"
|
||||
label="คำอธิบาย (English)"
|
||||
type="textarea"
|
||||
outlined
|
||||
autogrow
|
||||
rows="3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Pricing -->
|
||||
<q-separator class="my-6" />
|
||||
<h2 class="text-lg font-semibold text-primary-600 mb-4">ราคาและการตั้งค่า</h2>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
<q-toggle
|
||||
v-model="form.is_free"
|
||||
label="หลักสูตรฟรี"
|
||||
color="primary"
|
||||
/>
|
||||
<q-input
|
||||
v-if="!form.is_free"
|
||||
v-model.number="form.price"
|
||||
label="ราคา (บาท)"
|
||||
type="number"
|
||||
outlined
|
||||
prefix="฿"
|
||||
:rules="[val => form.is_free || val > 0 || 'กรุณากรอกราคา']"
|
||||
/>
|
||||
<q-toggle
|
||||
v-model="form.have_certificate"
|
||||
label="มีใบประกาศนียบัตร"
|
||||
color="primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Thumbnail -->
|
||||
<q-separator class="my-6" />
|
||||
<h2 class="text-lg font-semibold text-primary-600 mb-4">รูปภาพปก</h2>
|
||||
|
||||
<div class="mb-6">
|
||||
<q-input
|
||||
v-model="form.thumbnail_url"
|
||||
label="URL รูปภาพปก"
|
||||
outlined
|
||||
hint="ใส่ URL รูปภาพ (optional)"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<q-icon name="image" />
|
||||
</template>
|
||||
</q-input>
|
||||
</div>
|
||||
|
||||
<!-- Preview thumbnail if exists -->
|
||||
<div v-if="form.thumbnail_url" class="mb-6">
|
||||
<img
|
||||
:src="form.thumbnail_url"
|
||||
alt="Thumbnail preview"
|
||||
class="max-w-xs rounded-lg shadow"
|
||||
@error="form.thumbnail_url = ''"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end gap-3 mt-8">
|
||||
<q-btn
|
||||
flat
|
||||
label="ยกเลิก"
|
||||
color="grey-7"
|
||||
@click="navigateTo('/instructor/courses')"
|
||||
/>
|
||||
<q-btn
|
||||
type="submit"
|
||||
label="สร้างหลักสูตร"
|
||||
color="primary"
|
||||
:loading="saving"
|
||||
/>
|
||||
</div>
|
||||
</q-form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useQuasar } from 'quasar';
|
||||
import { instructorService, type CreateCourseRequest } from '~/services/instructor.service';
|
||||
import { adminService, type CategoryResponse } from '~/services/admin.service';
|
||||
|
||||
definePageMeta({
|
||||
layout: 'instructor',
|
||||
middleware: ['auth']
|
||||
});
|
||||
|
||||
const $q = useQuasar();
|
||||
const router = useRouter();
|
||||
|
||||
// Data
|
||||
const saving = ref(false);
|
||||
const categories = ref<CategoryResponse[]>([]);
|
||||
|
||||
// Form
|
||||
const form = ref<CreateCourseRequest>({
|
||||
category_id: 0,
|
||||
title: { th: '', en: '' },
|
||||
slug: '',
|
||||
description: { th: '', en: '' },
|
||||
thumbnail_url: null,
|
||||
price: 0,
|
||||
is_free: true,
|
||||
have_certificate: true
|
||||
});
|
||||
|
||||
// Category options
|
||||
const categoryOptions = computed(() =>
|
||||
categories.value.map(cat => ({
|
||||
label: cat.name.th,
|
||||
value: cat.id
|
||||
}))
|
||||
);
|
||||
|
||||
// Auto-generate slug from Thai title
|
||||
watch(() => form.value.title.th, (newTitle) => {
|
||||
if (newTitle && !form.value.slug) {
|
||||
// Simple slug generation - replace spaces with dashes
|
||||
form.value.slug = newTitle
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^\u0E00-\u0E7Fa-z0-9-]/g, '');
|
||||
}
|
||||
});
|
||||
|
||||
// Methods
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
categories.value = await adminService.getCategories();
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch categories');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
saving.value = true;
|
||||
try {
|
||||
// Set price to 0 if free
|
||||
if (form.value.is_free) {
|
||||
form.value.price = 0;
|
||||
}
|
||||
|
||||
await instructorService.createCourse(form.value);
|
||||
|
||||
$q.notify({
|
||||
type: 'positive',
|
||||
message: 'สร้างหลักสูตรสำเร็จ',
|
||||
position: 'top'
|
||||
});
|
||||
|
||||
navigateTo('/instructor/courses');
|
||||
} catch (error: any) {
|
||||
$q.notify({
|
||||
type: 'negative',
|
||||
message: error.message || 'เกิดข้อผิดพลาด กรุณาลองใหม่',
|
||||
position: 'top'
|
||||
});
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
fetchCategories();
|
||||
});
|
||||
</script>
|
||||
|
|
@ -84,8 +84,15 @@
|
|||
class="bg-white rounded-xl shadow-sm overflow-hidden hover:shadow-md transition-shadow"
|
||||
>
|
||||
<!-- Thumbnail -->
|
||||
<div class="h-40 bg-gradient-to-br from-primary-400 to-primary-600 flex items-center justify-center">
|
||||
<q-icon name="school" size="60px" color="white" />
|
||||
<div class="h-40 bg-gradient-to-br from-primary-400 to-primary-600 flex items-center justify-center relative overflow-hidden">
|
||||
<img
|
||||
v-if="course.thumbnail_url"
|
||||
:src="course.thumbnail_url"
|
||||
:alt="course.title.th"
|
||||
class="absolute inset-0 w-full h-full object-cover"
|
||||
@error="($event.target as HTMLImageElement).style.display = 'none'"
|
||||
/>
|
||||
<q-icon v-else name="school" size="60px" color="white" />
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
|
|
|
|||
|
|
@ -185,7 +185,7 @@ export const adminService = {
|
|||
}
|
||||
|
||||
const token = getAuthToken();
|
||||
const response = await $fetch<CategoriesListResponse>('/api/admin/categories', {
|
||||
const response = await $fetch<CategoriesListResponse>('/api/categories', {
|
||||
baseURL: config.public.apiBaseUrl as string,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
|
|
|
|||
|
|
@ -126,6 +126,54 @@ export const instructorService = {
|
|||
}
|
||||
});
|
||||
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async createCourse(data: CreateCourseRequest): Promise<CourseResponse> {
|
||||
const config = useRuntimeConfig();
|
||||
const useMockData = config.public.useMockData as boolean;
|
||||
|
||||
if (useMockData) {
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
return {
|
||||
...MOCK_COURSES[0],
|
||||
id: Date.now(),
|
||||
...data,
|
||||
price: String(data.price), // Convert number to string to match CourseResponse type
|
||||
status: 'DRAFT',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
} as CourseResponse;
|
||||
}
|
||||
|
||||
const token = getAuthToken();
|
||||
const response = await $fetch<{ code: number; data: CourseResponse }>('/api/instructors/courses', {
|
||||
method: 'POST',
|
||||
baseURL: config.public.apiBaseUrl as string,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: { data }
|
||||
});
|
||||
|
||||
return response.data;
|
||||
}
|
||||
};
|
||||
|
||||
// Create course request
|
||||
export interface CreateCourseRequest {
|
||||
category_id: number;
|
||||
title: {
|
||||
en: string;
|
||||
th: string;
|
||||
};
|
||||
slug: string;
|
||||
description: {
|
||||
en: string;
|
||||
th: string;
|
||||
};
|
||||
thumbnail_url?: string | null;
|
||||
price: number;
|
||||
is_free: boolean;
|
||||
have_certificate: boolean;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue