diff --git a/docs/superpowers/specs/2026-08-08-yunke-export-chat-html-design.md b/docs/superpowers/specs/2026-08-08-yunke-export-chat-html-design.md index 30e05ab..17b761c 100644 --- a/docs/superpowers/specs/2026-08-08-yunke-export-chat-html-design.md +++ b/docs/superpowers/specs/2026-08-08-yunke-export-chat-html-design.md @@ -1,100 +1,115 @@ -# 导出会话聊天为 HTML(③,前端接线)设计 +# 导出会话聊天为 HTML(③,ChatTimeline 接线 + 文件名修复)设计 > 日期:2026-08-08|分支:`feat/yunke-data-mirror`|路线图 ③ -> 前置(已就绪):后端 `GET /api/export/chat-html`(`app/api/routers/export.py`)已实现、已注册、有测试——按 (channel, owner_account_id, talker_id|room_id, start?, end?) 拉消息 → 渲染**自包含 HTML 气泡页**(is_send 左右、带时间/文本)→ `text/html` attachment 下载。 -> 数据/交互:全本地(运行时不碰云客)。云客「导出聊天记录」**2026-08-08 真机核实**:异步任务 → 下载中心(`POST /wechatweb/msg/exportChatData2Html`,参数 startdate/enddate/wechatId/friendId)。 -> 用户决策(2026-08-08):**按我们的内部逻辑做**——不复刻云客的异步下载中心;HTML 渲染沿用已有 export.py(即"我们的逻辑")。 +> 前置(已就绪):后端 `GET /api/export/chat-html`(`app/api/routers/export.py`)已实现、已注册、有测试——按 (channel, owner_account_id, talker_id|room_id, start?, end?) 拉消息 → 渲染自包含 HTML 气泡页 → attachment 下载,文件名 `chat-{channel}-{owner}-{peer}.html`。 +> 数据/交互:全本地。云客「导出聊天记录」2026-08-08 真机核实(异步任务→下载中心);用户定:**按我们内部逻辑做,不复刻异步下载中心**。 ## 1. 目标与范围 -**目标**:在 3 处加「导出」入口,触发已有的 `/api/export/chat-html`,让用户把一段会话(员工×客户/群,可带日期窗)下载成 HTML 文件。 +**现状(plan 核实,2026-08-08)**: +- 后端 `/api/export/chat-html` 已就绪(export.py,不改)。 +- **CustomerList、GroupList 操作列「导出HTML聊天记录」按钮已存在**(wechat tab;用 `downloadBlob('/export/chat-html', params)`,CustomerList 还带时间范围选择 modal)。**本 plan 不动这两处。** +- **ChatTimeline(聊天窗)缺「导出」按钮** —— 唯一缺失的入口。 +- `downloadBlob`(`api/client.ts`)有个 **bug**:因响应拦截器 `(response)=>response.data` 丢头,文件名硬编码 `'chat-export.html'`,丢失后端 `chat-{...}.html` 命名(影响已存的 2 个按钮 + 即将加的 ChatTimeline 按钮)。 -**在本 plan(纯前端,后端不动)**: -1. **ChatTimeline 头部「导出」按钮**:用当前会话(channel/owner/talkerId 或 roomId)+ 当前 dateRange(start/end)→ 下载该窗内聊天 HTML。仅在 `hasActive` 时可用。 -2. **CustomerList 操作列「导出HTML」**(在「查看聊天记录」旁):该 owner×friend_id,**全量消息**(列表无日期选择器)。 -3. **GroupList 操作列「导出HTML」**:该 owner×room_id,全量消息。 +**目标**:补 ChatTimeline「导出」入口 + 修 `downloadBlob` 文件名丢失。 -**机制(我们的内部逻辑)**:直接浏览器下载——构造 `/api/export/chat-html?...` URL,`window.open(url, '_blank')` 触发后端返回的 attachment 下载。**同步、无下载中心**(本地小工具;后端 attachment `Content-Disposition` 强制下载)。 +**在本 plan(纯前端)**: +1. **ChatTimeline 头部加「导出」按钮**(`v-if="hasActive"`)→ `downloadBlob('/export/chat-html', {channel, owner_account_id, talker_id/room_id, start: winStart, end: winEnd})`,即导出当前会话在当前 dateRange 内的聊天 HTML。沿用现有 `downloadBlob`(与 CustomerList/GroupList 一致,不引入 window.open 第二套下载路径)。 +2. **修 `downloadBlob` 文件名**:改用裸 `axios.get`(不经解包拦截器)拿全响应 → 从 `Content-Disposition` 解析文件名(`chat-{channel}-{owner}-{peer}.html`),fallback `'chat-export.html'`。惠及 3 处按钮。 **不在本 plan**: -- 云客的异步任务 / 下载中心(不需要)。 -- 导出多个会话 / 批量导出(云客也是单个会话导出)。 -- HTML 排版改版(沿用 export.py 现有渲染)。 -- 媒体字节内嵌(HTML 里媒体保持链接,不内嵌;export.py 现状)。 +- 云客异步任务/下载中心(不需要)。 +- CustomerList/GroupList 导出改造(已实现,不动;WA tab 无导出 = 与云客一致,保留)。 +- HTML 排版改版(沿用 export.py)。 +- ChatTimeline 的导出时间范围 modal:聊天窗已有 dateRange(step 5),直接用,不加 modal。 -## 2. 后端契约(已就绪,引用) +## 2. 后端契约(已就绪,引用,不改) -`GET /api/export/chat-html`: -- query:`channel`(wechat/whatsapp)、`owner_account_id`(必填)、`talker_id?`、`room_id?`(二选一)、`start?`、`end?`(ms-epoch,可省=全量)。 -- 返回:`text/html; charset=utf-8`,`Content-Disposition: attachment; filename="chat-...html"`。 -- 逻辑:DM 限定 `room_id` 空;按 timestamp asc;渲染气泡(is_send 右、否则左)。 -- 本 plan **不改后端**。 +`GET /api/export/chat-html`:query `channel`/`owner_account_id`(必)/`talker_id?`/`room_id?`/`start?`/`end?`(ms) → `text/html` attachment,`Content-Disposition: attachment; filename="chat-{channel}-{owner}-{peer}.html"`。 ## 3. 前端 -### 3.1 共用 URL 构造(`yunkes/src/api/index.ts`) +### 3.1 `downloadBlob` 文件名修复(`yunkes/src/api/client.ts`) +当前用 `http.get(url,{params,responseType:'blob'})` 但拦截器把响应解包成 body(丢头)。改为裸 `axios`(不经拦截器): ```typescript -/** 构造 /api/export/chat-html 下载 URL(浏览器直接下载,不走 getJson)。 */ -export function exportChatUrl(params: { - channel: Channel - owner_account_id: string - talker_id?: string - room_id?: string - start?: number - end?: number -}): string { - const q = clean({ - channel: params.channel, - owner_account_id: params.owner_account_id, - talker_id: params.talker_id, - room_id: params.room_id, - start: params.start, - end: params.end, - }) - const usp = new URLSearchParams(q as Record) - return `/api/export/chat-html?${usp.toString()}` +import axios from 'axios' // 已 import + +/** 以 blob 下载(HTML 导出等附件)。从 Content-Disposition 取文件名(绕开解包拦截器)。 */ +export async function downloadBlob( + url: string, + params?: Record, +): Promise { + // 裸 axios(baseURL '/api')拿全响应(含 headers),不经 http 的 (r)=>r.data 拦截器 + const res = await axios.get(`/api${url}`, { params, responseType: 'blob' }) + const cd = (res.headers['content-disposition'] as string) || '' + const m = /filename="?([^";]+)"?/i.exec(cd) + const filename = m ? m[1] : 'chat-export.html' + const objUrl = URL.createObjectURL(res.data as Blob) + const a = document.createElement('a') + a.href = objUrl + a.download = filename + document.body.appendChild(a) + try { + a.click() + } finally { + document.body.removeChild(a) + URL.revokeObjectURL(objUrl) + } + return filename } ``` -> 触发下载:`window.open(exportChatUrl({...}), '_blank')`(attachment 强制下载;本地 viewer 无 api_token,window.open 可达)。 +> `url` 入参仍是相对 `/export/chat-html`(与 CustomerList 现有调用一致),函数内补 `/api` 前缀。 -### 3.2 ChatTimeline.vue -- 头部 `.yk-chat__filters` 区(step 5 已建,含日期选择器/搜索)加「导出」`el-button`:`v-if="hasActive"`,`@click` → `window.open(exportChatUrl({channel: channelTyped, owner_account_id: props.ownerId, talker_id: props.talkerId, room_id: props.roomId, start: winStart, end: winEnd}))`。 -- 即「导出当前会话在当前日期窗内的聊天」。 +### 3.2 ChatTimeline 头部「导出」按钮(`yunkes/src/views/ChatTimeline.vue`) +- import `{ downloadBlob } from '@/api/client'`(或经 `@/api` 若已 re-export;plan 核实)。 +- `.yk-chat__filters` 区(step 5,含日期选择器/搜索)加: +```vue +导出 +``` +```typescript +async function exportCurrent(): Promise { + if (!hasActive.value) return + try { + await downloadBlob('/export/chat-html', { + channel: channelTyped.value, + owner_account_id: props.ownerId, + talker_id: isGroup.value ? undefined : props.talkerId, + room_id: isGroup.value ? props.roomId : undefined, + start: winStart.value, + end: winEnd.value, + }) + } catch (e) { + ElMessage.error(`导出失败:${(e as Error).message}`) + } +} +``` +> `ElMessage` 已 import。`channelTyped`/`isGroup`/`props`/`winStart`/`winEnd`/`hasActive` 均存在(step 5/Task4)。 -### 3.3 CustomerList.vue(操作列) -- 在「查看聊天记录」按钮旁加 `导出HTML`(wechat + WA 两 tab 的操作列)→ `window.open(exportChatUrl({channel: row.channel, owner_account_id: row.owner_account_id, talker_id: row.friend_id}))`(全量,无 start/end)。 -- plan 核实两 tab 操作列结构(Plan 2 已建「查看聊天记录」→ chat)。 +## 4. 关键决策(已与用户确认 + spec review 纠正 2026-08-08) -### 3.4 GroupList.vue(操作列) -- 在「查看聊天记录」旁加「导出HTML」→ `exportChatUrl({channel: row.channel, owner_account_id: row.owner_account_id, room_id: row.room_id})`(全量)。 - -## 4. 关键决策(已与用户确认 2026-08-08) - -1. **后端已就绪**(export.py),③ = 纯前端 3 处按钮接线。 -2. **同步直接下载**(`window.open` + attachment),不做云客异步下载中心。 -3. **HTML 渲染沿用 export.py**(我们的内部逻辑,不改)。 -4. **ChatTimeline 导出带 dateRange**;CustomerList/GroupList 导出**全量**(列表无日期选择器)。 -5. 媒体保持链接(export.py 现状)。 +1. **③ = 仅 ChatTimeline 加导出按钮 + 修 downloadBlob 文件名**(CustomerList/GroupList 已实现,不动)。 +2. **沿用 `downloadBlob`**(与现存 2 处一致),**不引入** window.open/exportChatUrl 第二套路径(spec review 指出)。 +3. **修文件名 bug**(惠及 3 处):裸 axios 保头 + 解析 Content-Disposition。 +4. **同步直接下载**(无云客异步下载中心);HTML 渲染沿用 export.py。 +5. ChatTimeline 导出用现有 dateRange(不加 modal);CustomerList 的时间 modal 已存,不动。 ## 5. 文件清单 -- 改 `yunkes/src/api/index.ts`(`exportChatUrl` helper)。 -- 改 `yunkes/src/views/ChatTimeline.vue`(头部「导出」按钮)。 -- 改 `yunkes/src/views/CustomerList.vue`(操作列「导出HTML」)。 -- 改 `yunkes/src/views/GroupList.vue`(操作列「导出HTML」)。 -- **无后端改动**;export.py 不动。 +- 改 `yunkes/src/api/client.ts`(`downloadBlob` 文件名修复)。 +- 改 `yunkes/src/views/ChatTimeline.vue`(头部「导出」按钮 + `exportCurrent`)。 +- **无后端改动**;CustomerList/GroupList/export.py 不动。 ## 6. 验收 - `vue-tsc -b` exit 0。 - **浏览器实测**(本地 smoke.db,无云客): - - ChatTimeline:进一个会话 → 「导出」→ 下载 `chat-wechat-{owner}-{talker}.html`,打开含该窗内消息气泡(员工右/客户左、时间、文本);改 dateRange 再导出 → 内容随窗变。 - - CustomerList/GroupList:操作列「导出HTML」→ 下载该 owner×friend/room 的全量聊天 HTML。 - - network:仅打 `/api/export/chat-html`(无云客)。 + - ChatTimeline:进一个会话 → 「导出」→ 下载文件名 `chat-wechat-{owner}-{talker}.html`(**文件名正确**,非 chat-export.html);打开含该 dateRange 内消息气泡(员工右/客户左、时间、文本);改 dateRange 再导出 → 内容随窗变。 + - (回归)CustomerList/GroupList「导出HTML聊天记录」→ 文件名也变正确(`chat-...html`),行为不变。 + - network:仅 `/api/export/chat-html`(无云客)。 ## 7. 风险 -- **window.open 被拦截**:用户手势触发(button click)一般不拦;若拦可降级为隐藏 `` + `click()`。 -- **api_token 鉴权**:本地 viewer 默认无 token(require_auth no-op)→ window.open 可达;若启用 token 需改 fetch+blob(本 plan 不涉及,记录)。 -- **大对话导出耗时**:同步生成;超大对话可能慢,可接受(本地工具);后续必要时再异步。 -- **CustomerList/GroupList 操作列结构**:plan 核实两 tab(wechat/WA)操作列「查看聊天记录」位置,照旁加按钮。 +- **裸 axios 不带拦截器的错误处理**:`downloadBlob` 失败时裸 axios 抛原始 axios 错(无统一 message 包装);`exportCurrent` 用 try/catch + ElMessage 兜底(CustomerList 现状亦如此)。 +- **Content-Disposition 解析**:正则 `filename="..."`;后端用双引号包裹(export.py 确认);fallback 固定名。 +- **WA/群导出**:ChatTimeline 群会话(isGroup)传 room_id;WA 通道同理(channel=whatsapp)。WA tab 列表导出不在本 plan(与云客一致,无)。