# 微信员工 沟通数/及时回复 5 数字 modal(Phase A)设计 > 日期:2026-08-07|分支:`feat/yunke-data-mirror`|路线图 #2 Phase A > 前置:Plan 1(StaffList 微信员工列表已对齐云客,含 `YkStaffDetailModal` 通用件、数字→modal 模式);message 表已同步(含 `owner_account_id`/`talker_id`/`is_send`/`timestamp`)。 > 数据来源:**全本地 message 表聚合**(用户定,符合 [[yunke-verify-data-in-browser]] 全本地原则;数字口径**近似**云客——已与用户确认接受)。 ## 1. 目标与范围 **目标**:StaffList(微信员工列表)每行的「沟通数」「及时回复」复合列里 **5 个数字可点 → 弹 modal 展示对应客户列表**,对齐云客。数据**从本地 message 表实时聚合**(按 员工微信 × 客户 × 日期窗),运行时不碰云客。 **5 个数字**(云客 `/cms/wechat/wechatlist` 微信员工列表,真机核实 2026-08-07): - 沟通数:沟通人数 / 有效沟通数 / 跟进客户数 - 及时回复:员工最后回复数 / 未回复客户数 **在本 plan(Phase A)**: - 后端:新聚合 endpoint `GET /api/staff-chat-stats`(per 员工微信 + 日期窗 → 5 个汇总数 + 客户明细行)。 - 前端:StaffList 5 个数字可点 → 复用 `YkStaffDetailModal` 弹对应客户子集 + 日期选择器(默认近 3 天)。 **不在本 plan(Phase B / 后续)**: - 员工姓名 → 员工详情(降级 drawer)、主 badge → 聊天窗口(Phase B,小前端)。 - modal 内「客户微信/员工微信 → 聊天记录」二级可点(Phase B/后)。 - WA 员工表同类数字 modal(WA 员工表数字 modal 待复核,本 plan 只做微信员工)。 - 与云客数字**精确对齐**(口径近似,见 §4)。 ## 2. 数据来源(真机核实) 云客微信员工列表两个接口(2026-08-07 真机): - 行的 5 数字 ← `POST /wechatweb/statistics/wxStatsByTypes`(per 员工聚合,固定窗)。 - 数字 modal 内容 ← `POST /wechatweb/statistics/wxChatInfo`(body `{weChatId, userId, ymdS, ymdE, page, size}`;**5 个 modal 共用此 endpoint**,仅筛选/展示不同;modal 带日期选择器 = 动态窗)。响应每条 = 一个客户 + `sendNum`/`receiveNum`/`noResponse`/`responseCount`/`saleSendLastNum`/`customerSendLastNum` 等字段。 **我们的方案**(用户定):**不抓 wxStatsByTypes/wxChatInfo**,从本地 `message` 表实时聚合(动态日期窗没法预抓,且全本地原则)。message 表字段齐(smoke.db 实测 wechat 消息 `owner_account_id`/`talker_id`/`is_send`/`timestamp` 全非空)。 ## 3. 数据模型 / 聚合 **无新表、无新索引**。聚合在查询时算。现有索引 `ix_message_channel_owner_talker_time(channel, owner_account_id, talker_id, timestamp)` 已覆盖(**不需要 Alembic 迁移**)。 **只聚合 1:1 私聊**(对齐云客 `wxChatInfo` 是员工×客户对,不含群):聚合 SQL 必须 `WHERE channel='wechat' AND owner_account_id=:owner AND (room_id IS NULL OR room_id = '') AND timestamp BETWEEN :start AND :end`,再 `GROUP BY talker_id`。**漏 room_id 过滤会把群消息算进客户统计**(talker 在群消息里是群成员,非客户对)。 **NULL 语义**:`timestamp IS NULL` 的行排除(窗口/排序都依赖它);`is_send IS NULL` 的行不计入 send/receive(未知方向,skip)。 **每个客户(talker_id)的聚合字段**(窗口内私聊): - `send_count` = `is_send` True 的消息数(员工发) - `receive_count` = `is_send` False 的消息数(客户发) - `interaction_days` = 不同 `timestamp` 日期数(**跨库日期提取**:用 SQLAlchemy 在 epoch-秒 上算 `func.date(...)`,MySQL `DATE(FROM_UNIXTIME(ts/1000))` / SQLite `date(ts/1000,'unixepoch')`——不要写裸 SQL) - `last_is_send` = 窗口内最后一条(按 timestamp)消息的 `is_send`(True=员工最后发,False=客户最后发) - `last_time` = 最后一条时间 - 客户身份:`talker_id` → left join `customer` 表取 `friend_nickname`/`friend_remark`/`real_name`(缺失则用 talker_id)。 > 注:`message.is_send` 是 `Boolean`(过滤用 `is_(True)/is_(False)`,不是 `==1`);`timestamp` 是 `BigInteger` ms-epoch,nullable。 ## 4. 5 数字口径(近似,从聚合字段派生) | 数字 | 口径(本系统) | 云客原口径 | |---|---|---| | 沟通人数 `chat_count` | 窗口内 ≥1 条消息的去重客户数 | 同(去重沟通客户) | | 有效沟通数 `effective_count` | 双向客户数(`send_count≥1 且 receive_count≥1`) | 双向有效沟通 | | 跟进客户数 `follow_count` | 窗口内 `interaction_days≥2` 的客户数(**近似**,跟进精确口径云客未公开) | 跟进(精确口径未知) | | 员工最后回复数 `last_reply_count` | `last_is_send=1` 的客户数(员工最后发) | 员工最后回复 | | 未回复客户数 `no_reply_count` | `last_is_send=0` 的客户数(客户最后发、员工未回) | 未回复客户 | > **近似声明**:跟进客户数口径为合理近似(云客精确标准未公开);其余 4 个与云客语义一致,但**数字可能因日期窗/数据完整性略有出入**。modal 内显示口径说明(小字)。 ## 5. API `GET /api/staff-chat-stats`(新 router,镜像现有 list endpoints 风格) - query:`owner_account_id`(必填,员工微信号=wxid)、`start?`/`end?`(ms-epoch,默认近 3 天)。 - 响应: ```json { "owner_account_id": "wxid_...", "start": ..., "end": ..., "summary": { "chat_count": N, "effective_count": N, "follow_count": N, "last_reply_count": N, "no_reply_count": N }, "customers": [ { "talker_id": "...", "friend_name": "...", "send_count": N, "receive_count": N, "interaction_days": N, "last_is_send": 0|1, "last_time": } ] } ``` - 前端 `getStaffChatStats({owner_account_id, start, end})`。 - 聚合一条 SQL(GROUP BY talker_id)+ 5 个汇总在 Python 侧从 customers 派生(或 SQL 同时算)。 ## 6. 前端(StaffList + modal) > **事实(plan 核实)**:StaffList.vue 的 5 个数字 cell **点击 handler 已存在**(现都调 `openWechatCommModal`,数据源 `getCustomers`)。Phase A 是**换数据源**(→ `getStaffChatStats`)+ 换每行 summary 数字来源,**不是加点击接线**。 - **每行 5 个数字**:现来自上游 camelCase 字段(`chatCount`/`contactCount`/`followCount`/`saleSendLastNum`/`customerSendLastNum`)。Phase A 改为:每行(按 `row.account_id` = 员工微信号)调 `getStaffChatStats({owner_account_id: row.account_id, start, end})`(默认近 3 天)取 `summary`,5 个数字映射到 `chat_count/effective_count/follow_count/last_reply_count/no_reply_count`。 - **员工最后回复率**(现 `saleSendLastNumRate`,line ~545)是比率非计数、无 Phase-A 本地等价 → **保持现状不动**(不可点),只有 5 个**计数**变本地可点。 - **点数字 → 复用 `YkStaffDetailModal`**(Plan 1 通用件,`fetcher: (page,page_size)=>Page` 服务端分页): - **分页适配(重要)**:本 endpoint 返回扁平 `{summary, customers[]}`(窗口内客户通常几十个,无需服务端分页)。StaffList 一次性取 `customers`,**在 fetcher 闭包里按 metric 过滤 + 切页包成合成 `Page`** 传给 modal(不动 `YkStaffDetailModal` 的通用分页契约)。 - metric 过滤:沟通人数=全集 / 有效沟通=`send≥1&&receive≥1` / 跟进=`interaction_days≥2` / 最后回复=`last_is_send=true` / 未回复=`last_is_send=false`。 - modal 列(对齐云客):序号 / 客户微信(昵称+号) / [有效沟通 modal 多 发送数/接收数 列] / 员工信息 / 员工微信。 - **日期选择器放 StaffList**(不放 `YkStaffDetailModal`,保持其通用):StaffList 持 `start/end` 状态,传进 fetcher 闭包;改日期 → 重查 `getStaffChatStats`。 - modal 标题对齐云客(沟通人数(已去重) / 有效沟通数(已去重) / 跟进客户数 / 员工最后回复数 / 未回复客户数)+ 近似口径小字说明(跟进为近似口径)。 ## 7. 关键决策(已与用户确认 2026-08-07) 1. **全本地消息聚合**(不抓 wxStatsByTypes/wxChatInfo):从 message 表算。 2. **口径近似**:跟进客户数用合理近似;数字可能与云客略出入,用户接受。 3. **范围 Phase A**:只做 5 数字 modal;姓名 drawer + 主badge + modal 内二级可点 → Phase B。 4. **默认窗近 3 天**(对齐云客 modal 默认 ymdS/ymdE)。 5. **无新表**:聚合在查询时算(动态窗不宜预存)。 ## 8. 风险 - **数据完整性**:smoke.db 微信消息稀疏(4 员工×6 客户×399 条),数字小但机制成立;真实部署数据全后数字才有意义。 - **跟进口径近似**:与云客可能不一致(已声明)。 - **性能**:每行一次聚合;现有复合索引 `ix_message_channel_owner_talker_time(channel, owner_account_id, talker_id, timestamp)` 已覆盖本查询(plan 用 EXPLAIN 验证即可,**不加新索引/无 Alembic**)。 - **StaffList 行 ↔ owner_account_id 映射**:确认 StaffList 行有员工微信号字段(plan 核实 Plan 1 的 row shape)。 ## 9. 文件清单(预期) **后端**(路径前缀 `yunke-server/`): - 新 `yunke-server/app/api/routers/staff_chat_stats.py`(`/api/staff-chat-stats` 聚合查询,含 room_id 私聊过滤) - 改 `yunke-server/app/api/main.py`(注册 router) - 改 `yunke-server/app/api/schemas.py`(`StaffChatStatsOut`/`StaffChatCustomerOut`) - **不需要 Alembic**(复用现有 `ix_message_channel_owner_talker_time` 索引) - 新测试 `yunke-server/tests/test_api_staff_chat_stats.py`(种私聊+群消息 → 验证群被过滤、5 数字 + 客户行 + 日期窗正确) **前端**: - 改 `yunkes/src/api/index.ts` + `types.ts`:`getStaffChatStats` / `StaffChatStats` / `StaffChatCustomer` 类型 - 改 `yunkes/src/views/StaffList.vue`:**换 5 数字数据源**(handler 已存在)→ `getStaffChatStats` summary;日期选择器状态 + fetcher 闭包(含 metric 过滤 + 合成分页);员工最后回复率保持不动 - `YkStaffDetailModal.vue` **不改**(保持通用)—— StaffList 侧包装 fetcher 适配 ## 10. 验收 - 后端测试全绿(含 `test_api_staff_chat_stats.py`:种若干 message → summary 5 数字 + customers 字段正确;日期窗过滤生效)。 - `vue-tsc -b` exit 0。 - 浏览器:StaffList 微信员工行 5 数字显示且可点 → modal 弹对应客户子集;切日期窗重查;列对齐云客。 - 数据来自本地 message(network 只打 `/api/staff-chat-stats`,不碰云客)。