# 技术方案 - REQ-用户-001 群聊双模式 > **版本**: v1.0 > **日期**: 2026-07-14 > **REQ编号**: REQ-用户-001 > **关联PRD**: `01-产品文档/05-用户端H5/PRD-REQ-用户-001-群聊双模式-v1.0.md` > **状态**: 待评审 > **作者**: 高见远(架构师) --- ## Part A: System Design ### 1. 实现方案 #### 1.1 核心技术挑战 | 挑战 | 说明 | |------|------| | **双端数据源异构** | H5 端从 `ConversationStore` 获取参与者(owner + agent + invited),坐席端从 Props 获取(primary_agent + collaborators + owner + invited),数据结构不同但需统一渲染逻辑 | | **角色边框统一** | 4 种角色(主责坐席/协作坐席/发起人/被邀请人)需不同的头像边框色,且"自己"需额外高亮环,需在不修改后端的前提下前端推断角色 | | **H5 弹出面板与布局** | van-popup 为独立浮层,需遮罩输入框但不影响上方聊天记录可见性,关闭后恢复布局 | | **坐席端就地展开** | 展开面板向下撑开 ~150px,消息区被 flex 压缩但需保持最小 200px 高度可滚动 | | **超员溢出处理** | H5 端 N=4、坐席端 N=6,超出显示"+N"文本,不横向滚动 | #### 1.2 方案选型与架构模式 **总体策略:数据规范化层 + 双模式组件** 引入一个 **`useParticipantDisplay` composable**(数据规范化层),将异构的原始数据统一为 `NormalizedParticipant[]`,供缩略条和展开面板共用。组件层分两个模式:缩略模式(紧凑头像条)和展开模式(详情列表)。 ``` ┌──────────────────────────────────────────────────────┐ │ 原始数据源 │ │ H5: store.currentConversation + store.participants │ │ Agent: Props (participants, agentName, employee...) │ └──────────────┬───────────────────────┬───────────────┘ │ │ ┌────────▼────────┐ ┌────────▼────────┐ │ useParticipant │ │ useParticipant │ │ Display (H5) │ │ Display (Agent) │ │ 规范化 → 统一 │ │ 规范化 → 统一 │ │ NormalizedPar │ │ NormalizedPar │ │ ticipant[] │ │ ticipant[] │ └────────┬────────┘ └────────┬────────┘ │ │ ┌────────▼────────┐ ┌────────▼────────┐ │ ParticipantStrip │ │ ParticipantBar │ │ (缩略模式 H5) │ │ (缩略模式 Agent) │ │ + ParticipantList│ │ + ExpandedPanel │ │ (展开模式 H5) │ │ (展开模式) │ └─────────────────┘ └─────────────────┘ ``` **为什么这样设计:** 1. **数据规范化层隔离数据源差异** — composable 负责将 owner/agent/collaborator/invited 拼装为统一列表并推断角色,组件只需消费 `NormalizedParticipant[]`,不关心数据来源。若后端将来增加角色字段,只需改 composable。 2. **角色推断纯前端** — 无需后端改动。H5 端:`conv.agent_name` → 主责坐席,`conv.employee_id` → 发起人,其余为被邀请人。坐席端:`assigned_agent_name` → 主责坐席,`collaborating_agent_ids` → 协作坐席,`employee_name` → 发起人,`participants` → 被邀请人。 3. **缩略/展开组件解耦** — 缩略条只负责"概览",展开面板只负责"详情",各自独立渲染,通过 composable 共享数据,互不耦合。 **框架/组件选型:** | 端 | 用途 | 选型 | 理由 | |----|------|------|------| | H5 | 展开模式弹出层 | Vant4 `van-popup` `position="bottom"` `round` | PRD 已指定方案B,Vant 原生支持下滑关闭/遮罩关闭/圆角 | | H5 | 退出确认 | Vant4 `showConfirmDialog` | 现有代码已使用,保持一致 | | Agent | 展开面板动画 | CSS `transition` (max-height + opacity) | 轻量,无需额外动画库,≤200ms 满足 PRD | | Agent | 头像 tooltip | Element Plus `el-tooltip` | PRD P1 要求,Element Plus 原生组件 | | 共用 | 头像降级 | `` + `@error` → 首字母 | 现有代码已实现此策略,保持一致 | | 共用 | 状态管理 | Vue3 `ref`/`computed` + composable | 项目已有模式 | **架构模式:Composable + 组件化(Vue3 Composition API)** #### 1.3 不选型说明 - **不引入新 npm 包** — 所有功能均可通过现有 Vant4 / Element Plus / Vue3 原生能力实现 - **不使用 Vuex/Pinia 新 store 模块** — 展开状态为 UI 局部状态,用组件 `ref` + `Map` 记忆即可,无需全局 store - **不修改后端** — 参与者数据 API 已有,角色由前端推断 --- ### 2. 文件列表 | # | 文件路径 | 操作 | 说明 | |---|---------|------|------| | 1 | `frontend-h5/src/composables/useParticipantDisplay.ts` | **新建** | H5 端参与者数据规范化 composable | | 2 | `frontend-h5/src/components/chat/ParticipantStrip.vue` | **新建** | H5 端缩略头像条组件 | | 3 | `frontend-h5/src/components/chat/ParticipantList.vue` | **修改** | H5 端展开详情列表(迁入 van-popup,增加头部+角色边框) | | 4 | `frontend-h5/src/components/chat/ChatPanel.vue` | **修改** | H5 端页面集成(替换 banner 为 strip + popup) | | 5 | `frontend-agent/src/composables/useParticipantDisplay.ts` | **新建** | 坐席端参与者数据规范化 composable | | 6 | `frontend-agent/src/components/conversation/ParticipantBar.vue` | **修改** | 坐席端缩略横条改造(角色边框+超员+展开触发) | | 7 | `frontend-agent/src/components/conversation/ParticipantExpandedPanel.vue` | **新建** | 坐席端就地展开详情面板 | | 8 | `frontend-agent/src/components/chat/ChatArea.vue` | **修改** | 坐席端页面集成(传递发起人 props) | --- ### 3. 数据结构和接口 > 完整类图见 `docs/02-技术文档/技术架构/class-diagram-截图拍照.mermaid` #### 3.1 共享类型定义(两端 composable 内各自定义,结构一致) ```typescript /** 参与者角色枚举 */ type ParticipantRole = 'primary_agent' | 'collaborator' | 'owner' | 'invitee' /** 规范化参与者 — 统一内部数据模型 */ interface NormalizedParticipant { /** 唯一标识(employee_id 或 agent_id) */ id: string /** 显示姓名(当前用户显示"我") */ name: string /** 头像URL(空字符串 = 无头像,使用首字母降级) */ avatar: string /** 部门名称 */ department: string /** 角色 */ role: ParticipantRole /** 是否已加入会话 */ joined: boolean /** 是否为当前登录用户 */ isSelf: boolean /** 加入时间(ISO 格式,用于排序,可选) */ joinedAt?: string } /** 角色边框色常量 */ const ROLE_BORDER_COLORS: Record = { primary_agent: '#3b82f6', // 蓝色 — 主责坐席 collaborator: '#07C160', // 绿色 — 协作坐席 owner: '#FF9800', // 橙色 — 发起人 invitee: 'transparent', // 无边框 — 被邀请人 } /** 自己的高亮环色 */ const SELF_HIGHLIGHT_COLOR = '#07C160' /** 角色显示标签 */ const ROLE_LABELS: Record = { primary_agent: '主责', collaborator: '协作', owner: '发起人', invitee: '', // 被邀请人不显示角色标签,显示加入状态 } ``` #### 3.2 H5 端 composable: `useParticipantDisplay` ```typescript // frontend-h5/src/composables/useParticipantDisplay.ts /** * H5 端参与者数据规范化 composable * 从 ConversationStore 读取异构数据,输出统一的 NormalizedParticipant[] * * 排列顺序:主责坐席 → 发起人 → 被邀请人(按 joinedAt 排序,未加入排末尾) */ export function useParticipantDisplay() { const store = useConversationStore() const employeeStore = useEmployeeStore() /** 当前登录用户 ID */ const currentUserId = computed(() => store.userInfo?.employee_id || '') /** * 规范化参与者列表 * 做什么:将 owner/agent/invited 拼装为统一列表,推断角色,标记 isSelf */ const normalizedParticipants = computed(() => { const conv = store.currentConversation if (!conv) return [] const list: NormalizedParticipant[] = [] // 1. 主责坐席(第一位,仅在已接入时显示) if (conv.agent_name) { list.push({ id: conv.agent_id || 'agent_primary', name: conv.agent_name, avatar: '', // 坐席无头像接口,首字母降级 department: 'IT坐席', role: 'primary_agent', joined: true, isSelf: false, // H5 端用户是员工,不可能是坐席 }) } // 2. 发起人(原始员工) if (conv.employee_name) { const isSelf = conv.employee_id === currentUserId.value list.push({ id: conv.employee_id, name: isSelf ? '我' : conv.employee_name, avatar: isSelf ? (employeeStore.employeeInfo?.avatar || '') : '', department: '', role: 'owner', joined: true, isSelf, }) } // 3. 被邀请参与者(按 joinedAt 排序,已加入在前,未加入在后) const invited = (store.participants || []) .slice() .sort((a, b) => { // 已加入的排前面 if (a.joined !== b.joined) return a.joined ? -1 : 1 // 同状态按 joinedAt 排序 if (a.joined_at && b.joined_at) { return new Date(a.joined_at).getTime() - new Date(b.joined_at).getTime() } return 0 }) for (const p of invited) { list.push({ id: p.id, name: p.name, avatar: p.avatar || '', department: p.department || '', role: 'invitee', joined: p.joined ?? false, isSelf: p.id === currentUserId.value, joinedAt: p.joined_at, }) } return list }) /** 总参与人数 */ const totalCount = computed(() => normalizedParticipants.value.length) /** 是否有待加入参与者(用于角标提示) */ const hasPending = computed(() => normalizedParticipants.value.some(p => !p.joined) ) return { normalizedParticipants, totalCount, hasPending, currentUserId } } ``` #### 3.3 坐席端 composable: `useParticipantDisplay` ```typescript // frontend-agent/src/composables/useParticipantDisplay.ts /** * 坐席端参与者数据规范化 composable * 从 Props 读取异构数据,输出统一的 NormalizedParticipant[] * * 排列顺序:主责坐席 → 发起人 → 协作坐席 → 被邀请人(按 joinedAt 排序) */ export function useParticipantDisplay(props: { participants: ParticipantInfo[] agentName: string agentId?: string employeeName: string employeeId: string collaboratingAgentIds: string[] collaboratingAgentNames: Record currentAgentId: string // 当前登录坐席 ID,用于 isSelf 判断 }) { const normalizedParticipants = computed(() => { const list: NormalizedParticipant[] = [] // 1. 主责坐席(第一位) if (props.agentName) { list.push({ id: props.agentId || 'agent_primary', name: props.agentName, avatar: '', // 坐席无头像接口 department: 'IT坐席', role: 'primary_agent', joined: true, isSelf: props.agentId === props.currentAgentId, }) } // 2. 发起人(原始员工) if (props.employeeName) { list.push({ id: props.employeeId, name: props.employeeName, avatar: '', // 坐席端暂无员工头像接口 department: '', role: 'owner', joined: true, isSelf: false, // 坐席不是员工 }) } // 3. 协作坐席 for (const aid of props.collaboratingAgentIds) { const name = props.collaboratingAgentNames[aid] || aid list.push({ id: aid, name, avatar: '', department: 'IT坐席', role: 'collaborator', joined: true, isSelf: aid === props.currentAgentId, }) } // 4. 被邀请参与者(按 joinedAt 排序,已加入在前) const invited = (props.participants || []) .slice() .sort((a, b) => { if (a.joined !== b.joined) return a.joined ? -1 : 1 if (a.joined_at && b.joined_at) { return new Date(a.joined_at).getTime() - new Date(b.joined_at).getTime() } return 0 }) for (const p of invited) { list.push({ id: p.id, name: p.name, avatar: p.avatar || '', department: p.department || '', role: 'invitee', joined: p.joined ?? false, isSelf: false, joinedAt: p.joined_at, }) } return list }) const totalCount = computed(() => normalizedParticipants.value.length) const hasPending = computed(() => normalizedParticipants.value.some(p => !p.joined) ) return { normalizedParticipants, totalCount, hasPending } } ``` #### 3.4 H5 端组件接口 **ParticipantStrip.vue(新建 — 缩略头像条)** ```typescript // 无 Props — 直接使用 useParticipantDisplay composable 读取 store // 无 Emits — 通过 store.toggleParticipantPanel() 控制弹出 // 内部状态 const MAX_AVATARS = 4 // 超员阈值 N=4 // composable const { normalizedParticipants, totalCount, hasPending } = useParticipantDisplay() // 计算属性 const visibleAvatars = computed(() => normalizedParticipants.value.slice(0, MAX_AVATARS)) const overflowCount = computed(() => Math.max(0, normalizedParticipants.value.length - MAX_AVATARS) ) // 方法 function handleExpand(): void { store.participantPanelVisible = true } // 头像降级(复用现有逻辑) function avatarLetter(name: string): string { return (name || '?').charAt(0) } ``` **ParticipantList.vue(修改 — 展开详情列表)** ```typescript // 无 Props — 直接使用 useParticipantDisplay composable 读取 store // 无 Emits — 退出操作直接调 store.leaveAsParticipant() // 保留现有逻辑: // - ownerAvatarFailed / failedIds 头像降级 // - handleLeave() 退出确认 + store.leaveAsParticipant() // - avatarLetter() 首字母降级 // 改动点: // 1. 改用 useParticipantDisplay() 获取 normalizedParticipants(替代手动拼装 ownerInfo/agentInfo/invitedParticipants) // 2. 增加头部区域(标题"参与者" + 关闭按钮) // 3. 头像增加角色边框(根据 role 应用对应 border-color) // 4. 角色徽标改用 ROLE_LABELS 映射 ``` **ChatPanel.vue(修改 — 页面集成)** ```typescript // 改动点: // 1. 删除旧的 participant-banner div 和 participant-panel div // 2. 在原位置插入 // 3. 在模板末尾(与其他弹窗并列)添加 包裹 // 4. van-popup 的 show 绑定 store.participantPanelVisible // van-popup 配置 // // // ``` #### 3.5 坐席端组件接口 **ParticipantBar.vue(修改 — 缩略横条 + 展开容器)** ```typescript interface Props { /** 参与者列表(不含主责坐席) */ participants: ParticipantInfo[] /** 主责坐席姓名 */ agentName: string /** 主责坐席ID(新增,用于 isSelf 判断) */ agentId?: string /** 发起人姓名(新增) */ employeeName: string /** 发起人ID(新增) */ employeeId: string /** 当前登录坐席是否为主责坐席 */ isPrimaryAgent: boolean /** 协作坐席ID列表 */ collaboratingAgentIds?: string[] /** 协作坐席姓名映射 */ collaboratingAgentNames?: Record /** 当前登录坐席ID(新增,用于 isSelf 判断) */ currentAgentId?: string } const emit = defineEmits<{ /** 点击邀请按钮 */ 'invite': [] /** 移除参与者 */ 'remove': [userId: string] }>() // 常量 const MAX_AVATARS = 6 // 超员阈值 N=6 // 展开状态(本地 ref) const expanded = ref(false) // 状态记忆(P1):按会话ID记忆展开状态 const expandedStateMap = ref>(new Map()) // composable const { normalizedParticipants, totalCount, hasPending } = useParticipantDisplay( props as any // 传入响应式 props ) // 计算属性 const visibleAvatars = computed(() => normalizedParticipants.value.slice(0, MAX_AVATARS)) const overflowCount = computed(() => Math.max(0, normalizedParticipants.value.length - MAX_AVATARS) ) // 方法 function toggleExpand(): void { expanded.value = !expanded.value // P1: 记忆状态 // const convId = ...; expandedStateMap.value.set(convId, expanded.value) } function handleRemove(userId: string): void { if (!props.isPrimaryAgent) return emit('remove', userId) } ``` **ParticipantExpandedPanel.vue(新建 — 展开详情面板)** ```typescript interface Props { /** 规范化参与者列表 */ participants: NormalizedParticipant[] /** 当前坐席是否为主责 */ isPrimaryAgent: boolean } const emit = defineEmits<{ /** 移除参与者 */ 'remove': [userId: string] /** 收起面板 */ 'collapse': [] }>() // 头像降级 const failedIds = ref>({}) function avatarLetter(name: string): string { // 坐席端用末字降级(与现有逻辑一致) return (name || '?').charAt(name.length - 1) } ``` **ChatArea.vue(修改 — 传递新 props)** ```typescript // 改动点: // ParticipantBar 标签增加 :employee-name, :employee-id, :agent-id, :current-agent-id 属性 // ``` --- ### 4. 程序调用流程 > 完整时序图见 `docs/02-技术文档/技术架构/sequence-diagram-截图拍照.mermaid` #### 4.1 H5 端:缩略 → 展开切换 ``` 用户点击 ParticipantStrip → ParticipantStrip.handleExpand() → store.participantPanelVisible = true → ChatPanel 模板中 van-popup :show 变为 true → van-popup 渲染 ParticipantList(底部弹出动画) → ParticipantList 通过 useParticipantDisplay() 获取 normalizedParticipants → 渲染详情列表(头像+姓名+部门+角色徽标) 用户关闭面板(下滑/遮罩/关闭按钮) → van-popup @update:show = false → store.participantPanelVisible = false → van-popup 收起动画 ``` #### 4.2 H5 端:退出会话 ``` 用户点击 ParticipantList 中的"退出会话"按钮 → handleLeave() → showConfirmDialog({ title: '退出会话', message: '...' }) → 用户点击"确定退出" → store.leaveAsParticipant() → API: POST /h5/conversations/{id}/leave → 成功:store 清空 currentConversation / participants / messages → store.participantPanelVisible = false(popup 关闭) → showToast('已退出会话') ``` #### 4.3 坐席端:缩略 → 展开切换 ``` 用户点击 ParticipantBar 横条主体区域(非"+ 邀请"按钮) → toggleExpand() → expanded = !expanded → CSS transition: max-height 0 → 150px, opacity 0 → 1(≤200ms) → ParticipantExpandedPanel 渲染详情列表 → ChatArea flex 布局:消息区自动压缩(min-height: 200px) 用户点击"收起"或再次点击横条 → toggleExpand() → expanded = false → CSS transition: max-height 150px → 0, opacity 1 → 0 → 面板收起,消息区恢复原高度 ``` #### 4.4 坐席端:移除参与者 ``` 主责坐席点击 ParticipantExpandedPanel 中某项的移除图标 → emit('remove', userId) → ParticipantBar 透传 emit('remove', userId) → ChatArea.handleRemoveParticipant(userId) → ElMessageBox.confirm('确定移除该参与者?') → 用户确认 → conversationStore.removeParticipantFromConv(convId, userId) → API 调用成功 → participants 列表响应式更新 → normalizedParticipants 自动重算 → ParticipantBar 缩略条 + ExpandedPanel 自动更新 ``` --- ### 5. 待明确事项 | # | 问题 | 当前假设 | 影响范围 | |---|------|---------|---------| | 1 | H5 端坐席头像首字母 + 蓝色边框的视觉效果 | 跳过设计稿,实现后由用户调整。首字母使用姓名首字(`charAt(0)`),蓝框 2px solid | H5 缩略+展开 | | 2 | 坐席端发起人头像 | 暂无员工头像接口,使用末字降级 + 橙色边框。后续如有接口可填充 `avatar` 字段 | 坐席端缩略+展开 | | 3 | 协作坐席加入时间 | 后端 `collaborating_agent_ids` 无加入时间字段,协作坐席排在发起人之后、被邀请人之前(固定顺序) | 坐席端排序 | | 4 | H5 端缩略条显示条件 | 仅当 `store.participants.length > 0` 时显示(与现有 banner 逻辑一致),即仅有被邀请参与者时才展示条。如需"有坐席接入就显示"可调整 | H5 端 | | 5 | 展开状态记忆的会话切换时机 | H5 端为单会话(无列表切换),状态记忆主要影响坐席端切会话场景。坐席端在 `watch(currentConversationId)` 时恢复记忆状态 | 坐席端 P1 | --- ## Part B: Task Decomposition ### 6. 依赖包列表 **无需安装任何新依赖包。** 所有功能通过现有技术栈实现: ``` # H5 端已有 - vant@^4.x: van-popup / showConfirmDialog(展开模式 + 退出确认) - vue@^3.x: Composition API / ref / computed # 坐席端已有 - element-plus@^2.x: el-tooltip / el-button / el-icon(展开面板交互) - @element-plus/icons-vue: Close 图标(移除按钮) - vue@^3.x: Composition API / ref / computed ``` --- ### 7. 任务列表(按依赖顺序) > **说明**:本项目为现有 Vue3 项目功能增量开发,无新增配置文件/入口文件/依赖包。T01 为数据规范化层基础设施(两端 composable),为后续组件任务提供统一数据接口。 #### T01: H5 端参与者双模式实现 | 字段 | 内容 | |------|------| | **任务名** | H5 端参与者双模式实现(数据规范化层 + 缩略头像条 + 底部弹出面板 + ChatPanel 集成) | | **优先级** | P0 | | **依赖** | 无 | | **涉及文件** | `frontend-h5/src/composables/useParticipantDisplay.ts`(新建)
`frontend-h5/src/components/chat/ParticipantStrip.vue`(新建)
`frontend-h5/src/components/chat/ParticipantList.vue`(修改)
`frontend-h5/src/components/chat/ChatPanel.vue`(修改) | **详细任务说明:** **步骤 1 — 创建 composable** `frontend-h5/src/composables/useParticipantDisplay.ts` - 定义 `ParticipantRole` 类型、`NormalizedParticipant` 接口、`ROLE_BORDER_COLORS` / `ROLE_LABELS` / `SELF_HIGHLIGHT_COLOR` 常量 - 实现 `useParticipantDisplay()` 函数:从 `useConversationStore()` + `useEmployeeStore()` 读取数据,输出 `normalizedParticipants` computed - 排列顺序:主责坐席 → 发起人 → 被邀请人(已加入在前,按 `joinedAt` 排序) - 输出 `totalCount`、`hasPending`、`currentUserId` 计算属性 **步骤 2 — 创建 ParticipantStrip.vue** `frontend-h5/src/components/chat/ParticipantStrip.vue` - 使用 `useParticipantDisplay()` 获取数据 - 布局:`[N人在群]` 文字徽标 + 头像列表(最多 4 个,28px 圆形)+ `+N` 溢出文本 + `▼` 展开箭头 - 头像渲染:有 `avatar` 用 ``(`@error` 降级),无则首字母(`charAt(0)`) - 角色边框:根据 `role` 应用 `ROLE_BORDER_COLORS[role]`(2px solid) - 自己标识:`isSelf` 为 true 时加 `SELF_HIGHLIGHT_COLOR` 高亮环(box-shadow 2px ring)+ `scale(1.1)` - 点击整个 strip 调用 `store.participantPanelVisible = true` - 高度 ≤ 44px,`flex-shrink: 0` - P1 预留:`hasPending` 为 true 时右上角显示 8px 红点角标(T03 实现,先留位置) **步骤 3 — 修改 ParticipantList.vue** `frontend-h5/src/components/chat/ParticipantList.vue` - 改用 `useParticipantDisplay()` 获取 `normalizedParticipants`(替代手动拼装 `ownerInfo` / `agentInfo` / `invitedParticipants`) - 保留现有:`failedIds` / `ownerAvatarFailed` 头像降级逻辑、`handleLeave()` 退出确认 + `store.leaveAsParticipant()`、`avatarLetter()` 函数 - 新增头部区域:`
` 包含"参与者"标题 + 关闭按钮(`×`),关闭按钮设置 `store.participantPanelVisible = false` - 遍历 `normalizedParticipants` 渲染每行:头像(32px) + 姓名(+`(我)`标签) + 部门 + 角色徽标 - 角色徽标:主责坐席显示"坐席"、发起人显示"发起人"、被邀请人显示"已加入"/"待加入" - 头像增加角色边框:根据 `role` 应用对应 border-color - 当前用户高亮:`isSelf` 行加背景色 + "(我)"标签(保留现有逻辑) - 退出按钮:仅 `store.isParticipant` 为 true 时显示(保留现有逻辑) - 内容区 `max-height` 由 van-popup 控制(60vh),内部 `overflow-y: auto` **步骤 4 — 修改 ChatPanel.vue** `frontend-h5/src/components/chat/ChatPanel.vue` - 删除旧 `participant-banner` div(`store.participants.length > 0` 那段) - 删除旧 `participant-panel` div(`store.participantPanelVisible` 那段) - 在原位置(排查步骤下方、消息列表上方)插入 `` - 在模板末尾(与 CallAgentModal / EvaluationDialog 并列)添加 van-popup: ```html ``` - 删除不再需要的旧 CSS(`.chat-panel__participant-banner` / `.chat-panel__participant-panel` 等相关样式) - import 新增 `ParticipantStrip` --- #### T02: 坐席端参与者双模式实现 | 字段 | 内容 | |------|------| | **任务名** | 坐席端参与者双模式实现(数据规范化层 + 缩略横条改造 + 就地展开面板 + ChatArea 集成) | | **优先级** | P0 | | **依赖** | 无(与 T01 独立,可并行开发) | | **涉及文件** | `frontend-agent/src/composables/useParticipantDisplay.ts`(新建)
`frontend-agent/src/components/conversation/ParticipantBar.vue`(修改)
`frontend-agent/src/components/conversation/ParticipantExpandedPanel.vue`(新建)
`frontend-agent/src/components/chat/ChatArea.vue`(修改) | **详细任务说明:** **步骤 1 — 创建 composable** `frontend-agent/src/composables/useParticipantDisplay.ts` - 定义与 H5 端一致的 `ParticipantRole`、`NormalizedParticipant`、`ROLE_BORDER_COLORS`、`ROLE_LABELS`、`SELF_HIGHLIGHT_COLOR` - 实现 `useParticipantDisplay(props)` 函数:接收响应式 props,输出 `normalizedParticipants` computed - 排列顺序:主责坐席 → 发起人 → 协作坐席 → 被邀请人(已加入在前,按 `joinedAt` 排序) - `isSelf` 判断:主责坐席 `agentId === currentAgentId`,协作坐席 `aid === currentAgentId` - 输出 `totalCount`、`hasPending` **步骤 2 — 修改 ParticipantBar.vue** `frontend-agent/src/components/conversation/ParticipantBar.vue` - Props 扩展:新增 `agentId`、`employeeName`、`employeeId`、`currentAgentId`(均为 string,可选/必选见接口定义) - 使用 `useParticipantDisplay(props)` 获取 `normalizedParticipants` - 缩略横条改造: - 保持一行横条结构:`[N人参与:]` + 头像列表(最多 6 个,20px 圆形)+ `+N` 溢出文本 + `+ 邀请`按钮 + `▼` 展开箭头 - 头像渲染:有 `avatar` 用 ``(`@error` 降级),无则末字(`charAt(name.length - 1)`,与现有逻辑一致) - 角色边框:根据 `role` 应用 `ROLE_BORDER_COLORS[role]`(2px solid) - 自己标识:`isSelf` 为 true 时加高亮环 + `scale(1.1)` - **取消横向滚动**(`overflow: hidden` 替代 `overflow-x: auto`),溢出部分显示 `+N` - `totalCount` 改为 `normalizedParticipants.length`(含主责+发起人+协作+被邀请) - 交互: - 点击横条主体区域 → `toggleExpand()`(切换 `expanded` ref) - `+ 邀请`按钮 `@click.stop="$emit('invite')"`(`stop` 阻止冒泡,不触发展开) - 展开箭头 `▼` / `▲` 随 `expanded` 切换 - 展开面板:当 `expanded === true` 时,在横条下方渲染 ``,传入 `normalizedParticipants` 和 `isPrimaryAgent` ```html ``` - CSS transition:`max-height: 0 → 150px`,`opacity: 0 → 1`,`overflow: hidden`,时长 `0.2s ease` - 缩略模式高度 ≤ 36px **步骤 3 — 创建 ParticipantExpandedPanel.vue** `frontend-agent/src/components/conversation/ParticipantExpandedPanel.vue` - Props:`participants: NormalizedParticipant[]`、`isPrimaryAgent: boolean` - Emits:`remove: [userId: string]`、`collapse: []` - 布局: - 头部:`参与者详情` 标题 + `收起` 按钮(emit `collapse`) - 内容区:`max-height: 150px`,`overflow-y: auto` - 每行:头像(28px) + 姓名(+角色标签) + 部门 + 加入状态 + [移除图标] - 头像渲染:有 `avatar` 用 ``(`@error` 降级到 `failedIds`),无则末字 - 角色标签:`主责` / `协作` / `发起人`(来自 `ROLE_LABELS`),被邀请人不显示角色标签 - 加入状态:`joined === true` 显示"已加入"(灰色),`joined === false` 显示"待加入"(橙色) - 移除图标:仅 `isPrimaryAgent === true` 且 `role === 'invitee'` 时显示 `Close` 图标,`@click="emit('remove', p.id)"` - P1 预留:姓名被截断时鼠标悬停头像显示 `el-tooltip`(T03 实现,先留位置) **步骤 4 — 修改 ChatArea.vue** `frontend-agent/src/components/chat/ChatArea.vue` - ParticipantBar 标签新增 props 传递: ```html ``` - 消息列表区域 `.message-list-scroll` 添加 `min-height: 200px`(保证展开时消息区仍可用) - import 新增 `ParticipantExpandedPanel`(如 ParticipantBar 内部 import 则 ChatArea 无需 import) --- #### T03: P1 增强功能(状态记忆 + 角标 + tooltip + 双端联调) | 字段 | 内容 | |------|------| | **任务名** | P1 增强功能实现(展开状态记忆 + 待加入角标 + 头像 tooltip + 双端联调) | | **优先级** | P1 | | **依赖** | T01, T02 | | **涉及文件** | `frontend-h5/src/components/chat/ParticipantStrip.vue`(修改)
`frontend-h5/src/components/chat/ChatPanel.vue`(修改)
`frontend-agent/src/components/conversation/ParticipantBar.vue`(修改)
`frontend-agent/src/components/conversation/ParticipantExpandedPanel.vue`(修改) | **详细任务说明:** **步骤 1 — H5 端待加入角标** `frontend-h5/src/components/chat/ParticipantStrip.vue` - 在 strip 容器右上角添加红点角标:`v-if="hasPending"` 时显示 8px 红色圆点(`position: absolute; top: 2px; right: 2px`) - `hasPending` 来自 `useParticipantDisplay()` 返回值 **步骤 2 — H5 端展开状态记忆** `frontend-h5/src/components/chat/ChatPanel.vue` - H5 端为单会话模式,切换会话通过 `store.switchToConversation()`。在 ChatPanel 中 watch `store.currentConversation?.conversation_id` 变化时,可选择保持或重置 `participantPanelVisible` - 默认行为:切换会话时重置为 false(`store.participantPanelVisible = false`) - 如需跨会话记忆,添加 `const panelStateMap = ref>(new Map())`,在 watch 中恢复 **步骤 3 — 坐席端展开状态记忆** `frontend-agent/src/components/conversation/ParticipantBar.vue` - 添加 `expandedStateMap = ref>(new Map())` - watch `props` 中的会话标识变化(由 ChatArea 传入 `conversationId` prop,或通过 inject 获取): - 会话切换时,从 map 恢复该会话的展开状态 - `toggleExpand()` 时,同步写入 map - 页面刷新后 map 清空,恢复默认收起(符合 PRD 约束) **步骤 4 — 坐席端头像 tooltip** `frontend-agent/src/components/conversation/ParticipantExpandedPanel.vue` - 在展开面板的每行姓名外层包裹 ``: ```html {{ p.name }} ``` - `isNameTruncated(p)` 判断:通过 `el.scrollWidth > el.clientWidth` 检测文字是否被 CSS `text-overflow: ellipsis` 截断 - 仅在截断时显示 tooltip(PRD 约束) **步骤 5 — 双端联调** - 验证 H5 端:缩略条显示 → 点击弹出 van-popup → 详情列表正确 → 退出会话流程 → 关闭 popup - 验证坐席端:缩略横条显示 → 点击展开 → 详情面板正确 → 移除参与者 → 收起 → 邀请按钮独立工作 - 验证角色边框色:主责蓝、协作绿、发起人橙、被邀请无框、自己高亮环 - 验证超员:H5 端 >4 显示+N,坐席端 >6 显示+N - 验证动画:坐席端展开/收起 ≤200ms --- ### 8. 共享知识(跨文件约定) #### 8.1 角色边框色规范 | 角色 | 边框色 | 色值 | CSS 应用方式 | |------|--------|------|-------------| | 主责坐席 | 蓝色 | `#3b82f6` | `border: 2px solid #3b82f6` | | 协作坐席 | 绿色 | `#07C160` | `border: 2px solid #07C160` | | 发起人 | 橙色 | `#FF9800` | `border: 2px solid #FF9800` | | 被邀请人 | 无边框 | `transparent` | `border: 2px solid transparent` | | 自己(任意角色) | 高亮环 | `#07C160` | `box-shadow: 0 0 0 2px #07C160` + `transform: scale(1.1)` | > **注意**:角色边框色为硬编码值,不使用 CSS 变量(因两端 `--accent` 在深色模式下会变色,而角色边框色需保持稳定)。 #### 8.2 角色判断逻辑(前端推断,无需后端改动) **H5 端**(在 `useParticipantDisplay` 中): - `conv.agent_name` 存在 → `primary_agent` - `conv.employee_id` → `owner`(发起人) - `store.participants[]` → `invitee`(被邀请人) - `isSelf`: `p.id === store.userInfo?.employee_id` **坐席端**(在 `useParticipantDisplay` 中): - `props.agentName` → `primary_agent` - `props.employeeName` / `props.employeeId` → `owner` - `props.collaboratingAgentIds[]` → `collaborator` - `props.participants[]` → `invitee` - `isSelf`: 主责 `agentId === currentAgentId`,协作 `aid === currentAgentId` #### 8.3 头像降级策略(两端一致) ``` 有 avatar URL? ├─ 是 → 渲染 │ └─ 加载失败 → 降级显示首字母 └─ 否 → 直接显示首字母 ``` - **H5 端首字母**:`name.charAt(0)`(姓名第一个字) - **坐席端首字母**:`name.charAt(name.length - 1)`(姓名最后一个字,与现有逻辑一致) - **坐席头像**:暂无头像接口,`avatar` 始终为空字符串,使用首字母降级 - **降级背景色**:渐变色(H5: `linear-gradient(135deg, #667eea, #764ba2)`,坐席端: `var(--accent)`) #### 8.4 超员阈值 | 端 | N 值 | 头像尺寸 | 溢出显示 | |----|------|---------|---------| | H5 | 4 | 28px | `+N` 文本(N = 总数 - 4) | | 坐席端 | 6 | 20px | `+N` 文本(N = 总数 - 6) | #### 8.5 展开面板高度约束 | 端 | 展开方式 | 面板高度 | 滚动 | |----|---------|---------|------| | H5 | van-popup 底部弹出 | `max-height: 60vh` | 内容区 `overflow-y: auto` | | 坐席端 | 就地展开 | 固定 `150px` | 内容区 `overflow-y: auto` | #### 8.6 动画时长 | 动画 | 时长 | 属性 | |------|------|------| | 坐席端展开/收起 | ≤200ms | `transition: max-height 0.2s ease, opacity 0.2s ease` | | H5 端 popup 弹出 | Vant 默认 | `van-popup` 内置 transition | | 自己头像放大 | 0.15s | `transition: transform 0.15s` | #### 8.7 CSS 变量使用约定 组件内样式优先使用项目全局 CSS 变量: - 背景色:`var(--bg-primary)` / `var(--bg-secondary)` / `var(--bg-tertiary)` - 文字色:`var(--text-primary)` / `var(--text-secondary)` / `var(--text-tertiary)` - 边框色:`var(--border-color)` / `var(--border-light)` - 主色:`var(--accent)` / `var(--accent-soft)` - 语义色:`var(--color-success)` / `var(--color-warning)` / `var(--color-danger)` **例外**:角色边框色(#3b82f6 / #07C160 / #FF9800)为硬编码,不走 CSS 变量。 #### 8.8 van-popup 行为约定(H5 端) - `teleport="body"`:弹层挂载到 body,避免被父级 `overflow` 裁剪 - `position="bottom"` + `round`:底部圆角弹出 - 遮罩默认显示,点击遮罩关闭(`close-on-click-overlay` 默认 true) - 下滑关闭(`closeable` + Vant 默认手势) - 弹出时遮罩输入框(标准 popup 行为,输入框在遮罩下方不可点击) --- ### 9. 任务依赖图 ```mermaid graph TD T01[T01: H5端参与者双模式
composable + ParticipantStrip
+ ParticipantList + ChatPanel] T02[T02: 坐席端参与者双模式
composable + ParticipantBar
+ ExpandedPanel + ChatArea] T03[T03: P1增强功能
状态记忆 + 角标
+ tooltip + 联调] T01 --> T03 T02 --> T03 style T01 fill:#07C16020,stroke:#07C160,stroke-width:2px style T02 fill:#3b82f620,stroke:#3b82f6,stroke-width:2px style T03 fill:#FF980020,stroke:#FF9800,stroke-width:2px ``` **说明**: - T01 与 T02 **无依赖关系**,可并行开发(不同项目目录,互不影响) - T03 依赖 T01 + T02 完成(在已有组件上追加 P1 功能) - 关键路径:T01 → T03 或 T02 → T03(取较慢者)