|
|
@@ -0,0 +1,460 @@
|
|
|
+<template>
|
|
|
+ <div ref="barEchartRef" class="chart_width" :style="{ height: `${height}px` }"></div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script lang="ts" setup>
|
|
|
+import { ref, watch, onMounted, onUnmounted, nextTick, withDefaults } from 'vue'
|
|
|
+import _ from 'lodash'
|
|
|
+import * as echarts from 'echarts'
|
|
|
+import { getCompareAnalysis, getScorePerformanceAnalysis } from '@/utils/common'
|
|
|
+
|
|
|
+// ===================== 类型定义 =====================
|
|
|
+interface TooltipItem {
|
|
|
+ name: string
|
|
|
+ value: string | number
|
|
|
+}
|
|
|
+interface TooltipDataItem {
|
|
|
+ list: TooltipItem[]
|
|
|
+}
|
|
|
+interface MarkLineDataItem {
|
|
|
+ value: string | number
|
|
|
+ isShow: boolean
|
|
|
+}
|
|
|
+
|
|
|
+interface BarChartProps {
|
|
|
+ datax: (string | number)[]
|
|
|
+ datay: (string | number)[]
|
|
|
+ color: string
|
|
|
+ height: number
|
|
|
+ unit: string
|
|
|
+ showNuitY: boolean
|
|
|
+ showSplitArea: boolean
|
|
|
+ showTooltip: boolean
|
|
|
+ tooltipData: TooltipDataItem[]
|
|
|
+ typeName: string
|
|
|
+ average: string | number | (string | number)[]
|
|
|
+ markLineData: MarkLineDataItem[]
|
|
|
+ isShowMarkLine: boolean
|
|
|
+ showMarkPoint: boolean
|
|
|
+ isClick: boolean
|
|
|
+ answerValue: string
|
|
|
+ unitX: string
|
|
|
+ fullMark: string | number
|
|
|
+ gridLeft: number
|
|
|
+ gridRight: number
|
|
|
+ gridTop: number
|
|
|
+ fontSize: string | number
|
|
|
+ fontColor: string
|
|
|
+ showDataZoom: boolean
|
|
|
+ barMaxWidth: number
|
|
|
+ barMinWidth: number
|
|
|
+}
|
|
|
+
|
|
|
+interface BarChartEmits {
|
|
|
+ HandleChartClick: (index: number, xName: string | number) => void
|
|
|
+ HandleBarClick: (paintingId: unknown) => void
|
|
|
+}
|
|
|
+
|
|
|
+// ===================== Props + 默认值(核心修复) =====================
|
|
|
+const props = withDefaults(defineProps<BarChartProps>(), {
|
|
|
+ datax: () => [],
|
|
|
+ datay: () => [],
|
|
|
+ color: '#5470C6',
|
|
|
+ height: 380,
|
|
|
+ unit: '%',
|
|
|
+ showNuitY: true,
|
|
|
+ showSplitArea: false,
|
|
|
+ showTooltip: true,
|
|
|
+ tooltipData: () => [],
|
|
|
+ typeName: '',
|
|
|
+ average: '0',
|
|
|
+ markLineData: () => [],
|
|
|
+ isShowMarkLine: true,
|
|
|
+ showMarkPoint: false,
|
|
|
+ isClick: false,
|
|
|
+ answerValue: '',
|
|
|
+ unitX: '',
|
|
|
+ fullMark: '',
|
|
|
+ gridLeft: 40,
|
|
|
+ gridRight: 60,
|
|
|
+ gridTop: 20,
|
|
|
+ fontSize: '',
|
|
|
+ fontColor: '',
|
|
|
+ showDataZoom: true,
|
|
|
+ barMaxWidth: 50,
|
|
|
+ barMinWidth: 20
|
|
|
+})
|
|
|
+
|
|
|
+const emit = defineEmits<BarChartEmits>()
|
|
|
+
|
|
|
+// ===================== 响应式变量 =====================
|
|
|
+const barEchartRef = ref<HTMLDivElement | null>(null)
|
|
|
+let echart: echarts.ECharts | null = null
|
|
|
+
|
|
|
+// ===================== 工具方法 =====================
|
|
|
+const GetMaxValue = (value: number): number => {
|
|
|
+ const thresholdMap = [
|
|
|
+ { maxThreshold: 0.3, bound: 0.3 },
|
|
|
+ { maxThreshold: 0.6, bound: 0.6 },
|
|
|
+ { maxThreshold: 1, bound: 1 },
|
|
|
+ { maxThreshold: 1.5, bound: 1.5 },
|
|
|
+ { maxThreshold: 2, bound: 2 },
|
|
|
+ { maxThreshold: 4, bound: 4 },
|
|
|
+ { maxThreshold: 8, bound: 8 },
|
|
|
+ { maxThreshold: 10, bound: 10 },
|
|
|
+ { maxThreshold: 40, bound: 40 },
|
|
|
+ { maxThreshold: 50, bound: 50 },
|
|
|
+ { maxThreshold: 60, bound: 60 },
|
|
|
+ { maxThreshold: 70, bound: 70 },
|
|
|
+ { maxThreshold: 80, bound: 80 },
|
|
|
+ { maxThreshold: 90, bound: 90 },
|
|
|
+ { maxThreshold: 100, bound: 100 },
|
|
|
+ { maxThreshold: 120, bound: 120 },
|
|
|
+ { maxThreshold: 150, bound: 150 },
|
|
|
+ { maxThreshold: 180, bound: 180 },
|
|
|
+ { maxThreshold: 200, bound: 200 },
|
|
|
+ { maxThreshold: 250, bound: 250 },
|
|
|
+ { maxThreshold: 300, bound: 300 },
|
|
|
+ { maxThreshold: 350, bound: 350 },
|
|
|
+ { maxThreshold: 400, bound: 400 },
|
|
|
+ { maxThreshold: 450, bound: 450 },
|
|
|
+ { maxThreshold: 500, bound: 500 },
|
|
|
+ { maxThreshold: 600, bound: 600 },
|
|
|
+ { maxThreshold: 700, bound: 700 }
|
|
|
+ ]
|
|
|
+ const matchedRule = thresholdMap.find(item => value === item.maxThreshold || value < item.maxThreshold)
|
|
|
+ if (matchedRule) return matchedRule.bound
|
|
|
+ return Math.ceil(value / 10) * 10
|
|
|
+}
|
|
|
+
|
|
|
+const LoadEchart = () => {
|
|
|
+ if (!barEchartRef.value) return
|
|
|
+ if (echart) echart.dispose()
|
|
|
+
|
|
|
+ const colors: string[] = []
|
|
|
+ const xColors: string[] = []
|
|
|
+ const rightAnswerValue = props.answerValue || ''
|
|
|
+ const globalColorArr = getScorePerformanceAnalysis()
|
|
|
+
|
|
|
+ for (let i = 0; i < props.datax.length; i++) {
|
|
|
+ colors.push(props.color || globalColorArr[i])
|
|
|
+ xColors.push('#666666')
|
|
|
+ }
|
|
|
+
|
|
|
+ const markPointColor = props.color
|
|
|
+ echart = echarts.init(barEchartRef.value, null, { devicePixelRatio: 2 })
|
|
|
+ const totalWidth = barEchartRef.value.clientWidth
|
|
|
+ const singleSeriesWidth = Math.ceil((totalWidth - 140) / props.datax.length)
|
|
|
+
|
|
|
+ const datayNumList = props.datay.filter(num => !isNaN(Number(num))).map(Number)
|
|
|
+ const maxValue = datayNumList.length ? Math.max(...datayNumList) : 0
|
|
|
+ const minValue = datayNumList.length ? Math.min(...datayNumList) : 0
|
|
|
+
|
|
|
+ let average: string | number | (string | number)[] = 0
|
|
|
+ const markLineData: echarts.MarkLineDataItem[] = []
|
|
|
+ let maxAverage = 0
|
|
|
+
|
|
|
+ if (Object.prototype.toString.call(props.average) === '[object Array]') {
|
|
|
+ average = props.average
|
|
|
+ ;(average as (string | number)[]).forEach(item => {
|
|
|
+ const itemAvg = parseFloat(String(item)) || 0
|
|
|
+ if (Number(item) > 0) {
|
|
|
+ markLineData.push({ symbol: 'circle', type: 'value', name: '', yAxis: itemAvg })
|
|
|
+ }
|
|
|
+ })
|
|
|
+ maxAverage = average.length ? Math.max(...(average as number[])) : 0
|
|
|
+ } else if (props.markLineData && props.markLineData.length) {
|
|
|
+ average = []
|
|
|
+ props.markLineData.forEach((item, index) => {
|
|
|
+ const itemAvg = parseFloat(String(item.value)) || 0
|
|
|
+ ;(average as number[]).push(itemAvg)
|
|
|
+ if (item.isShow) {
|
|
|
+ const compareColorArr = getCompareAnalysis()
|
|
|
+ markLineData.push({
|
|
|
+ symbol: 'circle',
|
|
|
+ type: 'value',
|
|
|
+ name: '',
|
|
|
+ yAxis: itemAvg,
|
|
|
+ lineStyle: { color: compareColorArr[index] },
|
|
|
+ label: { color: compareColorArr[index] }
|
|
|
+ })
|
|
|
+ }
|
|
|
+ })
|
|
|
+ maxAverage = (average as number[]).length ? Math.max(...(average as number[])) : 0
|
|
|
+ } else {
|
|
|
+ average = parseFloat(String(props.average)) || 0
|
|
|
+ maxAverage = parseFloat(String(props.average)) || 0
|
|
|
+ if (average !== 0) {
|
|
|
+ markLineData.push({ symbol: 'circle', type: 'value', name: '', yAxis: average })
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const nearestValue = GetMaxValue(maxValue)
|
|
|
+ const yAxisUnit = props.showNuitY && props.unit === '%' ? props.unit : ''
|
|
|
+ const splitArea = props.showSplitArea
|
|
|
+ ? { show: true, areaStyle: { color: ['#fafafa', '#ffffff'] } }
|
|
|
+ : {}
|
|
|
+
|
|
|
+ const barMinWidth = 30
|
|
|
+ const dataZoomNum = Math.floor((totalWidth - 140) / (barMinWidth * 1))
|
|
|
+ const dataZoomEnd = Math.floor((100 / props.datax.length) * dataZoomNum)
|
|
|
+
|
|
|
+ const dataZoomOption: echarts.DataZoomSliderOption = {
|
|
|
+ start: 0,
|
|
|
+ end: dataZoomEnd,
|
|
|
+ type: 'slider',
|
|
|
+ show: true,
|
|
|
+ borderColor: 'transparent',
|
|
|
+ borderCap: 'round',
|
|
|
+ xAxisIndex: [0],
|
|
|
+ height: 8,
|
|
|
+ left: 20,
|
|
|
+ right: 20,
|
|
|
+ bottom: 0,
|
|
|
+ fillerColor: 'transparent',
|
|
|
+ zoomLock: true,
|
|
|
+ handleSize: '0',
|
|
|
+ handleStyle: { color: '#b8b8b8', borderWidth: 2 },
|
|
|
+ backgroundColor: 'transparent',
|
|
|
+ showDataShadow: false,
|
|
|
+ showDetail: false,
|
|
|
+ filterMode: 'filter'
|
|
|
+ }
|
|
|
+
|
|
|
+ const dataZoom = props.showDataZoom && singleSeriesWidth < barMinWidth ? dataZoomOption : null
|
|
|
+
|
|
|
+ const option: echarts.EChartsOption = {
|
|
|
+ tooltip: {
|
|
|
+ show: props.showTooltip,
|
|
|
+ trigger: props.showTooltip ? 'axis' : 'item',
|
|
|
+ triggerOn: 'mousemove',
|
|
|
+ renderMode: 'html',
|
|
|
+ confine: true,
|
|
|
+ extraCssText: 'border-radius: 4px;padding:5px 0px 5px 5px;white-space:normal;word-warp:break-word;max-width: 400px;',
|
|
|
+ borderColor: '#fff',
|
|
|
+ formatter: (params: any) => {
|
|
|
+ let tooltip = `<div class='tooltip_content'>`
|
|
|
+ const title = params?.name || params[0]?.name
|
|
|
+ const value = params?.value || params[0]?.value
|
|
|
+ tooltip += `<div class='tooltip_title'>${title}</div>`
|
|
|
+ if (props.typeName) {
|
|
|
+ tooltip += `<div class='tooltip_student'>${props.typeName}:${value}${props.unit}</div>`
|
|
|
+ }
|
|
|
+ if (props.tooltipData.length > 0) {
|
|
|
+ const list = props.tooltipData[params[0].dataIndex]?.list || []
|
|
|
+ for (const item of list) {
|
|
|
+ tooltip += `<div class='tooltip_student'>${item.name}:${item.value}</div>`
|
|
|
+ }
|
|
|
+ }
|
|
|
+ tooltip += `</div>`
|
|
|
+ return tooltip
|
|
|
+ }
|
|
|
+ },
|
|
|
+ grid: {
|
|
|
+ left: props.gridLeft,
|
|
|
+ right: props.gridRight,
|
|
|
+ top: props.gridTop,
|
|
|
+ bottom: 0,
|
|
|
+ containLabel: true
|
|
|
+ },
|
|
|
+ dataZoom,
|
|
|
+ xAxis: [
|
|
|
+ {
|
|
|
+ type: 'category',
|
|
|
+ data: props.datax,
|
|
|
+ axisPointer: { type: 'shadow' },
|
|
|
+ axisLabel: {
|
|
|
+ interval: 0,
|
|
|
+ fontWeight: 400,
|
|
|
+ rotate: singleSeriesWidth < 80 ? 45 : 0,
|
|
|
+ formatter: (value: string) => {
|
|
|
+ const valueWidth = value.length * 14
|
|
|
+ if (valueWidth > singleSeriesWidth) {
|
|
|
+ if (singleSeriesWidth < 100) {
|
|
|
+ return valueWidth > 80 ? value.slice(0, 2) + '...' + value.slice(-3) : value
|
|
|
+ } else {
|
|
|
+ const maxLength = Math.floor(singleSeriesWidth / 14) - 1
|
|
|
+ return value.slice(0, maxLength) + '...'
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return `${value}${props.unitX}`
|
|
|
+ },
|
|
|
+ color: props.fontColor || '#666666',
|
|
|
+ fontSize: props.fontSize ? Number(props.fontSize) : 14
|
|
|
+ }
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ yAxis: {
|
|
|
+ type: 'value',
|
|
|
+ axisLabel: {
|
|
|
+ color: props.fontColor || '#666666',
|
|
|
+ fontSize: props.fontSize ? Number(props.fontSize) : 14,
|
|
|
+ formatter: `{value}${yAxisUnit}`
|
|
|
+ },
|
|
|
+ max: maxAverage > nearestValue ? maxAverage : nearestValue,
|
|
|
+ splitArea
|
|
|
+ },
|
|
|
+ series: [
|
|
|
+ {
|
|
|
+ name: '阅卷进度',
|
|
|
+ type: 'bar',
|
|
|
+ barMaxWidth: props.barMaxWidth,
|
|
|
+ barMinWidth: props.barMinWidth,
|
|
|
+ itemStyle: {
|
|
|
+ color: (params: any) => {
|
|
|
+ if (params.name === rightAnswerValue || params.name === props.fullMark) return '#3BA272'
|
|
|
+ if (params.name === '0') return '#EE6666'
|
|
|
+ return colors[params.dataIndex]
|
|
|
+ }
|
|
|
+ },
|
|
|
+ data: props.datay.map(value => ({
|
|
|
+ value,
|
|
|
+ label: {
|
|
|
+ show: props.showMarkPoint ? (value === 0 || value === maxValue || value === minValue) : singleSeriesWidth > 26,
|
|
|
+ position: 'top',
|
|
|
+ fontSize: props.fontSize ? Number(props.fontSize) : 14,
|
|
|
+ formatter: `{c}${props.unit}`,
|
|
|
+ color: '#666'
|
|
|
+ }
|
|
|
+ })),
|
|
|
+ markLine: props.isShowMarkLine
|
|
|
+ ? {
|
|
|
+ symbolSize: [8, 8],
|
|
|
+ symbolOffset: [[0, 0], [0, 0]],
|
|
|
+ label: {
|
|
|
+ color: '#F56C6C',
|
|
|
+ fontSize: props.fontSize ? Number(props.fontSize) : 15,
|
|
|
+ formatter: `{c}${props.unit}`,
|
|
|
+ position: 'end'
|
|
|
+ },
|
|
|
+ data: markLineData,
|
|
|
+ lineStyle: props.markLineData.length ? undefined : { color: '#F56C6C' }
|
|
|
+ }
|
|
|
+ : undefined,
|
|
|
+ markPoint: props.showMarkPoint
|
|
|
+ ? {
|
|
|
+ data: props.datay
|
|
|
+ .map((value, index) => {
|
|
|
+ if (value === maxValue || value === minValue) {
|
|
|
+ return {
|
|
|
+ name: value === maxValue ? '最大值' : '最小值',
|
|
|
+ coord: [props.datax[index], value],
|
|
|
+ symbolSize: 65,
|
|
|
+ label: {
|
|
|
+ show: true,
|
|
|
+ position: 'inside',
|
|
|
+ color: '#fff',
|
|
|
+ formatter: `${value}${props.unit}`
|
|
|
+ },
|
|
|
+ itemStyle: { color: markPointColor }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return null
|
|
|
+ })
|
|
|
+ .filter(Boolean) as echarts.MarkPointDataItem[],
|
|
|
+ label: { show: true, fontSize: 10, fontWeight: 'bold' }
|
|
|
+ }
|
|
|
+ : undefined
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ }
|
|
|
+
|
|
|
+ echart.setOption(option)
|
|
|
+
|
|
|
+ if (props.isClick) {
|
|
|
+ const xAxisDataLength = props.datax.length
|
|
|
+ const gridRect = echart.getModel().getComponent('grid').coordinateSystem.getRect()
|
|
|
+ let singleLabelWidth = gridRect.width / xAxisDataLength
|
|
|
+ if (singleSeriesWidth < barMinWidth) singleLabelWidth = barMinWidth
|
|
|
+
|
|
|
+ echart.off('click')
|
|
|
+ echart.on('click', (params: any) => {
|
|
|
+ if (params.seriesType === 'bar') {
|
|
|
+ echart?.setOption({
|
|
|
+ series: [
|
|
|
+ {
|
|
|
+ itemStyle: {
|
|
|
+ color: (p: any) => {
|
|
|
+ if (p.name === rightAnswerValue || p.name === props.fullMark) return '#3BA272'
|
|
|
+ if (p.name === '0') return '#EE6666'
|
|
|
+ return colors[p.dataIndex]
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ })
|
|
|
+ const pixelPosition = echart.convertToPixel({ xAxisIndex: 0 }, params.name)
|
|
|
+ echart?.setOption({
|
|
|
+ graphic: {
|
|
|
+ id: 'highlight-box',
|
|
|
+ type: 'rect',
|
|
|
+ shape: {
|
|
|
+ x: pixelPosition - singleLabelWidth / 2,
|
|
|
+ y: gridRect.y,
|
|
|
+ width: singleLabelWidth,
|
|
|
+ height: gridRect.height
|
|
|
+ },
|
|
|
+ style: { fill: `${params.color}30` }
|
|
|
+ }
|
|
|
+ })
|
|
|
+ emit('HandleChartClick', params.dataIndex, params.name)
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ const defaultPixelPosition = echart.convertToPixel({ xAxisIndex: 0 }, props.datax[0])
|
|
|
+ echart.setOption({
|
|
|
+ graphic: {
|
|
|
+ id: 'highlight-box',
|
|
|
+ type: 'rect',
|
|
|
+ shape: {
|
|
|
+ x: defaultPixelPosition - singleLabelWidth / 2,
|
|
|
+ y: gridRect.y,
|
|
|
+ width: singleLabelWidth,
|
|
|
+ height: gridRect.height
|
|
|
+ },
|
|
|
+ style: { fill: 'rgba(84,112,198,0.1)' }
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const UpdateColors = () => {
|
|
|
+ if (!echart) return
|
|
|
+ const newColors = ['#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0', '#9966FF', '#FF9F40', '#FF6384']
|
|
|
+ echart.setOption({
|
|
|
+ series: [
|
|
|
+ {
|
|
|
+ itemStyle: { color: (params: any) => newColors[params.dataIndex] }
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+const handleResize = _.throttle(() => {
|
|
|
+ nextTick(() => LoadEchart())
|
|
|
+}, 500)
|
|
|
+
|
|
|
+// 监听 & 生命周期
|
|
|
+watch(() => props.datay, LoadEchart, { deep: true })
|
|
|
+watch(() => props.markLineData, LoadEchart, { deep: true })
|
|
|
+
|
|
|
+onMounted(() => {
|
|
|
+ window.addEventListener('resize', handleResize)
|
|
|
+ LoadEchart()
|
|
|
+})
|
|
|
+
|
|
|
+onUnmounted(() => {
|
|
|
+ window.removeEventListener('resize', handleResize)
|
|
|
+ if (echart) {
|
|
|
+ echart.dispose()
|
|
|
+ echart = null
|
|
|
+ }
|
|
|
+})
|
|
|
+</script>
|
|
|
+
|
|
|
+<style lang="scss" scoped>
|
|
|
+.chart_width {
|
|
|
+ width: 100%;
|
|
|
+ height: 380px;
|
|
|
+}
|
|
|
+</style>
|