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 @@ 今日 本周 本月 + 近30天 数据截至 {{ asOfText }} @@ -69,12 +70,21 @@
- + +
@@ -416,6 +426,61 @@ function draw() { // post:等 DOM 更新后再画,保证首帧 chartEl 已挂载(趋势图恒 30 天,随 data 重画) watch([data, trendMetric], draw, { flush: 'post' }) +// ── 员工排行条形图:幅度对比的正确图型(横向条形),单系列单色(名义 +// 类别不做值渐变——颜色随实体不随排名),条端标值,其余靠轴+表。── +const rankMetric = ref<'msg_total' | 'active_friends' | 'call_cnt'>('msg_total') +const rankBarEl = ref(null) +const { render: renderRankBar } = useChart(rankBarEl) + +const RANK_BAR_TOP = 20 +const rankBarRows = computed(() => + (data.value?.staff_ranking ?? []) + .filter((r) => r[rankMetric.value] > 0) + .slice(0, RANK_BAR_TOP), +) + +function drawRankBar() { + const rows = rankBarRows.value + if (!rows.length) return + renderRankBar({ + grid: { left: 8, right: 64, top: 8, bottom: 8, containLabel: true }, + tooltip: { + trigger: 'item', + valueFormatter: (v: unknown) => compact(Number(v)), + }, + xAxis: { + type: 'value', + axisLabel: { formatter: (v: number) => compact(v), color: '#909399' }, + splitLine: { lineStyle: { type: 'solid', color: '#f0f1f3' } }, + }, + yAxis: { + type: 'category', + inverse: true, // 第 1 名在顶部 + data: rows.map((r) => r.name), + axisTick: { show: false }, + axisLine: { show: false }, + axisLabel: { color: '#606266' }, + }, + series: [ + { + type: 'bar', + barWidth: 14, // ≤24px 细条规范 + itemStyle: { color: '#409EFF', borderRadius: [0, 4, 4, 0] }, // 数据端 4px 圆角,基线方角 + label: { + show: true, + position: 'right', + formatter: (p: { value: unknown }) => compact(Number(p.value)), + color: '#606266', + fontSize: 11, + }, + data: rows.map((r) => r[rankMetric.value]), + }, + ], + }) +} + +watch([data, rankMetric], drawRankBar, { flush: 'post' }) + onMounted(() => { load() // health(数据截至)与筛选无关,挂载拉一次即可;失败只影响角标(静默隐藏), @@ -515,6 +580,10 @@ onMounted(() => { font-size: 22px; font-weight: 600; } +.rank-bar { + height: 480px; + margin-bottom: 8px; +} .rank-card { margin-top: 16px; }