Files
yunke/yunkes/src/views/management/EmployeeAnalyticsDetailView.vue
T
浅念 577617545f feat(management): 员工详情页——五维条/五页签懒加载/四对比/评分明细/事实下钻复用
- 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>
2026-09-10 12:08:35 +08:00

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>