129 lines
23 KiB
Vue
129 lines
23 KiB
Vue
<template>
|
||
<div class="performance-page">
|
||
<div class="kra-search-box"><el-form inline @submit.prevent="search">
|
||
<el-form-item label="年份"><el-select v-model="query.year" style="width:110px"><el-option v-for="year in years" :key="year" :label="year" :value="year" /></el-select></el-form-item>
|
||
<el-form-item label="月份区间"><el-select v-model="query.beginMonth" style="width:95px"><el-option v-for="month in 12" :key="month" :label="`${month} 月`" :value="month" /></el-select><span class="mx-2">至</span><el-select v-model="query.endMonth" style="width:95px"><el-option v-for="month in 12" :key="month" :label="`${month} 月`" :value="month" /></el-select></el-form-item>
|
||
<el-form-item label="商务名称"><el-input v-model="query.name" clearable placeholder="名称、账号或负责人" /></el-form-item>
|
||
<el-form-item label="层级"><el-select v-model="query.level" clearable style="width:130px"><el-option v-for="(label,value) in businessLevels" :key="value" :label="label" :value="Number(value)" /></el-select></el-form-item>
|
||
<el-form-item><el-button type="primary" native-type="submit">查询</el-button><el-button @click="reset">重置</el-button><el-button :loading="exporting" @click="download">导出</el-button></el-form-item>
|
||
</el-form></div>
|
||
<div class="performance-cards"><el-card v-for="card in cards" :key="card.label" shadow="never"><el-tooltip :content="card.tip"><span>{{ card.label }}</span></el-tooltip><strong>{{ card.value }}</strong></el-card></div>
|
||
<div class="kra-table-box" v-loading="loading">
|
||
<el-alert v-if="error" :title="error" type="error" :closable="false" class="mb-3" />
|
||
<el-tabs v-model="tab"><el-tab-pane label="商务绩效" name="business" /><el-tab-pane label="趋势图" name="trend" /><el-tab-pane label="小组绩效" name="groups" /></el-tabs>
|
||
<div v-if="tab === 'business'" class="matrix-toolbar"><el-select v-model="groupFilter" clearable @change="load" placeholder="全部小组" style="width:190px"><el-option v-for="group in groupOptions" :key="group.id" :label="group.name" :value="group.id" /></el-select><el-text type="info">每行包含本人及全部下级,组小计和合计按渠道去重;按住表格可拖动查看。</el-text></div>
|
||
<performance-scroll v-if="tab === 'business' && data" vertical>
|
||
<table class="matrix" :style="{ width: `${businessWidth}px` }"><colgroup><col v-for="col in businessColumns" :key="col.key" :style="{ width: `${col.width}px` }" /><template v-for="period in descendingPeriods" :key="period.key"><col v-for="metric in metrics" :key="metric.key" style="width:110px" /></template></colgroup>
|
||
<thead><tr><th v-for="col in businessColumns" :key="col.key" rowspan="2" class="sticky" :style="{ left: `${col.left}px` }" @click="col.sort && sortBy(col.key)"><el-tooltip :content="col.tip || col.label"><span>{{ col.label }}<span v-if="col.sort" class="sort-icon">{{ query.sort === col.key ? query.order === 'asc' ? '▲' : '▼' : '◇' }}</span></span></el-tooltip></th><th v-for="period in descendingPeriods" :key="period.key" colspan="3">{{ period.label }}</th></tr><tr><template v-for="period in descendingPeriods" :key="period.key"><th v-for="metric in metrics" :key="metric.key" :class="metric.key">{{ metric.label }}</th></template></tr></thead>
|
||
<tbody><tr v-for="row in matrixRows" :key="row.key" :class="{ subtotal: row.subtotal, departed: row.member?.employment === 'departed' }">
|
||
<td v-if="row.span" :rowspan="row.span" class="sticky group-cell" style="left:0"><strong>{{ row.groupName || '—' }}</strong><div v-if="row.groupName" class="muted">{{ row.memberCount }} 人</div></td>
|
||
<td class="sticky" :style="{ left: `${businessColumns[1].left}px` }" :title="row.member ? `含 ${row.descendantCount} 个下级商务` : '成员及其下级的渠道并集去重'"><strong>{{ row.name }}</strong><div v-if="row.member" class="muted">{{ businessLevels[row.member.level] }}</div></td>
|
||
<td class="sticky" :style="{ left: `${businessColumns[2].left}px` }">{{ row.member?.hireDate || '—' }}</td><td class="sticky" :style="{ left: `${businessColumns[3].left}px` }">{{ row.member ? employmentLabels[row.member.employment] : '—' }}</td>
|
||
<td v-for="(metric,index) in metrics" :key="metric.key" class="sticky" :class="metric.key" :style="{ left: `${businessColumns[index + 4].left}px` }"><button :disabled="!Number(row.total[metric.key])" @click="drill(row, null, metric.key)">{{ displayMetric(metric.key, row.total[metric.key]) }}</button></td>
|
||
<td class="sticky" :style="{ left: `${businessColumns[7].left}px` }">{{ rateText(effectiveRate(row.total)) }}</td>
|
||
<template v-for="period in descendingPeriods" :key="period.key"><td v-for="metric in metrics" :key="metric.key" :class="metric.key"><button :disabled="!Number(row.periods[period.key]?.[metric.key])" @click="drill(row, period, metric.key)">{{ displayMetric(metric.key, row.periods[period.key]?.[metric.key] ?? 0) }}</button></td></template>
|
||
</tr><tr v-if="!matrixRows.length"><td :colspan="8 + descendingPeriods.length * 3">暂无符合条件的商务</td></tr></tbody>
|
||
<tfoot><tr><td colspan="4" class="sticky" style="left:0">筛选结果去重合计</td><td v-for="(metric,index) in metrics" :key="metric.key" class="sticky" :style="{ left: `${businessColumns[index + 4].left}px` }">{{ displayMetric(metric.key, data?.matrixTotal?.[metric.key] ?? 0) }}</td><td class="sticky" :style="{ left: `${businessColumns[7].left}px` }">{{ data?.matrixRate == null ? '—' : `${data.matrixRate}%` }}</td><template v-for="period in descendingPeriods" :key="period.key"><td v-for="metric in metrics" :key="metric.key">{{ displayMetric(metric.key, data?.matrixTrend?.[period.key]?.[metric.key] ?? 0) }}</td></template></tr></tfoot>
|
||
</table>
|
||
</performance-scroll>
|
||
<template v-if="tab === 'groups' && data">
|
||
<el-text tag="p" type="info" class="mb-3">小组目标独立设置,实际为成员绩效之和;目标可能在月中调整。{{ data?.granularity === 'day' ? '当日目标 = 月目标 ÷ 当月天数;左侧展示当月目标和达成。' : '完成率只纳入已设置相应目标的月份。' }}</el-text>
|
||
<performance-scroll>
|
||
<table class="matrix group-matrix" :style="{ width: `${groupWidth}px` }"><colgroup><col v-for="col in groupColumns" :key="col.key" :style="{ width: `${col.width}px` }" /><template v-for="period in descendingPeriods" :key="period.key"><col style="width:155px" /><col style="width:170px" /></template></colgroup>
|
||
<thead><tr><th v-for="col in groupColumns" :key="col.key" class="sticky" rowspan="2" :style="{ left: `${col.left}px` }">{{ col.label }}</th><th v-for="period in descendingPeriods" :key="period.key" colspan="2">{{ period.label }}</th></tr><tr><template v-for="period in descendingPeriods" :key="period.key"><th>目标</th><th>实际</th></template></tr></thead>
|
||
<tbody><tr v-for="row in teamRows" :key="row.key" :class="{ 'group-heading': !row.member, 'member-row': row.member, departed: row.member?.employment === 'departed' }">
|
||
<td class="sticky" style="left:0"><button v-if="!row.member" class="expand" :aria-expanded="expanded.has(row.id)" @click="toggleGroup(row.id)">{{ expanded.has(row.id) ? '▾' : '▸' }}</button><span v-else class="tree-line">└</span><strong>{{ row.name }}</strong><el-tooltip v-if="row.overlapping" content="组员包含上下级,成员绩效相加会发生重叠"><span class="warning"> ⚠</span></el-tooltip></td>
|
||
<td class="sticky" :style="{ left: `${groupColumns[1].left}px` }">{{ row.member ? employmentLabels[row.member.employment] : `${row.memberCount} 人` }}</td>
|
||
<td v-if="data?.granularity === 'day'" class="sticky" :style="{ left: `${groupColumns[2].left}px` }"><div v-for="metric in metrics" :key="metric.key" class="metric-line"><small>{{ metric.label }}</small><span>{{ displayTarget(metric.goal, row.goals?.[data.begin.slice(0,7)]?.[metric.goal]) }}</span></div></td>
|
||
<td class="sticky" :style="{ left: `${groupColumns[data?.granularity === 'day' ? 3 : 2].left}px` }"><div v-for="metric in metrics" :key="metric.key" class="metric-line"><small>{{ metric.label }}</small><span>{{ displayMetric(metric.key, row.total[metric.key]) }}</span></div></td>
|
||
<td class="sticky" :style="{ left: `${groupColumns.at(-1).left}px` }"><div v-for="metric in metrics" :key="metric.key" class="metric-line" :title="groupRate(row, metric) == null ? '未设置目标' : '有目标月份的实际合计 ÷ 目标合计'"><small>{{ metric.label }}</small><span>{{ rateText(groupRate(row, metric)) }}</span></div></td>
|
||
<template v-for="period in descendingPeriods" :key="period.key"><td class="target-cell"><div v-for="metric in metrics" :key="metric.key" class="metric-line"><small>{{ metric.label }}</small><span>{{ displayTarget(metric.goal, periodTarget(row, period, metric)) }}</span></div></td><td class="actual-cell"><div v-for="metric in metrics" :key="metric.key" class="metric-line"><small>{{ metric.label }}</small><button v-if="hasGoal(row.goals?.[period.key.slice(0,7)])" :disabled="!Number(row.periods[period.key]?.[metric.key])" @click="drill(row, period, metric.key)">{{ displayMetric(metric.key, row.periods[period.key]?.[metric.key] ?? 0) }}</button><span v-else title="未设置目标">—</span></div><div v-if="periodRate(row, period) != null" class="cross-progress"><span><i :class="{ complete: periodRate(row, period) >= 100 }" :style="{ width: `${progressWidth(periodRate(row, period))}%` }" /></span><small>{{ rateText(periodRate(row, period)) }}</small></div></td></template>
|
||
</tr><tr v-if="!teamRows.length"><td :colspan="groupColumns.length + descendingPeriods.length * 2">暂无可见小组</td></tr></tbody>
|
||
</table>
|
||
</performance-scroll>
|
||
</template>
|
||
<div v-if="tab === 'trend' && data" class="trend-layout"><performance-trend :periods="data?.periods || []" :trend="data?.trend || {}" /><div><el-text type="info">共 {{ descendingPeriods.length }} {{ data?.granularity === 'day' ? '天,表内可滚动' : '个月' }}</el-text><el-table :data="trendRows" height="421" class="mt-2"><el-table-column prop="label" :label="data?.granularity === 'day' ? '日期' : '月份'" width="90" /><el-table-column v-for="metric in metrics" :key="metric.key" :label="metric.label" min-width="100"><template #default="{ row }">{{ displayMetric(metric.key, row.values[metric.key]) }}</template></el-table-column><el-table-column label="有效率" width="95"><template #default="{ row }">{{ rateText(effectiveRate(row.values)) }}</template></el-table-column><el-table-column v-for="metric in metrics" :key="`${metric.key}-change`" :label="`${metric.label}环比`" min-width="115"><template #default="{ row }">{{ row.changes[metric.key] }}</template></el-table-column></el-table></div></div>
|
||
</div>
|
||
<performance-detail-drawer v-model="detailOpen" :query="detailQuery" :title="detailTitle" />
|
||
</div>
|
||
</template>
|
||
<script setup>
|
||
import { computed, onMounted, reactive, ref } from 'vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import { getPerformance, exportPerformance } from '@/api/performance'
|
||
import { performanceMetrics as metrics, businessLevels, employmentLabels, displayMember, displayMetric, displayTarget, rateText, completionRate, progressWidth, effectiveRate, hasGoal } from './performance-display'
|
||
import PerformanceScroll from './components/PerformanceScroll.vue'
|
||
import PerformanceTrend from './components/PerformanceTrend.vue'
|
||
import PerformanceDetailDrawer from './components/PerformanceDetailDrawer.vue'
|
||
defineOptions({ name: 'SwBusinessPerformance' })
|
||
const now = new Date(), years = Array.from({ length: now.getFullYear() - 2019 }, (_, i) => now.getFullYear() + 1 - i)
|
||
const query = reactive({ year: now.getFullYear(), beginMonth: now.getMonth() + 1, endMonth: now.getMonth() + 1, name: '', level: undefined, sort: '', order: '' })
|
||
const tab = ref('business'), data = ref(null), loading = ref(false), error = ref(''), exporting = ref(false), groupFilter = ref(), groupOptions = ref([]), expanded = ref(new Set())
|
||
let version = 0
|
||
const params = () => ({ ...query, groupId: groupFilter.value || undefined })
|
||
const loadedParams = ref(null)
|
||
const load = async () => { if (query.beginMonth > query.endMonth) { ElMessage.warning('开始月份不能晚于结束月份'); return } const v = ++version; loading.value = true; error.value = ''; expanded.value = new Set(); try { const requested = params(); const res = await getPerformance(requested); if (v !== version) return; if (res.code !== 0) { error.value = res.msg || '绩效读取失败'; data.value = null; return } if (res.data.available === false) { data.value = null; error.value = '绩效所需业务数据尚未接入'; return } data.value = res.data; loadedParams.value = requested; groupOptions.value = res.data.groups.map(g => g.group) } catch { if (v === version) { error.value = '绩效读取失败,请重试'; data.value = null } } finally { if (v === version) loading.value = false } }
|
||
const search = () => { query.sort = ''; query.order = ''; return load() }
|
||
const reset = () => { query.name = ''; query.level = undefined; return search() }
|
||
const sortBy = key => { if (query.sort !== key) { query.sort = key; query.order = 'asc' } else if (query.order === 'asc') query.order = 'desc'; else { query.sort = ''; query.order = '' } return load() }
|
||
const cards = computed(() => [{ label: '开端数', value: data.value?.total?.open ?? '—', tip: '区间内新接入的渠道数,按渠道创建日期统计。' }, { label: '有效端数', value: data.value?.total?.effective ?? '—', tip: `注册后 ${data.value?.settings?.days ?? 30} 天内成功充值达到 ¥${data.value?.settings?.amount ?? 3000},按达标日期统计。` }, { label: '有效率', value: data.value?.rate == null ? '—' : `${data.value.rate}%`, tip: '有效端 ÷ 开端,跨月达标时单月可能超过100%。' }, { label: '有产出商务数', value: data.value?.productiveMembers ?? '—', tip: '本人直接归属渠道在区间内有开端或有效端的商务人数,不重复计算上级。' }])
|
||
const descendingPeriods = computed(() => [...(data.value?.periods || [])].reverse())
|
||
const withOffsets = columns => { let left = 0; return columns.map(col => { const result = { ...col, left }; left += col.width; return result }) }
|
||
const businessColumns = withOffsets([{ key: 'group', label: '小组', width: 125 }, { key: 'member', label: '商务', width: 150 }, { key: 'hire', label: '入职', width: 110 }, { key: 'employment', label: '在职', width: 80 }, { key: 'open', label: '区间开端', width: 105, sort: true }, { key: 'effective', label: '区间有效端', width: 110, sort: true }, { key: 'pay', label: '区间充值流水', width: 150 }, { key: 'rate', label: '有效率', width: 95, sort: true }])
|
||
const businessWidth = computed(() => businessColumns.reduce((n, c) => n + c.width, 0) + descendingPeriods.value.length * 330)
|
||
const groupColumns = computed(() => withOffsets([{ key: 'name', label: '小组 / 成员', width: 220 }, { key: 'count', label: '人数 / 在职', width: 105 }, ...(data.value?.granularity === 'day' ? [{ key: 'target', label: '目标', width: 160 }] : []), { key: 'actual', label: '达成', width: 165 }, { key: 'rate', label: '完成率', width: 145 }]))
|
||
const groupWidth = computed(() => groupColumns.value.reduce((n, c) => n + c.width, 0) + descendingPeriods.value.length * 325)
|
||
const matrixRows = computed(() => { const result = [], groups = new Map((data.value?.groups || []).map(g => [g.group.id, g])); const blocks = new Map(); for (const row of (data.value?.rows || [])) { const key = row.member.groupId || `none-${row.member.userId}`; if (!blocks.has(key)) blocks.set(key, []); blocks.get(key).push(row) } for (const [id, members] of blocks) { const group = groups.get(id); members.forEach((row, i) => result.push({ ...row, key: `person-${row.member.userId}`, kind: 'person', id: row.member.userId, name: displayMember(row.member), span: group ? i === 0 ? members.length + 1 : 0 : 1, groupName: group?.group.name, memberCount: members.length })); if (group) result.push({ key: `subtotal-${id}`, kind: 'group', id, name: `${group.group.name} 小计`, subtotal: true, memberIds: members.map(m => m.member.userId), total: group.uniqueTotal, periods: group.uniquePeriods }) } return result })
|
||
const toggleGroup = id => { const next = new Set(expanded.value); if (next.has(id)) next.delete(id); else next.add(id); expanded.value = next }
|
||
const teamRows = computed(() => (data.value?.groups || []).flatMap(group => { const row = { key: `group-${group.group.id}`, id: group.group.id, kind: 'team', name: group.group.name, total: group.total, periods: group.periods, goals: group.goals, memberCount: group.group.memberIds.length, overlapping: group.group.overlapping }; return [row, ...(expanded.value.has(group.group.id) ? group.members.map(m => ({ ...m, key: `group-${group.group.id}-person-${m.member.userId}`, id: m.member.userId, kind: 'person', name: displayMember(m.member) })) : [])] }))
|
||
const periodTarget = (row, period, metric) => { const value = row.goals?.[period.key.slice(0,7)]?.[metric.goal]; if (value == null) return null; if (data.value?.granularity !== 'day') return value; const [y, m] = period.key.split('-').map(Number); return value / new Date(y, m, 0).getDate() }
|
||
const periodRate = (row, period) => completionRate(row.periods[period.key]?.pay, periodTarget(row, period, metrics[2]))
|
||
const groupRate = (row, metric) => { let actual = 0, target = 0; const months = new Set(); for (const p of data.value?.periods || []) { const month = p.key.slice(0,7), value = row.goals?.[month]?.[metric.goal]; if (value == null) continue; actual += Number(row.periods[p.key]?.[metric.key] || 0); if (!months.has(month)) { target += value; months.add(month) } } return completionRate(actual, target) }
|
||
const trendRows = computed(() => (data.value?.periods || []).map((p, i, arr) => { const values = data.value.trend[p.key], previous = i ? data.value.trend[arr[i - 1].key] : data.value.prior; return { ...p, values, changes: Object.fromEntries(metrics.map(m => [m.key, previous && Number(previous[m.key]) ? `${((Number(values[m.key]) - Number(previous[m.key])) / Number(previous[m.key]) * 100).toFixed(1)}%` : '—'])) } }).reverse())
|
||
const detailOpen = ref(false), detailQuery = ref(null), detailTitle = ref('')
|
||
const drill = (row, period, metric) => { detailQuery.value = { kind: row.kind, targetId: row.id, memberIds: row.memberIds?.join(','), metric, begin: period?.begin || data.value.begin, end: period?.end || data.value.end }; detailTitle.value = `${row.name} · ${period?.key || '区间'} · ${metrics.find(m => m.key === metric).label}明细`; detailOpen.value = true }
|
||
const download = async () => { if (exporting.value || !data.value || !loadedParams.value || loading.value) return; exporting.value = true; try { const result = await exportPerformance({ ...loadedParams.value, groupId: tab.value === 'business' ? loadedParams.value.groupId : undefined, view: tab.value }); const blob = result instanceof Blob ? result : result.data; if (!(blob instanceof Blob) || blob.type.includes('json')) return; const url = URL.createObjectURL(blob), a = document.createElement('a'); a.href = url; a.download = '商务绩效统计.xlsx'; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000) } finally { exporting.value = false } }
|
||
onMounted(load)
|
||
</script>
|
||
<style scoped>
|
||
.performance-page { min-width: 0; }
|
||
.performance-cards { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 14px; margin-bottom: 16px; }
|
||
.performance-cards span { color: var(--el-text-color-secondary); font-size: 13px; }
|
||
.performance-cards strong { display: block; font-size: 28px; margin-top: 12px; }
|
||
.matrix-toolbar { display: flex; align-items: center; gap: 16px; margin-bottom: 14px; flex-wrap: wrap; }
|
||
.matrix { table-layout: fixed; border-collapse: separate; border-spacing: 0; font-size: 12px; }
|
||
.matrix th, .matrix td { box-sizing: border-box; padding: 10px; border-bottom: 1px solid var(--el-border-color-lighter); border-right: 1px solid var(--el-border-color-lighter); background: var(--el-bg-color); overflow-wrap: anywhere; text-align: right; }
|
||
.matrix th { position: sticky; top: 0; z-index: 3; height: 38px; background: var(--el-fill-color-light); color: var(--el-text-color-regular); cursor: pointer; }
|
||
.matrix thead tr:nth-child(2) th { top: 38px; }
|
||
.matrix .sticky { position: sticky; z-index: 2; }
|
||
.matrix th.sticky { z-index: 5; text-align: left; }
|
||
.matrix td:nth-child(-n+2) { text-align: left; }
|
||
.matrix button { color: inherit; padding: 0; background: none; border: 0; font: inherit; cursor: pointer; }
|
||
.matrix button:disabled { cursor: default; opacity: .65; }
|
||
.matrix button:not(:disabled):hover { text-decoration: underline; }
|
||
.open { color: #4f8cff; }.effective { color: #22b98a; }.pay { color: #b98b1f; }
|
||
.muted, .metric-line small { color: var(--el-text-color-secondary); font-size: 11px; }
|
||
.sort-icon { margin-left: 5px; color: var(--el-color-primary); }
|
||
.subtotal td, tfoot td { font-weight: 600; background: var(--el-fill-color-light) !important; }
|
||
.subtotal td { border-top: 1px dashed var(--el-border-color); }
|
||
.departed { color: var(--el-text-color-placeholder); }
|
||
.group-heading td { background: var(--el-color-primary-light-9); border-top: 2px solid var(--el-border-color); }
|
||
.member-row td { color: var(--el-text-color-secondary); font-size: 11px; }
|
||
.member-row td:first-child { border-left: 3px solid var(--el-color-primary); padding-left: 22px; }
|
||
.tree-line { color: var(--el-color-primary); margin-right: 7px; }
|
||
.expand { width: 22px; height: 22px; border-radius: 4px !important; background: var(--el-color-primary-light-8) !important; color: var(--el-color-primary) !important; margin-right: 8px; }
|
||
.metric-line { display: flex; align-items: start; justify-content: space-between; gap: 6px; min-height: 27px; white-space: nowrap; }
|
||
.group-matrix td { vertical-align: top; }
|
||
.target-cell { padding-bottom: 35px !important; }
|
||
.actual-cell { position: relative; padding-bottom: 35px !important; overflow: visible; }
|
||
.cross-progress { position: absolute; width: 305px; bottom: 10px; right: 10px; display: flex; align-items: center; gap: 6px; z-index: 1; }
|
||
.cross-progress > span { flex: 1; background: var(--el-fill-color-darker); height: 5px; border-radius: 3px; overflow: hidden; }
|
||
.cross-progress i { display: block; height: 100%; background: var(--el-color-primary); }
|
||
.cross-progress i.complete { background: var(--el-color-success); }
|
||
.warning { color: var(--el-color-warning); }
|
||
.trend-layout { display: grid; grid-template-columns: 1.32fr 1fr; gap: 22px; align-items: start; min-width: 0; }
|
||
.trend-layout > * { min-width: 0; }
|
||
@media(max-width:1180px) { .trend-layout { grid-template-columns: 1fr; } }
|
||
@media(max-width:600px) { .performance-cards { grid-template-columns: repeat(2,minmax(0,1fr)); } }
|
||
</style>
|