feat: 优化头像加载兜底展示

This commit is contained in:
Wenjie Zhang 2026-06-09 22:53:14 +08:00
parent 2474fb0a6c
commit 84dc55ee74
13 changed files with 355 additions and 116 deletions

View File

@ -503,6 +503,12 @@ open http://localhost:5173</code></pre>
.yx-home {
--yx-max: 1152px;
--yx-gap: 24px;
--yx-brand: var(--main-700);
--yx-brand-hover: var(--main-bright);
--yx-brand-soft: rgba(4, 106, 130, .12);
--yx-lime: #c6f43a;
--yx-lime-strong: #6f9300;
--yx-lime-soft: rgba(198, 244, 58, .24);
color: var(--vp-c-text-1);
font-size: 16px;
line-height: 1.6;
@ -513,7 +519,7 @@ open http://localhost:5173</code></pre>
padding: 0 24px;
}
.yx-center { text-align: center; }
.yx-accent { color: var(--vp-c-brand-1); }
.yx-accent { color: var(--yx-brand); }
/* 段落节奏 */
.yx-section { padding: 96px 0; }
@ -527,14 +533,14 @@ open http://localhost:5173</code></pre>
font-weight: 600;
letter-spacing: .08em;
text-transform: uppercase;
color: var(--vp-c-brand-1);
color: var(--yx-brand);
margin-bottom: 12px;
}
.yx-head__title { font-size: 34px; font-weight: 700; line-height: 1.25; margin: 0; letter-spacing: -.01em; }
.yx-head__sub { margin: 16px auto 0; color: var(--vp-c-text-2); font-size: 17px; }
.yx-head__sub code, .yx-quick__tip code {
font-size: .85em; padding: 2px 6px; border-radius: 6px;
background: var(--vp-c-bg-alt); color: var(--vp-c-brand-1);
background: var(--vp-c-bg-alt); color: var(--yx-brand);
}
/* ===== Hero ===== */
@ -546,17 +552,17 @@ open http://localhost:5173</code></pre>
.yx-orb { position: absolute; border-radius: 50%; filter: blur(70px); will-change: transform; }
.yx-orb--1 {
width: 460px; height: 460px; top: -180px; right: -80px;
background: var(--vp-c-brand-soft); opacity: .6;
background: var(--yx-brand-soft); opacity: .6;
animation: yxOrbFloat 18s ease-in-out infinite;
}
.yx-orb--2 {
width: 380px; height: 380px; top: -120px; left: -120px;
background: var(--vp-c-brand-soft); opacity: .4;
background: var(--yx-lime-soft); opacity: .5;
animation: yxOrbFloat 22s ease-in-out infinite reverse;
}
.yx-orb--3 {
width: 320px; height: 320px; top: 30px; left: 46%;
background: var(--vp-c-brand-soft); opacity: .5;
background: var(--yx-brand-soft); opacity: .5;
animation: yxOrbFloat 26s ease-in-out infinite;
}
.yx-grid {
@ -592,12 +598,12 @@ open http://localhost:5173</code></pre>
transition: transform .2s ease, background-color .2s ease, border-color .2s ease, color .2s ease;
border: 1px solid transparent; text-decoration: none;
}
.yx-btn--primary { background: var(--vp-c-brand-1); color: #fff; }
.yx-btn--primary:hover { background: var(--vp-c-brand-2); transform: translateY(-1px); }
.yx-btn--primary { background: var(--yx-brand); color: #fff; }
.yx-btn--primary:hover { background: var(--yx-brand-hover); transform: translateY(-1px); }
.yx-btn--ghost { border-color: var(--vp-c-divider); color: var(--vp-c-text-1); background: var(--vp-c-bg); }
.yx-btn--ghost:hover { border-color: var(--vp-c-brand-1); color: var(--vp-c-brand-1); }
.yx-btn--ghost:hover { border-color: var(--yx-brand); color: var(--yx-brand); }
.yx-btn--text { color: var(--vp-c-text-2); }
.yx-btn--text:hover { color: var(--vp-c-brand-1); }
.yx-btn--text:hover { color: var(--yx-brand); }
.yx-hero__shot { margin-top: 56px; }
.yx-hero__img {
@ -627,7 +633,7 @@ open http://localhost:5173</code></pre>
.yx-stat { text-align: center; padding: 32px 16px; }
.yx-stat + .yx-stat { border-left: 1px solid var(--vp-c-divider); }
.yx-stat__value {
font-size: 36px; font-weight: 800; color: var(--vp-c-brand-1);
font-size: 36px; font-weight: 800; color: var(--yx-brand);
font-variant-numeric: tabular-nums; letter-spacing: -.02em;
}
.yx-stat__label { margin-top: 6px; font-size: 14px; color: var(--vp-c-text-2); }
@ -642,12 +648,12 @@ open http://localhost:5173</code></pre>
padding: 28px; background: var(--vp-c-bg);
transition: transform .2s ease, border-color .2s ease;
}
.yx-cap:hover { transform: translateY(-3px); border-color: var(--vp-c-brand-1); }
.yx-cap:hover { transform: translateY(-3px); border-color: var(--yx-brand); }
.yx-cap--lg { grid-column: span 1; grid-row: span 2; display: flex; flex-direction: column; }
.yx-cap__icon {
display: inline-flex; align-items: center; justify-content: center;
width: 44px; height: 44px; border-radius: 12px;
background: var(--vp-c-brand-soft); color: var(--vp-c-brand-1);
background: var(--yx-brand-soft); color: var(--yx-brand);
margin-bottom: 18px;
}
.yx-cap__icon svg { width: 22px; height: 22px; }
@ -681,9 +687,10 @@ open http://localhost:5173</code></pre>
.yx-list__ic {
flex: none; width: 38px; height: 38px; border-radius: 10px;
display: inline-flex; align-items: center; justify-content: center;
background: var(--vp-c-brand-soft); color: var(--vp-c-brand-1);
background: var(--yx-brand-soft); color: var(--yx-brand);
}
.yx-list__ic svg { width: 20px; height: 20px; }
.yx-tab--active .yx-list__ic { background: var(--yx-lime-soft); color: var(--yx-lime-strong); }
.yx-tab__body { min-width: 0; }
.yx-tab strong { display: block; font-size: 16px; }
.yx-tab__desc {
@ -741,7 +748,7 @@ img.yx-engine-frame { object-fit: cover; }
/* ===== 工作原理 ===== */
.yx-steps { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--yx-gap); }
.yx-step { position: relative; padding: 28px 24px; border: 1px solid var(--vp-c-divider); border-radius: 16px; background: var(--vp-c-bg); }
.yx-step__n { font-size: 26px; font-weight: 800; color: var(--vp-c-brand-1); font-variant-numeric: tabular-nums; }
.yx-step__n { font-size: 26px; font-weight: 800; color: var(--yx-brand); font-variant-numeric: tabular-nums; }
.yx-step__title { font-size: 17px; font-weight: 700; margin: 10px 0 8px; }
.yx-step__desc { margin: 0; color: var(--vp-c-text-2); font-size: 14px; }
.yx-step__arrow {
@ -763,10 +770,10 @@ img.yx-engine-frame { object-fit: cover; }
padding: 28px; background: var(--vp-c-bg);
transition: transform .2s ease, border-color .2s ease;
}
.yx-card:hover { transform: translateY(-3px); border-color: var(--vp-c-brand-1); }
.yx-card:hover { transform: translateY(-3px); border-color: var(--yx-brand); }
.yx-card h3 { font-size: 18px; font-weight: 700; margin: 0 0 8px; }
.yx-card p { margin: 0; color: var(--vp-c-text-2); font-size: 14.5px; }
.yx-card--case { border-left: 3px solid var(--vp-c-brand-1); }
.yx-card--case { border-left: 3px solid var(--yx-lime); }
/* ===== 技术栈 ===== */
.yx-tech { max-width: 860px; margin: 0 auto; }
@ -796,7 +803,7 @@ img.yx-engine-frame { object-fit: cover; }
.yx-contrib { display: block; max-width: 720px; margin: 0 auto; }
.yx-contrib img { width: 100%; border-radius: 12px; }
.yx-credits { margin: 32px 0 0; color: var(--vp-c-text-2); font-size: 14.5px; }
.yx-credits a { color: var(--vp-c-brand-1); text-decoration: none; }
.yx-credits a { color: var(--yx-brand); text-decoration: none; }
.yx-credits a:hover { text-decoration: underline; }
/* ===== 最终 CTA ===== */

View File

@ -24,6 +24,7 @@
- 收敛 MCP 创建与编辑入口:前端移除整段配置文本入口和模式切换器,仅保留表单字段提交;后端 MCP 创建/更新请求拒绝额外配置字段,避免绕过表单约束。
- 调整内置 MCP 默认项:移除 `sequentialthinking` 的系统内置同步,启动同步时清理历史系统内置记录,保留用户手动创建的同名 MCP。
- 图片生成能力迁移为 SkillQwen-Image 从内置 Python 生成工具迁移到内置 Skill `image-gen`,模型调用与图片下载在 Agent 沙盒中完成,生成结果保存到 outputs 并通过 `present_artifacts` 展示,为多图片生成模型接入复用同一产物展示链路。
- 优化前端头像加载兜底:用户与智能体头像优先展示已配置图片,加载失败后回退到基于 ID 的 DiceBear 默认头像;离线或默认头像不可达时显示名称前两个字和稳定背景色。
- 降低知识库路由与工具模块复杂度:示例问题生成迁移到知识库 utils文件上传统一 100 MB 限制URL 预处理入库路径与旧 `content_type=url` 行为收敛,并修复 uid、导出 MIME 与异常透传等路由问题。
- 重构智能体配置语义:用户可见的 `AgentConfig` 收敛为数据库持久化的一级 `Agent`,内置 Python Agent 改为智能体后端;新增 `/api/agent` 管理与运行接口,聊天、运行任务、恢复审批和文件预览均从线程绑定的 Agent 解析运行时上下文,前端只提交 `agent_id`,并在模型配置页新增“智能体”管理页签。
- 删除 Upload 与 LightRAG 图谱/知识库能力:知识库类型收敛为 Milvus 与 Dify只保留 Milvus 知识库内图谱构建/展示/检索,移除独立 `/graph` 页面和默认上传图谱工具。

View File

@ -20,9 +20,17 @@
accept="image/*"
>
<div class="avatar-upload" :class="{ uploading: avatarUploading }">
<a-avatar :size="80" :src="avatarSrc" @error="handleAvatarError">
<template #icon><CircleUser :size="48" /></template>
</a-avatar>
<FallbackAvatar
:src="userStore.avatar"
:default-src="avatarDefaultSrc"
:name="userStore.username"
:seed="userStore.uid || userStore.username"
kind="user"
:size="80"
shape="circle"
:alt="userStore.username"
class="account-avatar"
/>
<div class="avatar-mask">
<Upload v-if="!avatarUploading" :size="16" />
<RefreshCw v-else :size="16" class="spin" />
@ -103,8 +111,9 @@
<script setup>
import { computed, nextTick, reactive, ref, watch } from 'vue'
import { message } from 'ant-design-vue'
import { CircleUser, RefreshCw, Upload } from 'lucide-vue-next'
import { RefreshCw, Upload } from 'lucide-vue-next'
import ApiKeyManagementComponent from '@/components/ApiKeyManagementComponent.vue'
import FallbackAvatar from '@/components/common/FallbackAvatar.vue'
import { useUserStore } from '@/stores/user'
import { generatePixelAvatar } from '@/utils/pixelAvatar'
@ -115,16 +124,12 @@ const savingField = ref('')
const editingField = ref('')
const usernameInput = ref(null)
const phoneInput = ref(null)
const avatarLoadFailed = ref(false)
const profileDraft = reactive({
username: '',
phone_number: ''
})
const avatarSrc = computed(() => {
if (userStore.avatar && !avatarLoadFailed.value) return userStore.avatar
return generatePixelAvatar(userStore.uid)
})
const avatarDefaultSrc = computed(() => (userStore.uid ? generatePixelAvatar(userStore.uid) : ''))
const userRoleText = computed(() => {
switch (userStore.userRole) {
@ -144,13 +149,6 @@ const syncProfileDraft = () => {
profileDraft.phone_number = userStore.phoneNumber || ''
}
const handleAvatarError = () => {
if (userStore.avatar && !avatarLoadFailed.value) {
avatarLoadFailed.value = true
}
return false
}
const refreshProfile = async () => {
refreshing.value = true
try {
@ -270,7 +268,6 @@ const handleAvatarChange = async (info) => {
try {
avatarUploading.value = true
await userStore.uploadAvatar(info.file.originFileObj || info.file)
avatarLoadFailed.value = false
message.success('头像上传成功!')
} catch (error) {
console.error('头像上传失败:', error)
@ -280,13 +277,6 @@ const handleAvatarChange = async (info) => {
}
}
watch(
() => userStore.avatar,
() => {
avatarLoadFailed.value = false
}
)
watch(() => [userStore.username, userStore.phoneNumber], syncProfileDraft, { immediate: true })
</script>
@ -337,19 +327,10 @@ watch(() => [userStore.username, userStore.phoneNumber], syncProfileDraft, { imm
flex: 0 0 auto;
box-shadow: 0 2px 8px var(--shadow-2);
:deep(.ant-avatar) {
.account-avatar {
width: 80px;
height: 80px;
border: 3px solid var(--gray-150);
background: var(--gray-50);
color: var(--gray-400);
display: flex;
align-items: center;
justify-content: center;
}
:deep(.ant-avatar-image img) {
object-fit: cover;
}
&:hover .avatar-mask,

View File

@ -400,13 +400,17 @@
:class="{ 'is-clickable': run.child_thread_id }"
@click="run.child_thread_id && openSubagentThread(run)"
>
<img
v-if="getSubagentIconSrc(run)"
<FallbackAvatar
class="state-subagent-icon"
:src="getSubagentIconSrc(run)"
:default-src="getSubagentDefaultIconSrc(run)"
:name="getSubagentRunName(run)"
:seed="run.subagent_type || getSubagentRunName(run)"
kind="agent"
:size="28"
shape="rounded"
:alt="`${getSubagentRunName(run)}图标`"
/>
<span v-else class="state-subagent-icon" aria-hidden="true"></span>
<div class="state-list-item-body">
<div class="state-list-item-title state-subagent-title">
<span>{{ getSubagentRunName(run) }}</span>
@ -445,6 +449,7 @@
:child-thread-id="subagentThreadModal.childThreadId"
:subagent-name="activeSubagentThreadName"
:subagent-avatar="activeSubagentThreadAvatar"
:subagent-default-avatar="activeSubagentThreadDefaultAvatar"
/>
</div>
</template>
@ -500,6 +505,7 @@ import AgentArtifactsCard from '@/components/AgentArtifactsCard.vue'
import AgentPanel from '@/components/AgentPanel.vue'
import AttachmentTmpUploadModal from '@/components/AttachmentTmpUploadModal.vue'
import SubagentThreadModal from '@/components/SubagentThreadModal.vue'
import FallbackAvatar from '@/components/common/FallbackAvatar.vue'
import { enrichTaskToolCalls, parseToolCallArgs } from '@/components/ToolCallingResult/toolRegistry'
import { getConversationDisplayItems } from '@/utils/messageGrouping'
import { makeChildThreadId } from '@/utils/subagentThread'
@ -680,9 +686,12 @@ const getSubagentAgent = (run) => {
const getSubagentIconSrc = (run) => {
const agent = getSubagentAgent(run)
return agent?.icon || (run?.subagent_type ? generatePixelAvatar(run.subagent_type) : '')
return agent?.icon || ''
}
const getSubagentDefaultIconSrc = (run) =>
run?.subagent_type ? generatePixelAvatar(run.subagent_type) : ''
const getSubagentRunMeta = (run) => {
const artifacts = Array.isArray(run?.artifacts) ? run.artifacts.length : 0
return artifacts ? `${artifacts} 个产物` : run?.id || ''
@ -902,13 +911,15 @@ const subagentThreadModal = reactive({
open: false,
childThreadId: '',
subagentName: '',
subagentAvatar: ''
subagentAvatar: '',
subagentDefaultAvatar: ''
})
const openSubagentThread = (run) => {
if (!run?.child_thread_id) return
subagentThreadModal.childThreadId = String(run.child_thread_id)
subagentThreadModal.subagentName = getSubagentRunName(run)
subagentThreadModal.subagentAvatar = getSubagentIconSrc(run)
subagentThreadModal.subagentDefaultAvatar = getSubagentDefaultIconSrc(run)
subagentThreadModal.open = true
}
const isStateSectionExpanded = (key) => !collapsedStateSections[key]
@ -1159,6 +1170,12 @@ const activeSubagentThreadAvatar = computed(() =>
? getSubagentIconSrc(activeSubagentThreadRun.value) || subagentThreadModal.subagentAvatar
: subagentThreadModal.subagentAvatar
)
const activeSubagentThreadDefaultAvatar = computed(() =>
activeSubagentThreadRun.value
? getSubagentDefaultIconSrc(activeSubagentThreadRun.value) ||
subagentThreadModal.subagentDefaultAvatar
: subagentThreadModal.subagentDefaultAvatar
)
// child_thread_id/
watch(

View File

@ -8,13 +8,17 @@
>
<template #title>
<div class="subagent-thread-modal-title">
<img
v-if="subagentAvatar"
<FallbackAvatar
class="subagent-thread-modal-avatar"
:src="subagentAvatar"
:default-src="subagentDefaultAvatar"
:name="modalTitleName"
:seed="childThreadId || modalTitleName"
kind="agent"
:size="28"
shape="rounded"
:alt="`${modalTitleName} icon`"
/>
<span v-else class="subagent-thread-modal-avatar" aria-hidden="true"></span>
<span class="subagent-thread-modal-name">{{ modalTitleName }}</span>
</div>
</template>
@ -31,6 +35,7 @@ import { computed, ref, watch } from 'vue'
import { agentApi } from '@/apis'
import { MessageProcessor } from '@/utils/messageProcessor'
import ThreadMessageList from '@/components/ThreadMessageList.vue'
import FallbackAvatar from '@/components/common/FallbackAvatar.vue'
const props = defineProps({
open: {
@ -48,6 +53,10 @@ const props = defineProps({
subagentAvatar: {
type: String,
default: ''
},
subagentDefaultAvatar: {
type: String,
default: ''
}
})

View File

@ -3,14 +3,17 @@
<a-dropdown :trigger="['click']" v-if="userStore.isLoggedIn">
<div class="user-info-dropdown" :data-align="showRole ? 'left' : 'center'">
<div class="user-avatar">
<img
v-if="avatarSrc"
:src="avatarSrc"
<FallbackAvatar
:src="userStore.avatar"
:default-src="avatarDefaultSrc"
:name="userStore.username"
:seed="userStore.uid || userStore.username"
kind="user"
:size="32"
shape="circle"
:alt="userStore.username"
class="avatar-image"
@error="handleAvatarError"
/>
<CircleUser v-else />
<!-- <div class="user-role-badge" :class="userRoleClass"></div> -->
</div>
<div v-if="showRole" class="user-name">{{ userStore.username }}</div>
@ -67,14 +70,15 @@
</template>
<script setup>
import { computed, ref, inject, useSlots, watch } from 'vue'
import { computed, ref, inject, useSlots } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
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 { BookOpen, Sun, Moon, LogOut, Settings, Terminal } from 'lucide-vue-next'
import { useThemeStore } from '@/stores/theme'
import { generatePixelAvatar } from '@/utils/pixelAvatar'
import FallbackAvatar from '@/components/common/FallbackAvatar.vue'
const router = useRouter()
const userStore = useUserStore()
@ -87,26 +91,7 @@ const showDebug = ref(false)
// Inject settings modal methods
const { openSettingsModal } = inject('settingsModal', {})
const avatarLoadFailed = ref(false)
// 退 UID
const avatarSrc = computed(() => {
if (userStore.avatar && !avatarLoadFailed.value) return userStore.avatar
return generatePixelAvatar(userStore.uid)
})
const handleAvatarError = () => {
if (userStore.avatar && !avatarLoadFailed.value) {
avatarLoadFailed.value = true
}
}
watch(
() => userStore.avatar,
() => {
avatarLoadFailed.value = false
}
)
const avatarDefaultSrc = computed(() => (userStore.uid ? generatePixelAvatar(userStore.uid) : ''))
defineProps({
showRole: {

View File

@ -73,7 +73,17 @@
<div class="card-header">
<div class="user-info-main">
<div class="user-avatar">
<img :src="getUserAvatarSrc(user)" :alt="user.username" class="avatar-img" />
<FallbackAvatar
:src="user.avatar"
:default-src="getUserDefaultAvatarSrc(user)"
:name="user.username"
:seed="user.uid || user.username"
kind="user"
:size="40"
shape="circle"
:alt="user.username"
class="avatar-img"
/>
</div>
<div class="user-info-content">
<div class="name-tag-row">
@ -282,6 +292,7 @@ import {
} from 'lucide-vue-next'
import { formatDateTime } from '@/utils/time'
import { generatePixelAvatar } from '@/utils/pixelAvatar'
import FallbackAvatar from '@/components/common/FallbackAvatar.vue'
const userStore = useUserStore()
@ -465,7 +476,7 @@ watch(
//
const formatTime = (timeStr) => formatDateTime(timeStr)
const getUserAvatarSrc = (user) => user.avatar || generatePixelAvatar(user.uid)
const getUserDefaultAvatarSrc = (user) => (user.uid ? generatePixelAvatar(user.uid) : '')
const isUserDeleteDisabled = (user) =>
user.id === userStore.userId ||

View File

@ -0,0 +1,145 @@
<template>
<span
class="fallback-avatar"
:class="[`fallback-avatar--${shape}`, { 'fallback-avatar--image': currentSrc }]"
:style="[avatarSizeStyle, fallbackStyle]"
>
<img
v-if="currentSrc"
:key="currentSrc"
class="fallback-avatar-image"
:src="currentSrc"
:alt="resolvedAlt"
@error="handleImageError"
/>
<span v-else class="fallback-avatar-text" aria-hidden="true">{{ initials }}</span>
</span>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { getAvatarFallbackStyle, getAvatarInitials } from '@/utils/pixelAvatar'
const props = defineProps({
src: {
type: String,
default: ''
},
defaultSrc: {
type: String,
default: ''
},
name: {
type: String,
default: ''
},
seed: {
type: [String, Number],
default: ''
},
kind: {
type: String,
default: 'user',
validator: (value) => ['user', 'agent'].includes(value)
},
size: {
type: [String, Number],
default: 32
},
shape: {
type: String,
default: 'circle',
validator: (value) => ['circle', 'rounded'].includes(value)
},
alt: {
type: String,
default: ''
}
})
const failedImageCount = ref(0)
const imageCandidates = computed(() => {
const candidates = [props.src, props.defaultSrc]
.map((value) => String(value || '').trim())
.filter(Boolean)
return [...new Set(candidates)]
})
const currentSrc = computed(() => imageCandidates.value[failedImageCount.value] || '')
const initials = computed(() => getAvatarInitials(props.name, props.kind))
const fallbackStyle = computed(() => getAvatarFallbackStyle(props.seed || props.name || props.kind))
const avatarSizeStyle = computed(() => {
const size = typeof props.size === 'number' ? `${props.size}px` : props.size
const fontSize =
typeof props.size === 'number' ? `${Math.max(10, Math.floor(props.size * 0.34))}px` : '12px'
return {
'--fallback-avatar-size': size,
'--fallback-avatar-font-size': fontSize
}
})
const resolvedAlt = computed(() => props.alt || props.name || (props.kind === 'agent' ? '智能体头像' : '用户头像'))
const handleImageError = () => {
if (failedImageCount.value < imageCandidates.value.length) {
failedImageCount.value += 1
}
}
watch(
() => [props.src, props.defaultSrc],
() => {
failedImageCount.value = 0
}
)
</script>
<style lang="less" scoped>
.fallback-avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--fallback-avatar-size);
height: var(--fallback-avatar-size);
flex: 0 0 var(--fallback-avatar-size);
overflow: hidden;
border: 1px solid var(--gray-150);
color: var(--gray-0);
font-size: var(--fallback-avatar-font-size);
font-weight: 600;
line-height: 1;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.18);
user-select: none;
}
.fallback-avatar--circle {
border-radius: 50%;
}
.fallback-avatar--rounded {
border-radius: 7px;
}
.fallback-avatar--image {
background: var(--gray-0);
}
.fallback-avatar-image {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.fallback-avatar-text {
display: block;
max-width: 100%;
overflow: hidden;
text-align: center;
white-space: nowrap;
}
</style>

View File

@ -19,9 +19,17 @@
<!-- 卡片头部用户信息和反馈类型 -->
<div class="card-header">
<div class="user-info">
<a-avatar :src="getFeedbackAvatarSrc(feedback)" :size="32" class="user-avatar">
{{ feedback.username ? feedback.username.charAt(0).toUpperCase() : 'U' }}
</a-avatar>
<FallbackAvatar
:src="feedback.avatar"
:default-src="getFeedbackDefaultAvatarSrc(feedback)"
:name="feedback.username"
:seed="feedback.uid || feedback.username"
kind="user"
:size="32"
shape="circle"
:alt="feedback.username"
class="user-avatar"
/>
<div class="user-details">
<div class="username">{{ feedback.username || '未知用户' }}</div>
</div>
@ -117,6 +125,7 @@ import { LikeOutlined, DislikeOutlined, ClockCircleOutlined } from '@ant-design/
import { dashboardApi } from '@/apis/dashboard_api'
import { formatFullDateTime } from '@/utils/time'
import { generatePixelAvatar } from '@/utils/pixelAvatar'
import FallbackAvatar from '@/components/common/FallbackAvatar.vue'
//
const CONFIG = {
@ -212,7 +221,7 @@ const loadFeedbacks = async () => {
}
}
const getFeedbackAvatarSrc = (feedback) => feedback.avatar || generatePixelAvatar(feedback.uid)
const getFeedbackDefaultAvatarSrc = (feedback) => (feedback.uid ? generatePixelAvatar(feedback.uid) : '')
//
const formatFullDate = (dateString) => formatFullDateTime(dateString)

View File

@ -24,6 +24,7 @@ import { isBuiltinAgent, useAgentStore } from '@/stores/agent'
import { useUserStore } from '@/stores/user'
import PageShoulder from '@/components/shared/PageShoulder.vue'
import InfoCard from '@/components/shared/InfoCard.vue'
import FallbackAvatar from '@/components/common/FallbackAvatar.vue'
import ExtensionCardGrid from '@/components/extensions/ExtensionCardGrid.vue'
import { generatePixelAvatar } from '@/utils/pixelAvatar'
import { MAX_IMAGE_UPLOAD_SIZE_BYTES, MAX_IMAGE_UPLOAD_SIZE_MB } from '@/utils/upload_limits'
@ -150,16 +151,15 @@ const getAgentShareAllowedLevels = () => {
const canManageAgent = (agent) => !!agent?.can_manage
const agentModalTitle = computed(() => (editingAgentId.value ? '编辑智能体' : '新增智能体'))
const getAgentIconSrc = (agent) => agent.icon || (agent.id ? generatePixelAvatar(agent.id) : '')
const getAgentDefaultIconSrc = (agent) => (agent.id ? generatePixelAvatar(agent.id) : '')
const getAgentTags = (agent) => [
...(!agent?.can_manage ? [{ name: '只读', color: 'default' }] : []),
...(agent?.backend_id ? [{ name: agent.backend_id, color: 'blue' }] : [])
]
const agentPreviewIcon = computed(() => {
if (agentForm.icon?.trim()) return agentForm.icon.trim()
if (editingAgentId.value) return generatePixelAvatar(editingAgentId.value)
return ''
})
const agentPreviewDefaultIcon = computed(() =>
editingAgentId.value ? generatePixelAvatar(editingAgentId.value) : ''
)
const agentPreviewName = computed(() => agentForm.name || editingAgentId.value || '智能体')
const selectedBackendOption = computed(() =>
agentBackendOptions.value.find((backend) => backend.value === agentForm.backend_id)
)
@ -424,10 +424,15 @@ defineExpose({
@click="canManageAgent(agent) && openEditAgentModal(agent)"
>
<template #icon>
<img
v-if="getAgentIconSrc(agent)"
<FallbackAvatar
class="agent-card-icon-image"
:src="getAgentIconSrc(agent)"
:src="agent.icon"
:default-src="getAgentDefaultIconSrc(agent)"
:name="agent.name || agent.id"
:seed="agent.id || agent.name"
kind="agent"
:size="40"
shape="rounded"
:alt="`${agent.name || '智能体'}图标`"
/>
</template>
@ -534,13 +539,19 @@ defineExpose({
class="agent-icon-upload"
:class="{
uploading: agentIconUploading,
'is-empty': !agentPreviewIcon
'is-empty': !agentForm.icon && !editingAgentId && !agentForm.name.trim()
}"
>
<img
v-if="agentPreviewIcon"
:src="agentPreviewIcon"
<FallbackAvatar
:src="agentForm.icon"
:default-src="agentPreviewDefaultIcon"
:name="agentPreviewName"
:seed="editingAgentId || agentForm.slug || agentForm.name"
kind="agent"
:size="56"
shape="rounded"
:alt="`${agentForm.name || '智能体'}图标`"
class="agent-icon-preview-avatar"
/>
<div class="agent-icon-mask">
<RefreshCw v-if="agentIconUploading" :size="16" class="spinning" />
@ -667,7 +678,7 @@ defineExpose({
display: block;
width: 100%;
height: 100%;
object-fit: cover;
border: 0;
}
.agent-card-menu-trigger {
@ -911,11 +922,10 @@ defineExpose({
border-color 0.16s ease,
box-shadow 0.16s ease;
img {
display: block;
.agent-icon-preview-avatar {
width: 100%;
height: 100%;
object-fit: cover;
border: 0;
}
&:hover,

View File

@ -1,5 +1,11 @@
import assert from 'node:assert/strict'
import { generatePixelAvatar } from '../pixelAvatar.js'
import {
AVATAR_BACKGROUND_TOKENS,
generatePixelAvatar,
getAvatarColorIndex,
getAvatarFallbackStyle,
getAvatarInitials
} from '../pixelAvatar.js'
const DICEBEAR_GLYPHS_AVATAR_BASE_URL = 'https://api.dicebear.com/10.x/glyphs/svg'
@ -52,7 +58,32 @@ const run = () => {
console.log('T5 Missing ID fails: PASS')
}
console.log('\nAll 5 pixel avatar tests passed!')
{
assert.equal(getAvatarInitials('张三丰', 'user'), '张三', 'Chinese initials use first two chars')
assert.equal(getAvatarInitials('Alice', 'user'), 'Al', 'ASCII initials use first two chars')
assert.equal(getAvatarInitials('', 'user'), '用户', 'User fallback should be localized')
assert.equal(getAvatarInitials('', 'agent'), '智能', 'Agent fallback should be localized')
console.log('T6 Initials: PASS')
}
{
const first = getAvatarColorIndex('user-001')
const second = getAvatarColorIndex('user-001')
const third = getAvatarColorIndex('user-002')
assert.equal(first, second, 'Same seed should select the same fallback color')
assert.notEqual(first, third, 'Different seeds should be able to select different colors')
assert.ok(first >= 0 && first < AVATAR_BACKGROUND_TOKENS.length, 'Color index should be valid')
console.log('T7 Stable fallback color: PASS')
}
{
const style = getAvatarFallbackStyle('agent-001')
assert.equal(typeof style.background, 'string', 'Fallback style should include background')
assert.equal(typeof style.color, 'string', 'Fallback style should include text color')
console.log('T8 Fallback style: PASS')
}
console.log('\nAll 8 pixel avatar tests passed!')
}
run()

View File

@ -1,5 +1,13 @@
const DICEBEAR_GLYPHS_AVATAR_BASE_URL = 'https://api.dicebear.com/10.x/glyphs/svg'
export const AVATAR_BACKGROUND_TOKENS = [
{ background: 'linear-gradient(135deg, var(--main-600), var(--color-info-500))', color: '#fff' },
{ background: 'linear-gradient(135deg, var(--chart-palette-5), var(--chart-palette-9))', color: '#fff' },
{ background: 'linear-gradient(135deg, var(--chart-palette-7), var(--chart-palette-3))', color: '#fff' },
{ background: 'linear-gradient(135deg, var(--color-accent-500), var(--chart-palette-6))', color: '#fff' },
{ background: 'linear-gradient(135deg, var(--chart-palette-4), var(--color-error-500))', color: '#fff' }
]
const normalizeSeed = (id) => {
if (id === null || id === undefined || String(id).trim() === '') {
throw new Error('generatePixelAvatar requires an id')
@ -11,3 +19,22 @@ export const generatePixelAvatar = (id) => {
const seed = normalizeSeed(id)
return `${DICEBEAR_GLYPHS_AVATAR_BASE_URL}?seed=${encodeURIComponent(seed)}`
}
export const getAvatarInitials = (name, kind = 'user') => {
const fallback = kind === 'agent' ? '智能' : '用户'
const normalizedName = String(name || '').trim()
if (!normalizedName) return fallback
return Array.from(normalizedName).slice(0, 2).join('')
}
export const getAvatarColorIndex = (seed) => {
const normalizedSeed = String(seed || '').trim()
const value = normalizedSeed || 'avatar'
let hash = 0
for (const char of value) {
hash = (hash * 31 + char.codePointAt(0)) >>> 0
}
return hash % AVATAR_BACKGROUND_TOKENS.length
}
export const getAvatarFallbackStyle = (seed) => AVATAR_BACKGROUND_TOKENS[getAvatarColorIndex(seed)]

View File

@ -40,13 +40,17 @@
}"
@click="handleAgentSwitch(agent.value, hasActiveThread)"
>
<img
v-if="agent.icon"
<FallbackAvatar
class="config-dropdown-item-icon-image"
:src="agent.icon"
:default-src="agent.defaultIcon"
:name="agent.label"
:seed="agent.value || agent.label"
kind="agent"
:size="24"
shape="rounded"
:alt="`${agent.label}图标`"
/>
<span v-else class="config-dropdown-item-icon-empty" aria-hidden="true"></span>
<span class="config-dropdown-item-label">{{ agent.label }}</span>
<span v-if="agent.isBuiltin" class="config-dropdown-item-badge">内置</span>
<Check
@ -103,6 +107,7 @@ import AgentChatComponent from '@/components/AgentChatComponent.vue'
import { isBuiltinAgent, useAgentStore } from '@/stores/agent'
import { handleChatError } from '@/utils/errorHandler'
import { generatePixelAvatar } from '@/utils/pixelAvatar'
import FallbackAvatar from '@/components/common/FallbackAvatar.vue'
import { storeToRefs } from 'pinia'
@ -178,7 +183,8 @@ const agentQuickSwitchOptions = computed(() =>
.map((agent) => ({
label: agent.name || agent.id,
value: agent.id,
icon: agent.icon || (agent.id ? generatePixelAvatar(agent.id) : ''),
icon: agent.icon || '',
defaultIcon: agent.id ? generatePixelAvatar(agent.id) : '',
isBuiltin: isBuiltinAgent(agent)
}))
)