diff --git a/frontend/src/components/user/profile/ProfileAvatarCard.vue b/frontend/src/components/user/profile/ProfileAvatarCard.vue
index 357c0f27..9945f7b7 100644
--- a/frontend/src/components/user/profile/ProfileAvatarCard.vue
+++ b/frontend/src/components/user/profile/ProfileAvatarCard.vue
@@ -32,14 +32,6 @@
-
-
+
+
+
+ {{ hint.text }}
+
+
@@ -58,7 +71,7 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Icon from '@/components/icons/Icon.vue'
-import type { User } from '@/types'
+import type { User, UserAuthProvider, UserProfileSourceContext } from '@/types'
const props = defineProps<{
user: User | null
@@ -69,4 +82,108 @@ const { t } = useI18n()
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')
+
+const providerLabels = computed>(() => ({
+ 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, ...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
+ 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
+})
diff --git a/frontend/src/components/user/profile/__tests__/ProfileAvatarCard.spec.ts b/frontend/src/components/user/profile/__tests__/ProfileAvatarCard.spec.ts
index e5f57d69..cba24960 100644
--- a/frontend/src/components/user/profile/__tests__/ProfileAvatarCard.spec.ts
+++ b/frontend/src/components/user/profile/__tests__/ProfileAvatarCard.spec.ts
@@ -88,6 +88,8 @@ function createUser(overrides: Partial = {}): User {
async function flushAsyncWork(): Promise {
await Promise.resolve()
await Promise.resolve()
+ await Promise.resolve()
+ await Promise.resolve()
}
const originalFileReader = globalThis.FileReader
@@ -156,6 +158,23 @@ describe('ProfileAvatarCard', () => {
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 () => {
installAvatarCompressionMocks()
const updatedUser = createUser({ avatar_url: 'data:image/webp;base64,Y29tcHJlc3NlZC1hdmF0YXI=' })
diff --git a/frontend/src/components/user/profile/__tests__/ProfileInfoCard.spec.ts b/frontend/src/components/user/profile/__tests__/ProfileInfoCard.spec.ts
index 0ee9aebb..87b070a7 100644
--- a/frontend/src/components/user/profile/__tests__/ProfileInfoCard.spec.ts
+++ b/frontend/src/components/user/profile/__tests__/ProfileInfoCard.spec.ts
@@ -21,9 +21,19 @@ vi.mock('vue-i18n', async (importOriginal) => {
return {
...actual,
useI18n: () => ({
- t: (key: string) => {
+ t: (key: string, params?: Record) => {
if (key === 'profile.administrator') return 'Administrator'
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
}
})
@@ -69,4 +79,26 @@ describe('ProfileInfoCard', () => {
expect(wrapper.find('[data-testid="profile-avatar-save"]').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')
+ })
})
diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts
index 7b37009f..b01f8f67 100644
--- a/frontend/src/i18n/locales/en.ts
+++ b/frontend/src/i18n/locales/en.ts
@@ -943,11 +943,10 @@ export default {
},
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.',
- inputLabel: 'Avatar URL or data URL',
- inputPlaceholder: 'https://cdn.example.com/avatar.png',
+ description: 'Upload an avatar image. Static uploads are compressed to 20KB before saving.',
uploadAction: 'Upload image',
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',
deleteSuccess: 'Avatar removed',
invalidType: 'Please choose an image file',
@@ -955,7 +954,6 @@ export default {
compressTooLarge: 'Unable to compress this image below 20KB. Try a smaller image.',
compressFailed: 'Failed to compress 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',
},
authBindings: {
diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts
index 97de9c9c..921fc06e 100644
--- a/frontend/src/i18n/locales/zh.ts
+++ b/frontend/src/i18n/locales/zh.ts
@@ -947,11 +947,10 @@ export default {
},
avatar: {
title: '资料头像',
- description: '支持填写远程图片 URL,或上传头像图片;静态图片会自动压缩到 20KB 以内后再保存。',
- inputLabel: '头像 URL 或 data URL',
- inputPlaceholder: 'https://cdn.example.com/avatar.png',
+ description: '仅支持上传头像图片;静态图片会自动压缩到 20KB 以内后再保存。',
uploadAction: '上传图片',
uploadHint: '上传图片时会自动压缩静态图片到 20KB 以内,GIF 需自行控制在 20KB 以内',
+ uploadRequired: '请先上传头像图片',
saveSuccess: '头像已更新',
deleteSuccess: '头像已删除',
invalidType: '请选择图片文件',
@@ -959,7 +958,6 @@ export default {
compressTooLarge: '无法将图片压缩到 20KB 以内,请换一张更小的图片',
compressFailed: '压缩所选图片失败',
readFailed: '读取所选图片失败',
- invalidValue: '请输入有效的头像 URL 或图片 data URL',
emptyDeleteHint: '当前没有可删除的头像',
},
authBindings: {