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
17c6348b
Commit
17c6348b
authored
Apr 21, 2026
by
IanShaw027
Browse files
fix(profile): restore source hints and upload-only avatar
parent
7309c02f
Changes
6
Hide whitespace changes
Inline
Side-by-side
frontend/src/components/user/profile/ProfileAvatarCard.vue
View file @
17c6348b
...
@@ -32,14 +32,6 @@
...
@@ -32,14 +32,6 @@
</p>
</p>
</div>
</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"
>
<div
class=
"flex flex-wrap items-center gap-3"
>
<label
class=
"btn btn-secondary btn-sm cursor-pointer"
>
<label
class=
"btn btn-secondary btn-sm cursor-pointer"
>
<input
<input
...
@@ -56,7 +48,7 @@
...
@@ -56,7 +48,7 @@
data-testid=
"profile-avatar-save"
data-testid=
"profile-avatar-save"
type=
"button"
type=
"button"
class=
"btn btn-primary btn-sm"
class=
"btn btn-primary btn-sm"
:disabled=
"avatarSaving"
:disabled=
"avatarSaving
|| !avatarDraft
"
@
click=
"handleAvatarSave"
@
click=
"handleAvatarSave"
>
>
{{
t
(
'
common.save
'
)
}}
{{
t
(
'
common.save
'
)
}}
...
@@ -97,7 +89,7 @@ const appStore = useAppStore()
...
@@ -97,7 +89,7 @@ const appStore = useAppStore()
const
targetAvatarUploadBytes
=
20
*
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
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
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
avatarDraft
=
ref
(
''
)
const
avatarSaving
=
ref
(
false
)
const
avatarSaving
=
ref
(
false
)
const
displayName
=
computed
(()
=>
props
.
user
?.
username
?.
trim
()
||
props
.
user
?.
email
?.
trim
()
||
'
User
'
)
const
displayName
=
computed
(()
=>
props
.
user
?.
username
?.
trim
()
||
props
.
user
?.
email
?.
trim
()
||
'
User
'
)
...
@@ -106,36 +98,23 @@ const avatarPreviewUrl = computed(() => avatarDraft.value.trim() || props.user?.
...
@@ -106,36 +98,23 @@ const avatarPreviewUrl = computed(() => avatarDraft.value.trim() || props.user?.
watch
(
watch
(
()
=>
props
.
user
?.
avatar_url
,
()
=>
props
.
user
?.
avatar_url
,
(
value
)
=>
{
()
=>
{
avatarDraft
.
value
=
value
?.
trim
()
||
''
avatarDraft
.
value
=
''
}
}
)
)
function
validate
Avatar
Input
(
value
:
string
):
string
|
null
{
function
normalizeUploaded
Avatar
(
value
:
string
):
string
|
null
{
const
normalized
=
value
.
trim
()
const
normalized
=
value
.
trim
()
if
(
!
normalized
)
{
if
(
!
normalized
)
{
return
null
return
null
}
}
if
(
normalized
.
startsWith
(
'
data:
'
))
{
if
(
!
/^data:image
\/[
a-zA-Z0-9.+-
]
+;base64,/i
.
test
(
normalized
))
{
if
(
!
/^data:image
\/[
a-zA-Z0-9.+-
]
+;base64,/i
.
test
(
normalized
))
{
appStore
.
showError
(
t
(
'
profile.avatar.uploadRequired
'
))
appStore
.
showError
(
t
(
'
profile.avatar.invalidValue
'
))
return
null
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
normalized
return
null
}
}
function
readFileAsDataURL
(
file
:
File
):
Promise
<
string
>
{
function
readFileAsDataURL
(
file
:
File
):
Promise
<
string
>
{
...
@@ -226,7 +205,7 @@ async function handleAvatarFileChange(event: Event) {
...
@@ -226,7 +205,7 @@ async function handleAvatarFileChange(event: Event) {
try
{
try
{
const
preparedFile
=
await
prepareAvatarUpload
(
file
)
const
preparedFile
=
await
prepareAvatarUpload
(
file
)
const
dataURL
=
await
readFileAsDataURL
(
preparedFile
)
const
dataURL
=
await
readFileAsDataURL
(
preparedFile
)
const
normalized
=
validate
Avatar
Input
(
dataURL
)
const
normalized
=
normalizeUploaded
Avatar
(
dataURL
)
if
(
!
normalized
)
{
if
(
!
normalized
)
{
return
return
}
}
...
@@ -237,7 +216,7 @@ async function handleAvatarFileChange(event: Event) {
...
@@ -237,7 +216,7 @@ async function handleAvatarFileChange(event: Event) {
}
}
async
function
handleAvatarSave
()
{
async
function
handleAvatarSave
()
{
const
normalized
=
validate
Avatar
Input
(
avatarDraft
.
value
)
const
normalized
=
normalizeUploaded
Avatar
(
avatarDraft
.
value
)
if
(
!
normalized
)
{
if
(
!
normalized
)
{
return
return
}
}
...
@@ -277,4 +256,3 @@ async function handleAvatarDelete() {
...
@@ -277,4 +256,3 @@ async function handleAvatarDelete() {
}
}
}
}
</
script
>
</
script
>
frontend/src/components/user/profile/ProfileInfoCard.vue
View file @
17c6348b
...
@@ -50,6 +50,19 @@
...
@@ -50,6 +50,19 @@
</div>
</div>
</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>
</div>
</div>
</div>
</
template
>
</
template
>
...
@@ -58,7 +71,7 @@
...
@@ -58,7 +71,7 @@
import
{
computed
}
from
'
vue
'
import
{
computed
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useI18n
}
from
'
vue-i18n
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
type
{
User
}
from
'
@/types
'
import
type
{
User
,
UserAuthProvider
,
UserProfileSourceContext
}
from
'
@/types
'
const
props
=
defineProps
<
{
const
props
=
defineProps
<
{
user
:
User
|
null
user
:
User
|
null
...
@@ -69,4 +82,108 @@ const { t } = useI18n()
...
@@ -69,4 +82,108 @@ const { t } = useI18n()
const
avatarUrl
=
computed
(()
=>
props
.
user
?.
avatar_url
?.
trim
()
||
''
)
const
avatarUrl
=
computed
(()
=>
props
.
user
?.
avatar_url
?.
trim
()
||
''
)
const
displayName
=
computed
(()
=>
props
.
user
?.
username
?.
trim
()
||
props
.
user
?.
email
?.
trim
()
||
'
User
'
)
const
displayName
=
computed
(()
=>
props
.
user
?.
username
?.
trim
()
||
props
.
user
?.
email
?.
trim
()
||
'
User
'
)
const
avatarInitial
=
computed
(()
=>
displayName
.
value
.
charAt
(
0
).
toUpperCase
()
||
'
U
'
)
const
avatarInitial
=
computed
(()
=>
displayName
.
value
.
charAt
(
0
).
toUpperCase
()
||
'
U
'
)
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
:
'
OIDC
'
}),
wechat
:
t
(
'
profile.authBindings.providers.wechat
'
)
}))
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
})
</
script
>
</
script
>
frontend/src/components/user/profile/__tests__/ProfileAvatarCard.spec.ts
View file @
17c6348b
...
@@ -88,6 +88,8 @@ function createUser(overrides: Partial<User> = {}): User {
...
@@ -88,6 +88,8 @@ function createUser(overrides: Partial<User> = {}): User {
async
function
flushAsyncWork
():
Promise
<
void
>
{
async
function
flushAsyncWork
():
Promise
<
void
>
{
await
Promise
.
resolve
()
await
Promise
.
resolve
()
await
Promise
.
resolve
()
await
Promise
.
resolve
()
await
Promise
.
resolve
()
await
Promise
.
resolve
()
}
}
const
originalFileReader
=
globalThis
.
FileReader
const
originalFileReader
=
globalThis
.
FileReader
...
@@ -156,6 +158,23 @@ describe('ProfileAvatarCard', () => {
...
@@ -156,6 +158,23 @@ describe('ProfileAvatarCard', () => {
vi
.
restoreAllMocks
()
vi
.
restoreAllMocks
()
})
})
it
(
'
does not render a manual avatar input field
'
,
()
=>
{
authStoreState
.
user
=
createUser
()
const
wrapper
=
mount
(
ProfileAvatarCard
,
{
props
:
{
user
:
authStoreState
.
user
},
global
:
{
stubs
:
{
Icon
:
true
}
}
})
expect
(
wrapper
.
find
(
'
[data-testid="profile-avatar-input"]
'
).
exists
()).
toBe
(
false
)
})
it
(
'
compresses an uploaded image that exceeds the 20KB target before saving
'
,
async
()
=>
{
it
(
'
compresses an uploaded image that exceeds the 20KB target before saving
'
,
async
()
=>
{
installAvatarCompressionMocks
()
installAvatarCompressionMocks
()
const
updatedUser
=
createUser
({
avatar_url
:
'
data:image/webp;base64,Y29tcHJlc3NlZC1hdmF0YXI=
'
})
const
updatedUser
=
createUser
({
avatar_url
:
'
data:image/webp;base64,Y29tcHJlc3NlZC1hdmF0YXI=
'
})
...
...
frontend/src/components/user/profile/__tests__/ProfileInfoCard.spec.ts
View file @
17c6348b
...
@@ -21,9 +21,19 @@ vi.mock('vue-i18n', async (importOriginal) => {
...
@@ -21,9 +21,19 @@ vi.mock('vue-i18n', async (importOriginal) => {
return
{
return
{
...
actual
,
...
actual
,
useI18n
:
()
=>
({
useI18n
:
()
=>
({
t
:
(
key
:
string
)
=>
{
t
:
(
key
:
string
,
params
?:
Record
<
string
,
string
>
)
=>
{
if
(
key
===
'
profile.administrator
'
)
return
'
Administrator
'
if
(
key
===
'
profile.administrator
'
)
return
'
Administrator
'
if
(
key
===
'
profile.user
'
)
return
'
User
'
if
(
key
===
'
profile.user
'
)
return
'
User
'
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
'
}
`
}
return
key
return
key
}
}
})
})
...
@@ -69,4 +79,26 @@ describe('ProfileInfoCard', () => {
...
@@ -69,4 +79,26 @@ describe('ProfileInfoCard', () => {
expect
(
wrapper
.
find
(
'
[data-testid="profile-avatar-save"]
'
).
exists
()).
toBe
(
false
)
expect
(
wrapper
.
find
(
'
[data-testid="profile-avatar-save"]
'
).
exists
()).
toBe
(
false
)
expect
(
wrapper
.
find
(
'
[data-testid="profile-binding-email-status"]
'
).
exists
()).
toBe
(
false
)
expect
(
wrapper
.
find
(
'
[data-testid="profile-binding-email-status"]
'
).
exists
()).
toBe
(
false
)
})
})
it
(
'
renders third-party source hints from profile sources
'
,
()
=>
{
const
wrapper
=
mount
(
ProfileInfoCard
,
{
props
:
{
user
:
createUser
({
avatar_url
:
'
https://cdn.example.com/linuxdo.png
'
,
profile_sources
:
{
avatar
:
{
provider
:
'
linuxdo
'
,
source
:
'
linuxdo
'
},
username
:
{
provider
:
'
linuxdo
'
,
source
:
'
linuxdo
'
}
}
})
},
global
:
{
stubs
:
{
Icon
:
true
}
}
})
expect
(
wrapper
.
text
()).
toContain
(
'
Avatar synced from LinuxDo
'
)
expect
(
wrapper
.
text
()).
toContain
(
'
Username synced from LinuxDo
'
)
})
})
})
frontend/src/i18n/locales/en.ts
View file @
17c6348b
...
@@ -943,11 +943,10 @@ export default {
...
@@ -943,11 +943,10 @@ export default {
},
},
avatar
:
{
avatar
:
{
title
:
'
Profile Avatar
'
,
title
:
'
Profile Avatar
'
,
description
:
'
Set your avatar with a remote image URL or upload an image. Static uploads are compressed to 20KB before saving.
'
,
description
:
'
Upload an avatar image. Static uploads are compressed to 20KB before saving.
'
,
inputLabel
:
'
Avatar URL or data URL
'
,
inputPlaceholder
:
'
https://cdn.example.com/avatar.png
'
,
uploadAction
:
'
Upload image
'
,
uploadAction
:
'
Upload image
'
,
uploadHint
:
'
Static uploads are compressed to 20KB when possible. GIF uploads must already be within 20KB.
'
,
uploadHint
:
'
Static uploads are compressed to 20KB when possible. GIF uploads must already be within 20KB.
'
,
uploadRequired
:
'
Upload an avatar image first
'
,
saveSuccess
:
'
Avatar updated
'
,
saveSuccess
:
'
Avatar updated
'
,
deleteSuccess
:
'
Avatar removed
'
,
deleteSuccess
:
'
Avatar removed
'
,
invalidType
:
'
Please choose an image file
'
,
invalidType
:
'
Please choose an image file
'
,
...
@@ -955,7 +954,6 @@ export default {
...
@@ -955,7 +954,6 @@ export default {
compressTooLarge
:
'
Unable to compress this image below 20KB. Try a smaller image.
'
,
compressTooLarge
:
'
Unable to compress this image below 20KB. Try a smaller image.
'
,
compressFailed
:
'
Failed to compress the selected image.
'
,
compressFailed
:
'
Failed to compress the selected image.
'
,
readFailed
:
'
Failed to read the selected image.
'
,
readFailed
:
'
Failed to read the selected image.
'
,
invalidValue
:
'
Enter a valid avatar URL or image data URL
'
,
emptyDeleteHint
:
'
Avatar is already empty
'
,
emptyDeleteHint
:
'
Avatar is already empty
'
,
},
},
authBindings
:
{
authBindings
:
{
...
...
frontend/src/i18n/locales/zh.ts
View file @
17c6348b
...
@@ -947,11 +947,10 @@ export default {
...
@@ -947,11 +947,10 @@ export default {
},
},
avatar
:
{
avatar
:
{
title
:
'
资料头像
'
,
title
:
'
资料头像
'
,
description
:
'
支持填写远程图片 URL,或上传头像图片;静态图片会自动压缩到 20KB 以内后再保存。
'
,
description
:
'
仅支持上传头像图片;静态图片会自动压缩到 20KB 以内后再保存。
'
,
inputLabel
:
'
头像 URL 或 data URL
'
,
inputPlaceholder
:
'
https://cdn.example.com/avatar.png
'
,
uploadAction
:
'
上传图片
'
,
uploadAction
:
'
上传图片
'
,
uploadHint
:
'
上传图片时会自动压缩静态图片到 20KB 以内,GIF 需自行控制在 20KB 以内
'
,
uploadHint
:
'
上传图片时会自动压缩静态图片到 20KB 以内,GIF 需自行控制在 20KB 以内
'
,
uploadRequired
:
'
请先上传头像图片
'
,
saveSuccess
:
'
头像已更新
'
,
saveSuccess
:
'
头像已更新
'
,
deleteSuccess
:
'
头像已删除
'
,
deleteSuccess
:
'
头像已删除
'
,
invalidType
:
'
请选择图片文件
'
,
invalidType
:
'
请选择图片文件
'
,
...
@@ -959,7 +958,6 @@ export default {
...
@@ -959,7 +958,6 @@ export default {
compressTooLarge
:
'
无法将图片压缩到 20KB 以内,请换一张更小的图片
'
,
compressTooLarge
:
'
无法将图片压缩到 20KB 以内,请换一张更小的图片
'
,
compressFailed
:
'
压缩所选图片失败
'
,
compressFailed
:
'
压缩所选图片失败
'
,
readFailed
:
'
读取所选图片失败
'
,
readFailed
:
'
读取所选图片失败
'
,
invalidValue
:
'
请输入有效的头像 URL 或图片 data URL
'
,
emptyDeleteHint
:
'
当前没有可删除的头像
'
,
emptyDeleteHint
:
'
当前没有可删除的头像
'
,
},
},
authBindings
:
{
authBindings
:
{
...
...
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