chore: final Minor polish — export filename/test断言, downloadBlob 类型/清理, plan docs

address review Minors: drop redundant filename replace, assert 暂无消息,
tighten downloadBlob return type + try/finally, fix plan group time-picker wording.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
浅念
2026-08-06 18:06:17 +08:00
parent 6dfed36c05
commit a66c3be00a
6 changed files with 1019 additions and 6 deletions
@@ -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 范围)
@@ -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'<div class="bubble {side}"><div class="t">{ts}</div><div class="b">{text}</div></div>'
)
title = f"聊天记录 · {escape(channel)} · {escape(owner)} / {escape(peer)}"
return f"""<!doctype html><html lang="zh"><head><meta charset="utf-8">
<title>{title}</title><style>
body{{font-family:-apple-system,Segoe UI,sans-serif;background:#f5f5f5;margin:0;padding:16px}}
h1{{font-size:16px;margin:0 0 12px}}
.bubble{{max-width:70%;margin:6px 0;padding:8px 12px;border-radius:8px;clear:both}}
.bubble.right{{float:right;background:#95ec69;text-align:right}}
.bubble.left{{float:left;background:#fff}}
.bubble .t{{font-size:11px;color:#999;margin-bottom:2px}}
.bubble .b{{font-size:14px;white-space:pre-wrap;word-break:break-word}}
</style></head><body><h1>{title}</h1>
{''.join(bubbles) or '<p>暂无消息</p>'}
</body></html>"""
@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 <noreply@anthropic.com>
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<string, unknown>,
): Promise<string | undefined> {
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 <noreply@anthropic.com>
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"` 的 `<el-table-column>`,其 `<template #default="{ row }">` 内)。把单元格默认文本渲染改为:有 `onClick` 则包 `el-button link`:
```html
<el-table-column
v-for="c in cols"
:key="c.prop"
:prop="c.prop"
:label="c.label"
:width="c.width"
:min-width="c.minWidth"
:align="c.align"
:formatter="c.formatter"
show-overflow-tooltip
>
<template v-if="c.onClick" #default="{ row }">
<el-button link type="primary" size="small" @click="c.onClick!(row)">
{{ c.formatter ? c.formatter(row, c, row[c.prop], 0) : row[c.prop] }}
</el-button>
</template>
</el-table-column>
```
> 没有 `onClick` 的列保留 `:formatter` 原生渲染(el-table 默认 slot 为空时走 prop+formatter)。implementer 先读 L57-91 现有模板对齐写法,保留无 onClick 列的原行为。
- [ ] **Step 3: 类型检查**
```bash
cd yunkes && npx vue-tsc -b
```
预期:exit 0(现有 StaffList 用法未传 onClick,向后兼容)。
- [ ] **Step 4: Commit**
```bash
git add yunkes/src/components/YkStaffDetailModal.vue
git commit -m "$(cat <<'EOF'
feat(modal): YkStaffDetailModal Col 支持可选 onClick(单元格可点)
Co-Authored-By: Claude <noreply@anthropic.com>
EOF
)"
```
---
## Task 4: StaffList 微信员工「沟通数」3 数字 → modal
> 事实:微信 tab 沟通数列 `StaffList.vue` L489-504,3 个 `<span class="yk-pair__v">` 纯文本:`chatCount`(L493 沟通人数) / `contactCount`(L497 有效沟通数) / `followCount`(L501 跟进客户数)。`YkStaffDetailModal` 已 import(L7)/已用(L673-678),状态 `detailOpen/detailTitle/detailFetcher/detailCols`(L226-232),`baseCustomerCols`(L237-242)。WhatsApp tab 的 `openAddedModal`(L244-255)/`openTalkedModal`(L257-272) 是现成模板。`getCustomers` 在 `@/api`(index.ts L46-60)。
**Files:** Modify `yunkes/src/views/StaffList.vue`
- [ ] **Step 1: 读现有模板对齐**。先 Read `StaffList.vue` L226-272(detail 状态 + baseCustomerCols + openAddedModal/openTalkedModal)+ L673-678(modal 用法),理解 `detailFetcher` 怎么调 `getCustomers`、列怎么定义。新增的微信 modal 照此模式。
- [ ] **Step 2: 加 3 个微信沟通数 modal 开启函数**(script 内,`openTalkedModal` 之后)。fetcher 统一调 `getCustomers({channel:'wechat', owner_account_id: row.account_id, ...分页})`:
```ts
/** 微信员工「沟通数」3 数字 → 明细 modal(数据源:该员工客户列表,度量精确区分见 plan 数据源说明)。 */
function openWechatCommModal(row: Account, title: string, withMsgCols: boolean): void {
detailTitle.value = title
detailCols.value = withMsgCols
? [
{ prop: 'friend_nickname', label: '客户微信', minWidth: 160 },
{ prop: 'send_num', label: '发送消息数', width: 110, align: 'center' },
{ prop: 'receive_num', label: '接收消息数', width: 110, align: 'center' },
]
: [{ prop: 'friend_nickname', label: '客户微信', minWidth: 160 }]
detailFetcher.value = async (page: number, page_size: number) =>
getCustomers({ channel: 'wechat', owner_account_id: row.account_id, page, page_size })
detailOpen.value = true
}
```
> `getCustomers` 已在 L5 import。列对齐云客:沟通人数/跟进客户数 = 基础列;有效沟通数 = 基础列 + 发送/接收消息数。员工信息/员工微信列本 plan 省略(modal 已是某员工的明细,复用 modal 标题体现)。
- [ ] **Step 3: 沟通数列 3 个 span 改可点**(L489-504)。把 3 个 `<span class="yk-pair__v">{{ cellNum(row, '...') }}</span>` 改为 `el-button link`:
```html
<!-- 沟通人数 -->
<el-button link type="primary" size="small" class="yk-pair__v" @click.stop="openWechatCommModal(row, '沟通人数(已去重)', false)">{{ cellNum(row, 'chatCount') }}</el-button>
<!-- 有效沟通数 -->
<el-button link type="primary" size="small" class="yk-pair__v" @click.stop="openWechatCommModal(row, '有效沟通数(已去重)', true)">{{ cellNum(row, 'contactCount') }}</el-button>
<!-- 跟进客户数 -->
<el-button link type="primary" size="small" class="yk-pair__v" @click.stop="openWechatCommModal(row, '跟进客户数', false)">{{ cellNum(row, 'followCount') }}</el-button>
```
- [ ] **Step 4: 类型检查**
```bash
cd yunkes && npx vue-tsc -b
```
预期:exit 0。
- [ ] **Step 5: 浏览器验证**(`/staff` 微信 tab)→ 点沟通人数/有效沟通数/跟进客户数 → 分别弹对应标题 modal,有效沟通数 modal 多两列。
- [ ] **Step 6: Commit**
```bash
git add yunkes/src/views/StaffList.vue
git commit -m "$(cat <<'EOF'
feat(staff): 微信员工沟通数 3 数字 → 明细 modal(补 Plan1 误判漏做的操作)
Co-Authored-By: Claude <noreply@anthropic.com>
EOF
)"
```
---
## Task 5: StaffList 微信员工「及时回复」2 数字 → modal
> 事实:及时回复列 L507-522,`saleSendLastNum`(L511 员工最后回复数) / `saleSendLastNumRate`(L515 回复率,**不可点**) / `customerSendLastNum`(L519 未回复客户数)。
**Files:** Modify `yunkes/src/views/StaffList.vue`
- [ ] **Step 1: 复用 Task4 的 `openWechatCommModal`**(同模式,基础列)。及时回复 2 数字:
```html
<!-- 员工最后回复数 L511 -->
<el-button link type="primary" size="small" class="yk-pair__v" @click.stop="openWechatCommModal(row, '员工最后回复数', false)">{{ cellNum(row, 'saleSendLastNum') }}</el-button>
<!-- 回复率 L515 保持纯 span(不可点) -->
<span class="yk-pair__v">{{ cellText(row, 'saleSendLastNumRate') }}</span>
<!-- 未回复客户数 L519 -->
<el-button link type="primary" size="small" class="yk-pair__v" @click.stop="openWechatCommModal(row, '未回复客户数', false)">{{ cellNum(row, 'customerSendLastNum') }}</el-button>
```
- [ ] **Step 2: 类型检查 + 浏览器验证**(点 2 数字弹 modal;回复率不可点)
```bash
cd yunkes && npx vue-tsc -b
```
- [ ] **Step 3: Commit**
```bash
git add yunkes/src/views/StaffList.vue
git commit -m "$(cat <<'EOF'
feat(staff): 微信员工及时回复 2 数字 → 明细 modal(回复率不可点)
Co-Authored-By: Claude <noreply@anthropic.com>
EOF
)"
```
---
## Task 6: StaffList 微信员工 主badge→聊天 + 员工姓名→drawer(纠正手机号误判)
> 事实:员工信息列 主/副badge 是 `<el-tag>`(L410-416,`duoTagText`),云客点「主」badge→聊天窗口;微信昵称 L417-419 已绑 `viewChat`;员工姓名 L422-424 纯 `<span>`,云客点→员工详情页;微信手机号 L426-428 纯 span **不可点**。`openDrawer(row)` L334-337 已存在。`viewChat(row)` L196-219 跳 yunkes `/chat`(聊天记录,降级云客 wechat-window)。
> **纠正**:原 missing-operations plan Task1「微信手机号→openDrawer」是误判——云客 `button.ant-btn-link` 包的是**员工姓名**(带 history 图标),手机号不可点。本 task 改员工姓名。
**Files:** Modify `yunkes/src/views/StaffList.vue`
- [ ] **Step 1: 主badge 可点→viewChat**(L410-416 的 `<el-tag>`)加 `@click.stop="viewChat(row)"` 与 `style="cursor:pointer"`:
```html
<el-tag v-if="duoTagText(row)" :type="duoTagType(row)" effect="dark" size="small" class="yk-staffinfo__duo" style="cursor:pointer" @click.stop="viewChat(row)">{{ duoTagText(row) }}</el-tag>
```
> 云客点主badge→wechat-window 聊天窗口;yunkes 降级跳 `/chat` 聊天记录(同昵称)。implementer 读 L410-416 现有 el-tag 写法对齐属性。
- [ ] **Step 2: 员工姓名 可点→openDrawer**(L422-424):
```html
<el-button link type="primary" size="small" class="yk-staffinfo__v" @click.stop="openDrawer(row)">{{ cellText(row, 'userName') }}</el-button>
```
> 云客跳员工详情页 `customer/userDetailNew`;yunkes 无该页,降级开 drawer(全字段,复用现有 `openDrawer`)。**手机号 L426-428 保持纯 span 不变**(云客也不可点)。
- [ ] **Step 3: 类型检查 + 浏览器验证**(点主badge/员工姓名→分别跳聊天/开 drawer;手机号不可点)
```bash
cd yunkes && npx vue-tsc -b
```
- [ ] **Step 4: Commit**
```bash
git add yunkes/src/views/StaffList.vue
git commit -m "$(cat <<'EOF'
feat(staff): 微信员工 主badge→聊天 + 员工姓名→drawer(纠正手机号误判)
云客跳详情页的是员工姓名(button+history),非手机号;手机号不可点。
Co-Authored-By: Claude <noreply@anthropic.com>
EOF
)"
```
---
## Task 7: StaffList modal 内客户微信 → 聊天记录
> 事实:依赖 Task3 的 `Col.onClick`。云客 modal 内客户微信列→开 `wechat/detail/message` 聊天记录;yunkes 降级跳 `/chat/{channel}/{owner}/{talker}`(ChatTimeline)。modal 列数据是 `Customer`(`friend_nickname`/`friend_id`/`owner_account_id`/`channel`)。
**Files:** Modify `yunkes/src/views/StaffList.vue`
- [ ] **Step 1: 给 `openWechatCommModal` 的基础列加 `onClick`**(修改 Task4 Step2 加的列定义,给「客户微信」列加 onClick):
```ts
import type { Customer } from '@/api/types' // 若未 import 则加(参考 CustomerList 用法)
function openWechatCommModal(row: Account, title: string, withMsgCols: boolean): void {
detailTitle.value = title
const goChat = (r: Customer) => {
if (!r.owner_account_id || !r.friend_id) return
router.push({ name: 'chat', params: { channel: r.channel, ownerId: r.owner_account_id, talkerId: r.friend_id } })
}
detailCols.value = withMsgCols
? [
{ prop: 'friend_nickname', label: '客户微信', minWidth: 160, onClick: goChat },
{ prop: 'send_num', label: '发送消息数', width: 110, align: 'center' },
{ prop: 'receive_num', label: '接收消息数', width: 110, align: 'center' },
]
: [{ prop: 'friend_nickname', label: '客户微信', minWidth: 160, onClick: goChat }]
detailFetcher.value = async (page: number, page_size: number) =>
getCustomers({ channel: 'wechat', owner_account_id: row.account_id, page, page_size })
detailOpen.value = true
}
```
> `router` 已在 L3 import/use。`chat` 路由名参照 CustomerList viewChat(L147-160 用 `name:'chat'`)。implementer 确认 `Customer` 类型 import 路径(`@/api/types`)。
- [ ] **Step 2: 类型检查 + 浏览器验证**(点沟通数 modal 内客户微信→跳该客户聊天记录页)
```bash
cd yunkes && npx vue-tsc -b
```
- [ ] **Step 3: Commit**
```bash
git add yunkes/src/views/StaffList.vue
git commit -m "$(cat <<'EOF'
feat(staff): 微信员工明细 modal 内客户微信 → 聊天记录(二级操作)
Co-Authored-By: Claude <noreply@anthropic.com>
EOF
)"
```
---
## Task 8: CustomerList 微信客户「导出HTML聊天记录」+ 时间选择
> 事实:微信 tab 操作列 L301-307(单按钮「查看聊天记录」);WhatsApp tab L373-379(**勿改**)。`viewChat` L147-160。云客导出HTML→先弹「请选择时间」modal→确定下载。`downloadBlob`(Task2)。导出参数:channel/owner_account_id/talker_id/start/end。
**Files:** Modify `yunkes/src/views/CustomerList.vue`
- [ ] **Step 1: 加导出相关状态 + 函数**(script 内,`viewChat` 附近):
```ts
import { downloadBlob } from '@/api/client'
const exportOpen = ref(false)
const exportRow = ref<Customer | null>(null)
const exportRange = ref<[number, number] | null>(null) // [startMs, endMs]
function openExport(row: Customer): void {
if (!row.owner_account_id || !row.friend_id) {
ElMessage.warning('该客户缺少归属账号/客户标识,无法导出')
return
}
exportRow.value = row
exportRange.value = null
exportOpen.value = true
}
async function confirmExport(): Promise<void> {
const row = exportRow.value
if (!row) return
const params: Record<string, unknown> = {
channel: row.channel,
owner_account_id: row.owner_account_id,
talker_id: row.friend_id,
}
if (exportRange.value) {
// value-format="X" 返回秒级字符串,后端要毫秒
params.start = Number(exportRange.value[0]) * 1000
params.end = Number(exportRange.value[1]) * 1000
}
exportOpen.value = false
await downloadBlob('/export/chat-html', params)
}
```
> `ref`/`ElMessage` 已在文件顶部 import(确认);`Customer` 类型已用。时间范围用 element-plus date-picker(毫秒时间戳)。
- [ ] **Step 2: 微信 tab 操作列加导出按钮 + 加宽**(L301-307):
```html
<el-table-column label="操作" width="220" fixed="right" align="center">
<template #default="{ row }">
<el-button link type="primary" size="small" @click.stop="viewChat(row)">查看聊天记录</el-button>
<el-button link type="primary" size="small" @click.stop="openExport(row)">导出HTML聊天记录</el-button>
</template>
</el-table-column>
```
> **仅微信 tab(L301-307)改**,WhatsApp tab(L373-379)不动(云客 WA 客户无导出按钮)。
- [ ] **Step 3: 加时间选择 dialog**(模板末尾,关闭标签前):
```html
<el-dialog v-model="exportOpen" title="请选择时间" width="420px">
<el-form label-width="90px">
<el-form-item label="时间范围">
<el-date-picker
v-model="exportRange"
type="datetimerange"
range-separator="至"
start-placeholder="开始时间"
end-placeholder="结束时间"
value-format="X"
format="YYYY-MM-DD HH:mm:ss"
style="width:100%"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="exportOpen = false">取 消</el-button>
<el-button type="primary" @click="confirmExport">确 定</el-button>
</template>
</el-dialog>
```
> `value-format="X"` 返回秒级 unix 字符串;需转毫秒。改 `confirmExport` 里:`params.start = Number(exportRange.value[0]) * 1000`(若 date-picker 返回字符串)。implementer 按 element-plus 实际返回类型调整(`X` 返回 string 秒)。
- [ ] **Step 4: 类型检查 + 浏览器验证**(微信客户 tab→点导出→弹时间选择→确定→下载 HTML 含消息;不选时间→导出全部)
```bash
cd yunkes && npx vue-tsc -b
```
- [ ] **Step 5: Commit**
```bash
git add yunkes/src/views/CustomerList.vue
git commit -m "$(cat <<'EOF'
feat(customers): 微信客户操作列加「导出HTML聊天记录」+ 时间选择
对齐云客:先弹「请选择时间」→ 确定后调 /api/export/chat-html 下载。
Co-Authored-By: Claude <noreply@anthropic.com>
EOF
)"
```
---
## Task 9: GroupList 微信群「导出HTML聊天记录」
> 事实:微信群 tab 操作列 L270-276(单按钮);WA 群 L345-351(**勿改**,云客 WA 群无导出)。`viewChat` L137-146。群导出用 `room_id`(非 talker_id)。
**Files:** Modify `yunkes/src/views/GroupList.vue`
- [ ] **Step 1: 加 `exportGroupHtml` 函数**(script 内,`viewChat` 附近):
```ts
import { downloadBlob } from '@/api/client'
/** 微信群「导出HTML聊天记录」→ 后端 /api/export/chat-html(room_id)。云客此操作在微信群列表。 */
async function exportGroupHtml(row: Group): Promise<void> {
if (!row.owner_account_id || !row.room_id) {
ElMessage.warning('该群缺少归属账号/群标识,无法导出')
return
}
await downloadBlob('/export/chat-html', {
channel: row.channel,
owner_account_id: row.owner_account_id,
room_id: row.room_id,
})
}
```
> 群导出**不加时间选择**(云客群列表导出按钮直接导出,无时间 modal——仅微信客户有「请选择时间」)。若实测发现群也有时间选择,implementer 补;本 plan 按客户列表采集事实(群操作列点采集时未见时间 modal,直接下载)。`Group`/`ElMessage` 已 import。
- [ ] **Step 2: 微信群 tab 操作列加导出按钮 + 加宽**(L270-276):
```html
<el-table-column label="操作" width="220" fixed="right" align="center">
<template #default="{ row }">
<el-button link type="primary" size="small" @click.stop="viewChat(row)">查看聊天记录</el-button>
<el-button link type="primary" size="small" @click.stop="exportGroupHtml(row)">导出HTML聊天记录</el-button>
</template>
</el-table-column>
```
> **仅微信群 tab(L270-276)改**,WA 群(L345-351)不动。
- [ ] **Step 3: 类型检查 + 浏览器验证**(微信群 tab→点导出→下载 HTML 含群消息)
```bash
cd yunkes && npx vue-tsc -b
```
- [ ] **Step 4: Commit**
```bash
git add yunkes/src/views/GroupList.vue
git commit -m "$(cat <<'EOF'
feat(groups): 微信群操作列加「导出HTML聊天记录」(补 Plan3 漏的操作)
Co-Authored-By: Claude <noreply@anthropic.com>
EOF
)"
```
---
## Task 10: 端到端验证 + 回归
- [ ] **Step 1: 起后端 + 前端**(若未在跑)。
- [ ] **Step 2: 后端 curl 抽查**(用已有 wechat customer 的 owner+friend):
```bash
curl -s -o /tmp/chat.html "http://localhost:8000/api/export/chat-html?channel=wechat&owner_account_id=wxid_fnkf5b0iaqrb22&talker_id=wxid_p6417n8wveno22" && head -c 300 /tmp/chat.html
```
预期:返回 HTML(含 `<title>聊天记录` + 消息文本)。无消息则「暂无消息」。
- [ ] **Step 3: 浏览器 `/staff` 微信 tab**:① 点沟通人数/有效沟通数/跟进客户数/员工最后回复数/未回复客户数 → 各弹对应标题 modal(有效沟通数多2列);② modal 内点客户微信 → 跳聊天记录;③ 点主badge → 跳聊天;④ 点员工姓名 → 开 drawer;⑤ 手机号不可点。
- [ ] **Step 4: 浏览器 `/customers` 微信 tab** → 点导出HTML → 选时间 → 确定 → 下载 HTML(含消息)。
- [ ] **Step 5: 浏览器 `/groups` 微信群 tab** → 点导出HTML → 下载 HTML。
- [ ] **Step 6: 全回归**
```bash
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/ -q
cd yunkes && npx vue-tsc -b
```
预期:pytest 全绿(≥333),vue-tsc exit 0。
---
## 收尾验收
- [ ] `vue-tsc -b` exit 0;pytest ≥333 绿(新增 3 export 测试)。
- [ ] 微信员工 5 数字均可点→弹对应 modal(有效沟通数多发送/接收消息数列);回复率不可点;主badge→聊天;员工姓名→drawer;手机号不可点;modal 内客户微信→聊天记录。
- [ ] 微信客户导出HTML:时间选择 dialog → 下载 HTML。
- [ ] 微信群导出HTML:直接下载 HTML。
- [ ] WA 两侧(员工/客户/群)操作不受影响(Plan1/2/3 已对齐,本 plan 未改 WA)。
- [ ] **数据源简化已确认产品可接受**:5 个数字 modal 统一用员工客户列表,度量精确区分(已回复 vs 未回复)留待后续 message 聚合端点。
---
## 不在本 plan 范围(显式)
- 语音通话列表 CallList(微信 voice-call-list + WA wavoicecalllist + 长语音列表 modal + 录音播放/手动匹配/录音异常检查)—— 独立 Plan 4,前置抓取修复(voice-call-list/wavoicecalllist 抓取缺失)。
- 工具栏批量操作(批量语音下载/全部导出/高级筛选)—— 随 CallList。
- 员工详情独立页(用户选降级 drawer,不建)。
- 5 个数字 modal 的精确 KPI 度量还原(需 message 聚合端点,独立增强)。
- 导出 HTML 的真实文件名(响应拦截器限制,用固定名;如需真实文件名另开 getRaw helper)。
@@ -0,0 +1,258 @@
# 云客「沟通」板块操作全面核对表
> 采集时间:2026-08-06|采集源:crm2.yunkecn.com(登录态真机核对)|范围:沟通板块 6 个列表(微信/WA × 员工/客户/语音通话)
> 用途:作为重写 `2026-08-06-yunke-missing-operations.md` plan 的事实依据。
## 核对方法(关键纠错)
- **可点判定**:`getComputedStyle(el).cursor === 'pointer'`,覆盖 `<a>` / `<button>` / `div.link` / 图标(`.anticon-*`) / 统计数字,**不再只看 a/button**。
- **🔴 纠正内存**:云客表格**无 shadow DOM**(`shadowRootCount: 0`),evaluate 的 CSS 选择器可直接命中。内存里"云客表格在 shadow DOM、evaluate CSS 命中 0、必须用 getByRole"的说法**已过时/错误**——后续采集脚本可直接用 `.ant-table` CSS。
- **行为探测**:实际点击 + 看 modal/新 tab/URL 跳转/网络请求。标注 `✅实测` 的为真机点过;标注 `⚠️采信` 的为沿用 Plan1/2 既对齐结论或同类模式推断,**实现时建议复核**。
## 重要 URL 纠正(vs 内存)
| 实体 | 内存说法 | 实际(2026-08-06 真机) |
|---|---|---|
| 微信语音通话列表 | `callstatistics`(按员工KPI统计) | **`/cms/wechat/voice-call-list`**(每条通话记录表)。`callstatistics` 是「微信看板→微信语音统计」另一个 KPI 页,直链白屏,**非**"语音通话列表" |
| WA 员工列表 | (未明确) | `/cms/wechat/waBusiness/userlist` |
| WA 客户列表 | `waBusiness/friendlist` | `/cms/wechat/waBusiness/friendlist/0/{userId}`(按员工 userId 过滤) |
| WA 语音通话 | `waBusiness/wavoicecalllist` | ✅ 正确 `/cms/wechat/waBusiness/wavoicecalllist` |
| 微信侧边栏 | "微信侧边栏有员工/客户列表" | 员工/客户/语音都在**「微信号管理」**一级菜单下;「微信看板」下是数据看板/微信语音统计/数据统计 |
## 侧边栏结构(真机)
- **微信**一级菜单:微信看板 / 微信号管理 / 微信风控 / 微信设置 / **WhatsApp商业版**
- 微信号管理子菜单:**员工微信列表** / **客户列表** / **语音通话列表** / 微信群统计 / 重复好友统计 / 微信验证消息
- 微信看板子菜单:数据看板 / 微信语音统计(callstatistics) / 数据统计
- **WhatsApp商业版**一级菜单:数据统计 / **员工列表** / **客户列表** / 账号解绑 / 敏感词设置 / 敏感词统计 / **语音通话列表**
---
## 1. 微信员工列表 `/cms/wechat/wechatlist`
**列**(7):`checkbox` / `员工信息` / `数据更新时间/最新消息时间` / `好友数` / `消息数` / `沟通数` / `及时回复`
复合列内容:
- 员工信息:主(badge) / 微信昵称 / 员工姓名 / 微信手机号 / 微信号
- 好友数:好友数 / 微信群数
- 消息数:发送消息数 / 接收消息数 / 撤回消息数
- 沟通数:沟通人数 / 有效沟通数 / 跟进客户数
- 及时回复:员工最后回复数 / 员工最后回复率 / 未回复客户数
**可点元素 × 行为**:
| 元素 (selector) | 文本样本 | 行为 |
|---|---|---|
| 主 badge `员工信息 a:first` | 主 | ✅ 新 tab 聊天窗口 `wechat/salewechat/message/wechat-window/1/{wxId}/...` |
| 微信昵称 `员工信息 a` | Pasha Zhang | ✅ 新 tab 聊天窗口 `wechat-window/1/{wxId}/1` |
| **员工姓名** `button.ant-btn-link`(含 anticon-history) | 张士蓓 | ✅ **新 tab 员工详情** `customer/userDetailNew?userId={userId}` |
| 好友数 `好友数 a:eq(0)` | 1229 | ✅ 当前 tab 跳客户列表 `wechatlist/wechatdetail/1/{wxId}?ymdStart=0000-00-00&ymdEnd=YYYY-MM-DD` |
| 微信群数 `好友数 a:eq(1)` | 185 | ⚠️采信 跳群列表 `wechatdetail/2/{wxId}`(type=2 群) |
| 消息数(发送/接收/撤回) | — | ❌ **不可点** |
| 沟通人数 `沟通数 div.link:eq(0)` | 160 | ✅ modal「沟通人数(已去重)」列:序号/客户微信/员工信息/员工微信/操作 |
| 有效沟通数 `沟通数 div.link:eq(1)` | 32 | ✅ modal「有效沟通数(已去重)」列:序号/客户微信/**发送消息数/接收消息数**/员工信息/员工微信/操作 |
| 跟进客户数 `沟通数 div.link:eq(2)` | 37 | ✅ modal「跟进客户数」列:序号/客户微信/员工信息/员工微信/操作 |
| 员工最后回复数 `及时回复 div.link:eq(0)` | 15 | ✅ modal(标题空)列:序号/客户微信/员工信息/员工微信/操作 |
| 未回复客户数 `及时回复 div.link:eq(1)` | 17 | ✅ modal(标题空)列:序号/客户微信/员工信息/员工微信/操作 |
| 员工最后回复率 | 9% | ❌ **不可点** |
| modal 内 客户微信 / 员工微信 | 脱敏昵称 | ✅ 新 tab 聊天记录详情 `wechat/detail/message/{owner}/{friend}/1?start&end`(带 modal 时间范围) |
**工具栏**:导出 / 导出聊天记录(未逐一探测)
**🔴 vs missing-operations plan 的纠正**:plan Task1 写"微信员工**手机号**→openDrawer"。实际云客 `button.ant-btn-link` 包的是**员工姓名**(带 history 图标),手机号本身**不可点**。跳详情页的触发元素是员工姓名。
---
## 2. 微信客户列表 `/cms/wechat/wechatlist/wechatdetail/1/{wxId}?ymdStart&ymdEnd`
**列**(8):`checkbox` / `客户微信` / `数据来源` / `客户备注名` / `最近沟通时间` / `发送条数` / `接收条数` / `操作`
**可点元素 × 行为**:仅操作列(其余列全不可点)
| 元素 | 行为 |
|---|---|
| 查看聊天记录 | ✅ 新 tab 聊天详情 `wechat/detail/message/{owner}/{friend}/1?lastChatTime=...` |
| 导出HTML聊天记录 | ✅ **先弹 modal「请选择时间」**(选时间范围,按钮 确认/取消)→ 确定后下载 HTML |
**🔴 vs missing-operations plan 的补充**:plan Task2 的 `GET /api/export/chat-html` **未含时间范围参数**。云客原生是两步(先选时间再导出),yunkes 实现建议:加可选 `start/end` 参数;或简化为导出全部(但 UI 上可保留时间选择器对齐云客)。
---
## 3. 微信语音通话列表 `/cms/wechat/voice-call-list` 🔴纠正:非 callstatistics
**范围 tab**:`微信好友` / `微信群`
**列**(微信好友 tab,7):`checkbox` / `通话信息` / `客户信息` / `员工微信` / `员工信息` / `通话录音` / `通话时长`
**列**(微信群 tab,7):`checkbox` / `通话信息` / **`群信息`** / `员工微信` / `员工信息` / `通话录音` / `通话时长`(仅 客户信息↔群信息 之差)
复合列内容:
- 通话信息:通话状态(已接通/未接通) / 通话类型(微信语音) / 通话来源(手机/未知)
- 客户信息:微信昵称(脱敏) / 微信号(脱敏)
- 员工微信:微信昵称 / 微信号
- 员工信息:姓名 / 手机号
- 通话录音:录音时长 或 -(未接通无)
- 通话时长:结束时间 / 通话时长
**统计卡片**(顶部):今日好友语音呼入量/外呼量、今日微信群语音量、今日好友呼入呼出时长/群通话录音时长
**可点元素 × 行为**:
| 元素 (selector) | 行为 |
|---|---|
| 客户头像/昵称 `客户信息 a` | ⚠️采信 开聊天 |
| 员工头像/昵称 `员工微信 a` | ⚠️采信 聊天窗口 |
| 员工姓名/手机 `员工信息 div.link` | ⚠️采信 员工详情 |
| 通话录音播放 `通话录音 div.yk-voice`(含 anticon-wifi) | ✅ 播放录音 |
| 通话录音编辑 `通话录音 button.ant-btn`(含 anticon-edit) | ✅ **modal「微信长语音列表」**(见通用组件) |
**工具栏**:批量语音下载 / 全部导出 / 高级筛选(通话结束时间范围)
---
## 4. WA 员工列表 `/cms/wechat/waBusiness/userlist`
**列**(11):`checkbox` / `序号` / `员工信息` / `员工WhatsApp` / `是否在职` / `添加人数` / `沟通人数` / `好友数` / `群数` / `最新消息时间` / `操作`
**可点元素 × 行为**:
| 元素 | 文本样本 | 行为 |
|---|---|---|
| 员工WhatsApp 昵称 `员工WhatsApp a` | Hesucar | ⚠️采信 WA 聊天窗口 |
| 添加人数 | -(无数据时) | ⚠️采信(Plan1已做) modal |
| 沟通人数 `沟通人数 button` | 2 | ✅ modal「沟通人数」列:序号/员工信息/员工WhatsApp/客户WhatsApp/**添加时间/最后沟通时间** |
| 好友数 / 群数 | 551 / 2 | ❌ **不可点**(纯数字展示) |
| 操作-好友列表 `操作 button` | 好友列表 | ✅ 当前 tab 跳 WA 客户列表 `waBusiness/friendlist/0/{userId}` |
| 操作-群聊 `操作 button` | 群聊 | ⚠️采信 跳 WA 群列表 |
| 员工信息(姓名/手机) | — | ❌ **不可点**(与微信员工不同) |
**工具栏**:查询 / 导出 / 聊天记录类型 / 导出聊天记录
**与微信员工差异**:WA 用操作列按钮(好友列表/群聊)跳转,微信直接点数字(好友数/群数);WA 员工信息列不可点,微信员工姓名可点跳详情。
---
## 5. WA 客户列表 `/cms/wechat/waBusiness/friendlist/0/{userId}`
**列**(7,无 checkbox):`序号` / `员工信息` / `员工WhatsApp` / `客户WhatsApp` / `添加时间` / `最后沟通时间` / `操作`
**tab**:好友列表 / 群列表
**可点元素**:仅操作列「查看聊天记录」(⚠️采信 → WA 聊天记录)。**无导出HTML**(与微信客户不同,印证 Plan2)。
---
## 6. WA 语音通话列表 `/cms/wechat/waBusiness/wavoicecalllist`
**列**(6):`checkbox` / `状态` / `客户信息` / `员工信息` / `通话时间和录音` / `操作`
复合列内容:
- 状态:通话状态 / 通话时长
- 客户信息:WhatsApp昵称(脱敏) / WhatsApp号(脱敏)
- 员工信息:姓名 / 手机号 / 工号 / WhatsApp昵称 / WhatsApp号
- 通话时间和录音:通话时间
**可点元素 × 行为**:
| 元素 | 行为 |
|---|---|
| 客户WA 昵称 `客户信息 a` | ⚠️采信 开聊天 |
| 员工姓名 `员工信息 div.link` | ⚠️采信 员工详情 |
| 操作-点击语音列表手动匹配 | ✅ **modal「微信长语音列表」**(与微信同,见通用组件) |
| 操作-录音异常检查 | ✅ GET `/wechatweb/call/check/{callId}`(异步检查,toast 反馈) |
| "语音未匹配?" | 状态提示文本,非按钮 |
**工具栏**:筛选(WhatsApp好友/呼入呼出/员工姓名手机号WA昵称/客户WA昵称账号/接通状态)/ 批量语音下载 / 全部导出
---
## 通用组件:微信长语音列表 modal
微信 `voice-call-list` 的通话录音编辑按钮 + WA `wavoicecalllist` 的「手动匹配」**打开同一个 modal**。
- **标题**:微信长语音列表
- **筛选**:录音结束时间 / 员工姓名 / 企业串码
- **列**:`员工姓名` / `企业串码` / `文件地址`(yunke-pcfile...) / `录音开始时间` / `录音结束时间` / `录音时长` / `操作(匹配)`
- **底部**:取消 / 确定
- 含多条长语音片段,每条可「匹配」到当前通话
---
## yunkes 实现差距总览
### 已对齐(Plan1/2/3,✅无需改)
- 微信员工:好友数→客户列表、操作跳转、昵称→ChatTimeline、(部分)数字 modal
- 微信客户:查看聊天记录→ChatTimeline
- WA 员工:添加人数/沟通人数 modal、操作跳转
- WA 客户:查看聊天记录
### 🔴 漏做 / 需补(本次核对新发现)
1. **微信员工 沟通数/及时回复 的 5 个数字 modal 漏做**(Plan1 误判"不可点"):沟通人数/有效沟通数/跟进客户数/员工最后回复数/未回复客户数 都是 `div.link` 可点→modal。modal 内客户微信还可点→聊天记录。
2. **微信员工 员工姓名(button+history)→员工详情**:missing-operations plan 写的是"手机号",应改为**员工姓名**触发(手机号不可点)。
3. **微信员工 「主」badge→聊天窗口**:漏做(开该员工微信号聊天窗口)。
4. **微信客户 导出HTML**:缺**时间范围选择**(云客先弹「请选择时间」modal)。
5. **微信语音通话列表 CallList**:整个页面未实现。应按 `voice-call-list` 对齐(每条通话记录,微信好友/群 tab,列:通话信息/客户信息/员工微信/员工信息/通话录音/通话时长),含录音播放 + 长语音列表 modal。**不是** callstatistics KPI 看板。
6. **WA 语音通话列表 CallList**:未实现。对齐 `wavoicecalllist`(状态/客户信息/员工信息/通话时间和录音/操作),操作列「手动匹配」(→长语音modal) +「录音异常检查」(→/call/check)。
7. **长语音列表 modal**(通用组件):未实现。微信/WA 语音都用到。
8. **工具栏操作**:批量语音下载 / 全部导出 / 导出聊天记录 / 高级筛选(语音列表)—— 之前按 spec「不复刻装饰按钮」跳过,按用户新需求需评估实现。
### ✅ 采信项已全部实测(第二轮真机,见文末)
原列出的采信项均已实际点击验证,结果见文末「第二轮:采信项实测补全」。
---
## 内存待纠正点(写入 memory)
1. 云客表格**无 shadow DOM**,evaluate CSS 直接可用(旧说法过时)。
2. 微信语音通话列表 = **`/cms/wechat/voice-call-list`**(每条记录表),**非** `callstatistics`(那是另一个 KPI 看板,直链白屏)。
3. 微信员工跳详情页的是**员工姓名**(`button.ant-btn-link`+history icon),**非手机号**(手机号不可点)。
4. 微信员工「主」badge→聊天窗口;沟通数/及时回复 5 个数字均可点→modal(Plan1 误判)。
5. 微信/WA 语音共享「微信长语音列表」modal(手动匹配/录音编辑)。
6. WA 语音操作:手动匹配(→长语音modal) + 录音异常检查(→GET /call/check/{id})。
7. WA 客户列表 URL 带 userId:`friendlist/0/{userId}`;WA 员工 `userlist`。
8. 微信客户导出HTML 是两步(先选时间范围)。
---
## 第二轮:采信项实测补全(2026-08-06,零采信)
所有原 ⚠️ 采信项均已真机点击验证:
### 微信员工
- 微信群数「185」→ ✅ 当前 tab 跳群列表 `wechatlist/wechatdetail/2/{wxId}?ymdStart&ymdEnd`(type=2 群,标题「微信群」)
### 微信群列表(wechatdetail/2,Plan3 复核)
- 列:`checkbox` / `群信息` / `数据来源` / `群主信息` / `群成员数量` / `群成员好友数量` / `消息数量` / `操作`
- 操作列:查看聊天记录 + **导出HTML聊天记录**(🔴 Plan3 GroupList 可能漏做导出HTML,需补)
- 其余列(群信息/群主信息/数字)不可点
### 微信语音 voice-call-list
- 客户昵称 → ✅ 新 tab 聊天记录 `wechat/detail/message/{ownerWx}/{friendWx}/1?start={通话ts}&scrollToBottom=0`
- 员工微信昵称 → ✅ 新 tab 聊天窗口 `wechat-window/1/{ownerWx}/filehelper?...`
- 员工姓名/手机 → ✅ 新 tab 员工详情 `customer/userDetailNew?userId={userId}`
### WA 员工 userlist
- 员工WhatsApp 昵称 → ✅ 新 tab WA 聊天窗口 `waBusiness/wschatroom/1/{ownerWA}/null/{sessionId}?menuId=0`(friend=null=账号会话列表)
- 添加人数「32」→ ✅ modal「添加人数」列:`序号/员工信息/员工WhatsApp/客户WhatsApp/添加时间/最后沟通时间`(与沟通人数 modal 同列结构)
- 操作列「群聊」→ ✅ 当前 tab 跳 `waBusiness/friendlist/1/{userId}`(type=1 群;好友是 type=0)
- 好友数/群数 → ❌ 不可点(纯数字)
### WA 群列表(friendlist/1,补 Plan3)
- 列:`序号` / `员工信息` / `员工WhatsApp` / `群信息` / `添加时间` / `最后沟通时间` / `操作`
- 操作列:查看聊天记录(与 WA 客户列表仅 客户WhatsApp↔群信息 之差)
### WA 客户 friendlist
- 查看聊天记录 → ✅ 新 tab WA 聊天记录 `waBusiness/wschatroom/0/{ownerWA}/{friendWA}/{sessionId}`
### WA 语音 wavoicecalllist
- 客户WA 昵称 → ✅ 新 tab `waBusiness/wschatroom/0/{ownerWA}/{friendWA}/{sessionId}?start={通话ts}`
- 员工姓名 → ✅ 新 tab 员工详情 `customer/userDetailNew?userId={userId}`
### 通用 URL 模式总结(全部实测)
| 跳转类型 | URL 模式 | 触发来源 |
|---|---|---|
| 员工详情 | `customer/userDetailNew?userId={userId}` | 微信员工姓名 / 微信语音员工姓名 / WA语音员工姓名 |
| 微信聊天窗口 | `wechat/salewechat/message/wechat-window/1/{wxId}/...` | 微信员工 昵称/主badge / 微信语音 员工昵称 |
| 微信聊天记录 | `wechat/detail/message/{ownerWx}/{friendWx}/1?{lastChatTime \| start&end \| start&scrollToBottom}` | 微信客户查看 / 微信员工modal内 / 微信语音客户昵称 |
| WA 聊天(窗口/记录) | `waBusiness/wschatroom/{type}/{ownerWA}/{friendWA\|null}/{sessionId}?{menuId \| start}` | WA员工昵称(type=1,friend=null) / WA客户查看(type=0) / WA语音客户(type=0,+start) |
| 微信 客户/群列表 | `wechatlist/wechatdetail/{1\|2}/{wxId}` | 微信员工 好友数(1) / 群数(2) |
| WA 客户/群列表 | `waBusiness/friendlist/{0\|1}/{userId}` | WA员工 操作-好友列表(0) / 群聊(1) |
+1 -1
View File
@@ -87,7 +87,7 @@ async def export_chat_html(
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"
safe = peer or "all"
filename = f"chat-{channel}-{owner_account_id}-{safe}.html".replace("@", "_").replace("/", "_")
return Response(
content=html,
+1
View File
@@ -57,3 +57,4 @@ async def test_export_empty(make_client):
"channel": "wechat", "owner_account_id": "wx_owner", "talker_id": "none"})
assert r.status_code == 200
assert r.headers["content-type"].startswith("text/html")
assert "暂无消息" in r.text
+5 -2
View File
@@ -42,11 +42,11 @@ export function getJson<T>(url: string, params?: Record<string, unknown>): Promi
return http.get(url, { params }) as unknown as Promise<T>
}
/** 以 blob 下载(用于 HTML 导出等二进制/文本附件下载)。返回建议文件名(来自 content-disposition,缺失则 undefined)。 */
/** 以 blob 下载(用于 HTML 导出等二进制/文本附件下载)。返回固定文件名(响应拦截器丢失 content-disposition 头)。 */
export async function downloadBlob(
url: string,
params?: Record<string, unknown>,
): Promise<string | undefined> {
): Promise<string> {
const res = await http.get(url, { params, responseType: 'blob' })
const blob = res as unknown as Blob
// 从响应头取文件名(拦截器只解包 body,需用 raw 拦截;此处 res 已是 blob,header 丢失 → 用固定名)
@@ -56,8 +56,11 @@ export async function downloadBlob(
a.href = objUrl
a.download = filename
document.body.appendChild(a)
try {
a.click()
} finally {
document.body.removeChild(a)
URL.revokeObjectURL(objUrl)
}
return filename
}