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 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
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
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