feat: 优化头像加载兜底展示
This commit is contained in:
parent
2474fb0a6c
commit
84dc55ee74
@ -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 ===== */
|
||||
|
||||
@ -24,6 +24,7 @@
|
||||
- 收敛 MCP 创建与编辑入口:前端移除整段配置文本入口和模式切换器,仅保留表单字段提交;后端 MCP 创建/更新请求拒绝额外配置字段,避免绕过表单约束。
|
||||
- 调整内置 MCP 默认项:移除 `sequentialthinking` 的系统内置同步,启动同步时清理历史系统内置记录,保留用户手动创建的同名 MCP。
|
||||
- 图片生成能力迁移为 Skill:Qwen-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` 页面和默认上传图谱工具。
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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: ''
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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 ||
|
||||
|
||||
145
web/src/components/common/FallbackAvatar.vue
Normal file
145
web/src/components/common/FallbackAvatar.vue
Normal 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>
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)]
|
||||
|
||||
@ -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)
|
||||
}))
|
||||
)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user