# 云客沟通板块漏操作补丁 Implementation Plan(重写) > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 补全云客「沟通」板块已实现页面里被漏掉/判错的表格内操作——微信员工 5 个统计数字→明细 modal、主badge→聊天、员工姓名→详情(纠正误判的手机号)、modal 内客户微信→聊天记录、微信客户/微信群的「导出HTML聊天记录」(含时间选择)。 **Architecture:** 前端为主:`StaffList.vue` 微信 tab 把 4 列纯文本数字改成可点→复用现成的 `YkStaffDetailModal`(数据源调现有 `/api/customers`);`YkStaffDetailModal` 的 `Col` 加可选 `onClick` 支持二级跳转;`CustomerList.vue` 微信 tab 操作列加导出按钮 + 时间选择 dialog;`GroupList.vue` 微信群 tab 操作列加导出按钮(群无时间选择)。后端仅新增 1 个 `GET /api/export/chat-html` 端点(DM/群通用 + 时间范围),TDD。 **Tech Stack:** FastAPI + SQLAlchemy 2.0 async(后端);Vue3 + TS + Element Plus + Pinia(前端);后端测试 pytest + `make_client` fixture(in-memory sqlite)。 **Spec/事实依据:** `docs/superpowers/specs/2026-08-06-yunke-comm-operations-audit.md`(2026-08-06 真机核对表,cursor:pointer 全覆盖 + 零采信)。 **参考既有:** Plan1 `StaffList.vue`(WhatsApp tab 已用 `YkStaffDetailModal` 的 `openAddedModal`/`openTalkedModal` 是现成模板)。 --- ## 范围 **本 plan 覆盖**(已实现页面的表格内漏操作): - 微信员工:沟通数 3 数字(沟通人数/有效沟通数/跟进客户数)→ modal;及时回复 2 数字(员工最后回复数/未回复客户数)→ modal;「主」badge→聊天;员工姓名→drawer(**纠正**:原 missing-operations 写的「手机号」是误判,手机号不可点,可点的是员工姓名);modal 内客户微信→聊天记录。 - 微信客户:操作列加「导出HTML聊天记录」+ 时间选择 dialog。 - 微信群:操作列加「导出HTML聊天记录」。 - 后端:`GET /api/export/chat-html`(DM 用 talker_id / 群用 room_id + start/end 时间范围)。 **不在本 plan 范围**: - 语音通话列表 CallList(微信 voice-call-list + WA wavoicecalllist + 长语音 modal)—— 独立 Plan 4,前置抓取修复。 - 工具栏批量操作(批量语音下载/全部导出/高级筛选)—— 随 CallList。 - WA 侧(员工/客户/群)—— Plan1/2/3 已对齐,本轮真机复核无新漏操作。 - 精确 KPI 度量还原(见下「数据源说明」)。 ### ⚠️ 数据源说明(重要设计简化,执行前已知) 云客微信员工 5 个数字 modal 各有独立 KPI 语义(沟通人数/有效沟通数/跟进客户数/员工最后回复数/未回复客户数 是**不同**客户集合)。yunkes 的 `Customer` 表**无「最后回复方向」汇总字段**,要精确区分(尤其「员工最后回复数」vs「未回复客户数」需逐客户查最后一条 `Message.is_send`)需新增 message 聚合端点,超出「补丁」定位。 **本 plan 决策**:5 个数字 modal 的 `fetcher` 统一调 `/api/customers?channel=wechat&owner_account_id={员工账号}`(该员工的客户列表),modal **列结构**按云客对齐(有效沟通数 modal 额外显示 `send_num`/`receive_num` 列,其余 4 个用基础列)。即**交互完整**(点任意数字→弹对应标题/列的 modal),**度量精确区分**(已回复 vs 未回复)作为已知简化,留待后续 message 聚合端点独立增强。此简化须在收尾验收时确认产品可接受。 --- ## 运行命令(验证用) - 后端:`cd yunke-server && DB_URL="sqlite+aiosqlite:///./smoke.db" .venv/Scripts/python.exe -m uvicorn app.api.main:app --port 8000 --log-level warning` - 后端测试:`cd yunke-server && .venv/Scripts/python.exe -m pytest tests/ -q` - 前端:`cd yunkes && npm run dev` - 前端类型检查:`cd yunkes && npx vue-tsc -b` --- ## File Map **后端(yunke-server/):** - Create `app/api/routers/export.py` — `GET /api/export/chat-html`(拉 messages + 渲染 HTML + attachment 下载) - Modify `app/api/main.py` — 注册 export router(参照其他 router 注册,约 L38-48 import + L59-67 include_router) - Create `tests/test_api_export.py` — 导出端点 TDD(`make_client` 范式) **前端(yunkes/src/):** - Modify `components/YkStaffDetailModal.vue` — `Col` 接口加 `onClick?: (row) => void`,渲染时该列包 `el-button link` - Modify `views/StaffList.vue` — 微信 tab:4 列数字→可点 modal、主badge→viewChat、员工姓名→openDrawer、modal 客户微信列 onClick - Modify `views/CustomerList.vue` — 微信 tab 操作列(L301-307)加导出按钮 + 时间选择 dialog + `exportChatHtml` - Modify `views/GroupList.vue` — 微信群 tab 操作列(L270-276)加导出按钮 + `exportChatHtml` - Modify `api/client.ts` — 加 `downloadBlob(url, params)` helper(`http.get` + `responseType:'blob'`) --- ## Task 1: 后端导出 HTML 端点(TDD) > 事实:`Message` 有 `is_send`(Boolean 方向)/`timestamp`(ms)/`talker_id`/`room_id`/`text`/`channel`/`owner_account_id`,**无 direction**。`get_session` 无参(`app.api.deps`)。测试用 `make_client`(**无 async_engine fixture**),范式见 `tests/test_api_messages.py` 的 `_seed`。 **Files:** - Create: `yunke-server/tests/test_api_export.py` - Create: `yunke-server/app/api/routers/export.py` - Modify: `yunke-server/app/api/main.py` - [ ] **Step 1: 写失败测试**(`tests/test_api_export.py`): ```python from sqlalchemy.ext.asyncio import AsyncSession from app.db.models import Message async def _seed(session: AsyncSession) -> None: session.add_all([ Message(msg_id="dm1", channel="wechat", owner_account_id="wx_owner", talker_id="wx_friend", room_id=None, text="你好", timestamp=1000, is_send=False, type="text", type_raw=1), Message(msg_id="dm2", channel="wechat", owner_account_id="wx_owner", talker_id="wx_friend", room_id=None, text="在的", timestamp=2000, is_send=True, type="text", type_raw=1), Message(msg_id="g1", channel="wechat", owner_account_id="wx_owner", talker_id="room_topic", room_id="g1@chatroom", text="群消息", timestamp=3000, is_send=True, type="text", type_raw=1), Message(msg_id="g2", channel="wechat", owner_account_id="wx_owner", talker_id="room_topic", room_id="g1@chatroom", text="范围外", timestamp=9999999, is_send=True, type="text", type_raw=1), ]) await session.commit() async def test_export_dm_returns_html(make_client): client = await make_client(_seed) r = await client.get("/api/export/chat-html", params={ "channel": "wechat", "owner_account_id": "wx_owner", "talker_id": "wx_friend"}) assert r.status_code == 200 assert r.headers["content-type"].startswith("text/html") assert "attachment" in r.headers["content-disposition"] body = r.text assert "你好" in body and "在的" in body assert "wx_owner" in body async def test_export_group_with_time_range(make_client): client = await make_client(_seed) r = await client.get("/api/export/chat-html", params={ "channel": "wechat", "owner_account_id": "wx_owner", "room_id": "g1@chatroom", "start": 0, "end": 5000}) assert r.status_code == 200 assert "群消息" in r.text assert "范围外" not in r.text # end=5000 过滤掉 timestamp=9999999 async def test_export_empty(make_client): client = await make_client(_seed) r = await client.get("/api/export/chat-html", params={ "channel": "wechat", "owner_account_id": "wx_owner", "talker_id": "none"}) assert r.status_code == 200 assert r.headers["content-type"].startswith("text/html") ``` - [ ] **Step 2: 跑测试确认 FAIL** ```bash cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_api_export.py -x -q ``` 预期:FAIL(404 路由不存在)。 - [ ] **Step 3: 实现 export router**(`app/api/routers/export.py`): ```python """GET /api/export/chat-html — 导出一段 1:1 / 群聊天为独立 HTML 文件。 对齐云客微信客户列表「导出HTML聊天记录」:按 (channel, owner, talker_id|room_id) 拉消息(可选 start/end 时间范围),渲染自包含 HTML 聊天页(is_send 决定左右气泡), 作为 attachment 返回下载。 """ from __future__ import annotations from datetime import datetime, timezone from html import escape from typing import Annotated import sqlalchemy as sa from fastapi import APIRouter, Depends, Query, Response from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_session from app.db.models import Message router = APIRouter(prefix="/api/export", tags=["export"]) SessionDep = Annotated[AsyncSession, Depends(get_session)] def _ts_text(ts: int | None) -> str: if not ts: return "" try: return datetime.fromtimestamp(ts / 1000, tz=timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M:%S") except (ValueError, OSError, OverflowError): return str(ts) def _render_chat_html(rows: list[Message], channel: str, owner: str, peer: str) -> str: bubbles = [] for m in rows: side = "right" if m.is_send else "left" ts = escape(_ts_text(getattr(m, "timestamp", None))) text = escape(getattr(m, "text", "") or "") bubbles.append( f'
{ts}
{text}
' ) title = f"聊天记录 · {escape(channel)} · {escape(owner)} / {escape(peer)}" return f""" {title}

{title}

{''.join(bubbles) or '

暂无消息

'} """ @router.get("/chat-html") async def export_chat_html( session: SessionDep, channel: Annotated[str, Query()], owner_account_id: Annotated[str, Query()], talker_id: Annotated[str | None, Query()] = None, room_id: Annotated[str | None, Query()] = None, start: Annotated[int | None, Query(ge=0)] = None, end: Annotated[int | None, Query(ge=0)] = None, ) -> Response: q = ( sa.select(Message) .where(Message.channel == channel) .where(Message.owner_account_id == owner_account_id) ) peer = "" if room_id: q = q.where(Message.room_id == room_id) peer = room_id elif talker_id: # DM:限定 room_id 为空,避免误捞群消息 q = q.where(Message.talker_id == talker_id).where( (Message.room_id == None) | (Message.room_id == "") # noqa: E711 ) peer = talker_id if start is not None: q = q.where(Message.timestamp >= start) if end is not None: q = q.where(Message.timestamp <= end) q = q.order_by(sa.nullslast(Message.timestamp.asc()), Message.msg_id.asc()) rows = (await session.execute(q)).scalars().all() html = _render_chat_html(list(rows), channel, owner_account_id, peer) safe = peer.replace("@", "_").replace("/", "_") or "all" filename = f"chat-{channel}-{owner_account_id}-{safe}.html".replace("@", "_").replace("/", "_") return Response( content=html, media_type="text/html; charset=utf-8", headers={"Content-Disposition": f'attachment; filename="{filename}"'}, ) ``` - [ ] **Step 4: 注册 router**(`app/api/main.py`,参照现有 import 块 L38-48 + include_router 块 L59-67 加两行): ```python from app.api.routers import export # 加入 import 列表 app.include_router(export.router) # 加入 include_router 列表 ``` - [ ] **Step 5: 跑测试确认 PASS** ```bash cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_api_export.py -x -q ``` - [ ] **Step 6: 全后端回归** ```bash cd yunke-server && .venv/Scripts/python.exe -m pytest tests/ -q ``` 预期:全 PASS(原 ≥330 + 新增 3)。 - [ ] **Step 7: Commit** ```bash git add yunke-server/app/api/routers/export.py yunke-server/app/api/main.py yunke-server/tests/test_api_export.py git commit -m "$(cat <<'EOF' feat(api): GET /api/export/chat-html — 导出 1:1/群聊天为 HTML 支持 DM(talker_id) 与 群(room_id),可选 start/end 时间范围; is_send 决定气泡左右。对齐云客微信客户/群列表「导出HTML聊天记录」。 Co-Authored-By: Claude EOF )" ``` --- ## Task 2: 前端 downloadBlob helper > 事实:`api/client.ts` 的 `http`(baseURL `/api`)响应拦截器返回 `response.data`;对 `responseType:'blob'` 也会原样透传 blob。**无现成下载 helper**。鉴权走同域 cookie,axios 自动带。 **Files:** Modify `yunkes/src/api/client.ts` - [ ] **Step 1: 加 `downloadBlob`**(`client.ts` 末尾,`getJson` 之后): ```ts /** 以 blob 下载(用于 HTML 导出等二进制/文本附件下载)。返回建议文件名(来自 content-disposition,缺失则 undefined)。 */ export async function downloadBlob( url: string, params?: Record, ): Promise { const res = await http.get(url, { params, responseType: 'blob' }) const blob = res as unknown as Blob // 从响应头取文件名(拦截器只解包 body,需用 raw 拦截;此处 res 已是 blob,header 丢失 → 用固定名) const filename = 'chat-export.html' const objUrl = URL.createObjectURL(blob) const a = document.createElement('a') a.href = objUrl a.download = filename document.body.appendChild(a) a.click() document.body.removeChild(a) URL.revokeObjectURL(objUrl) return filename } ``` > 说明:响应拦截器(client.ts L19-34)解包了 `response.data`,拿不到 `Content-Disposition` 头,故用固定文件名。可接受(云客导出文件名也非关键)。若需真实文件名,需另开 `http.getRaw` 走 `AxiosResponse`,本 plan 不做。 - [ ] **Step 2: 类型检查** ```bash cd yunkes && npx vue-tsc -b ``` 预期:exit 0。 - [ ] **Step 3: Commit** ```bash git add yunkes/src/api/client.ts git commit -m "$(cat <<'EOF' feat(api): add downloadBlob helper for blob attachment download Co-Authored-By: Claude EOF )" ``` --- ## Task 3: YkStaffDetailModal Col 加 onClick 支持 > 事实:`YkStaffDetailModal.vue` 的 `Col` 接口(L9-26)= `{prop,label,width?,minWidth?,align?,formatter?}`;列由 `v-for="c in cols"` 渲染(L57-91)。**无 defineExpose**,只能 v-model + props 驱动。目的:让 modal 内「客户微信」列可点→跳聊天记录(Task 7 用)。 **Files:** Modify `yunkes/src/components/YkStaffDetailModal.vue` - [ ] **Step 1: Col 接口加 `onClick`**(L9-17 的 `interface Col`,L18-26 是 defineProps): ```ts interface Col { prop: string label: string width?: number | string minWidth?: number | string align?: 'left' | 'center' | 'right' formatter?: (row: any, column: any, cellValue: any, index: number) => string onClick?: (row: any) => void // 新增:该列单元格可点(渲染为 link button) } ``` - [ ] **Step 2: 模板列渲染支持 onClick**(找到 `v-for="c in cols"` 的 ``,其 `