feat(analytics): 看板管理化补强——员工活跃排行表+预警行动入口卡片
管理者反馈「纯汇总数字看不到有用信息」:overview 载荷扩 staff_ranking(全员行:msg_total rollup 优先/空表回退 msg_daily_stats 聚合,active_* 两路径统一走 msg_daily_stats 窗内 distinct peer—— stat_daily_staff 的 active_* 是当日快照,跨日 SUM/MAX 语义皆错; call_cnt 按 user_id 落窗直查;环比同期对齐)+ actions(该回未回 2h/ 真流失 30d/敏感词 pending,复用已测试查询路径各取 total)。 前端看板:toolbar 下三张可点行动卡(0 灰显仍可点,直达处置页, 随 channel 切换刷新)+ 趋势卡下员工排行表(max-height 420 滚动, 未映射小 tag,环比色同卡片惯例 None 显 '-')。 测试 574→577 全绿(rollup 聚合/回退/行动计数三新);build 过。 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,7 @@ from app.db.models import (
|
|||||||
Message,
|
Message,
|
||||||
MsgDailyStats,
|
MsgDailyStats,
|
||||||
PhoneCall,
|
PhoneCall,
|
||||||
|
SensitiveHit,
|
||||||
Staff,
|
Staff,
|
||||||
StatDailyStaff,
|
StatDailyStaff,
|
||||||
)
|
)
|
||||||
@@ -264,6 +265,189 @@ async def anomaly(session: AsyncSession, channel: str) -> dict | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def staff_ranking(
|
||||||
|
session: AsyncSession, *, channel: str, scope: str,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""员工活跃排行(A2 核心子集,2026-09-07 管理者反馈补强)。
|
||||||
|
|
||||||
|
口径:msg_total=stat_daily_staff 聚合(空表回退 msg_daily_stats+映射,
|
||||||
|
同 _active_staff 模式);call_cnt=phone_call 按 user_id 落窗直查(3k 行
|
||||||
|
小表);环比=与 _prev_scope_days 同期对比(None=前窗无数据);名字取
|
||||||
|
staff.name,无 staff 行/未映射账号回退 user_id 原值标 unmapped。
|
||||||
|
排序 msg_total desc + user_id asc 决胜。返回全部行(员工 ~41)。
|
||||||
|
|
||||||
|
active_friends/active_rooms 两路径统一从 msg_daily_stats 算窗内
|
||||||
|
COUNT(DISTINCT peer)(映射合并到员工后,同 recompute_days 模式):
|
||||||
|
stat_daily_staff 的 active_* 是**当日快照**,跨日 SUM 会重复计数、
|
||||||
|
MAX 只是峰值,皆非「窗内活跃去重数」——rollup 只供 msg_total,
|
||||||
|
避免两路径语义分叉。
|
||||||
|
"""
|
||||||
|
days = scope_day_keys(scope, today_key())
|
||||||
|
amap = await account_map(session, channel)
|
||||||
|
|
||||||
|
# 活跃客户/群 + 回退 msg_total:落窗拉行,Python 侧映射后按 user 去重
|
||||||
|
# (COUNT(DISTINCT peer) 必须在「账号合并到员工」之后——同人多账号
|
||||||
|
# 跨账号去重,写 SQL 反而绕且踩 only_full_group_by)
|
||||||
|
rows = (
|
||||||
|
await session.execute(
|
||||||
|
sa.select(
|
||||||
|
MsgDailyStats.owner_account_id,
|
||||||
|
MsgDailyStats.peer_kind,
|
||||||
|
MsgDailyStats.peer_id,
|
||||||
|
MsgDailyStats.send_cnt,
|
||||||
|
MsgDailyStats.recv_cnt,
|
||||||
|
).where(
|
||||||
|
MsgDailyStats.channel == channel, MsgDailyStats.day.in_(days)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
act: dict[str, dict[str, set]] = {}
|
||||||
|
msg_fallback: dict[str, int] = {}
|
||||||
|
for owner, kind, peer, s, r in rows:
|
||||||
|
uid = amap.get(owner, owner)
|
||||||
|
a = act.setdefault(uid, {"friends": set(), "rooms": set()})
|
||||||
|
if kind == "friend": # 显式分支:未来新 kind 跳过,不误入 rooms
|
||||||
|
a["friends"].add(peer)
|
||||||
|
elif kind == "room":
|
||||||
|
a["rooms"].add(peer)
|
||||||
|
msg_fallback[uid] = msg_fallback.get(uid, 0) + int(s or 0) + int(r or 0)
|
||||||
|
|
||||||
|
use_rollup = await _staff_rows(session)
|
||||||
|
if use_rollup:
|
||||||
|
rollup = (
|
||||||
|
await session.execute(
|
||||||
|
sa.select(
|
||||||
|
StatDailyStaff.user_id,
|
||||||
|
sa.func.coalesce(
|
||||||
|
sa.func.sum(
|
||||||
|
StatDailyStaff.msg_sent + StatDailyStaff.msg_recv
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
StatDailyStaff.channel == channel,
|
||||||
|
StatDailyStaff.day.in_(days),
|
||||||
|
)
|
||||||
|
.group_by(StatDailyStaff.user_id)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
msg_total = {uid: int(v) for uid, v in rollup}
|
||||||
|
else:
|
||||||
|
msg_total = msg_fallback
|
||||||
|
|
||||||
|
# 通话:按 user_id 落窗直查(窗口换算同 _call_count 写法)
|
||||||
|
calls: dict[str, int] = {}
|
||||||
|
if days:
|
||||||
|
lo, _ = day_bounds_ms(days[0])
|
||||||
|
hi_start, _ = day_bounds_ms(days[-1])
|
||||||
|
call_rows = (
|
||||||
|
await session.execute(
|
||||||
|
sa.select(PhoneCall.user_id, sa.func.count())
|
||||||
|
.where(
|
||||||
|
PhoneCall.user_id.is_not(None),
|
||||||
|
PhoneCall.start_call_time.is_not(None),
|
||||||
|
PhoneCall.start_call_time >= lo,
|
||||||
|
PhoneCall.start_call_time < hi_start + 86_400_000,
|
||||||
|
)
|
||||||
|
.group_by(PhoneCall.user_id)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
calls = {uid: int(v) for uid, v in call_rows}
|
||||||
|
|
||||||
|
# 环比:同期对齐前窗(仅周/月;今日 vs 昨日全天会误导,与 overview 同口径)
|
||||||
|
prev_total: dict[str, int] = {}
|
||||||
|
if scope in ("week", "month"):
|
||||||
|
prev_days = _prev_scope_days(scope, days)
|
||||||
|
if use_rollup:
|
||||||
|
prev_rows = (
|
||||||
|
await session.execute(
|
||||||
|
sa.select(
|
||||||
|
StatDailyStaff.user_id,
|
||||||
|
sa.func.coalesce(
|
||||||
|
sa.func.sum(
|
||||||
|
StatDailyStaff.msg_sent + StatDailyStaff.msg_recv
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
StatDailyStaff.channel == channel,
|
||||||
|
StatDailyStaff.day.in_(prev_days),
|
||||||
|
)
|
||||||
|
.group_by(StatDailyStaff.user_id)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
prev_total = {uid: int(v) for uid, v in prev_rows}
|
||||||
|
else:
|
||||||
|
prev_rows = (
|
||||||
|
await session.execute(
|
||||||
|
sa.select(
|
||||||
|
MsgDailyStats.owner_account_id,
|
||||||
|
sa.func.coalesce(
|
||||||
|
sa.func.sum(
|
||||||
|
MsgDailyStats.send_cnt + MsgDailyStats.recv_cnt
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
MsgDailyStats.channel == channel,
|
||||||
|
MsgDailyStats.day.in_(prev_days),
|
||||||
|
)
|
||||||
|
.group_by(MsgDailyStats.owner_account_id)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
for owner, v in prev_rows:
|
||||||
|
uid = amap.get(owner, owner) # 同人多账号累加,不覆盖
|
||||||
|
prev_total[uid] = prev_total.get(uid, 0) + int(v)
|
||||||
|
|
||||||
|
# 名字/未映射:staff 几十行全拉;映射值集=在册员工 user_id 全集
|
||||||
|
staff_names = {
|
||||||
|
uid: name
|
||||||
|
for uid, name in (
|
||||||
|
await session.execute(sa.select(Staff.user_id, Staff.name))
|
||||||
|
).all()
|
||||||
|
}
|
||||||
|
mapped_uids = set(amap.values())
|
||||||
|
|
||||||
|
out = []
|
||||||
|
for uid in sorted(set(msg_total) | set(act) | set(calls),
|
||||||
|
key=lambda u: (-msg_total.get(u, 0), u)):
|
||||||
|
cur, prev = msg_total.get(uid, 0), prev_total.get(uid, 0)
|
||||||
|
out.append({
|
||||||
|
"user_id": uid,
|
||||||
|
"name": staff_names.get(uid) or uid,
|
||||||
|
"unmapped": uid not in staff_names and uid not in mapped_uids,
|
||||||
|
"msg_total": cur,
|
||||||
|
"active_friends": len(act[uid]["friends"]) if uid in act else 0,
|
||||||
|
"active_rooms": len(act[uid]["rooms"]) if uid in act else 0,
|
||||||
|
"call_cnt": calls.get(uid, 0),
|
||||||
|
"msg_delta": round((cur - prev) / prev, 3) if cur and prev else None,
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def action_counts(session: AsyncSession, *, channel: str) -> dict:
|
||||||
|
"""预警行动入口计数(看板顶部卡片):复用已测试的查询路径,各取 total。"""
|
||||||
|
un = await unreplied(session, channel=channel, hours=2, page=1, page_size=1)
|
||||||
|
ch = await churn(session, channel=channel, kind="real", tier=30, min_msgs=5,
|
||||||
|
page=1, page_size=1)
|
||||||
|
pending = (
|
||||||
|
await session.execute(
|
||||||
|
sa.select(sa.func.count()).select_from(SensitiveHit).where(
|
||||||
|
SensitiveHit.channel == channel,
|
||||||
|
SensitiveHit.disposition == "pending",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
return {
|
||||||
|
"unreplied_2h": int(un["total"]),
|
||||||
|
"churn_real_30d": int(ch["total"]),
|
||||||
|
"sensitive_pending": int(pending),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def overview(
|
async def overview(
|
||||||
session: AsyncSession, *, channel: str, scope: str, trend_days: int = 30
|
session: AsyncSession, *, channel: str, scope: str, trend_days: int = 30
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
@@ -341,6 +525,8 @@ async def overview(
|
|||||||
"cards": cards,
|
"cards": cards,
|
||||||
"trend": trend,
|
"trend": trend,
|
||||||
"anomaly": await anomaly(session, channel),
|
"anomaly": await anomaly(session, channel),
|
||||||
|
"staff_ranking": await staff_ranking(session, channel=channel, scope=scope),
|
||||||
|
"actions": await action_counts(session, channel=channel),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
"""A1 总览指标:卡片/首沟通口径/环比同期对齐(仅周月)/异常警示。"""
|
"""A1 总览指标:卡片/首沟通口径/环比同期对齐(仅周月)/异常警示。"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.analytics import metrics
|
from app.analytics import metrics
|
||||||
from app.db.models import (
|
from app.db.models import (
|
||||||
|
Customer,
|
||||||
|
Message,
|
||||||
MsgDailyStats,
|
MsgDailyStats,
|
||||||
PhoneCall,
|
PhoneCall,
|
||||||
|
SensitiveHit,
|
||||||
|
SensitiveWord,
|
||||||
|
Staff,
|
||||||
StatDailyStaff,
|
StatDailyStaff,
|
||||||
WechatAccount,
|
WechatAccount,
|
||||||
)
|
)
|
||||||
@@ -204,3 +211,136 @@ async def test_anomaly_new_staff_detail_and_sort(make_client, monkeypatch):
|
|||||||
assert "new" not in staff["u1"] # 老员工不带标记
|
assert "new" not in staff["u1"] # 老员工不带标记
|
||||||
|
|
||||||
await make_client(seed)
|
await make_client(seed)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_staff_ranking(make_client, monkeypatch):
|
||||||
|
"""员工活跃排行:rollup 供 msg_total;active_* 统一走 msg_daily_stats 去重。
|
||||||
|
|
||||||
|
_TODAY=20260904 周五 → 本周 [0831..0904],上周 [0824..0828]。
|
||||||
|
rollup 的 active_friends 故意 seed 999——若实现误用 rollup 聚合(跨日
|
||||||
|
SUM/MAX 皆错)会立刻暴露;正确值=窗内 distinct peer(u1: f1+f2)。
|
||||||
|
"""
|
||||||
|
monkeypatch.setattr(metrics, "today_key", lambda: _TODAY)
|
||||||
|
week = [20260831, 20260901, 20260902, 20260903, 20260904]
|
||||||
|
prev_week = [20260824, 20260825, 20260826, 20260827, 20260828]
|
||||||
|
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 本周 100(5×20),u2 50(5×10);active_* 999=诱饵(不得被采用)
|
||||||
|
for d in week:
|
||||||
|
session.add(StatDailyStaff(day=d, channel="wechat", user_id="u1",
|
||||||
|
msg_sent=20, msg_recv=0,
|
||||||
|
active_friends=999, active_rooms=999))
|
||||||
|
session.add(StatDailyStaff(day=d, channel="wechat", user_id="u2",
|
||||||
|
msg_sent=10, msg_recv=0,
|
||||||
|
active_friends=1, active_rooms=0))
|
||||||
|
# u3 仅存在于 rollup(无账号映射/无 staff 行)→ 未映射,名字回退 user_id
|
||||||
|
session.add(StatDailyStaff(day=20260904, channel="wechat", user_id="u3",
|
||||||
|
msg_sent=10, msg_recv=0,
|
||||||
|
active_friends=0, active_rooms=0))
|
||||||
|
# 上周 rollup:u1 80(5×16) → msg_delta=(100-80)/80=0.25;u2 无前窗→None
|
||||||
|
for d in prev_week:
|
||||||
|
session.add(StatDailyStaff(day=d, channel="wechat", user_id="u1",
|
||||||
|
msg_sent=16, msg_recv=0,
|
||||||
|
active_friends=1, active_rooms=0))
|
||||||
|
# 活跃指标真源:wx1→u1 两 friend(f1/f2 不同日)+一 room;wx2→u2 一 friend
|
||||||
|
for day, kind, peer, owner in [
|
||||||
|
(20260831, "friend", "f1", "wx1"), (20260904, "friend", "f2", "wx1"),
|
||||||
|
(20260902, "room", "r1", "wx1"), (20260903, "friend", "f1", "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 一通落窗(1788500000000=2026-09-04 当日);u2 的在 2025 年不计
|
||||||
|
session.add(PhoneCall(call_id="cr1", channel="voice_call", user_id="u1",
|
||||||
|
start_call_time=1788500000000, call_duration=60))
|
||||||
|
session.add(PhoneCall(call_id="cr2", channel="voice_call", user_id="u2",
|
||||||
|
start_call_time=1756950000000, call_duration=60))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
out = await metrics.staff_ranking(session, channel="wechat", scope="week")
|
||||||
|
assert [r["user_id"] for r in out] == ["u1", "u2", "u3"] # msg_total 降序
|
||||||
|
r1, r2, r3 = out
|
||||||
|
assert r1["name"] == "张三" and r1["unmapped"] is False
|
||||||
|
assert r1["msg_total"] == 100 # rollup 聚合
|
||||||
|
assert r1["active_friends"] == 2 # distinct peer,非 rollup 的 999
|
||||||
|
assert r1["active_rooms"] == 1
|
||||||
|
assert r1["call_cnt"] == 1
|
||||||
|
assert r1["msg_delta"] == 0.25 # (100-80)/80
|
||||||
|
assert r2["msg_total"] == 50 and r2["active_friends"] == 1
|
||||||
|
assert r2["active_rooms"] == 0 and r2["call_cnt"] == 0 # 窗外通话不计
|
||||||
|
assert r2["msg_delta"] is None # 前窗无数据
|
||||||
|
assert r3["name"] == "u3" and r3["unmapped"] is True
|
||||||
|
assert r3["msg_total"] == 10 and r3["active_friends"] == 0
|
||||||
|
|
||||||
|
await make_client(seed)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_staff_ranking_fallback_when_rollup_empty(make_client, monkeypatch):
|
||||||
|
"""stat_daily_staff 空表 → msg_total 回退 msg_daily_stats 聚合(部署即用)。"""
|
||||||
|
monkeypatch.setattr(metrics, "today_key", lambda: _TODAY)
|
||||||
|
async def seed(session):
|
||||||
|
session.add(WechatAccount(wx_id="wx1", user_id="u1", user_name="张三"))
|
||||||
|
session.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
|
||||||
|
peer_kind="friend", peer_id="t1", day=20260904,
|
||||||
|
send_cnt=10, recv_cnt=5, last_ts=1788500000000,
|
||||||
|
last_is_send=True))
|
||||||
|
# wx9 无账号行 → 映射不上按 owner 原值计入并标未映射
|
||||||
|
session.add(MsgDailyStats(channel="wechat", owner_account_id="wx9",
|
||||||
|
peer_kind="friend", peer_id="t2", day=20260904,
|
||||||
|
send_cnt=2, recv_cnt=0, last_ts=1788500000000,
|
||||||
|
last_is_send=True))
|
||||||
|
await session.commit()
|
||||||
|
out = await metrics.staff_ranking(session, channel="wechat", scope="today")
|
||||||
|
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]["unmapped"] is False
|
||||||
|
assert out[0]["msg_delta"] is None # 今日 scope 无环比
|
||||||
|
assert out[1]["msg_total"] == 2 and out[1]["unmapped"] is True
|
||||||
|
assert out[1]["name"] == "wx9"
|
||||||
|
|
||||||
|
await make_client(seed)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_action_counts(make_client, monkeypatch):
|
||||||
|
"""预警行动入口:unreplied 走真实路径,churn/sensitive 各计 pending/真流失。"""
|
||||||
|
monkeypatch.setattr(metrics, "_now_ms", lambda: 1756950000000)
|
||||||
|
async def seed(session):
|
||||||
|
now = 1756950000000
|
||||||
|
h3 = 3 * 3600 * 1000
|
||||||
|
# 该回未回 1 条:客户尾向 3h 前 + 有效 text 消息
|
||||||
|
session.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
|
||||||
|
peer_kind="friend", peer_id="t1", day=20260904,
|
||||||
|
send_cnt=5, recv_cnt=3, last_ts=now - h3,
|
||||||
|
last_is_send=False))
|
||||||
|
session.add(Message(msg_id="t1m", channel="wechat", owner_account_id="wx1",
|
||||||
|
talker_id="t1", timestamp=now - h3, is_send=False,
|
||||||
|
type="text", text="在吗,方案发我一下",
|
||||||
|
fetched_at=dt.datetime(2026, 9, 4, 12, 0)))
|
||||||
|
# 真流失 1 条:沉默 45 天 + 历史 ≥5
|
||||||
|
day_ms = 86_400_000
|
||||||
|
session.add(Customer(friend_id="c_real", channel="wechat",
|
||||||
|
owner_account_id="wx1", user_id="u1",
|
||||||
|
last_chat_time=now - 45 * day_ms, delete_flag=0))
|
||||||
|
session.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
|
||||||
|
peer_kind="friend", peer_id="c_real", day=20260601,
|
||||||
|
send_cnt=6, recv_cnt=0, last_ts=now - 50 * day_ms,
|
||||||
|
last_is_send=True))
|
||||||
|
# 敏感词:1 pending + 1 confirmed(只计 pending)
|
||||||
|
w = SensitiveWord(word="测试词")
|
||||||
|
session.add(w)
|
||||||
|
await session.flush()
|
||||||
|
session.add(SensitiveHit(word_id=w.id, msg_id="m1", channel="wechat",
|
||||||
|
disposition="pending"))
|
||||||
|
session.add(SensitiveHit(word_id=w.id, msg_id="m2", channel="wechat",
|
||||||
|
disposition="confirmed"))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
out = await metrics.action_counts(session, channel="wechat")
|
||||||
|
assert out == {"unreplied_2h": 1, "churn_real_30d": 1,
|
||||||
|
"sensitive_pending": 1}
|
||||||
|
|
||||||
|
await make_client(seed)
|
||||||
|
|||||||
@@ -19,6 +19,14 @@ export interface OverviewCards {
|
|||||||
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,
|
||||||
|
// msg_delta 仅周/月且当前/前窗均非 0 才有值
|
||||||
|
export interface StaffRankingRow {
|
||||||
|
user_id: string; name: string; unmapped: boolean
|
||||||
|
msg_total: number; active_friends: number; active_rooms: number
|
||||||
|
call_cnt: number; msg_delta: number | null
|
||||||
|
}
|
||||||
|
export interface ActionCounts { unreplied_2h: number; churn_real_30d: number; sensitive_pending: number }
|
||||||
export interface OverviewResp {
|
export interface OverviewResp {
|
||||||
scope: Scope
|
scope: Scope
|
||||||
channel: Channel
|
channel: Channel
|
||||||
@@ -26,6 +34,8 @@ export interface OverviewResp {
|
|||||||
cards: OverviewCards
|
cards: OverviewCards
|
||||||
trend: TrendPoint[]
|
trend: TrendPoint[]
|
||||||
anomaly: Anomaly | null
|
anomaly: Anomaly | null
|
||||||
|
staff_ranking: StaffRankingRow[]
|
||||||
|
actions: ActionCounts
|
||||||
}
|
}
|
||||||
export interface HealthResp { data_as_of: number | null; entities: { table: string; last_seen: string | null }[] }
|
export interface HealthResp { data_as_of: number | null; entities: { table: string; last_seen: string | null }[] }
|
||||||
export interface ChurnRow {
|
export interface ChurnRow {
|
||||||
|
|||||||
@@ -13,6 +13,20 @@
|
|||||||
<span v-if="asOfText" class="as-of">数据截至 {{ asOfText }}</span>
|
<span v-if="asOfText" class="as-of">数据截至 {{ asOfText }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 预警行动入口:三卡直达处置页(2026-09-07 管理者反馈「纯汇总数字看不到
|
||||||
|
行动入口」);计数随 channel 切换自然跟随 overview 载荷,0 灰显仍可点 -->
|
||||||
|
<div class="action-row">
|
||||||
|
<el-card
|
||||||
|
v-for="a in actionCards" :key="a.to" shadow="never"
|
||||||
|
class="action-card" :class="{ zero: a.count === 0 }"
|
||||||
|
@click="router.push(a.to)"
|
||||||
|
>
|
||||||
|
<span class="action-icon">{{ a.icon }}</span>
|
||||||
|
<span class="action-label">{{ a.label }}</span>
|
||||||
|
<span class="action-count">{{ a.count }}</span>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
<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">
|
||||||
@@ -49,19 +63,62 @@
|
|||||||
</template>
|
</template>
|
||||||
<div ref="chartEl" class="trend-chart"></div>
|
<div ref="chartEl" class="trend-chart"></div>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 员工活跃排行:全行展示(~41 人,420px 内滚),按消息量降序(后端排好) -->
|
||||||
|
<el-card v-loading="loading" shadow="never" class="rank-card">
|
||||||
|
<template #header>
|
||||||
|
<span>员工活跃排行</span>
|
||||||
|
<span class="rank-scope">按所选时间范围</span>
|
||||||
|
</template>
|
||||||
|
<el-table :data="data?.staff_ranking ?? []" max-height="420" size="small">
|
||||||
|
<el-table-column type="index" label="#" width="48" />
|
||||||
|
<el-table-column label="员工" min-width="160">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.name }}
|
||||||
|
<el-tag v-if="row.unmapped" size="small" type="info" class="unmapped-tag">未映射</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="消息量" align="right">
|
||||||
|
<template #default="{ row }">{{ fmt(row.msg_total) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="活跃客户" align="right">
|
||||||
|
<template #default="{ row }">{{ fmt(row.active_friends) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="活跃群" align="right">
|
||||||
|
<template #default="{ row }">{{ fmt(row.active_rooms) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="通话量" align="right">
|
||||||
|
<template #default="{ row }">{{ fmt(row.call_cnt) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="环比" align="right" width="90">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span
|
||||||
|
v-if="row.msg_delta !== null"
|
||||||
|
class="rank-delta"
|
||||||
|
:class="{ down: row.msg_delta < 0 }"
|
||||||
|
>{{ (row.msg_delta * 100).toFixed(1) }}%</span>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// A1 数据看板:筛选栏(渠道×时间范围) → 异常警示条 → 五卡片 → 30 日趋势折线。
|
// A1 数据看板:筛选栏(渠道×时间范围) → 预警行动入口 → 异常警示条 → 五卡片
|
||||||
|
// → 30 日趋势折线 → 员工活跃排行表。
|
||||||
// 数据来自 /analytics/overview + /analytics/health(数据截至角标)。
|
// 数据来自 /analytics/overview + /analytics/health(数据截至角标)。
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import {
|
import {
|
||||||
getAnalyticsHealth, getOverview, type AnomalyStaff, type Channel, type OverviewResp, type Scope,
|
getAnalyticsHealth, getOverview, type AnomalyStaff, type Channel, type OverviewResp, type Scope,
|
||||||
} 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'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
interface CardItem {
|
interface CardItem {
|
||||||
label: string
|
label: string
|
||||||
value: string
|
value: string
|
||||||
@@ -151,6 +208,16 @@ const anomalyView = computed<AnomalyView | null>(() => {
|
|||||||
|
|
||||||
const fmt = (n: number | undefined | null) => (n ?? 0).toLocaleString()
|
const fmt = (n: number | undefined | null) => (n ?? 0).toLocaleString()
|
||||||
|
|
||||||
|
// 预警行动入口:计数来自 overview 载荷 actions(0 灰显仍可点,直达处置页)
|
||||||
|
const actionCards = computed(() => {
|
||||||
|
const a = data.value?.actions
|
||||||
|
return [
|
||||||
|
{ icon: '🔴', label: '该回未回 2h', count: a?.unreplied_2h ?? 0, to: '/analytics/unreplied' },
|
||||||
|
{ icon: '🟡', label: '流失预警 30天+', count: a?.churn_real_30d ?? 0, to: '/analytics/churn' },
|
||||||
|
{ icon: '⚠', label: '敏感词待处置', count: a?.sensitive_pending ?? 0, to: '/analytics/sensitive' },
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
const cardList = computed<CardItem[]>(() => {
|
const cardList = computed<CardItem[]>(() => {
|
||||||
const c = data.value?.cards
|
const c = data.value?.cards
|
||||||
if (!c) return []
|
if (!c) return []
|
||||||
@@ -260,4 +327,48 @@ onMounted(() => {
|
|||||||
.trend-chart {
|
.trend-chart {
|
||||||
height: 320px;
|
height: 320px;
|
||||||
}
|
}
|
||||||
|
.action-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.action-card {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.action-card.zero {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
.action-card :deep(.el-card__body) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
}
|
||||||
|
.action-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
.action-count {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.rank-card {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
.rank-scope {
|
||||||
|
margin-left: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
.unmapped-tag {
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
.rank-delta {
|
||||||
|
color: var(--el-color-success);
|
||||||
|
}
|
||||||
|
.rank-delta.down {
|
||||||
|
color: var(--el-color-danger);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user