feat: Implement initial e-learning platform frontend structure including dashboard, course management, authentication, and common UI components.
This commit is contained in:
parent
aceeb80d9a
commit
ad11c6b7c5
44 changed files with 720 additions and 578 deletions
|
|
@ -1,18 +1,21 @@
|
|||
// Interface สำหรับข้อมูลหมวดหมู่ (Category)
|
||||
/**
|
||||
* @interface Category
|
||||
* @description โครงสร้างข้อมูลหมวดหมู่ (Category)
|
||||
*/
|
||||
export interface Category {
|
||||
id: number
|
||||
name: {
|
||||
name: { // ชื่อหมวดหมู่รองรับ 2 ภาษา
|
||||
th: string
|
||||
en: string
|
||||
[key: string]: string
|
||||
}
|
||||
slug: string
|
||||
description: {
|
||||
slug: string // Slug สำหรับใช้งานบน URL
|
||||
description: { // รายละเอียดหมวดหมู่
|
||||
th: string
|
||||
en: string
|
||||
[key: string]: string
|
||||
}
|
||||
icon: string
|
||||
icon: string // ไอคอนของหมวดหมู่อ้างอิงจาก Material Icons
|
||||
sort_order: number
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
|
|
@ -20,7 +23,7 @@ export interface Category {
|
|||
}
|
||||
|
||||
export interface CategoryData {
|
||||
total: number
|
||||
total: number // จำนวนหมวดหมู่ทั้งหมด
|
||||
categories: Category[]
|
||||
}
|
||||
|
||||
|
|
@ -30,18 +33,24 @@ interface CategoryResponse {
|
|||
data: CategoryData
|
||||
}
|
||||
|
||||
// Composable สำหรับจัดการข้อมูลหมวดหมู่
|
||||
/**
|
||||
* @composable useCategory
|
||||
* @description จัดการข้อมูลหมวดหมู่ (Categories) การดึงข้อมูล และการจัดการ Cache (เก็บข้อมูลจำลองชั่วคราว)
|
||||
*/
|
||||
export const useCategory = () => {
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBase as string
|
||||
const { token } = useAuth()
|
||||
|
||||
// ใช้ useState เพื่อเก็บ Cached Data ระดับ Global (แชร์กันทุก Component)
|
||||
// เก็บ Cache การดึงข้อมูลหมวดหมู่ในระดับ Global (ใช้ข้าม Component ได้โดยไม่ต้องโหลดใหม่)
|
||||
const categoriesState = useState<Category[]>('categories_cache', () => [])
|
||||
const isLoaded = useState<boolean>('categories_loaded', () => false)
|
||||
|
||||
// ฟังก์ชันดึงข้อมูลหมวดหมู่ทั้งหมด
|
||||
// Endpoint: GET /categories
|
||||
/**
|
||||
* @function fetchCategories
|
||||
* @description ดึงรายการหมวดหมู่ทั้งหมดจาก API (GET /categories)
|
||||
* หากมีแคชอยู่แล้วจะดึงจาก State ทันที แต่อาจบังคับให้โหลดใหม่ (forceRefresh) ได้
|
||||
*/
|
||||
const fetchCategories = async (forceRefresh = false) => {
|
||||
// ถ้ามีข้อมูลอยู่แล้วและไม่สั่งบังคับโหลดใหม่ ให้ใช้ข้อมูลเดิมทันที
|
||||
if (isLoaded.value && !forceRefresh && categoriesState.value.length > 0) {
|
||||
|
|
@ -62,7 +71,7 @@ export const useCategory = () => {
|
|||
|
||||
const categories = response.data?.categories || []
|
||||
|
||||
// เก็บข้อมูลลง State
|
||||
// บันทึกรายการหมวดหมู่ลง State Cache
|
||||
categoriesState.value = categories
|
||||
isLoaded.value = true
|
||||
|
||||
|
|
@ -74,9 +83,9 @@ export const useCategory = () => {
|
|||
} catch (err: any) {
|
||||
console.error('Fetch categories failed:', err)
|
||||
|
||||
// Retry logic for 429 Too Many Requests
|
||||
// กรณีเกิด Error 429 ระบบจะทำการหน่วงเวลา (1.5 วิ) และลองโหลดข้อมูลใหม่อีก 1 ครั้ง (Retry)
|
||||
if (err.statusCode === 429 || err.status === 429) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1500)); // Wait 1.5s
|
||||
await new Promise(resolve => setTimeout(resolve, 1500)); // หน่วงเวลา 1.5 วินาที
|
||||
try {
|
||||
const retryRes = await $fetch<CategoryResponse>(`${API_BASE_URL}/categories`, {
|
||||
method: 'GET',
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
/**
|
||||
* @interface ValidationRule
|
||||
* @description กฎการตรวจสอบความถูกต้องของฟิลด์ (เช่น บังคับกรอก, เช็คความยาว, ตรวจอีเมล)
|
||||
*/
|
||||
export interface ValidationRule {
|
||||
required?: boolean
|
||||
minLength?: number
|
||||
|
|
@ -8,10 +12,18 @@ export interface ValidationRule {
|
|||
custom?: (value: string) => string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* @interface FieldErrors
|
||||
* @description รูปแบบการเก็บข้อผิดพลาด (Key ของฟิลด์ คู่กับข้อความแจ้งเตือน)
|
||||
*/
|
||||
export interface FieldErrors {
|
||||
[key: string]: string
|
||||
}
|
||||
|
||||
/**
|
||||
* @composable useFormValidation
|
||||
* @description ใช้สำหรับตรวจสอบความถูกต้อง (Validate) ของข้อมูลในแบบฟอร์ม
|
||||
*/
|
||||
export function useFormValidation() {
|
||||
const errors = ref<FieldErrors>({})
|
||||
|
||||
|
|
@ -52,6 +64,7 @@ export function useFormValidation() {
|
|||
return null
|
||||
}
|
||||
|
||||
// ฟังก์ชันหลักที่เอาแบบฟอร์ม (formData) มาตรวจกับเช็คลิสต์ทั้งหมด (validationRules)
|
||||
const validate = (
|
||||
formData: Record<string, string>,
|
||||
validationRules: Record<string, { rules: ValidationRule; label: string; messages?: Record<string, string> }>
|
||||
|
|
@ -67,14 +80,18 @@ export function useFormValidation() {
|
|||
}
|
||||
}
|
||||
|
||||
// บันทึกข้อผิดพลาดที่พบทั้งหมดลงใน State
|
||||
errors.value = newErrors
|
||||
// คืนค่าบอกว่า "ฟอร์มนี้ผ่านทั้งหมดไหม" (true คือผ่านหมด)
|
||||
return isValid
|
||||
}
|
||||
|
||||
// ฟังก์ชันลบข้อผิดพลาดทิ้งทั้งหมด (สำหรับตอนเริ่มกรอกใหม่)
|
||||
const clearErrors = () => {
|
||||
errors.value = {}
|
||||
}
|
||||
|
||||
// ฟังก์ชันลบข้อผิดพลาดเฉพาะฟิลด์ที่กำหนด
|
||||
const clearFieldError = (field: string) => {
|
||||
if (errors.value[field]) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
||||
|
|
|
|||
|
|
@ -1,22 +1,27 @@
|
|||
/**
|
||||
* @composable useMediaPrefs
|
||||
* @description จัดการตั้งค่าระดับเสียงและสถานะปิดเสียง (Mute) ของวิดีโอ/สื่อ
|
||||
* โดยจะเก็บค่าแยกตามบัญชีผู้ใช้งาน และสั่งประยุกต์ใช้กับ <video> ให้อัตโนมัติ
|
||||
*/
|
||||
export const useMediaPrefs = () => {
|
||||
// 1. Global State
|
||||
// ใช้ useState เพื่อแชร์ค่าเดียวกันทั่วทั้ง App (เช่น เปลี่ยนหน้าแล้วเสียงยังเท่าเดิม)
|
||||
// 1. สถานะส่วนกลาง (Global State)
|
||||
// ใช้ useState เพื่อแชร์ค่าเดียวกันทั่วหน้าเว็บ (เช่น เปลี่ยนหน้าแล้วระดับเสียงยังคงที่)
|
||||
const volume = useState<number>('media_prefs_volume', () => 1)
|
||||
const muted = useState<boolean>('media_prefs_muted', () => false)
|
||||
|
||||
const { user } = useAuth()
|
||||
|
||||
// 2. Storage Key Helper (User Specific)
|
||||
// 2. ฟังก์ชันช่วยสร้าง Key สำหรับ Storage (เก็บแยกตาม User)
|
||||
const getStorageKey = () => {
|
||||
const userId = user.value?.id || 'guest'
|
||||
return `media:prefs:v1:${userId}`
|
||||
}
|
||||
|
||||
// 3. Save Logic (Throttled)
|
||||
// 3. ระบบบันทึกการตั้งค่าลงเบราว์เซอร์ (Throttled เพื่อไม่ให้บันทึกถี่เกินไป)
|
||||
let saveTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const save = () => {
|
||||
if (import.meta.server) return
|
||||
if (import.meta.server) return // เลี่ยงไม่ได้ต้องทำงานบนฝั่ง Client เท่านั้น
|
||||
|
||||
if (saveTimeout) clearTimeout(saveTimeout)
|
||||
saveTimeout = setTimeout(() => {
|
||||
|
|
@ -29,12 +34,12 @@ export const useMediaPrefs = () => {
|
|||
}
|
||||
localStorage.setItem(key, JSON.stringify(data))
|
||||
} catch (e) {
|
||||
console.error('Failed to save media prefs', e)
|
||||
console.error('ไม่สามารถบันทึกการตั้งค่าสื่อได้', e)
|
||||
}
|
||||
}, 500) // Throttle 500ms
|
||||
}, 500) // หน่วงเวลา 500ms
|
||||
}
|
||||
|
||||
// 4. Load Logic
|
||||
// 4. ระบบโหลดการตั้งค่าเก่าขึ้นมา (Load Logic)
|
||||
const load = () => {
|
||||
if (import.meta.server) return
|
||||
|
||||
|
|
@ -51,20 +56,20 @@ export const useMediaPrefs = () => {
|
|||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load media prefs', e)
|
||||
console.error('ไม่สามารถโหลดการตั้งค่าสื่อได้', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Setters (With Logic)
|
||||
// 5. ฟังก์ชันสำหรับอัปเดตและสั่งบันทึกการตั้งค่า (Setters)
|
||||
const setVolume = (val: number) => {
|
||||
const clamped = Math.max(0, Math.min(1, val))
|
||||
volume.value = clamped
|
||||
|
||||
// Auto unmute if volume increased from 0
|
||||
// ยกเลิกปิดเสียงอัตโนมัติ ถ้าระดับเสียงเพิ่มขึ้นจาก 0
|
||||
if (clamped > 0 && muted.value) {
|
||||
muted.value = false
|
||||
}
|
||||
// Auto mute if volume set to 0
|
||||
// ปิดเสียงอัตโนมัติ ถ้าระดับเสียงกลายเป็น 0
|
||||
if (clamped === 0 && !muted.value) {
|
||||
muted.value = true
|
||||
}
|
||||
|
|
@ -75,7 +80,7 @@ export const useMediaPrefs = () => {
|
|||
const setMuted = (val: boolean) => {
|
||||
muted.value = val
|
||||
|
||||
// Logic: Unmuting should restore volume if it was 0
|
||||
// หากผู้ใช้กดยกเลิกการปิดเสียงขณะที่ระดับเสียงเคยเป็น 0 ควรตั้งค่าเริ่มต้นให้เป็น 1
|
||||
if (!val && volume.value === 0) {
|
||||
volume.value = 1
|
||||
}
|
||||
|
|
@ -83,15 +88,15 @@ export const useMediaPrefs = () => {
|
|||
save()
|
||||
}
|
||||
|
||||
// 6. Apply & Bind to Element (The Magic)
|
||||
// 6. ฟังก์ชันจับคู่ใช้กับการเล่นสื่อ (ตย. <video ref="videoEl"> -> applyTo(videoEl.value))
|
||||
const applyTo = (el: HTMLMediaElement | null | undefined) => {
|
||||
if (!el) return () => {}
|
||||
|
||||
// Initial Apply
|
||||
// ใส่ค่าตั้งต้นให้กับออบเจ็กต์สื่อ
|
||||
el.volume = volume.value
|
||||
el.muted = muted.value
|
||||
|
||||
// A. Watch State -> Update Element
|
||||
// A. สังเกตการเปลี่ยนแปลงจาก State -> เพื่อส่งไปอัปเดต Element สื่อ
|
||||
const stopVolWatch = watch(volume, (v) => {
|
||||
if (Math.abs(el.volume - v) > 0.01) el.volume = v
|
||||
})
|
||||
|
|
@ -99,9 +104,9 @@ export const useMediaPrefs = () => {
|
|||
if (el.muted !== m) el.muted = m
|
||||
})
|
||||
|
||||
// B. Listen Element -> Update State (e.g. Native Controls)
|
||||
// B. สังเกตการเปลี่ยนแปลงจาก Element (เช่น ผู้ใช้กดปุ่มเร่งเสียงในวิดีโอตรงๆ) -> เพื่อเอาค่ามาอัปเดต State
|
||||
const onVolumeChange = () => {
|
||||
// Update state only if diff allows (prevent loop)
|
||||
// อัปเดตเฉพาะเมื่อมีความแตกต่างเพื่อหลีกเลี่ยง Loop อนันต์
|
||||
if (Math.abs(el.volume - volume.value) > 0.01) {
|
||||
volume.value = el.volume
|
||||
save()
|
||||
|
|
@ -113,7 +118,7 @@ export const useMediaPrefs = () => {
|
|||
}
|
||||
el.addEventListener('volumechange', onVolumeChange)
|
||||
|
||||
// Cleanup function
|
||||
// ฟังก์ชันล้างค่าเพื่อเลิกติดตาม (Cleanup แบบส่งกลับ (Return))
|
||||
return () => {
|
||||
stopVolWatch()
|
||||
stopMutedWatch()
|
||||
|
|
@ -121,11 +126,11 @@ export const useMediaPrefs = () => {
|
|||
}
|
||||
}
|
||||
|
||||
// 7. Lifecycle & Sync
|
||||
// 7. จังหวะวงจรชีวิตตอนโหลดเสร็จและระบบ Sync
|
||||
if (import.meta.client) {
|
||||
onMounted(() => {
|
||||
load()
|
||||
// Cross-tab sync
|
||||
// ระบบ Sync กับแท็บหรือหน้าต่างเดียวกันหากถูกเปิดไว้
|
||||
window.addEventListener('storage', (e) => {
|
||||
if (e.key === getStorageKey()) {
|
||||
load()
|
||||
|
|
|
|||
|
|
@ -1,17 +1,19 @@
|
|||
/**
|
||||
* @file useNavItems.ts
|
||||
* @description Single Source of Truth for navigation items used across the app (Sidebar, Mobile Nav, User Menu).
|
||||
* @description ศูนย์รวมจัดการเมนูนำทาง (Navigation Items) เพื่อให้ใช้ข้อมูลชุดเดียวกันทั้งเว็บ
|
||||
* (เช่น เมนูด้านข้าง, เมนูบนมือถือ, และเมนูโปรไฟล์ผู้ใช้)
|
||||
*/
|
||||
|
||||
export interface NavItem {
|
||||
to: string
|
||||
labelKey: string
|
||||
icon: string
|
||||
showOn: ('sidebar' | 'mobile' | 'userMenu')[]
|
||||
roles?: string[]
|
||||
to: string // ลิงก์ปลายทาง
|
||||
labelKey: string // คีย์ภาษาสำหรับ i18n
|
||||
icon: string // ไอคอนจาก Material Icons
|
||||
showOn: ('sidebar' | 'mobile' | 'userMenu')[] // กำหนดให้โชว์ที่ส่วนไหนบ้าง
|
||||
roles?: string[] // กำหนดสิทธิ์ผู้ใช้ที่จะเห็น (ถ้ามี)
|
||||
}
|
||||
|
||||
export const useNavItems = () => {
|
||||
// เมนูทั้งหมดในระบบ กำหนดไว้ที่เดียว
|
||||
const allNavItems: NavItem[] = [
|
||||
{
|
||||
to: '/dashboard',
|
||||
|
|
@ -63,6 +65,7 @@ export const useNavItems = () => {
|
|||
}
|
||||
]
|
||||
|
||||
// คัดกรองเมนูที่จะเอาไปแสดงแต่ละตำแหน่ง
|
||||
const sidebarItems = computed(() => allNavItems.filter(item => item.showOn.includes('sidebar')))
|
||||
const mobileItems = computed(() => allNavItems.filter(item => item.showOn.includes('mobile')))
|
||||
const userMenuItems = computed(() => allNavItems.filter(item => item.showOn.includes('userMenu')))
|
||||
|
|
|
|||
|
|
@ -19,95 +19,107 @@ export interface AnswerState {
|
|||
|
||||
/**
|
||||
* @composable useQuizRunner
|
||||
* @description Manages the state and logic for running a quiz activity.
|
||||
* @description จัดการ State (สถานะ) และ Logic สำหรับการทำแบบทดสอบ (Quiz)
|
||||
* ครอบคลุมการแสดงคำถาม, บันทึกคำตอบ, และตรวจสอบการข้ามคำถาม
|
||||
*/
|
||||
export const useQuizRunner = () => {
|
||||
// State
|
||||
const questions = useState<QuizQuestion[]>('quiz-questions', () => []);
|
||||
const answers = useState<Record<number, AnswerState>>('quiz-answers', () => ({}));
|
||||
const currentQuestionIndex = useState<number>('quiz-current-index', () => 0);
|
||||
const loading = useState<boolean>('quiz-loading', () => false);
|
||||
const lastError = useState<string | null>('quiz-error', () => null);
|
||||
// ================= State (สถานะเก็บค่าต่างๆ ของข้อสอบ) =================
|
||||
const questions = useState<QuizQuestion[]>('quiz-questions', () => []); // เก็บรายการคำถามทั้งหมด
|
||||
const answers = useState<Record<number, AnswerState>>('quiz-answers', () => ({})); // เก็บคำตอบที่ผู้ใช้ตอบ แยกตาม ID คำถาม
|
||||
const currentQuestionIndex = useState<number>('quiz-current-index', () => 0); // ลำดับคำถามที่กำลังทำอยู่ปัจจุบัน (เริ่มที่ 0)
|
||||
const loading = useState<boolean>('quiz-loading', () => false); // สถานะตอนกำลังกดเซฟหรือโหลดข้อมูล
|
||||
const lastError = useState<string | null>('quiz-error', () => null); // เก็บข้อความแจ้งเตือนข้อผิดพลาดล่าสุด
|
||||
|
||||
// Getters
|
||||
const currentQuestion = computed(() => questions.value[currentQuestionIndex.value]);
|
||||
// ================= Getters (ดึงค่าที่ถูกประมวลผลแล้ว) =================
|
||||
const currentQuestion = computed(() => questions.value[currentQuestionIndex.value]); // ดึงคำถามข้อปัจจุบัน
|
||||
|
||||
const currentAnswer = computed(() => {
|
||||
const currentAnswer = computed(() => { // ดึงคำตอบในข้อปัจจุบัน
|
||||
if (!currentQuestion.value) return null;
|
||||
return answers.value[currentQuestion.value.id];
|
||||
});
|
||||
|
||||
const totalQuestions = computed(() => questions.value.length);
|
||||
const isLastQuestion = computed(() => currentQuestionIndex.value === questions.value.length - 1);
|
||||
const isFirstQuestion = computed(() => currentQuestionIndex.value === 0);
|
||||
const totalQuestions = computed(() => questions.value.length); // จำนวนคำถามทั้งหมดในแบบทดสอบ
|
||||
const isLastQuestion = computed(() => currentQuestionIndex.value === questions.value.length - 1); // เช็คว่าใช่คำถามข้อสุดท้ายหรือไม่
|
||||
const isFirstQuestion = computed(() => currentQuestionIndex.value === 0); // เช็คว่าใช่คำถามข้อแรกหรือไม่
|
||||
|
||||
// Actions
|
||||
// ================= Actions (ฟังก์ชันหลักสำหรับการทำงาน) =================
|
||||
|
||||
// ฟังก์ชันเริ่มต้นสร้าง/โหลดข้อสอบ (กำหนดโครงสร้างพื้นฐาน)
|
||||
function initQuiz(quizData: any) {
|
||||
if (!quizData || !quizData.questions) return;
|
||||
|
||||
questions.value = quizData.questions;
|
||||
currentQuestionIndex.value = 0;
|
||||
currentQuestionIndex.value = 0; // รีเซ็ตไปที่ข้อ 1 ใหม่
|
||||
answers.value = {};
|
||||
lastError.value = null;
|
||||
|
||||
// เตรียมโครงสร้างคำตอบรองรับทุกข้อ
|
||||
questions.value.forEach(q => {
|
||||
answers.value[q.id] = {
|
||||
questionId: q.id,
|
||||
value: null,
|
||||
is_saved: false,
|
||||
status: 'not_started',
|
||||
touched: false,
|
||||
is_saved: false, // บันทึกและส่ง API เรียบร้อยหรือยัง
|
||||
status: 'not_started', // สถานะเริ่มต้นของคำถาม
|
||||
touched: false, // ผู้ใช้เคยเปิดเข้ามาดูข้อนีัหรือยัง
|
||||
};
|
||||
});
|
||||
|
||||
// เริ่มต้นบันทึกเวลา/เข้าสู่ข้อที่ 1 ทันทีเมื่ออธิบายเสร็จ
|
||||
if (questions.value.length > 0) {
|
||||
enterQuestion(questions.value[0].id);
|
||||
}
|
||||
}
|
||||
|
||||
// ฟังก์ชันสลับสถานะเมื่อกดเข้ามาที่คำถามนั้นๆ
|
||||
function enterQuestion(qId: number) {
|
||||
const ans = answers.value[qId];
|
||||
if (ans) {
|
||||
ans.touched = true;
|
||||
if (ans.status === 'not_started' || ans.status === 'skipped') {
|
||||
ans.status = 'in_progress';
|
||||
ans.status = 'in_progress'; // เปลี่ยนสถานะเป็น 'กำลังทำ'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ตรวจสอบเงื่อนไขว่าผู้ใช้สามารถออกจากคำถามปัจจุบันไปยังข้ออื่นได้หรือไม่
|
||||
function canLeaveCurrent(): { allowed: boolean; reason?: string } {
|
||||
if (!currentQuestion.value) return { allowed: true };
|
||||
const q = currentQuestion.value;
|
||||
const a = answers.value[q.id];
|
||||
|
||||
// สามารถออกได้ถ้าคำถามทำถูกหรือโจทย์อนุญาตให้ข้ามได้
|
||||
if (a.status === 'completed' || a.is_saved) return { allowed: true };
|
||||
if (q.is_skippable) return { allowed: true };
|
||||
|
||||
// บังคับให้ตอบถ้าไม่ได้อนุญาตให้ข้าม และไม่ได้ตอบ
|
||||
if (!a.is_saved && a.value === null) {
|
||||
return { allowed: false, reason: 'This question is required.' };
|
||||
return { allowed: false, reason: 'ต้องการคำตอบสำหรับข้อบังคับนี้' };
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
// ฟังก์ชันอัปเดตค่าตัวเลือกที่ผู้ใช้กดเลือกในข้อปัจจุบัน
|
||||
function updateAnswer(val: any) {
|
||||
if (!currentQuestion.value) return;
|
||||
const qId = currentQuestion.value.id;
|
||||
answers.value[qId].value = val;
|
||||
|
||||
// หากมีแก้ไขคำตอบหลังจากกดเซฟไปแล้ว ให้เปลี่ยนสถานะให้ระบบรู้ว่าต้องเซฟใหม่
|
||||
if (answers.value[qId].is_saved) {
|
||||
answers.value[qId].is_saved = false;
|
||||
answers.value[qId].status = 'in_progress';
|
||||
answers.value[qId].status = 'in_progress';
|
||||
}
|
||||
}
|
||||
|
||||
// ล็อกและบันทึกข้อสอบเมื่อกดปุ่ม "ตกลง/ส่งคำตอบ" สำหรับข้อนั้นๆ
|
||||
async function saveCurrentAnswer() {
|
||||
if (!currentQuestion.value) return;
|
||||
const qId = currentQuestion.value.id;
|
||||
const ans = answers.value[qId];
|
||||
|
||||
if (ans.value === null) {
|
||||
lastError.value = "Please provide an answer.";
|
||||
lastError.value = "กรุณาเลือกคำตอบอย่างน้อย 1 ตัวเลือก";
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -115,30 +127,34 @@ export const useQuizRunner = () => {
|
|||
lastError.value = null;
|
||||
|
||||
try {
|
||||
// หมายเหตุ: การเชื่อมต่อ API หลักต้องทำที่ไฟล์ component, ตัวนี้จัดการแค่เรื่อง State
|
||||
ans.is_saved = true;
|
||||
ans.status = 'completed';
|
||||
ans.status = 'completed'; // มาร์คว่าเป็นข้อที่ทำเสร็จแล้ว
|
||||
ans.last_saved_at = new Date().toISOString();
|
||||
return true;
|
||||
} catch (e) {
|
||||
lastError.value = "Failed to save answer.";
|
||||
lastError.value = "เกิดข้อผิดพลาดในการบันทึกคำตอบ";
|
||||
return false;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// วินิจฉัยก่อนสั่งผู้ใช้ย้ายไปยังคำถามอื่นหน้าอื่นตามดัชนีระบุ
|
||||
function handleLeaveLogic(targetIndex: number) {
|
||||
if (targetIndex === currentQuestionIndex.value) return;
|
||||
|
||||
// ตรวจสอบขั้นสุดท้าย ป้องกันคนคลิกแอบหนีข้อที่บังคับทำ
|
||||
const check = canLeaveCurrent();
|
||||
if (!check.allowed) {
|
||||
lastError.value = check.reason || "Required question.";
|
||||
lastError.value = check.reason || "จำเป็นต้องตอบข้อนี้ก่อนข้าม";
|
||||
return false;
|
||||
}
|
||||
|
||||
const currQ = currentQuestion.value;
|
||||
if (currQ) {
|
||||
const currAns = answers.value[currQ.id];
|
||||
// หากผู้ใช้ทิ้งขว้างโดยที่ไม่บังคับ ให้ทิ้งสถานะเป็นข้าม ('skipped')
|
||||
if (currAns.status !== 'completed' && !currAns.is_saved) {
|
||||
currAns.status = 'skipped';
|
||||
}
|
||||
|
|
@ -147,6 +163,7 @@ export const useQuizRunner = () => {
|
|||
currentQuestionIndex.value = targetIndex;
|
||||
lastError.value = null;
|
||||
|
||||
// ติดตามสถานะ 'touched' ในข้อใหม่ที่เข้าไปล่าสุด
|
||||
if (questions.value[targetIndex]) {
|
||||
enterQuestion(questions.value[targetIndex].id);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,26 +1,41 @@
|
|||
import { useQuasar } from 'quasar'
|
||||
|
||||
/**
|
||||
* @composable useThemeMode
|
||||
* @description จัดการระบบโหมดสว่าง/มืด (Light/Dark Theme) ของแพลตฟอร์ม
|
||||
* ครอบคลุมการสั่งบันทึกการตั้งค่า, เปลี่ยนคลาส Tailwind และ Sync ซิงก์กับ Quasar UI
|
||||
*/
|
||||
export const useThemeMode = () => {
|
||||
const $q = useQuasar()
|
||||
|
||||
// deterministic on SSR: default = light
|
||||
// สถานะเริ่มต้นของโหมดมืด (สำหรับการทำ SSR ถูกเซ็ตเป็น false (สว่าง) ไว้ก่อน)
|
||||
const isDark = useState<boolean>('theme:isDark', () => false)
|
||||
|
||||
// ฟังก์ชันใช้คลาสกับ Tag <html> เพื่อให้ Tailwind หรือ CSS รันโหมดมืด
|
||||
const applyTheme = (value: boolean) => {
|
||||
if (!process.client) return
|
||||
if (!process.client) return // หากทำงานฝั่งเซิร์ฟเวอร์ จะไม่สั่งให้รัน DOM
|
||||
|
||||
// สลับคลาส 'dark' หรือปิด
|
||||
document.documentElement.classList.toggle('dark', value)
|
||||
|
||||
// สั่งให้ Quasar (UI Framework) ปรับโหมดสีให้ตรงกัน (มืด/สว่าง)
|
||||
$q.dark.set(value)
|
||||
|
||||
// บันทึกการตั้งค่าลงเครื่องระยะยาวเบราว์เซอร์
|
||||
localStorage.setItem('theme', value ? 'dark' : 'light')
|
||||
}
|
||||
|
||||
|
||||
|
||||
// จับตาดูเมื่อตัวแปรเปลี่ยนค่าค่อยทำการเปลี่ยนโหมดบนหน้าจอ
|
||||
watch(isDark, (v) => applyTheme(v))
|
||||
|
||||
// ฟังก์ชันสั่งสลับโหมด ไปมา (Toggle)
|
||||
const toggle = () => {
|
||||
isDark.value = !isDark.value
|
||||
}
|
||||
|
||||
// ฟังก์ชันสำหรับกำหนดตั้งค่าโหมดแบบเจาะจง
|
||||
const set = (v: boolean) => {
|
||||
isDark.value = v
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue