feat(analytics): 看板v2全平台口径——默认双渠道合计/拆分小字/双线趋势/跳转带query预选
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -84,9 +84,22 @@ def _prev_scope_days(scope: str, days: list[int]) -> list[int]:
|
||||
raise ValueError(f"bad scope: {scope!r}")
|
||||
|
||||
|
||||
async def _account_map(session: AsyncSession, channel: str | None) -> dict[str, str]:
|
||||
"""channel=None(全平台)→ 两渠道账号映射合并;owner 账号格式跨渠道不碰撞。"""
|
||||
if channel is not None:
|
||||
return await account_map(session, channel)
|
||||
merged = await account_map(session, "wechat")
|
||||
merged.update(await account_map(session, "whatsapp"))
|
||||
return merged
|
||||
|
||||
|
||||
async def _day_rows(
|
||||
session: AsyncSession, channel: str, days: Iterable[int]
|
||||
session: AsyncSession, channel: str | None, days: Iterable[int]
|
||||
) -> list[Any]:
|
||||
# channel=None(全平台)→ 不加渠道条件,两渠道行合并(peer_id 跨渠道不碰撞)
|
||||
conds = [MsgDailyStats.day.in_(list(days))]
|
||||
if channel is not None:
|
||||
conds.append(MsgDailyStats.channel == channel)
|
||||
return (
|
||||
await session.execute(
|
||||
sa.select(
|
||||
@@ -95,9 +108,7 @@ async def _day_rows(
|
||||
MsgDailyStats.peer_id,
|
||||
MsgDailyStats.send_cnt,
|
||||
MsgDailyStats.recv_cnt,
|
||||
).where(
|
||||
MsgDailyStats.channel == channel, MsgDailyStats.day.in_(list(days))
|
||||
)
|
||||
).where(*conds)
|
||||
)
|
||||
).all()
|
||||
|
||||
@@ -125,16 +136,20 @@ async def _call_count(session: AsyncSession, days: list[int]) -> int:
|
||||
|
||||
|
||||
async def _first_contact(
|
||||
session: AsyncSession, channel: str, days: list[int]
|
||||
session: AsyncSession, channel: str | None, days: list[int]
|
||||
) -> int:
|
||||
"""首次沟通客户数:peer 按渠道全史 MIN(day) 落窗(跨员工归一,不跨渠道)。"""
|
||||
"""首次沟通客户数:peer 按渠道全史 MIN(day) 落窗(跨员工归一,不跨渠道)。
|
||||
|
||||
channel=None(全平台)按 peer 全史 MIN(day)(peer 跨渠道不碰撞,直接
|
||||
跨渠道合并首现日)。
|
||||
"""
|
||||
conds = [MsgDailyStats.peer_kind == "friend"]
|
||||
if channel is not None:
|
||||
conds.append(MsgDailyStats.channel == channel)
|
||||
rows = (
|
||||
await session.execute(
|
||||
sa.select(MsgDailyStats.peer_id, sa.func.min(MsgDailyStats.day))
|
||||
.where(
|
||||
MsgDailyStats.channel == channel,
|
||||
MsgDailyStats.peer_kind == "friend",
|
||||
)
|
||||
.where(*conds)
|
||||
.group_by(MsgDailyStats.peer_id)
|
||||
)
|
||||
).all()
|
||||
@@ -143,10 +158,13 @@ async def _first_contact(
|
||||
|
||||
|
||||
async def _active_staff(
|
||||
session: AsyncSession, channel: str, days: list[int]
|
||||
session: AsyncSession, channel: str | None, days: list[int]
|
||||
) -> tuple[int, int]:
|
||||
"""活跃员工 (distinct 数, 未映射账号数);stat_daily_staff 空表回退 msg_daily_stats+映射。
|
||||
|
||||
channel=None(全平台):distinct user_id 跨渠道去重(同人两渠道各活跃
|
||||
算 1);映射取两渠道合并——看板 all 口径「跨渠道重复计数不拆」即此。
|
||||
|
||||
未映射口径(spec §8「未映射账号按账号计并标注」):
|
||||
- rollup:当窗 distinct user_id 不在 account_map 值集(=在册员工 user_id
|
||||
全集)——recompute 以 amap.get(owner, owner) 落表,映射不上的 owner
|
||||
@@ -155,24 +173,25 @@ async def _active_staff(
|
||||
活跃数)的计未映射——与 rollup 口径一致(映射不上的账号原样出现)。
|
||||
"""
|
||||
if await _staff_rows(session):
|
||||
conds = [StatDailyStaff.day.in_(days)]
|
||||
if channel is not None:
|
||||
conds.append(StatDailyStaff.channel == channel)
|
||||
uids = set(
|
||||
(
|
||||
await session.execute(
|
||||
sa.select(StatDailyStaff.user_id).where(
|
||||
StatDailyStaff.channel == channel,
|
||||
StatDailyStaff.day.in_(days),
|
||||
)
|
||||
sa.select(StatDailyStaff.user_id).where(*conds)
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
mapped = set((await account_map(session, channel)).values())
|
||||
mapped = set((await _account_map(session, channel)).values())
|
||||
return len(uids), len(uids - mapped)
|
||||
amap = await account_map(session, channel)
|
||||
amap = await _account_map(session, channel)
|
||||
conds = [MsgDailyStats.day.in_(days)]
|
||||
if channel is not None:
|
||||
conds.append(MsgDailyStats.channel == channel)
|
||||
owners = {o for (o,) in (
|
||||
await session.execute(
|
||||
sa.select(MsgDailyStats.owner_account_id).where(
|
||||
MsgDailyStats.channel == channel, MsgDailyStats.day.in_(days)
|
||||
)
|
||||
sa.select(MsgDailyStats.owner_account_id).where(*conds)
|
||||
)
|
||||
).all()}
|
||||
unmapped = sum(1 for o in owners if o not in amap)
|
||||
@@ -183,8 +202,12 @@ def anomaly_threshold() -> float:
|
||||
return 0.5 # 昨日 vs 前 7 日均值偏离阈值,v1 固定
|
||||
|
||||
|
||||
async def anomaly(session: AsyncSession, channel: str) -> dict | None:
|
||||
"""E0-lite 异常警示:昨日总量 vs 前 7 日均值偏离>50%,明细到员工。"""
|
||||
async def anomaly(session: AsyncSession, channel: str | None) -> dict | None:
|
||||
"""E0-lite 异常警示:昨日总量 vs 前 7 日均值偏离>50%,明细到员工。
|
||||
|
||||
channel=None(全平台):rollup/回退查询去渠道条件后,同 user_id 跨渠道
|
||||
行在逐日累加阶段自然合并(user 级总量/明细跨渠道口径)。
|
||||
"""
|
||||
today = today_key()
|
||||
yesterday = _day_date(today) - dt.timedelta(days=1)
|
||||
|
||||
@@ -193,19 +216,22 @@ async def anomaly(session: AsyncSession, channel: str) -> dict | None:
|
||||
|
||||
use_rollup = await _staff_rows(session)
|
||||
if use_rollup:
|
||||
conds = [StatDailyStaff.day.in_([yday] + prev7)]
|
||||
if channel is not None:
|
||||
conds.append(StatDailyStaff.channel == channel)
|
||||
rows = (
|
||||
await session.execute(
|
||||
sa.select(
|
||||
StatDailyStaff.day, StatDailyStaff.user_id,
|
||||
sa.func.coalesce(StatDailyStaff.msg_sent + StatDailyStaff.msg_recv, 0),
|
||||
).where(
|
||||
StatDailyStaff.channel == channel,
|
||||
StatDailyStaff.day.in_([yday] + prev7),
|
||||
)
|
||||
).where(*conds)
|
||||
)
|
||||
).all()
|
||||
else:
|
||||
amap = await account_map(session, channel)
|
||||
amap = await _account_map(session, channel)
|
||||
conds = [MsgDailyStats.day.in_([yday] + prev7)]
|
||||
if channel is not None:
|
||||
conds.append(MsgDailyStats.channel == channel)
|
||||
raw = (
|
||||
await session.execute(
|
||||
sa.select(
|
||||
@@ -214,10 +240,7 @@ async def anomaly(session: AsyncSession, channel: str) -> dict | None:
|
||||
sa.func.sum(MsgDailyStats.send_cnt + MsgDailyStats.recv_cnt), 0
|
||||
),
|
||||
)
|
||||
.where(
|
||||
MsgDailyStats.channel == channel,
|
||||
MsgDailyStats.day.in_([yday] + prev7),
|
||||
)
|
||||
.where(*conds)
|
||||
.group_by(MsgDailyStats.day, MsgDailyStats.owner_account_id)
|
||||
)
|
||||
).all()
|
||||
@@ -266,7 +289,7 @@ async def anomaly(session: AsyncSession, channel: str) -> dict | None:
|
||||
|
||||
|
||||
async def staff_ranking(
|
||||
session: AsyncSession, *, channel: str, scope: str,
|
||||
session: AsyncSession, *, channel: str | None, scope: str,
|
||||
) -> list[dict]:
|
||||
"""员工活跃排行(A2 核心子集,2026-09-07 管理者反馈补强)。
|
||||
|
||||
@@ -276,6 +299,9 @@ async def staff_ranking(
|
||||
staff.name,无 staff 行/未映射账号回退 user_id 原值标 unmapped。
|
||||
排序 msg_total desc + user_id asc 决胜。返回全部行(员工 ~41)。
|
||||
|
||||
channel=None(全平台):查询去渠道条件+映射合并,同 user 跨渠道量
|
||||
自然合到一行(员工排行本就是 user 级)。
|
||||
|
||||
active_friends/active_rooms 两路径统一从 msg_daily_stats 算窗内
|
||||
COUNT(DISTINCT peer)(映射合并到员工后,同 recompute_days 模式):
|
||||
stat_daily_staff 的 active_* 是**当日快照**,跨日 SUM 会重复计数、
|
||||
@@ -283,11 +309,14 @@ async def staff_ranking(
|
||||
避免两路径语义分叉。
|
||||
"""
|
||||
days = scope_day_keys(scope, today_key())
|
||||
amap = await account_map(session, channel)
|
||||
amap = await _account_map(session, channel)
|
||||
|
||||
# 活跃客户/群 + 回退 msg_total:落窗拉行,Python 侧映射后按 user 去重
|
||||
# (COUNT(DISTINCT peer) 必须在「账号合并到员工」之后——同人多账号
|
||||
# 跨账号去重,写 SQL 反而绕且踩 only_full_group_by)
|
||||
conds = [MsgDailyStats.day.in_(days)]
|
||||
if channel is not None:
|
||||
conds.append(MsgDailyStats.channel == channel)
|
||||
rows = (
|
||||
await session.execute(
|
||||
sa.select(
|
||||
@@ -296,9 +325,7 @@ async def staff_ranking(
|
||||
MsgDailyStats.peer_id,
|
||||
MsgDailyStats.send_cnt,
|
||||
MsgDailyStats.recv_cnt,
|
||||
).where(
|
||||
MsgDailyStats.channel == channel, MsgDailyStats.day.in_(days)
|
||||
)
|
||||
).where(*conds)
|
||||
)
|
||||
).all()
|
||||
act: dict[str, dict[str, set]] = {}
|
||||
@@ -314,6 +341,9 @@ async def staff_ranking(
|
||||
|
||||
use_rollup = await _staff_rows(session)
|
||||
if use_rollup:
|
||||
conds = [StatDailyStaff.day.in_(days)]
|
||||
if channel is not None:
|
||||
conds.append(StatDailyStaff.channel == channel)
|
||||
rollup = (
|
||||
await session.execute(
|
||||
sa.select(
|
||||
@@ -325,10 +355,7 @@ async def staff_ranking(
|
||||
0,
|
||||
),
|
||||
)
|
||||
.where(
|
||||
StatDailyStaff.channel == channel,
|
||||
StatDailyStaff.day.in_(days),
|
||||
)
|
||||
.where(*conds)
|
||||
.group_by(StatDailyStaff.user_id)
|
||||
)
|
||||
).all()
|
||||
@@ -360,6 +387,9 @@ async def staff_ranking(
|
||||
if scope in ("week", "month"):
|
||||
prev_days = _prev_scope_days(scope, days)
|
||||
if use_rollup:
|
||||
conds = [StatDailyStaff.day.in_(prev_days)]
|
||||
if channel is not None:
|
||||
conds.append(StatDailyStaff.channel == channel)
|
||||
prev_rows = (
|
||||
await session.execute(
|
||||
sa.select(
|
||||
@@ -371,15 +401,15 @@ async def staff_ranking(
|
||||
0,
|
||||
),
|
||||
)
|
||||
.where(
|
||||
StatDailyStaff.channel == channel,
|
||||
StatDailyStaff.day.in_(prev_days),
|
||||
)
|
||||
.where(*conds)
|
||||
.group_by(StatDailyStaff.user_id)
|
||||
)
|
||||
).all()
|
||||
prev_total = {uid: int(v) for uid, v in prev_rows}
|
||||
else:
|
||||
conds = [MsgDailyStats.day.in_(prev_days)]
|
||||
if channel is not None:
|
||||
conds.append(MsgDailyStats.channel == channel)
|
||||
prev_rows = (
|
||||
await session.execute(
|
||||
sa.select(
|
||||
@@ -391,10 +421,7 @@ async def staff_ranking(
|
||||
0,
|
||||
),
|
||||
)
|
||||
.where(
|
||||
MsgDailyStats.channel == channel,
|
||||
MsgDailyStats.day.in_(prev_days),
|
||||
)
|
||||
.where(*conds)
|
||||
.group_by(MsgDailyStats.owner_account_id)
|
||||
)
|
||||
).all()
|
||||
@@ -428,17 +455,20 @@ async def staff_ranking(
|
||||
return out
|
||||
|
||||
|
||||
async def action_counts(session: AsyncSession, *, channel: str) -> dict:
|
||||
"""预警行动入口计数(看板顶部卡片):复用已测试的查询路径,各取 total。"""
|
||||
async def action_counts(session: AsyncSession, *, channel: str | None) -> dict:
|
||||
"""预警行动入口计数(看板顶部卡片):复用已测试的查询路径,各取 total。
|
||||
|
||||
channel=None(全平台):三计数全部去渠道过滤(含 sensitive_pending)。
|
||||
"""
|
||||
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_conds = [SensitiveHit.disposition == "pending"]
|
||||
if channel is not None:
|
||||
pending_conds.append(SensitiveHit.channel == channel)
|
||||
pending = (
|
||||
await session.execute(
|
||||
sa.select(sa.func.count()).select_from(SensitiveHit).where(
|
||||
SensitiveHit.channel == channel,
|
||||
SensitiveHit.disposition == "pending",
|
||||
)
|
||||
sa.select(sa.func.count()).select_from(SensitiveHit).where(*pending_conds)
|
||||
)
|
||||
).scalar_one()
|
||||
return {
|
||||
@@ -449,10 +479,17 @@ async def action_counts(session: AsyncSession, *, channel: str) -> dict:
|
||||
|
||||
|
||||
async def overview(
|
||||
session: AsyncSession, *, channel: str, scope: str, trend_days: int = 30
|
||||
session: AsyncSession, *, channel: str | None, scope: str, trend_days: int = 30
|
||||
) -> dict[str, Any]:
|
||||
"""A1:卡片 + 环比(仅周/月,同期对齐) + 近 N 天趋势 + 异常警示。
|
||||
|
||||
channel=None(全平台,看板 v2 默认):所有指标跨渠道合并——msg/活跃客户
|
||||
直接合计(peer_id 跨渠道不碰撞);活跃员工按 user 去重(跨渠道重复计数
|
||||
不拆);通话量本就是 call-type 无渠道语义。此时追加:
|
||||
- by_channel:微信/WhatsApp 两渠道的 msg_total/active_customers/
|
||||
first_contact 拆分(卡片下小字;活跃员工/通话量不拆,口径不成立);
|
||||
- trend_by_channel:两渠道各自趋势序列(仅 all 时出现;单渠道不加键)。
|
||||
trend 在 all 时=两渠道合计(单系列口径保持可用)。
|
||||
cards.unmapped_accounts=活跃数里映射不上的账号数(spec §8 按账号计并标注)。
|
||||
"""
|
||||
if trend_days < 1 or trend_days > 90:
|
||||
@@ -490,12 +527,6 @@ async def overview(
|
||||
while cur <= end:
|
||||
t_days.append(_date_key(cur))
|
||||
cur += dt.timedelta(days=1)
|
||||
t_rows = await _day_rows(session, channel, t_days)
|
||||
by_day: dict[int, dict] = {d: {"msg": 0, "cust": set()} for d in t_days}
|
||||
for d, kind, p, s, r in t_rows:
|
||||
by_day[d]["msg"] += int(s or 0) + int(r or 0)
|
||||
if kind == "friend":
|
||||
by_day[d]["cust"].add(p)
|
||||
# 通话趋势:phone_call 拉窗内 (start_call_time) Python 侧按日分桶(3k 行)
|
||||
lo, _ = day_bounds_ms(t_days[0])
|
||||
hi_day, _ = day_bounds_ms(t_days[-1])
|
||||
@@ -512,15 +543,25 @@ async def overview(
|
||||
for ts in calls:
|
||||
k = _date_key(dt.datetime.fromtimestamp(ts / 1000, _SH).date())
|
||||
call_by_day[k] = call_by_day.get(k, 0) + 1
|
||||
trend = [
|
||||
{"day": d, "msg": v["msg"], "active_customers": len(v["cust"]),
|
||||
"call_cnt": call_by_day.get(d, 0)}
|
||||
for d, v in by_day.items()
|
||||
]
|
||||
|
||||
return {
|
||||
def _trend_points(rows: list[Any], call_by: dict[int, int]) -> list[dict]:
|
||||
by_day: dict[int, dict] = {d: {"msg": 0, "cust": set()} for d in t_days}
|
||||
for d, kind, p, s, r in rows:
|
||||
by_day[d]["msg"] += int(s or 0) + int(r or 0)
|
||||
if kind == "friend":
|
||||
by_day[d]["cust"].add(p)
|
||||
return [
|
||||
{"day": d, "msg": v["msg"], "active_customers": len(v["cust"]),
|
||||
"call_cnt": call_by.get(d, 0)}
|
||||
for d, v in by_day.items()
|
||||
]
|
||||
|
||||
t_rows = await _day_rows(session, channel, t_days)
|
||||
trend = _trend_points(t_rows, call_by_day)
|
||||
|
||||
out: dict[str, Any] = {
|
||||
"scope": scope,
|
||||
"channel": channel,
|
||||
"channel": "all" if channel is None else channel,
|
||||
"days": days,
|
||||
"cards": cards,
|
||||
"trend": trend,
|
||||
@@ -528,6 +569,27 @@ async def overview(
|
||||
"staff_ranking": await staff_ranking(session, channel=channel, scope=scope),
|
||||
"actions": await action_counts(session, channel=channel),
|
||||
}
|
||||
if channel is None:
|
||||
# 拆分小字/双线趋势的数据源:复用同查询路径按渠道各跑一遍(小表毫秒级)。
|
||||
# 渠道序列 call_cnt 恒 0——通话是 call-type 无渠道语义不拆,前端 all
|
||||
# 模式通话量画总趋势单线。
|
||||
out["by_channel"] = {}
|
||||
out["trend_by_channel"] = {}
|
||||
for ch in ("wechat", "whatsapp"):
|
||||
ch_rows = await _day_rows(session, ch, days)
|
||||
out["by_channel"][ch] = {
|
||||
"msg_total": sum(
|
||||
int(s or 0) + int(r or 0) for _d, _k, _p, s, r in ch_rows
|
||||
),
|
||||
"active_customers": len(
|
||||
{p for _d, kind, p, _s, _r in ch_rows if kind == "friend"}
|
||||
),
|
||||
"first_contact": await _first_contact(session, ch, days),
|
||||
}
|
||||
out["trend_by_channel"][ch] = _trend_points(
|
||||
await _day_rows(session, ch, t_days), {}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _now_ms() -> int:
|
||||
@@ -542,19 +604,24 @@ def _next_tier(tier: int) -> int:
|
||||
|
||||
|
||||
async def churn(
|
||||
session: AsyncSession, *, channel: str, kind: str, tier: int,
|
||||
session: AsyncSession, *, channel: str | None, kind: str, tier: int,
|
||||
min_msgs: int, page: int, page_size: int,
|
||||
) -> dict:
|
||||
"""A3 三分法(spec §3.3)。tier ∈ {30,60,90} 分层区间 [tier, next) ,90=≥90。
|
||||
|
||||
channel=None(全平台):去渠道过滤,两渠道客户合并(peer_id 跨渠道不
|
||||
碰撞,历史消息半连接按 peer 聚合同样成立);items[].channel 保留原值
|
||||
供前端跳转分流。
|
||||
|
||||
active_days=历史活跃天数(distinct 自然日;peer 跨账号转移同日多行只计一日)。
|
||||
"""
|
||||
day_ms = 86_400_000
|
||||
now = _now_ms()
|
||||
base = [
|
||||
Customer.channel == channel,
|
||||
sa.or_(Customer.delete_flag.is_(None), Customer.delete_flag == 0),
|
||||
]
|
||||
if channel is not None:
|
||||
base.append(Customer.channel == channel)
|
||||
if kind == "real":
|
||||
lo = now - _next_tier(tier) * day_ms
|
||||
hi = now - tier * day_ms
|
||||
@@ -563,13 +630,13 @@ async def churn(
|
||||
Customer.last_chat_time > lo,
|
||||
Customer.last_chat_time <= hi]
|
||||
# 历史消息 ≥ min_msgs 放 SQL 端(分页 total 语义正确):半连接聚合
|
||||
hist_conds = [MsgDailyStats.peer_kind == "friend"]
|
||||
if channel is not None:
|
||||
hist_conds.append(MsgDailyStats.channel == channel)
|
||||
base.append(
|
||||
Customer.friend_id.in_(
|
||||
sa.select(MsgDailyStats.peer_id)
|
||||
.where(
|
||||
MsgDailyStats.channel == channel,
|
||||
MsgDailyStats.peer_kind == "friend",
|
||||
)
|
||||
.where(*hist_conds)
|
||||
.group_by(MsgDailyStats.peer_id)
|
||||
.having(
|
||||
sa.func.coalesce(
|
||||
@@ -611,6 +678,10 @@ async def churn(
|
||||
items = []
|
||||
if rows:
|
||||
ids = [c.friend_id for c in rows]
|
||||
agg_conds = [MsgDailyStats.peer_kind == "friend",
|
||||
MsgDailyStats.peer_id.in_(ids)]
|
||||
if channel is not None:
|
||||
agg_conds.append(MsgDailyStats.channel == channel)
|
||||
agg = (
|
||||
await session.execute(
|
||||
sa.select(
|
||||
@@ -620,11 +691,7 @@ async def churn(
|
||||
),
|
||||
sa.func.count(sa.distinct(MsgDailyStats.day)),
|
||||
)
|
||||
.where(
|
||||
MsgDailyStats.channel == channel,
|
||||
MsgDailyStats.peer_kind == "friend",
|
||||
MsgDailyStats.peer_id.in_(ids),
|
||||
)
|
||||
.where(*agg_conds)
|
||||
.group_by(MsgDailyStats.peer_id)
|
||||
)
|
||||
).all()
|
||||
@@ -663,10 +730,16 @@ def _norm_phone(p: str | None) -> str | None:
|
||||
|
||||
|
||||
async def unreplied(
|
||||
session: AsyncSession, *, channel: str, hours: int, page: int, page_size: int,
|
||||
session: AsyncSession, *, channel: str | None, hours: int, page: int,
|
||||
page_size: int,
|
||||
) -> dict:
|
||||
"""B2 该回未回(spec §3.4)。候选=汇总表最新日行;决赛圈点查类型/预览/通话豁免。
|
||||
|
||||
channel=None(全平台,看板 v2 计数用):候选去渠道条件,窗口 partition
|
||||
本含 channel 列——(渠道,owner,peer) 三元组各自取最新日行,跨渠道
|
||||
天然正确;决赛圈点查以 (owner,peer) 定位(peer 跨渠道不碰撞),回显
|
||||
channel 取尾部消息实值供前端跳转分流。
|
||||
|
||||
候选资格看「最新日行」(row_number 先于方向/超时过滤——员工次日已回的
|
||||
旧未回日不复活);决赛圈再点查尾部消息:须员工未回(is_send 非 True——
|
||||
stats/message 漂移时以 message 实尾为准,写钩子吞重算失败是已记录状态;
|
||||
@@ -696,6 +769,12 @@ async def unreplied(
|
||||
)
|
||||
.label("rn")
|
||||
)
|
||||
sub_conds = [
|
||||
MsgDailyStats.peer_kind == "friend",
|
||||
MsgDailyStats.last_ts.is_not(None),
|
||||
]
|
||||
if channel is not None:
|
||||
sub_conds.append(MsgDailyStats.channel == channel)
|
||||
sub = (
|
||||
sa.select(
|
||||
MsgDailyStats.owner_account_id.label("owner"),
|
||||
@@ -704,11 +783,7 @@ async def unreplied(
|
||||
MsgDailyStats.last_is_send.label("last_is_send"),
|
||||
rn,
|
||||
)
|
||||
.where(
|
||||
MsgDailyStats.channel == channel,
|
||||
MsgDailyStats.peer_kind == "friend",
|
||||
MsgDailyStats.last_ts.is_not(None),
|
||||
)
|
||||
.where(*sub_conds)
|
||||
).subquery()
|
||||
cand = (
|
||||
await session.execute(
|
||||
@@ -724,15 +799,18 @@ async def unreplied(
|
||||
|
||||
async def _finalist(owner: str, peer: str, ts: int) -> dict | None:
|
||||
# 决赛圈点查:尾部消息类型+预览(走 ix_message_channel_owner_talker_time)
|
||||
msg_conds = [
|
||||
Message.owner_account_id == owner,
|
||||
Message.talker_id == peer,
|
||||
sa.or_(Message.room_id.is_(None), Message.room_id == ""),
|
||||
Message.timestamp.is_not(None),
|
||||
]
|
||||
if channel is not None:
|
||||
msg_conds.append(Message.channel == channel)
|
||||
last_msg = (
|
||||
await session.execute(
|
||||
sa.select(Message).where(
|
||||
Message.channel == channel,
|
||||
Message.owner_account_id == owner,
|
||||
Message.talker_id == peer,
|
||||
sa.or_(Message.room_id.is_(None), Message.room_id == ""),
|
||||
Message.timestamp.is_not(None),
|
||||
).order_by(Message.timestamp.desc()).limit(1)
|
||||
sa.select(Message).where(*msg_conds)
|
||||
.order_by(Message.timestamp.desc()).limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if last_msg is None or last_msg.is_send or last_msg.type not in _VALID_TAIL_TYPES:
|
||||
@@ -759,7 +837,7 @@ async def unreplied(
|
||||
text = (last_msg.text or "").replace("\n", " ")
|
||||
return {
|
||||
"peer_id": peer,
|
||||
"channel": channel,
|
||||
"channel": last_msg.channel,
|
||||
"owner_account_id": owner,
|
||||
"last_ts": ts,
|
||||
"wait_ms": now - ts,
|
||||
|
||||
@@ -18,6 +18,9 @@ router = APIRouter(prefix="/api/analytics", tags=["analytics"])
|
||||
|
||||
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||
ChannelParam = Annotated[Literal["wechat", "whatsapp"], Query()]
|
||||
# 看板 v2(2026-09-07):overview 默认全平台——"all" 在 router 层翻译成
|
||||
# metrics 的 channel=None,不把哨兵字符串传进 SQL
|
||||
AllChannelParam = Annotated[Literal["all", "wechat", "whatsapp"], Query()]
|
||||
ScopeParam = Annotated[Literal["today", "week", "month"], Query()]
|
||||
|
||||
|
||||
@@ -31,15 +34,19 @@ async def analytics_health(session: SessionDep) -> dict[str, Any]:
|
||||
@router.get("/overview")
|
||||
async def analytics_overview(
|
||||
session: SessionDep,
|
||||
channel: ChannelParam = "wechat",
|
||||
channel: AllChannelParam = "all",
|
||||
scope: ScopeParam = "today",
|
||||
) -> dict[str, Any]:
|
||||
"""A1 经营总览:卡片+趋势+异常警示一次返回(单往返)。
|
||||
|
||||
channel=all(默认)=全平台口径:卡片合计+by_channel 拆分+trend_by_channel
|
||||
双渠道趋势序列;单渠道行为不变。
|
||||
cards 不含 data_as_of——数据截至走 /health(data_as_of 为 ms-epoch int
|
||||
与 entities[].last_seen 为 ISO 字符串的双格式)。
|
||||
"""
|
||||
return await metrics_svc.overview(session, channel=channel, scope=scope)
|
||||
return await metrics_svc.overview(
|
||||
session, channel=None if channel == "all" else channel, scope=scope
|
||||
)
|
||||
|
||||
|
||||
@router.get("/churn")
|
||||
|
||||
@@ -344,3 +344,84 @@ async def test_action_counts(make_client, monkeypatch):
|
||||
"sensitive_pending": 1}
|
||||
|
||||
await make_client(seed)
|
||||
|
||||
|
||||
async def test_overview_all_channel(make_client, monkeypatch):
|
||||
"""看板 v2 全平台口径(channel=None):卡片=两渠道合计、by_channel 拆分、
|
||||
trend=合计、trend_by_channel 双序列(仅 all 时出现;单渠道不加键)。"""
|
||||
monkeypatch.setattr(metrics, "today_key", lambda: _TODAY)
|
||||
async def seed(session):
|
||||
await _seed(session) # wechat 当日:t1 friend 15 + r1 room 7 = 22
|
||||
# whatsapp 当日:w1 friend 6(全史首现=今日,首沟通落窗)
|
||||
session.add(MsgDailyStats(channel="whatsapp", owner_account_id="wa1",
|
||||
peer_kind="friend", peer_id="w1", day=20260904,
|
||||
send_cnt=4, recv_cnt=2, last_ts=1788500000000,
|
||||
last_is_send=True))
|
||||
await session.commit()
|
||||
|
||||
out = await metrics.overview(session, channel=None, scope="today")
|
||||
assert out["channel"] == "all"
|
||||
# 合计卡片(peer 跨渠道不碰撞:活跃客户/首沟通直接相加)
|
||||
assert out["cards"]["msg_total"] == 28 # 22 + 6
|
||||
assert out["cards"]["active_customers"] == 2 # t1 + w1
|
||||
assert out["cards"]["first_contact"] == 2
|
||||
# 拆分小字数据源
|
||||
assert out["by_channel"]["wechat"] == {
|
||||
"msg_total": 22, "active_customers": 1, "first_contact": 1}
|
||||
assert out["by_channel"]["whatsapp"] == {
|
||||
"msg_total": 6, "active_customers": 1, "first_contact": 1}
|
||||
# 趋势:合计单系列保持可用 + 双渠道序列(30 天窗末日有值)
|
||||
assert len(out["trend"]) == 30
|
||||
assert out["trend"][-1]["msg"] == 28
|
||||
assert out["trend"][-1]["active_customers"] == 2
|
||||
tbc = out["trend_by_channel"]
|
||||
assert len(tbc["wechat"]) == 30 and len(tbc["whatsapp"]) == 30
|
||||
assert tbc["wechat"][-1]["msg"] == 22
|
||||
assert tbc["whatsapp"][-1]["msg"] == 6
|
||||
# 通话量无渠道语义不拆:渠道序列 call_cnt 恒 0,总量走 trend
|
||||
assert tbc["wechat"][-1]["call_cnt"] == 0
|
||||
assert out["trend"][-1]["call_cnt"] == 1
|
||||
|
||||
# 单渠道:不加 by_channel/trend_by_channel 键(既有行为不破坏)
|
||||
single = await metrics.overview(session, channel="wechat", scope="today")
|
||||
assert single["cards"]["msg_total"] == 22
|
||||
assert "by_channel" not in single
|
||||
assert "trend_by_channel" not in single
|
||||
|
||||
await make_client(seed)
|
||||
|
||||
|
||||
async def test_action_counts_all(make_client, monkeypatch):
|
||||
"""all 口径行动计数:两渠道各一条未回 → unreplied_2h=2;sensitive_pending
|
||||
同样跨渠道合计(只计 pending)。"""
|
||||
monkeypatch.setattr(metrics, "_now_ms", lambda: 1756950000000)
|
||||
async def seed(session):
|
||||
now = 1756950000000
|
||||
h3 = 3 * 3600 * 1000
|
||||
for i, (ch, owner, peer) in enumerate(
|
||||
[("wechat", "wx1", "t1"), ("whatsapp", "wa1", "w1")]
|
||||
):
|
||||
session.add(MsgDailyStats(channel=ch, owner_account_id=owner,
|
||||
peer_kind="friend", peer_id=peer, day=20260904,
|
||||
send_cnt=5, recv_cnt=3, last_ts=now - h3,
|
||||
last_is_send=False))
|
||||
session.add(Message(msg_id=f"m{i}", channel=ch, owner_account_id=owner,
|
||||
talker_id=peer, timestamp=now - h3, is_send=False,
|
||||
type="text", text="在吗",
|
||||
fetched_at=dt.datetime(2026, 9, 4, 12, 0)))
|
||||
w = SensitiveWord(word="测试词")
|
||||
session.add(w)
|
||||
await session.flush()
|
||||
session.add(SensitiveHit(word_id=w.id, msg_id="s1", channel="wechat",
|
||||
disposition="pending"))
|
||||
session.add(SensitiveHit(word_id=w.id, msg_id="s2", channel="whatsapp",
|
||||
disposition="pending"))
|
||||
session.add(SensitiveHit(word_id=w.id, msg_id="s3", channel="whatsapp",
|
||||
disposition="confirmed"))
|
||||
await session.commit()
|
||||
|
||||
out = await metrics.action_counts(session, channel=None)
|
||||
assert out == {"unreplied_2h": 2, "churn_real_30d": 0,
|
||||
"sensitive_pending": 2}
|
||||
|
||||
await make_client(seed)
|
||||
|
||||
@@ -5,6 +5,9 @@ import type { Channel, Page } from './types'
|
||||
|
||||
export type { Channel }
|
||||
export type Scope = 'today' | 'week' | 'month'
|
||||
// 看板 v2(2026-09-07):overview 渠道参数支持 'all'=全平台(后端译成
|
||||
// channel=None);列表页(churn/unreplied)仍是单渠道,沿用 types.ts 的 Channel
|
||||
export type DashboardChannel = 'all' | Channel
|
||||
|
||||
export interface OverviewCards {
|
||||
msg_total: number
|
||||
@@ -27,15 +30,20 @@ export interface StaffRankingRow {
|
||||
call_cnt: number; msg_delta: number | null
|
||||
}
|
||||
export interface ActionCounts { unreplied_2h: number; churn_real_30d: number; sensitive_pending: number }
|
||||
// by_channel/trend_by_channel 仅 channel='all' 时后端才返回(可选字段);
|
||||
// 渠道趋势序列 call_cnt 恒 0——通话量无渠道语义不拆,总量走 trend
|
||||
export interface PerChannelCards { msg_total: number; active_customers: number; first_contact: number }
|
||||
export interface OverviewResp {
|
||||
scope: Scope
|
||||
channel: Channel
|
||||
channel: Channel | 'all'
|
||||
days: number[]
|
||||
cards: OverviewCards
|
||||
trend: TrendPoint[]
|
||||
anomaly: Anomaly | null
|
||||
staff_ranking: StaffRankingRow[]
|
||||
actions: ActionCounts
|
||||
by_channel?: { wechat: PerChannelCards; whatsapp: PerChannelCards }
|
||||
trend_by_channel?: { wechat: TrendPoint[]; whatsapp: TrendPoint[] }
|
||||
}
|
||||
export interface HealthResp { data_as_of: number | null; entities: { table: string; last_seen: string | null }[] }
|
||||
export interface ChurnRow {
|
||||
@@ -56,7 +64,7 @@ export interface SensitiveHitRow {
|
||||
room_id: string | null; ts: number | null; snippet: string | null; disposition: string
|
||||
}
|
||||
|
||||
export const getOverview = (channel: Channel, scope: Scope) =>
|
||||
export const getOverview = (channel: DashboardChannel, scope: Scope) =>
|
||||
getJson<OverviewResp>('/analytics/overview', { channel, scope })
|
||||
export const getAnalyticsHealth = () => getJson<HealthResp>('/analytics/health')
|
||||
export const getChurn = (params: Record<string, unknown>) =>
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
// 沉默分层(仅真流失) + CSV 导出 + 行点击跳聊天窗。
|
||||
// 数据来自 /analytics/churn;/analytics/health 挂载拉一次做「数据截至」角标。
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getAnalyticsHealth, getChurn, type Channel, type ChurnRow } from '@/api/analytics'
|
||||
import { exportListToCsv } from '@/utils/csvExport'
|
||||
@@ -68,9 +68,18 @@ const KIND_HINTS: Record<ChurnKind, string> = {
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const channel = ref<Channel>('wechat')
|
||||
const kind = ref<ChurnKind>('real')
|
||||
const tier = ref(30)
|
||||
// 看板行动卡跳转带 query 预选筛选(白名单外回默认;只在初始化读一次,不 watch)
|
||||
const route = useRoute()
|
||||
const qChannel = route.query.channel
|
||||
const channel = ref<Channel>(
|
||||
qChannel === 'wechat' || qChannel === 'whatsapp' ? qChannel : 'wechat',
|
||||
)
|
||||
const qType = route.query.type
|
||||
const kind = ref<ChurnKind>(
|
||||
qType === 'real' || qType === 'inactive' || qType === 'handover' ? qType : 'real',
|
||||
)
|
||||
const qTier = Number(route.query.tier)
|
||||
const tier = ref([30, 60, 90].includes(qTier) ? qTier : 30)
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
const rows = ref<ChurnRow[]>([])
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<div class="pa4-dashboard">
|
||||
<div class="toolbar">
|
||||
<el-radio-group v-model="channel" @change="load">
|
||||
<el-radio-button value="all">全部</el-radio-button>
|
||||
<el-radio-button value="wechat">微信</el-radio-button>
|
||||
<el-radio-button value="whatsapp">WhatsApp</el-radio-button>
|
||||
</el-radio-group>
|
||||
@@ -17,7 +18,7 @@
|
||||
行动入口」);计数随 channel 切换自然跟随 overview 载荷,0 灰显仍可点 -->
|
||||
<div class="action-row">
|
||||
<el-card
|
||||
v-for="a in actionCards" :key="a.to" shadow="never"
|
||||
v-for="a in actionCards" :key="a.label" shadow="never"
|
||||
class="action-card" :class="{ zero: a.count === 0 }"
|
||||
@click="router.push(a.to)"
|
||||
>
|
||||
@@ -112,11 +113,14 @@
|
||||
<script setup lang="ts">
|
||||
// A1 数据看板:筛选栏(渠道×时间范围) → 预警行动入口 → 异常警示条 → 五卡片
|
||||
// → 30 日趋势折线 → 员工活跃排行表。
|
||||
// 看板 v2(2026-09-07):渠道默认「全部」=全平台口径——卡片合计+微信/WA
|
||||
// 拆分小字,趋势双线(微信蓝/WA 绿)+legend,行动卡跳转带 query 预选目标页。
|
||||
// 数据来自 /analytics/overview + /analytics/health(数据截至角标)。
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
getAnalyticsHealth, getOverview, type AnomalyStaff, type Channel, type OverviewResp, type Scope,
|
||||
getAnalyticsHealth, getOverview, type AnomalyStaff, type Channel,
|
||||
type DashboardChannel, type OverviewResp, type Scope,
|
||||
} from '@/api/analytics'
|
||||
import { useChart } from '@/composables/useChart'
|
||||
import { formatTimeShort } from '@/utils/format'
|
||||
@@ -146,7 +150,8 @@ interface AnomalyView {
|
||||
topStaff: AnomalyStaffItem[]
|
||||
}
|
||||
|
||||
const channel = ref<Channel>('wechat')
|
||||
// 渠道默认「全部」(看板 v2:管理人员先看全平台,一眼看清再下钻单渠道)
|
||||
const channel = ref<DashboardChannel>('all')
|
||||
const scope = ref<Scope>('today')
|
||||
const trendMetric = ref<'msg' | 'active_customers' | 'call_cnt'>('msg')
|
||||
const data = ref<OverviewResp | null>(null)
|
||||
@@ -216,16 +221,37 @@ const fmt = (n: number | undefined | null) => (n ?? 0).toLocaleString()
|
||||
const trendVals = (): number[] =>
|
||||
(data.value?.trend ?? []).map((p) => p[trendMetric.value] as number)
|
||||
|
||||
// 预警行动入口:计数来自 overview 载荷 actions(0 灰显仍可点,直达处置页)
|
||||
// 预警行动入口:计数来自 overview 载荷 actions(0 灰显仍可点,直达处置页)。
|
||||
// 跳转带 query 预选目标页筛选(评审断裂修复):单渠道跟随当前选择,all 不带
|
||||
// channel(目标页是单渠道清单,落地默认微信);敏感词无渠道概念只带 disposition
|
||||
const actionCards = computed(() => {
|
||||
const a = data.value?.actions
|
||||
const ch: { channel?: Channel } =
|
||||
channel.value !== 'all' ? { channel: channel.value } : {}
|
||||
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' },
|
||||
{
|
||||
icon: '🔴', label: '该回未回 2h', count: a?.unreplied_2h ?? 0,
|
||||
to: { path: '/analytics/unreplied', query: { ...ch, hours: '2' } },
|
||||
},
|
||||
{
|
||||
icon: '🟡', label: '流失预警 30天+', count: a?.churn_real_30d ?? 0,
|
||||
to: { path: '/analytics/churn', query: { ...ch, type: 'real', tier: '30' } },
|
||||
},
|
||||
{
|
||||
icon: '⚠', label: '敏感词待处置', count: a?.sensitive_pending ?? 0,
|
||||
to: { path: '/analytics/sensitive', query: { disposition: 'pending' } },
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
// channel=all 时的卡片拆分小字(微信 X · WA Y);活跃员工跨渠道重复计数不拆、
|
||||
// 通话量无渠道语义不拆(后端 by_channel 只给这三项)
|
||||
function splitHint(key: 'msg_total' | 'active_customers' | 'first_contact'): string | undefined {
|
||||
const bc = data.value?.by_channel
|
||||
if (channel.value !== 'all' || !bc) return undefined
|
||||
return `微信 ${fmt(bc.wechat[key])} · WA ${fmt(bc.whatsapp[key])}`
|
||||
}
|
||||
|
||||
const cardList = computed<CardItem[]>(() => {
|
||||
const c = data.value?.cards
|
||||
if (!c) return []
|
||||
@@ -235,15 +261,16 @@ const cardList = computed<CardItem[]>(() => {
|
||||
value: fmt(c.msg_total),
|
||||
// 今日卡片无 msg_total_delta 键;周/月可能有 null(前值缺失)→ 统一 undefined 不展示
|
||||
delta: scope.value === 'today' ? undefined : (c.msg_total_delta ?? undefined),
|
||||
hint: splitHint('msg_total'),
|
||||
},
|
||||
{ label: '活跃客户', value: fmt(c.active_customers) },
|
||||
{ label: '活跃客户', value: fmt(c.active_customers), hint: splitHint('active_customers') },
|
||||
{
|
||||
label: '活跃员工',
|
||||
value: fmt(c.active_staff),
|
||||
hint: c.unmapped_accounts > 0 ? `含未映射账号 ${c.unmapped_accounts}` : undefined,
|
||||
},
|
||||
{ label: '通话量', value: fmt(c.call_cnt) },
|
||||
{ label: '首次沟通客户', value: fmt(c.first_contact) },
|
||||
{ label: '首次沟通客户', value: fmt(c.first_contact), hint: splitHint('first_contact') },
|
||||
]
|
||||
})
|
||||
|
||||
@@ -262,13 +289,63 @@ const trendSummary = computed(() => {
|
||||
return `合计 ${total.toLocaleString()} · 日均 ${Math.round(total / vals.length).toLocaleString()} · 峰值 ${max.toLocaleString()}(${dstr})`
|
||||
})
|
||||
|
||||
// all 模式双系列渠道色:微信=EP 主蓝,WhatsApp=EP success 绿(与单系列默认色系一致)
|
||||
const CHANNEL_SERIES = [
|
||||
{ key: 'wechat', name: '微信', color: '#409EFF' },
|
||||
{ key: 'whatsapp', name: 'WhatsApp', color: '#67C23A' },
|
||||
] as const
|
||||
|
||||
function draw() {
|
||||
const t = data.value?.trend
|
||||
if (!t || !t.length) return
|
||||
const labels = t.map((p) => `${String(p.day).slice(4, 6)}-${String(p.day).slice(6)}`)
|
||||
const vals = trendVals()
|
||||
// X 轴 ~6 个刻度(30 个日期全排会挤成一串不可读)
|
||||
const interval = Math.max(0, Math.ceil(labels.length / 6) - 1)
|
||||
|
||||
// all 模式双系列(消息量/活跃客户):两条线+legend(两系列必须有图例)。
|
||||
// 选择性标注原则——图例+tooltip+摘要行(合计口径)够了,此模式不画
|
||||
// markPoint/endLabel/markLine。通话量无渠道语义,all 下仍走下方单系列合计。
|
||||
const tbc = data.value?.trend_by_channel
|
||||
if (channel.value === 'all' && trendMetric.value !== 'call_cnt' && tbc) {
|
||||
render({
|
||||
grid: { left: 56, right: 56, top: 40, bottom: 28 },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
valueFormatter: (v: unknown) => compact(Number(v)),
|
||||
},
|
||||
legend: {
|
||||
data: CHANNEL_SERIES.map((s) => s.name),
|
||||
top: 4,
|
||||
itemWidth: 14,
|
||||
textStyle: { color: '#606266' },
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: labels,
|
||||
axisLabel: { interval, hideOverlap: true, color: '#909399' },
|
||||
axisLine: { lineStyle: { color: '#dcdfe6' } },
|
||||
axisTick: { show: false },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLabel: { formatter: (v: number) => compact(v), color: '#909399' },
|
||||
splitLine: { lineStyle: { type: 'solid', color: '#f0f1f3' } },
|
||||
},
|
||||
series: CHANNEL_SERIES.map((s) => ({
|
||||
name: s.name,
|
||||
type: 'line' as const,
|
||||
smooth: true,
|
||||
lineStyle: { width: 2, color: s.color },
|
||||
itemStyle: { color: s.color },
|
||||
areaStyle: { opacity: 0.1, color: s.color },
|
||||
showSymbol: false,
|
||||
data: tbc[s.key].map((p) => p[trendMetric.value] as number),
|
||||
})),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const vals = trendVals()
|
||||
const maxIdx = vals.indexOf(Math.max(...vals))
|
||||
const avg = Math.round(vals.reduce((a, b) => a + b, 0) / vals.length)
|
||||
render({
|
||||
|
||||
@@ -136,7 +136,7 @@
|
||||
// health 挂载拉一次做「数据截至」角标;seq 三路径守卫/分页/tab-change/
|
||||
// kind-hint 均照 ChurnView/UnrepliedView 定型。
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
createWord,
|
||||
@@ -168,6 +168,7 @@ function dispMeta(d: string): { label: string; type: 'warning' | 'danger' | 'inf
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const tab = ref<TabName>('hits')
|
||||
const asOf = ref<number | null>(null)
|
||||
const asOfText = computed(() => formatTimeShort(asOf.value))
|
||||
@@ -175,8 +176,19 @@ const asOfText = computed(() => formatTimeShort(asOf.value))
|
||||
// ---- 命中列表 tab ----
|
||||
|
||||
// 'all'=默认(pending+confirmed,后端不传 disposition 即此语义);其余必须是
|
||||
// 枚举单值——乱值后端 Literal 校验直接 422(空串同理,所以用哨兵值)
|
||||
const disposition = ref('all')
|
||||
// 枚举单值——乱值后端 Literal 校验直接 422(空串同理,所以用哨兵值)。
|
||||
// 看板敏感词行动卡跳转带 ?disposition=pending 预选(白名单外回默认,
|
||||
// 只在初始化读一次,不 watch;敏感词无渠道概念,不带 channel)
|
||||
type DispFilter = 'all' | 'pending' | 'confirmed' | 'false_positive' | 'ignored'
|
||||
const DISP_FILTERS: readonly DispFilter[] = [
|
||||
'all', 'pending', 'confirmed', 'false_positive', 'ignored',
|
||||
]
|
||||
const qDisp = route.query.disposition
|
||||
const disposition = ref<DispFilter>(
|
||||
typeof qDisp === 'string' && DISP_FILTERS.includes(qDisp as DispFilter)
|
||||
? (qDisp as DispFilter)
|
||||
: 'all',
|
||||
)
|
||||
const hitPage = ref(1)
|
||||
const hitPageSize = 20
|
||||
const hitRows = ref<SensitiveHitRow[]>([])
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
// ?msgid= 定位该条消息)。数据来自 /analytics/unreplied;不导出 CSV。
|
||||
// health 挂载拉一次做「数据截至」角标(seq/分页/health-once 均照 ChurnView)。
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getAnalyticsHealth, getUnreplied, type Channel, type UnrepliedRow } from '@/api/analytics'
|
||||
import { formatTime, formatTimeShort } from '@/utils/format'
|
||||
@@ -58,9 +58,15 @@ import { formatTime, formatTimeShort } from '@/utils/format'
|
||||
const DAY_MS = 24 * 3600 * 1000
|
||||
|
||||
const router = useRouter()
|
||||
const channel = ref<Channel>('wechat')
|
||||
const route = useRoute()
|
||||
// 看板行动卡跳转带 query 预选筛选(白名单外回默认;只在初始化读一次,不 watch)
|
||||
const qChannel = route.query.channel
|
||||
const channel = ref<Channel>(
|
||||
qChannel === 'wechat' || qChannel === 'whatsapp' ? qChannel : 'wechat',
|
||||
)
|
||||
// 默认 2h(spec §3.4/§8 口径速查表,后端 Query 默认同为 2)——首屏不是 >1h 超集
|
||||
const hours = ref(2)
|
||||
const qHours = Number(route.query.hours)
|
||||
const hours = ref([1, 2, 4, 24].includes(qHours) ? qHours : 2)
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
const rows = ref<UnrepliedRow[]>([])
|
||||
|
||||
Reference in New Issue
Block a user