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,334 @@
# 群聊参与者展开/缩略双模式 PRD
> **版本**: v1.0
> **日期**: 2026-07-14
> **作者**: 许清楚(产品经理)
> **状态**: 待评审
> **子系统**: 05-用户端H5
> **模块**: 群聊
---
## 1. 项目信息
| 字段 | 值 |
|------|-----|
| **项目名称** | participant_dual_mode |
| **技术栈** | H5端: Vue3 + Vant4 + TypeScript / 坐席端: Vue3 + Element Plus + TypeScript / 后端: 无需改动 |
| **语言** | 中文 |
| **UI风格** | 企微浅色扁平风格,accent = #07C160 |
### 原始需求复述
IT智能服务台群聊功能中,同一会话最多有4种角色:原始员工(发起人)、主责坐席、协作坐席、被邀请人。当前参与者展示存在两个问题:H5端 `ParticipantList.vue` 平铺完整列表始终占用较大空间,挤压聊天区域;坐席端 `ParticipantBar.vue` 一行横条信息密度低,只能看到名字缩略。本次需求为群聊参与者展示实现**展开/缩略双模式**,让用户在"快速概览谁在群里"和"查看参与者详情"之间切换。
### 已确认设计决策(用户已拍板)
1. H5端展开模式:方案B 底部弹出 — Vant4 `van-popup position="bottom"`
2. 坐席端展开模式:就地向下展开 — 点击 ParticipantBar 横条向下撑开详情面板(~150px)
3. 头像排列顺序:主责坐席第一位,其余按加入时间排列,不分组
4. 退出确认:H5端点击"退出会话"弹 `van-dialog` 二次确认(保留已有实现)
5. 缩略模式角色区分:头像边框色 — 坐席蓝框、发起人橙框、被邀请人无框
6. 缩略模式信息量:头像条显示数字徽标,如"5人在群"
7. 自己的标识:自己头像加高亮环 + 略放大
8. 默认模式:缩略模式为默认,点击展开
9. 超员处理:缩略头像条超过N个显示前几个 + "+剩余人数"文本;展开模式支持滚动
---
## 2. 产品定义
### 2.1 产品目标
**提升信息密度与空间利用率**:将H5端原本平铺占用大量纵向空间的参与者列表改为缩略头像条(一行),释放出的空间归还给聊天区域;将坐席端一行横条改为可展开的详情面板,在需要时才展示完整信息。两端默认均为缩略模式,用户按需展开,兼顾"快速概览"与"查看详情"两种场景。
**统一双端交互心智模型**:H5端和坐席端虽然技术栈不同(Vant4 vs Element Plus),但交互逻辑保持一致——缩略模式看"谁在群里",展开模式看"每个人是谁",降低用户跨端使用的学习成本。
**保持角色辨识度**:通过头像边框色(坐席蓝框、发起人橙框、被邀请人无框)和自己的高亮环,在缩略模式下也能快速辨识参与者的角色身份和自己的位置。
### 2.2 用户故事
| # | 角色 | 用户故事 |
|---|------|---------|
| US-1 | 员工 | **As a** 报修员工, **I want** 在聊天页面看到一行紧凑的参与者头像条而不是占满半屏的列表, **so that** 聊天区域有更多空间显示消息记录,我能看清上下文 |
| US-2 | 员工 | **As a** 被邀请参与会话的员工, **I want** 点击头像条从底部弹出参与者详情面板,看到每个人的姓名、部门和角色, **so that** 我能确认群里都有谁再决定是否发言 |
| US-3 | 员工 | **As a** 被邀请员工, **I want** 在展开的详情面板中点击"退出会话"并经过二次确认后退出, **so that** 我可以安全退出不再相关的会话,且不会误触退出 |
| US-4 | 坐席 | **As a** 主责坐席, **I want** 点击参与者横条就地展开详情面板,看到每个参与者的完整信息(姓名、角色、加入状态), **so that** 我在需要时快速了解协作人员情况,不展开时不占空间 |
| US-5 | 坐席 | **As a** 主责坐席, **I want** 在展开的详情面板中直接操作"邀请"和"移除参与者", **so that** 管理参与者不需要额外跳转到其他页面 |
| US-6 | 坐席/员工 | **As a** 任何参与者, **I want** 缩略模式中通过头像边框颜色快速区分坐席/发起人/被邀请人,并看到自己的头像有高亮标记, **so that** 我一眼就能知道群里的角色构成和自己的位置 |
---
## 3. 需求池(P0/P1/P2
### P0 — 必须完成(核心体验)
#### FE-H5-01 H5端缩略模式头像条
| 项目 | 说明 |
|------|------|
| **需求** | 将 `ParticipantList.vue` 改造为默认显示一行紧凑头像条 |
| **布局** | 横向排列:`[5人在群] [头像1][头像2][头像3]+2` ,高度约 40px |
| **头像排列** | 主责坐席第一位,其余按加入时间顺序排列,不分组 |
| **角色边框** | 坐席蓝框 `#3b82f6`、发起人橙框 `#FF9800`、被邀请人无边框 |
| **自己标识** | 自己头像加高亮环 `#07C160`2px ring+ scale(1.1) 放大 |
| **超员处理** | 缩略头像条最多显示 N 个头像(N=4),超出显示 `+剩余人数` 文本 |
| **数字徽标** | 头像条左侧显示"X人在群"文字徽标 |
| **交互** | 点击头像条任意位置触发展开 |
| **约束** | 缩略模式高度 ≤ 44px,不遮挡聊天消息区 |
#### FE-H5-02 H5端展开模式(底部弹出)
| 项目 | 说明 |
|------|------|
| **需求** | 点击缩略头像条,从底部弹出参与者详情面板 |
| **组件** | Vant4 `van-popup` `position="bottom"` `round` |
| **内容** | 完整参与者列表:头像(32px) + 姓名 + 部门 + 角色徽标(发起人/坐席/已加入/待加入) |
| **排列** | 与缩略模式一致:主责坐席第一位,其余按加入时间排列 |
| **当前用户** | 高亮背景色 + "(我)"文字标签(保留现有实现) |
| **头像降级** | 有avatar URL用img,加载失败显示姓名首字(保留现有逻辑) |
| **退出按钮** | 仅被邀请人可见,点击弹 `showConfirmDialog` 二次确认(保留现有实现) |
| **关闭方式** | 下滑关闭 / 点击遮罩关闭 / 点击右上角关闭按钮 |
| **约束** | 弹出面板高度不超过屏幕 60%,内容区支持纵向滚动;上方聊天记录仍可见 |
#### FE-Agent-01 坐席端缩略模式横条改造
| 项目 | 说明 |
|------|------|
| **需求** | 改造 `ParticipantBar.vue` 缩略模式,增加数字徽标和角色边框 |
| **布局** | 保持现有一行横条结构:`[N人参与:] [头像1][头像2]... + 邀请` |
| **角色边框** | 主责坐席蓝框 `#3b82f6`、协作坐席绿框 `#07C160`、发起人橙框 `#FF9800`、被邀请人无框 |
| **自己标识** | 自己头像加高亮环 + scale(1.1) 放大 |
| **超员处理** | 超过 N 个头像(N=6)只显示前几个 + `+剩余人数` 文本,溢出部分不横向滚动 |
| **数字徽标** | 保留现有"N人参与:"文字,增加总人数计算(主责+协作+被邀请) |
| **交互** | 点击横条主体区域触发展开;"+ 邀请"按钮独立点击不触发展开 |
| **约束** | 缩略模式高度 ≤ 36px |
#### FE-Agent-02 坐席端展开模式(就地展开)
| 项目 | 说明 |
|------|------|
| **需求** | 点击 ParticipantBar 横条,在其原位置向下撑开详情面板 |
| **布局** | flex 垂直布局,展开面板高度 ~150px,消息区被 flex 自动压缩但依然可见可滚动 |
| **内容** | 参与者详情:头像(28px) + 姓名 + 角色标签(主责/协作/发起人) + 加入状态(已加入/待加入) |
| **排列** | 主责坐席第一位,其余按加入时间排列,不分组 |
| **操作按钮** | 主责坐席可见"+ 邀请"按钮和每项的"移除"图标(保留现有功能) |
| **收起** | 面板右上角"收起"按钮 / 再次点击横条折叠回去 |
| **动画** | 展开/收起使用 CSS transitionmax-height / opacity),时长 ≤ 200ms |
| **约束** | 展开面板内容超出时支持纵向滚动;展开时消息区最小高度不低于 200px |
### P1 — 应该完成(体验增强)
#### FE-Common-01 展开状态记忆
| 项目 | 说明 |
|------|------|
| **需求** | 用户在当前会话中展开参与者面板后,切换会话再切回时保持展开/收起状态 |
| **实现** | 状态存储在组件本地 `ref`,按会话 ID 记忆(不持久化到 localStorage |
| **约束** | 页面刷新后恢复默认缩略模式 |
#### FE-H5-03 缩略头像条角标提示
| 项目 | 说明 |
|------|------|
| **需求** | 当有新的待加入参与者时,缩略头像条显示小红点角标 |
| **触发** | participants 列表中存在 `joined === false` 的项时显示 |
| **样式** | 头像条右上角 8px 红点 |
#### FE-Agent-03 展开面板头像悬停 tooltip
| 项目 | 说明 |
|------|------|
| **需求** | 坐席端展开面板中,鼠标悬停头像显示完整姓名和部门 tooltip |
| **组件** | Element Plus `el-tooltip` |
| **约束** | 仅在姓名被截断时显示(内容溢出时) |
### P2 — 可以后续实现(锦上添花)
#### FE-Common-02 参与者加入/退出动画
| 项目 | 说明 |
|------|------|
| **需求** | 参与者加入或退出时,头像条有平滑的增减动画 |
| **实现** | Vue `<TransitionGroup>` + CSS transition |
| **约束** | 不影响功能,动画时长 ≤ 300ms |
#### FE-Common-03 缩略头像条长按预览(H5端)
| 项目 | 说明 |
|------|------|
| **需求** | H5端长按缩略头像条中某个头像,弹出该参与者的简要信息卡片 |
| **组件** | Vant4 `van-popup` 小型弹窗 |
| **约束** | 不与点击展开冲突,长按 ≥ 500ms 触发 |
#### FE-Agent-04 展开面板键盘快捷键
| 项目 | 说明 |
|------|------|
| **需求** | 坐席端支持快捷键切换展开/收起 |
| **快捷键** | `Ctrl+Shift+P`Participant |
| **约束** | 仅在聊天区域聚焦时生效 |
---
## 4. UI 设计稿描述
### 4.1 H5端 — 缩略模式(默认)
```
┌─────────────────────────────────┐
│ [< 返回] IT智能服务台 │ ← 顶部导航栏
├─────────────────────────────────┤
│ │
│ [聊天消息区域 — 释放空间] │
│ │
│ 消息1... │
│ 消息2... │
│ │
├─────────────────────────────────┤
│ 5人在群 (🟦)(🟧)( )( )+1 ▼ │ ← 缩略头像条 (h≈40px)
├─────────────────────────────────┤
│ [输入框] [发送] │ ← 输入区
└─────────────────────────────────┘
缩略头像条细节:
┌──────────────────────────────────┐
│ 5人在群 [A][B][C][D]+1 ▼ │
│ ↑ ↑ ↑ ↑ │
│ 蓝框 橙框 无框 高亮环 │
│ (坐席)(发起人)(被邀请)(自己)│
└──────────────────────────────────┘
- 头像尺寸: 28px 圆形
- 角色边框: 2px solid (蓝#3b82f6/橙#FF9800/无)
- 自己: 额外高亮环 #07C160 + scale(1.1)
- 超员: 最多4个头像 + "+N" 文本
- 箭头: ▼ 表示可展开
```
### 4.2 H5端 — 展开模式(底部弹出)
```
┌─────────────────────────────────┐
│ [< 返回] IT智能服务台 │
├─────────────────────────────────┤
│ │
│ [聊天消息区域 — 仍可见] │ ← 上方聊天记录可见
│ 消息1... │
│ │
├─────────────────────────────────┤
│ 5人在群 [A][B][C][D]+1 ▼ │ ← 缩略条仍显示
╞═════════════════════════════════╡ ← van-popup 分隔
│ 参与者 [×] │ ← 弹出面板头部
│─────────────────────────────────│
│ (🟦) 张三 IT坐席 坐席 │ ← 主责坐席(蓝框)
│ (🟧) 李四 财务部 发起人 │ ← 发起人(橙框)
│ ( ) 王五 市场部 已加入 │ ← 被邀请人
│ ( ) 赵六 人事部 待加入 │ ← 待加入(灰色)
│─────────────────────────────────│
│ [ 退出会话 ] │ ← 仅被邀请人可见
└─────────────────────────────────┘
↑ 下滑关闭 / 点击遮罩关闭
弹出面板细节:
- van-popup position="bottom" round
- 最大高度: 60vh,内容超出纵向滚动
- 头像: 32px 圆形
- 每行: 头像 + 姓名 + 部门 + 角色徽标
- 当前用户: 背景色高亮 + "(我)"标签
- 退出按钮: 红色描边按钮,点击弹 van-dialog 二次确认
```
### 4.3 坐席端 — 缩略模式(默认)
```
┌──────────────────────────────────────────────────────┐
│ UserInfoBar (用户信息栏) │
├──────────────────────────────────────────────────────┤
│ TroubleshootBar (排查步骤栏) │
├──────────────────────────────────────────────────────┤
│ 5人参与: [A][B][C][D][E][F]+1 + 邀请 ▼ │ ← ParticipantBar 缩略 (h≈36px)
├──────────────────────────────────────────────────────┤
│ │
│ [聊天消息区域] │
│ │
│ 消息1... │
│ 消息2... │
│ │
├──────────────────────────────────────────────────────┤
│ [回复输入框] [发送] │
└──────────────────────────────────────────────────────┘
缩略横条细节:
┌──────────────────────────────────────────────────────┐
│ 5人参与: (🟦)(🟧)( )( )(🟩)( )+1 + 邀请 ▼ │
│ ↑ ↑ ↑ ↑ ↑ ↑ │
│ 主责 发起 被邀 被邀 协作 被邀 │
│ 蓝框 橙框 无框 无框 绿框 无框 │
└──────────────────────────────────────────────────────┘
- 头像尺寸: 20px 圆形
- 角色边框: 2px solid (蓝#3b82f6/绿#07C160/橙#FF9800/无)
- 自己: 高亮环 #07C160 + scale(1.1)
- 超员: 最多6个头像 + "+N" 文本
- "+ 邀请"按钮独立点击,不触发展开
- ▼ 箭头表示可展开
```
### 4.4 坐席端 — 展开模式(就地展开)
```
┌──────────────────────────────────────────────────────┐
│ UserInfoBar (用户信息栏) │
├──────────────────────────────────────────────────────┤
│ TroubleshootBar (排查步骤栏) │
├──────────────────────────────────────────────────────┤
│ 5人参与: [A][B][C][D][E][F]+1 + 邀请 ▲ │ ← 横条(点击收起)
│┌────────────────────────────────────────────────────┐│
││ 参与者详情 [收起] ││ ← 展开面板 (~150px)
││────────────────────────────────────────────────────││
││ (🟦) 张三(主责) IT坐席 ││ ← 主责坐席
││ (🟧) 李四 财务部 发起人 ││ ← 发起人
││ ( ) 王五 市场部 已加入 [×] ││ ← 被邀请人(可移除)
││ ( ) 赵六 人事部 待加入 [×] ││ ← 待加入(可移除)
││ (🟩) 钱七(协作) IT坐席 ││ ← 协作坐席
│└────────────────────────────────────────────────────┘│
├──────────────────────────────────────────────────────┤
│ │
│ [聊天消息区域 — 被压缩但可见] │ ← flex 自动压缩
│ 消息1... │
│ │
├──────────────────────────────────────────────────────┤
│ [回复输入框] [发送] │
└──────────────────────────────────────────────────────┘
展开面板细节:
- 面板高度: ~150px,内容超出纵向滚动
- 头像: 28px 圆形
- 每行: 头像 + 姓名 + 角色标签 + 部门 + 加入状态 + [移除图标]
- 移除图标: 仅主责坐席可见,点击移除参与者
- "+ 邀请"按钮: 仅主责坐席可见
- 收起: 点击"收起"按钮或再次点击横条
- 动画: max-height + opacity transition ≤ 200ms
- 消息区最小高度: 200px(保证可用性)
```
### 4.5 角色边框色规范
| 角色 | 边框色 | 色值 | 说明 |
|------|--------|------|------|
| 主责坐席 | 蓝色 | `#3b82f6` | 2px solid border |
| 协作坐席 | 绿色 | `#07C160` | 2px solid border |
| 发起人(员工) | 橙色 | `#FF9800` | 2px solid border |
| 被邀请人 | 无边框 | — | 默认样式 |
| 自己(任意角色) | 高亮环 | `#07C160` | 额外 2px ring + scale(1.1) |
---
## 5. 待确认问题
| # | 问题 | 影响范围 | 建议 |
|---|------|---------|------|
| Q1 | 缩略头像条超员阈值 N 的取值:H5端取4、坐席端取6是否合理? | 两端缩略模式 | 建议根据实际手机屏幕宽度(375px基准)和桌面端横条宽度做原型验证后确定 |
| Q2 | H5端坐席目前无头像接口,缩略模式中坐席头像使用首字母降级,蓝色边框 + 首字母的视觉效果是否需要设计稿确认? | H5端缩略+展开 | 建议出一张视觉稿确认首字母头像 + 边框的呈现效果 |
| Q3 | 坐席端展开面板高度固定 ~150px,当参与者超过5人时需要滚动。是否考虑改为自适应高度(如 min(150px, 内容高度))? | 坐席端展开 | 建议固定150px + 滚动,避免面板过高挤压消息区 |
| Q4 | 发起人(原始员工)在坐席端的角色边框目前使用橙色,但现有代码中坐席端未区分发起人边框(只有主责蓝边、协作绿边)。是否需要后端在 participants 数据中标记发起人角色? | 坐席端 | 当前 `participants` 不含发起人(发起人在 `currentConversation.employee_id`),需确认坐席端是否需要单独展示发起人头像条目 |
| Q5 | H5端展开模式弹出面板与输入框的层级关系:弹出面板是否遮罩输入框?还是输入框下移? | H5端展开 | 建议遮罩输入框,弹出面板为独立浮层,关闭后恢复输入框 |
@@ -0,0 +1,124 @@
# PRD - 文件上传
> **REQ编号**: REQ-用户-002
> **版本**: v1.0
> **优先级**: P0
> **阶段**: 近期(1-2个月)
> **作者**: 宋献
> **日期**: 2026-07-19
---
## 一、问题陈述
**用户问题**
- 员工无法在 H5 端上传截图/文件
- 遇到问题时只能文字描述,无法提供错误截图、日志文件等
- 限制了 AI 多模态理解能力
**业务目标**
- 员工可上传图片、文档、日志等文件
- 坐席端可查看上传的文件
- 为后续多模态 AI 能力铺路
---
## 二、需求范围
### 2.1 核心功能
| 功能 | 描述 |
|------|------|
| H5图片上传 | 支持拍照、相册选择上传图片 |
| H5文件上传 | 支持上传文档、日志等文件 |
| 坐席端查看 | 坐席可查看会话中上传的文件 |
| 文件预览 | 支持图片、PDF、TXT 等常见格式预览 |
### 2.2 非目标
- 不支持大文件(>10MB)直接上传
- 不支持文件编辑/协作
- 不支持文件夹上传
---
## 三、用户故事
| 角色 | 用户故事 | 验收标准 |
|------|----------|---------|
| 员工 | 我可以拍照或选择相册图片上传 | 点击上传按钮,可选择拍照或相册,上传成功后在消息中显示缩略图 |
| 员工 | 我可以上传文档或日志文件 | 上传后显示文件名,点击可预览或下载 |
| 坐席 | 我可以查看会话中的文件 | 文件作为消息的一部分显示,点击可查看详情 |
| 坐席 | 我可以下载原始文件 | 支持下载到本地 |
---
## 四、功能详情
### 4.1 H5 端
**入口**
- 聊天输入框右侧增加「附件」按钮
- 点击弹出操作菜单:拍照、相册、文件
**交互**
- 图片:选择后显示缩略图预览,支持删除重选
- 文件:选择后显示文件名、大小,支持删除重选
- 上传中显示进度条
- 上传失败显示错误提示,支持重试
**限制**
- 单文件最大 10MB
- 每天最多上传 20 个文件
### 4.2 坐席端
**展示**
- 图片显示为缩略图,点击放大
- 文件显示文件名、类型、大小
- 支持下载到本地
**预览**
- 图片:支持缩放、旋转
- PDF:内嵌 PDF 查看器
- TXT/LOG:文本内容预览
### 4.3 后端
**存储**
- 本地存储(/opt/wecom-it-desk/uploads
- 文件名使用 UUID 重命名
- 保留原始文件名和扩展名
**接口**
- `POST /api/file/upload` - 上传文件
- `GET /api/file/{id}` - 获取文件信息
- `GET /api/file/{id}/download` - 下载文件
- `DELETE /api/file/{id}` - 删除文件
---
## 五、指标设计
| 指标 | 目标 | 测量方式 |
|------|------|---------|
| 上传成功率 | ≥ 99% | 上传成功次数/上传总次数 |
| 平均上传时间 | ≤ 3s | 1MB以内文件上传耗时 |
| 日上传量 | 统计 | 每日上传文件数 |
---
## 六、技术方案
| 端 | 技术选型 |
|----|---------|
| H5端 | van-uploader 组件 |
| 坐席端 | el-upload 组件 |
| 后端 | FastAPI + 静态文件服务 |
---
## 七、关联文档
- 前置需求: 无
- 后续需求: REQ-AI-003 多模态视觉理解
@@ -0,0 +1,139 @@
# PRD - 自助服务门户
> **REQ编号**: REQ-用户-003
> **版本**: v1.0
> **优先级**: P2
> **阶段**: 远期(5-8个月)
> **作者**: 宋献
> **日期**: 2026-07-19
---
## 一、问题陈述
**用户问题**
- 员工只能通过聊天会话获取帮助
- 无法自主查询知识、查看进度
- 找坐席需要等待,不够自助
**业务目标**
- 提供独立自助服务门户
- 员工可自主搜索知识、查看工单进度、处理审批
- 减少对人工坐席的依赖
---
## 二、需求范围
### 2.1 核心功能
| 功能 | 描述 |
|------|------|
| 知识搜索 | 全局搜索知识库内容 |
| 工单进度 | 查看提交的工单处理进度 |
| 审批处理 | 处理待办审批事项 |
| 个人中心 | 查看历史会话、我的收藏等 |
### 2.2 非目标
- 不支持工单创建(仅展示)
- 不支持知识库管理
---
## 三、用户故事
| 角色 | 用户故事 | 验收标准 |
|------|----------|---------|
| 员工 | 我可以搜索知识库 | 输入关键词,返回相关知识 |
| 员工 | 我可以查看工单进度 | 显示工单列表及当前状态 |
| 员工 | 我可以处理审批 | 显示待审批列表,可通过/拒绝 |
| 员工 | 我可以查看历史会话 | 显示历史会话列表 |
---
## 四、功能详情
### 4.1 门户结构
```
IT智能服务台(独立门户)
├── 首页
│ ├── 快捷入口(我要提问、查进度、审批)
│ ├── 热门知识
│ └── 最近会话
├── 知识搜索
│ ├── 搜索框
│ └── 搜索结果列表
├── 我的工单
│ ├── 工单列表
│ └── 工单详情
├── 审批待办
│ ├── 审批列表
│ └── 审批操作
└── 个人中心
├── 历史会话
├── 我的收藏
└── 设置
```
### 4.2 知识搜索
**功能**
- 全文搜索知识库
- 支持关键词高亮
- 支持筛选(类型、更新时间)
**展示**
- 标题
- 摘要(匹配内容片段)
- 相关度排序
### 4.3 工单进度
**工单状态**
| 状态 | 描述 |
|------|------|
| 已提交 | 等待处理 |
| 处理中 | 坐席正在处理 |
| 已解决 | 问题已解决 |
| 已关闭 | 会话已结束 |
**显示内容**
- 工单编号
- 问题描述
- 当前状态
- 处理人
- 更新时间
### 4.4 审批处理
**功能**
- 显示待审批列表
- 支持通过/拒绝操作
- 查看审批历史
---
## 五、指标设计
| 指标 | 目标 | 测量方式 |
|------|------|---------|
| 门户使用率 | ≥ 40% | 访问门户用户/总会话用户 |
| 自助解决率 | ≥ 30% | 门户解决数/门户访问数 |
| 知识搜索准确率 | ≥ 75% | 搜索结果满意度 |
---
## 六、技术方案
- H5 端新增独立门户 Tab 或独立页面
- 调用现有 API 获取数据
- 支持深色模式
---
## 七、关联文档
- 前置需求: REQ-知识-001 知识库闭环
- 后续需求: 无
@@ -0,0 +1,137 @@
# PRD-REQ-用户-004-坐席在线状态查询-v1.0
## 基本信息
| 字段 | 内容 |
|------|------|
| **需求编号** | REQ-用户-004 |
| **版本** | v1.2 |
| **日期** | 2026-07-25 |
| **状态** | 已实现(BUG-用户-001 已修复) |
| **作者** | Simon |
| **关联模块** | 05-用户端H5 |
---
## 1. 需求描述
### 1.1 背景
当前 H5 员工端的标题栏显示"坐席在线"状态是硬编码为 `true`(始终显示在线),无法反映真实的坐席在线状态。
用户希望在 H5 员工端能够显示真实的坐席在线/离线状态,供员工判断是否有人工坐席可提供服务。
### 1.2 目标
- H5 员工端能够查询并显示真实的坐席在线/离线状态
- 定时轮询获取状态,无需用户手动刷新
---
## 2. 用户故事
| 序号 | 角色 | 故事 | 优先级 |
|------|------|------|--------|
| US1 | 员工 | 作为员工,我希望在 H5 员工端看到坐席是否在线,以便判断是否需要转人工 | P0 |
---
## 3. 功能需求
### 3.1 API 设计
#### 3.1.1 接口路径
```
GET /h5/agents/online-status
```
#### 3.1.2 请求参数
无(公开接口,无需认证)
#### 3.1.3 响应格式
```json
{
"code": 0,
"message": "success",
"data": {
"online": true
}
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| online | boolean | 是否有在线坐席(true=有在线坐席,false=无在线坐席) |
#### 3.1.4 业务逻辑
- 查询 `agents` 表中 `status = 'online'` 的记录数量
- 有在线坐席(数量 > 0)返回 `online: true`
- 无在线坐席(数量 = 0)返回 `online: false`
### 3.2 前端轮询设计
#### 3.2.1 轮询机制
- 页面加载时立即查询一次
- 之后每 **30 秒**自动查询一次
- 页面不可见时(如切换到其他标签页)暂停轮询
- 页面恢复可见时立即查询一次
#### 3.2.2 状态显示
| 后端返回 | 前端显示 |
|---------|---------|
| `online: true` | 🟢 坐席在线 |
| `online: false` | ⚪ 坐席离线 |
---
## 4. 验收标准
| 序号 | 验收条件 | 验证方式 |
|------|---------|---------|
| AC1 | 调用 `/api/h5/agents/online-status` 返回正确的 JSON 格式 | 手动 curl 测试 |
| AC2 | 当数据库有 `status='online'` 的坐席时,返回 `online: true` | 手动测试 |
| AC3 | 当数据库没有 `status='online'` 的坐席时,返回 `online: false` | 手动测试 |
| AC4 | H5 页面显示正确的在线/离线状态 | 浏览器验证 |
| AC5 | 轮询机制正常工作,每30秒更新状态 | 浏览器验证 |
| AC6 | 坐席离线时,H5 "人工咨询"按钮显示为不可用状态,提示"坐席离线,暂不可用" | 浏览器验证 |
| AC7 | 坐席离线时,后端呼叫人工接口拒绝入队,返回错误提示 | 手动 curl 测试 |
---
## 5. Non-goals
- 不返回在线坐席的具体数量
- 不返回在线坐席的详细信息列表
- 不支持 WebSocket 推送(后续迭代考虑)
---
## 6. 技术约束
- H5 员工端未登录,无法复用现有的 `/api/agents` 接口(需要认证)
- 需要新增公开接口,不依赖用户认证
---
## 7. 风险与依赖
| 风险 | 影响 | 缓解措施 |
|------|------|---------|
| 轮询频率过高 | 增加服务器压力 | 限制为 30 秒 |
| 接口被恶意调用 | 无(只读数据) | - |
---
## 8. 变更记录
| 日期 | 版本 | 变更内容 | 变更人 |
|------|------|---------|--------|
| 2026-07-25 | v1.0 | 初始版本 | Simon |
| 2026-07-25 | v1.1 | 修正 API 路径(/api/h5/ → /h5/);补充 WS 断连自动离线逻辑;Nginx 路由配置;状态更新为已实现 | Simon |
| 2026-07-25 | v1.2 | BUG-用户-001 修复:增加坐席离线时限制呼叫人工的验收标准(AC6/AC7);关联 BUG-用户-001 | Simon |
@@ -0,0 +1,235 @@
# 员工端结束会话 — 固定按钮退出
> **版本**: v1.1
> **日期**: 2026-07-26
> **REQ编号**: REQ-用户-005
> **状态**: [已实现]
> **作者**: Simon
> **关联文档**:
> - 技术方案: `02-技术文档/技术方案-REQ-用户-005-头像菜单退出-v1.0.md`
> - 任务说明书: `07-项目管理/任务说明书/任务说明书-REQ-用户-005-头像菜单退出.md`
> - 测试用例: `03-测试文档/03-功能测试用例/TC-用户-005-头像菜单退出.md`
> - 相关 PRD: `01-产品文档/02-会话管理/PRD-REQ-会话-001-员工结束会话-v1.0.md`InputBar 结束咨询按钮)
> - 参考原型: `01-产品文档/05-用户端H5/原型-REQ-用户-000-H5用户端-v2.0.html`
> - 前端组件: `frontend-h5/src/components/chat/ChatPanel.vue`(头像区域)
> - 后端 API: `backend/app/api/auth.py`logout)、`backend/app/api/h5.py`close conversation
---
## 一、问题陈述
**现状**:员工端 H5 标题栏右侧展示员工头像和姓名,但**缺少直接可见的退出入口**。员工若要退出,需要点击头像展开下拉菜单(交互隐蔽,发现性差)。前端 `employeeStore.logout()` 方法和后端 `POST /api/auth/logout` 接口均已实现。
**痛点**
- 下拉菜单入口隐蔽,员工难以发现退出功能
- 结束会话的唯一入口是 InputBar 的"结束咨询"按钮(仅人工服务态可见),AI 自助模式无快捷结束入口
- 企微 WebView 内用户期望简单直接的操作方式,多步菜单增加操作成本
**不解决的成本**:用户找不到退出入口,多人共用设备场景存在信息泄露风险;AI 对话结束后缺少自然的"离开"操作点。
---
## 二、目标
| # | 目标 | 衡量标准 |
|---|------|---------|
| G1 | 头像右侧提供固定的「结束会话」按钮 | 按钮始终可见,无需展开菜单 |
| G2 | 提供"结束会话"一键操作 | 用户点击后完成:关闭会话 → 登出 → 跳转登录页(或关闭窗口) |
| G3 | 操作前有确认步骤,防止误触 | 弹出确认对话框,用户确认后才执行 |
| G4 | 复用已有后端 API,不新增接口 | 复用 `POST /h5/conversations/current/close` + `POST /api/auth/logout` |
---
## 三、非目标(Non-Goals
| # | 明确不做 | 原因 |
|---|----------|------|
| N1 | 不提供下拉菜单/二级操作入口 | 固定按钮更直观,一步到位 |
| N2 | 不提供"切换账号"功能 | 企微 WebView 内账号由企微自身管理,不支持多账号 |
| N3 | 不修改人工呼叫/结束咨询的现有流程 | 本需求仅新增固定按钮入口,与 InputBar 按钮并存不冲突 |
| N4 | 不在按钮旁展示会话历史/设置等二级功能 | v1 范围仅限结束+退出操作,后续版本可扩展 |
| N5 | 不强制要求物理关闭窗口 | 企微 WebView 不支持 `window.close()`,降级方案为跳转登录页 |
---
## 四、用户故事
| # | 角色 | 用户故事 | 优先级 |
|---|------|----------|--------|
| US-1 | 员工 | 作为员工,我希望头像右侧有醒目的「结束会话」按钮,一键结束当前会话并退出登录 | P0 |
| US-2 | 员工 | 作为员工,我希望退出前有确认提示并被告知"会话记录会清空",以免误触导致数据丢失 | P0 |
---
## 五、需求详情
### 5.1 交互流程
```
员工看到头像右侧「结束会话」按钮
点击「结束会话」按钮
弹出确认对话框
├── 标题:结束会话
├── 内容:退出后会话记录会清空,当前咨询进度将丢失,确定要结束会话吗?
├── [取消] → 关闭对话框,回到聊天页
└── [确定退出] → 执行退出流程
1. 判断是否有活跃会话
├── 有 → 调用 POST /h5/conversations/current/close
└── 无 → 跳过
2. 调用 POST /api/auth/logout
3. 清除前端状态(token、employeeInfo、localStorage
4. 尝试关闭窗口
├── 成功 → 窗口关闭
└── 失败(WebView 限制)→ 跳转至 /h5/login
```
### 5.2 UI 规格
#### 5.2.1 标题栏右侧改造
| 属性 | 当前 | 改造后 |
|------|------|--------|
| 头像+姓名 | 纯展示 | 纯展示(去掉下拉箭头和点击事件) |
| "结束会话"按钮 | 无 | 头像右侧固定的红色边框按钮 |
| 按钮位置 | — | 头像右边,与主题切换开关相邻 |
| 按钮样式 | — | 红色边框圆角按钮,hover 填充红色白字 |
#### 5.2.2 固定按钮
| 属性 | 规格 |
|------|------|
| 文案 | 「结束会话」 |
| 颜色 | 红色(`#ee0a24`),与危险操作一致 |
| 样式 | 透明背景 + 红色边框,圆角 14px,字号 12px |
| hover 态 | 填充红色背景 + 白色文字 |
| 位置 | 头像右侧,header-actions 内 |
#### 5.2.3 确认对话框
| 属性 | 规格 |
|------|------|
| 组件 | Vant `Dialog``van-dialog` |
| 标题 | 结束会话 |
| 消息 | 退出后会话记录会清空,当前咨询进度将丢失,确定要结束会话吗? |
| 确认按钮 | 「确定退出」(红色/警告色,出风险) |
| 取消按钮 | 「取消」(灰色/默认色) |
### 5.3 状态处理
| 场景 | 行为 |
|------|------|
| 无活跃会话 | 跳过关闭会话步骤,直接登出 |
| 有活跃会话(AI 模式) | 调用 close API,等待响应后再登出 |
| 有活跃会话(人工服务中) | 调用 close API,等待响应后再登出 |
| close API 调用失败 | Toast 提示"会话关闭失败",仍然继续登出流程(不阻塞) |
| logout API 调用失败 | Toast 提示"退出失败,请稍后重试",不跳转 |
| 窗口关闭失败(WebView 限制) | 静默降级,`window.location.href = '/h5/login'` 或使用 `WeixinJSBridge` |
### 5.4 技术对接
#### 已有 API(直接复用,无需后端改动)
| API | 方法 | 用途 | 文件 |
|-----|------|------|------|
| `/api/h5/conversations/current/close` | POST | 关闭当前会话 | `backend/app/api/h5.py:1965` |
| `/api/auth/logout` | POST | 退出登录(Token 入黑名单 + 清 Redis | `backend/app/api/auth.py:305` |
#### 前端改动范围
| 文件 | 改动内容 |
|------|---------|
| `frontend-h5/src/components/chat/ChatPanel.vue` | 移除下拉菜单,头像区域简化;新增「结束会话」固定按钮 + 确认对话框文案更新 |
| `frontend-h5/src/stores/employee.ts` | 无需改动(`logout()` 已实现) |
| `frontend-h5/src/api/auth.ts` | 无需改动(`logout()` 已实现) |
| `frontend-h5/src/api/closing.ts` | 无需改动(`employeeClose()` 已实现) |
#### 企微 WebView 关闭窗口方案
```javascript
// 方案优先级
// 1. WeixinJSBridge(企微环境首选)
if (typeof WeixinJSBridge !== 'undefined') {
WeixinJSBridge.call('closeWindow')
}
// 2. 标准 window.close()
else {
window.close()
}
// 3. 兜底:跳转登录页
setTimeout(() => {
if (!document.hidden) {
window.location.href = '/h5/login'
}
}, 500)
```
---
## 六、验收标准
### 6.1 功能验收
| # | 验收项 | 验收方式 |
|---|--------|---------|
| AC1 | 头像右侧显示「结束会话」固定按钮 | 手动测试:页面加载后按钮可见 |
| AC2 | 点击「结束会话」弹出确认框 | 手动测试:点击按钮 → 确认框出现 |
| AC3 | 确认框标题和内容正确 | 手动测试:核对文案含"退出后会话记录会清空" |
| AC4 | 点击「取消」关闭确认框,回到聊天页 | 手动测试:取消 → 对话框消失,会话不受影响 |
| AC5 | 点击「确定退出」执行退出流程 | 手动测试:确认 → loading → 跳转登录页 |
| AC6 | 退出后 Token 失效,无法访问聊天页 | 手动测试:退出后访问 `/h5/` → 重定向到 `/h5/login` |
| AC7 | 按钮 hover 态有视觉反馈 | 手动测试:鼠标悬停 → 按钮填充红色背景+白字 |
| AC8 | 退出后 WebSocket 连接断开 | 手动测试:退出后无 WS 心跳或重连 |
### 6.2 边界验收
| # | 验收项 | 验收方式 |
|---|--------|---------|
| AC10 | 无活跃会话时退出,不报错 | 新登录用户直接退出 → 正常跳转登录页 |
| AC11 | close API 失败时不阻塞退出 | 模拟网络错误 → Toast 提示但仍跳转 |
| AC12 | logout API 失败时留在当前页 | 模拟网络错误 → Toast 提示,不跳转 |
| AC13 | 企微 WebView 内点击退出,窗口关闭或跳转 | 企微环境真实测试 |
| AC14 | 退出后 WebSocket 连接断开 | 手动测试:退出后无 WS 心跳或重连 |
---
## 七、成功指标
| 指标 | 类型 | 目标值 | 测量方式 |
|------|------|--------|---------|
| 「结束会话」按钮点击率 | 先行 | 上线首周 > 10% 活跃用户 | 前端埋点:按钮点击事件 |
| 误触取消率 | 健康 | < 30%(接受一定比例的"看看"行为)| 确认框中"取消"/"确定"比例 |
| 退出异常率 | 健康 | < 2%logout API 失败或窗口关闭失败)| 前端日志上报 |
---
## 八、风险与依赖
| 风险 | 概率 | 影响 | 缓解措施 |
|------|------|------|---------|
| R1:企微 WebView `closeWindow` 不可用 | 中 | 窗口无法关闭,需降级到跳转登录页 | 已设计三层降级方案(见 5.4) |
| R2:与 InputBar「结束咨询」并存,用户困惑 | 低 | 用户不知道该用哪个 | 两者功能一致——结束会话+登出 vs 仅结束会话不登出,文案区分 |
| R3:固定按钮误触(比下拉菜单更容易点到)| 中 | 用户意外点击 | 确认对话框拦截,误触取消率可能上升 |
**依赖**
- D1`POST /h5/conversations/current/close` API 可用(已实现)
- D2`POST /api/auth/logout` API 可用(已实现)
- D3Vant `ActionSheet``Dialog` 组件已在项目中可用(已引入)
---
## 九、变更记录
| 日期 | 版本 | 变更内容 | 变更人 | 变更原因 |
|------|------|----------|--------|----------|
| 2026-07-26 | v1.0 | 初始版本(头像下拉菜单方案) | Simon | 新增需求 |
| 2026-07-26 | v1.1 | 交互调整:下拉菜单 → 头像右侧固定「结束会话」按钮;对话框消息改为含"会话记录会清空"提示 | Simon | 用户反馈:下拉菜单隐蔽,固定按钮更直观 |
---
*文档结束*
@@ -0,0 +1,295 @@
# PRD - H5 智能推荐重构
> **REQ 编号**: REQ-用户-006
> **版本**: v1.0-Frozen**§4.7 已冻结为最终版**;§1~§8 扩展列入 §13 分阶段交付计划)
> **日期**: 2026-07-28
> **作者**: 宋献 (Simon) + Duckula
> **状态**: ✅ §4.7 已冻结(决策 ① A / ② B / ③ B / ④ B / ⑤ B 宋献拍板);🟡 §1~§8 扩展按 §13 计划分阶段交付
> **关联**:
> - 评估文档:`docs/02-技术文档/前端改造/前端设计-H5右侧栏动态推送-v1.0.md`
> - 实现概览:`docs/02-技术文档/前端改造/设计-H5用户端实现概览-v1.0.md`
> - 改造实施:`docs/02-技术文档/实现配置/AI对话链路全栈改造实施计划-v1.0.md`
> - 来源标识:`docs/01-产品文档/03-AI服务/PRD-REQ-AI-004-AI回复来源标识-v1.0.md`
> - 代码真相:`src/backend/app/services/asset_recommend_service.py` + `src/backend/app/config/assets.yaml` + `src/backend/app/tasks/h5_ai_task.py`
---
## 一、问题陈述(简述,待扩)
H5 员工端当前"智能推荐"功能存在 5 类问题:
1. **L1 关键词匹配太硬**`if keyword in message_lower` 纯字符串包含,无语义理解
2. **L2 画像依赖外部 API**:终端安全画像拿不到时 5 个 if 全部跳过
3. **L3 角色推荐不工作**`assets.yaml` 是英文 keydeveloper/finance/manager),`profile.position` 是中文,精确匹配永远失败
4. **双轨运行**`dynamic_recommend``asset_recommend` 两条 WS 并行,无去重、无排序
5. **审批类卡片 v2.3 后完全不出现在右侧**:用户发起的审批进度无法跨会话回看
## 二、范围(简述,待扩)
- **In-scope**5 类卡片位置归属 + UI 规范 + 触发来源与逻辑(§4.7)+ 审批进度回流
- **Out-of-scope**:智能推荐算法升级(语义匹配/向量召回)、坐席端 AI 辅助、多模态推荐
---
## §4 功能需求
### 4.1 卡片位置归属决策矩阵(已审定,见 §11.1 决策记录)
### 4.2 右侧栏生命周期(B2:跨会话 + 状态完结即消 + 30 天过期)(待 §4.7 审核通过后补)
### 4.3 双发策略(C4:智能切换规则)(待 §4.7 审核通过后补)
### 4.4 审批进度回流机制(待 §4.7 审核通过后补)
### 4.5 右侧栏 UI 规范(待 §4.7 审核通过后补)
### 4.6 新卡片类型扩展(待 §4.7 审核通过后补)
### **4.7 触发来源与触发逻辑** ⭐ **本草案核心,已冻结(v1.0-Frozen2026-07-28**
> 本章节是 PRD 的"心脏"——回答 **"卡片从哪里来、什么时机来、来了怎么合、没来怎么办"**。
#### 4.7.1 4 类触发来源(权威定义)
H5 智能推荐的卡片**只能**来自以下 4 类来源。任何新增来源必须经过 PRD 变更审批,不得私下接入。
| 来源 ID | 名称 | 触发主体 | 数据源 | 触发条件 | 输出位置(按 §11.1 决策) |
|--------|------|---------|--------|---------|---------------------------|
| **A** | **Dify action**dynamic_recommend | Dify 主推理(itdesk_main 工作流) | Dify 返回的 `action` 字段(非空且非审批类) | AI 回复生成的同一时刻 | 左侧气泡(动作类)+ 右侧栏(步骤/操作类) |
| **B** | **L1 关键词匹配** | 后端 `_push_asset_recommends` | `assets.yaml::keyword_assets`10+ 个中文关键词 + alias) | 用户消息包含配置关键词(substring 匹配) | 左侧气泡(仅作为对话补充) |
| **C** | **L2 画像触发** | 后端 `_push_asset_recommends` | `employee_profile_service` 画像字段(火绒版本/病毒库/离线天数/补丁缺失/违规项) | 画像字段非空且满足阈值(见 4.7.5) | 右侧栏 |
| **D** | **L3 角色匹配** | 后端 `_push_asset_recommends` | `assets.yaml::role_assets` + `profile.position` | 角色 key 匹配(中文岗位名子串匹配,见 4.7.5) | 左侧气泡 |
**关键约束**
- 4 类来源**必须全部登记**,代码里的任何新增触发点必须先更新本表
- 来源 A 走 `dynamic_recommend` WStype=dynamic_recommend),来源 B/C/D 走 `asset_recommend` WStype=asset_recommend
- 前端按 source 字段区分渲染入口(DifyActionCard vs DynamicRecommend
#### 4.7.2 3 种触发时机
| 时机 ID | 名称 | 触发点 | 典型来源 | 延迟要求 |
|--------|------|--------|---------|---------|
| **T0** | **同步触发**(与气泡同帧) | Dify 主推理返回瞬间 | ADify action | **零延迟**,与气泡同 WS 包发出 |
| **T1** | **异步触发**(AI 回复后独立步骤) | 后台任务 `_step_assets` | B(C 关键词)/ C(L2 画像)/ D(L3 角色) | **可容忍 0-3s 延迟**,失败不阻断主对话 |
| **T2** | **事件触发**(状态变化驱动) | 企微审批 webhook / ITSM 工单状态变更回调 | 审批进度回流 | **可容忍 5-30s 延迟**,走 60s 轮询兜底 |
**关键约束**
- T0 用于"用户在主动对话"场景,与 AI 文字强耦合
- T1 用于"补充信息"场景,独立于对话节奏
- T2 用于"异步通知"场景,必须支持跨会话持久化
#### 4.7.3 多源合并规则
当多个来源在同一时刻产生推荐时,按以下规则合并:
| 规则 | 规则描述 | 备注 |
|------|---------|------|
| **去重规则** | 同一卡片(按 `recommend_id``card_key`)出现 2 次以上时保留 confidence 最高的 | 避免双源重复推送 |
| **排序规则** | 同一时刻多卡:左侧按 layer 优先级(L1>L2>L3);右侧按"操作步骤 > 审批进度 > 运维提醒" | 用户视觉焦点固定 |
| **上限规则** | 左侧气泡:每条 AI 消息最多 1 张操作卡;右侧栏:跨所有来源最多 3 张 | 避免堆叠 |
| **同源抑制** | 同一会话内,L1/L2 重复触发同一关键词的同一资源卡片时,30 分钟内不再推送 | 避免噪音 |
**与决策 ② 多源合并 关联**:本节是决策 ② 选项 B("同卡片去重 + layer 排序")的产品化展开。
#### 4.7.4 冷启动策略
用户进入会话但**没说话**或**刚发首条消息还没收到 AI 回复**时,右侧栏**保持空白**,不强求兜底内容。
| 场景 | 触发条件 | 显示内容 | 来源 |
|------|---------|---------|------|
| **场景 1:会话首次打开** | `conversation.created_at < 5s` 且无任何消息 | **右侧栏完全空白** | 无 |
| **场景 2:首条消息刚发出** | 已发消息但 AI 还未回复 | **右侧栏完全空白** | 无(按 §4.7.5 触发失败降级走) |
| **场景 3AI 回复到达** | AI 已生成至少一条回复 | 按 §4.7.3 合并规则填充真实推荐 | A/B/C/D |
| **场景 4:完全无数据** | 所有来源都无匹配 | **右侧栏完全空白** | 无(兜底即"不显示" |
**关键约束**
- 冷启动遵循 **"有则显,无则隐"** 原则,**不强求兜底**(避免冷启动内容与 AI 回复后的真实推荐冲突造成堆叠)
- 一旦 AI 回复到达(来源 A 卡片生成),立即按 §4.7.3 合并规则填充
- 冷启动状态的"空白" ≠ 异常/错误状态,前端无需展示"加载中"或"暂无数据"提示(避免视觉噪音)
**与决策 ① 冷启动 关联**:本节是决策 ① 选项 A("进会话无内容,右侧栏空")的产品化展开——**宁可空,也不堆叠**。
#### 4.7.5 触发失败与降级策略
| 失败场景 | 降级策略 | 兜底内容 |
|---------|---------|---------|
| **来源 A 失败**Dify 返回非 JSON / action 为空 | 跳过来源 A,仅走 B/C/D | 无 |
| **来源 B 失败**assets.yaml 配置缺失 | 跳过 B,走 C/D | 无 |
| **来源 C 失败**employee_profile_service 拿不到画像 | 跳过 C,走 B/D | 无(关键!必须降级到 D,不能空) |
| **来源 D 失败**profile.position 为 None 或空字符串 | 跳过 D | 来源 B + C(如果有) |
| **全部失败** | 按 §4.7.4 冷启动策略走"右侧栏空"(保持一致性,宁可空也不堆叠) | 无 |
**关键约束**
- 来源 C 失败时**必须降级到 D**(保证右侧栏有内容,不能直接空)—— 这是决策 ③ 选项 B("降级为 L3 角色")的产品化展开
- 来源 D 失败时**降级到 B + C**(保证左侧有内容)—— 这是新加的兜底
#### 4.7.6 话题切换检测(决策 ④ 落地)
会话话题切换时,**自动清空旧的同会话推荐**,避免推荐堆叠。
| 检测信号 | 检测方法 | 触发动作 |
|---------|---------|---------|
| **关键词突变** | 新消息与最近 3 条消息的关键词相似度 < 0.3(用 Jaccard 系数) | 清空所有 L1 推荐 |
| **意图突变** | Dify 返回的 `intent_type` 与上一条不同时 | 清空对应 layer 的推荐 |
| **会话分隔** | 用户点"结束会话"或新建会话 | 清空所有跨会话推荐 |
**关键约束**
- L2/L3 推荐**不清空**(它们与当前话题无关,是画像/角色通用)
- L1 推荐**按层清除**(关键词命中失效就清除)
- 审批进度 T2 回流**不清空**(跨会话持久)
#### 4.7.7 跨会话持久化规则(决策 ⑤ 落地)
| 推荐类型 | 持久化策略 | 过期规则 |
|---------|----------|---------|
| **L1 关键词推荐** | **不持久化**,仅当前会话有效 | 话题切换或会话结束即清空 |
| **L2 画像提醒** | **持久化 30 天**,跨会话可见 | 30 天后自动过期,或画像数据更新后清除 |
| **L3 角色资源** | **持久化**,跨会话可见 | 角色变更时清除(极少发生) |
| **来源 A 步骤卡** | **不持久化**,仅当前会话 | 话题切换或会话结束即清空 |
| **审批进度 T2 回流** | **持久化直到完结**,跨会话可见 | 审批结束/已读/已完成时清除 |
**关键约束**
- L1/A 不持久 → 避免"上周聊过 VPN 这周还推 VPN"的尴尬
- L2/L3/T2 持久 → 保证"运维提醒"和"审批进度"跨会话可查
- 持久化数据**存储在客户端**(localStorage),后端不持久化推荐状态
---
> **🟢 §4.7 已冻结(v1.0-Frozen2026-07-28**
>
> 本章节自 2026-07-28 起作为最终版冻结,任何修改必须经过 PRD 变更审批流程。
>
> **5 个核心决策**(宋献 2026-07-28 拍板):
> - **决策 ① 冷启动策略** → A(右侧栏空,宁可空也不堆叠)
> - **决策 ② 多源合并规则** → B(同卡片去重 + layer 排序 + 上限 3 张)
> - **决策 ③ 触发失败降级** → B(降级为下一优先级来源)
> - **决策 ④ 话题切换检测** → B(检测话题切换后清空旧推荐)
> - **决策 ⑤ 跨会话持久化** → B(按 layer 分级持久化)
>
> **约束声明**:后续扩展(§1~§8)必须遵守以上决策约束,**不得反向修改**。
> 如需调整任一决策,必须走 PRD 变更流程,并更新本章节 + §9 决策清单 + §11 变更日志。
---
## §5 非功能需求(待 §13 扩展计划 v1.3 补充)
## §6 接口需求(待 §13 扩展计划 v1.3 补充)
## §7 验收标准(待 §13 扩展计划 v1.3 补充)
## §8 风险与降级(待 §13 扩展计划 v1.3 补充)
---
## §9 决策待审清单
> 本节明确列出本草案中 **5 个待审决策**,请宋献逐项确认或修改。
### 决策 ① 冷启动策略(§4.7.4)
| 选项 | 含义 | 我的建议 | 宋献决策 |
|------|------|---------|----------|
| **A** ✅ | **进会话无内容(右侧栏空)** | | ✅ **宋献 2026-07-28 选定 A** |
| B | 默认显示 L3 角色通用 + 历史高频资源 | 避免首屏空 | 未选 |
### 决策 ② 多源合并规则(§4.7.3)
| 选项 | 含义 | 我的建议 |
|------|------|---------|
| A | 不合并,各推各的 | |
| **B** | **同卡片去重 + layer 排序 + 上限 3 张** | ✅ 建议 B(避免重复) |
### 决策 ③ 触发失败降级(§4.7.5)
| 选项 | 含义 | 我的建议 |
|------|------|---------|
| A | 静默失败(不推) | |
| **B** | **降级为下一优先级来源** | ✅ 建议 B(保证有内容) |
### 决策 ④ 话题切换检测(§4.7.6)
| 选项 | 含义 | 我的建议 |
|------|------|---------|
| A | 不检测(推荐持续堆叠) | |
| **B** | **检测话题切换后清空旧推荐** | ✅ 建议 B(避免堆叠) |
### 决策 ⑤ 跨会话持久化(§4.7.7)
| 选项 | 含义 | 我的建议 |
|------|------|---------|
| A | 全局持久化 | |
| **B** | **按 layer 分级持久化** | ✅ 建议 BL1/A 不持久,L2/L3/T2 持久) |
---
## §10 关联文档(同抬头所列)
---
## §11 变更日志
| 版本 | 日期 | 变更内容 | 作者 |
|------|------|---------|------|
| v1.0a | 2026-07-28 18:59 | 草案:仅完成 §4.7 触发来源与逻辑章节 | Duckula |
| v1.0b | 2026-07-28 19:06 | 宋献审核 §4.7 决策:① A / ② B / ③ B / ④ B / ⑤ B;同步调整 §4.7.4 冷启动策略、§4.7.5 全部失败兜底、§9 决策① | Duckula + 宋献 |
| v1.0-Frozen | 2026-07-28 19:09 | §4.7 冻结为最终版(决策锚点);新增 §13 §1~§8 扩展计划(v1.1/v1.2/v1.3 分阶段交付);§5~§8 占位标记改为"待 §13 计划补充";§12 待办标记完成项 | Duckula + 宋献 |
---
## §12 待办(v1.0-Frozen 阶段)
- [x] §4.7 触发来源与逻辑章节完成(v1.0a2026-07-28
- [x] 宋献审核 §4.7 决策(v1.0b2026-07-28,①~⑤ 拍板)
- [x] §4.7 冻结为最终版(v1.0-Frozen2026-07-28
- [ ] 扩展按 §13 计划分阶段交付(v1.1 → v1.2 → v1.3
- [ ] 配套技术方案 / 原型图 / 任务说明书 / 测试用例(详见 §13.4)
---
## §13 §1~§8 扩展计划(分阶段交付)
> §4.7 已冻结为最终版(v1.0-Frozen),§1~§8 扩展必须遵守 §4.7 决策约束。
### 13.1 扩展原则
1. **遵守 §4.7**:所有扩展章节的逻辑不得与 §4.7 冲突
2. **决策不反转**:5 个已审定决策(① A / ② B / ③ B / ④ B / ⑤ B)不得反向修改
3. **增量交付**:每个章节单独 PR / 单独 review,避免大爆炸
4. **依赖排序**:先扩 §1~§3(背景/范围/故事),再扩 §4.1~4.6(功能),最后扩 §5~§8(非功能/接口/验收/风险)
### 13.2 扩展任务清单
| # | 章节 | 扩展要点 | 依赖 | 估时 | 优先级 | 交付版本 |
|---|------|---------|------|------|--------|---------|
| 1 | §1 问题陈述 | 基于 5 类问题(L1/L2/L3/双轨/v2.3)展开详细描述 | 无 | 0.5h | P0 | v1.1 |
| 2 | §2 详细范围 | In-scope5 类卡片归属 + UI + §4.7 + 审批回流)/ Out-of-scope | §1 | 0.5h | P0 | v1.1 |
| 3 | §3 用户故事 | 员工/坐席/管理员 3 个角色视角 | §1 | 1h | P0 | v1.1 |
| 4 | §4.1 卡片归属矩阵 | 把先前对话中已审定的归属决策(A2/B2/C4 + L3/联系窗口/联系人/下载只放左边)整理为完整矩阵 | §4.7 | 0.5h | P1 | v1.1 |
| 5 | §4.2 右侧栏生命周期 | B2 跨会话 + 状态完结即消 + 30 天过期 | §4.7 | 0.5h | P1 | v1.2 |
| 6 | §4.3 双发策略 | C4 智能切换规则(按内容性质决定位置) | §4.7 | 0.5h | P1 | v1.2 |
| 7 | §4.4 审批进度回流 | T2 事件触发 + 60s 轮询兜底 | §4.7 | 1h | P1 | v1.2 |
| 8 | §4.5 右侧栏 UI 规范 | 无标题 + FIFO 插入 + 4 类卡片样式 | §4.7 | 1h | P1 | v1.2 |
| 9 | §4.6 新卡片类型 | 操作步骤(可展开)/ 联系窗口 / 联系人 / 下载 | §4.7 | 1h | P1 | v1.2 |
| 10 | §5 非功能需求 | 性能(<200ms 渲染)+ 兼容(v2.3 平滑过渡) | §4.7 | 0.5h | P2 | v1.3 |
| 11 | §6 接口需求 | WS 新增 type=recommend_update + 订阅 API | §4.7 | 1h | P1 | v1.3 |
| 12 | §7 验收标准 | 点击率>15% / 自助解决率>10% / 右侧栏 ≤3 张 | §4.7 | 0.5h | P0 | v1.3 |
| 13 | §8 风险与降级 | Dify 不可用 / WS 断连 / 画像 API 不可用 | §4.7 | 0.5h | P1 | v1.3 |
### 13.3 交付节奏建议
| 阶段 | 范围 | 估时 | 触发条件 |
|------|------|------|---------|
| **v1.1 立即扩** | §1 / §2 / §3 / §4.1 | 2.5h | §4.7 冻结后立即启动 |
| **v1.2 一周内扩** | §4.2 ~ §4.6 | 3.5h | v1.1 完成 + 原型图设计启动 |
| **v1.3 两周内扩** | §5 / §6 / §7 / §8 | 2.5h | v1.2 完成 + 技术方案评审通过 |
### 13.4 关联配套交付物
| 类型 | 路径 | 状态 | 依赖 |
|------|------|------|------|
| 技术方案 | `docs/02-技术文档/技术架构/技术方案-REQ-用户-006-智能推荐重构-v1.0.md` | 🟡 待启动 | §4.7 + §6 接口需求 |
| 原型图 | `docs/01-产品文档/05-用户端H5/原型-REQ-用户-006-智能推荐重构-v1.0.html` | 🟡 待启动 | §4.5 UI 规范 |
| 任务说明书 | `docs/07-项目管理/任务说明书/任务说明书-REQ-用户-006-智能推荐重构.md` | 🟡 待启动 | 技术方案 |
| 测试用例 | `docs/03-测试文档/03-功能测试用例/TC-用户-006-智能推荐重构.md` | 🟡 待启动 | §7 验收标准 |
| BUG 单模板 | `docs/03-测试文档/05-缺陷单/BUG-用户-006-xxx.md` | ⚪ 按需 | 上线后 |
### 13.5 冻结约束声明
- §4.7 已冻结,任何修改必须走 PRD 变更流程(更新 §4.7 + §9 决策清单 + §11 变更日志 + 本节)
- 扩展章节(§1~§8)的逻辑**不得与 §4.7 冲突**,如有冲突以 §4.7 为准
- 5 个核心决策(① A / ② B / ③ B / ④ B / ⑤ B)**不得反向修改**,如需调整必须升级文档版本号(v1.0-Frozen → v2.0
@@ -0,0 +1,395 @@
# PRD - H5 自助诊断面板优化
> **REQ 编号**: REQ-用户-007
> **版本**: v1.0-Live(前端 v2.3.6 已上线,2026-07-29
> **日期**: 2026-07-29
> **作者**: 宋献 (Simon) + Duckula
> **状态**: ✅ 前端已上线 v2.3.6(11 项决策全部锁定:①②③ 标题栏三优化 / ④ Tab 精简版 / ⑤ 移动端 toggle A / ⑥ queue 合并 / ⑦ M1 双角标 / ⑧ 徽章外提 / ⑨ 网络系统统一 .diag-item + 检测按钮 / ⑩ 内容高度自适应 / ⑪ 排队置底 + 未排队占位)
> **关联**:
> - 原型图:`docs/01-产品文档/05-用户端H5/原型-REQ-用户-007-自助诊断面板优化-v1.0.html`v2.3.6
> - 技术方案:`docs/02-技术文档/技术架构/技术方案-REQ-用户-007-自助诊断面板优化-v1.0.md`v1.0-Live
> - 任务说明书:`docs/07-项目管理/任务说明书/任务说明书-REQ-用户-007-自助诊断面板优化.md`
> - 测试用例:`docs/03-测试文档/03-功能测试用例/TC-REQ-用户-007-自助诊断面板优化.md`(50 条)
> - 设计文档:`docs/02-技术文档/前端改造/设计-H5用户端实现概览-v1.0.md`
> **代码真相**v2.3.6 生产部署版 hashCSS `index-GZiNwzZW.css` 167849B / JS `index-CAygTKBS.js` 375216B):
> - `src/frontend-h5/src/components/assistant/SelfDiagnosis.vue`v2.3.6 —— 内部仅保留 6 Tab + 内容区;标题栏(含徽章+一键诊断按钮)已通过 `defineExpose` 暴露给 RightPanel;网络/系统 Tab 的 diagItems 与其他 Tab 的 riskItems 统一渲染为 `.diag-item` 模板(带 `检测` 按钮)
> - `src/frontend-h5/src/components/assistant/QueueWaiting.vue`v3.0 位置卡片 + 答题区;新增 `showPosition?: boolean` prop(默认 true,向后兼容)
> - `src/frontend-h5/src/components/assistant/RightPanel.vue`v2.3.6 —— accordion-item__header 含徽章+一键诊断 actions 区;queue-section `margin-top: auto` 始终置底;未排队时显示 `未排队` 占位
> - `src/frontend-h5/src/views/ChatView.vue`:移动端 <500px 默认 `isMobilePanelOpen=false` 显示左对话,点击 🩺 信息按钮切到右栏;`showRightPanel` 计算:`!isMobile || isMobilePanelOpen`
---
## 一、问题陈述
H5 员工端"自助诊断"面板经多轮迭代后仍存在 5 类问题:
1. **标题栏缺异常数量**:用户进右栏看不到总异常数,必须逐个 Tab 点开才能汇总
2. **6 Tab 是 3×2 网格按钮**:占用屏高,不符合移动端/桌面端扁平化设计趋势
3. **移动端右栏不可见**ChatView.vue:80-89 移动端 <500px `showRightPanel=false`,用户无法在手机上查看诊断/推荐/排队
4. **排队区信息分散**:标题栏只显示 #5,位置/LV/分全在 queue-content 内的独立卡片,挤占答题区空间
5. **Tab 切换时数据混乱**(用户认知问题):未明确告知"切换 Tab 只显示当前 Tab 数据"
## 二、范围
### 2.1 In-scope(本次 v1.0-Live 交付)
**v2.3 已上线(PRD v1.0-Frozen 原始 7 项决策):**
- 自助诊断标题栏:分级显示异常徽章(5W·0F)+ 接入中提示 + 0 异常达标动画
- 6 Tab 由 3×2 网格按钮改为横向并排标签页
- 移动端 toggle 切换:左对话框 ⇄ 右栏(顶部双按钮方案)
- 排队标题栏:合并位置/LV/分信息到标题栏,queue-content 只保留答题区
- 异常 + 接入混合态:M1 双角标并存(红数字徽章 + 灰色斜体小角标)
- 离线 Browser 端可在原型图直接预览所有决策效果
**v2.3.3-v2.3.6 追加 4 项决策(生产环境 v2.3.6 已部署):**
- **⑧ 标题栏外提**:自助诊断内部不再保留 `self-diag__header` 标题栏;徽章 + 一键诊断按钮提升至外层 `RightPanel.vue` 的 accordion-item__headerSelfDiagnosis 通过 `defineExpose` 暴露 `totalBadge / totalBadgeByLevel / showCelebrate / isRunningAll / hasAnyDiagItems / runAllDiag`
- **⑨ 网络/系统 Tab 统一 .diag-item + 检测按钮**:所有诊断项(diagItems + riskItems)统一渲染 `.diag-item` 模板;每条加 `检测` 按钮,可对单条重新检测(riskItems 模拟 800ms 检测中态,diagItems 走 runSingle 流程)
- **⑩ 内容高度自适应**:删除 accordion-item--active 的 `max-height: 80%` 硬限制;右栏 `overflow-y: auto` 整体可滚动,内容自然撑开
- **⑪ 排队标签置底 + 未排队占位**queue-section 加 `margin-top: auto` 始终推至主轴底部;标题栏不在排队时显示 `未排队` 占位(替代空荡的标题栏)
### 2.2 Out-of-scope(不在本次范围)
- 诊断数据源接入(SIEM/IDS、联软代理等 → 2026-Q4 接入)
- 触达 0 异常时的自助解决率统计(依赖埋点系统 v1.1)
- 移动端右栏个性化配置(折叠状态本地记忆)
- 诊断结果 NLP 摘要(依赖 Dify 工作流升级)
## 三、用户故事
### 3.1 员工视角(核心用户)
| 角色 | 场景 | 期望 |
|------|------|------|
| 一线员工 | 打开 H5 进右栏 | 1 秒内看到"✗ 5 个待处理异常"(分级徽章) |
| 一线员工 | 切到"设备" Tab | 看到硬盘 SMART 警告(仅设备 Tab 内容,不显示其他 Tab 数据) |
| 一线员工 | 看到"安全" Tab 显示"•2"小角标 | 知道有 2 项正在接入 SIEM(不计入异常数) |
| 一线员工 | 处理完所有异常,徽章消失 | 看到"✓ 今日安全"绿色脉冲动画(持续 3 秒) |
| 一线员工 | 手机端(<500px) | 顶部双按钮切换"💬 对话 / 🩺 信息",单击即可 |
| 一线员工 | 查看排队位置 | 在标题栏直接看到 #5 / LV.3 / 120 分(不用展开内容区) |
### 3.2 管理员视角
| 角色 | 场景 | 期望 |
|------|------|------|
| 管理员 | 监控大盘异常分布 | 看到分级 `5W·0F` 直观知道"无 fail,全是 warning" |
| 管理员 | 排查接入中项目 | 看到 Tab "安全"挂"•2"灰色角标,知道 SIEM 还在接入 |
## 四、功能需求
### 4.1 异常徽章(标题栏,v2.3 三优化 + v2.3.3 外提)
#### 4.1.1 三优化(v2.3 已锁定)
| 优化 | 名称 | 实现 | 状态 |
|------|------|------|------|
| ① | **分级显示** | `5W · 0F` 双胶囊(warning 暖黄字 + fail 粉字);中间 `·` 分隔 | ✅ v2.3 已落地 |
| ② | **接入中提示** | Tab 文字后挂灰色斜体小角标"•N"(不计入异常数);行内展示"接入中"灰色 tag | ✅ v2.3 已落地 |
| ③ | **0 异常达标动画** | 绿色渐变徽章 + 脉冲光晕(2.4s 循环)+ ✓ 今日安全 文字;3 秒后渐隐 | ✅ v2.3 已落地 |
#### 4.1.2 v2.3.3 标题栏外提(已落地)
**改造前**v2.3 内部):SelfDiagnosis.vue 内部有 `.self-diag__header` 独立标题栏,含 🩺 + 自助诊断 + 徽章 + 一键诊断按钮。
**改造后**v2.3.3 起):SelfDiagnosis.vue 内部**删除** `.self-diag__header`;通过 `defineExpose` 暴露 6 个 API 给外层 RightPanel
```typescript
defineExpose({
totalBadge, // 全部 Tab badge 之和
totalBadgeByLevel, // { w: number, f: number }
showCelebrate, // 0 异常达标动画触发
isRunningAll, // 是否正在批量诊断
hasAnyDiagItems, // 当前 Tab 是否有 diagItems
runAllDiag, // 一键诊断方法
})
```
外层 `RightPanel.vue``accordion-item__header` 通过 `selfDiagRef?.totalBadgeByLevel` 读取数据并渲染徽章 + 一键诊断按钮(在 `accordion-item__header-actions` 容器中,`@click.stop` 阻止冒泡触发折叠)。
#### 4.1.3 5 种状态对照(已展示在原型图对照表)
| 状态 | 视觉 | 触发条件 |
|------|------|---------|
| ① 分级显示 | `5W · 0F` | 当前 Mock5 warning |
| ② 含 fail | `2W · 1F` | 假设场景(2 warning + 1 fail |
| ③ 旧版兼容 | `!5` | 历史版本降级(可选关闭) |
| ④ 达标动画 | `✓ 今日安全` | 全部异常清零时 |
| ⑤ 静态正常 | `✓ 全部正常` | 动画停止后稳态 |
#### 4.1.4 异常计数算法(v2.3.6 最终口径)
```
warning(W) + danger/fail(F) → 计入异常徽章
normal + pending(接入中) + idle(未执行) → 不计入
```
**算法实现在 SelfDiagnosis.vue**
- `totalBadge`:所有 Tab `badge` 之和(badge = diagItems warning/fail + riskItems warning/danger 项数)
- `totalBadgeByLevel`:分别统计 warning 数 + fail 数(跨所有 Tab
- `showCelebrate``totalBadgeByLevel.w === 0 && totalBadgeByLevel.f === 0`
#### 4.1.5 Tab 文字简称(v2.3.2 锁定)
| 完整名 | 简称(2字) |
|--------|-----------|
| 网络联通 | **网络** |
| 账号权限 | **账号** |
| 设备硬件 | **设备** |
| 系统软件 | **系统** |
| 终端安全 | **安全** |
| 合规检查 | **合规** |
简称通过 `DiagTab.shortLabel` 字段单独维护(避免模板硬编码,方便日后调整)。`tabs` computed 同步返回 `shortLabel` 字段。
### 4.2 6 Tab 横向并排标签页(替代 3×2 网格按钮)
**设计约束**v2.3 已锁定 + v2.3.2 简称统一):
- 6 Tab 文字:精简版(**网络 / 账号 / 设备 / 系统 / 安全 / 合规**,2 字简称)
- 高度:30pxv2.3.2 由 34px 改为 30px,更紧凑)· flex 1:1:1:1:1:1 等分
- 激活态:底部 2px 蓝色下划线 + 文字加粗
- 异常 Tab:底部额外一条半透明红色 2px 线(双层指示,激活态红线在蓝线下方 1px)
- 移动端:移动端 <500px 时整 RightPanel 默认不渲染,需点击 🩺 信息按钮切到右栏后可见
### 4.3 移动端 toggle 切换(核心需求)
**仅适用于移动端(<500px**,桌面端(≥500px)维持双栏不变。
| 决策 | 方案 | 状态 |
|------|------|------|
| 方案 A | 顶部双按钮(💬 对话 / 🩺 信息) | ✅ **锁定** |
**实现要点**
- ChatView.vue 新增 `isMobilePanelOpen` 状态(默认 false 显示左对话框)
-`isMobile=true` 时显示切换按钮
- 点击切换:左对话 ↔ 右栏(手风琴 + 智能推荐 + 排队)
- 切换时左对话/右栏状态保留(聊天滚动位置、答题展开状态)
### 4.4 queue 标题栏合并位置/LV/分信息
**改造前**v2.0/v2.2):RightPanel.vue queue-header 只显示 ⏳ + "排队等待" + #5 + 答题挑战按钮;queue-content 内 QueueWaiting 组件含完整 position-card(位置 + LV + 分)。
**改造后**v2.3):
```
⏳ 排队等待 | #5 前面4人 | LV.3 | 120分 | 答题挑战
```
- queue-header 内联展示三件套(position-inline / lv-inline / points-inline
- queue-content 只保留答题区,QueueWaiting 组件需新增 `show-position=false` prop
- 整体高度压缩 ~40px(≈50%)
### 4.5 Tab 切换只显示当前诊断项(行为已天然)
**用户需求**:"选择某项标签时(网络),下方只显示网络的诊断项,不显示其他标签页诊断项"
**代码现状**SelfDiagnosis.vue:499-501 `currentTab` 已 computed 自动过滤,切 Tab 时 Vue 自动卸载旧 DOM / 挂载新 DOM。
**结论**:**无需代码改动**,仅原型标注行为。
### 4.6 异常 + 接入混合显示:M1 双角标并存
**场景**:某 Tab 同时有异常(W/F)和接入中(pending)项时(如未来某次"安全" Tab 出现 1 异常 + 2 接入)。
**决策 M1**Tab 文字右侧显示双角标
- 红数字徽章(`.diag-tab__badge`= 异常项数
- 灰色斜体小角标(`.diag-tab__pending-hint`= 接入中项数
- 视觉区隔:颜色(红 vs 灰)+ 字体(标准 vs 斜体小号)
**示例**`安全 1•2`(红数字 1 + 灰斜体 2
### 4.7 网络/系统 Tab 样式统一 + 检测按钮(v2.3.4 已落地)
**问题(v2.3.3 之前)**:网络/系统 Tab 的"诊断项"(点击执行诊断)和 账号/设备/安全/合规 Tab 的"风险项"(展示状态信息)使用两套独立 UI:
- 诊断项:`.diag-item`(无图标、无状态徽章)
- 风险项:`.risk-item`(有图标 + 状态徽章)
用户反馈:网络/系统 Tab 的诊断项"样式与其他条目不一致,看起来像缺了一半"。
**v2.3.4 改造方案**:所有条目(diagItems + riskItems)统一渲染为 `.diag-item` 模板。
- 统一模板:`图标 + 名称 + 状态徽章 + 检测按钮 + 折叠箭头 + (展开后)详情行`
- **检测按钮**`.diag-item__btn`):每条加独立"检测"按钮,点击触发该条重新检测
- diagItems:走 `runSingle(item.key)` 流程,800ms-1.5s 后返回 Mock 结果
- riskItems:模拟 800ms "检测中..." 状态后恢复原态(不对接实际后端)
- pending 状态条目:禁用按钮
- 图标推断:diagItems 通过 `diagItemIcon(item)` 函数根据 label 文本推断 emoji 图标
- 状态映射:`DiagStatus``RiskLevel` 通过 `diagStatusLevel()` 函数映射,共用样式
**收益**:6 个 Tab 内所有条目视觉一致,用户操作路径统一(先看 → 不放心 → 点检测)。
### 4.8 内容高度自适应(v2.3.5 已落地)
**问题(v2.3.4 之前)**`accordion-item--active``max-height: 80%` 硬限制,当自助诊断展开且 Tab 内容多时(特别是网络 Tab 6 项 + 系统 Tab 6 项 + 4 个 riskItems)会被截断,用户需手动滚动手风琴区。
**v2.3.5 改造方案**
- 删除 `.accordion-item--active { max-height: 80% }` 限制
- 右栏 `.right-panel``overflow-y: auto`,超出视口时整个右栏整体滚动
- 手风琴展开态高度由内容自然撑开
- queue-section `margin-top: auto`(v2.3.6 追加)保证不管内容多寡,排队标签始终贴底
**收益**:自助诊断完全展开后,所有 Tab 内容完整可见,无需逐 Tab 切换查找。
### 4.9 排队标签置底 + 未排队占位(v2.3.6 已落地)
**问题(v2.3.5 之前)**v2.3.5 去掉 max-height 后,手风琴展开时智能推荐被推上去,queue-section 不再自动贴底(v3.0 设计的"排队到底部"布局失效)。
**v2.3.6 改造方案**
**(a) 排队标签置底**
- `.right-panel__queue-section { margin-top: auto }`
- flex column 容器中自动推至主轴底部,**不依赖 flex:1 占满中间空间**
- 即使自助诊断展开 + 智能推荐内容多,排队标签仍贴右栏底部
**(b) 未排队占位**(避免标题栏空荡):
```vue
<span v-if="isQueued && queuePosition > 0" class="qh-position-inline">
#<b>{{ queuePosition }}</b><small>前面{{ aheadCount }}</small>
</span>
<span v-else class="qh-position-inline qh-position-inline--idle">
<small>未排队</small>
</span>
```
- 排队时:`#5 前面4人`(蓝色背景徽章)
- 未排队时:`未排队`(斜体灰色占位)
- LV/分信息始终挂在 `pointsInfo` computed 上,存在时显示
## 五、非功能需求
| 维度 | 要求 |
|------|------|
| **性能** | Tab 切换响应 < 100ms(DOM 卸载/挂载);异常徽章实时刷新 < 200ms |
| **兼容** | 桌面端 ≥500px 双栏不变;移动端 <500px toggle 模式 |
| **无障碍** | Tab 切换支持键盘左右箭头;toggle 按钮含 aria-label |
| **动画** | 0 异常达标动画 3 秒后渐隐(避免持续吸引注意力) |
| **dark mode** | 所有徽章/角标在深色模式下对比度 ≥ 4.5:1 |
## 六、接口需求
**无新增后端 API**。本需求为前端 UI 改造,所有数据当前由 WS 推送(`/api/ws`)和 polling 拿到。
**前端组件 props/emit 变更**
| 组件 | 改动 | 说明 |
|------|------|------|
| `ChatView.vue` | 新增 `isMobilePanelOpen` ref | 默认 false`showRightPanel` 改为 `computed(!isMobile \|\| isMobilePanelOpen)` |
| `QueueWaiting.vue` | 新增 `showPosition?: boolean` prop | 默认 true(向后兼容) |
| `RightPanel.vue` | 传 `:show-position="false"` to QueueWaiting | queue-header 内联三件套 |
| `SelfDiagnosis.vue` | `defineExpose` 暴露 6 个 API | `totalBadge / totalBadgeByLevel / showCelebrate / isRunningAll / hasAnyDiagItems / runAllDiag`;外层 RightPanel 通过 `selfDiagRef` 读取 |
| `RightPanel.vue` | `selfDiagRef` 实例引用 | `<SelfDiagnosis ref="selfDiagRef" />`accordion-item__header 通过 `selfDiagRef?.totalBadgeByLevel?.w` 渲染徽章 |
## 七、验收标准
### 7.1 异常徽章
- ✅ 0 异常:徽章透明或显示"✓ 全部正常"(绿色)
- ✅ 5 个 warning:徽章显示 `5W · 0F`(红色背景)
- ✅ 2 warning + 1 fail:徽章显示 `2W · 1F`
- ✅ 全部清零:触发达标动画 3 秒后渐隐
### 7.2 6 Tab 横向并排
- ✅ 桌面端:6 个 Tab flex 等分显示,激活态有蓝色下划线
- ✅ 异常 Tab:底部红色双层下划线
- ✅ 移动端:RightPanel 不渲染(不需考虑)
### 7.3 移动端 toggle
- ✅ 默认显示左对话框
- ✅ 点击 🩺 切换按钮显示右栏(手风琴 + 智能推荐 + 排队)
- ✅ 点击 💬 切回左对话框,聊天滚动位置/输入文本保留
- ✅ 桌面端不显示 toggle 按钮(维持双栏)
### 7.4 queue 标题栏合并
- ✅ 标题栏显示 ⏳ + 排队等待 + #5 + 前面4人 + LV.3 + 120分 + 答题挑战
- ✅ queue-content 内不重复显示位置卡片
- ✅ 整体高度压缩 ~40px
### 7.5 Tab 切换行为
- ✅ 切到"设备" Tab:只显示设备相关诊断项 + 风险项
- ✅ 切到"账号" Tab:账号相关项替换设备相关项
- ✅ 不显示其他 Tab 的数据
### 7.6 异常 + 接入混合
- ✅ Tab "安全" 出现 1 异常 + 2 接入时显示 `安全 1•2`(红数字 + 灰斜体)
### 7.7 标题栏外提 + 一键诊断按钮(v2.3.3)
- ✅ 自助诊断展开区域**无**独立标题栏(v2.3.3 起)
- ✅ 异常徽章(5W·0F 或 ✓ 今日安全)显示在 RightPanel `accordion-item__header` 上 🩺 自助诊断 文字右侧
- ✅ 一键诊断按钮(绿色胶囊)位于徽章右侧;点击触发批量诊断,按钮文字变 "诊断中..."
- ✅ 点击徽章/按钮区域不会触发折叠(@click.stop
### 7.8 网络/系统 Tab 统一 .diag-item + 检测按钮(v2.3.4
- ✅ 网络 Tab 6 项 diagItems 与账号 Tab 4 项 riskItems 视觉一致(图标+名称+状态徽章+检测按钮)
- ✅ 每条诊断项右侧有 "检测" 按钮;点击触发该项重新检测
- ✅ diagItems 检测 800ms-1.5s 后显示 Mock 结果;riskItems 检测 800ms 模拟态后恢复
- ✅ pending(接入中)条目的检测按钮被禁用
### 7.9 内容高度自适应 + 排队标签置底 + 未排队占位(v2.3.5 / v2.3.6
- ✅ 自助诊断展开后:内容高度由条目数自然撑开,无 max-height 截断
- ✅ 右栏整体可滚动(`overflow-y: auto`);超出视口时滚动条出现
- ✅ 排队标签始终贴右栏底部(`margin-top: auto`),不依赖 flex:1
- ✅ 排队时标题栏显示 `#5 前面4人`(蓝色背景徽章)
- ✅ 未排队时标题栏显示 `未排队`(斜体灰色占位)
- ✅ LV/分信息始终按 `pointsInfo` 状态显示(存在则显示)
## 八、风险与降级
| 风险 | 等级 | 缓解措施 |
|------|------|---------|
| 移动端 toggle 切换导致左对话消息丢失 | 高 | Vue v-show 切换(不卸载 DOM),保留输入栏文本和滚动位置 |
| 异常徽章实时刷新失败(WS 断连) | 中 | 保留最后一次已知状态,15s 后重连重试 |
| 0 异常达标动画吸引注意力过度 | 低 | 3 秒后渐隐,不阻塞操作 |
| 移动端 toggle 按钮误触 | 低 | 切换后顶部按钮固定,触发区域 ≥ 44×44px(iOS HIG |
| queue 标题栏紧凑导致小屏挤压 | 中 | 6 项信息支持换行(flex-wrap: wrap |
## 九、决策清单(11 项全部锁定)
| # | 决策点 | 选项 | 状态 |
|---|--------|------|------|
| 1 | Tab 文字方案 | 精简版(网络/账号/设备/系统软件/安全/合规) | ✅ A 已锁定 |
| 2 | 异常徽章显示 | 分级 `5W · 0F` | ✅ v2.3 锁定 |
| 3 | 接入中角标位置 | A 文字右侧 | ✅ v2.3 锁定 |
| 4 | 桌面端 Tab 切换 | 只显示当前诊断项(已天然) | ✅ v2.3 锁定 |
| 5 | 移动端 toggle 方案 | A 顶部双按钮 | ✅ v2.3 锁定 |
| 6 | queue 标题栏合并 | 位置/LV/分 全部合并 | ✅ v2.3 锁定 |
| 7 | 异常+接入混合 | M1 双角标并存 | ✅ v2.3 锁定 |
| 8 | 标题栏位置 | 外提到 RightPanel accordion-item__headerSelfDiagnosis 通过 defineExpose 暴露) | ✅ v2.3.3 锁定 |
| 9 | 诊断项 UI 统一 | 所有条目统一 `.diag-item` 模板 + 检测按钮 | ✅ v2.3.4 锁定 |
| 10 | 内容高度策略 | 内容自然撑开 + 右栏 overflow-y: auto(删除 max-height | ✅ v2.3.5 锁定 |
| 11 | 排队位置策略 | queue-section margin-top: auto + 未排队时显示"未排队"占位 | ✅ v2.3.6 锁定 |
## 十、关联文档
- **原型图**`docs/01-产品文档/05-用户端H5/原型-REQ-用户-007-自助诊断面板优化-v1.0.html`
- **技术方案**`docs/02-技术文档/技术架构/技术方案-REQ-用户-007-自助诊断面板优化-v1.0.md`
- **任务说明书**`docs/07-项目管理/任务说明书/任务说明书-REQ-用户-007-自助诊断面板优化.md`
- **测试用例**`docs/03-测试文档/03-功能测试用例/TC-REQ-用户-007-自助诊断面板优化.md`
- **设计文档**`docs/02-技术文档/前端改造/设计-H5用户端实现概览-v1.0.md`(将追加 v2.3 章节)
- **现有代码**
- `src/frontend-h5/src/components/assistant/SelfDiagnosis.vue`
- `src/frontend-h5/src/components/assistant/QueueWaiting.vue`
- `src/frontend-h5/src/components/assistant/RightPanel.vue`
- `src/frontend-h5/src/views/ChatView.vue`
## 十一、变更日志
| 版本 | 日期 | 变更内容 | 作者 |
|------|------|---------|------|
| v1.0-Frozen | 2026-07-29 12:30 | 冻结 7 项决策(基于原型图 v2.3 累计升级);明确 4.1.1 三优化 + 4.2 6 Tab 横向 + 4.3 移动端 toggle A + 4.4 queue 合并 + 4.5 Tab 切换天然 + 4.6 M1 双角标 | Duckula + 宋献 |
| v2.3.1 | 2026-07-29 13:51 | 修复 Tab 网格 3×2 残留;CSS hash `B5iZHaWc` / JS `DWLS0ZMb` | Duckula |
| v2.3.1 | 2026-07-29 13:56 | 修复 Tab 标签仍为 4 字未用简称;CSS hash `CL_8bZ34` / JS `DRFXN8X-` | Duckula |
| v2.3.2 | 2026-07-29 14:35 | tabs computed 补 shortLabel 字段 + 恢复独立标题栏;CSS hash `BBycvpph` / JS `CbImPO1p` | Duckula |
| v2.3.3 | 2026-07-29 15:13 | 标题栏外提:SelfDiagnosis 删除 self-diag__headerdefineExpose 暴露 6 API 给 RightPanelCSS hash `CCAA9XNU` / JS `DkUAW4Da` | Duckula |
| v2.3.4 | 2026-07-29 15:38 | 网络/系统 Tab 统一 `.diag-item` 模板 + 检测按钮 + diagItemIcon 推断;CSS hash `C8kd4YJI` / JS `DBVfD3sS` | Duckula |
| v2.3.5 | 2026-07-29 15:51 | 删除 accordion-item--active max-height: 80% 限制 + 右栏 overflow-y: autoCSS hash `CNWqSvBk` / JS `5JtvoGYc` | Duckula |
| **v2.3.6** | **2026-07-29 16:56** | **queue-section margin-top: auto 置底 + 标题栏 v-else "未排队" 占位;CSS hash `GZiNwzZW` / JS `CAygTKBS`** | **Duckula** |
| v1.0-Live | 2026-07-29 17:11 | PRD 升级到 v1.0-Live:追加 4.1.2 标题栏外提、4.1.4 算法、4.1.5 简称、4.7-4.9 三节;§六 接口需求补 SelfDiagnosis defineExpose;§七 验收补 7.7-7.9 三节;§九 决策清单扩充到 11 项;§十一 变更日志补 v2.3.1-v2.3.6 全链路 hash | Duckula |
## 十二、待办
- [x] PRD v1.0-Frozen 建出(本文档)
- [x] 原型图 v2.3 升级(已含 7 项决策)
- [x] MEMORY.md 决策沉淀
- [x] PRD v1.0-Live 升级(追加 v2.3.3-v2.3.6 4 项决策 + 算法 + 简称 + 验收)
- [x] 原型图 v2.3.6 升级(标题栏外提 + 检测按钮 + 排队占位 + 标注区更新)
- [x] 技术方案 v1.0-Live 升级(v2.3.3-v2.3.6 完整实现方案)
- [x] 任务说明书 v1.0 建出
- [x] 测试用例 v1.0 建出(50 条)
- [x] 设计文档 v2.3 章节追加
- [x] 原型图重命名(v2.0.html → REQ-用户-007...v1.0.html
- [x] 前端改造实施(4 文件 + 8 次部署验证完成)
- [ ] 浏览器强缓存头部署(nginx 配置 `Cache-Control: no-cache, no-store, must-revalidate` HTML + `immutable 365d` assets
@@ -0,0 +1,617 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>H5端 - 群聊参与者展开/缩略双模式原型</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, 'PingFang SC', 'Helvetica Neue', sans-serif;
background: #f5f5f5;
max-width: 375px;
margin: 0 auto;
min-height: 100vh;
position: relative;
}
/* 模拟手机状态栏 */
.status-bar {
height: 44px;
background: #07C160;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 17px;
font-weight: 600;
}
/* 聊天区域 */
.chat-area {
background: #ededed;
min-height: 500px;
padding-bottom: 60px;
position: relative;
}
/* === 参与者缩略条 === */
.participant-strip {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
background: #fff;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
}
.participant-strip:hover { background: #fafafa; }
.strip-avatars {
display: flex;
align-items: center;
}
/* 头像基础样式 */
.avatar {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
color: #fff;
flex-shrink: 0;
border: 2px solid transparent;
background-size: cover;
background-position: center;
}
.avatar + .avatar { margin-left: -6px; }
/* 角色边框色 */
.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: 28px;
height: 28px;
border-radius: 50%;
background: #e0e0e0;
color: #666;
font-size: 11px;
display: flex;
align-items: center;
justify-content: center;
margin-left: -6px;
border: 2px solid #fff;
flex-shrink: 0;
}
.strip-label {
font-size: 12px;
color: #999;
margin-left: 4px;
}
/* 待加入红点角标 */
.avatar-wrap { position: relative; display: inline-flex; }
.pending-dot {
position: absolute;
top: -2px;
right: -2px;
width: 8px;
height: 8px;
border-radius: 50%;
background: #ee0a24;
border: 1.5px solid #fff;
}
/* === 底部弹出 Popup === */
.popup-overlay {
position: fixed;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 100%;
max-width: 375px;
height: 100%;
background: rgba(0, 0, 0, 0.7);
z-index: 100;
display: none;
align-items: flex-end;
}
.popup-overlay.show { display: flex; }
.popup-content {
background: #fff;
border-radius: 16px 16px 0 0;
width: 100%;
max-height: 60vh;
overflow-y: auto;
padding: 0 0 env(safe-area-inset-bottom);
animation: slideUp 0.3s ease;
}
@keyframes slideUp {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
.popup-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid #f0f0f0;
position: sticky;
top: 0;
background: #fff;
border-radius: 16px 16px 0 0;
z-index: 1;
}
.popup-title { font-size: 16px; font-weight: 600; color: #333; }
.popup-close {
width: 24px; height: 24px;
display: flex; align-items: center; justify-content: center;
font-size: 18px; color: #999; cursor: pointer;
}
.popup-body { padding: 8px 0; }
/* 参与者列表项 */
.participant-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
}
.participant-item:active { background: #f5f5f5; }
.participant-item .avatar-lg {
width: 40px;
height: 40px;
font-size: 16px;
border-width: 2px;
}
.participant-info {
flex: 1;
min-width: 0;
}
.participant-name {
font-size: 15px;
color: #333;
font-weight: 500;
display: flex;
align-items: center;
gap: 6px;
}
.participant-dept {
font-size: 12px;
color: #999;
margin-top: 2px;
}
/* 角色标签 */
.role-tag {
font-size: 10px;
padding: 1px 6px;
border-radius: 4px;
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; }
/* 退出按钮 */
.leave-btn {
font-size: 13px;
color: #ee0a24;
padding: 4px 12px;
border: 1px solid #ee0a24;
border-radius: 4px;
background: transparent;
cursor: pointer;
}
/* 底部输入栏模拟 */
.input-bar {
position: fixed;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 100%;
max-width: 375px;
height: 50px;
background: #f7f7f7;
border-top: 1px solid #e0e0e0;
display: flex;
align-items: center;
padding: 0 12px;
gap: 8px;
z-index: 10;
}
.input-bar input {
flex: 1;
height: 36px;
border: 1px solid #e0e0e0;
border-radius: 18px;
padding: 0 16px;
font-size: 14px;
background: #fff;
}
/* 控制面板 */
.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; }
/* 注释标签 */
.annotation {
position: absolute;
right: -180px;
top: 0;
width: 160px;
background: #fff3cd;
border: 1px solid #ffc107;
border-radius: 6px;
padding: 6px 10px;
font-size: 11px;
color: #856404;
line-height: 1.5;
}
.annotation::before {
content: '';
position: absolute;
left: -20px;
top: 10px;
border: 10px solid transparent;
border-right-color: #ffc107;
}
.section-title {
font-size: 12px;
color: #999;
padding: 8px 16px 4px;
font-weight: 500;
}
/* 聊天消息模拟 */
.chat-msg {
display: flex;
margin: 12px;
gap: 8px;
}
.chat-msg .msg-avatar {
width: 36px; height: 36px; border-radius: 4px;
background: #ccc; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
font-size: 14px; color: #fff;
}
.chat-msg .msg-content {
max-width: 70%;
}
.chat-msg .msg-name {
font-size: 11px; color: #999; margin-bottom: 2px;
}
.chat-msg .msg-bubble {
background: #fff; padding: 8px 12px; border-radius: 0 8px 8px 8px;
font-size: 14px; color: #333; display: inline-block;
}
</style>
</head>
<body>
<div class="control-panel">
<button onclick="togglePopup()">点击缩略条展开/收起</button>
<button onclick="toggleScene()">切换场景(3人/6人/8人)</button>
</div>
<div class="status-bar">IT服务支持</div>
<div class="chat-area">
<!-- 参与者缩略条 -->
<div class="participant-strip" onclick="togglePopup()" id="strip">
<div class="strip-avatars" id="stripAvatars">
<!-- JS 动态生成 -->
</div>
<span class="strip-label" id="stripLabel">点击查看全部</span>
</div>
<!-- 聊天消息模拟 -->
<div class="chat-msg">
<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="chat-msg">
<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="chat-msg">
<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>
<!-- 底部弹出 Popup -->
<div class="popup-overlay" id="popup">
<div class="popup-content">
<div class="popup-header">
<span class="popup-title">参与者 (<span id="totalCount">0</span>)</span>
<span class="popup-close" onclick="togglePopup()"></span>
</div>
<div class="popup-body" id="popupBody">
<!-- JS 动态生成 -->
</div>
</div>
</div>
<!-- 底部输入栏 -->
<div class="input-bar">
<input type="text" placeholder="输入消息..." readonly>
</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 = 4; // H5端超员阈值
// 场景数据
const scenes = [
// 3人场景
[
{ id: '1', name: '张明', avatar: '', dept: 'IT支持组', role: 'primary_agent', joined: true, isSelf: false, joinedAt: '2026-07-10T10:00:00' },
{ id: '2', name: '李四', avatar: '', dept: '财务部', role: 'owner', joined: true, isSelf: true, joinedAt: '2026-07-10T10:01:00' },
{ id: '3', name: '王五', avatar: '', dept: '网络运维组', role: 'invitee', joined: true, isSelf: false, joinedAt: '2026-07-10T10:05:00' }
],
// 6人场景
[
{ id: '1', name: '张明', avatar: '', dept: 'IT支持组', role: 'primary_agent', joined: true, isSelf: false, joinedAt: '2026-07-10T10:00:00' },
{ id: '2', name: '李四', avatar: '', dept: '财务部', role: 'owner', joined: true, isSelf: true, joinedAt: '2026-07-10T10:01: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' }
],
// 8人场景(超员)
[
{ id: '1', name: '张明', avatar: '', dept: 'IT支持组', role: 'primary_agent', joined: true, isSelf: false, joinedAt: '2026-07-10T10:00:00' },
{ id: '2', name: '李四', avatar: '', dept: '财务部', role: 'owner', joined: true, isSelf: true, joinedAt: '2026-07-10T10:01: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 = [];
function getInitial(name) {
return name ? name.charAt(0) : '?';
}
function renderStrip() {
const container = document.getElementById('stripAvatars');
const label = document.getElementById('stripLabel');
container.innerHTML = '';
// 排列:主责 → 发起人 → 被邀请人(按joinedAt)
const sorted = [...participants].sort((a, b) => {
const order = { primary_agent: 0, owner: 1, invitee: 2 };
if (order[a.role] !== order[b.role]) return order[a.role] - order[b.role];
if (a.joinedAt && b.joinedAt) return new Date(a.joinedAt) - new Date(b.joinedAt);
return 0;
});
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 = getInitial(p.name);
// 待加入红点
if (!p.joined) {
const dot = document.createElement('div');
dot.className = 'pending-dot';
wrap.appendChild(dot);
}
wrap.appendChild(av);
container.appendChild(wrap);
}
// 超员 +N
if (sorted.length > MAX_AVATARS) {
const more = document.createElement('div');
more.className = 'avatar-more';
more.textContent = `+${sorted.length - MAX_AVATARS}`;
container.appendChild(more);
}
label.textContent = `${participants.length}位参与者${hasPending ? ' · 有待加入' : ''} · 点击查看`;
}
function renderPopup() {
const body = document.getElementById('popupBody');
const count = document.getElementById('totalCount');
count.textContent = participants.length;
body.innerHTML = '';
const sorted = [...participants].sort((a, b) => {
const order = { primary_agent: 0, owner: 1, invitee: 2 };
if (order[a.role] !== order[b.role]) return order[a.role] - order[b.role];
if (a.joinedAt && b.joinedAt) return new Date(a.joinedAt) - new Date(b.joinedAt);
return 0;
});
// 已加入
const joined = sorted.filter(p => p.joined);
const pending = sorted.filter(p => !p.joined);
if (joined.length > 0) {
const title = document.createElement('div');
title.className = 'section-title';
title.textContent = `已加入 (${joined.length})`;
body.appendChild(title);
joined.forEach(p => body.appendChild(createItem(p)));
}
if (pending.length > 0) {
const title = document.createElement('div');
title.className = 'section-title';
title.textContent = `待加入 (${pending.length})`;
body.appendChild(title);
pending.forEach(p => body.appendChild(createItem(p)));
}
}
function createItem(p) {
const item = document.createElement('div');
item.className = 'participant-item';
const av = document.createElement('div');
av.className = `avatar avatar-lg role-${p.role}`;
if (p.isSelf) av.classList.add('is-self');
av.style.background = ROLE_BG[p.role];
av.textContent = getInitial(p.name);
const info = document.createElement('div');
info.className = 'participant-info';
const nameRow = document.createElement('div');
nameRow.className = 'participant-name';
const name = document.createElement('span');
name.textContent = p.name + (p.isSelf ? '(我)' : '');
nameRow.appendChild(name);
const tag = document.createElement('span');
tag.className = `role-tag ${p.role}`;
tag.textContent = ROLE_LABELS[p.role];
nameRow.appendChild(tag);
const dept = document.createElement('div');
dept.className = 'participant-dept';
dept.textContent = p.dept + (p.joined ? '' : ' · 待加入');
info.appendChild(nameRow);
info.appendChild(dept);
item.appendChild(av);
item.appendChild(info);
// 退出按钮(仅发起人可退出)
if (p.isSelf) {
const btn = document.createElement('button');
btn.className = 'leave-btn';
btn.textContent = '退出';
btn.onclick = (e) => {
e.stopPropagation();
alert('确认退出此会话?退出后将不再接收消息。');
};
item.appendChild(btn);
}
return item;
}
function togglePopup() {
const popup = document.getElementById('popup');
popup.classList.toggle('show');
if (popup.classList.contains('show')) {
renderPopup();
}
}
function toggleScene() {
currentScene = (currentScene + 1) % scenes.length;
participants = JSON.parse(JSON.stringify(scenes[currentScene]));
renderStrip();
}
// 初始化
participants = JSON.parse(JSON.stringify(scenes[0]));
renderStrip();
// 点击遮罩关闭
document.getElementById('popup').addEventListener('click', (e) => {
if (e.target.id === 'popup') togglePopup();
});
</script>
</body>
</html>
@@ -0,0 +1,387 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>员工端结束会话固定按钮 - 交互原型</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: #f5f5f5;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}
.phone-frame {
width: 390px;
height: 844px;
background: #fff;
border-radius: 36px;
box-shadow: 0 20px 60px rgba(0,0,0,.15);
overflow: hidden;
position: relative;
}
/* 标题栏 */
.header {
height: 56px;
background: #fff;
border-bottom: 1px solid #eee;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
position: relative;
}
.header-left {
display: flex;
align-items: center;
gap: 8px;
}
.logo-block {
width: 28px; height: 28px;
background: #07C160;
color: #fff;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
}
.header-title {
font-size: 15px;
font-weight: 600;
color: #333;
}
.header-actions {
display: flex;
align-items: center;
gap: 12px;
}
/* 头像区域 - 纯展示 */
.user-info {
display: flex;
align-items: center;
gap: 8px;
}
.avatar {
width: 32px; height: 32px;
border-radius: 6px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='11' r='6' fill='%2307C160'/%3E%3Cellipse cx='16' cy='28' rx='12' ry='8' fill='%2307C160'/%3E%3C/svg%3E");
background-size: cover;
}
.username {
font-size: 13px;
color: #666;
max-width: 60px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* "结束会话"固定按钮 */
.exit-btn {
padding: 4px 12px;
font-size: 12px;
color: #ee0a24;
background: transparent;
border: 1px solid #ee0a24;
border-radius: 14px;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
line-height: 1.4;
font-family: inherit;
}
.exit-btn:hover, .exit-btn.hover {
background: #ee0a24;
color: #fff;
}
/* 聊天区 */
.chat-area {
height: calc(100% - 56px);
display: flex;
flex-direction: column;
background: #ededed;
}
.messages {
flex: 1;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
overflow-y: auto;
}
.msg {
max-width: 75%;
padding: 10px 14px;
border-radius: 10px;
font-size: 14px;
line-height: 1.5;
}
.msg-ai {
align-self: flex-start;
background: #fff;
color: #333;
border-top-left-radius: 2px;
}
.msg-user {
align-self: flex-end;
background: #07C160;
color: #fff;
border-top-right-radius: 2px;
}
.msg-label {
font-size: 11px;
color: #07C160;
margin-bottom: 4px;
}
/* 确认对话框 */
.dialog-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,.5);
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
animation: fadeIn .2s ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.dialog {
background: #fff;
border-radius: 16px;
width: 300px;
overflow: hidden;
animation: scaleIn .25s ease;
}
@keyframes scaleIn {
from { opacity: 0; transform: scale(.9); }
to { opacity: 1; transform: scale(1); }
}
.dialog-body {
padding: 28px 20px 20px;
text-align: center;
}
.dialog-icon {
font-size: 44px;
margin-bottom: 12px;
}
.dialog-title {
font-size: 17px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.dialog-message {
font-size: 13px;
color: #999;
line-height: 1.5;
}
.dialog-footer {
display: flex;
border-top: 1px solid #eee;
}
.dialog-btn {
flex: 1;
padding: 14px;
text-align: center;
font-size: 15px;
cursor: pointer;
border: none;
background: none;
transition: background .15s;
}
.dialog-btn:hover {
background: #f5f5f5;
}
.dialog-btn-cancel {
color: #666;
border-right: 1px solid #eee;
}
.dialog-btn-confirm {
color: #ee0a24;
font-weight: 600;
}
/* Toast */
.toast {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(0,0,0,.75);
color: #fff;
padding: 12px 24px;
border-radius: 8px;
font-size: 14px;
z-index: 300;
animation: fadeIn .2s ease;
}
/* 状态切换按钮 */
.state-toggle {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
z-index: 50;
display: flex;
gap: 8px;
}
.state-btn {
padding: 6px 14px;
border-radius: 16px;
border: 1px solid #ddd;
background: #fff;
font-size: 12px;
cursor: pointer;
color: #666;
transition: all .15s;
font-family: inherit;
}
.state-btn.active {
background: #07C160;
color: #fff;
border-color: #07C160;
}
.state-label {
position: absolute;
top: -24px;
left: 0;
right: 0;
text-align: center;
font-size: 11px;
color: #999;
}
.hidden { display: none !important; }
</style>
</head>
<body>
<div class="phone-frame" id="app">
<!-- 标题栏 -->
<div class="header">
<div class="header-left">
<span class="logo-block">IT</span>
<span class="header-title">智能IT服务</span>
</div>
<div class="header-actions">
<!-- 员工头像+姓名(纯展示) -->
<div class="user-info">
<div class="avatar"></div>
<span class="username">宋献</span>
</div>
<!-- "结束会话"固定按钮 -->
<button class="exit-btn" id="exitBtn" onclick="showConfirm()">结束会话</button>
</div>
</div>
<!-- 聊天消息区 -->
<div class="chat-area" id="chatArea">
<div class="messages">
<div class="msg msg-ai">
<div class="msg-label">🤖 AI助手</div>
您好!我是IT智能服务助手,请问有什么可以帮您的?
</div>
<div class="msg msg-user">
我的打印机连不上了
</div>
<div class="msg msg-ai">
<div class="msg-label">🤖 AI助手</div>
了解,打印机连接问题通常有几种可能原因。我先帮您排查一下:<br><br>
1. 请确认打印机电源已开启<br>
2. 检查USB线或网络连接是否正常<br>
3. 在电脑上打开"设备和打印机"查看打印机状态
</div>
</div>
</div>
<!-- 确认对话框 -->
<div class="dialog-overlay hidden" id="dialogOverlay">
<div class="dialog">
<div class="dialog-body">
<div class="dialog-icon">⚠️</div>
<div class="dialog-title">结束会话</div>
<div class="dialog-message">退出后会话记录会清空,<br>当前咨询进度将丢失,<br>确定要结束会话吗?</div>
</div>
<div class="dialog-footer">
<button class="dialog-btn dialog-btn-cancel" onclick="cancelExit()">取消</button>
<button class="dialog-btn dialog-btn-confirm" onclick="confirmExit()">确定退出</button>
</div>
</div>
</div>
<!-- Toast -->
<div class="toast hidden" id="toast"></div>
<!-- 状态切换(演示用) -->
<div class="state-toggle">
<button class="state-btn active" onclick="setState('default', this)">默认</button>
<button class="state-btn" onclick="setState('hover', this)">Hover</button>
<button class="state-btn" onclick="setState('confirm', this)">确认退出</button>
</div>
</div>
<script>
function showConfirm() {
document.getElementById('dialogOverlay').classList.remove('hidden')
}
function cancelExit() {
document.getElementById('dialogOverlay').classList.add('hidden')
}
function confirmExit() {
document.getElementById('dialogOverlay').classList.add('hidden')
showToast('已退出登录')
setTimeout(() => {
showToast('正在跳转登录页…')
// 实际场景:window.location.href = '/h5/login' 或 closeWindow
}, 1500)
}
function showToast(msg) {
const toast = document.getElementById('toast')
toast.textContent = msg
toast.classList.remove('hidden')
setTimeout(() => toast.classList.add('hidden'), 2000)
}
function setState(state, btn) {
// 重置按钮
document.querySelectorAll('.state-btn').forEach(b => b.classList.remove('active'))
btn.classList.add('active')
// 重置所有状态
cancelExit()
const exitBtn = document.getElementById('exitBtn')
exitBtn.classList.remove('hover')
switch(state) {
case 'default':
break
case 'hover':
exitBtn.classList.add('hover')
break
case 'confirm':
showConfirm()
break
}
}
</script>
</body>
</html>
@@ -0,0 +1,990 @@
<!DOCTYPE html>
<html lang="zh-CN" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>智能推荐重构原型 v1.0 · REQ-用户-006</title>
<style>
/* ============================================================================
CSS 变量(沿用 H5 用户端原型 v2.0 风格)
============================================================================ */
:root {
--accent: #3b82f6;
--accent-soft: rgba(59, 130, 246, 0.12);
--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;
/* v1.0 新增:layer 边框颜色(PRD §4.5 UI 规范)*/
--color-layer-l1-border: #22c55e; /* L1 相关推荐:绿色 */
--color-layer-l2-border: #f97316; /* L2 运维提醒:橙色 */
--color-layer-l3-border: #94a3b8; /* L3 常用资源:灰色 */
--color-layer-a-border: #3b82f6; /* 来源 A Dify action:蓝色 */
--color-progress-border: #8b5cf6; /* 审批进度:紫色 */
}
[data-theme="dark"] {
--accent: #3b82f6;
--bg-primary: #0f172a;
--bg-secondary: #1e293b;
--bg-tertiary: #162032;
--text-primary: #e2e8f0;
--text-secondary: #94a3b8;
--text-tertiary: #64748b;
--border-color: #334155;
--shadow: 0 1px 4px rgba(0,0,0,0.3);
--color-layer-l1-border: #4ade80;
--color-layer-l2-border: #fb923c;
--color-layer-l3-border: #64748b;
--color-layer-a-border: #60a5fa;
--color-progress-border: #a78bfa;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
padding: 20px;
}
/* ============================================================================
顶部导航
============================================================================ */
.topbar {
max-width: 1400px;
margin: 0 auto 20px;
background: var(--bg-secondary);
border-radius: var(--radius);
padding: 16px 24px;
box-shadow: var(--shadow);
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
.topbar h1 { font-size: 18px; color: var(--accent); }
.topbar .meta { color: var(--text-secondary); font-size: 13px; }
.topbar .actions button {
background: var(--accent-soft);
color: var(--accent);
border: none;
padding: 6px 14px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
margin-left: 8px;
}
/* ============================================================================
场景标签页
============================================================================ */
.scene-tabs {
max-width: 1400px;
margin: 0 auto 20px;
background: var(--bg-secondary);
border-radius: var(--radius);
padding: 12px 16px;
box-shadow: var(--shadow);
display: flex;
gap: 8px;
overflow-x: auto;
}
.scene-tab {
background: transparent;
border: 1px solid var(--border-color);
color: var(--text-secondary);
padding: 8px 14px;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
white-space: nowrap;
transition: all 0.2s;
}
.scene-tab.active {
background: var(--accent);
color: white;
border-color: var(--accent);
}
/* ============================================================================
场景内容区
============================================================================ */
.scene {
max-width: 1400px;
margin: 0 auto;
background: var(--bg-secondary);
border-radius: var(--radius);
padding: 24px;
box-shadow: var(--shadow);
display: none;
}
.scene.active { display: block; }
.scene h2 {
font-size: 18px;
margin-bottom: 8px;
color: var(--text-primary);
}
.scene .subtitle {
color: var(--text-secondary);
font-size: 13px;
margin-bottom: 20px;
}
/* ============================================================================
对比布局(重构前 vs 重构后)
============================================================================ */
.compare {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
}
@media (max-width: 900px) { .compare { grid-template-columns: 1fr; } }
.compare-col h3 {
font-size: 14px;
margin-bottom: 12px;
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 8px;
}
.compare-col.old h3::before {
content: "重构前";
background: #fee2e2;
color: #dc2626;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
}
.compare-col.new h3::before {
content: "重构后";
background: #dcfce7;
color: #16a34a;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
}
/* ============================================================================
H5 模拟容器
============================================================================ */
.h5-mock {
background: var(--bg-tertiary);
border-radius: 8px;
padding: 12px;
display: grid;
grid-template-columns: 1fr 280px;
gap: 12px;
min-height: 500px;
}
.h5-mock.single-col { grid-template-columns: 1fr; }
.h5-chat, .h5-right {
background: var(--bg-secondary);
border-radius: 8px;
padding: 12px;
overflow-y: auto;
max-height: 600px;
}
/* ============================================================================
气泡样式
============================================================================ */
.bubble {
max-width: 75%;
padding: 10px 14px;
border-radius: 10px;
margin-bottom: 8px;
font-size: 13px;
}
.bubble.user {
background: var(--accent);
color: white;
margin-left: auto;
border-bottom-right-radius: 2px;
}
.bubble.ai {
background: var(--bg-tertiary);
color: var(--text-primary);
border-bottom-left-radius: 2px;
}
.bubble.system {
background: transparent;
color: var(--text-tertiary);
font-size: 12px;
text-align: center;
margin: 8px auto;
}
/* ============================================================================
推荐卡片(核心:4 类卡片样式)
============================================================================ */
.rec-card {
background: var(--bg-secondary);
border-left: 4px solid;
border-radius: 8px;
padding: 12px;
margin-bottom: 10px;
position: relative;
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
font-size: 13px;
}
.rec-card.layer-L1, .rec-card.source-keyword { border-left-color: var(--color-layer-l1-border); }
.rec-card.layer-L2, .rec-card.source-profile { border-left-color: var(--color-layer-l2-border); }
.rec-card.layer-L3, .rec-card.source-role { border-left-color: var(--color-layer-l3-border); }
.rec-card.layer-A, .rec-card.source-dify { border-left-color: var(--color-layer-a-border); }
.rec-card.layer-progress, .rec-card.source-approval { border-left-color: var(--color-progress-border); }
.rec-card .rec-title {
font-weight: 600;
color: var(--text-primary);
margin-bottom: 4px;
display: flex;
align-items: center;
gap: 6px;
}
.rec-card .rec-icon { font-size: 16px; }
.rec-card .rec-desc {
color: var(--text-secondary);
font-size: 12px;
margin-bottom: 8px;
line-height: 1.5;
}
.rec-card .rec-items {
list-style: none;
display: flex;
flex-direction: column;
gap: 4px;
}
.rec-card .rec-items li {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 0;
font-size: 12px;
color: var(--text-secondary);
}
.rec-card .rec-items li::before {
content: "•";
color: var(--accent);
}
.rec-card .rec-items a {
color: var(--accent);
text-decoration: none;
}
.rec-card .rec-meta {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed var(--border-color);
font-size: 11px;
color: var(--text-tertiary);
}
.rec-card .rec-source-tag {
background: var(--bg-tertiary);
padding: 2px 6px;
border-radius: 4px;
font-size: 10px;
}
.rec-card .rec-dismiss {
position: absolute;
top: 6px;
right: 8px;
background: transparent;
border: none;
color: var(--text-tertiary);
cursor: pointer;
font-size: 14px;
line-height: 1;
}
/* 操作步骤可展开样式 */
.rec-card.expandable .rec-steps {
display: none;
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed var(--border-color);
}
.rec-card.expandable.open .rec-steps { display: block; }
.rec-card .rec-toggle {
background: transparent;
border: 1px solid var(--border-color);
color: var(--accent);
font-size: 11px;
padding: 2px 8px;
border-radius: 4px;
cursor: pointer;
margin-top: 4px;
}
.rec-card .step-item {
padding: 6px 0;
border-bottom: 1px solid var(--border-color);
font-size: 12px;
}
.rec-card .step-item:last-child { border-bottom: none; }
.rec-card .step-num {
background: var(--accent);
color: white;
border-radius: 50%;
width: 18px;
height: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 10px;
margin-right: 6px;
}
/* 联系人/联系窗口卡片样式 */
.rec-card .contact-window-btn, .rec-card .contact-card-btn {
display: inline-block;
background: var(--accent-soft);
color: var(--accent);
padding: 4px 10px;
border-radius: 6px;
font-size: 12px;
text-decoration: none;
margin-top: 6px;
}
/* 审批进度卡片样式 */
.rec-card.layer-progress .progress-bar {
height: 4px;
background: var(--bg-tertiary);
border-radius: 2px;
margin-top: 6px;
overflow: hidden;
}
.rec-card.layer-progress .progress-fill {
height: 100%;
background: var(--color-progress-border);
border-radius: 2px;
transition: width 0.3s;
}
.rec-card.layer-progress .progress-status {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 10px;
font-weight: 600;
margin-top: 4px;
}
.rec-card.layer-progress .progress-status.pending {
background: rgba(251, 146, 60, 0.15);
color: #ea580c;
}
.rec-card.layer-progress .progress-status.approved {
background: rgba(34, 197, 94, 0.15);
color: #16a34a;
}
/* ============================================================================
旧的"重构前"样式(保留做对比)
============================================================================ */
.rec-section-title {
font-size: 12px;
color: var(--text-tertiary);
margin-bottom: 8px;
padding: 0 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.rec-card-old {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 10px;
margin-bottom: 10px;
font-size: 13px;
}
/* ============================================================================
注释说明区
============================================================================ */
.notes {
background: var(--bg-tertiary);
border-left: 3px solid var(--accent);
padding: 12px 16px;
border-radius: 6px;
margin-top: 16px;
font-size: 13px;
color: var(--text-secondary);
}
.notes strong { color: var(--text-primary); }
.notes ul { padding-left: 20px; margin-top: 6px; }
.notes li { margin-bottom: 4px; }
/* ============================================================================
冷启动空状态
============================================================================ */
.empty-state {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
min-height: 400px;
color: var(--text-tertiary);
font-size: 13px;
font-style: italic;
}
</style>
</head>
<body>
<!-- 顶部导航 -->
<div class="topbar">
<div>
<h1>智能推荐重构原型 v1.0</h1>
<div class="meta">REQ-用户-006 · 基于 PRD v1.0-Frozen + 技术方案 v1.0 · 2026-07-28</div>
</div>
<div class="actions">
<button onclick="toggleTheme()">🌓 切换主题</button>
</div>
</div>
<!-- 场景标签 -->
<div class="scene-tabs">
<button class="scene-tab active" onclick="showScene(0)">1. 重构前 vs 重构后</button>
<button class="scene-tab" onclick="showScene(1)">2. 冷启动空状态(决策 ① A</button>
<button class="scene-tab" onclick="showScene(2)">3. AI 回复瞬间 FIFO 插入</button>
<button class="scene-tab" onclick="showScene(3)">4. 4 类卡片样式</button>
<button class="scene-tab" onclick="showScene(4)">5. 跨会话持久化</button>
<button class="scene-tab" onclick="showScene(5)">6. 话题切换</button>
<button class="scene-tab" onclick="showScene(6)">7. 触发失败降级</button>
</div>
<!-- 场景 1:重构前 vs 重构后 -->
<div class="scene active" id="scene-0">
<h2>场景 1:重构前 vs 重构后 整体对比</h2>
<div class="subtitle">核心改动:移除"智能推荐"分类标题 + 边框颜色区分 layer + FIFO 插入</div>
<div class="compare">
<!-- 重构前 -->
<div class="compare-col old">
<h3>v2.3 现状(有标题 + 分组)</h3>
<div class="h5-mock">
<div class="h5-chat">
<div class="bubble user">VPN 连不上怎么办?</div>
<div class="bubble ai">请尝试以下步骤:1. 检查网络 2. 重启 aTrust</div>
</div>
<div class="h5-right">
<div class="rec-section-title">⚡ 相关推荐</div>
<div class="rec-card-old">
<div style="font-weight:600">VPN 相关信息</div>
<div style="font-size:12px;color:#555770">客户端下载、服务器地址...</div>
</div>
<div class="rec-section-title" style="margin-top:12px">🔔 运维提醒</div>
<div class="rec-card-old">
<div style="font-weight:600">火绒病毒库过期</div>
<div style="font-size:12px;color:#555770">病毒库已 7 天未更新</div>
</div>
<div class="rec-section-title" style="margin-top:12px">💼 常用资源</div>
<div class="rec-card-old">
<div style="font-weight:600">开发者资源</div>
<div style="font-size:12px;color:#555770">代码仓库、API 文档...</div>
</div>
</div>
</div>
<div class="notes">
<strong>问题:</strong>
<ul>
<li>三个分类标题占据空间,视觉杂乱</li>
<li>无 FIFO,新旧卡片位置固定</li>
<li>分组标题强化"分类"概念,用户误以为必须点击分类切换</li>
<li>卡片样式无 layer 区分,仅靠标题文字</li>
</ul>
</div>
</div>
<!-- 重构后 -->
<div class="compare-col new">
<h3>v1.0 重构后(无标题 + 边框颜色 + FIFO)</h3>
<div class="h5-mock">
<div class="h5-chat">
<div class="bubble user">VPN 连不上怎么办?</div>
<div class="bubble ai">请尝试以下步骤:1. 检查网络 2. 重启 aTrust<br><span style="font-size:11px;color:#9ca3af">📋💻</span></div>
</div>
<div class="h5-right">
<!-- v1.0 重构后:无任何分类标题 -->
<!-- v1.0 重构后:FIFO 顺序,最新在最上 -->
<div class="rec-card source-keyword">
<div class="rec-title"><span class="rec-icon">🔐</span>VPN 相关信息</div>
<div class="rec-desc">VPN 连接所需资源</div>
<ul class="rec-items">
<li>客户端下载(支持 Windows/macOS</li>
<li>服务器地址:vpn.servyou-it.com</li>
<li>申请审批 → <a>去申请</a></li>
</ul>
<div class="rec-meta">
<span class="rec-source-tag">L1 · 关键词命中</span>
<span>2 秒前</span>
</div>
</div>
<div class="rec-card source-profile">
<div class="rec-title"><span class="rec-icon">🦠</span>火绒病毒库过期</div>
<div class="rec-desc">病毒库已 7 天未更新,建议立即升级</div>
<div class="rec-meta">
<span class="rec-source-tag">L2 · 画像触发</span>
<span>2 秒前</span>
</div>
</div>
<div class="rec-card source-role">
<div class="rec-title"><span class="rec-icon">💻</span>开发者资源</div>
<div class="rec-desc">代码仓库、API 文档、测试环境申请</div>
<div class="rec-meta">
<span class="rec-source-tag">L3 · 角色匹配</span>
<span>2 秒前</span>
</div>
</div>
</div>
</div>
<div class="notes">
<strong>改进:</strong>
<ul>
<li>✅ 移除所有分类标题("⚡ 相关推荐"/"🔔 运维提醒"/"💼 常用资源"),视觉更清爽</li>
<li>✅ 边框颜色区分 layer:🟢 L1 / 🟠 L2 / ⚪ L3 / 🔵 A / 🟣 progress</li>
<li>✅ FIFO:新卡片插入最上方(最新在上)</li>
<li>✅ 来源标签在卡片底部可查(L1/L2/L3 + 来源类型)</li>
</ul>
</div>
</div>
</div>
</div>
<!-- 场景 2:冷启动空状态 -->
<div class="scene" id="scene-1">
<h2>场景 2:冷启动空状态(PRD §4.7.4 决策 ① A</h2>
<div class="subtitle">用户进入会话但未发言 / AI 还未回复 → 右侧栏完全空白(不强求兜底)</div>
<div class="h5-mock single-col">
<div class="h5-chat">
<div class="bubble user">VPN 连不上怎么办?</div>
<div class="bubble system">(消息已发送,等待 AI 回复...</div>
</div>
</div>
<div class="h5-mock">
<div class="h5-chat">
<div class="bubble user">VPN 连不上怎么办?</div>
<div class="bubble system">AI 正在思考...</div>
</div>
<div class="h5-right">
<!-- 冷启动空状态:完全空白 -->
<div class="empty-state">
<!-- 不显示任何卡片 -->
<!-- 不显示"暂无推荐"提示卡 -->
<!-- 不显示"试试问 XXX"引导 -->
<!-- 完全空白,等待 AI 回复到达 -->
</div>
</div>
</div>
<div class="notes">
<strong>PRD §4.7.4 决策 ① A 落地:</strong>
<ul>
<li>✅ 进会话 5s 内无任何消息:右侧栏空白</li>
<li>✅ 首条消息刚发出、AI 未回复:右侧栏空白</li>
<li>✅ AI 回复到达瞬间:按 §4.7.3 合并规则填充真实推荐</li>
<li>✅ 完全无数据:右侧栏空白(不显示兜底提示)</li>
<li>❌ 重构前(旧方案):进会话就显示"⚡ 相关推荐"占位</li>
<li>❌ 重构前(旧方案):无消息时显示"VPN/Outlook/打印机"等假数据</li>
</ul>
</div>
</div>
<!-- 场景 3:AI 回复瞬间 FIFO 插入 -->
<div class="scene" id="scene-2">
<h2>场景 3:AI 回复瞬间 FIFO 插入</h2>
<div class="subtitle">新生成的卡片插入到右侧栏最上方(最新优先)</div>
<div class="h5-mock">
<div class="h5-chat">
<div class="bubble user">VPN 连不上怎么办?</div>
<div class="bubble ai">
请尝试以下步骤:<br>
1. 检查网络连接<br>
2. 重启 aTrust 客户端<br>
3. 如仍无法连接,<strong>右侧已为您准备好重连步骤</strong>
<div style="font-size:11px;color:#9ca3af;margin-top:4px">📋💻</div>
</div>
<div class="bubble user">顺便帮我看下电脑补丁情况</div>
<div class="bubble ai">
您的电脑有 12 个安全补丁未安装,<strong>右侧已准备好补丁列表</strong>
<div style="font-size:11px;color:#9ca3af;margin-top:4px">💻⚠️</div>
</div>
</div>
<div class="h5-right">
<!-- FIFO:新卡片在最上方 -->
<!-- 最新:补丁缺失提醒(刚生成的 L1 推荐)-->
<div class="rec-card source-keyword">
<div class="rec-title"><span class="rec-icon">🔧</span>系统补丁缺失</div>
<div class="rec-desc">您的电脑有 12 个安全补丁未安装,可能存在安全风险</div>
<div class="rec-meta">
<span class="rec-source-tag">L1 · 关键词"补丁"</span>
<span>刚刚</span>
</div>
</div>
<!-- 之前:VPN 步骤 -->
<div class="rec-card source-dify expandable open">
<div class="rec-title"><span class="rec-icon">🔐</span>VPN 重连步骤</div>
<div class="rec-desc">完整操作步骤,点击展开</div>
<button class="rec-toggle" onclick="toggleExpand(this)">收起 ▲</button>
<div class="rec-steps">
<div class="step-item"><span class="step-num">1</span>打开 aTrust 客户端</div>
<div class="step-item"><span class="step-num">2</span>点击"重新连接"</div>
<div class="step-item"><span class="step-num">3</span>输入账号密码</div>
<div class="step-item"><span class="step-num">4</span>如失败,重启客户端</div>
</div>
<div class="rec-meta">
<span class="rec-source-tag">A · Dify 步骤卡</span>
<span>5 秒前</span>
</div>
</div>
</div>
</div>
<div class="notes">
<strong>FIFO 规则(PRD §4.7.3):</strong>
<ul>
<li>✅ 新卡片插入最上方("刚刚"在最上)</li>
<li>✅ 旧卡片往下排("5 秒前"在下)</li>
<li>✅ 上限 3 张:超出部分自动滚出(用户看不见)</li>
<li>✅ 来源 A(Dify 步骤卡)用蓝色边框 + 可展开/收起</li>
</ul>
</div>
</div>
<!-- 场景 44 类卡片样式 -->
<div class="scene" id="scene-3">
<h2>场景 44 类卡片样式</h2>
<div class="subtitle">基于 PRD §4.5 + §4.6 决策的完整卡片类型展示</div>
<!-- 4.1 操作步骤(A 来源,可展开) -->
<h3 style="margin-top:20px;margin-bottom:8px;color:var(--text-secondary);font-size:14px">① 操作步骤(来自 Dify action · 可展开)</h3>
<div class="rec-card source-dify expandable">
<div class="rec-title"><span class="rec-icon">🔐</span>VPN 重连操作步骤</div>
<div class="rec-desc">点击展开查看完整步骤</div>
<button class="rec-toggle" onclick="toggleExpand(this)">展开 ▼</button>
<div class="rec-steps">
<div class="step-item"><span class="step-num">1</span>打开 aTrust 客户端</div>
<div class="step-item"><span class="step-num">2</span>点击"重新连接"</div>
<div class="step-item"><span class="step-num">3</span>输入账号密码</div>
<div class="step-item"><span class="step-num">4</span>如失败,重启客户端</div>
</div>
<div class="rec-meta">
<span class="rec-source-tag">A · Dify 步骤卡</span>
<span>点击操作按钮 → aTrust 重连</span>
</div>
</div>
<!-- 4.2 联系窗口(来自 Dify action · 仅左侧栏) -->
<h3 style="margin-top:20px;margin-bottom:8px;color:var(--text-secondary);font-size:14px">② 联系窗口(仅左侧栏出现,右侧栏不显示)</h3>
<div style="background:var(--bg-tertiary);padding:12px;border-radius:8px;color:var(--text-secondary);font-size:13px">
位置:左侧气泡 / 右侧栏不出现<br>
关联:员工服务窗口链接(chat_id / openid<br>
示例气泡(左侧):<br>
<div class="rec-card source-dify" style="margin-top:8px;max-width:50%">
<div class="rec-title"><span class="rec-icon">💬</span>IT 服务窗口</div>
<div class="rec-desc">需要人工帮助?点击下方按钮跳转 IT 服务窗口</div>
<a class="contact-window-btn" href="javascript:void(0)">打开 IT 服务窗口</a>
<div class="rec-meta">
<span class="rec-source-tag">A · Dify action</span>
</div>
</div>
</div>
<!-- 4.3 联系人名片(来自 Dify action · 仅左侧栏) -->
<h3 style="margin-top:20px;margin-bottom:8px;color:var(--text-secondary);font-size:14px">③ 联系人名片(仅左侧栏出现,右侧栏不显示)</h3>
<div style="background:var(--bg-tertiary);padding:12px;border-radius:8px;color:var(--text-secondary);font-size:13px">
位置:左侧气泡 / 右侧栏不出现<br>
关联:员工名片(user_id)<br>
示例气泡(左侧):<br>
<div class="rec-card source-dify" style="margin-top:8px;max-width:50%">
<div class="rec-title"><span class="rec-icon">👤</span>联系:财务部 张三</div>
<div class="rec-desc">财务部经理 · 工号 10086</div>
<a class="contact-card-btn" href="javascript:void(0)">查看名片</a>
<div class="rec-meta">
<span class="rec-source-tag">A · Dify 路由名片</span>
</div>
</div>
</div>
<!-- 4.4 下载/链接(来自 Dify action · 仅左侧栏) -->
<h3 style="margin-top:20px;margin-bottom:8px;color:var(--text-secondary);font-size:14px">④ 下载/链接(仅左侧栏出现,右侧栏不显示)</h3>
<div style="background:var(--bg-tertiary);padding:12px;border-radius:8px;color:var(--text-secondary);font-size:13px">
位置:左侧气泡 / 右侧栏不出现<br>
关联:直接下载链接(exe/pdf/zip<br>
示例气泡(左侧):<br>
<div class="rec-card source-dify" style="margin-top:8px;max-width:50%">
<div class="rec-title"><span class="rec-icon">📦</span>VPN 客户端下载</div>
<div class="rec-desc">Windows 10/11 / macOS · 32.5 MB</div>
<a class="contact-card-btn" href="javascript:void(0)">立即下载</a>
<div class="rec-meta">
<span class="rec-source-tag">A · Dify 资源链接</span>
</div>
</div>
</div>
<!-- 4.5 常用资源 L3(来自角色匹配 · 仅左侧栏) -->
<h3 style="margin-top:20px;margin-bottom:8px;color:var(--text-secondary);font-size:14px">⑤ 常用资源 L3(来自角色匹配 · 仅左侧栏出现)</h3>
<div style="background:var(--bg-tertiary);padding:12px;border-radius:8px;color:var(--text-secondary);font-size:13px">
位置:左侧气泡("试试问:xxx"形式) / 右侧栏不出现<br>
触发:profile.position 匹配 role_assets<br>
示例气泡(左侧):<br>
<div class="rec-card source-role" style="margin-top:8px;max-width:50%">
<div class="rec-title"><span class="rec-icon">💻</span>开发者常用资源</div>
<div class="rec-desc">代码仓库 · API 文档 · 测试环境申请</div>
<ul class="rec-items">
<li>git.servyou-it.com</li>
<li>api-docs.servyou-it.com</li>
<li>测试环境申请 → <a>去申请</a></li>
</ul>
<div class="rec-meta">
<span class="rec-source-tag">L3 · 角色"开发"</span>
</div>
</div>
</div>
<!-- 4.6 审批进度(来自 T2 webhook · 右侧栏回流) -->
<h3 style="margin-top:20px;margin-bottom:8px;color:var(--text-secondary);font-size:14px">⑥ 审批进度回流(来自 T2 webhook · 右侧栏顶部插入)</h3>
<div class="rec-card layer-progress">
<div class="rec-title"><span class="rec-icon">📋</span>VPN 申请进度</div>
<div class="rec-desc">您发起的 VPN 权限申请</div>
<div class="progress-bar"><div class="progress-fill" style="width:60%"></div></div>
<div style="display:flex;justify-content:space-between;margin-top:4px">
<span class="progress-status approved">已审批</span>
<span style="font-size:11px;color:var(--text-tertiary)">60% · 等待 IT 开通</span>
</div>
<div class="rec-meta">
<span class="rec-source-tag">progress · T2 事件触发</span>
<span>2 分钟前更新</span>
</div>
</div>
</div>
<!-- 场景 5:跨会话持久化 -->
<div class="scene" id="scene-4">
<h2>场景 5:跨会话持久化(PRD §4.7.7 决策 ⑤ B</h2>
<div class="subtitle">L2/L3/progress 持久化到 localStorageL1/A 不持久</div>
<h3 style="margin-top:12px;margin-bottom:8px;color:var(--text-secondary);font-size:14px">会话 1(首次打开,3 张卡都已生成)</h3>
<div class="h5-mock">
<div class="h5-chat">
<div class="bubble user">帮我看看电脑情况</div>
<div class="bubble ai">已检测,您的电脑有 12 个补丁待安装,病毒库已过期 7 天</div>
</div>
<div class="h5-right">
<div class="rec-card source-profile">
<div class="rec-title"><span class="rec-icon">🦠</span>火绒病毒库过期</div>
<div class="rec-desc">病毒库已 7 天未更新</div>
<div class="rec-meta">
<span class="rec-source-tag">L2 · 持久化</span>
<span>✅ 已存 localStorage</span>
</div>
</div>
<div class="rec-card layer-progress">
<div class="rec-title"><span class="rec-icon">📋</span>VPN 申请进度</div>
<div class="rec-desc">已审批 · 等待 IT 开通</div>
<div class="progress-bar"><div class="progress-fill" style="width:60%"></div></div>
<div class="rec-meta">
<span class="rec-source-tag">progress · 持久化直到完结</span>
<span>✅ 已存 localStorage</span>
</div>
</div>
<div class="rec-card source-keyword">
<div class="rec-title"><span class="rec-icon">🔧</span>系统补丁缺失</div>
<div class="rec-desc">12 个安全补丁未安装</div>
<div class="rec-meta">
<span class="rec-source-tag">L1 · ❌ 不持久</span>
<span>会话结束即清</span>
</div>
</div>
</div>
</div>
<h3 style="margin-top:20px;margin-bottom:8px;color:var(--text-secondary);font-size:14px">会话 2(用户关闭浏览器后再次打开,localStorage 加载)</h3>
<div class="h5-mock">
<div class="h5-chat">
<div class="bubble user">(新会话,用户未发言)</div>
</div>
<div class="h5-right">
<!-- 跨会话持久化的 L2 + progress 仍在 -->
<!-- L1 已自动清空(不持久) -->
<div class="rec-card source-profile">
<div class="rec-title"><span class="rec-icon">🦠</span>火绒病毒库过期</div>
<div class="rec-desc">病毒库已 7 天未更新(来自 localStorage</div>
<div class="rec-meta">
<span class="rec-source-tag">L2 · 跨会话持久</span>
<span>30 天后过期</span>
</div>
</div>
<div class="rec-card layer-progress">
<div class="rec-title"><span class="rec-icon">📋</span>VPN 申请进度</div>
<div class="rec-desc">已审批 · 等待 IT 开通(来自 localStorage</div>
<div class="progress-bar"><div class="progress-fill" style="width:60%"></div></div>
<div class="rec-meta">
<span class="rec-source-tag">progress · 跨会话持久</span>
<span>完结后清除</span>
</div>
</div>
<!-- L1 不在(已被清空) -->
</div>
</div>
<div class="notes">
<strong>持久化规则(PRD §4.7.7):</strong>
<table style="margin-top:8px;width:100%;border-collapse:collapse">
<tr style="background:var(--bg-tertiary)">
<th style="padding:6px;text-align:left;font-size:12px">类型</th>
<th style="padding:6px;text-align:left;font-size:12px">持久化</th>
<th style="padding:6px;text-align:left;font-size:12px">过期规则</th>
</tr>
<tr><td style="padding:6px;font-size:12px">L1 关键词</td><td style="padding:6px;font-size:12px"></td><td style="padding:6px;font-size:12px">话题切换 / 会话结束即清</td></tr>
<tr><td style="padding:6px;font-size:12px">L2 画像</td><td style="padding:6px;font-size:12px">✅ 30 天</td><td style="padding:6px;font-size:12px">30 天后或画像更新时清</td></tr>
<tr><td style="padding:6px;font-size:12px">L3 角色</td><td style="padding:6px;font-size:12px"></td><td style="padding:6px;font-size:12px">角色变更时清(极少)</td></tr>
<tr><td style="padding:6px;font-size:12px">来源 A 步骤</td><td style="padding:6px;font-size:12px"></td><td style="padding:6px;font-size:12px">话题切换 / 会话结束即清</td></tr>
<tr><td style="padding:6px;font-size:12px">progress</td><td style="padding:6px;font-size:12px"></td><td style="padding:6px;font-size:12px">审批完结时清</td></tr>
</table>
</div>
</div>
<!-- 场景 6:话题切换 -->
<div class="scene" id="scene-5">
<h2>场景 6:话题切换(PRD §4.7.6 决策 ④ B</h2>
<div class="subtitle">用户切换话题后,L1 推荐清空;L2/L3/progress 保留</div>
<div class="h5-mock">
<div class="h5-chat">
<div class="bubble user">VPN 连不上</div>
<div class="bubble ai">请尝试重连 aTrust 客户端</div>
<div class="bubble user" style="background:#22c55e">顺便帮我预约一下会议室</div>
<!-- v1.0Jaccard 相似度 < 0.3,触发话题切换 -->
<div class="bubble system" style="margin-top:8px;color:#22c55e">话题已切换,旧的 L1 推荐已清空 ✅</div>
<div class="bubble ai">好的,您要预约哪个会议室?</div>
</div>
<div class="h5-right">
<!-- L1 已清空(VPN 卡消失)-->
<!-- L2/L3/progress 保留(与话题无关)-->
<div class="rec-card source-profile">
<div class="rec-title"><span class="rec-icon">🦠</span>火绒病毒库过期</div>
<div class="rec-desc">病毒库已 7 天未更新</div>
<div class="rec-meta">
<span class="rec-source-tag">L2 · 保留</span>
<span>画像无关</span>
</div>
</div>
<div class="rec-card layer-progress">
<div class="rec-title"><span class="rec-icon">📋</span>VPN 申请进度</div>
<div class="rec-desc">已审批 · 等待 IT 开通</div>
<div class="progress-bar"><div class="progress-fill" style="width:60%"></div></div>
<div class="rec-meta">
<span class="rec-source-tag">progress · 保留</span>
<span>跨会话持久</span>
</div>
</div>
</div>
</div>
<div class="notes">
<strong>话题切换检测(PRD §4.7.6):</strong>
<ul>
<li>✅ 关键词突变:新消息与最近 3 条消息的 Jaccard 相似度 &lt; 0.3 → 清空 L1</li>
<li>✅ 意图突变:Dify 返回的 intent_type 与上一条不同 → 清空对应 layer</li>
<li>✅ 会话分隔:用户点"结束会话"或新建会话 → 清空所有跨会话推荐</li>
<li>✅ L2/L3/progress 不清(与当前话题无关)</li>
</ul>
</div>
</div>
<!-- 场景 7:触发失败降级 -->
<div class="scene" id="scene-6">
<h2>场景 7:触发失败降级(PRD §4.7.5 决策 ③ B</h2>
<div class="subtitle">C 失败 → D 降级;全部失败 → 右侧栏空</div>
<h3 style="margin-top:12px;margin-bottom:8px;color:var(--text-secondary);font-size:14px">7.1 画像 API 故障(C 失败 → D 降级)</h3>
<div class="h5-mock">
<div class="h5-chat">
<div class="bubble user">我要申请 VPN</div>
<div class="bubble ai">好的,请填写 VPN 申请表单</div>
</div>
<div class="h5-right">
<!-- L1 关键词命中 -->
<div class="rec-card source-keyword">
<div class="rec-title"><span class="rec-icon">🔐</span>VPN 相关信息</div>
<div class="rec-desc">VPN 连接所需资源</div>
<div class="rec-meta">
<span class="rec-source-tag">L1 · 正常</span>
<span></span>
</div>
</div>
<!-- C 失败 → 降级到 D -->
<div class="rec-card source-role" style="opacity:0.8">
<div class="rec-title"><span class="rec-icon">⚠️</span>开发者资源(降级展示)</div>
<div class="rec-desc">画像 API 故障,降级使用 L3 角色通用资源</div>
<div class="rec-meta">
<span class="rec-source-tag">L3 · C→D 降级</span>
<span style="color:#f97316">画像不可用</span>
</div>
</div>
</div>
</div>
<h3 style="margin-top:20px;margin-bottom:8px;color:var(--text-secondary);font-size:14px">7.2 全部失败(右侧栏空)</h3>
<div class="h5-mock">
<div class="h5-chat">
<div class="bubble user">xxx(无关内容)</div>
<div class="bubble ai">您好,请问需要什么帮助?</div>
</div>
<div class="h5-right">
<div class="empty-state">
<!-- 全部失败:右侧栏空 -->
</div>
</div>
</div>
<div class="notes">
<strong>降级链路(PRD §4.7.5):</strong>
<ul>
<li>[A 失败] → [B + C + D]</li>
<li>[B 失败] → [C + D]</li>
<li>[C 失败] → [D] ★ 关键降级(保证有内容)</li>
<li>[D 失败] → []</li>
<li>[全部失败] → [] (按 §4.7.4 走空状态,与决策 ① A 保持一致)</li>
</ul>
</div>
</div>
<script>
// 场景切换
function showScene(idx) {
document.querySelectorAll('.scene').forEach(s => s.classList.remove('active'));
document.querySelectorAll('.scene-tab').forEach(t => t.classList.remove('active'));
document.getElementById('scene-' + idx).classList.add('active');
document.querySelectorAll('.scene-tab')[idx].classList.add('active');
}
// 主题切换
function toggleTheme() {
const html = document.documentElement;
const current = html.getAttribute('data-theme');
html.setAttribute('data-theme', current === 'light' ? 'dark' : 'light');
}
// 操作步骤可展开
function toggleExpand(btn) {
const card = btn.closest('.rec-card');
card.classList.toggle('open');
btn.textContent = card.classList.contains('open') ? '收起 ▲' : '展开 ▼';
}
</script>
</body>
</html>