diff --git a/yunke-server/app/analytics/metrics.py b/yunke-server/app/analytics/metrics.py
index ca6c2d0..a2055c0 100644
--- a/yunke-server/app/analytics/metrics.py
+++ b/yunke-server/app/analytics/metrics.py
@@ -44,7 +44,9 @@ def _day_date(day: int) -> dt.date:
def scope_day_keys(scope: str, today: int) -> list[int]:
- """today={今};week=本周一..今天;month=本月1号..今天(含端点)。"""
+ """today={今};week=本周一..今天;month=本月1号..今天;last30=近30天
+ 滚动窗(含端点)。last30 让排行/卡片命中任意有数据的月份(镜像停更期
+ 间日历月窗可能全空)。"""
end = _day_date(today)
if scope == "today":
start = end
@@ -52,6 +54,8 @@ def scope_day_keys(scope: str, today: int) -> list[int]:
start = end - dt.timedelta(days=end.weekday()) # 周一=0
elif scope == "month":
start = end.replace(day=1)
+ elif scope == "last30":
+ start = end - dt.timedelta(days=29)
else:
raise ValueError(f"bad scope: {scope!r}")
out, cur = [], start
@@ -65,8 +69,9 @@ def _prev_scope_days(scope: str, days: list[int]) -> list[int]:
"""同期对齐环比窗(与 days 等长逐日对齐;今日 scope 不调用)。
week=各日-7天(本周一..今 vs 上周一..上周同日);month=上月同日
- (短月钳到上月末,如 3/31→2/28)。同期对齐而非滚动紧邻窗——后者
- 周会跨周末、月取上月尾巴,管理者会误读成「上周/上月」(spec §8)。
+ (短月钳到上月末,如 3/31→2/28);last30=各日-30天(前一个滚动窗)。
+ 同期对齐而非滚动紧邻窗——后者周会跨周末、月取上月尾巴,管理者会
+ 误读成「上周/上月」(spec §8)。
"""
if scope == "week":
return [_date_key(_day_date(d) - dt.timedelta(days=7)) for d in days]
@@ -81,6 +86,8 @@ def _prev_scope_days(scope: str, days: list[int]) -> list[int]:
_date_key(dt.date(y, m, min(cur.day, calendar.monthrange(y, m)[1])))
)
return out
+ if scope == "last30":
+ return [_date_key(_day_date(d) - dt.timedelta(days=30)) for d in days]
raise ValueError(f"bad scope: {scope!r}")
@@ -384,7 +391,7 @@ async def staff_ranking(
# 环比:同期对齐前窗(仅周/月;今日 vs 昨日全天会误导,与 overview 同口径)
prev_total: dict[str, int] = {}
- if scope in ("week", "month"):
+ if scope in ("week", "month", "last30"):
prev_days = _prev_scope_days(scope, days)
if use_rollup:
conds = [StatDailyStaff.day.in_(prev_days)]
@@ -511,7 +518,7 @@ async def overview(
}
# 环比仅周/月,同期对齐(今日 vs 昨日全天会误导,spec §8)
- if scope in ("week", "month"):
+ if scope in ("week", "month", "last30"):
prev_days = _prev_scope_days(scope, 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)
diff --git a/yunke-server/app/api/routers/analytics.py b/yunke-server/app/api/routers/analytics.py
index 915bfdd..f8a1b29 100644
--- a/yunke-server/app/api/routers/analytics.py
+++ b/yunke-server/app/api/routers/analytics.py
@@ -21,7 +21,7 @@ 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()]
+ScopeParam = Annotated[Literal["today", "week", "month", "last30"], Query()]
@router.get("/health")
diff --git a/yunkes/src/api/analytics.ts b/yunkes/src/api/analytics.ts
index 9d63b4b..aebac56 100644
--- a/yunkes/src/api/analytics.ts
+++ b/yunkes/src/api/analytics.ts
@@ -4,7 +4,7 @@ import { getJson, requestJson } from './client'
import type { Channel, Page } from './types'
export type { Channel }
-export type Scope = 'today' | 'week' | 'month'
+export type Scope = 'today' | 'week' | 'month' | 'last30'
// 看板 v2(2026-09-07):overview 渠道参数支持 'all'=全平台(后端译成
// channel=None);列表页(churn/unreplied)仍是单渠道,沿用 types.ts 的 Channel
export type DashboardChannel = 'all' | Channel
diff --git a/yunkes/src/views/analytics/DashboardView.vue b/yunkes/src/views/analytics/DashboardView.vue
index 2b47960..5c57720 100644
--- a/yunkes/src/views/analytics/DashboardView.vue
+++ b/yunkes/src/views/analytics/DashboardView.vue
@@ -10,6 +10,7 @@