barChart.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460
  1. <template>
  2. <div ref="barEchartRef" class="chart_width" :style="{ height: `${height}px` }"></div>
  3. </template>
  4. <script lang="ts" setup>
  5. import { ref, watch, onMounted, onUnmounted, nextTick, withDefaults } from 'vue'
  6. import _ from 'lodash'
  7. import * as echarts from 'echarts'
  8. import { getCompareAnalysis, getScorePerformanceAnalysis } from '@/utils/common'
  9. // ===================== 类型定义 =====================
  10. interface TooltipItem {
  11. name: string
  12. value: string | number
  13. }
  14. interface TooltipDataItem {
  15. list: TooltipItem[]
  16. }
  17. interface MarkLineDataItem {
  18. value: string | number
  19. isShow: boolean
  20. }
  21. interface BarChartProps {
  22. datax: (string | number)[]
  23. datay: (string | number)[]
  24. color: string
  25. height: number
  26. unit: string
  27. showNuitY: boolean
  28. showSplitArea: boolean
  29. showTooltip: boolean
  30. tooltipData: TooltipDataItem[]
  31. typeName: string
  32. average: string | number | (string | number)[]
  33. markLineData: MarkLineDataItem[]
  34. isShowMarkLine: boolean
  35. showMarkPoint: boolean
  36. isClick: boolean
  37. answerValue: string
  38. unitX: string
  39. fullMark: string | number
  40. gridLeft: number
  41. gridRight: number
  42. gridTop: number
  43. fontSize: string | number
  44. fontColor: string
  45. showDataZoom: boolean
  46. barMaxWidth: number
  47. barMinWidth: number
  48. }
  49. interface BarChartEmits {
  50. HandleChartClick: (index: number, xName: string | number) => void
  51. HandleBarClick: (paintingId: unknown) => void
  52. }
  53. // ===================== Props + 默认值(核心修复) =====================
  54. const props = withDefaults(defineProps<BarChartProps>(), {
  55. datax: () => [],
  56. datay: () => [],
  57. color: '#5470C6',
  58. height: 380,
  59. unit: '%',
  60. showNuitY: true,
  61. showSplitArea: false,
  62. showTooltip: true,
  63. tooltipData: () => [],
  64. typeName: '',
  65. average: '0',
  66. markLineData: () => [],
  67. isShowMarkLine: true,
  68. showMarkPoint: false,
  69. isClick: false,
  70. answerValue: '',
  71. unitX: '',
  72. fullMark: '',
  73. gridLeft: 40,
  74. gridRight: 60,
  75. gridTop: 20,
  76. fontSize: '',
  77. fontColor: '',
  78. showDataZoom: true,
  79. barMaxWidth: 50,
  80. barMinWidth: 20
  81. })
  82. const emit = defineEmits<BarChartEmits>()
  83. // ===================== 响应式变量 =====================
  84. const barEchartRef = ref<HTMLDivElement | null>(null)
  85. let echart: echarts.ECharts | null = null
  86. // ===================== 工具方法 =====================
  87. const GetMaxValue = (value: number): number => {
  88. const thresholdMap = [
  89. { maxThreshold: 0.3, bound: 0.3 },
  90. { maxThreshold: 0.6, bound: 0.6 },
  91. { maxThreshold: 1, bound: 1 },
  92. { maxThreshold: 1.5, bound: 1.5 },
  93. { maxThreshold: 2, bound: 2 },
  94. { maxThreshold: 4, bound: 4 },
  95. { maxThreshold: 8, bound: 8 },
  96. { maxThreshold: 10, bound: 10 },
  97. { maxThreshold: 40, bound: 40 },
  98. { maxThreshold: 50, bound: 50 },
  99. { maxThreshold: 60, bound: 60 },
  100. { maxThreshold: 70, bound: 70 },
  101. { maxThreshold: 80, bound: 80 },
  102. { maxThreshold: 90, bound: 90 },
  103. { maxThreshold: 100, bound: 100 },
  104. { maxThreshold: 120, bound: 120 },
  105. { maxThreshold: 150, bound: 150 },
  106. { maxThreshold: 180, bound: 180 },
  107. { maxThreshold: 200, bound: 200 },
  108. { maxThreshold: 250, bound: 250 },
  109. { maxThreshold: 300, bound: 300 },
  110. { maxThreshold: 350, bound: 350 },
  111. { maxThreshold: 400, bound: 400 },
  112. { maxThreshold: 450, bound: 450 },
  113. { maxThreshold: 500, bound: 500 },
  114. { maxThreshold: 600, bound: 600 },
  115. { maxThreshold: 700, bound: 700 }
  116. ]
  117. const matchedRule = thresholdMap.find(item => value === item.maxThreshold || value < item.maxThreshold)
  118. if (matchedRule) return matchedRule.bound
  119. return Math.ceil(value / 10) * 10
  120. }
  121. const LoadEchart = () => {
  122. if (!barEchartRef.value) return
  123. if (echart) echart.dispose()
  124. const colors: string[] = []
  125. const xColors: string[] = []
  126. const rightAnswerValue = props.answerValue || ''
  127. const globalColorArr = getScorePerformanceAnalysis()
  128. for (let i = 0; i < props.datax.length; i++) {
  129. colors.push(props.color || globalColorArr[i])
  130. xColors.push('#666666')
  131. }
  132. const markPointColor = props.color
  133. echart = echarts.init(barEchartRef.value, null, { devicePixelRatio: 2 })
  134. const totalWidth = barEchartRef.value.clientWidth
  135. const singleSeriesWidth = Math.ceil((totalWidth - 140) / props.datax.length)
  136. const datayNumList = props.datay.filter(num => !isNaN(Number(num))).map(Number)
  137. const maxValue = datayNumList.length ? Math.max(...datayNumList) : 0
  138. const minValue = datayNumList.length ? Math.min(...datayNumList) : 0
  139. let average: string | number | (string | number)[] = 0
  140. const markLineData: echarts.MarkLineDataItem[] = []
  141. let maxAverage = 0
  142. if (Object.prototype.toString.call(props.average) === '[object Array]') {
  143. average = props.average
  144. ;(average as (string | number)[]).forEach(item => {
  145. const itemAvg = parseFloat(String(item)) || 0
  146. if (Number(item) > 0) {
  147. markLineData.push({ symbol: 'circle', type: 'value', name: '', yAxis: itemAvg })
  148. }
  149. })
  150. maxAverage = average.length ? Math.max(...(average as number[])) : 0
  151. } else if (props.markLineData && props.markLineData.length) {
  152. average = []
  153. props.markLineData.forEach((item, index) => {
  154. const itemAvg = parseFloat(String(item.value)) || 0
  155. ;(average as number[]).push(itemAvg)
  156. if (item.isShow) {
  157. const compareColorArr = getCompareAnalysis()
  158. markLineData.push({
  159. symbol: 'circle',
  160. type: 'value',
  161. name: '',
  162. yAxis: itemAvg,
  163. lineStyle: { color: compareColorArr[index] },
  164. label: { color: compareColorArr[index] }
  165. })
  166. }
  167. })
  168. maxAverage = (average as number[]).length ? Math.max(...(average as number[])) : 0
  169. } else {
  170. average = parseFloat(String(props.average)) || 0
  171. maxAverage = parseFloat(String(props.average)) || 0
  172. if (average !== 0) {
  173. markLineData.push({ symbol: 'circle', type: 'value', name: '', yAxis: average })
  174. }
  175. }
  176. const nearestValue = GetMaxValue(maxValue)
  177. const yAxisUnit = props.showNuitY && props.unit === '%' ? props.unit : ''
  178. const splitArea = props.showSplitArea
  179. ? { show: true, areaStyle: { color: ['#fafafa', '#ffffff'] } }
  180. : {}
  181. const barMinWidth = 30
  182. const dataZoomNum = Math.floor((totalWidth - 140) / (barMinWidth * 1))
  183. const dataZoomEnd = Math.floor((100 / props.datax.length) * dataZoomNum)
  184. const dataZoomOption: echarts.DataZoomSliderOption = {
  185. start: 0,
  186. end: dataZoomEnd,
  187. type: 'slider',
  188. show: true,
  189. borderColor: 'transparent',
  190. borderCap: 'round',
  191. xAxisIndex: [0],
  192. height: 8,
  193. left: 20,
  194. right: 20,
  195. bottom: 0,
  196. fillerColor: 'transparent',
  197. zoomLock: true,
  198. handleSize: '0',
  199. handleStyle: { color: '#b8b8b8', borderWidth: 2 },
  200. backgroundColor: 'transparent',
  201. showDataShadow: false,
  202. showDetail: false,
  203. filterMode: 'filter'
  204. }
  205. const dataZoom = props.showDataZoom && singleSeriesWidth < barMinWidth ? dataZoomOption : null
  206. const option: echarts.EChartsOption = {
  207. tooltip: {
  208. show: props.showTooltip,
  209. trigger: props.showTooltip ? 'axis' : 'item',
  210. triggerOn: 'mousemove',
  211. renderMode: 'html',
  212. confine: true,
  213. extraCssText: 'border-radius: 4px;padding:5px 0px 5px 5px;white-space:normal;word-warp:break-word;max-width: 400px;',
  214. borderColor: '#fff',
  215. formatter: (params: any) => {
  216. let tooltip = `<div class='tooltip_content'>`
  217. const title = params?.name || params[0]?.name
  218. const value = params?.value || params[0]?.value
  219. tooltip += `<div class='tooltip_title'>${title}</div>`
  220. if (props.typeName) {
  221. tooltip += `<div class='tooltip_student'>${props.typeName}:${value}${props.unit}</div>`
  222. }
  223. if (props.tooltipData.length > 0) {
  224. const list = props.tooltipData[params[0].dataIndex]?.list || []
  225. for (const item of list) {
  226. tooltip += `<div class='tooltip_student'>${item.name}:${item.value}</div>`
  227. }
  228. }
  229. tooltip += `</div>`
  230. return tooltip
  231. }
  232. },
  233. grid: {
  234. left: props.gridLeft,
  235. right: props.gridRight,
  236. top: props.gridTop,
  237. bottom: 0,
  238. containLabel: true
  239. },
  240. dataZoom,
  241. xAxis: [
  242. {
  243. type: 'category',
  244. data: props.datax,
  245. axisPointer: { type: 'shadow' },
  246. axisLabel: {
  247. interval: 0,
  248. fontWeight: 400,
  249. rotate: singleSeriesWidth < 80 ? 45 : 0,
  250. formatter: (value: string) => {
  251. const valueWidth = value.length * 14
  252. if (valueWidth > singleSeriesWidth) {
  253. if (singleSeriesWidth < 100) {
  254. return valueWidth > 80 ? value.slice(0, 2) + '...' + value.slice(-3) : value
  255. } else {
  256. const maxLength = Math.floor(singleSeriesWidth / 14) - 1
  257. return value.slice(0, maxLength) + '...'
  258. }
  259. }
  260. return `${value}${props.unitX}`
  261. },
  262. color: props.fontColor || '#666666',
  263. fontSize: props.fontSize ? Number(props.fontSize) : 14
  264. }
  265. }
  266. ],
  267. yAxis: {
  268. type: 'value',
  269. axisLabel: {
  270. color: props.fontColor || '#666666',
  271. fontSize: props.fontSize ? Number(props.fontSize) : 14,
  272. formatter: `{value}${yAxisUnit}`
  273. },
  274. max: maxAverage > nearestValue ? maxAverage : nearestValue,
  275. splitArea
  276. },
  277. series: [
  278. {
  279. name: '阅卷进度',
  280. type: 'bar',
  281. barMaxWidth: props.barMaxWidth,
  282. barMinWidth: props.barMinWidth,
  283. itemStyle: {
  284. color: (params: any) => {
  285. if (params.name === rightAnswerValue || params.name === props.fullMark) return '#3BA272'
  286. if (params.name === '0') return '#EE6666'
  287. return colors[params.dataIndex]
  288. }
  289. },
  290. data: props.datay.map(value => ({
  291. value,
  292. label: {
  293. show: props.showMarkPoint ? (value === 0 || value === maxValue || value === minValue) : singleSeriesWidth > 26,
  294. position: 'top',
  295. fontSize: props.fontSize ? Number(props.fontSize) : 14,
  296. formatter: `{c}${props.unit}`,
  297. color: '#666'
  298. }
  299. })),
  300. markLine: props.isShowMarkLine
  301. ? {
  302. symbolSize: [8, 8],
  303. symbolOffset: [[0, 0], [0, 0]],
  304. label: {
  305. color: '#F56C6C',
  306. fontSize: props.fontSize ? Number(props.fontSize) : 15,
  307. formatter: `{c}${props.unit}`,
  308. position: 'end'
  309. },
  310. data: markLineData,
  311. lineStyle: props.markLineData.length ? undefined : { color: '#F56C6C' }
  312. }
  313. : undefined,
  314. markPoint: props.showMarkPoint
  315. ? {
  316. data: props.datay
  317. .map((value, index) => {
  318. if (value === maxValue || value === minValue) {
  319. return {
  320. name: value === maxValue ? '最大值' : '最小值',
  321. coord: [props.datax[index], value],
  322. symbolSize: 65,
  323. label: {
  324. show: true,
  325. position: 'inside',
  326. color: '#fff',
  327. formatter: `${value}${props.unit}`
  328. },
  329. itemStyle: { color: markPointColor }
  330. }
  331. }
  332. return null
  333. })
  334. .filter(Boolean) as echarts.MarkPointDataItem[],
  335. label: { show: true, fontSize: 10, fontWeight: 'bold' }
  336. }
  337. : undefined
  338. }
  339. ]
  340. }
  341. echart.setOption(option)
  342. if (props.isClick) {
  343. const xAxisDataLength = props.datax.length
  344. const gridRect = echart.getModel().getComponent('grid').coordinateSystem.getRect()
  345. let singleLabelWidth = gridRect.width / xAxisDataLength
  346. if (singleSeriesWidth < barMinWidth) singleLabelWidth = barMinWidth
  347. echart.off('click')
  348. echart.on('click', (params: any) => {
  349. if (params.seriesType === 'bar') {
  350. echart?.setOption({
  351. series: [
  352. {
  353. itemStyle: {
  354. color: (p: any) => {
  355. if (p.name === rightAnswerValue || p.name === props.fullMark) return '#3BA272'
  356. if (p.name === '0') return '#EE6666'
  357. return colors[p.dataIndex]
  358. }
  359. }
  360. }
  361. ]
  362. })
  363. const pixelPosition = echart.convertToPixel({ xAxisIndex: 0 }, params.name)
  364. echart?.setOption({
  365. graphic: {
  366. id: 'highlight-box',
  367. type: 'rect',
  368. shape: {
  369. x: pixelPosition - singleLabelWidth / 2,
  370. y: gridRect.y,
  371. width: singleLabelWidth,
  372. height: gridRect.height
  373. },
  374. style: { fill: `${params.color}30` }
  375. }
  376. })
  377. emit('HandleChartClick', params.dataIndex, params.name)
  378. }
  379. })
  380. const defaultPixelPosition = echart.convertToPixel({ xAxisIndex: 0 }, props.datax[0])
  381. echart.setOption({
  382. graphic: {
  383. id: 'highlight-box',
  384. type: 'rect',
  385. shape: {
  386. x: defaultPixelPosition - singleLabelWidth / 2,
  387. y: gridRect.y,
  388. width: singleLabelWidth,
  389. height: gridRect.height
  390. },
  391. style: { fill: 'rgba(84,112,198,0.1)' }
  392. }
  393. })
  394. }
  395. }
  396. const UpdateColors = () => {
  397. if (!echart) return
  398. const newColors = ['#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0', '#9966FF', '#FF9F40', '#FF6384']
  399. echart.setOption({
  400. series: [
  401. {
  402. itemStyle: { color: (params: any) => newColors[params.dataIndex] }
  403. }
  404. ]
  405. })
  406. }
  407. const handleResize = _.throttle(() => {
  408. nextTick(() => LoadEchart())
  409. }, 500)
  410. // 监听 & 生命周期
  411. watch(() => props.datay, LoadEchart, { deep: true })
  412. watch(() => props.markLineData, LoadEchart, { deep: true })
  413. onMounted(() => {
  414. window.addEventListener('resize', handleResize)
  415. LoadEchart()
  416. })
  417. onUnmounted(() => {
  418. window.removeEventListener('resize', handleResize)
  419. if (echart) {
  420. echart.dispose()
  421. echart = null
  422. }
  423. })
  424. </script>
  425. <style lang="scss" scoped>
  426. .chart_width {
  427. width: 100%;
  428. height: 380px;
  429. }
  430. </style>