产出品管理 - 产出品概览页面饼图展示开发

This commit is contained in:
郭永超 2025-07-10 15:26:24 +08:00
parent f9f83986df
commit b741c624f2
10 changed files with 607 additions and 6 deletions

View File

@ -10,6 +10,7 @@ declare module 'vue' {
AreaCascader: typeof import('./src/components/AreaCascader/index.vue')['default'] AreaCascader: typeof import('./src/components/AreaCascader/index.vue')['default']
AreaSelect: typeof import('./src/components/AreaSelect/index.vue')['default'] AreaSelect: typeof import('./src/components/AreaSelect/index.vue')['default']
CodeDialog: typeof import('./src/components/code-dialog/index.vue')['default'] CodeDialog: typeof import('./src/components/code-dialog/index.vue')['default']
CustomEchartPie: typeof import('./src/components/custom-echart-pie/index.vue')['default']
FileUploader: typeof import('./src/components/FileUploader/index.vue')['default'] FileUploader: typeof import('./src/components/FileUploader/index.vue')['default']
LandSelect: typeof import('./src/components/LandSelect.vue')['default'] LandSelect: typeof import('./src/components/LandSelect.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']

View File

@ -0,0 +1,101 @@
<template>
<div ref="chartRef" :style="{ height, width }"></div>
</template>
<script>
import { ref, reactive, watch, watchEffect } from 'vue';
import { cloneDeep } from 'lodash';
import { useEcharts } from '../../hooks/useEcharts';
export default {
name: 'CustomEchartPie',
props: {
chartData: {
type: Array,
default: () => [],
},
size: {
type: Object,
default: () => {},
},
option: {
type: Object,
default: () => ({}),
},
width: {
type: String,
default: '100%',
},
height: {
type: String,
default: 'calc(100vh - 78px)',
},
},
emits: ['click'],
setup(props, { emit }) {
const chartRef = ref(null);
const { setOptions, getInstance, resize, startAutoPlay } = useEcharts(chartRef);
const option = reactive({
tooltip: {
backgroundColor: '#fff', // (RGBA)
borderColor: 'rgba(0, 0, 0, 0.1)', //
borderWidth: 1, //
textStyle: {
color: '#333', //
fontSize: 12,
},
formatter: '{b} ({c})',
},
series: [
{
type: 'pie',
radius: '72%',
center: ['50%', '55%'],
data: [],
labelLine: { show: true },
label: {
show: true,
formatter: '{b} \n ({d}%)',
color: '#B1B9D3',
},
},
],
});
watchEffect(() => {
props.chartData && initCharts();
});
watch(
() => props.size,
() => {
resize();
},
{
immediate: true,
}
);
function initCharts() {
if (props.option) {
Object.assign(option, cloneDeep(props.option));
}
option.series[0].data = props.chartData;
setOptions(option);
startAutoPlay({
interval: 2000,
seriesIndex: 0,
showTooltip: true,
});
resize();
getInstance()?.off('click', onClick);
getInstance()?.on('click', onClick);
}
function onClick(params) {
emit('click', params);
}
return { chartRef };
},
};
</script>

View File

@ -0,0 +1,84 @@
import { ref, computed, unref } from 'vue';
import { useEventListener } from './useEventListener';
let globalScreenRef = 0;
let globalWidthRef = 0;
let globalRealWidthRef = 0;
const screenMap = new Map();
screenMap.set('XS', 480);
screenMap.set('SM', 576);
screenMap.set('MD', 768);
screenMap.set('LG', 992);
screenMap.set('XL', 1200);
screenMap.set('XXL', 1600);
export function useBreakpoint() {
return {
screenRef: computed(() => unref(globalScreenRef)),
widthRef: globalWidthRef,
realWidthRef: globalRealWidthRef,
};
}
// Just call it once
export function createBreakpointListen(fn) {
const screenRef = ref('XL');
const realWidthRef = ref(window.innerWidth);
function getWindowWidth() {
const width = document.body.clientWidth;
const xs = screenMap.get('XS');
const sm = screenMap.get('SM');
const md = screenMap.get('MD');
const lg = screenMap.get('LG');
const xl = screenMap.get('XL');
const xxl = screenMap.set('XXL', 1600);
if (width < xs) {
screenRef.value = xs;
} else if (width < sm) {
screenRef.value = sm;
} else if (width < md) {
screenRef.value = md;
} else if (width < lg) {
screenRef.value = lg;
} else if (width < xl) {
screenRef.value = xl;
} else {
screenRef.value = xxl;
}
realWidthRef.value = width;
}
useEventListener({
el: window,
name: 'resize',
listener: () => {
getWindowWidth();
resizeFn();
},
// wait: 100,
});
getWindowWidth();
globalScreenRef = computed(() => unref(screenRef));
globalWidthRef = computed(() => screenMap.get(unref(screenRef)));
globalRealWidthRef = computed(() => unref(realWidthRef));
function resizeFn() {
fn?.({
screen: globalScreenRef,
width: globalWidthRef,
realWidth: globalRealWidthRef,
screenMap,
});
}
resizeFn();
return {
screenRef: globalScreenRef,
widthRef: globalWidthRef,
realWidthRef: globalRealWidthRef,
};
}

View File

@ -0,0 +1,191 @@
import { unref, nextTick, watch, computed, ref } from 'vue';
import { useDebounceFn, tryOnUnmounted } from '@vueuse/core';
import { useTimeoutFn } from './useTimeout';
import { useEventListener } from './useEventListener';
import { useBreakpoint } from './useBreakpoint';
import echarts from '../utils/echarts';
export const useEcharts = (elRef, theme = 'default') => {
// 新增轮播相关状态
const autoPlayTimer = ref(null);
const currentIndex = ref(-1);
const dataLength = ref(0);
// 新增方法 - 启动轮播
const startAutoPlay = (options = {}) => {
const {
interval = 2000, // 轮播间隔(ms)
seriesIndex = 0, // 默认操作第一个系列
showTooltip = true, // 是否显示提示框
} = options;
stopAutoPlay(); // 先停止已有轮播
// 获取数据长度
const seriesData = unref(getOptions).series?.[seriesIndex]?.data;
dataLength.value = seriesData?.length || 0;
if (dataLength.value === 0) return;
autoPlayTimer.value = setInterval(() => {
currentIndex.value = (currentIndex.value + 1) % dataLength.value;
// 执行轮播动作
chartInstance?.dispatchAction({
type: 'downplay',
seriesIndex: seriesIndex,
});
chartInstance?.dispatchAction({
type: 'highlight',
seriesIndex: seriesIndex,
dataIndex: currentIndex.value,
});
if (showTooltip) {
chartInstance?.dispatchAction({
type: 'showTip',
seriesIndex: seriesIndex,
dataIndex: currentIndex.value,
});
}
}, interval);
};
// 新增方法 - 停止轮播
const stopAutoPlay = () => {
if (autoPlayTimer.value) {
clearInterval(autoPlayTimer.value);
autoPlayTimer.value = null;
}
};
const getDarkMode = computed(() => {
return theme === 'default' ? 'dark' : theme;
});
let chartInstance = null;
let resizeFn = resize;
const cacheOptions = ref({});
let removeResizeFn = null;
resizeFn = useDebounceFn(resize, 200);
const getOptions = computed(() => {
if (getDarkMode.value !== 'dark') {
return cacheOptions.value;
}
return {
backgroundColor: 'transparent',
...cacheOptions.value,
};
});
function initCharts(t = theme) {
const el = unref(elRef);
if (!el || !unref(el)) {
return;
}
nextTick(() => {
if (el.offsetWidth === 0 || el.offsetHeight === 0) {
// console.warn('图表容器不可见,延迟初始化');
useTimeoutFn(() => initCharts(t), 100);
return;
}
chartInstance = echarts.init(el, t);
const { removeEvent } = useEventListener({
el: window,
name: 'resize',
listener: resizeFn,
});
removeResizeFn = removeEvent;
const { widthRef } = useBreakpoint();
if (unref(widthRef) <= 768 || el.offsetHeight === 0) {
useTimeoutFn(() => {
resizeFn();
}, 30);
}
});
}
function setOptions(options = {}, clear = true) {
const mergedOptions = {
animation: true,
animationDuration: 3000,
animationEasing: 'cubicOut',
...unref(options),
animationThreshold: 2000, // 数据量超过2000自动关闭动画
animationDelayUpdate: (idx) => idx * 50, // 数据项延迟
};
cacheOptions.value = mergedOptions;
cacheOptions.value = options;
if (unref(elRef)?.offsetHeight === 0) {
useTimeoutFn(() => {
setOptions(unref(getOptions));
}, 30);
return;
}
nextTick(() => {
useTimeoutFn(() => {
if (!chartInstance) {
initCharts(getDarkMode.value ?? 'default');
if (!chartInstance) return;
}
clear && chartInstance?.clear();
chartInstance?.setOption(unref(getOptions));
}, 30);
});
}
function resize() {
chartInstance?.resize();
}
/**
* 注册地图数据
* @param {string} mapName - 地图名称
* @param {object} geoJSON - GeoJSON 数据
*/
function registerMap(mapName, geoJSON) {
if (!mapName || !geoJSON) {
console.warn('地图名称或 GeoJSON 数据无效');
return;
}
echarts.registerMap(mapName, geoJSON);
}
watch(
() => getDarkMode.value,
(theme) => {
if (chartInstance) {
chartInstance.dispose();
initCharts(theme);
setOptions(cacheOptions.value);
}
}
);
tryOnUnmounted(() => {
stopAutoPlay(); // 清理定时器
if (!chartInstance) return;
removeResizeFn();
chartInstance.dispose();
chartInstance = null;
});
function getInstance() {
if (!chartInstance) {
initCharts(getDarkMode.value ?? 'default');
}
return chartInstance;
}
return {
setOptions,
resize,
echarts,
getInstance: () => chartInstance,
registerMap,
startAutoPlay, // 暴露轮播方法
stopAutoPlay,
};
};

View File

@ -0,0 +1,38 @@
import { ref, watch, unref } from 'vue';
import { useThrottleFn, useDebounceFn } from '@vueuse/core';
export function useEventListener({ el = window, name, listener, options, autoRemove = true, isDebounce = true, wait = 80 }) {
let remove;
const isAddRef = ref(false);
if (el) {
const element = ref(el);
const handler = isDebounce ? useDebounceFn(listener, wait) : useThrottleFn(listener, wait);
const realHandler = wait ? handler : listener;
const removeEventListener = (e) => {
isAddRef.value = true;
e.removeEventListener(name, realHandler, options);
};
const addEventListener = (e) => e.addEventListener(name, realHandler, options);
const removeWatch = watch(
element,
(v, _ov, cleanUp) => {
if (v) {
!unref(isAddRef) && addEventListener(v);
cleanUp(() => {
autoRemove && removeEventListener(v);
});
}
},
{ immediate: true }
);
remove = () => {
removeEventListener(element.value);
removeWatch();
};
}
return { removeEvent: remove };
}

View File

@ -0,0 +1,44 @@
import { ref, watch } from 'vue';
import { tryOnUnmounted } from '@vueuse/core';
export function useTimeoutFn(handle, wait, native = false) {
if (typeof handle !== 'function') {
throw new Error('handle is not Function!');
}
const { readyRef, stop, start } = useTimeoutRef(wait);
if (native) {
handle();
} else {
watch(
readyRef,
(maturity) => {
maturity && handle();
},
{ immediate: false }
);
}
return { readyRef, stop, start };
}
export function useTimeoutRef(wait) {
const readyRef = ref(false);
let timer;
function stop() {
readyRef.value = false;
timer && window.clearTimeout(timer);
}
function start() {
stop();
timer = setTimeout(() => {
readyRef.value = true;
}, wait);
}
start();
tryOnUnmounted(stop);
return { readyRef, stop, start };
}

View File

@ -247,7 +247,6 @@
justify-content: space-between; justify-content: space-between;
gap: 10px; gap: 10px;
padding: 3px 0; padding: 3px 0;
} }
.el-icon-custom { .el-icon-custom {
vertical-align: middle; vertical-align: middle;
@ -373,3 +372,16 @@
} }
} }
.statistics-cont {
padding: 10px 20px;
.statistics-echart-box {
height: 400px;
padding-bottom: 50px;
box-sizing: content-box;
background-color: #fff;
}
.statistics-echart-title {
font-size: 18px;
font-weight: bold;
}
}

View File

@ -0,0 +1,66 @@
import * as echarts from 'echarts/core';
import {
BarChart,
LineChart,
PieChart,
MapChart,
PictorialBarChart,
RadarChart,
GraphChart,
GaugeChart,
ScatterChart,
EffectScatterChart,
} from 'echarts/charts';
import 'echarts-gl';
import 'echarts-liquidfill';
import 'echarts-wordcloud';
import {
TitleComponent,
TooltipComponent,
GridComponent,
PolarComponent,
AriaComponent,
ParallelComponent,
LegendComponent,
RadarComponent,
ToolboxComponent,
DataZoomComponent,
VisualMapComponent,
TimelineComponent,
CalendarComponent,
GraphicComponent,
} from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';
echarts.use([
LegendComponent,
TitleComponent,
TooltipComponent,
GridComponent,
PolarComponent,
AriaComponent,
ParallelComponent,
BarChart,
LineChart,
PieChart,
MapChart,
RadarChart,
CanvasRenderer,
PictorialBarChart,
RadarComponent,
ToolboxComponent,
DataZoomComponent,
VisualMapComponent,
TimelineComponent,
CalendarComponent,
GraphicComponent,
GraphChart,
GaugeChart,
ScatterChart,
EffectScatterChart,
]);
export default echarts;

View File

@ -14,3 +14,67 @@ import { getAssetsFile } from '@/utils';
background-repeat: no-repeat; background-repeat: no-repeat;
} }
</style> </style>
<!-- <template>
<div class="app-container">
<div class="container-custom">
<h2 class="custom-h2">产出品概览</h2>
<div ref="searchBarRef" class="search-box">
<div class="search-bar">
<div class="search-bar-left">
<el-form ref="searchForm" :inline="true" :model="formInline" class="demo-form-inline" :label-width="'auto'">
<el-form-item label="" style="margin-left: -15px">
<AreaCascader v-model:region-code="formInline.regionCode" v-model:grid-id="formInline.gridId" label="行政区域-网格" :width="500" />
</el-form-item>
<el-form-item label="">
<el-button type="primary" icon="Search" @click="onSubmit">查询</el-button>
</el-form-item>
</el-form>
</div>
</div>
</div>
<div class="statistics-cont">
<el-row :gutter="20">
<el-col :span="12" class="statistics-echart-box">
<p class="statistics-echart-title">产出品产量数据</p>
<customEchartPie ref="pie1" :chart-data="chartData1" :option="option" :width="'100%'" :height="'100%'"></customEchartPie>
</el-col>
</el-row>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue';
import customEchartPie from '@/components/custom-echart-pie';
import { ElMessage } from 'element-plus';
//
const formInline = reactive({
regionCode: '',
regionName: '',
gridId: '',
gridName: '',
});
const searchForm = ref(null);
const chartData1 = ref([
{ value: 1048, name: '蔬菜类' },
{ value: 735, name: '水果类' },
{ value: 484, name: '药材类' },
]);
const option = reactive({
tooltip: {
formatter: '{b} ({c} 吨)',
},
});
const onSubmit = () => {
console.log(formInline);
};
onMounted(() => {
// onSubmit();
});
</script>
<style lang="scss" scoped></style> -->

View File

@ -137,7 +137,7 @@
</el-descriptions-item> </el-descriptions-item>
</el-descriptions> </el-descriptions>
<el-descriptions title="区块链认证信息" border class="mb-20 custom-descriptions" :column="1"> <el-descriptions title="区块链认证信息" border class="mb-20 custom-descriptions" :column="1">
<el-descriptions-item label="质检报告"> <el-descriptions-item label="认证信息">
<img :src="dialogForm?.traceUrl ?? ''" alt="" /> <img :src="dialogForm?.traceUrl ?? ''" alt="" />
</el-descriptions-item> </el-descriptions-item>
</el-descriptions> </el-descriptions>
@ -274,7 +274,7 @@ const seedTypeChange = () => {
// //
}; };
const tabsRadio = ref(2); const tabsRadio = ref(1);
const dialogFormVisible = ref(false); const dialogFormVisible = ref(false);
const dialogRef = ref(null); const dialogRef = ref(null);
const dialogTitle = ref('溯源产品详情'); const dialogTitle = ref('溯源产品详情');