|
|
|
@@ -0,0 +1,718 @@
|
|
|
|
|
<template>
|
|
|
|
|
<div class="emp-analytics">
|
|
|
|
|
<!-- 筛选栏:渠道/时间(URL 真源)+口径入口;aside=对比模式选择器。
|
|
|
|
|
compare 仅客户端显示态:employees 行恒带四对比(current/prev/
|
|
|
|
|
baseline/median),后端 employees 端点也无 compare 入参 -->
|
|
|
|
|
<ManagementFilterBar
|
|
|
|
|
:channel="channel"
|
|
|
|
|
:start="start"
|
|
|
|
|
:end="end"
|
|
|
|
|
:compare="compare"
|
|
|
|
|
@apply="apply"
|
|
|
|
|
@open-defs="defsVisible = true"
|
|
|
|
|
>
|
|
|
|
|
<template #aside>
|
|
|
|
|
<el-select
|
|
|
|
|
:model-value="compare"
|
|
|
|
|
class="compare-select"
|
|
|
|
|
@change="onCompareChange"
|
|
|
|
|
>
|
|
|
|
|
<el-option label="对比:上期" value="previous" />
|
|
|
|
|
<el-option label="对比:个人基线" value="personal_baseline" />
|
|
|
|
|
<el-option label="对比:团队中位数" value="team_median" />
|
|
|
|
|
</el-select>
|
|
|
|
|
</template>
|
|
|
|
|
</ManagementFilterBar>
|
|
|
|
|
|
|
|
|
|
<!-- 工具行:关键词(防抖)/在职状态/四视图;口径入口在筛选栏自带 -->
|
|
|
|
|
<div class="toolbar">
|
|
|
|
|
<el-input
|
|
|
|
|
v-model="keywordInput"
|
|
|
|
|
placeholder="姓名/ID"
|
|
|
|
|
clearable
|
|
|
|
|
:prefix-icon="Search"
|
|
|
|
|
class="kw-input"
|
|
|
|
|
/>
|
|
|
|
|
<el-select :model-value="status" class="status-select" @change="onStatusChange">
|
|
|
|
|
<el-option label="全部" value="all" />
|
|
|
|
|
<el-option label="在职" value="on_job" />
|
|
|
|
|
<el-option label="离线" value="offline" />
|
|
|
|
|
</el-select>
|
|
|
|
|
<el-radio-group :model-value="view" @change="onViewChange">
|
|
|
|
|
<el-radio-button value="management">管理概览</el-radio-button>
|
|
|
|
|
<el-radio-button value="workload">工作负载</el-radio-button>
|
|
|
|
|
<el-radio-button value="service">服务质量</el-radio-button>
|
|
|
|
|
<el-radio-button value="customer">客户经营</el-radio-button>
|
|
|
|
|
</el-radio-group>
|
|
|
|
|
<div class="spacer" />
|
|
|
|
|
<el-button
|
|
|
|
|
type="primary"
|
|
|
|
|
:disabled="selectedRows.length < 2 || selectedRows.length > 5"
|
|
|
|
|
@click="compareVisible = true"
|
|
|
|
|
>对比已选 ({{ selectedRows.length }})</el-button>
|
|
|
|
|
<el-button :loading="exporting" @click="exportCsv">导出CSV</el-button>
|
|
|
|
|
<!-- trial 常显:载荷无页面级 trial 键,批次C/D 评分处于试算期(同驾驶舱) -->
|
|
|
|
|
<DataAsOfBadge
|
|
|
|
|
:data-as-of="resp?.data_as_of ?? null"
|
|
|
|
|
:metric-version="resp?.metric_version"
|
|
|
|
|
:trial="true"
|
|
|
|
|
:config-source="resp?.config_source"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- 错误态:alert+重试;resp 不清空,保留上次成功数据 -->
|
|
|
|
|
<el-alert v-if="loadError" type="error" :closable="false" class="top-alert">
|
|
|
|
|
<template #title>
|
|
|
|
|
<span>{{ loadError }}</span>
|
|
|
|
|
<el-button size="small" type="primary" class="retry-btn" @click="load">重试</el-button>
|
|
|
|
|
</template>
|
|
|
|
|
</el-alert>
|
|
|
|
|
|
|
|
|
|
<!-- 主表:服务端排序/分页(sortable=custom,数据恒随 URL 查询);
|
|
|
|
|
列组随视图切换;多选(跨页保留)供对比抽屉。非排序列头 hover 提示,
|
|
|
|
|
关键列头「?」点击开指标口径抽屉 -->
|
|
|
|
|
<el-table
|
|
|
|
|
ref="tableRef"
|
|
|
|
|
v-loading="loading"
|
|
|
|
|
:data="rows"
|
|
|
|
|
row-key="user_id"
|
|
|
|
|
:default-sort="defaultSort"
|
|
|
|
|
@selection-change="onSelectionChange"
|
|
|
|
|
@sort-change="onSortChange"
|
|
|
|
|
>
|
|
|
|
|
<el-table-column type="selection" width="42" reserve-selection />
|
|
|
|
|
<el-table-column label="姓名" min-width="150">
|
|
|
|
|
<template #default="{ row }">
|
|
|
|
|
<!-- 详情路由批次D后续任务上线;当前点击走通配回 /customers(记录在案) -->
|
|
|
|
|
<el-link type="primary" underline="never" @click="goDetail(row)">
|
|
|
|
|
{{ row.name || row.user_id }}
|
|
|
|
|
</el-link>
|
|
|
|
|
<div class="uid">{{ row.user_id }}</div>
|
|
|
|
|
</template>
|
|
|
|
|
</el-table-column>
|
|
|
|
|
<el-table-column width="64">
|
|
|
|
|
<template #header>
|
|
|
|
|
<el-tooltip content="该列不支持服务端排序" placement="top"><span>在职</span></el-tooltip>
|
|
|
|
|
</template>
|
|
|
|
|
<template #default="{ row }">
|
|
|
|
|
<el-tag v-if="row.is_on_job === true" size="small" type="success">在职</el-tag>
|
|
|
|
|
<el-tag v-else-if="row.is_on_job === false" size="small" type="info">离职</el-tag>
|
|
|
|
|
<span v-else class="dim">--</span>
|
|
|
|
|
</template>
|
|
|
|
|
</el-table-column>
|
|
|
|
|
|
|
|
|
|
<!-- ── 管理概览:评分/异常/规模/维度 ── -->
|
|
|
|
|
<template v-if="view === 'management'">
|
|
|
|
|
<el-table-column
|
|
|
|
|
prop="total_score"
|
|
|
|
|
width="120"
|
|
|
|
|
sortable="custom"
|
|
|
|
|
:sort-orders="['descending', 'ascending']"
|
|
|
|
|
>
|
|
|
|
|
<template #header>
|
|
|
|
|
<span>综合分
|
|
|
|
|
<el-tooltip content="查看指标口径" placement="top">
|
|
|
|
|
<el-icon class="hd-q" @click.stop="defsVisible = true"><QuestionFilled /></el-icon>
|
|
|
|
|
</el-tooltip>
|
|
|
|
|
</span>
|
|
|
|
|
</template>
|
|
|
|
|
<template #default="{ row }">
|
|
|
|
|
<el-tooltip
|
|
|
|
|
v-if="row.score.score_status === 'insufficient_sample'"
|
|
|
|
|
content="样本不足·不参与考核"
|
|
|
|
|
placement="top"
|
|
|
|
|
>
|
|
|
|
|
<span class="dim">--</span>
|
|
|
|
|
</el-tooltip>
|
|
|
|
|
<span v-else class="score-cell">
|
|
|
|
|
{{ Math.round(row.score.total_score ?? 0) }}
|
|
|
|
|
<el-tag
|
|
|
|
|
v-if="row.score.grade"
|
|
|
|
|
size="small"
|
|
|
|
|
:type="gradeType(row.score.grade)"
|
|
|
|
|
class="grade-tag"
|
|
|
|
|
>{{ row.score.grade }}</el-tag>
|
|
|
|
|
<span v-if="row.score.trial" class="trial-mark" title="试算期评分">试</span>
|
|
|
|
|
</span>
|
|
|
|
|
</template>
|
|
|
|
|
</el-table-column>
|
|
|
|
|
<el-table-column
|
|
|
|
|
prop="anomaly_severity"
|
|
|
|
|
label="异常"
|
|
|
|
|
width="90"
|
|
|
|
|
sortable="custom"
|
|
|
|
|
:sort-orders="['descending', 'ascending']"
|
|
|
|
|
>
|
|
|
|
|
<template #default="{ row }">
|
|
|
|
|
<span :class="row.anomaly_count > 0 ? 'anomaly-hot' : 'dim'">{{ row.anomaly_count }}</span>
|
|
|
|
|
<div v-if="row.anomaly_count > 0" class="cell-sub">权重 {{ row.anomaly_weight }}</div>
|
|
|
|
|
</template>
|
|
|
|
|
</el-table-column>
|
|
|
|
|
<el-table-column
|
|
|
|
|
prop="active_friends"
|
|
|
|
|
width="100"
|
|
|
|
|
sortable="custom"
|
|
|
|
|
:sort-orders="['descending', 'ascending']"
|
|
|
|
|
>
|
|
|
|
|
<template #header>
|
|
|
|
|
<span>活跃客户
|
|
|
|
|
<el-tooltip content="查看指标口径" placement="top">
|
|
|
|
|
<el-icon class="hd-q" @click.stop="defsVisible = true"><QuestionFilled /></el-icon>
|
|
|
|
|
</el-tooltip>
|
|
|
|
|
</span>
|
|
|
|
|
</template>
|
|
|
|
|
<template #default="{ row }">{{ fmtInt(num(row, 'active_friends')) }}</template>
|
|
|
|
|
</el-table-column>
|
|
|
|
|
<el-table-column min-width="100">
|
|
|
|
|
<template #header>
|
|
|
|
|
<el-tooltip content="该列不支持服务端排序" placement="top"><span>消息量</span></el-tooltip>
|
|
|
|
|
</template>
|
|
|
|
|
<template #default="{ row }">{{ fmtInt(msgTotal(row)) }}</template>
|
|
|
|
|
</el-table-column>
|
|
|
|
|
<el-table-column min-width="110">
|
|
|
|
|
<template #header>
|
|
|
|
|
<el-tooltip content="该列不支持服务端排序" placement="top"><span>串/被回</span></el-tooltip>
|
|
|
|
|
</template>
|
|
|
|
|
<template #default="{ row }">
|
|
|
|
|
{{ fmtInt(num(row, 'cust_threads')) }} / {{ fmtInt(num(row, 'threads_answered')) }}
|
|
|
|
|
</template>
|
|
|
|
|
</el-table-column>
|
|
|
|
|
<el-table-column min-width="90">
|
|
|
|
|
<template #header>
|
|
|
|
|
<el-tooltip content="该列不支持服务端排序" placement="top"><span>客户经营</span></el-tooltip>
|
|
|
|
|
</template>
|
|
|
|
|
<template #default="{ row }">
|
|
|
|
|
<span :class="dimScore(row, 'customer') == null ? 'dim' : ''">{{ dimScoreText(row, 'customer') }}</span>
|
|
|
|
|
</template>
|
|
|
|
|
</el-table-column>
|
|
|
|
|
<el-table-column min-width="90">
|
|
|
|
|
<template #header>
|
|
|
|
|
<el-tooltip content="该列不支持服务端排序" placement="top"><span>服务质量</span></el-tooltip>
|
|
|
|
|
</template>
|
|
|
|
|
<template #default="{ row }">
|
|
|
|
|
<span :class="dimScore(row, 'service') == null ? 'dim' : ''">{{ dimScoreText(row, 'service') }}</span>
|
|
|
|
|
</template>
|
|
|
|
|
</el-table-column>
|
|
|
|
|
<el-table-column min-width="90">
|
|
|
|
|
<template #header>
|
|
|
|
|
<el-tooltip content="该列不支持服务端排序" placement="top"><span>工作负载</span></el-tooltip>
|
|
|
|
|
</template>
|
|
|
|
|
<template #default="{ row }">
|
|
|
|
|
<span :class="dimScore(row, 'workload') == null ? 'dim' : ''">{{ dimScoreText(row, 'workload') }}</span>
|
|
|
|
|
</template>
|
|
|
|
|
</el-table-column>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<!-- ── 工作负载:规模/消息/通话/串 ── -->
|
|
|
|
|
<template v-else-if="view === 'workload'">
|
|
|
|
|
<el-table-column
|
|
|
|
|
prop="active_friends"
|
|
|
|
|
width="100"
|
|
|
|
|
sortable="custom"
|
|
|
|
|
:sort-orders="['descending', 'ascending']"
|
|
|
|
|
>
|
|
|
|
|
<template #header>
|
|
|
|
|
<span>活跃客户
|
|
|
|
|
<el-tooltip content="查看指标口径" placement="top">
|
|
|
|
|
<el-icon class="hd-q" @click.stop="defsVisible = true"><QuestionFilled /></el-icon>
|
|
|
|
|
</el-tooltip>
|
|
|
|
|
</span>
|
|
|
|
|
</template>
|
|
|
|
|
<template #default="{ row }">{{ fmtInt(num(row, 'active_friends')) }}</template>
|
|
|
|
|
</el-table-column>
|
|
|
|
|
<el-table-column min-width="100">
|
|
|
|
|
<template #header>
|
|
|
|
|
<el-tooltip content="该列不支持服务端排序" placement="top"><span>活跃群(日均)</span></el-tooltip>
|
|
|
|
|
</template>
|
|
|
|
|
<template #default="{ row }">{{ fmtAvg(num(row, 'active_rooms_avg')) }}</template>
|
|
|
|
|
</el-table-column>
|
|
|
|
|
<el-table-column min-width="130">
|
|
|
|
|
<template #header>
|
|
|
|
|
<el-tooltip content="该列不支持服务端排序" placement="top"><span>消息发/收</span></el-tooltip>
|
|
|
|
|
</template>
|
|
|
|
|
<template #default="{ row }">
|
|
|
|
|
{{ fmtInt(num(row, 'msg_sent')) }} / {{ fmtInt(num(row, 'msg_recv')) }}
|
|
|
|
|
</template>
|
|
|
|
|
</el-table-column>
|
|
|
|
|
<el-table-column min-width="130">
|
|
|
|
|
<template #header>
|
|
|
|
|
<el-tooltip content="该列不支持服务端排序" placement="top"><span>通话量/时长</span></el-tooltip>
|
|
|
|
|
</template>
|
|
|
|
|
<template #default="{ row }">
|
|
|
|
|
{{ fmtInt(num(row, 'call_count')) }} / {{ fmtSec(num(row, 'call_seconds')) }}
|
|
|
|
|
</template>
|
|
|
|
|
</el-table-column>
|
|
|
|
|
<el-table-column
|
|
|
|
|
prop="cust_threads"
|
|
|
|
|
label="串"
|
|
|
|
|
width="80"
|
|
|
|
|
sortable="custom"
|
|
|
|
|
:sort-orders="['descending', 'ascending']"
|
|
|
|
|
>
|
|
|
|
|
<template #default="{ row }">{{ fmtInt(num(row, 'cust_threads')) }}</template>
|
|
|
|
|
</el-table-column>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<!-- ── 服务质量:首响分位/回复率/被回串 ── -->
|
|
|
|
|
<template v-else-if="view === 'service'">
|
|
|
|
|
<el-table-column min-width="110">
|
|
|
|
|
<template #header>
|
|
|
|
|
<el-tooltip content="该列不支持服务端排序" placement="top"><span>首响P50</span></el-tooltip>
|
|
|
|
|
</template>
|
|
|
|
|
<template #default="{ row }">{{ formatMsDuration(num(row, 'fr_p50_ms')) }}</template>
|
|
|
|
|
</el-table-column>
|
|
|
|
|
<el-table-column min-width="110">
|
|
|
|
|
<template #header>
|
|
|
|
|
<span>
|
|
|
|
|
<el-tooltip content="该列不支持服务端排序" placement="top"><span>首响P90</span></el-tooltip>
|
|
|
|
|
<el-tooltip content="查看指标口径" placement="top">
|
|
|
|
|
<el-icon class="hd-q" @click.stop="defsVisible = true"><QuestionFilled /></el-icon>
|
|
|
|
|
</el-tooltip>
|
|
|
|
|
</span>
|
|
|
|
|
</template>
|
|
|
|
|
<template #default="{ row }">{{ formatMsDuration(num(row, 'fr_p90_ms')) }}</template>
|
|
|
|
|
</el-table-column>
|
|
|
|
|
<el-table-column min-width="100">
|
|
|
|
|
<template #header>
|
|
|
|
|
<el-tooltip content="该列不支持服务端排序" placement="top"><span>2h回复率</span></el-tooltip>
|
|
|
|
|
</template>
|
|
|
|
|
<template #default="{ row }">{{ replyRate(row, 'reply_2h_count') }}</template>
|
|
|
|
|
</el-table-column>
|
|
|
|
|
<el-table-column min-width="110">
|
|
|
|
|
<template #header>
|
|
|
|
|
<el-tooltip content="该列不支持服务端排序" placement="top"><span>24h回复率</span></el-tooltip>
|
|
|
|
|
</template>
|
|
|
|
|
<template #default="{ row }">{{ replyRate(row, 'reply_24h_count') }}</template>
|
|
|
|
|
</el-table-column>
|
|
|
|
|
<el-table-column min-width="110">
|
|
|
|
|
<template #header>
|
|
|
|
|
<el-tooltip content="该列不支持服务端排序" placement="top"><span>被回/串</span></el-tooltip>
|
|
|
|
|
</template>
|
|
|
|
|
<template #default="{ row }">
|
|
|
|
|
{{ fmtInt(num(row, 'threads_answered')) }} / {{ fmtInt(num(row, 'cust_threads')) }}
|
|
|
|
|
</template>
|
|
|
|
|
</el-table-column>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<!-- ── 客户经营:生命周期四态+活跃占比 ── -->
|
|
|
|
|
<template v-else>
|
|
|
|
|
<el-table-column min-width="80">
|
|
|
|
|
<template #header>
|
|
|
|
|
<el-tooltip content="该列不支持服务端排序" placement="top"><span>新客</span></el-tooltip>
|
|
|
|
|
</template>
|
|
|
|
|
<template #default="{ row }">{{ fmtInt(num(row, 'new_customer')) }}</template>
|
|
|
|
|
</el-table-column>
|
|
|
|
|
<el-table-column min-width="80">
|
|
|
|
|
<template #header>
|
|
|
|
|
<el-tooltip content="该列不支持服务端排序" placement="top"><span>降温</span></el-tooltip>
|
|
|
|
|
</template>
|
|
|
|
|
<template #default="{ row }">{{ fmtInt(num(row, 'cooling_customer')) }}</template>
|
|
|
|
|
</el-table-column>
|
|
|
|
|
<el-table-column min-width="100">
|
|
|
|
|
<template #header>
|
|
|
|
|
<span>
|
|
|
|
|
<el-tooltip content="该列不支持服务端排序" placement="top"><span>沉睡(期末)</span></el-tooltip>
|
|
|
|
|
<el-tooltip content="查看指标口径" placement="top">
|
|
|
|
|
<el-icon class="hd-q" @click.stop="defsVisible = true"><QuestionFilled /></el-icon>
|
|
|
|
|
</el-tooltip>
|
|
|
|
|
</span>
|
|
|
|
|
</template>
|
|
|
|
|
<template #default="{ row }">{{ fmtInt(num(row, 'dormant_customer')) }}</template>
|
|
|
|
|
</el-table-column>
|
|
|
|
|
<el-table-column min-width="100">
|
|
|
|
|
<template #header>
|
|
|
|
|
<el-tooltip content="该列不支持服务端排序" placement="top"><span>回流(期内)</span></el-tooltip>
|
|
|
|
|
</template>
|
|
|
|
|
<template #default="{ row }">{{ fmtInt(num(row, 'reactivated_customer')) }}</template>
|
|
|
|
|
</el-table-column>
|
|
|
|
|
<el-table-column min-width="100">
|
|
|
|
|
<template #header>
|
|
|
|
|
<el-tooltip content="该列不支持服务端排序" placement="top"><span>活跃占比</span></el-tooltip>
|
|
|
|
|
</template>
|
|
|
|
|
<template #default="{ row }">{{ activeShare(row) }}</template>
|
|
|
|
|
</el-table-column>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<template #empty>
|
|
|
|
|
<el-empty :image-size="72" description="无匹配员工——调整关键词/状态" />
|
|
|
|
|
</template>
|
|
|
|
|
</el-table>
|
|
|
|
|
|
|
|
|
|
<el-pagination
|
|
|
|
|
:current-page="page"
|
|
|
|
|
:page-size="PAGE_SIZE"
|
|
|
|
|
:total="total"
|
|
|
|
|
layout="total, prev, pager, next"
|
|
|
|
|
class="pager"
|
|
|
|
|
@current-change="onPageChange"
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
<!-- 对比抽屉(2-5 行)与指标口径抽屉(自缓存) -->
|
|
|
|
|
<EmployeeCompareDrawer v-model="compareVisible" :rows="selectedRows" />
|
|
|
|
|
<MetricDefsDrawer v-model="defsVisible" />
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
// 员工总览(经营管理批次D):四视图列组共用 /management/employees 行集,
|
|
|
|
|
// 服务端排序(仅 综合分/异常/活跃客户/串 四列)+ 服务端分页,query 全量
|
|
|
|
|
// 真源(view/keyword/status/sort_by/sort_order/page),刷新/分享保持。
|
|
|
|
|
// 多选 2-5 人开对比抽屉;CSV 导出当前视图列并带口径注释前置行。
|
|
|
|
|
import { computed, ref, watch } from 'vue'
|
|
|
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
|
|
|
import { ElMessage, type TableInstance } from 'element-plus'
|
|
|
|
|
// QuestionFilled 全局已注册(main.ts 全量挂载),显式导入与项目其他页一致
|
|
|
|
|
import { QuestionFilled, Search } from '@element-plus/icons-vue'
|
|
|
|
|
import {
|
|
|
|
|
getEmployees,
|
|
|
|
|
type EmployeeRow,
|
|
|
|
|
type EmployeesResp,
|
|
|
|
|
} from '@/api/management'
|
|
|
|
|
import { useManagementFilters } from '@/composables/useManagementFilters'
|
|
|
|
|
import { exportListToCsv, type CsvColumn } from '@/utils/csvExport'
|
|
|
|
|
import { formatMsDuration, formatRatio } from '@/utils/format'
|
|
|
|
|
import ManagementFilterBar from '@/components/management/ManagementFilterBar.vue'
|
|
|
|
|
import DataAsOfBadge from '@/components/management/DataAsOfBadge.vue'
|
|
|
|
|
import MetricDefsDrawer from '@/components/management/MetricDefsDrawer.vue'
|
|
|
|
|
import EmployeeCompareDrawer from '@/components/management/EmployeeCompareDrawer.vue'
|
|
|
|
|
|
|
|
|
|
const PAGE_SIZE = 20
|
|
|
|
|
type EmpView = 'management' | 'workload' | 'service' | 'customer'
|
|
|
|
|
type SortBy = 'anomaly_severity' | 'total_score' | 'active_friends' | 'cust_threads'
|
|
|
|
|
const VIEW_VALUES: readonly EmpView[] = ['management', 'workload', 'service', 'customer']
|
|
|
|
|
const SORT_BY_VALUES: readonly SortBy[] = ['anomaly_severity', 'total_score', 'active_friends', 'cust_threads']
|
|
|
|
|
|
|
|
|
|
const route = useRoute()
|
|
|
|
|
const router = useRouter()
|
|
|
|
|
const { channel, start, end, compare, apply } = useManagementFilters()
|
|
|
|
|
|
|
|
|
|
// ── 页面级 query 真源(白名单外回默认;与后端 Literal 校验同域) ──
|
|
|
|
|
const view = computed<EmpView>(() =>
|
|
|
|
|
VIEW_VALUES.includes(route.query.view as EmpView) ? (route.query.view as EmpView) : 'management')
|
|
|
|
|
const keyword = computed(() => (typeof route.query.keyword === 'string' ? route.query.keyword.trim() : ''))
|
|
|
|
|
const status = computed<'all' | 'on_job' | 'offline'>(() =>
|
|
|
|
|
route.query.status === 'on_job' || route.query.status === 'offline' ? route.query.status : 'all')
|
|
|
|
|
const sortBy = computed<SortBy>(() =>
|
|
|
|
|
SORT_BY_VALUES.includes(route.query.sort_by as SortBy) ? (route.query.sort_by as SortBy) : 'anomaly_severity')
|
|
|
|
|
const sortOrder = computed<'asc' | 'desc'>(() => (route.query.sort_order === 'asc' ? 'asc' : 'desc'))
|
|
|
|
|
const page = computed(() => {
|
|
|
|
|
const n = Number(route.query.page)
|
|
|
|
|
return Number.isInteger(n) && n >= 1 ? n : 1
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// ── 加载(seq 守卫沿用驾驶舱/ChurnView 模式) ──
|
|
|
|
|
const resp = ref<EmployeesResp | null>(null)
|
|
|
|
|
const loading = ref(false)
|
|
|
|
|
const loadError = ref<string | null>(null)
|
|
|
|
|
let seq = 0
|
|
|
|
|
|
|
|
|
|
function queryParams(over: Record<string, unknown> = {}): Record<string, unknown> {
|
|
|
|
|
return {
|
|
|
|
|
channel: channel.value,
|
|
|
|
|
start: start.value,
|
|
|
|
|
end: end.value,
|
|
|
|
|
view: view.value,
|
|
|
|
|
keyword: keyword.value || undefined,
|
|
|
|
|
status: status.value,
|
|
|
|
|
sort_by: sortBy.value,
|
|
|
|
|
sort_order: sortOrder.value,
|
|
|
|
|
page: page.value,
|
|
|
|
|
page_size: PAGE_SIZE,
|
|
|
|
|
...over,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function load(): Promise<void> {
|
|
|
|
|
const my = ++seq
|
|
|
|
|
loading.value = true
|
|
|
|
|
loadError.value = null
|
|
|
|
|
try {
|
|
|
|
|
const r = await getEmployees(queryParams())
|
|
|
|
|
if (my !== seq) return
|
|
|
|
|
resp.value = r
|
|
|
|
|
} catch (e) {
|
|
|
|
|
// 失败保留上次成功数据,仅标记 error 供 alert+重试
|
|
|
|
|
if (my === seq) loadError.value = e instanceof Error ? e.message : String(e)
|
|
|
|
|
} finally {
|
|
|
|
|
if (my === seq) loading.value = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
watch([channel, start, end, view, keyword, status, sortBy, sortOrder, page], load, { immediate: true })
|
|
|
|
|
|
|
|
|
|
const rows = computed<EmployeeRow[]>(() => resp.value?.rows ?? [])
|
|
|
|
|
const total = computed(() => resp.value?.total ?? 0)
|
|
|
|
|
|
|
|
|
|
// ── 工具行交互(全部写回 URL;筛选变化重置分页) ──
|
|
|
|
|
const keywordInput = ref(keyword.value)
|
|
|
|
|
let kwTimer: ReturnType<typeof setTimeout> | undefined
|
|
|
|
|
watch(keywordInput, (v) => {
|
|
|
|
|
if (kwTimer) clearTimeout(kwTimer)
|
|
|
|
|
kwTimer = setTimeout(() => {
|
|
|
|
|
if ((v.trim() || '') !== keyword.value) apply({ keyword: v.trim() || undefined, page: undefined })
|
|
|
|
|
}, 300)
|
|
|
|
|
})
|
|
|
|
|
// 外部导航(回退/直链)同步输入框显示
|
|
|
|
|
watch(keyword, (v) => {
|
|
|
|
|
if (v !== keywordInput.value.trim()) keywordInput.value = v
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
function onStatusChange(v: string | number | boolean): void {
|
|
|
|
|
apply({ status: v === 'all' ? undefined : String(v), page: undefined })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 切视图:列组变化,排序回默认(异常降序)、回第一页
|
|
|
|
|
function onViewChange(v: string | number | boolean): void {
|
|
|
|
|
apply({ view: String(v), sort_by: undefined, sort_order: undefined, page: undefined })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function onCompareChange(v: string | number | boolean): void {
|
|
|
|
|
apply({ compare: v === 'previous' ? undefined : String(v) })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function onPageChange(p: number): void {
|
|
|
|
|
apply({ page: p === 1 ? undefined : p })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── 服务端排序:el-table custom 模式(全仓首个),prop 即后端 sort_by 值 ──
|
|
|
|
|
const tableRef = ref<TableInstance>()
|
|
|
|
|
const defaultSort = computed(() => ({
|
|
|
|
|
prop: sortBy.value,
|
|
|
|
|
order: sortOrder.value === 'asc' ? ('ascending' as const) : ('descending' as const),
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
function onSortChange({ prop, order }: { prop: string; order: 'ascending' | 'descending' | null }): void {
|
|
|
|
|
const by = SORT_BY_VALUES.find((v) => v === prop)
|
|
|
|
|
if (!by || !order) return // :sort-orders 两态,order 不会 null,防御
|
|
|
|
|
apply({ sort_by: by, sort_order: order === 'ascending' ? 'asc' : 'desc', page: undefined })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// URL 驱动的排序/视图变化(回退/切视图重置/直链)同步表头指示:sort() 对
|
|
|
|
|
// custom 列只设指示不本地排序(EP watcher sortData 对字符串 sortable 直接
|
|
|
|
|
// 返回原数据);prop 在当前视图无该列时为无害 no-op
|
|
|
|
|
watch([view, sortBy, sortOrder], ([, by, order]) => {
|
|
|
|
|
tableRef.value?.sort(by, order === 'asc' ? 'ascending' : 'descending')
|
|
|
|
|
}, { flush: 'post' })
|
|
|
|
|
|
|
|
|
|
// ── 取数/格式化(metrics 数值键;user_id 透传键是字符串,typeof 守卫) ──
|
|
|
|
|
function num(r: EmployeeRow, key: string): number | null {
|
|
|
|
|
const v = r.metrics[key]
|
|
|
|
|
return typeof v === 'number' ? v : null
|
|
|
|
|
}
|
|
|
|
|
function fmtInt(v: number | null | undefined): string {
|
|
|
|
|
return v == null ? '--' : v.toLocaleString()
|
|
|
|
|
}
|
|
|
|
|
function fmtAvg(v: number | null | undefined): string {
|
|
|
|
|
return v == null ? '--' : v.toLocaleString('zh-CN', { maximumFractionDigits: 1 })
|
|
|
|
|
}
|
|
|
|
|
function fmtSec(s: number | null): string {
|
|
|
|
|
return s && s > 0 ? formatMsDuration(s * 1000) : '-'
|
|
|
|
|
}
|
|
|
|
|
function msgTotal(r: EmployeeRow): number {
|
|
|
|
|
return (num(r, 'msg_sent') ?? 0) + (num(r, 'msg_recv') ?? 0)
|
|
|
|
|
}
|
|
|
|
|
function replyRate(r: EmployeeRow, countKey: 'reply_2h_count' | 'reply_24h_count'): string {
|
|
|
|
|
const den = num(r, 'cust_threads')
|
|
|
|
|
if (!den) return '--' // 分母 0/null → 无率(严格区分 0%)
|
|
|
|
|
return formatRatio((num(r, countKey) ?? 0) / den)
|
|
|
|
|
}
|
|
|
|
|
function activeShare(r: EmployeeRow): string {
|
|
|
|
|
const ea = num(r, 'end_active_friends') ?? 0
|
|
|
|
|
const den = ea + (num(r, 'cooling_customer') ?? 0) + (num(r, 'dormant_customer') ?? 0)
|
|
|
|
|
return den > 0 ? formatRatio(ea / den) : '--'
|
|
|
|
|
}
|
|
|
|
|
function dimScore(r: EmployeeRow, key: string): number | null {
|
|
|
|
|
return r.score.dimensions[key]?.score ?? null
|
|
|
|
|
}
|
|
|
|
|
function dimScoreText(r: EmployeeRow, key: string): string {
|
|
|
|
|
const s = dimScore(r, key)
|
|
|
|
|
return s == null ? '--' : String(Math.round(s))
|
|
|
|
|
}
|
|
|
|
|
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'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function goDetail(row: EmployeeRow): void {
|
|
|
|
|
router.push(`/management/employees/${row.user_id}`)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── 多选 → 对比抽屉(reserve-selection 跨页/重查保留;行是勾选时快照) ──
|
|
|
|
|
const selectedRows = ref<EmployeeRow[]>([])
|
|
|
|
|
const compareVisible = ref(false)
|
|
|
|
|
// 指标口径抽屉(列头「?」与筛选栏「指标口径」共用)
|
|
|
|
|
const defsVisible = ref(false)
|
|
|
|
|
|
|
|
|
|
function onSelectionChange(sel: EmployeeRow[]): void {
|
|
|
|
|
selectedRows.value = sel
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── CSV:当前视图列全量翻页导出,preamble 写口径/窗口注释(Excel 首列) ──
|
|
|
|
|
const exporting = ref(false)
|
|
|
|
|
|
|
|
|
|
const csvColumns = computed<CsvColumn<EmployeeRow>[]>(() => {
|
|
|
|
|
const base: CsvColumn<EmployeeRow>[] = [
|
|
|
|
|
{ key: 'name', label: '姓名', format: (r) => r.name ?? r.user_id },
|
|
|
|
|
{ key: 'user_id', label: 'ID' },
|
|
|
|
|
{
|
|
|
|
|
key: 'is_on_job',
|
|
|
|
|
label: '在职',
|
|
|
|
|
format: (r) => (r.is_on_job === true ? '在职' : r.is_on_job === false ? '离职' : '--'),
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
const dimCol = (key: string, label: string): CsvColumn<EmployeeRow> => ({
|
|
|
|
|
key: `dim_${key}`, label, format: (r) => dimScoreText(r, key),
|
|
|
|
|
})
|
|
|
|
|
if (view.value === 'management') {
|
|
|
|
|
return base.concat([
|
|
|
|
|
{
|
|
|
|
|
key: 'total_score',
|
|
|
|
|
label: '综合分',
|
|
|
|
|
format: (r) =>
|
|
|
|
|
r.score.score_status === 'insufficient_sample' || r.score.total_score == null
|
|
|
|
|
? '--(样本不足)'
|
|
|
|
|
: String(Math.round(r.score.total_score)),
|
|
|
|
|
},
|
|
|
|
|
{ key: 'grade', label: '等级', format: (r) => r.score.grade ?? '--' },
|
|
|
|
|
{ key: 'anomaly_count', label: '异常数', format: (r) => String(r.anomaly_count) },
|
|
|
|
|
{ key: 'anomaly_weight', label: '异常权重', format: (r) => String(r.anomaly_weight) },
|
|
|
|
|
{ key: 'active_friends', label: '活跃客户', format: (r) => fmtInt(num(r, 'active_friends')) },
|
|
|
|
|
{ key: 'msg_total', label: '消息量', format: (r) => fmtInt(msgTotal(r)) },
|
|
|
|
|
{ key: 'cust_threads', label: '串', format: (r) => fmtInt(num(r, 'cust_threads')) },
|
|
|
|
|
{ key: 'threads_answered', label: '被回', format: (r) => fmtInt(num(r, 'threads_answered')) },
|
|
|
|
|
dimCol('customer', '客户经营分'),
|
|
|
|
|
dimCol('service', '服务质量分'),
|
|
|
|
|
dimCol('workload', '工作负载分'),
|
|
|
|
|
])
|
|
|
|
|
}
|
|
|
|
|
if (view.value === 'workload') {
|
|
|
|
|
return base.concat([
|
|
|
|
|
{ key: 'active_friends', label: '活跃客户', format: (r) => fmtInt(num(r, 'active_friends')) },
|
|
|
|
|
{ key: 'active_rooms_avg', label: '活跃群(日均)', format: (r) => fmtAvg(num(r, 'active_rooms_avg')) },
|
|
|
|
|
{ key: 'msg_sent', label: '消息发', format: (r) => fmtInt(num(r, 'msg_sent')) },
|
|
|
|
|
{ key: 'msg_recv', label: '消息收', format: (r) => fmtInt(num(r, 'msg_recv')) },
|
|
|
|
|
{ key: 'call_count', label: '通话量', format: (r) => fmtInt(num(r, 'call_count')) },
|
|
|
|
|
{ key: 'call_seconds', label: '通话秒', format: (r) => String(num(r, 'call_seconds') ?? 0) },
|
|
|
|
|
{ key: 'cust_threads', label: '串', format: (r) => fmtInt(num(r, 'cust_threads')) },
|
|
|
|
|
])
|
|
|
|
|
}
|
|
|
|
|
if (view.value === 'service') {
|
|
|
|
|
return base.concat([
|
|
|
|
|
{ key: 'fr_p50_ms', label: '首响P50', format: (r) => formatMsDuration(num(r, 'fr_p50_ms')) },
|
|
|
|
|
{ key: 'fr_p90_ms', label: '首响P90', format: (r) => formatMsDuration(num(r, 'fr_p90_ms')) },
|
|
|
|
|
{ key: 'reply_2h_rate', label: '2h回复率', format: (r) => replyRate(r, 'reply_2h_count') },
|
|
|
|
|
{ key: 'reply_24h_rate', label: '24h回复率', format: (r) => replyRate(r, 'reply_24h_count') },
|
|
|
|
|
{ key: 'threads_answered', label: '被回', format: (r) => fmtInt(num(r, 'threads_answered')) },
|
|
|
|
|
{ key: 'cust_threads', label: '串', format: (r) => fmtInt(num(r, 'cust_threads')) },
|
|
|
|
|
])
|
|
|
|
|
}
|
|
|
|
|
return base.concat([
|
|
|
|
|
{ key: 'new_customer', label: '新客', format: (r) => fmtInt(num(r, 'new_customer')) },
|
|
|
|
|
{ key: 'cooling_customer', label: '降温', format: (r) => fmtInt(num(r, 'cooling_customer')) },
|
|
|
|
|
{ key: 'dormant_customer', label: '沉睡(期末)', format: (r) => fmtInt(num(r, 'dormant_customer')) },
|
|
|
|
|
{ key: 'reactivated_customer', label: '回流(期内)', format: (r) => fmtInt(num(r, 'reactivated_customer')) },
|
|
|
|
|
{ key: 'active_share', label: '活跃占比', format: (r) => activeShare(r) },
|
|
|
|
|
])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
async function exportCsv(): Promise<void> {
|
|
|
|
|
if (!total.value) {
|
|
|
|
|
ElMessage.warning('当前列表为空,无数据可导出')
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
exporting.value = true
|
|
|
|
|
try {
|
|
|
|
|
const result = await exportListToCsv<EmployeeRow>({
|
|
|
|
|
// 适配器:getEmployees 返回 {rows,total} → csvExport 期望 {items,total}
|
|
|
|
|
fetchPage: (p, size) =>
|
|
|
|
|
getEmployees(queryParams({ page: p, page_size: size })).then((r) => ({ items: r.rows, total: r.total })),
|
|
|
|
|
columns: csvColumns.value,
|
|
|
|
|
filename: `员工分析_${view.value}_${channel.value}`,
|
|
|
|
|
preamble: [
|
|
|
|
|
'口径:1:1私聊·跨日精确串(与数据看板混合口径不同)',
|
|
|
|
|
`窗口:${start.value}-${end.value} 渠道:${channel.value}`,
|
|
|
|
|
`评分为试算(${resp.value?.metric_version ?? 'v1.0'})·样本不足员工不计分`,
|
|
|
|
|
],
|
|
|
|
|
})
|
|
|
|
|
ElMessage.success(`已导出 ${result.rows} 条`)
|
|
|
|
|
} catch (e) {
|
|
|
|
|
ElMessage.error(`导出失败:${e instanceof Error ? e.message : String(e)}`)
|
|
|
|
|
} finally {
|
|
|
|
|
exporting.value = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
.toolbar {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 12px;
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
margin-top: 12px;
|
|
|
|
|
}
|
|
|
|
|
.kw-input {
|
|
|
|
|
width: 200px;
|
|
|
|
|
}
|
|
|
|
|
.compare-select {
|
|
|
|
|
width: 150px;
|
|
|
|
|
}
|
|
|
|
|
.status-select {
|
|
|
|
|
width: 90px;
|
|
|
|
|
}
|
|
|
|
|
.spacer {
|
|
|
|
|
flex: 1;
|
|
|
|
|
}
|
|
|
|
|
.top-alert {
|
|
|
|
|
margin-top: 12px;
|
|
|
|
|
}
|
|
|
|
|
.retry-btn {
|
|
|
|
|
margin-left: 12px;
|
|
|
|
|
}
|
|
|
|
|
.uid {
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
color: var(--el-text-color-secondary);
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
}
|
|
|
|
|
.dim {
|
|
|
|
|
color: var(--el-text-color-placeholder);
|
|
|
|
|
}
|
|
|
|
|
.score-cell {
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
}
|
|
|
|
|
.grade-tag {
|
|
|
|
|
margin-left: 4px;
|
|
|
|
|
}
|
|
|
|
|
.trial-mark {
|
|
|
|
|
margin-left: 2px;
|
|
|
|
|
font-size: 10px;
|
|
|
|
|
color: var(--el-color-warning);
|
|
|
|
|
vertical-align: super;
|
|
|
|
|
}
|
|
|
|
|
.anomaly-hot {
|
|
|
|
|
color: var(--el-color-danger);
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
}
|
|
|
|
|
.cell-sub {
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
color: var(--el-text-color-secondary);
|
|
|
|
|
}
|
|
|
|
|
.hd-q {
|
|
|
|
|
margin-left: 2px;
|
|
|
|
|
color: var(--el-text-color-secondary);
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
vertical-align: -2px;
|
|
|
|
|
}
|
|
|
|
|
.hd-q:hover {
|
|
|
|
|
color: var(--el-color-primary);
|
|
|
|
|
}
|
|
|
|
|
.pager {
|
|
|
|
|
margin-top: 12px;
|
|
|
|
|
display: flex;
|
|
|
|
|
justify-content: flex-end;
|
|
|
|
|
}
|
|
|
|
|
</style>
|