feat(management): 员工总览页——四视图/服务端排序分页/对比抽屉/CSV口径注释导出
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,169 @@
|
|||||||
|
<template>
|
||||||
|
<el-drawer
|
||||||
|
:model-value="modelValue"
|
||||||
|
title="员工对比"
|
||||||
|
size="620px"
|
||||||
|
@update:model-value="emit('update:modelValue', $event)"
|
||||||
|
>
|
||||||
|
<!-- v1 §6.6:对比只呈现可验证差异,不产生优劣结论(管理者需被提醒) -->
|
||||||
|
<el-alert
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
title="同口径同时窗对比,仅呈现可验证差异,不产生优劣结论"
|
||||||
|
class="cmp-alert"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- ① 维度对比:三可用维度,每人一行横向 bar(0-100;无样本灰「--」) -->
|
||||||
|
<div v-for="dim in DIMS" :key="dim.key" class="dim-block">
|
||||||
|
<div class="dim-title">{{ dim.label }}</div>
|
||||||
|
<div v-for="r in rows" :key="r.user_id" class="dim-row">
|
||||||
|
<span class="dim-name" :title="r.user_id">{{ displayName(r) }}</span>
|
||||||
|
<template v-if="dimScore(r, dim.key) !== null">
|
||||||
|
<div class="dim-track">
|
||||||
|
<div class="dim-bar" :style="{ width: `${dimScore(r, dim.key)}%` }" />
|
||||||
|
</div>
|
||||||
|
<span class="dim-score">{{ Math.round(dimScore(r, dim.key) ?? 0) }}</span>
|
||||||
|
</template>
|
||||||
|
<span v-else class="dim-na">--</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ② 关键指标表:行=指标,列=每人 -->
|
||||||
|
<el-table :data="metricRows" size="small" border class="cmp-table">
|
||||||
|
<el-table-column prop="label" label="指标" min-width="96" />
|
||||||
|
<el-table-column
|
||||||
|
v-for="r in rows"
|
||||||
|
:key="r.user_id"
|
||||||
|
align="right"
|
||||||
|
min-width="88"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<span :title="r.user_id">{{ displayName(r) }}</span>
|
||||||
|
</template>
|
||||||
|
<template #default="{ row }">{{ row.values[r.user_id] }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-drawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// 员工对比抽屉(批次D):总览页勾选 2-5 人后并排看维度分与关键指标。
|
||||||
|
// 只读展示——数据来自已选行快照(EmployeeRow),不二次请求;口径/时窗与
|
||||||
|
// 总览页完全一致(同载荷),故顶部钉 v1 §6.6 提示:仅呈现可验证差异。
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { EmployeeRow } from '@/api/management'
|
||||||
|
import { formatMsDuration, formatRatio } from '@/utils/format'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
/** 已勾选行(2-5,由父级按钮门槛保证);行是勾选时的快照,翻页/重查不影响 */
|
||||||
|
rows: EmployeeRow[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', v: boolean): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// 一期可用三维度(action/risk pending_data 不参与)
|
||||||
|
const DIMS = [
|
||||||
|
{ key: 'customer', label: '客户经营' },
|
||||||
|
{ key: 'service', label: '服务质量' },
|
||||||
|
{ key: 'workload', label: '工作负载' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
function displayName(r: EmployeeRow): string {
|
||||||
|
return r.name || r.user_id.slice(0, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
function dimScore(r: EmployeeRow, key: string): number | null {
|
||||||
|
return r.score.dimensions[key]?.score ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
// metrics 取数值键(user_id 透传键是字符串,typeof 守卫掉)
|
||||||
|
function num(r: EmployeeRow, key: string): number | null {
|
||||||
|
const v = r.metrics[key]
|
||||||
|
return typeof v === 'number' ? v : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function ratio(numV: number | null, den: number | null): string {
|
||||||
|
if (!den) return '--' // 分母 0/null → 无率(严格区分 0%)
|
||||||
|
return formatRatio(numV == null ? null : numV / den)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CmpMetricRow {
|
||||||
|
label: string
|
||||||
|
values: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
const metricRows = computed<CmpMetricRow[]>(() => {
|
||||||
|
const mk = (label: string, fmt: (r: EmployeeRow) => string): CmpMetricRow => {
|
||||||
|
const values: Record<string, string> = {}
|
||||||
|
for (const r of props.rows) values[r.user_id] = fmt(r)
|
||||||
|
return { label, values }
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
mk('活跃客户', (r) => String(num(r, 'active_friends') ?? '--')),
|
||||||
|
mk('消息量', (r) => String((num(r, 'msg_sent') ?? 0) + (num(r, 'msg_recv') ?? 0))),
|
||||||
|
mk('首响P90', (r) => formatMsDuration(num(r, 'fr_p90_ms'))),
|
||||||
|
mk('2h回复率', (r) => ratio(num(r, 'reply_2h_count'), num(r, 'cust_threads'))),
|
||||||
|
mk('异常数', (r) => String(r.anomaly_count)),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.cmp-alert {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.dim-block {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.dim-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.dim-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.dim-name {
|
||||||
|
width: 88px;
|
||||||
|
flex: none;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.dim-track {
|
||||||
|
flex: 1;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.dim-bar {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
.dim-score {
|
||||||
|
width: 28px;
|
||||||
|
flex: none;
|
||||||
|
text-align: right;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
}
|
||||||
|
.dim-na {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
}
|
||||||
|
.cmp-table {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -107,6 +107,9 @@ function logout(): void {
|
|||||||
<el-menu-item index="/management/dashboard" @click="goPlain('/management/dashboard')">
|
<el-menu-item index="/management/dashboard" @click="goPlain('/management/dashboard')">
|
||||||
管理驾驶舱
|
管理驾驶舱
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
|
<el-menu-item index="/management/employees" @click="goPlain('/management/employees')">
|
||||||
|
员工分析
|
||||||
|
</el-menu-item>
|
||||||
</el-sub-menu>
|
</el-sub-menu>
|
||||||
|
|
||||||
<!-- 数据分析 -->
|
<!-- 数据分析 -->
|
||||||
|
|||||||
@@ -99,6 +99,14 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import('@/views/management/ManagementDashboardView.vue'),
|
component: () => import('@/views/management/ManagementDashboardView.vue'),
|
||||||
meta: { title: '管理驾驶舱' },
|
meta: { title: '管理驾驶舱' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// 员工分析(批次D):四视图总览;详情路由 /management/employees/{user_id}
|
||||||
|
// 由批次D后续任务补挂(D2)
|
||||||
|
path: '/management/employees',
|
||||||
|
name: 'management-employees',
|
||||||
|
component: () => import('@/views/management/EmployeeAnalyticsView.vue'),
|
||||||
|
meta: { title: '员工分析' },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/chat/:channel/:ownerId/:talkerId?',
|
path: '/chat/:channel/:ownerId/:talkerId?',
|
||||||
name: 'chat',
|
name: 'chat',
|
||||||
|
|||||||
@@ -10,6 +10,9 @@
|
|||||||
* ],
|
* ],
|
||||||
* filename: '微信客户列表',
|
* filename: '微信客户列表',
|
||||||
* })
|
* })
|
||||||
|
*
|
||||||
|
* Optional `preamble: string[]` writes comment lines (口径/窗口注释等) before
|
||||||
|
* the header — each line occupies the first column of its own row.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface CsvColumn<T> {
|
export interface CsvColumn<T> {
|
||||||
@@ -30,6 +33,12 @@ export interface ExportOptions<T> {
|
|||||||
pageSize?: number
|
pageSize?: number
|
||||||
/** Max rows to export (safety cap, default 10000) */
|
/** Max rows to export (safety cap, default 10000) */
|
||||||
maxRows?: number
|
maxRows?: number
|
||||||
|
/**
|
||||||
|
* Optional comment lines written before the header row (batch D: management
|
||||||
|
* employee CSV carries caliber/window notes so the export is self-describing
|
||||||
|
* outside the app). Each line becomes a single-cell row in the first column.
|
||||||
|
*/
|
||||||
|
preamble?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeCsv(value: string | number | null | undefined): string {
|
function escapeCsv(value: string | number | null | undefined): string {
|
||||||
@@ -43,7 +52,7 @@ function escapeCsv(value: string | number | null | undefined): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function exportListToCsv<T>(options: ExportOptions<T>): Promise<{ rows: number; filename: string }> {
|
export async function exportListToCsv<T>(options: ExportOptions<T>): Promise<{ rows: number; filename: string }> {
|
||||||
const { fetchPage, columns, filename, pageSize = 100, maxRows = 10000 } = options
|
const { fetchPage, columns, filename, pageSize = 100, maxRows = 10000, preamble } = options
|
||||||
|
|
||||||
// Fetch all pages
|
// Fetch all pages
|
||||||
const allRows: T[] = []
|
const allRows: T[] = []
|
||||||
@@ -68,7 +77,7 @@ export async function exportListToCsv<T>(options: ExportOptions<T>): Promise<{ r
|
|||||||
return escapeCsv(val as string | number | null | undefined)
|
return escapeCsv(val as string | number | null | undefined)
|
||||||
}).join(',')
|
}).join(',')
|
||||||
)
|
)
|
||||||
const csv = '' + header + '\n' + lines.join('\n') // BOM for Excel Chinese support
|
const csv = '' + [...(preamble ?? []).map((p) => escapeCsv(p)), header, ...lines].join('\n') // BOM for Excel Chinese support
|
||||||
|
|
||||||
// Trigger download
|
// Trigger download
|
||||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
|
||||||
|
|||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user