| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460 |
- <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>
|