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-home {
--yx-max: 1152px; --yx-max: 1152px;
--yx-gap: 24px; --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); color: var(--vp-c-text-1);
font-size: 16px; font-size: 16px;
line-height: 1.6; line-height: 1.6;
@ -513,7 +519,7 @@ open http://localhost:5173</code></pre>
padding: 0 24px; padding: 0 24px;
} }
.yx-center { text-align: center; } .yx-center { text-align: center; }
.yx-accent { color: var(--vp-c-brand-1); } .yx-accent { color: var(--yx-brand); }
/* 段落节奏 */ /* 段落节奏 */
.yx-section { padding: 96px 0; } .yx-section { padding: 96px 0; }
@ -527,14 +533,14 @@ open http://localhost:5173</code></pre>
font-weight: 600; font-weight: 600;
letter-spacing: .08em; letter-spacing: .08em;
text-transform: uppercase; text-transform: uppercase;
color: var(--vp-c-brand-1); color: var(--yx-brand);
margin-bottom: 12px; margin-bottom: 12px;
} }
.yx-head__title { font-size: 34px; font-weight: 700; line-height: 1.25; margin: 0; letter-spacing: -.01em; } .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 { margin: 16px auto 0; color: var(--vp-c-text-2); font-size: 17px; }
.yx-head__sub code, .yx-quick__tip code { .yx-head__sub code, .yx-quick__tip code {
font-size: .85em; padding: 2px 6px; border-radius: 6px; 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 ===== */ /* ===== 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 { position: absolute; border-radius: 50%; filter: blur(70px); will-change: transform; }
.yx-orb--1 { .yx-orb--1 {
width: 460px; height: 460px; top: -180px; right: -80px; 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; animation: yxOrbFloat 18s ease-in-out infinite;
} }
.yx-orb--2 { .yx-orb--2 {
width: 380px; height: 380px; top: -120px; left: -120px; 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; animation: yxOrbFloat 22s ease-in-out infinite reverse;
} }
.yx-orb--3 { .yx-orb--3 {
width: 320px; height: 320px; top: 30px; left: 46%; 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; animation: yxOrbFloat 26s ease-in-out infinite;
} }
.yx-grid { .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; transition: transform .2s ease, background-color .2s ease, border-color .2s ease, color .2s ease;
border: 1px solid transparent; text-decoration: none; border: 1px solid transparent; text-decoration: none;
} }
.yx-btn--primary { background: var(--vp-c-brand-1); color: #fff; } .yx-btn--primary { background: var(--yx-brand); color: #fff; }
.yx-btn--primary:hover { background: var(--vp-c-brand-2); transform: translateY(-1px); } .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 { 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 { 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__shot { margin-top: 56px; }
.yx-hero__img { .yx-hero__img {
@ -627,7 +633,7 @@ open http://localhost:5173</code></pre>
.yx-stat { text-align: center; padding: 32px 16px; } .yx-stat { text-align: center; padding: 32px 16px; }
.yx-stat + .yx-stat { border-left: 1px solid var(--vp-c-divider); } .yx-stat + .yx-stat { border-left: 1px solid var(--vp-c-divider); }
.yx-stat__value { .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; font-variant-numeric: tabular-nums; letter-spacing: -.02em;
} }
.yx-stat__label { margin-top: 6px; font-size: 14px; color: var(--vp-c-text-2); } .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); padding: 28px; background: var(--vp-c-bg);
transition: transform .2s ease, border-color .2s ease; 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--lg { grid-column: span 1; grid-row: span 2; display: flex; flex-direction: column; }
.yx-cap__icon { .yx-cap__icon {
display: inline-flex; align-items: center; justify-content: center; display: inline-flex; align-items: center; justify-content: center;
width: 44px; height: 44px; border-radius: 12px; 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; margin-bottom: 18px;
} }
.yx-cap__icon svg { width: 22px; height: 22px; } .yx-cap__icon svg { width: 22px; height: 22px; }
@ -681,9 +687,10 @@ open http://localhost:5173</code></pre>
.yx-list__ic { .yx-list__ic {
flex: none; width: 38px; height: 38px; border-radius: 10px; flex: none; width: 38px; height: 38px; border-radius: 10px;
display: inline-flex; align-items: center; justify-content: center; 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-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__body { min-width: 0; }
.yx-tab strong { display: block; font-size: 16px; } .yx-tab strong { display: block; font-size: 16px; }
.yx-tab__desc { .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-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 { 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__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__desc { margin: 0; color: var(--vp-c-text-2); font-size: 14px; }
.yx-step__arrow { .yx-step__arrow {
@ -763,10 +770,10 @@ img.yx-engine-frame { object-fit: cover; }
padding: 28px; background: var(--vp-c-bg); padding: 28px; background: var(--vp-c-bg);
transition: transform .2s ease, border-color .2s ease; 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 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 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; } .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 { display: block; max-width: 720px; margin: 0 auto; }
.yx-contrib img { width: 100%; border-radius: 12px; } .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 { 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; } .yx-credits a:hover { text-decoration: underline; }
/* ===== 最终 CTA ===== */ /* ===== 最终 CTA ===== */

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -73,7 +73,17 @@
<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 :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>
<div class="user-info-content"> <div class="user-info-content">
<div class="name-tag-row"> <div class="name-tag-row">
@ -282,6 +292,7 @@ import {
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { formatDateTime } from '@/utils/time' import { formatDateTime } from '@/utils/time'
import { generatePixelAvatar } from '@/utils/pixelAvatar' import { generatePixelAvatar } from '@/utils/pixelAvatar'
import FallbackAvatar from '@/components/common/FallbackAvatar.vue'
const userStore = useUserStore() const userStore = useUserStore()
@ -465,7 +476,7 @@ watch(
// //
const formatTime = (timeStr) => formatDateTime(timeStr) const formatTime = (timeStr) => formatDateTime(timeStr)
const getUserAvatarSrc = (user) => user.avatar || generatePixelAvatar(user.uid) const getUserDefaultAvatarSrc = (user) => (user.uid ? generatePixelAvatar(user.uid) : '')
const isUserDeleteDisabled = (user) => const isUserDeleteDisabled = (user) =>
user.id === userStore.userId || 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="card-header">
<div class="user-info"> <div class="user-info">
<a-avatar :src="getFeedbackAvatarSrc(feedback)" :size="32" class="user-avatar"> <FallbackAvatar
{{ feedback.username ? feedback.username.charAt(0).toUpperCase() : 'U' }} :src="feedback.avatar"
</a-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="user-details">
<div class="username">{{ feedback.username || '未知用户' }}</div> <div class="username">{{ feedback.username || '未知用户' }}</div>
</div> </div>
@ -117,6 +125,7 @@ import { LikeOutlined, DislikeOutlined, ClockCircleOutlined } from '@ant-design/
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' import { generatePixelAvatar } from '@/utils/pixelAvatar'
import FallbackAvatar from '@/components/common/FallbackAvatar.vue'
// //
const CONFIG = { 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) const formatFullDate = (dateString) => formatFullDateTime(dateString)

View File

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

View File

@ -1,5 +1,11 @@
import assert from 'node:assert/strict' 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' 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('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() run()

View File

@ -1,5 +1,13 @@
const DICEBEAR_GLYPHS_AVATAR_BASE_URL = 'https://api.dicebear.com/10.x/glyphs/svg' 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) => { const normalizeSeed = (id) => {
if (id === null || id === undefined || String(id).trim() === '') { if (id === null || id === undefined || String(id).trim() === '') {
throw new Error('generatePixelAvatar requires an id') throw new Error('generatePixelAvatar requires an id')
@ -11,3 +19,22 @@ export const generatePixelAvatar = (id) => {
const seed = normalizeSeed(id) const seed = normalizeSeed(id)
return `${DICEBEAR_GLYPHS_AVATAR_BASE_URL}?seed=${encodeURIComponent(seed)}` 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)" @click="handleAgentSwitch(agent.value, hasActiveThread)"
> >
<img <FallbackAvatar
v-if="agent.icon"
class="config-dropdown-item-icon-image" class="config-dropdown-item-icon-image"
:src="agent.icon" :src="agent.icon"
:default-src="agent.defaultIcon"
:name="agent.label"
:seed="agent.value || agent.label"
kind="agent"
:size="24"
shape="rounded"
:alt="`${agent.label}图标`" :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 class="config-dropdown-item-label">{{ agent.label }}</span>
<span v-if="agent.isBuiltin" class="config-dropdown-item-badge">内置</span> <span v-if="agent.isBuiltin" class="config-dropdown-item-badge">内置</span>
<Check <Check
@ -103,6 +107,7 @@ import AgentChatComponent from '@/components/AgentChatComponent.vue'
import { isBuiltinAgent, useAgentStore } from '@/stores/agent' import { isBuiltinAgent, useAgentStore } from '@/stores/agent'
import { handleChatError } from '@/utils/errorHandler' import { handleChatError } from '@/utils/errorHandler'
import { generatePixelAvatar } from '@/utils/pixelAvatar' import { generatePixelAvatar } from '@/utils/pixelAvatar'
import FallbackAvatar from '@/components/common/FallbackAvatar.vue'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
@ -178,7 +183,8 @@ const agentQuickSwitchOptions = computed(() =>
.map((agent) => ({ .map((agent) => ({
label: agent.name || agent.id, label: agent.name || agent.id,
value: 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) isBuiltin: isBuiltinAgent(agent)
})) }))
) )