feat(management): 员工总览页——四视图/服务端排序分页/对比抽屉/CSV口径注释导出

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-10 11:44:03 +08:00
parent 602bee8fdf
commit b72e957da2
5 changed files with 909 additions and 2 deletions
@@ -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>
+3
View File
@@ -107,6 +107,9 @@ function logout(): void {
<el-menu-item index="/management/dashboard" @click="goPlain('/management/dashboard')">
管理驾驶舱
</el-menu-item>
<el-menu-item index="/management/employees" @click="goPlain('/management/employees')">
员工分析
</el-menu-item>
</el-sub-menu>
<!-- 数据分析 -->
+8
View File
@@ -99,6 +99,14 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/management/ManagementDashboardView.vue'),
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?',
name: 'chat',
+11 -2
View File
@@ -10,6 +10,9 @@
* ],
* filename: '微信客户列表',
* })
*
* Optional `preamble: string[]` writes comment lines (口径/窗口注释等) before
* the header — each line occupies the first column of its own row.
*/
export interface CsvColumn<T> {
@@ -30,6 +33,12 @@ export interface ExportOptions<T> {
pageSize?: number
/** Max rows to export (safety cap, default 10000) */
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 {
@@ -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 }> {
const { fetchPage, columns, filename, pageSize = 100, maxRows = 10000 } = options
const { fetchPage, columns, filename, pageSize = 100, maxRows = 10000, preamble } = options
// Fetch all pages
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)
}).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
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>