docs(plan): 微信员工 5 数字 modal Phase A 实现计划(全本地 message 聚合)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
浅念
2026-08-07 18:20:42 +08:00
parent c73d845874
commit f6c45e983a
@@ -0,0 +1,799 @@
# 微信员工 沟通数/及时回复 5 数字 modal(Phase A)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:** 微信员工列表(StaffList)每行「沟通数」「及时回复」复合列的 5 个数字可点 → 弹 modal 展示对应客户明细,数据全本地从 `message` 表实时聚合。
**Architecture:** 新增后端聚合端点 `GET /api/staff-chat-stats`(per 员工 wxid + ms-epoch 日期窗 → 5 汇总数 + 客户明细行;SQL `GROUP BY talker_id` + 跨库日期提取 + ROW_NUMBER 窗口子查询取每客户最后一条消息方向;customer 表 left-join 取昵称;5 汇总数在 Python 侧从 customers 派生)。前端复用 `YkStaffDetailModal`(**不改组件**),StaffList 持日期选择器 + 每行缓存 stats;5 数字改读本地 summary,点击时按 metric 过滤 + 合成分页。只算 1:1 私聊(`room_id` 过滤),无新表、无 Alembic。
**Tech Stack:** FastAPI + SQLAlchemy 2.0 async(`yunke-server/`);Vue3 + TS + Element Plus(`yunkes/`)。pytest(async, `make_client` factory);`vue-tsc -b` 类型门禁。
**Spec:** `docs/superpowers/specs/2026-08-07-yunke-staff-chat-stats-design.md`(已过审;本计划严格在其范围内,不重开已定决策)。
---
## 关键事实(plan 核实,来自 codebase)
- StaffList 5 数字 cell 与点击 handler **已存在**(`yunkes/src/views/StaffList.vue:519-552`):沟通人数→`chatCount`、有效沟通数→`contactCount`、跟进客户数→`followCount`、员工最后回复数→`saleSendLastNum`、未回复客户数→`customerSendLastNum`,全部调 `openWechatCommModal(row, title, withMsgCols)`(`StaffList.vue:274-300`)。Phase A 是**换数据源**,不是加接线。
- `row.account_id` = 员工微信号(wxid)✓;`row.user_name` = 显示名(标题用)。**仅 wechat tab 行做本地 stats;WA tab 与 `员工最后回复率(saleSendLastNumRate)` 保持不动。**
- `YkStaffDetailModal` fetcher 契约:`(page, page_size) => Promise<Page<any>>`,`Page = {items, total, page, page_size}`(`yunkes/src/components/YkStaffDetailModal.vue:26`)。组件按 `cols` 渲染、`watch(modelValue)` 开启时 `load()`、翻页回调 `load()`。
- 后端路由风格:每个 router 自带 `prefix="/api"`,`yunke-server/app/api/main.py` 用 `app.include_router(x.router)` 注册(无额外 prefix/tags)。鉴权全局 `Depends(require_auth)`,`Settings.api_token` 为空时 no-op。
- `Message` 模型(`yunke-server/app/db/models.py:327-394`):`is_send: bool|None`(Boolean)、`timestamp: int|None`(BigInteger ms-epoch)、`room_id: str|None`、`talker_id: str|None`、`owner_account_id: str|None`、`channel: str`。唯一非主键索引 `ix_message_channel_owner_talker_time(channel, owner_account_id, talker_id, timestamp)`。
- `Customer` 模型(`models.py:206-256`):`friend_id`(PK)、`channel`、`owner_account_id`、`friend_nickname`/`friend_remark`/`real_name`。与 Message **无 FK、无 ORM 关系**,逻辑 join 在 `(owner_account_id, talker_id ↔ friend_id)`;索引 `ix_customer_owner_friend`。
- **无现成跨库日期提取**(仅 `app/storage/repos.py` 有 `session.bind.dialect.name` 方言分支的先例)。本计划引入该 codebase 第一个 SQL 日期提取,**镜像 repos.py 的方言检测写法**。
- 测试模式(`tests/test_api_messages.py` + `tests/conftest.py:53-114` 的 `make_client` fixture):直接 ORM `session.add_all([...])` 播种;async mode(无需 `@pytest.mark.asyncio`);`client = await make_client(_seed)`。
## 实现决策(spec §5 允许范围内的选择)
1. **3 条查询而非 1 条**:`COUNT(DISTINCT 日期)` 必须用 `GROUP BY`,而「每客户最后一条消息方向」用 `ROW_NUMBER() OVER` 窗口子查询取 `rn=1`——两者无法在同一 SELECT 层共存(窗口聚合与 GROUP BY 不能混用,MySQL 也不支持 `COUNT(DISTINCT) OVER`)。故拆为:Query A(GROUP BY 聚合)、Query B(窗口取 last_is_send)、Query C(customer 昵称 left-join)。三者皆索引覆盖、可移植、易测。
2. **last_is_send 用 ROW_NUMBER 窗口**(不用相关子查询):干净、无 tie 歧义、SQLAlchemy `.over()` 表达自然;SQLite 3.25+ / MySQL 8.0+ 均支持。tie-break 加 `msg_id.desc()` 保证确定性。
3. **`is_send IS NULL` 语义**:聚合 base set 只排除 `timestamp IS NULL`(spec §3),保留方向未知的行(算入 chat_count / interaction_days / last_time);send/receive 用 `CASE WHEN is_send IS TRUE/FALSE` 天然跳过 NULL;`last_is_send` 为 NULL 时该客户既不算「最后回复」也不算「未回复」。
4. **5 汇总数全部 Python 侧从 customers 派生**(spec §5 明示允许)。
5. **昵称优先级**:`friend_remark` > `friend_nickname` > `real_name` > `talker_id`(fallback)。
## 文件清单
**后端**(`yunke-server/`):
- 改 `app/api/schemas.py`:新增 `StaffChatCustomerOut` / `StaffChatStatsSummary` / `StaffChatStatsOut`。
- 新 `app/api/routers/staff_chat_stats.py`:`GET /api/staff-chat-stats`(聚合查询,含 room_id 私聊过滤 + 跨库日期)。
- 改 `app/api/main.py`:注册 router。
- 新 `tests/test_api_staff_chat_stats.py`:私聊 + 群过滤、5 数字、日期窗、跨 owner、缺失昵称 fallback、空集。
**前端**(`yunkes/`):
- 改 `src/api/types.ts`:新增 `StaffChatCustomer` / `StaffChatStatsSummary` / `StaffChatStats`。
- 改 `src/api/index.ts`:新增 `getStaffChatStats` + `StaffChatStatsQuery`;re-export 3 类型。
- 改 `src/views/StaffList.vue`:日期选择器 + 每行本地 summary 缓存 + 5 数字换源 + `openWechatCommModal` 换源(metric 过滤 + 合成分页 + 列/标题);WA 与回复率不动。
- `YkStaffDetailModal.vue` **不改**。
---
## Task 1: 后端聚合端点 `GET /api/staff-chat-stats`(TDD)
**Files:**
- Create: `yunke-server/app/api/routers/staff_chat_stats.py`
- Modify: `yunke-server/app/api/schemas.py`(追加 3 个 schema)
- Modify: `yunke-server/app/api/main.py`(注册 router)
- Test: `yunke-server/tests/test_api_staff_chat_stats.py`
镜像参考:`app/api/routers/messages.py`、`tests/test_api_messages.py`、`tests/conftest.py` 的 `make_client`、`app/storage/repos.py` 的方言检测。
- [ ] **Step 1: 写 schema(追加到 `app/api/schemas.py` 末尾)**
```python
class StaffChatCustomerOut(BaseModel):
"""单个客户在日期窗内的私聊聚合行(本地 message 表派生)。"""
talker_id: str
friend_name: str # remark > nickname > real_name > talker_id
send_count: int # 员工发出(is_send True)
receive_count: int # 客户发出(is_send False)
interaction_days: int # 窗口内不同日期数
last_is_send: int | None # 1=员工最后发, 0=客户最后发, None=最后一条方向未知(不计入两率)
last_time: int | None = None # ms-epoch,最后一条时间
class StaffChatStatsSummary(BaseModel):
"""5 个汇总数字(从 customers 派生)。"""
chat_count: int # 沟通人数:窗口内 ≥1 条消息的去重客户数
effective_count: int # 有效沟通数:send≥1 且 receive≥1 的客户数
follow_count: int # 跟进客户数:interaction_days≥2 的客户数(近似口径)
last_reply_count: int # 员工最后回复数:last_is_send=1 的客户数
no_reply_count: int # 未回复客户数:last_is_send=0 的客户数
class StaffChatStatsOut(BaseModel):
owner_account_id: str
start: int # ms-epoch
end: int # ms-epoch
summary: StaffChatStatsSummary
customers: list[StaffChatCustomerOut]
```
> schema 由聚合数据构造(非 ORM 行),故不需 `from_attributes`。
- [ ] **Step 2: 写失败测试 `tests/test_api_staff_chat_stats.py`(完整测试套件)**
```python
"""Tests for GET /api/staff-chat-stats — 微信员工 5 数字 + 客户明细(本地 message 聚合)。"""
from __future__ import annotations
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import Customer, Message
OWNER = "wx_owner_1"
WIN_START = 1_700_000_000_000 # 固定 ms-epoch,确定性
WIN_END = WIN_START + 3 * 86_400_000
DAY = 86_400_000
async def _seed(session: AsyncSession) -> None:
session.add_all(
[
# 客户 A:跨 2 天双向 → effective + follow,员工最后发
Message(msg_id="a1", channel="wechat", owner_account_id=OWNER, talker_id="t_a",
room_id=None, is_send=True, timestamp=WIN_START, type="text", type_raw=1, text="x"),
Message(msg_id="a2", channel="wechat", owner_account_id=OWNER, talker_id="t_a",
room_id=None, is_send=False, timestamp=WIN_START + DAY, type="text", type_raw=1, text="y"),
Message(msg_id="a3", channel="wechat", owner_account_id=OWNER, talker_id="t_a",
room_id=None, is_send=True, timestamp=WIN_START + DAY + 1000, type="text", type_raw=1, text="z"),
# 客户 B:仅接收 → 非 effective,客户最后发(未回复)
Message(msg_id="b1", channel="wechat", owner_account_id=OWNER, talker_id="t_b",
room_id=None, is_send=False, timestamp=WIN_START + 2000, type="text", type_raw=1, text="hi"),
# 客户 C:单条员工发 → 员工最后发,1 天
Message(msg_id="c1", channel="wechat", owner_account_id=OWNER, talker_id="t_c",
room_id=None, is_send=True, timestamp=WIN_START + 3000, type="text", type_raw=1, text="c"),
# 群消息(room_id 非空)——必须被全部排除
Message(msg_id="g1", channel="wechat", owner_account_id=OWNER, talker_id="t_grp_member",
room_id="room123@chatroom", is_send=True, timestamp=WIN_START + 4000, type="text", type_raw=1, text="g"),
# 窗口外私聊——必须排除(不污染 t_a 统计)
Message(msg_id="o1", channel="wechat", owner_account_id=OWNER, talker_id="t_a",
room_id=None, is_send=True, timestamp=WIN_END + 1000, type="text", type_raw=1, text="future"),
# 其他 owner——必须排除
Message(msg_id="x1", channel="wechat", owner_account_id="wx_other", talker_id="t_a",
room_id=None, is_send=True, timestamp=WIN_START + 5000, type="text", type_raw=1, text="other"),
Customer(friend_id="t_a", channel="wechat", owner_account_id=OWNER,
friend_nickname="Alice", friend_remark="Alice备注", real_name=None),
Customer(friend_id="t_b", channel="wechat", owner_account_id=OWNER,
friend_nickname="Bob", friend_remark=None, real_name=None),
# t_c 无 customer 行 → 昵称 fallback 为 talker_id "t_c"
]
)
await session.commit()
async def test_summary_five_numbers(make_client):
client = await make_client(_seed)
r = await client.get(
"/api/staff-chat-stats",
params={"owner_account_id": OWNER, "start": WIN_START, "end": WIN_END},
)
assert r.status_code == 200
s = r.json()["summary"]
assert s == {
"chat_count": 3, # t_a, t_b, t_c(群/窗外/他owner 全排除)
"effective_count": 1, # 仅 t_a 双向
"follow_count": 1, # 仅 t_a 跨 2 天
"last_reply_count": 2, # t_a, t_c 员工最后发
"no_reply_count": 1, # t_b 客户最后发
}
async def test_customers_fields_and_names(make_client):
client = await make_client(_seed)
r = await client.get(
"/api/staff-chat-stats",
params={"owner_account_id": OWNER, "start": WIN_START, "end": WIN_END},
)
by_id = {c["talker_id"]: c for c in r.json()["customers"]}
assert set(by_id) == {"t_a", "t_b", "t_c"}
# t_a: send=2 recv=1 days=2 last_is_send=1 last_time=a3
assert by_id["t_a"]["friend_name"] == "Alice备注" # remark 优先
assert by_id["t_a"]["send_count"] == 2
assert by_id["t_a"]["receive_count"] == 1
assert by_id["t_a"]["interaction_days"] == 2
assert by_id["t_a"]["last_is_send"] == 1
assert by_id["t_a"]["last_time"] == WIN_START + DAY + 1000
# t_b
assert by_id["t_b"]["friend_name"] == "Bob"
assert by_id["t_b"]["send_count"] == 0
assert by_id["t_b"]["receive_count"] == 1
assert by_id["t_b"]["interaction_days"] == 1
assert by_id["t_b"]["last_is_send"] == 0
# t_c 无 customer 行 → fallback
assert by_id["t_c"]["friend_name"] == "t_c"
assert by_id["t_c"]["last_is_send"] == 1
async def test_group_messages_excluded(make_client):
client = await make_client(_seed)
r = await client.get(
"/api/staff-chat-stats",
params={"owner_account_id": OWNER, "start": WIN_START, "end": WIN_END},
)
ids = {c["talker_id"] for c in r.json()["customers"]}
assert "t_grp_member" not in ids # 群消息被 room_id 过滤
async def test_date_window_and_owner_filter(make_client):
"""窗外消息与他 owner 消息不污染统计(t_a 未被 o1/x1 抬高)。"""
client = await make_client(_seed)
r = await client.get(
"/api/staff-chat-stats",
params={"owner_account_id": OWNER, "start": WIN_START, "end": WIN_END},
)
by_id = {c["talker_id"]: c for c in r.json()["customers"]}
assert by_id["t_a"]["send_count"] == 2 # 非 4(a1+a3,排除 o1+x1)
assert by_id["t_a"]["last_time"] == WIN_START + DAY + 1000 # 非 o1
async def test_empty_when_no_messages(make_client):
async def _empty(session: AsyncSession) -> None:
await session.commit()
client = await make_client(_empty)
r = await client.get(
"/api/staff-chat-stats",
params={"owner_account_id": OWNER, "start": WIN_START, "end": WIN_END},
)
assert r.status_code == 200
body = r.json()
assert body["customers"] == []
assert body["summary"] == {
"chat_count": 0, "effective_count": 0, "follow_count": 0,
"last_reply_count": 0, "no_reply_count": 0,
}
async def test_default_window_returns_ok(make_client):
"""省略 start/end 走默认近 3 天;2023 固定种子在默认窗外 → 空集,仅验结构。"""
client = await make_client(_seed)
r = await client.get("/api/staff-chat-stats", params={"owner_account_id": OWNER})
assert r.status_code == 200
assert "summary" in r.json() and "customers" in r.json()
```
- [ ] **Step 3: 运行测试确认失败(endpoint 不存在 → 404)**
```bash
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_api_staff_chat_stats.py -v
```
Expected: FAIL(404 / 路由未注册)。
- [ ] **Step 4: 写 router `app/api/routers/staff_chat_stats.py`**
```python
"""GET /api/staff-chat-stats — 微信员工 5 数字 + 客户明细(本地 message 表实时聚合)。
数据全本地:按 员工微信 wxid × ms-epoch 日期窗 聚合 1:1 私聊(room_id 过滤),
不访问云客。5 汇总数从 customers 在 Python 侧派生(spec §5)。
跨库日期提取(codebase 首例)镜像 app/storage/repos.py 的方言检测写法。
"""
from __future__ import annotations
import time
from typing import Annotated
import sqlalchemy as sa
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_session
from app.api.schemas import StaffChatCustomerOut, StaffChatStatsOut, StaffChatStatsSummary
from app.db.models import Customer, Message
router = APIRouter(prefix="/api", tags=["staff-chat-stats"])
SessionDep = Annotated[AsyncSession, Depends(get_session)]
_MS_PER_DAY = 86_400_000
def _private_window(msg, owner: str, start: int, end: int):
"""1:1 私聊 + 窗口过滤条件(channel 固定 wechat),排除 timestamp IS NULL。"""
return [
msg.channel == "wechat",
msg.owner_account_id == owner,
sa.or_(msg.room_id.is_(None), msg.room_id == ""),
msg.timestamp.is_not(None),
msg.timestamp >= start,
msg.timestamp <= end,
]
async def _aggregate(session: AsyncSession, owner: str, start: int, end: int):
"""返回 [(talker_id, send, recv, interaction_days, last_time)] + last_is_send map。"""
# 跨库日期提取:从 ms-epoch 取日期。镜像 repos.py 方言检测。
dialect = session.bind.name # 镜像 app/storage/repos.py(.name 等价 .dialect.name)
if dialect == "sqlite":
day_expr = sa.func.date(Message.timestamp / 1000, "unixepoch")
else: # mysql(生产)
day_expr = sa.func.date(sa.func.from_unixtime(Message.timestamp / 1000))
base = _private_window(Message, owner, start, end)
agg_stmt = (
sa.select(
Message.talker_id,
sa.func.sum(sa.case((Message.is_send.is_(True), 1), else_=0)).label("send_count"),
sa.func.sum(sa.case((Message.is_send.is_(False), 1), else_=0)).label("receive_count"),
sa.func.count(sa.func.distinct(day_expr)).label("interaction_days"),
sa.func.max(Message.timestamp).label("last_time"),
)
.where(*base)
.group_by(Message.talker_id)
)
agg_rows = (await session.execute(agg_stmt)).all()
# 每客户最后一条消息方向:ROW_NUMBER 窗口取 rn=1(tie-break msg_id 保证确定性)
inner = (
sa.select(
Message.talker_id,
Message.is_send,
sa.func.row_number()
.over(
partition_by=Message.talker_id,
order_by=(Message.timestamp.desc(), Message.msg_id.desc()),
)
.label("rn"),
)
.where(*base)
)
sub = inner.subquery("last_msg")
last_stmt = sa.select(sub.c.talker_id, sub.c.is_send).where(sub.c.rn == 1)
last_map = {tid: is_send for tid, is_send in (await session.execute(last_stmt)).all()}
return agg_rows, last_map
async def _customer_names(session: AsyncSession, owner: str, talker_ids: list[str]) -> dict[str, str]:
if not talker_ids:
return {}
stmt = sa.select(
Customer.friend_id,
Customer.friend_remark,
Customer.friend_nickname,
Customer.real_name,
).where(
Customer.channel == "wechat",
Customer.owner_account_id == owner,
Customer.friend_id.in_(talker_ids),
)
names: dict[str, str] = {}
for fid, remark, nick, real in (await session.execute(stmt)).all():
names[fid] = remark or nick or real or fid
return names
@router.get("/staff-chat-stats")
async def get_staff_chat_stats(
session: SessionDep,
owner_account_id: Annotated[str, Query(min_length=1)],
start: Annotated[int | None, Query(ge=0)] = None,
end: Annotated[int | None, Query(ge=0)] = None,
) -> StaffChatStatsOut:
"""微信员工沟通统计:5 数字 + 客户明细(全本地 message 聚合,1:1 私聊)。"""
if end is None:
end = int(time.time() * 1000)
if start is None:
start = end - 3 * _MS_PER_DAY # 默认近 3 天
agg_rows, last_map = await _aggregate(session, owner_account_id, start, end)
talker_ids = [r.talker_id for r in agg_rows]
names = await _customer_names(session, owner_account_id, talker_ids)
customers: list[StaffChatCustomerOut] = []
for r in agg_rows:
tid = r.talker_id
last_is_send = last_map.get(tid) # bool | None(None=最后一条方向未知)
customers.append(
StaffChatCustomerOut(
talker_id=tid,
friend_name=names.get(tid, tid),
send_count=int(r.send_count or 0),
receive_count=int(r.receive_count or 0),
interaction_days=int(r.interaction_days or 0),
# True→1, False→0, None→None(方向未知不计入两率;summary 用 ==1/==0 自动排除 None)
last_is_send=int(last_is_send) if last_is_send is not None else None,
last_time=r.last_time,
)
)
summary = StaffChatStatsSummary(
chat_count=len(customers),
effective_count=sum(1 for c in customers if c.send_count >= 1 and c.receive_count >= 1),
follow_count=sum(1 for c in customers if c.interaction_days >= 2),
last_reply_count=sum(1 for c in customers if c.last_is_send == 1),
no_reply_count=sum(1 for c in customers if c.last_is_send == 0),
)
return StaffChatStatsOut(
owner_account_id=owner_account_id,
start=start,
end=end,
summary=summary,
customers=customers,
)
```
> 注意:方言检测用 `session.bind.name`(镜像 `app/storage/repos.py` 现有可用写法;`.name` 等价 `.dialect.name`,在 async session 下已验证可用)。不要新发明访问方式。
- [ ] **Step 5: 在 `app/api/main.py` 注册 router**
在 `from app.api.routers import (...)` 的 import 列表加 `staff_chat_stats`(按字母序插在 `sms,` 与 `sync,` 之间),并在 `app.include_router(...)` 块加 `app.include_router(staff_chat_stats.router)`。
- [ ] **Step 6: 运行新测试确认全绿**
```bash
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_api_staff_chat_stats.py -v
```
Expected: 6 passed。若日期/窗口/last_is_send 计算不符,按 failing assertion 修正(用 systematic-debugging:先看实际值 vs 期望)。
- [ ] **Step 7: 全量测试回归**
```bash
cd yunke-server && .venv/Scripts/python.exe -m pytest -q
```
Expected: 全绿(原计数 + 6 新增)。无 Alembic、无 schema 迁移。
- [ ] **Step 8: EXPLAIN 验证索引覆盖(spec §8/§10,可选但推荐)**
确认聚合查询走 `ix_message_channel_owner_talker_time` 而非全表扫描。直接用 sqlite3 对 smoke.db 跑 EXPLAIN(绕开 async session 导入):
```bash
cd yunke-server && .venv/Scripts/python.exe -c "
import sqlite3
con = sqlite3.connect('smoke.db')
print(con.execute(\"EXPLAIN QUERY PLAN SELECT talker_id FROM message WHERE channel='wechat' AND owner_account_id='wxid_x' AND (room_id IS NULL OR room_id='') AND timestamp IS NOT NULL GROUP BY talker_id\").fetchall())
"
```
Expected: 输出含 `ix_message_channel_owner_talker_time`、无 `SCAN TABLE message`(全表扫描)。此步为信心校验(不阻塞合并);WHERE 列序 (channel, owner_account_id, talker_id, timestamp) 与该索引前缀一致 → 应命中。**不加新索引、无 Alembic。**
- [ ] **Step 9: Commit**
```bash
git add yunke-server/app/api/schemas.py yunke-server/app/api/routers/staff_chat_stats.py yunke-server/app/api/main.py yunke-server/tests/test_api_staff_chat_stats.py
git commit -m "feat(api): staff-chat-stats 聚合端点(微信员工 5 数字 + 客户明细,全本地 message)"
```
---
## Task 2: 前端数据层 + StaffList 5 数字换源(仅 wechat)
**Files:**
- Modify: `yunkes/src/api/types.ts`(追加 3 类型)
- Modify: `yunkes/src/api/index.ts`(追加 `getStaffChatStats` + re-export)
- Modify: `yunkes/src/views/StaffList.vue`(日期选择器 + stats 缓存 + 5 数字换源;WA 与回复率不动)
镜像参考:`api/index.ts` 的 `getMessages`/`clean` 模式;`types.ts` 的 `Message`/`Page`。
- [ ] **Step 1: `types.ts` 追加类型**
```typescript
/** 微信员工沟通统计:单个客户聚合行(镜像 backend StaffChatCustomerOut)。 */
export interface StaffChatCustomer {
talker_id: string
friend_name: string
send_count: number
receive_count: number
interaction_days: number
/** 1 = 员工最后发,0 = 客户最后发,null = 最后一条方向未知(不计入两率) */
last_is_send: number | null
/** ms-epoch,最后一条时间 */
last_time: number | null
}
/** 5 个汇总数字(镜像 backend StaffChatStatsSummary)。 */
export interface StaffChatStatsSummary {
chat_count: number
effective_count: number
follow_count: number
last_reply_count: number
no_reply_count: number
}
/** GET /api/staff-chat-stats 响应(镜像 backend StaffChatStatsOut)。 */
export interface StaffChatStats {
owner_account_id: string
start: number
end: number
summary: StaffChatStatsSummary
customers: StaffChatCustomer[]
}
```
- [ ] **Step 2: `index.ts` 追加 client + re-export**
import 块加 `StaffChatStats`(按字母序插在 `Staff,` 与 `SyncRun,` 之间)。新增:
```typescript
// ---------------------------------------------------------------------------
// Staff chat stats (微信员工 5 数字 + 客户明细,全本地 message 聚合)
// ---------------------------------------------------------------------------
export interface StaffChatStatsQuery {
owner_account_id: string
/** ms-epoch inclusive 窗口(默认近 3 天,由后端兜底)。 */
start?: number
end?: number
}
export function getStaffChatStats(query: StaffChatStatsQuery): Promise<StaffChatStats> {
return getJson<StaffChatStats>(
'/staff-chat-stats',
clean({
owner_account_id: query.owner_account_id,
start: query.start,
end: query.end,
}),
)
}
```
文件末尾 re-export 块加 `StaffChatStats,`(按字母序插在 `Staff,` 与 `SyncRun,` 之间)。
- [ ] **Step 3: StaffList.vue —— import + 日期状态 + stats 缓存**
import 行(第 5 行)改为:
```typescript
import { getAccounts, getCustomers, getMessages, getStaffChatStats } from '@/api'
```
import type 行(第 6 行)加 `StaffChatStats`:
```typescript
import type { Account, Channel, StaffChatStats } from '@/api/types'
```
在 `<script setup>` 顶部状态区(drawerOpen 等附近)追加:
```typescript
// ---------------------------------------------------------------------------
// 微信员工沟通统计(全本地 message 聚合)——仅 wechat tab。
// 日期窗默认近 3 天;每行(按 account_id=wxid)缓存 stats 供 5 数字 + modal 复用。
// WA tab 与「员工最后回复率」保持原 raw_json 数据源不动。
// ---------------------------------------------------------------------------
const THREE_DAYS_MS = 3 * 86_400_000
function defaultDateRange(): [Date, Date] {
const end = new Date()
end.setHours(23, 59, 59, 999)
const start = new Date(end)
start.setDate(start.getDate() - 2)
start.setHours(0, 0, 0, 0)
return [start, end]
}
const dateRange = ref<[Date, Date]>(defaultDateRange())
const statsStart = computed(() => dateRange.value[0].getTime())
const statsEnd = computed(() => dateRange.value[1].getTime())
/** account_id → stats 缓存(wechat 行)。undefined 表示尚未加载,cell 显示 '--'。 */
const staffStats = ref<Record<string, StaffChatStats | undefined>>({})
async function loadWechatStats(rows: Account[]): Promise<void> {
const ids = rows.map((r) => r.account_id).filter(Boolean)
await Promise.all(
ids.map(async (id) => {
try {
staffStats.value[id] = await getStaffChatStats({
owner_account_id: id,
start: statsStart.value,
end: statsEnd.value,
})
} catch {
staffStats.value[id] = undefined // 失败留空,cell 显示 '--'
}
}),
)
}
/** 5 数字 cell:读本地 summary;未加载显示 '--'。 */
function statNum(
row: Account,
key: 'chat_count' | 'effective_count' | 'follow_count' | 'last_reply_count' | 'no_reply_count',
): string {
const s = staffStats.value[row.account_id]?.summary
return s ? String(s[key]) : '--'
}
function onStatsDateChange(): void {
// 改日期 → 重查当前可见 wechat 行(覆盖其缓存条目)。
loadWechatStats(tabState.wechat.items)
}
```
> `computed` 需在 `import { computed, onMounted, reactive, ref } from 'vue'`(已存在,第 2 行)。
- [ ] **Step 4: loadTab 末尾触发 wechat stats 加载**
在 `loadTab`(约 306 行)的 `st.loaded = true` 之后、`try` 块内追加:
```typescript
if (ch === 'wechat') {
// 5 数字改读本地 summary:员工列表到位后并行拉每行 stats。
void loadWechatStats(st.items)
}
```
- [ ] **Step 5: 模板——wechat tab 加日期选择器**
在 `<el-tab-pane label="微信员工" name="wechat">` 内、`<el-alert>` 之前插入:
```html
<div class="yk-staff__stats-bar">
<span class="yk-staff__stats-label">沟通统计时段</span>
<el-date-picker
v-model="dateRange"
type="daterange"
size="small"
:clearable="false"
@change="onStatsDateChange"
/>
<span class="yk-staff__stats-hint">数字为本地消息表实时聚合(口径近似云客)</span>
</div>
```
> 不加 `value-format`:daterange + value-format 会把 v-model 变成 `[string,string]`,而 `statsStart/End` 需对 Date 调 `.getTime()`。保持原生 Date 对象即可。
样式追加到 `<style scoped>`:
```css
.yk-staff__stats-bar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.yk-staff__stats-label {
font-size: 13px;
color: #606266;
}
.yk-staff__stats-hint {
font-size: 12px;
color: #c0c4cc;
}
```
- [ ] **Step 6: 模板——5 数字 cell 换源(5 处)+ handler 改传 metric**
「沟通数」列(约 519-534 行)与「及时回复」列(约 537-552 行)的 5 个 `el-button`:
| 原 | 改为 |
|---|---|
| `cellNum(row, 'chatCount')` | `statNum(row, 'chat_count')` |
| `cellNum(row, 'contactCount')` | `statNum(row, 'effective_count')` |
| `cellNum(row, 'followCount')` | `statNum(row, 'follow_count')` |
| `cellNum(row, 'saleSendLastNum')` | `statNum(row, 'last_reply_count')` |
| `cellNum(row, 'customerSendLastNum')` | `statNum(row, 'no_reply_count')` |
5 个 `@click.stop="openWechatCommModal(row, '<title>', <bool>)"` 改为传 metric:
- 沟通人数 → `openWechatCommModal(row, 'chat')`
- 有效沟通数 → `openWechatCommModal(row, 'effective')`
- 跟进客户数 → `openWechatCommModal(row, 'follow')`
- 员工最后回复数 → `openWechatCommModal(row, 'last_reply')`
- 未回复客户数 → `openWechatCommModal(row, 'no_reply')`
`员工最后回复率`(`cellText(row, 'saleSendLastNumRate')`,约 545 行)**保持不动**。WA tab 全部不动。
> Task 2 此刻 `openWechatCommModal` 签名变了会暂时类型不通过——本 step 仅改模板调用,handler 实现在 Task 3 重写。**为保持本 task 自洽可编译**,本 step 同时在 script 里把 `openWechatCommModal` 临时改为接收 `(row, metric)` 并先按 metric 返回标题占位(modal 真实数据在 Task 3 接)。临时实现:
> ```typescript
> type ChatMetric = 'chat' | 'effective' | 'follow' | 'last_reply' | 'no_reply'
> const METRIC_TITLES: Record<ChatMetric, string> = {
> chat: '沟通人数(已去重)',
> effective: '有效沟通数(已去重)',
> follow: '跟进客户数(口径:窗口内≥2个沟通日,近似)',
> last_reply: '员工最后回复数',
> no_reply: '未回复客户数',
> }
> // 对齐云客 spec §6:所有 modal 显示「客户微信」;仅「有效沟通数」modal 额外显示发送/接收数。
> // 序号需改 YkStaffDetailModal(spec §1 不改组件,略);员工信息/员工微信由 modal 标题承载,行内冗余故略。
> function commModalColsFor(metric: ChatMetric): any[] {
> const cols: any[] = [{ prop: 'friend_name', label: '客户微信', minWidth: 180 }]
> if (metric === 'effective') {
> cols.push(
> { prop: 'send_count', label: '发送消息数', width: 110, align: 'center' },
> { prop: 'receive_count', label: '接收消息数', width: 110, align: 'center' },
> )
> }
> return cols
> }
> function openWechatCommModal(row: Account, metric: ChatMetric): void {
> // Task 3 接真实数据 + metric 过滤 + 合成分页。此处先立标题与列骨架。
> detailTitle.value = `${METRIC_TITLES[metric]} · ${row.user_name ?? row.account_id}`
> detailCols.value = commModalColsFor(metric)
> detailFetcher.value = async () => ({ items: [], total: 0, page: 1, page_size: 10 })
> detailOpen.value = true
> }
> ```
> 删除旧 `openWechatCommModal(row, title, withMsgCols)` 实现(含 `goChat`——Phase A 不做 modal 二级可点,spec §1/§6)。**同步从 import 移除 `Customer`**(其唯一用处是 `goChat` 的 `(r: Customer)` 参数;删 goChat 后无引用,否则 `noUnusedLocals` 报错)。
- [ ] **Step 7: 类型门禁**
```bash
cd yunkes && npx vue-tsc -b
```
Expected: exit 0(无类型错误)。
- [ ] **Step 8: Commit**
```bash
git add yunkes/src/api/types.ts yunkes/src/api/index.ts yunkes/src/views/StaffList.vue
git commit -m "feat(staff): StaffList 微信 5 数字改读本地 summary + 日期选择器(Phase A 接线)"
```
---
## Task 3: StaffList modal 换源(metric 过滤 + 合成分页)
**Files:**
- Modify: `yunkes/src/views/StaffList.vue`(`openWechatCommModal` 真实数据源 + metric 过滤 + 合成分页)
> 依赖 Task 2 的 `staffStats` 缓存 / `statsStart`/`statsEnd` / `METRIC_TITLES` / `ChatMetric` / 列骨架。`YkStaffDetailModal` 不改。
- [ ] **Step 1: 实现 metric 过滤 + 合成分页 fetcher**
把 Task 2 的临时 `openWechatCommModal` 实现替换为真实数据源:
```typescript
import type { Page } from '@/api/types' // 若未 import 则补(Page 用于合成分页返回类型)
function metricFilter(c: StaffChatCustomer, m: ChatMetric): boolean {
switch (m) {
case 'chat':
return true
case 'effective':
return c.send_count >= 1 && c.receive_count >= 1
case 'follow':
return c.interaction_days >= 2
case 'last_reply':
return c.last_is_send === 1
case 'no_reply':
return c.last_is_send === 0
}
}
/** 微信员工 5 数字 → 明细 modal(数据源:本地 staff-chat-stats,按 metric 过滤 + 合成分页)。 */
function openWechatCommModal(row: Account, metric: ChatMetric): void {
detailTitle.value = `${METRIC_TITLES[metric]} · ${row.user_name ?? row.account_id}`
detailCols.value = commModalColsFor(metric) // Task 2 已定义(metric 决定是否含发送/接收列)
detailFetcher.value = async (page: number, page_size: number): Promise<Page<any>> => {
// 复用已缓存的 stats;点击早于列表加载完成则按需补拉。
let stats = staffStats.value[row.account_id]
if (!stats) {
try {
stats = await getStaffChatStats({
owner_account_id: row.account_id,
start: statsStart.value,
end: statsEnd.value,
})
staffStats.value[row.account_id] = stats
} catch {
return { items: [], total: 0, page, page_size }
}
}
const filtered = stats.customers.filter((c) => metricFilter(c, metric))
const startIdx = (page - 1) * page_size
return {
items: filtered.slice(startIdx, startIdx + page_size),
total: filtered.length,
page,
page_size,
}
}
detailOpen.value = true
}
```
> `StaffChatCustomer` 类型需 import(Task 2 已 import `StaffChatStats`;补 `StaffChatCustomer`)。`Page` 类型从 `@/api/types` import(StaffList 当前未直接 import `Page`——补上)。
- [ ] **Step 2: 清理遗留**
- 删除 Task 2 临时实现里已被替换的占位 fetcher。
- 确认旧 `goChat` / `withMsgCols` 参数已无引用(Task 2 已删;本 step 二次确认无残留)。
- `baseCustomerCols` 仍被 `openAddedModal`/`openTalkedModal`(WA tab)使用——**保留**。
- [ ] **Step 3: 类型门禁**
```bash
cd yunkes && npx vue-tsc -b
```
Expected: exit 0。
- [ ] **Step 4: Commit**
```bash
git add yunkes/src/views/StaffList.vue
git commit -m "feat(staff): 微信 5 数字 modal 换本地数据源(metric 过滤 + 合成分页)"
```
---
## 验收(spec §10)
- [ ] 后端 `pytest -q` 全绿(含 `test_api_staff_chat_stats.py` 6 用例:群过滤、窗外/他 owner 排除、5 数字、客户字段、缺失昵称 fallback、空集、默认窗)。
- [ ] `cd yunkes && npx vue-tsc -b` exit 0。
- [ ] **浏览器实测**(运行后台本地,绝不访问云客):
- 启动后端 `cd yunke-server && DB_URL="sqlite+aiosqlite:///./smoke.db" .venv/Scripts/python.exe -m uvicorn app.api.main:app --port 8000` + 前端 `cd yunkes && npm run dev`。
- 微信员工 tab:5 数字显示(非 '--',若 smoke.db 有 wechat 消息);点各数字 → modal 弹对应客户子集;改日期窗 → 数字与 modal 重查。
- Network 面板:仅打 `/api/staff-chat-stats`(**无任何 crm2.yunkecn.com 请求**)。
- 列对齐云客:客户微信 / 发送消息数 / 接收消息数;标题含「(已去重)」、跟进标题含近似口径说明。
- WA tab 数字不受影响(仍 raw_json);员工最后回复率仍展示(不可点)。
## 风险与边界
- **跨库日期提取首例**:镜像 `repos.py` 方言检测;SQLite 测试覆盖。若 MySQL 生产异常,TDD + 浏览器实测会暴露。
- **ROW_NUMBER 窗口**:SQLite 3.25+ / MySQL 8.0+ 均支持;tie 用 `msg_id.desc()` 确定。
- **smoke.db 数据稀疏**(spec §8):数字小但机制成立;真实部署数据全后才有意义。
- **每行一次聚合**(20 行/页 × 并发):现有复合索引覆盖;spec §8 已接受该 per-row 成本。
- **Phase A 不做**(spec §1):员工姓名→drawer、主 badge→聊天、modal 内二级可点 → Phase B;WA 员工表数字 modal 待复核。