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">
|
||||
<NuxtLink
|
||||
to="/instructor"
|
||||
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-primary-100 shadow-md transition mb-2"
|
||||
active-class="bg-primary-500 text-white hover:bg-primary-600 "
|
||||
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"
|
||||
>
|
||||
<span>📊</span>
|
||||
<q-icon name="dashboard" size="24px" />
|
||||
<span>Dashboard</span>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink
|
||||
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"
|
||||
>
|
||||
<span>📚</span>
|
||||
<q-icon name="school" size="24px" />
|
||||
<span>หลักสูตร</span>
|
||||
</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>
|
||||
|
||||
<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
|
||||
@click="handleLogout"
|
||||
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>
|
||||
</button>
|
||||
</div>
|
||||
</div> -->
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
|
|
|
|||
|
|
@ -29,8 +29,8 @@ export default defineNuxtConfig({
|
|||
],
|
||||
runtimeConfig: {
|
||||
public: {
|
||||
apiBase: process.env.API_BASE_URL || 'http://localhost:3001/api',
|
||||
useMockData: process.env.USE_MOCK_DATA === 'true'
|
||||
apiBaseUrl: process.env.NUXT_PUBLIC_API_BASE_URL,
|
||||
useMockData: process.env.NUXT_PUBLIC_USE_MOCK_DATA === 'true'
|
||||
}
|
||||
},
|
||||
devtools: { enabled: true },
|
||||
|
|
|
|||
|
|
@ -1,7 +1,59 @@
|
|||
<template>
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold mb-6">Dashboard</h1>
|
||||
<p class="text-gray-600 mb-8">ยินดีต้อนรับ, {{ authStore.user?.fullName }}</p>
|
||||
<!-- Header -->
|
||||
<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 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<div class="card">
|
||||
|
|
@ -28,7 +80,6 @@
|
|||
<p class="text-gray-600 text-sm">เรียนจบแล้ว</p>
|
||||
<p class="text-3xl font-bold text-accent-500">45</p>
|
||||
</div>
|
||||
<q-icon name="emoji_events" size="48px" class="text-accent-200" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -52,8 +103,20 @@
|
|||
</template>
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: 'instructor',
|
||||
middleware: 'instructor'
|
||||
layout: 'admin',
|
||||
middleware: 'auth'
|
||||
});
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const router = useRouter();
|
||||
|
||||
// Navigation functions
|
||||
const goToProfile = () => {
|
||||
router.push('/admin/profile');
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
authStore.logout();
|
||||
router.push('/login');
|
||||
};
|
||||
</script>
|
||||
|
|
@ -1,14 +1,55 @@
|
|||
<template>
|
||||
<div>
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900">สวัสดี, อาจารย์ทดสอบ 👋</h1>
|
||||
<p class="text-gray-600 mt-2">ยินดีต้อนรับกลับสู่ระบบ</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 bg-primary-100 rounded-full flex items-center justify-center text-2xl">
|
||||
👤
|
||||
<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 || '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>
|
||||
|
|
@ -96,9 +137,24 @@ definePageMeta({
|
|||
middleware: 'auth'
|
||||
});
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const instructorStore = useInstructorStore();
|
||||
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
|
||||
onMounted(() => {
|
||||
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>
|
||||
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-blue-100">
|
||||
<q-card class="w-full max-w-md p-8 shadow-xl">
|
||||
<div class=" p-6 items-center justify-center">
|
||||
<q-card-section>
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900">E-Learning</h1>
|
||||
|
|
@ -36,6 +35,12 @@
|
|||
/>
|
||||
</template>
|
||||
</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
|
||||
type="submit"
|
||||
color="primary"
|
||||
|
|
@ -46,12 +51,12 @@
|
|||
/>
|
||||
</q-form>
|
||||
<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>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useQuasar } from 'quasar';
|
||||
definePageMeta({
|
||||
|
|
@ -63,6 +68,7 @@ const router = useRouter();
|
|||
const email = ref('');
|
||||
const password = ref('');
|
||||
const showPassword = ref(false);
|
||||
const rememberMe = ref(false);
|
||||
const loading = ref(false);
|
||||
const handleLogin = async () => {
|
||||
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 { authService } from '~/services/auth.service';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
|
|
@ -22,32 +23,26 @@ export const useAuthStore = defineStore('auth', {
|
|||
|
||||
actions: {
|
||||
async login(email: string, password: string) {
|
||||
// TODO: Replace with real API call
|
||||
// const { $api } = useNuxtApp();
|
||||
// const response = await $api('/auth/login', {
|
||||
// method: 'POST',
|
||||
// body: { email, password }
|
||||
// });
|
||||
try {
|
||||
// Call real API
|
||||
const response = await authService.login(email, password);
|
||||
|
||||
// Mock login for development
|
||||
const mockUser: User = {
|
||||
id: '1',
|
||||
email: email,
|
||||
fullName: 'อาจารย์ทดสอบ',
|
||||
role: 'INSTRUCTOR'
|
||||
};
|
||||
this.token = response.token;
|
||||
this.user = response.user as User;
|
||||
this.isAuthenticated = true;
|
||||
|
||||
this.token = 'mock-jwt-token';
|
||||
this.user = mockUser;
|
||||
this.isAuthenticated = true;
|
||||
// Save to localStorage (including refreshToken)
|
||||
if (process.client) {
|
||||
localStorage.setItem('token', this.token);
|
||||
localStorage.setItem('refreshToken', response.refreshToken);
|
||||
localStorage.setItem('user', JSON.stringify(this.user));
|
||||
}
|
||||
|
||||
// Save to localStorage
|
||||
if (process.client) {
|
||||
localStorage.setItem('token', this.token);
|
||||
localStorage.setItem('user', JSON.stringify(this.user));
|
||||
return { token: this.token, user: this.user };
|
||||
} catch (error: any) {
|
||||
// Re-throw error to be handled by login page
|
||||
throw error;
|
||||
}
|
||||
|
||||
return { token: this.token, user: this.user };
|
||||
},
|
||||
|
||||
logout() {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue