feat(management): 驾驶舱六组件——KPI卡/筛选栏/异常榜/下钻抽屉/口径抽屉/数据截至角标+时长比率格式化共享
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="anomalies"
|
||||
:row-class-name="rowClass"
|
||||
@row-click="onRowClick"
|
||||
>
|
||||
<el-table-column label="级别" width="70" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.severity === 'critical'" type="danger" size="small">严重</el-tag>
|
||||
<el-tag v-else type="warning" size="small">关注</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="异常" min-width="240">
|
||||
<template #default="{ row }">
|
||||
<div class="cell-title">{{ row.title }}</div>
|
||||
<div class="cell-sub">{{ row.reason }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="当前值" width="150">
|
||||
<template #default="{ row }">
|
||||
<div>{{ formatValue(row.current_value, row.unit) }}</div>
|
||||
<div class="cell-sub">
|
||||
{{ row.baseline_value === null ? '无基线' : `基线 ${formatValue(row.baseline_value, row.unit)}` }}
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="样本" prop="sample_size" align="right" width="80" />
|
||||
<el-table-column label="影响" align="right" width="90">
|
||||
<template #default="{ row }">
|
||||
<span :class="{ 'affected-hot': row.affected_count >= 50 }">{{ row.affected_count }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="建议动作" min-width="180" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span class="cell-sub">{{ row.suggested_action }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="暂无异常" :image-size="60" />
|
||||
</template>
|
||||
</el-table>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 异常榜(批次C 驾驶舱):后端 AnomalyCard[] 平铺表——级别 tag/标题+原因
|
||||
// 双行/单位感知当前值+基线/样本/影响(≥50 红显)/建议动作。行 click 打开
|
||||
// 下钻抽屉(emit drill,抽屉自治加载);critical 整行淡红底提示优先处置。
|
||||
// 纯展示+事件,不取数。
|
||||
import { formatMsDuration, formatRatio } from '@/utils/format'
|
||||
import type { AnomalyCard } from '@/api/management'
|
||||
|
||||
defineProps<{
|
||||
anomalies: AnomalyCard[]
|
||||
loading?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'drill', card: AnomalyCard): void
|
||||
}>()
|
||||
|
||||
// 单位感知格式化:ms→时长,0-1→比率,其余千分位(与指标口径字典 unit 对齐)
|
||||
function formatValue(v: number, unit: string): string {
|
||||
if (unit === 'ms') return formatMsDuration(v)
|
||||
if (unit === '0-1') return formatRatio(v)
|
||||
return v.toLocaleString()
|
||||
}
|
||||
|
||||
function rowClass({ row }: { row: AnomalyCard }): string {
|
||||
return row.severity === 'critical'
|
||||
? 'anomaly-critical-row yk-clickable-row'
|
||||
: 'yk-clickable-row'
|
||||
}
|
||||
|
||||
function onRowClick(row: AnomalyCard): void {
|
||||
emit('drill', row)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* critical 行整行淡红底(优先处置);hover 深一档由 EP 行 hover 自带 */
|
||||
:deep(.el-table .anomaly-critical-row) {
|
||||
background-color: var(--el-color-danger-light-9);
|
||||
}
|
||||
:deep(.el-table .anomaly-critical-row:hover > td.el-table__cell) {
|
||||
background-color: var(--el-color-danger-light-8);
|
||||
}
|
||||
.cell-title {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
.cell-sub {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.affected-hot {
|
||||
color: var(--el-color-danger);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<span class="data-asof">
|
||||
<el-tag v-if="asOfText" size="small" type="info" effect="plain">数据截至 {{ asOfText }}</el-tag>
|
||||
<el-tag v-else size="small" type="info">无数据</el-tag>
|
||||
<el-tag
|
||||
v-if="metricVersion"
|
||||
size="small"
|
||||
effect="plain"
|
||||
class="asof-tag"
|
||||
:title="configSource === 'db' ? '口径配置:自定义(数据库)' : '口径配置:默认'"
|
||||
>口径 {{ metricVersion }}</el-tag>
|
||||
<el-tag v-if="trial" size="small" type="warning" class="asof-tag">试算</el-tag>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 数据截至角标(批次C 驾驶舱/员工页共用):数据时间 + 指标版本 + 试算标记。
|
||||
// 纯展示——数据由父级从 dashboard/employees 载荷透传(data_as_of/metric_version/
|
||||
// trial/config_source),不自行请求。configSource 无独立标签(避免噪音),
|
||||
// 挂在口径版本 tag 的 title 提示上。
|
||||
import { computed } from 'vue'
|
||||
import { formatTime } from '@/utils/format'
|
||||
|
||||
const props = defineProps<{
|
||||
dataAsOf?: number | null
|
||||
metricVersion?: string
|
||||
trial?: boolean
|
||||
configSource?: 'db' | 'default'
|
||||
}>()
|
||||
|
||||
// ms 时间戳 → `YYYY-MM-DD HH:mm`(formatTime 去秒);缺失/非法 → 灰 tag「无数据」
|
||||
const asOfText = computed(() => {
|
||||
const v = props.dataAsOf
|
||||
if (v == null || !Number.isFinite(v) || v <= 0) return ''
|
||||
return formatTime(v).slice(0, 16)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.data-asof {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.asof-tag {
|
||||
margin-left: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<div class="mgmt-filter-bar">
|
||||
<el-radio-group :model-value="channel" @change="onChannelChange">
|
||||
<el-radio-button v-for="c in CHANNELS" :key="c.value" :value="c.value">{{ c.label }}</el-radio-button>
|
||||
</el-radio-group>
|
||||
|
||||
<el-select
|
||||
v-model="preset"
|
||||
class="preset-select"
|
||||
placeholder="近30天"
|
||||
@change="onPresetChange"
|
||||
>
|
||||
<el-option label="近7天" value="last7" />
|
||||
<el-option label="近30天" value="last30" />
|
||||
<el-option label="本月" value="thisMonth" />
|
||||
<el-option label="上月" value="lastMonth" />
|
||||
<el-option label="自定义" value="custom" />
|
||||
</el-select>
|
||||
|
||||
<!-- 自定义区间:daterange 自保证 start≤end,选完才 emit;value-format
|
||||
直接给 yyyymmdd 字符串,转 number 走 URL 真源 -->
|
||||
<el-date-picker
|
||||
v-if="preset === 'custom'"
|
||||
v-model="customRange"
|
||||
type="daterange"
|
||||
size="small"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
value-format="YYYYMMDD"
|
||||
:clearable="false"
|
||||
style="width: 240px"
|
||||
@change="onCustomRange"
|
||||
/>
|
||||
|
||||
<div class="spacer" />
|
||||
<el-button text type="primary" @click="emit('open-defs')">指标口径</el-button>
|
||||
<slot name="aside" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 管理页统一筛选栏(批次C):渠道 + 时间预设/自定义区间 + 指标口径入口。
|
||||
// 受控组件——channel/start/end 来自父级(useManagementFilters 的 URL 真源
|
||||
// computed),变更只 emit apply(patch) 由父写回 URL,组件不实例化 composable
|
||||
// (只 import CHANNELS/presetRange 常量,避免二次 useRoute/useRouter)。
|
||||
//
|
||||
// 预设选中值:内部 ref 记用户最近选择,初始 null 显示 placeholder「近30天」,
|
||||
// 不与 URL 双向同步(URL 直接访问无 start/end 时默认即 last30,placeholder
|
||||
// 恰好如实)。非 custom 预设把 presetRange 计算出的具体日期 emit 给父——
|
||||
// URL 真源可分享/刷新,且选中项与实际窗口一致(composable 的默认(last30)
|
||||
// 无法表达 last7/本月/上月,只清 start/end 会静默回落 30 天窗)。
|
||||
import { ref } from 'vue'
|
||||
import { CHANNELS, presetRange, type Preset } from '@/composables/useManagementFilters'
|
||||
|
||||
defineProps<{
|
||||
channel: string
|
||||
start: number
|
||||
end: number
|
||||
/** 预留(对比模式选择器未上),父级透传保持 props 面完整 */
|
||||
compare?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'apply', patch: { channel?: string; start?: number; end?: number }): void
|
||||
(e: 'open-defs'): void
|
||||
}>()
|
||||
|
||||
// 用户最近选择的预设;null = 未选(placeholder 近30天)。custom 选完后保持,
|
||||
// 日期改动走 onCustomRange 增量 emit。
|
||||
const preset = ref<Preset | null>(null)
|
||||
const customRange = ref<[string, string] | null>(null)
|
||||
|
||||
function onChannelChange(v: string | number | boolean): void {
|
||||
emit('apply', { channel: String(v) })
|
||||
}
|
||||
|
||||
function onPresetChange(v: string): void {
|
||||
if (v === 'custom') return // 等 daterange 选完区间再 emit
|
||||
emit('apply', presetRange(v as Preset))
|
||||
}
|
||||
|
||||
function onCustomRange(): void {
|
||||
const r = customRange.value
|
||||
if (!r?.[0] || !r?.[1]) return // 未选完区间不请求
|
||||
emit('apply', { start: Number(r[0]), end: Number(r[1]) })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mgmt-filter-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.preset-select {
|
||||
width: 120px;
|
||||
}
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<el-card shadow="never" class="kpi-card" :class="{ link: !!to }" @click="onClick">
|
||||
<div class="kpi-label">{{ label }}</div>
|
||||
<div class="kpi-value" :class="{ danger }">
|
||||
{{ valueText }}<span v-if="unit && value !== null" class="kpi-unit">{{ unit }}</span>
|
||||
</div>
|
||||
<div v-if="hint" class="kpi-hint">{{ hint }}</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 经营管理 KPI 卡:label 上小灰字 + 主值大号字(千分位/unit 后缀/danger 红)
|
||||
// + hint 下小灰字。to 存在时整卡可点(hover 手型)跳转——纯展示+导航,
|
||||
// 不取数。null 值语义(无样本)显 `--`,由父级决定传 null。
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const props = defineProps<{
|
||||
label: string
|
||||
value: number | null
|
||||
unit?: string
|
||||
hint?: string
|
||||
to?: string
|
||||
danger?: boolean
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const valueText = computed(() => (props.value === null ? '--' : props.value.toLocaleString()))
|
||||
|
||||
function onClick(): void {
|
||||
if (props.to) router.push(props.to)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.kpi-card.link {
|
||||
cursor: pointer;
|
||||
}
|
||||
.kpi-label {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
.kpi-value {
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
margin: 6px 0 2px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
.kpi-value.danger {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
.kpi-unit {
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-left: 2px;
|
||||
}
|
||||
.kpi-hint {
|
||||
font-size: 11px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,199 @@
|
||||
<template>
|
||||
<el-drawer
|
||||
:model-value="modelValue"
|
||||
title="指标口径"
|
||||
direction="rtl"
|
||||
size="480px"
|
||||
@update:model-value="onVisible"
|
||||
>
|
||||
<div v-loading="loading">
|
||||
<el-alert v-if="error" type="error" :title="error" :closable="false" class="defs-alert" />
|
||||
|
||||
<template v-if="defs">
|
||||
<!-- 三方口径差异总述(管理域 vs 现有看板) -->
|
||||
<el-alert type="info" :closable="false" show-icon class="defs-alert">
|
||||
<div class="caliber-text">{{ defs.caliber_notes }}</div>
|
||||
</el-alert>
|
||||
|
||||
<!-- 评分配置摘要:版本/名称/试算/来源 + 样本门槛/等级线 + 维度权重 -->
|
||||
<div class="cfg-card">
|
||||
<div class="cfg-head">
|
||||
<b>{{ defs.score_config.name }}</b>
|
||||
<el-tag size="small" effect="plain">v{{ defs.score_config.version }}</el-tag>
|
||||
<el-tag v-if="defs.score_config.trial" size="small" type="warning">试算</el-tag>
|
||||
<el-tag size="small" :type="defs.config_source === 'db' ? 'primary' : 'info'" effect="plain">
|
||||
{{ defs.config_source === 'db' ? '自定义配置' : '默认配置' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="cfg-row">样本门槛:{{ guardsText }}</div>
|
||||
<div class="cfg-row">等级线:{{ gradesText }}(低于 D 线为 E)</div>
|
||||
<el-table :data="dims" size="small" class="dims-table">
|
||||
<el-table-column prop="name" label="维度" min-width="110" />
|
||||
<el-table-column label="权重" width="70" align="right">
|
||||
<template #default="{ row }">{{ row.weight }}%</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.status === 'pending_data'" size="small" type="info">待数据</el-tag>
|
||||
<el-tag v-else size="small" type="success">可用</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- 指标定义:19 项平铺(端点不按维度分组),按 code 字母序逐项展示 -->
|
||||
<div v-for="[code, d] in defsSorted" :key="code" class="def-item">
|
||||
<div class="def-head">
|
||||
<b>{{ d.name }}</b>
|
||||
<code class="def-code">{{ code }}</code>
|
||||
<span class="def-unit">{{ d.unit }}</span>
|
||||
<el-tag size="small" :type="d.direction === 1 ? 'success' : 'warning'">
|
||||
{{ d.direction === 1 ? '越大越好' : '越小越好' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<pre v-if="d.formula" class="def-formula">{{ d.formula }}</pre>
|
||||
<div v-if="d.caliber" class="def-caliber">{{ d.caliber }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 指标口径抽屉(批次C):口径字典 + 当前评分配置。打开时拉取
|
||||
// /management/metric-definitions 并缓存一次(口径变化走配置版本,会话内
|
||||
// 不重拉)。definitions 为平铺 19 项(dimensions 只有名称/权重),不做按
|
||||
// 维度分组,按 code 字母序逐项列:名称+单位+方向 tag+formula+caliber。
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { getMetricDefs, type MetricDefsResp } from '@/api/management'
|
||||
|
||||
const props = defineProps<{ modelValue: boolean }>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: boolean): void
|
||||
}>()
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const defs = ref<MetricDefsResp | null>(null)
|
||||
let loaded = false // 缓存一次:首次打开拉取,后续打开直接用
|
||||
|
||||
// 打开时取数;成功后缓存(口径变化走配置版本,会话内不重拉),
|
||||
// 失败(无 defs 且有 error)则重开抽屉时再试一次
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(visible) => {
|
||||
if (visible && (!loaded || (!defs.value && error.value))) load()
|
||||
},
|
||||
)
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loaded = true // 请求已发出即视为已加载,失败不反复自动重试
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
defs.value = await getMetricDefs()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : String(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onVisible(v: boolean): void {
|
||||
emit('update:modelValue', v)
|
||||
}
|
||||
|
||||
// 平铺定义按 code 字母序(中文环境 code 恒为 ASCII,localeCompare 稳定)
|
||||
const defsSorted = computed(() =>
|
||||
Object.entries(defs.value?.definitions ?? {}).sort(([a], [b]) => a.localeCompare(b)),
|
||||
)
|
||||
|
||||
// 配置维度行(仅名称/权重/状态)
|
||||
const dims = computed(() =>
|
||||
Object.entries(defs.value?.score_config.dimensions ?? {}).map(([key, d]) => ({
|
||||
key,
|
||||
name: d.name,
|
||||
weight: d.weight,
|
||||
status: d.status,
|
||||
})),
|
||||
)
|
||||
|
||||
const guardsText = computed(() => {
|
||||
const g = defs.value?.score_config.sample_guards ?? {}
|
||||
return Object.entries(g).map(([k, v]) => `${k} ≥ ${v}`).join(' · ') || '--'
|
||||
})
|
||||
|
||||
const gradesText = computed(() => {
|
||||
const gb = defs.value?.score_config.grade_bounds ?? {}
|
||||
return Object.entries(gb).map(([g, v]) => `${g} ≥ ${v}`).join(' · ') || '--'
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.defs-alert {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.caliber-text {
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
white-space: normal;
|
||||
}
|
||||
.cfg-card {
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.cfg-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.cfg-row {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.dims-table {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.def-item {
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
.def-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.def-code {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
background: var(--el-fill-color-light);
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.def-unit {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
.def-formula {
|
||||
margin: 6px 0 0;
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 4px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
.def-caliber {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,369 @@
|
||||
<template>
|
||||
<el-drawer
|
||||
:model-value="modelValue"
|
||||
:title="card?.title ?? '指标下钻'"
|
||||
direction="rtl"
|
||||
size="760px"
|
||||
@update:model-value="onVisible"
|
||||
>
|
||||
<!-- 无 metric_code 或 drilldown.type==='employee':员工维度异常,下钻
|
||||
明细属批次D 员工分析页——先给原因/建议动作兜底 -->
|
||||
<template v-if="unsupported">
|
||||
<el-empty description="员工分析页批次D上线,此类异常先看建议动作" :image-size="80" />
|
||||
<div v-if="card" class="fallback">
|
||||
<div class="fallback-label">原因</div>
|
||||
<div class="fallback-text">{{ card.reason }}</div>
|
||||
<div class="fallback-label">建议动作</div>
|
||||
<div class="fallback-text">{{ card.suggested_action }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<el-alert v-if="error" type="error" :title="error" :closable="false" class="drill-alert" />
|
||||
<el-alert v-if="resp?.note" type="info" :title="resp.note" :closable="false" class="drill-alert" />
|
||||
<el-alert
|
||||
v-if="resp?.truncated"
|
||||
type="warning"
|
||||
title="候选超500,列表可能不全"
|
||||
:closable="false"
|
||||
class="drill-alert"
|
||||
/>
|
||||
|
||||
<!-- call_followup:期聚合摘要(期通话/接通/跟进) -->
|
||||
<div v-if="kind === 'calls' && resp?.summary" class="call-summary">
|
||||
<div class="sum-item">
|
||||
<span class="sum-num">{{ resp.summary.call_count ?? 0 }}</span>
|
||||
<span class="sum-label">期通话</span>
|
||||
</div>
|
||||
<div class="sum-item">
|
||||
<span class="sum-num">{{ resp.summary.answered_call_count ?? 0 }}</span>
|
||||
<span class="sum-label">接通</span>
|
||||
</div>
|
||||
<div class="sum-item">
|
||||
<span class="sum-num">{{ resp.summary.call_followup_count ?? 0 }}</span>
|
||||
<span class="sum-label">跟进</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 首响分位:逐串证据行(后端已按 fr_ms desc),行 click 跳聊天 -->
|
||||
<el-table
|
||||
v-if="kind === 'first_reply'"
|
||||
v-loading="loading"
|
||||
:data="frRows"
|
||||
:row-class-name="clickableRow"
|
||||
@row-click="onFrRowClick"
|
||||
>
|
||||
<el-table-column prop="peer_id" label="客户" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column label="渠道" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="row.channel === 'whatsapp' ? 'success' : 'primary'">
|
||||
{{ chTag(row.channel) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="串末条时间" width="170">
|
||||
<template #default="{ row }">{{ formatTime(row.thread_end_ts) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="回复时间" width="170">
|
||||
<template #default="{ row }">{{ formatTime(row.reply_ts) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="首响时长" align="right" min-width="110">
|
||||
<template #default="{ row }">{{ formatMsDuration(row.fr_ms) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 未回串:等待中的客户,行 click 跳聊天(带 msgid 定位最后一条客户消息) -->
|
||||
<el-table
|
||||
v-else-if="kind === 'unreplied'"
|
||||
v-loading="loading"
|
||||
:data="unrepliedRows"
|
||||
:row-class-name="clickableRow"
|
||||
@row-click="onUnrepliedRowClick"
|
||||
>
|
||||
<el-table-column prop="peer_id" label="客户" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column label="渠道" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="row.channel === 'whatsapp' ? 'success' : 'primary'">
|
||||
{{ chTag(row.channel) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="等待时长" width="140">
|
||||
<template #default="{ row }">{{ formatMsDuration(row.wait_ms) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="预览" min-width="220" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.preview ?? '' }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 降温/沉睡客户名单。lifecycle 行没有 owner_account_id(互动史按
|
||||
(channel,peer) 跨 owner 合并,归属不定)——不提供跳转,批次D 员工
|
||||
分析页补员工账号维度的直达入口 -->
|
||||
<el-table v-else-if="kind === 'lifecycle'" v-loading="loading" :data="lifecycleRows">
|
||||
<el-table-column prop="nickname" label="客户" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column label="渠道" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="row.channel === 'whatsapp' ? 'success' : 'primary'">
|
||||
{{ chTag(row.channel) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最后互动日" width="120">
|
||||
<template #default="{ row }">{{ fmtDay(row.last_day) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.state === 'dormant'" size="small" type="danger">沉睡</el-tag>
|
||||
<el-tag v-else-if="row.state === 'cooling'" size="small" type="warning">降温</el-tag>
|
||||
<el-tag v-else size="small" type="info">{{ row.state }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 通话跟进:窗口通话列表(通话无渠道过滤维度,后端恒全渠道) -->
|
||||
<el-table v-else-if="kind === 'calls'" v-loading="loading" :data="callRows">
|
||||
<el-table-column label="时间" width="180">
|
||||
<template #default="{ row }">{{ formatTime(row.start_call_time) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="类型" width="120">
|
||||
<template #default="{ row }">{{ callTypeLabel(row.channel) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="号码" min-width="140">
|
||||
<template #default="{ row }">{{ row.call_number ?? '--' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="时长(秒)" align="right" min-width="100">
|
||||
<template #default="{ row }">{{ formatCallDuration(row.call_duration) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
:current-page="page"
|
||||
:page-size="PAGE_SIZE"
|
||||
:total="resp?.total ?? 0"
|
||||
layout="total, prev, pager, next"
|
||||
class="pager"
|
||||
@current-change="onPageChange"
|
||||
/>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 指标下钻抽屉(批次C):点异常卡按 metric_code 调 drilldown 白名单端点,
|
||||
// 四种明细形态——首响逐串证据/未回串/降温·沉睡名单/通话跟进。抽屉自治
|
||||
// 加载(打开即取数,后端分页);员工维度异常(drilldown.type==='employee')
|
||||
// 无 metric_code,给批次D 占位 + 原因/建议动作兜底。行 click 跳聊天:
|
||||
// WA→ChatTimeline(/chat/whatsapp/{owner}/{peer}),微信→员工聊天窗 single
|
||||
// tab(/staff-chat/{owner}/single/{peer},项目惯例路由名跳转,路径等价)。
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
getEmployeeDrilldown,
|
||||
type AnomalyCard,
|
||||
type DrilldownCode,
|
||||
type DrilldownResp,
|
||||
type MgmtChannel,
|
||||
} from '@/api/management'
|
||||
import { callTypeLabel, formatCallDuration, formatMsDuration, formatTime } from '@/utils/format'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
userId: string
|
||||
card: AnomalyCard | null
|
||||
start: number
|
||||
end: number
|
||||
channel: MgmtChannel
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: boolean): void
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
const page = ref(1)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const resp = ref<DrilldownResp | null>(null)
|
||||
|
||||
let seq = 0 // 竞态守卫:快速换卡/翻页时旧响应作废(项目惯例)
|
||||
|
||||
// drilldown 白名单(与后端 _DRILLDOWN_CODES 一致;白名单外按不支持兜底)
|
||||
const DRILL_CODES: readonly string[] = [
|
||||
'first_reply_p50', 'first_reply_p90', 'unreplied',
|
||||
'cooling_customers', 'dormant_customers', 'call_followup',
|
||||
]
|
||||
|
||||
const metricCode = computed<DrilldownCode | null>(() => {
|
||||
const c = props.card?.drilldown.query.metric_code
|
||||
return c && DRILL_CODES.includes(c) ? (c as DrilldownCode) : null
|
||||
})
|
||||
|
||||
// 员工维度异常(无 metric_code / type==='employee')→ 批次D 占位兜底
|
||||
const unsupported = computed(
|
||||
() => !metricCode.value || props.card?.drilldown.type === 'employee',
|
||||
)
|
||||
|
||||
type Kind = 'first_reply' | 'unreplied' | 'lifecycle' | 'calls'
|
||||
const kind = computed<Kind | null>(() => {
|
||||
switch (metricCode.value) {
|
||||
case 'first_reply_p50':
|
||||
case 'first_reply_p90':
|
||||
return 'first_reply'
|
||||
case 'unreplied':
|
||||
return 'unreplied'
|
||||
case 'cooling_customers':
|
||||
case 'dormant_customers':
|
||||
return 'lifecycle'
|
||||
case 'call_followup':
|
||||
return 'calls'
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
// 打开(或换卡重开)即取数;start/end/channel 变化由父级在抽屉关闭时发生,
|
||||
// 下次打开经 visible 变化自然重查
|
||||
watch(
|
||||
() => [props.modelValue, props.card] as const,
|
||||
([visible]) => {
|
||||
if (visible && !unsupported.value) {
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async function load(): Promise<void> {
|
||||
if (!metricCode.value) return
|
||||
const my = ++seq
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const r = await getEmployeeDrilldown(
|
||||
props.userId, metricCode.value, props.start, props.end,
|
||||
props.channel, page.value, PAGE_SIZE,
|
||||
)
|
||||
if (my !== seq) return
|
||||
resp.value = r
|
||||
} catch (e) {
|
||||
if (my === seq) error.value = e instanceof Error ? e.message : String(e)
|
||||
} finally {
|
||||
// 过期请求不清 loading(新一轮还在飞)也不覆盖 error
|
||||
if (my === seq) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 分页翻页:vue-tsc strict 不推 inline-arrow 参数,单列包装(项目惯例)
|
||||
function onPageChange(p: number): void {
|
||||
page.value = p
|
||||
load()
|
||||
}
|
||||
|
||||
function onVisible(v: boolean): void {
|
||||
emit('update:modelValue', v)
|
||||
}
|
||||
|
||||
// ---- 各分支行类型(items 是 Record<string,unknown>[],按白名单端点形状收窄) ----
|
||||
|
||||
type FrRow = {
|
||||
day: number; channel: string; owner_account_id: string; peer_id: string
|
||||
thread_end_ts: number; reply_ts: number; fr_ms: number
|
||||
}
|
||||
type UnrepliedRow = {
|
||||
peer_id: string; channel: string; owner_account_id: string
|
||||
last_ts: number; wait_ms: number; preview?: string; msg_id?: string
|
||||
}
|
||||
type LifecycleRow = { friend_id: string; channel: string; nickname: string; last_day: number; state: string }
|
||||
type CallRow = { call_id: string; channel: string; call_number: string | null; start_call_time: number; call_duration: number }
|
||||
|
||||
const frRows = computed<FrRow[]>(() =>
|
||||
kind.value === 'first_reply' ? (resp.value?.items ?? []) as FrRow[] : [])
|
||||
const unrepliedRows = computed<UnrepliedRow[]>(() =>
|
||||
kind.value === 'unreplied' ? (resp.value?.items ?? []) as UnrepliedRow[] : [])
|
||||
const lifecycleRows = computed<LifecycleRow[]>(() =>
|
||||
kind.value === 'lifecycle' ? (resp.value?.items ?? []) as LifecycleRow[] : [])
|
||||
const callRows = computed<CallRow[]>(() =>
|
||||
kind.value === 'calls' ? (resp.value?.items ?? []) as CallRow[] : [])
|
||||
|
||||
// ---- 展示辅助 ----
|
||||
|
||||
// 渠道短标签:异常下钻列窄,微信/WA 两字(全称 channelLabel 留给宽列页面)
|
||||
const CHANNEL_TAG: Record<string, string> = { wechat: '微信', whatsapp: 'WA' }
|
||||
const chTag = (c: string): string => CHANNEL_TAG[c] ?? c
|
||||
|
||||
// yyyymmdd(20260810)→ 2026-08-10:最后互动日跨月/年,保留完整年份
|
||||
const fmtDay = (d: number): string =>
|
||||
`${String(d).slice(0, 4)}-${String(d).slice(4, 6)}-${String(d).slice(6, 8)}`
|
||||
|
||||
// ---- 行点击跳聊天 ----
|
||||
|
||||
function goChat(owner: string, peer: string, channel: string, msgId?: string): void {
|
||||
if (channel === 'whatsapp') {
|
||||
router.push({ name: 'chat', params: { channel: 'whatsapp', ownerId: owner, talkerId: peer } })
|
||||
return
|
||||
}
|
||||
router.push({
|
||||
name: 'staff-chat',
|
||||
params: { ownerId: owner, tab: 'single', talkerId: peer },
|
||||
query: msgId ? { msgid: msgId } : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
// owner 理论恒非空(fact/未回清单均来自 owner 维度查询),防御判空不亏
|
||||
function clickableRow({ row }: { row: { owner_account_id?: string } }): string {
|
||||
return row.owner_account_id ? 'yk-clickable-row' : ''
|
||||
}
|
||||
|
||||
function onFrRowClick(row: FrRow): void {
|
||||
goChat(row.owner_account_id, row.peer_id, row.channel)
|
||||
}
|
||||
|
||||
// 微信跳转带 ?msgid= 定位最后一条客户消息(同 UnrepliedView 惯例)
|
||||
function onUnrepliedRowClick(row: UnrepliedRow): void {
|
||||
if (!row.owner_account_id) return
|
||||
goChat(row.owner_account_id, row.peer_id, row.channel, row.msg_id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.drill-alert {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.call-summary {
|
||||
display: flex;
|
||||
gap: 32px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.sum-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.sum-num {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.sum-label {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
.fallback {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.fallback-label {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin: 12px 0 4px;
|
||||
}
|
||||
.fallback-text {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
.pager {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
@@ -55,6 +55,28 @@ export function formatDuration(seconds?: number | null): string {
|
||||
return `${m}'${String(rem).padStart(2, '0')}"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 经营管理板块共享格式化(批次C)——自 DashboardView 本地 fmtFr/fmtPct 提升,
|
||||
// 驾驶舱 KPI / 异常榜 / 下钻抽屉与旧看板共用同一展示口径。
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 毫秒时长 → `<1h 显「X分Y秒」/ ≥1h 显「X小时Y分」`;null(无样本)显 `-`。
|
||||
* 与 formatDuration(秒,聊天气泡口径)区分:此处是指标口径(首响等)。
|
||||
*/
|
||||
export function formatMsDuration(ms?: number | null): string {
|
||||
if (ms == null) return '-'
|
||||
const totalSec = Math.round(ms / 1000)
|
||||
const mins = Math.floor(totalSec / 60)
|
||||
if (mins < 60) return `${mins}分${totalSec % 60}秒`
|
||||
return `${Math.floor(mins / 60)}小时${mins % 60}分`
|
||||
}
|
||||
|
||||
/** 比率(0-1) → 百分比 1 位小数(回复率/接通率等);null 显 `-`。 */
|
||||
export function formatRatio(r?: number | null): string {
|
||||
return r == null ? '-' : `${(r * 100).toFixed(1)}%`
|
||||
}
|
||||
|
||||
/** Friendly label for a Channel value. */
|
||||
export function channelLabel(channel?: string | null): string {
|
||||
switch (channel) {
|
||||
|
||||
@@ -143,16 +143,16 @@
|
||||
<template #default="{ row }">{{ fmt(row.first_contact_cnt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="首响" align="right">
|
||||
<template #default="{ row }">{{ fmtFr(row.fr_avg_ms) }}</template>
|
||||
<template #default="{ row }">{{ formatMsDuration(row.fr_avg_ms) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="回复率" align="right">
|
||||
<template #default="{ row }">{{ fmtPct(row.reply_rate) }}</template>
|
||||
<template #default="{ row }">{{ formatRatio(row.reply_rate) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="通话时长" align="right">
|
||||
<template #default="{ row }">{{ fmtDur(row.call_seconds) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="接通率" align="right">
|
||||
<template #default="{ row }">{{ fmtPct(row.answer_rate) }}</template>
|
||||
<template #default="{ row }">{{ formatRatio(row.answer_rate) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="删粉/拉黑" align="right">
|
||||
<template #default="{ row }">{{ fmtDelBlocked(row) }}</template>
|
||||
@@ -187,7 +187,7 @@ import {
|
||||
type StaffRankingRow,
|
||||
} from '@/api/analytics'
|
||||
import { useChart } from '@/composables/useChart'
|
||||
import { formatTimeShort } from '@/utils/format'
|
||||
import { formatMsDuration, formatRatio, formatTimeShort } from '@/utils/format'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -295,16 +295,8 @@ const fmt = (n: number | undefined | null) => (n ?? 0).toLocaleString()
|
||||
const assets = computed<CustomerAssets | null>(() => data.value?.customer_assets ?? null)
|
||||
|
||||
// ── 排行新列格式化 ──
|
||||
// 首响:<1h 显「X分Y秒」,≥1h 显「X小时Y分」;null(无样本)显 '-'
|
||||
const fmtFr = (ms: number | null) => {
|
||||
if (ms == null) return '-'
|
||||
const totalSec = Math.round(ms / 1000)
|
||||
const mins = Math.floor(totalSec / 60)
|
||||
if (mins < 60) return `${mins}分${totalSec % 60}秒`
|
||||
return `${Math.floor(mins / 60)}小时${mins % 60}分`
|
||||
}
|
||||
// 百分比 1 位小数(回复率/接通率);null 显 '-'
|
||||
const fmtPct = (v: number | null) => (v == null ? '-' : `${(v * 100).toFixed(1)}%`)
|
||||
// 首响/百分比用共享版(formatMsDuration/formatRatio,utils/format——批次C
|
||||
// 驾驶舱组件同口径,原本地 fmtFr/fmtPct 已提升去重)
|
||||
// 通话时长(秒):<1h 显「X分」,否则「X小时Y分」;0/null 显 '-'
|
||||
const fmtDur = (s: number | null) => {
|
||||
if (!s) return '-'
|
||||
|
||||
Reference in New Issue
Block a user