Browse Source

班级对比及图表

liurongli 6 days ago
parent
commit
0217e6f503

+ 8 - 0
src/api/analysis.ts

@@ -51,4 +51,12 @@ export const scoreSegment = (data: any): Promise<ApiResponse> => {
     method: "post",
     data,
   });
+};
+// ==========================================班级对比============================================
+export const classContrastSubjectTable = (data: any): Promise<ApiResponse> => {
+  return request({
+    url: "/api/v1/ai_analysis/classContrastSubjectTable",
+    method: "post",
+    data,
+  });
 };

BIN
src/assets/chart/chart_switch.webp


BIN
src/assets/chart/chart_switch_cur.webp


BIN
src/assets/chart/difference_chart.webp


BIN
src/assets/chart/difference_chart_cur.webp


BIN
src/assets/chart/horizontal_bar.webp


BIN
src/assets/chart/horizontal_bar_cur.webp


BIN
src/assets/chart/line_bar_chart.webp


BIN
src/assets/chart/line_bar_chart_cur.webp


BIN
src/assets/chart/radar_chart.webp


BIN
src/assets/chart/radar_chart_cur.webp


BIN
src/assets/chart/stacked_chart.webp


BIN
src/assets/chart/stacked_chart_cur.webp


+ 158 - 0
src/components/EchartType.vue

@@ -0,0 +1,158 @@
+<template>
+  <div class="echart_type">
+    <span
+      class="chart_icon_item"
+      v-for="item in chartTypeList"
+      :key="item.value"
+      :class="[current === item.value ? `${item.value}_cur` : item.value]"
+      @click="ChangeEchartType(item.value)"
+    >
+      {{ item.label }}
+    </span>
+  </div>
+</template>
+
+<script lang="ts" setup>
+// 定义列表项类型
+interface ChartItem {
+  lable: string
+  value: string | number
+  [key: string]: unknown
+}
+
+// 定义自定义事件类型
+type Emits = {
+  ChangeEchartType: (value: string | number) => void
+}
+
+// 声明 emit
+const emit = defineEmits<Emits>()
+
+// 定义 props 并做类型校验 + 默认值
+const props = defineProps({
+  current: {
+    type: String as () => string | number,
+    default: ''
+  },
+  chartTypeList: {
+    type: Array as () => ChartItem[],
+    default: () => []
+  }
+})
+
+/**
+ * 切换图表类型
+ * @param value 选中项标识
+ */
+const ChangeEchartType = (value: string | number) => {
+  // 遵循单向数据流,不修改 props,仅向外派发事件
+  emit('ChangeEchartType', value)
+}
+</script>
+
+<style lang="scss" scoped>
+.echart_type {
+  width: auto;
+  height: auto;
+  display: flex;
+  justify-content: flex-end;
+  gap: 12px;
+
+  .chart_icon_item {
+    font-weight: 400;
+    font-size: 14px;
+    color: #909399;
+    line-height: 16px;
+    background-repeat: no-repeat;
+    background-size: 16px 16px;
+    background-position: 0 50%;
+    text-indent: 20px;
+    cursor: pointer;
+  }
+
+  // 堆叠图
+  .stacked_chart {
+    background-image: url("@/assets/chart/stacked_chart.webp");
+  }
+  .stacked_chart_cur {
+    font-weight: 500;
+    font-size: 14px;
+    color: #2e64fa;
+    background-image: url("@/assets/chart/stacked_chart_cur.webp");
+  }
+
+  // 水平条形图
+  .horizontal_bar {
+    background-image: url("@/assets/chart/horizontal_bar.webp");
+  }
+  .horizontal_bar_cur {
+    font-weight: 500;
+    font-size: 14px;
+    color: #2e64fa;
+    background-image: url("@/assets/chart/horizontal_bar_cur.webp");
+  }
+
+  // 垂直条形图
+  .vertical_bar {
+    background-image: url("@/assets/chart/vertical_bar.webp");
+  }
+  .vertical_bar_cur {
+    font-weight: 500;
+    font-size: 14px;
+    color: #2e64fa;
+    background-image: url("@/assets/chart/vertical_bar_cur.webp");
+  }
+
+  // 率差图
+  .difference_chart {
+    background-image: url("@/assets/chart/difference_chart.webp");
+  }
+  .difference_chart_cur {
+    font-weight: 500;
+    font-size: 14px;
+    color: #2e64fa;
+    background-image: url("@/assets/chart/difference_chart_cur.webp");
+  }
+
+  // 雷达图
+  .radar_chart {
+    background-image: url("@/assets/chart/radar_chart.webp");
+  }
+  .radar_chart_cur {
+    font-weight: 500;
+    font-size: 14px;
+    color: #2e64fa;
+    background-image: url("@/assets/chart/radar_chart_cur.webp");
+  }
+
+  // 折线图
+  .line_chart {
+    background-image: url("@/assets/chart/line_chart.webp");
+  }
+  .line_chart_cur {
+    font-weight: 500;
+    font-size: 14px;
+    color: #2e64fa;
+    background-image: url("@/assets/chart/line_chart_cur.webp");
+  }
+
+  // 折线柱状组合图
+  .line_bar_chart {
+    background-image: url("@/assets/chart/line_bar_chart.webp");
+  }
+  .line_bar_chart_cur {
+    font-weight: 500;
+    font-size: 14px;
+    color: #2e64fa;
+    background-image: url("@/assets/chart/line_bar_chart_cur.webp");
+  }
+
+  .chart_switch {
+    background-image: url("@/assets/chart/chart_switch.webp");
+    &:hover {
+      background-image: url("@/assets/chart/chart_switch_cur.webp");
+      color: #2e64fa;
+    }
+  }
+}
+</style>

+ 71 - 0
src/components/ModuleTab.vue

@@ -0,0 +1,71 @@
+<template>
+    <div class="module_tab">
+        <div class="tab_item" :class="state.tabActive === item.value ? 'tab_active' : ''" :key="item.value"
+            v-for="item in tabList" @click="TabChange(item)">
+            {{ item.title }}
+        </div>
+    </div>
+</template>
+
+<script lang="ts" setup>
+import { reactive } from 'vue'
+
+// 1. 定义 tab 项类型
+interface TabItem {
+    title: string
+    value: string | number
+    [key: string]: unknown
+}
+
+// 2. 定义 Props + 完整类型 + 默认值
+const props = defineProps({
+    tabList: {
+        type: Array as () => TabItem[],
+        default: () => []
+    }
+})
+
+// 3. 定义 emit 并做 TS 类型约束
+const emit = defineEmits<{
+    TabChange: (value: string | number) => void
+}>()
+
+// 4. 响应式状态
+const state = reactive({
+    tabActive: '0' as string | number
+})
+
+// 5. tab 切换方法 + 入参类型
+const TabChange = (item: Object) => {
+    state.tabActive = item.value;
+    emit('TabChange', item)
+}
+</script>
+
+<style lang="scss" scoped>
+.module_tab {
+    flex: 1;
+    height: 100%;
+    padding: 10px;
+    display: flex;
+    justify-content: flex-start;
+    gap: 20px;
+    position: relative;
+
+    .tab_item {
+        font-weight: 400;
+        font-size: 14px;
+        color: #999999;
+        cursor: pointer;
+        padding-bottom: 6px;
+    }
+
+    .tab_active {
+        font-weight: 600;
+        font-size: 14px;
+        color: #2E64FA;
+        cursor: pointer;
+        border-bottom: 2px solid #2E64FA;
+    }
+}
+</style>

+ 35 - 10
src/components/ReportModule.vue

@@ -1,6 +1,6 @@
 <template>
     <div class="report_module">
-        <div class="module_title">
+        <div class="module_title" v-if="tableOrChart!='qita'">
             <div class="title_left">
                 <template v-if="showTitle && titleList.length">
                     <template v-for="(item, index) in titleList">
@@ -25,6 +25,8 @@
             </div>
         </div>
         <div :class="[`module_${tableOrChart}`, { table_42: tableOrChart == 'table' }]">
+            <!-- 其他 -->
+            <slot name="module_qita" />
             <!-- 表格或图表显示 -->
             <slot name="module_table_chart" />
             <!-- 表格分页 -->
@@ -54,7 +56,7 @@ interface TitleListType {
     showTitle?: boolean
     showPrintBtn?: boolean
     showExportBtn?: boolean
-    tableOrChart?: 'table' | 'chart'
+    tableOrChart?: 'table' | 'chart' | 'qita'
     showTablePage?: boolean
     currentPage?: number
     pageSize?: number
@@ -64,17 +66,17 @@ interface TitleListType {
 }
 
 const props = withDefaults(defineProps<TitleListType>(), {
-    titleList: () => [],
-    showTitle: true,
-    showPrintBtn: true,
-    showExportBtn: true,
-    tableOrChart: 'table',
-    showTablePage: true,
+    titleList: () => [],//标题
+    showTitle: true,//是否显示标题
+    showPrintBtn: true,//是否显示打印按钮
+    showExportBtn: true,//是否显示导出按钮
+    tableOrChart: 'table',//表格、图表、其他
+    showTablePage: true,//是否显示分页
     currentPage: 1,
     pageSize: 10,
     pageSizes: () => [10, 20, 30, 40, 50, 100],
     total: 0,
-    showDescribe: true
+    showDescribe: true//是否显示描述
 })
 
 const emit = defineEmits<{
@@ -216,7 +218,8 @@ onMounted(() => {
         padding: 0 20px 14px;
         box-sizing: border-box;
         border-collapse: collapse;
-        :deep(.table_row_blue){
+
+        :deep(.table_row_blue) {
             color: #2e64fa;
             cursor: pointer;
         }
@@ -227,6 +230,23 @@ onMounted(() => {
         padding: 0 20px;
         box-sizing: border-box;
         min-height: 360px;
+
+        //无数据显示
+        :deep(.no_content_data) {
+            background-image: url("../assets/bg/no_content_bg.png");
+            background-repeat: no-repeat;
+            background-size: 360px auto;
+            background-position: 50% 30%;
+            color: #999999;
+            justify-content: center;
+            display: flex;
+            align-items: center;
+            min-height: 360px;
+            span{
+                margin-top: 80px;
+                font-size: 14px;
+            }
+        }
     }
 
     .module_describe {
@@ -287,5 +307,10 @@ onMounted(() => {
             background-repeat: no-repeat;
         }
     }
+
+    .module_qita {
+        width: 100%;
+        display: flex;
+    }
 }
 </style>

+ 460 - 0
src/components/echarts/barChart.vue

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

+ 460 - 0
src/components/echarts/barHorizontal.vue

@@ -0,0 +1,460 @@
+<template>
+  <div ref="barEchartHorizontal" class="echart_content" :style="{ height: `${chartHeight}px` }"></div>
+</template>
+
+<script setup lang="ts">
+import { ref, watch, onMounted, onBeforeUnmount, nextTick, withDefaults } from 'vue'
+import _ from 'lodash'
+import * as echarts from 'echarts'
+import { getCompareAnalysis } from '@/utils/common'
+
+// ===================== 类型定义 =====================
+/** 单条辅助线配置 */
+interface MarkLineItem {
+  isShow: boolean
+  value: number | string
+}
+
+/** 悬浮提示子项 */
+interface TooltipListItem {
+  name?: string
+  value: string | number
+}
+
+/** 悬浮提示数据 */
+interface TooltipDataItem {
+  rank?: string
+  list: TooltipListItem[]
+}
+
+/** Props 类型 */
+interface BarHorizontalProps {
+  datax: string[]
+  datay: (number | string)[]
+  color: string
+  isClick: boolean
+  markNumber: number | number[]
+  markLineData: MarkLineItem[]
+  unit: string
+  typeName: string
+  tooltipData: TooltipDataItem[]
+}
+
+// ===================== Props 与默认值 =====================
+const props = withDefaults(defineProps<BarHorizontalProps>(), {
+  datax: () => [1, 2, 3, 4, 5, 6, 7].map(String),
+  datay: () => [50, 60, 70, 80, 70, 70, 100],
+  color: '#FAC858',
+  isClick: false,
+  markNumber: 65,
+  markLineData: () => [],
+  unit: '%',
+  typeName: '',
+  tooltipData: () => []
+})
+
+// 自定义事件
+const emit = defineEmits<{
+  HandleChartClick: [index: number, xName: string]
+}>()
+
+// ===================== 响应式变量 =====================
+const barEchartHorizontal = ref<HTMLDivElement | null>(null)
+let echart: echarts.ECharts | null = null
+const chartHeight = ref(0)
+
+// 窗口 resize 节流
+const handleResize = _.throttle(async () => {
+  await nextTick()
+  loadEchart()
+}, 500)
+
+// 全局监听窗口变化(替代 Vue2 created)
+window.addEventListener('resize', handleResize)
+
+// ===================== 工具函数 =====================
+/** 计算坐标轴最大值规则 */
+function 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(
+    ({ maxThreshold }) => value === maxThreshold || value < maxThreshold
+  )
+
+  if (matchedRule) {
+    return matchedRule.bound
+  }
+  return Math.ceil(value / 10) * 10
+}
+
+/** 设置图表高度 */
+function setChartHeight() {
+  const len = props.datax.length
+  let h = len * 24 + 60
+  if (h < 380) h = 380
+  if (h > 600) h = 600
+  chartHeight.value = h
+}
+
+// ===================== 初始化 ECharts =====================
+function loadEchart() {
+  const dom = barEchartHorizontal.value
+  if (!dom) return
+
+  // 销毁旧实例
+  if (echart) {
+    echart.dispose()
+    echart = null
+  }
+
+  // 初始化实例
+  echart = echarts.init(dom, null, { devicePixelRatio: 2 })
+
+  const { datax, datay, color, isClick, markNumber, markLineData, unit, typeName, tooltipData } = props
+  const totalHeight = dom.clientHeight
+  const xLen = datax.length
+
+  // 计算单条高度 & 滚动条配置
+  const singleSeriesHeight = Math.ceil((totalHeight - 97) / xLen)
+  const barMinHeight = 20
+  const dataZoomNum = Math.floor((totalHeight - 97) / barMinHeight)
+  const dataZoomEnd = Math.floor((100 / xLen) * dataZoomNum)
+
+  // 纵向 dataZoom
+  const dataZoom: echarts.DataZoomSliderOption | null = singleSeriesHeight < barMinHeight
+    ? {
+        start: 0,
+        end: dataZoomEnd,
+        type: 'slider',
+        show: true,
+        borderColor: 'transparent',
+        borderCap: 'round',
+        yAxisIndex: [0],
+        width: 8,
+        right: 20,
+        top: 20,
+        bottom: 20,
+        fillerColor: 'transparent',
+        zoomLock: true,
+        handleSize: '0',
+        handleStyle: {
+          color: '#b8b8b8',
+          borderWidth: 2
+        },
+        backgroundColor: 'transparent',
+        showDataShadow: false,
+        showDetail: false,
+        filterMode: 'filter'
+      }
+    : null
+
+  // 处理辅助线数据
+  let maxMarkNumber = 0
+  const markLineArr: echarts.MarkLineDataItem[] = []
+  const isMarkArr = Array.isArray(markNumber)
+
+  if (isMarkArr) {
+    const numArr = markNumber as number[]
+    maxMarkNumber = numArr.length ? Math.max(...numArr) : 0
+    numArr.forEach(item => {
+      markLineArr.push({
+        name: '辅助线',
+        xAxis: item,
+        label: {
+          show: true,
+          formatter: (e) => `${e.value}${unit}`,
+          position: 'start',
+          color: '#F56C6C',
+          fontSize: 14
+        }
+      })
+    })
+  } else if (markLineData.length) {
+    const tempNum: number[] = []
+    markLineData.forEach((item, index) => {
+      tempNum.push(Number(item.value))
+      if (item.isShow) {
+        const lineColor = getCompareAnalysis[index] || '#F56C6C'
+        markLineArr.push({
+          name: '辅助线',
+          xAxis: Number(item.value),
+          label: {
+            show: true,
+            formatter: (e) => `${e.value}${unit}`,
+            position: 'start',
+            color: lineColor,
+            fontSize: 14
+          },
+          lineStyle: { color: lineColor }
+        })
+      }
+    })
+    maxMarkNumber = tempNum.length ? Math.max(...tempNum) : 0
+  } else {
+    maxMarkNumber = markNumber as number
+    markLineArr.push({
+      name: '辅助线',
+      xAxis: markNumber as number,
+      label: {
+        show: true,
+        formatter: (e) => `${e.value}${unit}`,
+        position: 'start',
+        color: '#F56C6C',
+        fontSize: 14
+      }
+    })
+  }
+
+  // 过滤有效数值 & 计算X轴最大值
+  const validData = datay.filter(num => !isNaN(Number(num))).map(Number)
+  const maxValue = validData.length ? Math.max(...validData) : 0
+  const nearestValue = getMaxValue(maxValue)
+  const xAxisMax = maxMarkNumber > nearestValue ? maxMarkNumber : nearestValue
+
+  // ECharts 配置项
+  const option: echarts.EChartsOption = {
+    tooltip: {
+      axisPointer: { type: 'shadow' },
+      trigger: 'axis',
+      triggerOn: 'mousemove | click',
+      confine: true,
+      enterable: true,
+      borderColor: '#fff',
+      extraCssText: 'border-radius: 4px;padding:5px 0 5px 5px;white-space:normal;word-wrap:break-word;max-width: 400px;',
+      formatter: (params) => {
+        const p = Array.isArray(params) ? params[0] : params
+        const title = p.name || ''
+        const val = p.value ?? ''
+        let tipHtml = `<div class="tooltip_content">`
+
+        if (tooltipData.length) {
+          const tipItem = tooltipData[p.dataIndex]
+          if (typeName) {
+            tipHtml += `<div class="tooltip_title">${title}</div>`
+            tipHtml += `<div class="tooltip_student">${typeName}:${val}${unit}</div>`
+          } else {
+            tipHtml += `<div class="tooltip_title">${title} ${tipItem?.rank ?? ''}</div>`
+          }
+          if (tipItem?.list?.length) {
+            tipItem.list.forEach(item => {
+              if (item.name) {
+                tipHtml += `<div class="tooltip_student">${item.name}:${item.value}</div>`
+              } else {
+                tipHtml += `<div class="tooltip_student">${item.value}</div>`
+              }
+            })
+          }
+        } else {
+          tipHtml += `<div class="tooltip_title">${title}</div>`
+          tipHtml += `<div class="tooltip_student">${typeName}:${val}${unit}</div>`
+        }
+        tipHtml += `</div>`
+        return tipHtml
+      }
+    },
+    grid: {
+      left: 40,
+      right: 50,
+      top: 20,
+      bottom: 0,
+      containLabel: true
+    },
+    dataZoom: dataZoom,
+    yAxis: {
+      type: 'category',
+      data: datax,
+      axisPointer: { type: 'shadow' },
+      axisLabel: {
+        interval: 0,
+        color: '#666666',
+        fontSize: 14
+      },
+      splitArea: {
+        show: true,
+        areaStyle: {
+          color: ['#fafafa', '#ffffff']
+        }
+      },
+      inverse: true
+    },
+    xAxis: {
+      type: 'value',
+      max: xAxisMax,
+      axisLabel: {
+        formatter: `{value}${unit === '%' ? unit : ''}`,
+        color: '#666666',
+        fontSize: 14
+      },
+      axisLine: {
+        show: true,
+        lineStyle: {
+          color: '#E4E7ED',
+          width: 1
+        }
+      },
+      splitLine: {
+        show: false
+      },
+      axisTick: {
+        show: true,
+        alignWithLabel: false
+      }
+    },
+    series: [
+      {
+        name: '',
+        type: 'bar',
+        barMaxWidth: 50,
+        barMinWidth: 14,
+        itemStyle: { color: color },
+        label: {
+          show: true,
+          position: 'right',
+          formatter: `{c}${unit}`,
+          color: '#666',
+          fontSize: 14
+        },
+        data: datay,
+        markLine: markNumber
+          ? {
+              symbol: ['triangle', 'none'],
+              symbolSize: [8, 8],
+              lineStyle: {
+                type: 'dashed',
+                color: '#F56C6C',
+                width: 1
+              },
+              label: {
+                formatter: `{c}${unit}`,
+                position: 'start'
+              },
+              data: markLineArr
+            }
+          : undefined
+      }
+    ]
+  }
+
+  echart.setOption(option)
+
+  // 点击高亮 + 点击事件
+  if (isClick && echart) {
+    const gridModel = echart.getModel().getComponent('grid')
+    const gridRect = gridModel.coordinateSystem.getRect()
+    const singleLabelHeight = gridRect.height / xLen
+
+    // 默认高亮第一项
+    if (datax.length > 0) {
+      const defaultPixelPos = echart.convertToPixel({ yAxisIndex: 0 }, datax[0])
+      echart.setOption({
+        graphic: {
+          id: 'highlight-box',
+          type: 'rect',
+          shape: {
+            y: defaultPixelPos - singleLabelHeight / 2,
+            x: gridRect.x,
+            width: gridRect.width,
+            height: singleLabelHeight
+          },
+          style: {
+            fill: 'rgba(84,112,198,0.1)'
+          }
+        }
+      })
+    }
+
+    // 柱子点击监听
+    echart.on('click', (params) => {
+      if (params.componentSubType !== 'bar') return
+
+      const pixelPosition = echart.convertToPixel({ yAxisIndex: 0 }, params.name)
+      echart.setOption({
+        graphic: {
+          id: 'highlight-box',
+          type: 'rect',
+          shape: {
+            y: pixelPosition - singleLabelHeight / 2,
+            x: gridRect.x,
+            width: gridRect.width,
+            height: singleLabelHeight
+          },
+          style: {
+            fill: (params.color as string) + '30'
+          }
+        }
+      })
+
+      emit('HandleChartClick', params.dataIndex, params.name as string)
+    })
+  }
+}
+
+// ===================== 监听 & 生命周期 =====================
+// 数据变化 → 重算高度 + 重绘
+watch(
+  () => props.datay,
+  async () => {
+    setChartHeight()
+    await nextTick()
+    loadEchart()
+  },
+  { deep: true }
+)
+
+// 辅助线变化 → 重绘
+watch(
+  () => props.markLineData,
+  () => loadEchart(),
+  { deep: true }
+)
+
+// 组件挂载初始化
+onMounted(async () => {
+  setChartHeight()
+  await nextTick()
+  loadEchart()
+})
+
+// 组件卸载:清除监听、销毁图表
+onBeforeUnmount(() => {
+  window.removeEventListener('resize', handleResize)
+  if (echart) {
+    echart.dispose()
+    echart = null
+  }
+})
+</script>
+
+<style lang="scss" scoped>
+.echart_content {
+  width: 100%;
+}
+</style>

+ 444 - 0
src/components/echarts/barScoringRate_horzontal.vue

@@ -0,0 +1,444 @@
+<template>
+  <!-- 横向堆叠条形图 -->
+  <div 
+    ref="barScoringRateHorzontal" 
+    class="echart_content" 
+    :style="{ height: `${chartHeight}px` }"
+  ></div>
+</template>
+
+<script setup lang="ts">
+import { ref, watch, onMounted, onBeforeUnmount, nextTick, withDefaults } from 'vue'
+import throttle from 'lodash/throttle'
+import * as echarts from 'echarts'
+import { getCompareAnalysis } from '@/utils/common'
+
+// ===================== 类型定义 =====================
+/** 单条辅助线配置 */
+interface MarkLineItem {
+  isShow: boolean
+  value: string | number
+}
+
+/** 悬浮提示子项 */
+interface TooltipListItem {
+  name: string
+  value: string | number
+}
+
+/** 悬浮提示数据 */
+interface TooltipDataItem {
+  list: TooltipListItem[]
+}
+
+/** Props 类型 */
+interface BarScoringRateHorizontalProps {
+  datax: string[]
+  datay: (string | number)[][]
+  color: string[]
+  unit: string
+  showNuitY: boolean
+  showTooltip: boolean
+  tooltipData: TooltipDataItem[]
+  typeName: string
+  average: string | number | (string | number)[]
+  markLineData: MarkLineItem[]
+  isClick: boolean
+}
+
+// ===================== Props 配置 & 默认值 =====================
+const props = withDefaults(defineProps<BarScoringRateHorizontalProps>(), {
+  datax: () => [],
+  datay: () => [],
+  color: () => ['#3BA272', '#EE6666'],
+  unit: '%',
+  showNuitY: true,
+  showTooltip: true,
+  tooltipData: () => [],
+  typeName: '',
+  average: '0',
+  markLineData: () => [],
+  isClick: false
+})
+
+// 自定义事件
+const emit = defineEmits<{
+  HandleChartClick: [index: number, xName: string]
+}>()
+
+// ===================== 响应式变量 =====================
+const barScoringRateHorzontal = ref<HTMLDivElement | null>(null)
+let myChart: echarts.ECharts | null = null
+const chartHeight = ref(0)
+let firstBarColor = ''
+
+// 窗口 resize 节流函数
+const handleResize = throttle(async () => {
+  await nextTick()
+  loadEchart()
+}, 500)
+
+// 全局监听窗口变化(替代 Vue2 created)
+window.addEventListener('resize', handleResize)
+
+// ===================== 图表初始化核心方法 =====================
+function loadEchart() {
+  const dom = barScoringRateHorzontal.value
+  if (!dom) return
+
+  // 销毁旧实例
+  if (myChart) {
+    myChart.dispose()
+    myChart = null
+  }
+
+  // 初始化 ECharts
+  myChart = echarts.init(dom, null, { devicePixelRatio: 2 })
+
+  const {
+    datax,
+    datay,
+    color,
+    unit,
+    showNuitY,
+    showTooltip,
+    tooltipData,
+    typeName,
+    average,
+    markLineData,
+    isClick
+  } = props
+
+  const colors = color
+  const totalHeight = dom.clientHeight
+  const xLen = datax.length
+
+  // 计算单条高度 & 纵向滚动条配置
+  const singleSeriesHeight = Math.ceil((totalHeight - 97) / xLen)
+  const barMinHeight = 20
+  const dataZoomNum = Math.floor((totalHeight - 97) / barMinHeight)
+  const dataZoomEnd = Math.floor((100 / xLen) * dataZoomNum)
+
+  // 纵向 dataZoom
+  const dataZoom: echarts.DataZoomSliderOption | null = singleSeriesHeight < barMinHeight
+    ? {
+        start: 0,
+        end: dataZoomEnd,
+        type: 'slider',
+        show: true,
+        borderColor: 'transparent',
+        borderCap: 'round',
+        yAxisIndex: [0],
+        width: 8,
+        right: 20,
+        top: 20,
+        bottom: 20,
+        zoomLock: true,
+        brushSelect: false,
+        preventDefaultMouseMove: false,
+        handleSize: 0,
+        handleStyle: {
+          color: '#b8b8b8',
+          borderWidth: 2
+        },
+        backgroundColor: 'transparent',
+        showDataShadow: false,
+        showDetail: false,
+        filterMode: 'filter'
+      }
+    : null
+
+  // 处理平均线/多条辅助线
+  const isArrayAvg = Object.prototype.toString.call(average) === '[object Array]'
+  let markNumber: number | (string | number)[] = 0
+  let markLineArr: echarts.MarkLineDataItem[] = []
+
+  if (isArrayAvg) {
+    markNumber = average
+  } else {
+    markNumber = parseFloat(average as string) || 0
+  }
+
+  if (markLineData && markLineData.length > 0) {
+    markLineData.forEach((item, index) => {
+      if (item.isShow) {
+        const lineColor = getCompareAnalysis[index] || '#F56C6C'
+        markLineArr.push({
+          name: '辅助线',
+          xAxis: Number(item.value),
+          label: {
+            show: true,
+            formatter: (e) => {
+              return e.value === 0 ? '' : `${e.value}%`
+            },
+            position: 'start',
+            color: lineColor,
+            fontSize: 14
+          },
+          lineStyle: { color: lineColor }
+        })
+      }
+    })
+  } else {
+    const val = Array.isArray(markNumber) ? markNumber[0] : markNumber
+    markLineArr.push({
+      name: '辅助线',
+      xAxis: Number(val),
+      label: {
+        show: true,
+        formatter: (e) => {
+          return e.value === 0 ? '' : `${e.value}%`
+        },
+        position: 'start',
+        color: '#F56C6C',
+        fontSize: 14
+      }
+    })
+  }
+
+  const yAxisUnit = showNuitY ? unit : ''
+
+  // 计算柱子颜色列表
+  const barColorList = datay[0]?.map((item, key) => {
+    const targetVal = Array.isArray(markNumber) ? markNumber[key] : markNumber
+    const barColor = Number(item) >= Number(targetVal) ? colors[0] : colors[1]
+    if (key === 0) {
+      firstBarColor = barColor
+    }
+    return barColor
+  }) || []
+
+  // 组装 series 数据
+  const seriesData: echarts.SeriesOption[] = []
+  for (let i = 0; i < datay.length; i++) {
+    const itemYData = datay[i].map((item, key) => {
+      let itemColor = ''
+      let borderColor = ''
+      if (i === 0) {
+        itemColor = barColorList[key]
+        borderColor = barColorList[key]
+      } else {
+        itemColor = '#fff'
+        borderColor = barColorList[key]
+      }
+      return {
+        value: item,
+        itemStyle: {
+          color: itemColor,
+          borderWidth: 1,
+          borderColor: borderColor
+        }
+      }
+    })
+
+    const hasMarkLine = (!isArrayAvg && Number(markNumber) !== 0) || markLineData.length > 0
+
+    seriesData.push({
+      name: i === 0 ? '得分率' : '失分率',
+      type: 'bar',
+      stack: 'total',
+      barMaxWidth: 50,
+      barMinWidth: 14,
+      label: {
+        show: true,
+        fontSize: 12,
+        formatter: `{c}${unit}`,
+        color: i === 1 ? '#666' : '#fff'
+      },
+      data: itemYData,
+      markLine: hasMarkLine
+        ? {
+            symbol: ['triangle', 'none'],
+            symbolSize: [8, 8],
+            lineStyle: {
+              type: 'dashed',
+              color: '#F56C6C',
+              width: 1
+            },
+            label: {
+              formatter: `{c}${unit}`,
+              position: 'start'
+            },
+            data: markLineArr
+          }
+        : undefined
+    })
+  }
+
+  // ECharts 配置项
+  const option: echarts.EChartsOption = {
+    grid: {
+      left: 20,
+      right: 50,
+      top: 20,
+      bottom: 0,
+      containLabel: true
+    },
+    dataZoom: dataZoom,
+    tooltip: {
+      show: showTooltip,
+      trigger: showTooltip ? 'axis' : 'item',
+      triggerOn: 'mousemove',
+      confine: true,
+      borderColor: '#fff',
+      extraCssText: 'border-radius: 4px;padding:5px 0 5px 5px;white-space:normal;word-wrap:break-word;max-width: 400px;',
+      formatter: (params) => {
+        const pList = Array.isArray(params) ? params : [params]
+        const title = pList[0]?.name || ''
+        const value = pList[0]?.value ?? ''
+        let tipHtml = `<div class="tooltip_content"><div class="tooltip_title">${title}</div>`
+        tipHtml += `<div class="tooltip_student">${typeName}:${value}${unit}</div>`
+
+        const tipItem = tooltipData[pList[0]?.dataIndex ?? 0]
+        if (tipItem?.list?.length) {
+          tipItem.list.forEach(item => {
+            tipHtml += `<div class="tooltip_student">${item.name}:${item.value}</div>`
+          })
+        }
+        tipHtml += '</div>'
+        return tipHtml
+      }
+    },
+    xAxis: {
+      type: 'value',
+      max: 100,
+      axisLabel: {
+        color: '#666666',
+        fontSize: 14,
+        formatter: `{value}${yAxisUnit}`
+      },
+      axisLine: {
+        show: true,
+        lineStyle: {
+          color: '#E4E7ED',
+          width: 1
+        }
+      },
+      splitLine: {
+        show: false
+      },
+      axisTick: {
+        show: true,
+        alignWithLabel: false
+      }
+    },
+    yAxis: {
+      type: 'category',
+      data: datax,
+      axisPointer: {
+        type: 'shadow'
+      },
+      axisLabel: {
+        interval: 0,
+        color: '#666666',
+        width: 80,
+        fontSize: 14
+      },
+      inverse: true
+    },
+    series: seriesData
+  }
+
+  myChart.setOption(option, true)
+
+  // 点击高亮逻辑
+  if (isClick && myChart) {
+    const gridModel = myChart.getModel().getComponent('grid')
+    const gridRect = gridModel.coordinateSystem.getRect()
+    const singleLabelHeight = gridRect.height / xLen
+
+    // 默认选中第一项
+    if (datax.length > 0) {
+      const defaultPixelPos = myChart.convertToPixel({ yAxisIndex: 0 }, datax[0])
+      myChart.setOption({
+        graphic: {
+          id: 'highlight-box',
+          type: 'rect',
+          shape: {
+            y: defaultPixelPos - singleLabelHeight / 2,
+            x: gridRect.x,
+            width: gridRect.width,
+            height: singleLabelHeight
+          },
+          style: {
+            fill: firstBarColor + '30'
+          }
+        }
+      })
+    }
+
+    // 柱子点击事件
+    myChart.on('click', (params) => {
+      if (params.componentSubType !== 'bar') return
+
+      const pixelPosition = myChart.convertToPixel({ yAxisIndex: 0 }, params.name)
+      myChart.setOption({
+        graphic: {
+          id: 'highlight-box',
+          type: 'rect',
+          shape: {
+            y: pixelPosition - singleLabelHeight / 2,
+            x: gridRect.x,
+            width: gridRect.width,
+            height: singleLabelHeight
+          },
+          style: {
+            fill: (params.borderColor as string) + '30'
+          }
+        }
+      })
+
+      emit('HandleChartClick', params.dataIndex, params.name as string)
+    })
+  }
+}
+
+// ===================== 监听 & 生命周期 =====================
+// 监听 datax 变化,动态计算高度并重绘图表
+watch(
+  () => props.datax,
+  async () => {
+    const len = props.datax.length
+    let h = len * 24 + 60
+    if (h < 380) h = 380
+    if (h > 600) h = 600
+    chartHeight.value = h
+    await nextTick()
+    loadEchart()
+  },
+  { deep: true }
+)
+
+// 监听辅助线数据变化
+watch(
+  () => props.markLineData,
+  () => loadEchart(),
+  { deep: true }
+)
+
+// 组件挂载初始化
+onMounted(async () => {
+  const len = props.datax.length
+  let h = len * 24 + 60
+  if (h < 380) h = 380
+  if (h > 600) h = 600
+  chartHeight.value = h
+  await nextTick()
+  loadEchart()
+})
+
+// 组件卸载:清除监听 & 销毁图表
+onBeforeUnmount(() => {
+  window.removeEventListener('resize', handleResize)
+  if (myChart) {
+    myChart.dispose()
+    myChart = null
+  }
+})
+</script>
+
+<style lang="scss" scoped>
+.echart_content {
+  width: 100%;
+}
+</style>

+ 408 - 0
src/components/echarts/barScoringRate_vertical.vue

@@ -0,0 +1,408 @@
+<template>
+  <!-- 竖向堆叠条形图 -->
+  <div 
+    ref="barScoringRateVertical" 
+    class="chart_width" 
+    :style="{ height: `${height}px` }"
+  ></div>
+</template>
+
+<script setup lang="ts">
+import { ref, watch, onMounted, onBeforeUnmount, withDefaults } from 'vue'
+import throttle from 'lodash/throttle'
+import * as echarts from 'echarts'
+import { getCompareAnalysis } from '@/utils/common'
+
+// ===================== 类型定义 =====================
+/** 单条辅助线配置 */
+interface MarkLineItem {
+  isShow: boolean
+  value: string | number
+}
+
+/** 悬浮提示单条数据 */
+interface TooltipListItem {
+  name: string
+  value: string | number
+}
+
+/** 悬浮提示数据项 */
+interface TooltipDataItem {
+  list: TooltipListItem[]
+}
+
+/** Props 类型 */
+interface BarScoringRateVerticalProps {
+  datax: string[]
+  datay: (string | number)[][]
+  color: string[]
+  height: number
+  unit: string
+  showNuitY: boolean
+  showTooltip: boolean
+  tooltipData: TooltipDataItem[]
+  typeName: string
+  average: string | number | (string | number)[]
+  markLineData: MarkLineItem[]
+  isClick: boolean
+}
+
+// ===================== Props 配置 =====================
+const props = withDefaults(defineProps<BarScoringRateVerticalProps>(), {
+  datax: () => [],
+  datay: () => [],
+  color: () => ['#3BA272', '#EE6666'],
+  height: 360,
+  unit: '%',
+  showNuitY: true,
+  showTooltip: true,
+  tooltipData: () => [],
+  typeName: '',
+  average: '0',
+  markLineData: () => [],
+  isClick: false
+})
+
+// 事件派发
+const emit = defineEmits<{
+  HandleChartClick: [index: number, xName: string]
+}>()
+
+// ===================== 响应式变量 =====================
+const barScoringRateVertical = ref<HTMLDivElement | null>(null)
+let myChart: echarts.ECharts | null = null
+let firstBarColor = ''
+
+// 窗口 resize 节流
+const handleResize = throttle(() => {
+  if (myChart) {
+    myChart.resize()
+  }
+}, 500)
+
+// 全局监听窗口变化(替代 Vue2 created)
+window.addEventListener('resize', handleResize)
+
+// ===================== 工具函数 & 图表初始化 =====================
+function loadEchart() {
+  const dom = barScoringRateVertical.value
+  if (!dom) return
+
+  // 销毁旧实例
+  if (myChart) {
+    myChart.dispose()
+    myChart = null
+  }
+
+  // 初始化 echarts
+  myChart = echarts.init(dom, null, { devicePixelRatio: 2 })
+  const colors = props.color
+  const { datax, datay, unit, showNuitY, showTooltip, tooltipData, typeName, average, markLineData, isClick } = props
+
+  // 计算柱子宽度 & 滚动条
+  const totalWidth = dom.clientWidth
+  const xLen = datax.length
+  const singleSeriesWidth = Math.ceil((totalWidth - 140) / xLen)
+  const barMinWidth = 30
+  const dataZoomNum = Math.floor((totalWidth - 140) / barMinWidth)
+  const dataZoomEnd = Math.floor((100 / xLen) * dataZoomNum)
+
+  // 滚动条配置
+  const dataZoom: echarts.DataZoomSliderOption | null = singleSeriesWidth < barMinWidth
+    ? {
+        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'
+      }
+    : null
+
+  // 处理平均线 / 多条辅助线
+  const isArrayAvg = Object.prototype.toString.call(average) === '[object Array]'
+  let markNumber: number | (string | number)[] = 0
+  let markLineArr: echarts.MarkLineDataItem[] = []
+
+  if (isArrayAvg) {
+    markNumber = average
+  } else {
+    markNumber = parseFloat(average as string) || 0
+  }
+
+  if (markLineData && markLineData.length > 0) {
+    markLineData.forEach((item, index) => {
+      if (item.isShow) {
+        const color = getCompareAnalysis[index] || '#F56C6C'
+        markLineArr.push({
+          symbol: 'circle',
+          type: 'value',
+          yAxis: Number(item.value),
+          lineStyle: { color },
+          label: { color }
+        })
+      }
+    })
+  } else {
+    const val = Array.isArray(markNumber) ? markNumber[0] : markNumber
+    markLineArr.push({
+      type: 'value',
+      yAxis: Number(val)
+    })
+  }
+
+  const yAxisUnit = showNuitY ? unit : ''
+
+  // 计算柱子颜色列表
+  const barColorList = datay[0]?.map((item, key) => {
+    const targetVal = Array.isArray(markNumber) ? markNumber[key] : markNumber
+    const barColor = Number(item) >= Number(targetVal) ? colors[0] : colors[1]
+    if (key === 0) {
+      firstBarColor = barColor
+    }
+    return barColor
+  }) || []
+
+  // 组装 series 数据
+  const seriesData: echarts.SeriesOption[] = []
+  for (let i = 0; i < datay.length; i++) {
+    const itemYData = datay[i].map((item, key) => {
+      let itemColor = ''
+      let borderColor = ''
+      if (i === 0) {
+        itemColor = barColorList[key]
+        borderColor = barColorList[key]
+      } else {
+        itemColor = '#fff'
+        borderColor = barColorList[key]
+      }
+      return {
+        value: item,
+        itemStyle: {
+          color: itemColor,
+          borderWidth: 1,
+          borderColor: borderColor
+        }
+      }
+    })
+
+    const hasMarkLine = (!isArrayAvg && Number(markNumber) !== 0) || markLineData.length > 0
+
+    seriesData.push({
+      name: i === 0 ? '得分率' : '失分率',
+      type: 'bar',
+      stack: 'total',
+      barMaxWidth: 50,
+      barMinWidth: 20,
+      label: {
+        show: singleSeriesWidth > 26,
+        fontSize: singleSeriesWidth < 60 ? 8 : 12,
+        formatter: (e) => {
+          if (e.value === '0' || e.value === '0.00') {
+            return ''
+          }
+          return `${e.value}%`
+        },
+        color: i === 1 ? '#666' : '#fff'
+      },
+      data: itemYData,
+      markLine: hasMarkLine
+        ? {
+            symbol: ['circle', 'arrow'],
+            symbolSize: [8, 8],
+            symbolOffset: [[0, 0], [0, 0]],
+            label: {
+              color: '#F56C6C',
+              fontSize: 14,
+              formatter: `{c}${unit}`,
+              position: 'end'
+            },
+            data: markLineArr,
+            lineStyle: markLineData.length ? undefined : { color: '#F56C6C' }
+          }
+        : undefined
+    })
+  }
+
+  // X轴文字截断逻辑
+  function formatXLabel(value: string): string {
+    const charWidth = 14
+    const valueWidth = value.length * charWidth
+    if (valueWidth <= singleSeriesWidth) return value
+
+    if (singleSeriesWidth < 80) {
+      return valueWidth > 80 ? `${value.slice(0, 2)}...${value.slice(-3)}` : value
+    } else {
+      const maxLength = Math.floor(singleSeriesWidth / charWidth) - 1
+      return value.slice(0, maxLength) + '...'
+    }
+  }
+
+  // ECharts 配置项
+  const option: echarts.EChartsOption = {
+    grid: {
+      top: 20,
+      left: 40,
+      right: 60,
+      bottom: 0,
+      containLabel: true
+    },
+    dataZoom: dataZoom,
+    tooltip: {
+      show: showTooltip,
+      trigger: showTooltip ? 'axis' : 'item',
+      triggerOn: 'mousemove',
+      confine: true,
+      borderColor: '#fff',
+      extraCssText: 'border-radius: 4px;padding:5px 0 5px 5px;white-space:normal;word-wrap:break-word;max-width: 400px;',
+      formatter: (params) => {
+        const pList = Array.isArray(params) ? params : [params]
+        const title = pList[0]?.name || ''
+        const value = pList[0]?.value ?? ''
+        let tooltip = `<div class="tooltip_content"><div class="tooltip_title">${title}</div>`
+        tooltip += `<div class="tooltip_student">${typeName}:${value}${unit}</div>`
+
+        const tipItem = tooltipData[pList[0]?.dataIndex ?? 0]
+        if (tipItem?.list?.length) {
+          tipItem.list.forEach(item => {
+            tooltip += `<div class="tooltip_student">${item.name}:${item.value}</div>`
+          })
+        }
+        tooltip += '</div>'
+        return tooltip
+      }
+    },
+    yAxis: {
+      type: 'value',
+      max: 100,
+      axisLabel: {
+        color: '#666666',
+        fontSize: 14,
+        formatter: `{value}${yAxisUnit}`
+      },
+      splitArea: {
+        show: true,
+        areaStyle: {
+          color: ['#fafafa', '#ffffff']
+        }
+      }
+    },
+    xAxis: {
+      type: 'category',
+      data: datax,
+      axisPointer: {
+        type: 'shadow'
+      },
+      axisLabel: {
+        interval: 0,
+        rotate: singleSeriesWidth < 80 ? 45 : 0,
+        color: '#666666',
+        fontSize: 14,
+        formatter: formatXLabel
+      }
+    },
+    series: seriesData
+  }
+
+  myChart.setOption(option, true)
+
+  // 柱子点击 + 高亮背景
+  if (isClick && myChart) {
+    const gridModel = myChart.getModel().getComponent('grid')
+    const gridRect = gridModel.coordinateSystem.getRect()
+    const singleLabelWidth = gridRect.width / xLen
+
+    // 默认第一个高亮
+    if (datax.length > 0) {
+      const defaultPixelPos = myChart.convertToPixel({ xAxisIndex: 0 }, datax[0])
+      myChart.setOption({
+        graphic: {
+          id: 'highlight-box',
+          type: 'rect',
+          shape: {
+            x: defaultPixelPos - singleLabelWidth / 2,
+            y: gridRect.y,
+            width: singleLabelWidth,
+            height: gridRect.height
+          },
+          style: {
+            fill: firstBarColor + '30'
+          }
+        }
+      })
+    }
+
+    // 点击事件
+    myChart.on('click', (params) => {
+      if (params.seriesType !== 'bar') return
+
+      const pixelPosition = myChart.convertToPixel({ xAxisIndex: 0 }, params.name)
+      myChart.setOption({
+        graphic: {
+          id: 'highlight-box',
+          type: 'rect',
+          shape: {
+            x: pixelPosition - singleLabelWidth / 2,
+            y: gridRect.y,
+            width: singleLabelWidth,
+            height: gridRect.height
+          },
+          style: {
+            fill: (params.borderColor as string) + '30'
+          }
+        }
+      })
+
+      emit('HandleChartClick', params.dataIndex, params.name as string)
+    })
+  }
+}
+
+// ===================== 监听 & 生命周期 =====================
+watch(
+  () => props.datax,
+  () => loadEchart(),
+  { deep: true }
+)
+
+watch(
+  () => props.markLineData,
+  () => loadEchart(),
+  { deep: true }
+)
+
+onMounted(() => {
+  loadEchart()
+})
+
+// 组件卸载:移除监听 + 销毁图表
+onBeforeUnmount(() => {
+  window.removeEventListener('resize', handleResize)
+  if (myChart) {
+    myChart.dispose()
+    myChart = null
+  }
+})
+</script>
+
+<style lang="scss" scoped>
+.chart_width {
+  width: 100%;
+}
+</style>

+ 468 - 0
src/components/echarts/barStackChart_vertical.vue

@@ -0,0 +1,468 @@
+<template>
+  <div class="echart_content">
+    <div class="is_show_all" v-if="showCheckBox">
+      <el-checkbox
+        v-model="isShowAll"
+        :indeterminate="isIndeterminate"
+        @change="toggleChangeAll"
+      >
+        显示全部
+      </el-checkbox>
+    </div>
+    <!-- 竖向堆叠条形图 -->
+    <div ref="barStackChartRef" class="chart_box"></div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, watch, onMounted, onCreated, onUnmounted, nextTick, withDefaults } from 'vue'
+import _throttle from 'lodash/throttle'
+import * as echarts from 'echarts'
+import { getScorePerformanceAnalysis } from '@/utils/common'
+
+// ===================== 类型定义 =====================
+/** 提示框额外数据项 */
+interface TooltipExtraItem {
+  label: string
+  value: string | number
+}
+
+/** 单条提示数据 */
+interface TooltipItem {
+  list: TooltipExtraItem[]
+  rateNum: string | number
+}
+
+/** Props 类型 */
+interface BarStackChartProps {
+  showCheckBox: boolean
+  data: (string | number)[][]
+  color: string[]
+  isClick: boolean
+  legendList: string[]
+  tooltipData: TooltipItem[][]
+}
+
+// ===================== 常量配置 =====================
+const CHART_DPR = 2
+const GRID_LEFT = 40
+const GRID_RIGHT = 60
+const GRID_HORIZONTAL_SUM = GRID_LEFT + GRID_RIGHT
+const BAR_MIN_WIDTH = 30
+const BAR_MAX_WIDTH = 50
+const DATA_ZOOM_HEIGHT = 8
+const THROTTLE_DELAY = 500
+const HIGHLIGHT_OPACITY = '30'
+const DEFAULT_CHART_HEIGHT = 400
+
+// ===================== Props 修复:纯TS类型 + withDefaults 设置默认值 =====================
+const props = withDefaults(defineProps<BarStackChartProps>(), {
+  showCheckBox: true,
+  data: () => [],
+  color: () => [],
+  isClick: false,
+  legendList: () => [],
+  tooltipData: () => []
+})
+
+// ===================== 事件 =====================
+const emit = defineEmits<{
+  HandleChartClick: [index: number, name: string]
+}>()
+
+// ===================== 响应式变量 =====================
+const barStackChartRef = ref<HTMLDivElement | null>(null)
+let myChart: echarts.ECharts | null = null
+
+// 多选框状态
+const isShowAll = ref(true)
+const isIndeterminate = ref(false)
+const legendSelected = ref<Record<string, boolean>>({})
+const legenSelectList = ref<string[]>([])
+const legenAllList = ref<string[]>([])
+
+// 全局颜色池
+const colorsPool = ref<string[]>(getScorePerformanceAnalysis())
+
+// 窗口缩放节流
+const handleResize = _throttle(async () => {
+  await nextTick()
+  myChart?.resize()
+}, THROTTLE_DELAY)
+
+// ===================== 工具函数 =====================
+/**
+ * 生成 DataZoom 滚动条配置
+ */
+function getDataZoom(totalWidth: number, dataCount: number): echarts.DataZoomSliderOption | null {
+  const contentWidth = totalWidth - GRID_HORIZONTAL_SUM
+  const singleSeriesWidth = Math.ceil(contentWidth / dataCount)
+
+  if (singleSeriesWidth >= BAR_MIN_WIDTH) return null
+
+  const dataZoomNum = Math.floor(contentWidth / BAR_MIN_WIDTH)
+  const dataZoomEnd = Math.floor((100 / dataCount) * dataZoomNum)
+
+  return {
+    type: 'slider',
+    xAxisIndex: [0],
+    start: 0,
+    end: dataZoomEnd,
+    height: DATA_ZOOM_HEIGHT,
+    left: 20,
+    right: 20,
+    bottom: 0,
+    borderColor: 'transparent',
+    borderCap: 'round',
+    fillerColor: 'transparent',
+    zoomLock: true,
+    handleSize: '0',
+    handleStyle: {
+      color: '#b8b8b8',
+      borderWidth: 2
+    },
+    backgroundColor: 'transparent',
+    showDataShadow: false,
+    showDetail: false,
+    filterMode: 'filter'
+  }
+}
+
+/**
+ * X轴文字超长截断处理
+ */
+function formatXLabel(value: string, singleSeriesWidth: number): string {
+  const charUnit = 14
+  const textWidth = value.length * charUnit
+
+  if (textWidth <= singleSeriesWidth) return value
+
+  if (singleSeriesWidth < 100) {
+    return textWidth > 80 ? `${value.slice(0, 2)}...${value.slice(-3)}` : value
+  }
+
+  const maxLen = Math.floor(singleSeriesWidth / charUnit) - 1
+  return value.slice(0, maxLen) + '...'
+}
+
+/**
+ * 图例选中状态更新
+ */
+function updateLegendStatus() {
+  isShowAll.value = legenSelectList.value.length === legenAllList.value.length
+  isIndeterminate.value = legenSelectList.value.length > 0 && legenSelectList.value.length < legenAllList.value.length
+}
+
+/**
+ * 绑定柱子点击与高亮效果
+ */
+function bindBarClick(totalWidth: number, dataCount: number) {
+  if (!myChart) return
+
+  const gridModel = myChart.getModel().getComponent('grid')
+  const gridRect = gridModel.coordinateSystem.getRect()
+  const xAxisDataLength = dataCount - 1
+  const singleLabelWidth = gridRect.width / xAxisDataLength
+
+  // 默认高亮第一项
+  const firstItem = props.data[1]?.[0] ?? ''
+  const defaultPixelPos = myChart.convertToPixel({ xAxisIndex: 0 }, firstItem)
+  myChart.setOption({
+    graphic: {
+      id: 'highlight-box',
+      type: 'rect',
+      shape: {
+        x: defaultPixelPos - singleLabelWidth / 2,
+        y: gridRect.y,
+        width: singleLabelWidth,
+        height: gridRect.height
+      },
+      style: {
+        fill: 'rgba(84,112,198,0.1)'
+      }
+    }
+  })
+
+  // 点击事件
+  myChart.on('click', (params) => {
+    if (params.seriesType !== 'bar') return
+
+    const pixelPos = myChart.convertToPixel({ xAxisIndex: 0 }, params.name)
+    myChart.setOption({
+      graphic: {
+        id: 'highlight-box',
+        type: 'rect',
+        shape: {
+          x: pixelPos - singleLabelWidth / 2,
+          y: gridRect.y,
+          width: singleLabelWidth,
+          height: gridRect.height
+        },
+        style: {
+          fill: (params.color as string) + HIGHLIGHT_OPACITY
+        }
+      }
+    })
+
+    emit('HandleChartClick', params.dataIndex, params.name as string)
+  })
+}
+
+// ===================== 核心:初始化图表 =====================
+function loadEchart() {
+  const dom = barStackChartRef.value
+  if (!dom) return
+
+  // 销毁旧实例,防止多实例叠加
+  if (myChart) {
+    myChart.dispose()
+    myChart = null
+  }
+
+  myChart = echarts.init(dom, null, { devicePixelRatio: CHART_DPR })
+  const sourceData = props.data
+  if (sourceData.length === 0) return
+
+  const dataCount = sourceData.length
+  const headerRow = sourceData[0]
+  const allLegend = headerRow.slice(1)
+  legenAllList.value = allLegend
+
+  // 初始化图例选中状态
+  legendSelected.value = allLegend.reduce((acc, dim) => {
+    acc[dim as string] = false
+    return acc
+  }, {} as Record<string, boolean>)
+
+  // 优先使用外部传入图例
+  if (props.legendList.length > 0) {
+    legenSelectList.value = [...props.legendList]
+  } else {
+    legenSelectList.value = [...allLegend]
+  }
+
+  // 同步选中状态
+  legenSelectList.value.forEach(item => {
+    legendSelected.value[item] = true
+  })
+  updateLegendStatus()
+
+  // 计算柱子宽度 & 滚动条
+  const totalWidth = dom.clientWidth
+  const contentWidth = totalWidth - GRID_HORIZONTAL_SUM
+  const singleSeriesWidth = Math.ceil(contentWidth / dataCount)
+  const dataZoomOpt = getDataZoom(totalWidth, dataCount)
+
+  // 构建 series
+  const seriesList: echarts.SeriesBarOption[] = allLegend.map((dim, index) => {
+    const color = props.color[index] || colorsPool.value[index]
+    return {
+      type: 'bar',
+      stack: 'total',
+      barMaxWidth: BAR_MAX_WIDTH,
+      label: {
+        show: singleSeriesWidth > 22,
+        position: 'inside',
+        color: '#fff',
+        fontSize: 12,
+        formatter: (params) => {
+          const val = params.value?.[params.seriesIndex + 1]
+          return val === 0 ? '' : `${val}%`
+        }
+      },
+      itemStyle: {
+        color
+      }
+    }
+  })
+
+  // ECharts 配置项
+  const option: echarts.EChartsOption = {
+    dataset: {
+      source: sourceData
+    },
+    tooltip: {
+      confine: true,
+      enterable: true,
+      extraCssText: 'border-radius: 4px;padding:5px 0 5px 5px;white-space:normal;word-wrap:break-word;max-width: 400px;',
+      triggerOn: 'mousemove',
+      formatter: (params) => {
+        const firstParams = Array.isArray(params) ? params[0] : params
+        const title = firstParams.name ?? ''
+        const rateLabelList = headerRow.slice(1)
+        const dataIndex = firstParams.dataIndex
+
+        let tooltip = `<div class="tooltip_content"><div class="tooltip_title">${title}</div>`
+
+        rateLabelList.forEach((label, i) => {
+          const rate = firstParams.value?.[i + 1]
+          const curTip = props.tooltipData[dataIndex]?.[i]
+          const rateNum = curTip?.rateNum ?? '-'
+          const tipList = curTip?.list ?? []
+          const showTip = props.tooltipData.length > 0
+
+          const rateText = rate === '-' ? '-' : `${rate}%`
+          const color = props.color[i] || colorsPool.value[i]
+
+          if (showTip) {
+            tooltip += `<div class="tooltip_student">
+              <span class="tooltip_rect_icon" style="background:${color}"></span>
+              ${label}:${rateText},${rateNum}人
+            </div>`
+            // 最后一项追加额外提示
+            if (i === rateLabelList.length - 1) {
+              tipList.forEach(item => {
+                tooltip += `<div class="tooltip_student">
+                  <span class="tooltip_rect_icon" style="background:#CCCCCC"></span>
+                  ${item.label}:${item.value}
+                </div>`
+              })
+            }
+          } else {
+            tooltip += `<div class="tooltip_student">
+              <span class="tooltip_rect_icon" style="background:${color}"></span>
+              ${label}:${rateText}
+            </div>`
+          }
+        })
+
+        tooltip += '</div>'
+        return tooltip
+      }
+    },
+    legend: {
+      show: true,
+      left: props.showCheckBox ? '110px' : '0px',
+      itemGap: 20,
+      itemHeight: 10,
+      itemWidth: 20,
+      textStyle: { fontSize: 12, color: '#333' },
+      selectedMode: true,
+      selected: legendSelected.value
+    },
+    grid: {
+      top: 50,
+      left: GRID_LEFT,
+      right: GRID_RIGHT,
+      bottom: 0,
+      containLabel: true
+    },
+    dataZoom: dataZoomOpt,
+    yAxis: {
+      type: 'value',
+      axisLabel: {
+        formatter: '{value}%',
+        fontSize: 14,
+        color: '#666',
+        fontWeight: 400
+      },
+      axisTick: {
+        lineStyle: {
+          color: 'red',
+          type: 'dashed',
+          width: 1
+        }
+      }
+    },
+    xAxis: {
+      type: 'category',
+      axisLabel: {
+        fontSize: 14,
+        color: '#666',
+        fontWeight: 400,
+        interval: 0,
+        rotate: singleSeriesWidth < 80 ? 45 : 0,
+        formatter: (val) => formatXLabel(val, singleSeriesWidth)
+      }
+    },
+    series: seriesList
+  }
+
+  myChart.setOption(option)
+
+  // 图例切换监听
+  myChart.on('legendselectchanged', handleLegendSelectChanged)
+
+  // 开启柱子点击事件
+  if (props.isClick) {
+    bindBarClick(totalWidth, dataCount)
+  }
+}
+
+// ===================== 事件处理 =====================
+/**
+ * 图例切换事件
+ */
+function handleLegendSelectChanged(params: echarts.LegendSelectChangedParams) {
+  const selected = params.selected
+  legenSelectList.value = []
+
+  legenAllList.value.forEach(item => {
+    if (selected[item]) {
+      legenSelectList.value.push(item)
+    }
+  })
+
+  updateLegendStatus()
+}
+
+/**
+ * 显示全部 复选框切换
+ */
+function toggleChangeAll(val: boolean) {
+  if (val) {
+    legenSelectList.value = [...legenAllList.value]
+  } else {
+    legenSelectList.value = []
+  }
+  isIndeterminate.value = false
+  loadEchart()
+}
+
+// ===================== 监听 & 生命周期 =====================
+// 数据源变化重绘图表
+watch(
+  () => props.data,
+  () => {
+    loadEchart()
+  },
+  { deep: true }
+)
+
+onMounted(() => {
+  window.addEventListener('resize', handleResize)
+  loadEchart()
+})
+
+onUnmounted(() => {
+  // 清除监听 & 销毁实例,防止内存泄漏
+  window.removeEventListener('resize', handleResize)
+  if (myChart) {
+    myChart.dispose()
+    myChart = null
+  }
+})
+</script>
+
+<style lang="scss" scoped>
+.echart_content {
+  width: 100%;
+}
+
+.is_show_all {
+  margin-bottom: 8px;
+}
+
+.chart_box {
+  width: 100%;
+  height: v-bind(DEFAULT_CHART_HEIGHT + 'px');
+}
+
+:deep(.tooltip_rect_icon) {
+  display: inline-block;
+  width: 8px;
+  height: 8px;
+  border-radius: 2px;
+  margin-right: 6px;
+}
+</style>

+ 497 - 0
src/components/echarts/differenceChart.vue

@@ -0,0 +1,497 @@
+<template>
+  <div ref="differenceChartRef" class="echart_content"></div>
+</template>
+
+<script setup lang="ts">
+import { ref, watch, onMounted, onBeforeUnmount, nextTick, withDefaults } from 'vue'
+import _ from 'lodash'
+import * as echarts from 'echarts'
+import { getFormatNumber } from '@/utils/common'
+
+// ===================== 类型定义 =====================
+/** 悬浮提示额外数据项 */
+interface TooltipListItem {
+  name: string
+  value: string | number
+}
+
+/** 单条悬浮数据 */
+interface TooltipDataItem {
+  list: TooltipListItem[]
+}
+
+/** Props 类型 */
+interface DifferenceChartProps {
+  datax: string[]
+  datay: (string | number)[]
+  tooltipData: TooltipDataItem[]
+  title: string
+  rate: number | string
+  unit: string
+  type: string
+  isClick: boolean
+  color: string[]
+  gridLeft: number
+  gridRight: number
+  gridTop: number
+  fontSize: number | string
+  fontColor: string
+  showDataZoom: boolean
+}
+
+/** y轴最大最小值返回结构 */
+interface MaxMinResult {
+  maxValue: number
+  minValue: number
+}
+
+// ===================== 常量配置 =====================
+const BAR_MIN_WIDTH = 30
+const THROTTLE_DELAY = 500
+const DPR = 2
+const HIGHLIGHT_OPACITY = '30'
+const DEFAULT_GRID_HORIZONTAL = 140
+
+// ===================== Props 配置(TS + withDefaults) =====================
+const props = withDefaults(defineProps<DifferenceChartProps>(), {
+  datax: () => [],
+  datay: () => [],
+  tooltipData: () => [],
+  title: '率差',
+  rate: 60,
+  unit: '',
+  type: '0',
+  isClick: true,
+  color: () => ['#3BA272', '#EE6666'],
+  gridLeft: 40,
+  gridRight: 60,
+  gridTop: 20,
+  fontSize: '',
+  fontColor: '',
+  showDataZoom: true
+})
+
+// 事件派发
+const emit = defineEmits<{
+  HandleChartClick: [index: number, name: string]
+}>()
+
+// ===================== 响应式变量 =====================
+const differenceChartRef = ref<HTMLDivElement | null>(null)
+let echart: echarts.ECharts | null = null
+
+// 窗口节流监听
+const handleResize = _.throttle(async () => {
+  await nextTick()
+  loadEchart()
+}, THROTTLE_DELAY)
+
+// 组件初始化阶段直接绑定 resize 监听(替代 onCreated)
+window.addEventListener('resize', handleResize)
+
+// ===================== 工具函数 =====================
+/**
+ * 计算Y轴最大/最小值
+ */
+function getMaxMinValue(max: number, min: number, rateValue: number): MaxMinResult {
+  let maxValue: number
+  let minValue: number
+
+  if (rateValue === 0) {
+    const differenceValue = Math.abs(max) > Math.abs(min) ? Math.abs(max) : Math.abs(min)
+    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: 6, bound: 10 }
+    ]
+    const matchedRule = thresholdMap.find(item => differenceValue < item.maxThreshold)
+    const bound = matchedRule ? matchedRule.bound : getCustomRoundUp(differenceValue)
+
+    maxValue = bound
+    minValue = -bound
+  } else {
+    const maxValues = max - rateValue
+    const minValues = min - rateValue
+    const differenceValue = Math.abs(maxValues) > Math.abs(minValues) ? Math.abs(maxValues) : Math.abs(minValues)
+    const val = getCustomRoundUp(differenceValue)
+    maxValue = val
+    minValue = -val
+  }
+
+  return { maxValue, minValue }
+}
+
+/**
+ * 数值向上取整到10的倍数
+ */
+function getCustomRoundUp(number: number): number {
+  const nearest10 = Math.ceil(number / 10) * 10
+  return nearest10
+}
+
+/**
+ * 生成 DataZoom 滚动条配置
+ */
+function getDataZoom(totalWidth: number, xLen: number): echarts.DataZoomSliderOption {
+  const dataZoomNum = Math.floor((totalWidth - DEFAULT_GRID_HORIZONTAL) / BAR_MIN_WIDTH)
+  const dataZoomEnd = Math.floor((100 / xLen) * dataZoomNum)
+
+  return {
+    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'
+  }
+}
+
+/**
+ * X轴文字超长截断
+ */
+function formatXLabel(value: string, singleSeriesWidth: number): string {
+  const charUnit = 14
+  const valueWidth = value.length * charUnit
+
+  if (valueWidth <= singleSeriesWidth) return value
+
+  if (singleSeriesWidth < 80) {
+    return valueWidth > 80 ? `${value.slice(0, 2)}...${value.slice(-3)}` : value
+  }
+
+  const maxLength = Math.floor(singleSeriesWidth / charUnit) - 1
+  return value.slice(0, maxLength) + '...'
+}
+
+/**
+ * 绑定柱子点击 & 高亮样式
+ */
+function bindBarClick(totalWidth: number, singleLabelWidth: number, gridRect: echarts.Rect) {
+  if (!echart) return
+  const xData = props.datax
+
+  // 初始高亮第一个
+  if (xData.length > 0) {
+    const defaultPixelPos = echart.convertToPixel({ xAxisIndex: 0 }, xData[0])
+    echart.setOption({
+      graphic: {
+        id: 'highlight-box',
+        type: 'rect',
+        shape: {
+          x: defaultPixelPos - singleLabelWidth / 2,
+          y: gridRect.y,
+          width: singleLabelWidth,
+          height: gridRect.height
+        },
+        style: {
+          fill: 'rgba(84,112,198,0.1)'
+        }
+      }
+    })
+  }
+
+  // 点击事件
+  echart.on('click', (params) => {
+    if (params.seriesType !== 'bar') return
+
+    // 修改x轴文字颜色
+    echart.setOption({
+      xAxis: [{
+        axisLabel: {
+          color: (_val: string, index: number) => {
+            return index === params.dataIndex ? '#2E64FA' : '#333'
+          }
+        }
+      }]
+    })
+
+    // 更新高亮背景
+    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 as string) + HIGHLIGHT_OPACITY
+        }
+      }
+    })
+
+    emit('HandleChartClick', params.dataIndex, params.name as string)
+  })
+}
+
+// ===================== 核心:初始化图表 =====================
+function loadEchart() {
+  const dom = differenceChartRef.value
+  if (!dom) return
+
+  // 销毁旧实例,防止内存泄漏
+  if (echart) {
+    echart.dispose()
+    echart = null
+  }
+
+  echart = echarts.init(dom, null, { devicePixelRatio: DPR })
+  const { datax, datay, unit, type, rate, color, tooltipData, showDataZoom } = props
+  const xLen = datax.length
+  if (xLen === 0) return
+
+  // 计算单柱宽度
+  const totalWidth = dom.clientWidth
+  const singleSeriesWidth = Math.ceil((totalWidth - DEFAULT_GRID_HORIZONTAL) / xLen)
+
+  // 基准线数值
+  const rateValue = Number(rate) || 0
+
+  // 处理y轴原始数据
+  const numArr = datay.map(item => Number(item))
+  const max = Math.max(...numArr)
+  const min = Math.min(...numArr)
+  const { maxValue, minValue } = getMaxMinValue(max, min, rateValue)
+
+  // 滚动条配置
+  const dataZoomOpt = showDataZoom && singleSeriesWidth < BAR_MIN_WIDTH
+    ? getDataZoom(totalWidth, xLen)
+    : null
+
+  // 处理柱子数据(颜色、标签)
+  const processedData = datay.map((yVal) => {
+    const numVal = Number(yVal) - rateValue
+    const fixedVal = Number(numVal.toFixed(2))
+
+    return {
+      value: fixedVal,
+      label: {
+        show: singleSeriesWidth > 22,
+        position: fixedVal > 0 ? 'top' : 'bottom',
+        formatter: (params: echarts.LabelFormatterParams) => {
+          if (type === '1') {
+            return `${getFormatNumber(Number(params.value).toFixed(2))}${unit}`
+          } else {
+            const originVal = (Number(params.value) + rateValue).toFixed(2)
+            return `${getFormatNumber(originVal)}${unit}`
+          }
+        },
+        color: props.fontColor || '#666',
+        fontSize: props.fontSize ? Number(props.fontSize) : 14
+      },
+      itemStyle: {
+        color: fixedVal > 0 ? color[0] : color[1]
+      }
+    }
+  })
+
+  // ECharts 配置项
+  const option: echarts.EChartsOption = {
+    tooltip: {
+      trigger: 'item',
+      triggerOn: 'mousemove',
+      confine: true,
+      borderColor: '#fff',
+      extraCssText: 'border-radius: 4px;padding:5px 0 5px 5px;white-space:normal;word-wrap:break-word;max-width: 400px;',
+      formatter: (params) => {
+        const p = Array.isArray(params) ? params[0] : params
+        const title = p.name ?? ''
+        const tipType = type === '1' ? '分差' : props.title
+        let showVal = ''
+
+        if (type === '1') {
+          showVal = `${p.value}${unit}`
+        } else {
+          const originVal = (Number(p.value) + rateValue).toFixed(2)
+          showVal = `${originVal}${unit}`
+        }
+
+        let tooltip = `<div class="tooltip_content"><div class="tooltip_title">${title}</div>`
+        const tipItem = tooltipData[p.dataIndex]
+
+        if (tipItem?.list?.length) {
+          tipItem.list.forEach(item => {
+            tooltip += `<div class="tooltip_student">${item.name}:${item.value}</div>`
+          })
+        } else {
+          tooltip += `<div class="tooltip_student">${tipType}:${showVal}</div>`
+        }
+        tooltip += '</div>'
+        return tooltip
+      }
+    },
+    grid: {
+      left: props.gridLeft,
+      right: props.gridRight,
+      top: props.gridTop,
+      bottom: 0,
+      containLabel: true
+    },
+    dataZoom: dataZoomOpt,
+    xAxis: [{
+      type: 'category',
+      data: datax,
+      axisPointer: {
+        type: 'shadow'
+      },
+      axisLabel: {
+        interval: 0,
+        margin: 20,
+        rotate: singleSeriesWidth < 80 ? 45 : 0,
+        fontSize: props.fontSize ? Number(props.fontSize) : 14,
+        color: props.fontColor || '#666',
+        fontWeight: 400,
+        formatter: (val) => formatXLabel(val, singleSeriesWidth)
+      }
+    }],
+    yAxis: {
+      type: 'value',
+      max: maxValue,
+      min: minValue,
+      axisLabel: {
+        formatter: (value) => {
+          const totalVal = Number(value) + rateValue
+          return `${getFormatNumber(totalVal)}${unit}`
+        },
+        color: props.fontColor || '#666',
+        fontSize: props.fontSize ? Number(props.fontSize) : 14
+      },
+      splitLine: {
+        show: true,
+        lineStyle: {
+          color: '#E4E7ED',
+          type: 'solid'
+        }
+      },
+      axisLine: {
+        show: false,
+        lineStyle: {
+          color: '#E4E7ED',
+          width: 1
+        }
+      },
+      splitArea: {
+        show: true,
+        areaStyle: {
+          color: ['#fafafa', '#ffffff']
+        }
+      }
+    },
+    series: [{
+      type: 'bar',
+      emphasis: {
+        focus: 'series'
+      },
+      barMinHeight: 1,
+      barMaxWidth: 50,
+      barMinWidth: showDataZoom ? 20 : 1,
+      data: processedData
+    }]
+  }
+
+  echart.setOption(option)
+
+  // 开启点击与高亮
+  if (props.isClick) {
+    const gridModel = echart.getModel().getComponent('grid')
+    const gridRect = gridModel.coordinateSystem.getRect()
+    const singleLabelWidth = gridRect.width / xLen
+    bindBarClick(totalWidth, singleLabelWidth, gridRect)
+  }
+}
+
+// ===================== 监听 & 生命周期 =====================
+watch(
+  () => props.datay,
+  () => loadEchart(),
+  { deep: true }
+)
+
+onMounted(() => {
+  window.addEventListener("resize", handleResize); //创建时增加监听窗口变化事件
+  loadEchart()
+})
+
+// 组件卸载前:移除监听 + 销毁图表实例
+onBeforeUnmount(() => {
+  window.removeEventListener('resize', handleResize)
+  if (echart) {
+    echart.dispose()
+    echart = null
+  }
+})
+</script>
+
+<style lang="scss" scoped>
+.echart_content {
+  position: relative;
+  width: 100%;
+  margin: auto;
+  min-height: 360px;
+  height: auto;
+}
+
+//弹窗悬浮层样式更改
+.echart_content::-webkit-scrollbar {
+  width: 100%;
+  height: 8px;
+  /* 设置滚动条宽度为8像素 */
+  background-color: transparent;
+}
+
+/* 滑块样式 */
+.echart_content::-webkit-scrollbar-thumb {
+  background-color: #b8b8b8;
+  /* 设置滑块颜色为深灰色 */
+  border-radius: 4px;
+  /* 设置滑块边角半径为4像素 */
+  min-height: 40px; //设置手柄最小高度
+}
+
+/* 滚动条轨道内部空白区域样式 */
+.echart_content::-webkit-scrollbar-track {
+  background-color: #f0f0f0;
+  /* 设置轨道背景色为浅灰色 */
+}
+
+/* 滚动条两端按钮样式 */
+.echart_content::-webkit-scrollbar-button {
+  display: none;
+  /* 不显示按钮 */
+}
+
+/* 交叉点处的区域样式 */
+.echart_content::-webkit-scrollbar-corner {
+  background-color: transparent;
+  /* 设置交叉点处的背景色为透明 */
+}
+
+/* 调整大小手柄样式 */
+.echart_content::-webkit-resizer {
+  display: none;
+  /* 不显示调整大小手柄 */
+}
+</style>

+ 146 - 0
src/utils/common.ts

@@ -85,3 +85,149 @@ export const mmToPx=(num:number) :number=>{
       return 0;
     }
 };
+// 获取分析报告成绩分析中默认的20个颜色
+export const getCompareAnalysis = () => {
+  return [
+    "#EE6666",
+    "#5470C6",
+    "#3BA272",
+    "#FAC858",
+    "#995FB3",
+    "#72C0DD",
+    "#90CB75",
+    "#EA7ACB",
+    "#FC8451",
+    "#65789B",
+    "#178BD3",
+    "#26A616",
+    "#A6129E",
+    "#D3A141",
+    "#234098",
+    "#047640",
+    "#B43535",
+    "#848BDC",
+    "#D6AF83",
+    "#84313D",
+    //20个颜色一组
+    "#EE6666",
+    "#5470C6",
+    "#3BA272",
+    "#FAC858",
+    "#995FB3",
+    "#72C0DD",
+    "#90CB75",
+    "#EA7ACB",
+    "#FC8451",
+    "#65789B",
+    "#178BD3",
+    "#26A616",
+    "#A6129E",
+    "#D3A141",
+    "#234098",
+    "#047640",
+    "#B43535",
+    "#848BDC",
+    "#D6AF83",
+    "#84313D",
+  ];
+}
+// 获取分析报告成绩分析中默认的20个颜色
+export const getScorePerformanceAnalysis = () => {
+  return [
+    "#5470C6",
+    "#3BA272",
+    "#EE6666",
+    "#FAC858",
+    "#995FB3",
+    "#72C0DD",
+    "#90CB75",
+    "#EA7ACB",
+    "#FC8451",
+    "#65789B",
+    "#178BD3",
+    "#26A616",
+    "#A6129E",
+    "#D3A141",
+    "#234098",
+    "#047640",
+    "#B43535",
+    "#848BDC",
+    "#D6AF83",
+    "#84313D",
+    //20个颜色一组
+    "#5470C6",
+    "#3BA272",
+    "#EE6666",
+    "#FAC858",
+    "#995FB3",
+    "#72C0DD",
+    "#90CB75",
+    "#EA7ACB",
+    "#FC8451",
+    "#65789B",
+    "#178BD3",
+    "#26A616",
+    "#A6129E",
+    "#D3A141",
+    "#234098",
+    "#047640",
+    "#B43535",
+    "#848BDC",
+    "#D6AF83",
+    "#84313D",
+    //
+    "#5470C6",
+    "#3BA272",
+    "#EE6666",
+    "#FAC858",
+    "#995FB3",
+    "#72C0DD",
+    "#90CB75",
+    "#EA7ACB",
+    "#FC8451",
+    "#65789B",
+    "#178BD3",
+    "#26A616",
+    "#A6129E",
+    "#D3A141",
+    "#234098",
+    "#047640",
+    "#B43535",
+    "#848BDC",
+    "#D6AF83",
+    "#84313D",
+  ];
+}
+// 去掉小数点后面为0的 如果不为0 最多只保留2位小数
+/**
+ * 数字格式化:整数直接返回、小数最多保留2位并去除末尾多余0
+ * @param numberStr 待格式化的数字/字符串
+ * @returns 格式化后的字符串
+ */
+export const getFormatNumber = (numberStr: string | number): string => {
+  // 尝试转为数字
+  const num = parseFloat(String(numberStr));
+
+  // 非数字原样返回
+  if (isNaN(num)) {
+    return String(numberStr);
+  }
+
+  // 整数直接转字符串
+  if (num % 1 === 0) {
+    return num.toString();
+  }
+
+  // 固定保留两位小数
+  let formatted = num.toFixed(2);
+
+  // .00 结尾直接返回整数部分
+  if (formatted.endsWith('.00')) {
+    return formatted.split('.')[0];
+  }
+
+  // 去除末尾多余的 0
+  formatted = formatted.replace(/\.?0+$/, '');
+
+  return formatted;
+}

File diff suppressed because it is too large
+ 1093 - 42
src/views/analysis/classComparison.vue


+ 20 - 14
src/views/analysis/levelDistribution.vue

@@ -19,21 +19,27 @@
       </div>
     </template>
     <template #module_table_chart>
-      <template v-if="state.radioRangeScore == 0">
-        <BarLineChart :datax="state.scoreSegmentData.datax" :datay="state.scoreSegmentData.datay"
-          :fullScore="state.scoreSegmentData.fullScore" :markLine="state.scoreSegmentData.markLine"
-          :color="state.scoreSegmentData.color" :title="state.scoreSegmentData.title"
-          :tooltipData="state.scoreSegmentData.tooltipData" :unit="state.scoreSegmentData.unit"
-          :tooltipTitle="state.scoreSegmentData.tooltipTitle">
-        </BarLineChart>
-      </template>
-      <!-- 按班级 -->
-      <template v-if="state.radioRangeScore == 1">
-        <LineBarChart :datax="state.scoreSegmentClassData.datax" :datay="state.scoreSegmentClassData.datay"
-          :showBackground="false" :legendList="state.scoreSegmentClassData.legendList"
-          :title="state.scoreSegmentClassData.title" :tooltipData="state.scoreSegmentClassData.tooltipData"
-          :hideOverlap="true"></LineBarChart>
+      <template v-if="state.scoreSegmentData.datay.length">
+        <template v-if="state.radioRangeScore == 0">
+          <BarLineChart :datax="state.scoreSegmentData.datax" :datay="state.scoreSegmentData.datay"
+            :fullScore="state.scoreSegmentData.fullScore" :markLine="state.scoreSegmentData.markLine"
+            :color="state.scoreSegmentData.color" :title="state.scoreSegmentData.title"
+            :tooltipData="state.scoreSegmentData.tooltipData" :unit="state.scoreSegmentData.unit"
+            :tooltipTitle="state.scoreSegmentData.tooltipTitle">
+          </BarLineChart>
+        </template>
+        <!-- 按班级 -->
+        <template v-if="state.radioRangeScore == 1">
+          <LineBarChart :datax="state.scoreSegmentClassData.datax" :datay="state.scoreSegmentClassData.datay"
+            :showBackground="false" :legendList="state.scoreSegmentClassData.legendList"
+            :title="state.scoreSegmentClassData.title" :tooltipData="state.scoreSegmentClassData.tooltipData"
+            :hideOverlap="true"></LineBarChart>
+        </template>
       </template>
+      <div v-else class="no_content_data" v-loading="state.tableLoading" :element-loading-text="state.loadingText"
+        element-loading-spinner="el-icon-loading" element-loading-background="#ffffff">
+        <span>暂无数据</span>
+      </div>
     </template>
     <template #module_describe>
       说明:信度是反应考试一致性或可靠性的指标,信度高,表示考试成绩较为准确,误差较小;

Some files were not shown because too many files changed in this diff