577617545f
- EmployeeAnalyticsDetailView:头卡(账号跳聊天/试算分grade色/辅导记录批次E占位) +五维条(3可用实分bar+2待数据灰条,以metrics键缺失判pending_data) +五页签 (section懒加载·loaded缓存一次·切页签replace进URL section) +概览四对比表 (derive_metrics六键,unit感知) +服务/工作负载双线趋势(connectNulls:false) +异常区(AnomalyList showEmployee=false) +评分明细(metrics_detail全表 +prev/baseline原始字典二级折叠) +伪卡片下钻(超时明细/降温沉睡名单/通话明细) - seq守卫:detail单seq,每页签独立seq;筛选/换人清缓存重取;404→员工不存在结果态 - router /management/employees/:userId(props:true);MainLayout activeMenu 沿用route.name→父路径先例映射management-employees-detail→/management/employees - AnomalyList 预挂showEmployee prop(默认true;D3员工行消费,本页先传false)——D3清单项提前 Co-Authored-By: Claude Code <noreply@anthropic.com>
1071 lines
37 KiB
Vue
1071 lines
37 KiB
Vue
<template>
|
|
<!-- 404:员工不在员工表(URL 参数误入/已删除)——整页结果态,给回列表出口 -->
|
|
<el-result
|
|
v-if="notFound"
|
|
icon="error"
|
|
title="员工不存在"
|
|
sub-title="该员工不在员工表,可能 ID 有误或已删除"
|
|
>
|
|
<template #extra>
|
|
<el-button type="primary" @click="backToList">返回员工分析</el-button>
|
|
</template>
|
|
</el-result>
|
|
|
|
<div v-else class="emp-detail">
|
|
<!-- 筛选栏:渠道/时间(URL 真源,与总览页同源同参,返回列表不丢窗口) -->
|
|
<ManagementFilterBar
|
|
:channel="channel"
|
|
:start="start"
|
|
:end="end"
|
|
:compare="compare"
|
|
@apply="apply"
|
|
@open-defs="defsVisible = true"
|
|
/>
|
|
|
|
<!-- 详情加载失败:alert+重试(保留上次成功数据) -->
|
|
<el-alert v-if="detailError" type="error" :closable="false" class="top-alert">
|
|
<template #title>
|
|
<span>{{ detailError }}</span>
|
|
<el-button size="small" type="primary" class="retry-btn" @click="loadDetail">重试</el-button>
|
|
</template>
|
|
</el-alert>
|
|
|
|
<!-- ── 头部卡:身份+账号清单 | 本期试算分+截至角标+返回 ── -->
|
|
<el-card shadow="never" class="head-card" v-loading="detailLoading">
|
|
<div class="head">
|
|
<div class="head-left">
|
|
<div class="who">
|
|
<span class="name">{{ detail?.name || userId }}</span>
|
|
<el-tag v-if="detail?.is_on_job === true" size="small" type="success">在职</el-tag>
|
|
<el-tag v-else-if="detail?.is_on_job === false" size="small" type="info">离职</el-tag>
|
|
<span class="uid">{{ userId }}</span>
|
|
</div>
|
|
<!-- 账号清单:微信→员工聊天窗 single 页签;WA→账号级聊天记录;一行排开自动折行 -->
|
|
<div class="accounts">
|
|
<span
|
|
v-for="a in detail?.accounts ?? []"
|
|
:key="a.channel + ':' + a.account_id"
|
|
class="acct"
|
|
>
|
|
<el-tag size="small" :type="a.channel === 'whatsapp' ? 'success' : 'primary'">
|
|
{{ a.channel === 'whatsapp' ? 'WA' : '微信' }}
|
|
</el-tag>
|
|
<el-link type="primary" underline="never" class="acct-id" @click="goAccount(a)">
|
|
{{ a.account_id }}
|
|
</el-link>
|
|
<span v-if="a.nick_name || a.user_name" class="acct-name">
|
|
{{ a.nick_name || a.user_name }}
|
|
</span>
|
|
</span>
|
|
<span v-if="!(detail?.accounts ?? []).length" class="dim">无账号</span>
|
|
</div>
|
|
</div>
|
|
<div class="head-right">
|
|
<div class="score-block">
|
|
<template v-if="detail && detail.score.score_status === 'scored'">
|
|
<span class="score-num">{{ Math.round(detail.score.total_score ?? 0) }}</span>
|
|
<el-tag
|
|
v-if="detail.score.grade"
|
|
size="small"
|
|
:type="gradeType(detail.score.grade)"
|
|
class="grade-tag"
|
|
>{{ detail.score.grade }}</el-tag>
|
|
<span v-if="detail.score.trial" class="trial-mark" title="试算期评分">试</span>
|
|
<div class="score-label">本期试算分</div>
|
|
</template>
|
|
<template v-else>
|
|
<span class="score-num score-dim">--</span>
|
|
<div class="score-label dim">样本不足·不参与考核</div>
|
|
</template>
|
|
</div>
|
|
<DataAsOfBadge
|
|
:data-as-of="detail?.data_as_of ?? null"
|
|
:metric-version="detail?.metric_version"
|
|
:trial="detail?.score.trial"
|
|
:config-source="detail?.config_source"
|
|
/>
|
|
<div class="head-actions">
|
|
<el-tooltip content="批次E上线" placement="top">
|
|
<span class="coach-wrap">
|
|
<el-button size="small" disabled>创建辅导记录</el-button>
|
|
</span>
|
|
</el-tooltip>
|
|
<el-button size="small" text @click="backToList">返回员工分析</el-button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</el-card>
|
|
|
|
<!-- ── 五维条:页面内直画(3 可用维度实分 + 2 待数据灰条) ── -->
|
|
<el-card shadow="never" class="dims-card" v-loading="detailLoading">
|
|
<template #header>
|
|
五维得分
|
|
<span class="card-sub">0-100 · 行动执行/合规风险待数据(批次E)</span>
|
|
</template>
|
|
<div v-for="[key, d] in dimEntries" :key="key" class="dim-row">
|
|
<span class="dim-name">{{ d.name }}</span>
|
|
<span class="dim-weight">权重 {{ d.weight }}%</span>
|
|
<div class="dim-track">
|
|
<div v-if="dimPendingData(d)" class="dim-bar dim-bar--pending" />
|
|
<div
|
|
v-else-if="d.score != null"
|
|
class="dim-bar"
|
|
:style="{ width: Math.max(0, Math.min(100, d.score)) + '%' }"
|
|
/>
|
|
</div>
|
|
<span class="dim-score" :class="{ dim: d.status !== 'scored' }">
|
|
{{ dimScoreText(d) }}
|
|
</span>
|
|
</div>
|
|
</el-card>
|
|
|
|
<!-- ── 五页签:section 懒加载(激活才取数,缓存一次切回即显) ── -->
|
|
<el-card shadow="never" class="tabs-card">
|
|
<el-tabs :model-value="section" @tab-change="onSectionChange">
|
|
<!-- 概览:六数卡(detail.metrics) + 四对比表(section comparisons) -->
|
|
<el-tab-pane label="概览" name="overview" lazy>
|
|
<div class="pane" v-loading="sectionState.overview.loading">
|
|
<el-alert v-if="sectionState.overview.error" type="error" :closable="false" class="pane-alert">
|
|
<template #title>
|
|
<span>{{ sectionState.overview.error }}</span>
|
|
<el-button size="small" type="primary" class="retry-btn" @click="ensureSection('overview')">
|
|
重试
|
|
</el-button>
|
|
</template>
|
|
</el-alert>
|
|
<template v-if="sectionState.overview.resp">
|
|
<div class="tiles">
|
|
<div v-for="t in ovTiles" :key="t.label" class="tile">
|
|
<div class="tile-label">{{ t.label }}</div>
|
|
<div class="tile-value">{{ t.value }}</div>
|
|
</div>
|
|
</div>
|
|
<el-table :data="compareRows" size="small" class="cmp-table">
|
|
<el-table-column label="指标(四对比)" min-width="180">
|
|
<template #default="{ row }">
|
|
<span>{{ row.name }}</span>
|
|
<span class="cell-sub code">{{ row.code }}</span>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column label="当前" align="right" min-width="110">
|
|
<template #default="{ row }">{{ fmtCmp(row.current, row.unit) }}</template>
|
|
</el-table-column>
|
|
<el-table-column label="上期" align="right" min-width="110">
|
|
<template #default="{ row }">{{ fmtCmp(row.prev, row.unit) }}</template>
|
|
</el-table-column>
|
|
<el-table-column label="个人基线" align="right" min-width="110">
|
|
<template #default="{ row }">{{ fmtCmp(row.baseline, row.unit) }}</template>
|
|
</el-table-column>
|
|
<el-table-column label="团队中位" align="right" min-width="110">
|
|
<template #default="{ row }">{{ fmtCmp(row.median, row.unit) }}</template>
|
|
</el-table-column>
|
|
<template #empty>该窗口无对比数据</template>
|
|
</el-table>
|
|
</template>
|
|
</div>
|
|
</el-tab-pane>
|
|
|
|
<!-- 服务质量:P50/P90 数卡 + 客户端派生回复率 + 首响双线趋势 + 超时明细 -->
|
|
<el-tab-pane label="服务质量" name="service" lazy>
|
|
<div class="pane" v-loading="sectionState.service.loading">
|
|
<el-alert v-if="sectionState.service.error" type="error" :closable="false" class="pane-alert">
|
|
<template #title>
|
|
<span>{{ sectionState.service.error }}</span>
|
|
<el-button size="small" type="primary" class="retry-btn" @click="ensureSection('service')">
|
|
重试
|
|
</el-button>
|
|
</template>
|
|
</el-alert>
|
|
<template v-if="sectionState.service.resp">
|
|
<div class="tiles">
|
|
<div v-for="t in svcTiles" :key="t.label" class="tile">
|
|
<div class="tile-label">{{ t.label }}</div>
|
|
<div class="tile-value">{{ t.value }}</div>
|
|
</div>
|
|
</div>
|
|
<div class="pane-actions">
|
|
<el-button size="small" @click="openDrill('首响P90明细', 'first_reply_p90')">
|
|
查看超时明细
|
|
</el-button>
|
|
</div>
|
|
<!-- 首响 P50/P90 双线(ms 轴,None 断点);员工级无每日率序列,不做率右轴 -->
|
|
<div ref="svcTrendEl" class="chart"></div>
|
|
</template>
|
|
</div>
|
|
</el-tab-pane>
|
|
|
|
<!-- 客户经营:期末四态数卡(detail.metrics) + 降温/沉睡名单下钻 -->
|
|
<el-tab-pane label="客户经营" name="customer" lazy>
|
|
<div class="pane" v-loading="sectionState.customer.loading">
|
|
<el-alert v-if="sectionState.customer.error" type="error" :closable="false" class="pane-alert">
|
|
<template #title>
|
|
<span>{{ sectionState.customer.error }}</span>
|
|
<el-button size="small" type="primary" class="retry-btn" @click="ensureSection('customer')">
|
|
重试
|
|
</el-button>
|
|
</template>
|
|
</el-alert>
|
|
<div class="tiles">
|
|
<div v-for="t in custTiles" :key="t.label" class="tile">
|
|
<div class="tile-label">{{ t.label }}</div>
|
|
<div class="tile-value">{{ t.value }}</div>
|
|
</div>
|
|
</div>
|
|
<div class="pane-actions">
|
|
<el-button size="small" @click="openDrill('降温客户名单', 'cooling_customers')">
|
|
降温客户名单
|
|
</el-button>
|
|
<el-button size="small" @click="openDrill('沉睡客户名单', 'dormant_customers')">
|
|
沉睡客户名单
|
|
</el-button>
|
|
</div>
|
|
</div>
|
|
</el-tab-pane>
|
|
|
|
<!-- 工作负载:收发/活跃客户/活跃群均/通话量数卡 + 活跃双线趋势 -->
|
|
<el-tab-pane label="工作负载" name="workload" lazy>
|
|
<div class="pane" v-loading="sectionState.workload.loading">
|
|
<el-alert v-if="sectionState.workload.error" type="error" :closable="false" class="pane-alert">
|
|
<template #title>
|
|
<span>{{ sectionState.workload.error }}</span>
|
|
<el-button size="small" type="primary" class="retry-btn" @click="ensureSection('workload')">
|
|
重试
|
|
</el-button>
|
|
</template>
|
|
</el-alert>
|
|
<div class="tiles">
|
|
<div v-for="t in wlTiles" :key="t.label" class="tile">
|
|
<div class="tile-label">{{ t.label }}</div>
|
|
<div class="tile-value">{{ t.value }}</div>
|
|
</div>
|
|
</div>
|
|
<div ref="wlTrendEl" class="chart"></div>
|
|
</div>
|
|
</el-tab-pane>
|
|
|
|
<!-- 通话:四数卡(detail.metrics) + 通话明细下钻 -->
|
|
<el-tab-pane label="通话" name="calls" lazy>
|
|
<div class="pane" v-loading="sectionState.calls.loading">
|
|
<el-alert v-if="sectionState.calls.error" type="error" :closable="false" class="pane-alert">
|
|
<template #title>
|
|
<span>{{ sectionState.calls.error }}</span>
|
|
<el-button size="small" type="primary" class="retry-btn" @click="ensureSection('calls')">
|
|
重试
|
|
</el-button>
|
|
</template>
|
|
</el-alert>
|
|
<div class="tiles">
|
|
<div v-for="t in callTiles" :key="t.label" class="tile">
|
|
<div class="tile-label">{{ t.label }}</div>
|
|
<div class="tile-value">{{ t.value }}</div>
|
|
</div>
|
|
</div>
|
|
<div class="pane-actions">
|
|
<el-button size="small" @click="openDrill('通话明细', 'call_followup')">
|
|
通话明细
|
|
</el-button>
|
|
</div>
|
|
</div>
|
|
</el-tab-pane>
|
|
</el-tabs>
|
|
</el-card>
|
|
|
|
<!-- ── 异常区:本员工的异常卡(不显示员工行);点行开下钻抽屉 ── -->
|
|
<el-card shadow="never" class="anomaly-card">
|
|
<template #header>
|
|
异常
|
|
<span class="card-sub">按严重度排序 · 点行查看明细</span>
|
|
</template>
|
|
<AnomalyList
|
|
:anomalies="detail?.anomalies ?? []"
|
|
:loading="detailLoading"
|
|
:show-employee="false"
|
|
@drill="onAnomalyDrill"
|
|
/>
|
|
</el-card>
|
|
|
|
<!-- ── 评分明细:metrics_detail 全表 + 维度权重 + 原始数据(二级折叠) ── -->
|
|
<el-collapse class="score-detail" v-loading="detailLoading">
|
|
<el-collapse-item name="score" title="评分是怎么算出来的">
|
|
<div class="weights-line">维度权重:{{ weightsLine }}</div>
|
|
<el-table :data="metricsDetailRows" size="small" class="md-table">
|
|
<el-table-column label="指标" min-width="190">
|
|
<template #default="{ row }">
|
|
<div>{{ row.name }}</div>
|
|
<div class="cell-sub code">{{ row.code }}</div>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column label="当前" align="right" min-width="110">
|
|
<template #default="{ row }">{{ fmtCmp(row.value, row.unit) }}</template>
|
|
</el-table-column>
|
|
<el-table-column label="上期" align="right" min-width="110">
|
|
<template #default="{ row }">{{ fmtCmp(row.prev, row.unit) }}</template>
|
|
</el-table-column>
|
|
<el-table-column label="基线" align="right" min-width="110">
|
|
<template #default="{ row }">{{ fmtCmp(row.baseline, row.unit) }}</template>
|
|
</el-table-column>
|
|
<el-table-column label="团队中位" align="right" min-width="110">
|
|
<template #default="{ row }">{{ fmtCmp(row.median, row.unit) }}</template>
|
|
</el-table-column>
|
|
<el-table-column label="样本量" prop="sample_size" align="right" width="80" />
|
|
<el-table-column label="样本状态" align="center" width="90">
|
|
<template #default="{ row }">
|
|
<el-tag v-if="row.sample_status === 'ok'" size="small" type="success">正常</el-tag>
|
|
<el-tag v-else-if="row.sample_status === 'low_confidence'" size="small" type="warning">
|
|
低置信
|
|
</el-tag>
|
|
<el-tag v-else size="small" type="info">不足</el-tag>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column label="得分" align="right" width="70">
|
|
<template #default="{ row }">
|
|
<span :class="{ dim: row.score == null }">
|
|
{{ row.score == null ? '--' : Math.round(row.score) }}
|
|
</span>
|
|
</template>
|
|
</el-table-column>
|
|
</el-table>
|
|
<el-collapse class="raw-collapse">
|
|
<el-collapse-item name="raw" title="原始数据(调试用)">
|
|
<div class="raw-label">上期 prev · PeriodMetrics 原始字典</div>
|
|
<pre class="raw-json">{{ prevJson }}</pre>
|
|
<div class="raw-label">个人基线 baseline · PeriodMetrics 原始字典</div>
|
|
<pre class="raw-json">{{ baseJson }}</pre>
|
|
</el-collapse-item>
|
|
</el-collapse>
|
|
</el-collapse-item>
|
|
</el-collapse>
|
|
|
|
<!-- 事实下钻抽屉(异常行与伪卡片按钮共用) + 指标口径抽屉 -->
|
|
<MetricDrilldownDrawer
|
|
v-model="drillVisible"
|
|
:card="drillCard"
|
|
:user-id="userId"
|
|
:start="start"
|
|
:end="end"
|
|
:channel="channel"
|
|
/>
|
|
<MetricDefsDrawer v-model="defsVisible" />
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
// 员工分析详情(经营管理批次D2):头卡(身份/账号跳聊天/试算分)+ 五维条
|
|
// (3 可用实分 + 2 待数据灰条)+ 五页签(section 懒加载:激活才调
|
|
// getEmployeeMetrics,loaded 缓存一次,切回即显不重拉;页签切换 replace
|
|
// 进 URL section 参数,刷新回到该页签)+ 异常区 + 评分明细(metrics_detail
|
|
// 全表 + prev/baseline 原始字典二级折叠)。query 真源:channel/start/end/
|
|
// compare+section(useManagementFilters + 本页 section 白名单)。seq 守卫:
|
|
// detail 一个 seq,每页签独立 seq(切页签/改筛选旧响应不覆盖)。
|
|
import { computed, reactive, ref, watch } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import type { TabPaneName } from 'element-plus'
|
|
import {
|
|
getEmployeeDetail,
|
|
getEmployeeMetrics,
|
|
type AnomalyCard,
|
|
type DrilldownCode,
|
|
type EmployeeAccount,
|
|
type EmployeeDetailResp,
|
|
type MetricsSection,
|
|
type SectionResp,
|
|
} from '@/api/management'
|
|
import { useManagementFilters } from '@/composables/useManagementFilters'
|
|
import { useChart } from '@/composables/useChart'
|
|
import { formatMsDuration, formatRatio } from '@/utils/format'
|
|
import ManagementFilterBar from '@/components/management/ManagementFilterBar.vue'
|
|
import DataAsOfBadge from '@/components/management/DataAsOfBadge.vue'
|
|
import AnomalyList from '@/components/management/AnomalyList.vue'
|
|
import MetricDrilldownDrawer from '@/components/management/MetricDrilldownDrawer.vue'
|
|
import MetricDefsDrawer from '@/components/management/MetricDefsDrawer.vue'
|
|
|
|
const props = defineProps<{ userId: string }>()
|
|
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const { channel, start, end, compare, apply } = useManagementFilters()
|
|
|
|
// ── section query 真源(白名单外回概览;切换 replace,刷新回该页签) ──
|
|
const SECTIONS: readonly MetricsSection[] = ['overview', 'service', 'customer', 'workload', 'calls']
|
|
const section = computed<MetricsSection>(() =>
|
|
SECTIONS.includes(route.query.section as MetricsSection)
|
|
? (route.query.section as MetricsSection)
|
|
: 'overview',
|
|
)
|
|
|
|
function onSectionChange(name: TabPaneName): void {
|
|
apply({ section: name === 'overview' ? undefined : String(name) })
|
|
}
|
|
|
|
// ── 详情加载(单 seq;404 → 整页结果态) ──
|
|
const detail = ref<EmployeeDetailResp | null>(null)
|
|
const detailLoading = ref(false)
|
|
const detailError = ref<string | null>(null)
|
|
const notFound = ref(false)
|
|
let detailSeq = 0
|
|
|
|
async function loadDetail(): Promise<void> {
|
|
const my = ++detailSeq
|
|
detailLoading.value = true
|
|
detailError.value = null
|
|
try {
|
|
const r = await getEmployeeDetail(props.userId, channel.value, start.value, end.value)
|
|
if (my !== detailSeq) return
|
|
detail.value = r
|
|
} catch (e) {
|
|
if (my !== detailSeq) return
|
|
const msg = e instanceof Error ? e.message : String(e)
|
|
// client 拦截器只透传 detail 文案(状态码已丢),按后端 404 文案判别
|
|
if (/not found/i.test(msg)) notFound.value = true
|
|
else detailError.value = msg
|
|
} finally {
|
|
if (my === detailSeq) detailLoading.value = false
|
|
}
|
|
}
|
|
|
|
// ── 分段指标:懒加载 + loaded 缓存 + 每页签独立 seq ──
|
|
interface SectionState {
|
|
resp: SectionResp | null
|
|
loading: boolean
|
|
error: string | null
|
|
loaded: boolean
|
|
}
|
|
function blankSection(): SectionState {
|
|
return { resp: null, loading: false, error: null, loaded: false }
|
|
}
|
|
const sectionState = reactive<Record<MetricsSection, SectionState>>({
|
|
overview: blankSection(),
|
|
service: blankSection(),
|
|
customer: blankSection(),
|
|
workload: blankSection(),
|
|
calls: blankSection(),
|
|
})
|
|
// 独立 seq(非响应式:仅竞态计数):切页签/改筛选时旧响应按 seq 作废
|
|
const sectionSeq: Record<MetricsSection, number> = {
|
|
overview: 0, service: 0, customer: 0, workload: 0, calls: 0,
|
|
}
|
|
|
|
function ensureSection(s: MetricsSection): void {
|
|
if (notFound.value) return
|
|
const st = sectionState[s]
|
|
if (st.loaded || st.loading) return // 缓存一次不重拉;在飞不重复发
|
|
const my = ++sectionSeq[s]
|
|
st.loading = true
|
|
st.error = null
|
|
getEmployeeMetrics(props.userId, s, channel.value, start.value, end.value).then(
|
|
(r) => {
|
|
if (my !== sectionSeq[s]) return
|
|
st.resp = r
|
|
st.loaded = true
|
|
st.loading = false
|
|
},
|
|
(e) => {
|
|
if (my !== sectionSeq[s]) return
|
|
st.error = e instanceof Error ? e.message : String(e)
|
|
st.loading = false
|
|
},
|
|
)
|
|
}
|
|
|
|
// 筛选/换人:detail 重查 + 全部页签缓存清零(独立 seq 递增作废在飞响应),
|
|
// 当前页签立即重取;页签切换只 ensure(未加载才发)
|
|
watch(
|
|
[() => props.userId, channel, start, end],
|
|
() => {
|
|
notFound.value = false
|
|
loadDetail()
|
|
for (const s of SECTIONS) {
|
|
sectionSeq[s]++
|
|
sectionState[s] = blankSection()
|
|
}
|
|
ensureSection(section.value)
|
|
},
|
|
{ immediate: true },
|
|
)
|
|
watch(section, (s) => ensureSection(s))
|
|
|
|
// ── 头卡:账号跳聊天 / 返回列表(保留筛选窗口) ──
|
|
function goAccount(a: EmployeeAccount): void {
|
|
if (a.channel === 'whatsapp') router.push(`/chat/whatsapp/${a.account_id}`)
|
|
else router.push(`/staff-chat/${a.account_id}/single`)
|
|
}
|
|
|
|
function backToList(): void {
|
|
router.push({
|
|
name: 'management-employees',
|
|
query: {
|
|
channel: channel.value === 'all' ? undefined : channel.value,
|
|
start: start.value,
|
|
end: end.value,
|
|
compare: compare.value === 'previous' ? undefined : compare.value,
|
|
},
|
|
})
|
|
}
|
|
|
|
// grade 色(与 D1 总览页同 map:A绿/B蓝/C橙/D红/E灰)
|
|
const GRADE_TYPES: Record<string, 'success' | 'primary' | 'warning' | 'danger' | 'info'> = {
|
|
A: 'success', B: 'primary', C: 'warning', D: 'danger', E: 'info',
|
|
}
|
|
function gradeType(g: string | null): 'success' | 'primary' | 'warning' | 'danger' | 'info' {
|
|
return (g && GRADE_TYPES[g]) || 'info'
|
|
}
|
|
|
|
// ── 五维条 ──
|
|
const dimEntries = computed(() => Object.entries(detail.value?.score.dimensions ?? {}))
|
|
|
|
// 待数据维度判定:后端 score.dimensions 的 status 恒为 scored/insufficient/
|
|
// pending 三值(scoring.py)——配置级 pending_data 维不带 metrics 键,可用但
|
|
// 全部无值的维 status 也是 pending 却带 metrics。以 metrics 缺失为准(
|
|
// status==='pending_data' 防御性并含)。
|
|
function dimPendingData(d: { status: string; metrics?: unknown }): boolean {
|
|
return d.status === 'pending_data' || d.metrics == null
|
|
}
|
|
|
|
function dimScoreText(d: { status: string; score: number | null; metrics?: unknown }): string {
|
|
if (dimPendingData(d)) return '待数据(批次E)'
|
|
return d.score == null ? '--' : `${Math.round(d.score)} 分`
|
|
}
|
|
|
|
const weightsLine = computed(() =>
|
|
dimEntries.value
|
|
.map(([, d]) => `${d.name} ${d.weight}%${dimPendingData(d) ? '(待数据)' : ''}`)
|
|
.join(' · '),
|
|
)
|
|
|
|
// ── 取数/格式化 ──
|
|
// detail.metrics 数值键(PeriodMetrics._asdict 透传,user_id 键是字符串,typeof 守卫)
|
|
function mNum(key: string): number | null {
|
|
const v = detail.value?.metrics?.[key]
|
|
return typeof v === 'number' ? v : null
|
|
}
|
|
// section metrics/trend 逐键取值(Record 值域 number|null,缺键→null)
|
|
function colVal(m: Record<string, number | null> | undefined, key: string): number | null {
|
|
return (m ? m[key] : null) ?? null
|
|
}
|
|
function fmtInt(v: number | null): string {
|
|
return v == null ? '--' : v.toLocaleString()
|
|
}
|
|
function fmtAvg(v: number | null): string {
|
|
return v == null ? '--' : v.toLocaleString('zh-CN', { maximumFractionDigits: 1 })
|
|
}
|
|
function fmtSec(s: number | null): string {
|
|
return s && s > 0 ? formatMsDuration(s * 1000) : '--'
|
|
}
|
|
// 单位感知(与指标口径字典 unit 对齐):ms→时长/0-1→比率/余千分位;null→-
|
|
function fmtCmp(v: number | null, unit: string): string {
|
|
if (v == null) return '-'
|
|
if (unit === 'ms') return formatMsDuration(v)
|
|
if (unit === '0-1') return formatRatio(v)
|
|
return v.toLocaleString()
|
|
}
|
|
|
|
// ── 各页签数卡 ──
|
|
const ovTiles = computed(() => [
|
|
{ label: '发出消息', value: fmtInt(mNum('msg_sent')) },
|
|
{ label: '收到消息', value: fmtInt(mNum('msg_recv')) },
|
|
{ label: '活跃客户', value: fmtInt(mNum('active_friends')) },
|
|
{ label: '客户消息串', value: fmtInt(mNum('cust_threads')) },
|
|
{ label: '通话量', value: fmtInt(mNum('call_count')) },
|
|
{ label: '活跃群(日均)', value: fmtAvg(mNum('active_rooms_avg')) },
|
|
])
|
|
|
|
// 服务质量:分位直取 section metrics(fr_p50_ms/fr_p90_ms,D0 已核键名);
|
|
// 回复率客户端派生(reply_*h_count/cust_threads 同源,分母 0/null→无率)
|
|
function svcRate(countKey: 'reply_2h_count' | 'reply_24h_count'): string {
|
|
const m = sectionState.service.resp?.metrics
|
|
const den = colVal(m, 'cust_threads')
|
|
if (!den) return '--'
|
|
return formatRatio((colVal(m, countKey) ?? 0) / den)
|
|
}
|
|
const svcTiles = computed(() => [
|
|
{ label: '首响中位', value: formatMsDuration(colVal(sectionState.service.resp?.metrics, 'fr_p50_ms')) },
|
|
{ label: '首响P90', value: formatMsDuration(colVal(sectionState.service.resp?.metrics, 'fr_p90_ms')) },
|
|
{ label: '2h回复率', value: svcRate('reply_2h_count') },
|
|
{ label: '24h回复率', value: svcRate('reply_24h_count') },
|
|
])
|
|
|
|
const custTiles = computed(() => [
|
|
{ label: '新客(期末)', value: fmtInt(mNum('new_customer')) },
|
|
{ label: '降温(期末)', value: fmtInt(mNum('cooling_customer')) },
|
|
{ label: '沉睡(期末)', value: fmtInt(mNum('dormant_customer')) },
|
|
{ label: '回流(期内)', value: fmtInt(mNum('reactivated_customer')) },
|
|
])
|
|
|
|
const wlTiles = computed(() => [
|
|
{ label: '发出消息', value: fmtInt(mNum('msg_sent')) },
|
|
{ label: '收到消息', value: fmtInt(mNum('msg_recv')) },
|
|
{ label: '活跃客户', value: fmtInt(mNum('active_friends')) },
|
|
{ label: '活跃群(日均)', value: fmtAvg(mNum('active_rooms_avg')) },
|
|
{ label: '通话量', value: fmtInt(mNum('call_count')) },
|
|
])
|
|
|
|
const callTiles = computed(() => [
|
|
{ label: '通话量', value: fmtInt(mNum('call_count')) },
|
|
{ label: '通话时长', value: fmtSec(mNum('call_seconds')) },
|
|
{ label: '接通', value: fmtInt(mNum('answered_call')) },
|
|
{ label: '通话后跟进', value: fmtInt(mNum('call_followup')) },
|
|
])
|
|
|
|
// ── 四对比表(overview 页签):行=源指标名(derive_metrics 键域,冻结契约) ──
|
|
const CMP_META: Record<string, { name: string; unit: string }> = {
|
|
fr_p90_ms: { name: '首响P90', unit: 'ms' },
|
|
active_friends: { name: '活跃客户(周期)', unit: '人' },
|
|
call_count: { name: '通话数', unit: '通' },
|
|
reply_2h_rate: { name: '2h回复率', unit: '0-1' },
|
|
reply_24h_rate: { name: '24h回复率', unit: '0-1' },
|
|
active_share: { name: '活跃客户占比', unit: '0-1' },
|
|
}
|
|
const compareRows = computed(() =>
|
|
Object.entries(sectionState.overview.resp?.comparisons ?? {}).map(([code, c]) => ({
|
|
code,
|
|
name: CMP_META[code]?.name ?? code,
|
|
unit: CMP_META[code]?.unit ?? '',
|
|
current: c.current,
|
|
prev: c.prev,
|
|
baseline: c.baseline,
|
|
median: c.median,
|
|
})),
|
|
)
|
|
|
|
// ── 评分明细:metrics_detail 全表 + 原始数据 ──
|
|
const metricsDetailRows = computed(() =>
|
|
Object.entries(detail.value?.score.metrics_detail ?? {}).map(([code, d]) => ({ code, ...d })),
|
|
)
|
|
const prevJson = computed(() =>
|
|
detail.value?.prev ? JSON.stringify(detail.value.prev, null, 2) : '无上期数据',
|
|
)
|
|
const baseJson = computed(() =>
|
|
detail.value?.baseline ? JSON.stringify(detail.value.baseline, null, 2) : '无基线数据',
|
|
)
|
|
|
|
// ── 下钻抽屉:异常行 + 伪卡片按钮共用 ──
|
|
const drillVisible = ref(false)
|
|
const drillCard = ref<AnomalyCard | null>(null)
|
|
const defsVisible = ref(false)
|
|
|
|
function onAnomalyDrill(card: AnomalyCard): void {
|
|
drillCard.value = card
|
|
drillVisible.value = true
|
|
}
|
|
|
|
// 伪卡片:让 MetricDrilldownDrawer 按 metric_code 走既有白名单下钻
|
|
// (drilldown.type='metric' 非 'employee',不受员工维度兜底拦截)
|
|
function openDrill(title: string, code: DrilldownCode): void {
|
|
drillCard.value = {
|
|
rule_code: code,
|
|
severity: 'warning',
|
|
employee_user_id: props.userId,
|
|
title,
|
|
current_value: 0,
|
|
baseline_value: null,
|
|
unit: '',
|
|
sample_size: 0,
|
|
affected_count: 0,
|
|
reason: '',
|
|
suggested_action: '',
|
|
drilldown: { type: 'metric', query: { metric_code: code, user_id: props.userId } },
|
|
}
|
|
drillVisible.value = true
|
|
}
|
|
|
|
// ── 趋势图(useChart:容器随页签 lazy 挂载,resp 就绪后 post 帧绘制) ──
|
|
const svcTrendEl = ref<HTMLElement | null>(null)
|
|
const wlTrendEl = ref<HTMLElement | null>(null)
|
|
const { render: renderSvcTrend } = useChart(svcTrendEl)
|
|
const { render: renderWlTrend } = useChart(wlTrendEl)
|
|
|
|
// yyyymmdd → 08-10 短格式;大数压缩;ms 轴紧凑时长;X 轴 ~6 刻度(驾驶舱同款)
|
|
const shortDay = (d: number | null | undefined): string =>
|
|
d == null ? '' : `${String(d).slice(4, 6)}-${String(d).slice(6)}`
|
|
const compact = (n: number): string =>
|
|
n >= 10000 ? `${(n / 10000).toFixed(1)}万` : n.toLocaleString()
|
|
const fmtMsAxis = (v: number): string =>
|
|
v >= 3_600_000 ? `${(v / 3_600_000).toFixed(1)}时` : `${Math.round(v / 60_000)}分`
|
|
const axisInterval = (n: number): number => Math.max(0, Math.ceil(n / 6) - 1)
|
|
|
|
const AXIS_LABEL = { color: '#909399' }
|
|
const SPLIT_LINE = { lineStyle: { type: 'solid' as const, color: '#f0f1f3' } }
|
|
|
|
// 首响双线 tooltip:ms→时长(null 显 '-',与断点语义一致)
|
|
interface TipParam { marker?: string; seriesName?: string; value?: number | null; axisValueLabel?: string }
|
|
function msTooltip(ps: unknown): string {
|
|
const arr = (Array.isArray(ps) ? ps : [ps]) as TipParam[]
|
|
if (!arr.length) return ''
|
|
const rows = arr.map((p) => {
|
|
const v = typeof p.value === 'number' ? p.value : null
|
|
return `<div>${p.marker ?? ''}${p.seriesName}: ${v === null ? '-' : formatMsDuration(v)}</div>`
|
|
})
|
|
return `<div>${arr[0].axisValueLabel ?? ''}</div>${rows.join('')}`
|
|
}
|
|
|
|
function drawSvcTrend(r: SectionResp): void {
|
|
const t = r.trend
|
|
if (!t.length) return
|
|
const labels = t.map((p) => shortDay(p.day))
|
|
renderSvcTrend({
|
|
grid: { left: 56, right: 24, top: 40, bottom: 28 },
|
|
tooltip: { trigger: 'axis', formatter: msTooltip },
|
|
legend: { data: ['首响中位', '首响九分位'], top: 4, itemWidth: 14, textStyle: { color: '#606266' } },
|
|
xAxis: {
|
|
type: 'category',
|
|
data: labels,
|
|
axisLabel: { interval: axisInterval(labels.length), hideOverlap: true, ...AXIS_LABEL },
|
|
axisLine: { lineStyle: { color: '#dcdfe6' } },
|
|
axisTick: { show: false },
|
|
},
|
|
yAxis: {
|
|
type: 'value',
|
|
name: '首响',
|
|
axisLabel: { formatter: fmtMsAxis, ...AXIS_LABEL },
|
|
splitLine: SPLIT_LINE,
|
|
},
|
|
series: [
|
|
// null(无样本日)=断点:connectNulls:false 显式钉死,不跨缺口连线
|
|
{
|
|
name: '首响中位',
|
|
type: 'line', smooth: true, showSymbol: false, connectNulls: false,
|
|
lineStyle: { width: 2, color: '#409EFF' },
|
|
itemStyle: { color: '#409EFF' },
|
|
data: t.map((p) => colVal(p, 'first_reply_p50_ms')),
|
|
},
|
|
{
|
|
name: '首响九分位',
|
|
type: 'line', smooth: true, showSymbol: false, connectNulls: false,
|
|
lineStyle: { width: 2, color: '#E6A23C' },
|
|
itemStyle: { color: '#E6A23C' },
|
|
data: t.map((p) => colVal(p, 'first_reply_p90_ms')),
|
|
},
|
|
],
|
|
})
|
|
}
|
|
|
|
function drawWlTrend(r: SectionResp): void {
|
|
const t = r.trend
|
|
if (!t.length) return
|
|
const labels = t.map((p) => shortDay(p.day))
|
|
renderWlTrend({
|
|
grid: { left: 56, right: 24, top: 40, bottom: 28 },
|
|
tooltip: { trigger: 'axis', valueFormatter: (v: unknown) => compact(Number(v)) },
|
|
legend: { data: ['活跃客户', '活跃群'], top: 4, itemWidth: 14, textStyle: { color: '#606266' } },
|
|
xAxis: {
|
|
type: 'category',
|
|
data: labels,
|
|
axisLabel: { interval: axisInterval(labels.length), hideOverlap: true, ...AXIS_LABEL },
|
|
axisLine: { lineStyle: { color: '#dcdfe6' } },
|
|
axisTick: { show: false },
|
|
},
|
|
yAxis: {
|
|
type: 'value',
|
|
axisLabel: { formatter: (v: number) => compact(v), ...AXIS_LABEL },
|
|
splitLine: SPLIT_LINE,
|
|
},
|
|
series: [
|
|
{
|
|
name: '活跃客户',
|
|
type: 'line', smooth: true, showSymbol: false, connectNulls: false,
|
|
lineStyle: { width: 2, color: '#409EFF' },
|
|
itemStyle: { color: '#409EFF' },
|
|
areaStyle: { opacity: 0.06, color: '#409EFF' },
|
|
data: t.map((p) => colVal(p, 'active_friends')),
|
|
},
|
|
{
|
|
name: '活跃群',
|
|
type: 'line', smooth: true, showSymbol: false, connectNulls: false,
|
|
lineStyle: { width: 2, color: '#67C23A' },
|
|
itemStyle: { color: '#67C23A' },
|
|
data: t.map((p) => colVal(p, 'active_rooms')),
|
|
},
|
|
],
|
|
})
|
|
}
|
|
|
|
// resp 单源驱动绘制;post 帧保证 lazy 页签容器已挂载(ensure 仅对激活页签
|
|
// 发请求,resp 就绪时容器必在 DOM)
|
|
watch(() => sectionState.service.resp, (r) => { if (r) drawSvcTrend(r) }, { flush: 'post' })
|
|
watch(() => sectionState.workload.resp, (r) => { if (r) drawWlTrend(r) }, { flush: 'post' })
|
|
</script>
|
|
|
|
<style scoped>
|
|
.top-alert {
|
|
margin-top: 12px;
|
|
}
|
|
.retry-btn {
|
|
margin-left: 12px;
|
|
}
|
|
.head-card {
|
|
margin-top: 12px;
|
|
}
|
|
.head-card :deep(.el-card__body) {
|
|
padding: 16px 20px;
|
|
}
|
|
.head {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 24px;
|
|
flex-wrap: wrap;
|
|
}
|
|
.head-left {
|
|
flex: 1 1 460px;
|
|
min-width: 0;
|
|
}
|
|
.who {
|
|
display: flex;
|
|
align-items: baseline;
|
|
gap: 8px;
|
|
flex-wrap: wrap;
|
|
}
|
|
.name {
|
|
font-size: 24px;
|
|
font-weight: 700;
|
|
color: var(--el-text-color-primary);
|
|
line-height: 1.2;
|
|
}
|
|
.uid {
|
|
font-size: 12px;
|
|
color: var(--el-text-color-secondary);
|
|
word-break: break-all;
|
|
}
|
|
.accounts {
|
|
margin-top: 10px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 14px;
|
|
row-gap: 6px;
|
|
flex-wrap: wrap;
|
|
}
|
|
.acct {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 5px;
|
|
min-width: 0;
|
|
}
|
|
.acct-id {
|
|
font-size: 13px;
|
|
}
|
|
.acct-id :deep(.el-link__inner) {
|
|
max-width: 260px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
display: inline-block;
|
|
vertical-align: bottom;
|
|
}
|
|
.acct-name {
|
|
font-size: 12px;
|
|
color: var(--el-text-color-secondary);
|
|
max-width: 180px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.head-right {
|
|
margin-left: auto;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: flex-end;
|
|
gap: 10px;
|
|
}
|
|
.score-block {
|
|
display: flex;
|
|
align-items: baseline;
|
|
gap: 6px;
|
|
flex-wrap: wrap;
|
|
justify-content: flex-end;
|
|
}
|
|
.score-num {
|
|
font-size: 40px;
|
|
font-weight: 700;
|
|
line-height: 1;
|
|
color: var(--el-text-color-primary);
|
|
}
|
|
.score-num.score-dim {
|
|
color: var(--el-text-color-placeholder);
|
|
}
|
|
.grade-tag {
|
|
margin-left: 2px;
|
|
}
|
|
.trial-mark {
|
|
font-size: 11px;
|
|
color: var(--el-color-warning);
|
|
vertical-align: super;
|
|
}
|
|
.score-label {
|
|
width: 100%;
|
|
text-align: right;
|
|
font-size: 12px;
|
|
color: var(--el-text-color-secondary);
|
|
}
|
|
.head-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
.coach-wrap {
|
|
display: inline-block;
|
|
}
|
|
.dim {
|
|
color: var(--el-text-color-placeholder);
|
|
}
|
|
|
|
/* 五维条 */
|
|
.dims-card {
|
|
margin-top: 12px;
|
|
}
|
|
.card-sub {
|
|
margin-left: 8px;
|
|
font-size: 12px;
|
|
font-weight: 400;
|
|
color: var(--el-text-color-secondary);
|
|
}
|
|
.dim-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
padding: 7px 0;
|
|
}
|
|
.dim-name {
|
|
width: 76px;
|
|
flex: none;
|
|
font-size: 13px;
|
|
color: var(--el-text-color-primary);
|
|
}
|
|
.dim-weight {
|
|
width: 66px;
|
|
flex: none;
|
|
font-size: 12px;
|
|
color: var(--el-text-color-secondary);
|
|
}
|
|
.dim-track {
|
|
flex: 1;
|
|
min-width: 120px;
|
|
height: 10px;
|
|
border-radius: 5px;
|
|
background: var(--el-fill-color-lighter);
|
|
overflow: hidden;
|
|
}
|
|
.dim-bar {
|
|
height: 100%;
|
|
border-radius: 5px;
|
|
background: var(--el-color-primary);
|
|
}
|
|
.dim-bar--pending {
|
|
width: 100%;
|
|
/* 待数据维度:全宽灰条(不冒充分数) */
|
|
background: repeating-linear-gradient(
|
|
-45deg,
|
|
var(--el-fill-color),
|
|
var(--el-fill-color) 6px,
|
|
var(--el-fill-color-light) 6px,
|
|
var(--el-fill-color-light) 12px
|
|
);
|
|
}
|
|
.dim-score {
|
|
width: 92px;
|
|
flex: none;
|
|
text-align: right;
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
color: var(--el-color-primary);
|
|
}
|
|
|
|
/* 页签 */
|
|
.tabs-card {
|
|
margin-top: 12px;
|
|
}
|
|
.tabs-card :deep(.el-card__body) {
|
|
padding: 4px 20px 16px;
|
|
}
|
|
.pane {
|
|
min-height: 120px;
|
|
}
|
|
.pane-alert {
|
|
margin: 10px 0;
|
|
}
|
|
.pane-actions {
|
|
margin: 12px 0;
|
|
display: flex;
|
|
gap: 8px;
|
|
flex-wrap: wrap;
|
|
}
|
|
.tiles {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
|
gap: 12px;
|
|
margin-top: 12px;
|
|
}
|
|
.tile {
|
|
border: 1px solid var(--el-border-color-lighter);
|
|
border-radius: 6px;
|
|
padding: 10px 14px;
|
|
min-width: 0;
|
|
}
|
|
.tile-label {
|
|
font-size: 12px;
|
|
color: var(--el-text-color-secondary);
|
|
}
|
|
.tile-value {
|
|
margin-top: 4px;
|
|
font-size: 22px;
|
|
font-weight: 600;
|
|
color: var(--el-text-color-primary);
|
|
white-space: nowrap;
|
|
}
|
|
.cmp-table {
|
|
margin-top: 14px;
|
|
}
|
|
.chart {
|
|
height: 300px;
|
|
margin-top: 4px;
|
|
}
|
|
|
|
/* 异常区/评分明细 */
|
|
.anomaly-card {
|
|
margin-top: 12px;
|
|
}
|
|
.score-detail {
|
|
margin-top: 12px;
|
|
border-top: 1px solid var(--el-border-color-lighter);
|
|
}
|
|
.score-detail :deep(.el-collapse-item__content) {
|
|
padding-bottom: 8px;
|
|
}
|
|
.weights-line {
|
|
font-size: 12px;
|
|
color: var(--el-text-color-secondary);
|
|
margin: 8px 0;
|
|
}
|
|
.raw-collapse {
|
|
margin-top: 12px;
|
|
border-top: 1px dashed var(--el-border-color-lighter);
|
|
}
|
|
.raw-label {
|
|
font-size: 12px;
|
|
color: var(--el-text-color-secondary);
|
|
margin: 8px 0 4px;
|
|
}
|
|
.raw-json {
|
|
margin: 0;
|
|
padding: 8px 10px;
|
|
font-size: 12px;
|
|
line-height: 1.5;
|
|
background: var(--el-fill-color-light);
|
|
border-radius: 4px;
|
|
color: var(--el-text-color-secondary);
|
|
white-space: pre-wrap;
|
|
word-break: break-all;
|
|
max-height: 320px;
|
|
overflow: auto;
|
|
}
|
|
.cell-sub {
|
|
font-size: 12px;
|
|
color: var(--el-text-color-secondary);
|
|
margin-top: 2px;
|
|
}
|
|
.code {
|
|
font-family: var(--el-font-family, monospace);
|
|
}
|
|
.md-table {
|
|
width: 100%;
|
|
}
|
|
</style>
|