Files
yunke/docs/superpowers/plans/2026-08-07-yunke-staff-chat-stats.md
T

800 lines
37 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 微信员工 沟通数/及时回复 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 待复核。