<template>
|
<div class="screen-drill" v-loading="loading">
|
<div v-if="level > 0" class="screen-drill__nav">
|
<el-button text type="primary" size="small" @click="goBack">返回上一级</el-button>
|
<span class="screen-drill__crumb">{{ crumb }}</span>
|
</div>
|
<v-chart
|
:option="option"
|
:update-options="{ notMerge: true }"
|
autoresize
|
class="screen-drill__chart"
|
:class="{ 'is-drilled': level > 0 }"
|
@click="onChartClick"
|
/>
|
</div>
|
</template>
|
|
<script lang="ts" setup>
|
import { computed, ref, watch } from 'vue';
|
import { ElMessage } from 'element-plus';
|
import { useCool } from '/@/cool';
|
import { chartSeries, pieTooltipFormatter, toPieData } from '../data/screen-chart';
|
import { ITEM_COLORS } from '../data/screen-structure';
|
import { useChartTheme, useScreenData } from '../hooks/use-screen';
|
|
type Metric = 'weight' | 'value' | 'count' | 'carbon';
|
type ChartType = 'pie' | 'bar';
|
|
type SourceItem = {
|
name: string;
|
code?: string;
|
color?: string;
|
weight?: number;
|
value?: number;
|
count?: number;
|
carbon?: number;
|
[key: string]: any;
|
};
|
|
type DrillItem = {
|
code?: string;
|
name: string;
|
value: number;
|
color?: string;
|
even?: boolean;
|
id?: number;
|
percent?: number;
|
};
|
|
const props = withDefaults(
|
defineProps<{
|
type: ChartType;
|
source: SourceItem[];
|
metric: Metric;
|
unit?: string;
|
legend?: boolean;
|
center?: [string, string];
|
radius?: [string, string];
|
label?: string;
|
nameSuffix?: string;
|
evenSplit?: boolean;
|
}>(),
|
{
|
unit: '',
|
legend: false,
|
center: () => ['50%', '50%'],
|
radius: () => ['38%', '62%'],
|
label: '{b}\n{d}%',
|
nameSuffix: '',
|
evenSplit: undefined
|
}
|
);
|
|
const { service } = useCool();
|
const { timeRange, loading: screenLoading } = useScreenData();
|
const { textColor, mutedColor, splitColor, primary, tooltip } = useChartTheme();
|
|
const loading = ref(false);
|
const level = ref(0);
|
const categoryCode = ref('');
|
const categoryName = ref('');
|
const categoryColor = ref('');
|
const departmentId = ref<number | null>(null);
|
const departmentName = ref('');
|
const drillItems = ref<DrillItem[]>([]);
|
const deptCache = ref<DrillItem[]>([]);
|
|
const isCarbon = computed(() => props.metric === 'carbon');
|
const evenSplit = computed(() =>
|
props.evenSplit != null ? props.evenSplit : props.type === 'pie'
|
);
|
|
const rootItems = computed<DrillItem[]>(() =>
|
chartSeries(
|
props.source || [],
|
e => Number(e?.[props.metric] || 0),
|
{ carbon: isCarbon.value, evenSplit: evenSplit.value }
|
).map(e => ({
|
code: e.item.code,
|
name: e.item.name + (props.nameSuffix || ''),
|
value: e.value,
|
color: e.item.color,
|
even: e.even
|
}))
|
);
|
|
const rows = computed(() => (level.value === 0 ? rootItems.value : drillItems.value));
|
|
const crumb = computed(() =>
|
[categoryName.value, level.value >= 2 ? departmentName.value : '']
|
.filter(Boolean)
|
.join(' · ')
|
);
|
|
function drillColor(i: number) {
|
if (i === 0 && categoryColor.value) return categoryColor.value;
|
return ITEM_COLORS[i % ITEM_COLORS.length];
|
}
|
|
const option = computed(() => {
|
const list = rows.value;
|
if (props.type === 'pie') {
|
return {
|
tooltip: {
|
trigger: 'item',
|
...tooltip(),
|
formatter: pieTooltipFormatter(props.unit)
|
},
|
legend: props.legend
|
? { bottom: 0, textStyle: { color: mutedColor.value } }
|
: undefined,
|
series: [
|
{
|
type: 'pie',
|
radius: props.radius,
|
center: props.center,
|
padAngle: 3,
|
cursor: level.value >= 2 ? 'default' : 'pointer',
|
itemStyle: { borderRadius: 6 },
|
label: {
|
color: textColor.value,
|
formatter:
|
level.value > 0
|
? `{b}\n{c}${props.unit ? ' ' + props.unit : ''}\n{d}%`
|
: props.label
|
},
|
data: toPieData(
|
list.map(e => ({
|
item: { ...e, color: e.color || primary.value },
|
value: e.value,
|
even: !!e.even
|
})),
|
e => e.name,
|
e => e.color
|
)
|
}
|
]
|
};
|
}
|
|
const barRows = [...list].reverse();
|
const total = barRows.reduce((s, e) => s + (e.even ? 0 : e.value), 0);
|
return {
|
grid: { containLabel: true, left: 8, right: level.value > 0 ? 80 : 24, top: 12, bottom: 8 },
|
tooltip: {
|
trigger: 'axis',
|
axisPointer: { type: 'shadow' },
|
...tooltip(),
|
formatter: (params: any) => {
|
const p = Array.isArray(params) ? params[0] : params;
|
const row = barRows[p?.dataIndex];
|
if (!row) return '';
|
const val = row.even ? 0 : row.value;
|
const u = props.unit ? ` ${props.unit}` : '';
|
const pct =
|
level.value > 0 && total
|
? ` (${((val / total) * 100).toFixed(1)}%)`
|
: '';
|
return `${row.name}<br/>${Number(val || 0).toLocaleString()}${u}${pct}`;
|
}
|
},
|
xAxis: {
|
type: 'value',
|
name: props.unit,
|
nameTextStyle: { color: mutedColor.value },
|
splitLine: { lineStyle: { color: splitColor.value, type: 'dashed' } },
|
axisLabel: { color: mutedColor.value },
|
axisLine: { show: false },
|
axisTick: { show: false }
|
},
|
yAxis: {
|
type: 'category',
|
data: barRows.map(e => e.name),
|
axisLabel: { color: textColor.value },
|
axisLine: { show: false },
|
axisTick: { show: false }
|
},
|
series: [
|
{
|
type: 'bar',
|
barWidth: 14,
|
cursor: level.value >= 2 ? 'default' : 'pointer',
|
label:
|
level.value > 0
|
? {
|
show: true,
|
position: 'right',
|
color: textColor.value,
|
fontSize: 11,
|
formatter: (p: any) => {
|
const val = Number(p.value || 0);
|
const pct = total ? ((val / total) * 100).toFixed(1) : '0.0';
|
const u = props.unit ? ` ${props.unit}` : '';
|
return `${val.toLocaleString()}${u} ${pct}%`;
|
}
|
}
|
: { show: false },
|
data: barRows.map(e => ({
|
value: e.value,
|
name: e.name,
|
code: e.code,
|
id: e.id,
|
even: e.even,
|
itemStyle: {
|
borderRadius: [0, 8, 8, 0],
|
color: e.color || primary.value
|
}
|
}))
|
}
|
]
|
};
|
});
|
|
function reset() {
|
level.value = 0;
|
categoryCode.value = '';
|
categoryName.value = '';
|
categoryColor.value = '';
|
departmentId.value = null;
|
departmentName.value = '';
|
drillItems.value = [];
|
deptCache.value = [];
|
}
|
|
async function requestDrill(deptId?: number) {
|
const payload = {
|
range: timeRange.value,
|
code: categoryCode.value,
|
metric: props.metric,
|
departmentId: deptId
|
};
|
const screen = service.report?.screen;
|
return screen?.drill
|
? await screen.drill(payload)
|
: await service.request({
|
url: 'admin/report/screen/drill',
|
method: 'POST',
|
data: payload
|
});
|
}
|
|
function paint(items: { id: number; name: string; value: number; percent?: number }[]) {
|
return (items || []).map((e, i) => ({
|
id: e.id,
|
name: e.name,
|
value: Number(e.value) || 0,
|
percent: e.percent,
|
color: drillColor(i),
|
even: false,
|
code: categoryCode.value
|
}));
|
}
|
|
async function enterDepartment(item: DrillItem) {
|
if (!item.code) {
|
ElMessage.warning('该类没有可下钻的编码');
|
return;
|
}
|
loading.value = true;
|
try {
|
categoryCode.value = item.code;
|
categoryName.value = item.name;
|
categoryColor.value = item.color || '';
|
const res = await requestDrill();
|
const items = paint(res?.items || []);
|
if (!items.length) {
|
ElMessage.info('该分类暂无部门数据');
|
return;
|
}
|
drillItems.value = items;
|
deptCache.value = items;
|
departmentId.value = null;
|
departmentName.value = '';
|
level.value = 1;
|
} catch (e: any) {
|
ElMessage.error(e?.message || '下钻失败');
|
} finally {
|
loading.value = false;
|
}
|
}
|
|
async function enterSite(item: DrillItem) {
|
if (item.id == null) return;
|
loading.value = true;
|
try {
|
const res = await requestDrill(item.id);
|
const items = paint(res?.items || []);
|
if (!items.length) {
|
ElMessage.info('该部门暂无站点数据');
|
return;
|
}
|
departmentId.value = item.id;
|
departmentName.value = res?.departmentName || item.name;
|
drillItems.value = items;
|
level.value = 2;
|
} catch (e: any) {
|
ElMessage.error(e?.message || '下钻失败');
|
} finally {
|
loading.value = false;
|
}
|
}
|
|
async function reloadLevel() {
|
if (level.value === 1) {
|
loading.value = true;
|
try {
|
const res = await requestDrill();
|
const items = paint(res?.items || []);
|
drillItems.value = items;
|
deptCache.value = items;
|
} finally {
|
loading.value = false;
|
}
|
} else if (level.value === 2 && departmentId.value != null) {
|
loading.value = true;
|
try {
|
const res = await requestDrill(departmentId.value);
|
drillItems.value = paint(res?.items || []);
|
} finally {
|
loading.value = false;
|
}
|
}
|
}
|
|
function onChartClick(params: any) {
|
if (params?.componentType !== 'series') return;
|
if (loading.value || screenLoading.value) return;
|
if (level.value >= 2) return;
|
const data = params.data || {};
|
if (data.even) return;
|
if (level.value === 0) {
|
const hit =
|
rootItems.value.find(e => e.code && e.code === data.code) ||
|
rootItems.value.find(e => e.name === (data.name || params.name));
|
if (!hit || hit.even) return;
|
enterDepartment(hit);
|
return;
|
}
|
const hit =
|
drillItems.value.find(e => e.id != null && e.id === data.id) ||
|
drillItems.value.find(e => e.name === (data.name || params.name));
|
if (!hit) return;
|
enterSite(hit);
|
}
|
|
function goBack() {
|
if (level.value >= 2) {
|
level.value = 1;
|
departmentId.value = null;
|
departmentName.value = '';
|
if (deptCache.value.length) {
|
drillItems.value = deptCache.value;
|
return;
|
}
|
reloadLevel();
|
return;
|
}
|
reset();
|
}
|
|
watch(timeRange, () => reset());
|
watch(
|
() => props.metric,
|
() => {
|
if (level.value > 0) reloadLevel();
|
}
|
);
|
watch(screenLoading, (now, prev) => {
|
if (prev && !now && level.value > 0) reloadLevel();
|
});
|
</script>
|
|
<style lang="scss" scoped>
|
.screen-drill {
|
position: relative;
|
display: flex;
|
flex-direction: column;
|
width: 100%;
|
height: 100%;
|
min-height: inherit;
|
|
&__nav {
|
display: flex;
|
align-items: center;
|
gap: 8px;
|
flex-shrink: 0;
|
padding: 0 8px 4px;
|
min-height: 28px;
|
}
|
|
&__crumb {
|
font-size: 12px;
|
color: var(--el-text-color-secondary);
|
overflow: hidden;
|
text-overflow: ellipsis;
|
white-space: nowrap;
|
}
|
|
&__chart {
|
flex: 1;
|
min-height: 0;
|
width: 100%;
|
height: 100%;
|
}
|
}
|
</style>
|