feat: Add user profile management page including personal info editing, avatar upload, and password change.

This commit is contained in:
supalerk-ar66 2026-01-28 14:44:41 +07:00
parent cf12ef965e
commit 70d2dfa4c7
4 changed files with 185 additions and 31 deletions

View file

@ -44,8 +44,13 @@ const handleLogout = async () => {
<template>
<div class="q-pa-md">
<q-btn round flat class="text-slate-700 dark:text-white">
<q-avatar color="primary" text-color="white" size="40px" font-size="14px" class="font-bold shadow-md">
{{ userInitials }}
<q-avatar color="primary" text-color="white" size="40px" font-size="14px" class="font-bold shadow-md cursor-pointer">
<img
v-if="currentUser?.photoURL"
:src="currentUser.photoURL"
class="object-cover w-full h-full"
/>
<span v-else>{{ userInitials }}</span>
</q-avatar>
<q-menu

View file

@ -255,30 +255,84 @@ export const useAuth = () => {
const uploadAvatar = async (file: File) => {
if (!token.value) return { success: false, error: 'ไม่พบ Token การใช้งาน' }
const formData = new FormData()
formData.append('file', file)
// optional: validate เบื้องต้น
const allowed = ['image/jpeg', 'image/png', ]
if (!allowed.includes(file.type)) {
return { success: false, error: 'รองรับเฉพาะไฟล์ jpg/png/' }
}
const maxMB = 5
if (file.size > maxMB * 1024 * 1024) {
return { success: false, error: `ไฟล์ต้องไม่เกิน ${maxMB}MB` }
}
try {
const data = await $fetch<{ code: number; message: string; data: { avatar_url: string; id: number } }>(`${API_BASE_URL}/user/upload-avatar`, {
const formData = new FormData()
formData.append('file', file) // ✅ field = file
interface AvatarResponse {
code: number
message: string
data: {
id: number
avatar_url: string
}
}
const doUpload = async () => {
return await $fetch<AvatarResponse>(`${API_BASE_URL}/user/upload-avatar`, {
method: 'POST',
headers: {
accept: 'application/json',
Authorization: `Bearer ${token.value}`
},
body: formData
})
// อัปเดตข้อมูล User ใน Local State เมื่ออัปโหลดสำเร็จ
if (data.data.avatar_url && user.value && user.value.profile) {
user.value.profile.avatar_url = data.data.avatar_url
} else {
// หากไม่มีข้อมูลใน cache ให้ดึงใหม่
await fetchUserProfile(true)
}
return { success: true, data: data.data }
try {
let response: AvatarResponse
try {
response = await doUpload()
} catch (err: any) {
// ✅ ถ้า 401 ให้ refresh แล้วลองใหม่ 1 ครั้ง
if (err?.statusCode === 401) {
const refreshed = await refreshAccessToken()
if (!refreshed) {
logout()
return { success: false, error: 'Token หมดอายุ กรุณาเข้าสู่ระบบใหม่' }
}
response = await doUpload()
} else {
throw err
}
}
const newUrl = response?.data?.avatar_url
// ✅ สำคัญ: re-assign ทั้งก้อน เพื่อให้ cookie/reactivity อัปเดตชัวร์
if (newUrl && user.value) {
user.value = {
...user.value,
profile: {
...(user.value.profile ?? {
prefix: { th: '', en: '' },
first_name: '',
last_name: '',
phone: null,
avatar_url: null
}),
avatar_url: newUrl
}
}
}
// ✅ ถ้าต้องการ sync ให้ตรง server 100% ค่อย force refresh (optional)
await fetchUserProfile(true)
return { success: true, data: response.data }
} catch (err: any) {
console.error('Upload avatar failed:', err)
return { success: false, error: err.data?.message || 'อัปโหลดรูปภาพไม่สำเร็จ' }
return { success: false, error: err.data?.message || err.message || 'อัปโหลดรูปภาพไม่สำเร็จ' }
}
}

View file

@ -93,7 +93,10 @@
"editPersonalDesc": "แก้ไขข้อมูลส่วนตัว",
"yourAvatar": "รูปโปรไฟล์ของคุณ",
"avatarHint": "เฉพาะไฟล์ png , jpg",
"uploadNew": "อัปโหลดรูปใหม่",
"uploadNew": "อัพโหลดรูปโปรไฟล์",
"changeAvatar": "เปลี่ยนรูปโปรไฟล์",
"removeAvatar": "ลบรูปโปรไฟล์",
"uploadLimit": "เฉพาะไฟล์ png, jpg, ขนาดไม่เกิน 5 MB",
"prefix": "คำนำหน้า",
"firstName": "ชื่อ",
"lastName": "นามสกุล",

View file

@ -8,7 +8,7 @@ useHead({
title: 'ตั้งค่าบัญชี - e-Learning'
})
const { currentUser, updateUserProfile, changePassword } = useAuth()
const { currentUser, updateUserProfile, changePassword, uploadAvatar } = useAuth()
const { t } = useI18n()
const { errors, validate, clearFieldError } = useFormValidation()
@ -79,14 +79,66 @@ const triggerUpload = () => {
fileInput.value?.click()
}
const handleFileUpload = (event: Event) => {
const handleFileUpload = async (event: Event) => {
const target = event.target as HTMLInputElement
if (target.files && target.files[0]) {
const file = target.files[0]
// Preview
const reader = new FileReader()
reader.onload = (e) => {
userData.value.photoURL = e.target?.result as string
}
reader.readAsDataURL(target.files[0])
reader.readAsDataURL(file)
// uploadAvatar useAuth
isProfileSaving.value = true
const result = await uploadAvatar(file)
isProfileSaving.value = false
if (result.success && result.data?.avatar_url) {
userData.value.photoURL = result.data.avatar_url
} else {
// error ( 500) preview alert
console.error('Upload failed:', result.error)
alert(result.error || t('profile.updateError'))
}
}
}
const handleDeleteAvatar = async () => {
// 1. Clear local preview
userData.value.photoURL = ''
// 2. Update Auth State
if (currentUser.value?.role) {
// Direct manipulation since useAuth is shared state
if (currentUser.value.photoURL) {
// We can't set currentUser directly as it is computed.
// We must access the underlying user state from useAuth?
// But we only destructured what we needed.
}
}
// Access the user raw ref from useAuth would be better but we didn't destructure it.
// However, we can use a workaround: call updateUserProfile with empty avatar if supported OR just assume local state.
// The user requirement is: "If delete is clicked ... display q-avatar__content".
// Since we don't have a confirmed DELETE API, we will just update the visual state.
// To make it persist somewhat (until refresh), we update the cookie via a helper if possible,
// BUT since we don't have access to `user` ref here (only currentUser computed),
// let's grab `user` from useAuth() again or add it to destructure.
// Actually, I'll allow this component to just visual clear for now,
// until we confirm API. But wait, `uploadAvatar` updates the global user state.
// We should probably export `user` from useAuth or Add a `clearAvatar` method to useAuth.
// For now, let's keep it simple in the component:
// TODO: Call API to delete avatar if endpoint exists.
// For now, visual clear.
const { user } = useAuth() // Re-access to get the raw user ref
if (user.value && user.value.profile) {
user.value.profile.avatar_url = null
}
}
@ -145,6 +197,18 @@ const handleUpdatePassword = async () => {
isPasswordSaving.value = false
}
// Watch for changes in global user state (e.g. after avatar upload)
watch(() => currentUser.value, (newUser) => {
if (newUser) {
userData.value.photoURL = newUser.photoURL || ''
userData.value.firstName = newUser.firstName
userData.value.lastName = newUser.lastName
userData.value.prefix = newUser.prefix?.th || ''
userData.value.email = newUser.email
userData.value.phone = newUser.phone || ''
}
}, { deep: true })
onMounted(() => {
isHydrated.value = true
})
@ -233,7 +297,7 @@ onMounted(() => {
{{ $t('profile.editPersonalDesc') }}
</h2>
<q-form @submit="handleUpdateProfile" class="flex flex-col gap-6">
<div class="flex flex-col gap-6">
<div class="flex items-center gap-6">
<div class="relative group cursor-pointer" @click="triggerUpload">
@ -244,27 +308,53 @@ onMounted(() => {
size="80"
class="rounded-2xl border-2 border-slate-100 dark:border-white/10"
/>
<!-- Hover Overlay only if has photo (optional) or always? User didn't specify, keeping simple clickable -->
<div class="absolute inset-0 bg-black/40 rounded-2xl flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<q-icon name="camera_alt" class="text-white text-xl" />
</div>
<!-- Hidden Input -->
<input ref="fileInput" type="file" class="hidden" accept="image/*" @change="handleFileUpload" >
</div>
<div>
<div class="flex flex-col gap-2">
<div class="font-bold text-slate-900 dark:text-white mb-1">{{ $t('profile.yourAvatar') }}</div>
<!-- Buttons Row -->
<div class="flex items-center gap-3">
<template v-if="userData.photoURL">
<q-btn
flat
dense
no-caps
unelevated
rounded
color="primary"
:label="$t('profile.uploadNew')"
size="sm"
:label="$t('profile.changeAvatar')"
class="font-bold shadow-lg shadow-blue-500/30"
@click="triggerUpload"
/>
</template>
<template v-else>
<q-btn
unelevated
rounded
color="primary"
:label="$t('profile.uploadNew')"
class="font-bold shadow-lg shadow-blue-500/30"
@click="triggerUpload"
/>
</template>
</div>
<!-- Add Limit Text -->
<div class="mt-1 text-xs text-slate-500 dark:text-slate-400">
{{ $t('profile.uploadLimit') }}
</div>
</div>
</div>
<q-separator class="bg-slate-100 dark:bg-white/5" />
<q-form @submit="handleUpdateProfile" class="flex flex-col gap-6">
<div class="grid grid-cols-1 gap-4">
<div class="space-y-1">
<label class="text-xs font-bold text-slate-500 dark:text-slate-400 ml-1 uppercase">{{ $t('profile.prefix') }}</label>
@ -340,6 +430,8 @@ onMounted(() => {
/>
</div>
</q-form>
</div> <!-- Close the wrapper div -->
</div>