46 KiB
技术核查 — 坐席端会话条目操作菜单(左栏三点菜单 / 右键菜单)
文档编号: 技术核查-坐席会话条目操作菜单-v1.0 版本: v1.0 状态: ✅ 核查完成 — 供产品设计原型 v1.7 与后续开发决策使用 日期: 2026-08-09 作者: 高见远(架构师) 需求来源: 宋献(IT 支持组组长)口述需求 4 条(见 §0) 关联: PRD-REQ-坐席-011-统一工作队列重构-v0.1、REQ-坐席-004、REQ-坐席-009、REQ-坐席-010 核查范围: 仅
src/frontend-agent/(活跃代码)与src/backend/。根目录frontend-agent/为 2026-07-13 monorepo 重组前遗留副本,线上无挂载、不在核查范围
0. 需求原文与核查任务映射
| # | 需求原文 | 本文对应章节 |
|---|---|---|
| 1 | 确认现有坐席架构,控件能否在「选中对象-右键调出菜单」模式实现 | §2 全章 + §4 |
| 2 | 左栏每个会话表单右边增加菜单按钮(三个点),点击弹出接单/置顶/代办/转接 | §2.2、§2.3、§4 |
| 3 | 上述完成后,去除中栏上方 4 个按钮 | §5.1(本文提出重大异议) |
| 4 | 先基于最新原型(V1.6)更新细致原型图 | §1.4(本文提出前置警告) |
| — | 前置认知:「V1.0–V1.6 原型中 4 个按钮丢失了」 | §1.4 结论:认知需修正 |
1. A. 现状核查(代码实证)
1.1 四个操作的完整实现链路
结论:四个操作全部为真实闭环,非 Mock。
| 操作 | UI 组件 | 事件处理 | Store Action | API 函数 | 后端端点 | 闭环 |
|---|---|---|---|---|---|---|
| 接单 | UserInfoBar.vue:116-124 <el-button @click="$emit('assign')"> |
ChatArea.vue:431-442 handleAssign() |
stores/conversation.ts:498-505 assignConv() |
api/conversation.ts:176-181 assignConversation() |
POST /conversations/{id}/assignbackend/app/api/conversations.py:265-271 |
✅ 真实 |
| 置顶 | UserInfoBar.vue:127-133 |
ChatArea.vue:507-514 handleTogglePin() |
stores/conversation.ts:535-542 togglePinConv() |
api/conversation.ts:215-218 togglePin() |
POST /conversations/{id}/pinconversations.py:373-378 |
✅ 真实 |
| 代办 | UserInfoBar.vue:136-142 |
ChatArea.vue:519-526 handleToggleTodo() |
stores/conversation.ts:549-556 toggleTodoConv() |
api/conversation.ts:227-230 toggleTodo() |
POST /conversations/{id}/todoconversations.py:466-471 |
✅ 真实 |
| 转接 | UserInfoBar.vue:145-164 <el-dropdown> + 坐席列表 |
ChatArea.vue:531-547 handleTransfer(targetAgentId)(含 ElMessageBox.confirm 二次确认) |
stores/conversation.ts:564-571 transferConv() |
api/conversation.ts:239-244 transferConversation() |
POST /conversations/{id}/transferconversations.py:493-499 |
✅ 真实 |
四个端点均带权限装饰器:
assign/transfer/grab→@require_permission("conversation", "update", "all")resolve/pin/todo→@require_permission("conversation", "update", "own")
⚠️ 权限差异对菜单设计的直接影响:
pin/todo是own权限,意味着后端只允许对自己名下的会话置顶/代办。若三点菜单在「同事会话」「历史会话」条目上无差别地展示这两项,用户点击后将收到 403,属于必须在前端拦截的可见性约束(详见 §1.2 与 §4.4)。
对比参照 — PRD-011 §3.1 提到的 Mock 问题现状:
TaskDetailView.vue 的 handleAction() 已被修复,不再是 PRD-011 所记录的无条件 ElMessage.success:
// src/frontend-agent/src/components/chat/TaskDetailView.vue:127-133(当前实际代码)
function handleAction(action: string): void {
if (props.todoItem.type === 'approval') {
console.info('[TaskDetailView] 审批动作已跳转企微审批原系统:', action)
return
}
ElMessage.info('该操作需在原系统中完成')
}
结论:会话侧 4 个操作是真闭环,任务侧(审批/工单)是降级跳转。二者性质根本不同——这一差异是 §5 组件抽象设计的核心依据。建议同步更新 PRD-011 §3.1 的行号与描述,该条 P0 阻塞项的表述已过期。
1.2 中栏 UserInfoBar 按钮完整清单与可见性条件
UserInfoBar.vue:114-194 的 .user-info-bar__actions 区共 6 个操作控件 + 1 个模式 chip,PM 设计菜单项状态时须逐条对齐:
| # | 按钮 | 代码位置 | 显示条件(v-if) |
禁用条件(:disabled) |
文案动态切换 | 本次是否移除 |
|---|---|---|---|---|---|---|
| 1 | 接单 | :116-124 |
常驻显示 | status !== 'queued' |
queued→「接单」;其他→「已接单」 |
✅ 用户要求移除 |
| 2 | 置顶 | :127-133 |
常驻显示 | 无 | is_pinned→「取消置顶」;否则→「📌 置顶」 |
✅ 用户要求移除 |
| 3 | 代办 | :136-142 |
常驻显示 | 无 | is_todo→「取消代办」;否则→「📋 代办」 |
✅ 用户要求移除 |
| 4 | 转接 | :145-164 |
常驻显示 | 无(但列表可为空,空时显示 disabled 项「暂无可用坐席」:159-161) |
固定「转接」,二级为在线坐席列表 姓名 (当前负载/最大负载) |
✅ 用户要求移除 |
| 5 | 摇人 | :167-174 |
canInviteCollaborator= status === 'serving' && (is_mine || is_collaborator)( ChatArea.vue:325-329) |
无 | 固定「🤝 摇人」 | ❌ 保留 |
| 6 | 结单 / 等待确认 | :177-193 |
status === 'serving' → 结单(danger)status === 'pending_close' → 「⏳ 等待员工确认」(warning, disabled) |
pending_close 态恒禁用 |
二态互斥 | ❌ 保留 |
| — | 历史会话开关 chip | :100-110 |
常驻(在 chips 区,非 actions 区) | historyLoading 时 is-loading |
historyMode→「🕐 返回当前」;否则→「🕐 历史会话」 |
❌ 保留 |
其他相关状态源:
conversation.status五态:ai_handling/queued/serving/pending_close/resolved(api/conversation.ts:46)is_mine、is_collaborator、can_grab三个归属判别字段(api/conversation.ts:69, 72, 79)- 转接目标坐席列表来源:
agentStore.availableAgents,由stores/agent.ts:226-243loadAvailableAgents()拉取getAgents('online')并排除自己;DEV 环境失败时降级到 mock 数据
⚠️ 关键设计缺口:现有 4 个按钮几乎没有基于会话归属的可见性控制——置顶/代办按钮在任何状态下都常驻且不禁用。这在中栏是可接受的(中栏只显示当前打开的会话,多为自己的),但一旦下沉到左栏,左栏同时呈现「我的会话 / 同事会话 / 历史会话」三个分区(ConversationList.vue:44-77),菜单项必须新增归属维度的可见性规则。这是本次改造真正新增的产品设计工作量,而非单纯的控件搬家。
1.3 左栏 ConversationItem 现状:DOM 结构、右侧空间占用、事件绑定
1.3.1 DOM 结构(ConversationItem.vue:11-149)
.conversation-item (display:flex; align-items:center; gap:8px; padding:8px 10px; margin:1px 6px; position:relative)
│ ← global.css:473-484;@click="$emit('click')" 绑在根节点(:21)
├── .conv-avatar-wrap (48×48 头像 + .new-msg-dot 新消息圆点) :23-43
├── .conversation-info (flex:1; min-width:0; overflow:hidden) :46-138
│ ├── .conversation-name 第一行 :48-96
│ │ ├── 📌 置顶图标 (v-if is_pinned) :50
│ │ ├── 📋 代办图标 (v-if is_todo) :52
│ │ ├── 姓名 .text-ellipsis :54
│ │ ├── VIP / 招手 / 需介入 / 情绪 / 坐席名 / 待确认 共 6 类 tag-badge :56-82
│ │ └── .priority-icons (margin-left:auto) 最多 4 个 16×16 图标 :84-95
│ ├── .conversation-summary-row 第二行:摘要(flex:1) + 时间(flex-shrink:0) :99-102
│ └── .conversation-meta 第三行:5 星紧急度 + 「接手」/「退出」按钮 :105-137
└── .conv-target-avatar 右侧 处理对象缩略头像(v-if showTargetAvatar) :141-148
1.3.2 右侧空间占用盘点(这是三点按钮方案最大的物理约束)
左栏总宽 --sidebar-width: 260px(global.css:98,min-width: 200px,可拖拽调整 global.css:270-271)。
条目内部可用宽度 ≈ 260 − 6×2(margin)− 10×2(padding)= 228px。
| 区域 | 宽度占用 | 可否让位 |
|---|---|---|
头像 .conv-avatar-wrap |
48px + gap 8px = 56px | ❌ 不可 |
.conv-target-avatar(右侧缩略头像) |
约 28-32px + gap 8px ≈ 36-40px,section !== 'history' 时恒显示(:316-321) |
⚠️ 可争议 |
.conversation-info 剩余 |
≈ 132-136px | — |
结论:.conversation-item 的最右侧已被 .conv-target-avatar 占用(除历史会话外恒显示)。三点按钮没有现成的空槽位,PM 必须在以下三条中做出明确取舍,不能回避:
- 方案 a:三点按钮 hover 时覆盖(absolute 定位)在
.conv-target-avatar之上 —— 保留信息密度,但 hover 时缩略头像被遮挡; - 方案 b:三点按钮替换
.conv-target-avatar(hover 时缩略头像淡出、三点淡入)—— 视觉最干净,推荐; - 方案 c:三点按钮插到
.conv-target-avatar右侧 —— 再吃掉 ~24px,.conversation-info压到 ~108px,第一行的 6 类 tag-badge + 4 个优先级图标将严重挤压,不推荐。
补充约束:第三行
.conversation-meta已存在「接手」grab-btn(:116-125,v-if="showGrab && can_grab")与「退出」leave-btn(:127-136,v-if="showLeave")两个margin-left:auto的 link 按钮。三点菜单里若也放「接手」,会与之重复,需在 §4.4 的菜单项矩阵中统一收口。
1.3.3 hover 态与事件绑定现状
| 项 | 现状 | 代码位置 |
|---|---|---|
| hover 背景 | .conversation-item:hover { background: var(--bg-hover) } |
global.css:486-488 |
| active 态 | .conversation-item.active { background: accent-soft; border-color: accent } |
global.css:490-493 |
| hover 显隐控件 | 当前完全没有。所有子元素都是常显或 v-if 数据驱动 |
— |
| 根节点点击 | @click="$emit('click')" → ConversationList.vue:52 → conversationStore.selectConversation(conv.id) |
ConversationItem.vue:21 |
| 内部按钮防冒泡 | 已有先例:@click.stop="$emit('grab')" / @click.stop="$emit('leave')" |
:122, :133 |
| 右键事件 | 左栏当前无任何 @contextmenu 绑定 |
全项目仅 MessageItem.vue:23 一处 |
| 键盘可达性 | 零。根节点是裸 <div>,无 tabindex、无 role、无 @keydown。列表不可 Tab 遍历、不可方向键导航 |
ConversationItem.vue:12-21 |
| 虚拟滚动 | 未启用。全项目 grep virtual / RecycleScroller 零命中,ConversationList.vue:44-77 为三段 v-for 全量渲染 |
— |
1.3.4 🔴 关键约束:父容器 overflow 会裁剪菜单
/* global.css:269-279 */
.workspace-sidebar { width: var(--sidebar-width); overflow: hidden; position: relative; }
/* global.css:356-360 */
.conversation-list-scroll { flex: 1; overflow-y: auto; overflow-x: hidden; }
双重裁剪:滚动容器 overflow-x: hidden + 侧栏 overflow: hidden。
推论(必须写入技术约束):任何以 position: absolute 挂在 .conversation-item(position: relative)内部的菜单,
- 横向必被裁掉 —— 260px 窄栏内放不下一个 120-160px 的菜单还要向右展开;
- 纵向在列表首尾会被滚动容器裁掉。
→ 菜单 DOM 必须脱离左栏容器,只有两条合规路径:position: fixed + 视口坐标(MessageItem.vue 现有做法),或 Teleport 到 body(Element Plus Popper 默认行为)。这一条直接决定了 §4 的技术选型。
1.4 「原型丢失 vs 代码丢失」——确定结论
结论:4 个按钮在真实运行代码中从未丢失,只在原型 HTML 的 v1.4 及之后版本中不存在。用户的前置认知需要修正两处:一是丢失发生在原型侧不是代码侧;二是起始版本是 v1.4 不是 v1.5。
逐版本关键词计数实证
| 原型版本 | 行数 | 接单 | 置顶 | 代办 | 转接 | 结单 | 判定 |
|---|---|---|---|---|---|---|---|
| v1.0 | 2447 | 11 | 3 | 1 | 2 | 1 | ✅ 有(:1559-1567) |
| v1.1 | 2448 | 11 | 3 | 1 | 2 | 1 | ✅ 有 |
| v1.2 | 2544 | 12 | 3 | 1 | 2 | 1 | ✅ 有 |
| v1.3 | 2707 | 12 | 3 | 1 | 2 | 1 | ✅ 有 |
| v1.4.archive | 961 | 0 | 0 | 0 | 0 | 0 | ❌ 首次消失 |
| v1.5 | 1058 | 0 | 0 | 0 | 0 | 0 | ❌ 无 |
| v1.6 | 1088 | 0 | 0 | 0 | 0 | 0 | ❌ 无 |
v1.0 原文(原型-...-v1.0.html:1559-1567):
<!-- 2026-07-24 优化:接单按钮(用户信息栏右侧操作区) -->
<div style="display:flex;align-items:center;gap:6px;margin-left:auto;">
<button class="btn-action primary" ...>📥 接单</button>
<button class="btn-action warning" ...>📌 置顶</button>
<button class="btn-action default" ...>📋 代办</button>
<button class="btn-action default" ...>🔄 转接</button>
</div>
根因:v1.4 起原型换了血统,不是同一条演进线
| 版本 | <title> |
性质 |
|---|---|---|
| v1.0–v1.3 | (完整工作台原型) | 全量高保真原型,2400-2700 行 |
| v1.4 | 坐席工作台 v1.4(B方案分栏 + 排查步骤全屏流程图) |
专题示意原型,961 行 |
| v1.5 | v1.5(B方案分栏 + 全屏流程图 + 智能推荐竖排 + 工具栏重组) |
专题示意原型 |
| v1.6 | v1.6(B方案分栏 + 全屏流程图 + 智能推荐竖排 + 工具栏重组 + 工具栏图标统一) |
专题示意原型 |
v1.4 是为论证「B 方案三栏分栏比例」和「排查步骤全屏流程图」而新画的布局示意图,不是 v1.3 的增量演进。证据:v1.6 通篇带 col-badge 标注(260px 固定 中栏 ≈ 60% 右栏 ≈ 40%),中栏 .user-info-bar(:539-546)只有头像+姓名+工号+在线状态三个元素,无任何操作按钮区;左栏 .conv-item(:498-533)只有 头像/姓名/时间/摘要/未读数 五个字段。
对照真实代码的保真度落差(这是给 PM 的核心警告):
| 元素 | 真实代码(ConversationItem.vue) |
v1.6 原型(.conv-item) |
|---|---|---|
| 置顶/代办图标 | ✅ 📌📋 | ❌ 无 |
| tag-badge | ✅ VIP/招手/需介入/情绪/坐席名/待确认 共 6 类 | ❌ 无 |
| 优先级图标 | ✅ ⛔👥⭐🔁 最多 4 个 | ❌ 无 |
| 紧急度星级 | ✅ 5 星 | ❌ 无 |
| 接手/退出按钮 | ✅ 有 | ❌ 无 |
| 处理对象缩略头像 | ✅ 有 | ❌ 无 |
| 中栏操作按钮 | ✅ 6 个 | ❌ 0 个 |
🔴 给 PM 的前置警告:需求 4「基于 V1.6 更新细致原型图」若直接在 v1.6 上加三点菜单,产出的原型将继续偏离真实实现两代——按 v1.6 那个五字段的简版条目去设计菜单按钮位置,会得出「右侧有大片空槽」的错误结论,而真实条目右侧已被缩略头像占满(§1.3.2)。
建议:v1.7 的左栏必须回填真实字段(可参照 v1.3 的高保真度 +
ConversationItem.vue的当前实现),否则本次评审通过的原型无法作为开发依据。这是一个需要 PM 明确接受的额外工作量,不宜静默跳过。
2. B. 交互形态可行性评估
2.1 技术底座盘点
| 能力 | 现状 | 结论 |
|---|---|---|
| Element Plus 版本 | element-plus@^2.7.0(package.json) |
2.x,el-dropdown / el-popover 均可用 |
| EP 有无原生右键菜单组件 | 没有。Element Plus 2.x 不提供 ContextMenu 组件(对比 Ant Design 的 Dropdown trigger=['contextMenu']、Naive UI 的 n-dropdown :x :y) |
⚠️ 右键方案需自行拼装 |
el-dropdown 现有用法 |
UserInfoBar.vue:145(转接)、TopBar.vue:37(在线状态) |
✅ 团队已熟练 |
el-popover 现有用法 |
AiReplyModeSwitch.vue:24-82(🤖 三态开关,含 trigger="click" placement="bottom-start" :hide-after="0" popper-class) |
✅ 最佳复用模板 |
| 自研右键菜单 | MessageItem.vue:23, 135-155, 326-336 一处 |
⚠️ 实现较粗糙,见 §2.2 |
@vueuse/core |
^14.0.0 |
✅ 有 onClickOutside / useElementBounding,可用于自研定位 |
| z-index 约定 | 无统一约定。散落 100/999/1000/1200/3000/9999/999999 |
🔴 风险,见 §6 R-3 |
2.2 精读 MessageItem.vue 现有右键实现(复用成本评估)
// MessageItem.vue:326-336
function showContextMenu(event: MouseEvent): void {
contextMenuStyle.value = { left: `${event.clientX}px`, top: `${event.clientY}px` }
contextMenuVisible.value = true
}
function closeContextMenu(): void { contextMenuVisible.value = false }
<!-- :23 --> <div class="message-row" @contextmenu.prevent="showContextMenu">
<!-- :135 --> <div v-if="contextMenuVisible" class="context-menu" :style="contextMenuStyle">
<!-- :155 --> <div v-if="contextMenuVisible" class="context-menu__overlay" @click="closeContextMenu"></div>
/* :631-669 */
.context-menu { position: fixed; z-index: 1000; ... }
.context-menu__overlay { position: fixed; inset: 0; z-index: 999; }
逐项评估:
| 技术点 | 现有实现 | 评估 |
|---|---|---|
| 定位算法 | left/top = clientX/clientY,position: fixed |
✅ fixed 是正确选择,天然规避 §1.3.4 的 overflow 裁剪。这是本实现最有价值的部分 |
| 边界处理 | 完全没有。菜单在视口右缘/下缘会溢出屏幕外 | 🔴 缺陷。中栏消息区较宽、菜单项少(最多 3 项 ≈ 120px 高),问题不明显;左栏条目密集,列表底部条目右键必然溢出视口下缘,不修复不可用 |
| 关闭时机 | 全屏 overlay @click 关闭 |
⚠️ 仅覆盖鼠标左键点击。未处理:Esc 键、滚轮滚动、窗口 resize、在别处再次右键(会叠出第二个菜单)、路由/会话切换 |
| z-index | 菜单 1000 / overlay 999 | 🔴 低于 Element Plus popper 默认起始值 2000。EP 的 el-dropdown/el-popover/el-message-box 若同时在场会盖住自研菜单 |
| 无障碍 | 无 role="menu" / role="menuitem",无焦点管理,Esc 不关闭,方向键不导航 |
🔴 不满足基本 a11y |
| 多实例互斥 | 无。每个 MessageItem 各持一份 contextMenuVisible,理论上可同时弹出多个 |
⚠️ 靠 overlay 侥幸规避 |
复用成本量化:
- 直接复制:0.2 人日,但把上述 5 项缺陷一并复制到左栏 —— 不建议;
- 提取 + 加固为通用 composable(
useContextMenu:fixed 定位 + 视口翻转 + Esc/scroll/resize 关闭 + 全局单例互斥 + z-index token):1.0-1.5 人日,同时可反向修复MessageItem.vue的现有缺陷; - 改用
el-popover虚拟触发(virtual-triggering+virtual-ref):EP 自带 Popper.js 翻转/贴边/Teleport/z-index 自动管理,0.5 人日,但需接受 EP popper 的默认动画与样式覆写成本。
2.3 三选项对比
选项 1:纯右键上下文菜单(选中对象 → 右键调出)
| 维度 | 评估 |
|---|---|
| 可行性 | ✅ 技术上完全可行。@contextmenu.prevent 绑到 .conversation-item 根节点即可 |
| 改造量 | 中。1.0-1.5 人日(含 composable 加固) |
| 优势 | 零视觉侵入 —— 不占用 §1.3.2 中已经紧张的右侧 24px;对熟练坐席效率最高;天然适配未来 PRD-011 的长列表 |
| 风险 | 🔴 发现性为零。无任何视觉提示,新坐席不可能自己发现。IT 支持组虽是技术人群,但坐席工作台是多人轮岗场景,不能假设全员知晓 🔴 需覆写浏览器原生右键菜单,部分用户会感到被剥夺 ⚠️ 触摸屏/远程桌面(VDI)环境下右键可能不可靠 —— 需确认坐席实际办公环境 |
| 适用性 | ❌ 不适合作为唯一入口。作为快捷方式是加分项 |
选项 2:三点按钮 + Popover(用户倾向方案)
| 维度 | 评估 |
|---|---|
| 可行性 | ✅ 可行,且有现成模板 AiReplyModeSwitch.vue |
| 改造量 | 中。el-popover + MoreFilled 图标(@element-plus/icons-vue@^2.3.0 已装)+ 菜单项可见性矩阵,1.5-2.0 人日(大头在菜单项状态矩阵与归属权限判定,不在控件本身) |
| 优势 | ✅ 发现性明确,零学习成本,与微信/钉钉/Slack 一致 ✅ el-popover 默认 Teleport 到 body,自动绕开 §1.3.4 的 overflow 裁剪✅ Popper.js 自动处理 placement 翻转与贴边,260px 窄栏用 placement="bottom-end" 即可稳定✅ z-index 由 EP 统一管理(2000+ 自增),不会与 MessageItem 的 1000 打架 |
| 风险 | ⚠️ 占位冲突(§1.3.2)—— 必须与 .conv-target-avatar 二选一或叠放,PM 须明确⚠️ 冒泡冲突 —— 三点按钮必须 @click.stop,否则点开菜单会连带切换会话。项目内已有正确先例(ConversationItem.vue:122/133 的 @click.stop),风险可控⚠️ hover 显隐会引入「鼠标移出条目→按钮消失→菜单是否跟着关」的边界判定,需明确规则(建议:菜单打开期间按钮强制常显并给条目加 .is-menu-open 态)⚠️ 每个条目挂一个 el-popover 实例,50+ 条目时有轻微渲染开销(EP popover 内容 lazy 渲染,实测可接受;若上虚拟滚动则必须改为单例菜单) |
| 适用性 | ✅ 适合作为主入口 |
选项 3:三点按钮为主 + 右键为快捷方式(并存)
| 维度 | 评估 |
|---|---|
| 可行性 | ✅ 可行,且是成本最低的并存实现 —— 前提是菜单内容抽成一个独立组件,两个触发源共用同一份菜单项定义与 handler |
| 改造量 | 选项 2 基础上 +0.3-0.5 人日(仅新增 @contextmenu.prevent 触发 + el-popover 的 virtual-triggering 坐标注入) |
| 优势 | ✅ 覆盖新手(三点可见)与熟手(右键高效)两类用户 ✅ 与 IM 类产品心智完全一致(微信 PC 会话列表:右键有菜单;企微:右键有菜单) ✅ 对宋献本人的原始诉求(需求 1 问右键、需求 2 要三点)是唯一的「全都要」解,且不是妥协——二者本就是同一菜单的两个触发器 |
| 风险 | ⚠️ 需保证两个入口的菜单项与状态永远一致(用同一 computed 生成菜单项数组即可根治)⚠️ 互斥:右键打开 A 条目菜单时若又点 B 条目三点,须先关 A(EP popover 需手动做单例控制,或用全局 activeMenuId) |
| 适用性 | ✅ 推荐 |
2.4 必答技术点逐条结论
| # | 技术点 | 结论 |
|---|---|---|
| 1 | Element Plus 是否有可复用组件 | 部分有。el-popover(✅ 推荐,模板见 AiReplyModeSwitch.vue:24-82)、el-dropdown(可用但 trigger 不支持 contextmenu,且默认渲染一个触发容器,用于条目内略重)。EP 2.x 无原生 ContextMenu 组件,右键需用 el-popover 的 virtual-triggering + virtual-ref 拼装,或自研 |
| 2 | 复用 MessageItem 右键实现的成本 | 直接复制 0.2 人日但继承 5 项缺陷(无边界翻转 / z-index 1000 偏低 / 关闭时机不全 / 无 a11y / 无单例互斥);提取加固为通用 composable 1.0-1.5 人日;改用 el-popover 虚拟触发 0.5 人日且缺陷全免 —— 推荐后者 |
| 3 | 260px 窄栏下的弹出方向与溢出裁剪 | 🔴 必须 Teleport 或 fixed。.workspace-sidebar{overflow:hidden}(global.css:277)+ .conversation-list-scroll{overflow-x:hidden}(global.css:359)双重裁剪,absolute 定位的菜单必被裁。用 el-popover 默认 teleport 到 body 即可根除。弹出方向建议 placement="bottom-end"(右对齐向下),Popper 在视口下缘不足时自动翻转为 top-end。菜单宽度建议 132-148px(4 项中文两字 + 图标),不要超过侧栏宽度 |
| 4 | 三点按钮与条目点击的冒泡冲突 | ⚠️ 真实存在但已有解。@click="$emit('click')" 绑在根节点(ConversationItem.vue:21),三点按钮须 @click.stop;项目内 grab-btn/leave-btn(:122/:133)已是此写法。额外注意:el-popover 的 popper 内容 teleport 到 body 后不在条目 DOM 子树内,不会冒泡到条目——菜单项点击天然安全;但 #reference 插槽内的按钮仍在子树内,必须 .stop。右键同理需 @contextmenu.prevent.stop |
| 5 | 虚拟滚动(PRD-011 U-5)下菜单跟随 | 当前未启用虚拟滚动(全项目零命中)。但 PRD-011 U-5 已挂起该项。🔴 前瞻性约束:虚拟滚动会在滚动时回收并复用条目 DOM,若菜单实例挂在条目内部,滚动时 reference 元素被销毁 → 菜单错位或残留。唯一稳妥的架构是「单例菜单」:整个左栏只渲染一个菜单组件(挂在 ConversationList 层级或全局),条目只负责 emit('open-menu', { item, x, y })。建议本次就按单例架构实现,成本相同,可免除未来上虚拟滚动时的返工 |
| 6 | 键盘可达性 | 🔴 现状为零:.conversation-item 是裸 <div>,无 tabindex/role/keydown(ConversationItem.vue:12-21)。本次改造不会让它变差,但也不会自动变好。最低要求:三点按钮用原生 <button> 或 el-button(可 Tab 聚焦、Enter/Space 激活);菜单容器加 role="menu",项加 role="menuitem",支持 ↑↓ 导航与 Esc 关闭 —— el-popover 不自带这套,需手写约 0.3 人日。建议列为 P2 单独排期,不阻塞本次。项目已有 useKeyboardShortcuts.ts(含 Escape/Tab/方向键处理框架,:110/:124-125/:151),可挂接 |
3. C. 与 PRD-011 的兼容性设计
3.1 耦合点识别
PRD-011 Phase 2 计划:「定义统一 ListItem 类型(含 kind 判别式),条目组件支持会话态与任务态两种渲染」(PRD-011 §6 Phase 2)。
本次要在左栏会话条目上加操作菜单 —— 这正是那个「条目组件」。若本次把菜单硬编码进 ConversationItem.vue,Phase 2 落地时必然重写。这是三个月后二次返工的确切来源。
3.2 kind 分派设计
graph TD
A["ListItem (PRD-011 统一类型)"] --> B{"kind 判别式"}
B -->|conversation| C["会话菜单<br/>接单 / 置顶 / 代办 / 转接<br/>(+ 接手 / 退出协作)"]
B -->|approval| D["审批菜单<br/>在企微审批中打开<br/>(U-1 已定:降级跳转)"]
B -->|ticket| E["工单菜单<br/>在 ITSM 中打开<br/>(U-1.2 阻塞:读链路即断)"]
C --> F["WorkItemActionMenu<br/>(统一渲染 + 定位 + 关闭 + a11y)"]
D --> F
E --> F
F --> G["emit('action', { kind, id, action, payload })"]
G --> H["ConversationList / WorkQueueList<br/>按 kind 路由到对应 store action"]
三类菜单的本质差异(必须在架构上体现,不能靠 if-else 混写):
| kind | 操作性质 | 执行路径 | 反馈模型 | 依据 |
|---|---|---|---|---|
conversation |
服务端真闭环 | store → API → 后端端点 → fetchConversations() 刷新 |
同步成功/失败 | §1.1 实证 |
approval |
降级跳转 | <a target="_blank"> 跳企微 → sys_approval_change 回调回写 → WS 推送 |
异步最终一致 | PRD-011 §6 Phase 0、U-1 |
ticket |
当前无对象 | ITSMService.get_todo_list() 无条件 return [](itsm_service.py:113-130) |
无 | PRD-011 U-1.2 |
🔴 对宋献的直接回答:「未来待办条目进入左栏后,同一个三点菜单是否要按 kind 分派出不同菜单项?」 必须分派,而且不只是菜单项文案不同 —— 是执行语义与反馈模型的根本不同。 会话操作点完即生效;审批操作点完只是跳走,真正的状态变更在企微侧异步回写。若共用一套「点击 → toast 成功」的反馈模型,就会重演 PRD-011 §3.1 定性为 P0 的那个错误(绿色成功提示 + 外部系统实际未变更)。菜单项定义必须携带
execMode: 'direct' | 'redirect' | 'disabled'字段,由容器按 mode 分派反馈策略。
3.3 建议的组件抽象边界
结论:应该抽 WorkItemActionMenu,但要抽在正确的层次上——抽「菜单壳」,不抽「业务动作」。
components/common/WorkItemActionMenu.vue ← 【本次新建】纯 UI 壳,零业务逻辑
职责:单例渲染 / fixed 或 teleport 定位 / 视口翻转 / Esc·scroll·resize·outside 关闭
/ role=menu + ↑↓ 键盘导航 / z-index token / 分组与分隔线 / danger 项样式
Props: items: MenuItem[] visible: boolean anchor: {x,y} | HTMLElement
Emits: select(action: string) close()
MenuItem: { key, label, icon?, danger?, disabled?, hidden?,
execMode: 'direct'|'redirect'|'disabled', tooltip? }
⛔ 不认识 conversation / approval / ticket,不 import 任何 store 与 api
composables/useConversationMenuItems.ts ← 【本次新建】会话菜单项工厂
入参:conversation + currentAgentId + section('my'|'colleague'|'history')
出参:MenuItem[](已按 §4.4 矩阵算好 hidden/disabled/label)
✅ 承载全部可见性规则,可单测,PRD-011 Phase 2 时原样复用
composables/useTodoMenuItems.ts ← 【PRD-011 Phase 2 新建】任务菜单项工厂
components/conversation/ConversationList.vue ← 【本次改造】持有单例菜单实例 + 路由 action 到 store
components/conversation/ConversationItem.vue ← 【本次改造】仅新增三点按钮与 @contextmenu,
只 emit('open-menu', {id, x, y}),不含菜单 DOM
为什么这样切:
- 单例菜单挂在 List 层 → 直接解决 §2.4-5 的虚拟滚动难题,且 Phase 2 换成
WorkQueueList时菜单壳零改动; - 菜单项工厂按 kind 分文件 → 完全对齐 PRD-011 的
kind判别式,新增 kind 只加一个 composable,不动壳; - 壳不认识业务 → 中栏
MessageItem.vue的右键菜单也可以迁过来复用,顺带修掉 §2.2 列出的 5 项缺陷(可作为 P2 收益)。
与 PRD-011 Phase 2 的衔接检查表:
| PRD-011 Phase 2 任务 | 本次设计是否兼容 |
|---|---|
定义统一 ListItem(含 kind) |
✅ MenuItem[] 由 kind 专属工厂生成,WorkItemActionMenu 不感知 kind |
| 条目组件支持会话态/任务态两种渲染 | ✅ 条目只 emit('open-menu'),两态共用同一菜单壳 |
| 左栏 Tab 分层(会话/待办/全部) | ✅ 「全部」Tab 混排时,容器按 item.kind 选工厂即可 |
| U-5 虚拟滚动 | ✅ 单例架构天然兼容 |
| U-6 待办未读/变更标识 | ➖ 与菜单无关 |
4. D. 明确推荐
4.1 推荐方案
推荐选项 3:三点按钮为主入口 + 右键为快捷方式,菜单壳单例化并抽象为
WorkItemActionMenu。
我同意宋献的方案 2 方向(三点按钮),但认为不应止步于此,理由如下。
-
需求 1 与需求 2 不是二选一,而是同一件事的两个触发器。 宋献先问「右键能不能做」,再提「加三个点」——从提问顺序看,右键是他真正想要的效率形态,三点是他为了保险给出的可实现形态。二者共用同一份菜单定义时,并存的边际成本只有 0.3-0.5 人日(见 §2.3 选项 3)。为省这半天而只做一个,是错误的取舍。
-
纯右键(选项 1)不可单独采用。 左栏会话列表是坐席每天使用频率最高的区域,且服务台存在轮岗与新人。零发现性的交互在这个位置是产品事故,不是效率优化。
-
单例菜单架构不是过度设计,是防返工的最小投入。 PRD-011 U-5 已把虚拟滚动挂起、Phase 2 已明确要做统一容器。若本次把
el-popover逐条挂在ConversationItem内,虚拟滚动落地时条目 DOM 回收会直接导致菜单错位——那时的重写成本 ≥ 现在多花的 0.5 人日。这正是宋献要求「避免三个月后第二次重做」的具体着力点。
推荐实施顺序:
| 阶段 | 内容 | 估时 |
|---|---|---|
| S1 | WorkItemActionMenu.vue(单例壳,fixed + 翻转 + 关闭时机 + z-index token) |
1.0 人日 |
| S2 | useConversationMenuItems.ts(§4.4 可见性矩阵)+ 单测 |
0.5 人日 |
| S3 | ConversationItem 三点按钮(hover 显隐 + @click.stop)+ @contextmenu.prevent.stop |
0.5 人日 |
| S4 | ConversationList 单例接线 + action 路由到 store |
0.5 人日 |
| S5 | 中栏按钮调整(见 §5.1,不建议全删) | 0.3 人日 |
| S6 | 键盘导航与 a11y(role/↑↓/Esc) | 0.3 人日(可 P2) |
| — | 合计 | 2.8-3.1 人日 |
4.2 与用户倾向的差异点(明确说明,不附和)
| 项 | 宋献倾向 | 我的建议 | 论据 |
|---|---|---|---|
| 交互形态 | 三点按钮(方案 2) | 三点按钮 + 右键并存 | 并存边际成本仅 0.3-0.5 人日;需求 1 本就在问右键 |
| 组件位置 | (未明确) | 菜单单例挂 List 层,不挂条目内 | PRD-011 U-5 虚拟滚动 + Phase 2 统一容器 |
| 中栏按钮 | 全删 4 个 | 仅删 3 个,转接保留(详见 §5.1) | 操作路径退化,见 §5.1 论证 |
| 原型基线 | 基于 v1.6 | v1.6 需先回填左栏真实字段再改 | v1.6 是专题示意图,左栏保真度落后真实代码两代(§1.4) |
4.3 三点按钮的落位建议(供 PM 直接采用)
- 位置:
.conversation-item最右侧,替换.conv-target-avatar(hover 时缩略头像opacity:0淡出、三点opacity:1淡入,同一栅格位,不产生布局抖动)——即 §1.3.2 的方案 b; - 尺寸:24×24px 命中区,图标
MoreFilled(@element-plus/icons-vue@^2.3.0已装)14-16px; - 显隐:默认隐藏,
.conversation-item:hover或.is-menu-open或:focus-visible时显示(保证键盘可达); - 菜单:
placement="bottom-end",宽 140px,视口下缘不足时自动翻转top-end; - 历史会话分区:
section === 'history'时本无缩略头像(ConversationItem.vue:318),三点按钮直接占该位。
4.4 菜单项可见性矩阵(PM 设计原型的直接输入)
按会话 status × 归属(is_mine / is_collaborator / can_grab)× 所属分区(section)分派。这是 §1.2 指出的「真正新增的产品设计工作量」的收口。
| 菜单项 | 显示条件 | 禁用条件 | 动态文案 | 后端权限 |
|---|---|---|---|---|
| 接单 | status === 'queued' |
— | 「接单」 | update:all |
| 置顶 / 取消置顶 | is_mine || is_collaborator |
— | is_pinned → 「取消置顶」 |
update:own ⚠️ 非本人会话会 403,必须靠显示条件拦住 |
| 代办 / 取消代办 | is_mine || is_collaborator |
— | is_todo → 「取消代办」 |
update:own ⚠️ 同上 |
| 转接 ▸ | status === 'serving' && is_mine |
availableAgents.length === 0 → 禁用并提示「暂无可用坐席」 |
「转接」,二级为在线坐席 姓名 (负载/上限) |
update:all |
| 接手 | can_grab && !is_mine && status === 'serving' |
— | 「接手」 | update:all |
| 退出协作 | is_collaborator && status === 'serving' |
— | 「退出协作」(danger) | — |
待 PM 决策的三个问题:
- 转接的二级菜单:三点菜单里做二级展开(
el-popover嵌套,260px 窄栏下体验待验证),还是点「转接」后弹独立 Dialog 选坐席?架构建议后者——窄栏内嵌套二级菜单在视口边缘的翻转组合过于脆弱,且坐席列表可能较长需要滚动与搜索。 - 接手/退出协作是否收进三点菜单?收进则第三行的
grab-btn/leave-btn(ConversationItem.vue:116-136)应同步移除,避免同一动作两个入口;不收则菜单只有 4 项、更聚焦。架构倾向:本次不动,保持第三行按钮,减少变更面。 - 结单是否也进左栏菜单?用户未提,但它是与置顶/代办同等级的会话动作。架构建议不进——结单需走摘要确认 Dialog(
ChatArea.vue:448-502),在左栏触发一个模态流程属于路径错位。
5. E. 风险与待明确事项
5.1 🔴 核心风险:移除中栏按钮导致的操作路径退化(正面回答)
判断:会构成退化,且退化程度按操作分为两档。必须区别对待,不建议 4 个一刀切全删。
场景推演
坐席正在中栏与员工对话,需要执行某操作:
| 操作 | 当前路径 | 全删后路径 | 退化判定 |
|---|---|---|---|
| 接单 | 中栏点「接单」 | 左栏找条目 → hover → 点三点 → 点接单 | ⚪ 不退化。接单发生在打开会话之前/之时,左栏本就是接单的自然场所(Zendesk/ServiceNow 皆如此)。且中栏按钮在 status !== 'queued' 时恒为 disabled 的「已接单」,长期占位却不可点,删除反而是净收益 |
| 置顶 | 中栏点「置顶」 | 左栏找条目 → 三点 → 置顶 | 🟡 轻微退化,可接受。置顶是列表组织行为,其效果(条目上浮)也只在左栏可见。操作与反馈同处一栏,语义上反而更内聚 |
| 代办 | 中栏点「代办」 | 左栏找条目 → 三点 → 代办 | 🟡 同置顶 |
| 转接 | 中栏点「转接」→ 下拉选坐席 → 确认 | 左栏找条目 → hover → 三点 → 转接 → 选坐席 → 确认 | 🔴 显著退化 |
为什么转接是显著退化
-
触发时机不同。置顶/代办是列表管理动作,坐席在扫视队列时执行;转接是对话过程动作——坐席读完员工描述、判断"这不归我管"的那一刻,注意力和鼠标都在中栏。此时被要求"回左栏,找到当前这条,hover 出三点,点开菜单,选转接",是注意力焦点的强制迁移。
-
交互步数从 3 步涨到 6 步:
- 现状:
点转接 → 选坐席 → 确认 - 全删后:
视线移左栏 → 定位当前条目 → hover → 点三点 → 点转接 → 选坐席 → 确认 - 且第 2 步「定位当前条目」本身有成本——左栏三分区、可滚动、条目会因置顶/紧急度动态重排(
ConversationList.vue:44-77),当前会话未必在视口内。
- 现状:
-
中栏本就要留按钮,删了转接反而制造不一致。用户明确只删 4 个,「摇人」(
UserInfoBar.vue:167-174)与「结单」(:177-193)保留在中栏。结果是:中栏留着"摇人/结单",转接却要去左栏——同为对话过程中的协作类动作,入口被劈成两处。这比"全部在中栏"或"全部在左栏"都差。
缓解建议(按优先级)
| 优先级 | 建议 | 说明 |
|---|---|---|
| P0 | 中栏保留「转接」,删除「接单/置顶/代办」3 个 | 与「摇人/结单」构成语义一致的"对话过程动作组"(转接/摇人/结单 = 把这件事交出去/拉人进来/做完了)。左栏三点菜单同时也提供转接,两个入口共用同一 handler,符合"高频动作允许多入口"原则 |
| P1 | 若宋献坚持 4 个全删 → 必须补快捷键 | 项目已有 useKeyboardShortcuts.ts 框架(含 ctrl+shift+a 等组合的注册范式,:141-151)。为转接注册全局快捷键(如 Ctrl+Shift+T)作用于当前会话,可把 6 步压回 2 步 |
| P1 | 若 4 个全删 → 中栏 UserInfoBar 收一个「⋯」总菜单 | 把接单/置顶/代办/转接收进中栏自己的三点菜单。视觉上达成用户"去掉 4 个按钮"的诉求(栏内只剩摇人/结单/⋯),功能上零退化。这可能是最贴近宋献真实意图的解——他要的多半是"顶栏太挤、按钮太多",而不是"这些功能不该在中栏" |
| P2 | 灰度与回滚 | 中栏按钮删除做成配置开关,上线后收集坐席反馈,2 周内可回滚 |
给宋献的一句话建议:如果去掉中栏 4 个按钮的动机是「顶栏视觉太拥挤」,那么把它们收进中栏自己的「⋯」菜单比赶到左栏更优——既减少了视觉元素,又不迁移操作焦点。建议在原型 v1.7 里把这两种形态都画出来做 A/B 对比,再定稿。
5.2 风险登记
| 编号 | 风险 | 等级 | 缓解 |
|---|---|---|---|
| R-1 | 转接操作路径退化,坐席转接效率下降 | 🔴 高 | §5.1:中栏保留转接,或补快捷键,或改中栏「⋯」总菜单 |
| R-2 | 原型 v1.7 基于低保真的 v1.6,左栏条目字段缺失导致菜单落位设计与实现对不上 | 🔴 高 | §1.4:v1.7 左栏必须回填 ConversationItem.vue 的真实字段后再设计;否则本次评审产出不可作为开发依据 |
| R-3 | z-index 层级冲突。项目无统一约定(100/999/1000/1200/3000/9999/999999 混用),EP popper 默认 2000+ |
🟡 中 | 引入 z-index token(建议:--z-dropdown: 2000 对齐 EP,--z-modal: 3000,--z-toast: 9000);优先用 EP popover 交由其管理 |
| R-4 | 菜单项可见性与后端权限不一致 → 点击后 403。pin/todo 是 update:own 权限(conversations.py:374, 467) |
🟡 中 | §4.4 矩阵在前端严格拦截;后端 403 时前端给明确文案而非静默(现状 stores/conversation.ts:539-541 仅 console.error,用户无任何感知——这是既有缺陷,建议一并修) |
| R-5 | 复制 MessageItem.vue 右键实现,把无边界翻转 / 无 Esc 关闭 / 无单例互斥等缺陷带入左栏 |
🟡 中 | §2.2:改用 el-popover 虚拟触发,或提取加固为通用 composable |
| R-6 | 未来虚拟滚动(PRD-011 U-5)导致条目内菜单实例随 DOM 回收而错位 | 🟡 中 | §3.3:本次即采用单例菜单架构 |
| R-7 | 三点按钮与 .conv-target-avatar 争位,导致 hover 时信息遮挡或布局抖动 |
🟢 低 | §4.3:同栅格位淡入淡出替换,不改变布局盒模型 |
| R-8 | PRD-011 §3.1 的 P0 描述已过期(TaskDetailView.vue 的无条件 ElMessage.success 已修复) |
🟢 低 | 建议 PM 更新 PRD-011 §3.1 行号与结论,避免后续评审基于过期事实 |
5.3 待明确事项
| 编号 | 事项 | 需谁决策 | 阻塞级别 |
|---|---|---|---|
| Q-1 | 中栏 4 个按钮是全删、删 3 留转接、还是改收进中栏「⋯」菜单? | 宋献 | 🔴 阻塞原型 v1.7 定稿 |
| Q-2 | 三点按钮与 .conv-target-avatar 的占位取舍(覆盖 / 替换 / 并列) |
宋献 + PM | 🔴 阻塞原型 |
| Q-3 | 转接的坐席选择用二级菜单还是独立 Dialog? | PM(架构建议 Dialog) | 🟡 阻塞开发 |
| Q-4 | 「接手」「退出协作」是否收进三点菜单(收则移除第三行按钮)? | PM(架构建议不动) | 🟡 阻塞开发 |
| Q-5 | 「结单」是否进左栏菜单? | PM(架构建议不进) | 🟢 |
| Q-6 | 是否同时提供右键快捷方式? | 宋献(架构强烈建议:是) | 🟡 |
| Q-7 | 坐席实际办公环境是否有 VDI/远程桌面/触摸屏?影响右键可靠性 | 宋献 | 🟡 影响 Q-6 |
| Q-8 | 原型 v1.7 是否接受"先回填左栏真实字段"的额外工作量? | 宋献 | 🔴 阻塞原型 |
| Q-9 | 键盘导航(↑↓/Esc/Tab)是本次做还是 P2 单排? | 宋献 | 🟢 |
6. 附录:核查方法与证据清单
| 类别 | 文件 | 用途 |
|---|---|---|
| 中栏按钮 | src/frontend-agent/src/components/chat/UserInfoBar.vue:114-194 |
6 个按钮清单与可见性条件 |
| 事件处理 | src/frontend-agent/src/components/chat/ChatArea.vue:325-336, 431-547 |
4 个 handler + 摇人可见性 computed |
| Store | src/frontend-agent/src/stores/conversation.ts:498-587 |
assignConv/togglePinConv/toggleTodoConv/transferConv/grabConv |
| API | src/frontend-agent/src/api/conversation.ts:176-256 |
5 个 API 函数 |
| 后端 | src/backend/app/api/conversations.py:265, 327, 373, 466, 493, 525 |
6 个端点与权限装饰器 |
| 左栏条目 | src/frontend-agent/src/components/conversation/ConversationItem.vue(全文 550 行) |
DOM 结构 / 事件 / 计算属性 |
| 左栏列表 | src/frontend-agent/src/components/conversation/ConversationList.vue:44-77 |
三分区渲染与 section 传参 |
| 全局样式 | src/frontend-agent/src/styles/global.css:98, 269-279, 356-360, 473-501 |
侧栏宽度 / overflow 裁剪 / 条目样式 |
| 右键先例 | src/frontend-agent/src/components/chat/MessageItem.vue:23, 135-155, 326-336, 631-669 |
定位 / 关闭 / z-index |
| EP popover 先例 | src/frontend-agent/src/components/chat/ai-assist/AiReplyModeSwitch.vue:24-82 |
最佳复用模板 |
| 任务侧 Mock | src/frontend-agent/src/components/chat/TaskDetailView.vue:127-133 |
证明 PRD-011 §3.1 已过期 |
| 依赖版本 | src/frontend-agent/package.json |
element-plus ^2.7.0 / @element-plus/icons-vue ^2.3.0 / @vueuse/core ^14.0.0 |
| 快捷键框架 | src/frontend-agent/src/composables/useKeyboardShortcuts.ts:102-234 |
可挂接的快捷键注册范式 |
| 原型 | docs/01-产品文档/04-坐席工作台/原型-...-v1.0~v1.6.html |
逐版本关键词计数(§1.4) |
| PRD | docs/01-产品文档/04-坐席工作台/PRD-REQ-坐席-011-统一工作队列重构-v0.1.md |
kind 判别式 / U-5 / §3.1 |
核查声明:本文所有结论均基于 2026-08-09 时点 src/ 目录下的实际代码,未依赖任何历史文档描述或记忆。核查过程未修改任何现有文件,未编写任何业务代码。
7. 变更记录
| 日期 | 版本 | 变更内容 | 变更人 |
|---|---|---|---|
| 2026-08-09 | v1.0 | 创建。完成现状核查(A)、三形态可行性评估(B)、PRD-011 兼容性设计(C)、方案推荐(D)、风险与待明确事项(E) | 高见远(架构师) |