Files
wecom_it_smart_desk/docs/02-技术文档/技术架构/技术方案-REQ-用户-001-群聊双模式-v1.0.md
T
Simon facc04aa65 chore: docs 结构整改 + compose 双目录对齐(合并重建提交)
本提交为 .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-*/
2026-08-07 22:31:32 +08:00

39 KiB
Raw Blame History

技术方案 - REQ-用户-001 群聊双模式

版本: v1.0 日期: 2026-07-14 REQ编号: REQ-用户-001 关联PRD: 01-产品文档/05-用户端H5/PRD-REQ-用户-001-群聊双模式-v1.0.md 状态: 待评审 作者: 高见远(架构师)


Part A: System Design

1. 实现方案

1.1 核心技术挑战

挑战 说明
双端数据源异构 H5 端从 ConversationStore 获取参与者(owner + agent + invited),坐席端从 Props 获取(primary_agent + collaborators + owner + invited),数据结构不同但需统一渲染逻辑
角色边框统一 4 种角色(主责坐席/协作坐席/发起人/被邀请人)需不同的头像边框色,且"自己"需额外高亮环,需在不修改后端的前提下前端推断角色
H5 弹出面板与布局 van-popup 为独立浮层,需遮罩输入框但不影响上方聊天记录可见性,关闭后恢复布局
坐席端就地展开 展开面板向下撑开 ~150px,消息区被 flex 压缩但需保持最小 200px 高度可滚动
超员溢出处理 H5 端 N=4、坐席端 N=6,超出显示"+N"文本,不横向滚动

1.2 方案选型与架构模式

总体策略:数据规范化层 + 双模式组件

引入一个 useParticipantDisplay composable(数据规范化层),将异构的原始数据统一为 NormalizedParticipant[],供缩略条和展开面板共用。组件层分两个模式:缩略模式(紧凑头像条)和展开模式(详情列表)。

┌──────────────────────────────────────────────────────┐
│                   原始数据源                          │
│  H5: store.currentConversation + store.participants   │
│  Agent: Props (participants, agentName, employee...)  │
└──────────────┬───────────────────────┬───────────────┘
               │                       │
      ┌────────▼────────┐    ┌────────▼────────┐
      │  useParticipant  │    │  useParticipant  │
      │  Display (H5)    │    │  Display (Agent) │
      │  规范化 → 统一    │    │  规范化 → 统一    │
      │  NormalizedPar   │    │  NormalizedPar   │
      │  ticipant[]      │    │  ticipant[]      │
      └────────┬────────┘    └────────┬────────┘
               │                       │
      ┌────────▼────────┐    ┌────────▼────────┐
      │ ParticipantStrip │    │ ParticipantBar   │
      │ (缩略模式 H5)    │    │ (缩略模式 Agent) │
      │ + ParticipantList│    │ + ExpandedPanel  │
      │   (展开模式 H5)  │    │   (展开模式)     │
      └─────────────────┘    └─────────────────┘

为什么这样设计:

  1. 数据规范化层隔离数据源差异 — composable 负责将 owner/agent/collaborator/invited 拼装为统一列表并推断角色,组件只需消费 NormalizedParticipant[],不关心数据来源。若后端将来增加角色字段,只需改 composable。
  2. 角色推断纯前端 — 无需后端改动。H5 端:conv.agent_name → 主责坐席,conv.employee_id → 发起人,其余为被邀请人。坐席端:assigned_agent_name → 主责坐席,collaborating_agent_ids → 协作坐席,employee_name → 发起人,participants → 被邀请人。
  3. 缩略/展开组件解耦 — 缩略条只负责"概览",展开面板只负责"详情",各自独立渲染,通过 composable 共享数据,互不耦合。

框架/组件选型:

用途 选型 理由
H5 展开模式弹出层 Vant4 van-popup position="bottom" round PRD 已指定方案B,Vant 原生支持下滑关闭/遮罩关闭/圆角
H5 退出确认 Vant4 showConfirmDialog 现有代码已使用,保持一致
Agent 展开面板动画 CSS transition (max-height + opacity) 轻量,无需额外动画库,≤200ms 满足 PRD
Agent 头像 tooltip Element Plus el-tooltip PRD P1 要求,Element Plus 原生组件
共用 头像降级 <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 = 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(新建)
frontend-h5/src/components/chat/ParticipantStrip.vue(新建)
frontend-h5/src/components/chat/ParticipantList.vue(修改)
frontend-h5/src/components/chat/ChatPanel.vue(修改)

详细任务说明:

步骤 1 — 创建 composable frontend-h5/src/composables/useParticipantDisplay.ts

  • 定义 ParticipantRole 类型、NormalizedParticipant 接口、ROLE_BORDER_COLORS / ROLE_LABELS / SELF_HIGHLIGHT_COLOR 常量
  • 实现 useParticipantDisplay() 函数:从 useConversationStore() + useEmployeeStore() 读取数据,输出 normalizedParticipants computed
  • 排列顺序:主责坐席 → 发起人 → 被邀请人(已加入在前,按 joinedAt 排序)
  • 输出 totalCounthasPendingcurrentUserId 计算属性

步骤 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
  • 高度 ≤ 44pxflex-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 divstore.participants.length > 0 那段)
  • 删除旧 participant-panel divstore.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 端一致的 ParticipantRoleNormalizedParticipantROLE_BORDER_COLORSROLE_LABELSSELF_HIGHLIGHT_COLOR
  • 实现 useParticipantDisplay(props) 函数:接收响应式 props,输出 normalizedParticipants computed
  • 排列顺序:主责坐席 → 发起人 → 协作坐席 → 被邀请人(已加入在前,按 joinedAt 排序)
  • isSelf 判断:主责坐席 agentId === currentAgentId,协作坐席 aid === currentAgentId
  • 输出 totalCounthasPending

步骤 2 — 修改 ParticipantBar.vue frontend-agent/src/components/conversation/ParticipantBar.vue

  • Props 扩展:新增 agentIdemployeeNameemployeeIdcurrentAgentId(均为 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>,传入 normalizedParticipantsisPrimaryAgent
    <Transition name="expand">
      <ParticipantExpandedPanel
        v-if="expanded"
        :participants="normalizedParticipants"
        :is-primary-agent="isPrimaryAgent"
        @remove="handleRemove"
        @collapse="expanded = false"
      />
    </Transition>
    
  • CSS transitionmax-height: 0 → 150pxopacity: 0 → 1overflow: hidden,时长 0.2s ease
  • 缩略模式高度 ≤ 36px

步骤 3 — 创建 ParticipantExpandedPanel.vue frontend-agent/src/components/conversation/ParticipantExpandedPanel.vue

  • Propsparticipants: NormalizedParticipant[]isPrimaryAgent: boolean
  • Emitsremove: [userId: string]collapse: []
  • 布局:
    • 头部:参与者详情 标题 + 收起 按钮(emit collapse
    • 内容区:max-height: 150pxoverflow-y: auto
    • 每行:头像(28px) + 姓名(+角色标签) + 部门 + 加入状态 + [移除图标]
  • 头像渲染:有 avatar<img>@error 降级到 failedIds),无则末字
  • 角色标签:主责 / 协作 / 发起人(来自 ROLE_LABELS),被邀请人不显示角色标签
  • 加入状态:joined === true 显示"已加入"(灰色),joined === false 显示"待加入"(橙色)
  • 移除图标:仅 isPrimaryAgent === truerole === 'invitee' 时显示 Close 图标,@click="emit('remove', p.id)"
  • P1 预留:姓名被截断时鼠标悬停头像显示 el-tooltipT03 实现,先留位置)

步骤 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 中 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>
    <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_idowner(发起人)
  • store.participants[]invitee(被邀请人)
  • isSelf: p.id === store.userInfo?.employee_id

坐席端(在 useParticipantDisplay 中):

  • props.agentNameprimary_agent
  • props.employeeName / props.employeeIdowner
  • 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. 任务依赖图

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(取较慢者)