# 技术方案 - 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