feat(analytics): 员工排行条形图对比+近30天范围——幅度一眼可读/本地停更窗口也可用
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<el-radio-button value="today">今日</el-radio-button>
|
||||
<el-radio-button value="week">本周</el-radio-button>
|
||||
<el-radio-button value="month">本月</el-radio-button>
|
||||
<el-radio-button value="last30">近30天</el-radio-button>
|
||||
</el-radio-group>
|
||||
<span v-if="asOfText" class="as-of">数据截至 {{ asOfText }}</span>
|
||||
</div>
|
||||
@@ -69,12 +70,21 @@
|
||||
<div ref="chartEl" class="trend-chart"></div>
|
||||
</el-card>
|
||||
|
||||
<!-- 员工活跃排行:全行展示(~41 人,420px 内滚),按消息量降序(后端排好) -->
|
||||
<!-- 员工活跃排行:条形图(量级对比一眼可读,2026-09-07「看不到对比」反馈)
|
||||
+ 明细表(图表的 table-view 孪生)。图取当前指标 TOP 20,表仍全量。 -->
|
||||
<el-card v-loading="loading" shadow="never" class="rank-card">
|
||||
<template #header>
|
||||
<span>员工活跃排行</span>
|
||||
<span class="rank-scope">按所选时间范围</span>
|
||||
<div class="trend-header">
|
||||
<span>员工活跃排行</span>
|
||||
<el-radio-group v-model="rankMetric" size="small">
|
||||
<el-radio-button value="msg_total">消息量</el-radio-button>
|
||||
<el-radio-button value="active_friends">活跃客户</el-radio-button>
|
||||
<el-radio-button value="call_cnt">通话量</el-radio-button>
|
||||
</el-radio-group>
|
||||
<span class="rank-scope">按所选时间范围 · TOP {{ rankBarRows.length }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="rankBarRows.length" ref="rankBarEl" class="rank-bar"></div>
|
||||
<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">
|
||||
@@ -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<HTMLElement | null>(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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user