Files
kra-new/web/src/view/sw/BusinessPerformance.vue
T

134 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="performance-page sw-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" :class="query.sort === col.key ? query.order : ''"><i class="sort-up" /><i class="sort-down" /></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" title="当前显示商务及其下级的渠道并集,同一渠道只计一次;随名称、层级和小组筛选变化。">当前筛选合计</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 scrollbar-always-on :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" :context="detailContext" :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} 小计`, groupName: 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), detailContext = 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 }; detailContext.value = { name: row.name, groupName: row.groupName || row.member?.groupName || (row.kind === 'group' || row.kind === 'team' ? row.name : ''), periodKey: period?.key, descendantCount: row.descendantCount, metrics: { ...(period ? row.periods[period.key] : row.total) }, settings: data.value.settings }; 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 { display: inline-flex; flex-direction: column; gap: 2px; margin-left: 6px; vertical-align: middle; }
.sort-icon i { width: 0; height: 0; border-left: 4px solid transparent; border-right: 4px solid transparent; }
.sort-up { border-bottom: 5px solid var(--el-text-color-placeholder); }
.sort-down { border-top: 5px solid var(--el-text-color-placeholder); }
.sort-icon.asc .sort-up { border-bottom-color: var(--el-color-primary); }
.sort-icon.desc .sort-down { border-top-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>