facc04aa65
本提交为 .git 对象库损坏后的重建提交,内容等价于原先三个本地提交 (5e2fd4c2 / 57a53c98 / 5d7e1873)的累积结果,未做任何额外改动。 一、docs 结构整改(整改 #14) 根因:重构时新结构为 untracked 文件,执行 git stash(未带 -u)未纳入, 随后 git reset 拉回 HEAD 旧 tracked 树,导致旧树复活、新旧两棵目录 树并存于 docs/,共 791 文件、双分类体系冲突。 修复动作: - b2 同名异主题文件改名迁移保全 9 个 - C 类 39 个孤立文件按主题正确归类 - A/B1 类 222 个重复文件删除(新结构已有内容副本) - 9 个旧独有空目录删除 - 270 处内部引用按 verified 映射改写 - 整改记录 #14 登记于 04-运维文档/部署运维 结果:docs 791 → 569 文件,顶层仅规范 8 类 + 治理文件,单树恢复。 残留:约 20 处指向从未存在文件的陈旧死链,归入独立文档卫生任务。 二、compose 双目录对齐(消除踩坑 A) - docker-compose.yml:nginx 前端挂载全部由根目录 frontend-*/dist 改为 src/frontend-*/dist(h5 / agent / admin / terminal) - docker-compose.dev.yml:dev 服务 build context 与卷同步改 src/ - 效果:本地 docker compose up 不再把根目录 stale dist 挂回, 与线上一致,分叉隐患消除(已 docker compose config 校验通过) 防复发铁律: - 重构须提交;仓库修复须 git stash -u 或先 commit - 新结构须 git add 并提交,避免再次 untracked 复活 - H5 改动只动 src/frontend-h5/,禁改根目录遗留 frontend-*/
15 KiB
15 KiB
系统架构设计 — 历史会话开关功能
架构师:高见远(Bob)
日期:2026-07-01
基于 PRD v1.0 + 现有代码结构分析
Part A: 系统设计
1. 实现方案 + 框架选型
1.1 核心技术挑战
| 挑战 | 说明 | 方案 |
|---|---|---|
| 跨会话消息聚合 | 需要将同一员工的所有会话消息合并为一条时间线,按时间排序 | 后端新增按 employee_id 聚合查询的接口,JOIN conversations + messages 表,按 created_at 全局排序 |
| 分隔条主题提取 | 分隔条显示该会话中员工首条消息摘要(前20字),不新增数据库字段 | 后端在聚合查询时,对每个会话查找 sender_type='employee' 的最早一条消息,截取前20字作为 conversation_summaries 返回 |
| 游标分页(跨会话) | 向上滚动加载更多历史消息,需跨会话游标分页 | 使用 before 参数(消息ID),后端根据该消息的 created_at 查询更早的消息,全局时间线分页 |
| 模式切换无闪烁 | 开关切换时正常模式↔历史模式,消息列表无缝切换 | Store 新增 displayMessages computed,根据 historyMode 返回不同数据源;前端 v-if 切换加载态 |
| 历史模式只读 | 历史模式下隐藏输入框、回复建议区 | ChatArea 中用 historyMode 控制 ReplyBox / ReplySuggestArea 的 v-if |
| 会话切换自动重置 | 切换会话时关闭历史模式 | Store 的 selectConversation() 中调用 resetHistoryState() |
1.2 框架与库选型
| 层 | 技术 | 说明 |
|---|---|---|
| 后端 | FastAPI + SQLAlchemy 2.0 (async) | 沿用现有技术栈,新增一个 GET 接口 |
| 前端 | Vue 3 + Pinia + Element Plus | 沿用现有技术栈,新增一个组件 + Store 扩展 |
| 分页 | 游标分页(before 参数) |
与现有 getMessages() 的分页方式一致,前端向上滚动触发 |
1.3 后端新接口设计
GET /api/employees/{employee_id}/history-messages
| 参数 | 类型 | 默认 | 说明 |
|---|---|---|---|
employee_id |
path (str) | — | 员工企微 UserID |
limit |
query (int) | 50 | 每页消息数量(1~100) |
before |
query (str?) | null | 游标:加载此消息ID之前的消息(向上翻页) |
current_conversation_id |
query (str?) | null | 当前会话ID(用于标记当前会话的分隔条) |
响应体:
{
"code": 200,
"data": {
"items": [ /* Message[] — 按时间倒序(最新在前),前端 reverse 后展示 */ ],
"has_more": true,
"conversation_summaries": {
"conv-uuid-1": "VPN连接不上怎么办急",
"conv-uuid-2": "邮箱登录失败提示密码"
}
}
}
后端查询逻辑:
- 查询
conversations表中employee_id = ?的所有会话,获取会话ID列表 - 查询
messages表中conversation_id IN (会话ID列表)的消息 - 如有
before参数,获取该消息的created_at,只查更早的消息 - 按
created_at DESC排序,取limit + 1条(多取1条判断has_more) - 对涉及的每个会话,查询其
sender_type='employee'的最早一条消息,取前20字作为摘要 - 返回消息列表 +
has_more+conversation_summaries
1.4 前端组件改动方案
| 文件 | 改动类型 | 改动概述 |
|---|---|---|
UserInfoBar.vue |
修改 | chips 区末尾(L86 备注chip之后)新增"历史会话"开关按钮,三态视觉(关闭/打开/加载中),新增 toggle-history emit |
ChatArea.vue |
修改 | 消息列表使用 displayMessages 替代 messages;渲染时插入 ConversationSeparator;历史模式隐藏 ReplyBox/ReplySuggestArea;监听向上滚动触发分页 |
ConversationSeparator.vue |
新增 | 会话分隔条组件,props: summary(首条消息摘要前20字)、isCurrent(是否当前会话) |
conversation.ts (Store) |
修改 | 新增历史模式状态(6个 ref + 2个 computed + 4个 action) |
message.ts (API) |
修改 | 新增 getHistoryMessages() 函数 + HistoryMessageListData 类型 |
data.ts (Mock) |
修改 | 新增 mock 历史消息数据(开发环境 fallback) |
1.5 状态管理方案(Store 新增)
新增 State(6个 ref):
historyMode: ref<boolean>(false) // 历史模式开关
historyMessages: ref<Message[]>([]) // 历史合并时间线消息
historyLoading: ref<boolean>(false) // 加载中状态
historyHasMore: ref<boolean>(false) // 是否还有更多历史消息
historyConversationSummaries: ref<Record<string, string>>({}) // 会话ID→首条消息摘要
historyCursor: ref<string | null>(null) // 分页游标(最后加载的消息ID)
新增 Getters(2个 computed):
displayMessages // historyMode ? historyMessages : messages
isHistoryReadonly // historyMode(历史模式只读)
新增 Actions(4个):
enableHistoryMode() // 打开历史模式,加载初始消息
disableHistoryMode() // 关闭历史模式,清空历史状态
loadMoreHistory() // 向上滚动加载更多(分页)
resetHistoryState() // 重置所有历史状态(切换会话时调用)
2. 文件列表及相对路径
| # | 文件路径 | 改动类型 | 改动概述 |
|---|---|---|---|
| 1 | backend/app/api/messages.py |
修改 | 新增 GET /employees/{employee_id}/history-messages 路由处理函数 |
| 2 | backend/app/services/conversation/session_query_service.py |
修改 | 新增 get_employee_history_messages() 方法 |
| 3 | backend/app/schemas/message.py |
修改 | 新增 HistoryMessageListResponse Pydantic Schema |
| 4 | frontend-agent/src/api/message.ts |
修改 | 新增 getHistoryMessages() API 函数 + HistoryMessageListData 接口 |
| 5 | frontend-agent/src/stores/conversation.ts |
修改 | 新增历史模式 state/getters/actions,修改 selectConversation 加入重置逻辑 |
| 6 | frontend-agent/src/mock/data.ts |
修改 | 新增 mockHistoryMessageData mock 数据(开发 fallback) |
| 7 | frontend-agent/src/components/chat/UserInfoBar.vue |
修改 | chips 区末尾新增历史开关按钮 + toggle-history emit + 三态样式 |
| 8 | frontend-agent/src/components/chat/ConversationSeparator.vue |
新增 | 会话分隔条组件 |
| 9 | frontend-agent/src/components/chat/ChatArea.vue |
修改 | 消息列表切换、分隔条插入、只读模式、滚动分页、空状态提示 |
3. 数据结构和接口
3.1 类图
详见
docs/02-技术文档/技术架构/class-diagram-代办事项.mermaid
3.2 后端 Schema(Pydantic)
# backend/app/schemas/message.py — 新增
class ConversationSummary(BaseModel):
"""会话分隔条摘要信息"""
conversation_id: str
summary: str # 员工首条消息前20字
status: str # 会话状态
created_at: datetime # 会话创建时间
class HistoryMessageListResponse(BaseModel):
"""历史消息列表响应(跨会话聚合)"""
items: List[MessageResponse] # 消息列表(按时间倒序)
has_more: bool # 是否还有更多
conversation_summaries: Dict[str, str] # {conversation_id: "前20字摘要"}
3.3 前端 TypeScript 类型定义
// frontend-agent/src/api/message.ts — 新增
/** 历史消息列表响应(跨会话聚合) */
export interface HistoryMessageListData {
/** 消息列表(按时间倒序,最新在前) */
items: Message[]
/** 是否还有更多历史消息 */
has_more: boolean
/** 会话ID → 首条消息摘要(前20字) */
conversation_summaries: Record<string, string>
}
// frontend-agent/src/components/chat/ConversationSeparator.vue — Props
interface ConversationSeparatorProps {
/** 分隔条显示文本(首条消息摘要前20字) */
summary: string
/** 是否为当前会话(当前会话高亮显示) */
isCurrent: boolean
}
4. 程序调用流程(时序图)
详见
docs/02-技术文档/技术架构/sequence-diagram-代办事项.mermaid
核心流程:
- 打开历史模式:点击开关 → Store.enableHistoryMode() → API 请求 → 渲染合并时间线
- 向上滚动加载更多:检测滚动到顶部 → Store.loadMoreHistory() → API 请求(before游标)→ 前插消息
- 关闭历史模式:点击开关 → Store.disableHistoryMode() → 恢复正常消息列表
- 切换会话重置:selectConversation() → resetHistoryState() → historyMode=false
5. 任务列表
| 任务ID | 任务名称 | 涉及文件 | 依赖 | 优先级 |
|---|---|---|---|---|
| T01 | 后端 — 历史消息聚合接口 | backend/app/api/messages.py、backend/app/services/conversation/session_query_service.py、backend/app/schemas/message.py |
无 | P0 |
| T02 | 前端数据层 — API + Store + Mock | frontend-agent/src/api/message.ts、frontend-agent/src/stores/conversation.ts、frontend-agent/src/mock/data.ts |
T01 | P0 |
| T03 | 前端组件层 — 开关 + 分隔条 + 消息列表改造 | frontend-agent/src/components/chat/UserInfoBar.vue、frontend-agent/src/components/chat/ConversationSeparator.vue(新增)、frontend-agent/src/components/chat/ChatArea.vue |
T02 | P0 |
6. 依赖包列表
无需新增任何第三方依赖。
- 后端:复用现有 FastAPI + SQLAlchemy 2.0 async
- 前端:复用现有 Vue 3 + Pinia + Element Plus + Axios
7. 共享知识(跨文件约定)
7.1 消息合并时间线数据结构约定
历史模式 displayMessages 返回的是一维 Message[] 数组(与正常模式相同的类型),
按 created_at 升序排列(最旧在前,最新在后),与正常聊天列表一致。
前端在渲染时遍历 displayMessages,当检测到相邻两条消息的 conversation_id 不同时,
在它们之间插入一个 ConversationSeparator 组件。
conversation_summaries 是一个 Record<string, string> 映射:
key = conversation_id
value = 该会话中员工首条消息的前20字摘要
分隔条的 summary 从 conversation_summaries[message.conversation_id] 获取。
7.2 分隔条组件 Props 约定
// ConversationSeparator.vue
interface Props {
summary: string // 首条消息摘要(前20字),已由后端截取
isCurrent: boolean // 是否为当前会话(当前会话的分隔条高亮/加粗)
}
// 无 emit,纯展示组件(P1 搁置跳转功能)
7.3 Store 状态切换约定
正常模式 → 历史模式:
1. historyMode = true
2. historyLoading = true(触发 UI loading 态)
3. 调用 API 加载初始50条
4. 成功后:historyMessages = data.items.reverse(),historyHasMore = data.has_more
5. historyCursor = historyMessages[0]?.id(最旧消息ID,用于下次分页)
6. historyLoading = false
历史模式 → 正常模式:
1. historyMode = false
2. 清空 historyMessages、historyConversationSummaries、historyCursor
3. messages ref 不受影响(正常模式数据源未变)
切换会话时:
1. resetHistoryState() — 强制 historyMode = false,清空所有历史状态
2. 然后执行正常的 fetchMessages()
7.4 API 响应格式约定
所有后端 API 响应统一使用 success_response() 包装:
{
"code": 200,
"data": { ... },
"message": "success"
}
前端 apiClient 拦截器已自动解包,返回 response.data.data。
因此 getHistoryMessages() 返回的是 data 字段内容(HistoryMessageListData)。
7.5 消息排序约定
后端返回:按 created_at DESC(最新在前)
前端 Store:reverse() 后存储为 ASC(最旧在前,最新在后)
前端渲染:从上到下 = 从旧到新(与正常聊天一致)
分页游标:historyCursor = 最旧消息的ID(数组第一个元素)
向上滚动:用 before=historyCursor 请求更旧的消息,prepend 到数组头部
7.6 分隔条插入逻辑约定
// ChatArea.vue 渲染逻辑伪代码
const renderedItems = computed(() => {
const msgs = conversationStore.displayMessages
const result: Array<{ type: 'separator'; data: SeparatorData } | { type: 'message'; data: Message }> = []
let lastConvId = ''
for (const msg of msgs) {
if (msg.conversation_id !== lastConvId) {
// 会话切换,插入分隔条
result.push({
type: 'separator',
data: {
summary: conversationStore.historyConversationSummaries[msg.conversation_id] || '未知会话',
isCurrent: msg.conversation_id === conversationStore.currentConversationId,
}
})
lastConvId = msg.conversation_id
}
result.push({ type: 'message', data: msg })
}
return result
})
8. 待明确事项
| # | 问题 | 当前假设 | 建议确认方 |
|---|---|---|---|
| 1 | "当前会话排在最上方"的视觉含义 | 假设为:消息按时间正序排列(旧→新,与正常聊天一致),当前会话因最新而位于列表底部(用户初始可视区域)。分隔条中当前会话高亮标记。 | 产品经理 |
| 2 | 历史模式下是否暂停消息轮询 | 假设:历史模式下暂停当前会话的消息轮询(stopMessagePoll),避免新消息混入历史时间线。关闭历史模式后恢复轮询。 |
产品经理 |
| 3 | 历史消息是否需要标记已读 | 假设:历史消息不触发标记已读逻辑(只读查看,不修改 is_read 状态) | 产品经理 |
| 4 | 员工无任何历史会话(仅当前会话)时的展示 | 假设:正常展示当前会话消息 + 一条当前会话的分隔条,不显示"暂无历史会话"提示 | 产品经理 |
| 5 | 分隔条中是否显示会话状态(如"已结单") | 假设:P0 仅显示首条消息摘要,不显示状态标签。P1 可扩展。 | 产品经理 |
| 6 | 历史模式下 WebSocket 新消息推送的处理 | 假设:历史模式下收到新消息仍更新 messages ref(正常数据源),但不混入 historyMessages。关闭历史模式后即可看到。 |
架构师 |
9. 任务依赖图
graph TD
T01[T01: 后端历史消息聚合接口]
T02[T02: 前端数据层 API+Store+Mock]
T03[T03: 前端组件层 开关+分隔条+消息列表]
T01 --> T02
T02 --> T03
style T01 fill:#4CAF50,color:#fff
style T02 fill:#2196F3,color:#fff
style T03 fill:#FF9800,color:#fff
说明:
- T01(后端)无依赖,可最先开始
- T02(前端数据层)依赖 T01 的接口契约(URL、参数、响应格式),但可基于接口契约先行开发 mock
- T03(前端组件层)依赖 T02 的 Store API,是最终集成层