Files
wecom_it_smart_desk/docs/03-技术架构/群聊参与者展开缩略双模式-架构设计.md
T
Simon bea288e414 feat: 2026-07-11 全量更新 - 代办集成+会议室预定+知识迭代修复+UI统一+Bug修复
== 已部署上线 (9项) ==
- 代办事项真实数据源集成 (企微审批API 8bug修复链)
- H5/坐席端 Logo样式统一+绿色背景
- 视频引导页修复 (localStorage key v2)
- 坐席端 v9 Vue版本修复 (ElMessage._context)
- 截图按钮 v10 修复 (getDisplayMedia user gesture)
- 扫码样式恢复+H5扫码登录跳转修复
- H5截图快捷键提示

== 代码完成待部署 (3项) ==
- 知识迭代3Bug修复 (#8 POST端点/#7 MERGE幂等/#6 过期检查)
- 会议室预定-小鱼易联终端 (40文件, 40/40测试通过)
- IT资产升级审批推送 (asset_service.py)

== 需求文档 (2项) ==
- 坐席端AI辅助消息框-PRD (4项新功能确认)
- 坐席端布局优化建议 v2.0 (7天计划)

== 新增文档 ==
- 日报-2026-07-11.md
- 知识迭代Bug修复报告-20260711.md
- 会议室预定-部署指南.md
- CHANGELOG.md 更新

== 测试 ==
- test_todo_integration.py: 40/40
- test_meetingroom.py: 40/40
- test_bugfix_ki_suggestions.py: 21/21
2026-07-11 23:13:10 +08:00

932 lines
39 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 群聊参与者展开/缩略双模式 — 架构设计
> **版本**: v1.0
> **日期**: 2026-07-14
> **作者**: 高见远(架构师)
> **状态**: 待评审
> **PRD**: `docs/02-产品需求/群聊参与者展开缩略双模式-PRD.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 原生组件 |
| 共用 | 头像降级 | `<img>` + `@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/03-技术架构/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<ParticipantRole, string> = {
primary_agent: '#3b82f6', // 蓝色 — 主责坐席
collaborator: '#07C160', // 绿色 — 协作坐席
owner: '#FF9800', // 橙色 — 发起人
invitee: 'transparent', // 无边框 — 被邀请人
}
/** 自己的高亮环色 */
const SELF_HIGHLIGHT_COLOR = '#07C160'
/** 角色显示标签 */
const ROLE_LABELS: Record<ParticipantRole, string> = {
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<NormalizedParticipant[]>(() => {
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<string, string>
currentAgentId: string // 当前登录坐席 ID,用于 isSelf 判断
}) {
const normalizedParticipants = computed<NormalizedParticipant[]>(() => {
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. 在原位置插入 <ParticipantStrip />
// 3. 在模板末尾(与其他弹窗并列)添加 <van-popup> 包裹 <ParticipantList />
// 4. van-popup 的 show 绑定 store.participantPanelVisible
// van-popup 配置
// <van-popup
// :show="store.participantPanelVisible"
// @update:show="store.participantPanelVisible = $event"
// position="bottom"
// round
// :style="{ maxHeight: '60vh' }"
// teleport="body"
// >
// <ParticipantList />
// </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<string, string>
/** 当前登录坐席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<Map<string, boolean>>(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<Record<string, boolean>>({})
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 属性
// <ParticipantBar
// v-if="hasAnyParticipants"
// :participants="currentConv?.participants || []"
// :agent-name="currentConv?.assigned_agent_name || '坐席'"
// :agent-id="currentConv?.assigned_agent_id || ''"
// :employee-name="currentConv?.employee_name || ''"
// :employee-id="currentConv?.employee_id || ''"
// :is-primary-agent="isPrimaryAgent"
// :collaborating-agent-ids="currentConv?.collaborating_agent_ids || []"
// :collaborating-agent-names="currentConv?.collaborating_agent_names || {}"
// :current-agent-id="agentStore.userId"
// @invite="showInviteParticipantDialog = true"
// @remove="handleRemoveParticipant"
// />
```
---
### 4. 程序调用流程
> 完整时序图见 `docs/03-技术架构/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 = falsepopup 关闭)
→ 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`(新建)<br>`frontend-h5/src/components/chat/ParticipantStrip.vue`(新建)<br>`frontend-h5/src/components/chat/ParticipantList.vue`(修改)<br>`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``<img>``@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()` 函数
- 新增头部区域:`<div class="participant-list__header">` 包含"参与者"标题 + 关闭按钮(`×`),关闭按钮设置 `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` 那段)
- 在原位置(排查步骤下方、消息列表上方)插入 `<ParticipantStrip v-if="store.participants.length > 0" />`
- 在模板末尾(与 CallAgentModal / EvaluationDialog 并列)添加 van-popup
```html
<van-popup
:show="store.participantPanelVisible"
@update:show="store.participantPanelVisible = $event"
position="bottom"
round
teleport="body"
:style="{ maxHeight: '60vh' }"
>
<ParticipantList />
</van-popup>
```
- 删除不再需要的旧 CSS`.chat-panel__participant-banner` / `.chat-panel__participant-panel` 等相关样式)
- import 新增 `ParticipantStrip`
---
#### T02: 坐席端参与者双模式实现
| 字段 | 内容 |
|------|------|
| **任务名** | 坐席端参与者双模式实现(数据规范化层 + 缩略横条改造 + 就地展开面板 + ChatArea 集成) |
| **优先级** | P0 |
| **依赖** | 无(与 T01 独立,可并行开发) |
| **涉及文件** | `frontend-agent/src/composables/useParticipantDisplay.ts`(新建)<br>`frontend-agent/src/components/conversation/ParticipantBar.vue`(修改)<br>`frontend-agent/src/components/conversation/ParticipantExpandedPanel.vue`(新建)<br>`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` 用 `<img>``@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` 时,在横条下方渲染 `<ParticipantExpandedPanel>`,传入 `normalizedParticipants` 和 `isPrimaryAgent`
```html
<Transition name="expand">
<ParticipantExpandedPanel
v-if="expanded"
:participants="normalizedParticipants"
:is-primary-agent="isPrimaryAgent"
@remove="handleRemove"
@collapse="expanded = false"
/>
</Transition>
```
- 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` 用 `<img>``@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
<ParticipantBar
v-if="hasAnyParticipants"
:participants="currentConv?.participants || []"
:agent-name="currentConv?.assigned_agent_name || '坐席'"
:agent-id="currentConv?.assigned_agent_id || ''"
:employee-name="currentConv?.employee_name || ''"
:employee-id="currentConv?.employee_id || ''"
:is-primary-agent="isPrimaryAgent"
:collaborating-agent-ids="currentConv?.collaborating_agent_ids || []"
:collaborating-agent-names="currentConv?.collaborating_agent_names || {}"
:current-agent-id="agentStore.userId"
@invite="showInviteParticipantDialog = true"
@remove="handleRemoveParticipant"
/>
```
- 消息列表区域 `.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`(修改)<br>`frontend-h5/src/components/chat/ChatPanel.vue`(修改)<br>`frontend-agent/src/components/conversation/ParticipantBar.vue`(修改)<br>`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<Map<string, boolean>>(new Map())`,在 watch 中恢复
**步骤 3 — 坐席端展开状态记忆** `frontend-agent/src/components/conversation/ParticipantBar.vue`
- 添加 `expandedStateMap = ref<Map<string, boolean>>(new Map())`
- watch `props` 中的会话标识变化(由 ChatArea 传入 `conversationId` prop,或通过 inject 获取):
- 会话切换时,从 map 恢复该会话的展开状态
- `toggleExpand()` 时,同步写入 map
- 页面刷新后 map 清空,恢复默认收起(符合 PRD 约束)
**步骤 4 — 坐席端头像 tooltip** `frontend-agent/src/components/conversation/ParticipantExpandedPanel.vue`
- 在展开面板的每行姓名外层包裹 `<el-tooltip>`
```html
<el-tooltip
:content="`${p.name}${p.department ? ' · ' + p.department : ''}`"
:disabled="!isNameTruncated(p)"
placement="top"
>
<span class="expanded-panel__name" ref="nameRefs">{{ p.name }}</span>
</el-tooltip>
```
- `isNameTruncated(p)` 判断:通过 `el.scrollWidth > el.clientWidth` 检测文字是否被 CSS `text-overflow: ellipsis` 截断
- 仅在截断时显示 tooltipPRD 约束)
**步骤 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
├─ 是 → 渲染 <img src={avatar} @error="failedIds[id] = true">
│ └─ 加载失败 → 降级显示首字母
└─ 否 → 直接显示首字母
```
- **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端参与者双模式<br/>composable + ParticipantStrip<br/>+ ParticipantList + ChatPanel]
T02[T02: 坐席端参与者双模式<br/>composable + ParticipantBar<br/>+ ExpandedPanel + ChatArea]
T03[T03: P1增强功能<br/>状态记忆 + 角标<br/>+ 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(取较慢者)