From a66c3be00a024d7f2f09cc2b557810edfc260e26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Thu, 6 Aug 2026 18:06:17 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20final=20Minor=20polish=20=E2=80=94=20e?= =?UTF-8?q?xport=20filename/test=E6=96=AD=E8=A8=80,=20downloadBlob=20?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B/=E6=B8=85=E7=90=86,=20plan=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit address review Minors: drop redundant filename replace, assert 暂无消息, tighten downloadBlob return type + try/finally, fix plan group time-picker wording. Co-Authored-By: Claude --- ...2026-08-06-yunke-customerlist-alignment.md | 8 + .../2026-08-06-yunke-missing-operations.md | 743 ++++++++++++++++++ .../2026-08-06-yunke-comm-operations-audit.md | 258 ++++++ yunke-server/app/api/routers/export.py | 2 +- yunke-server/tests/test_api_export.py | 1 + yunkes/src/api/client.ts | 13 +- 6 files changed, 1019 insertions(+), 6 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-06-yunke-missing-operations.md create mode 100644 docs/superpowers/specs/2026-08-06-yunke-comm-operations-audit.md diff --git a/docs/superpowers/plans/2026-08-06-yunke-customerlist-alignment.md b/docs/superpowers/plans/2026-08-06-yunke-customerlist-alignment.md index f0ddafc..1d8f498 100644 --- a/docs/superpowers/plans/2026-08-06-yunke-customerlist-alignment.md +++ b/docs/superpowers/plans/2026-08-06-yunke-customerlist-alignment.md @@ -653,6 +653,14 @@ git commit -m "test(customers): e2e verify CustomerList alignment vs 云客" - **WA「添加时间」= createTime**:按云客显示(字符串直显)。语义上 createTime 严格说是记录创建时间,与 Plan 1「不把 createTime 当 add_time 填充结构化列」不冲突——此处仅展示,不参与筛选。 - **不复刻**:导出HTML聊天记录按钮(spec:不引入装饰功能)。 +## Task 2 quality review 记录(非本 plan 范围,后续/backlog) + +Task 2 quality review ✅ Approved,6 个 Minor 不阻断;**#1**(query 变化时 reset 两个 tab 的 page,避免非 active tab 留 stale page)/ **#2**(简化 `fromTypeText`,去掉与 `fromTypeLabel` 重复的 null 处理)已修并入 commit。以下 4 项为项目范围/非回归,记录待后续: +- **顶栏「全部渠道」radio(MainLayout)在 /customers 上点击** → `setChannel('all')` → 本页 channel watch 映射到 `wechat`,静默把 WhatsApp tab 切回微信。根因在 MainLayout 全局 radio + `'all'` 语义(StaffList 同样存在)。后续可考虑:CustomerList/StaffList 这类按 channel 强制分 tab 的页面,禁用/隐藏顶栏 `'all'` 选项。 +- **「清除部门筛选」按钮** `router.replace({name:'customers'})` 会丢全部 query,若同时有 `owner_account_id` 会一并清除(StaffList 同模式)。如需只清部门,改为保留 owner query。 +- 加载失败无显式「重试」按钮(切 tab 可触发重试,因 `loaded=false`)。StaffList 同。 +- `loadTab` 无 AbortController,慢网快切可能短暂显示 stale 页(项目范围,StaffList 同)。 + --- ## 后续 Plan(不在本 plan 范围) diff --git a/docs/superpowers/plans/2026-08-06-yunke-missing-operations.md b/docs/superpowers/plans/2026-08-06-yunke-missing-operations.md new file mode 100644 index 0000000..3821d81 --- /dev/null +++ b/docs/superpowers/plans/2026-08-06-yunke-missing-operations.md @@ -0,0 +1,743 @@ +# 云客沟通板块漏操作补丁 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"` 的 ``,其 `