feat(analytics): 管理层指标呈现——首响回复率上墙/客户资产总览/自定义日期/排行五新列

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-07 14:41:08 +08:00
parent feb3b98665
commit 085a9b4fd3
6 changed files with 606 additions and 43 deletions
+216 -30
View File
@@ -43,23 +43,43 @@ def _day_date(day: int) -> dt.date:
return dt.date(day // 10000, (day // 100) % 100, day % 100) return dt.date(day // 10000, (day // 100) % 100, day % 100)
def scope_day_keys(scope: str, today: int) -> list[int]: def scope_day_keys(
scope: str, today: int, start: int | None = None, end: int | None = None
) -> list[int]:
"""today={今};week=本周一..今天;month=本月1号..今天;last30=近30天 """today={今};week=本周一..今天;month=本月1号..今天;last30=近30天
滚动窗(含端点)。last30 让排行/卡片命中任意有数据的月份(镜像停更期 滚动窗(含端点)。last30 让排行/卡片命中任意有数据的月份(镜像停更期
间日历月窗可能全空)。""" 间日历月窗可能全空)。
end = _day_date(today)
custom=显式 [start..end] yyyymmdd(C1):start≤end 且跨度≤366 天
(router 层已 422 校验,这里防御性同检——metrics 直调也拿到 ValueError
而非空窗/怪窗)。
"""
if scope == "custom":
if start is None or end is None:
raise ValueError("custom scope requires start and end")
s, e = _day_date(start), _day_date(end) # 非法日历值此处抛 ValueError
if s > e:
raise ValueError("custom scope requires start <= end")
if (e - s).days > 365: # 含端点共 366 天
raise ValueError("custom span must be <= 366 days")
out, cur = [], s
while cur <= e:
out.append(_date_key(cur))
cur += dt.timedelta(days=1)
return out
end_d = _day_date(today)
if scope == "today": if scope == "today":
start = end start_d = end_d
elif scope == "week": elif scope == "week":
start = end - dt.timedelta(days=end.weekday()) # 周一=0 start_d = end_d - dt.timedelta(days=end_d.weekday()) # 周一=0
elif scope == "month": elif scope == "month":
start = end.replace(day=1) start_d = end_d.replace(day=1)
elif scope == "last30": elif scope == "last30":
start = end - dt.timedelta(days=29) start_d = end_d - dt.timedelta(days=29)
else: else:
raise ValueError(f"bad scope: {scope!r}") raise ValueError(f"bad scope: {scope!r}")
out, cur = [], start out, cur = [], start_d
while cur <= end: while cur <= end_d:
out.append(_date_key(cur)) out.append(_date_key(cur))
cur += dt.timedelta(days=1) cur += dt.timedelta(days=1)
return out return out
@@ -69,7 +89,8 @@ def _prev_scope_days(scope: str, days: list[int]) -> list[int]:
"""同期对齐环比窗(与 days 等长逐日对齐;今日 scope 不调用)。 """同期对齐环比窗(与 days 等长逐日对齐;今日 scope 不调用)。
week=各日-7天(本周一..今 vs 上周一..上周同日);month=上月同日 week=各日-7天(本周一..今 vs 上周一..上周同日);month=上月同日
(短月钳到上月末,如 3/31→2/28);last30=各日-30天(前一个滚动窗)。 (短月钳到上月末,如 3/31→2/28);last30=各日-30天(前一个滚动窗);
custom=各日-N 天(等长紧邻前窗,例外不做同期对齐)。
同期对齐而非滚动紧邻窗——后者周会跨周末、月取上月尾巴,管理者会 同期对齐而非滚动紧邻窗——后者周会跨周末、月取上月尾巴,管理者会
误读成「上周/上月」(spec §8)。 误读成「上周/上月」(spec §8)。
""" """
@@ -88,6 +109,11 @@ def _prev_scope_days(scope: str, days: list[int]) -> list[int]:
return out return out
if scope == "last30": if scope == "last30":
return [_date_key(_day_date(d) - dt.timedelta(days=30)) for d in days] return [_date_key(_day_date(d) - dt.timedelta(days=30)) for d in days]
if scope == "custom":
# C1 例外:等长紧邻前窗(各日 -N 天滚动)。custom 不做同期对齐——
# 任意起点无「周/月」节律可对,紧邻窗是最不易误读的对照。
n = len(days)
return [_date_key(_day_date(d) - dt.timedelta(days=n)) for d in days]
raise ValueError(f"bad scope: {scope!r}") raise ValueError(f"bad scope: {scope!r}")
@@ -297,8 +323,9 @@ async def anomaly(session: AsyncSession, channel: str | None) -> dict | None:
async def staff_ranking( async def staff_ranking(
session: AsyncSession, *, channel: str | None, scope: str, session: AsyncSession, *, channel: str | None, scope: str,
start: int | None = None, end: int | None = None,
) -> list[dict]: ) -> list[dict]:
"""员工活跃排行(A2 核心子集,2026-09-07 管理者反馈补强)。 """员工活跃排行(A2 核心子集,2026-09-07 管理者反馈补强;批2 五新列)。
口径:msg_total=stat_daily_staff 聚合(空表回退 msg_daily_stats+映射, 口径:msg_total=stat_daily_staff 聚合(空表回退 msg_daily_stats+映射,
同 _active_staff 模式);call_cnt=phone_call 按 user_id 落窗直查(3k 行 同 _active_staff 模式);call_cnt=phone_call 按 user_id 落窗直查(3k 行
@@ -306,6 +333,19 @@ async def staff_ranking(
staff.name,无 staff 行/未映射账号回退 user_id 原值标 unmapped。 staff.name,无 staff 行/未映射账号回退 user_id 原值标 unmapped。
排序 msg_total desc + user_id asc 决胜。返回全部行(员工 ~41)。 排序 msg_total desc + user_id asc 决胜。返回全部行(员工 ~41)。
批2 新列(A3/A4/A5):
- fr_avg_ms=Σfr_sum_ms/Σthreads_answered(answered=0→None);
reply_rate=Σanswered/Σthreads(threads=0→None)——两路径同源
(rollup 三列 / 回退 msg_daily_stats 三列累加,含 room 行,与批1
管道口径一致);
- first_contact_cnt=该员工当期首沟通客户数:(owner,peer) 全史
MIN(day) 落窗的 distinct peer(映射到 user 后跨账号去重);
- call_seconds/answer_rate=phone_call 落窗 SUM(call_duration)(秒)
与 call_duration>0 占比(count=0→None);
- deleted/blocked_friends=customer 表按 user_id 归属的**全库**存量
delete_flag=1 / is_block 计数(与 churn handover 同归属口径;
不随窗——删粉是存量状态)。
channel=None(全平台):查询去渠道条件+映射合并,同 user 跨渠道量 channel=None(全平台):查询去渠道条件+映射合并,同 user 跨渠道量
自然合到一行(员工排行本就是 user 级)。 自然合到一行(员工排行本就是 user 级)。
@@ -315,12 +355,12 @@ async def staff_ranking(
MAX 只是峰值,皆非「窗内活跃去重数」——rollup 只供 msg_total, MAX 只是峰值,皆非「窗内活跃去重数」——rollup 只供 msg_total,
避免两路径语义分叉。 避免两路径语义分叉。
""" """
days = scope_day_keys(scope, today_key()) days = scope_day_keys(scope, today_key(), start, end)
amap = await _account_map(session, channel) amap = await _account_map(session, channel)
# 活跃客户/群 + 回退 msg_total:落窗拉行,Python 侧映射后按 user 去重 # 活跃客户/群 + 回退 msg_total/首响三列:落窗拉行,Python 侧映射后按
# (COUNT(DISTINCT peer) 必须在「账号合并到员工」之后——同人多账号 # user 去重(COUNT(DISTINCT peer) 必须在「账号合并到员工」之后——同人
# 跨账号去重,写 SQL 反而绕且踩 only_full_group_by) # 多账号跨账号去重,写 SQL 反而绕且踩 only_full_group_by)
conds = [MsgDailyStats.day.in_(days)] conds = [MsgDailyStats.day.in_(days)]
if channel is not None: if channel is not None:
conds.append(MsgDailyStats.channel == channel) conds.append(MsgDailyStats.channel == channel)
@@ -332,12 +372,16 @@ async def staff_ranking(
MsgDailyStats.peer_id, MsgDailyStats.peer_id,
MsgDailyStats.send_cnt, MsgDailyStats.send_cnt,
MsgDailyStats.recv_cnt, MsgDailyStats.recv_cnt,
MsgDailyStats.cust_threads,
MsgDailyStats.threads_answered,
MsgDailyStats.fr_sum_ms,
).where(*conds) ).where(*conds)
) )
).all() ).all()
act: dict[str, dict[str, set]] = {} act: dict[str, dict[str, set]] = {}
msg_fallback: dict[str, int] = {} msg_fallback: dict[str, int] = {}
for owner, kind, peer, s, r in rows: th_fallback: dict[str, list[int]] = {}
for owner, kind, peer, s, r, t, a_ns, fr in rows:
uid = amap.get(owner, owner) uid = amap.get(owner, owner)
a = act.setdefault(uid, {"friends": set(), "rooms": set()}) a = act.setdefault(uid, {"friends": set(), "rooms": set()})
if kind == "friend": # 显式分支:未来新 kind 跳过,不误入 rooms if kind == "friend": # 显式分支:未来新 kind 跳过,不误入 rooms
@@ -345,8 +389,13 @@ async def staff_ranking(
elif kind == "room": elif kind == "room":
a["rooms"].add(peer) a["rooms"].add(peer)
msg_fallback[uid] = msg_fallback.get(uid, 0) + int(s or 0) + int(r or 0) msg_fallback[uid] = msg_fallback.get(uid, 0) + int(s or 0) + int(r or 0)
tf = th_fallback.setdefault(uid, [0, 0, 0])
tf[0] += int(t or 0)
tf[1] += int(a_ns or 0)
tf[2] += int(fr or 0)
use_rollup = await _staff_rows(session) use_rollup = await _staff_rows(session)
th: dict[str, tuple[int, int, int]] = {}
if use_rollup: if use_rollup:
conds = [StatDailyStaff.day.in_(days)] conds = [StatDailyStaff.day.in_(days)]
if channel is not None: if channel is not None:
@@ -361,23 +410,45 @@ async def staff_ranking(
), ),
0, 0,
), ),
sa.func.coalesce(sa.func.sum(StatDailyStaff.cust_threads), 0),
sa.func.coalesce(
sa.func.sum(StatDailyStaff.threads_answered), 0
),
sa.func.coalesce(sa.func.sum(StatDailyStaff.fr_sum_ms), 0),
) )
.where(*conds) .where(*conds)
.group_by(StatDailyStaff.user_id) .group_by(StatDailyStaff.user_id)
) )
).all() ).all()
msg_total = {uid: int(v) for uid, v in rollup} msg_total = {}
for uid, mv, t, a_ns, fr in rollup:
msg_total[uid] = int(mv)
th[uid] = (int(t), int(a_ns), int(fr))
else: else:
msg_total = msg_fallback msg_total = msg_fallback
th = {uid: (v[0], v[1], v[2]) for uid, v in th_fallback.items()}
# 通话:按 user_id 落窗直查(窗口换算同 _call_count 写法) # 通话:按 user_id 落窗直查(窗口换算同 _call_count 写法)——量/时长/
# 接通率一次取回(call_duration>0 计为接通)
calls: dict[str, int] = {} calls: dict[str, int] = {}
call_secs: dict[str, int] = {}
call_ans: dict[str, int] = {}
if days: if days:
lo, _ = day_bounds_ms(days[0]) lo, _ = day_bounds_ms(days[0])
hi_start, _ = day_bounds_ms(days[-1]) hi_start, _ = day_bounds_ms(days[-1])
call_rows = ( call_rows = (
await session.execute( await session.execute(
sa.select(PhoneCall.user_id, sa.func.count()) sa.select(
PhoneCall.user_id,
sa.func.count(),
sa.func.coalesce(sa.func.sum(PhoneCall.call_duration), 0),
sa.func.coalesce(
sa.func.sum(
sa.case((PhoneCall.call_duration > 0, 1), else_=0)
),
0,
),
)
.where( .where(
PhoneCall.user_id.is_not(None), PhoneCall.user_id.is_not(None),
PhoneCall.start_call_time.is_not(None), PhoneCall.start_call_time.is_not(None),
@@ -387,11 +458,61 @@ async def staff_ranking(
.group_by(PhoneCall.user_id) .group_by(PhoneCall.user_id)
) )
).all() ).all()
calls = {uid: int(v) for uid, v in call_rows} for uid, cnt, secs, ans in call_rows:
calls[uid] = int(cnt)
call_secs[uid] = int(secs)
call_ans[uid] = int(ans)
# 首沟通归属:peer 全史 MIN(day) 落窗 → 记到 (channel,)owner 映射后的
# user;distinct 在 user 级(同人多账号/客户换跟进人不重复计)
fc_conds = [MsgDailyStats.peer_kind == "friend"]
if channel is not None:
fc_conds.append(MsgDailyStats.channel == channel)
fc_rows = (
await session.execute(
sa.select(
MsgDailyStats.owner_account_id,
MsgDailyStats.peer_id,
sa.func.min(MsgDailyStats.day),
)
.where(*fc_conds)
.group_by(MsgDailyStats.owner_account_id, MsgDailyStats.peer_id)
)
).all()
dayset = set(days)
first_contacts: dict[str, set[str]] = {}
for owner, peer, first in fc_rows:
if first in dayset:
first_contacts.setdefault(amap.get(owner, owner), set()).add(peer)
# 删粉/拉黑:customer 全库存量按 user_id 归属(NULL user_id 跳过——
# 排行是员工视图,无归属客户进 overview 的 customer_assets)
del_conds = [Customer.user_id.is_not(None)]
if channel is not None:
del_conds.append(Customer.channel == channel)
del_rows = (
await session.execute(
sa.select(
Customer.user_id,
sa.func.coalesce(
sa.func.sum(sa.case((Customer.delete_flag == 1, 1), else_=0)),
0,
),
sa.func.coalesce(
sa.func.sum(sa.case((Customer.is_block.is_(True), 1), else_=0)),
0,
),
)
.where(*del_conds)
.group_by(Customer.user_id)
)
).all()
deleted = {uid: int(d) for uid, d, _b in del_rows}
blocked = {uid: int(b) for uid, _d, b in del_rows}
# 环比:同期对齐前窗(仅周/月;今日 vs 昨日全天会误导,与 overview 同口径) # 环比:同期对齐前窗(仅周/月;今日 vs 昨日全天会误导,与 overview 同口径)
prev_total: dict[str, int] = {} prev_total: dict[str, int] = {}
if scope in ("week", "month", "last30"): if scope in ("week", "month", "last30", "custom"):
prev_days = _prev_scope_days(scope, days) prev_days = _prev_scope_days(scope, days)
if use_rollup: if use_rollup:
conds = [StatDailyStaff.day.in_(prev_days)] conds = [StatDailyStaff.day.in_(prev_days)]
@@ -449,6 +570,8 @@ async def staff_ranking(
for uid in sorted(set(msg_total) | set(act) | set(calls), for uid in sorted(set(msg_total) | set(act) | set(calls),
key=lambda u: (-msg_total.get(u, 0), u)): key=lambda u: (-msg_total.get(u, 0), u)):
cur, prev = msg_total.get(uid, 0), prev_total.get(uid, 0) cur, prev = msg_total.get(uid, 0), prev_total.get(uid, 0)
t, a_ns, fr = th.get(uid, (0, 0, 0))
cnt = calls.get(uid, 0)
out.append({ out.append({
"user_id": uid, "user_id": uid,
"name": staff_names.get(uid) or uid, "name": staff_names.get(uid) or uid,
@@ -456,8 +579,15 @@ async def staff_ranking(
"msg_total": cur, "msg_total": cur,
"active_friends": len(act[uid]["friends"]) if uid in act else 0, "active_friends": len(act[uid]["friends"]) if uid in act else 0,
"active_rooms": len(act[uid]["rooms"]) if uid in act else 0, "active_rooms": len(act[uid]["rooms"]) if uid in act else 0,
"call_cnt": calls.get(uid, 0), "call_cnt": cnt,
"msg_delta": round((cur - prev) / prev, 3) if cur and prev else None, "msg_delta": round((cur - prev) / prev, 3) if cur and prev else None,
"fr_avg_ms": round(fr / a_ns) if a_ns else None,
"reply_rate": round(a_ns / t, 4) if t else None,
"first_contact_cnt": len(first_contacts.get(uid, ())),
"call_seconds": call_secs.get(uid, 0),
"answer_rate": round(call_ans[uid] / cnt, 4) if cnt else None,
"deleted_friends": deleted.get(uid, 0),
"blocked_friends": blocked.get(uid, 0),
}) })
return out return out
@@ -485,11 +615,62 @@ async def action_counts(session: AsyncSession, *, channel: str | None) -> dict:
} }
async def _customer_assets(session: AsyncSession) -> dict:
"""B1 客户资产总览:全库存量快照,不随 scope/channel(分层/删粉是存量
状态,与筛选无关)。
剔除 delete_flag=1 后按 last_chat_time 分层(边界与 churn real 同式:
>now-Nd 为近 N 天):active30=≤30 天;silent30_90=(30,90] 天前;
sleeping90p=≥90 天前;从未激活(last_chat_time NULL,云客没聊过/新加)
单列 never。渠道计数与已删好友单列。customer ~12k 行单条聚合直查。
"""
day_ms = 86_400_000
now = _now_ms()
not_deleted = sa.or_(Customer.delete_flag.is_(None), Customer.delete_flag == 0)
lt = Customer.last_chat_time
def _n(cond: Any) -> int:
return sa.func.coalesce(sa.func.sum(sa.case((cond, 1), else_=0)), 0)
row = (
await session.execute(
sa.select(
_n(sa.and_(not_deleted, lt.is_(None))),
_n(sa.and_(not_deleted, lt.is_not(None), lt > now - 30 * day_ms)),
_n(sa.and_(not_deleted, lt.is_not(None),
lt <= now - 30 * day_ms, lt > now - 90 * day_ms)),
_n(sa.and_(not_deleted, lt.is_not(None), lt <= now - 90 * day_ms)),
_n(sa.and_(not_deleted, Customer.channel == "wechat")),
_n(sa.and_(not_deleted, Customer.channel == "whatsapp")),
_n(Customer.delete_flag == 1),
)
)
).one()
never, active30, silent, sleeping, wechat, whatsapp, deleted = (
int(v) for v in row
)
return {
"total": never + active30 + silent + sleeping,
"active30": active30,
"silent30_90": silent,
"sleeping90p": sleeping,
"never": never,
"wechat": wechat,
"whatsapp": whatsapp,
"deleted": deleted,
}
async def overview( async def overview(
session: AsyncSession, *, channel: str | None, scope: str, trend_days: int = 30 session: AsyncSession, *, channel: str | None, scope: str, trend_days: int = 30,
start: int | None = None, end: int | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""A1:卡片 + 环比(仅周/月,同期对齐) + 近 N 天趋势 + 异常警示。 """A1:卡片 + 环比(仅周/月,同期对齐) + 近 N 天趋势 + 异常警示。
批2:C1 自定义日期(scope=custom + start/end yyyymmdd,环比=等长紧邻
前窗滚动,不做同期对齐——见 _prev_scope_days);B1 customer_assets
客户资产总览(全库存量,不随 scope/channel)。
channel=None(全平台,看板 v2 默认):所有指标跨渠道合并——msg/活跃客户 channel=None(全平台,看板 v2 默认):所有指标跨渠道合并——msg/活跃客户
直接合计(peer_id 跨渠道不碰撞);活跃员工按 user 去重(跨渠道重复计数 直接合计(peer_id 跨渠道不碰撞);活跃员工按 user 去重(跨渠道重复计数
不拆);通话量本就是 call-type 无渠道语义。此时追加: 不拆);通话量本就是 call-type 无渠道语义。此时追加:
@@ -502,7 +683,7 @@ async def overview(
if trend_days < 1 or trend_days > 90: if trend_days < 1 or trend_days > 90:
raise ValueError("trend_days must be 1..90") raise ValueError("trend_days must be 1..90")
today = today_key() today = today_key()
days = scope_day_keys(scope, today) days = scope_day_keys(scope, today, start, end)
rows = await _day_rows(session, channel, days) rows = await _day_rows(session, channel, days)
msg_total = sum(int(s or 0) + int(r or 0) for _d, _k, _p, s, r in rows) msg_total = sum(int(s or 0) + int(r or 0) for _d, _k, _p, s, r in rows)
active_customers = len({p for _d, kind, p, _s, _r in rows if kind == "friend"}) active_customers = len({p for _d, kind, p, _s, _r in rows if kind == "friend"})
@@ -517,8 +698,9 @@ async def overview(
"first_contact": await _first_contact(session, channel, days), "first_contact": await _first_contact(session, channel, days),
} }
# 环比仅周/月,同期对齐(今日 vs 昨日全天会误导,spec §8) # 环比仅周/月,同期对齐(今日 vs 昨日全天会误导,spec §8);
if scope in ("week", "month", "last30"): # custom=等长紧邻前窗滚动(例外,见 _prev_scope_days)
if scope in ("week", "month", "last30", "custom"):
prev_days = _prev_scope_days(scope, days) prev_days = _prev_scope_days(scope, days)
prev_rows = await _day_rows(session, channel, prev_days) prev_rows = await _day_rows(session, channel, prev_days)
prev_total = sum(int(s or 0) + int(r or 0) for _d, _k, _p, s, r in prev_rows) prev_total = sum(int(s or 0) + int(r or 0) for _d, _k, _p, s, r in prev_rows)
@@ -528,10 +710,11 @@ async def overview(
) )
# 趋势:近 trend_days 天 每日 消息量/活跃客户/通话量 # 趋势:近 trend_days 天 每日 消息量/活跃客户/通话量
end = _day_date(today) # (t_end 不复用 end 参数名——那是 custom scope 的 yyyymmdd 入参)
t_end = _day_date(today)
t_days = [] t_days = []
cur = end - dt.timedelta(days=trend_days - 1) cur = t_end - dt.timedelta(days=trend_days - 1)
while cur <= end: while cur <= t_end:
t_days.append(_date_key(cur)) t_days.append(_date_key(cur))
cur += dt.timedelta(days=1) cur += dt.timedelta(days=1)
# 通话趋势:phone_call 拉窗内 (start_call_time) Python 侧按日分桶(3k 行) # 通话趋势:phone_call 拉窗内 (start_call_time) Python 侧按日分桶(3k 行)
@@ -571,9 +754,12 @@ async def overview(
"channel": "all" if channel is None else channel, "channel": "all" if channel is None else channel,
"days": days, "days": days,
"cards": cards, "cards": cards,
"customer_assets": await _customer_assets(session),
"trend": trend, "trend": trend,
"anomaly": await anomaly(session, channel), "anomaly": await anomaly(session, channel),
"staff_ranking": await staff_ranking(session, channel=channel, scope=scope), "staff_ranking": await staff_ranking(
session, channel=channel, scope=scope, start=start, end=end
),
"actions": await action_counts(session, channel=channel), "actions": await action_counts(session, channel=channel),
} }
if channel is None: if channel is None:
+27 -2
View File
@@ -1,6 +1,7 @@
"""GET /api/analytics/* —— 数据分析板块端组(前缀分组=未来角色权限落点)。""" """GET /api/analytics/* —— 数据分析板块端组(前缀分组=未来角色权限落点)。"""
from __future__ import annotations from __future__ import annotations
import datetime as dt
from typing import Annotated, Any, Literal from typing import Annotated, Any, Literal
import sqlalchemy as sa import sqlalchemy as sa
@@ -21,7 +22,11 @@ ChannelParam = Annotated[Literal["wechat", "whatsapp"], Query()]
# 看板 v2(2026-09-07):overview 默认全平台——"all" 在 router 层翻译成 # 看板 v2(2026-09-07):overview 默认全平台——"all" 在 router 层翻译成
# metrics 的 channel=None,不把哨兵字符串传进 SQL # metrics 的 channel=None,不把哨兵字符串传进 SQL
AllChannelParam = Annotated[Literal["all", "wechat", "whatsapp"], Query()] AllChannelParam = Annotated[Literal["all", "wechat", "whatsapp"], Query()]
ScopeParam = Annotated[Literal["today", "week", "month", "last30"], Query()] # C1(批2):custom=自定义日期范围,配合 start/end yyyymmdd
ScopeParam = Annotated[
Literal["today", "week", "month", "last30", "custom"], Query()
]
YmdParam = Annotated[int, Query(ge=20000101, le=29991231)]
@router.get("/health") @router.get("/health")
@@ -36,6 +41,8 @@ async def analytics_overview(
session: SessionDep, session: SessionDep,
channel: AllChannelParam = "all", channel: AllChannelParam = "all",
scope: ScopeParam = "today", scope: ScopeParam = "today",
start: YmdParam | None = None,
end: YmdParam | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""A1 经营总览:卡片+趋势+异常警示一次返回(单往返)。 """A1 经营总览:卡片+趋势+异常警示一次返回(单往返)。
@@ -43,9 +50,27 @@ async def analytics_overview(
双渠道趋势序列;单渠道行为不变。 双渠道趋势序列;单渠道行为不变。
cards 不含 data_as_of——数据截至走 /health(data_as_of 为 ms-epoch int cards 不含 data_as_of——数据截至走 /health(data_as_of 为 ms-epoch int
与 entities[].last_seen 为 ISO 字符串的双格式)。 与 entities[].last_seen 为 ISO 字符串的双格式)。
scope=custom(C1):start/end=yyyymmdd(缺一/日历非法/Start>End/跨度
>366 天皆 422);环比=等长紧邻前窗滚动(不做同期对齐)。
""" """
s = e = None
if scope == "custom":
if start is None or end is None:
raise HTTPException(422, "scope=custom requires start and end")
try:
sd = dt.date(start // 10000, (start // 100) % 100, start % 100)
ed = dt.date(end // 10000, (end // 100) % 100, end % 100)
except ValueError as exc: # 如 20260230——Query 只拦范围不拦日历
raise HTTPException(422, "invalid start/end calendar date") from exc
if sd > ed:
raise HTTPException(422, "start must be <= end")
if (ed - sd).days > 365: # 含端点共 366 天
raise HTTPException(422, "custom span must be <= 366 days")
s, e = start, end
return await metrics_svc.overview( return await metrics_svc.overview(
session, channel=None if channel == "all" else channel, scope=scope session, channel=None if channel == "all" else channel, scope=scope,
start=s, end=e,
) )
+48
View File
@@ -147,3 +147,51 @@ async def test_overview_unmapped_rollup(make_client, monkeypatch):
body = r.json() body = r.json()
assert body["cards"]["active_staff"] == 2 assert body["cards"]["active_staff"] == 2
assert body["cards"]["unmapped_accounts"] == 1 assert body["cards"]["unmapped_accounts"] == 1
async def test_overview_custom_scope_api(make_client, monkeypatch):
"""C1 自定义日期:正常窗 200 + customer_assets 恒在;缺参/start>end/
跨度超 366 天/非法日历 全 422;闰年整 366 天放行。"""
monkeypatch.setattr(metrics, "today_key", lambda: _TODAY)
async def seed(session):
session.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
peer_kind="friend", peer_id="t1", day=20260815,
send_cnt=10, recv_cnt=0, last_ts=1786000000000,
last_is_send=True))
await session.commit()
client = await make_client(seed)
r = await client.get("/api/analytics/overview", params={
"channel": "wechat", "scope": "custom",
"start": 20260801, "end": 20260827,
})
assert r.status_code == 200
body = r.json()
assert body["scope"] == "custom"
assert len(body["days"]) == 27
assert body["cards"]["msg_total"] == 10
# B1 客户资产总览恒在(全库快照,不随 scope)
assert body["customer_assets"]["total"] >= 0
# 缺 start/end → 422(不让静默落到空窗)
r2 = await client.get("/api/analytics/overview",
params={"scope": "custom"})
assert r2.status_code == 422
# start > end → 422
r3 = await client.get("/api/analytics/overview", params={
"scope": "custom", "start": 20260827, "end": 20260801})
assert r3.status_code == 422
# 跨度 367 天 → 422(2024-01-01..2025-01-01 含端点 367 天)
r4 = await client.get("/api/analytics/overview", params={
"scope": "custom", "start": 20240101, "end": 20250101})
assert r4.status_code == 422
# 非法日历(2 月 30)→ 422——Query ge/le 只拦数值范围不拦日历
r5 = await client.get("/api/analytics/overview", params={
"scope": "custom", "start": 20260230, "end": 20260301})
assert r5.status_code == 422
# 闰年整年 366 天(含端点)→ 放行
r6 = await client.get("/api/analytics/overview", params={
"scope": "custom", "start": 20240101, "end": 20241231})
assert r6.status_code == 200
assert len(r6.json()["days"]) == 366
+155 -3
View File
@@ -280,27 +280,37 @@ async def test_staff_ranking(make_client, monkeypatch):
async def test_staff_ranking_fallback_when_rollup_empty(make_client, monkeypatch): async def test_staff_ranking_fallback_when_rollup_empty(make_client, monkeypatch):
"""stat_daily_staff 空表 → msg_total 回退 msg_daily_stats 聚合(部署即用)。""" """stat_daily_staff 空表 → msg_total/首响三列回退 msg_daily_stats 聚合(部署即用)。"""
monkeypatch.setattr(metrics, "today_key", lambda: _TODAY) monkeypatch.setattr(metrics, "today_key", lambda: _TODAY)
async def seed(session): async def seed(session):
session.add(WechatAccount(wx_id="wx1", user_id="u1", user_name="张三")) session.add(WechatAccount(wx_id="wx1", user_id="u1", user_name="张三"))
session.add(MsgDailyStats(channel="wechat", owner_account_id="wx1", session.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
peer_kind="friend", peer_id="t1", day=20260904, peer_kind="friend", peer_id="t1", day=20260904,
send_cnt=10, recv_cnt=5, last_ts=1788500000000, send_cnt=10, recv_cnt=5, last_ts=1788500000000,
last_is_send=True)) last_is_send=True,
cust_threads=2, threads_answered=1,
fr_sum_ms=60000))
# wx9 无账号行 → 映射不上按 owner 原值计入并标未映射 # wx9 无账号行 → 映射不上按 owner 原值计入并标未映射
session.add(MsgDailyStats(channel="wechat", owner_account_id="wx9", session.add(MsgDailyStats(channel="wechat", owner_account_id="wx9",
peer_kind="friend", peer_id="t2", day=20260904, peer_kind="friend", peer_id="t2", day=20260904,
send_cnt=2, recv_cnt=0, last_ts=1788500000000, send_cnt=2, recv_cnt=0, last_ts=1788500000000,
last_is_send=True)) last_is_send=True,
cust_threads=3, threads_answered=0,
fr_sum_ms=0))
await session.commit() await session.commit()
out = await metrics.staff_ranking(session, channel="wechat", scope="today") out = await metrics.staff_ranking(session, channel="wechat", scope="today")
assert [r["user_id"] for r in out] == ["u1", "wx9"] assert [r["user_id"] for r in out] == ["u1", "wx9"]
assert out[0]["msg_total"] == 15 and out[0]["active_friends"] == 1 assert out[0]["msg_total"] == 15 and out[0]["active_friends"] == 1
assert out[0]["unmapped"] is False assert out[0]["unmapped"] is False
assert out[0]["msg_delta"] is None # 今日 scope 无环比 assert out[0]["msg_delta"] is None # 今日 scope 无环比
# 回退路径首响三列:60000/1=60000;回复率 1/2
assert out[0]["fr_avg_ms"] == 60000
assert out[0]["reply_rate"] == 0.5
assert out[1]["msg_total"] == 2 and out[1]["unmapped"] is True assert out[1]["msg_total"] == 2 and out[1]["unmapped"] is True
assert out[1]["name"] == "wx9" assert out[1]["name"] == "wx9"
# threads>0 answered=0 → 回复率 0.0(有串无回);首响 None(无样本)
assert out[1]["reply_rate"] == 0.0
assert out[1]["fr_avg_ms"] is None
await make_client(seed) await make_client(seed)
@@ -425,3 +435,145 @@ async def test_action_counts_all(make_client, monkeypatch):
"sensitive_pending": 2} "sensitive_pending": 2}
await make_client(seed) await make_client(seed)
# ── 批2(2026-09-07):排行五新列 / B1 客户资产 / C1 自定义日期 ──────────────
async def test_staff_ranking_batch2_columns(make_client, monkeypatch):
"""排行五新列:首响均值+回复率(rollup 三列)、首沟通归属、通话时长+
接通率、删粉/拉黑(全库存量按 user_id 归属,不随窗)。"""
monkeypatch.setattr(metrics, "today_key", lambda: _TODAY)
async def seed(session):
session.add(WechatAccount(wx_id="wx1", user_id="u1", user_name="张三"))
session.add(WechatAccount(wx_id="wx2", user_id="u2", user_name="李四"))
session.add(Staff(user_id="u1", name="张三"))
session.add(Staff(user_id="u2", name="李四"))
# rollup 当窗行:u1 threads=10 answered=4 fr=1_200_000(→300000ms/0.4);
# u2 三列缺省 0 → 两率皆 None
session.add(StatDailyStaff(day=20260904, channel="wechat", user_id="u1",
msg_sent=20, msg_recv=0, active_friends=1,
active_rooms=0, cust_threads=10,
threads_answered=4, fr_sum_ms=1_200_000))
session.add(StatDailyStaff(day=20260904, channel="wechat", user_id="u2",
msg_sent=5, msg_recv=0, active_friends=1,
active_rooms=0))
# 首沟通:f1 全史 MIN=20260831 落窗(计);f2 MIN=20260801 窗外(不计);
# r1 是 room 不计;f3 归 wx2→u2(计)
for day, kind, peer, owner in [
(20260831, "friend", "f1", "wx1"),
(20260902, "friend", "f1", "wx1"), # 同 peer 次日不影响 MIN
(20260801, "friend", "f2", "wx1"),
(20260903, "room", "r1", "wx1"),
(20260903, "friend", "f3", "wx2"),
]:
session.add(MsgDailyStats(channel="wechat", owner_account_id=owner,
peer_kind=kind, peer_id=peer, day=day,
send_cnt=1, recv_cnt=0,
last_ts=1788500000000, last_is_send=True))
# 通话:u1 三通落窗(60/0/120s → 180s、2/3 接通);u2 通话在 2025 年
for cid, uid, ts, dur in [
("c1", "u1", 1788500000000, 60),
("c2", "u1", 1788500001000, 0),
("c3", "u1", 1788500002000, 120),
("c4", "u2", 1756950000000, 60),
]:
session.add(PhoneCall(call_id=cid, channel="voice_call", user_id=uid,
start_call_time=ts, call_duration=dur))
# 客户归属存量:u1 删 1 拉黑 1;u2 双属性各 1;无归属(user_id NULL)跳过
session.add(Customer(friend_id="d1", channel="wechat", user_id="u1",
delete_flag=1))
session.add(Customer(friend_id="b1", channel="wechat", user_id="u1",
is_block=True))
session.add(Customer(friend_id="x1", channel="wechat", user_id="u2",
delete_flag=1, is_block=True))
session.add(Customer(friend_id="n1", channel="wechat", user_id=None))
await session.commit()
out = await metrics.staff_ranking(session, channel="wechat", scope="week")
by = {r["user_id"]: r for r in out}
r1, r2 = by["u1"], by["u2"]
assert r1["fr_avg_ms"] == 300000 # 1_200_000 / 4
assert r1["reply_rate"] == 0.4
assert r1["first_contact_cnt"] == 1 # f1;f2 窗外、r1 群不计
assert r1["call_seconds"] == 180
assert r1["answer_rate"] == round(2 / 3, 4)
assert r1["deleted_friends"] == 1 and r1["blocked_friends"] == 1
assert r2["fr_avg_ms"] is None and r2["reply_rate"] is None
assert r2["first_contact_cnt"] == 1 # f3
assert r2["call_seconds"] == 0 and r2["answer_rate"] is None # 窗外通话
assert r2["deleted_friends"] == 1 and r2["blocked_friends"] == 1
await make_client(seed)
async def test_customer_assets_layers(make_client, monkeypatch):
"""B1:剔除已删后按 last_chat_time 分层(从未激活单列 never);渠道计数;
已删单列。全库口径——channel=wechat 也不滤 whatsapp 客户。"""
monkeypatch.setattr(metrics, "today_key", lambda: _TODAY)
monkeypatch.setattr(metrics, "_now_ms", lambda: 1788550000000)
async def seed(session):
now = 1788550000000
day_ms = 86_400_000
rows = [
("a1", "wechat", now - 5 * day_ms, 0), # active
("a2", "whatsapp", now - 29 * day_ms, 0), # active(边界内)
("s1", "whatsapp", now - 45 * day_ms, 0), # silent
("p1", "wechat", now - 91 * day_ms, 0), # sleeping
("n1", "whatsapp", None, 0), # never
("n2", "wechat", None, None), # never(delete_flag NULL 同未删)
]
for fid, ch, lt, df in rows:
session.add(Customer(friend_id=fid, channel=ch, last_chat_time=lt,
delete_flag=df))
# 已删不进分层、单列(deleted)
session.add(Customer(friend_id="d1", channel="wechat",
last_chat_time=now - 1 * day_ms, delete_flag=1))
await session.commit()
out = await metrics.overview(session, channel="wechat", scope="today")
assert out["customer_assets"] == {
"total": 6, "active30": 2, "silent30_90": 1, "sleeping90p": 1,
"never": 2, "wechat": 3, "whatsapp": 3, "deleted": 1,
}
await make_client(seed)
async def test_overview_custom_scope(make_client, monkeypatch):
"""C1:custom days=[start..end](含端点);环比=等长紧邻前窗滚动(各日 -N);
metrics 直调的参数防御(缺参/逆序/超 366 天)。"""
monkeypatch.setattr(metrics, "today_key", lambda: _TODAY)
async def seed(session):
# 窗内 20260801..20260827:t1 10 条;紧邻前窗 20260705..20260731:t1 20 条
for day, sent in [(20260815, 10), (20260710, 20)]:
session.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
peer_kind="friend", peer_id="t1", day=day,
send_cnt=sent, recv_cnt=0,
last_ts=1786000000000, last_is_send=True))
await session.commit()
out = await metrics.overview(session, channel="wechat", scope="custom",
start=20260801, end=20260827)
assert out["days"][0] == 20260801 and out["days"][-1] == 20260827
assert len(out["days"]) == 27
assert out["cards"]["msg_total"] == 10
assert out["cards"]["prev_msg_total"] == 20 # 紧邻前窗命中 07-10 行
assert out["cards"]["msg_total_delta"] == -0.5
# 排行吃同一窗(回退路径):wx1 未映射按原值出列
assert out["staff_ranking"][0]["user_id"] == "wx1"
assert out["staff_ranking"][0]["msg_total"] == 10
# 紧邻前窗逐日 -27:08-01→07-05、08-27→07-31
prev = metrics._prev_scope_days("custom", out["days"])
assert (len(prev), prev[0], prev[-1]) == (27, 20260705, 20260731)
with pytest.raises(ValueError):
await metrics.overview(session, channel="wechat", scope="custom")
with pytest.raises(ValueError):
await metrics.overview(session, channel="wechat", scope="custom",
start=20260827, end=20260801)
with pytest.raises(ValueError):
await metrics.overview(session, channel="wechat", scope="custom",
start=20240101, end=20250101) # 367 天
await make_client(seed)
+29 -4
View File
@@ -4,7 +4,8 @@ import { getJson, requestJson } from './client'
import type { Channel, Page } from './types' import type { Channel, Page } from './types'
export type { Channel } export type { Channel }
export type Scope = 'today' | 'week' | 'month' | 'last30' // 批2(2026-09-07):'custom'=自定义日期范围,须配合 start/end yyyymmdd
export type Scope = 'today' | 'week' | 'month' | 'last30' | 'custom'
// 看板 v2(2026-09-07):overview 渠道参数支持 'all'=全平台(后端译成 // 看板 v2(2026-09-07):overview 渠道参数支持 'all'=全平台(后端译成
// channel=None);列表页(churn/unreplied)仍是单渠道,沿用 types.ts 的 Channel // channel=None);列表页(churn/unreplied)仍是单渠道,沿用 types.ts 的 Channel
export type DashboardChannel = 'all' | Channel export type DashboardChannel = 'all' | Channel
@@ -19,15 +20,35 @@ export interface OverviewCards {
prev_msg_total?: number prev_msg_total?: number
msg_total_delta?: number | null msg_total_delta?: number | null
} }
// B1 客户资产总览:全库存量快照,不随 scope/channel
export interface CustomerAssets {
total: number
active30: number
silent30_90: number
sleeping90p: number
never: number
wechat: number
whatsapp: number
deleted: number
}
export interface TrendPoint { day: number; msg: number; active_customers: number; call_cnt: number } export interface TrendPoint { day: number; msg: number; active_customers: number; call_cnt: number }
export interface AnomalyStaff { user_id: string; yesterday: number; prev7_mean: number; new?: boolean } export interface AnomalyStaff { user_id: string; yesterday: number; prev7_mean: number; new?: boolean }
export interface Anomaly { total_delta_pct: number; yesterday_total: number; prev7_mean: number; staff: AnomalyStaff[] } export interface Anomaly { total_delta_pct: number; yesterday_total: number; prev7_mean: number; staff: AnomalyStaff[] }
// 员工活跃排行(2026-09-07 管理者反馈补强):active_*=窗内 distinct peer, // 员工活跃排行(2026-09-07 管理者反馈补强):active_*=窗内 distinct peer,
// msg_delta 仅周/月且当前/前窗均非 0 才有值 // msg_delta 仅周/月/custom 且当前/前窗均非 0 才有值。
// 批2 五新列:fr_avg_ms/reply_rate(=null 无样本/无串)、first_contact_cnt、
// call_seconds/answer_rate(=null 无通话);deleted/blocked_friends=全库存量
export interface StaffRankingRow { export interface StaffRankingRow {
user_id: string; name: string; unmapped: boolean user_id: string; name: string; unmapped: boolean
msg_total: number; active_friends: number; active_rooms: number msg_total: number; active_friends: number; active_rooms: number
call_cnt: number; msg_delta: number | null call_cnt: number; msg_delta: number | null
fr_avg_ms: number | null
reply_rate: number | null
first_contact_cnt: number
call_seconds: number
answer_rate: number | null
deleted_friends: number
blocked_friends: number
} }
export interface ActionCounts { unreplied_2h: number; churn_real_30d: number; sensitive_pending: number } export interface ActionCounts { unreplied_2h: number; churn_real_30d: number; sensitive_pending: number }
// by_channel/trend_by_channel 仅 channel='all' 时后端才返回(可选字段); // by_channel/trend_by_channel 仅 channel='all' 时后端才返回(可选字段);
@@ -38,6 +59,7 @@ export interface OverviewResp {
channel: Channel | 'all' channel: Channel | 'all'
days: number[] days: number[]
cards: OverviewCards cards: OverviewCards
customer_assets: CustomerAssets
trend: TrendPoint[] trend: TrendPoint[]
anomaly: Anomaly | null anomaly: Anomaly | null
staff_ranking: StaffRankingRow[] staff_ranking: StaffRankingRow[]
@@ -64,8 +86,11 @@ export interface SensitiveHitRow {
room_id: string | null; ts: number | null; snippet: string | null; disposition: string room_id: string | null; ts: number | null; snippet: string | null; disposition: string
} }
export const getOverview = (channel: DashboardChannel, scope: Scope) => // scope='custom' 时必须传 start/end(yyyymmdd,后端校验跨度≤366 天)
getJson<OverviewResp>('/analytics/overview', { channel, scope }) export const getOverview = (
channel: DashboardChannel, scope: Scope, start?: string, end?: string,
) =>
getJson<OverviewResp>('/analytics/overview', { channel, scope, start, end })
export const getAnalyticsHealth = () => getJson<HealthResp>('/analytics/health') export const getAnalyticsHealth = () => getJson<HealthResp>('/analytics/health')
export const getChurn = (params: Record<string, unknown>) => export const getChurn = (params: Record<string, unknown>) =>
getJson<Page<ChurnRow>>('/analytics/churn', params) getJson<Page<ChurnRow>>('/analytics/churn', params)
+131 -4
View File
@@ -11,7 +11,23 @@
<el-radio-button value="week">本周</el-radio-button> <el-radio-button value="week">本周</el-radio-button>
<el-radio-button value="month">本月</el-radio-button> <el-radio-button value="month">本月</el-radio-button>
<el-radio-button value="last30">近30天</el-radio-button> <el-radio-button value="last30">近30天</el-radio-button>
<el-radio-button value="custom">自定义</el-radio-button>
</el-radio-group> </el-radio-group>
<!-- C1 自定义日期范围:daterange 自保证 start≤end,选完触发 load;
跨度>366 天后端 422 走页面 error alert -->
<el-date-picker
v-if="scope === 'custom'"
v-model="customRange"
type="daterange"
size="small"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYYMMDD"
:clearable="false"
style="width: 260px"
@change="load"
/>
<span v-if="asOfText" class="as-of">数据截至 {{ asOfText }}</span> <span v-if="asOfText" class="as-of">数据截至 {{ asOfText }}</span>
</div> </div>
@@ -29,6 +45,21 @@
</el-card> </el-card>
</div> </div>
<!-- B1 客户资产总览:全库存量快照(不随筛选变)——先看客户盘子,再看
下方当期经营卡片;分层按最近沟通时间(从未激活=加过没聊过) -->
<el-card v-if="assets" shadow="never" class="assets-card">
<div class="assets-row">
<span class="assets-title">客户资产</span>
<span class="asset-item">总客户 <b>{{ fmt(assets.total) }}</b></span>
<span class="asset-item">活跃(30天) <b>{{ fmt(assets.active30) }}</b></span>
<span class="asset-item">沉默(30-90天) <b>{{ fmt(assets.silent30_90) }}</b></span>
<span class="asset-item">沉睡(90天+) <b>{{ fmt(assets.sleeping90p) }}</b></span>
<span class="asset-item">从未激活 <b>{{ fmt(assets.never) }}</b></span>
<span class="asset-item small">微信 {{ fmt(assets.wechat) }} · WA {{ fmt(assets.whatsapp) }}</span>
<span class="asset-item muted">已删好友 {{ fmt(assets.deleted) }}</span>
</div>
</el-card>
<el-alert v-if="error" type="error" :title="error" :closable="false" class="anomaly" /> <el-alert v-if="error" type="error" :title="error" :closable="false" class="anomaly" />
<el-alert v-if="anomalyView" type="warning" :closable="false" class="anomaly"> <el-alert v-if="anomalyView" type="warning" :closable="false" class="anomaly">
@@ -105,6 +136,27 @@
<el-table-column label="通话量" align="right"> <el-table-column label="通话量" align="right">
<template #default="{ row }">{{ fmt(row.call_cnt) }}</template> <template #default="{ row }">{{ fmt(row.call_cnt) }}</template>
</el-table-column> </el-table-column>
<!-- 批2 五新列(2026-09-07 管理者需求):新增客户=当期首沟通;
首响/回复率=消息串口径(null=无样本/无串);
通话时长/接通率=落窗通话;删粉/拉黑=全库存量(「删/拉」) -->
<el-table-column label="新增客户" align="right">
<template #default="{ row }">{{ fmt(row.first_contact_cnt) }}</template>
</el-table-column>
<el-table-column label="首响" align="right">
<template #default="{ row }">{{ fmtFr(row.fr_avg_ms) }}</template>
</el-table-column>
<el-table-column label="回复率" align="right">
<template #default="{ row }">{{ fmtPct(row.reply_rate) }}</template>
</el-table-column>
<el-table-column label="通话时长" align="right">
<template #default="{ row }">{{ fmtDur(row.call_seconds) }}</template>
</el-table-column>
<el-table-column label="接通率" align="right">
<template #default="{ row }">{{ fmtPct(row.answer_rate) }}</template>
</el-table-column>
<el-table-column label="删粉/拉黑" align="right">
<template #default="{ row }">{{ fmtDelBlocked(row) }}</template>
</el-table-column>
<el-table-column label="环比" align="right" width="90"> <el-table-column label="环比" align="right" width="90">
<template #default="{ row }"> <template #default="{ row }">
<span <span
@@ -121,8 +173,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
// A1 数据看板:筛选栏(渠道×时间范围) → 预警行动入口 → 异常警示条 → 五卡片 // A1 数据看板:筛选栏(渠道×时间范围,自定义日期 C1) → 预警行动入口 →
// → 30 日趋势折线 → 员工活跃排行表。 // 客户资产总览(B1 全库快照) → 异常警示条 → 五卡片 → 30 日趋势折线 →
// 员工活跃排行表(批2 五新列:首响/回复率/新增客户/通话时长+接通率/删粉拉黑)。
// 看板 v2(2026-09-07):渠道默认「全部」=全平台口径——卡片合计+微信/WA // 看板 v2(2026-09-07):渠道默认「全部」=全平台口径——卡片合计+微信/WA
// 拆分小字,趋势双线(微信蓝/WA 绿)+legend,行动卡跳转带 query 预选目标页。 // 拆分小字,趋势双线(微信蓝/WA 绿)+legend,行动卡跳转带 query 预选目标页。
// 数据来自 /analytics/overview + /analytics/health(数据截至角标)。 // 数据来自 /analytics/overview + /analytics/health(数据截至角标)。
@@ -130,7 +183,8 @@ import { computed, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { import {
getAnalyticsHealth, getOverview, type AnomalyStaff, type Channel, getAnalyticsHealth, getOverview, type AnomalyStaff, type Channel,
type DashboardChannel, type OverviewResp, type Scope, type CustomerAssets, type DashboardChannel, type OverviewResp, type Scope,
type StaffRankingRow,
} from '@/api/analytics' } from '@/api/analytics'
import { useChart } from '@/composables/useChart' import { useChart } from '@/composables/useChart'
import { formatTimeShort } from '@/utils/format' import { formatTimeShort } from '@/utils/format'
@@ -163,6 +217,8 @@ interface AnomalyView {
// 渠道默认「全部」(看板 v2:管理人员先看全平台,一眼看清再下钻单渠道) // 渠道默认「全部」(看板 v2:管理人员先看全平台,一眼看清再下钻单渠道)
const channel = ref<DashboardChannel>('all') const channel = ref<DashboardChannel>('all')
const scope = ref<Scope>('today') const scope = ref<Scope>('today')
// C1 自定义日期范围:[start, end] yyyymmdd 字符串(value-format)
const customRange = ref<[string, string] | null>(null)
const trendMetric = ref<'msg' | 'active_customers' | 'call_cnt'>('msg') const trendMetric = ref<'msg' | 'active_customers' | 'call_cnt'>('msg')
const data = ref<OverviewResp | null>(null) const data = ref<OverviewResp | null>(null)
const asOf = ref<number | null>(null) const asOf = ref<number | null>(null)
@@ -174,11 +230,19 @@ const { render } = useChart(chartEl)
let seq = 0 // 竞态守卫:channel/scope 快速切换时旧响应作废(项目惯例) let seq = 0 // 竞态守卫:channel/scope 快速切换时旧响应作废(项目惯例)
async function load() { async function load() {
// custom 未选完区间不请求——保留上一次数据等选择(daterange 保证 start≤end)
if (scope.value === 'custom' && (!customRange.value?.[0] || !customRange.value?.[1])) {
return
}
const my = ++seq const my = ++seq
loading.value = true loading.value = true
error.value = '' error.value = ''
try { try {
const ov = await getOverview(channel.value, scope.value) const ov = await getOverview(
channel.value, scope.value,
scope.value === 'custom' ? customRange.value?.[0] : undefined,
scope.value === 'custom' ? customRange.value?.[1] : undefined,
)
if (my !== seq) return if (my !== seq) return
data.value = ov data.value = ov
} catch (e) { } catch (e) {
@@ -227,6 +291,33 @@ const anomalyView = computed<AnomalyView | null>(() => {
const fmt = (n: number | undefined | null) => (n ?? 0).toLocaleString() const fmt = (n: number | undefined | null) => (n ?? 0).toLocaleString()
// ── B1 客户资产总览(全库存量,随 overview 载荷回来) ──
const assets = computed<CustomerAssets | null>(() => data.value?.customer_assets ?? null)
// ── 排行新列格式化 ──
// 首响:<1h 显「X分Y秒」,≥1h 显「X小时Y分」;null(无样本)显 '-'
const fmtFr = (ms: number | null) => {
if (ms == null) return '-'
const totalSec = Math.round(ms / 1000)
const mins = Math.floor(totalSec / 60)
if (mins < 60) return `${mins}分${totalSec % 60}秒`
return `${Math.floor(mins / 60)}小时${mins % 60}分`
}
// 百分比 1 位小数(回复率/接通率);null 显 '-'
const fmtPct = (v: number | null) => (v == null ? '-' : `${(v * 100).toFixed(1)}%`)
// 通话时长(秒):<1h 显「X分」,否则「X小时Y分」;0/null 显 '-'
const fmtDur = (s: number | null) => {
if (!s) return '-'
const mins = Math.round(s / 60)
if (mins < 60) return `${mins}分`
return `${Math.floor(mins / 60)}小时${mins % 60}分`
}
// 删粉/拉黑合并一列「删/拉」;都 0 显 '-'
const fmtDelBlocked = (r: StaffRankingRow) =>
r.deleted_friends || r.blocked_friends
? `${r.deleted_friends}/${r.blocked_friends}`
: '-'
// 趋势摘要:先给数字再看图(合计/日均/峰值及日期) // 趋势摘要:先给数字再看图(合计/日均/峰值及日期)
const trendVals = (): number[] => const trendVals = (): number[] =>
(data.value?.trend ?? []).map((p) => p[trendMetric.value] as number) (data.value?.trend ?? []).map((p) => p[trendMetric.value] as number)
@@ -583,6 +674,42 @@ onMounted(() => {
font-size: 22px; font-size: 22px;
font-weight: 600; font-weight: 600;
} }
/* B1 客户资产总览:轻量一行条(参照 action-card 的密度) */
.assets-card {
margin-bottom: 16px;
}
.assets-card :deep(.el-card__body) {
padding: 10px 14px;
}
.assets-row {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 4px 20px;
}
.assets-title {
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-primary);
margin-right: 4px;
}
.asset-item {
font-size: 13px;
color: var(--el-text-color-secondary);
}
.asset-item b {
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
margin-left: 2px;
}
.asset-item.small {
font-size: 12px;
}
.asset-item.muted {
font-size: 12px;
color: var(--el-text-color-placeholder);
}
.rank-bar { .rank-bar {
height: 480px; height: 480px;
margin-bottom: 8px; margin-bottom: 8px;