diff --git a/docs/superpowers/plans/2026-08-07-yunke-staff-chat-stats.md b/docs/superpowers/plans/2026-08-07-yunke-staff-chat-stats.md new file mode 100644 index 0000000..b337a4a --- /dev/null +++ b/docs/superpowers/plans/2026-08-07-yunke-staff-chat-stats.md @@ -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 = {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 { + return getJson( + '/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' +``` + +在 `