From 5ebdf612b8291106370bba5e79996b28a7452e67 Mon Sep 17 00:00:00 2001
From: wangrong <wangrong@shsening.com>
Date: 星期一, 12 十月 2026 00:04:33 +0800
Subject: [PATCH] 首页图表做下钻

---
 src/modules/report/views/screen/overview.vue         |   37 +-
 src/modules/report/views/screen/carbon.vue           |   35 +-
 src/modules/report/views/screen/resource.vue         |   79 +----
 src/modules/report/views/dailysite.vue               |   20 
 src/modules/report/views/dailydepartment.vue         |   21 
 src/modules/report/data/screen-structure.ts          |    1 
 src/modules/report/components/screen-drill-chart.vue |  426 ++++++++++++++++++++++++++++++++
 src/modules/report/data/screen.ts                    |    2 
 src/modules/report/data/screen-chart.ts              |    2 
 src/modules/report/views/screen/map.vue              |  123 ++------
 10 files changed, 537 insertions(+), 209 deletions(-)

diff --git a/src/modules/report/components/screen-drill-chart.vue b/src/modules/report/components/screen-drill-chart.vue
new file mode 100644
index 0000000..574e685
--- /dev/null
+++ b/src/modules/report/components/screen-drill-chart.vue
@@ -0,0 +1,426 @@
+<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>
diff --git a/src/modules/report/data/screen-chart.ts b/src/modules/report/data/screen-chart.ts
index 6cf1685..8083f4e 100644
--- a/src/modules/report/data/screen-chart.ts
+++ b/src/modules/report/data/screen-chart.ts
@@ -50,6 +50,8 @@
 		name: name(row.item),
 		value: row.value,
 		even: row.even,
+		code: (row.item as any)?.code,
+		id: (row.item as any)?.id,
 		itemStyle: { color: color(row.item) }
 	}));
 }
diff --git a/src/modules/report/data/screen-structure.ts b/src/modules/report/data/screen-structure.ts
index c35f682..9d37e2b 100644
--- a/src/modules/report/data/screen-structure.ts
+++ b/src/modules/report/data/screen-structure.ts
@@ -120,6 +120,7 @@
 		})),
 		recycleItems: RECYCLE_NAMES.map((name, i) => ({
 			name,
+			code: `SW62${String(i + 1).padStart(3, '0')}`,
 			weight: 0,
 			value: 0,
 			carbon: 0,
diff --git a/src/modules/report/data/screen.ts b/src/modules/report/data/screen.ts
index 6c6bde9..dbb90d9 100644
--- a/src/modules/report/data/screen.ts
+++ b/src/modules/report/data/screen.ts
@@ -35,7 +35,7 @@
 	axis: string[];
 	kpis: Record<string, number>;
 	wasteMix: { name: string; code?: string; weight: number; value: number; count: number; color: string }[];
-	recycleItems: { name: string; weight: number; value: number; carbon: number; color: string }[];
+	recycleItems: { name: string; code?: string; weight: number; value: number; carbon: number; color: string }[];
 	trend: { weight: number[]; value: number[]; carbon: number[] };
 	yearCarbon: number[];
 	deptRank: any[];
diff --git a/src/modules/report/views/dailydepartment.vue b/src/modules/report/views/dailydepartment.vue
index e42de03..9cf0c0e 100644
--- a/src/modules/report/views/dailydepartment.vue
+++ b/src/modules/report/views/dailydepartment.vue
@@ -461,25 +461,28 @@
 			sortable: "custom",
 			component: { name: "cl-date-text", props: { format: "YYYY-MM-DD" } }
 		},
-		{ label: t('閮ㄩ棬鍚嶇О'), prop: "departmentName", minWidth: 140 },
+		{ label: t('閮ㄩ棬鍚嶇О'), prop: "departmentName", minWidth: 140, sortable: "custom" },
 		{ label: t('鎬婚噸閲�'), prop: "weightTotal", minWidth: 140, sortable: "custom" },
-		{ label: t('sw60閲嶉噺'), prop: "weightSw60", minWidth: 140, sortable: "custom" },
+		{ label: t('sw64閲嶉噺'), prop: "weightSw64", minWidth: 140, sortable: "custom" },
 		{ label: t('sw61閲嶉噺'), prop: "weightSw61", minWidth: 140, sortable: "custom" },
 		{ label: t('sw62閲嶉噺'), prop: "weightSw62", minWidth: 140, sortable: "custom" },
+		{ label: t('sw60閲嶉噺'), prop: "weightSw60", minWidth: 140, sortable: "custom" },
 		{ label: t('sw63閲嶉噺'), prop: "weightSw63", minWidth: 140, sortable: "custom" },
-		{ label: t('sw64閲嶉噺'), prop: "weightSw64", minWidth: 140, sortable: "custom" },
+
 		{ label: t('鎬荤О閲嶆鏁�'), prop: "timeTotal", minWidth: 140, sortable: "custom" },
-		{ label: t('sw60娆℃暟'), prop: "timeSw60", minWidth: 140, sortable: "custom" },
-		{ label: t('sw61娆℃暟'), prop: "timeSw61", minWidth: 140, sortable: "custom" },
-		{ label: t('sw62娆℃暟'), prop: "timeSw62", minWidth: 140, sortable: "custom" },
-		{ label: t('sw63娆℃暟'), prop: "timeSw63", minWidth: 140, sortable: "custom" },
 		{ label: t('sw64娆℃暟'), prop: "timeSw64", minWidth: 140, sortable: "custom" },
+		{ label: t('sw61娆℃暟'), prop: "timeSw61", minWidth: 140, sortable: "custom" },
+		{ label: t('sw62娆℃暟'), prop: "timeSw62", minWidth: 140, sortable: "custom" },		
+		{ label: t('sw60娆℃暟'), prop: "timeSw60", minWidth: 140, sortable: "custom" },
+		{ label: t('sw63娆℃暟'), prop: "timeSw63", minWidth: 140, sortable: "custom" },
+
 		{ label: t('鎬荤⒊鎺掗噺'), prop: "carbonTotal", minWidth: 140, sortable: "custom" },
-		{ label: t('sw60纰虫帓閲�'), prop: "carbonSw60", minWidth: 140, sortable: "custom" },
+		{ label: t('sw64纰虫帓閲�'), prop: "carbonSw64", minWidth: 140, sortable: "custom" },
 		{ label: t('sw61纰虫帓閲�'), prop: "carbonSw61", minWidth: 140, sortable: "custom" },
 		{ label: t('sw62纰虫帓閲�'), prop: "carbonSw62", minWidth: 140, sortable: "custom" },
+		{ label: t('sw60纰虫帓閲�'), prop: "carbonSw60", minWidth: 140, sortable: "custom" },
 		{ label: t('sw63纰虫帓閲�'), prop: "carbonSw63", minWidth: 140, sortable: "custom" },
-		{ label: t('sw64纰虫帓閲�'), prop: "carbonSw64", minWidth: 140, sortable: "custom" },
+		
 		{
 			label: t('sw62浼扮畻閿�鍞'),
 			prop: "estimatedSalesSw62",
diff --git a/src/modules/report/views/dailysite.vue b/src/modules/report/views/dailysite.vue
index 6073e61..3579180 100644
--- a/src/modules/report/views/dailysite.vue
+++ b/src/modules/report/views/dailysite.vue
@@ -464,26 +464,26 @@
 			sortable: "custom",
 			component: { name: "cl-date-text", props: { format: "YYYY-MM-DD" } }
 		},
-		{ label: t('绔欑偣鍚嶇О'), prop: "siteName", minWidth: 140 },
-		{ label: t('鎵�灞炲崟浣�'), prop: "departmentName", minWidth: 140 },
+		{ label: t('绔欑偣鍚嶇О'), prop: "siteName", minWidth: 140, sortable: "custom" },
+		{ label: t('鎵�灞炲崟浣�'), prop: "departmentName", minWidth: 140, sortable: "custom" },
 		{ label: t('鎬婚噸閲�'), prop: "weightTotal", minWidth: 140, sortable: "custom" },
-		{ label: t('sw60閲嶉噺'), prop: "weightSw60", minWidth: 140, sortable: "custom" },
+		{ label: t('sw64閲嶉噺'), prop: "weightSw64", minWidth: 140, sortable: "custom" },
 		{ label: t('sw61閲嶉噺'), prop: "weightSw61", minWidth: 140, sortable: "custom" },
 		{ label: t('sw62閲嶉噺'), prop: "weightSw62", minWidth: 140, sortable: "custom" },
+		{ label: t('sw60閲嶉噺'), prop: "weightSw60", minWidth: 140, sortable: "custom" },
 		{ label: t('sw63閲嶉噺'), prop: "weightSw63", minWidth: 140, sortable: "custom" },
-		{ label: t('sw64閲嶉噺'), prop: "weightSw64", minWidth: 140, sortable: "custom" },
 		{ label: t('鎬荤О閲嶆鏁�'), prop: "timeTotal", minWidth: 140, sortable: "custom" },
-		{ label: t('sw60娆℃暟'), prop: "timeSw60", minWidth: 140, sortable: "custom" },
-		{ label: t('sw61娆℃暟'), prop: "timeSw61", minWidth: 140, sortable: "custom" },
-		{ label: t('sw62娆℃暟'), prop: "timeSw62", minWidth: 140, sortable: "custom" },
-		{ label: t('sw63娆℃暟'), prop: "timeSw63", minWidth: 140, sortable: "custom" },
 		{ label: t('sw64娆℃暟'), prop: "timeSw64", minWidth: 140, sortable: "custom" },
+		{ label: t('sw61娆℃暟'), prop: "timeSw61", minWidth: 140, sortable: "custom" },
+		{ label: t('sw62娆℃暟'), prop: "timeSw62", minWidth: 140, sortable: "custom" },		
+		{ label: t('sw60娆℃暟'), prop: "timeSw60", minWidth: 140, sortable: "custom" },
+		{ label: t('sw63娆℃暟'), prop: "timeSw63", minWidth: 140, sortable: "custom" },
 		{ label: t('鎬荤⒊鎺掗噺'), prop: "carbonTotal", minWidth: 140, sortable: "custom" },
-		{ label: t('sw60纰虫帓閲�'), prop: "carbonSw60", minWidth: 140, sortable: "custom" },
+		{ label: t('sw64纰虫帓閲�'), prop: "carbonSw64", minWidth: 140, sortable: "custom" },
 		{ label: t('sw61纰虫帓閲�'), prop: "carbonSw61", minWidth: 140, sortable: "custom" },
 		{ label: t('sw62纰虫帓閲�'), prop: "carbonSw62", minWidth: 140, sortable: "custom" },
+		{ label: t('sw60纰虫帓閲�'), prop: "carbonSw60", minWidth: 140, sortable: "custom" },
 		{ label: t('sw63纰虫帓閲�'), prop: "carbonSw63", minWidth: 140, sortable: "custom" },
-		{ label: t('sw64纰虫帓閲�'), prop: "carbonSw64", minWidth: 140, sortable: "custom" },
 		{
 			label: t('sw62浼扮畻閿�鍞'),
 			prop: "estimatedSalesSw62",
diff --git a/src/modules/report/views/screen/carbon.vue b/src/modules/report/views/screen/carbon.vue
index b58c3ec..72d8b00 100644
--- a/src/modules/report/views/screen/carbon.vue
+++ b/src/modules/report/views/screen/carbon.vue
@@ -12,7 +12,17 @@
 				</el-col>
 				<el-col :lg="10" :xs="24">
 					<panel-card title="纰冲噺鎺掓潵婧�" sub="鎸夊彲鍥炴敹瀛愮被鎷嗗垎" :demo="isDemoSection('recycleItems')">
-						<v-chart :option="sourceOption" autoresize class="chart" />
+						<screen-drill-chart
+							class="chart"
+							type="pie"
+							:source="data.recycleItems"
+							metric="carbon"
+							unit="kgCO鈧俥"
+							name-suffix="鍥炴敹"
+							legend
+							:center="['50%', '46%']"
+							:radius="['36%', '60%']"
+						/>
 					</panel-card>
 				</el-col>
 			</el-row>
@@ -46,12 +56,12 @@
 import KpiCard from '../../components/kpi-card.vue';
 import PanelCard from '../../components/panel-card.vue';
 import { TIME_RANGE_OPTIONS } from '../../data/screen';
-import { chartSeries, pieTooltipFormatter, toPieData } from '../../data/screen-chart';
+import ScreenDrillChart from '../../components/screen-drill-chart.vue';
 import { useChartTheme, useScreenData, useScreenFieldSource } from '../../hooks/use-screen';
 
 const { data, timeRange } = useScreenData();
 const { isDemoSection } = useScreenFieldSource();
-const { textColor, mutedColor, splitColor, primary, baseGrid, axisStyle, tooltip } = useChartTheme();
+const { mutedColor, splitColor, primary, baseGrid, axisStyle, tooltip } = useChartTheme();
 
 const rangeLabel = computed(
 	() => TIME_RANGE_OPTIONS.find(e => e.value === timeRange.value)?.label || '鏈尯闂�'
@@ -127,25 +137,6 @@
 	]
 }));
 
-const sourceOption = computed(() => ({
-	tooltip: { trigger: 'item', ...tooltip(), formatter: pieTooltipFormatter('kgCO鈧俥') },
-	legend: { bottom: 0, textStyle: { color: mutedColor.value } },
-	series: [
-		{
-			type: 'pie',
-			radius: ['36%', '60%'],
-			center: ['50%', '46%'],
-			padAngle: 3,
-			itemStyle: { borderRadius: 6 },
-			label: { color: textColor.value, formatter: '{b}\n{d}%' },
-			data: toPieData(
-				chartSeries(data.value.recycleItems, e => e.carbon, { carbon: true }),
-				e => e.name + '鍥炴敹',
-				e => e.color
-			)
-		}
-	]
-}));
 </script>
 
 <style lang="scss" scoped>
diff --git a/src/modules/report/views/screen/map.vue b/src/modules/report/views/screen/map.vue
index 0582eda..b626f2f 100755
--- a/src/modules/report/views/screen/map.vue
+++ b/src/modules/report/views/screen/map.vue
@@ -17,7 +17,15 @@
 					<template #extra>
 						<cl-select-button v-model="mixMetric" :options="mixOptions" small />
 					</template>
-					<v-chart :option="mixOption" autoresize class="chart" />
+					<screen-drill-chart
+						class="chart"
+						type="pie"
+						:source="data.wasteMix"
+						:metric="mixMetric"
+						:unit="mixUnit"
+						:center="['50%', '50%']"
+						:radius="['38%', '62%']"
+					/>
 				</panel-card>
 
 				<panel-card
@@ -25,7 +33,16 @@
 					:demo="isDemoSection('recycleItems')"
 					class="dash-card dash-card--flex"
 				>
-					<v-chart :option="carbonOption" autoresize class="chart" />
+					<screen-drill-chart
+						class="chart"
+						type="pie"
+						:source="data.recycleItems"
+						metric="carbon"
+						unit="kgCO鈧俥"
+						name-suffix="鍥炴敹"
+						:center="['50%', '50%']"
+						:radius="['36%', '60%']"
+					/>
 				</panel-card>
 			</div>
 
@@ -57,7 +74,14 @@
 					<template #extra>
 						<cl-select-button v-model="itemMetric" :options="itemOptions" small />
 					</template>
-					<v-chart :option="itemOption" autoresize class="chart chart--items" />
+					<screen-drill-chart
+						class="chart chart--items"
+						type="bar"
+						:source="data.recycleItems"
+						:metric="itemMetric"
+						:unit="itemUnit"
+						:even-split="false"
+					/>
 				</panel-card>
 			</div>
 		</div>
@@ -85,12 +109,12 @@
 import ScreenMapPanel from '../../components/screen-map-panel.vue';
 import CameraMonitor from '../../../monitor/views/cameras.vue';
 import { TIME_RANGE_OPTIONS } from '../../data/screen';
-import { chartSeries, pieTooltipFormatter, toPieData } from '../../data/screen-chart';
+import ScreenDrillChart from '../../components/screen-drill-chart.vue';
 import { useChartTheme, useScreenData, useScreenFieldSource } from '../../hooks/use-screen';
 
 const { data, timeRange } = useScreenData();
 const { isDemoSection } = useScreenFieldSource();
-const { textColor, mutedColor, splitColor, primary, baseGrid, axisStyle, tooltip } = useChartTheme();
+const { mutedColor, splitColor, primary, baseGrid, axisStyle, tooltip } = useChartTheme();
 
 const mixMetric = ref<'weight' | 'value' | 'count'>('weight');
 const trendMetric = ref<'weight' | 'value' | 'carbon'>('weight');
@@ -141,45 +165,11 @@
 	return 'kg';
 });
 
-/** 鍨冨溇鍒嗙被缁撴瀯锛堥ゼ锛� */
-const mixOption = computed(() => ({
-	tooltip: { trigger: 'item', ...tooltip(), formatter: pieTooltipFormatter() },
-	series: [
-		{
-			type: 'pie',
-			radius: ['38%', '62%'],
-			center: ['50%', '50%'],
-			padAngle: 3,
-			itemStyle: { borderRadius: 6 },
-			label: { color: textColor.value, formatter: '{b}\n{d}%' },
-			data: toPieData(
-				chartSeries(data.value.wasteMix, e => e[mixMetric.value]),
-				e => e.name,
-				e => e.color
-			)
-		}
-	]
-}));
-
-/** 纰冲噺鎺掓潵婧愶紙楗硷級锛氱⒊鍊煎彲涓鸿礋锛屾寜缁濆鍊煎睍绀哄噺鎺掗噺 */
-const carbonOption = computed(() => ({
-	tooltip: { trigger: 'item', ...tooltip(), formatter: pieTooltipFormatter('kgCO鈧俥') },
-	series: [
-		{
-			type: 'pie',
-			radius: ['36%', '60%'],
-			center: ['50%', '50%'],
-			padAngle: 3,
-			itemStyle: { borderRadius: 6 },
-			label: { color: textColor.value, formatter: '{b}\n{d}%' },
-			data: toPieData(
-				chartSeries(data.value.recycleItems, e => e.carbon, { carbon: true }),
-				e => e.name + '鍥炴敹',
-				e => e.color
-			)
-		}
-	]
-}));
+const mixUnit = computed(() => {
+	if (mixMetric.value === 'value') return '鍏�';
+	if (mixMetric.value === 'count') return '娆�';
+	return 'kg';
+});
 
 /** 鍥炴敹瓒嬪娍锛堟姌绾匡級 */
 const trendOption = computed(() => ({
@@ -207,51 +197,6 @@
 	]
 }));
 
-/** 鍚勭被鍥炴敹閲忥紙妯悜鏉″舰锛� */
-const itemOption = computed(() => {
-	const carbon = itemMetric.value === 'carbon';
-	const rows = chartSeries(
-		data.value.recycleItems,
-		e => Number(e[itemMetric.value] || 0),
-		{ carbon, evenSplit: false }
-	).reverse();
-	return {
-		grid: { containLabel: true, left: 8, right: 24, top: 12, bottom: 8 },
-		tooltip: {
-			trigger: 'axis',
-			axisPointer: { type: 'shadow' },
-			valueFormatter: (v: number) => `${Number(v || 0).toLocaleString()} ${itemUnit.value}`,
-			...tooltip()
-		},
-		xAxis: {
-			type: 'value',
-			name: itemUnit.value,
-			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: rows.map(e => e.item.name),
-			axisLabel: { color: textColor.value },
-			axisLine: { show: false },
-			axisTick: { show: false }
-		},
-		series: [
-			{
-				type: 'bar',
-				barWidth: 14,
-				data: rows.map(e => e.value),
-				itemStyle: {
-					borderRadius: [0, 8, 8, 0],
-					color: (params: any) => rows[params.dataIndex]?.item.color || primary.value
-				}
-			}
-		]
-	};
-});
 </script>
 
 <style lang="scss" scoped>
diff --git a/src/modules/report/views/screen/overview.vue b/src/modules/report/views/screen/overview.vue
index 2c12d09..0051a15 100644
--- a/src/modules/report/views/screen/overview.vue
+++ b/src/modules/report/views/screen/overview.vue
@@ -10,7 +10,16 @@
 					<template #extra>
 						<cl-select-button v-model="mixMetric" :options="mixOptions" small />
 					</template>
-					<v-chart :option="mixOption" autoresize class="chart" />
+					<screen-drill-chart
+						class="chart"
+						type="pie"
+						:source="data.wasteMix"
+						:metric="mixMetric"
+						:unit="mixUnit"
+						legend
+						:center="['50%', '46%']"
+						:radius="['38%', '62%']"
+					/>
 				</panel-card>
 			</el-col>
 			<el-col :lg="10" :xs="24">
@@ -52,7 +61,7 @@
 import PanelCard from '../../components/panel-card.vue';
 import ScreenCameraPreview from '../../components/screen-camera-preview.vue';
 import { TIME_RANGE_OPTIONS } from '../../data/screen';
-import { chartSeries, pieTooltipFormatter, toPieData } from '../../data/screen-chart';
+import ScreenDrillChart from '../../components/screen-drill-chart.vue';
 import { useChartTheme, useScreenData, useScreenFieldSource } from '../../hooks/use-screen';
 
 const { data, timeRange } = useScreenData();
@@ -88,25 +97,11 @@
 	{ field: 'kpis.totalParticipants', label: '绱鍙備笌浜烘暟', value: data.value.kpis.totalParticipants, suffix: '浜�', fixed: 0, hint: '鍘婚噸鏍″洯鐢ㄦ埛' }
 ]);
 
-const mixOption = computed(() => ({
-	tooltip: { trigger: 'item', ...tooltip(), formatter: pieTooltipFormatter() },
-	legend: { bottom: 0, textStyle: { color: mutedColor.value } },
-	series: [
-		{
-			type: 'pie',
-			radius: ['38%', '62%'],
-			center: ['50%', '46%'],
-			padAngle: 3,
-			itemStyle: { borderRadius: 6 },
-			label: { color: textColor.value, formatter: '{b}\n{d}%' },
-			data: toPieData(
-				chartSeries(data.value.wasteMix, e => e[mixMetric.value]),
-				e => e.name,
-				e => e.color
-			)
-		}
-	]
-}));
+const mixUnit = computed(() => {
+	if (mixMetric.value === 'value') return '鍏�';
+	if (mixMetric.value === 'count') return '娆�';
+	return 'kg';
+});
 
 const trendHint = computed(() => {
 	const map = { today: '杩� 7 鏃�', week: '鎸夋棩', month: '鎸夋棩', quarter: '鎸夋棩', year: '鎸夋湀' };
diff --git a/src/modules/report/views/screen/resource.vue b/src/modules/report/views/screen/resource.vue
index cc65457..8e0c854 100644
--- a/src/modules/report/views/screen/resource.vue
+++ b/src/modules/report/views/screen/resource.vue
@@ -7,12 +7,29 @@
 			<el-row :gutter="10" class="mid-row">
 				<el-col :lg="10" :xs="24">
 					<panel-card title="鍙洖鏀剁墿鍝佺被鍗犳瘮" sub="鍚屼竴鏃堕棿鑼冨洿锛岀湅缁撴瀯" :demo="isDemoSection('recycleItems')">
-						<v-chart :option="pieOption" autoresize class="chart" />
+						<screen-drill-chart
+							class="chart"
+							type="pie"
+							:source="data.recycleItems"
+							metric="weight"
+							unit="kg"
+							legend
+							:center="['50%', '46%']"
+							:radius="['36%', '60%']"
+							label="{b} {d}%"
+						/>
 					</panel-card>
 				</el-col>
 				<el-col :lg="14" :xs="24">
 					<panel-card title="鍚勭被鍥炴敹閲�" sub="妯悜鏌辩姸鍥炬洿閫傚悎闀垮搧绫诲悕" :demo="isDemoSection('recycleItems')">
-						<v-chart :option="weightOption" autoresize class="chart" />
+						<screen-drill-chart
+							class="chart"
+							type="bar"
+							:source="data.recycleItems"
+							metric="weight"
+							unit="kg"
+							:even-split="false"
+						/>
 					</panel-card>
 				</el-col>
 			</el-row>
@@ -35,12 +52,13 @@
 import { computed } from 'vue';
 import KpiCard from '../../components/kpi-card.vue';
 import PanelCard from '../../components/panel-card.vue';
-import { chartSeries, pieTooltipFormatter, toPieData } from '../../data/screen-chart';
+import ScreenDrillChart from '../../components/screen-drill-chart.vue';
+import { chartSeries } from '../../data/screen-chart';
 import { useChartTheme, useScreenData, useScreenFieldSource } from '../../hooks/use-screen';
 
 const { data } = useScreenData();
 const { isDemoSection } = useScreenFieldSource();
-const { textColor, mutedColor, splitColor, primary, tooltip } = useChartTheme();
+const { mutedColor, splitColor, primary, tooltip } = useChartTheme();
 
 const kpis = computed(() => [
 	{ field: 'kpis.periodWeightTon', label: '鎬婚噺', value: data.value.kpis.periodWeightTon, suffix: '鍚�', fixed: 2, hint: '鍏ㄩ儴鍒嗙被閲嶉噺鍚堣' },
@@ -50,59 +68,6 @@
 	{ field: 'kpis.fabricTon', label: '搴熸棫缁囩墿', value: data.value.kpis.fabricTon, suffix: '鍚�', fixed: 2, hint: '鍙洖鏀跺瓙绫�' }
 ]);
 
-const pieOption = computed(() => ({
-	tooltip: { trigger: 'item', ...tooltip(), formatter: pieTooltipFormatter('kg') },
-	legend: { bottom: 0, textStyle: { color: mutedColor.value } },
-	series: [
-		{
-			type: 'pie',
-			radius: ['36%', '60%'],
-			center: ['50%', '46%'],
-			padAngle: 3,
-			itemStyle: { borderRadius: 6 },
-			label: { color: textColor.value, formatter: '{b} {d}%' },
-			data: toPieData(
-				chartSeries(data.value.recycleItems, e => e.weight),
-				e => e.name,
-				e => e.color
-			)
-		}
-	]
-}));
-
-const weightRows = computed(() =>
-	chartSeries(data.value.recycleItems, e => e.weight, { evenSplit: false }).reverse()
-);
-
-const weightOption = computed(() => ({
-	grid: { containLabel: true, left: 8, right: 28, top: 12, bottom: 8 },
-	tooltip: { trigger: 'axis', ...tooltip() },
-	xAxis: {
-		type: 'value',
-		name: 'kg',
-		splitLine: { lineStyle: { color: splitColor.value, type: 'dashed' } },
-		axisLabel: { color: mutedColor.value },
-		axisLine: { show: false },
-		axisTick: { show: false }
-	},
-	yAxis: {
-		type: 'category',
-		data: weightRows.value.map(e => e.item.name),
-		axisLabel: { color: textColor.value },
-		axisLine: { show: false },
-		axisTick: { show: false }
-	},
-	series: [
-		{
-			type: 'bar',
-			barWidth: 16,
-			data: weightRows.value.map(e => ({
-				value: e.value,
-				itemStyle: { color: e.item.color, borderRadius: [0, 8, 8, 0] }
-			}))
-		}
-	]
-}));
 
 const valueRows = computed(() =>
 	chartSeries(data.value.recycleItems, e => e.value, { evenSplit: false })

--
Gitblit v1.9.1