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,14 @@ export interface OverviewCards {
|
||||
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 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 {
|
||||
scope: Scope
|
||||
channel: Channel
|
||||
@@ -26,6 +34,8 @@ export interface OverviewResp {
|
||||
cards: OverviewCards
|
||||
trend: TrendPoint[]
|
||||
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 ChurnRow {
|
||||
|
||||
@@ -13,6 +13,20 @@
|
||||
<span v-if="asOfText" class="as-of">数据截至 {{ asOfText }}</span>
|
||||
</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="anomalyView" type="warning" :closable="false" class="anomaly">
|
||||
@@ -49,19 +63,62 @@
|
||||
</template>
|
||||
<div ref="chartEl" class="trend-chart"></div>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// A1 数据看板:筛选栏(渠道×时间范围) → 异常警示条 → 五卡片 → 30 日趋势折线。
|
||||
// A1 数据看板:筛选栏(渠道×时间范围) → 预警行动入口 → 异常警示条 → 五卡片
|
||||
// → 30 日趋势折线 → 员工活跃排行表。
|
||||
// 数据来自 /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,
|
||||
} from '@/api/analytics'
|
||||
import { useChart } from '@/composables/useChart'
|
||||
import { formatTimeShort } from '@/utils/format'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
interface CardItem {
|
||||
label: string
|
||||
value: string
|
||||
@@ -151,6 +208,16 @@ const anomalyView = computed<AnomalyView | null>(() => {
|
||||
|
||||
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 c = data.value?.cards
|
||||
if (!c) return []
|
||||
@@ -260,4 +327,48 @@ onMounted(() => {
|
||||
.trend-chart {
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user