Parcourir la source

完善群聊成员组合头像并移除动物头像选择

LSamien il y a 2 mois
Parent
commit
4e89792ffc

+ 9 - 0
design-qa.md

@@ -96,3 +96,12 @@ final result: passed
 - 无剩余 P0、P1 或 P2 视觉问题。
 
 final result: passed
+
+## 群聊组合头像补充验收
+
+- 浏览器证据:`/Users/samien/.codex/visualizations/2026/09/02/01a05f7c-9ccc-76f2-99f3-116cd8ca64cb/group-mascot/web.jpg`。
+- 群聊默认头像由前三位成员角色组成;不足三位时使用圆、方、短三角补足,旧动物值按新组合头像显示。
+- 上传图片仍优先;创建与管理界面改为“成员组合 / 上传图片”,不再出现动物选择器。
+- `1440 × 1024` 浏览器检查确认三个成员均可辨识,短三角未被完全遮挡,控制台无 warning/error。
+
+final result: passed

+ 8 - 0
src/client/components/app/AgentIdentityFixture.vue

@@ -1,5 +1,6 @@
 <script setup lang="ts">
 import AgentAvatar from '@/components/common/AgentAvatar.vue'
+import TeamAvatar from '@/components/common/TeamAvatar.vue'
 
 const shapes = [
   { name: '圆圆', avatar: 'yaoyao-mascot:v1:circle:0ea5c6:friendly' },
@@ -12,6 +13,8 @@ const states = [
   { name: '有新消息', detail: '头像弹跳并提醒', avatar: 'yaoyao-mascot:v1:triangle:d94b52:curious', state: 'notifying' },
   { name: '等待你操作', detail: '状态驱动的眼睛和身体动作', avatar: 'yaoyao-mascot:v1:square:377fe6:curious', state: 'waiting' },
 ] as const
+
+const teamMembers = shapes.map(item => ({ name: item.name, avatar: item.avatar }))
 </script>
 
 <template>
@@ -33,9 +36,14 @@ const states = [
         <div><strong>{{ item.name }}</strong><span>{{ item.detail }}</span></div>
       </article>
     </section>
+    <section class="group-preview" aria-label="群聊组合头像">
+      <TeamAvatar name="产品设计团队" :members="teamMembers" fallback-key="design-team" :size="92" />
+      <div><strong>产品设计团队</strong><span>群聊图标由成员角色自动组成</span></div>
+    </section>
   </main>
 </template>
 
 <style scoped>
 .identity-fixture{min-height:100vh;box-sizing:border-box;padding:72px clamp(28px,7vw,100px);background:var(--canvas);color:var(--text-primary)}header{max-width:850px}header>span{color:var(--accent);font-size:13px;font-weight:750;letter-spacing:.12em;text-transform:uppercase}h1{margin:13px 0 12px;font-size:clamp(38px,5vw,68px);letter-spacing:-.045em;line-height:1.02}header p{max-width:720px;margin:0;color:var(--text-muted);font-size:18px;line-height:1.65}.shape-row{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));max-width:760px;gap:28px;margin-top:60px}.shape-row article{display:grid;place-items:center;gap:16px;min-height:160px;border:1px solid var(--line);border-radius:22px;background:var(--surface-raised)}.shape-row strong{font-size:14px}.state-list{display:grid;max-width:760px;gap:14px;margin-top:28px}.state-list article{display:flex;align-items:center;gap:20px;padding:19px 24px;border:1px solid var(--line);border-radius:20px;background:var(--surface-soft)}.state-list div{display:grid;gap:5px}.state-list strong{font-size:18px}.state-list span{color:var(--text-muted);font-size:13px}@media(max-width:620px){.identity-fixture{padding:44px 22px}.shape-row{gap:10px}.shape-row article{min-height:132px}.state-list article{padding:16px}}
+.group-preview{display:flex;max-width:712px;align-items:center;gap:28px;margin-top:18px;padding:24px;border:1px solid var(--line);border-radius:22px;background:var(--surface-raised)}.group-preview>div{display:grid;gap:6px}.group-preview strong{font-size:20px}.group-preview span{color:var(--text-muted);font-size:13px}
 </style>

+ 51 - 5
src/client/components/common/TeamAvatar.vue

@@ -1,6 +1,7 @@
 <script setup lang="ts">
 import { computed } from 'vue'
-import { resolvedTeamAvatarSource } from '@/utils/teamAvatar'
+import AgentAvatar from './AgentAvatar.vue'
+import { AGENT_MASCOT_COLORS, AGENT_MASCOT_SHAPES } from '@shared/agentIdentity'
 
 export type TeamAvatarMember = {
   name: string
@@ -20,7 +21,36 @@ const props = withDefaults(defineProps<{
   size: 32,
 })
 
-const avatarSource = computed(() => resolvedTeamAvatarSource(props.avatar, props.fallbackKey || props.name))
+const hasUploadedImage = computed(() => /^data:image\/(png|jpeg|webp);base64,/i.test(props.avatar))
+
+function stableIndex(value: string, count: number): number {
+  let hash = 2166136261
+  for (const byte of new TextEncoder().encode(value || 'team')) {
+    hash ^= byte
+    hash = Math.imul(hash, 16777619) >>> 0
+  }
+  return hash % count
+}
+
+const clusterMembers = computed(() => {
+  const entries = props.members.slice(0, 3).map(member => ({
+    name: member.name,
+    avatar: member.avatar || '',
+  }))
+  const key = props.fallbackKey || props.name || 'team'
+  while (entries.length < 3) {
+    const index = entries.length
+    const shape = AGENT_MASCOT_SHAPES[index]!
+    const color = AGENT_MASCOT_COLORS[
+      stableIndex(`${key}:group:${index}`, AGENT_MASCOT_COLORS.length)
+    ]!.slice(1)
+    entries.push({
+      name: `${props.name || '团队'} ${index + 1}`,
+      avatar: `yaoyao-mascot:v1:${shape}:${color}:friendly`,
+    })
+  }
+  return entries
+})
 </script>
 
 <template>
@@ -30,11 +60,27 @@ const avatarSource = computed(() => resolvedTeamAvatarSource(props.avatar, props
     role="img"
     :aria-label="`${name}团队头像`"
   >
-    <img class="team-avatar__image" :src="avatarSource" alt="" />
+    <img v-if="hasUploadedImage" class="team-avatar__image" :src="avatar" alt="" />
+    <span v-else class="team-avatar__cluster" aria-hidden="true">
+      <AgentAvatar
+        v-for="(member, index) in clusterMembers"
+        :key="`${member.name}:${index}`"
+        class="team-avatar__member"
+        :class="`team-avatar__member--${index + 1}`"
+        :name="member.name"
+        :avatar="member.avatar"
+        :size="Math.round(size * .64)"
+      />
+    </span>
   </span>
 </template>
 
 <style scoped>
-.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; }
+.team-avatar { position: relative; display: block; flex: 0 0 auto; overflow: visible; }
+.team-avatar__image { display: block; width: 100%; height: 100%; object-fit: cover; border-radius: 50%; box-shadow: inset 0 0 0 1px var(--line); }
+.team-avatar__cluster { position: absolute; inset: 0; display: block; }
+.team-avatar__member { position: absolute; filter: drop-shadow(0 1px 1px color-mix(in srgb, #000 18%, transparent)); }
+.team-avatar__member--1 { z-index: 3; left: 0; top: 18%; }
+.team-avatar__member--2 { z-index: 2; top: 0; right: 0; }
+.team-avatar__member--3 { z-index: 1; right: 1%; bottom: 0; }
 </style>

+ 6 - 9
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, randomTeamAnimalAvatar, TEAM_ANIMAL_AVATARS } from '@/utils/teamAvatar'
+import { processTeamAvatarFile } from '@/utils/teamAvatar'
 import type { GroupProfileOption } from './types'
 import { TEAM_PRESETS, type TeamPreset } from './teamPresets'
 
@@ -39,8 +39,7 @@ const hostProfile = ref('')
 const autoReply = ref(true)
 const replyRounds = ref(3)
 const orchestrationMode = ref<'free' | 'host'>('free')
-const avatarMode = ref<'animal' | 'upload'>('animal')
-const animalAvatar = ref<string>(TEAM_ANIMAL_AVATARS[0].value)
+const avatarMode = ref<'dynamic' | 'upload'>('dynamic')
 const avatarDataURL = ref('')
 const avatarError = ref('')
 const avatarInput = ref<HTMLInputElement>()
@@ -68,8 +67,7 @@ watch(() => props.open, open => {
   autoReply.value = true
   replyRounds.value = 3
   orchestrationMode.value = 'free'
-  avatarMode.value = 'animal'
-  animalAvatar.value = randomTeamAnimalAvatar().value
+  avatarMode.value = 'dynamic'
   avatarDataURL.value = ''
   avatarError.value = ''
 }, { immediate: true })
@@ -139,7 +137,7 @@ function create() {
     autoReply: autoReply.value,
     replyRounds: replyRounds.value,
   }
-  if (props.avatarEnabled) payload.avatar = avatarMode.value === 'upload' ? avatarDataURL.value : animalAvatar.value
+  if (props.avatarEnabled && avatarMode.value === 'upload') payload.avatar = avatarDataURL.value
   if (props.hostEnabled) payload.hostProfile = hostProfile.value
   if (props.hostFlowEnabled) payload.orchestrationMode = orchestrationMode.value
   if (props.roomInstructionsEnabled) payload.instructions = instructions.value.trim()
@@ -166,8 +164,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 : 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>
+            <TeamAvatar :name="name || '团队'" :avatar="avatarMode === 'upload' ? avatarDataURL : ''" :members="selectedMembers.map(member => ({ name: member.displayName, avatar: member.avatar }))" :fallback-key="name || 'team'" :size="58" />
+            <div><strong id="team-avatar-label">团队头像</strong><small>{{ avatarMode === 'dynamic' ? '由成员的动态角色自动组成' : '使用上传的图片' }}</small><p><button class="quiet-button" :class="{ active: avatarMode === 'dynamic' }" type="button" @click="avatarMode = 'dynamic'">成员组合</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>
@@ -220,7 +218,6 @@ 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); }

+ 10 - 13
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, randomTeamAnimalAvatar, teamAnimalAvatar, TEAM_ANIMAL_AVATARS } from '@/utils/teamAvatar'
+import { processTeamAvatarFile } from '@/utils/teamAvatar'
 import type { UiRoom } from './types'
 import type { GroupProfileOption } from './types'
 
@@ -79,7 +79,10 @@ 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 selectedAnimalAvatar = computed(() => teamAnimalAvatar(form.avatar))
+const avatarMembers = computed(() => props.agents.map(agent => ({
+  name: agent.displayName,
+  avatar: agent.nodeId === 'local' ? props.agentAvatars[agent.profile] || '' : '',
+})))
 
 function modelKey(provider?: string | null, model?: string | null): string {
   return provider && model ? JSON.stringify([provider, model]) : ''
@@ -156,16 +159,12 @@ async function chooseAvatar(event: Event) {
   }
 }
 
-function chooseAnimalAvatar(value: string) {
-  form.avatar = value
+function useDynamicAvatar() {
+  form.avatar = ''
   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
@@ -259,13 +258,12 @@ 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" :fallback-key="room.id" :size="52" />
+        <TeamAvatar :name="room.name" :avatar="form.avatar" :members="avatarMembers" :fallback-key="room.id" :size="52" />
         <div>
           <strong>团队头像</strong>
-          <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>
+          <small>{{ form.avatar.startsWith('data:image/') ? '使用上传的图片' : '由成员的动态角色自动组成' }}</small>
           <span>
-            <button class="quiet-button" type="button" :disabled="busy" @click="useRandomAvatar">随机一个</button>
+            <button class="quiet-button" type="button" :disabled="busy" @click="useDynamicAvatar">成员组合</button>
             <button class="quiet-button" type="button" :disabled="busy" @click="avatarInput?.click()"><AppIcon name="image" :size="14" />上传图片</button>
           </span>
         </div>
@@ -381,7 +379,6 @@ 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); }

+ 5 - 5
tests/client/groupHost.test.ts

@@ -217,7 +217,7 @@ describe('group host controls', () => {
     wrapper.unmount()
   })
 
-  it('shows five animal choices and submits a randomly initialized team avatar', async () => {
+  it('builds a team avatar from member mascots without persisting a legacy animal', async () => {
     const choices = profiles('yaoyao', 'yaoer').map((profile, index) => ({
       ...profile,
       ...(index === 0 ? { avatar: 'data:image/png;base64,AA==' } : {}),
@@ -229,15 +229,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 .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$/)
+    expect(wrapper.findAll('.avatar-picker .team-avatar__member')).toHaveLength(3)
+    expect(wrapper.get('.avatar-picker').text()).toContain('由成员的动态角色自动组成')
+    expect(wrapper.findAll('.avatar-picker .agent-avatar--image')).toHaveLength(1)
     await wrapper.get('.solid-button').trigger('click')
     expect(wrapper.emitted('create')?.[0]?.[0]).toMatchObject({
       name: '头像团队',
       members: choices,
     })
-    expect((wrapper.emitted('create')?.[0]?.[0] as { avatar?: string }).avatar).toMatch(/^builtin:team-animal:(fox|whale|owl|rabbit|bear)$/)
+    expect((wrapper.emitted('create')?.[0]?.[0] as { avatar?: string }).avatar).toBeUndefined()
     wrapper.unmount()
   })
 

+ 13 - 13
tests/e2e/workspace.spec.ts

@@ -145,26 +145,25 @@ test('uses split search for topics, teams, and archived content', async ({ page
   await expect(searchDialog).toBeHidden()
 })
 
-test('shows the team animal avatar in the list and five choices during creation', async ({ page }) => {
+test('shows member mascot clusters in the list and during creation', async ({ page }) => {
   await page.getByRole('button', { name: '团队' }).click()
 
   const topicRow = page.locator('[data-sidebar-id="topic:22222222-2222-4222-8222-222222222222:77777777-7777-4777-8777-777777777777"]')
   const roomAvatar = topicRow.getByRole('img', { name: '设计验收团队头像' })
   await expect(roomAvatar).toBeVisible()
-  await expect(roomAvatar.locator('.team-avatar__image')).toBeVisible()
-  await expect(roomAvatar).toHaveCSS('border-radius', '50%')
-  const releaseTopicAvatar = page.locator('[data-sidebar-id="topic:22222222-2222-4222-8222-222222222222:88888888-8888-4888-8888-888888888888"] .team-avatar__image').first()
-  await expect(releaseTopicAvatar).toHaveAttribute('src', await roomAvatar.locator('.team-avatar__image').getAttribute('src') || '')
+  await expect(roomAvatar.locator('.team-avatar__member')).toHaveCount(3)
+  const releaseTopicAvatar = page.locator('[data-sidebar-id="topic:22222222-2222-4222-8222-222222222222:88888888-8888-4888-8888-888888888888"]').first().locator('.team-avatar__member')
+  await expect(releaseTopicAvatar).toHaveCount(3)
 
   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('选择一个简洁的动物图标')).toBeVisible()
-  await expect(dialog.locator('.animal-avatar-options button')).toHaveCount(5)
+  await expect(dialog.getByText('由成员的动态角色自动组成')).toBeVisible()
+  await expect(dialog.getByRole('button', { name: '成员组合' })).toBeVisible()
+  await expect(dialog.locator('.team-avatar__member')).toHaveCount(3)
   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()
@@ -176,17 +175,18 @@ test('updates an existing team avatar from the Web manager', async ({ page }) =>
 
   const manager = page.locator('.group-manager')
   const preview = manager.getByRole('img', { name: '设计与工程协作团队头像' })
-  await expect(preview).toHaveCSS('border-radius', '50%')
+  await expect(preview.locator('.team-avatar__member')).toHaveCount(3)
   const uploadRequest = page.waitForRequest(request => request.method() === 'PATCH' && request.url().endsWith('/rooms/22222222-2222-4222-8222-222222222222'))
   await manager.locator('input[type="file"]').setInputFiles('public/brand/AppIcon-1024.png')
   expect((await uploadRequest).postDataJSON().avatar).toMatch(/^data:image\/png;base64,/)
   await expect(manager.getByText('使用上传的图片')).toBeVisible()
   await expect(preview.locator('img')).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()
+  const dynamicRequest = page.waitForRequest(request => request.method() === 'PATCH' && request.url().endsWith('/rooms/22222222-2222-4222-8222-222222222222'))
+  await manager.getByRole('button', { name: '成员组合' }).click()
+  expect((await dynamicRequest).postDataJSON()).toMatchObject({ avatar: '' })
+  await expect(manager.getByText('由成员的动态角色自动组成')).toBeVisible()
+  await expect(preview.locator('.team-avatar__member')).toHaveCount(3)
 })
 
 test('selects one protocol v5 host independently from no-mention replies', async ({ page }) => {