feat: Add admin user and category management services and instructor course creation and listing pages.

This commit is contained in:
Missez 2026-01-19 15:03:01 +07:00
parent 715d94fbf9
commit 9fa70efaf6
4 changed files with 299 additions and 3 deletions

View 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>

View file

@ -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 -->

View file

@ -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}`

View file

@ -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;
}