Przeglądaj źródła

统一团队动物头像并支持选择上传

LSamien 2 miesięcy temu
rodzic
commit
a512fb5806

+ 48 - 0
design-qa.md

@@ -184,3 +184,51 @@ final result: passed
 - [x] Verify tag filtering, file grid, session list, and group navigation.
 
 final result: passed
+
+## 团队动物头像 Design QA
+
+- source visual truth path: `/Users/samien/git/hermes-yaoyao/src/client/assets/team-avatars/{fox,whale,owl,rabbit,bear}.png`
+- Web implementation screenshot: `/Users/samien/.codex/visualizations/2026/08/27/01a040a6-d36a-7fb3-bb37-2f181f6fd5e8/team-avatar-web-implementation.png`
+- iOS implementation screenshot: `/Users/samien/.codex/visualizations/2026/08/27/01a040a6-d36a-7fb3-bb37-2f181f6fd5e8/team-avatar-ios-implementation.png`
+- combined comparison evidence: `/Users/samien/.codex/visualizations/2026/08/27/01a040a6-d36a-7fb3-bb37-2f181f6fd5e8/team-avatar-qa-comparison.png`
+- viewport: Web 1035 x 874 CSS px; iPhone 17 simulator 402 x 874 pt
+- pixels and density: source assets 512 x 512 px; Web capture 1035 x 874 px at 1x; iOS capture 1206 x 2622 px at 3x; comparison canvas normalizes both implementations into one 1200 x 1260 px image
+- state: existing legacy team with empty avatar, team list visible, avatar settings visible, light theme
+
+## Full-view comparison evidence
+
+The combined comparison shows the five source animals at full size, the deployed Web team list and settings panel, and the iOS simulator team list and settings picker. Both clients preserve the same silhouette, foreground color, pale background, circular crop, and ordering.
+
+## Focused region comparison evidence
+
+A separate crop was not needed: the combined canvas keeps the Web/iOS avatar-setting rows large enough to inspect all five silhouettes and also retains the small list-avatar state. The original implementation screenshots remain available for pixel-level inspection.
+
+## Findings
+
+- No actionable P0/P1/P2 findings.
+- Fonts and typography: existing platform typography and hierarchy are unchanged; new labels remain readable at Web and iOS caption sizes.
+- Spacing and layout rhythm: five circular choices fit without clipping or crowding; preview, picker, random action, and upload action remain visually grouped.
+- Colors and visual tokens: exact flat foreground/background colors from the source assets are retained; selection uses each platform's existing accent token.
+- Image quality and asset fidelity: source raster assets remain sharp at 23–58 px Web sizes and 34–58 pt iOS sizes, with clean circular masks and no composite-avatar seam or gray center.
+- Copy and content: both clients use the same animal labels, “随机一个”, and “上传图片”; legacy empty avatars explicitly explain that the old composite was replaced.
+
+## Comparison history
+
+- Initial pass: no P0/P1/P2 mismatch found, so no visual-fix iteration was required.
+
+## Primary interactions tested
+
+- Existing empty avatar renders as a deterministic animal in the Web list and settings preview.
+- Web settings expose five selectable animals, random selection, and image upload.
+- iOS team list renders animal avatars; settings expose the same five choices, random selection, and Photos upload.
+- Browser console checked with no new avatar-related errors.
+
+## Implementation checklist
+
+- [x] Five source animals render on Web and iOS.
+- [x] Team avatars remain circular at list and settings sizes.
+- [x] Legacy empty/composite state is replaced deterministically.
+- [x] New teams receive a random animal when no explicit choice is made.
+- [x] Uploaded custom images remain supported.
+
+final result: passed

BIN
src/client/assets/team-avatars/bear.png


BIN
src/client/assets/team-avatars/fox.png


BIN
src/client/assets/team-avatars/owl.png


BIN
src/client/assets/team-avatars/rabbit.png


BIN
src/client/assets/team-avatars/whale.png


+ 1 - 1
src/client/components/app/ResourceSidebar.vue

@@ -149,7 +149,7 @@ defineExpose({ focusSearch })
               <AppIcon name="chevron-down" :size="13" />
             </button>
             <span v-if="!singleLine" class="sidebar-item__icon" :class="{ 'sidebar-item__icon--avatar': !row.item.icon }">
-              <TeamAvatar v-if="row.item.avatar !== undefined || row.item.avatarMembers?.length" :name="row.item.title" :avatar="row.item.avatar || ''" :members="row.item.avatarMembers || []" :size="23" />
+              <TeamAvatar v-if="row.item.avatar !== undefined || row.item.avatarMembers?.length" :name="row.item.title" :avatar="row.item.avatar || ''" :members="row.item.avatarMembers || []" :fallback-key="row.item.id" :size="23" />
               <AppIcon v-else-if="row.item.icon" :name="row.item.icon" :size="15" />
               <template v-else>{{ row.item.title.slice(0, 1).toUpperCase() }}</template>
               <span v-if="row.item.status" class="presence" :class="`presence--${row.item.status}`" />

+ 7 - 35
src/client/components/common/TeamAvatar.vue

@@ -1,6 +1,6 @@
 <script setup lang="ts">
 import { computed } from 'vue'
-import AgentAvatar from '@/components/common/AgentAvatar.vue'
+import { resolvedTeamAvatarSource } from '@/utils/teamAvatar'
 
 export type TeamAvatarMember = {
   name: string
@@ -11,58 +11,30 @@ const props = withDefaults(defineProps<{
   name: string
   avatar?: string
   members?: TeamAvatarMember[]
+  fallbackKey?: string
   size?: number
 }>(), {
   avatar: '',
   members: () => [],
+  fallbackKey: '',
   size: 32,
 })
 
-const hasCustomAvatar = computed(() => /^data:image\/(png|jpeg|webp);base64,/i.test(props.avatar))
-const visibleMembers = computed(() => props.members.slice(0, 4))
-const memberSize = computed(() => Math.max(12, Math.round(props.size * ({ 1: .72, 2: .62, 3: .54, 4: .48 }[visibleMembers.value.length] || .48))))
+const avatarSource = computed(() => resolvedTeamAvatarSource(props.avatar, props.fallbackKey || props.name))
 </script>
 
 <template>
   <span
     class="team-avatar"
-    :class="`team-avatar--${Math.max(1, visibleMembers.length)}`"
     :style="{ width: `${size}px`, height: `${size}px` }"
     role="img"
     :aria-label="`${name}团队头像`"
   >
-    <img v-if="hasCustomAvatar" class="team-avatar__custom" :src="avatar" alt="" />
-    <template v-else-if="visibleMembers.length">
-      <span
-        v-for="(member, index) in visibleMembers"
-        :key="`${member.name}:${index}`"
-        class="team-avatar__member"
-        :style="{ width: `${memberSize}px`, height: `${memberSize}px` }"
-      >
-        <AgentAvatar :name="member.name" :avatar="member.avatar" :size="memberSize" />
-      </span>
-    </template>
-    <span v-else class="team-avatar__fallback">{{ name.trim().slice(0, 1).toUpperCase() || '团' }}</span>
+    <img class="team-avatar__image" :src="avatarSource" alt="" />
   </span>
 </template>
 
 <style scoped>
-.team-avatar { position: relative; display: block; flex: 0 0 auto; overflow: hidden; border-radius: 50%; background: var(--surface-raised); color: var(--text-secondary); }
-.team-avatar__custom { width: 100%; height: 100%; object-fit: cover; }
-.team-avatar__member { position: absolute; display: grid; place-items: center; overflow: hidden; border-radius: 50%; background: color-mix(in srgb, var(--surface-raised) 82%, var(--accent)); box-shadow: 0 0 0 max(1px, calc(v-bind(size) * .035)) var(--surface-raised); }
-.team-avatar__member :deep(.agent-avatar) { border-radius: 50%; }
-.team-avatar__member:nth-of-type(2) { background: color-mix(in srgb, var(--surface-raised) 84%, var(--danger)); }
-.team-avatar__member:nth-of-type(3) { background: color-mix(in srgb, var(--surface-raised) 84%, var(--success)); }
-.team-avatar__member:nth-of-type(4) { background: color-mix(in srgb, var(--surface-raised) 84%, var(--warning)); }
-.team-avatar--1 .team-avatar__member { top: 50%; left: 50%; transform: translate(-50%, -50%); }
-.team-avatar--2 .team-avatar__member { top: 50%; left: 8%; transform: translateY(-50%); }
-.team-avatar--2 .team-avatar__member:nth-of-type(2) { right: 8%; left: auto; }
-.team-avatar--3 .team-avatar__member:first-of-type { top: 2%; left: 50%; transform: translateX(-50%); }
-.team-avatar--3 .team-avatar__member:nth-of-type(2) { bottom: 3%; left: 4%; }
-.team-avatar--3 .team-avatar__member:nth-of-type(3) { right: 4%; bottom: 3%; }
-.team-avatar--4 .team-avatar__member:first-of-type { top: 4%; left: 4%; }
-.team-avatar--4 .team-avatar__member:nth-of-type(2) { top: 4%; right: 4%; }
-.team-avatar--4 .team-avatar__member:nth-of-type(3) { bottom: 4%; left: 4%; }
-.team-avatar--4 .team-avatar__member:nth-of-type(4) { right: 4%; bottom: 4%; }
-.team-avatar__fallback { display: grid; width: 100%; height: 100%; place-items: center; background: var(--surface-hover); font-size: calc(v-bind(size) * .36); font-weight: 700; }
+.team-avatar { position: relative; display: block; flex: 0 0 auto; overflow: hidden; border-radius: 50%; background: var(--surface-raised); }
+.team-avatar__image { display: block; width: 100%; height: 100%; object-fit: cover; }
 </style>

+ 9 - 6
src/client/components/groups/CreateGroupDialog.vue

@@ -3,7 +3,7 @@ import { computed, ref, watch } from 'vue'
 import AppIcon from '@/components/common/AppIcon.vue'
 import AgentAvatar from '@/components/common/AgentAvatar.vue'
 import TeamAvatar from '@/components/common/TeamAvatar.vue'
-import { processTeamAvatarFile } from '@/utils/teamAvatar'
+import { processTeamAvatarFile, randomTeamAnimalAvatar, TEAM_ANIMAL_AVATARS } from '@/utils/teamAvatar'
 import type { GroupProfileOption } from './types'
 import { TEAM_PRESETS, type TeamPreset } from './teamPresets'
 
@@ -39,7 +39,8 @@ const hostProfile = ref('')
 const autoReply = ref(true)
 const replyRounds = ref(3)
 const orchestrationMode = ref<'free' | 'host'>('free')
-const avatarMode = ref<'auto' | 'upload'>('auto')
+const avatarMode = ref<'animal' | 'upload'>('animal')
+const animalAvatar = ref<string>(TEAM_ANIMAL_AVATARS[0].value)
 const avatarDataURL = ref('')
 const avatarError = ref('')
 const avatarInput = ref<HTMLInputElement>()
@@ -67,7 +68,8 @@ watch(() => props.open, open => {
   autoReply.value = true
   replyRounds.value = 3
   orchestrationMode.value = 'free'
-  avatarMode.value = 'auto'
+  avatarMode.value = 'animal'
+  animalAvatar.value = randomTeamAnimalAvatar().value
   avatarDataURL.value = ''
   avatarError.value = ''
 }, { immediate: true })
@@ -137,7 +139,7 @@ function create() {
     autoReply: autoReply.value,
     replyRounds: replyRounds.value,
   }
-  if (props.avatarEnabled) payload.avatar = avatarMode.value === 'upload' ? avatarDataURL.value : ''
+  if (props.avatarEnabled) payload.avatar = avatarMode.value === 'upload' ? avatarDataURL.value : animalAvatar.value
   if (props.hostEnabled) payload.hostProfile = hostProfile.value
   if (props.hostFlowEnabled) payload.orchestrationMode = orchestrationMode.value
   if (props.roomInstructionsEnabled) payload.instructions = instructions.value.trim()
@@ -164,8 +166,8 @@ function create() {
           </section>
           <label class="field"><span>团队名称</span><input v-model="name" maxlength="80" autofocus placeholder="例如:产品评审" /></label>
           <section v-if="avatarEnabled" class="avatar-picker" aria-labelledby="team-avatar-label">
-            <TeamAvatar :name="name || '团队'" :avatar="avatarMode === 'upload' ? avatarDataURL : ''" :members="selectedMembers.map(member => ({ name: member.displayName, avatar: member.avatar }))" :size="58" />
-            <div><strong id="team-avatar-label">团队头像</strong><small>{{ avatarMode === 'auto' ? '自动组合已选 Agent 的头像' : '使用上传的图片' }}</small><p><button class="quiet-button" :class="{ active: avatarMode === 'auto' }" type="button" @click="avatarMode = 'auto'">自动生成</button><button class="quiet-button" type="button" @click="avatarInput?.click()"><AppIcon name="image" :size="14" />上传图片</button></p></div>
+            <TeamAvatar :name="name || '团队'" :avatar="avatarMode === 'upload' ? avatarDataURL : animalAvatar" :size="58" />
+            <div><strong id="team-avatar-label">团队头像</strong><small>{{ avatarMode === 'animal' ? '选择一个简洁的动物图标' : '使用上传的图片' }}</small><div class="animal-avatar-options" role="radiogroup" aria-label="动物团队头像"><button v-for="option in TEAM_ANIMAL_AVATARS" :key="option.id" type="button" role="radio" :aria-checked="avatarMode === 'animal' && animalAvatar === option.value" :aria-label="option.label" :class="{ selected: avatarMode === 'animal' && animalAvatar === option.value }" @click="animalAvatar = option.value; avatarMode = 'animal'"><img :src="option.src" alt="" /></button></div><p><button class="quiet-button" type="button" @click="animalAvatar = randomTeamAnimalAvatar().value; avatarMode = 'animal'">随机一个</button><button class="quiet-button" type="button" @click="avatarInput?.click()"><AppIcon name="image" :size="14" />上传图片</button></p></div>
             <input ref="avatarInput" class="sr-only" type="file" accept="image/png,image/jpeg,image/webp" @change="chooseAvatar" />
           </section>
           <p v-if="avatarError" class="avatar-error" role="alert">{{ avatarError }}</p>
@@ -217,6 +219,7 @@ header { display: flex; align-items: flex-start; justify-content: space-between;
 .preset-picker { margin-bottom: 15px; }.preset-picker__heading, .role-mapping__heading { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 7px; color: var(--text-secondary); font-size: 10px; }.preset-picker__heading small, .role-mapping__heading small { color: var(--text-muted); font-size: 9px; }.preset-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 7px; }.preset-grid button { position: relative; display: grid; min-height: 82px; align-content: start; gap: 4px; padding: 10px; border: 1px solid var(--line); border-radius: 11px; background: var(--surface-soft); color: var(--text-primary); cursor: pointer; text-align: left; }.preset-grid button:hover { border-color: var(--line-strong); background: var(--surface-hover); }.preset-grid button.selected { border-color: color-mix(in srgb, var(--accent) 58%, var(--line)); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 13%, transparent); }.preset-grid button:disabled { cursor: not-allowed; opacity: .48; }.preset-grid strong { padding-right: 42px; font-size: 10px; }.preset-grid small { color: var(--text-muted); font-size: 9px; line-height: 1.4; }.preset-grid em { position: absolute; top: 9px; right: 9px; color: var(--accent); font-size: 8px; font-style: normal; }
 .field { display: flex; flex-direction: column; gap: 6px; color: var(--text-secondary); font-size: 10px; }.field span { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }.field small { color: var(--text-muted); font-size: 9px; font-weight: 400; }.field input, .field textarea { padding: 0 10px; border: 1px solid var(--line); border-radius: 10px; outline: none; resize: vertical; background: var(--surface-soft); color: var(--text-primary); }.field input { height: 38px; }.field textarea { min-height: 86px; padding-block: 9px; line-height: 1.5; }.field input:focus, .field textarea:focus { border-color: var(--line-strong); box-shadow: 0 0 0 3px var(--focus-ring); }.room-instructions { margin-top: 13px; }
 .avatar-picker { display: flex; align-items: center; gap: 12px; margin-top: 13px; padding: 11px; border-radius: 11px; background: var(--surface-soft); }.avatar-picker > div { display: grid; min-width: 0; gap: 3px; }.avatar-picker strong { font-size: 10px; }.avatar-picker small { color: var(--text-muted); font-size: 9px; }.avatar-picker p { display: flex; gap: 5px; margin: 3px 0 0; }.avatar-picker .quiet-button { display: inline-flex; min-height: 27px; align-items: center; gap: 5px; padding: 0 8px; border: 1px solid transparent; border-radius: 7px; background: var(--surface-hover); color: var(--text-secondary); cursor: pointer; font-size: 9px; }.avatar-picker .quiet-button.active { border-color: color-mix(in srgb, var(--accent) 42%, var(--line)); color: var(--accent); }.avatar-error { margin: 6px 0 0; color: var(--danger); font-size: 9px; }.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
+.animal-avatar-options { display: flex; gap: 6px; margin-top: 5px; }.animal-avatar-options button { display: grid; width: 34px; height: 34px; place-items: center; padding: 0; overflow: hidden; border: 2px solid transparent; border-radius: 50%; background: transparent; cursor: pointer; }.animal-avatar-options button:hover { border-color: var(--line-strong); }.animal-avatar-options button.selected { border-color: var(--accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 14%, transparent); }.animal-avatar-options img { width: 100%; height: 100%; object-fit: cover; }
 .agent-picker { margin-top: 17px; }.agent-picker__heading { display: flex; justify-content: space-between; margin-bottom: 7px; color: var(--text-secondary); font-size: 10px; }.agent-picker__heading small { color: var(--text-muted); }.agent-picker > button { display: flex; width: 100%; min-height: 42px; align-items: center; gap: 9px; padding: 5px 8px; border: 0; border-radius: 9px; background: transparent; color: var(--text-primary); cursor: pointer; text-align: left; }.agent-picker > button:hover, .agent-picker > button.selected { background: var(--surface-soft); }.agent-picker > button:disabled { opacity: .35; }.agent-picker > button strong { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 2px; font-size: 11px; }.agent-picker > button strong small { overflow: hidden; color: var(--text-muted); font-size: 9px; font-weight: 400; text-overflow: ellipsis; white-space: nowrap; }.agent-picker p { color: var(--text-muted); font-size: 10px; }
 .role-mapping { margin-top: 17px; padding: 11px; border: 1px solid var(--line); border-radius: 11px; background: var(--surface-soft); }.role-mapping > label { display: grid; grid-template-columns: minmax(0, 1fr) minmax(180px, 42%); align-items: center; gap: 12px; padding: 8px 0; border-top: 1px solid var(--line); }.role-mapping > label:first-of-type { border-top: 0; }.role-mapping > label > span { display: flex; min-width: 0; flex-direction: column; gap: 3px; }.role-mapping strong { display: flex; align-items: center; gap: 6px; font-size: 10px; }.role-mapping strong em { padding: 2px 5px; border-radius: 999px; background: color-mix(in srgb, var(--accent) 12%, transparent); color: var(--accent); font-size: 8px; font-style: normal; font-weight: 600; }.role-mapping small { color: var(--text-muted); font-size: 9px; line-height: 1.4; }.role-mapping select { width: 100%; min-width: 0; padding: 7px 8px; border: 1px solid var(--line); border-radius: 8px; outline: none; background: var(--surface-raised); color: var(--text-primary); font-size: 9px; }.role-mapping select:focus { border-color: var(--line-strong); box-shadow: 0 0 0 3px var(--focus-ring); }
 .host-picker { display: grid; grid-template-columns: minmax(0, 1fr) 148px; align-items: center; gap: 12px; margin-top: 13px; padding: 11px; border: 1px solid var(--line); border-radius: 10px; color: var(--text-secondary); font-size: 10px; }.host-picker > span { display: flex; min-width: 0; flex-direction: column; gap: 3px; font-weight: 650; }.host-picker small { color: var(--text-muted); font-size: 9px; font-weight: 400; line-height: 1.45; }.host-picker select { width: 100%; min-width: 0; padding: 7px 8px; border: 1px solid var(--line); border-radius: 8px; outline: 0; background: var(--surface-soft); color: var(--text-primary); font-size: 10px; }.host-picker select:focus { border-color: var(--line-strong); box-shadow: 0 0 0 3px var(--focus-ring); }

+ 13 - 10
src/client/components/groups/GroupManager.vue

@@ -4,7 +4,7 @@ import type { GroupAgent, ModelOption } from '@shared/types'
 import AppIcon from '@/components/common/AppIcon.vue'
 import AgentAvatar from '@/components/common/AgentAvatar.vue'
 import TeamAvatar from '@/components/common/TeamAvatar.vue'
-import { processTeamAvatarFile } from '@/utils/teamAvatar'
+import { processTeamAvatarFile, randomTeamAnimalAvatar, teamAnimalAvatar, TEAM_ANIMAL_AVATARS } from '@/utils/teamAvatar'
 import type { UiRoom } from './types'
 import type { GroupProfileOption } from './types'
 
@@ -79,10 +79,7 @@ const dirtyAgents = reactive(new Set<string>())
 const selectedAgent = computed(() => props.agents.find(agent => agent.id === expandedAgentId.value))
 const hostAgent = computed(() => props.agents.find(agent => agent.isHost))
 const hasActiveAgents = computed(() => props.agents.some(agent => ['queued', 'running', 'awaiting_input'].includes(agent.status)))
-const avatarMembers = computed(() => props.agents.slice(0, 4).map(agent => ({
-  name: agent.displayName || agent.profile,
-  avatar: agent.nodeId === 'local' ? props.agentAvatars[agent.profile] : undefined,
-})))
+const selectedAnimalAvatar = computed(() => teamAnimalAvatar(form.avatar))
 
 function modelKey(provider?: string | null, model?: string | null): string {
   return provider && model ? JSON.stringify([provider, model]) : ''
@@ -159,12 +156,16 @@ async function chooseAvatar(event: Event) {
   }
 }
 
-function useAutomaticAvatar() {
-  form.avatar = ''
+function chooseAnimalAvatar(value: string) {
+  form.avatar = value
   avatarError.value = ''
   saveRoom()
 }
 
+function useRandomAvatar() {
+  chooseAnimalAvatar(randomTeamAnimalAvatar().value)
+}
+
 function selectHost(event: Event) {
   const id = (event.target as HTMLSelectElement).value
   if (!id || id === hostAgent.value?.id) return
@@ -258,12 +259,13 @@ function statusLabel(status: GroupAgent['status']): string {
       <h3>团队设置</h3>
       <label><span>名称</span><input v-model="form.name" maxlength="80" @change="saveRoom" /></label>
       <div v-if="avatarEnabled" class="team-avatar-setting">
-        <TeamAvatar :name="room.name" :avatar="form.avatar" :members="avatarMembers" :size="52" />
+        <TeamAvatar :name="room.name" :avatar="form.avatar" :fallback-key="room.id" :size="52" />
         <div>
           <strong>团队头像</strong>
-          <small>{{ form.avatar ? '使用上传的图片' : '自动组合当前 Agent 头像' }}</small>
+          <small>{{ selectedAnimalAvatar ? `当前图标:${selectedAnimalAvatar.label}` : form.avatar ? '使用上传的图片' : '旧组合头像已替换为动物图标' }}</small>
+          <span class="animal-avatar-options" role="radiogroup" aria-label="动物团队头像"><button v-for="option in TEAM_ANIMAL_AVATARS" :key="option.id" type="button" role="radio" :aria-checked="selectedAnimalAvatar?.value === option.value" :aria-label="option.label" :class="{ selected: selectedAnimalAvatar?.value === option.value }" :disabled="busy" @click="chooseAnimalAvatar(option.value)"><img :src="option.src" alt="" /></button></span>
           <span>
-            <button class="quiet-button" type="button" :disabled="busy || !form.avatar" @click="useAutomaticAvatar">自动生成</button>
+            <button class="quiet-button" type="button" :disabled="busy" @click="useRandomAvatar">随机一个</button>
             <button class="quiet-button" type="button" :disabled="busy" @click="avatarInput?.click()"><AppIcon name="image" :size="14" />上传图片</button>
           </span>
         </div>
@@ -379,6 +381,7 @@ h3 { margin: 0 0 13px; color: var(--text-secondary); font-size: 10px; font-weigh
 label { display: flex; flex-direction: column; gap: 5px; margin: 0 0 11px; color: var(--text-muted); font-size: 10px; }
 input, textarea, select { width: 100%; padding: 8px 9px; border: 1px solid var(--line); border-radius: 9px; outline: 0; resize: vertical; background: var(--surface-soft); color: var(--text-primary); font-size: 11px; } input:focus, textarea:focus, select:focus { border-color: var(--line-strong); box-shadow: 0 0 0 3px var(--focus-ring); }
 .team-avatar-setting { display: flex; align-items: center; gap: 12px; margin: 0 0 13px; padding: 10px; border-radius: 11px; background: var(--surface-soft); }.team-avatar-setting > div { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 3px; }.team-avatar-setting strong { color: var(--text-secondary); font-size: 10px; }.team-avatar-setting small { color: var(--text-muted); font-size: 9px; }.team-avatar-setting span { display: flex; gap: 6px; margin-top: 4px; }.team-avatar-setting .quiet-button { display: inline-flex; min-height: 28px; align-items: center; gap: 5px; padding: 0 8px; border: 1px solid var(--line); border-radius: 7px; background: var(--surface); color: var(--text-secondary); cursor: pointer; font-size: 9px; }.team-avatar-setting .quiet-button:disabled { cursor: not-allowed; opacity: .4; }.avatar-error { margin: -7px 0 11px; color: var(--danger); font-size: 9px; }.sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
+.team-avatar-setting .animal-avatar-options { display: flex; gap: 6px; margin-top: 5px; }.team-avatar-setting .animal-avatar-options button { display: grid; width: 32px; height: 32px; place-items: center; padding: 0; overflow: hidden; border: 2px solid transparent; border-radius: 50%; background: transparent; cursor: pointer; }.team-avatar-setting .animal-avatar-options button:hover { border-color: var(--line-strong); }.team-avatar-setting .animal-avatar-options button.selected { border-color: var(--accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 14%, transparent); }.team-avatar-setting .animal-avatar-options button:disabled { cursor: not-allowed; opacity: .45; }.team-avatar-setting .animal-avatar-options img { width: 100%; height: 100%; object-fit: cover; }
 .rounds { flex-direction: row; align-items: center; justify-content: space-between; }.rounds > span { display: flex; flex-direction: column; gap: 2px; }.rounds small { color: var(--text-muted); font-size: 9px; }.rounds input { width: 62px; text-align: center; }
 .flow-mode { display: grid; grid-template-columns: minmax(0, 1fr) 128px; align-items: center; gap: 10px; }.flow-mode > span { display: flex; flex-direction: column; gap: 2px; color: var(--text-secondary); font-weight: 650; }.flow-mode small { color: var(--text-muted); font-size: 9px; font-weight: 400; line-height: 1.4; }.flow-mode select { margin: 0; padding: 7px 8px; background: var(--surface); }
 .host-selector { display: grid; grid-template-columns: minmax(0, 1fr) 128px; align-items: center; gap: 10px; margin-bottom: 10px; padding: 9px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface-soft); }.host-selector > span { display: flex; min-width: 0; flex-direction: column; gap: 2px; color: var(--text-secondary); font-weight: 650; }.host-selector small { color: var(--text-muted); font-size: 9px; font-weight: 400; line-height: 1.4; }.host-selector select { padding: 7px 8px; background: var(--surface); }

+ 38 - 0
src/client/utils/teamAvatar.ts

@@ -1,3 +1,41 @@
+import foxAvatar from '@/assets/team-avatars/fox.png'
+import whaleAvatar from '@/assets/team-avatars/whale.png'
+import owlAvatar from '@/assets/team-avatars/owl.png'
+import rabbitAvatar from '@/assets/team-avatars/rabbit.png'
+import bearAvatar from '@/assets/team-avatars/bear.png'
+
+export const TEAM_ANIMAL_AVATAR_PREFIX = 'builtin:team-animal:'
+
+export const TEAM_ANIMAL_AVATARS = [
+  { id: 'fox', label: '狐狸', value: `${TEAM_ANIMAL_AVATAR_PREFIX}fox`, src: foxAvatar },
+  { id: 'whale', label: '鲸鱼', value: `${TEAM_ANIMAL_AVATAR_PREFIX}whale`, src: whaleAvatar },
+  { id: 'owl', label: '猫头鹰', value: `${TEAM_ANIMAL_AVATAR_PREFIX}owl`, src: owlAvatar },
+  { id: 'rabbit', label: '兔子', value: `${TEAM_ANIMAL_AVATAR_PREFIX}rabbit`, src: rabbitAvatar },
+  { id: 'bear', label: '小熊', value: `${TEAM_ANIMAL_AVATAR_PREFIX}bear`, src: bearAvatar },
+] as const
+
+export function teamAnimalAvatar(value?: string) {
+  return TEAM_ANIMAL_AVATARS.find(avatar => avatar.value === value)
+}
+
+export function randomTeamAnimalAvatar(): typeof TEAM_ANIMAL_AVATARS[number] {
+  return TEAM_ANIMAL_AVATARS[Math.floor(Math.random() * TEAM_ANIMAL_AVATARS.length)]!
+}
+
+export function fallbackTeamAnimalAvatar(key: string): typeof TEAM_ANIMAL_AVATARS[number] {
+  let hash = 2166136261
+  for (const byte of new TextEncoder().encode(key || 'team')) {
+    hash ^= byte
+    hash = Math.imul(hash, 16777619) >>> 0
+  }
+  return TEAM_ANIMAL_AVATARS[hash % TEAM_ANIMAL_AVATARS.length]!
+}
+
+export function resolvedTeamAvatarSource(value: string | undefined, fallbackKey: string): string {
+  if (value && /^data:image\/(png|jpeg|webp);base64,/i.test(value)) return value
+  return (teamAnimalAvatar(value) ?? fallbackTeamAnimalAvatar(fallbackKey)).src
+}
+
 const SUPPORTED_TEAM_AVATAR_TYPES = new Set([
   'image/png',
   'image/jpeg',

+ 10 - 4
tests/client/groupHost.test.ts

@@ -6,6 +6,7 @@ import CreateGroupDialog from '@/components/groups/CreateGroupDialog.vue'
 import GroupManager from '@/components/groups/GroupManager.vue'
 import { TEAM_PRESETS } from '@/components/groups/teamPresets'
 import { groupMessageToUi, roomSidebarItem } from '@/components/workspace/viewModels'
+import { fallbackTeamAnimalAvatar } from '@/utils/teamAvatar'
 
 const profiles = (...names: string[]) => names.map(name => ({
   id: `local|${name}`, profile: name, displayName: name, nodeId: 'local', nodeLabel: '当前 Hermes',
@@ -21,6 +22,10 @@ function agent(id: string, profile: string, displayName: string, isHost: boolean
 }
 
 describe('group host controls', () => {
+  it('uses the shared stable animal mapping for legacy empty avatars', () => {
+    expect(fallbackTeamAnimalAvatar('22222222-2222-4222-8222-222222222222').id).toBe('bear')
+  })
+
   it('provides office and development presets with unique roles within the Agent limit', () => {
     expect(TEAM_PRESETS.map(preset => preset.name)).toEqual([
       '信息收集团队', '产品设计团队', '软件开发团队', '文案书写团队',
@@ -211,7 +216,7 @@ describe('group host controls', () => {
     wrapper.unmount()
   })
 
-  it('shows and submits an automatic team avatar from selected Agents', async () => {
+  it('shows five animal choices and submits a randomly initialized team avatar', async () => {
     const choices = profiles('yaoyao', 'yaoer').map((profile, index) => ({
       ...profile,
       ...(index === 0 ? { avatar: 'data:image/png;base64,AA==' } : {}),
@@ -223,14 +228,15 @@ describe('group host controls', () => {
     })
     await wrapper.get('input[placeholder="例如:产品评审"]').setValue('头像团队')
     await wrapper.findAll('.agent-picker > button')[1]!.trigger('click')
-    expect(wrapper.findAll('.avatar-picker .team-avatar__member')).toHaveLength(2)
-    expect(wrapper.get('.avatar-picker').text()).toContain('自动组合已选 Agent 的头像')
+    expect(wrapper.findAll('.avatar-picker .animal-avatar-options button')).toHaveLength(5)
+    expect(wrapper.get('.avatar-picker').text()).toContain('选择一个简洁的动物图标')
+    expect(wrapper.get('.avatar-picker .team-avatar__image').attributes('src')).toMatch(/\.png$/)
     await wrapper.get('.solid-button').trigger('click')
     expect(wrapper.emitted('create')?.[0]?.[0]).toMatchObject({
       name: '头像团队',
-      avatar: '',
       members: choices,
     })
+    expect((wrapper.emitted('create')?.[0]?.[0] as { avatar?: string }).avatar).toMatch(/^builtin:team-animal:(fox|whale|owl|rabbit|bear)$/)
     wrapper.unmount()
   })
 

+ 10 - 23
tests/e2e/workspace.spec.ts

@@ -118,37 +118,24 @@ test('uses a floating search dialog for group rooms', async ({ page }) => {
   await expect(searchDialog).toBeHidden()
 })
 
-test('shows the team avatar in the list and automatic avatar controls during creation', async ({ page }) => {
+test('shows the team animal avatar in the list and five choices during creation', async ({ page }) => {
   await page.getByRole('button', { name: '团队' }).click()
 
   const roomRow = page.locator('[data-sidebar-id="22222222-2222-4222-8222-222222222222"]')
   const roomAvatar = roomRow.getByRole('img', { name: '设计与工程协作团队头像' })
   await expect(roomAvatar).toBeVisible()
-  await expect(roomAvatar.locator('.team-avatar__member')).toHaveCount(2)
-  await expect(roomAvatar).toContainText('夭')
-  await expect(roomAvatar).toContainText('瑶')
-  const avatarGeometry = await roomAvatar.evaluate(element => {
-    const outer = element.getBoundingClientRect()
-    return [...element.querySelectorAll<HTMLElement>('.team-avatar__member')].map(member => {
-      const rect = member.getBoundingClientRect()
-      return { width: rect.width, height: rect.height, outerWidth: outer.width, radius: getComputedStyle(member).borderRadius }
-    })
-  })
-  expect(avatarGeometry).toHaveLength(2)
-  for (const member of avatarGeometry) {
-    expect(member.width).toBeLessThan(member.outerWidth * .75)
-    expect(member.height).toBe(member.width)
-    expect(member.radius).toBe('50%')
-  }
+  await expect(roomAvatar.locator('.team-avatar__image')).toBeVisible()
+  await expect(roomAvatar).toHaveCSS('border-radius', '50%')
 
   await page.getByRole('button', { name: '新建团队' }).click()
   const dialog = page.getByRole('dialog', { name: '新建团队' })
   await expect(dialog.getByRole('region', { name: '团队预设' })).toBeVisible()
   await expect(dialog.getByRole('button', { name: /信息收集团队/ })).toBeDisabled()
   await expect(dialog.getByRole('button', { name: /信息收集团队/ })).toContainText('还缺 2 人')
-  await expect(dialog.getByText('自动组合已选 Agent 的头像')).toBeVisible()
+  await expect(dialog.getByText('选择一个简洁的动物图标')).toBeVisible()
+  await expect(dialog.locator('.animal-avatar-options button')).toHaveCount(5)
   await expect(dialog.getByRole('button', { name: '上传图片' })).toBeVisible()
-  await expect(dialog.getByRole('button', { name: '自动生成' })).toBeVisible()
+  await expect(dialog.getByRole('button', { name: '随机一个' })).toBeVisible()
   await dialog.locator('input[type="file"]').setInputFiles('public/brand/AppIcon-1024.png')
   await expect(dialog.getByText('使用上传的图片')).toBeVisible()
   await expect(dialog.getByRole('img', { name: '团队团队头像' }).locator('img')).toBeVisible()
@@ -167,10 +154,10 @@ test('updates an existing team avatar from the Web manager', async ({ page }) =>
   await expect(manager.getByText('使用上传的图片')).toBeVisible()
   await expect(preview.locator('img')).toBeVisible()
 
-  const automaticRequest = page.waitForRequest(request => request.method() === 'PATCH' && request.url().endsWith('/rooms/22222222-2222-4222-8222-222222222222'))
-  await manager.getByRole('button', { name: '自动生成' }).click()
-  expect((await automaticRequest).postDataJSON()).toMatchObject({ avatar: '' })
-  await expect(manager.getByText('自动组合当前 Agent 头像')).toBeVisible()
+  const animalRequest = page.waitForRequest(request => request.method() === 'PATCH' && request.url().endsWith('/rooms/22222222-2222-4222-8222-222222222222'))
+  await manager.getByRole('radio', { name: '狐狸' }).click()
+  expect((await animalRequest).postDataJSON()).toMatchObject({ avatar: 'builtin:team-animal:fox' })
+  await expect(manager.getByText('当前图标:狐狸')).toBeVisible()
 })
 
 test('selects one protocol v5 host independently from no-mention replies', async ({ page }) => {