932 lines
39 KiB
Markdown
932 lines
39 KiB
Markdown
|
|
# 群聊参与者展开/缩略双模式 — 架构设计
|
|||
|
|
|
|||
|
|
> **版本**: 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 = 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`(新建)<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` 截断
|
|||
|
|
- 仅在截断时显示 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?
|
|||
|
|
├─ 是 → 渲染 <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(取较慢者)
|