feat: 统一用户头像生成

This commit is contained in:
Wenjie Zhang 2026-06-01 22:29:43 +08:00
parent 1e922acb43
commit c6d0c57bf9
6 changed files with 130 additions and 33 deletions

View File

@ -106,8 +106,7 @@ import { message } from 'ant-design-vue'
import { CircleUser, RefreshCw, Upload } from 'lucide-vue-next'
import ApiKeyManagementComponent from '@/components/ApiKeyManagementComponent.vue'
import { useUserStore } from '@/stores/user'
const DEFAULT_AVATAR_URL = 'https://xerrors.oss-cn-shanghai.aliyuncs.com/github/default.jpeg'
import { generatePixelAvatar } from '@/utils/pixelAvatar'
const userStore = useUserStore()
const avatarUploading = ref(false)
@ -117,7 +116,6 @@ const editingField = ref('')
const usernameInput = ref(null)
const phoneInput = ref(null)
const avatarLoadFailed = ref(false)
const defaultAvatarLoadFailed = ref(false)
const profileDraft = reactive({
username: '',
phone_number: ''
@ -125,8 +123,7 @@ const profileDraft = reactive({
const avatarSrc = computed(() => {
if (userStore.avatar && !avatarLoadFailed.value) return userStore.avatar
if (!defaultAvatarLoadFailed.value) return DEFAULT_AVATAR_URL
return ''
return generatePixelAvatar(userStore.uid)
})
const userRoleText = computed(() => {
@ -150,9 +147,7 @@ const syncProfileDraft = () => {
const handleAvatarError = () => {
if (userStore.avatar && !avatarLoadFailed.value) {
avatarLoadFailed.value = true
return false
}
defaultAvatarLoadFailed.value = true
return false
}
@ -276,7 +271,6 @@ const handleAvatarChange = async (info) => {
avatarUploading.value = true
await userStore.uploadAvatar(info.file.originFileObj || info.file)
avatarLoadFailed.value = false
defaultAvatarLoadFailed.value = false
message.success('头像上传成功!')
} catch (error) {
console.error('头像上传失败:', error)
@ -290,7 +284,6 @@ watch(
() => userStore.avatar,
() => {
avatarLoadFailed.value = false
defaultAvatarLoadFailed.value = false
}
)

View File

@ -74,14 +74,13 @@ import DebugComponent from '@/components/DebugComponent.vue'
import { message } from 'ant-design-vue'
import { CircleUser, BookOpen, Sun, Moon, LogOut, Settings, Terminal } from 'lucide-vue-next'
import { useThemeStore } from '@/stores/theme'
import { generatePixelAvatar } from '@/utils/pixelAvatar'
const router = useRouter()
const userStore = useUserStore()
const themeStore = useThemeStore()
const slots = useSlots()
const DEFAULT_AVATAR_URL = 'https://xerrors.oss-cn-shanghai.aliyuncs.com/github/default.jpeg'
//
const showDebug = ref(false)
@ -89,28 +88,23 @@ const showDebug = ref(false)
const { openSettingsModal } = inject('settingsModal', {})
const avatarLoadFailed = ref(false)
const defaultAvatarLoadFailed = ref(false)
// 退
// 退 UID
const avatarSrc = computed(() => {
if (userStore.avatar && !avatarLoadFailed.value) return userStore.avatar
if (!defaultAvatarLoadFailed.value) return DEFAULT_AVATAR_URL
return ''
return generatePixelAvatar(userStore.uid)
})
const handleAvatarError = () => {
if (userStore.avatar && !avatarLoadFailed.value) {
avatarLoadFailed.value = true
return
}
defaultAvatarLoadFailed.value = true
}
watch(
() => userStore.avatar,
() => {
avatarLoadFailed.value = false
defaultAvatarLoadFailed.value = false
}
)

View File

@ -73,15 +73,7 @@
<div class="card-header">
<div class="user-info-main">
<div class="user-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>
<img :src="getUserAvatarSrc(user)" :alt="user.username" class="avatar-img" />
</div>
<div class="user-info-content">
<div class="name-tag-row">
@ -279,6 +271,7 @@ import {
MoreVertical
} from 'lucide-vue-next'
import { formatDateTime } from '@/utils/time'
import { generatePixelAvatar } from '@/utils/pixelAvatar'
const userStore = useUserStore()
@ -437,6 +430,8 @@ watch(
//
const formatTime = (timeStr) => formatDateTime(timeStr)
const getUserAvatarSrc = (user) => user.avatar || generatePixelAvatar(user.uid)
const isUserDeleteDisabled = (user) =>
user.id === userStore.userId ||
(user.role === 'superadmin' && userStore.userRole !== 'superadmin')
@ -832,11 +827,6 @@ onMounted(async () => {
object-fit: cover;
}
.avatar-placeholder {
color: var(--gray-600);
font-weight: 500;
font-size: 14px;
}
}
.user-info-content {

View File

@ -19,7 +19,7 @@
<!-- 卡片头部用户信息和反馈类型 -->
<div class="card-header">
<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' }}
</a-avatar>
<div class="user-details">
@ -116,6 +116,7 @@ import { message } from 'ant-design-vue'
import { LikeOutlined, DislikeOutlined, ClockCircleOutlined } from '@ant-design/icons-vue'
import { dashboardApi } from '@/apis/dashboard_api'
import { formatFullDateTime } from '@/utils/time'
import { generatePixelAvatar } from '@/utils/pixelAvatar'
//
const CONFIG = {
@ -211,6 +212,8 @@ const loadFeedbacks = async () => {
}
}
const getFeedbackAvatarSrc = (feedback) => feedback.avatar || generatePixelAvatar(feedback.uid)
//
const formatFullDate = (dateString) => formatFullDateTime(dateString)

View 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()

View 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)}`
}