Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
7309c02f
Commit
7309c02f
authored
Apr 21, 2026
by
IanShaw027
Browse files
refactor(profile): split avatar and bindings cards
parent
ee3f158f
Changes
10
Hide whitespace changes
Inline
Side-by-side
frontend/src/components/user/profile/ProfileAccountBindingsCard.vue
0 → 100644
View file @
7309c02f
<
template
>
<ProfileIdentityBindingsSection
:user=
"user"
:linuxdo-enabled=
"linuxdoEnabled"
:oidc-enabled=
"oidcEnabled"
:oidc-provider-name=
"oidcProviderName"
:wechat-enabled=
"wechatEnabled"
:wechat-open-enabled=
"wechatOpenEnabled"
:wechat-mp-enabled=
"wechatMpEnabled"
/>
</
template
>
<
script
setup
lang=
"ts"
>
import
ProfileIdentityBindingsSection
from
'
@/components/user/profile/ProfileIdentityBindingsSection.vue
'
import
type
{
User
}
from
'
@/types
'
withDefaults
(
defineProps
<
{
user
:
User
|
null
linuxdoEnabled
?:
boolean
oidcEnabled
?:
boolean
oidcProviderName
?:
string
wechatEnabled
?:
boolean
wechatOpenEnabled
?:
boolean
wechatMpEnabled
?:
boolean
}
>
(),
{
linuxdoEnabled
:
false
,
oidcEnabled
:
false
,
oidcProviderName
:
'
OIDC
'
,
wechatEnabled
:
false
,
wechatOpenEnabled
:
undefined
,
wechatMpEnabled
:
undefined
}
)
</
script
>
frontend/src/components/user/profile/ProfileAvatarCard.vue
0 → 100644
View file @
7309c02f
<
template
>
<div
class=
"card"
>
<div
class=
"border-b border-gray-100 px-6 py-4 dark:border-dark-700"
>
<h2
class=
"text-lg font-medium text-gray-900 dark:text-white"
>
{{
t
(
'
profile.avatar.title
'
)
}}
</h2>
<p
class=
"mt-1 text-sm text-gray-500 dark:text-gray-400"
>
{{
t
(
'
profile.avatar.description
'
)
}}
</p>
</div>
<div
class=
"flex flex-col gap-5 px-6 py-6 sm:flex-row sm:items-start"
>
<div
class=
"flex h-24 w-24 shrink-0 items-center justify-center overflow-hidden rounded-2xl bg-gradient-to-br from-primary-500 to-primary-600 text-3xl font-bold text-white shadow-lg shadow-primary-500/20"
>
<img
v-if=
"avatarPreviewUrl"
:src=
"avatarPreviewUrl"
:alt=
"displayName"
class=
"h-full w-full object-cover"
>
<span
v-else
>
{{
avatarInitial
}}
</span>
</div>
<div
class=
"min-w-0 flex-1 space-y-4"
>
<div
class=
"space-y-1"
>
<p
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
{{
displayName
}}
</p>
<p
class=
"text-sm text-gray-500 dark:text-gray-400"
>
{{
t
(
'
profile.avatar.uploadHint
'
)
}}
</p>
</div>
<textarea
data-testid=
"profile-avatar-input"
v-model=
"avatarDraft"
rows=
"3"
class=
"input min-h-[88px]"
:placeholder=
"t('profile.avatar.inputPlaceholder')"
/>
<div
class=
"flex flex-wrap items-center gap-3"
>
<label
class=
"btn btn-secondary btn-sm cursor-pointer"
>
<input
data-testid=
"profile-avatar-file-input"
type=
"file"
accept=
"image/*"
class=
"hidden"
@
change=
"handleAvatarFileChange"
>
{{
t
(
'
profile.avatar.uploadAction
'
)
}}
</label>
<button
data-testid=
"profile-avatar-save"
type=
"button"
class=
"btn btn-primary btn-sm"
:disabled=
"avatarSaving"
@
click=
"handleAvatarSave"
>
{{
t
(
'
common.save
'
)
}}
</button>
<button
data-testid=
"profile-avatar-delete"
type=
"button"
class=
"btn btn-secondary btn-sm"
:disabled=
"avatarSaving"
@
click=
"handleAvatarDelete"
>
{{
t
(
'
common.delete
'
)
}}
</button>
</div>
</div>
</div>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
computed
,
ref
,
watch
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
userAPI
}
from
'
@/api
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
useAuthStore
}
from
'
@/stores/auth
'
import
type
{
User
}
from
'
@/types
'
import
{
extractApiErrorMessage
}
from
'
@/utils/apiError
'
const
props
=
defineProps
<
{
user
:
User
|
null
}
>
()
const
{
t
}
=
useI18n
()
const
authStore
=
useAuthStore
()
const
appStore
=
useAppStore
()
const
targetAvatarUploadBytes
=
20
*
1024
const
avatarScaleSteps
=
[
1
,
0.92
,
0.84
,
0.76
,
0.68
,
0.6
,
0.52
,
0.44
,
0.36
]
const
avatarQualitySteps
=
[
0.92
,
0.84
,
0.76
,
0.68
,
0.6
,
0.52
,
0.44
,
0.36
]
const
avatarDraft
=
ref
(
props
.
user
?.
avatar_url
?.
trim
()
||
''
)
const
avatarSaving
=
ref
(
false
)
const
displayName
=
computed
(()
=>
props
.
user
?.
username
?.
trim
()
||
props
.
user
?.
email
?.
trim
()
||
'
User
'
)
const
avatarInitial
=
computed
(()
=>
displayName
.
value
.
charAt
(
0
).
toUpperCase
()
||
'
U
'
)
const
avatarPreviewUrl
=
computed
(()
=>
avatarDraft
.
value
.
trim
()
||
props
.
user
?.
avatar_url
?.
trim
()
||
''
)
watch
(
()
=>
props
.
user
?.
avatar_url
,
(
value
)
=>
{
avatarDraft
.
value
=
value
?.
trim
()
||
''
}
)
function
validateAvatarInput
(
value
:
string
):
string
|
null
{
const
normalized
=
value
.
trim
()
if
(
!
normalized
)
{
return
null
}
if
(
normalized
.
startsWith
(
'
data:
'
))
{
if
(
!
/^data:image
\/[
a-zA-Z0-9.+-
]
+;base64,/i
.
test
(
normalized
))
{
appStore
.
showError
(
t
(
'
profile.avatar.invalidValue
'
))
return
null
}
return
normalized
}
try
{
const
parsed
=
new
URL
(
normalized
)
if
(
parsed
.
protocol
===
'
http:
'
||
parsed
.
protocol
===
'
https:
'
)
{
return
normalized
}
}
catch
{
// Invalid URL is handled below.
}
appStore
.
showError
(
t
(
'
profile.avatar.invalidValue
'
))
return
null
}
function
readFileAsDataURL
(
file
:
File
):
Promise
<
string
>
{
return
new
Promise
((
resolve
,
reject
)
=>
{
const
reader
=
new
FileReader
()
reader
.
onload
=
()
=>
resolve
(
typeof
reader
.
result
===
'
string
'
?
reader
.
result
:
''
)
reader
.
onerror
=
()
=>
reject
(
reader
.
error
??
new
Error
(
'
avatar_read_failed
'
))
reader
.
readAsDataURL
(
file
)
})
}
function
loadImage
(
dataURL
:
string
):
Promise
<
HTMLImageElement
>
{
return
new
Promise
((
resolve
,
reject
)
=>
{
const
image
=
new
Image
()
image
.
onload
=
()
=>
resolve
(
image
)
image
.
onerror
=
()
=>
reject
(
new
Error
(
t
(
'
profile.avatar.readFailed
'
)))
image
.
src
=
dataURL
})
}
function
canvasToBlob
(
canvas
:
HTMLCanvasElement
,
type
:
string
,
quality
:
number
):
Promise
<
Blob
>
{
return
new
Promise
((
resolve
,
reject
)
=>
{
canvas
.
toBlob
((
blob
)
=>
{
if
(
!
blob
)
{
reject
(
new
Error
(
t
(
'
profile.avatar.compressFailed
'
)))
return
}
resolve
(
blob
)
},
type
,
quality
)
})
}
async
function
compressAvatarFile
(
file
:
File
):
Promise
<
File
>
{
const
sourceDataURL
=
await
readFileAsDataURL
(
file
)
const
image
=
await
loadImage
(
sourceDataURL
)
const
canvas
=
document
.
createElement
(
'
canvas
'
)
const
ctx
=
canvas
.
getContext
(
'
2d
'
)
if
(
!
ctx
)
{
throw
new
Error
(
t
(
'
profile.avatar.compressFailed
'
))
}
for
(
const
scale
of
avatarScaleSteps
)
{
const
width
=
Math
.
max
(
1
,
Math
.
round
(
image
.
naturalWidth
*
scale
))
const
height
=
Math
.
max
(
1
,
Math
.
round
(
image
.
naturalHeight
*
scale
))
canvas
.
width
=
width
canvas
.
height
=
height
ctx
.
clearRect
(
0
,
0
,
width
,
height
)
ctx
.
drawImage
(
image
,
0
,
0
,
width
,
height
)
for
(
const
quality
of
avatarQualitySteps
)
{
const
blob
=
await
canvasToBlob
(
canvas
,
'
image/webp
'
,
quality
)
if
(
blob
.
size
<=
targetAvatarUploadBytes
)
{
const
fileName
=
file
.
name
.
replace
(
/
\.[^
.
]
+$/
,
''
)
||
'
avatar
'
return
new
File
([
blob
],
`
${
fileName
}
.webp`
,
{
type
:
'
image/webp
'
})
}
}
}
throw
new
Error
(
t
(
'
profile.avatar.compressTooLarge
'
))
}
async
function
prepareAvatarUpload
(
file
:
File
):
Promise
<
File
>
{
if
(
!
file
.
type
.
startsWith
(
'
image/
'
))
{
throw
new
Error
(
t
(
'
profile.avatar.invalidType
'
))
}
if
(
file
.
type
===
'
image/gif
'
)
{
if
(
file
.
size
>
targetAvatarUploadBytes
)
{
throw
new
Error
(
t
(
'
profile.avatar.gifTooLarge
'
))
}
return
file
}
if
(
file
.
size
<=
targetAvatarUploadBytes
)
{
return
file
}
return
compressAvatarFile
(
file
)
}
async
function
handleAvatarFileChange
(
event
:
Event
)
{
const
input
=
event
.
target
as
HTMLInputElement
|
null
const
file
=
input
?.
files
?.[
0
]
if
(
input
)
{
input
.
value
=
''
}
if
(
!
file
)
{
return
}
try
{
const
preparedFile
=
await
prepareAvatarUpload
(
file
)
const
dataURL
=
await
readFileAsDataURL
(
preparedFile
)
const
normalized
=
validateAvatarInput
(
dataURL
)
if
(
!
normalized
)
{
return
}
avatarDraft
.
value
=
normalized
}
catch
(
error
:
unknown
)
{
appStore
.
showError
(
extractApiErrorMessage
(
error
,
t
(
'
common.error
'
)))
}
}
async
function
handleAvatarSave
()
{
const
normalized
=
validateAvatarInput
(
avatarDraft
.
value
)
if
(
!
normalized
)
{
return
}
avatarSaving
.
value
=
true
try
{
const
updated
=
await
userAPI
.
updateProfile
({
avatar_url
:
normalized
})
authStore
.
user
=
updated
avatarDraft
.
value
=
updated
.
avatar_url
?.
trim
()
||
''
appStore
.
showSuccess
(
t
(
'
profile.avatar.saveSuccess
'
))
}
catch
(
error
:
unknown
)
{
appStore
.
showError
(
extractApiErrorMessage
(
error
,
t
(
'
common.error
'
)))
}
finally
{
avatarSaving
.
value
=
false
}
}
async
function
handleAvatarDelete
()
{
if
(
avatarSaving
.
value
)
{
return
}
if
(
!
avatarDraft
.
value
.
trim
()
&&
!
props
.
user
?.
avatar_url
?.
trim
())
{
appStore
.
showError
(
t
(
'
profile.avatar.emptyDeleteHint
'
))
return
}
avatarSaving
.
value
=
true
try
{
const
updated
=
await
userAPI
.
updateProfile
({
avatar_url
:
''
})
authStore
.
user
=
updated
avatarDraft
.
value
=
''
appStore
.
showSuccess
(
t
(
'
profile.avatar.deleteSuccess
'
))
}
catch
(
error
:
unknown
)
{
appStore
.
showError
(
extractApiErrorMessage
(
error
,
t
(
'
common.error
'
)))
}
finally
{
avatarSaving
.
value
=
false
}
}
</
script
>
frontend/src/components/user/profile/ProfileIdentityBindingsSection.vue
View file @
7309c02f
<
template
>
<div
class=
"
rounded-2xl border border-gray-100 bg-gray-50/80 p-4 dark:border-dark-700 dark:bg-dark-900/30
"
>
<div>
<h
3
class=
"text-
sm
font-
semibold
text-gray-900 dark:text-white"
>
<div
class=
"
card overflow-hidden
"
>
<div
class=
"border-b border-gray-100 px-6 py-4 dark:border-dark-700"
>
<h
2
class=
"text-
lg
font-
medium
text-gray-900 dark:text-white"
>
{{
t
(
'
profile.authBindings.title
'
)
}}
</h
3
>
<p
class=
"mt-1 text-
x
s text-gray-500 dark:text-gray-400"
>
</h
2
>
<p
class=
"mt-1 text-s
m
text-gray-500 dark:text-gray-400"
>
{{
t
(
'
profile.authBindings.description
'
)
}}
</p>
</div>
<div
class=
"
mt-4 space-y-2
"
>
<div
class=
"
divide-y divide-gray-100 dark:divide-dark-700
"
>
<div
v-for=
"item in providerItems"
:key=
"item.provider"
class=
"
rounded-xl bg-white/80
px-
3
py-
3 dark:bg-dark-800/70
"
class=
"px-
6
py-
5
"
>
<div
class=
"flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4"
>
<div
class=
"min-w-0 flex-1"
>
<div
class=
"flex items-center gap-2"
>
<div
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
{{
item
.
label
}}
</div>
<span
:data-testid=
"`profile-binding-$
{item.provider}-status`"
:class="['badge', item.bound ? 'badge-success' : 'badge-gray']"
>
{{
item
.
bound
?
t
(
'
profile.authBindings.status.bound
'
)
:
t
(
'
profile.authBindings.status.notBound
'
)
}}
</span>
</div>
<div
class=
"flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
<div
class=
"flex min-w-0 flex-1 items-start gap-4"
>
<div
v-if=
"
item.provider
=== 'email'
"
class=
"
mt-3 grid gap-2 sm:grid-cols-[minmax(0,1.4fr)_auto]
"
:class=
"providerIconClass(
item.provider
)
"
class=
"
flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl text-sm font-semibold
"
>
<input
v-model.trim=
"emailBindingForm.email"
data-testid=
"profile-binding-email-input"
type=
"email"
class=
"input"
:placeholder=
"t('profile.authBindings.emailPlaceholder')"
:disabled=
"isSendingEmailCode || isBindingEmail"
<Icon
v-if=
"item.provider === 'email'"
name=
"mail"
size=
"sm"
class=
"text-current"
/>
<button
data-testid=
"profile-binding-email-send-code"
type=
"button"
class=
"btn btn-secondary btn-sm"
:disabled=
"isSendingEmailCode || isBindingEmail"
@
click=
"sendEmailCode"
<span
v-else
>
{{
providerInitial
(
item
.
provider
)
}}
</span>
</div>
<div
class=
"min-w-0 flex-1"
>
<div
class=
"flex flex-wrap items-center gap-2"
>
<h3
class=
"font-medium text-gray-900 dark:text-white"
>
{{
item
.
label
}}
</h3>
<span
:data-testid=
"`profile-binding-$
{item.provider}-status`"
:class="['badge', item.bound ? 'badge-success' : 'badge-gray']"
>
{{
item
.
bound
?
t
(
'
profile.authBindings.status.bound
'
)
:
t
(
'
profile.authBindings.status.notBound
'
)
}}
</span>
</div>
<p
v-if=
"providerSummary(item.provider)"
class=
"mt-1 text-sm text-gray-600 dark:text-gray-300"
>
{{
isSendingEmailCode
?
t
(
'
common.loading
'
)
:
t
(
'
profile.authBindings.sendCodeAction
'
)
}}
</button>
<input
v-model.trim=
"emailBindingForm.verifyCode"
data-testid=
"profile-binding-email-code-input"
type=
"text"
inputmode=
"numeric"
maxlength=
"6"
class=
"input"
:placeholder=
"t('profile.authBindings.codePlaceholder')"
:disabled=
"isBindingEmail"
/>
<input
v-model=
"emailBindingForm.password"
data-testid=
"profile-binding-email-password-input"
type=
"password"
class=
"input"
:placeholder=
"emailPasswordPlaceholder"
:disabled=
"isBindingEmail"
/>
<button
data-testid=
"profile-binding-email-submit"
type=
"button"
class=
"btn btn-primary btn-sm sm:col-span-2"
:disabled=
"isBindingEmail"
@
click=
"bindEmail"
{{
providerSummary
(
item
.
provider
)
}}
</p>
<div
v-if=
"item.provider === 'email'"
class=
"mt-4 grid gap-2 sm:grid-cols-[minmax(0,1.4fr)_auto]"
>
{{
isBindingEmail
?
t
(
'
common.loading
'
)
:
emailSubmitActionLabel
}}
</button>
<input
v-model.trim=
"emailBindingForm.email"
data-testid=
"profile-binding-email-input"
type=
"email"
class=
"input"
:placeholder=
"t('profile.authBindings.emailPlaceholder')"
:disabled=
"isSendingEmailCode || isBindingEmail"
/>
<button
data-testid=
"profile-binding-email-send-code"
type=
"button"
class=
"btn btn-secondary btn-sm"
:disabled=
"isSendingEmailCode || isBindingEmail"
@
click=
"sendEmailCode"
>
{{
isSendingEmailCode
?
t
(
'
common.loading
'
)
:
t
(
'
profile.authBindings.sendCodeAction
'
)
}}
</button>
<input
v-model.trim=
"emailBindingForm.verifyCode"
data-testid=
"profile-binding-email-code-input"
type=
"text"
inputmode=
"numeric"
maxlength=
"6"
class=
"input"
:placeholder=
"t('profile.authBindings.codePlaceholder')"
:disabled=
"isBindingEmail"
/>
<input
v-model=
"emailBindingForm.password"
data-testid=
"profile-binding-email-password-input"
type=
"password"
class=
"input"
:placeholder=
"emailPasswordPlaceholder"
:disabled=
"isBindingEmail"
/>
<button
data-testid=
"profile-binding-email-submit"
type=
"button"
class=
"btn btn-primary btn-sm sm:col-span-2"
:disabled=
"isBindingEmail"
@
click=
"bindEmail"
>
{{
isBindingEmail
?
t
(
'
common.loading
'
)
:
emailSubmitActionLabel
}}
</button>
</div>
</div>
</div>
<div
class=
"flex shrink-0 items-center gap-
2
"
>
<div
class=
"flex shrink-0 items-center gap-
3
"
>
<button
v-if=
"item.canBind"
:data-testid=
"`profile-binding-$
{item.provider}-action`"
type="button"
class="btn btn-
second
ary btn-sm"
class="btn btn-
prim
ary btn-sm"
@click="startBinding(item.provider)"
>
{{
t
(
'
profile.authBindings.bindAction
'
,
{
providerName
:
item
.
label
}
)
}}
...
...
@@ -119,6 +141,7 @@ import {
type
WeChatOAuthPublicSettings
,
}
from
'
@/api/auth
'
import
{
bindEmailIdentity
,
sendEmailBindingCode
,
startOAuthBinding
}
from
'
@/api/user
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
{
useAppStore
,
useAuthStore
}
from
'
@/stores
'
import
type
{
User
,
UserAuthBindingStatus
,
UserAuthProvider
}
from
'
@/types
'
...
...
@@ -265,6 +288,39 @@ const providerItems = computed(() => [
}
,
])
function
providerInitial
(
provider
:
UserAuthProvider
):
string
{
if
(
provider
===
'
linuxdo
'
)
{
return
'
L
'
}
if
(
provider
===
'
wechat
'
)
{
return
'
W
'
}
if
(
provider
===
'
oidc
'
)
{
return
'
O
'
}
return
'
E
'
}
function
providerIconClass
(
provider
:
UserAuthProvider
):
string
{
if
(
provider
===
'
linuxdo
'
)
{
return
'
bg-orange-100 text-orange-600 dark:bg-orange-900/20 dark:text-orange-300
'
}
if
(
provider
===
'
wechat
'
)
{
return
'
bg-green-100 text-green-600 dark:bg-green-900/20 dark:text-green-300
'
}
if
(
provider
===
'
oidc
'
)
{
return
'
bg-sky-100 text-sky-600 dark:bg-sky-900/20 dark:text-sky-300
'
}
return
'
bg-primary-100 text-primary-600 dark:bg-primary-900/20 dark:text-primary-300
'
}
function
providerSummary
(
provider
:
UserAuthProvider
):
string
{
if
(
provider
===
'
email
'
)
{
return
currentUser
.
value
?.
email
||
''
}
return
''
}
function
startBinding
(
provider
:
UserAuthProvider
):
void
{
if
(
provider
===
'
email
'
)
{
return
...
...
frontend/src/components/user/profile/ProfileInfoCard.vue
View file @
7309c02f
...
...
@@ -17,8 +17,11 @@
</div>
<div
class=
"min-w-0 flex-1"
>
<h2
class=
"truncate text-lg font-semibold text-gray-900 dark:text-white"
>
{{
user
?.
email
}}
{{
displayName
}}
</h2>
<p
class=
"mt-1 truncate text-sm text-gray-500 dark:text-gray-400"
>
{{
user
?.
email
}}
</p>
<div
class=
"mt-1 flex items-center gap-2"
>
<span
:class=
"['badge', user?.role === 'admin' ? 'badge-primary' : 'badge-gray']"
>
{{
user
?.
role
===
'
admin
'
?
t
(
'
profile.administrator
'
)
:
t
(
'
profile.user
'
)
}}
...
...
@@ -47,438 +50,23 @@
</div>
</div>
<div
v-if=
"sourceHints.length"
class=
"mt-4 grid gap-2 rounded-2xl border border-gray-100 bg-gray-50/80 p-3 text-xs text-gray-500 dark:border-dark-700 dark:bg-dark-900/30 dark:text-gray-400"
>
<div
v-for=
"hint in sourceHints"
:key=
"hint.key"
class=
"flex items-start gap-2"
>
<Icon
name=
"link"
size=
"sm"
class=
"mt-0.5 text-gray-400 dark:text-gray-500"
/>
<span>
{{
hint
.
text
}}
</span>
</div>
</div>
<div
class=
"mt-4 rounded-2xl border border-gray-100 bg-white/90 p-4 dark:border-dark-700 dark:bg-dark-900/30"
>
<div
class=
"flex flex-wrap items-start justify-between gap-3"
>
<div>
<h3
class=
"text-sm font-semibold text-gray-900 dark:text-white"
>
{{
t
(
'
profile.avatar.title
'
)
}}
</h3>
<p
class=
"mt-1 text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
profile.avatar.description
'
)
}}
</p>
</div>
<button
data-testid=
"profile-avatar-delete"
type=
"button"
class=
"btn btn-secondary btn-sm"
:disabled=
"avatarSaving"
@
click=
"handleAvatarDelete"
>
{{
t
(
'
common.delete
'
)
}}
</button>
</div>
<div
class=
"mt-3 space-y-3"
>
<label
for=
"profile-avatar-input"
class=
"text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400"
>
{{
t
(
'
profile.avatar.inputLabel
'
)
}}
</label>
<textarea
id=
"profile-avatar-input"
data-testid=
"profile-avatar-input"
v-model=
"avatarDraft"
rows=
"3"
class=
"input min-h-[88px]"
:placeholder=
"t('profile.avatar.inputPlaceholder')"
/>
<div
class=
"flex flex-wrap items-center gap-2"
>
<label
class=
"btn btn-secondary btn-sm cursor-pointer"
>
<input
data-testid=
"profile-avatar-file-input"
type=
"file"
accept=
"image/*"
class=
"hidden"
@
change=
"handleAvatarFileChange"
>
{{
t
(
'
profile.avatar.uploadAction
'
)
}}
</label>
<button
data-testid=
"profile-avatar-save"
type=
"button"
class=
"btn btn-primary btn-sm"
:disabled=
"avatarSaving"
@
click=
"handleAvatarSave"
>
{{
t
(
'
common.save
'
)
}}
</button>
<span
class=
"text-xs text-gray-400 dark:text-gray-500"
>
{{
t
(
'
profile.avatar.uploadHint
'
)
}}
</span>
</div>
</div>
</div>
<ProfileIdentityBindingsSection
class=
"mt-4"
:user=
"user"
:linuxdo-enabled=
"linuxdoEnabled"
:oidc-enabled=
"oidcEnabled"
:oidc-provider-name=
"oidcProviderName"
:wechat-enabled=
"wechatEnabled"
:wechat-open-enabled=
"wechatOpenEnabled"
:wechat-mp-enabled=
"wechatMpEnabled"
/>
</div>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
computed
,
ref
,
watch
}
from
'
vue
'
import
{
computed
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
userAPI
}
from
'
@/api
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
ProfileIdentityBindingsSection
from
'
@/components/user/profile/ProfileIdentityBindingsSection.vue
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
useAuthStore
}
from
'
@/stores/auth
'
import
type
{
User
,
UserAuthProvider
,
UserProfileSourceContext
}
from
'
@/types
'
import
{
extractApiErrorMessage
}
from
'
@/utils/apiError
'
import
type
{
User
}
from
'
@/types
'
const
props
=
withDefaults
(
defineProps
<
{
user
:
User
|
null
linuxdoEnabled
?:
boolean
oidcEnabled
?:
boolean
oidcProviderName
?:
string
wechatEnabled
?:
boolean
wechatOpenEnabled
?:
boolean
wechatMpEnabled
?:
boolean
}
>
(),
{
linuxdoEnabled
:
false
,
oidcEnabled
:
false
,
oidcProviderName
:
'
OIDC
'
,
wechatEnabled
:
false
,
wechatOpenEnabled
:
undefined
,
wechatMpEnabled
:
undefined
,
}
)
const
props
=
defineProps
<
{
user
:
User
|
null
}
>
()
const
{
t
}
=
useI18n
()
const
authStore
=
useAuthStore
()
const
appStore
=
useAppStore
()
const
maxAvatarBytes
=
100
*
1024
const
targetAvatarUploadBytes
=
20
*
1024
const
avatarScaleSteps
=
[
1
,
0.92
,
0.84
,
0.76
,
0.68
,
0.6
,
0.52
,
0.44
,
0.36
]
const
avatarQualitySteps
=
[
0.92
,
0.84
,
0.76
,
0.68
,
0.6
,
0.52
,
0.44
,
0.36
]
const
avatarDraft
=
ref
(
props
.
user
?.
avatar_url
?.
trim
()
||
''
)
const
avatarSaving
=
ref
(
false
)
const
providerLabels
=
computed
<
Record
<
UserAuthProvider
,
string
>>
(()
=>
({
email
:
t
(
'
profile.authBindings.providers.email
'
),
linuxdo
:
t
(
'
profile.authBindings.providers.linuxdo
'
),
oidc
:
t
(
'
profile.authBindings.providers.oidc
'
,
{
providerName
:
props
.
oidcProviderName
}),
wechat
:
t
(
'
profile.authBindings.providers.wechat
'
),
}))
const
avatarUrl
=
computed
(()
=>
props
.
user
?.
avatar_url
?.
trim
()
||
''
)
const
displayName
=
computed
(()
=>
props
.
user
?.
username
?.
trim
()
||
props
.
user
?.
email
?.
trim
()
||
'
User
'
)
const
avatarInitial
=
computed
(()
=>
displayName
.
value
.
charAt
(
0
).
toUpperCase
()
||
'
U
'
)
watch
(
()
=>
props
.
user
?.
avatar_url
,
(
value
)
=>
{
avatarDraft
.
value
=
value
?.
trim
()
||
''
}
)
function
normalizeProvider
(
value
:
string
):
UserAuthProvider
|
null
{
const
normalized
=
value
.
trim
().
toLowerCase
()
if
(
normalized
===
'
email
'
||
normalized
===
'
linuxdo
'
||
normalized
===
'
wechat
'
)
{
return
normalized
}
if
(
normalized
===
'
oidc
'
||
normalized
.
startsWith
(
'
oidc:
'
)
||
normalized
.
startsWith
(
'
oidc/
'
))
{
return
'
oidc
'
}
return
null
}
function
readObjectString
(
source
:
Record
<
string
,
unknown
>
,
...
keys
:
string
[]):
string
{
for
(
const
key
of
keys
)
{
const
value
=
source
[
key
]
if
(
typeof
value
===
'
string
'
&&
value
.
trim
())
{
return
value
.
trim
()
}
}
return
''
}
function
resolveThirdPartySource
(
rawSource
:
string
|
UserProfileSourceContext
|
null
|
undefined
):
{
provider
:
UserAuthProvider
;
label
:
string
}
|
null
{
if
(
!
rawSource
)
{
return
null
}
if
(
typeof
rawSource
===
'
string
'
)
{
const
provider
=
normalizeProvider
(
rawSource
)
if
(
!
provider
||
provider
===
'
email
'
)
{
return
null
}
return
{
provider
,
label
:
providerLabels
.
value
[
provider
],
}
}
const
sourceRecord
=
rawSource
as
Record
<
string
,
unknown
>
const
provider
=
normalizeProvider
(
readObjectString
(
sourceRecord
,
'
provider
'
,
'
source
'
,
'
provider_type
'
,
'
auth_provider
'
)
)
if
(
!
provider
||
provider
===
'
email
'
)
{
return
null
}
const
explicitLabel
=
readObjectString
(
sourceRecord
,
'
provider_label
'
,
'
label
'
,
'
provider_name
'
,
'
providerName
'
)
return
{
provider
,
label
:
explicitLabel
||
providerLabels
.
value
[
provider
],
}
}
const
sourceHints
=
computed
(()
=>
{
const
currentUser
=
props
.
user
if
(
!
currentUser
)
{
return
[]
}
const
hints
:
Array
<
{
key
:
string
;
text
:
string
}
>
=
[]
const
avatarSource
=
resolveThirdPartySource
(
currentUser
.
profile_sources
?.
avatar
??
currentUser
.
avatar_source
)
const
usernameSource
=
resolveThirdPartySource
(
currentUser
.
profile_sources
?.
username
??
currentUser
.
profile_sources
?.
display_name
??
currentUser
.
profile_sources
?.
nickname
??
currentUser
.
display_name_source
??
currentUser
.
username_source
??
currentUser
.
nickname_source
)
if
(
avatarSource
)
{
hints
.
push
({
key
:
'
avatar
'
,
text
:
t
(
'
profile.authBindings.source.avatar
'
,
{
providerName
:
avatarSource
.
label
}),
})
}
if
(
usernameSource
)
{
hints
.
push
({
key
:
'
username
'
,
text
:
t
(
'
profile.authBindings.source.username
'
,
{
providerName
:
usernameSource
.
label
}),
})
}
return
hints
})
function
estimateDataURLByteSize
(
value
:
string
):
number
{
const
[,
encoded
=
''
]
=
value
.
split
(
'
,
'
,
2
)
const
sanitized
=
encoded
.
replace
(
/
\s
+/g
,
''
)
const
padding
=
sanitized
.
endsWith
(
'
==
'
)
?
2
:
sanitized
.
endsWith
(
'
=
'
)
?
1
:
0
return
Math
.
max
(
0
,
Math
.
floor
((
sanitized
.
length
*
3
)
/
4
)
-
padding
)
}
function
validateAvatarInput
(
value
:
string
):
string
|
null
{
const
normalized
=
value
.
trim
()
if
(
!
normalized
)
{
return
null
}
if
(
normalized
.
startsWith
(
'
data:
'
))
{
if
(
!
/^data:image
\/[
a-zA-Z0-9.+-
]
+;base64,/i
.
test
(
normalized
))
{
appStore
.
showError
(
t
(
'
profile.avatar.invalidValue
'
))
return
null
}
if
(
estimateDataURLByteSize
(
normalized
)
>
maxAvatarBytes
)
{
appStore
.
showError
(
t
(
'
profile.avatar.fileTooLarge
'
))
return
null
}
return
normalized
}
try
{
const
parsed
=
new
URL
(
normalized
)
if
(
parsed
.
protocol
===
'
http:
'
||
parsed
.
protocol
===
'
https:
'
)
{
return
normalized
}
}
catch
{
// Invalid URL is handled below.
}
appStore
.
showError
(
t
(
'
profile.avatar.invalidValue
'
))
return
null
}
function
readFileAsDataURL
(
file
:
File
):
Promise
<
string
>
{
return
new
Promise
((
resolve
,
reject
)
=>
{
const
reader
=
new
FileReader
()
reader
.
onload
=
()
=>
resolve
(
typeof
reader
.
result
===
'
string
'
?
reader
.
result
:
''
)
reader
.
onerror
=
()
=>
reject
(
reader
.
error
??
new
Error
(
'
avatar_read_failed
'
))
reader
.
readAsDataURL
(
file
)
})
}
function
loadImage
(
dataURL
:
string
):
Promise
<
HTMLImageElement
>
{
return
new
Promise
((
resolve
,
reject
)
=>
{
const
image
=
new
Image
()
image
.
onload
=
()
=>
resolve
(
image
)
image
.
onerror
=
()
=>
reject
(
new
Error
(
t
(
'
profile.avatar.readFailed
'
)))
image
.
src
=
dataURL
})
}
function
canvasToBlob
(
canvas
:
HTMLCanvasElement
,
type
:
string
,
quality
:
number
):
Promise
<
Blob
>
{
return
new
Promise
((
resolve
,
reject
)
=>
{
canvas
.
toBlob
((
blob
)
=>
{
if
(
!
blob
)
{
reject
(
new
Error
(
t
(
'
profile.avatar.compressFailed
'
)))
return
}
resolve
(
blob
)
},
type
,
quality
)
})
}
async
function
compressAvatarFile
(
file
:
File
):
Promise
<
File
>
{
const
sourceDataURL
=
await
readFileAsDataURL
(
file
)
const
image
=
await
loadImage
(
sourceDataURL
)
const
canvas
=
document
.
createElement
(
'
canvas
'
)
const
ctx
=
canvas
.
getContext
(
'
2d
'
)
if
(
!
ctx
)
{
throw
new
Error
(
t
(
'
profile.avatar.compressFailed
'
))
}
for
(
const
scale
of
avatarScaleSteps
)
{
const
width
=
Math
.
max
(
1
,
Math
.
round
(
image
.
naturalWidth
*
scale
))
const
height
=
Math
.
max
(
1
,
Math
.
round
(
image
.
naturalHeight
*
scale
))
canvas
.
width
=
width
canvas
.
height
=
height
ctx
.
clearRect
(
0
,
0
,
width
,
height
)
ctx
.
drawImage
(
image
,
0
,
0
,
width
,
height
)
for
(
const
quality
of
avatarQualitySteps
)
{
const
blob
=
await
canvasToBlob
(
canvas
,
'
image/webp
'
,
quality
)
if
(
blob
.
size
<=
targetAvatarUploadBytes
)
{
const
fileName
=
file
.
name
.
replace
(
/
\.[^
.
]
+$/
,
''
)
||
'
avatar
'
return
new
File
([
blob
],
`
${
fileName
}
.webp`
,
{
type
:
'
image/webp
'
})
}
}
}
throw
new
Error
(
t
(
'
profile.avatar.compressTooLarge
'
))
}
async
function
prepareAvatarUpload
(
file
:
File
):
Promise
<
File
>
{
if
(
!
file
.
type
.
startsWith
(
'
image/
'
))
{
throw
new
Error
(
t
(
'
profile.avatar.invalidType
'
))
}
if
(
file
.
type
===
'
image/gif
'
)
{
if
(
file
.
size
>
targetAvatarUploadBytes
)
{
throw
new
Error
(
t
(
'
profile.avatar.gifTooLarge
'
))
}
return
file
}
if
(
file
.
size
<=
targetAvatarUploadBytes
)
{
return
file
}
return
compressAvatarFile
(
file
)
}
async
function
handleAvatarFileChange
(
event
:
Event
)
{
const
input
=
event
.
target
as
HTMLInputElement
|
null
const
file
=
input
?.
files
?.[
0
]
if
(
input
)
{
input
.
value
=
''
}
if
(
!
file
)
{
return
}
if
(
!
file
.
type
.
startsWith
(
'
image/
'
))
{
appStore
.
showError
(
t
(
'
profile.avatar.invalidType
'
))
return
}
if
(
file
.
size
>
maxAvatarBytes
)
{
appStore
.
showError
(
t
(
'
profile.avatar.fileTooLarge
'
))
return
}
try
{
const
preparedFile
=
await
prepareAvatarUpload
(
file
)
const
dataURL
=
await
readFileAsDataURL
(
preparedFile
)
const
normalized
=
validateAvatarInput
(
dataURL
)
if
(
!
normalized
)
{
return
}
avatarDraft
.
value
=
normalized
}
catch
(
error
:
unknown
)
{
appStore
.
showError
(
extractApiErrorMessage
(
error
,
t
(
'
common.error
'
)))
}
}
async
function
handleAvatarSave
()
{
const
normalized
=
validateAvatarInput
(
avatarDraft
.
value
)
if
(
!
normalized
)
{
return
}
avatarSaving
.
value
=
true
try
{
const
updated
=
await
userAPI
.
updateProfile
({
avatar_url
:
normalized
})
authStore
.
user
=
updated
avatarDraft
.
value
=
updated
.
avatar_url
?.
trim
()
||
''
appStore
.
showSuccess
(
t
(
'
profile.avatar.saveSuccess
'
))
}
catch
(
error
:
unknown
)
{
appStore
.
showError
(
extractApiErrorMessage
(
error
,
t
(
'
common.error
'
)))
}
finally
{
avatarSaving
.
value
=
false
}
}
async
function
handleAvatarDelete
()
{
if
(
avatarSaving
.
value
)
{
return
}
if
(
!
avatarDraft
.
value
.
trim
()
&&
!
props
.
user
?.
avatar_url
?.
trim
())
{
appStore
.
showError
(
t
(
'
profile.avatar.emptyDeleteHint
'
))
return
}
avatarSaving
.
value
=
true
try
{
const
updated
=
await
userAPI
.
updateProfile
({
avatar_url
:
''
})
authStore
.
user
=
updated
avatarDraft
.
value
=
''
appStore
.
showSuccess
(
t
(
'
profile.avatar.deleteSuccess
'
))
}
catch
(
error
:
unknown
)
{
appStore
.
showError
(
extractApiErrorMessage
(
error
,
t
(
'
common.error
'
)))
}
finally
{
avatarSaving
.
value
=
false
}
}
</
script
>
frontend/src/components/user/profile/__tests__/ProfileAvatarCard.spec.ts
0 → 100644
View file @
7309c02f
import
{
mount
}
from
'
@vue/test-utils
'
import
{
afterEach
,
beforeEach
,
describe
,
expect
,
it
,
vi
}
from
'
vitest
'
import
ProfileAvatarCard
from
'
@/components/user/profile/ProfileAvatarCard.vue
'
import
type
{
User
}
from
'
@/types
'
const
{
updateProfileMock
,
showSuccessMock
,
showErrorMock
,
authStoreState
}
=
vi
.
hoisted
(()
=>
({
updateProfileMock
:
vi
.
fn
(),
showSuccessMock
:
vi
.
fn
(),
showErrorMock
:
vi
.
fn
(),
authStoreState
:
{
user
:
null
as
User
|
null
}
}))
vi
.
mock
(
'
@/api
'
,
()
=>
({
userAPI
:
{
updateProfile
:
updateProfileMock
}
}))
vi
.
mock
(
'
@/stores/auth
'
,
()
=>
({
useAuthStore
:
()
=>
authStoreState
}))
vi
.
mock
(
'
@/stores/app
'
,
()
=>
({
useAppStore
:
()
=>
({
showSuccess
:
showSuccessMock
,
showError
:
showErrorMock
})
}))
vi
.
mock
(
'
@/utils/apiError
'
,
()
=>
({
extractApiErrorMessage
:
(
error
:
unknown
)
=>
(
error
as
Error
).
message
||
'
request failed
'
}))
vi
.
mock
(
'
vue-i18n
'
,
async
(
importOriginal
)
=>
{
const
actual
=
await
importOriginal
<
typeof
import
(
'
vue-i18n
'
)
>
()
return
{
...
actual
,
useI18n
:
()
=>
({
t
:
(
key
:
string
,
params
?:
Record
<
string
,
string
>
)
=>
{
if
(
key
===
'
profile.avatar.title
'
)
return
'
Profile avatar
'
if
(
key
===
'
profile.avatar.description
'
)
return
'
Upload and manage your avatar
'
if
(
key
===
'
profile.avatar.uploadAction
'
)
return
'
Upload image
'
if
(
key
===
'
profile.avatar.uploadHint
'
)
return
'
Uploaded images are compressed to 20KB when possible
'
if
(
key
===
'
profile.avatar.saveSuccess
'
)
return
'
Avatar updated
'
if
(
key
===
'
profile.avatar.deleteSuccess
'
)
return
'
Avatar removed
'
if
(
key
===
'
profile.avatar.invalidType
'
)
return
'
Please choose an image file
'
if
(
key
===
'
profile.avatar.gifTooLarge
'
)
return
'
GIF avatars must already be 20KB or smaller
'
if
(
key
===
'
profile.avatar.compressTooLarge
'
)
return
'
Unable to compress this image below 20KB
'
if
(
key
===
'
profile.avatar.compressFailed
'
)
return
'
Failed to compress the selected image
'
if
(
key
===
'
profile.avatar.readFailed
'
)
return
'
Failed to read the selected image
'
if
(
key
===
'
common.save
'
)
return
'
Save
'
if
(
key
===
'
common.delete
'
)
return
'
Delete
'
if
(
key
===
'
profile.avatar.compressedReady
'
)
return
`Compressed from
${
params
?.
from
}
to
${
params
?.
to
}
`
if
(
key
===
'
profile.avatar.sizeReady
'
)
return
`Ready:
${
params
?.
size
}
`
return
key
}
})
}
})
function
createUser
(
overrides
:
Partial
<
User
>
=
{}):
User
{
return
{
id
:
5
,
username
:
'
alice
'
,
email
:
'
alice@example.com
'
,
avatar_url
:
null
,
role
:
'
user
'
,
balance
:
10
,
concurrency
:
2
,
status
:
'
active
'
,
allowed_groups
:
null
,
balance_notify_enabled
:
true
,
balance_notify_threshold
:
null
,
balance_notify_extra_emails
:
[],
created_at
:
'
2026-04-20T00:00:00Z
'
,
updated_at
:
'
2026-04-20T00:00:00Z
'
,
...
overrides
}
}
async
function
flushAsyncWork
():
Promise
<
void
>
{
await
Promise
.
resolve
()
await
Promise
.
resolve
()
}
const
originalFileReader
=
globalThis
.
FileReader
const
originalImage
=
globalThis
.
Image
const
originalCreateElement
=
document
.
createElement
.
bind
(
document
)
function
installAvatarCompressionMocks
(
blobSize
=
8
*
1024
)
{
class
MockFileReader
{
result
:
string
|
ArrayBuffer
|
null
=
null
onload
:
((
this
:
FileReader
,
ev
:
ProgressEvent
<
FileReader
>
)
=>
any
)
|
null
=
null
onerror
:
((
this
:
FileReader
,
ev
:
ProgressEvent
<
FileReader
>
)
=>
any
)
|
null
=
null
error
:
DOMException
|
null
=
null
readAsDataURL
(
blob
:
Blob
)
{
if
(
blob
.
type
===
'
image/webp
'
)
{
this
.
result
=
'
data:image/webp;base64,
'
+
Buffer
.
from
(
'
compressed-avatar
'
).
toString
(
'
base64
'
)
}
else
{
this
.
result
=
'
data:image/png;base64,
'
+
Buffer
.
from
(
'
original-avatar
'
).
toString
(
'
base64
'
)
}
this
.
onload
?.
call
(
this
as
unknown
as
FileReader
,
new
ProgressEvent
(
'
load
'
))
}
}
class
MockImage
{
naturalWidth
=
1200
naturalHeight
=
1200
onload
:
(()
=>
void
)
|
null
=
null
onerror
:
(()
=>
void
)
|
null
=
null
set
src
(
_value
:
string
)
{
this
.
onload
?.()
}
}
globalThis
.
FileReader
=
MockFileReader
as
unknown
as
typeof
FileReader
globalThis
.
Image
=
MockImage
as
unknown
as
typeof
Image
vi
.
spyOn
(
document
,
'
createElement
'
).
mockImplementation
(((
tagName
:
string
,
options
?:
ElementCreationOptions
)
=>
{
if
(
tagName
===
'
canvas
'
)
{
return
{
width
:
0
,
height
:
0
,
getContext
:
()
=>
({
clearRect
:
vi
.
fn
(),
drawImage
:
vi
.
fn
()
}),
toBlob
:
(
callback
:
BlobCallback
)
=>
{
callback
(
new
Blob
([
new
Uint8Array
(
blobSize
)],
{
type
:
'
image/webp
'
}))
}
}
as
unknown
as
HTMLCanvasElement
}
return
originalCreateElement
(
tagName
,
options
)
})
as
typeof
document
.
createElement
)
}
describe
(
'
ProfileAvatarCard
'
,
()
=>
{
beforeEach
(()
=>
{
updateProfileMock
.
mockReset
()
showSuccessMock
.
mockReset
()
showErrorMock
.
mockReset
()
authStoreState
.
user
=
null
})
afterEach
(()
=>
{
globalThis
.
FileReader
=
originalFileReader
globalThis
.
Image
=
originalImage
vi
.
restoreAllMocks
()
})
it
(
'
compresses an uploaded image that exceeds the 20KB target before saving
'
,
async
()
=>
{
installAvatarCompressionMocks
()
const
updatedUser
=
createUser
({
avatar_url
:
'
data:image/webp;base64,Y29tcHJlc3NlZC1hdmF0YXI=
'
})
updateProfileMock
.
mockResolvedValue
(
updatedUser
)
authStoreState
.
user
=
createUser
()
const
wrapper
=
mount
(
ProfileAvatarCard
,
{
props
:
{
user
:
authStoreState
.
user
},
global
:
{
stubs
:
{
Icon
:
true
}
}
})
const
fileInput
=
wrapper
.
get
(
'
[data-testid="profile-avatar-file-input"]
'
)
Object
.
defineProperty
(
fileInput
.
element
,
'
files
'
,
{
value
:
[
new
File
([
new
Uint8Array
(
220
*
1024
)],
'
avatar.png
'
,
{
type
:
'
image/png
'
})],
configurable
:
true
})
await
fileInput
.
trigger
(
'
change
'
)
await
flushAsyncWork
()
await
wrapper
.
get
(
'
[data-testid="profile-avatar-save"]
'
).
trigger
(
'
click
'
)
expect
(
updateProfileMock
).
toHaveBeenCalledWith
({
avatar_url
:
'
data:image/webp;base64,Y29tcHJlc3NlZC1hdmF0YXI=
'
})
expect
(
showErrorMock
).
not
.
toHaveBeenCalled
()
})
it
(
'
deletes the current avatar
'
,
async
()
=>
{
const
updatedUser
=
createUser
({
avatar_url
:
null
})
updateProfileMock
.
mockResolvedValue
(
updatedUser
)
authStoreState
.
user
=
createUser
({
avatar_url
:
'
https://cdn.example.com/old.png
'
})
const
wrapper
=
mount
(
ProfileAvatarCard
,
{
props
:
{
user
:
authStoreState
.
user
},
global
:
{
stubs
:
{
Icon
:
true
}
}
})
await
wrapper
.
get
(
'
[data-testid="profile-avatar-delete"]
'
).
trigger
(
'
click
'
)
expect
(
updateProfileMock
).
toHaveBeenCalledWith
({
avatar_url
:
''
})
expect
(
authStoreState
.
user
?.
avatar_url
).
toBeNull
()
expect
(
showSuccessMock
).
toHaveBeenCalledWith
(
'
Avatar removed
'
)
})
})
frontend/src/components/user/profile/__tests__/ProfileInfoCard.spec.ts
View file @
7309c02f
import
{
mount
}
from
'
@vue/test-utils
'
import
{
afterEach
,
beforeEach
,
describe
,
expect
,
it
,
vi
}
from
'
vitest
'
import
{
describe
,
expect
,
it
,
vi
}
from
'
vitest
'
import
ProfileInfoCard
from
'
@/components/user/profile/ProfileInfoCard.vue
'
import
type
{
User
}
from
'
@/types
'
const
{
updateProfileMock
,
showSuccessMock
,
showErrorMock
,
authStoreState
}
=
vi
.
hoisted
(()
=>
({
updateProfileMock
:
vi
.
fn
(),
showSuccessMock
:
vi
.
fn
(),
showErrorMock
:
vi
.
fn
(),
authStoreState
:
{
user
:
null
as
User
|
null
}
}))
vi
.
mock
(
'
@/api
'
,
()
=>
({
userAPI
:
{
updateProfile
:
updateProfileMock
}
}))
vi
.
mock
(
'
@/stores/auth
'
,
()
=>
({
useAuthStore
:
()
=>
authStoreState
useAuthStore
:
()
=>
({
user
:
null
})
}))
vi
.
mock
(
'
@/stores/app
'
,
()
=>
({
useAppStore
:
()
=>
({
show
Success
:
showSuccessMock
,
show
Error
:
showErrorMock
show
Error
:
vi
.
fn
()
,
show
Success
:
vi
.
fn
()
})
}))
vi
.
mock
(
'
@/utils/apiError
'
,
()
=>
({
extractApiErrorMessage
:
(
error
:
unknown
)
=>
(
error
as
Error
).
message
||
'
request failed
'
}))
vi
.
mock
(
'
vue-i18n
'
,
async
(
importOriginal
)
=>
{
const
actual
=
await
importOriginal
<
typeof
import
(
'
vue-i18n
'
)
>
()
return
{
...
actual
,
useI18n
:
()
=>
({
t
:
(
key
:
string
,
params
?:
Record
<
string
,
string
>
)
=>
{
t
:
(
key
:
string
)
=>
{
if
(
key
===
'
profile.administrator
'
)
return
'
Administrator
'
if
(
key
===
'
profile.user
'
)
return
'
User
'
if
(
key
===
'
profile.avatar.title
'
)
return
'
Profile avatar
'
if
(
key
===
'
profile.avatar.description
'
)
return
'
Set avatar by image URL or upload
'
if
(
key
===
'
profile.avatar.inputLabel
'
)
return
'
Avatar URL or data URL
'
if
(
key
===
'
profile.avatar.inputPlaceholder
'
)
return
'
https://cdn.example.com/avatar.png
'
if
(
key
===
'
profile.avatar.uploadAction
'
)
return
'
Upload image
'
if
(
key
===
'
profile.avatar.uploadHint
'
)
return
'
Images must be 100KB or smaller and will be compressed to 20KB
'
if
(
key
===
'
profile.avatar.saveSuccess
'
)
return
'
Avatar updated
'
if
(
key
===
'
profile.avatar.deleteSuccess
'
)
return
'
Avatar removed
'
if
(
key
===
'
profile.avatar.invalidType
'
)
return
'
Please choose an image file
'
if
(
key
===
'
profile.avatar.fileTooLarge
'
)
return
'
Avatar image must be 100KB or smaller
'
if
(
key
===
'
profile.avatar.gifTooLarge
'
)
return
'
GIF avatars must already be 20KB or smaller
'
if
(
key
===
'
profile.avatar.compressTooLarge
'
)
return
'
Unable to compress this image below 20KB
'
if
(
key
===
'
profile.avatar.compressFailed
'
)
return
'
Failed to compress the selected image
'
if
(
key
===
'
profile.avatar.readFailed
'
)
return
'
Failed to read the selected image
'
if
(
key
===
'
profile.avatar.invalidValue
'
)
return
'
Enter a valid avatar URL or image data URL
'
if
(
key
===
'
profile.avatar.emptyDeleteHint
'
)
return
'
Avatar already removed
'
if
(
key
===
'
profile.authBindings.providers.email
'
)
return
'
Email
'
if
(
key
===
'
profile.authBindings.providers.linuxdo
'
)
return
'
LinuxDo
'
if
(
key
===
'
profile.authBindings.providers.wechat
'
)
return
'
WeChat
'
if
(
key
===
'
profile.authBindings.providers.oidc
'
)
return
params
?.
providerName
||
'
OIDC
'
if
(
key
===
'
profile.authBindings.source.avatar
'
)
return
`Avatar synced from
${
params
?.
providerName
||
'
provider
'
}
`
if
(
key
===
'
profile.authBindings.source.username
'
)
return
`Username synced from
${
params
?.
providerName
||
'
provider
'
}
`
if
(
key
===
'
common.save
'
)
return
'
Save
'
if
(
key
===
'
common.delete
'
)
return
'
Delete
'
return
key
}
})
...
...
@@ -96,204 +50,23 @@ function createUser(overrides: Partial<User> = {}): User {
}
}
async
function
flushAsyncWork
():
Promise
<
void
>
{
await
Promise
.
resolve
()
await
Promise
.
resolve
()
}
const
originalFileReader
=
globalThis
.
FileReader
const
originalImage
=
globalThis
.
Image
const
originalCreateElement
=
document
.
createElement
.
bind
(
document
)
function
installAvatarCompressionMocks
()
{
class
MockFileReader
{
result
:
string
|
ArrayBuffer
|
null
=
null
onload
:
((
this
:
FileReader
,
ev
:
ProgressEvent
<
FileReader
>
)
=>
any
)
|
null
=
null
onerror
:
((
this
:
FileReader
,
ev
:
ProgressEvent
<
FileReader
>
)
=>
any
)
|
null
=
null
error
:
DOMException
|
null
=
null
readAsDataURL
(
blob
:
Blob
)
{
if
(
blob
.
type
===
'
image/webp
'
)
{
this
.
result
=
'
data:image/webp;base64,
'
+
Buffer
.
from
(
'
compressed-avatar
'
).
toString
(
'
base64
'
)
}
else
{
this
.
result
=
'
data:image/png;base64,
'
+
Buffer
.
from
(
'
original-avatar
'
).
toString
(
'
base64
'
)
}
this
.
onload
?.
call
(
this
as
unknown
as
FileReader
,
new
ProgressEvent
(
'
load
'
))
}
}
class
MockImage
{
naturalWidth
=
1200
naturalHeight
=
1200
onload
:
(()
=>
void
)
|
null
=
null
onerror
:
(()
=>
void
)
|
null
=
null
set
src
(
_value
:
string
)
{
this
.
onload
?.()
}
}
globalThis
.
FileReader
=
MockFileReader
as
unknown
as
typeof
FileReader
globalThis
.
Image
=
MockImage
as
unknown
as
typeof
Image
vi
.
spyOn
(
document
,
'
createElement
'
).
mockImplementation
(((
tagName
:
string
,
options
?:
ElementCreationOptions
)
=>
{
if
(
tagName
===
'
canvas
'
)
{
return
{
width
:
0
,
height
:
0
,
getContext
:
()
=>
({
clearRect
:
vi
.
fn
(),
drawImage
:
vi
.
fn
(),
}),
toBlob
:
(
callback
:
BlobCallback
)
=>
{
callback
(
new
Blob
([
new
Uint8Array
(
8
*
1024
)],
{
type
:
'
image/webp
'
}))
},
}
as
unknown
as
HTMLCanvasElement
}
return
originalCreateElement
(
tagName
,
options
)
})
as
typeof
document
.
createElement
)
}
describe
(
'
ProfileInfoCard
'
,
()
=>
{
beforeEach
(()
=>
{
updateProfileMock
.
mockReset
()
showSuccessMock
.
mockReset
()
showErrorMock
.
mockReset
()
authStoreState
.
user
=
null
})
afterEach
(()
=>
{
globalThis
.
FileReader
=
originalFileReader
globalThis
.
Image
=
originalImage
vi
.
restoreAllMocks
()
})
it
(
'
saves a remote avatar URL and updates the auth store
'
,
async
()
=>
{
const
updatedUser
=
createUser
({
avatar_url
:
'
https://cdn.example.com/new.png
'
})
updateProfileMock
.
mockResolvedValue
(
updatedUser
)
authStoreState
.
user
=
createUser
()
const
wrapper
=
mount
(
ProfileInfoCard
,
{
props
:
{
user
:
authStoreState
.
user
},
global
:
{
stubs
:
{
Icon
:
true
,
ProfileIdentityBindingsSection
:
true
}
}
})
await
wrapper
.
get
(
'
[data-testid="profile-avatar-input"]
'
).
setValue
(
'
https://cdn.example.com/new.png
'
)
await
wrapper
.
get
(
'
[data-testid="profile-avatar-save"]
'
).
trigger
(
'
click
'
)
expect
(
updateProfileMock
).
toHaveBeenCalledWith
({
avatar_url
:
'
https://cdn.example.com/new.png
'
})
expect
(
authStoreState
.
user
?.
avatar_url
).
toBe
(
'
https://cdn.example.com/new.png
'
)
expect
(
showSuccessMock
).
toHaveBeenCalledWith
(
'
Avatar updated
'
)
})
it
(
'
rejects an oversized data URL before sending the request
'
,
async
()
=>
{
authStoreState
.
user
=
createUser
()
const
oversized
=
`data:image/png;base64,
${
Buffer
.
from
(
new
Uint8Array
(
102401
)).
toString
(
'
base64
'
)}
`
const
wrapper
=
mount
(
ProfileInfoCard
,
{
props
:
{
user
:
authStoreState
.
user
},
global
:
{
stubs
:
{
Icon
:
true
,
ProfileIdentityBindingsSection
:
true
}
}
})
await
wrapper
.
get
(
'
[data-testid="profile-avatar-input"]
'
).
setValue
(
oversized
)
await
wrapper
.
get
(
'
[data-testid="profile-avatar-save"]
'
).
trigger
(
'
click
'
)
expect
(
updateProfileMock
).
not
.
toHaveBeenCalled
()
expect
(
showErrorMock
).
toHaveBeenCalledWith
(
'
Avatar image must be 100KB or smaller
'
)
})
it
(
'
compresses uploaded images under 100KB before saving
'
,
async
()
=>
{
installAvatarCompressionMocks
()
const
updatedUser
=
createUser
({
avatar_url
:
'
data:image/webp;base64,Y29tcHJlc3NlZC1hdmF0YXI=
'
})
updateProfileMock
.
mockResolvedValue
(
updatedUser
)
authStoreState
.
user
=
createUser
()
const
wrapper
=
mount
(
ProfileInfoCard
,
{
props
:
{
user
:
authStoreState
.
user
},
global
:
{
stubs
:
{
Icon
:
true
,
ProfileIdentityBindingsSection
:
true
}
}
})
const
fileInput
=
wrapper
.
get
(
'
[data-testid="profile-avatar-file-input"]
'
)
Object
.
defineProperty
(
fileInput
.
element
,
'
files
'
,
{
value
:
[
new
File
([
new
Uint8Array
(
80
*
1024
)],
'
avatar.png
'
,
{
type
:
'
image/png
'
})],
configurable
:
true
})
await
fileInput
.
trigger
(
'
change
'
)
await
flushAsyncWork
()
await
wrapper
.
get
(
'
[data-testid="profile-avatar-save"]
'
).
trigger
(
'
click
'
)
expect
(
updateProfileMock
).
toHaveBeenCalledWith
({
avatar_url
:
'
data:image/webp;base64,Y29tcHJlc3NlZC1hdmF0YXI=
'
})
})
it
(
'
deletes the current avatar
'
,
async
()
=>
{
const
updatedUser
=
createUser
({
avatar_url
:
null
})
updateProfileMock
.
mockResolvedValue
(
updatedUser
)
authStoreState
.
user
=
createUser
({
avatar_url
:
'
https://cdn.example.com/old.png
'
})
const
wrapper
=
mount
(
ProfileInfoCard
,
{
props
:
{
user
:
authStoreState
.
user
},
global
:
{
stubs
:
{
Icon
:
true
,
ProfileIdentityBindingsSection
:
true
}
}
})
await
wrapper
.
get
(
'
[data-testid="profile-avatar-delete"]
'
).
trigger
(
'
click
'
)
expect
(
updateProfileMock
).
toHaveBeenCalledWith
({
avatar_url
:
''
})
expect
(
authStoreState
.
user
?.
avatar_url
).
toBeNull
()
expect
(
showSuccessMock
).
toHaveBeenCalledWith
(
'
Avatar removed
'
)
})
it
(
'
renders third-party source hints from profile_sources
'
,
()
=>
{
authStoreState
.
user
=
createUser
({
avatar_url
:
'
https://cdn.example.com/linuxdo.png
'
,
profile_sources
:
{
avatar
:
{
provider
:
'
linuxdo
'
,
source
:
'
linuxdo
'
},
username
:
{
provider
:
'
linuxdo
'
,
source
:
'
linuxdo
'
}
}
})
it
(
'
renders basic account information without avatar or bindings actions
'
,
()
=>
{
const
wrapper
=
mount
(
ProfileInfoCard
,
{
props
:
{
user
:
authStoreSt
ate
.
u
ser
user
:
cre
ate
U
ser
()
},
global
:
{
stubs
:
{
Icon
:
true
,
ProfileIdentityBindingsSection
:
true
Icon
:
true
}
}
})
expect
(
wrapper
.
text
()).
toContain
(
'
Avatar synced from LinuxDo
'
)
expect
(
wrapper
.
text
()).
toContain
(
'
Username synced from LinuxDo
'
)
expect
(
wrapper
.
text
()).
toContain
(
'
alice@example.com
'
)
expect
(
wrapper
.
text
()).
toContain
(
'
alice
'
)
expect
(
wrapper
.
text
()).
toContain
(
'
User
'
)
expect
(
wrapper
.
find
(
'
[data-testid="profile-avatar-save"]
'
).
exists
()).
toBe
(
false
)
expect
(
wrapper
.
find
(
'
[data-testid="profile-binding-email-status"]
'
).
exists
()).
toBe
(
false
)
})
})
frontend/src/i18n/locales/en.ts
View file @
7309c02f
...
...
@@ -943,15 +943,14 @@ export default {
},
avatar
:
{
title
:
'
Profile Avatar
'
,
description
:
'
Set your avatar with a remote image URL or upload an image
under 100KB. Uploaded image
s are compressed to 20KB.
'
,
description
:
'
Set your avatar with a remote image URL or upload an image
. Static upload
s are compressed to 20KB
before saving
.
'
,
inputLabel
:
'
Avatar URL or data URL
'
,
inputPlaceholder
:
'
https://cdn.example.com/avatar.png
'
,
uploadAction
:
'
Upload image
'
,
uploadHint
:
'
Uploaded images must be 100KB or smal
le
r
.
Static images
are
compressed to
20KB.
'
,
uploadHint
:
'
Static uploads are compressed to 20KB when possib
le.
GIF uploads must
a
l
re
ady be within
20KB.
'
,
saveSuccess
:
'
Avatar updated
'
,
deleteSuccess
:
'
Avatar removed
'
,
invalidType
:
'
Please choose an image file
'
,
fileTooLarge
:
'
Avatar image must be 100KB or smaller
'
,
gifTooLarge
:
'
GIF avatars must already be 20KB or smaller
'
,
compressTooLarge
:
'
Unable to compress this image below 20KB. Try a smaller image.
'
,
compressFailed
:
'
Failed to compress the selected image.
'
,
...
...
frontend/src/i18n/locales/zh.ts
View file @
7309c02f
...
...
@@ -947,15 +947,14 @@ export default {
},
avatar
:
{
title
:
'
资料头像
'
,
description
:
'
支持填写远程图片 URL,或上传
不超过 100KB 的
头像图片;
上传
图片会自动压缩到 20KB 以内。
'
,
description
:
'
支持填写远程图片 URL,或上传头像图片;
静态
图片会自动压缩到 20KB 以内
后再保存
。
'
,
inputLabel
:
'
头像 URL 或 data URL
'
,
inputPlaceholder
:
'
https://cdn.example.com/avatar.png
'
,
uploadAction
:
'
上传图片
'
,
uploadHint
:
'
上传图片
需不超过 100KB,静态图片会自动压缩到
20KB 以内
'
,
uploadHint
:
'
上传图片
时会自动压缩静态图片到 20KB 以内,GIF 需自行控制在
20KB 以内
'
,
saveSuccess
:
'
头像已更新
'
,
deleteSuccess
:
'
头像已删除
'
,
invalidType
:
'
请选择图片文件
'
,
fileTooLarge
:
'
头像图片必须不超过 100KB
'
,
gifTooLarge
:
'
GIF 头像必须在 20KB 以内
'
,
compressTooLarge
:
'
无法将图片压缩到 20KB 以内,请换一张更小的图片
'
,
compressFailed
:
'
压缩所选图片失败
'
,
...
...
frontend/src/views/user/ProfileView.vue
View file @
7309c02f
...
...
@@ -22,7 +22,11 @@
/>
</div>
<ProfileInfoCard
<ProfileInfoCard
:user=
"user"
/>
<ProfileAvatarCard
:user=
"user"
/>
<ProfileAccountBindingsCard
:user=
"user"
:linuxdo-enabled=
"linuxdoOAuthEnabled"
:oidc-enabled=
"oidcOAuthEnabled"
...
...
@@ -73,6 +77,8 @@ import { Icon } from '@/components/icons'
import
StatCard
from
'
@/components/common/StatCard.vue
'
import
AppLayout
from
'
@/components/layout/AppLayout.vue
'
import
ProfileBalanceNotifyCard
from
'
@/components/user/profile/ProfileBalanceNotifyCard.vue
'
import
ProfileAccountBindingsCard
from
'
@/components/user/profile/ProfileAccountBindingsCard.vue
'
import
ProfileAvatarCard
from
'
@/components/user/profile/ProfileAvatarCard.vue
'
import
ProfileEditForm
from
'
@/components/user/profile/ProfileEditForm.vue
'
import
ProfileInfoCard
from
'
@/components/user/profile/ProfileInfoCard.vue
'
import
ProfilePasswordForm
from
'
@/components/user/profile/ProfilePasswordForm.vue
'
...
...
frontend/src/views/user/__tests__/ProfileView.spec.ts
0 → 100644
View file @
7309c02f
import
{
flushPromises
,
mount
}
from
'
@vue/test-utils
'
import
{
beforeEach
,
describe
,
expect
,
it
,
vi
}
from
'
vitest
'
import
ProfileView
from
'
@/views/user/ProfileView.vue
'
const
{
fetchPublicSettingsMock
,
refreshUserMock
,
authState
}
=
vi
.
hoisted
(()
=>
({
fetchPublicSettingsMock
:
vi
.
fn
(),
refreshUserMock
:
vi
.
fn
(),
authState
:
{
user
:
null
as
Record
<
string
,
unknown
>
|
null
,
refreshUser
:
vi
.
fn
()
}
}))
vi
.
mock
(
'
@/stores/auth
'
,
()
=>
({
useAuthStore
:
()
=>
authState
}))
vi
.
mock
(
'
@/stores/app
'
,
()
=>
({
useAppStore
:
()
=>
({
fetchPublicSettings
:
fetchPublicSettingsMock
})
}))
vi
.
mock
(
'
@/utils/format
'
,
()
=>
({
formatDate
:
()
=>
'
April 2026
'
}))
vi
.
mock
(
'
vue-i18n
'
,
async
(
importOriginal
)
=>
{
const
actual
=
await
importOriginal
<
typeof
import
(
'
vue-i18n
'
)
>
()
return
{
...
actual
,
useI18n
:
()
=>
({
t
:
(
key
:
string
)
=>
key
})
}
})
describe
(
'
ProfileView
'
,
()
=>
{
beforeEach
(()
=>
{
refreshUserMock
.
mockReset
()
fetchPublicSettingsMock
.
mockReset
()
refreshUserMock
.
mockResolvedValue
(
undefined
)
authState
.
refreshUser
=
refreshUserMock
authState
.
user
=
{
id
:
1
,
username
:
'
alice
'
,
email
:
'
alice@example.com
'
,
role
:
'
user
'
,
balance
:
10
,
concurrency
:
2
,
status
:
'
active
'
,
allowed_groups
:
null
,
balance_notify_enabled
:
true
,
balance_notify_threshold
:
null
,
balance_notify_extra_emails
:
[],
created_at
:
'
2026-04-20T00:00:00Z
'
,
updated_at
:
'
2026-04-20T00:00:00Z
'
}
fetchPublicSettingsMock
.
mockResolvedValue
({
contact_info
:
''
,
balance_low_notify_enabled
:
false
,
balance_low_notify_threshold
:
0
,
linuxdo_oauth_enabled
:
true
,
wechat_oauth_enabled
:
true
,
wechat_oauth_open_enabled
:
true
,
wechat_oauth_mp_enabled
:
false
,
oidc_oauth_enabled
:
true
,
oidc_oauth_provider_name
:
'
OIDC
'
})
})
it
(
'
renders info, avatar, and account binding cards as separate sections
'
,
async
()
=>
{
const
wrapper
=
mount
(
ProfileView
,
{
global
:
{
stubs
:
{
AppLayout
:
{
template
:
'
<div><slot /></div>
'
},
StatCard
:
{
template
:
'
<div class="stat-card" />
'
},
ProfileInfoCard
:
{
template
:
'
<div data-testid="profile-info-card" />
'
},
ProfileAvatarCard
:
{
template
:
'
<div data-testid="profile-avatar-card" />
'
},
ProfileAccountBindingsCard
:
{
template
:
'
<div data-testid="profile-account-bindings-card" />
'
},
ProfileEditForm
:
true
,
ProfileBalanceNotifyCard
:
true
,
ProfilePasswordForm
:
true
,
ProfileTotpCard
:
true
,
Icon
:
true
}
}
})
await
flushPromises
()
const
html
=
wrapper
.
html
()
expect
(
html
.
indexOf
(
'
profile-info-card
'
)).
toBeGreaterThan
(
-
1
)
expect
(
html
.
indexOf
(
'
profile-avatar-card
'
)).
toBeGreaterThan
(
html
.
indexOf
(
'
profile-info-card
'
))
expect
(
html
.
indexOf
(
'
profile-account-bindings-card
'
)).
toBeGreaterThan
(
html
.
indexOf
(
'
profile-avatar-card
'
))
})
})
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment