chore(docs): docs/ 目录全面重新编号 + 重组

**重构前**(旧编号 02-11):
- docs/02-产品需求/      → 00 产品规划/PRD
- docs/03-技术架构/      → 01-05 子目录散落
- docs/04-原型设计/      → 01-02 产品设计(HTML 原型)
- docs/05-原型设计/      → screens/
- docs/06-测试素材/      → 02-E2E / 03-功能 / 04-版本测试
- docs/07-项目管理/      → 任务说明书/日报/计划
- docs/08-安全审计/      → 审计报告
- docs/09-堡垒运维/      → toolbox / deploy
- docs/10-项目管理/      → 任务说明书(重复)
- docs/11-历史归档/      → deploy-nas-archived

**重构后**(新编号 00-07,语义化):
- docs/00-产品开发流程与文档管理规范.md
- docs/00-版本迭代总览.md
- docs/01-产品文档/      (PRD/原型/认证/会话/AI 服务/坐席/集成)
- docs/02-技术文档/      (技术方案/架构图/重构记录/前端改造/实现配置)
- docs/03-测试文档/      (E2E/功能用例/版本报告/缺陷单)
- docs/04-运维文档/      (部署运维/运维指南)
- docs/05-运营文档/      (品牌推广/用户手册)
- docs/06-安全审计/      (审计报告)
- docs/07-项目管理/      (任务说明书/日报/计划/看板)

**净收益**:
- 目录编号与产品文档管理规范对齐(按文档阶段 01-07 编号)
- 消除 02-产品需求 与 10-项目管理 的编号重叠
- 子目录按文档类型分组(如 01-产品文档/00-产品规划、01-产品文档/01-认证与登录)
- 把运维/安全/项目管理从 0X 散落改为 04/06/07

合计 494 文件 + 78495 行 / - 14076 行
This commit is contained in:
Simon
2026-08-03 18:46:55 +08:00
parent 3a44141eac
commit 44e77dcb0e
494 changed files with 78494 additions and 14075 deletions
@@ -0,0 +1,51 @@
# PRD - 摇人功能(坐席呼叫坐席)
> **REQ编号**: REQ-会话-003
> **版本**: v1.0
> **优先级**: P1
> **日期**: 2026-07-19
---
## 一、需求概述
坐席在处理会话时,可呼叫其他坐席进入群聊协助处理,实现多人协作。
---
## 二、需求详情
### 2.1 核心概念
| 术语 | 定义 | 区别 |
|------|------|------|
| **人工** | 用户(员工)呼叫坐席 | H5用户端触发 |
| **摇人** | 坐席呼叫其他坐席 | 仅坐席端可用 |
### 2.2 功能设计
**触发方式**
- 坐席点击「摇人」按钮 → 选择坐席 → 发送邀请
- 被邀请坐席收到通知 → 点击加入群聊
**群聊模式**
- 主责坐席 + 被邀请坐席 + 员工
- 消息实时同步所有参与者
- 支持转让主责
### 2.3 坐席端摇人话术体系
| 触发场景 | 话术 | 语气 |
|---------|------|------|
| 点击「摇人」按钮 | 大哥,俺这就去摇人,稍等... | 亲切 |
| 排队等待(30秒) | 人还在路上,别急别急~ | 安抚 |
| 被邀请坐席接入 | 坐席已加入群聊 | 明确交接 |
| 等待超时(2分钟) | 坐席都在忙,不过AI还在呢... | 降级安抚 |
> 话术存配置表,支持后台动态修改
---
## 三、关联文档
- 原型: `原型-REQ-集成-002-邀请流程-v1.0.html`
@@ -0,0 +1,191 @@
# 坐席端截图 + 拍照功能 PRD
> **子系统**: 04-坐席工作台
> **模块**: 截图拍照
## 项目信息
| 项 | 内容 |
|---|---|
| **语言** | 中文 |
| **技术栈** | Vue3 + Element Plus + Tailwind CSS |
| **项目名称** | agent_screenshot_camera |
| **前端目录** | `D:\资料\03-项目开发\wecom_it_smart_desk\frontend-agent` |
| **涉及组件** | `ReplyBox.vue` / `ScreenshotEditor.vue` / `ScreenCapture.vue`(改造)+ 新增 `CameraCapture.vue` |
### 原始需求复述
IT智能服务台坐席端(PC Web 应用)需要增加截图和相机拍照功能。客服在处理工单时经常需要截取屏幕内容或拍照来辅助沟通。截图需支持全屏/窗口/标签页选择(含跨屏),截图时屏幕变暗出现矩形选框 + 工具栏(马赛克、文字标注、箭头、撤销),样式参考微信/企微截图工具。拍照需用 `getUserMedia` + `<video>` 实现内嵌摄像头预览,支持重拍。截图和拍照结果均插入到聊天输入区作为图片消息等待发送(非立即发送)。
---
## 产品定义
### 产品目标
1. **提升沟通效率**:客服无需切换到外部截图工具或手机拍照,直接在工作台内完成截图/拍照→编辑→发送的全流程,减少上下文切换。
2. **保证信息准确性**:通过截图标注(马赛克打码、箭头指引、文字说明)和实时拍照,让客服能够精准传递屏幕画面和实物状况,减少口头描述的歧义。
3. **统一图片消息发送体验**:截图和拍照结果统一进入输入区待发送队列,客服可在发送前预览、补充文字说明或删除,与现有粘贴图片/文件上传的交互保持一致。
### 用户故事
1. **作为客服坐席**,我想直接在工作台点击截图按钮截取屏幕画面,这样我就能把用户报障的屏幕现象快速发给用户确认,而不需要切换到微信截图或系统截图工具。
2. **作为客服坐席**,我想在截图时用马赛克遮挡敏感信息(如密码、个人信息),这样在发送截图时不会泄露用户隐私。
3. **作为客服坐席**,我想在截图选区内用箭头和文字标注重点位置,这样用户能快速理解我指的具体是哪里。
4. **作为客服坐席**,我想通过摄像头拍照来记录实物设备状态(如硬件故障、线缆连接),这样比文字描述更直观。
5. **作为客服坐席**,我想截图/拍照后的图片先进入输入区等我自己确认后再发送,这样我能在发送前检查标注是否正确、是否打码到位。
---
## 技术规范
### 现有代码分析
通过对 `frontend-agent/src/components/chat/` 目录的代码审查,发现以下现状:
| 组件 | 现状 | 差距 |
|---|---|---|
| `ReplyBox.vue` | 工具栏有表情、文件、邀请、快速回复按钮;已有 `handleScreenshot()`(用 `html2canvas`)和 `handleScreenCapture()`(用 `getDisplayMedia`,但未接入任何按钮) | 缺少截图/拍照入口按钮;截图结果直接上传发送而非插入输入区 |
| `ScreenshotEditor.vue` | 裁剪 + 编辑两步模式,工具有画笔/高亮/矩形/箭头/文字,支持撤销/重做 | 缺少马赛克工具 |
| `ScreenCapture.vue` | 全屏遮罩框选模式,工具有画笔/矩形/箭头/文字 | 缺少马赛克工具;结果直接发送 |
| `upload.ts` | 文件上传 API,支持重试,返回 `{ url, filename, file_size, msg_type }` | 可复用 |
| 拍照功能 | **完全不存在** | 需新建 `CameraCapture.vue` 组件 |
**关键发现**`ReplyBox.vue` 中已有 `handleScreenCapture()` 方法调用了 `navigator.mediaDevices.getDisplayMedia()`,但该方法未绑定到任何 UI 按钮。当前截图按钮实际走的是 `handleScreenshot()``html2canvas`(仅能截取当前页面 DOM,无法截取其他窗口/屏幕)。
### 需求池
#### P0 — 必须实现(Must Have
| 编号 | 需求 | 验收标准 |
|---|---|---|
| P0-1 | **截图入口按钮**:在 `ReplyBox.vue` 工具栏新增截图按钮(图标裁剪刀或相机图标),与表情、文件等按钮并列 | 点击后触发 `getDisplayMedia()` 屏幕选择流程 |
| P0-2 | **`getDisplayMedia` 屏幕捕获**:调用 `navigator.mediaDevices.getDisplayMedia()` 获取屏幕流,捕获一帧画面传入截图编辑器 | 浏览器弹出屏幕/窗口/标签页选择器;支持选择跨屏显示器;用户取消时静默退出不报错 |
| P0-3 | **截图选区编辑**:全屏遮罩 + 矩形选框拖拽,选区内可编辑,选区外半透明遮罩(对标微信截图) | 屏幕变暗;拖拽出现选框;选框可拖动调整大小;8 个方向手柄 |
| P0-4 | **马赛克工具**:截图编辑工具栏新增马赛克工具,拖拽涂抹区域进行像素化打码 | 选择马赛克工具后在选区内拖拽,拖过区域被马赛克覆盖;支持撤销 |
| P0-5 | **文字标注工具**:点击选区内位置弹出文本输入,输入后渲染为画布上的文字标注 | 点击出现输入框(非 `prompt()`,用内联 input);回车确认渲染到 canvas |
| P0-6 | **箭头工具**:拖拽绘制带箭头的线段 | 起点到终点绘制箭头;箭头头部自动计算角度 |
| P0-7 | **撤销功能**:工具栏撤销按钮,逐步撤销编辑操作 | 每次操作入栈,撤销弹栈;撤销到空时禁用按钮 |
| P0-8 | **截图结果插入输入区**:截图确认后,图片以缩略图形式显示在输入框上方(textarea 之上),作为待发送图片,不自动发送 | 输入区上方出现图片缩略图 + 删除按钮;可继续输入文字;点击发送时图片和文字一起发送 |
| P0-9 | **拍照入口按钮**:在 `ReplyBox.vue` 工具栏新增拍照按钮(摄像头图标) | 点击后弹出摄像头预览弹窗 |
| P0-10 | **`getUserMedia` 摄像头预览**:用 `navigator.mediaDevices.getUserMedia({ video: true })` 获取摄像头流,渲染到 `<video>` 元素实时预览 | 弹窗内嵌视频预览;自动选择前置摄像头;摄像头不可用时显示错误提示 |
| P0-11 | **拍照捕获**:点击拍照按钮,从 `<video>` 当前帧 capture 到 canvas,转为图片 | 拍照后预览切换为静态图片;显示"重拍"和"确认"按钮 |
| P0-12 | **重拍功能**:拍照后点击"重拍"返回实时预览状态 | 重拍后清除上一张照片,恢复 `<video>` 实时预览 |
| P0-13 | **拍照结果插入输入区**:拍照确认后,图片以缩略图形式显示在输入框上方,作为待发送图片 | 与 P0-8 行为一致 |
| P0-14 | **待发送图片发送流程**:发送消息时,如果有待发送图片,先上传图片再发送图片消息(`msg_type: 'image'`),如有文字则额外发送文字消息 | 图片上传复用 `uploadFile()` API;发送成功后清空待发送图片和文字 |
| P0-15 | **待发送图片删除**:输入区待发送图片缩略图上有删除按钮,点击移除 | 点击 × 后缩略图消失,不影响文字内容 |
#### P1 — 应该实现(Should Have
| 编号 | 需求 | 验收标准 |
|---|---|---|
| P1-1 | **多张待发送图片**:输入区可同时插入多张待发送图片(如先截一张再拍一张) | 支持多张缩略图横向排列;发送时按顺序上传发送;可单独删除某一张 |
| P1-2 | **颜色选择**:截图编辑工具栏提供颜色选择(红/黄/蓝/绿/黑等常用色) | 点击颜色切换标注颜色;当前选中颜色高亮 |
| P1-3 | **矩形框工具**:拖拽绘制矩形边框(用于圈选区域) | 与微信截图矩形工具一致 |
| P1-4 | **截图保存到本地**:截图编辑器提供"保存"按钮,将截图下载到本地 | 下载为 PNG 文件,文件名 `screenshot-{timestamp}.png` |
| P1-5 | **摄像头切换**:如果有多个摄像头,支持切换前后摄像头 | 弹窗内显示切换按钮;切换后预览更新 |
| P1-6 | **ESC 退出**:截图模式和拍照弹窗均支持 ESC 键退出 | 按 ESC 等同于取消 |
| P1-7 | **截图选区尺寸显示**:拖拽选区时实时显示选区宽×高像素值 | 选区右下角或工具栏旁显示 `W×H` 像素值 |
#### P2 — 优先级较低(Nice to Have
| 编号 | 需求 | 验收标准 |
|---|---|---|
| P2-1 | **画笔自由绘制**:截图编辑器提供自由画笔工具 | 拖拽绘制自由曲线;线宽可调 |
| P2-2 | **高亮工具**:半透明黄色高亮笔 | 拖拽覆盖区域呈半透明高亮 |
| P2-3 | **快捷键截图**:支持 `Ctrl+Shift+A` 快捷键触发截图 | 全局快捷键,焦点不在输入框也能触发 |
| P2-4 | **拍照镜像翻转**:前置摄像头预览水平镜像(符合自拍习惯),但保存的图片不镜像 | 预览镜像、输出正向 |
| P2-5 | **截图延时捕获**:支持 3 秒延时截图(用于捕获鼠标悬停菜单等动态画面) | 选择延时后倒计时 3-2-1 再捕获 |
---
## UI 设计要点
### 1. 工具栏布局(ReplyBox.vue
现有工具栏顺序:`表情 | 文件 | 分隔线 | 邀请 | 快速回复`
**改造后**`截图 | 拍照 | 分隔线 | 表情 | 文件 | 分隔线 | 邀请 | 快速回复`
- 截图按钮放在最左侧(最高频使用),图标用裁剪刀 SVG 或 ✂️
- 拍照按钮紧跟截图按钮,图标用摄像头 SVG 或 📷
- 与现有按钮样式保持一致(`.tb-btn` 类,32×28pxhover 高亮 + tooltip
### 2. 截图模式(全屏覆盖层)
- **触发**:点击截图按钮 → 浏览器弹出 `getDisplayMedia` 选择器(选屏幕/窗口/标签页)
- **选区**:选中后屏幕变暗(`rgba(0,0,0,0.5)` 遮罩),鼠标变为十字光标
- **拖拽框选**:按住鼠标拖拽出现矩形选框(绿色边框 `#07C160`,对标微信),选区内画面清晰,选区外半透明
- **8 方向手柄**:选框四角 + 四边中点各一个手柄,可拖拽调整大小
- **工具栏**:选区下方居中浮动工具栏,从左到右:
- 工具组:`马赛克 | 文字 | 箭头 | 矩形`P1
- 颜色组:`🔴 🟡 🔵 🟢 ⚫`P1
- 操作组:`撤销 | 保存(P1) | 取消 | 确认✓`
- **尺寸提示**:选区右下角实时显示 `宽×高` 像素值(P1
- **ESC 退出**:按 ESC 取消截图
### 3. 拍照弹窗(CameraCapture.vue
- **弹出方式**Element Plus `el-dialog` 居中弹窗,宽度约 640px
- **预览区**`<video>` 元素填满弹窗主体,自适应摄像头分辨率
- **控制区**:弹窗底部居中放置拍照按钮(圆形大按钮,图标 📷)
- **拍照后状态**
- `<video>` 隐藏,显示 `<canvas>``<img>` 静态画面
- 底部按钮变为:`重拍` (左)| `确认` (右,绿色主按钮)
- **错误状态**:摄像头不可用时显示"无法访问摄像头,请检查权限设置"提示 + 关闭按钮
- **关闭**:右上角 ✕ 或 ESC 关闭弹窗,停止摄像头流
### 4. 待发送图片预览区(输入框上方)
- **位置**:在 `ReplyBox.vue``textarea` 上方、工具栏下方,新增一个待发送图片预览条
- **布局**:横向排列缩略图,每张缩略图约 80×80px,圆角,右上角有 × 删除按钮
- **样式**:浅色背景容器,与现有引用回复预览条(`.reply-preview`)风格一致
- **多张图片**:横向排列,超出宽度时可横向滚动
- **交互**
- 鼠标悬停缩略图可放大预览(可选 P2)
- 点击 × 删除该张待发送图片
- 发送后清空所有待发送图片
### 5. 视觉风格
- 主色调:与现有坐席端一致,使用 CSS 变量 `var(--accent)``var(--bg-secondary)`
- 截图选框颜色:`#07C160`(微信绿,与现有 `ScreenCapture.vue` 一致)
- 工具栏:白色背景 + 阴影,与现有 `ScreenshotEditor.vue` 工具栏风格一致
- 弹窗:Element Plus 默认主题,圆角 8px
---
## 待确认问题
| 编号 | 问题 | 影响范围 | 建议 |
|---|---|---|---|
| Q1 | **企微内嵌浏览器兼容性**`getDisplayMedia()``getUserMedia()` 在企微内置浏览器中是否可用?现有代码注释提到"Screen Capture API 在企微桌面端被限制,不稳定"。 | 截图功能核心可行性 | 建议在企微环境下做一次可用性测试;如不可用,保留 `html2canvas` 作为 fallback(仅能截当前页面) |
| Q2 | **HTTPS 要求**`getDisplayMedia()``getUserMedia()` 要求 HTTPS 环境(或 localhost)。坐席端部署是否为 HTTPS? | 截图 + 拍照功能可行性 | 确认生产环境域名是否为 HTTPS;如非 HTTPS 则两个功能都无法使用 |
| Q3 | **待发送图片与文字的组合发送**:用户在输入区有待发送图片 + 文字时,发送方式是:(a) 先发图片消息再发文字消息(两条消息)?还是 (b) 合并为一条富文本消息? | 消息发送逻辑 | 建议方案 (a):先发图片再发文字,与现有粘贴图片行为一致。后端如支持图文混排消息可改为 (b) |
| Q4 | **截图工具栏工具优先级**:需求明确提到"马赛克、文字标注、箭头、撤销"四个工具。是否也需要矩形框工具(P1)和高亮/画笔(P2)? | 编辑器功能范围 | 建议 P0 只做需求明确的 4 个工具,矩形框放入 P1 |
| Q5 | **摄像头权限提示**:首次使用拍照功能时浏览器会请求摄像头权限。是否需要自定义权限引导提示? | 用户体验 | 建议在调用 `getUserMedia` 前先显示一个简短提示"即将请求摄像头权限,请在弹窗中允许" |
| Q6 | **InputBox.vue 是否同步改造**:代码库中存在 `InputBox.vue`(旧版输入组件)和 `ReplyBox.vue`(当前使用)。是否需要同步改造 `InputBox.vue` | 改造范围 | 建议只改 `ReplyBox.vue``InputBox.vue` 如已废弃则不动 |
| Q7 | **截图后的编辑器复用**:现有 `ScreenshotEditor.vue`(裁剪+编辑两步)和 `ScreenCapture.vue`(全屏框选+编辑)两个组件功能重叠。新截图功能基于哪个改造? | 架构设计 | 建议基于 `ScreenCapture.vue` 改造(全屏遮罩框选更符合微信截图体验),废弃或保留 `ScreenshotEditor.vue``html2canvas` fallback 使用 |
---
## 附录:现有代码关键路径
```
frontend-agent/src/
├── components/chat/
│ ├── ReplyBox.vue ← 主输入组件(改造:加按钮 + 待发送图片区)
│ ├── InputBox.vue ← 旧版输入组件(待确认是否改造)
│ ├── ScreenshotEditor.vue ← 截图编辑器(改造:加马赛克工具)
│ ├── ScreenCapture.vue ← 全屏框选截图(改造:接入 getDisplayMedia + 加马赛克)
│ └── CameraCapture.vue ← 新建:摄像头拍照组件
├── api/
│ ├── upload.ts ← 文件上传 API(复用)
│ └── message.ts ← 消息发送 API(复用)
└── stores/
└── conversation.ts ← 会话状态管理(可能需扩展:待发送图片队列)
```
@@ -0,0 +1,622 @@
# 坐席端 AI 辅助消息框 — 产品需求文档 (PRD)
> **版本**: v1.0
> **日期**: 2026-07-11
> **作者**: 宋献
> **状态**: 需求已确认,待开发
> **子系统**: 04-坐席工作台
> **模块**: AI辅助
---
## 1. 背景与目标
### 1.1 现状分析
坐席端已具备较完善的 AI 辅助基础设施:
| 已有功能 | 模式 | 状态 |
|----------|------|------|
| AI 草稿生成 | 点击右栏按钮 → 生成完整回复 → 采纳 | 已对接 Dify API |
| AI 草稿气泡 | 消息流内联气泡 → 采纳/编辑/忽略 | 已对接后端 |
| 快速回复模板 | 三层渐进导航 + 变量替换 | 本地静态数据 |
| AI 推荐回复(内联) | 3 张卡片 + Ctrl+1/2/3 | Mock 数据,未对接 API |
| 会话摘要 + 智能标注 | 结单时自动生成 | 已对接 Dify API |
**核心差距**:现有功能均为"生成完整草稿 → 坐席采纳"模式,缺失"输入过程中实时辅助 + 发送前精修"模式。
### 1.2 行业标杆
- **Freshdesk Freddy AI**:语气增强(更正式/更随意/专业/友好/随性),选中文字后一键改写
- **智齿科技**:AI 扩写润色 + 风格设置(友好口吻/专业口吻)
- **Yellow.ai**Tone Changer + Sentiment Detection + AI Copilot
### 1.3 目标
在现有 ReplyBox 输入框中新增 4 项 AI 辅助功能,补齐"输入过程中实时辅助 + 发送前精修"能力:
1. **实时自动补齐** — 输入停顿时显示灰色幽灵文字,Tab 键接受
2. **语气调整** — 选中文字后选择专业/友好/简洁风格,一键改写
3. **文字润色** — 扩写/压缩/纠错,弹出精修面板确认后替换
4. **智能改写** — 基于对话上下文 + 知识库,生成 3 个备选版本
### 1.4 设计原则
- **共存不替代**:新增功能与现有草稿生成互补,不替代
- **渐进增强**:坐席可选择使用,不强制改变现有工作流
- **低延迟感知**:补齐建议 < 1.5s,语气/润色/改写 < 3s
- **可降级**:AI 服务不可用时不影响正常输入和发送
---
## 2. 功能详细需求
### 2.1 实时自动补齐
#### 2.1.1 交互流程
```
坐席输入文字 → 停顿 > 0.8s → 调用 AI 补齐 API → 光标位置显示灰色幽灵文字
├─ Tab 键 → 接受补齐,文字变为正常颜色
├─ 继续输入 → 忽略补齐,重新触发
└─ Esc 键 → 清除补齐建议
```
#### 2.1.2 功能规格
| 项目 | 规格 |
|------|------|
| 触发条件 | 输入停顿 > 800ms,且输入框内容 > 5 字符 |
| 请求防抖 | debounce 800ms,输入中取消上一个请求 |
| 显示方式 | 光标后方灰色斜体文字(ghost text) |
| 补齐长度 | 1-2 个短句(不超过 80 字符) |
| 接受方式 | Tab 键接受全部;Shift+Tab 接受一个词 |
| 忽略方式 | 继续输入自动忽略;Esc 清除 |
| 上下文 | 携带最近 5 条对话消息 + 当前输入内容 |
| 并发控制 | 同一时刻只保留最新的补齐请求 |
| 加载状态 | 幽灵文字位置显示 3 个点动画(typing indicator |
| 超时处理 | 1.5s 未返回则取消,不显示 |
#### 2.1.3 前端实现要点
-`<textarea>` 上方覆盖一层 `<div>` 渲染幽灵文字,通过计算光标位置定位
- 或使用 `contenteditable` div 替换 textarea(更灵活但改动较大)
- **推荐方案**:保持 `<textarea>` 不变,在下方独立区域显示补齐建议条(简化实现,降低风险)
> **决策点**:纯内联幽灵文字需要精确计算光标坐标,实现复杂度高。若需降低首版复杂度,可采用"输入框下方建议条"作为过渡方案。当前需求确认为内联幽灵文字。
#### 2.1.4 后端 API
```
POST /api/conversations/{conversation_id}/wingman/autocomplete
```
**请求体**
```json
{
"current_text": "张工您好,您反馈的VPN连接问题我已经",
"cursor_position": 22,
"max_length": 80
}
```
**响应体**
```json
{
"completion": "正在查看相关工单记录,请稍候。",
"confidence": 0.85
}
```
**Dify System Prompt**
```
你是一个IT服务坐席输入助手。根据坐席当前正在输入的内容和对话上下文,补齐下一句话。
要求:
1. 补齐内容自然衔接当前文字,不要重复已有内容
2. 长度控制在1-2个短句,不超过80字
3. 语气专业、简洁,符合IT服务规范
4. 只返回补齐的文字,不要加引号或其他标记
```
---
### 2.2 语气调整
#### 2.2.1 交互流程
```
坐席选中已输入文字 → 点击工具栏"语气"按钮 → 弹出语气选择菜单
├─ 专业 → 调用 AI 改写 → 原文/改写文对比 → 确认替换
├─ 友好 → 调用 AI 改写 → 原文/改写文对比 → 确认替换
└─ 简洁 → 调用 AI 改写 → 原文/改写文对比 → 确认替换
```
#### 2.2.2 功能规格
| 项目 | 规格 |
|------|------|
| 触发条件 | 选中输入框中的文字(≥ 5 字符),点击工具栏"语气"按钮 |
| 语气选项 | 专业 / 友好 / 简洁(3 种) |
| 显示方式 | 弹出浮层菜单,3 个选项纵向排列 |
| 改写显示 | 浮层中显示"原文 → 改写文"对比 |
| 确认方式 | 点击"替换"按钮,用改写文替换选中文字 |
| 取消方式 | 点击外部区域或 Esc 关闭浮层 |
| 上下文 | 携带对话上下文 + 选中文字 + 当前完整输入内容 |
| 超时处理 | 3s 未返回显示重试按钮 |
#### 2.2.3 语气定义
| 语气 | 定义 | 示例 |
|------|------|------|
| **专业** | 使用准确的技术术语,结构化表达,去除口语化内容 | "经排查,您的VPN连接异常是由本地网络DNS解析超时引起,建议执行以下操作:" |
| **友好** | 适当增加问候和关心语,语气更亲和 | "张工您好~VPN连不上确实挺急的,我帮您看了下,可能是DNS解析有点慢,咱们试试这样操作:" |
| **简洁** | 去除冗余,直奔主题,控制字数 | "VPN异常原因:DNS超时。操作:1. 刷新DNS 2. 重连VPN" |
#### 2.2.4 后端 API
```
POST /api/conversations/{conversation_id}/wingman/tone-adjust
```
**请求体**
```json
{
"selected_text": "VPN连接问题我已经在看了,你别急啊,我查一下",
"full_text": "张工您好,VPN连接问题我已经在看了,你别急啊,我查一下",
"tone": "professional",
"conversation_context": true
}
```
**响应体**
```json
{
"rewritten_text": "经排查,您的VPN连接异常由本地网络DNS解析超时引起,建议执行以下操作:",
"tone": "professional",
"changes_summary": "去除口语化表达,增加技术术语,结构化排版"
}
```
**Dify System Prompt(按语气动态选择)**
```
你是一个IT服务话术改写助手。将坐席选中的文字改写为{tone}风格。
要求:
1. 保持原意不变,只调整语气和表达方式
2. 改写后的文字长度与原文相近(±30%)
3. 符合IT服务坐席的专业规范
4. 只返回改写后的文字,不要加引号或解释
```
---
### 2.3 文字润色
#### 2.3.1 交互流程
```
坐席点击工具栏"润色"按钮 → 弹出精修面板
├─ 扩写 → AI 扩展内容,增加细节和步骤
├─ 压缩 → AI 精简内容,去除冗余
└─ 纠错 → AI 检查并修正语法/错别字
→ 显示结果 → 确认替换 / 取消
```
#### 2.3.2 功能规格
| 项目 | 规格 |
|------|------|
| 触发条件 | 点击工具栏"润色"按钮(无需选中文字,对全部内容操作) |
| 操作选项 | 扩写 / 压缩 / 纠错(3 种) |
| 显示方式 | 弹出精修面板,左右对比(原文 | 结果) |
| 确认方式 | 点击"替换全部"用结果替换输入框全部内容 |
| 部分采纳 | 支持在结果区域手动编辑后再替换 |
| 超时处理 | 3s 未返回显示重试按钮 |
#### 2.3.3 操作定义
| 操作 | 定义 |
|------|------|
| **扩写** | 在原文基础上增加操作步骤、注意事项、解释说明,使回复更完整 |
| **压缩** | 精简表达,去除重复和冗余,保留核心信息,控制字数 |
| **纠错** | 检查并修正错别字、语法错误、标点符号、格式问题 |
#### 2.3.4 后端 API
```
POST /api/conversations/{conversation_id}/wingman/polish
```
**请求体**
```json
{
"text": "你先试试重启一下vpn客户端 然后看看能不能连上来",
"action": "expand",
"conversation_context": true
}
```
**响应体**
```json
{
"polished_text": "建议您按以下步骤操作:\n1. 完全退出VPN客户端(不只是断开,需要关闭进程)\n2. 重新启动VPN客户端\n3. 输入账号密码重新连接\n4. 如仍无法连接,请截图错误信息发给我",
"action": "expand",
"changes_summary": "增加了操作步骤细化、注意事项和兜底方案"
}
```
---
### 2.4 智能改写
#### 2.4.1 交互流程
```
坐席点击工具栏"改写"按钮 → AI 基于对话上下文+知识库生成3个备选版本
→ 弹出选择面板,展示3个版本
→ 坐席选择一个版本 → 替换输入框内容 / 追加到输入框
```
#### 2.4.2 功能规格
| 项目 | 规格 |
|------|------|
| 触发条件 | 点击工具栏"改写"按钮 |
| 生成数量 | 3 个备选版本 |
| 版本差异 | 版本1=简洁直接 / 版本2=详细带步骤 / 版本3=带知识库引用 |
| 显示方式 | 弹出面板,3 个版本纵向排列,可滚动预览 |
| 选择方式 | 点击版本卡片 → "替换"或"追加" |
| 上下文 | 携带最近 10 条对话消息 + 知识库检索结果 |
| 超时处理 | 5s 未返回显示重试按钮 |
| 与草稿生成关系 | 草稿生成是右栏入口;改写是输入框内入口,更轻量 |
#### 2.4.3 后端 API
```
POST /api/conversations/{conversation_id}/wingman/rewrite
```
**请求体**
```json
{
"current_text": "",
"generate_count": 3,
"include_knowledge": true
}
```
**响应体**
```json
{
"versions": [
{
"text": "VPN连接异常,建议重启客户端后重试。",
"style": "简洁直接",
"source": "对话上下文"
},
{
"text": "VPN连接问题处理步骤:\n1. 退出VPN客户端\n2. 重启客户端\n3. 重新连接\n如仍有问题请截图反馈",
"style": "详细带步骤",
"source": "对话上下文"
},
{
"text": "根据知识库文档《VPN故障排查指南》,DNS解析超时是常见原因。建议:\n1. 执行 ipconfig /flushdns\n2. 重启VPN客户端\n3. 重连\n参考文档:[KB-VPN-001]",
"style": "带知识库引用",
"source": "RAGFlow知识库"
}
]
}
```
---
## 3. UI 设计规格
### 3.1 工具栏布局
在 ReplyBox.vue 现有工具栏中新增 4 个 AI 辅助按钮:
```
现有工具栏: [表情] [文件] [截图] [拍照] [语音] [闪电快捷回复] [邀请] [发送]
新增区域: [补齐] [语气] [润色] [改写] [发送]
```
- AI 辅助按钮使用 Element Plus 的 `el-tooltip` 悬浮提示
- 按钮图标使用简洁线性图标,AI 相关按钮使用统一的紫色系(#534AB7)以区分
- 按钮间距 8px,与现有按钮风格一致
### 3.2 幽灵文字样式
```css
.ghost-text {
color: #B4B2A9; /* 灰色 */
font-style: italic; /* 斜体 */
pointer-events: none; /* 不可点击 */
user-select: none; /* 不可选中 */
}
```
### 3.3 语气选择浮层
```
+-------------------------------+
| 选择语气 |
+-------------------------------+
| [专业] 使用准确术语,结构化 |
| [友好] 增加问候,语气亲和 |
| [简洁] 去除冗余,直奔主题 |
+-------------------------------+
| 原文: VPN连不上别急我看看... |
| 改写: 经排查,VPN连接异常... |
+-------------------------------+
| [替换] [取消] |
+-------------------------------+
```
### 3.4 润色精修面板
```
+------------------------------------------+
| 文字润色 [扩写] [压缩] [纠错] |
+------------------------------------------+
| 原文 | 结果(可编辑) |
| ---------- | ---------- |
| 你先试试重启 | 建议您按以下步骤: |
| 一下vpn客户端 | 1. 退出VPN客户端 |
| 然后看看能不能 | 2. 重新启动 |
| 连上来 | 3. 重新连接 |
| | 4. 如仍不行请截图 |
+------------------------------------------+
| [替换全部] [取消] |
+------------------------------------------+
```
### 3.5 改写版本选择面板
```
+------------------------------------------+
| 智能改写 — 3 个备选版本 |
+------------------------------------------+
| 版本1: 简洁直接 |
| VPN连接异常,建议重启客户端后重试。 |
| [替换] [追加] |
+------------------------------------------+
| 版本2: 详细带步骤 |
| VPN连接问题处理步骤: |
| 1. 退出VPN客户端 |
| 2. 重启客户端 |
| ... |
| [替换] [追加] |
+------------------------------------------+
| 版本3: 带知识库引用 |
| 根据知识库文档《VPN故障排查》... |
| [替换] [追加] |
+------------------------------------------+
```
---
## 4. 技术实现方案
### 4.1 后端
#### 4.1.1 新增 API 端点
`backend/app/api/wingman.py` 中新增 4 个端点:
| 端点 | 方法 | 功能 |
|------|------|------|
| `/api/conversations/{id}/wingman/autocomplete` | POST | 实时自动补齐 |
| `/api/conversations/{id}/wingman/tone-adjust` | POST | 语气调整 |
| `/api/conversations/{id}/wingman/polish` | POST | 文字润色 |
| `/api/conversations/{id}/wingman/rewrite` | POST | 智能改写 |
#### 4.1.2 WingmanService 扩展
`backend/app/services/wingman_service.py` 中新增 4 个方法:
- `generate_completion()` — 补齐,使用低 temperature (0.2)
- `adjust_tone()` — 语气调整,使用中 temperature (0.3)
- `polish_text()` — 润色,使用中 temperature (0.3)
- `rewrite_versions()` — 改写,使用高 temperature (0.6) 生成多样化版本
每个方法使用独立的 system prompt,复用现有 Dify API 调用管道。
#### 4.1.3 性能优化
- 补齐 API 设置独立超时:`timeout=3s`(比其他 API 更短)
- 补齐 API 请求添加 `X-Request-Id` 头,前端可取消过时请求
- 考虑对补齐结果做 Redis 缓存(key = hash(当前文字+对话ID)TTL=30s
### 4.2 前端
#### 4.2.1 组件结构
```
frontend-agent/src/components/chat/
├── ReplyBox.vue (修改: 新增 AI 工具栏按钮)
├── ai-assist/
│ ├── GhostText.vue (新建: 幽灵文字渲染层)
│ ├── ToneAdjustPopover.vue (新建: 语气选择浮层)
│ ├── PolishPanel.vue (新建: 润色精修面板)
│ ├── RewritePanel.vue (新建: 改写版本选择面板)
│ └── AiAssistToolbar.vue (新建: AI 辅助工具栏组件)
```
#### 4.2.2 API 封装
`frontend-agent/src/api/wingman.ts` 中新增 4 个方法:
```typescript
export function autocomplete(convId: number, text: string, cursorPos: number): Promise<AutocompleteResponse>
export function adjustTone(convId: number, selectedText: string, fullText: string, tone: ToneType): Promise<ToneAdjustResponse>
export function polishText(convId: number, text: string, action: PolishAction): Promise<PolishResponse>
export function rewriteVersions(convId: number, currentText: string): Promise<RewriteResponse>
```
#### 4.2.3 关键实现细节
**幽灵文字定位**
- 创建一个隐藏的 `<div>` 镜像 textarea 的样式和内容
- 通过镜像 div 计算光标在文本中的像素位置
- 在该位置覆盖一层透明 `<textarea>` 或使用 `contenteditable` 渲染幽灵文字
**请求取消**
- 使用 `AbortController` 管理补齐请求
- 新请求发起时 abort 上一个未完成的请求
**防抖**
- 补齐:debounce 800ms
- 语气/润色/改写:无防抖(用户主动触发)
---
## 5. 与现有功能的关系
| 现有功能 | 新功能 | 关系 |
|----------|--------|------|
| AI 草稿生成(右栏 Wingman) | 智能改写(输入框内) | **互补**:草稿生成用于从零开始;改写用于对已有输入的多版本化 |
| AI 草稿气泡 | 实时自动补齐 | **互补**:草稿气泡是系统主动推荐;补齐是输入过程中的延续 |
| 快速回复模板 | 语气调整 / 润色 | **互补**:快速回复是模板填充;语气/润色是对自由文本的加工 |
| AI 推荐回复(内联 Mock) | 智能改写 | **替代路径**:改写功能可使用真实 API,逐步替代 Mock 推荐回复 |
---
## 6. 非功能需求
| 项目 | 要求 |
|------|------|
| 补齐延迟 | P95 < 1.5s |
| 语气/润色延迟 | P95 < 3s |
| 改写延迟 | P95 < 5s |
| 可用性 | AI 服务不可用时不影响正常输入和发送 |
| 并发 | 单个坐席同一时刻最多 1 个补齐请求 + 1 个其他 AI 请求 |
| 数据安全 | 补齐请求不记录到数据库;语气/润色/改写可记录用于后续优化 |
| 浏览器兼容 | Chrome 90+ / Edge 90+ / 企微内置浏览器 |
---
## 7. 开发计划
### 阶段一:后端 API + 前端骨架(预计 2 天)
- [ ] WingmanService 新增 4 个方法 + Dify system prompt
- [ ] wingman.py 新增 4 个 API 端点
- [ ] 前端 wingman.ts 新增 API 封装
- [ ] ReplyBox.vue 新增 AI 工具栏按钮
### 阶段二:语气调整 + 文字润色(预计 2 天)
- [ ] ToneAdjustPopover.vue 组件
- [ ] PolishPanel.vue 组件
- [ ] 联调测试
### 阶段三:智能改写(预计 1 天)
- [ ] RewritePanel.vue 组件
- [ ] 知识库检索集成
- [ ] 联调测试
### 阶段四:实时自动补齐(预计 2 天)
- [ ] GhostText.vue 幽灵文字渲染
- [ ] 光标定位计算
- [ ] 请求防抖 + 取消机制
- [ ] 联调测试
### 阶段五:集成测试 + 优化(预计 1 天)
- [ ] 端到端测试
- [ ] 性能优化
- [ ] 边界情况处理
---
## 8. 验收标准
### 8.1 功能验收
- [ ] 坐席输入停顿 > 0.8s 后,光标位置出现灰色幽灵文字
- [ ] Tab 键接受补齐,文字变为正常颜色
- [ ] 选中文字后点击"语气"按钮,弹出 3 种语气选项
- [ ] 选择语气后显示原文/改写文对比,确认后替换
- [ ] 点击"润色"按钮,弹出精修面板,支持扩写/压缩/纠错
- [ ] 点击"改写"按钮,生成 3 个备选版本,可选择替换或追加
- [ ] AI 服务不可用时不影响正常输入和发送
### 8.2 性能验收
- [ ] 补齐 P95 延迟 < 1.5s
- [ ] 语气/润色 P95 延迟 < 3s
- [ ] 改写 P95 延迟 < 5s
- [ ] 输入过程中无明显卡顿
### 8.3 兼容性验收
- [ ] Chrome 90+ 正常工作
- [ ] 企微内置浏览器正常工作
- [ ] 移动端 H5 不受影响(本功能仅坐席端)
---
## 9. 风险与缓解
| 风险 | 概率 | 影响 | 缓解措施 |
|------|------|------|----------|
| 幽灵文字定位不准确 | 中 | 中 | 首版可采用建议条过渡方案 |
| Dify 补齐延迟过高 | 中 | 高 | 设置 1.5s 超时,降级为不显示 |
| 坐席过度依赖 AI | 低 | 中 | 补齐建议有置信度阈值,低置信度不显示 |
| 语气改写偏离原意 | 中 | 中 | 显示原文/改写文对比,坐席确认后才替换 |
| 并发请求冲突 | 低 | 低 | AbortController 管理请求生命周期 |
---
## 附录 ADify System Prompt 汇总
### A.1 自动补齐
```
你是一个IT服务坐席输入助手。根据坐席当前正在输入的内容和对话上下文,补齐下一句话。
要求:
1. 补齐内容自然衔接当前文字,不要重复已有内容
2. 长度控制在1-2个短句,不超过80字
3. 语气专业、简洁,符合IT服务规范
4. 只返回补齐的文字,不要加引号或其他标记
```
### A.2 语气调整
```
你是一个IT服务话术改写助手。将坐席选中的文字改写为{tone}风格。
语气定义:
- 专业:使用准确的技术术语,结构化表达,去除口语化内容
- 友好:适当增加问候和关心语,语气更亲和
- 简洁:去除冗余,直奔主题,控制字数
要求:
1. 保持原意不变,只调整语气和表达方式
2. 改写后的文字长度与原文相近(±30%)
3. 符合IT服务坐席的专业规范
4. 只返回改写后的文字,不要加引号或解释
```
### A.3 文字润色
```
你是一个IT服务文字精修助手。对坐席输入的文字进行{action}处理。
操作定义:
- 扩写:在原文基础上增加操作步骤、注意事项、解释说明,使回复更完整
- 压缩:精简表达,去除重复和冗余,保留核心信息,控制字数
- 纠错:检查并修正错别字、语法错误、标点符号、格式问题
要求:
1. 保持原意不变
2. 只返回处理后的文字,不要加引号或解释
```
### A.4 智能改写
```
你是一个IT服务智能回复助手。基于对话上下文和知识库,为坐席生成3个不同风格的备选回复。
版本要求:
- 版本1:简洁直接,一句话说明问题和解决方案
- 版本2:详细带步骤,包含操作步骤和注意事项
- 版本3:带知识库引用,引用相关文档并给出权威解决方案
要求:
1. 3个版本内容不重复,各有侧重
2. 每个版本独立成段,用---分隔
3. 只返回回复内容,不要加额外解释
```
@@ -0,0 +1,104 @@
# PRD - 坐席代答
> **REQ编号**: REQ-坐席-003
> **版本**: v1.0
> **优先级**: P2
> **阶段**: 中期(3-4个月)
> **作者**: 宋献
> **日期**: 2026-07-19
---
## 一、问题陈述
**用户问题**
- AI 推荐答案后,坐席需要复制粘贴再发送
- 操作繁琐,效率不高
- 坐席倾向于直接自己写答案,不使用 AI 推荐
**业务目标**
- 坐席可一键采用 AI 推荐的答案
- 减少操作步骤,提升坐席效率
- 提升 AI 推荐的使用率
---
## 二、需求范围
### 2.1 核心功能
| 功能 | 描述 |
|------|------|
| 一键采纳 | 坐席点击按钮直接发送 AI 推荐答案 |
| 编辑后发送 | 坐席可修改答案后再发送 |
| 推荐历史 | 坐席可查看历史推荐记录 |
### 2.2 非目标
- 不支持自动发送(必须人工确认)
- 不支持批量采纳
---
## 三、用户故事
| 角色 | 用户故事 | 验收标准 |
|------|----------|---------|
| 坐席 | 我可以一键发送 AI 推荐答案 | 点击"采用"按钮,答案直接发送到会话 |
| 坐席 | 我可以修改答案后再发送 | 点击"编辑",修改后点击发送 |
| 坐席 | 我可以查看历史推荐 | 点击"历史"可查看推荐记录 |
---
## 四、功能详情
### 4.1 AI 推荐面板
**位置**:右侧 AI 助手面板
**内容**
- AI 生成的推荐答案(文字)
- 关联的知识库来源(可点击查看)
- 操作按钮:「采用」「编辑」「取消」
### 4.2 一键采纳
**流程**
1. 坐席查看 AI 推荐答案
2. 点击「采用」按钮
3. 答案直接发送到会话
4. 会话标记为"已解决"
### 4.3 编辑后发送
**流程**
1. 坐席点击「编辑」按钮
2. 进入编辑模式,可修改答案内容
3. 点击「发送」按钮
4. 答案发送到会话
---
## 五、指标设计
| 指标 | 目标 | 测量方式 |
|------|------|---------|
| 采纳率 | ≥ 60% | 采纳推荐/总会话数 |
| 平均处理时间 | ≤ 3min | 采纳推荐的会话平均处理时间 |
| 坐席效率提升 | ≥ 30% | 使用代答 vs 手动输入的时间对比 |
---
## 六、技术依赖
| 依赖项 | 说明 |
|--------|------|
| AI 推荐服务 | REQ-AI-001 复杂场景路由 |
| 消息发送 | 现有消息发送能力 |
---
## 七、关联文档
- 前置需求: REQ-AI-001 复杂场景与统一路由
- 后续需求: 无
@@ -0,0 +1,53 @@
# PRD - 任务详情视图切换
> **REQ编号**: REQ-坐席-004
> **版本**: v1.0
> **优先级**: P0
> **日期**: 2026-06-06
---
## 一、需求概述
点击左栏待办事项条目时,中间栏从聊天视图切换为任务详情视图。返回时切换回聊天视图。
---
## 二、需求详情
### 2.1 触发条件
点击左栏待办事项条目 → 中间栏从聊天视图切换为任务详情视图
### 2.2 返回交互
"← 返回会话" 按钮,切换回聊天视图
### 2.3 任务类型
| 类型 | 页面内容 |
|------|---------|
| 运维工单 | 工单描述卡片 + 处理进度卡片 + 操作按钮 |
| 审批单 | 审批内容卡片 + 审批意见输入区 + 操作按钮 |
| 设备异常 | 设备状态网格 + 处理记录卡片 + 操作按钮 |
### 2.4 操作按钮
| 任务类型 | 操作按钮 |
|----------|---------|
| 工单 | 接单、开始处理、结单、转派 |
| 审批 | 审批通过、拒绝审批、转交审批 |
| 设备 | 一键开单、派工、标记恢复、加入巡检 |
---
## 三、UI 规格
- 卡片样式:白底 + border + radius-lg(10px) + padding 14px 16px
- 状态色值:正常=success绿、告警=warning黄、异常=danger红
---
## 四、关联文档
- 前端组件: `TaskDetailView.vue`
@@ -0,0 +1,44 @@
# PRD - 快捷键系统
> **REQ编号**: REQ-坐席-005
> **版本**: v1.0
> **优先级**: P1
> **日期**: 2026-06-06
---
## 一、需求概述
全局快捷键注册与统一管理,提升坐席操作效率。
---
## 二、需求详情
### 2.1 快捷键列表
| 快捷键 | 功能 | 生效条件 |
|--------|------|---------|
| Ctrl+1/2/3 | AI 推荐填入 | 坐席未回复时 |
| Alt+1~5 | 快速回复分类切换 | 任意 |
| ↑↓ | 快速回复条目导航 | 任意 |
| Enter | 确认填入 | 任意 |
| / | 聚焦快速回复搜索框 | 任意 |
### 2.2 约束条件
- 快捷键仅在未聚焦输入框时生效(避免与打字冲突)
- 需提供快捷键提示 UI(右栏底部键盘指南)
---
## 三、技术实现
- 新增 `composables/useKeyboardShortcuts.ts`
- 右栏底部常驻键盘指南:`Alt+1-5 切换` / `↑↓ 选择` / `Enter 填入` / `/ 搜索`
---
## 四、关联文档
- 前端组件: `useKeyboardShortcuts.ts`
@@ -0,0 +1,51 @@
# PRD - IT 等级系统
> **REQ编号**: REQ-坐席-006
> **版本**: v1.0
> **优先级**: P0
> **日期**: 2026-06-06
---
## 一、需求概述
建立用户 IT 技能等级体系,坐席可查看用户 IT 等级,并支持手动调整。
---
## 二、需求详情
### 2.1 IT 等级定义(7级)
| 等级 | 标识 | 配色 | 说明 |
|------|------|------|------|
| 青铜 | 🛡️ Lv.1 | 棕色渐变 | 基础操作需指导 |
| 白银 | 🛡️ Lv.2 | 灰色渐变 | 能完成常规操作 |
| 黄金 | 🥇 Lv.3 | 金色渐变 | 熟练使用,高级操作需指导 |
| 铂金 | 💎 Lv.4 | 青蓝渐变 | 独立解决大部分问题 |
| 钻石 | 💎 Lv.5 | 靛蓝渐变 | 高级排障能力 |
| 星耀 | ⭐ Lv.6 | 粉紫渐变 | 技术专家级 |
| 王者 | 👑 Lv.7 | 橙红渐变 + 发光动画 | IT 管理员级 |
### 2.2 数据模型扩展
**Employee 模型新增字段**
- `it_level`: IT 等级枚举(bronze/silver/gold/platinum/diamond/star/king
- `it_level_source`: 等级来源(system/manual
- `notes`: 备注 JSON
**Conversation 模型新增字段**
- `impact_scope`: 影响范围(整数)
- `is_blocking`: 阻断性标记
- `emotion_state`: 情绪状态
### 2.3 手动调整交互
坐席可在用户信息详情卡片中手动调整用户 IT 等级,调整后记录 `it_level_source=manual`
---
## 三、关联文档
- 前端组件: `ItLevelBadge.vue`, `UserInfoBar.vue`
- 后端模型: `models/employee.py`, `models/conversation.py`
@@ -0,0 +1,423 @@
# PRD - 分诊排查系统(排查流程图管理)
> **REQ编号**: REQ-坐席-007
> **版本**: v1.3(存量文件名暂保留 v1.0,避免破坏既有引用)
> **日期**: 2026-07-28
> **状态**: [待评审]
> **作者**: 宋献
> **优先级**: P0
> **需求类型**: 存量功能补充与生产化改造
> **关联文档**:
> - 原型: `原型-REQ-坐席-007-分诊排查系统-v1.0.html`
> - 技术方案: `../../../02-技术文档/技术架构/技术方案-REQ-坐席-007-分诊排查系统-v1.0.md`
> - 功能测试: `../../../03-测试文档/03-功能测试用例/TC-坐席-007-分诊排查系统.md`(v1.0,54 条用例,评审通过后补齐)
---
## 一、需求背景
### 1.1 业务问题
常见 IT 故障依赖坐席个人经验排查,容易出现步骤遗漏、处理口径不一致、新坐席上手慢,以及员工转人工后重复描述和重复排查等问题。当前仓库已经存在“排查流程图管理”页面、坐席排查步骤组件和员工 H5 自助排查页面,但数据契约、持久化、发布机制和跨端执行闭环尚未完成。
### 1.2 产品定位
“排查流程图管理”是**分诊排查系统的管理入口**,负责将高频故障的标准排查方法沉淀为可发布、可执行、可追踪的模板。产品统一名称建议为“分诊排查管理”,流程图是模板内容的一种表达方式,不是独立绘图工具。
完整链路:
`管理员编制模板 → 校验与发布 → 坐席/员工选择或被推荐模板 → 按节点执行 → 记录路径与结果 → 未解决时携带上下文转人工 → 运营数据回流优化模板`
### 1.3 目标用户
| 角色 | 主要诉求 |
|---|---|
| IT 支持组长/管理员 | 统一维护排查标准,控制版本和发布范围,查看使用效果 |
| 呼叫坐席 | 快速选择模板、按步骤推进,避免遗漏,完整记录排查过程 |
| 员工 | 对常见问题进行低门槛自助排查,未解决时无损转人工 |
| 运营/质量管理员 | 分析模板采用率、解决率、断点和过期内容 |
---
## 二、目标与成功标准
### 2.1 用户目标
1. 管理员能在一个入口完成模板创建、校验、预览、发布、停用和版本回滚。
2. 坐席能在会话中 10 秒内定位并启动合适模板,执行状态与会话绑定。
3. 员工能按清晰步骤自助排查,转人工时自动携带已完成路径。
4. 管理者可追踪模板是否有效,而不是只统计“建了多少模板”。
### 2.2 业务目标
| 指标 | 口径 | 上线后 30 天成功阈值 | 远期目标 |
|---|---|---:|---:|
| 模板采用率 | 启动模板的适用会话数 / 适用会话总数 | ≥40% | ≥65% |
| 模板完成率 | 到达结束节点的执行实例 / 启动实例 | ≥70% | ≥85% |
| 模板辅助解决率 | 标记“已解决”的完成实例 / 完成实例 | ≥45% | ≥60% |
| 员工自助解决率 | 无需转人工的自助实例 / 自助启动实例 | ≥20% | ≥35% |
| 重复排查率 | 转人工后重复执行已完成节点的实例 / 转人工实例 | ≤10% | ≤5% |
| 模板执行异常率 | 死路、无后继、同步失败实例 / 启动实例 | ≤2% | ≤1% |
> 当前无可信基线,目标值为首版假设;上线前需通过埋点获取 2 周基线并评审调整。
---
## 三、范围
### 3.1 P0:生产可用最小闭环
1. **模板列表**:按名称、分类、状态搜索;显示版本、节点数、预估时间、最后发布人和更新时间。
2. **模板编辑**:维护基本信息和节点树;支持步骤节点、判断节点、结束节点及是/否分支。
3. **结构校验**:保存前检查根节点、节点 ID 唯一性、分支完整性、不可达节点、循环和结束节点。
4. **草稿与发布**:编辑只产生草稿;发布后生成不可变版本;已发布版本供执行端使用。
5. **版本管理**:查看历史版本、版本差异、回滚为新草稿;禁止直接覆盖历史版本。
6. **启用与停用**:停用后不可新启动,但已有执行实例可继续完成。
7. **权限与审计**:管理员可维护;坐席和员工只读已发布模板;记录创建、修改、发布、停用、回滚和删除操作。
8. **坐席执行**:模板启动、节点推进、分支选择、暂停/恢复、结束;执行状态绑定会话并持久化。
9. **员工自助**:查看可用模板、按节点执行、反馈是否解决;转人工时传递模板版本、已完成节点、选择路径和备注。
10. **导入导出**:使用统一 JSON Schema;单条导入、单条导出、全部导出;导入先校验再进入草稿。
11. **问诊模板**:通过 `purpose` 字段统一管理,单实例(`code=triage_intake`),由路由层兜底自动启动;实例来源 `triage_fallback`,不计入模板完成率和模板辅助解决率;问诊结束自动回到标准模板选择界面(路由 + 手动双轨),详见 §5.4.4 和 §5.4.8。
12. **purpose 字段管理**:所有模板主记录新增 `purpose` 字段,值域 `troubleshooting` / `triage`,新建时强制选择且事后不允许切换;编辑器、列表、API 全部按 `purpose` 区分校验规则集、列表 Tab 和筛选条件,详见 §5.4.8。
### 3.2 P1:体验与运营增强
- AI 根据会话意图推荐模板,默认模板直接启动,详见 §5.4。
- 节点绑定知识库文章、下载文件、检测接口或审批入口。
- 模板灰度发布、适用部门/角色范围和定时生效。
- 使用漏斗、节点退出率、版本效果对比和过期提醒。
- 可视化拖拽编辑器;首版仍可保留 JSON 高级编辑能力。
### 3.3 P2:远期能力
- 节点触发自动化修复动作并支持回滚。
- 基于历史成功路径自动推荐模板优化方案。
- 模板跨租户/跨组织共享与模板市场。
### 3.4 非目标(Non-goals
1. **不建设通用 BPM 工作流引擎**:首版只服务 IT 故障排查,不承载审批和复杂业务编排。
2. **不直接自动执行高风险命令**:涉及终端变更、账号权限和安全策略的动作另行走自动化审批与审计。
3. **不替代知识库**:模板负责顺序和决策,详细知识内容通过引用关联。
4. **不让 AI 自动发布模板**:AI 可建议,发布责任仍由具备权限的管理员承担。
5. **不追求自由画布制图**:首版优先保证结构化、可校验、可执行,而不是视觉排版自由度。
---
## 四、用户故事
### 4.1 管理员
- 作为 IT 支持组长,我希望把 VPN、邮箱、账号等故障沉淀为标准模板,以便不同坐席按同一口径处理。
- 作为模板管理员,我希望发布前获得结构校验结果,以便避免断路、死循环和缺失分支进入生产。
- 作为模板管理员,我希望修改已发布模板时产生新版本,以便进行追溯、回滚和效果比较。
- 作为质量管理员,我希望查看模板采用率、完成率和节点退出率,以便持续优化无效步骤。
### 4.2 呼叫坐席
- 作为呼叫坐席,我希望根据员工问题快速搜索并启动模板,以便减少判断成本。
- 作为呼叫坐席,我希望看到当前步骤、已完成步骤和后续分支,以便清楚掌握排查进度。
- 作为呼叫坐席,我希望暂停后恢复同一执行实例,以便处理长耗时或跨班次问题。
### 4.3 员工
- 作为员工,我希望按照易懂的步骤完成自助排查,以便常见问题无需等待坐席。
- 作为员工,我希望在未解决时一键转人工,并自动携带已做过的操作,以便不重复描述和排查。
- 作为员工,我希望明确知道某一步是否会修改设备或账号,以便在执行前作出知情选择。
---
## 五、核心业务流程
### 5.1 模板生命周期
1. 管理员新建或导入模板,系统创建草稿。
2. 编辑基本信息、节点、分支、说明和风险提示。
3. 系统执行语法校验和结构校验;失败时禁止发布。
4. 管理员预览坐席端和员工端呈现效果。
5. 管理员填写版本说明并发布,生成版本快照。
6. 执行端只读取已发布且启用的版本。
7. 后续修改从已发布版本复制为新草稿;重新发布产生新版本。
8. 停用模板后禁止新启动;历史实例仍关联原版本快照。
### 5.2 坐席协同执行
模板选择采用 **“统一路由推荐 + 坐席手动覆盖”** 的双轨策略,详细策略见 §5.4。
1. 路由层根据员工描述、会话上下文、历史成功率推荐默认模板和候选模板。
2. 路由推荐结果**直接启动**默认模板的执行实例;坐席无需点击二次确认弹窗,会话区立即出现排查步骤栏。
3. 启动实例同时记录 `conversation_id``employee_id``agent_id``template_version_id``source``ai_recommend` / `agent`)和起始时间。
4. 坐席可以随时通过排查栏顶部的 “换其他模板” 入口手动覆盖;手动切换会触发确认提示,详见 §5.4。
5. 坐席完成步骤或选择判断分支,后端计算下一个合法节点并实时同步给会话双方。
6. 任一方断线重连时,从服务端恢复执行状态。
7. 结束时记录“已解决/未解决/转其他流程”、耗时、备注和终止原因。
### 5.3 员工自助转人工
1. 员工从 H5 选择已发布模板并开始自助排查。
2. 系统逐步展示节点,不暴露不相关分支和内部运维信息。
3. 员工选择“未解决/需要人工”时,创建或关联会话。
4. 坐席接入后可查看执行摘要,并从当前节点继续或重新选择模板。
---
### 5.4 模板选择策略 · 路由推荐与手动覆盖
#### 5.4.1 策略总览
坐席端采用 **“统一路由推荐 + 手动覆盖”** 的双轨结构:
- 路由层负责默认选择,坐席负责最终决定。
- 路由层不直接修改会话状态,只产生推荐结果;模板启动命令由坐席端发起或由路由层在坐席无操作时自动触发。
- 手动覆盖始终是合法路径,不被路由层阻断。
- **问诊模板结束时自动回到标准模板选择界面(路由 + 手动双轨)**,由服务端在执行 `triage_fallback` 实例的结束命令时复位路由状态,确保问诊不会把会话卡在兜底分支。
#### 5.4.2 路由层职责
| 输入 | 输出 |
|---|---|
| 员工首句描述、会话上下文、历史类似会话、AI 意图识别结果 | 默认模板 + 2~3 个候选模板 + 每个候选的推荐理由与置信度 |
| 员工补充信息或会话状态变化 | 重新计算候选并刷新推荐 |
| 路由无任何匹配 | 触发兜底 B:自动推荐“问诊对话模板” |
#### 5.4.3 启动方式:直接启动
1. 路由层给出推荐后,**默认模板直接启动**,不弹出“是否确认启动”的二次确认弹窗。
2. 排查栏立即出现,展示模板名称、版本、当前节点和“换其他模板”入口。
3. 若坐席在 3 秒内点击 “换其他模板”,系统视为“立即覆盖”,仍视为手动选择。
4. 自动启动的实例来源记为 `ai_recommend`,用于统计路由采纳率。
#### 5.4.4 路由失败的兜底 B:问诊对话模板
1. 当路由无匹配时,由后端创建一个特殊模板 `code=triage_intake`,版本独立维护。
2. 模板内容是一组短问题与是/否分支,例如:“故障现象是什么 / 是否仅发生在公司网络 / 是否影响其他同事 / 是否重启过终端 / 是否变更过密码”,目的不是排查根因,而是帮助坐席快速收敛问题方向。
3. 问诊对话模板走与正式模板一致的会话级实例、版本快照、同步和结束流程,不在 UI 上做特殊样式,仅在排查栏顶部显示 “问诊中” 标识。
4. 问诊模板结束或任一节点触发跳转时,自动回到标准模板选择界面(路由推荐 + 手动覆盖双轨)。
5. 问诊模板本身不计入“模板完成率”和“模板辅助解决率”,仅作为路由层的服务能力。
6. 问诊模板在数据库层面通过 `purpose=triage` 标识,与排查共享同一套 Schema、版本、执行实例、状态机和权限机制;不允许独立建表或独立 API。
7. 同一会话中 `source=triage_fallback` 的活跃实例与普通实例遵循同样的“同会话单活跃实例”约束,问诊实例被取消后下一次路由仍可能再次触发问诊,直到员工描述落入某个排查的覆盖范围。
#### 5.4.5 手动切换模板:必须提示并结束当前实例
1. 坐席点击 “换其他模板” 时,前端必须弹出确认提示:“切换模板将结束当前排查实例并开始新的实例,是否继续?”。
2. 确认后:
- 当前实例状态置为 `cancelled`,写入 `reason="agent_switched_template"`
- 新模板创建新执行实例;旧实例的路径摘要保留在会话归档中,但不计入当前活跃会话的执行进度。
3. 取消则保留当前实例继续推进。
4. **同一会话同一时刻只允许一个活跃实例**,避免多实例污染指标;该约束由后端在创建实例时校验,同一会话存在 `running` 实例时拒绝新的 `ai_recommend` 自动启动和 `agent` 手动启动。
#### 5.4.6 关键交互规则
| 场景 | 行为 |
|---|---|
| 路由高置信度推荐 | 直接启动默认模板 |
| 路由低置信度推荐 | 直接启动默认模板 + 在排查栏顶部显示“推荐置信度低,是否换其他模板?”提示 |
| 路由无匹配 | 自动启动问诊对话模板 |
| 坐席无操作 | 保持自动启动的实例继续推进 |
| 坐席主动换模板 | 弹出确认 → 旧实例 cancelled → 启动新实例 |
| 跨模板跳转(如 VPN → 网络) | 仅由坐席主动发起,不允许路由自动跨模板跳转 |
#### 5.4.7 与现有文档的差异说明
- 原 §3.2 P1 中描述 “AI 根据会话意图推荐模板,坐席确认后启动” 的措辞在本版被替换为 **“直接启动 + 手动覆盖”**;产品决策点已与本节对齐。
- 原 §5.2 第 1 步 “坐席在会话中搜索模板或接受 AI 推荐” 调整为 “路由直接启动 + 手动覆盖”,更准确反映实际操作。
- 原 §3.1 P0 #8 “坐席执行” 仍保留作为后端能力,但 UI 入口与交互由本节定义。
---
#### 5.4.8 与排查流程图的一体两面关系
排查流程(`purpose=troubleshooting`)和问诊对话(`purpose=triage`)**不是两个独立系统**,而是同一个模板体系的两个面向:
| 维度 | 排查流程 | 问诊对话 |
|---|---|---|
| 主导角色 | 坐席 | 员工 |
| 目标 | 标准化排查根因 | 收敛故障方向为下一轮排查铺路 |
| 节点粒度 | 中长流程,可能含终端检测、权限变更 | 短问题与是/否分支,≤10 节点 |
| 实例来源 | `ai_recommend` / `agent` | `triage_fallback` |
| 指标归属 | 计入完成率、辅助解决率 | 仅计转化率 |
| 结束行为 | 已解决/未解决/转其他流程 | 自动回到标准模板选择(路由 + 手动双轨) |
**统一规则**
1. 数据模型同一份:模板主表、`nodes[]`、版本、执行实例、状态机、WebSocket 同步、权限矩阵全部复用。
2. 唯一区分字段是 `purpose`,值域 `troubleshooting`(默认)/ `triage`
3. 新建模板时强制选择 `purpose`;模板创建后 `purpose` 字段不可修改,只能通过“基于此模板复制为另一种用途”的方式新建。
4. **UI 必须分而不拆**:管理后台列表按 `purpose` 分 TabTroubleshooting / Triage),编辑器顶部加“模板用途”选项,详见 §7.1。
5. **校验规则必须按 purpose 区分**:结构校验入口加载对应 `purpose` 的规则集,`triage` 模板限制节点数 ≤10、深度 ≤4、强制包含结束节点且 `result=redirect`、标签必须包含 `triage``intake`,详见 §6.1 FR-04。
6. **指标必须显式隔离**:指标服务层硬编码 `source=triage_fallback` 的实例不计入 `troubleshooting_*` 指标,仅计入 `troubleshooting_template_start_total{source=triage_conversion}` 转化率指标,详见 §11。
7. **权限必须显式隔离**`triage` 模板对员工**始终可见**,对坐席**始终只读**,不参与排查的部门/角色过滤矩阵,详见 §8.2。
8. **禁止新增独立页面或独立 API**`triage` 模板的入口、编辑、列表、导入导出全部复用现有分诊排查管理页面,不引入“问诊模板管理”新页面。
**一体两面的承诺**:用户在任一端(员工或坐席)看到的模板都来自同一份后端真相,只是 `purpose` 不同;运营和开发只需要维护一套系统。
---
## 六、功能需求与验收标准
### 6.1 模板管理
| 编号 | 需求 | 优先级 | 验收标准摘要 |
|---|---|---|---|
| FR-01 | 列表搜索、分类和状态过滤 | P0 | 组合筛选结果准确,空状态有引导 |
| FR-02 | 新建、复制、编辑、预览、归档 | P0 | 操作有权限校验和审计记录 |
| FR-03 | 节点树编辑和 JSON 高级编辑 | P0 | 两种模式使用同一 Schema,切换不丢数据 |
| FR-04 | 结构校验 | P0 | 无根、重复 ID、缺失分支、循环、无结束节点禁止发布;按 `purpose` 加载校验规则集:`troubleshooting` 节点数 ≤100、深度 ≤20`triage` 节点数 ≤10、深度 ≤4、必须包含结束节点且 `result=redirect`、标签必须含 `triage``intake` |
| FR-05 | 草稿、发布、停用、回滚 | P0 | 历史版本不可变,回滚产生新草稿 |
| FR-06 | 导入导出 | P0 | 导入错误定位到字段/节点;不得静默丢字段;导入时若未声明 `purpose` 默认 `troubleshooting` |
| FR-07 | 操作审计 | P0 | 可按模板、操作者、动作和日期查询;`purpose` 变更(仅允许在复制新建场景发生)需写入审计 |
| FR-13 | purpose 字段 | P0 | 模板主记录必含 `purpose` 字段;新建强制选择,事后不可修改;列表筛选和列表 Tab 按 `purpose` 区分 |
| FR-14 | 问诊模板统一管理 | P0 | 问诊模板与排查共用同一管理入口、编辑器、版本、审计和 API;不在前端新增独立页面,在后端不新增独立表 |
### 6.2 执行端
| 编号 | 需求 | 优先级 | 验收标准摘要 |
|---|---|---|---|
| FR-08 | 创建会话级执行实例 | P0 | 同一会话可暂停恢复;状态持久化 |
| FR-09 | 服务端推进节点 | P0 | 客户端不能跳至非法节点;分支结果可追溯 |
| FR-10 | 坐席/员工实时同步 | P0 | 正常网络下状态同步 P95 ≤2 秒;重连后恢复一致 |
| FR-11 | 自助转人工上下文 | P0 | 坐席能看到模板版本、路径、已完成步骤和员工备注 |
| FR-12 | 结束结果采集 | P0 | 必填解决结果;异常退出可标记原因 |
### 6.3 Given / When / Then
1. **发布校验**
Given 草稿存在缺失的“否”分支,When 管理员点击发布,Then 系统拒绝发布并定位问题节点。
2. **版本隔离**
Given 会话正在执行 v1.0When 管理员发布 v1.1Then该会话继续执行 v1.0,新会话使用 v1.1。
3. **停用行为**
Given 模板已停用,When 新用户搜索模板,Then 不返回该模板;已有执行实例仍可完成。
4. **跨端同步**
Given 坐席和员工处于同一执行实例,When 坐席完成节点,Then员工端在 2 秒内显示相同进度。
5. **断线恢复**
Given 客户端在第 4 个节点断线,When 重新连接,Then从服务端恢复至第 4 个节点且历史路径完整。
6. **转人工**
Given 员工已完成 3 个节点仍未解决,When 点击转人工,Then坐席收到执行摘要且不要求员工重复已完成步骤。
7. **越权保护**
Given 普通坐席登录,When 调用创建或发布接口,Then返回 403 并写入安全日志。
8. **并发保护**
Given 两名管理员同时编辑同一草稿,When后保存者提交旧版本号,Then返回 409 并提示刷新或比较差异。
9. **purpose 不可修改**
Given 已存在模板 `purpose=troubleshooting`When 尝试修改 `purpose` 字段为 `triage`,Then 返回 400 并提示“请基于此模板复制为另一种用途”。
10. **问诊模板独立指标**
Given 路由层无匹配自动启动 `triage_intake` 实例,When 实例结束,Then 该实例不计入 `troubleshooting_template_complete_total``troubleshooting_template_resolved_total`,仅计入 `troubleshooting_template_start_total{source=triage_conversion}`
11. **问诊结束回到标准模板选择**
Given 问诊实例正常结束,When 坐席回到会话区,Then 排查栏消失并恢复路由 + 手动双轨状态,可由路由层再次推荐新模板。
12. **Triage 模板可见性**
Given 员工登录 H5When 查询可用模板列表,Then `purpose=triage` 的模板始终可见,不受部门或角色可见范围限制。
---
## 七、信息架构与页面
### 7.1 管理后台
- 导航:运营配置 / 分诊排查管理
- 页面:模板列表(**按 `purpose` 分 TabTroubleshooting / Triage**)、模板编辑/预览、版本历史、发布确认、操作审计、使用分析(P1)
- 模板编辑区顶部增加 **“模板用途” 选项**,新建时强制选择 `purpose`,事后不可修改
- 模板编辑区:基本信息(含 `purpose`)、结构化节点树、JSON 高级模式、实时校验(按 `purpose` 加载校验规则集)、双端预览
- 排查 Tab 的表头显示当前选中模板的 `purpose` 标识,避免运营误编辑问诊模板为排查
### 7.2 坐席工作台
- 会话区底部“排查步骤”栏
- 模板选择器、最优路径、完整决策树、当前节点操作、暂停/结束
- 展示与会话关联的执行摘要
- **当执行实例 `source=triage_fallback` 时,排查栏顶部显示 “问诊中” 标识**
- 问诊实例结束时排查栏收起并恢复路由 + 手动双轨状态
### 7.3 员工 H5
- 自助排查入口、分类/搜索、模板详情、逐步执行页、解决结果页、转人工确认页
- 内部敏感说明不向员工显示
- **员工看到的 Triage 模板永远在分类首位(不参与部门/角色可见范围过滤),且作为路由无匹配时的兜底入口**
---
## 八、数据、权限与合规要求
### 8.1 关键数据
- 模板主记录、不可变版本快照、节点定义、执行实例、节点执行日志、发布审计日志。
- 执行日志仅保存排查必要信息,不在模板节点中采集账号密码、验证码、密钥等敏感数据。
- 会话归档时应保留模板版本号和路径摘要,避免模板更新后历史记录失真。
### 8.2 权限矩阵
| 能力 | 管理员/组长 | 呼叫坐席 | 员工 | 审计员 |
|---|:---:|:---:|:---:|:---:|
| 查看已发布排查 | ✓ | ✓ | ✓(员工可见范围) | ✓ |
| 查看已发布问诊模板(`purpose=triage`) | ✓ | ✓(只读) | ✓(**始终可见,不受部门/角色过滤**) | ✓ |
| 创建/编辑草稿 | ✓ | - | - | - |
| 发布/停用/回滚 | ✓ | - | - | - |
| 修改 `purpose` 字段 | -(创建后不可修改) | - | - | - |
| 启动与执行 | 可测试 | ✓ | ✓(triage 永远允许) | - |
| 查看执行明细 | ✓ | 当前会话 | 本人实例 | ✓ |
| 查看审计日志 | ✓ | - | - | ✓ |
### 8.3 合规
- 操作日志和执行日志按照最小必要原则采集,并遵循《个人信息保护法》相关要求。
- 节点若涉及终端检测、截屏、日志采集或权限变更,必须明确告知用途并按既有授权流程执行。
- 导出的模板文件不得包含员工个人数据、真实凭据和生产密钥。
- `purpose=triage` 的模板对员工始终可见,**不得在该模板的可见范围字段中写入敏感过滤条件**(如部门、角色、外部标签),避免出现“兜底模板被过滤掉”的场景。
---
## 九、依赖与约束
1. 依赖管理后台 RBAC、会话服务、WebSocket 单进程消息管理、员工 H5 认证和数据库迁移能力。
2. 当前实现中后端 CRUD 使用进程内 Mock 数据,正式开发必须切换到数据库。
3. 当前管理端列表期望数组,而后端返回 `{items,total}`,需统一接口契约。
4. 当前存在 `flowchart``root_node``path_steps` 多套结构,必须建立唯一 Schema 和迁移规则。
5. 当前 H5 自助页面未正式注册路由,坐席与员工执行状态未形成后端闭环。
---
## 十、风险与对策
| 风险 | 影响 | 对策 |
|---|---|---|
| 模板内容过时 | 引导错误,扩大故障 | 设置负责人、复审周期、过期提醒和一键停用 |
| 流程结构复杂 | 管理成本和执行放弃率上升 | 限制深度,提供结构校验和双端预览 |
| 发布修改影响执行中会话 | 历史路径失真 | 执行实例固定绑定版本快照 |
| 双端状态不一致 | 重复或跳步 | 服务端作为唯一状态源,事件幂等和重连恢复 |
| 模板包含敏感信息 | 合规与安全风险 | 字段审查、导出脱敏、发布权限和审计 |
| 指标驱动坐席机械执行 | 忽略个案 | 允许合规跳出并记录原因,不以完成率单一考核 |
| 路由推荐结果不可信时坐席仍机械跟随 | 排查方向偏差 | 路由低置信度时显示提示;问诊模板兜底;手动覆盖始终开放 |
| 同一会话存在多个并发实例 | 指标污染、转人工摘要混乱 | 服务端约束“同一会话同一时刻仅一个活跃实例”;手动切换前必须确认 |
---
## 十一、开放问题
| 问题 | 责任方 | 是否阻塞 |
|---|---|:---:|
| 模板发布权限是否仅限管理员,还是组长也可发布? | 产品/安全 | 是 |
| 员工可见模板是否按部门、设备或角色过滤? | 产品/运营 | 是 |
| 已发布模板删除采用归档还是硬删除?建议仅归档 | 产品/技术 | 是 |
| 模板节点最大深度和节点数量限制 | 产品/技术 | 否 |
| 执行日志保留期限及会话归档策略 | 安全/合规 | 是 |
| 首批生产模板的负责人和复审周期 | 运营 | 否 |
---
## 十二、上线阶段建议
1. **阶段 A:管理闭环**——数据库持久化、统一 Schema、草稿/发布/版本、RBAC、审计。
2. **阶段 B:坐席闭环**——会话级实例、步骤推进、暂停恢复、结果采集。
3. **阶段 C:员工自助**——H5 路由、双端同步、转人工上下文。
4. **阶段 D:智能与运营**——AI 推荐、效果看板、灰度和内容治理。
上线闸门:P0 验收通过、独立测试用例完备、端到端验证通过、部署与回滚方案齐备后,方可标记为生产可用。
---
## 十三、变更记录
| 日期 | 版本 | 变更内容 | 变更人 | 变更原因 |
|---|---|---|---|---|
| 2026-06-06 | v1.0 | 创建简版 PRD,定义排查步骤栏、数据模型和基础 API | 宋献 | 初始需求 |
| 2026-07-28 | v1.1 | 补充产品定位、角色与场景、完整范围、非目标、指标、权限、验收标准、风险和生产化路线 | Duckula | 文档规范化补充,解决存量文档不完整问题 |
| 2026-07-28 | v1.2 | 新增 §5.4 模板选择策略:明确“统一路由推荐 + 手动覆盖”双轨;路由推荐默认模板直接启动;路由无匹配时兜底使用问诊对话模板;手动切换模板必须确认并结束当前实例;同会话单实例约束由后端校验 | 宋献 + Duckula | 完成坐席端选择策略的产品决策 |
| 2026-07-28 | v1.3 | 升级到“一体两面”模型:所有模板通过 `purpose` 字段(`troubleshooting`/`triage`)统一管理;§3.1 P0 新增 #11 问诊模板与 #12 purpose 字段;新增 §5.4.8 “与排查流程图的一体两面关系”;§6.1 新增 FR-13/FR-14;§6.3 新增验收用例 #9~#12;§7 页面分 Tab + 模板用途选项 + 问诊中标识;§8.2 权限矩阵拆分排查/问诊;§11 指标增加 `triage_conversion`;§13 变更记录追加 | 宋献 + Duckula | 落实用户决策:排查流程与问诊对话是同一系统的两个面向,通过 `purpose` 区分而非分系统 |
@@ -0,0 +1,100 @@
# PRD - AI Wingman(坐席智能辅助)
> **REQ编号**: REQ-坐席-008
> **版本**: v1.0
> **优先级**: P1
> **日期**: 2026-07-19
---
## 一、需求概述
让AI成为坐席的"智能副驾驶"——消灭重复劳动、增强认知能力、守护情绪健康。
---
## 二、三层设计架构
```
┌─────────────────────────────────────────────────────────────────────┐
│ AI Wingman 三层设计架构 │
│ │
│ ┌─────────────────────────────────────────────────────────────────┐│
│ │ 情感层 · 守护情绪健康 ││
│ │ 情绪识别预警 → 安抚话术推荐 → 语气润色 → 正向激励 → 疲劳检测 ││
│ └─────────────────────────────────────────────────────────────────┘│
│ ┌─────────────────────────────────────────────────────────────────┐│
│ │ 认知层 · 消除认知负荷 ││
│ │ 知识推荐 → SOP流程导航 → 相似工单推荐 → 下一步建议 → 客户画像 ││
│ └─────────────────────────────────────────────────────────────────┘│
│ ┌─────────────────────────────────────────────────────────────────┐│
│ │ 效率层 · 消灭重复劳动 ││
│ │ AI草稿回复 → 会话自动摘要 → 智能填单 → 自动标签 → 快捷回复库 ││
│ └─────────────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────────────┘
```
---
## 三、Phase 1 效率层(本期)
### 3.1 AI草稿回复
| 属性 | 值 |
|------|------|
| 触发 | 每条新员工消息 |
| 生成时间 | <3秒 |
| 呈现位置 | 内嵌在对应的员工消息下方 |
| 操作按钮 | [采纳] / [编辑] / [忽略] |
### 3.2 会话自动摘要
| 属性 | 值 |
|------|------|
| 触发 | 会话结单时 |
| 内容 | 问题 / 原因 / 解决方案 |
| 操作 | 坐席可编辑后确认 |
### 3.3 自动标签
| 属性 | 值 |
|------|------|
| 触发 | 会话结单时 |
| 推荐 | AI基于对话内容自动建议标签 |
| 自定义 | 支持添加自定义标签 |
### 3.4 AI建议采纳追踪
记录坐席对AI建议的采纳/编辑/忽略行为,存入数据库用于持续优化。
---
## 四、Phase 2+ 规划
| 阶段 | 功能 | 预计周期 |
|------|------|---------|
| Phase 2 认知层 | 知识推荐 + SOP导航 + 相似工单 + 客户画像 | 3-4周 |
| Phase 3 情感层 | 情绪识别 + 安抚话术 + 语气润色 + 疲劳检测 | 4-6周 |
---
## 五、技术实现
**底层实现**:扩展现有 Dify,新增 `assistant` 类型的 Dify Agent。
| Agent | 用途 | system prompt 侧重 |
|-------|------|-------------------|
| Agent 1 — 员工端 AI | 回答员工问题 | 友好、准确、引导自助 |
| Agent 2 — 坐席端 Wingman | 为坐席生成草稿/摘要/知识 | 专业、结构化、可操作 |
---
## 六、行业基准数据
| 指标 | 数值 | 来源 |
|------|------|------|
| AI草稿回复减少打字量 | 70%-80% | 天润融通、合力亿捷 |
| 自动填单节省时间 | 从1分钟降至10秒 | 天润融通 |
| 知识推荐缩短新人上手 | 50% | 循环智能 |
| 情绪识别预警准确率 | 85%+ | Helpshift |
| 自动摘要节省文书时间 | 70% | 合力亿捷 |
Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

File diff suppressed because one or more lines are too long
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,239 @@
<!DOCTYPE html>
<html lang="zh-CN" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>坐席端 · 版本链可视化</title>
<style>
:root {
--accent: #3b82f6;
--primary: #07C160;
--bg-primary: #f5f6fa;
--bg-secondary: #ffffff;
--bg-tertiary: #eef0f5;
--text-primary: #1a1a2e;
--text-secondary: #555770;
--text-tertiary: #9ca3af;
--border-color: #e5e7eb;
--shadow: 0 1px 4px rgba(0,0,0,0.06);
--radius: 12px;
--color-employee-bg: #3b82f6;
--color-agent-bg: #ffffff;
--color-agent-border: #e5e7eb;
--color-ai-bg: #e8f5e9;
--color-ai-text: #1b5e20;
--color-system-text: #9ca3af;
--color-system-bg: #eef0f5;
--color-warning: #ff9800;
--color-danger: #ef4444;
--color-success: #22c55e;
}
[data-theme="dark"] {
--bg-primary: #0f172a;
--bg-secondary: #1e293b;
--bg-tertiary: #162032;
--text-primary: #e2e8f0;
--text-secondary: #94a3b8;
--border-color: #334155;
--shadow: 0 1px 4px rgba(0,0,0,0.3);
--color-ai-bg: #0d3320;
--color-ai-text: #86efac;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; font-family: -apple-system, sans-serif; background: var(--bg-primary); color: var(--text-primary); }
/* 布局 */
.container { display: flex; height: 100vh; }
/* 左侧边栏 */
.sidebar { width: 260px; background: var(--bg-secondary); border-right: 1px solid var(--border-color); display: flex; flex-direction: column; }
.sidebar-header { padding: 14px 16px; border-bottom: 1px solid var(--border-color); font-weight: 600; font-size: 14px; }
.session-list { flex: 1; overflow-y: auto; }
.session-item { padding: 12px 16px; border-bottom: 1px solid var(--border-color); cursor: pointer; }
.session-item:hover { background: var(--bg-tertiary); }
.session-item.active { background: var(--color-ai-bg); border-left: 3px solid var(--primary); }
.session-item .title { font-size: 13px; font-weight: 500; margin-bottom: 4px; }
.session-item .meta { display: flex; justify-content: space-between; font-size: 11px; color: var(--text-secondary); }
.session-item .status { padding: 2px 6px; border-radius: 8px; font-size: 10px; }
.status.paused { background: #fff7e6; color: #d46b08; }
.status.corrected { background: #e6f7ff; color: #1890ff; }
/* 主内容区 */
.main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.header { padding: 14px 20px; background: var(--bg-secondary); border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; }
.header h1 { font-size: 16px; font-weight: 600; }
.header .btn { padding: 6px 14px; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-secondary); font-size: 12px; cursor: pointer; }
/* 标签页 */
.tabs { display: flex; background: var(--bg-secondary); border-bottom: 1px solid var(--border-color); }
.tab { padding: 12px 20px; font-size: 13px; color: var(--text-secondary); cursor: pointer; position: relative; }
.tab.active { color: var(--primary); font-weight: 500; }
.tab.active::after { content: ''; position: absolute; bottom: 0; left: 0; right: 0; height: 2px; background: var(--primary); }
/* 内容区 */
.content { flex: 1; display: flex; overflow: hidden; }
/* 聊天面板 */
.chat-panel { flex: 1; border-right: 1px solid var(--border-color); overflow-y: auto; padding: 16px; }
.message { display: flex; gap: 10px; margin-bottom: 14px; max-width: 80%; }
.message.employee { margin-left: auto; flex-direction: row-reverse; }
.message .avatar { width: 28px; height: 28px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 12px; flex-shrink: 0; }
.message.employee .avatar { background: var(--color-employee-bg); color: white; }
.message.ai .avatar { background: var(--accent); color: white; }
.message .bubble { padding: 8px 12px; border-radius: 12px; font-size: 13px; }
.message.employee .bubble { background: var(--color-employee-bg); color: white; }
.message.ai .bubble { background: var(--bg-secondary); border: 1px solid var(--border-color); }
/* 右侧面板 */
.info-panel { width: 340px; background: var(--bg-secondary); overflow-y: auto; }
.panel-section { padding: 14px; border-bottom: 1px solid var(--border-color); }
.panel-section h3 { font-size: 12px; font-weight: 600; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 12px; }
/* 版本时间线 */
.version-timeline { position: relative; padding-left: 16px; }
.version-timeline::before { content: ''; position: absolute; left: 4px; top: 0; bottom: 0; width: 2px; background: var(--border-color); }
.version-item { position: relative; padding: 8px 0 12px; }
.version-item::before { content: ''; position: absolute; left: -14px; top: 10px; width: 8px; height: 8px; border-radius: 50%; background: white; border: 2px solid var(--primary); }
.version-item.current::before { background: var(--primary); }
.version-item .v-header { display: flex; justify-content: space-between; margin-bottom: 4px; }
.version-item .v-tag { font-size: 10px; padding: 2px 6px; border-radius: 4px; font-weight: 500; }
.version-item .v-tag.v3 { background: var(--color-ai-bg); color: var(--color-ai-text); }
.version-item .v-tag.v2 { background: #fff7e6; color: #d46b08; }
.version-item .v-tag.v1 { background: var(--bg-tertiary); color: #999; }
.version-item .v-time { font-size: 11px; color: var(--text-secondary); }
.version-item .v-content { font-size: 13px; }
.version-item .v-change { font-size: 11px; color: var(--color-danger); margin-top: 2px; }
/* Diff代码块 */
.diff-block { background: #1e1e1e; border-radius: 8px; padding: 12px; font-family: 'Consolas', monospace; font-size: 11px; overflow-x: auto; }
.diff-line { display: flex; line-height: 1.6; }
.diff-line .num { width: 24px; color: #666; text-align: right; padding-right: 8px; }
.diff-line .content { color: #d4d4d4; }
.diff-line.added { background: rgba(34,197,94,0.15); }
.diff-line.added .content { color: #4ec9b0; }
.diff-line.removed { background: rgba(239,68,68,0.15); }
.diff-line.removed .content { color: #f14c4c; }
/* 排查步骤 */
.diagnosis-steps { display: flex; flex-wrap: wrap; gap: 8px; }
.step-chip { display: flex; align-items: center; gap: 6px; padding: 6px 10px; background: var(--bg-tertiary); border-radius: 16px; font-size: 12px; cursor: pointer; }
.step-chip.active { background: var(--color-ai-bg); color: var(--color-ai-text); }
.step-chip .num { width: 18px; height: 18px; border-radius: 50%; background: #ddd; display: flex; align-items: center; justify-content: center; font-size: 10px; }
.step-chip.active .num { background: var(--primary); color: white; }
.step-chip.completed .num { background: var(--primary); color: white; }
/* 按钮组 */
.btn-group { display: flex; gap: 8px; margin-top: 12px; }
.btn-group button { flex: 1; padding: 8px; border: 1px solid var(--border-color); background: var(--bg-secondary); border-radius: 6px; font-size: 12px; cursor: pointer; }
.btn-group button:hover { background: var(--bg-tertiary); }
.btn-group .undo { color: var(--color-warning); }
/* 统计 */
.stats-row { display: flex; gap: 10px; }
.stat-card { flex: 1; background: var(--bg-tertiary); border-radius: 8px; padding: 10px; text-align: center; }
.stat-card .val { font-size: 20px; font-weight: 600; color: var(--primary); }
.stat-card .label { font-size: 11px; color: var(--text-secondary); margin-top: 2px; }
</style>
</head>
<body>
<div class="container">
<!-- 左侧会话列表 -->
<div class="sidebar">
<div class="sidebar-header">📋 活跃会话</div>
<div class="session-list">
<div class="session-item">
<div class="title">打印机故障排查</div>
<div class="meta"><span>14:30</span><span class="status paused">已暂停</span></div>
</div>
<div class="session-item active">
<div class="title">VPN连接问题</div>
<div class="meta"><span>14:35</span><span class="status corrected">已更正</span></div>
</div>
<div class="session-item">
<div class="title">账号权限申请</div>
<div class="meta"><span>14:20</span><span class="status">进行中</span></div>
</div>
</div>
</div>
<!-- 主内容区 -->
<div class="main">
<div class="header">
<h1>VPN连接问题 - 会话详情</h1>
<div style="display:flex;gap:8px;">
<button class="btn">📤 转接</button>
<button class="btn">🔒 关闭</button>
</div>
</div>
<div class="tabs">
<div class="tab">对话记录</div>
<div class="tab active">版本链</div>
<div class="tab">排查步骤</div>
<div class="tab">用户信息</div>
</div>
<div class="content">
<!-- 聊天面板 -->
<div class="chat-panel">
<div class="message employee"><div class="avatar">👤</div><div class="bubble">VPN连不上了</div></div>
<div class="message ai"><div class="avatar">🤖</div><div class="bubble">好的,我来帮您排查。请问您是电脑端还是手机端?</div></div>
<div class="message employee"><div class="avatar">👤</div><div class="bubble">电脑端</div></div>
<div class="message employee"><div class="avatar">👤</div><div class="bubble">抱歉,部门写错了,我是财务部的</div></div>
<div class="message ai"><div class="avatar">🤖</div><div class="bubble">好的,已更新为财务部。排查步骤已重新计算。</div></div>
</div>
<!-- 右侧面板 -->
<div class="info-panel">
<div class="panel-section">
<h3>📜 版本链</h3>
<div class="version-timeline">
<div class="version-item current">
<div class="v-header"><span class="v-tag v3">V3</span><span class="v-time">14:35</span></div>
<div class="v-content">部门:财务部</div>
<div class="v-change">← 刚刚更正</div>
</div>
<div class="version-item">
<div class="v-header"><span class="v-tag v2">V2</span><span class="v-time">14:32</span></div>
<div class="v-content">部门:技术部</div>
</div>
<div class="version-item">
<div class="v-header"><span class="v-tag v1">V1</span><span class="v-time">14:30</span></div>
<div class="v-content">初始收集</div>
</div>
</div>
<div class="btn-group">
<button class="undo">↩️ 撤销更改</button>
<button>🔄 恢复此版本</button>
</div>
</div>
<div class="panel-section">
<h3>📊 V2 → V3 Diff</h3>
<div class="diff-block">
<div class="diff-line"><span class="num">1</span><span class="content"> "department": "技术部",</span></div>
<div class="diff-line removed"><span class="num">2</span><span class="content">- "department": "技术部",</span></div>
<div class="diff-line added"><span class="num">2</span><span class="content">+ "department": "财务部",</span></div>
<div class="diff-line"><span class="num">3</span><span class="content"> "device": "电脑端",</span></div>
</div>
</div>
<div class="panel-section">
<h3>🔧 排查步骤</h3>
<div class="diagnosis-steps">
<div class="step-chip completed"><span class="num"></span>确认端类型</div>
<div class="step-chip active"><span class="num">2</span>确认部门</div>
<div class="step-chip"><span class="num">3</span>检测网络权限</div>
<div class="step-chip"><span class="num">4</span>检查客户端</div>
</div>
</div>
<div class="panel-section">
<h3>📈 统计</h3>
<div class="stats-row">
<div class="stat-card"><div class="val">3</div><div class="label">版本数</div></div>
<div class="stat-card"><div class="val">1</div><div class="label">更正次数</div></div>
<div class="stat-card"><div class="val">2</div><div class="label">触发重算</div></div>
</div>
</div>
</div>
</div>
</div>
</div>
</body>
</html>
@@ -0,0 +1,653 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>坐席端 - 群聊参与者展开/缩略双模式原型</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, 'PingFang SC', 'Helvetica Neue', sans-serif;
background: #f0f2f5;
min-height: 100vh;
display: flex;
}
/* === 左侧会话列表(模拟) === */
.sidebar {
width: 280px;
background: #fff;
border-right: 1px solid #e8e8e8;
display: flex;
flex-direction: column;
}
.sidebar-header {
height: 56px;
display: flex;
align-items: center;
padding: 0 20px;
border-bottom: 1px solid #f0f0f0;
font-size: 16px;
font-weight: 600;
color: #333;
}
.conv-item {
padding: 12px 20px;
border-bottom: 1px solid #f5f5f5;
cursor: pointer;
display: flex;
align-items: center;
gap: 12px;
}
.conv-item:hover { background: #f5f5f5; }
.conv-item.active { background: #e6f7ff; border-left: 3px solid #07C160; }
.conv-avatar {
width: 40px; height: 40px; border-radius: 4px;
background: #ccc; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
color: #fff; font-size: 16px;
}
.conv-info { flex: 1; min-width: 0; }
.conv-name { font-size: 14px; color: #333; font-weight: 500; }
.conv-preview { font-size: 12px; color: #999; margin-top: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* === 右侧聊天区域 === */
.chat-area {
flex: 1;
display: flex;
flex-direction: column;
background: #f5f5f5;
min-width: 0;
}
.chat-header {
height: 56px;
background: #fff;
border-bottom: 1px solid #e8e8e8;
display: flex;
align-items: center;
padding: 0 20px;
gap: 12px;
}
.chat-title { font-size: 16px; font-weight: 600; color: #333; }
.chat-subtitle { font-size: 12px; color: #999; }
/* === 参与者缩略条 === */
.participant-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 20px;
background: #fafafa;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
transition: background 0.2s;
}
.participant-bar:hover { background: #f0f0f0; }
.bar-avatars {
display: flex;
align-items: center;
}
/* 头像基础样式 */
.avatar {
width: 20px;
height: 20px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 9px;
font-weight: 600;
color: #fff;
flex-shrink: 0;
border: 1.5px solid transparent;
background-size: cover;
background-position: center;
}
.avatar + .avatar { margin-left: -4px; }
/* 角色边框色 */
.avatar.role-primary { border-color: #3b82f6; }
.avatar.role-collaborator { border-color: #07C160; }
.avatar.role-owner { border-color: #FF9800; }
.avatar.role-invitee { border-color: transparent; }
/* 自己高亮环 */
.avatar.is-self {
box-shadow: 0 0 0 2px #07C160;
transform: scale(1.1);
z-index: 10;
}
/* 超员 +N */
.avatar-more {
width: 20px;
height: 20px;
border-radius: 50%;
background: #e0e0e0;
color: #666;
font-size: 8px;
display: flex;
align-items: center;
justify-content: center;
margin-left: -4px;
border: 1.5px solid #fff;
flex-shrink: 0;
}
/* 待加入红点 */
.avatar-wrap { position: relative; display: inline-flex; }
.pending-dot {
position: absolute;
top: -1px;
right: -1px;
width: 6px;
height: 6px;
border-radius: 50%;
background: #ee0a24;
border: 1px solid #fff;
}
.bar-label {
font-size: 12px;
color: #666;
margin-left: 4px;
display: flex;
align-items: center;
gap: 4px;
}
.bar-expand-icon {
font-size: 10px;
transition: transform 0.2s;
}
.bar-expand-icon.expanded { transform: rotate(180deg); }
/* === 就地展开面板 === */
.expanded-panel {
max-height: 0;
overflow: hidden;
transition: max-height 0.2s ease, opacity 0.2s ease;
opacity: 0;
background: #fff;
border-bottom: 1px solid #e8e8e8;
}
.expanded-panel.show {
max-height: 150px;
overflow-y: auto;
opacity: 1;
}
.expanded-panel-inner {
padding: 8px 20px;
}
/* 参与者列表项 */
.participant-item {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 0;
}
.participant-item:hover { background: #f9f9f9; }
.participant-item .avatar-md {
width: 24px;
height: 24px;
font-size: 11px;
border-width: 1.5px;
}
.participant-info {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
}
.participant-name {
font-size: 13px;
color: #333;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 120px;
}
/* 角色标签 */
.role-tag {
font-size: 10px;
padding: 1px 5px;
border-radius: 3px;
font-weight: 500;
}
.role-tag.primary { background: #3b82f6; color: #fff; }
.role-tag.collaborator { background: #07C160; color: #fff; }
.role-tag.owner { background: #FF9800; color: #fff; }
.role-tag.invitee { background: #f0f0f0; color: #666; }
.participant-dept {
font-size: 11px;
color: #999;
}
/* 移除图标 */
.remove-icon {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
color: #ee0a24;
cursor: pointer;
font-size: 14px;
border-radius: 4px;
opacity: 0;
transition: opacity 0.15s;
}
.participant-item:hover .remove-icon { opacity: 1; }
.remove-icon:hover { background: #fee; }
/* 消息列表区域 */
.message-list {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
min-height: 200px;
}
.message-item {
display: flex;
margin-bottom: 16px;
gap: 10px;
}
.msg-avatar {
width: 32px; height: 32px; border-radius: 4px;
flex-shrink: 0; display: flex; align-items: center; justify-content: center;
font-size: 13px; color: #fff;
}
.msg-content { max-width: 60%; }
.msg-name { font-size: 11px; color: #999; margin-bottom: 2px; }
.msg-bubble {
background: #fff; padding: 8px 12px; border-radius: 0 8px 8px 8px;
font-size: 13px; color: #333; display: inline-block;
}
/* 底部输入区 */
.input-area {
height: 120px;
background: #fff;
border-top: 1px solid #e8e8e8;
padding: 12px 20px;
display: flex;
flex-direction: column;
gap: 8px;
}
.input-toolbar {
display: flex;
gap: 8px;
}
.input-toolbar button {
font-size: 12px;
color: #666;
background: #f5f5f5;
border: none;
border-radius: 4px;
padding: 4px 10px;
cursor: pointer;
}
.input-textbox {
flex: 1;
height: 60px;
border: 1px solid #e0e0e0;
border-radius: 4px;
padding: 8px;
font-size: 13px;
resize: none;
outline: none;
}
.input-textbox:focus { border-color: #07C160; }
/* 控制面板 */
.control-panel {
position: fixed;
top: 10px;
right: 10px;
z-index: 200;
background: rgba(0,0,0,0.8);
border-radius: 8px;
padding: 8px;
display: flex;
flex-direction: column;
gap: 6px;
}
.control-panel button {
font-size: 12px;
color: #fff;
background: #07C160;
border: none;
border-radius: 4px;
padding: 4px 10px;
cursor: pointer;
white-space: nowrap;
}
.control-panel button:hover { opacity: 0.85; }
.control-panel .info {
font-size: 11px;
color: #aaa;
padding: 2px 4px;
}
/* P1: expandedStateMap 按会话ID记忆 */
.panel-section-title {
font-size: 11px;
color: #999;
padding: 4px 0 2px;
font-weight: 500;
}
</style>
</head>
<body>
<div class="control-panel">
<button onclick="toggleExpand()">点击缩略条展开/收起</button>
<button onclick="toggleScene()">切换场景(3人/7人/10人)</button>
<div class="info" id="sceneInfo">场景: 3人</div>
</div>
<!-- 左侧会话列表 -->
<div class="sidebar">
<div class="sidebar-header">会话列表</div>
<div class="conv-item active">
<div class="conv-avatar" style="background:#FF9800"></div>
<div class="conv-info">
<div class="conv-name">李四 · 网络故障</div>
<div class="conv-preview">王五: 我看了一下交换机日志...</div>
</div>
</div>
<div class="conv-item">
<div class="conv-avatar" style="background:#3b82f6"></div>
<div class="conv-info">
<div class="conv-name">陈六 · 电脑蓝屏</div>
<div class="conv-preview">已重启,问题解决</div>
</div>
</div>
<div class="conv-item">
<div class="conv-avatar" style="background:#07C160"></div>
<div class="conv-info">
<div class="conv-name">周九 · 打印机问题</div>
<div class="conv-preview">好的,我来看看</div>
</div>
</div>
</div>
<!-- 右侧聊天区域 -->
<div class="chat-area">
<div class="chat-header">
<span class="chat-title">李四 · 网络故障</span>
<span class="chat-subtitle" id="convStatus">3人参与</span>
</div>
<!-- 参与者缩略条 -->
<div class="participant-bar" onclick="toggleExpand()" id="bar">
<div class="bar-avatars" id="barAvatars"></div>
<span class="bar-label" id="barLabel">
<span id="barText">3位参与者</span>
<span class="bar-expand-icon" id="expandIcon"></span>
</span>
</div>
<!-- 就地展开面板 -->
<div class="expanded-panel" id="panel">
<div class="expanded-panel-inner" id="panelBody"></div>
</div>
<!-- 消息列表 -->
<div class="message-list">
<div class="message-item">
<div class="msg-avatar" style="background:#3b82f6"></div>
<div class="msg-content">
<div class="msg-name">张明(主责坐席)</div>
<div class="msg-bubble">您好,网络问题我来协助处理。已邀请网络组同事加入。</div>
</div>
</div>
<div class="message-item">
<div class="msg-avatar" style="background:#FF9800"></div>
<div class="msg-content">
<div class="msg-name">李四(发起人)</div>
<div class="msg-bubble">好的谢谢,我们网段的电脑都上不了网</div>
</div>
</div>
<div class="message-item">
<div class="msg-avatar" style="background:#07C160"></div>
<div class="msg-content">
<div class="msg-name">王五(被邀请人)</div>
<div class="msg-bubble">交换机端口有异常告警,正在排查中</div>
</div>
</div>
</div>
<!-- 底部输入区 -->
<div class="input-area">
<div class="input-toolbar">
<button>快速回复</button>
<button>转接</button>
<button>邀请</button>
<button>摇人</button>
</div>
<textarea class="input-textbox" placeholder="输入回复内容..."></textarea>
</div>
</div>
<script>
// 角色配置
const ROLE_COLORS = {
primary_agent: '#3b82f6',
collaborator: '#07C160',
owner: '#FF9800',
invitee: 'transparent'
};
const ROLE_LABELS = {
primary_agent: '主责',
collaborator: '协作',
owner: '发起人',
invitee: '被邀请'
};
const ROLE_BG = {
primary_agent: '#3b82f6',
collaborator: '#07C160',
owner: '#FF9800',
invitee: '#bbb'
};
const MAX_AVATARS = 6; // 坐席端超员阈值
const CURRENT_AGENT_ID = '1'; // 当前坐席 = 张明(主责)
// 场景数据
const scenes = [
// 3人场景
[
{ id: '1', name: '张明', avatar: '', dept: 'IT支持组', role: 'primary_agent', joined: true, isSelf: true, joinedAt: '2026-07-10T10:00:00' },
{ id: '2', name: '李四', avatar: '', dept: '财务部', role: 'owner', joined: true, isSelf: false, joinedAt: '2026-07-10T10:01:00' },
{ id: '3', name: '王五', avatar: '', dept: '网络运维组', role: 'invitee', joined: true, isSelf: false, joinedAt: '2026-07-10T10:05:00' }
],
// 7人场景
[
{ id: '1', name: '张明', avatar: '', dept: 'IT支持组', role: 'primary_agent', joined: true, isSelf: true, joinedAt: '2026-07-10T10:00:00' },
{ id: '2', name: '李四', avatar: '', dept: '财务部', role: 'owner', joined: true, isSelf: false, joinedAt: '2026-07-10T10:01:00' },
{ id: '10', name: '陈十', avatar: '', dept: 'IT支持组', role: 'collaborator', joined: true, isSelf: false, joinedAt: '2026-07-10T10:02:00' },
{ id: '3', name: '王五', avatar: '', dept: '网络运维组', role: 'invitee', joined: true, isSelf: false, joinedAt: '2026-07-10T10:05:00' },
{ id: '4', name: '赵六', avatar: '', dept: '系统组', role: 'invitee', joined: true, isSelf: false, joinedAt: '2026-07-10T10:06:00' },
{ id: '5', name: '钱七', avatar: '', dept: '安全组', role: 'invitee', joined: false, isSelf: false, joinedAt: null },
{ id: '6', name: '孙八', avatar: '', dept: '桌面组', role: 'invitee', joined: true, isSelf: false, joinedAt: '2026-07-10T10:08:00' }
],
// 10人场景(超员)
[
{ id: '1', name: '张明', avatar: '', dept: 'IT支持组', role: 'primary_agent', joined: true, isSelf: true, joinedAt: '2026-07-10T10:00:00' },
{ id: '2', name: '李四', avatar: '', dept: '财务部', role: 'owner', joined: true, isSelf: false, joinedAt: '2026-07-10T10:01:00' },
{ id: '10', name: '陈十', avatar: '', dept: 'IT支持组', role: 'collaborator', joined: true, isSelf: false, joinedAt: '2026-07-10T10:02:00' },
{ id: '11', name: '郑十一', avatar: '', dept: '运维组', role: 'collaborator', joined: true, isSelf: false, joinedAt: '2026-07-10T10:03:00' },
{ id: '3', name: '王五', avatar: '', dept: '网络运维组', role: 'invitee', joined: true, isSelf: false, joinedAt: '2026-07-10T10:05:00' },
{ id: '4', name: '赵六', avatar: '', dept: '系统组', role: 'invitee', joined: true, isSelf: false, joinedAt: '2026-07-10T10:06:00' },
{ id: '5', name: '钱七', avatar: '', dept: '安全组', role: 'invitee', joined: false, isSelf: false, joinedAt: null },
{ id: '6', name: '孙八', avatar: '', dept: '桌面组', role: 'invitee', joined: true, isSelf: false, joinedAt: '2026-07-10T10:08:00' },
{ id: '7', name: '周九', avatar: '', dept: '数据库组', role: 'invitee', joined: true, isSelf: false, joinedAt: '2026-07-10T10:09:00' },
{ id: '8', name: '吴十', avatar: '', dept: '中间件组', role: 'invitee', joined: false, isSelf: false, joinedAt: null }
]
];
let currentScene = 0;
let participants = [];
let isExpanded = false;
// P1: expandedStateMap 按会话ID记忆展开状态
const expandedStateMap = {};
function getLastChar(name) {
return name ? name.charAt(name.length - 1) : '?';
}
function sortParticipants(list) {
// 排列:主责 → 发起人 → 协作坐席 → 被邀请人(按joinedAt, 已加入在前)
return [...list].sort((a, b) => {
const order = { primary_agent: 0, owner: 1, collaborator: 2, invitee: 3 };
if (order[a.role] !== order[b.role]) return order[a.role] - order[b.role];
// 已加入在前
if (a.joined !== b.joined) return a.joined ? -1 : 1;
if (a.joinedAt && b.joinedAt) return new Date(a.joinedAt) - new Date(b.joinedAt);
return 0;
});
}
function renderBar() {
const container = document.getElementById('barAvatars');
const text = document.getElementById('barText');
const status = document.getElementById('convStatus');
container.innerHTML = '';
const sorted = sortParticipants(participants);
const showCount = Math.min(sorted.length, MAX_AVATARS);
const hasPending = sorted.some(p => !p.joined);
for (let i = 0; i < showCount; i++) {
const p = sorted[i];
const wrap = document.createElement('div');
wrap.className = 'avatar-wrap';
const av = document.createElement('div');
av.className = `avatar role-${p.role}`;
if (p.isSelf) av.classList.add('is-self');
av.style.background = ROLE_BG[p.role];
av.textContent = getLastChar(p.name);
if (!p.joined) {
const dot = document.createElement('div');
dot.className = 'pending-dot';
wrap.appendChild(dot);
}
wrap.appendChild(av);
container.appendChild(wrap);
}
if (sorted.length > MAX_AVATARS) {
const more = document.createElement('div');
more.className = 'avatar-more';
more.textContent = `+${sorted.length - MAX_AVATARS}`;
container.appendChild(more);
}
text.textContent = `${participants.length}位参与者${hasPending ? ' · 有待加入' : ''}`;
status.textContent = `${participants.length}人参与`;
}
function renderPanel() {
const body = document.getElementById('panelBody');
body.innerHTML = '';
const sorted = sortParticipants(participants);
const isPrimaryAgent = participants.some(p => p.id === CURRENT_AGENT_ID && p.role === 'primary_agent');
sorted.forEach(p => {
const item = document.createElement('div');
item.className = 'participant-item';
const av = document.createElement('div');
av.className = `avatar avatar-md role-${p.role}`;
if (p.isSelf) av.classList.add('is-self');
av.style.background = ROLE_BG[p.role];
av.textContent = getLastChar(p.name);
const info = document.createElement('div');
info.className = 'participant-info';
const name = document.createElement('span');
name.className = 'participant-name';
name.textContent = p.name + (p.isSelf ? '(我)' : '');
name.title = p.name; // P1: tooltip仅截断时显示
info.appendChild(name);
const tag = document.createElement('span');
tag.className = `role-tag ${p.role}`;
tag.textContent = ROLE_LABELS[p.role];
info.appendChild(tag);
const dept = document.createElement('span');
dept.className = 'participant-dept';
dept.textContent = p.dept + (p.joined ? '' : ' · 待加入');
info.appendChild(dept);
item.appendChild(av);
item.appendChild(info);
// P1: 移除图标仅主责坐席 + invitee角色显示
if (isPrimaryAgent && p.role === 'invitee') {
const remove = document.createElement('div');
remove.className = 'remove-icon';
remove.textContent = '×';
remove.title = '移除参与者';
remove.onclick = (e) => {
e.stopPropagation();
if (confirm(`确认移除 ${p.name}`)) {
alert('已移除(模拟)');
}
};
item.appendChild(remove);
}
body.appendChild(item);
});
}
function toggleExpand() {
isExpanded = !isExpanded;
const panel = document.getElementById('panel');
const icon = document.getElementById('expandIcon');
if (isExpanded) {
panel.classList.add('show');
icon.classList.add('expanded');
renderPanel();
// P1: 记忆展开状态
expandedStateMap['conv-1'] = true;
} else {
panel.classList.remove('show');
icon.classList.remove('expanded');
expandedStateMap['conv-1'] = false;
}
}
function toggleScene() {
currentScene = (currentScene + 1) % scenes.length;
participants = JSON.parse(JSON.stringify(scenes[currentScene]));
document.getElementById('sceneInfo').textContent = `场景: ${participants.length}`;
renderBar();
if (isExpanded) renderPanel();
}
// 初始化
participants = JSON.parse(JSON.stringify(scenes[0]));
renderBar();
</script>
</body>
</html>
File diff suppressed because one or more lines are too long