fix: align business pages and detail drawers with prototype

This commit is contained in:
Yvan
2026-11-05 16:18:39 +08:00
parent 27cacffc7f
commit db11b6eb54
28 changed files with 603 additions and 87 deletions
+13 -8
View File
@@ -11,10 +11,10 @@
<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>
<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>
<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>
@@ -23,7 +23,7 @@
<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>
<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">
@@ -44,7 +44,7 @@
</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" :title="detailTitle" />
<performance-detail-drawer v-model="detailOpen" :query="detailQuery" :context="detailContext" :title="detailTitle" />
</div>
</template>
<script setup>
@@ -73,15 +73,15 @@ const businessColumns = withOffsets([{ key: 'group', label: '小组', width: 125
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 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), 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 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>
@@ -103,7 +103,12 @@ onMounted(load)
.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); }
.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); }