@@ -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)。