Co-Authored-By: Claude <noreply@anthropic.com>
37 KiB
微信员工 沟通数/及时回复 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)保持不动。YkStaffDetailModalfetcher 契约:(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_clientfixture):直接 ORMsession.add_all([...])播种;async mode(无需@pytest.mark.asyncio);client = await make_client(_seed)。
实现决策(spec §5 允许范围内的选择)
- 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)。三者皆索引覆盖、可移植、易测。 - last_is_send 用 ROW_NUMBER 窗口(不用相关子查询):干净、无 tie 歧义、SQLAlchemy
.over()表达自然;SQLite 3.25+ / MySQL 8.0+ 均支持。tie-break 加msg_id.desc()保证确定性。 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 时该客户既不算「最后回复」也不算「未回复」。- 5 汇总数全部 Python 侧从 customers 派生(spec §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 已 importStaffChatStats;补StaffChatCustomer)。Page类型从@/api/typesimport(StaffList 当前未直接 importPage——补上)。
-
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.py6 用例:群过滤、窗外/他 owner 排除、5 数字、客户字段、缺失昵称 fallback、空集、默认窗)。 cd yunkes && npx vue-tsc -bexit 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 待复核。