login with api
This commit is contained in:
parent
d8a9909eb9
commit
ff5b189b2f
16 changed files with 1241 additions and 66 deletions
59
frontend_management/components/common/AppCard.vue
Normal file
59
frontend_management/components/common/AppCard.vue
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
<template>
|
||||||
|
<q-card
|
||||||
|
:class="[
|
||||||
|
'rounded-lg shadow-md transition-shadow',
|
||||||
|
hoverable && 'hover:shadow-lg cursor-pointer',
|
||||||
|
customClass
|
||||||
|
]"
|
||||||
|
style="border: 1px solid #e5e7eb;"
|
||||||
|
@click="handleClick"
|
||||||
|
>
|
||||||
|
<!-- Card Header -->
|
||||||
|
<q-card-section v-if="$slots.header || title" class="border-b border-gray-200">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h3 v-if="title" class="text-lg font-semibold text-gray-900">{{ title }}</h3>
|
||||||
|
<p v-if="subtitle" class="text-sm text-gray-600 mt-1">{{ subtitle }}</p>
|
||||||
|
</div>
|
||||||
|
<slot name="header-actions"></slot>
|
||||||
|
</div>
|
||||||
|
<slot name="header"></slot>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<!-- Card Content -->
|
||||||
|
<q-card-section :class="contentClass">
|
||||||
|
<slot></slot>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<!-- Card Footer -->
|
||||||
|
<q-card-section v-if="$slots.footer" class="border-t border-gray-200">
|
||||||
|
<slot name="footer"></slot>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
title?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
hoverable?: boolean;
|
||||||
|
customClass?: string;
|
||||||
|
contentClass?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
withDefaults(defineProps<Props>(), {
|
||||||
|
title: '',
|
||||||
|
subtitle: '',
|
||||||
|
hoverable: false,
|
||||||
|
customClass: '',
|
||||||
|
contentClass: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
click: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
emit('click');
|
||||||
|
};
|
||||||
|
</script>
|
||||||
53
frontend_management/components/common/AppHeader.vue
Normal file
53
frontend_management/components/common/AppHeader.vue
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
<template>
|
||||||
|
<header class="bg-white shadow-sm border-b border-gray-200 px-8 py-4">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<!-- Left: Page Title -->
|
||||||
|
<div>
|
||||||
|
<h1 v-if="title" class="text-2xl font-bold text-gray-900">{{ title }}</h1>
|
||||||
|
<p v-if="subtitle" class="text-sm text-gray-600 mt-1">{{ subtitle }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right: Actions & User -->
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<!-- Action Buttons Slot -->
|
||||||
|
<slot name="actions"></slot>
|
||||||
|
|
||||||
|
<!-- User Avatar & Info -->
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="text-right">
|
||||||
|
<div class="text-sm font-semibold text-gray-900">{{ userName }}</div>
|
||||||
|
<div class="text-xs text-gray-500">{{ userRole }}</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center text-lg cursor-pointer hover:bg-primary-200 transition"
|
||||||
|
@click="$emit('avatar-click')"
|
||||||
|
>
|
||||||
|
{{ userAvatar }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
title?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
userName?: string;
|
||||||
|
userRole?: string;
|
||||||
|
userAvatar?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
withDefaults(defineProps<Props>(), {
|
||||||
|
title: '',
|
||||||
|
subtitle: '',
|
||||||
|
userName: 'ผู้ใช้งาน',
|
||||||
|
userRole: 'User',
|
||||||
|
userAvatar: '👤'
|
||||||
|
});
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
'avatar-click': [];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
101
frontend_management/components/common/AppModal.vue
Normal file
101
frontend_management/components/common/AppModal.vue
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
<template>
|
||||||
|
<q-dialog
|
||||||
|
:model-value="modelValue"
|
||||||
|
@update:model-value="$emit('update:modelValue', $event)"
|
||||||
|
:persistent="persistent"
|
||||||
|
>
|
||||||
|
<q-card :style="{ width: width, maxWidth: maxWidth }">
|
||||||
|
<!-- Modal Header -->
|
||||||
|
<q-card-section class="row items-center q-pb-none">
|
||||||
|
<div class="text-h6">{{ title }}</div>
|
||||||
|
<q-space />
|
||||||
|
<q-btn
|
||||||
|
v-if="showClose"
|
||||||
|
icon="close"
|
||||||
|
flat
|
||||||
|
round
|
||||||
|
dense
|
||||||
|
@click="handleClose"
|
||||||
|
/>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<!-- Modal Content -->
|
||||||
|
<q-card-section :class="contentClass">
|
||||||
|
<slot></slot>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<!-- Modal Actions -->
|
||||||
|
<q-card-actions v-if="$slots.actions || showDefaultActions" align="right" class="q-px-md q-pb-md">
|
||||||
|
<slot name="actions">
|
||||||
|
<q-btn
|
||||||
|
v-if="showDefaultActions"
|
||||||
|
flat
|
||||||
|
:label="cancelLabel"
|
||||||
|
color="grey"
|
||||||
|
@click="handleCancel"
|
||||||
|
/>
|
||||||
|
<q-btn
|
||||||
|
v-if="showDefaultActions"
|
||||||
|
unelevated
|
||||||
|
:label="confirmLabel"
|
||||||
|
:color="confirmColor"
|
||||||
|
:loading="loading"
|
||||||
|
@click="handleConfirm"
|
||||||
|
/>
|
||||||
|
</slot>
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
modelValue: boolean;
|
||||||
|
title?: string;
|
||||||
|
width?: string;
|
||||||
|
maxWidth?: string;
|
||||||
|
persistent?: boolean;
|
||||||
|
showClose?: boolean;
|
||||||
|
showDefaultActions?: boolean;
|
||||||
|
cancelLabel?: string;
|
||||||
|
confirmLabel?: string;
|
||||||
|
confirmColor?: string;
|
||||||
|
loading?: boolean;
|
||||||
|
contentClass?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
withDefaults(defineProps<Props>(), {
|
||||||
|
title: '',
|
||||||
|
width: '500px',
|
||||||
|
maxWidth: '90vw',
|
||||||
|
persistent: false,
|
||||||
|
showClose: true,
|
||||||
|
showDefaultActions: true,
|
||||||
|
cancelLabel: 'ยกเลิก',
|
||||||
|
confirmLabel: 'ยืนยัน',
|
||||||
|
confirmColor: 'primary',
|
||||||
|
loading: false,
|
||||||
|
contentClass: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: boolean];
|
||||||
|
'confirm': [];
|
||||||
|
'cancel': [];
|
||||||
|
'close': [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
emit('update:modelValue', false);
|
||||||
|
emit('close');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
emit('update:modelValue', false);
|
||||||
|
emit('cancel');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
emit('confirm');
|
||||||
|
};
|
||||||
|
</script>
|
||||||
177
frontend_management/components/common/AppTable.vue
Normal file
177
frontend_management/components/common/AppTable.vue
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
<template>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full border-collapse">
|
||||||
|
<!-- Table Header -->
|
||||||
|
<thead class="bg-gray-50 border-b border-gray-200">
|
||||||
|
<tr>
|
||||||
|
<th
|
||||||
|
v-for="column in columns"
|
||||||
|
:key="column.key"
|
||||||
|
:class="[
|
||||||
|
'px-6 py-3 text-left text-xs font-semibold text-gray-700 uppercase tracking-wider',
|
||||||
|
column.align === 'center' && 'text-center',
|
||||||
|
column.align === 'right' && 'text-right',
|
||||||
|
column.headerClass
|
||||||
|
]"
|
||||||
|
:style="{ width: column.width }"
|
||||||
|
>
|
||||||
|
{{ column.label }}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<!-- Table Body -->
|
||||||
|
<tbody class="bg-white divide-y divide-gray-200">
|
||||||
|
<tr
|
||||||
|
v-for="(row, index) in data"
|
||||||
|
:key="getRowKey(row, index)"
|
||||||
|
:class="[
|
||||||
|
'hover:bg-gray-50 transition',
|
||||||
|
rowClickable && 'cursor-pointer'
|
||||||
|
]"
|
||||||
|
@click="handleRowClick(row)"
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
v-for="column in columns"
|
||||||
|
:key="column.key"
|
||||||
|
:class="[
|
||||||
|
'px-6 py-4 text-sm text-gray-900',
|
||||||
|
column.align === 'center' && 'text-center',
|
||||||
|
column.align === 'right' && 'text-right',
|
||||||
|
column.cellClass
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<!-- Custom Cell Slot -->
|
||||||
|
<slot
|
||||||
|
v-if="$slots[`cell-${column.key}`]"
|
||||||
|
:name="`cell-${column.key}`"
|
||||||
|
:row="row"
|
||||||
|
:value="row[column.key]"
|
||||||
|
></slot>
|
||||||
|
|
||||||
|
<!-- Default Cell Content -->
|
||||||
|
<span v-else>{{ row[column.key] }}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<tr v-if="data.length === 0">
|
||||||
|
<td :colspan="columns.length" class="px-6 py-12 text-center text-gray-500">
|
||||||
|
<slot name="empty">
|
||||||
|
<div class="text-gray-400">
|
||||||
|
<p class="text-lg mb-2">📭</p>
|
||||||
|
<p>{{ emptyMessage }}</p>
|
||||||
|
</div>
|
||||||
|
</slot>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<div v-if="showPagination && totalPages > 1" class="flex justify-between items-center px-6 py-4 border-t border-gray-200">
|
||||||
|
<div class="text-sm text-gray-600">
|
||||||
|
แสดง {{ startItem }}-{{ endItem }} จาก {{ totalItems }} รายการ
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
icon="chevron_left"
|
||||||
|
:disable="currentPage === 1"
|
||||||
|
@click="changePage(currentPage - 1)"
|
||||||
|
/>
|
||||||
|
<q-btn
|
||||||
|
v-for="page in visiblePages"
|
||||||
|
:key="page"
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
:label="String(page)"
|
||||||
|
:color="page === currentPage ? 'primary' : 'grey'"
|
||||||
|
@click="changePage(page)"
|
||||||
|
/>
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
icon="chevron_right"
|
||||||
|
:disable="currentPage === totalPages"
|
||||||
|
@click="changePage(currentPage + 1)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface Column {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
width?: string;
|
||||||
|
align?: 'left' | 'center' | 'right';
|
||||||
|
headerClass?: string;
|
||||||
|
cellClass?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
columns: Column[];
|
||||||
|
data: any[];
|
||||||
|
rowKey?: string;
|
||||||
|
rowClickable?: boolean;
|
||||||
|
emptyMessage?: string;
|
||||||
|
showPagination?: boolean;
|
||||||
|
currentPage?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
totalItems?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
rowKey: 'id',
|
||||||
|
rowClickable: false,
|
||||||
|
emptyMessage: 'ไม่พบข้อมูล',
|
||||||
|
showPagination: false,
|
||||||
|
currentPage: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
totalItems: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'row-click': [row: any];
|
||||||
|
'page-change': [page: number];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const getRowKey = (row: any, index: number) => {
|
||||||
|
return row[props.rowKey] || index;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRowClick = (row: any) => {
|
||||||
|
if (props.rowClickable) {
|
||||||
|
emit('row-click', row);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const changePage = (page: number) => {
|
||||||
|
emit('page-change', page);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pagination calculations
|
||||||
|
const totalPages = computed(() => Math.ceil(props.totalItems / props.pageSize));
|
||||||
|
const startItem = computed(() => (props.currentPage - 1) * props.pageSize + 1);
|
||||||
|
const endItem = computed(() => Math.min(props.currentPage * props.pageSize, props.totalItems));
|
||||||
|
|
||||||
|
const visiblePages = computed(() => {
|
||||||
|
const pages = [];
|
||||||
|
const maxVisible = 5;
|
||||||
|
let start = Math.max(1, props.currentPage - Math.floor(maxVisible / 2));
|
||||||
|
let end = Math.min(totalPages.value, start + maxVisible - 1);
|
||||||
|
|
||||||
|
if (end - start < maxVisible - 1) {
|
||||||
|
start = Math.max(1, end - maxVisible + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = start; i <= end; i++) {
|
||||||
|
pages.push(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
return pages;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
34
frontend_management/composables/useApi.ts
Normal file
34
frontend_management/composables/useApi.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import type { UseFetchOptions } from 'nuxt/app';
|
||||||
|
|
||||||
|
export const useApi = () => {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const baseURL = config.public.apiBaseUrl as string;
|
||||||
|
|
||||||
|
const apiFetch = <T>(url: string, options?: UseFetchOptions<T>) => {
|
||||||
|
return $fetch<T>(url, {
|
||||||
|
baseURL,
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
...options?.headers,
|
||||||
|
},
|
||||||
|
onRequest({ options }) {
|
||||||
|
// Add auth token if available
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (token) {
|
||||||
|
options.headers = {
|
||||||
|
...options.headers,
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onResponseError({ response }) {
|
||||||
|
// Handle errors globally
|
||||||
|
console.error('API Error:', response.status, response._data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
apiFetch
|
||||||
|
};
|
||||||
|
};
|
||||||
83
frontend_management/layouts/admin.vue
Normal file
83
frontend_management/layouts/admin.vue
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-gray-50">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside class="fixed left-0 top-0 h-screen w-64 bg-white shadow-lg">
|
||||||
|
<div class="p-6">
|
||||||
|
<h2 class="text-xl font-bold text-primary-600">E-Learning</h2>
|
||||||
|
<p class="text-sm text-gray-500">Admin Panel</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="px-4">
|
||||||
|
<NuxtLink
|
||||||
|
to="/admin"
|
||||||
|
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-primary-100 transition mb-2"
|
||||||
|
active-class="bg-primary-500 text-white hover:bg-primary-600"
|
||||||
|
>
|
||||||
|
<q-icon name="dashboard" size="24px" />
|
||||||
|
<span>Dashboard</span>
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<NuxtLink
|
||||||
|
to="/admin/courses"
|
||||||
|
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-primary-100 transition mb-2"
|
||||||
|
active-class="bg-primary-500 text-white hover:bg-primary-600"
|
||||||
|
>
|
||||||
|
<q-icon name="school" size="24px" />
|
||||||
|
<span>จัดการหลักสูตร</span>
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<NuxtLink
|
||||||
|
to="/admin/users"
|
||||||
|
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-primary-100 transition mb-2"
|
||||||
|
active-class="bg-primary-500 text-white hover:bg-primary-600"
|
||||||
|
>
|
||||||
|
<q-icon name="people" size="24px" />
|
||||||
|
<span>จัดการผู้ใช้</span>
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<NuxtLink
|
||||||
|
to="/admin/categories"
|
||||||
|
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-primary-100 transition mb-2"
|
||||||
|
active-class="bg-primary-500 text-white hover:bg-primary-600"
|
||||||
|
>
|
||||||
|
<q-icon name="folder" size="24px" />
|
||||||
|
<span>หมวดหมู่</span>
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<NuxtLink
|
||||||
|
to="/admin/settings"
|
||||||
|
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-primary-100 transition mb-2"
|
||||||
|
active-class="bg-primary-500 text-white hover:bg-primary-600"
|
||||||
|
>
|
||||||
|
<q-icon name="settings" size="24px" />
|
||||||
|
<span>ตั้งค่าระบบ</span>
|
||||||
|
</NuxtLink>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- <div class="absolute bottom-0 left-0 right-0 p-4 border-t">
|
||||||
|
<button
|
||||||
|
@click="handleLogout"
|
||||||
|
class="w-full flex items-center gap-3 px-4 py-3 text-red-600 hover:bg-red-50 rounded-lg transition"
|
||||||
|
>
|
||||||
|
<span>🚪</span>
|
||||||
|
<span>ออกจากระบบ</span>
|
||||||
|
</button>
|
||||||
|
</div> -->
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main class="ml-64 p-8">
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
authStore.logout();
|
||||||
|
router.push('/login');
|
||||||
|
};
|
||||||
|
</script>
|
||||||
47
frontend_management/layouts/auth.vue
Normal file
47
frontend_management/layouts/auth.vue
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
<template>
|
||||||
|
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 via-blue-100 to-indigo-100">
|
||||||
|
<!-- Background Pattern (Optional) -->
|
||||||
|
<div class="absolute inset-0 overflow-hidden opacity-10">
|
||||||
|
<div class="absolute -top-40 -right-40 w-80 h-80 bg-primary-500 rounded-full blur-3xl"></div>
|
||||||
|
<div class="absolute -bottom-40 -left-40 w-80 h-80 bg-secondary-500 rounded-full blur-3xl"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Auth Container -->
|
||||||
|
<div class="relative z-10 w-full max-w-md px-6">
|
||||||
|
<!-- Logo/Brand -->
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Auth Card -->
|
||||||
|
<q-card class="shadow-2xl rounded-2xl overflow-hidden">
|
||||||
|
<q-card-section class="p-1">
|
||||||
|
<slot></slot>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="text-center mt-6 text-sm text-gray-600">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// No props needed - pure layout component
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Optional: Add custom animations */
|
||||||
|
@keyframes float {
|
||||||
|
0%, 100% {
|
||||||
|
transform: translateY(0px);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(-20px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-float {
|
||||||
|
animation: float 6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -10,42 +10,24 @@
|
||||||
<nav class="px-4">
|
<nav class="px-4">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/instructor"
|
to="/instructor"
|
||||||
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-primary-100 shadow-md transition mb-2"
|
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-primary-100 transition mb-2"
|
||||||
active-class="bg-primary-500 text-white hover:bg-primary-600 "
|
active-class="bg-primary-500 text-white hover:bg-primary-600"
|
||||||
>
|
>
|
||||||
<span>📊</span>
|
<q-icon name="dashboard" size="24px" />
|
||||||
<span>Dashboard</span>
|
<span>Dashboard</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/instructor/courses"
|
to="/instructor/courses"
|
||||||
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-primary-200 shadow-md transition mb-2"
|
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-primary-100 transition mb-2"
|
||||||
active-class="bg-primary-500 text-white hover:bg-primary-600"
|
active-class="bg-primary-500 text-white hover:bg-primary-600"
|
||||||
>
|
>
|
||||||
<span>📚</span>
|
<q-icon name="school" size="24px" />
|
||||||
<span>หลักสูตร</span>
|
<span>หลักสูตร</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
||||||
<NuxtLink
|
|
||||||
to="/instructor/announcements"
|
|
||||||
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-primary-200 shadow-md transition mb-2"
|
|
||||||
active-class="bg-primary-500 text-white hover:bg-primary-600"
|
|
||||||
>
|
|
||||||
<span>📢</span>
|
|
||||||
<span>ประกาศ</span>
|
|
||||||
</NuxtLink>
|
|
||||||
|
|
||||||
<NuxtLink
|
|
||||||
to="/instructor/reports"
|
|
||||||
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-primary-200 shadow-md transition mb-2"
|
|
||||||
active-class="bg-primary-500 text-white hover:bg-primary-600"
|
|
||||||
>
|
|
||||||
<span>📈</span>
|
|
||||||
<span>รายงาน</span>
|
|
||||||
</NuxtLink>
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="absolute bottom-0 left-0 right-0 p-4 border-t">
|
<!-- <div class="absolute bottom-0 left-0 right-0 p-4 border-t">
|
||||||
<button
|
<button
|
||||||
@click="handleLogout"
|
@click="handleLogout"
|
||||||
class="w-full flex items-center gap-3 px-4 py-3 text-red-600 hover:bg-red-50 rounded-lg transition"
|
class="w-full flex items-center gap-3 px-4 py-3 text-red-600 hover:bg-red-50 rounded-lg transition"
|
||||||
|
|
@ -53,7 +35,7 @@
|
||||||
<span>🚪</span>
|
<span>🚪</span>
|
||||||
<span>ออกจากระบบ</span>
|
<span>ออกจากระบบ</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div> -->
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,8 @@ export default defineNuxtConfig({
|
||||||
],
|
],
|
||||||
runtimeConfig: {
|
runtimeConfig: {
|
||||||
public: {
|
public: {
|
||||||
apiBase: process.env.API_BASE_URL || 'http://localhost:3001/api',
|
apiBaseUrl: process.env.NUXT_PUBLIC_API_BASE_URL,
|
||||||
useMockData: process.env.USE_MOCK_DATA === 'true'
|
useMockData: process.env.NUXT_PUBLIC_USE_MOCK_DATA === 'true'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
devtools: { enabled: true },
|
devtools: { enabled: true },
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,59 @@
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-3xl font-bold mb-6">Dashboard</h1>
|
<!-- Header -->
|
||||||
<p class="text-gray-600 mb-8">ยินดีต้อนรับ, {{ authStore.user?.fullName }}</p>
|
<div class="mb-8">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900">
|
||||||
|
สวัสดี, {{ authStore.user?.fullName || 'ผู้ดูแลระบบ' }}
|
||||||
|
</h1>
|
||||||
|
<p class="text-gray-600 mt-2">ยินดีต้อนรับกลับสู่ระบบ</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="text-right">
|
||||||
|
<div class="text-sm font-semibold text-gray-900">{{ authStore.user?.fullName || 'ผู้ดูแลระบบ' }}</div>
|
||||||
|
<div class="text-xs text-gray-500">{{ authStore.user?.role || 'ADMIN' }}</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"
|
||||||
|
>
|
||||||
|
👨💼
|
||||||
|
<q-menu>
|
||||||
|
<q-list style="min-width: 200px">
|
||||||
|
<!-- User Info Header -->
|
||||||
|
<q-item class="bg-primary-50">
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label class="text-weight-bold">{{ authStore.user?.fullName }}</q-item-label>
|
||||||
|
<q-item-label caption>{{ authStore.user?.email }}</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
<q-separator />
|
||||||
|
|
||||||
|
<!-- Profile -->
|
||||||
|
<q-item clickable v-close-popup @click="goToProfile">
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-icon name="person" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>โปรไฟล์</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
<q-separator />
|
||||||
|
|
||||||
|
<!-- Logout -->
|
||||||
|
<q-item clickable v-close-popup @click="handleLogout">
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-icon name="logout" color="negative" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section class="text-negative">ออกจากระบบ</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
</q-menu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Stats Cards -->
|
<!-- Stats Cards -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
|
@ -28,7 +80,6 @@
|
||||||
<p class="text-gray-600 text-sm">เรียนจบแล้ว</p>
|
<p class="text-gray-600 text-sm">เรียนจบแล้ว</p>
|
||||||
<p class="text-3xl font-bold text-accent-500">45</p>
|
<p class="text-3xl font-bold text-accent-500">45</p>
|
||||||
</div>
|
</div>
|
||||||
<q-icon name="emoji_events" size="48px" class="text-accent-200" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -52,8 +103,20 @@
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: 'instructor',
|
layout: 'admin',
|
||||||
middleware: 'instructor'
|
middleware: 'auth'
|
||||||
});
|
});
|
||||||
|
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// Navigation functions
|
||||||
|
const goToProfile = () => {
|
||||||
|
router.push('/admin/profile');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
authStore.logout();
|
||||||
|
router.push('/login');
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -1,14 +1,55 @@
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="flex justify-between items-center mb-8">
|
<div class="mb-8">
|
||||||
<div>
|
<div class="flex justify-between items-center">
|
||||||
<h1 class="text-3xl font-bold text-gray-900">สวัสดี, อาจารย์ทดสอบ 👋</h1>
|
<div>
|
||||||
<p class="text-gray-600 mt-2">ยินดีต้อนรับกลับสู่ระบบ</p>
|
<h1 class="text-2xl font-bold text-gray-900">
|
||||||
</div>
|
สวัสดี, {{ authStore.user?.fullName || 'อาจารย์' }}
|
||||||
<div class="flex items-center gap-4">
|
</h1>
|
||||||
<div class="w-12 h-12 bg-primary-100 rounded-full flex items-center justify-center text-2xl">
|
<p class="text-gray-600 mt-2">ยินดีต้อนรับกลับสู่ระบบ</p>
|
||||||
👤
|
</div>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="text-right">
|
||||||
|
<div class="text-sm font-semibold text-gray-900">{{ authStore.user?.fullName || 'อาจารย์ทดสอบ' }}</div>
|
||||||
|
<div class="text-xs text-gray-500">{{ authStore.user?.role || 'INSTRUCTOR' }}</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"
|
||||||
|
>
|
||||||
|
👨🏫
|
||||||
|
<q-menu>
|
||||||
|
<q-list style="min-width: 200px">
|
||||||
|
<!-- User Info Header -->
|
||||||
|
<q-item class="bg-primary-50">
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label class="text-weight-bold">{{ authStore.user?.fullName }}</q-item-label>
|
||||||
|
<q-item-label caption>{{ authStore.user?.email }}</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
<q-separator />
|
||||||
|
|
||||||
|
<!-- Profile -->
|
||||||
|
<q-item clickable v-close-popup @click="goToProfile">
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-icon name="person" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>โปรไฟล์</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
<q-separator />
|
||||||
|
|
||||||
|
<!-- Logout -->
|
||||||
|
<q-item clickable v-close-popup @click="handleLogout">
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-icon name="logout" color="negative" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section class="text-negative">ออกจากระบบ</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
</q-menu>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -96,9 +137,24 @@ definePageMeta({
|
||||||
middleware: 'auth'
|
middleware: 'auth'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const authStore = useAuthStore();
|
||||||
const instructorStore = useInstructorStore();
|
const instructorStore = useInstructorStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
// Navigation functions
|
||||||
|
const goToProfile = () => {
|
||||||
|
router.push('/instructor/profile');
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToSettings = () => {
|
||||||
|
router.push('/instructor/settings');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
authStore.logout();
|
||||||
|
router.push('/login');
|
||||||
|
};
|
||||||
|
|
||||||
// Fetch dashboard data on mount
|
// Fetch dashboard data on mount
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
instructorStore.fetchDashboardData();
|
instructorStore.fetchDashboardData();
|
||||||
|
|
|
||||||
419
frontend_management/pages/instructor/profile/index.vue
Normal file
419
frontend_management/pages/instructor/profile/index.vue
Normal file
|
|
@ -0,0 +1,419 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900">โปรไฟล์ของฉัน</h1>
|
||||||
|
<p class="text-gray-600 mt-2">จัดการข้อมูลส่วนตัวของคุณ</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Profile Card -->
|
||||||
|
<AppCard class="mb-10 ">
|
||||||
|
<div class="flex flex-col md:flex-row gap-8">
|
||||||
|
<!-- Avatar Section -->
|
||||||
|
<div class="flex flex-col items-center">
|
||||||
|
<div class="w-32 h-32 bg-primary-100 rounded-full flex items-center justify-center text-6xl mb-4">
|
||||||
|
{{ profile.avatar }}
|
||||||
|
</div>
|
||||||
|
<q-btn
|
||||||
|
outline
|
||||||
|
color="primary"
|
||||||
|
label="เปลี่ยนรูป"
|
||||||
|
icon="photo_camera"
|
||||||
|
@click="handleAvatarUpload"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Profile Info -->
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 mb-1">ชื่อ-นามสกุล</div>
|
||||||
|
<div class="text-lg font-semibold text-gray-900">{{ profile.fullName }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 mb-1">อีเมล</div>
|
||||||
|
<div class="text-lg text-gray-900">{{ profile.email }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 mb-1">Username</div>
|
||||||
|
<div class="text-lg text-gray-900">{{ profile.username }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 mb-1">เบอร์โทร</div>
|
||||||
|
<div class="text-lg text-gray-900">{{ profile.phone || '-' }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 mb-1">ตำแหน่ง</div>
|
||||||
|
<div class="text-lg text-gray-900">
|
||||||
|
<q-badge color="primary">{{ getRoleLabel(profile.role) }}</q-badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 mb-1">วันที่สมัคร</div>
|
||||||
|
<div class="text-lg text-gray-900">{{ formatDate(profile.createdAt) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="flex gap-3 mt-6">
|
||||||
|
<q-btn
|
||||||
|
color="primary"
|
||||||
|
label="แก้ไขโปรไฟล์"
|
||||||
|
icon="edit"
|
||||||
|
@click="showEditModal = true"
|
||||||
|
/>
|
||||||
|
<q-btn
|
||||||
|
outline
|
||||||
|
color="grey-7"
|
||||||
|
label="เปลี่ยนรหัสผ่าน"
|
||||||
|
icon="lock"
|
||||||
|
@click="showPasswordModal = true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AppCard>
|
||||||
|
|
||||||
|
<!-- Stats Cards
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-5 mt-5">
|
||||||
|
<AppCard>
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<div class="text-4xl font-bold text-primary-600 mb-2">{{ stats.totalCourses }}</div>
|
||||||
|
<div class="text-gray-600">หลักสูตรที่สร้าง</div>
|
||||||
|
</div>
|
||||||
|
</AppCard>
|
||||||
|
|
||||||
|
<AppCard>
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<div class="text-4xl font-bold text-secondary-600 mb-2">{{ stats.totalStudents }}</div>
|
||||||
|
<div class="text-gray-600">ผู้เรียนทั้งหมด</div>
|
||||||
|
</div>
|
||||||
|
</AppCard>
|
||||||
|
</div> -->
|
||||||
|
|
||||||
|
<!-- Edit Profile Modal -->
|
||||||
|
<q-dialog v-model="showEditModal" persistent>
|
||||||
|
<q-card style="min-width: 500px">
|
||||||
|
<q-card-section class="row items-center q-pb-none">
|
||||||
|
<div class="text-h6">แก้ไขโปรไฟล์</div>
|
||||||
|
<q-space />
|
||||||
|
<q-btn icon="close" flat round dense @click="showEditModal = false" />
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-card-section>
|
||||||
|
<q-form @submit="handleUpdateProfile">
|
||||||
|
<q-input
|
||||||
|
v-model="editForm.fullName"
|
||||||
|
label="ชื่อ-นามสกุล"
|
||||||
|
outlined
|
||||||
|
class="mb-4"
|
||||||
|
:rules="[val => !!val || 'กรุณากรอกชื่อ-นามสกุล']"
|
||||||
|
>
|
||||||
|
<template v-slot:prepend>
|
||||||
|
<q-icon name="person" />
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
|
||||||
|
<q-input
|
||||||
|
v-model="editForm.email"
|
||||||
|
label="อีเมล"
|
||||||
|
type="email"
|
||||||
|
outlined
|
||||||
|
class="mb-4"
|
||||||
|
:rules="[val => !!val || 'กรุณากรอกอีเมล']"
|
||||||
|
>
|
||||||
|
<template v-slot:prepend>
|
||||||
|
<q-icon name="email" />
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
|
||||||
|
<q-input
|
||||||
|
v-model="editForm.username"
|
||||||
|
label="Username"
|
||||||
|
outlined
|
||||||
|
class="mb-4"
|
||||||
|
:rules="[val => !!val || 'กรุณากรอก username']"
|
||||||
|
>
|
||||||
|
<template v-slot:prepend>
|
||||||
|
<q-icon name="account_circle" />
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
|
||||||
|
<q-input
|
||||||
|
v-model="editForm.phone"
|
||||||
|
label="เบอร์โทร"
|
||||||
|
outlined
|
||||||
|
class="mb-4"
|
||||||
|
>
|
||||||
|
<template v-slot:prepend>
|
||||||
|
<q-icon name="phone" />
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2 mt-4">
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
label="ยกเลิก"
|
||||||
|
color="grey-7"
|
||||||
|
@click="showEditModal = false"
|
||||||
|
/>
|
||||||
|
<q-btn
|
||||||
|
type="submit"
|
||||||
|
label="บันทึก"
|
||||||
|
color="primary"
|
||||||
|
:loading="saving"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</q-form>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
|
||||||
|
<!-- Change Password Modal -->
|
||||||
|
<q-dialog v-model="showPasswordModal" persistent>
|
||||||
|
<q-card style="min-width: 500px">
|
||||||
|
<q-card-section class="row items-center q-pb-none">
|
||||||
|
<div class="text-h6">เปลี่ยนรหัสผ่าน</div>
|
||||||
|
<q-space />
|
||||||
|
<q-btn icon="close" flat round dense @click="showPasswordModal = false" />
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-card-section>
|
||||||
|
<q-form @submit="handleChangePassword">
|
||||||
|
<q-input
|
||||||
|
v-model="passwordForm.currentPassword"
|
||||||
|
label="รหัสผ่านปัจจุบัน"
|
||||||
|
:type="showCurrentPassword ? 'text' : 'password'"
|
||||||
|
outlined
|
||||||
|
class="mb-4"
|
||||||
|
:rules="[val => !!val || 'กรุณากรอกรหัสผ่านปัจจุบัน']"
|
||||||
|
>
|
||||||
|
<template v-slot:prepend>
|
||||||
|
<q-icon name="lock" />
|
||||||
|
</template>
|
||||||
|
<template v-slot:append>
|
||||||
|
<q-icon
|
||||||
|
:name="showCurrentPassword ? 'visibility_off' : 'visibility'"
|
||||||
|
class="cursor-pointer"
|
||||||
|
@click="showCurrentPassword = !showCurrentPassword"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
|
||||||
|
<q-input
|
||||||
|
v-model="passwordForm.newPassword"
|
||||||
|
label="รหัสผ่านใหม่"
|
||||||
|
:type="showNewPassword ? 'text' : 'password'"
|
||||||
|
outlined
|
||||||
|
class="mb-4"
|
||||||
|
:rules="[
|
||||||
|
val => !!val || 'กรุณากรอกรหัสผ่านใหม่',
|
||||||
|
val => val.length >= 6 || 'รหัสผ่านต้องมีอย่างน้อย 6 ตัวอักษร'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<template v-slot:prepend>
|
||||||
|
<q-icon name="lock" />
|
||||||
|
</template>
|
||||||
|
<template v-slot:append>
|
||||||
|
<q-icon
|
||||||
|
:name="showNewPassword ? 'visibility_off' : 'visibility'"
|
||||||
|
class="cursor-pointer"
|
||||||
|
@click="showNewPassword = !showNewPassword"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
|
||||||
|
<q-input
|
||||||
|
v-model="passwordForm.confirmPassword"
|
||||||
|
label="ยืนยันรหัสผ่านใหม่"
|
||||||
|
:type="showConfirmPassword ? 'text' : 'password'"
|
||||||
|
outlined
|
||||||
|
class="mb-4"
|
||||||
|
:rules="[
|
||||||
|
val => !!val || 'กรุณายืนยันรหัสผ่าน',
|
||||||
|
val => val === passwordForm.newPassword || 'รหัสผ่านไม่ตรงกัน'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<template v-slot:prepend>
|
||||||
|
<q-icon name="lock" />
|
||||||
|
</template>
|
||||||
|
<template v-slot:append>
|
||||||
|
<q-icon
|
||||||
|
:name="showConfirmPassword ? 'visibility_off' : 'visibility'"
|
||||||
|
class="cursor-pointer"
|
||||||
|
@click="showConfirmPassword = !showConfirmPassword"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2 mt-4">
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
label="ยกเลิก"
|
||||||
|
color="grey-7"
|
||||||
|
@click="showPasswordModal = false"
|
||||||
|
/>
|
||||||
|
<q-btn
|
||||||
|
type="submit"
|
||||||
|
label="เปลี่ยนรหัสผ่าน"
|
||||||
|
color="primary"
|
||||||
|
:loading="changingPassword"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</q-form>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useQuasar } from 'quasar';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'instructor',
|
||||||
|
middleware: 'auth'
|
||||||
|
});
|
||||||
|
|
||||||
|
const $q = useQuasar();
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
|
// Profile data
|
||||||
|
const profile = ref({
|
||||||
|
fullName: 'อาจารย์ทดสอบ ระบบอีเลินนิ่ง',
|
||||||
|
email: 'instructor@example.com',
|
||||||
|
username: 'instructor_test',
|
||||||
|
phone: '081-234-5678',
|
||||||
|
role: 'INSTRUCTOR',
|
||||||
|
avatar: '👨🏫',
|
||||||
|
createdAt: '2024-01-01'
|
||||||
|
});
|
||||||
|
|
||||||
|
const stats = ref({
|
||||||
|
totalCourses: 5,
|
||||||
|
totalStudents: 125
|
||||||
|
});
|
||||||
|
|
||||||
|
// Edit form
|
||||||
|
const showEditModal = ref(false);
|
||||||
|
const saving = ref(false);
|
||||||
|
const editForm = ref({
|
||||||
|
fullName: '',
|
||||||
|
email: '',
|
||||||
|
username: '',
|
||||||
|
phone: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
// Password form
|
||||||
|
const showPasswordModal = ref(false);
|
||||||
|
const changingPassword = ref(false);
|
||||||
|
const showCurrentPassword = ref(false);
|
||||||
|
const showNewPassword = ref(false);
|
||||||
|
const showConfirmPassword = ref(false);
|
||||||
|
const passwordForm = ref({
|
||||||
|
currentPassword: '',
|
||||||
|
newPassword: '',
|
||||||
|
confirmPassword: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const getRoleLabel = (role: string) => {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
INSTRUCTOR: 'ผู้สอน',
|
||||||
|
ADMIN: 'ผู้ดูแลระบบ',
|
||||||
|
STUDENT: 'ผู้เรียน'
|
||||||
|
};
|
||||||
|
return labels[role] || role;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (date: string) => {
|
||||||
|
return new Date(date).toLocaleDateString('th-TH', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAvatarUpload = () => {
|
||||||
|
$q.notify({
|
||||||
|
type: 'info',
|
||||||
|
message: 'ฟีเจอร์อัพโหลดรูปภาพจะพร้อมใช้งานเร็วๆ นี้',
|
||||||
|
position: 'top'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateProfile = async () => {
|
||||||
|
saving.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// TODO: Call API to update profile
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
|
||||||
|
// Update local data
|
||||||
|
profile.value = { ...profile.value, ...editForm.value };
|
||||||
|
|
||||||
|
$q.notify({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'อัพเดทโปรไฟล์สำเร็จ',
|
||||||
|
position: 'top'
|
||||||
|
});
|
||||||
|
|
||||||
|
showEditModal.value = false;
|
||||||
|
} catch (error) {
|
||||||
|
$q.notify({
|
||||||
|
type: 'negative',
|
||||||
|
message: 'เกิดข้อผิดพลาด กรุณาลองใหม่อีกครั้ง',
|
||||||
|
position: 'top'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChangePassword = async () => {
|
||||||
|
changingPassword.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// TODO: Call API to change password
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
|
||||||
|
$q.notify({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'เปลี่ยนรหัสผ่านสำเร็จ',
|
||||||
|
position: 'top'
|
||||||
|
});
|
||||||
|
|
||||||
|
showPasswordModal.value = false;
|
||||||
|
passwordForm.value = {
|
||||||
|
currentPassword: '',
|
||||||
|
newPassword: '',
|
||||||
|
confirmPassword: ''
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
$q.notify({
|
||||||
|
type: 'negative',
|
||||||
|
message: 'รหัสผ่านปัจจุบันไม่ถูกต้อง',
|
||||||
|
position: 'top'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
changingPassword.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Watch edit modal
|
||||||
|
watch(showEditModal, (newVal) => {
|
||||||
|
if (newVal) {
|
||||||
|
editForm.value = {
|
||||||
|
fullName: profile.value.fullName,
|
||||||
|
email: profile.value.email,
|
||||||
|
username: profile.value.username,
|
||||||
|
phone: profile.value.phone
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-blue-100">
|
<div class=" p-6 items-center justify-center">
|
||||||
<q-card class="w-full max-w-md p-8 shadow-xl">
|
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<div class="text-center mb-8">
|
<div class="text-center mb-8">
|
||||||
<h1 class="text-3xl font-bold text-gray-900">E-Learning</h1>
|
<h1 class="text-3xl font-bold text-gray-900">E-Learning</h1>
|
||||||
|
|
@ -36,6 +35,12 @@
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</q-input>
|
</q-input>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<q-checkbox v-model="rememberMe" label="จดจำฉันไว้" />
|
||||||
|
<a href="#" class="text-sm text-primary-600 hover:text-primary-700">ลืมรหัสผ่าน?</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<q-btn
|
<q-btn
|
||||||
type="submit"
|
type="submit"
|
||||||
color="primary"
|
color="primary"
|
||||||
|
|
@ -46,12 +51,12 @@
|
||||||
/>
|
/>
|
||||||
</q-form>
|
</q-form>
|
||||||
<div class="mt-6 text-center text-sm text-gray-600">
|
<div class="mt-6 text-center text-sm text-gray-600">
|
||||||
<p>ทดสอบ: instructor@test.com / admin@test.com</p>
|
<p>ทดสอบ: admin@elearning.local / instructor@elearning.local</p>
|
||||||
</div>
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useQuasar } from 'quasar';
|
import { useQuasar } from 'quasar';
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
|
|
@ -63,6 +68,7 @@ const router = useRouter();
|
||||||
const email = ref('');
|
const email = ref('');
|
||||||
const password = ref('');
|
const password = ref('');
|
||||||
const showPassword = ref(false);
|
const showPassword = ref(false);
|
||||||
|
const rememberMe = ref(false);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const handleLogin = async () => {
|
const handleLogin = async () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
|
|
||||||
8
frontend_management/plugins/auth.ts
Normal file
8
frontend_management/plugins/auth.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
export default defineNuxtPlugin(() => {
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
|
// Restore auth state from localStorage before any navigation
|
||||||
|
if (process.client) {
|
||||||
|
authStore.checkAuth();
|
||||||
|
}
|
||||||
|
});
|
||||||
92
frontend_management/services/auth.service.ts
Normal file
92
frontend_management/services/auth.service.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
export interface LoginRequest {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// API Response structure (from backend)
|
||||||
|
export interface ApiLoginResponse {
|
||||||
|
token: string;
|
||||||
|
refreshToken: string;
|
||||||
|
user: {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
updated_at: string;
|
||||||
|
created_at: string;
|
||||||
|
role: {
|
||||||
|
code: string;
|
||||||
|
name: {
|
||||||
|
en: string;
|
||||||
|
th: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
profile: {
|
||||||
|
prefix: {
|
||||||
|
en: string;
|
||||||
|
th: string;
|
||||||
|
};
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
phone: string | null;
|
||||||
|
avatar_url: string | null;
|
||||||
|
birth_date: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Frontend User structure
|
||||||
|
export interface LoginResponse {
|
||||||
|
token: string;
|
||||||
|
refreshToken: string;
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
fullName: string;
|
||||||
|
role: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authService = {
|
||||||
|
async login(email: string, password: string): Promise<LoginResponse> {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await $fetch<ApiLoginResponse>('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
baseURL: config.public.apiBaseUrl as string,
|
||||||
|
body: {
|
||||||
|
email,
|
||||||
|
password
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Transform API response to frontend format
|
||||||
|
return {
|
||||||
|
token: response.token,
|
||||||
|
refreshToken: response.refreshToken,
|
||||||
|
user: {
|
||||||
|
id: response.user.id.toString(),
|
||||||
|
email: response.user.email,
|
||||||
|
fullName: `${response.user.profile.first_name} ${response.user.profile.last_name}`,
|
||||||
|
role: response.user.role.code // Use role.code (ADMIN, INSTRUCTOR, etc.)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
// Handle API errors
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
throw new Error('Invalid credentials');
|
||||||
|
}
|
||||||
|
throw new Error('Login failed');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async logout(): Promise<void> {
|
||||||
|
// TODO: Call logout API if available
|
||||||
|
// For now, just clear local storage
|
||||||
|
if (process.client) {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
localStorage.removeItem('refreshToken');
|
||||||
|
localStorage.removeItem('user');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
|
import { authService } from '~/services/auth.service';
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -22,32 +23,26 @@ export const useAuthStore = defineStore('auth', {
|
||||||
|
|
||||||
actions: {
|
actions: {
|
||||||
async login(email: string, password: string) {
|
async login(email: string, password: string) {
|
||||||
// TODO: Replace with real API call
|
try {
|
||||||
// const { $api } = useNuxtApp();
|
// Call real API
|
||||||
// const response = await $api('/auth/login', {
|
const response = await authService.login(email, password);
|
||||||
// method: 'POST',
|
|
||||||
// body: { email, password }
|
|
||||||
// });
|
|
||||||
|
|
||||||
// Mock login for development
|
this.token = response.token;
|
||||||
const mockUser: User = {
|
this.user = response.user as User;
|
||||||
id: '1',
|
this.isAuthenticated = true;
|
||||||
email: email,
|
|
||||||
fullName: 'อาจารย์ทดสอบ',
|
|
||||||
role: 'INSTRUCTOR'
|
|
||||||
};
|
|
||||||
|
|
||||||
this.token = 'mock-jwt-token';
|
// Save to localStorage (including refreshToken)
|
||||||
this.user = mockUser;
|
if (process.client) {
|
||||||
this.isAuthenticated = true;
|
localStorage.setItem('token', this.token);
|
||||||
|
localStorage.setItem('refreshToken', response.refreshToken);
|
||||||
|
localStorage.setItem('user', JSON.stringify(this.user));
|
||||||
|
}
|
||||||
|
|
||||||
// Save to localStorage
|
return { token: this.token, user: this.user };
|
||||||
if (process.client) {
|
} catch (error: any) {
|
||||||
localStorage.setItem('token', this.token);
|
// Re-throw error to be handled by login page
|
||||||
localStorage.setItem('user', JSON.stringify(this.user));
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { token: this.token, user: this.user };
|
|
||||||
},
|
},
|
||||||
|
|
||||||
logout() {
|
logout() {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue