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

37 KiB
Raw Blame History

微信员工 沟通数/及时回复 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 末尾)
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(完整测试套件)
"""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)
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
"""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: 运行新测试确认全绿
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: 全量测试回归
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 导入):

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
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 追加类型
/** 微信员工沟通统计:单个客户聚合行(镜像 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, 之间)。新增:

// ---------------------------------------------------------------------------
// 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 行)改为:

import { getAccounts, getCustomers, getMessages, getStaffChatStats } from '@/api'

import type 行(第 6 行)加 StaffChatStats:

import type { Account, Channel, StaffChatStats } from '@/api/types'

在 <script setup> 顶部状态区(drawerOpen 等附近)追加:

// ---------------------------------------------------------------------------
// 微信员工沟通统计(全本地 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 块内追加:

    if (ch === 'wechat') {
      // 5 数字改读本地 summary:员工列表到位后并行拉每行 stats。
      void loadWechatStats(st.items)
    }
  • Step 5: 模板——wechat tab 加日期选择器

在 <el-tab-pane label="微信员工" name="wechat"> 内、<el-alert> 之前插入:

        <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>:

.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 接)。临时实现:

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: 类型门禁
cd yunkes && npx vue-tsc -b

Expected: exit 0(无类型错误)。

  • Step 8: Commit
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 实现替换为真实数据源:

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: 类型门禁

cd yunkes && npx vue-tsc -b

Expected: exit 0。

  • Step 4: Commit
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 待复核。