wip: 2026-08-11 工作树快照(docs/memory/h5.py/scripts 等 447 项未评审改动,安全提交到 feat 分支)

This commit is contained in:
Simon
2026-08-11 09:59:44 +08:00
parent 6be361fb63
commit f2fd4fa012
447 changed files with 273482 additions and 1386 deletions
@@ -1,12 +1,13 @@
# IT智能服务台 - 产品开发流程与文档管理规范
> **版本**: v1.13
> **日期**: 2026-08-03
> **版本**: v1.14
> **日期**: 2026-08-09
> **状态**: [已评审]
> **作者**: Simon
> **变更说明**:
> - v1.11 → v1.12:新增 §14 大需求重构的文档化策略(2026-07-28 管理后台 IA 重构经验)
> - **v1.12 → v1.13**:新增 §16 技术方案偏离时的"增量更新章节"治理(基于 2026-08-03 voice_asr.py P0 安全巡检 + 文档-代码不一致修复经验),涵盖:① §16.1 技术方案与实际部署偏离的场景识别;② §16.2 §10 增量更新章节模板;③ §16.3 多策略功能(如语音输入的"手机 JS-SDK / PC 百度 ASR / Web Speech"三分支)的文档化要求;④ §16.4 API 端点调用方分析强制检查项;⑤ §16.5 运营/配置/运维文档与代码一致性核查清单模板
> - v1.12 → v1.13:新增 §16 技术方案偏离时的"增量更新章节"治理(基于 2026-08-03 voice_asr.py P0 安全巡检 + 文档-代码不一致修复经验),涵盖:① §16.1 技术方案与实际部署偏离的场景识别;② §16.2 §10 增量更新章节模板;③ §16.3 多策略功能(如语音输入的"手机 JS-SDK / PC 百度 ASR / Web Speech"三分支)的文档化要求;④ §16.4 API 端点调用方分析强制检查项;⑤ §16.5 运营/配置/运维文档与代码一致性核查清单模板
> - **v1.13 → v1.14**:新增 §13.5.1~13.5.4 归档命名约定全员一致性(基于 2026-08-09 工具栏统一设计 v0.3~v1.9 批量归档经验),涵盖:① §13.5.1 归档命名格式表 + 全员一致性铁律;② §13.5.2 引用指向规则(活文档指向当前生产版本);③ §13.5.3 归档目录位置规则;④ §13.5.4 归档操作 7 步 SOP;同步清理 §13.5 旧表述 `-archived-日期`(统一为 `.archive`
---
@@ -895,10 +896,90 @@ BUG-[模块]-[序号]
### 13.5 归档文件处理
- 归档文件命名:`原文件名-archived-日期.后缀`
- 归档目录`08-历史归档/`
- 归档条件:无活跃引用(不被其他文档引用)
- 归档前确认:仍有引用 → 提升回主文档;无引用 → 归档
- **归档命名约定**`.archive` 后缀(详见 §13.5.1
- **归档目录**:默认留在原目录(命名加 `.archive` 即可);仅当完全无活跃引用 + 跨多个子系统时,移至 `08-历史归档/{原类别}/`
- **归档条件**:无活跃引用(不被其他文档引用)
- **归档前确认**:仍有引用 → 提升回主文档(不归档);无引用 → 归档
#### 13.5.1 归档命名约定(全员一致性铁律)
> **核心原则**:**同一条演进线上所有旧版本必须全员打 `.archive` 后缀,不留半归档半未归档。**
**命名格式表**
| 文档类型 | 归档命名格式 | 示例 |
|---------|-------------|------|
| PRD / 技术方案 | `原文件名.archive.md` | `PRD-REQ-会话-001-工具栏统一设计-v1.3.archive.md` |
| 原型 HTML | `原文件名.archive.html` | `原型-REQ-会话-001-工具栏统一设计v1.4-xxx.archive.html` |
| 任务说明书 | `原文件名.v{X}.archive.md`(保留版本号) | `任务说明书-REQ-集成-002-xxx.v1.0.archive.md` |
| Vue 组件 | `原文件名.archive.vue` | `AssignmentMode.archive.vue` |
**触发场景(任一命中即应归档)**
| 场景 | 说明 |
|------|------|
| **被新版本接替** | 同一需求编号的 vN → vN+1,前序版本归档 |
| **被合并/收编** | 多源 → 单一源,被合并方归档 |
| **文档状态变更** | 文档状态变为 `[已废弃]`(§8.4 |
**全员一致性原则**
- 同一条演进线:**第一个版本加 `.archive` 之后,所有同级版本必须全员加 `.archive`**
- 自查:`ls *.html` 第一眼必须能区分"当前生产"和"历史归档"
- 反例:v1.9 加了 `.archive` 但 v1.4~v1.8 仍无 `.archive` → 目录里"半归档半未归档",结构混乱(2026-08-09 工具栏原型原状)
**`.archive` vs `.archive-日期` 取舍**
- **推荐**`.archive`(无日期)—— 文件位置 + REQ 编号本身就是时间戳
- 仅当需要明确"精确归档日期"时附加 `-YYYYMMDD`(如 `xxx.archive-20260809.md`
- §2.5 / §13.5 旧版提到的 `-archived-日期` 格式已**不再推荐**,统一为 `.archive`v1.14 清理)
**禁止**
- ❌ 把归档文件直接删除(保留作为历史快照)
- ❌ 在活文档里混用半归档(部分打 `.archive` 部分不打)
- ❌ 用 `-old` / `-deprecated` / `_bak` 等其他后缀命名归档(统一用 `.archive`
#### 13.5.2 引用指向规则
> **核心原则**:活文档的引用应指向当前生产版本;归档文件仅作历史参考。
| 引用场景 | 指向规则 |
|---------|---------|
| 任务说明书 / 交付清单的"基线" | 指向当前生产版本(不带 `.archive`|
| PRD / 技术方案引用 | 指向当前生产版本 |
| 运维 SOP / 故障排查 | 指向当前生产版本 |
| 历史对照 / 复盘 | 可引用 `.archive` 文件(说明对比意向)|
**反例**2026-08-09 群聊入口接线任务说明书教训):
- 任务说明书"5 按钮基线"指向 `v1.9-员工端落地版.archive.html` → 误导实施者锁错版本
- 修正:扫描 grep,识别所有指向 `.archive` 的活文档引用,**必须更新到当前生产版本**
#### 13.5.3 归档文件目录位置
- **首选**:留在原目录(不移动),命名加 `.archive` 后缀即可
- 仅当满足以下全部条件时才能移到 `08-历史归档/`
- 完全没有活跃引用
- 跨多个子系统的历史归档
- 明确标 `08-历史归档/{原类别}/原文件名`
**禁止**
- ❌ 把被引用的归档文件移动到 `08-历史归档/`(违反"归档 = 历史留痕"的可达性)
- ❌ 修改归档文件内容(保持历史不变性)
#### 13.5.4 归档操作 SOP(基于 2026-08-09 工具栏原型归档经验)
| # | 步骤 | 操作 |
|---|------|------|
| 1 | 识别归档范围 | 列出该 REQ 编号下所有版本文件,按时间/版本号排序 |
| 2 | 区分当前生产 vs 旧版本 | 当前生产保留原名,其余加 `.archive` |
| 3 | 批量重命名 | `Bash``mv` 或 PowerShell 的 `Rename-Item` |
| 4 | 跨文档树扫描引用 | `grep -rn 原文件名 docs/ src/` |
| 5 | 同步更新活文档引用 | 将指向 `.archive` 的引用更新到当前生产 |
| 6 | 跳过归档区 | `archives/` / `08-历史归档/` 目录内的引用**不动**(历史不变性) |
| 7 | 验证 | `ls` 目录确认当前生产 vs 归档分离清晰 |
### 13.6 Dify DSL 备份与变更管理规范
@@ -4,7 +4,7 @@
> **REQ 编号**: REQ-会话-001
> **负责人**: Duckula 主理人
> **状态**: ✅ 已拍板,正式交付开发
> **基线原型**: `原型-REQ-会话-001-工具栏统一设计v1.9-员工端落地版.html`
> **基线原型**: `原型-REQ-会话-001-工具栏统一设计v1.9-员工端落地版.archive.html`
> **历史版本**(已归档,仅供回溯): v1.3 / v1.4 / v1.5 / v1.6 / v1.7 / v1.8
---
@@ -1,554 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>人工坐席按钮 - 灵动岛风格 v0.1</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
background: #f5f5f5;
color: #1f2937;
padding: 24px;
line-height: 1.5;
}
h1 { font-size: 22px; font-weight: 600; margin-bottom: 8px; }
h2 { font-size: 18px; font-weight: 600; margin: 32px 0 12px; padding-bottom: 6px; border-bottom: 1px solid #e5e7eb; }
.meta { color: #6b7280; font-size: 13px; margin-bottom: 24px; }
/* === 现有工具按钮(基准) === */
.baseline { background: #fff; border: 1px solid #e5e7eb; border-radius: 10px; padding: 20px; margin-bottom: 20px; }
.baseline-row { display: flex; align-items: center; gap: 12px; padding: 8px 0; }
.baseline-row .name { width: 100px; font-size: 12px; color: #6b7280; }
.input-bar__tool-btn {
width: 36px; height: 36px; border-radius: 8px;
border: 1px solid #e5e7eb; background: #ffffff;
cursor: pointer; display: flex; align-items: center; justify-content: center;
font-size: 18px;
}
/* === 灵动岛按钮(核心) === */
.island-stage {
background: linear-gradient(135deg, #f0f4ff 0%, #fef7ff 100%);
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 32px 24px;
margin: 16px 0;
text-align: center;
}
.island-title { font-size: 12px; color: #6b7280; margin-bottom: 16px; }
/* 灵动岛主按钮 - 黑色胶囊 */
.island-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
height: 36px;
min-width: 36px;
padding: 0 12px;
background: #000;
color: #fff;
border: none;
border-radius: 18px;
cursor: pointer;
font-family: inherit;
font-size: 13px;
font-weight: 500;
position: relative;
overflow: hidden;
transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
-webkit-tap-highlight-color: transparent;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.island-btn:hover { transform: scale(1.05); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); }
.island-btn:active { transform: scale(0.96); }
/* 灵动岛状态标签 */
.island-label {
display: inline-block;
max-width: 0;
overflow: hidden;
white-space: nowrap;
opacity: 0;
transition: max-width 0.4s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.3s ease;
}
.island-btn:hover .island-label,
.island-btn.show-label .island-label {
max-width: 100px;
opacity: 1;
}
/* SVG 拟人客服容器 */
.island-char {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.island-char svg { width: 100%; height: 100%; }
/* === 6 态特定样式 + 动画 === */
/* disabled 态:灰色 + 闭眼 */
.island-btn.is-disabled {
background: #6b7280;
cursor: not-allowed;
opacity: 0.7;
}
.island-btn.is-disabled:hover { transform: none; }
.island-btn.is-disabled .island-char { animation: sleepBreath 3s ease-in-out infinite; }
@keyframes sleepBreath {
0%, 100% { transform: translateY(0) scale(1); }
50% { transform: translateY(-2px) scale(0.95); }
}
/* active 态:默认 + 站立 */
.island-btn.is-active {
background: #1f2937;
}
.island-btn.is-active .island-char { animation: idleBounce 2s ease-in-out infinite; }
@keyframes idleBounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-1.5px); }
}
/* urgent 态:红色 + 抖动 + 脉冲 */
.island-btn.is-urgent {
background: #dc2626;
animation: urgentShake 0.4s ease-in-out infinite, urgentPulse 1.2s ease-in-out infinite;
}
@keyframes urgentShake {
0%, 100% { transform: translateX(0) rotate(0deg); }
25% { transform: translateX(-1.5px) rotate(-3deg); }
75% { transform: translateX(1.5px) rotate(3deg); }
}
@keyframes urgentPulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(220, 38, 38, 0.6), 0 2px 8px rgba(0, 0, 0, 0.15); }
50% { box-shadow: 0 0 0 8px rgba(220, 38, 38, 0), 0 2px 8px rgba(0, 0, 0, 0.15); }
}
/* waiting 态:橙色 + 摇头 */
.island-btn.is-waiting {
background: #ea580c;
}
.island-btn.is-waiting .island-char { animation: waitingThink 2.5s ease-in-out infinite; }
@keyframes waitingThink {
0%, 100% { transform: rotate(0deg); }
20% { transform: rotate(-8deg); }
40% { transform: rotate(8deg); }
60% { transform: rotate(-6deg); }
80% { transform: rotate(6deg); }
}
/* end 态:绿色 + 电话中 */
.island-btn.is-end {
background: #16a34a;
}
.island-btn.is-end .island-char { animation: phoneTalk 1s ease-in-out infinite; }
@keyframes phoneTalk {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.15); }
}
/* reopen 态:蓝色 + 招手 */
.island-btn.is-reopen {
background: #2563eb;
}
.island-btn.is-reopen .island-char { animation: reopenWave 1.5s ease-in-out infinite; }
@keyframes reopenWave {
0%, 100% { transform: rotate(0deg) translateX(0); }
25% { transform: rotate(-15deg) translateX(-1px); }
75% { transform: rotate(15deg) translateX(1px); }
}
/* === 演示区:状态切换 === */
.state-demo {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 10px;
padding: 24px;
margin: 16px 0;
}
.state-tabs {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid #e5e7eb;
}
.state-tab {
padding: 6px 14px;
border: 1px solid #e5e7eb;
background: #fff;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
}
.state-tab.active {
background: #1f2937;
color: #fff;
border-color: #1f2937;
}
.state-tab:hover:not(.active) { background: #f3f4f6; }
.state-stage {
min-height: 140px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #f0f4ff 0%, #fef7ff 100%);
border-radius: 12px;
padding: 32px;
margin-bottom: 16px;
}
.state-desc {
color: #4b5563;
font-size: 13px;
line-height: 1.7;
background: #f9fafb;
padding: 12px 16px;
border-radius: 6px;
}
.state-desc strong { color: #1f2937; }
/* === 对比表 === */
.cmp-table { width: 100%; border-collapse: collapse; margin: 16px 0; font-size: 13px; }
.cmp-table th, .cmp-table td { border: 1px solid #e5e7eb; padding: 8px 10px; text-align: left; }
.cmp-table th { background: #f9fafb; font-weight: 500; }
.cmp-table .yes { color: #16a34a; }
.cmp-table .no { color: #dc2626; }
.cmp-table .partial { color: #f59e0b; }
.divider { border: 0; border-top: 1px dashed #d1d5db; margin: 24px 0; }
.note { background: #fef3c7; border-left: 3px solid #f59e0b; padding: 12px 16px; border-radius: 4px; font-size: 13px; color: #78350f; margin: 12px 0; }
</style>
</head>
<body>
<h1>🎧 人工坐席按钮 - 灵动岛风格 v0.1</h1>
<p class="meta">参考 iPhone 灵动岛(Dynamic Island)动画 + 拟人卡通客服 · 设计时间: 2026-08-04 凌晨</p>
<div class="note">
⚠️ 这是新方向(灵动岛)的概念原型,只做 1 套主方案。如认可,再扩成 2-3 套横向对比(不同卡通风格/不同灵动岛形状)。
</div>
<h2>📐 与现有工具按钮对比(尺寸/位置)</h2>
<div class="baseline">
<div class="baseline-row">
<span class="name">现有工具按钮</span>
<button class="input-bar__tool-btn">😊</button>
<button class="input-bar__tool-btn">📎</button>
<button class="input-bar__tool-btn">🎤</button>
<span style="color: #9ca3af; font-size: 12px;">36×36 / 8px 圆角 / 1px 浅灰边框 / 18px 图标</span>
</div>
<div class="baseline-row">
<span class="name">灵动岛按钮</span>
<button class="island-btn is-active">
<span class="island-char">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="9" r="3" fill="#fbbf24"/>
<path d="M5 21c0-3.866 3.134-7 7-7s7 3.134 7 7" fill="#fbbf24"/>
<circle cx="9" cy="9" r="0.5" fill="#1f2937"/>
<circle cx="15" cy="9" r="0.5" fill="#1f2937"/>
</svg>
</span>
<span class="island-label">客服在线</span>
</button>
<span style="color: #6b7280; font-size: 12px;">36×36 / 18px 圆角胶囊 / 纯黑背景 / 24px SVG 卡通</span>
</div>
</div>
<h2>🎮 6 态实时切换(点击下方 tab 切换)</h2>
<div class="state-demo">
<div class="state-tabs">
<button class="state-tab active" data-state="active">active 可呼叫</button>
<button class="state-tab" data-state="disabled">disabled 不可用</button>
<button class="state-tab" data-state="urgent">urgent 紧急</button>
<button class="state-tab" data-state="waiting">waiting 排队</button>
<button class="state-tab" data-state="end">end 已接入</button>
<button class="state-tab" data-state="reopen">reopen 重新打开</button>
</div>
<div class="state-stage">
<div id="stage"></div>
</div>
<div class="state-desc" id="desc"></div>
</div>
<h2>🎨 6 态设计详解</h2>
<div class="state-demo">
<h3 style="margin-bottom: 16px;">disabled(无会话/坐席离线)</h3>
<div class="state-stage" style="background: #f3f4f6;">
<button class="island-btn is-disabled">
<span class="island-char">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="9" r="3" fill="#d1d5db"/>
<path d="M5 21c0-3.866 3.134-7 7-7s7 3.134 7 7" fill="#d1d5db"/>
<path d="M9 9.5l2 1 2-1" stroke="#1f2937" stroke-width="0.5" stroke-linecap="round"/>
<path d="M7 7l4 2 4-2" stroke="#6b7280" stroke-width="0.8" stroke-linecap="round"/>
</svg>
</span>
<span class="island-label">无会话</span>
</button>
</div>
<div class="state-desc">
<strong>视觉</strong>: 灰色 (#6b7280) 背景 + 灰色卡通闭眼/打盹姿态<br>
<strong>动画</strong>: sleepBreath 3s 呼吸(微缩放 0.95-1.0),表达"睡着了/不在"<br>
<strong>交互</strong>: cursor: not-allowed + opacity 0.7,点击无响应
</div>
</div>
<div class="state-demo">
<h3 style="margin-bottom: 16px;">active(可呼叫人工坐席)</h3>
<div class="state-stage">
<button class="island-btn is-active">
<span class="island-char">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="9" r="3" fill="#fbbf24"/>
<path d="M5 21c0-3.866 3.134-7 7-7s7 3.134 7 7" fill="#fbbf24"/>
<circle cx="9" cy="9" r="0.6" fill="#1f2937"/>
<circle cx="15" cy="9" r="0.6" fill="#1f2937"/>
<path d="M10 11c0.5 0.5 3 0.5 4 0" stroke="#1f2937" stroke-width="0.5" stroke-linecap="round"/>
</svg>
</span>
<span class="island-label">客服在线</span>
</button>
</div>
<div class="state-desc">
<strong>视觉</strong>: 深灰 (#1f2937) 背景 + 卡通标准姿态(眼睛+微笑)<br>
<strong>动画</strong>: idleBounce 2s 微弹跳(上下 1.5px),表达"待机/有活力"<br>
<strong>交互</strong>: hover 弹出文字标签"客服在线",点击触发 shakeAgent
</div>
</div>
<div class="state-demo">
<h3 style="margin-bottom: 16px;">urgent(检测到紧急关键词)</h3>
<div class="state-stage" style="background: #fef2f2;">
<button class="island-btn is-urgent">
<span class="island-char">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="9" r="3" fill="#fbbf24"/>
<path d="M5 21c0-3.866 3.134-7 7-7s7 3.134 7 7" fill="#fbbf24"/>
<circle cx="9" cy="8" r="0.8" fill="#1f2937"/>
<circle cx="15" cy="8" r="0.8" fill="#1f2937"/>
<ellipse cx="12" cy="12" rx="1.5" ry="0.8" fill="#1f2937"/>
</svg>
</span>
<span class="island-label">紧急!</span>
</button>
</div>
<div class="state-desc">
<strong>视觉</strong>: 红色 (#dc2626) 背景 + 卡通惊恐表情(大眼睛+张嘴)<br>
<strong>动画</strong>: urgentShake 0.4s 抖动(左右摇) + urgentPulse 1.2s 脉冲(box-shadow 扩散)<br>
<strong>交互</strong>: hover 弹出"紧急!"提示,点击直接 shakeAgent(跳过 AI 拦截)
</div>
</div>
<div class="state-demo">
<h3 style="margin-bottom: 16px;">waiting(排队等待中)</h3>
<div class="state-stage" style="background: #fff7ed;">
<button class="island-btn is-waiting">
<span class="island-char">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="9" r="3" fill="#fbbf24"/>
<path d="M5 21c0-3.866 3.134-7 7-7s7 3.134 7 7" fill="#fbbf24"/>
<circle cx="9" cy="9" r="0.5" fill="#1f2937"/>
<circle cx="15" cy="9" r="0.5" fill="#1f2937"/>
<path d="M8 4l-1 2 2-1" stroke="#1f2937" stroke-width="0.8" stroke-linecap="round" fill="none"/>
</svg>
</span>
<span class="island-label">排队中</span>
</button>
</div>
<div class="state-desc">
<strong>视觉</strong>: 橙色 (#ea580c) 背景 + 卡通思考姿态(问号/挠头)<br>
<strong>动画</strong>: waitingThink 2.5s 摇头(左右 8°-6°-8°),表达"思考/等待"<br>
<strong>交互</strong>: hover 弹出"排队中 · 前面 N 人",点击取消排队
</div>
</div>
<div class="state-demo">
<h3 style="margin-bottom: 16px;">end(已接入坐席)</h3>
<div class="state-stage" style="background: #f0fdf4;">
<button class="island-btn is-end">
<span class="island-char">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="9" r="3" fill="#fbbf24"/>
<path d="M5 21c0-3.866 3.134-7 7-7s7 3.134 7 7" fill="#fbbf24"/>
<path d="M6 11 Q4 11 4 13 Q4 15 6 15 L7 14" stroke="#16a34a" stroke-width="1.5" fill="none" stroke-linecap="round"/>
<circle cx="9" cy="9" r="0.5" fill="#1f2937"/>
<circle cx="15" cy="9" r="0.5" fill="#1f2937"/>
<path d="M10 11.5c0.5 0.4 3 0.4 4 0" stroke="#1f2937" stroke-width="0.5" stroke-linecap="round"/>
</svg>
</span>
<span class="island-label">通话中</span>
</button>
</div>
<div class="state-desc">
<strong>视觉</strong>: 绿色 (#16a34a) 背景 + 卡通+耳机图标(电话通话中)<br>
<strong>动画</strong>: phoneTalk 1s 缩放脉冲(1.0-1.15),表达"对话/活跃"<br>
<strong>交互</strong>: hover 弹出"通话中 · 客服小王",点击结束会话
</div>
</div>
<div class="state-demo">
<h3 style="margin-bottom: 16px;">reopen(会话已关闭,可重新打开)</h3>
<div class="state-stage" style="background: #eff6ff;">
<button class="island-btn is-reopen">
<span class="island-char">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="9" r="3" fill="#fbbf24"/>
<path d="M5 21c0-3.866 3.134-7 7-7s7 3.134 7 7" fill="#fbbf24"/>
<circle cx="9" cy="9" r="0.5" fill="#1f2937"/>
<circle cx="15" cy="9" r="0.5" fill="#1f2937"/>
<path d="M9 11 Q9 13 12 13 Q15 13 15 11" stroke="#1f2937" stroke-width="0.5" fill="none"/>
<path d="M5 4 Q4 3 5 2" stroke="#2563eb" stroke-width="1" fill="none" stroke-linecap="round"/>
</svg>
</span>
<span class="island-label">重新打开</span>
</button>
</div>
<div class="state-desc">
<strong>视觉</strong>: 蓝色 (#2563eb) 背景 + 卡通招手姿态(嘴笑+招手)<br>
<strong>动画</strong>: reopenWave 1.5s 招手(左右 15° 旋转)<br>
<strong>交互</strong>: hover 弹出"重新打开 · 24h 内有效",点击 reopenCurrentConversation
</div>
</div>
<hr class="divider">
<h2>📊 与之前方案对比(为什么改方向)</h2>
<table class="cmp-table">
<thead>
<tr><th>维度</th><th>v0.1(4 套方块按钮)</th><th>v0.2(灵动岛 + 卡通)</th></tr>
</thead>
<tbody>
<tr>
<td>视觉</td>
<td class="partial">方块按钮 + emoji</td>
<td class="yes">黑色胶囊 + 拟人卡通</td>
</tr>
<tr>
<td>动画</td>
<td class="no">静态 6 态切换,无过渡</td>
<td class="yes">每态有专属动画(呼吸/抖动/摇头/招手)+ 状态间过渡</td>
</tr>
<tr>
<td>iPhone 灵动岛感</td>
<td class="no"></td>
<td class="yes">黑色胶囊 + 弹性动画 + label 弹出</td>
</tr>
<tr>
<td>视觉吸引力</td>
<td class="partial">功能可见,情感弱</td>
<td class="yes">卡通表情 + 动作,情感化(用户会"喜欢"这按钮)</td>
</tr>
<tr>
<td>与现有工具按钮统一性</td>
<td class="yes">★★★★★(同尺寸/同边框)</td>
<td class="partial">★★★(尺寸 36×36 一致,但黑色胶囊 + 圆角 18px 与方块 8px 不同)</td>
</tr>
<tr>
<td>hover 反馈</td>
<td class="partial">背景变灰</td>
<td class="yes">scale 1.05 + label 弹出文字"客服在线"等</td>
</tr>
<tr>
<td>6 态区分度</td>
<td class="partial">靠颜色(普通)</td>
<td class="yes">颜色 + 卡通表情 + 动画(强区分)</td>
</tr>
<tr>
<td>技术实现</td>
<td class="yes">纯 CSS 简单</td>
<td class="partial">CSS + SVG 卡通(中等)</td>
</tr>
</tbody>
</table>
<h2>❓ 待你拍板</h2>
<ol style="margin-left: 20px; line-height: 1.8;">
<li>这个 <strong>灵动岛 + 拟人小客服</strong> 方向 OK 吗?(如 OK,我会做 2-3 套不同卡通风格对比)</li>
<li>卡通角色是 <strong>拟人客服(戴耳机小黄人风格)</strong> 还是其他?(动物?机器人?抽象图形?)</li>
<li>hover 时是否要 <strong>弹出文字标签</strong>?("客服在线" / "排队中" 等)——这与"图标 only"工具有冲突</li>
<li>如整体 OK,要 <strong>画 2-3 个不同卡通风格</strong> 让你挑,还是先微调当前这套?</li>
</ol>
<p style="margin-top: 32px; padding-top: 16px; border-top: 1px solid #e5e7eb; color: #6b7280; font-size: 12px;">
v0.1 高保真原型 · 2026-08-04 · Duckula 主理人 · 等待用户拍板方向
</p>
<script>
// 6 态实时切换逻辑
const states = {
active: {
cls: 'is-active',
label: '客服在线',
char: '<svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="9" r="3" fill="#fbbf24"/><path d="M5 21c0-3.866 3.134-7 7-7s7 3.134 7 7" fill="#fbbf24"/><circle cx="9" cy="9" r="0.6" fill="#1f2937"/><circle cx="15" cy="9" r="0.6" fill="#1f2937"/><path d="M10 11c0.5 0.5 3 0.5 4 0" stroke="#1f2937" stroke-width="0.5" stroke-linecap="round"/></svg>',
desc: '<strong>视觉</strong>: 深灰背景 + 卡通标准姿态(眼睛+微笑) | <strong>动画</strong>: idleBounce 2s 微弹跳 | <strong>交互</strong>: hover 弹出文字"客服在线",点击呼叫人工坐席'
},
disabled: {
cls: 'is-disabled',
label: '无会话',
char: '<svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="9" r="3" fill="#d1d5db"/><path d="M5 21c0-3.866 3.134-7 7-7s7 3.134 7 7" fill="#d1d5db"/><path d="M9 9.5l2 1 2-1" stroke="#1f2937" stroke-width="0.5" stroke-linecap="round"/><path d="M7 7l4 2 4-2" stroke="#6b7280" stroke-width="0.8" stroke-linecap="round"/></svg>',
desc: '<strong>视觉</strong>: 灰色背景 + 卡通闭眼/打盹 | <strong>动画</strong>: sleepBreath 3s 呼吸(微缩放 0.95-1.0) | <strong>交互</strong>: cursor not-allowed,点击无响应'
},
urgent: {
cls: 'is-urgent',
label: '紧急!',
char: '<svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="9" r="3" fill="#fbbf24"/><path d="M5 21c0-3.866 3.134-7 7-7s7 3.134 7 7" fill="#fbbf24"/><circle cx="9" cy="8" r="0.8" fill="#1f2937"/><circle cx="15" cy="8" r="0.8" fill="#1f2937"/><ellipse cx="12" cy="12" rx="1.5" ry="0.8" fill="#1f2937"/></svg>',
desc: '<strong>视觉</strong>: 红色背景 + 卡通惊恐表情(大眼睛+张嘴) | <strong>动画</strong>: urgentShake 0.4s 抖动 + urgentPulse 1.2s 脉冲 | <strong>交互</strong>: hover 弹出"紧急!"提示,点击直接呼叫坐席'
},
waiting: {
cls: 'is-waiting',
label: '排队中',
char: '<svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="9" r="3" fill="#fbbf24"/><path d="M5 21c0-3.866 3.134-7 7-7s7 3.134 7 7" fill="#fbbf24"/><circle cx="9" cy="9" r="0.5" fill="#1f2937"/><circle cx="15" cy="9" r="0.5" fill="#1f2937"/><path d="M8 4l-1 2 2-1" stroke="#1f2937" stroke-width="0.8" stroke-linecap="round" fill="none"/></svg>',
desc: '<strong>视觉</strong>: 橙色背景 + 卡通思考姿态(问号/挠头) | <strong>动画</strong>: waitingThink 2.5s 摇头(左右 8°-6°-8°) | <strong>交互</strong>: hover 弹出"排队中 · 前面 N 人",点击取消排队'
},
end: {
cls: 'is-end',
label: '通话中',
char: '<svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="9" r="3" fill="#fbbf24"/><path d="M5 21c0-3.866 3.134-7 7-7s7 3.134 7 7" fill="#fbbf24"/><path d="M6 11 Q4 11 4 13 Q4 15 6 15 L7 14" stroke="#16a34a" stroke-width="1.5" fill="none" stroke-linecap="round"/><circle cx="9" cy="9" r="0.5" fill="#1f2937"/><circle cx="15" cy="9" r="0.5" fill="#1f2937"/><path d="M10 11.5c0.5 0.4 3 0.4 4 0" stroke="#1f2937" stroke-width="0.5" stroke-linecap="round"/></svg>',
desc: '<strong>视觉</strong>: 绿色背景 + 卡通+耳机图标(电话通话中) | <strong>动画</strong>: phoneTalk 1s 缩放脉冲(1.0-1.15) | <strong>交互</strong>: hover 弹出"通话中 · 客服小王",点击结束会话'
},
reopen: {
cls: 'is-reopen',
label: '重新打开',
char: '<svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="9" r="3" fill="#fbbf24"/><path d="M5 21c0-3.866 3.134-7 7-7s7 3.134 7 7" fill="#fbbf24"/><circle cx="9" cy="9" r="0.5" fill="#1f2937"/><circle cx="15" cy="9" r="0.5" fill="#1f2937"/><path d="M9 11 Q9 13 12 13 Q15 13 15 11" stroke="#1f2937" stroke-width="0.5" fill="none"/><path d="M5 4 Q4 3 5 2" stroke="#2563eb" stroke-width="1" fill="none" stroke-linecap="round"/></svg>',
desc: '<strong>视觉</strong>: 蓝色背景 + 卡通招手姿态(嘴笑+招手) | <strong>动画</strong>: reopenWave 1.5s 招手(左右 15° 旋转) | <strong>交互</strong>: hover 弹出"重新打开 · 24h 内有效",点击 reopenCurrentConversation'
}
};
const stage = document.getElementById('stage');
const descEl = document.getElementById('desc');
const tabs = document.querySelectorAll('.state-tab');
function render(state) {
const s = states[state];
stage.innerHTML = `<button class="island-btn ${s.cls} show-label">
<span class="island-char">${s.char}</span>
<span class="island-label">${s.label}</span>
</button>`;
descEl.innerHTML = s.desc;
}
tabs.forEach(tab => {
tab.addEventListener('click', () => {
tabs.forEach(t => t.classList.remove('active'));
tab.classList.add('active');
render(tab.dataset.state);
});
});
render('active');
</script>
</body>
</html>
@@ -1,561 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>人工坐席按钮设计方案 v0.1 — 4 套候选</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
background: #f5f5f5;
color: #1f2937;
padding: 24px;
line-height: 1.5;
}
h1 { font-size: 22px; font-weight: 600; margin-bottom: 8px; }
h2 { font-size: 18px; font-weight: 600; margin: 32px 0 12px; padding-bottom: 6px; border-bottom: 1px solid #e5e7eb; }
h3 { font-size: 15px; font-weight: 600; margin: 20px 0 10px; }
.meta { color: #6b7280; font-size: 13px; margin-bottom: 24px; }
/* 方案卡片 */
.scheme { background: #fff; border: 1px solid #e5e7eb; border-radius: 10px; padding: 20px; margin-bottom: 20px; }
.scheme.recommend { border: 2px solid #1989fa; box-shadow: 0 0 0 4px rgba(25, 137, 250, 0.08); }
.scheme-title { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
.scheme-title h3 { margin: 0; }
.badge { display: inline-block; padding: 2px 8px; font-size: 11px; border-radius: 4px; font-weight: 500; }
.badge-rec { background: #1989fa; color: #fff; }
.badge-info { background: #ecf5ff; color: #1989fa; }
.scheme-desc { color: #4b5563; font-size: 13px; margin-bottom: 14px; }
.pros-cons { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 12px; }
.pros, .cons { padding: 10px 12px; border-radius: 6px; font-size: 12.5px; }
.pros { background: #f0fdf4; border-left: 3px solid #16a34a; }
.cons { background: #fef2f2; border-left: 3px solid #dc2626; }
.pros h4, .cons h4 { font-size: 12px; margin-bottom: 6px; color: #166534; }
.cons h4 { color: #991b1b; }
.pros ul, .cons ul { list-style: none; padding: 0; }
.pros li::before { content: "✓ "; color: #16a34a; }
.cons li::before { content: "✗ "; color: #dc2626; }
.pros li, .cons li { padding: 2px 0; }
/* 按钮展示区 */
.demo-area {
background: #fafafa;
border: 1px dashed #d1d5db;
border-radius: 8px;
padding: 16px;
margin: 12px 0;
}
.demo-label { font-size: 12px; color: #6b7280; margin-bottom: 10px; }
.demo-row { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; padding: 8px 0; }
.demo-name { width: 80px; font-size: 12px; color: #6b7280; }
.demo-buttons { display: flex; align-items: center; gap: 8px; }
/* 现有工具按钮(基准) */
.input-bar__tool-btn {
width: 36px; height: 36px; border-radius: 8px;
border: 1px solid #e5e7eb; background: #ffffff;
cursor: pointer; display: flex; align-items: center; justify-content: center;
font-size: 18px; transition: all 0.2s;
-webkit-tap-highlight-color: transparent;
}
.input-bar__tool-btn:hover { background: #f3f4f6; border-color: #1989fa; }
/* 方案 A:轻量图标态 */
.scheme-a .btn { width: 36px; height: 36px; border-radius: 8px; border: 1px solid transparent; background: transparent; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 18px; transition: all 0.2s; -webkit-tap-highlight-color: transparent; }
.scheme-a .btn:hover { background: #f3f4f6; }
.scheme-a .btn--disabled { color: #9ca3af; cursor: not-allowed; }
.scheme-a .btn--active { color: #1989fa; }
.scheme-a .btn--urgent { color: #ee0a24; animation: pulseA 1.5s ease-in-out infinite; }
.scheme-a .btn--waiting { color: #f59e0b; }
.scheme-a .btn--end { color: #ee0a24; }
.scheme-a .btn--reopen { color: #1989fa; }
@keyframes pulseA { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.15); } }
/* 方案 B:边框+底色态(8px 圆角) */
.scheme-b .btn { width: 36px; height: 36px; border-radius: 8px; border: 1px solid #e5e7eb; background: #ffffff; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 18px; transition: all 0.2s; -webkit-tap-highlight-color: transparent; }
.scheme-b .btn--disabled { color: #9ca3af; border-color: #e5e7eb; background: #f9fafb; cursor: not-allowed; opacity: 0.7; }
.scheme-b .btn--active { color: #1989fa; border-color: #1989fa; background: rgba(25, 137, 250, 0.08); }
.scheme-b .btn--active:hover { background: rgba(25, 137, 250, 0.15); }
.scheme-b .btn--urgent { color: #ee0a24; border-color: #ee0a24; background: rgba(238, 10, 36, 0.08); animation: pulseB 1.5s ease-in-out infinite; }
.scheme-b .btn--waiting { color: #f59e0b; border-color: #f59e0b; background: rgba(245, 158, 11, 0.08); }
.scheme-b .btn--end { color: #ee0a24; border-color: #ee0a24; background: #ee0a24; }
.scheme-b .btn--end:hover { background: #dc2626; }
.scheme-b .btn--end .ico { color: #fff; }
.scheme-b .btn--reopen { color: #1989fa; border-color: #1989fa; background: #ecf5ff; }
@keyframes pulseB { 0%, 100% { box-shadow: 0 0 0 0 rgba(238, 10, 36, 0.4); } 50% { box-shadow: 0 0 0 6px rgba(238, 10, 36, 0); } }
/* 方案 C:仅 disabled/active 两态 */
.scheme-c .btn { width: 36px; height: 36px; border-radius: 8px; border: 1px solid #e5e7eb; background: #ffffff; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 18px; transition: all 0.2s; -webkit-tap-highlight-color: transparent; }
.scheme-c .btn--disabled { color: #9ca3af; cursor: not-allowed; opacity: 0.6; }
.scheme-c .btn--active { color: #1989fa; border-color: #1989fa; background: rgba(25, 137, 250, 0.08); }
.scheme-c .btn--active:hover { background: rgba(25, 137, 250, 0.15); }
.scheme-c .btn--urgent, .scheme-c .btn--waiting, .scheme-c .btn--end, .scheme-c .btn--reopen {
color: #1989fa; border-color: #1989fa; background: rgba(25, 137, 250, 0.08);
}
.scheme-c .btn--urgent::after { content: "•"; color: #ee0a24; position: absolute; margin-left: 16px; margin-top: -12px; font-size: 24px; }
/* 方案 D:边框+底色+右上角小圆点 */
.scheme-d .btn { width: 36px; height: 36px; border-radius: 8px; border: 1px solid #e5e7eb; background: #ffffff; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 18px; transition: all 0.2s; position: relative; -webkit-tap-highlight-color: transparent; }
.scheme-d .btn::after { content: ""; position: absolute; top: 4px; right: 4px; width: 8px; height: 8px; border-radius: 50%; background: #e5e7eb; }
.scheme-d .btn--disabled { color: #9ca3af; cursor: not-allowed; opacity: 0.6; }
.scheme-d .btn--disabled::after { background: #d1d5db; }
.scheme-d .btn--active::after { background: #1989fa; }
.scheme-d .btn--urgent { color: #ee0a24; border-color: #ee0a24; }
.scheme-d .btn--urgent::after { background: #ee0a24; animation: pulseD 1.5s ease-in-out infinite; }
.scheme-d .btn--waiting { color: #f59e0b; border-color: #f59e0b; }
.scheme-d .btn--waiting::after { background: #f59e0b; }
.scheme-d .btn--end { color: #ee0a24; border-color: #ee0a24; background: rgba(238, 10, 36, 0.05); }
.scheme-d .btn--end::after { background: #ee0a24; }
.scheme-d .btn--reopen { color: #1989fa; border-color: #1989fa; background: #ecf5ff; }
.scheme-d .btn--reopen::after { background: #1989fa; }
@keyframes pulseD { 0%, 100% { box-shadow: 0 0 0 0 rgba(238, 10, 36, 0.6); } 50% { box-shadow: 0 0 0 4px rgba(238, 10, 36, 0); } }
/* 对比表 */
.cmp-table { width: 100%; border-collapse: collapse; margin: 16px 0; font-size: 13px; }
.cmp-table th, .cmp-table td { border: 1px solid #e5e7eb; padding: 8px 10px; text-align: left; }
.cmp-table th { background: #f9fafb; font-weight: 500; }
.cmp-table .yes { color: #16a34a; }
.cmp-table .no { color: #dc2626; }
.cmp-table .partial { color: #f59e0b; }
/* 标题区分 */
.emoji-set { display: inline-block; min-width: 28px; text-align: center; font-size: 18px; }
.section-divider { border: 0; border-top: 1px dashed #d1d5db; margin: 24px 0; }
</style>
</head>
<body>
<h1>🎧 人工坐席按钮设计方案 v0.1</h1>
<p class="meta">4 套候选方案 · 6 态对比 · 与现有 😊📎🎤 工具按钮视觉统一 | 设计时间: 2026-08-04</p>
<h2>📐 现有工具按钮基准(😊📎🎤)</h2>
<div class="demo-area">
<div class="demo-label">规格: 36×36 / 8px 圆角 / 1px 边框 / 18px 图标</div>
<div class="demo-row">
<span class="demo-name">正常</span>
<div class="demo-buttons">
<button class="input-bar__tool-btn" title="表情">😊</button>
<button class="input-bar__tool-btn" title="文件">📎</button>
<button class="input-bar__tool-btn" title="语音">🎤</button>
</div>
</div>
<div class="demo-row">
<span class="demo-name">hover</span>
<div class="demo-buttons">
<button class="input-bar__tool-btn" style="background: #f3f4f6; border-color: #1989fa;">😊</button>
<button class="input-bar__tool-btn" style="background: #f3f4f6; border-color: #1989fa;">📎</button>
<button class="input-bar__tool-btn" style="background: #f3f4f6; border-color: #1989fa;">🎤</button>
</div>
</div>
</div>
<hr class="section-divider">
<!-- ============== 方案 A ============== -->
<div class="scheme scheme-a recommend">
<div class="scheme-title">
<h3>方案 A: 轻量图标态(⭐推荐)</h3>
<span class="badge badge-rec">推荐</span>
<span class="badge badge-info">最轻量</span>
</div>
<p class="scheme-desc">36×36 与现有工具按钮完全一致的尺寸,默认无背景无边框(透明),仅 emoji 图标 + 颜色变化区分 6 态。最符合"工具栏统一"的设计意图。</p>
<div class="demo-area">
<div class="demo-label">6 态对比(实际 HTML/CSS 渲染效果)</div>
<div class="demo-row">
<span class="demo-name">disabled</span>
<div class="demo-buttons">
<button class="btn btn--disabled" title="无会话">🔒</button>
<span class="emoji-set">🔒</span>
<span style="color: #9ca3af; font-size: 12px;">无会话 / 不可点击</span>
</div>
</div>
<div class="demo-row">
<span class="demo-name">active</span>
<div class="demo-buttons">
<button class="btn btn--active" title="可呼叫">🎧</button>
<span class="emoji-set">🎧</span>
<span style="color: #1989fa; font-size: 12px;">可呼叫人工坐席(默认蓝)</span>
</div>
</div>
<div class="demo-row">
<span class="demo-name">urgent</span>
<div class="demo-buttons">
<button class="btn btn--urgent" title="紧急">🚨</button>
<span class="emoji-set">🚨</span>
<span style="color: #ee0a24; font-size: 12px;">紧急关键词命中(脉冲动画)</span>
</div>
</div>
<div class="demo-row">
<span class="demo-name">waiting</span>
<div class="demo-buttons">
<button class="btn btn--waiting" title="排队中"></button>
<span class="emoji-set"></span>
<span style="color: #f59e0b; font-size: 12px;">排队等待中(橙色)</span>
</div>
</div>
<div class="demo-row">
<span class="demo-name">end</span>
<div class="demo-buttons">
<button class="btn btn--end" title="已接入">📴</button>
<span class="emoji-set">📴</button>
<span style="color: #ee0a24; font-size: 12px;">已接入坐席(红色)</span>
</div>
</div>
<div class="demo-row">
<span class="demo-name">reopen</span>
<div class="demo-buttons">
<button class="btn btn--reopen" title="重新打开">🔄</button>
<span class="emoji-set">🔄</span>
<span style="color: #1989fa; font-size: 12px;">会话已关闭,可重新打开</span>
</div>
</div>
</div>
<div class="pros-cons">
<div class="pros">
<h4>✓ 优点</h4>
<ul>
<li>视觉与现有工具按钮完全统一</li>
<li>工具栏最干净,无视觉噪音</li>
<li>hover 状态与现有 😊📎 完全一致</li>
<li>代码量最少(CSS 简单)</li>
</ul>
</div>
<div class="cons">
<h4>✗ 缺点</h4>
<ul>
<li>6 态仅靠图标 + 颜色区分,变化幅度小</li>
<li>重要状态(urgent 红 + 脉冲)可能不够醒目</li>
<li>disabled 状态可能与其他工具按钮混淆(都是灰色图标)</li>
</ul>
</div>
</div>
</div>
<!-- ============== 方案 B ============== -->
<div class="scheme scheme-b">
<div class="scheme-title">
<h3>方案 B: 边框+底色态(沿用 v1.3 风格,8px 圆角)</h3>
<span class="badge badge-info">视觉强</span>
</div>
<p class="scheme-desc">36×36 与现有按钮尺寸一致,8px 圆角与现有工具按钮对齐,1px 边框 + 底色变化区分 6 态。继承 v1.3 整合区 .call-agent-btn 视觉风格,只是缩小到与现有按钮一致。</p>
<div class="demo-area">
<div class="demo-label">6 态对比</div>
<div class="demo-row">
<span class="demo-name">disabled</span>
<div class="demo-buttons">
<button class="btn btn--disabled" title="无会话">🔒</button>
<span style="color: #9ca3af; font-size: 12px;">灰色边框 + 浅灰背景</span>
</div>
</div>
<div class="demo-row">
<span class="demo-name">active</span>
<div class="demo-buttons">
<button class="btn btn--active" title="可呼叫">🎧</button>
<span style="color: #1989fa; font-size: 12px;">蓝色边框 + 浅蓝背景</span>
</div>
</div>
<div class="demo-row">
<span class="demo-name">urgent</span>
<div class="demo-buttons">
<button class="btn btn--urgent" title="紧急">🚨</button>
<span style="color: #ee0a24; font-size: 12px;">红色边框 + 浅红背景 + 脉冲</span>
</div>
</div>
<div class="demo-row">
<span class="demo-name">waiting</span>
<div class="demo-buttons">
<button class="btn btn--waiting" title="排队中"></button>
<span style="color: #f59e0b; font-size: 12px;">橙色边框 + 浅橙背景</span>
</div>
</div>
<div class="demo-row">
<span class="demo-name">end</span>
<div class="demo-buttons">
<button class="btn btn--end" title="已接入"><span class="ico">📴</span></button>
<span style="color: #ee0a24; font-size: 12px;">红色填充 + 白字</span>
</div>
</div>
<div class="demo-row">
<span class="demo-name">reopen</span>
<div class="demo-buttons">
<button class="btn btn--reopen" title="重新打开">🔄</button>
<span style="color: #1989fa; font-size: 12px;">蓝色边框 + 浅蓝背景</span>
</div>
</div>
</div>
<div class="pros-cons">
<div class="pros">
<h4>✓ 优点</h4>
<ul>
<li>6 态视觉变化最明显(边框 + 底色 + 文字色)</li>
<li>继承 v1.3 整合区 .call-agent-btn 风格,代码复用高</li>
<li>disabled 状态明显区别于正常态</li>
<li>end 态红色填充强警示</li>
</ul>
</div>
<div class="cons">
<h4>✗ 缺点</h4>
<ul>
<li>比其他工具按钮视觉更"重"(有底色)</li>
<li>工具栏看起来像"3 个轻 + 1 个重",略不协调</li>
</ul>
</div>
</div>
</div>
<!-- ============== 方案 C ============== -->
<div class="scheme scheme-c">
<div class="scheme-title">
<h3>方案 C: 仅 disabled/active 两态(其他用 title 提示)</h3>
<span class="badge badge-info">最简洁</span>
</div>
<p class="scheme-desc">36×36 与现有工具按钮完全一致,只有 disabled 和 active 在工具栏有视觉变化。其他 4 态(urgent/waiting/end/reopen)保持 active 蓝边框,靠 title 属性 + 整合区 toast 提示。</p>
<div class="demo-area">
<div class="demo-label">6 态对比(除 disabled 外,其他都用 active 蓝边框)</div>
<div class="demo-row">
<span class="demo-name">disabled</span>
<div class="demo-buttons">
<button class="btn btn--disabled" title="无会话,暂不可用">🔒</button>
<span style="color: #9ca3af; font-size: 12px;">灰色 + 不可点击</span>
</div>
</div>
<div class="demo-row">
<span class="demo-name">active</span>
<div class="demo-buttons">
<button class="btn btn--active" title="点击呼叫人工坐席">🎧</button>
<span style="color: #1989fa; font-size: 12px;">蓝边框(可点击)</span>
</div>
</div>
<div class="demo-row">
<span class="demo-name">urgent</span>
<div class="demo-buttons">
<button class="btn btn--urgent" title="检测到紧急问题,点击直接呼叫">🚨</button>
<span style="color: #1989fa; font-size: 12px;">同 active 视觉,靠 title 提示</span>
</div>
</div>
<div class="demo-row">
<span class="demo-name">waiting</span>
<div class="demo-buttons">
<button class="btn btn--waiting" title="排队中,点击取消"></button>
<span style="color: #1989fa; font-size: 12px;">同 active 视觉,靠 title 提示</span>
</div>
</div>
<div class="demo-row">
<span class="demo-name">end</span>
<div class="demo-buttons">
<button class="btn btn--end" title="已接入,点击结束咨询">📴</button>
<span style="color: #1989fa; font-size: 12px;">同 active 视觉,靠 title 提示</span>
</div>
</div>
<div class="demo-row">
<span class="demo-name">reopen</span>
<div class="demo-buttons">
<button class="btn btn--reopen" title="会话已关闭,点击重新打开">🔄</button>
<span style="color: #1989fa; font-size: 12px;">同 active 视觉,靠 title 提示</span>
</div>
</div>
</div>
<div class="pros-cons">
<div class="pros">
<h4>✓ 优点</h4>
<ul>
<li>工具栏最简洁(只有蓝/灰两态视觉)</li>
<li>完全不会"视觉抢戏"其他工具按钮</li>
</ul>
</div>
<div class="cons">
<h4>✗ 缺点</h4>
<ul>
<li>用户错过重要状态(urgent 红 + 脉冲 失效)</li>
<li>必须依赖整合区 toast 提示,体验割裂</li>
<li>disabled 状态和其他工具按钮可点击状态难以区分</li>
<li>功能降级感明显,用户可能不知道当前是哪种状态</li>
</ul>
</div>
</div>
</div>
<!-- ============== 方案 D ============== -->
<div class="scheme scheme-d">
<div class="scheme-title">
<h3>方案 D: 边框+底色+右上角小圆点(角标方案)</h3>
<span class="badge badge-info">状态最强</span>
</div>
<p class="scheme-desc">36×36 与现有工具按钮完全一致,8px 圆角,默认无边框(与其他工具风格统一),靠右上角小圆点(8×8 圆)区分 6 态。状态最显眼的方案,但视觉复杂。</p>
<div class="demo-area">
<div class="demo-label">6 态对比(右上角小圆点不同色)</div>
<div class="demo-row">
<span class="demo-name">disabled</span>
<div class="demo-buttons">
<button class="btn btn--disabled" title="无会话,暂不可用">🔒</button>
<span style="color: #6b7280; font-size: 12px;">灰色 + 灰色角标</span>
</div>
</div>
<div class="demo-row">
<span class="demo-name">active</span>
<div class="demo-buttons">
<button class="btn btn--active" title="点击呼叫人工坐席">🎧</button>
<span style="color: #1989fa; font-size: 12px;">蓝色角标</span>
</div>
</div>
<div class="demo-row">
<span class="demo-name">urgent</span>
<div class="demo-buttons">
<button class="btn btn--urgent" title="紧急,点击直接呼叫">🚨</button>
<span style="color: #ee0a24; font-size: 12px;">红色边框 + 红色角标(脉冲)</span>
</div>
</div>
<div class="demo-row">
<span class="demo-name">waiting</span>
<div class="demo-buttons">
<button class="btn btn--waiting" title="排队中,点击取消"></button>
<span style="color: #f59e0b; font-size: 12px;">橙色边框 + 橙色角标</span>
</div>
</div>
<div class="demo-row">
<span class="demo-name">end</span>
<div class="demo-buttons">
<button class="btn btn--end" title="已接入,点击结束">📴</button>
<span style="color: #ee0a24; font-size: 12px;">红色边框 + 红色角标</span>
</div>
</div>
<div class="demo-row">
<span class="demo-name">reopen</span>
<div class="demo-buttons">
<button class="btn btn--reopen" title="重新打开">🔄</button>
<span style="color: #1989fa; font-size: 12px;">蓝色边框 + 蓝色角标</span>
</div>
</div>
</div>
<div class="pros-cons">
<div class="pros">
<h4>✓ 优点</h4>
<ul>
<li>状态最强视觉(角标 + 边框 + 颜色三重提示)</li>
<li>图标不变(只用角标),不切换图标含义</li>
<li>扩展性好(可加更多状态)</li>
</ul>
</div>
<div class="cons">
<h4>✗ 缺点</h4>
<ul>
<li>CSS 最复杂(角标需要 ::after 伪元素)</li>
<li>小圆点在 36×36 空间内视觉占比偏大</li>
<li>多个状态同时存在时可能视觉混乱</li>
<li>其他工具按钮没有角标,只这一个有"特殊"</li>
</ul>
</div>
</div>
</div>
<hr class="section-divider">
<h2>📊 4 套方案对比表</h2>
<table class="cmp-table">
<thead>
<tr>
<th>维度</th>
<th>方案 A(轻量)</th>
<th>方案 B(边框底色)</th>
<th>方案 C(仅两态)</th>
<th>方案 D(角标)</th>
</tr>
</thead>
<tbody>
<tr>
<td>尺寸 / 圆角</td>
<td>36×36 / 8px ✓</td>
<td>36×36 / 8px ✓</td>
<td>36×36 / 8px ✓</td>
<td>36×36 / 8px ✓</td>
</tr>
<tr>
<td>与现有工具按钮视觉一致</td>
<td class="yes">★★★★★</td>
<td class="partial">★★★</td>
<td class="yes">★★★★★</td>
<td class="partial">★★★</td>
</tr>
<tr>
<td>6 态视觉变化强度</td>
<td class="partial">★★</td>
<td class="yes">★★★★★</td>
<td class="no"></td>
<td class="yes">★★★★★</td>
</tr>
<tr>
<td>disabled 区分度</td>
<td class="partial">★★</td>
<td class="yes">★★★★</td>
<td class="yes">★★★★</td>
<td class="yes">★★★★</td>
</tr>
<tr>
<td>紧急态(urgent)醒目度</td>
<td class="partial">★★★(脉冲动画)</td>
<td class="yes">★★★★★(红+脉冲)</td>
<td class="no">★(无变化)</td>
<td class="yes">★★★★★(角标脉冲)</td>
</tr>
<tr>
<td>CSS 复杂度</td>
<td class="yes"></td>
<td class="partial"></td>
<td class="yes"></td>
<td class="no">高(::after)</td>
</tr>
<tr>
<td>代码改动量</td>
<td class="yes">最小</td>
<td class="partial"></td>
<td class="yes">最小</td>
<td class="partial"></td>
</tr>
<tr>
<td>工具栏整体协调性</td>
<td class="yes">★★★★★</td>
<td class="partial">★★★</td>
<td class="yes">★★★★★</td>
<td class="partial">★★</td>
</tr>
</tbody>
</table>
<h2>💡 我的推荐</h2>
<p><strong>方案 A(轻量图标态)⭐推荐</strong></p>
<ul style="margin-left: 20px; line-height: 1.8;">
<li>完全符合你的需求("与现有表情、文件大小基本一致")</li>
<li>工具栏最干净,4 个按钮视觉完全统一</li>
<li>6 态用 emoji + 颜色 + 脉冲动画组合,够用且不抢戏</li>
<li>CSS 最简单,后续维护成本低</li>
</ul>
<p><strong>备选:方案 B(边框底色态)</strong></p>
<ul style="margin-left: 20px; line-height: 1.8;">
<li>如果你觉得 A 的 6 态视觉变化不够强,选 B</li>
<li>继承 v1.3 整合区 .call-agent-btn 风格,代码复用高</li>
<li>但工具栏看起来像"3 个轻 + 1 个重"</li>
</ul>
<h2>❓ 待你拍板</h2>
<ol style="margin-left: 20px; line-height: 1.8;">
<li>选 A / B / C / D 哪套?(或需要微调?)</li>
<li>6 态 emoji 选用是否同意?(🔒/🎧/🚨/⏳/📴/🔄)</li>
<li>动画(urgent 脉冲 + waiting 黄色 + end 红色填充)是否都保留?</li>
<li>如选 B 或 D:边框颜色是否与现有工具按钮 hover 色(--accent)一致?</li>
</ol>
<p style="margin-top: 32px; padding-top: 16px; border-top: 1px solid #e5e7eb; color: #6b7280; font-size: 12px;">
原型 v0.1 · 2026-08-04 · Duckula 主理人 · 等待用户拍板后实施
</p>
</body>
</html>
@@ -1,12 +1,12 @@
# PRD — 坐席端统一工作队列重构
> **REQ编号**: REQ-坐席-011
> **版本**: v0.1(方案草案 / 沟通确认阶段)
> **版本**: v0.1(方案草案 / 沟通确认阶段C-8 决策见 §6.4
> **状态**: 🟡 待评审 — 含未决项,不得据此排期开发
> **优先级**: P1(其中 Phase 0 为 P0
> **日期**: 2026-08-08
> **作者**: 宋献
> **关联**: REQ-坐席-004(任务详情视图切换)、REQ-坐席-009(会话状态Tab筛选)、任务说明书 #132
> **关联**: REQ-坐席-004(任务详情视图切换)、REQ-坐席-009(会话状态Tab筛选)、任务说明书 #132、原型 `原型-REQ-坐席-000-坐席工作台-v1.8.html`v1.8 演进)
---
@@ -66,22 +66,61 @@ D-3 不是筛选条件的调整,而是新增一条状态机路径。当前 `to
---
## 3. 阻塞项(P0
## 3. 阻塞项(原 P02026-08-09 修订为 P1
### 3.1 任务详情操作按钮全部为 Mock
### 3.1 任务详情操作按钮无服务端闭环(原「全部为 Mock」,已部分修复)
```js
// src/frontend-agent/src/components/chat/TaskDetailView.vue:117
> **📝 2026-08-09 修订**:本节原记录 `TaskDetailView.vue:117` 为无条件 `ElMessage.success` 的 Mock 实现。
> 经架构师代码核查(`技术核查-坐席会话条目操作菜单-v1.0.md` §1.1),**该无条件成功提示已被修复**,行号亦已变更。
> 原描述已过期,现更新如下,避免后续评审基于过期事实。
#### 3.1.1 当前实际代码
```ts
// src/frontend-agent/src/components/chat/TaskDetailView.vue:129-1352026-08-09 时点)
function handleAction(action: string): void {
ElMessage.success(`操作成功:${action}`) // 仅 toast,不调用任何接口
if (props.todoItem.type === 'approval') {
// 企微不支持服务端代审批,跳转由 ApprovalDetail 的 <a target="_blank"> 原生完成
console.info('[TaskDetailView] 审批动作已跳转企微审批原系统:', action)
return
}
ElMessage.info('该操作需在原系统中完成')
}
```
REQ-坐席-004 §2.4 定义的全部操作 —— 工单(接单 / 开始处理 / 结单 / 转派)、审批(通过 / 拒绝 / 转交)—— **均不生效**
**修复内容**:无条件 `ElMessage.success` 已移除,改为按 `type` 分支——
- `type === 'approval'` → 静默返回(跳转由 `ApprovalDetail``<a target="_blank">` 原生完成),不再弹成功提示;
- 其他类型(工单等) → `ElMessage.info('该操作需在原系统中完成')`,中性提示,不谎报成功。
**风险定级:P0,阻塞布局合并。**
#### 3.1.2 修订后的结论
理由:当前待办面板位于右栏底部 260px 区域,坐席误操作的暴露面有限。一旦将待办提升至左栏主队列首屏,等同于把不可用功能放置于最高可见度位置。坐席点击「审批通过」后收到绿色成功提示,而企业微信侧该单仍处于挂起状态 —— 属于会造成真实业务后果的错误反馈。
| 项 | 原结论(v0.1 初稿) | 现结论(2026-08-09 核查后) |
|---|---|---|
| 是否谎报成功 | 是(无条件绿色成功 toast) | ❌ **否,已修复**。审批静默跳转,其他类型为中性 info |
| 是否服务端闭环 | 否 | ❌ **仍否**。审批为降级跳转 + 回调回写(U-1 已定),工单受 U-1.1 / U-1.2 外部阻塞 |
| 风险定级 | 🔴 P0,阻塞布局合并 | 🟡 **降级为 P1**,不再是"错误反馈"型 P0,但**服务端闭环缺失依然阻塞 Phase 2 的工单/审批混排**(Phase 0 的降级跳转 + 回写仍须先落地) |
**剩余阻塞点**(未因本次修复而消解):
- 审批:`approval.py:902``sys_approval_change` 回调状态回写尚未补全 → 跳转后前端无法感知外部状态变更(Phase 0 待办项);
- 工单:`ITSMService.get_todo_list()` 无条件 `return []`(U-1.2),读链路即断,写接口更无从谈起(U-1.1 / T05)。
#### 3.1.3 🔴 与会话侧操作的性质差异(Phase 2 组件抽象的核心依据)
| 维度 | 会话侧 4 操作(接单/置顶/代办/转接) | 任务侧操作(审批/工单) |
|---|---|---|
| 闭环性质 | **服务端真闭环**`UserInfoBar.vue:116-164``ChatArea.vue` handler → `stores/conversation.ts:498-571``api/conversation.ts:176-244` → 后端 `conversations.py` 六端点,**全链路实证可用** | **降级跳转**(审批)/ **当前无对象**(工单) |
| 执行路径 | store → API → 后端 → `fetchConversations()` 刷新 | `<a target="_blank">` 跳外部系统 → 回调回写 → WS 推送 |
| 反馈模型 | **同步**成功/失败 | **异步最终一致** |
| 后端权限 | `assign`/`transfer`/`grab` = `update:all``resolve`/`pin`/`todo` = `update:own` | 不适用 |
> **这一差异是 Phase 2 组件抽象不可回避的约束**:左栏统一容器上的操作菜单**必须按 `kind` 分派反馈策略**,菜单项定义需携带 `execMode: 'direct' | 'redirect' | 'disabled'` 字段。
> **若三类工作项共用一套「点击 → toast 成功」的反馈模型,就会以另一种形式重演本节原先定性为 P0 的那个错误。**
> 具体设计见 `PRD-REQ-坐席-012-会话条目操作菜单-v1.0.md` §7.2。
**风险定级:🟡 P1(由 P0 降级)。**
降级理由:谎报成功的错误反馈已消除,坐席不再会因绿色提示误判外部系统已变更。
**但仍阻塞 Phase 2**:待办提升至左栏主队列首屏后,若操作仍只是"提示到原系统操作",等同于把无实际能力的入口放在最高可见度位置——Phase 0 的降级跳转 + 状态回写仍是 Phase 2 的硬前置(§6 D-2 顺序不变)。
---
@@ -123,7 +162,7 @@ REQ-坐席-004 §2.4 定义的全部操作 —— 工单(接单 / 开始处理
|---|---|---|
| 审批操作 → 降级跳转 + 回写 | 通过 / 拒绝 / 转交 → 点击「在企微审批中打开」跳转原系统,由 `approval_webhook.py` 接收 `sys_approval_change` 回调 → 补全 `approval.py:902` 状态回写 → WS 推送 | Level 0 立即可做;Level 1 需补回调回写 |
| 工单操作 → 降级跳转(暂) | 接单 / 开始处理 / 结单 / 转派 → 点击「在 ITSM 中打开」跳转原系统;ITSM 写接口到位前不承诺服务端闭环(受 T05 外部阻塞)。**⚠️ 注意:该跳转依赖工单可见,而当前 ITSM 读列表亦未实现(见 U-1.2),故本行在读链路打通前无实际可操作对象** | Level 0(暂,受 U-1.2 前置) |
| 失败态处理 | 移除无条件 `ElMessage.success`改为「跳转成功提示 + 状态回写后刷新」按三态分派;仍禁止以 toast 作为验收依据 | — |
| 失败态处理 | ~~移除无条件 `ElMessage.success`~~**已完成**2026-08-09 核查确认,见 §3.1.1)。**剩余部分**:改为「跳转成功提示 + 状态回写后刷新」按三态分派(当前仅做到中性 info,尚无回写后刷新);仍禁止以 toast 作为验收依据 | — |
**验收标准**:操作后外部系统(ITSM / 企微审批)**状态真实变更**,且前端反馈与外部状态**最终一致**(通过回调 / webhook 回写达成)。禁止以 toast 成功作为验收依据。审批 / 工单在降级跳转模式下,以「跳转成功 + 原系统状态通过回调回写并刷新」作为验收闭环,不要求前端内嵌直接操作。
@@ -158,6 +197,36 @@ REQ-坐席-004 §2.4 定义的全部操作 —— 工单(接单 / 开始处理
**观测指标**:会话首响时长(是否因混排而劣化)、待办平均处理时长、坐席 Tab 切换频次、认领冲突率。
### Phase 0.5 — TaskDetailView 操作区精简(C-8 决策,2026-08-10
**背景**:原 `TaskDetailView` 操作区固定渲染 4 按钮(接单 / 开始处理 / 结单 / 转派),垂直占 body 区 50%+ 空间,导致「处理进度 / SLA」等关键状态信息需滚动才看完。
**设计变更**:状态机驱动 + 二级收纳
| Task Type | 主操作按钮(按状态) | ⋯ 次要动作 |
|---|---|---|
| 工单 queued | 📥 接单 | 转派 / 挂起 |
| 工单 serving | ✅ 结单 | 转派 / 挂起 |
| 审批 pending | ✅ 审批通过 | 拒绝审批 / 转交审批 / 加签 |
| 设备异常 | ✅ 标记恢复 | 一键开单 / 派工 / 加入巡检计划 |
**收益**
- 操作区按钮数 4 → 1~2(-50%)
- body 区可用垂直高度 +50%↑
- 状态信息一眼可见,无需滚动
**状态机原则**:开始处理被吸收到状态推进逻辑(点「接单」后接单 + 开始处理隐式完成,不显式拆分)。
**实现细节**
- 前端:组件 `useConversationMenuItems.ts` 状态机新增 `claim / recover / approve` reducer
- 后端:**不变**(仅前端状态机调整 + ⋯ 菜单 UI)
- 视觉密度补偿:主按钮 padding 从 `8×18``8×22`
**影响范围**
- ✅ 不影响:左栏三点菜单(v1.7 §4.4 矩阵)、中栏顶栏 UserInfoBarv1.7 D-2 已删 3 留 1
- ⚠️ 涉及文件:`TaskDetailView.vue:128-135``useConversationMenuItems.ts`
- 📐 原型版本:v1.7 → v1.8(已落,决策依据详见原型变更说明段)
---
## 7. 未决项
@@ -203,3 +272,7 @@ REQ-坐席-004 §2.4 定义的全部操作 —— 工单(接单 / 开始处理
| 2026-08-08 | v0.1 | 创建方案草案;固化 D-1/D-2/D-3 三项决策;完成原始论据核查与现状事实核实 | 宋献 |
| 2026-08-08 | v0.1 | 据 U-1 技术验证结论(架构师高见远)修订 §6 Phase 0 与 §7 U-1:审批明确为降级跳转 + 回写(U-1 已验证);新增 U-1.1 ITSM 写接口外部阻塞项;同步下调 R-5 风险表述 | 宋献 |
| 2026-08-08 | v0.1 | 主理人复核补录 **U-1.2**:代码实证 `ITSMService.get_todo_list()` 无条件返回空列表,ITSM 工单在坐席端读链路即断、当前待办全为企微审批单。该项前置于 U-1.1,一并标注于 §6 Phase 0 工单行 | 齐活林(交付总监) |
| 2026-08-09 | v0.1 | **修正 §3.1(原描述已过期)**`TaskDetailView.vue` 的无条件 `ElMessage.success` 已被修复,行号由 `:117` 更新为 `:129-135`,现为按 `type` 分支(approval 静默跳企微 / 其他 `ElMessage.info('该操作需在原系统中完成')`)。风险由 🔴 P0 降级为 🟡 P1(谎报成功已消除,服务端闭环缺失仍阻塞 Phase 2)。新增 §3.1.3 明确「**会话侧 4 操作是真闭环,任务侧审批/工单是降级跳转,二者性质根本不同**」,并将其确立为 Phase 2 按 `kind` 分派反馈策略(`execMode` 字段)的核心依据,指向 PRD-REQ-坐席-012 §7.2。依据:`技术核查-坐席会话条目操作菜单-v1.0.md` §1.1 | 许清楚(产品经理) |
| 2026-08-09 | v0.1 | **Phase 0 审批线落地**:T01(前端降级跳转)+ T02(后端 `/approval/callback` + `approval_webhook` 回调回写)已实现并通过 QA 独立回归(51 例全绿,前端 type-check 改动文件干净,无源码缺陷)。代码已 commit `9292f41` 于分支 `feat/agent-approval-degrade-jump`,并已推送 Gitea(远端 SHA 与本地一致,`main` 未被改写)。PR 入口:<http://192.168.3.200:8418/simon/wecom_it_smart_desk/pulls/new/feat/agent-approval-degrade-jump>。**工单线(T03+)仍未启动**,受 U-1.2ITSM 读链路断裂)+ U-1.1(写接口缺失)外部阻塞 | 寇豆码(工程师)→ 严过关(QA)→ 齐活林(主理人编排) |
| 2026-08-10 | v0.1 | **C-8 决策**TaskDetailView 操作区由原版固定 4 按钮(接单/开始处理/结单/转派)重构为「状态驱动主操作按钮 + ⋯ 次要动作收纳」。依据:操作区占 body 区底部 50%+ 垂直空间,导致「处理进度 / SLA」等状态信息需滚动才看完。详见新增 §6.4。原型 v1.7 → v1.8,代码待 `useConversationMenuItems.ts` 状态机 reducer 改造。**不影响**:左栏三点菜单(v1.7 §4.4 矩阵)、中栏顶栏 UserInfoBar、后端 API | 宋献 |
| 2026-08-10 | v0.1 | **PR #5 已合并**commit `af87f1de` redis_client 修复(approval.py + byod.py)经 Gitea UI 合并为双亲 merge `d8e7dbe`main 现位于 `5db3079d`。本地 main 已同步。审批回调 `POST /approval/callback` 生产实测 HTTP 200 `{errcode:0}` | 齐活林 → 宋献(合并)|
@@ -0,0 +1,507 @@
# PRD — 坐席端会话条目操作菜单(左栏三点菜单 + 右键菜单)
> **REQ编号**: REQ-坐席-012
> **版本**: v1.0
> **状态**: 🟡 待评审 — 交互形态与中栏取舍已由需求方拍板,菜单矩阵与组件边界待技术评审
> **优先级**: P1
> **日期**: 2026-08-09
> **作者**: 许清楚(产品经理)
> **需求来源**: 宋献(IT 支持组组长)口述需求 4 条
> **技术依据**: `docs/02-技术文档/技术架构/技术核查-坐席会话条目操作菜单-v1.0.md`(高见远,2026-08-09
> **配套原型**: `原型-REQ-坐席-000-坐席工作台-v1.7.html`
> **关联**: PRD-REQ-坐席-011(统一工作队列重构)、REQ-坐席-004、REQ-坐席-009、REQ-坐席-010
> **性质**: 增量 PRD。仅描述本次新增/变更,不复述既有功能。
---
## 1. 背景
### 1.1 需求原文与映射
| # | 宋献原始需求 | 本 PRD 对应 |
|---|---|---|
| 1 | 确认现有坐席架构,控件能否在「选中对象-右键调出菜单」模式实现 | §3.1 D-3、§4.3 |
| 2 | 左栏每个会话表单右边增加菜单按钮(三个点),点击弹出接单/置顶/代办/转接 | §4.1、§4.2、§5 |
| 3 | 上述完成后,去除中栏上方「接单/置顶/代办/转接」4 个按钮 | §3.1 D-2、§6 |
| 4 | 先基于最新原型更新细致原型图,与用户确认后再改代码 | §3.1 D-1、§10 |
### 1.2 🔴 前置事实修正(必须在评审开场说明)
需求提出时的前置认知是「V1.0–V1.6 原型中这 4 个按钮丢失了」。经架构师代码核查,**该认知需要修正两处**:
| 认知 | 事实 |
|---|---|
| 「按钮丢失了」 | ❌ **4 个按钮在真实运行代码中从未丢失**。接单 `UserInfoBar.vue:116-124`、置顶 `:127-133`、代办 `:136-142`、转接 `:145-164`,且四者均为**服务端真闭环**(store → API → 后端端点,非 Mock) |
| 「从 v1.5 开始丢失」 | ❌ 丢失只发生在**原型 HTML 侧**,且起始版本是 **v1.4**。v1.0–v1.3 均含 4 按钮(关键词计数实证:接单 11-12 次 / 置顶 3 次 / 代办 1 次 / 转接 2 次),v1.4 起归零 |
**根因**v1.4 起原型**换了血统**。v1.0v1.3 是 24002700 行的全量高保真原型;v1.4 是为论证「B 方案三栏分栏比例」与「排查步骤全屏流程图」而**新画的专题示意图**(961 行),并非 v1.3 的增量演进。v1.5/v1.6 沿此专题线继续演进。
**后果(这是本次必须先做字段回填的原因)**v1.6 的左栏 `.conv-item` 只有「头像/姓名/时间/摘要/未读数」5 个字段,而真实代码 `ConversationItem.vue` 的条目含:
| 元素 | 真实代码 | v1.6 原型 |
|---|---|---|
| 置顶 📌 / 代办 📋 图标 | ✅ `:50` `:52` | ❌ |
| tag-badgeVIP/招手/需介入/情绪/坐席名/待确认) | ✅ 6 类 `:56-82` | ❌ |
| 优先级图标(⛔👥⭐🔁) | ✅ 最多 4 个 `:84-95` | ❌ |
| 紧急度星级 | ✅ 5 星 `:107-114` | ❌ |
| 接手 / 退出 link 按钮 | ✅ `:116-136` | ❌ |
| 处理对象缩略头像 | ✅ `:141-148` | ❌ |
**若直接在 v1.6 上加三点按钮,会得出「条目右侧有大片空槽」的错误结论**——真实条目右侧已被 `.conv-target-avatar` 占满(`section !== 'history'` 时恒显示)。这是 §3.1 D-1 选择 v1.3 而非 v1.6 作为 v1.7 基线的直接原因。
### 1.3 本次真正的新增工作量在哪里
架构核查指出:现有 4 个按钮**几乎没有基于会话归属的可见性控制**——置顶/代办在中栏任何状态下都常驻且不禁用。这在中栏可接受(中栏只显示当前打开的会话,多为自己的);**但一旦下沉到左栏,左栏同时呈现「我的会话 / 同事会话 / 历史会话」三个分区**(`ConversationList.vue:43-76`),菜单项必须新增**归属维度**的可见性规则。
> **本次改造的产品设计核心不是"控件搬家",而是补齐一张此前不存在的「状态 × 归属 × 分区」菜单项可见性矩阵**(§5)。控件本身是低成本工作。
---
## 2. 产品目标
| # | 目标 | 衡量方式 |
|---|---|---|
| G-1 | 把会话的**列表管理动作**(接单/置顶/代办)下沉到动作对象所在处,消除「操作在中栏、效果在左栏」的焦点跳跃 | 置顶/代办操作后无需视线迁移即可看到条目重排 |
| G-2 | 在不牺牲对话过程效率的前提下精简中栏顶栏 | 中栏操作控件由 6 个降至 3 个(转接/摇人/结单),且**转接路径不退化**(保持 3 步) |
| G-3 | 菜单壳一次抽对,避免 PRD-011 Phase 2 与虚拟滚动落地时二次重写 | 新增 `kind`(审批/工单)时只加一个菜单项工厂,不改菜单壳 |
## 2.1 用户故事
| # | 故事 |
|---|---|
| US-1 | 作为坐席,我想在左栏直接对某条会话置顶/代办,这样我不必先打开它、再去中栏操作、再回来看列表变化 |
| US-2 | 作为坐席,我想在扫视排队会话时就地接单,这样我不必"先点开、再接单"两步走 |
| US-3 | 作为熟练坐席,我想右键会话条目直接出菜单,这样我不必先 hover 找到三点按钮 |
| US-4 | 作为坐席,我不想在同事的会话上看到「置顶」——点了会 403 报错,还不给我任何提示 |
| US-5 | 作为坐席,我在中栏读完员工描述判断"这不归我管"时,希望能就地转接,而不是回左栏重新定位这条会话 |
---
## 3. 已锁定决策
### 3.1 需求方已拍板项(D-1 ~ D-6
| # | 决策项 | 结论 | 依据 |
|---|---|---|---|
| **D-1** | 原型基线 | **以 v1.3 为基线复制为 v1.7**(保留高保真左/中/右三栏),再增量叠加 v1.4~v1.6 演进特征(B 方案分栏比例、排查步骤全屏流程图、智能推荐竖排、工具栏重组、Emoji 图标统一)。**不以 v1.6 为基线** | §1.2;架构报告 §1.4 / R-2 |
| **D-2** | 中栏按钮处理 | **删除「接单/置顶/代办」3 个,保留「转接」**。转接与既有的「摇人」「结单」构成语义一致的**对话过程动作组**(转接=交出去 / 摇人=拉进来 / 结单=做完了) | §6;架构报告 §5.1 |
| **D-3** | 交互形态 | **三点按钮为主入口 + 右键为快捷方式,两者并存**。二者共用同一份菜单项定义与 handler | §4.3;架构报告 §2.3 选项 3 |
| **D-4** | 菜单实例架构 | **菜单为单例**,挂在 `ConversationList` 层级,**不挂在条目内部** | §7;架构报告 §2.4-5 |
| **D-5** | 三点按钮落位 | **替换** `.conv-target-avatar`(同栅格位淡入淡出),非覆盖、非并列 | §4.1;架构报告 §4.3 |
| **D-6** | 菜单弹层定位 | `placement="bottom-end"`,宽 140px,视口下缘不足时翻转 `top-end`**DOM 必须 `position:fixed` 或 Teleport 到 body** | §4.2;架构报告 §1.3.4 |
### 3.2 D-2 的完整论证(为什么不是 4 个全删)
需求原文是「去除中栏上方 4 个按钮」。经操作路径推演,**4 个一刀切全删会造成分档不同的退化**:
| 操作 | 当前路径 | 全删后路径 | 退化判定 |
|---|---|---|---|
| 接单 | 中栏 1 步 | 左栏 定位→hover→三点→接单 | ⚪ **不退化,删除是净收益**。接单发生在打开会话**之前/之时**,左栏本就是接单的自然场所(Zendesk / ServiceNow 同构)。且中栏该按钮在 `status !== 'queued'` 时恒为 disabled 的「已接单」,长期占位不可点 |
| 置顶 | 中栏 1 步 | 左栏 4 步 | 🟡 **轻微退化,可接受**。置顶是**列表组织行为**,其效果(条目上浮)只在左栏可见。操作与反馈同处一栏,语义反而更内聚 |
| 代办 | 中栏 1 步 | 左栏 4 步 | 🟡 同上 |
| **转接** | `点转接 → 选坐席 → 确认` = **3 步** | `视线移左栏 → 定位当前条目 → hover → 点三点 → 点转接 → 选坐席 → 确认` = **6+ 步** | 🔴 **显著退化,不接受** |
**转接不可下放的三条理由**
1. **触发时机根本不同**。置顶/代办是坐席**扫视队列时**的列表管理动作;转接是坐席**读完员工描述、判断"这不归我管"那一刻**的对话过程动作——此时注意力与鼠标都在中栏。要求回左栏是**注意力焦点的强制迁移**。
2. **"定位当前条目"本身有成本**。左栏三分区、可滚动,且条目会因置顶/紧急度**动态重排**,当前会话未必在视口内。这一步不是零成本的。
3. **删了转接反而制造不一致**。中栏保留「摇人」「结单」是既定事实。若转接被赶到左栏,结果是**同为对话过程中的协作类动作,入口被劈成两处**——这比"全部在中栏"或"全部在左栏"都差。
**同时,左栏三点菜单仍提供「转接」**。两个入口共用同一 handler 与同一坐席选择 Dialog,符合"高频动作允许多入口"原则,不构成语义分裂。
> **给需求方的备选(如坚持"顶栏太挤"是主要动机)**:可将 3 个被删按钮收进中栏自己的「⋯」总菜单,视觉上顶栏只剩「摇人 / 结单 / ⋯」,功能零退化。此形态**未在 v1.7 中作为主方案呈现**,如需要请在评审时提出。
---
## 4. 交互设计
### 4.1 三点按钮(主入口)
| 项 | 规格 | 依据 |
|---|---|---|
| **位置** | `.conversation-item` 最右侧,**替换** `.conv-target-avatar` 所在栅格位 | D-5 |
| **切换方式** | 同一栅格位内:缩略头像 `opacity:0` 淡出、三点 `opacity:1` 淡入(`transition ≈ 140ms`)。**不改变布局盒模型,无抖动** | 架构报告 R-7 |
| **命中区** | 24 × 24 px;图标 `MoreFilled``@element-plus/icons-vue@^2.3.0` 已装)1416px | D-5 |
| **默认态** | 隐藏(`opacity:0; pointer-events:none` | — |
| **显示触发** | `.conversation-item:hover` `.is-menu-open` 按钮 `:focus-visible` | 保证键盘可达 |
| **菜单打开期间** | 按钮**强制常显**,条目加 `.is-menu-open` 类(背景保持 hover 态),避免"鼠标移出→按钮消失→菜单悬空"的观感断裂 | 架构报告 §2.3 选项 2 风险项 |
| **历史分区** | `section === 'history'` 本无缩略头像,三点直接占该位(可常显淡色 `opacity:.35`hover 加深) | D-5 |
| **事件** | 必须 `@click.stop` —— 根节点 `@click="$emit('click')"``ConversationItem.vue:20`,不阻断会连带切换会话。项目内已有正确先例(`:122` `:133``@click.stop` | 架构报告 §2.4-4 |
**为什么不选"覆盖"或"并列"**
- 覆盖(absolute 盖在缩略头像上):hover 时缩略头像被遮挡,信息丢失且视觉脏;
- 并列(插到缩略头像右侧):再吃掉 ~24px,`.conversation-info` 从 ~132px 压到 ~108px,第一行 6 类 tag + 4 个优先级图标严重挤压。左栏条目内部可用宽度仅 ≈ 228px260 margin 12 padding 20),没有余量。
### 4.2 菜单弹层
| 项 | 规格 |
|---|---|
| 宽度 | 140px(4 项中文两字 + 图标;不得超过侧栏 260px) |
| 定位 | `placement="bottom-end"`(右对齐向下) |
| 翻转 | 视口下缘空间不足时自动翻转 `top-end`;右缘不足时自动贴边 |
| **DOM 归属** | 🔴 **必须 `position:fixed` + 视口坐标,或 Teleport 到 body**。**禁止**以 `position:absolute` 挂在 `.conversation-item` 内部 |
| 层级 | 使用 EP popper 默认 z-index2000+ 自增)或新引入的 `--z-dropdown: 2000` token。**不得沿用 `MessageItem.vue` 的 1000**(低于 EP popper,会被 dropdown/messagebox 盖住) |
| 关闭时机 | 点击菜单外 ∪ Esc ∪ 列表滚动 ∪ 窗口 resize ∪ 切换会话 ∪ 在另一条目再次右键 |
| 互斥 | 全局仅一个菜单可见(单例天然满足,见 §7) |
**为什么 DOM 必须脱离左栏**(这是硬约束,不是优化建议):
```css
.workspace-sidebar { overflow: hidden; } /* global.css:277 */
.conversation-list-scroll { overflow-x: hidden; } /* global.css:359 */
```
双重裁剪导致:① 横向——260px 窄栏内放不下 140px 菜单向右展开,必被裁;② 纵向——列表首尾条目的菜单会被滚动容器裁掉。
### 4.3 右键(快捷方式)
| 项 | 规格 |
|---|---|
| 绑定 | `.conversation-item` 根节点 `@contextmenu.prevent.stop` |
| 定位 | 以鼠标 `clientX/clientY` 为锚点,`position:fixed` |
| 菜单内容 | **与三点按钮完全一致**——由同一个 `computed` 生成的菜单项数组驱动,不允许两套定义 |
| 覆写原生菜单 | 是(`.prevent`)。**仅在会话条目上覆写**,列表空白区、消息区、输入框保留浏览器原生右键 |
| 右键是否需先选中条目 | **否**。右键直接对该条目生效,不改变当前打开的会话(避免误切换丢失输入草稿) |
**为什么两者并存而不是二选一**
- 纯右键**发现性为零**。左栏是坐席每天使用频率最高的区域,且服务台存在**多人轮岗与新人**,不能假设全员知晓。零发现性交互在此位置是产品事故。
- 需求 1(问右键)与需求 2(要三点)**本就是同一菜单的两个触发器**,不是二选一。并存的边际成本仅 0.3–0.5 人日。
### 4.4 反馈与错误处理
| 场景 | 反馈 |
|---|---|
| 操作成功 | 条目即时反映状态变化(📌/📋 图标出现或消失、条目重排)。**不额外弹 toast**——视觉变化本身即反馈 |
| 接单成功 | 条目从「排队区」移入「我的会话」,并自动打开该会话 |
| 转接成功 | 条目移出「我的会话」,`ElMessage.success('已转接给 XXX')` |
| **后端 403 / 失败** | 🔴 必须给明确 `ElMessage.error` 文案。**现状是既有缺陷**`stores/conversation.ts:539-541``console.error`,用户零感知。本次一并修复(P0-7) |
| 无可用坐席 | 「转接」项 disabled + 灰字提示「暂无可用坐席」 |
---
## 5. 菜单项可见性矩阵(核心交付物)
> 按 会话 `status` × 归属(`is_mine` / `is_collaborator` / `can_grab`)× 分区(`section`)分派。
> 与架构报告 §4.4 完全一致,本节为其产品侧收口版本。
| 菜单项 | 显示条件 | 禁用条件 | 动态文案 | 后端权限 | 备注 |
|---|---|---|---|---|---|
| **接单** | `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` → 禁用,提示「暂无可用坐席」 | 「转接…」(省略号表示会开 Dialog) | `update:all` | 点击开独立 Dialog,见 Q-3 |
| **接手** | `can_grab && !is_mine && status === 'serving'` | — | 「接手」 | `update:all` | 与第三行 `grab-btn` 同义,双入口,见 Q-4 |
| **退出协作** | `is_collaborator && status === 'serving'` | — | 「退出协作」(danger 样式) | — | 与第三行 `leave-btn` 同义,见 Q-4 |
> ⚠️ **实现细节校准(影响 A-11 双入口一致性验收)**:第三行 `grab-btn` 的显示条件在两个分区**并不相同**——
> `ConversationList.vue:49`(我的会话区)传入 `show-grab = status==='serving' && !is_mine && !is_collaborator`
> `ConversationList.vue:62`(同事会话区)传入 `show-grab = status==='serving' && !is_mine`**少了 `!is_collaborator`**)。
> 条目内再与 `conversation.can_grab` 取交集(`ConversationItem.vue:117`)。
> 菜单工厂 `useConversationMenuItems.ts` **必须复用同一套条件**(建议把该判断上提为一个共享 `computed`,两处引用),
> 否则会出现「第三行有接手 link、菜单里没有」或反之的不一致,直接违反 A-11。
### 5.1 分区实例推演(原型必须逐一演示)
| 实例 | 状态 | 菜单实际渲染项 | 说明 |
|---|---|---|---|
| **A. 排队会话** | `status=queued``is_mine=false` | **接单** | 仅 1 项。置顶/代办不显示(非本人) |
| **B. 我的会话(serving** | `is_mine=true`, `is_pinned=false`, `is_todo=false` | 置顶 / 代办 / 转接… | 3 项。无接单(非 queued)、无接手(非 can_grab |
| **C. 我的会话(已置顶已代办)** | `is_mine=true`, `is_pinned=true`, `is_todo=true` | **取消置顶** / **取消代办** / 转接… | 文案动态翻转 |
| **D. 同事会话** | `is_mine=false`, `can_grab=true`, `status=serving` | **接手** | 仅 1 项。**置顶/代办被显示条件拦住**(点了会 403) |
| **E. 我协作中的会话** | `is_collaborator=true`, `status=serving` | 置顶 / 代办 / **退出协作**(danger) | 无转接(`is_mine=false` |
| **F. 历史会话** | `section=history`, `status=resolved` | **(空)** | 全部条件不满足 → 菜单显示「无可用操作」占位,或三点按钮直接不可点 |
| **G. 转接无可用坐席** | `is_mine=true`, `availableAgents=[]` | 置顶 / 代办 / ~~转接(灰)~~ | 转接项 disabledhover 提示「暂无可用坐席」 |
> **F 的产品决策**:历史会话菜单为空时,**三点按钮仍渲染但呈禁用态**`opacity:.35``cursor:default`,点击无响应),**不弹空菜单**。理由:弹出一个写着"无可用操作"的空菜单是无效交互;但完全不渲染按钮会让历史分区条目右侧出现空洞,与其它分区视觉不齐。
### 5.2 与「结单」的边界
「结单」**不进入**左栏菜单,理由见 Q-5。中栏保留其原有入口。
---
## 6. 中栏改造
### 6.1 变更清单(`UserInfoBar.vue:114-194`
| # | 控件 | 代码位置 | 本次处理 |
|---|---|---|---|
| 1 | 接单 | `:116-124` | ❌ **删除** |
| 2 | 置顶 | `:127-133` | ❌ **删除** |
| 3 | 代办 | `:136-142` | ❌ **删除** |
| 4 | **转接** | `:145-164` | ✅ **保留**D-2 |
| 5 | 摇人 | `:167-174` | ✅ 保留(不在本次范围) |
| 6 | 结单 / 等待确认 | `:177-193` | ✅ 保留(不在本次范围) |
| — | 历史会话开关 chip | `:100-110` | ✅ 保留(在 chips 区,非 actions 区) |
**改造后中栏 actions 区 = 转接 / 摇人 / 结单**,语义收敛为「对话过程动作组」。
### 6.2 转接入口一致性要求
中栏「转接」与左栏菜单「转接」必须:
- 调用同一 store action`transferConv`);
- 打开**同一个坐席选择 Dialog 组件**(Q-3 结论);
- 保留现有的 `ElMessageBox.confirm` 二次确认(`ChatArea.vue:531-547`)。
不得出现"中栏是下拉、左栏是 Dialog"的两套 UI。
---
## 7. 组件抽象边界与 PRD-011 兼容
### 7.1 抽象原则
> **抽「菜单壳」,不抽「业务动作」。**
```
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()
⛔ 不认识 conversation / approval / ticket,不 import 任何 store 与 api
composables/useConversationMenuItems.ts 【本次新建】会话菜单项工厂
入参:conversation + currentAgentId + section('my'|'colleague'|'history')
出参:MenuItem[](已按 §5 矩阵算好 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
```
### 7.2 `MenuItem` 类型契约
```ts
interface MenuItem {
key: string
label: string
icon?: string
danger?: boolean
disabled?: boolean
hidden?: boolean
tooltip?: string
/** 🔴 关键字段:决定容器采用哪种反馈策略 */
execMode: 'direct' | 'redirect' | 'disabled'
}
```
**`execMode` 为什么是必需字段**(对宋献"未来待办条目进入左栏后菜单怎么分派"的直接回答):
| kind | 操作性质 | 执行路径 | 反馈模型 |
|---|---|---|---|
| `conversation` | **服务端真闭环** | store → API → 后端端点 → 刷新列表 | 同步成功/失败 |
| `approval` | **降级跳转** | `<a target="_blank">` 跳企微 → `sys_approval_change` 回调回写 → WS 推送 | 异步最终一致 |
| `ticket` | **当前无对象** | `ITSMService.get_todo_list()` 无条件 `return []` | 无(PRD-011 U-1.2 |
> **必须分派,且不只是文案不同——是执行语义与反馈模型的根本不同。** 会话操作点完即生效;审批操作点完只是跳走,真正的状态变更在企微侧异步回写。**若三类共用一套「点击 → toast 成功」的反馈模型,就会重演 PRD-011 §3.1 定性为 P0 的那个错误**(绿色成功提示 + 外部系统实际未变更)。
### 7.3 与 PRD-011 Phase 2 的衔接检查表
| PRD-011 Phase 2 任务 | 本设计是否兼容 | 说明 |
|---|---|---|
| 定义统一 `ListItem`(含 `kind` 判别式) | ✅ | `MenuItem[]` 由 kind 专属工厂生成,菜单壳不感知 kind |
| 条目组件支持会话态 / 任务态两种渲染 | ✅ | 条目只 `emit('open-menu')`,两态共用同一菜单壳 |
| 左栏 Tab 分层(会话/待办/全部) | ✅ | 「全部」Tab 混排时,容器按 `item.kind` 选工厂 |
| **U-5 虚拟滚动** | ✅ | **单例架构天然兼容**(见下) |
| U-6 待办未读/变更标识 | ➖ | 与菜单无关 |
**D-4 单例架构的强制理由**PRD-011 U-5 已把虚拟滚动挂起。虚拟滚动会在滚动时**回收并复用条目 DOM**——若菜单实例挂在条目内部,reference 元素被销毁会导致菜单错位或残留。单例架构的实现成本与逐条挂载**相同**,却可免除未来上虚拟滚动时的整体返工。这正是宋献「避免三个月后第二次重做」诉求的具体着力点。
---
## 8. 产品经理自决项结论(Q-3 / Q-4 / Q-5
架构报告 §4.4 将三项留给产品决策。以下为结论与论证。
### Q-3 转接的坐席选择:二级菜单 还是 独立 Dialog?
> ### ✅ **结论:独立 Dialog。菜单项文案为「转接…」(带省略号,遵循"点击会开弹窗"的通用约定)。**
| 论据 | 说明 |
|---|---|
| **① 窄栏二级翻转过于脆弱** | 主菜单已 140px、`bottom-end` 右对齐。二级菜单需再向左或向右展开 ~160px。在 260px 侧栏 + 视口右缘的组合下,Popper 需同时做**水平翻转 + 垂直翻转**,边缘 case 组合爆炸,稳定性无法保证 |
| **② 坐席列表承载不下** | 二级菜单需展示 `姓名 (当前负载/最大负载)`,且在线坐席可能十余人,需要**滚动 + 搜索**。二级 popover 里塞搜索框是反模式 |
| **③ 悬停路径问题** | 二级菜单靠 hover 展开时,鼠标从主菜单项斜向移动到二级区域会经过其它菜单项,导致二级意外收起(经典 "diagonal problem"),需额外做安全三角形算法——成本高于直接开 Dialog |
| **④ 与中栏入口天然统一** | 中栏保留的「转接」也需要选坐席。共用同一个 Dialog 组件后,**两个入口 UI 完全一致**,符合 §6.2 的一致性要求。若左栏用二级菜单、中栏用下拉,就是两套 UI |
| **⑤ 转接是有后果的动作** | 转接后会话脱手。现有实现已带 `ElMessageBox.confirm` 二次确认。Dialog 形态与"需要慎重决策"的语义匹配,二级菜单的轻量感反而不合适 |
**Dialog 规格**:宽 380px,标题「转接会话」,含搜索框 + 坐席列表(头像 / 姓名 / `负载/上限`)+ 满载坐席置灰不可选 + 取消按钮。选中坐席后走原有二次确认。
**反方意见记录**:二级菜单少一次弹窗、路径更短。但转接**日频次不高**(非置顶/代办那类高频动作),为低频动作牺牲稳定性不划算。
---
### Q-4 「接手」「退出协作」是否收进三点菜单(收则移除第三行按钮)?
> ### ✅ **结论:收进菜单,但第三行 `grab-btn` / `leave-btn` 保留不动 —— 双入口并存。**
**这与架构建议("本次不动,保持第三行按钮,菜单只放 4 项")有分歧,理由如下:**
| 论据 | 说明 |
|---|---|
| **① 右键路径的完整性** | D-3 已定右键并存。坐席右键条目时,鼠标位置是任意的——若菜单里没有「接手」,坐席仍需**移动鼠标到第三行那个小 link 按钮**才能接手。这让右键快捷方式在最需要它的同事会话场景下失效,自相矛盾 |
| **② 移除第三行按钮才是真损失** | 第三行 grab/leave 是 `v-if` **数据驱动的常显 link**,零 hover 成本、一次点击即达。三点菜单需 hover→点击→点击共 3 步。**移除它是明确的效率退化**,不能为了"避免重复"而做 |
| **③ "同一动作两个入口"不是缺陷** | 这与 §3.2 中栏保留转接是同一条原则:**高频/关键动作允许多入口**,只要共用同一 handler。真正的缺陷是两个入口**行为不一致**,而非入口数量 |
| **④ 变更面反而更小** | 保留第三行按钮 = 不动既有 DOM,只在菜单工厂里多产出两个 MenuItem。移除按钮才需要改 `ConversationItem.vue:116-136` 并回归测试 |
**约束**:两个入口必须 `emit` 同一事件(`grab` / `leave`),走同一 store action。**禁止**在菜单里另写一份逻辑。
**验收要求**:同事会话条目上,第三行「接手」link 与菜单「接手」项必须同时可见、同时消失(受同一 `can_grab` 条件驱动)。
---
### Q-5 「结单」是否进左栏菜单?
> ### ✅ **结论:不进。维持中栏唯一入口。**
| 论据 | 说明 |
|---|---|
| **① 路径错位** | 结单需走摘要确认 Dialog(`ChatArea.vue:448-502`),坐席要在弹窗里确认/编辑会话摘要。**在左栏一个 24px 的三点按钮里触发一个需要阅读与编辑的模态流程**,是明确的路径错位 |
| **② 语义归属** | 结单属「对话过程动作组」(转接/摇人/结单),与置顶/代办的"列表管理"性质不同。§3.2 已确立该分组,结单进左栏会破坏刚刚建立的分组一致性 |
| **③ 结单前需要看对话内容** | 结单意味着"问题已解决",这个判断必须基于中栏的对话内容。坐席不可能在左栏扫视时判断某条会话可以结单——**动作发生地必然是中栏** |
| **④ 误操作成本高** | 结单会触发员工端确认流程(`pending_close` 态)。在密集列表中一个 hover 出现的小菜单里放置这种动作,误触成本过高 |
| **⑤ 用户未提** | 需求原文只提 4 个操作,不做需求扩张 |
---
## 9. 需求池(Requirements Pool
### P0 — Must have
| # | 需求 |
|---|---|
| P0-1 | 新建 `WorkItemActionMenu.vue` 单例菜单壳:fixed/Teleport 定位、视口翻转、Esc / 点击外部 / 滚动 / resize / 切换会话 关闭、z-index ≥ 2000、danger 项样式 |
| P0-2 | 新建 `useConversationMenuItems.ts`:完整实现 §5 矩阵,含动态文案翻转与 disabled 计算,**附单元测试覆盖 §5.1 的 A–G 七个实例** |
| P0-3 | `ConversationItem.vue` 新增三点按钮:§4.1 全部规格(同栅格位淡入淡出、24×24、hover/`.is-menu-open`/`:focus-visible` 显示、`@click.stop` |
| P0-4 | `ConversationItem.vue` 新增 `@contextmenu.prevent.stop`,与三点按钮共用同一菜单项定义 |
| P0-5 | `ConversationList.vue` 持有单例菜单实例,按 action 路由到对应 store action |
| P0-6 | `UserInfoBar.vue` 删除接单/置顶/代办 3 个按钮,**保留转接** |
| P0-7 | 修复既有缺陷:`stores/conversation.ts` 中 4 个操作的失败分支由 `console.error` 改为 `ElMessage.error` 明确文案(403 场景必须有用户可见反馈) |
| P0-8 | 转接坐席选择 Dialog 组件化,中栏与左栏共用 |
### P1 — Should have
| # | 需求 |
|---|---|
| P1-1 | 引入 z-index token`--z-dropdown: 2000` / `--z-modal: 3000` / `--z-toast: 9000`),替换菜单相关硬编码 |
| P1-2 | 菜单 `role="menu"` / 项 `role="menuitem"`,三点按钮用原生 `<button>``el-button`(可 Tab 聚焦、Enter/Space 激活) |
| P1-3 | 历史分区三点按钮禁用态(§5.1 F 的处理) |
| P1-4 | 转接 Dialog 支持坐席姓名搜索 |
### P2 — Nice to have
| # | 需求 |
|---|---|
| P2-1 | 菜单 ↑↓ 方向键导航 + Enter 确认(可挂接既有 `useKeyboardShortcuts.ts` |
| P2-2 | 将 `MessageItem.vue` 的自研右键菜单迁移到 `WorkItemActionMenu`,顺带修复其 5 项缺陷(无边界翻转 / z-index 1000 偏低 / 关闭时机不全 / 无 a11y / 无单例互斥) |
| P2-3 | 中栏按钮删除做成配置开关,支持 2 周内灰度回滚 |
| P2-4 | 为转接注册全局快捷键(如 `Ctrl+Shift+T` |
---
## 10. 验收标准
### 10.1 功能验收
| # | 验收项 | 通过标准 |
|---|---|---|
| A-1 | 三点按钮显隐 | 默认不可见;hover 条目出现且缩略头像淡出;**条目高度与各元素位置零位移** |
| A-2 | 点击不误触 | 点击三点按钮**不切换当前会话**(当前打开的会话保持不变) |
| A-3 | 右键一致性 | 同一条目上,右键菜单项与三点菜单项**逐项完全一致**(项数、顺序、文案、禁用态) |
| A-4 | 菜单不被裁剪 | 左栏**第一条**与**最后一条**会话的菜单完整可见,不被侧栏或滚动容器裁掉;视口下缘不足时向上翻转 |
| A-5 | 矩阵正确性 | §5.1 的 A–G 七个实例逐一验证,菜单项与预期完全一致 |
| A-6 | 403 拦截 | 同事会话菜单中**不出现**置顶/代办项;构造非法请求时前端有明确错误提示 |
| A-7 | 单例互斥 | 打开条目 A 的菜单后,右键条目 B,A 的菜单自动关闭,同屏只有一个菜单 |
| A-8 | 关闭时机 | Esc / 点击外部 / 滚动列表 / 窗口 resize / 切换会话 —— 五种情况菜单均关闭 |
| A-9 | 中栏改造 | 中栏 actions 区仅剩 转接 / 摇人 / 结单;**转接路径仍为 3 步** |
| A-10 | 转接双入口 | 中栏转接与左栏菜单转接打开**同一个 Dialog**,行为一致 |
| A-11 | 接手双入口 | 同事会话条目第三行「接手」link 与菜单「接手」项同时可见/同时消失,点击效果相同 |
| A-12 | 状态即时反映 | 置顶/代办后条目 📌/📋 图标即时出现或消失,列表按新权重重排 |
### 10.2 架构验收(防返工)
| # | 验收项 | 通过标准 |
|---|---|---|
| B-1 | 单例 | 全局 DOM 中 `.work-item-action-menu` 实例数恒 ≤ 1,**不随会话条目数增长** |
| B-2 | 壳零业务 | `WorkItemActionMenu.vue``import` 不含任何 store / api / `Conversation` 类型 |
| B-3 | 工厂可单测 | `useConversationMenuItems.ts` 为纯函数,可脱离组件树单测 |
| B-4 | DOM 归属 | 菜单 DOM 的 `parentElement``body` 或使用 `position: fixed` |
### 10.3 明确不作为验收依据
- ❌ toast 成功提示(沿用 PRD-011 §6 Phase 0 的验收原则)
- ❌ 原型截图(原型是设计依据,不是实现验收标准)
---
## 11. 风险登记
| 编号 | 风险 | 等级 | 缓解 |
|---|---|---|---|
| **R-1** | 转接若被误删将造成 3→6 步路径退化 | 🔴 高 → 🟢 已缓解 | D-2 决定中栏保留转接;若评审推翻,必须同时落地 P2-4 快捷键 |
| **R-2** | v1.7 若沿用 v1.6 低保真基线,菜单落位设计与实现对不上 | 🔴 高 → 🟢 已缓解 | D-1 改以 v1.3 为基线并回填真实字段 |
| **R-3** | z-index 冲突(项目现有 `100/999/1000/1200/3000/9999/999999` 混用,EP popper 默认 2000+ | 🟡 中 | P1-1 引入 token;优先用 EP popover 交由其管理 |
| **R-4** | 菜单可见性与后端权限不一致 → 403。`pin`/`todo``update:own` | 🟡 中 | §5 矩阵前端严格拦截 + P0-7 失败提示 |
| **R-5** | 复制 `MessageItem.vue` 右键实现,带入其 5 项缺陷 | 🟡 中 | 强制走 `WorkItemActionMenu` 新壳,禁止复制粘贴 |
| **R-6** | 未来虚拟滚动导致条目内菜单错位 | 🟡 中 → 🟢 已缓解 | D-4 本次即采用单例架构 |
| **R-7** | 三点按钮与缩略头像争位导致布局抖动 | 🟢 低 | D-5 同栅格位淡入淡出,不改盒模型;A-1 专项验收 |
| **R-8** | 右键在 VDI / 远程桌面 / 触摸屏环境下不可靠 | 🟢 低 | 三点按钮为主入口,右键仅为快捷方式,降级不影响可用性。**需宋献确认坐席实际办公环境(架构 Q-7)** |
| **R-9** | 坐席习惯迁移成本:老坐席习惯在中栏点置顶 | 🟢 低 | 变更公告 + 首次使用时的引导气泡(可选);P2-3 保留灰度回滚 |
---
## 12. 明确不在本次范围
- 中栏「摇人」「结单」的任何改动
- 待办条目(审批/工单)进入左栏 —— 属 PRD-011 Phase 2
- 左栏虚拟滚动 —— 属 PRD-011 U-5(本次仅在架构上预留兼容)
- 会话列表排序权重调整
- 后端任何改动(4 个端点及权限装饰器均已就绪,无需变更)
- 键盘方向键导航(列为 P2-1,不阻塞本次)
---
## 13. 待需求方(宋献)评审确认清单
| # | 待确认项 | 本 PRD 的默认取值 | 若推翻的影响 |
|---|---|---|---|
| **C-1** | 中栏**保留转接**是否接受?(原需求是 4 个全删) | 保留(D-2) | 推翻则转接路径 3→6 步,必须补 P2-4 快捷键,工期 +0.3 人日 |
| **C-2** | 是否接受**右键并存**? | 接受(D-3) | 推翻则去掉 P0-4,省 0.3–0.5 人日,但熟练坐席效率损失 |
| **C-3** | 是否接受 v1.7 **回填左栏真实字段**的额外工作量? | 接受(D-1) | 推翻则原型不可作为开发依据 |
| **C-4** | 坐席实际办公环境是否存在 **VDI / 远程桌面 / 触摸屏**? | 假定为普通 PC 浏览器 | 若有 VDI,右键可靠性下降,需重新评估 C-2 |
| **C-5** | 历史会话条目的三点按钮:**禁用态**还是**完全不渲染**? | 禁用态(§5.1 F) | 视觉偏好项,成本相同 |
| **C-6** | 备选形态:是否需要看「中栏 3 按钮收进中栏自己的『⋯』菜单」的 A/B 对比? | 未在 v1.7 中呈现 | 若需要,v1.7 需追加一版对比图 |
| **C-7** | Q-4 结论(接手/退出**双入口**,不移除第三行按钮)是否认可? | 双入口 | 若要求单入口,需明确保留哪一个 |
---
## 14. 工期估算
> 粗估,用于排序参考,不作为承诺。基于架构报告 §4.1 的分解并按本 PRD 结论调整。
| 阶段 | 内容 | 估时 |
|---|---|---|
| S1 | `WorkItemActionMenu.vue` 单例壳 | 1.0 人日 |
| S2 | `useConversationMenuItems.ts` + 单测(§5 矩阵) | 0.5 人日 |
| S3 | `ConversationItem` 三点按钮 + 右键触发 | 0.5 人日 |
| S4 | `ConversationList` 单例接线 + action 路由 | 0.5 人日 |
| S5 | 中栏删 3 留转接 | 0.2 人日 |
| S6 | 转接 Dialog 组件化(Q-3+ 中栏复用 | 0.5 人日 |
| S7 | P0-7 失败反馈修复 | 0.2 人日 |
| S8 | a11yP1-2+ 键盘导航(P2-1 | 0.3 人日(可 P2 单排) |
| — | **合计** | **3.43.7 人日** |
---
## 15. 变更记录
| 日期 | 版本 | 变更内容 | 变更人 |
|---|---|---|---|
| 2026-08-09 | v1.0 | 创建。基于架构核查报告 v1.0 与宋献口述需求,固化 D-1~D-6 六项决策;产出菜单项可见性矩阵(§5)与七实例推演;给出 Q-3/Q-4/Q-5 三项自决结论(Q-4 与架构建议存在分歧并说明理由);定义 `WorkItemActionMenu` + `useConversationMenuItems` 组件边界与 PRD-011 Phase 2 兼容检查表;登记 R-1~R-9 风险与 C-1~C-7 待确认项 | 许清楚(产品经理) |
</content>
</invoke>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,549 @@
# 技术核查 — 坐席端会话条目操作菜单(左栏三点菜单 / 右键菜单)
> **文档编号**: 技术核查-坐席会话条目操作菜单-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}/assign`<br>`backend/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}/pin`<br>`conversations.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}/todo`<br>`conversations.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}/transfer`<br>`conversations.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`
```js
// 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`<br>= `status === 'serving' && (is_mine \|\| is_collaborator)`<br>`ChatArea.vue:325-329`) | 无 | 固定「🤝 摇人」 | ❌ **保留** |
| 6 | **结单 / 等待确认** | `:177-193` | `status === 'serving'` → 结单(danger<br>`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-243` `loadAvailableAgents()` 拉取 `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×2margin)− 10×2padding= **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 会裁剪菜单
```css
/* 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`)内部的菜单,
1. **横向必被裁掉** —— 260px 窄栏内放不下一个 120-160px 的菜单还要向右展开;
2. **纵向在列表首尾会被滚动容器裁掉**
**菜单 DOM 必须脱离左栏容器**,只有两条合规路径:`position: fixed` + 视口坐标(`MessageItem.vue` 现有做法),或 Teleport 到 bodyElement 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`):
```html
<!-- 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.0v1.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` 现有右键实现(复用成本评估)
```js
// 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 }
```
```html
<!-- :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>
```
```css
/* :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 支持组虽是技术人群,但坐席工作台是**多人轮岗**场景,不能假设全员知晓<br>🔴 需覆写浏览器原生右键菜单,部分用户会感到被剥夺<br>⚠️ 触摸屏/远程桌面(VDI)环境下右键可能不可靠 —— 需确认坐席实际办公环境 |
| **适用性** | ❌ **不适合作为唯一入口**。作为快捷方式是加分项 |
#### 选项 2:三点按钮 + Popover(用户倾向方案)
| 维度 | 评估 |
|---|---|
| **可行性** | ✅ 可行,且有现成模板 `AiReplyModeSwitch.vue` |
| **改造量** | 中。`el-popover` + `MoreFilled` 图标(`@element-plus/icons-vue@^2.3.0` 已装)+ 菜单项可见性矩阵,**1.5-2.0 人日**(大头在菜单项状态矩阵与归属权限判定,不在控件本身) |
| **优势** | ✅ 发现性明确,零学习成本,与微信/钉钉/Slack 一致<br>✅ `el-popover` **默认 Teleport 到 body**,自动绕开 §1.3.4 的 overflow 裁剪<br>✅ Popper.js 自动处理 `placement` 翻转与贴边,260px 窄栏用 `placement="bottom-end"` 即可稳定<br>✅ z-index 由 EP 统一管理(2000+ 自增),不会与 `MessageItem` 的 1000 打架 |
| **风险** | ⚠️ **占位冲突**(§1.3.2)—— 必须与 `.conv-target-avatar` 二选一或叠放,PM 须明确<br>⚠️ **冒泡冲突** —— 三点按钮必须 `@click.stop`,否则点开菜单会连带切换会话。项目内已有正确先例(`ConversationItem.vue:122/133``@click.stop`),风险可控<br>⚠️ hover 显隐会引入「鼠标移出条目→按钮消失→菜单是否跟着关」的边界判定,需明确规则(建议:菜单打开期间按钮**强制常显**并给条目加 `.is-menu-open` 态)<br>⚠️ 每个条目挂一个 `el-popover` 实例,50+ 条目时有轻微渲染开销(EP popover 内容 lazy 渲染,实测可接受;若上虚拟滚动则必须改为单例菜单) |
| **适用性** | ✅ **适合作为主入口** |
#### 选项 3:三点按钮为主 + 右键为快捷方式(并存)
| 维度 | 评估 |
|---|---|
| **可行性** | ✅ 可行,**且是成本最低的并存实现** —— 前提是菜单内容抽成一个独立组件,两个触发源共用同一份菜单项定义与 handler |
| **改造量** | 选项 2 基础上 **+0.3-0.5 人日**(仅新增 `@contextmenu.prevent` 触发 + `el-popover``virtual-triggering` 坐标注入) |
| **优势** | ✅ 覆盖新手(三点可见)与熟手(右键高效)两类用户<br>✅ 与 IM 类产品心智完全一致(微信 PC 会话列表:右键有菜单;企微:右键有菜单)<br>✅ **对宋献本人的原始诉求(需求 1 问右键、需求 2 要三点)是唯一的「全都要」解**,且不是妥协——二者本就是同一菜单的两个触发器 |
| **风险** | ⚠️ 需保证两个入口的菜单项与状态**永远一致**(用同一 `computed` 生成菜单项数组即可根治)<br>⚠️ 互斥:右键打开 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 分派设计
```mermaid
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
```
**为什么这样切**
1. **单例菜单挂在 List 层** → 直接解决 §2.4-5 的虚拟滚动难题,且 Phase 2 换成 `WorkQueueList` 时菜单壳零改动;
2. **菜单项工厂按 kind 分文件** → 完全对齐 PRD-011 的 `kind` 判别式,新增 kind 只加一个 composable,不动壳;
3. **壳不认识业务** → 中栏 `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. **需求 1 与需求 2 不是二选一,而是同一件事的两个触发器。** 宋献先问「右键能不能做」,再提「加三个点」——从提问顺序看,右键是他真正想要的效率形态,三点是他为了保险给出的可实现形态。二者共用同一份菜单定义时,**并存的边际成本只有 0.3-0.5 人日**(见 §2.3 选项 3)。为省这半天而只做一个,是错误的取舍。
2. **纯右键(选项 1)不可单独采用。** 左栏会话列表是坐席**每天使用频率最高**的区域,且服务台存在轮岗与新人。零发现性的交互在这个位置是产品事故,不是效率优化。
3. **单例菜单架构不是过度设计,是防返工的最小投入。** 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 | 键盘导航与 a11yrole/↑↓/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 决策的三个问题**
1. **转接的二级菜单**:三点菜单里做二级展开(`el-popover` 嵌套,260px 窄栏下体验待验证),还是点「转接」后弹独立 Dialog 选坐席?**架构建议后者**——窄栏内嵌套二级菜单在视口边缘的翻转组合过于脆弱,且坐席列表可能较长需要滚动与搜索。
2. **接手/退出协作**是否收进三点菜单?收进则第三行的 `grab-btn`/`leave-btn``ConversationItem.vue:116-136`)应同步移除,避免同一动作两个入口;不收则菜单只有 4 项、更聚焦。**架构倾向:本次不动,保持第三行按钮**,减少变更面。
3. **结单**是否也进左栏菜单?用户未提,但它是与置顶/代办同等级的会话动作。**架构建议不进**——结单需走摘要确认 Dialog`ChatArea.vue:448-502`),在左栏触发一个模态流程属于路径错位。
---
## 5. E. 风险与待明确事项
### 5.1 🔴 核心风险:移除中栏按钮导致的操作路径退化(正面回答)
**判断:会构成退化,且退化程度按操作分为两档。必须区别对待,不建议 4 个一刀切全删。**
#### 场景推演
坐席正在中栏与员工对话,需要执行某操作:
| 操作 | 当前路径 | 全删后路径 | 退化判定 |
|---|---|---|---|
| **接单** | 中栏点「接单」 | 左栏找条目 → hover → 点三点 → 点接单 | ⚪ **不退化**。接单发生在**打开会话之前/之时**,左栏本就是接单的自然场所(Zendesk/ServiceNow 皆如此)。且中栏按钮在 `status !== 'queued'` 时恒为 disabled 的「已接单」,长期占位却不可点,**删除反而是净收益** |
| **置顶** | 中栏点「置顶」 | 左栏找条目 → 三点 → 置顶 | 🟡 **轻微退化,可接受**。置顶是**列表组织行为**,其效果(条目上浮)也只在左栏可见。操作与反馈同处一栏,语义上反而更内聚 |
| **代办** | 中栏点「代办」 | 左栏找条目 → 三点 → 代办 | 🟡 同置顶 |
| **转接** | 中栏点「转接」→ 下拉选坐席 → 确认 | 左栏找条目 → hover → 三点 → 转接 → 选坐席 → 确认 | 🔴 **显著退化** |
#### 为什么转接是显著退化
1. **触发时机不同**。置顶/代办是**列表管理动作**,坐席在扫视队列时执行;转接是**对话过程动作**——坐席读完员工描述、判断"这不归我管"的那一刻,注意力和鼠标都在中栏。此时被要求"回左栏,找到当前这条,hover 出三点,点开菜单,选转接",是**注意力焦点的强制迁移**。
2. **交互步数从 3 步涨到 6 步**
- 现状:`点转接 → 选坐席 → 确认`
- 全删后:`视线移左栏 → 定位当前条目 → hover → 点三点 → 点转接 → 选坐席 → 确认`
- 且第 2 步「定位当前条目」本身有成本——左栏三分区、可滚动、条目会因置顶/紧急度**动态重排**(`ConversationList.vue:44-77`),当前会话未必在视口内。
3. **中栏本就要留按钮,删了转接反而制造不一致**。用户明确只删 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.0v1.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) | 高见远(架构师) |
</content>
</invoke>
@@ -54,7 +54,7 @@
| 来源文档 | 页面 | 说明 |
|----------|------|------|
| `docs/01-产品文档/05-用户端H5/原型-REQ-用户-001-群聊双模式-v1.0.html` | H5 缩略 / 展开 | 参与者面板交互基准 |
| `docs/01-产品文档/02-会话管理/原型-REQ-会话-001-工具栏统一设计v1.9-员工端落地版.html` | 工具栏 | **5 按钮基线**,本次严禁改动其 DOM 结构 |
| `docs/01-产品文档/02-会话管理/原型-REQ-会话-001-工具栏统一设计v2.0-员工端服务蓝扁平版.html` | 工具栏 | **5 按钮基线(当前生产版本 v2.0**,本次严禁改动其 DOM 结构v1.9 已归档为 `.archive` 仅供回溯 |
### 2.4 需了解的现有代码(历史现状)