Przeglądaj źródła

修复刷新时绿色默认头像闪现

Samien 1 miesiąc temu
rodzic
commit
245ef919f2

+ 16 - 11
src/client/components/common/AgentAvatar.vue

@@ -1,6 +1,6 @@
 <script setup lang="ts">
 import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
-import { decodeAgentAvatar, defaultAgentIdentity } from '@shared/agentIdentity'
+import { decodeAgentAvatar } from '@shared/agentIdentity'
 import { mountCursorAvatar, type CursorState } from './maus/cursor-engine'
 import { MASCOT_SILHOUETTES, LAOA_BODIES } from './maus/silhouettes'
 type MascotState = 'idle' | 'working' | 'loading' | 'notifying' | 'waiting' | 'success' | 'failure'
@@ -13,19 +13,22 @@ let beat: ReturnType<typeof setTimeout> | undefined
 let observer: IntersectionObserver | undefined
 let media: MediaQueryList | undefined
 let renderer: ReturnType<typeof mountCursorAvatar> | undefined
-const identity = computed(() => decodeAgentAvatar(props.avatar) ?? defaultAgentIdentity(props.name))
-const hasImage = computed(() => identity.value.avatarMode === 'image' && !!identity.value.imageDataURL && !imageFailed.value)
-const radius = computed(() => identity.value.imageCrop === 'circle' ? '50%' : identity.value.imageCrop === 'square' ? '0' : '22%')
+// Missing metadata during refresh is not a configured green mascot.
+const identity = computed(() => decodeAgentAvatar(props.avatar))
+const hasImage = computed(() => identity.value?.avatarMode === 'image' && !!identity.value.imageDataURL && !imageFailed.value)
+const hasMascot = computed(() => identity.value?.avatarMode === 'mascot')
+const hasVisual = computed(() => hasImage.value || hasMascot.value)
+const radius = computed(() => identity.value?.imageCrop === 'circle' ? '50%' : identity.value?.imageCrop === 'square' ? '0' : '22%')
 const states: Record<MascotState, CursorState> = {idle:'idle',working:'working',loading:'loading',waiting:'alerting',notifying:'notifying',success:'happy',failure:'sad'}
 const runtimeActive = computed(() => props.state !== 'idle' && !beatEnded.value)
 const imageActivity = computed(() => hasImage.value && runtimeActive.value && ['working', 'loading'].includes(props.state))
-const state = computed<CursorState>(() => runtimeActive.value ? states[props.state] : oneShot.value ?? identity.value.expression)
-const live = computed(() => visible.value && foreground.value && !reduced.value && props.animated !== false && (props.animated === true || props.size >= 56 || runtimeActive.value || !!oneShot.value || hover.value))
-const options = computed(() => ({ state: state.value, paused: !live.value, color: identity.value.color, gaze: gaze.value,
+const state = computed<CursorState>(() => runtimeActive.value ? states[props.state] : oneShot.value ?? identity.value?.expression ?? 'idle')
+const live = computed(() => hasVisual.value && visible.value && foreground.value && !reduced.value && props.animated !== false && (props.animated === true || props.size >= 56 || runtimeActive.value || !!oneShot.value || hover.value))
+const options = computed(() => identity.value && hasVisual.value ? ({ state: state.value, paused: !live.value, color: identity.value.color, gaze: gaze.value,
   silhouette: hasImage.value ? MASCOT_SILHOUETTES.circle : identity.value.bodyId ? LAOA_BODIES[identity.value.bodyId] : MASCOT_SILHOUETTES[identity.value.shape === 'ellipse' ? 'oval' : identity.value.shape], fixedTime: props.fixedTime, expression: props.expressionIndex,
-  effectsOnly: hasImage.value, ribbons: runtimeActive.value || state.value !== 'working' }))
+  effectsOnly: hasImage.value, ribbons: runtimeActive.value || state.value !== 'working' }) : undefined)
 function sync() {
-  if (!svg.value || (hasImage.value && !imageActivity.value)) { renderer?.dispose();renderer=undefined;return }
+  if (!svg.value || !options.value || (hasImage.value && !imageActivity.value)) { renderer?.dispose();renderer=undefined;return }
   if (!renderer) renderer=mountCursorAvatar(svg.value, options.value)
   else renderer.update(options.value)
 }
@@ -54,11 +57,13 @@ onBeforeUnmount(()=>{renderer?.dispose();observer?.disconnect();clearTimeout(bea
 </script>
 <template>
   <span ref="host" class="agent-avatar" :class="[`agent-avatar--${props.state}`, {'agent-avatar--image':hasImage, 'agent-avatar--image-active': imageActivity && live}]" :style="{width:`${size}px`,height:`${size}px`}" role="img" :aria-label="`${name} 的头像`" @pointermove="pointer" @pointerleave="leave" :data-animated="live && (!hasImage || imageActivity)" :data-expression="state">
-    <img @error="imageFailed = true" v-if="hasImage" :src="identity.imageDataURL" alt="" :style="{borderRadius:radius}" />
-    <svg v-show="!hasImage || imageActivity" ref="svg" aria-hidden="true" />
+    <span v-if="!hasVisual" class="agent-avatar__placeholder" aria-hidden="true" />
+    <img @error="imageFailed = true" v-if="hasImage" :src="identity?.imageDataURL" alt="" :style="{borderRadius:radius}" />
+    <svg v-if="hasMascot || imageActivity" ref="svg" aria-hidden="true" />
   </span>
 </template>
 <style scoped>
 .agent-avatar{display:block;position:relative;flex:0 0 auto;overflow:visible;line-height:1;background:transparent}.agent-avatar img,.agent-avatar svg{display:block;width:100%;height:100%;object-fit:cover}
+.agent-avatar__placeholder{display:block;width:100%;height:100%;border-radius:50%;background:var(--surface-soft);box-shadow:inset 0 0 0 1px var(--line)}
 .agent-avatar svg{overflow:visible;pointer-events:none}.agent-avatar--image svg{position:absolute;inset:0}.agent-avatar--image-active img{transform:scale(.72)}
 </style>

+ 29 - 3
tests/client/agentAvatar.test.ts

@@ -34,7 +34,7 @@ describe('AgentAvatar', () => {
   })
 
   it('renders a stable animated mascot when no image is configured', () => {
-    const wrapper = mount(AgentAvatar, { props: { name: '丫头' } })
+    const wrapper = mount(AgentAvatar, { props: { name: '丫头', avatar: encodeAgentAvatar(defaultAgentIdentity('丫头')) } })
 
     expect(wrapper.classes()).not.toContain('agent-avatar--image')
     expect(wrapper.find('img').exists()).toBe(false)
@@ -45,6 +45,28 @@ describe('AgentAvatar', () => {
     wrapper.unmount()
   })
 
+  it.each(['', 'invalid-avatar'])('keeps a neutral placeholder until avatar metadata arrives (%s)', async (avatar) => {
+    const wrapper = mount(AgentAvatar, { props: { name: '丫头', avatar, size: 56, state: 'working' } })
+    expect(wrapper.find('.agent-avatar__placeholder').exists()).toBe(true)
+    expect(wrapper.find('svg').exists()).toBe(false)
+    expect(wrapper.attributes('data-animated')).toBe('false')
+
+    await wrapper.setProps({ avatar: encodeAgentAvatar({ ...defaultAgentIdentity('丫头'), color: '#1488ff' }) })
+    expect(wrapper.find('.agent-avatar__placeholder').exists()).toBe(false)
+    expect(wrapper.get('[data-part=outline] path').attributes('fill')).toBe('#1488ff')
+    expect(wrapper.attributes('data-animated')).toBe('true')
+
+    await wrapper.setProps({ avatar: '' })
+    expect(wrapper.find('.agent-avatar__placeholder').exists()).toBe(true)
+    expect(wrapper.find('svg').exists()).toBe(false)
+    expect(wrapper.attributes('data-animated')).toBe('false')
+
+    await wrapper.setProps({ avatar: transparentPng })
+    expect(wrapper.find('.agent-avatar__placeholder').exists()).toBe(false)
+    expect(wrapper.get('img').attributes('src')).toBe(transparentPng)
+    wrapper.unmount()
+  })
+
   it('uses the compact triangle and exposes the notifying motion state', () => {
     const wrapper = mount(AgentAvatar, {
       props: {
@@ -85,7 +107,7 @@ it('parks animation frames for idle, hidden, background and reduced-motion avata
   vi.stubGlobal('IntersectionObserver',class {constructor(callback:typeof visible){visible=callback} observe(){} disconnect(){}})
   vi.stubGlobal('matchMedia',()=>({get matches(){return reduced},addEventListener(_event:string,fn:()=>void){motion=fn},removeEventListener(){}}))
   Object.defineProperty(document,'visibilityState',{configurable:true,get:()=>background?'hidden':'visible'})
-  const wrapper=mount(AgentAvatar,{props:{name:'quiet',size:32}})
+  const wrapper=mount(AgentAvatar,{props:{name:'quiet',avatar:encodeAgentAvatar(defaultAgentIdentity('quiet')),size:32}})
   try {
     expect(raf).not.toHaveBeenCalled()
     await wrapper.setProps({state:'working'})
@@ -111,6 +133,10 @@ it('renders all picture crops from the same bytes and falls back cleanly on deco
   expect(wrapper.get('img').attributes('src')).toBe(transparentPng)
   await wrapper.get('img').trigger('error');await nextTick()
   expect(wrapper.find('img').exists()).toBe(false)
-  expect(wrapper.get('[data-part=outline] path').attributes('fill')).toBe('#00c875')
+  expect(wrapper.find('.agent-avatar__placeholder').exists()).toBe(true)
+  expect(wrapper.find('svg').exists()).toBe(false)
+  await wrapper.setProps({avatar:encodeAgentAvatar({...identity,imageCrop:'rounded'})})
+  expect(wrapper.find('.agent-avatar__placeholder').exists()).toBe(false)
+  expect(wrapper.get('img').attributes('src')).toBe(transparentPng)
   wrapper.unmount()
 })