feat: Add user profile management page including personal info editing, avatar upload, and password change.
This commit is contained in:
parent
cf12ef965e
commit
70d2dfa4c7
4 changed files with 185 additions and 31 deletions
|
|
@ -44,8 +44,13 @@ const handleLogout = async () => {
|
||||||
<template>
|
<template>
|
||||||
<div class="q-pa-md">
|
<div class="q-pa-md">
|
||||||
<q-btn round flat class="text-slate-700 dark:text-white">
|
<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">
|
<q-avatar color="primary" text-color="white" size="40px" font-size="14px" class="font-bold shadow-md cursor-pointer">
|
||||||
{{ userInitials }}
|
<img
|
||||||
|
v-if="currentUser?.photoURL"
|
||||||
|
:src="currentUser.photoURL"
|
||||||
|
class="object-cover w-full h-full"
|
||||||
|
/>
|
||||||
|
<span v-else>{{ userInitials }}</span>
|
||||||
</q-avatar>
|
</q-avatar>
|
||||||
|
|
||||||
<q-menu
|
<q-menu
|
||||||
|
|
|
||||||
|
|
@ -255,30 +255,84 @@ export const useAuth = () => {
|
||||||
const uploadAvatar = async (file: File) => {
|
const uploadAvatar = async (file: File) => {
|
||||||
if (!token.value) return { success: false, error: 'ไม่พบ Token การใช้งาน' }
|
if (!token.value) return { success: false, error: 'ไม่พบ Token การใช้งาน' }
|
||||||
|
|
||||||
const formData = new FormData()
|
// optional: validate เบื้องต้น
|
||||||
formData.append('file', file)
|
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 formData = new FormData()
|
||||||
const data = await $fetch<{ code: number; message: string; data: { avatar_url: string; id: number } }>(`${API_BASE_URL}/user/upload-avatar`, {
|
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',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token.value}`
|
accept: 'application/json',
|
||||||
|
Authorization: `Bearer ${token.value}`
|
||||||
},
|
},
|
||||||
body: formData
|
body: formData
|
||||||
})
|
})
|
||||||
|
}
|
||||||
// อัปเดตข้อมูล User ใน Local State เมื่ออัปโหลดสำเร็จ
|
|
||||||
if (data.data.avatar_url && user.value && user.value.profile) {
|
try {
|
||||||
user.value.profile.avatar_url = data.data.avatar_url
|
let response: AvatarResponse
|
||||||
} else {
|
|
||||||
// หากไม่มีข้อมูลใน cache ให้ดึงใหม่
|
try {
|
||||||
await fetchUserProfile(true)
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true, data: data.data }
|
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) {
|
} catch (err: any) {
|
||||||
console.error('Upload avatar failed:', err)
|
console.error('Upload avatar failed:', err)
|
||||||
return { success: false, error: err.data?.message || 'อัปโหลดรูปภาพไม่สำเร็จ' }
|
return { success: false, error: err.data?.message || err.message || 'อัปโหลดรูปภาพไม่สำเร็จ' }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,10 @@
|
||||||
"editPersonalDesc": "แก้ไขข้อมูลส่วนตัว",
|
"editPersonalDesc": "แก้ไขข้อมูลส่วนตัว",
|
||||||
"yourAvatar": "รูปโปรไฟล์ของคุณ",
|
"yourAvatar": "รูปโปรไฟล์ของคุณ",
|
||||||
"avatarHint": "เฉพาะไฟล์ png , jpg",
|
"avatarHint": "เฉพาะไฟล์ png , jpg",
|
||||||
"uploadNew": "อัปโหลดรูปใหม่",
|
"uploadNew": "อัพโหลดรูปโปรไฟล์",
|
||||||
|
"changeAvatar": "เปลี่ยนรูปโปรไฟล์",
|
||||||
|
"removeAvatar": "ลบรูปโปรไฟล์",
|
||||||
|
"uploadLimit": "เฉพาะไฟล์ png, jpg, ขนาดไม่เกิน 5 MB",
|
||||||
"prefix": "คำนำหน้า",
|
"prefix": "คำนำหน้า",
|
||||||
"firstName": "ชื่อ",
|
"firstName": "ชื่อ",
|
||||||
"lastName": "นามสกุล",
|
"lastName": "นามสกุล",
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ useHead({
|
||||||
title: 'ตั้งค่าบัญชี - e-Learning'
|
title: 'ตั้งค่าบัญชี - e-Learning'
|
||||||
})
|
})
|
||||||
|
|
||||||
const { currentUser, updateUserProfile, changePassword } = useAuth()
|
const { currentUser, updateUserProfile, changePassword, uploadAvatar } = useAuth()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { errors, validate, clearFieldError } = useFormValidation()
|
const { errors, validate, clearFieldError } = useFormValidation()
|
||||||
|
|
||||||
|
|
@ -79,17 +79,69 @@ const triggerUpload = () => {
|
||||||
fileInput.value?.click()
|
fileInput.value?.click()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleFileUpload = (event: Event) => {
|
const handleFileUpload = async (event: Event) => {
|
||||||
const target = event.target as HTMLInputElement
|
const target = event.target as HTMLInputElement
|
||||||
if (target.files && target.files[0]) {
|
if (target.files && target.files[0]) {
|
||||||
|
const file = target.files[0]
|
||||||
|
|
||||||
|
// แสดงรูป Preview ก่อนอัปโหลด
|
||||||
const reader = new FileReader()
|
const reader = new FileReader()
|
||||||
reader.onload = (e) => {
|
reader.onload = (e) => {
|
||||||
userData.value.photoURL = e.target?.result as string
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleUpdateProfile = async () => {
|
const handleUpdateProfile = async () => {
|
||||||
isProfileSaving.value = true
|
isProfileSaving.value = true
|
||||||
|
|
||||||
|
|
@ -145,6 +197,18 @@ const handleUpdatePassword = async () => {
|
||||||
isPasswordSaving.value = false
|
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(() => {
|
onMounted(() => {
|
||||||
isHydrated.value = true
|
isHydrated.value = true
|
||||||
})
|
})
|
||||||
|
|
@ -233,7 +297,7 @@ onMounted(() => {
|
||||||
{{ $t('profile.editPersonalDesc') }}
|
{{ $t('profile.editPersonalDesc') }}
|
||||||
</h2>
|
</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="flex items-center gap-6">
|
||||||
<div class="relative group cursor-pointer" @click="triggerUpload">
|
<div class="relative group cursor-pointer" @click="triggerUpload">
|
||||||
|
|
@ -244,27 +308,53 @@ onMounted(() => {
|
||||||
size="80"
|
size="80"
|
||||||
class="rounded-2xl border-2 border-slate-100 dark:border-white/10"
|
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">
|
<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" />
|
<q-icon name="camera_alt" class="text-white text-xl" />
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Hidden Input -->
|
||||||
<input ref="fileInput" type="file" class="hidden" accept="image/*" @change="handleFileUpload" >
|
<input ref="fileInput" type="file" class="hidden" accept="image/*" @change="handleFileUpload" >
|
||||||
</div>
|
</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>
|
<div class="font-bold text-slate-900 dark:text-white mb-1">{{ $t('profile.yourAvatar') }}</div>
|
||||||
<q-btn
|
|
||||||
flat
|
<!-- Buttons Row -->
|
||||||
dense
|
<div class="flex items-center gap-3">
|
||||||
no-caps
|
<template v-if="userData.photoURL">
|
||||||
color="primary"
|
<q-btn
|
||||||
:label="$t('profile.uploadNew')"
|
unelevated
|
||||||
size="sm"
|
rounded
|
||||||
@click="triggerUpload"
|
color="primary"
|
||||||
/>
|
: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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<q-separator class="bg-slate-100 dark:bg-white/5" />
|
<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="grid grid-cols-1 gap-4">
|
||||||
<div class="space-y-1">
|
<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>
|
<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>
|
</div>
|
||||||
</q-form>
|
</q-form>
|
||||||
|
|
||||||
|
</div> <!-- Close the wrapper div -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue