本提交为 .git 对象库损坏后的重建提交,内容等价于原先三个本地提交 (5e2fd4c2 / 57a53c98 / 5d7e1873)的累积结果,未做任何额外改动。 一、docs 结构整改(整改 #14) 根因:重构时新结构为 untracked 文件,执行 git stash(未带 -u)未纳入, 随后 git reset 拉回 HEAD 旧 tracked 树,导致旧树复活、新旧两棵目录 树并存于 docs/,共 791 文件、双分类体系冲突。 修复动作: - b2 同名异主题文件改名迁移保全 9 个 - C 类 39 个孤立文件按主题正确归类 - A/B1 类 222 个重复文件删除(新结构已有内容副本) - 9 个旧独有空目录删除 - 270 处内部引用按 verified 映射改写 - 整改记录 #14 登记于 04-运维文档/部署运维 结果:docs 791 → 569 文件,顶层仅规范 8 类 + 治理文件,单树恢复。 残留:约 20 处指向从未存在文件的陈旧死链,归入独立文档卫生任务。 二、compose 双目录对齐(消除踩坑 A) - docker-compose.yml:nginx 前端挂载全部由根目录 frontend-*/dist 改为 src/frontend-*/dist(h5 / agent / admin / terminal) - docker-compose.dev.yml:dev 服务 build context 与卷同步改 src/ - 效果:本地 docker compose up 不再把根目录 stale dist 挂回, 与线上一致,分叉隐患消除(已 docker compose config 校验通过) 防复发铁律: - 重构须提交;仓库修复须 git stash -u 或先 commit - 新结构须 git add 并提交,避免再次 untracked 复活 - H5 改动只动 src/frontend-h5/,禁改根目录遗留 frontend-*/
39 KiB
群聊参与者展开/缩略双模式 — 架构设计
版本: v1.0 日期: 2026-07-14 作者: 高见远(架构师) 状态: 待评审 PRD:
docs/01-产品文档/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) │ │ (展开模式) │
└─────────────────┘ └─────────────────┘
为什么这样设计:
- 数据规范化层隔离数据源差异 — composable 负责将 owner/agent/collaborator/invited 拼装为统一列表并推断角色,组件只需消费
NormalizedParticipant[],不关心数据来源。若后端将来增加角色字段,只需改 composable。 - 角色推断纯前端 — 无需后端改动。H5 端:
conv.agent_name→ 主责坐席,conv.employee_id→ 发起人,其余为被邀请人。坐席端:assigned_agent_name→ 主责坐席,collaborating_agent_ids→ 协作坐席,employee_name→ 发起人,participants→ 被邀请人。 - 缩略/展开组件解耦 — 缩略条只负责"概览",展开面板只负责"详情",各自独立渲染,通过 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/02-技术文档/技术架构/class-diagram-截图拍照.mermaid
3.1 共享类型定义(两端 composable 内各自定义,结构一致)
/** 参与者角色枚举 */
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
// 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
// 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(新建 — 缩略头像条)
// 无 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(修改 — 展开详情列表)
// 无 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(修改 — 页面集成)
// 改动点:
// 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(修改 — 缩略横条 + 展开容器)
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(新建 — 展开详情面板)
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)
// 改动点:
// 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/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()读取数据,输出normalizedParticipantscomputed - 排列顺序:主责坐席 → 发起人 → 被邀请人(已加入在前,按
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-bannerdiv(store.participants.length > 0那段) - 删除旧
participant-paneldiv(store.participantPanelVisible那段) - 在原位置(排查步骤下方、消息列表上方)插入
<ParticipantStrip v-if="store.participants.length > 0" /> - 在模板末尾(与 CallAgentModal / EvaluationDialog 并列)添加 van-popup:
<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(新建)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,输出normalizedParticipantscomputed - 排列顺序:主责坐席 → 发起人 → 协作坐席 → 被邀请人(已加入在前,按
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()(切换expandedref) + 邀请按钮@click.stop="$emit('invite')"(stop阻止冒泡,不触发展开)- 展开箭头
▼/▲随expanded切换
- 点击横条主体区域 →
- 展开面板:当
expanded === true时,在横条下方渲染<ParticipantExpandedPanel>,传入normalizedParticipants和isPrimaryAgent<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: [] - 布局:
- 头部:
参与者详情标题 +收起按钮(emitcollapse) - 内容区:
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 传递:
<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(修改)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 中 watchstore.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 传入conversationIdprop,或通过 inject 获取):- 会话切换时,从 map 恢复该会话的展开状态
toggleExpand()时,同步写入 map
- 页面刷新后 map 清空,恢复默认收起(符合 PRD 约束)
步骤 4 — 坐席端头像 tooltip frontend-agent/src/components/conversation/ParticipantExpandedPanel.vue
- 在展开面板的每行姓名外层包裹
<el-tooltip>:<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检测文字是否被 CSStext-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_agentconv.employee_id→owner(发起人)store.participants[]→invitee(被邀请人)isSelf:p.id === store.userInfo?.employee_id
坐席端(在 useParticipantDisplay 中):
props.agentName→primary_agentprops.employeeName/props.employeeId→ownerprops.collaboratingAgentIds[]→collaboratorprops.participants[]→inviteeisSelf: 主责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. 任务依赖图
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(取较慢者)