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:
@@ -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) |
|
||||
Reference in New Issue
Block a user