feat: 统一用户头像生成
This commit is contained in:
parent
1e922acb43
commit
c6d0c57bf9
@ -106,8 +106,7 @@ import { message } from 'ant-design-vue'
|
|||||||
import { CircleUser, RefreshCw, Upload } from 'lucide-vue-next'
|
import { CircleUser, RefreshCw, Upload } from 'lucide-vue-next'
|
||||||
import ApiKeyManagementComponent from '@/components/ApiKeyManagementComponent.vue'
|
import ApiKeyManagementComponent from '@/components/ApiKeyManagementComponent.vue'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
|
import { generatePixelAvatar } from '@/utils/pixelAvatar'
|
||||||
const DEFAULT_AVATAR_URL = 'https://xerrors.oss-cn-shanghai.aliyuncs.com/github/default.jpeg'
|
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const avatarUploading = ref(false)
|
const avatarUploading = ref(false)
|
||||||
@ -117,7 +116,6 @@ const editingField = ref('')
|
|||||||
const usernameInput = ref(null)
|
const usernameInput = ref(null)
|
||||||
const phoneInput = ref(null)
|
const phoneInput = ref(null)
|
||||||
const avatarLoadFailed = ref(false)
|
const avatarLoadFailed = ref(false)
|
||||||
const defaultAvatarLoadFailed = ref(false)
|
|
||||||
const profileDraft = reactive({
|
const profileDraft = reactive({
|
||||||
username: '',
|
username: '',
|
||||||
phone_number: ''
|
phone_number: ''
|
||||||
@ -125,8 +123,7 @@ const profileDraft = reactive({
|
|||||||
|
|
||||||
const avatarSrc = computed(() => {
|
const avatarSrc = computed(() => {
|
||||||
if (userStore.avatar && !avatarLoadFailed.value) return userStore.avatar
|
if (userStore.avatar && !avatarLoadFailed.value) return userStore.avatar
|
||||||
if (!defaultAvatarLoadFailed.value) return DEFAULT_AVATAR_URL
|
return generatePixelAvatar(userStore.uid)
|
||||||
return ''
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const userRoleText = computed(() => {
|
const userRoleText = computed(() => {
|
||||||
@ -150,9 +147,7 @@ const syncProfileDraft = () => {
|
|||||||
const handleAvatarError = () => {
|
const handleAvatarError = () => {
|
||||||
if (userStore.avatar && !avatarLoadFailed.value) {
|
if (userStore.avatar && !avatarLoadFailed.value) {
|
||||||
avatarLoadFailed.value = true
|
avatarLoadFailed.value = true
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
defaultAvatarLoadFailed.value = true
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -276,7 +271,6 @@ const handleAvatarChange = async (info) => {
|
|||||||
avatarUploading.value = true
|
avatarUploading.value = true
|
||||||
await userStore.uploadAvatar(info.file.originFileObj || info.file)
|
await userStore.uploadAvatar(info.file.originFileObj || info.file)
|
||||||
avatarLoadFailed.value = false
|
avatarLoadFailed.value = false
|
||||||
defaultAvatarLoadFailed.value = false
|
|
||||||
message.success('头像上传成功!')
|
message.success('头像上传成功!')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('头像上传失败:', error)
|
console.error('头像上传失败:', error)
|
||||||
@ -290,7 +284,6 @@ watch(
|
|||||||
() => userStore.avatar,
|
() => userStore.avatar,
|
||||||
() => {
|
() => {
|
||||||
avatarLoadFailed.value = false
|
avatarLoadFailed.value = false
|
||||||
defaultAvatarLoadFailed.value = false
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -74,14 +74,13 @@ import DebugComponent from '@/components/DebugComponent.vue'
|
|||||||
import { message } from 'ant-design-vue'
|
import { message } from 'ant-design-vue'
|
||||||
import { CircleUser, BookOpen, Sun, Moon, LogOut, Settings, Terminal } from 'lucide-vue-next'
|
import { CircleUser, BookOpen, Sun, Moon, LogOut, Settings, Terminal } from 'lucide-vue-next'
|
||||||
import { useThemeStore } from '@/stores/theme'
|
import { useThemeStore } from '@/stores/theme'
|
||||||
|
import { generatePixelAvatar } from '@/utils/pixelAvatar'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const themeStore = useThemeStore()
|
const themeStore = useThemeStore()
|
||||||
const slots = useSlots()
|
const slots = useSlots()
|
||||||
|
|
||||||
const DEFAULT_AVATAR_URL = 'https://xerrors.oss-cn-shanghai.aliyuncs.com/github/default.jpeg'
|
|
||||||
|
|
||||||
// 调试面板状态
|
// 调试面板状态
|
||||||
const showDebug = ref(false)
|
const showDebug = ref(false)
|
||||||
|
|
||||||
@ -89,28 +88,23 @@ const showDebug = ref(false)
|
|||||||
const { openSettingsModal } = inject('settingsModal', {})
|
const { openSettingsModal } = inject('settingsModal', {})
|
||||||
|
|
||||||
const avatarLoadFailed = ref(false)
|
const avatarLoadFailed = ref(false)
|
||||||
const defaultAvatarLoadFailed = ref(false)
|
|
||||||
|
|
||||||
// 用户头像不可用时回退到公共默认头像;默认头像异常时再显示图标占位。
|
// 用户头像不可用时回退到基于 UID 生成的本地像素头像。
|
||||||
const avatarSrc = computed(() => {
|
const avatarSrc = computed(() => {
|
||||||
if (userStore.avatar && !avatarLoadFailed.value) return userStore.avatar
|
if (userStore.avatar && !avatarLoadFailed.value) return userStore.avatar
|
||||||
if (!defaultAvatarLoadFailed.value) return DEFAULT_AVATAR_URL
|
return generatePixelAvatar(userStore.uid)
|
||||||
return ''
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleAvatarError = () => {
|
const handleAvatarError = () => {
|
||||||
if (userStore.avatar && !avatarLoadFailed.value) {
|
if (userStore.avatar && !avatarLoadFailed.value) {
|
||||||
avatarLoadFailed.value = true
|
avatarLoadFailed.value = true
|
||||||
return
|
|
||||||
}
|
}
|
||||||
defaultAvatarLoadFailed.value = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => userStore.avatar,
|
() => userStore.avatar,
|
||||||
() => {
|
() => {
|
||||||
avatarLoadFailed.value = false
|
avatarLoadFailed.value = false
|
||||||
defaultAvatarLoadFailed.value = false
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -73,15 +73,7 @@
|
|||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<div class="user-info-main">
|
<div class="user-info-main">
|
||||||
<div class="user-avatar">
|
<div class="user-avatar">
|
||||||
<img
|
<img :src="getUserAvatarSrc(user)" :alt="user.username" class="avatar-img" />
|
||||||
v-if="user.avatar"
|
|
||||||
:src="user.avatar"
|
|
||||||
:alt="user.username"
|
|
||||||
class="avatar-img"
|
|
||||||
/>
|
|
||||||
<div v-else class="avatar-placeholder">
|
|
||||||
{{ user.username.charAt(0).toUpperCase() }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="user-info-content">
|
<div class="user-info-content">
|
||||||
<div class="name-tag-row">
|
<div class="name-tag-row">
|
||||||
@ -279,6 +271,7 @@ import {
|
|||||||
MoreVertical
|
MoreVertical
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { formatDateTime } from '@/utils/time'
|
import { formatDateTime } from '@/utils/time'
|
||||||
|
import { generatePixelAvatar } from '@/utils/pixelAvatar'
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
@ -437,6 +430,8 @@ watch(
|
|||||||
// 格式化时间显示
|
// 格式化时间显示
|
||||||
const formatTime = (timeStr) => formatDateTime(timeStr)
|
const formatTime = (timeStr) => formatDateTime(timeStr)
|
||||||
|
|
||||||
|
const getUserAvatarSrc = (user) => user.avatar || generatePixelAvatar(user.uid)
|
||||||
|
|
||||||
const isUserDeleteDisabled = (user) =>
|
const isUserDeleteDisabled = (user) =>
|
||||||
user.id === userStore.userId ||
|
user.id === userStore.userId ||
|
||||||
(user.role === 'superadmin' && userStore.userRole !== 'superadmin')
|
(user.role === 'superadmin' && userStore.userRole !== 'superadmin')
|
||||||
@ -832,11 +827,6 @@ onMounted(async () => {
|
|||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar-placeholder {
|
|
||||||
color: var(--gray-600);
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-info-content {
|
.user-info-content {
|
||||||
|
|||||||
@ -19,7 +19,7 @@
|
|||||||
<!-- 卡片头部:用户信息和反馈类型 -->
|
<!-- 卡片头部:用户信息和反馈类型 -->
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<div class="user-info">
|
<div class="user-info">
|
||||||
<a-avatar :src="feedback.avatar" :size="32" class="user-avatar">
|
<a-avatar :src="getFeedbackAvatarSrc(feedback)" :size="32" class="user-avatar">
|
||||||
{{ feedback.username ? feedback.username.charAt(0).toUpperCase() : 'U' }}
|
{{ feedback.username ? feedback.username.charAt(0).toUpperCase() : 'U' }}
|
||||||
</a-avatar>
|
</a-avatar>
|
||||||
<div class="user-details">
|
<div class="user-details">
|
||||||
@ -116,6 +116,7 @@ import { message } from 'ant-design-vue'
|
|||||||
import { LikeOutlined, DislikeOutlined, ClockCircleOutlined } from '@ant-design/icons-vue'
|
import { LikeOutlined, DislikeOutlined, ClockCircleOutlined } from '@ant-design/icons-vue'
|
||||||
import { dashboardApi } from '@/apis/dashboard_api'
|
import { dashboardApi } from '@/apis/dashboard_api'
|
||||||
import { formatFullDateTime } from '@/utils/time'
|
import { formatFullDateTime } from '@/utils/time'
|
||||||
|
import { generatePixelAvatar } from '@/utils/pixelAvatar'
|
||||||
|
|
||||||
// 常量配置
|
// 常量配置
|
||||||
const CONFIG = {
|
const CONFIG = {
|
||||||
@ -211,6 +212,8 @@ const loadFeedbacks = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getFeedbackAvatarSrc = (feedback) => feedback.avatar || generatePixelAvatar(feedback.uid)
|
||||||
|
|
||||||
// 格式化完整日期
|
// 格式化完整日期
|
||||||
const formatFullDate = (dateString) => formatFullDateTime(dateString)
|
const formatFullDate = (dateString) => formatFullDateTime(dateString)
|
||||||
|
|
||||||
|
|||||||
43
web/src/utils/__tests__/pixelAvatar.test.js
Normal file
43
web/src/utils/__tests__/pixelAvatar.test.js
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { generatePixelAvatar } from '../pixelAvatar.js'
|
||||||
|
|
||||||
|
const decodeAvatarSvg = (avatar) => decodeURIComponent(avatar.replace('data:image/svg+xml,', ''))
|
||||||
|
|
||||||
|
const run = () => {
|
||||||
|
{
|
||||||
|
const first = generatePixelAvatar('user-001')
|
||||||
|
const second = generatePixelAvatar('user-001')
|
||||||
|
assert.equal(first, second, 'Same ID should generate the same avatar')
|
||||||
|
console.log('T1 Stable output: PASS')
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const first = generatePixelAvatar('user-001')
|
||||||
|
const second = generatePixelAvatar('user-002')
|
||||||
|
assert.notEqual(first, second, 'Different IDs should generate different avatars')
|
||||||
|
console.log('T2 Different IDs: PASS')
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const avatar = generatePixelAvatar('user-003')
|
||||||
|
assert.ok(avatar.startsWith('data:image/svg+xml,'), 'Should return an SVG data URL')
|
||||||
|
console.log('T3 Data URL prefix: PASS')
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const svg = decodeAvatarSvg(generatePixelAvatar('user-004'))
|
||||||
|
assert.ok(svg.includes('<svg'), 'Decoded output should contain an SVG tag')
|
||||||
|
assert.ok(svg.includes('<rect'), 'Decoded output should contain pixel rects')
|
||||||
|
console.log('T4 Decodable SVG: PASS')
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
assert.throws(() => generatePixelAvatar(''), /requires an id/, 'Empty ID should be treated as invalid data')
|
||||||
|
assert.throws(() => generatePixelAvatar(null), /requires an id/, 'Null ID should be treated as invalid data')
|
||||||
|
console.log('T5 Missing ID fails: PASS')
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nAll 5 pixel avatar tests passed!')
|
||||||
|
}
|
||||||
|
|
||||||
|
run()
|
||||||
74
web/src/utils/pixelAvatar.js
Normal file
74
web/src/utils/pixelAvatar.js
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
const DEFAULT_SIZE = 64
|
||||||
|
const GRID_SIZE = 5
|
||||||
|
const MIRROR_COLUMNS = 3
|
||||||
|
const DEFAULT_BACKGROUND = '#f3f4f6'
|
||||||
|
|
||||||
|
const COLOR_PALETTE = [
|
||||||
|
'#265c96',
|
||||||
|
'#3996ae',
|
||||||
|
'#13c2c2',
|
||||||
|
'#52c41a',
|
||||||
|
'#faad14',
|
||||||
|
'#ff7a45',
|
||||||
|
'#ff4d4f',
|
||||||
|
'#9254de',
|
||||||
|
'#597ef7',
|
||||||
|
'#4f4f4f'
|
||||||
|
]
|
||||||
|
|
||||||
|
const normalizeSeed = (id) => {
|
||||||
|
if (id === null || id === undefined || String(id).trim() === '') {
|
||||||
|
throw new Error('generatePixelAvatar requires an id')
|
||||||
|
}
|
||||||
|
return String(id).trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashSeed = (seed) => {
|
||||||
|
let hash = 2166136261
|
||||||
|
|
||||||
|
for (let i = 0; i < seed.length; i += 1) {
|
||||||
|
hash ^= seed.charCodeAt(i)
|
||||||
|
hash = Math.imul(hash, 16777619)
|
||||||
|
}
|
||||||
|
|
||||||
|
return hash >>> 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildCells = (hash) => {
|
||||||
|
const cells = []
|
||||||
|
|
||||||
|
for (let row = 0; row < GRID_SIZE; row += 1) {
|
||||||
|
for (let col = 0; col < MIRROR_COLUMNS; col += 1) {
|
||||||
|
const bitIndex = row * MIRROR_COLUMNS + col
|
||||||
|
const filled = ((hash >>> bitIndex) & 1) === 1
|
||||||
|
|
||||||
|
if (!filled) continue
|
||||||
|
|
||||||
|
cells.push([col, row])
|
||||||
|
|
||||||
|
const mirroredCol = GRID_SIZE - col - 1
|
||||||
|
if (mirroredCol !== col) {
|
||||||
|
cells.push([mirroredCol, row])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cells.length === 0) {
|
||||||
|
cells.push([2, 2])
|
||||||
|
}
|
||||||
|
|
||||||
|
return cells
|
||||||
|
}
|
||||||
|
|
||||||
|
export const generatePixelAvatar = (id) => {
|
||||||
|
const seed = normalizeSeed(id)
|
||||||
|
const hash = hashSeed(seed)
|
||||||
|
const color = COLOR_PALETTE[(hash >>> 16) % COLOR_PALETTE.length]
|
||||||
|
const cells = buildCells(hash)
|
||||||
|
.map(([x, y]) => `<rect x="${x}" y="${y}" width="1" height="1"/>`)
|
||||||
|
.join('')
|
||||||
|
|
||||||
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${DEFAULT_SIZE}" height="${DEFAULT_SIZE}" viewBox="0 0 ${GRID_SIZE} ${GRID_SIZE}" shape-rendering="crispEdges"><rect width="${GRID_SIZE}" height="${GRID_SIZE}" fill="${DEFAULT_BACKGROUND}"/><g fill="${color}">${cells}</g></svg>`
|
||||||
|
|
||||||
|
return `data:image/svg+xml,${encodeURIComponent(svg)}`
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user