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:
@@ -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 transition(max-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 可用(已实现)
|
||||
- D3:Vant `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` 是英文 key(developer/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-Frozen,2026-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` WS(type=dynamic_recommend),来源 B/C/D 走 `asset_recommend` WS(type=asset_recommend)
|
||||
- 前端按 source 字段区分渲染入口(DifyActionCard vs DynamicRecommend)
|
||||
|
||||
#### 4.7.2 3 种触发时机
|
||||
|
||||
| 时机 ID | 名称 | 触发点 | 典型来源 | 延迟要求 |
|
||||
|--------|------|--------|---------|---------|
|
||||
| **T0** | **同步触发**(与气泡同帧) | Dify 主推理返回瞬间 | A(Dify 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 触发失败降级走) |
|
||||
| **场景 3:AI 回复到达** | 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-Frozen,2026-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 分级持久化** | ✅ 建议 B(L1/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.0a,2026-07-28)
|
||||
- [x] 宋献审核 §4.7 决策(v1.0b,2026-07-28,①~⑤ 拍板)
|
||||
- [x] §4.7 冻结为最终版(v1.0-Frozen,2026-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-scope(5 类卡片归属 + 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 生产部署版 hash:CSS `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__header;SelfDiagnosis 通过 `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` | 当前 Mock(5 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 字简称)
|
||||
- 高度:30px(v2.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__header(SelfDiagnosis 通过 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__header,defineExpose 暴露 6 API 给 RightPanel;CSS 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: auto;CSS 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>
|
||||
|
||||
<!-- 场景 4:4 类卡片样式 -->
|
||||
<div class="scene" id="scene-3">
|
||||
<h2>场景 4:4 类卡片样式</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 持久化到 localStorage;L1/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.0:Jaccard 相似度 < 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 相似度 < 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>
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user