Browse Source

水平分布及echarts图表

liurongli 1 week ago
parent
commit
1feedc792c

+ 26 - 3
package-lock.json

@@ -12,8 +12,10 @@
         "@tsparticles/slim": "^3.9.1",
         "@tsparticles/vue3": "^3.0.1",
         "axios": "^1.9.0",
+        "echarts": "^6.1.0",
         "element-plus": "^2.9.11",
         "jsencrypt": "^3.5.4",
+        "lodash-es": "^4.18.1",
         "pinia": "^3.0.4",
         "vue": "^3.5.13",
         "vue-router": "^4.5.1",
@@ -2223,6 +2225,15 @@
         "node": ">= 0.4"
       }
     },
+    "node_modules/echarts": {
+      "version": "6.1.0",
+      "resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz",
+      "integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==",
+      "dependencies": {
+        "tslib": "2.3.0",
+        "zrender": "6.1.0"
+      }
+    },
     "node_modules/element-plus": {
       "version": "2.14.0",
       "resolved": "https://registry.npmmirror.com/element-plus/-/element-plus-2.14.0.tgz",
@@ -2607,9 +2618,8 @@
     },
     "node_modules/lodash-es": {
       "version": "4.18.1",
-      "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.18.1.tgz",
-      "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
-      "license": "MIT"
+      "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
+      "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="
     },
     "node_modules/lodash-unified": {
       "version": "1.0.3",
@@ -3014,6 +3024,11 @@
         "url": "https://github.com/sponsors/SuperchupuDev"
       }
     },
+    "node_modules/tslib": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
+      "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="
+    },
     "node_modules/typescript": {
       "version": "5.8.3",
       "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.8.3.tgz",
@@ -3187,6 +3202,14 @@
       "peerDependencies": {
         "vue": "^3.2.0"
       }
+    },
+    "node_modules/zrender": {
+      "version": "6.1.0",
+      "resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz",
+      "integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==",
+      "dependencies": {
+        "tslib": "2.3.0"
+      }
     }
   }
 }

+ 2 - 0
package.json

@@ -13,8 +13,10 @@
     "@tsparticles/slim": "^3.9.1",
     "@tsparticles/vue3": "^3.0.1",
     "axios": "^1.9.0",
+    "echarts": "^6.1.0",
     "element-plus": "^2.9.11",
     "jsencrypt": "^3.5.4",
+    "lodash-es": "^4.18.1",
     "pinia": "^3.0.4",
     "vue": "^3.5.13",
     "vue-router": "^4.5.1",

+ 8 - 0
src/api/analysis.ts

@@ -43,4 +43,12 @@ export const errorQuestionAnalysis = (data: any): Promise<ApiResponse> => {
     method: "post",
     data,
   });
+};
+// ==========================================水平分布============================================
+export const scoreSegment = (data: any): Promise<ApiResponse> => {
+  return request({
+    url: "/api/v1/ai_analysis/scoreSegment",
+    method: "post",
+    data,
+  });
 };

BIN
src/assets/chart/line_chart.webp


BIN
src/assets/chart/line_chart_cur.webp


BIN
src/assets/chart/vertical_bar.webp


BIN
src/assets/chart/vertical_bar_cur.webp


+ 4 - 0
src/components/ReportModule.vue

@@ -216,6 +216,10 @@ onMounted(() => {
         padding: 0 20px 14px;
         box-sizing: border-box;
         border-collapse: collapse;
+        :deep(.table_row_blue){
+            color: #2e64fa;
+            cursor: pointer;
+        }
     }
 
     .module_chart {

+ 315 - 0
src/components/echarts/barLineChart.vue

@@ -0,0 +1,315 @@
+<template>
+  <div ref="barLineChart" class="echart_line"></div>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
+import * as echarts from 'echarts'
+import { throttle } from 'lodash-es'
+
+// 类型定义
+interface MarkLineItem {
+  xAxis: number | string
+  name: string
+  color: string
+}
+
+interface TooltipDataItem {
+  value: string
+}
+
+// 定义 Props 与类型
+const props = defineProps({
+  datax: {
+    type: Array<string>,
+    default: () => ['语文', '数学', '英语', '物理', '化学', '生物', '政治', '历史', '地理'],
+  },
+  datay: {
+    type: Array<Array<string | number>>,
+    default: () => [
+      ['85', '78', '69', '67', '77', '88', '74', '66', '73'],
+      ['82', '74', '63', '62', '73', '86', '71', '62', '71'],
+    ],
+  },
+  fullScore: {
+    type: Number,
+    default: 100,
+  },
+  markLine: {
+    type: Array<MarkLineItem>,
+    default: () => [],
+  },
+  title: {
+    type: Array<string>,
+    default: () => [],
+  },
+  color: {
+    type: Array<string>,
+    default: () => ['#3BA272', '#FAC858'],
+  },
+  unit: {
+    type: String,
+    default: '',
+  },
+  tooltipTitle: {
+    type: String,
+    default: '',
+  },
+  tooltipData: {
+    type: Array<TooltipDataItem>,
+    default: () => [],
+  },
+  gridLeft: {
+    type: Number,
+    default: 30,
+  },
+  gridRight: {
+    type: Number,
+    default: 20,
+  },
+  gridTop: {
+    type: Number,
+    default: 20,
+  },
+  fontSize: {
+    type: [Number, String],
+    default: '',
+  },
+  fontColor: {
+    type: String,
+    default: '',
+  },
+})
+
+// 图表实例
+const barLineChart = ref<HTMLElement>()
+let echartInstance: echarts.ECharts | null = null
+
+// 监听数据变化
+watch(
+  () => props.datay,
+  () => {
+    loadEchart()
+  },
+  { deep: true }
+)
+
+// 窗口 resize 节流监听
+const handleResize = throttle(() => {
+  echartInstance?.resize()
+}, 500)
+
+// 初始化图表
+const loadEchart = () => {
+  if (!barLineChart.value) return
+
+  // 销毁旧实例
+  if (echartInstance) {
+    echartInstance.dispose()
+    echartInstance = null
+  }
+
+  if (props.datay.length === 0) return
+
+  // 初始化新实例
+  echartInstance = echarts.init(barLineChart.value, undefined, {
+    devicePixelRatio: 2,
+  })
+
+  // 辅助线数据处理
+  const markLineData = props.markLine.map((item) => ({
+    xAxis: item.xAxis,
+    label: {
+      show: true,
+      position: 'end',
+      color: props.fontColor || '#666',
+      fontSize: props.fontSize ? +props.fontSize : 14,
+      formatter: () => item.name,
+    },
+    name: item.name,
+    itemStyle: {
+      color: item.color,
+      type: 'dashed',
+    },
+  }))
+
+  // Y轴最大值
+  const flatData = props.datay.flat().map(Number)
+  const maxValue = Math.max(...flatData)
+
+  const option: echarts.EChartsOption = {
+    tooltip: {
+      show: true,
+      axisPointer: { type: 'shadow' },
+      trigger: 'item',
+      triggerOn: 'mousemove | click',
+      renderMode: 'html',
+      confine: true,
+      extraCssText:
+        'border-radius: 4px;padding:5px 0px 5px 5px;white-space:normal;word-wrap:break-word;max-width: 400px;',
+      enterable: true,
+      formatter: (params: any) => {
+        if (params.componentType === 'series') {
+          let tooltip = `<div class='tooltip_content'>`
+          const title = params.name
+          tooltip += `<div class='tooltip_title'>${title}</div>`
+          const obj = props.tooltipData[params.dataIndex]
+          tooltip += `<div class='tooltip_student'>
+                      <div class='tooltip_rect_icon' style='background:${params.color}'></div>
+                      <div class='tooltip_student_name'>${obj?.value || ''}</div>
+                    </div>`
+          tooltip += `</div>`
+          return tooltip
+        }
+        return ''
+      },
+    },
+    legend: {
+      show: false,
+      left: 0,
+      itemGap: 20,
+      itemHeight: 10,
+      itemWidth: 20,
+      textStyle: {
+        fontSize: props.fontSize ? +props.fontSize : 12,
+        color: '#333',
+      },
+    },
+    grid: {
+      top: props.gridTop,
+      left: props.gridLeft,
+      right: props.gridRight,
+      bottom: 0,
+      containLabel: true,
+    },
+    yAxis: {
+      type: 'value',
+      splitArea: {
+        show: true,
+        areaStyle: {
+          color: ['#fafafa', '#ffffff'],
+        },
+      },
+      axisLabel: {
+        formatter: '{value}',
+        fontSize: props.fontSize ? +props.fontSize : 14,
+        color: props.fontColor || '#666',
+        fontWeight: 400,
+      },
+      max: getMaxValue(maxValue),
+    },
+    xAxis: {
+      type: 'category',
+      data: props.datax,
+      axisLabel: {
+        rotate: props.datax.length > 12 ? 45 : 0,
+        width: 100,
+        overflow: 'break',
+        interval: 0,
+        hideOverlap: true,
+        fontSize: props.fontSize ? +props.fontSize : 14,
+        color: props.fontColor || '#666',
+      },
+    },
+    series: [
+      {
+        type: 'bar',
+        name: props.title[0],
+        data: props.datay[0].map((value, index) => ({
+          value: value,
+          itemStyle: {
+            color: getColor(props.datax[index], index),
+          },
+        })),
+        barGap: 0,
+        barCategoryGap: '30%',
+        barMinWidth: 8,
+        barMaxWidth: 50,
+        label: {
+          show: true,
+          position: 'top',
+          color: props.fontColor || '#666',
+          fontSize: props.fontSize ? +props.fontSize : 12,
+          formatter: (params: any) => {
+            return params.value == 0 ? '' : `${params.value}人`
+          },
+        },
+        markLine: {
+          symbol: ['none', 'none'],
+          label: { position: 'top', formatter: '{b}' },
+          itemStyle: { color: props.fontColor || '#666' },
+          data: markLineData,
+        },
+      },
+      {
+        type: 'line',
+        name: props.title[1],
+        data: props.datay[1],
+        symbol: 'emptyCircle',
+        showSymbol: true,
+        symbolSize: 8,
+        itemStyle: {
+          color: props.color[1],
+          borderColor: props.color[0],
+          borderWidth: 2,
+        },
+        lineStyle: {
+          color: props.color[1],
+          type: 'solid',
+          width: 4,
+        },
+        label: {
+          show: false,
+          position: 'top',
+          color: props.fontColor || '#666',
+          fontSize: props.fontSize ? +props.fontSize : 12,
+          formatter: '{c}' + props.unit,
+        },
+      },
+    ],
+  }
+
+  echartInstance.setOption(option)
+}
+
+// 获取最大值(保留全局方法逻辑)
+const getMaxValue = (value: number) => {
+  // 如果你有全局工具方法,直接替换即可
+  // return window.$global.getMaxValue(value)
+  return value
+}
+
+// 柱子颜色逻辑
+const getColor = (value: string, index: number) => {
+  const list = value.split('-')
+  let num1 = list[0].replace(/[\[\]()]/g, '')
+  const num = Number(num1)
+
+  if (index !== 0) {
+    num1 = String(num - 1)
+  }
+
+  const fullScore = props.fullScore
+  if (num >= fullScore * 0.8) return '#3BA272'
+  if (num >= fullScore * 0.6 && num < fullScore * 0.8) return '#FAC858'
+  return '#EE6666'
+}
+
+// 生命周期
+onMounted(() => {
+  loadEchart()
+  window.addEventListener('resize', handleResize)
+})
+
+onBeforeUnmount(() => {
+  window.removeEventListener('resize', handleResize)
+  echartInstance?.dispose()
+})
+</script>
+
+<style lang="scss" scoped>
+.echart_line {
+  width: 100%;
+  height: 380px;
+}
+</style>

+ 489 - 0
src/components/echarts/lineBarChart.vue

@@ -0,0 +1,489 @@
+<template>
+    <div class="echart_content">
+        <div class="is_show_all" v-if="showCheckBox">
+            <el-checkbox v-model="checkAll" :indeterminate="isIndeterminate" @change="ChangeCheckAll">
+                显示全部
+            </el-checkbox>
+        </div>
+        <div class="echart_type">
+            <div class="chart_icon_item" :class="chartType === 'line_chart' ? 'line_chart_cur' : 'line_chart'"
+                @click="ChangeChartType('line_chart')">
+                折线图
+            </div>
+            <div class="chart_icon_item" :class="chartType === 'vertical_bar' ? 'vertical_bar_cur' : 'vertical_bar'
+                " @click="ChangeChartType('vertical_bar')">
+                柱状图
+            </div>
+        </div>
+        <div ref="line_chart" class="chart_box"></div>
+    </div>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted, onBeforeUnmount, watch, nextTick } from "vue";
+import { throttle } from "lodash-es";
+import * as echarts from "echarts";
+import type { ECharts } from "echarts";
+
+// 类型定义
+type ChartType = "line_chart" | "vertical_bar";
+
+interface LegendSelected {
+    [key: string]: boolean;
+}
+
+// Props 定义
+const props = defineProps({
+    gridRight: { type: Number, default: 0 },
+    showCheckBox: { type: Boolean, default: true },
+    extraText: { type: Boolean, default: true },
+    datax: {
+        type: Array<string>,
+        default: () => [
+            "G10",
+            "G9",
+            "G8",
+            "G7",
+            "G6",
+            "G5",
+            "G4",
+            "G3",
+            "G2",
+            "G1",
+        ],
+    },
+    datay: {
+        type: Array<Array<number>>,
+        default: () => [
+            [12, 5, 8, 10, 15, 7, 9, 11, 13, 10],
+            [6, 14, 9, 10, 12, 8, 7, 11, 15, 8],
+            [5, 10, 15, 7, 9, 12, 8, 13, 14, 7],
+        ],
+    },
+    tooltipData: { type: Array<any>, default: () => [] },
+    colors: { type: Array<string>, default: () => [] },
+    unit: { type: String, default: "" },
+    title: { type: Array<string>, default: () => [] },
+    isSetMarkNumber: { type: Boolean, default: false },
+    markNumber: { type: Array<number>, default: () => [65] },
+    isSmooth: { type: Boolean, default: false },
+    showMarkLine: { type: Boolean, default: false },
+    showBackground: { type: Boolean, default: true },
+    isShowLabel: { type: Boolean, default: true },
+    showMarkPoint: { type: Boolean, default: false },
+    legendList: { type: Array<string>, default: () => [] },
+    hideOverlap: { type: Boolean, default: false },
+});
+
+// 响应式数据
+const line_chart = ref<HTMLElement | null>(null);
+let echartInstance: ECharts | null = null;
+
+const checkAll = ref<boolean>(true);
+const isIndeterminate = ref<boolean>(false);
+const legendSelected = ref<LegendSelected>({});
+const legenSelectList = ref<string[]>([]);
+const legenAllList = ref<string[]>([...props.title]);
+const chartType = ref<ChartType>("line_chart");
+const dataZoomEnd = ref<number>(0);
+const dataZoomNum = ref<number>(5);
+
+// 监听数据变化
+watch(
+    () => props.datay,
+    () => {
+        LoadEchart();
+    },
+    { deep: true },
+);
+
+// 辅助线配置
+const markLine = (index: number) => {
+    if (!props.showMarkLine) return {};
+    return {
+        data: [
+            {
+                type: "average",
+                name: "平均值",
+                yAxis: props.isSetMarkNumber ? props.markNumber[index] : null,
+            },
+        ],
+        label: {
+            position: "end",
+            formatter: (params: any) =>
+                props.isSetMarkNumber ? props.markNumber[index] : params.value,
+        },
+        lineStyle: { type: "dashed", color: props.colors[index] },
+    };
+};
+
+// 切换图表类型
+const ChangeChartType = (val: ChartType) => {
+    chartType.value = val;
+    LoadEchart();
+};
+
+// 初始化图表
+const LoadEchart = () => {
+    if (!line_chart.value) return;
+    if (echartInstance) {
+        echartInstance.dispose();
+        echartInstance = null;
+    }
+
+    echartInstance = echarts.init(line_chart.value, { devicePixelRatio: 2 });
+
+    // 颜色补齐
+    let customColors = [...props.colors];
+    const colorsList = [
+        "#5470C6",
+        "#91CC75",
+        "#FAC858",
+        "#EE6666",
+        "#73C0DE",
+        "#3BA272",
+        "#FC8452",
+        "#9A60B4",
+        "#EA7CCC",
+        "#66C",
+        "#C33",
+        "#FC0",
+        "#6C0",
+        "#09C",
+    ];
+    while (customColors.length < props.title.length)
+        customColors = customColors.concat(colorsList);
+
+    // 图例选中状态
+    legenAllList.value = [...props.title];
+    legendSelected.value = props.title.reduce((acc, dim) => {
+        acc[dim] = false;
+        return acc;
+    }, {} as LegendSelected);
+
+    legenSelectList.value.forEach((item) => {
+        legendSelected.value[item] = true;
+    });
+
+    checkAll.value = legenSelectList.value.length === legenAllList.value.length;
+    isIndeterminate.value =
+        legenSelectList.value.length > 0 &&
+        legenSelectList.value.length < legenAllList.value.length;
+
+    // 计算宽度与滚动条
+    const numSeries = Object.keys(legendSelected.value).filter(
+        (k) => legendSelected.value[k],
+    ).length;
+    const totalWidth = line_chart.value.clientWidth || 0;
+    let isShowDataZoom = false;
+    const barMinWidth = 30;
+    dataZoomNum.value = Math.floor((totalWidth - 60) / (barMinWidth * numSeries));
+    const echartWidth = (props.datax.length - 1) * barMinWidth * numSeries;
+    let singleSeriesWidth =
+        ((totalWidth - 60) / (props.datax.length - 1)) * numSeries - 60;
+    dataZoomEnd.value = Math.floor(
+        (100 / (props.datax.length - 1)) * dataZoomNum.value,
+    );
+    if (echartWidth > totalWidth) isShowDataZoom = true;
+    if (isShowDataZoom) singleSeriesWidth = 25 * legenSelectList.value.length;
+
+    // 滚动条配置
+    const dataZoomConfig = isShowDataZoom
+        ? {
+            start: 0,
+            end: dataZoomEnd.value,
+            type: "slider",
+            show: true,
+            borderColor: "transparent",
+            xAxisIndex: [0],
+            height: 8,
+            left: 20,
+            right: 20,
+            bottom: 0,
+            fillerColor: "transparent",
+            zoomLock: true,
+            handleSize: "0",
+            backgroundColor: "transparent",
+            showDataShadow: false,
+            showDetail: false,
+            filterMode: "filter",
+        }
+        : [];
+
+    const legendWidth = totalWidth - 270;
+    const extraText = props.extraText ? "占比" : "";
+
+    // 柱状图配置
+    const barOption: echarts.EChartsOption = {
+        tooltip: {
+            trigger: "axis",
+            axisPointer: { type: "line" },
+            renderMode: "html",
+            confine: true,
+            extraCssText:
+                "border-radius:4px; padding:5px; white-space:normal; max-width:400px;",
+            enterable: true,
+            formatter: (params: any) => {
+                let tip = `<div class='tooltip_content'><div class='tooltip_title'>${params[0]?.name}</div>`;
+                params.forEach((item: any) => {
+                    const val = props.tooltipData.length
+                        ? props.tooltipData[item.seriesIndex]?.[item.dataIndex] || ""
+                        : `${item.value}${props.unit}`;
+                    tip += `<div class='tooltip_student'>
+                    <div class='tooltip_rect_icon' style='background:${item.color}'></div>
+                    <div class='tooltip_student_name'>${item.seriesName}${extraText}:${val}</div>
+                  </div>`;
+                });
+                return tip + "</div>";
+            },
+        },
+        legend: {
+            show: true,
+            top: '6px',
+            left: props.showCheckBox ? "110px" : "0px",
+            width: legendWidth + "px",
+            itemGap: 20,
+            itemHeight: 10,
+            itemWidth: 25,
+            data: props.title,
+            textStyle: { fontSize: 12, color: "#333" },
+            selected: legendSelected.value,
+            type: "scroll",
+            pageButtonPosition: "end",
+            orient: "horizontal",
+        },
+        dataZoom: dataZoomConfig,
+        grid: { left: 30, right: 20, top: 50, bottom: 0, containLabel: true },
+        xAxis: {
+            type: "category",
+            data: props.datax,
+            boundaryGap: true,
+            axisTick: { alignWithLabel: true },
+            axisLabel: {
+                interval: 0,
+                rotate: singleSeriesWidth < 80 ? 45 : 0,
+                textStyle: { fontSize: 14, color: "#333" },
+                formatter: (val: string) => {
+                    const w = val.length * 10;
+                    if (w > singleSeriesWidth) return val.slice(0, 6) + "...";
+                    return val;
+                },
+            },
+        },
+        yAxis: {
+            type: "value",
+            axisLabel: { formatter: `{value}${props.unit}`, fontSize: 14 },
+            splitLine: { lineStyle: { color: "#E4E7ED" } },
+            splitArea: { show: true, areaStyle: { color: ["#fafafa", "#fff"] } },
+        },
+        series: props.datay.map((data, i) => ({
+            type: "bar",
+            name: props.title[i],
+            barGap: 0,
+            barCategoryGap: "30%",
+            barMinWidth: 20,
+            barMaxWidth: 50,
+            itemStyle: { color: customColors[i] },
+            label: {
+                show: true,
+                position: "top",
+                fontSize: 12,
+                formatter: (p: any) => (p.value === 0 ? "" : `${p.value}人`),
+            },
+            markLine: markLine(i),
+            data,
+        })),
+    };
+
+    // 折线图配置
+    const lineOption: echarts.EChartsOption = {
+        tooltip: {
+            trigger: "axis",
+            axisPointer: { type: "line" },
+            renderMode: "html",
+            confine: true,
+            extraCssText:
+                "border-radius:4px; padding:5px; white-space:normal; max-width:400px;",
+            enterable: true,
+            formatter: (params: any) => {
+                let tip = `<div class='tooltip_content'><div class='tooltip_title'>${params[0]?.name}</div>`;
+                params.forEach((item: any) => {
+                    const val = props.tooltipData.length
+                        ? props.tooltipData[item.seriesIndex]?.[item.dataIndex] || ""
+                        : `${item.value}${props.unit}`;
+                    tip += `<div class='tooltip_student'>
+                    <div class='tooltip_line_icon' style='background:${item.color}'></div>
+                    <div class='tooltip_student_name'>${item.seriesName}${extraText}:${val}</div>
+                  </div>`;
+                });
+                return tip + "</div>";
+            },
+        },
+        legend: {
+            show: true,
+            top: '6px',
+            left: props.showCheckBox ? "110px" : "0px",
+            width: legendWidth + "px",
+            itemGap: 20,
+            itemHeight: 10,
+            itemWidth: 25,
+            data: props.title,
+            textStyle: { fontSize: 12, color: "#333" },
+            selected: legendSelected.value,
+            type: "scroll",
+            pageButtonPosition: "end",
+            orient: "horizontal",
+        },
+        grid: { left: 30, right: 20, top: 50, bottom: 0, containLabel: true },
+        xAxis: {
+            type: "category",
+            data: props.datax,
+            boundaryGap: true,
+            axisTick: { alignWithLabel: true },
+            axisLabel: {
+                interval: 0,
+                hideOverlap: props.hideOverlap,
+                rotate: totalWidth / props.datax.length < 80 ? 45 : 0,
+                textStyle: { fontSize: 14, color: "#333" },
+                formatter: (val: string) => {
+                    const w = val.length * 10;
+                    if (w > singleSeriesWidth) return val.slice(0, 6) + "...";
+                    return val;
+                },
+            },
+        },
+        yAxis: {
+            type: "value",
+            axisLabel: { formatter: `{value}${props.unit}`, fontSize: 14 },
+            splitLine: { lineStyle: { color: "#E4E7ED" } },
+            splitArea: { show: true, areaStyle: { color: ["#fafafa", "#fff"] } },
+        },
+        series: props.datay.map((data, i) => ({
+            type: "line",
+            name: props.title[i],
+            symbol: "emptyCircle",
+            showSymbol: true,
+            symbolSize: 8,
+            smooth: props.isSmooth,
+            label: { show: false },
+            markLine: markLine(i),
+            markPoint: props.showMarkPoint
+                ? { data: [{ type: "max" }, { type: "min" }] }
+                : undefined,
+            itemStyle: { color: customColors[i] },
+            lineStyle: { width: 4 },
+            data,
+        })),
+    };
+
+    // 渲染
+    chartType.value === "vertical_bar"
+        ? echartInstance.setOption(barOption)
+        : echartInstance.setOption(lineOption);
+
+    echartInstance.on("legendselectchanged", HandleLegendSelectChanged);
+};
+
+// 图例切换
+const HandleLegendSelectChanged = (params: any) => {
+    const sel = params.selected;
+    legenSelectList.value = legenAllList.value.filter((item) => sel[item]);
+    LoadEchart();
+};
+
+// 全选
+const ChangeCheckAll = (value: boolean) => {
+    if (value) {
+        legenSelectList.value = [...legenAllList.value];
+        isIndeterminate.value = false;
+    } else {
+        legenSelectList.value = [];
+    }
+    LoadEchart();
+};
+
+// 窗口缩放
+const HandleResize = throttle(async () => {
+    await nextTick();
+    LoadEchart();
+}, 500);
+
+// 生命周期
+onMounted(() => {
+    if (props.legendList.length) legenSelectList.value = [...props.legendList];
+    LoadEchart();
+    window.addEventListener("resize", HandleResize);
+});
+
+onBeforeUnmount(() => {
+    window.removeEventListener("resize", HandleResize);
+    echartInstance?.dispose();
+});
+</script>
+
+<style lang="scss" scoped>
+.echart_content {
+    position: relative;
+    width: 100%;
+    margin: auto;
+    min-height: 360px;
+    height: auto;
+
+    .is_show_all {
+        position: absolute;
+        top: 2px;
+        left: 5px;
+    }
+
+    .chart_box {
+        width: 100%;
+        height: 400px;
+    }
+
+    .echart_type {
+        position: absolute;
+        right: 0;
+        top: 3px;
+        z-index: 7;
+        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;
+            text-align: right;
+            cursor: pointer;
+
+            //折线图
+            &.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");
+            }
+
+            &.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");
+            }
+        }
+    }
+}
+</style>

+ 5 - 6
src/views/analysis/errorAnalysis.vue

@@ -127,7 +127,7 @@
 <script lang="ts" setup>
 import ReportModule from "@/components/ReportModule.vue";
 import { useAnalysisStore } from "@/store/analysis";
-import { onMounted, reactive, watch, ref } from "vue";
+import { onMounted, reactive, watch } from "vue";
 import { errorQuestionAnalysis } from "@/api/analysis";
 const state = reactive({
   tableData: [],
@@ -135,10 +135,6 @@ const state = reactive({
   loadingText: "加载中……",
 });
 const analysisStore = useAnalysisStore();
-// 初始化
-const pageInit = () => {
-  GetErrorQuestionAnalysis();
-};
 //错题分析
 const GetErrorQuestionAnalysis = async () => {
   state.tableLoading = true;
@@ -155,7 +151,6 @@ const GetErrorQuestionAnalysis = async () => {
         return item.className == analysisStore.filterObject.classGroupName;
       }
     });
-    console.log(tableData, 2323);
     if (tableData?.length) {
       tableData[0].questionStatsList.forEach((item, key) => {
         const answerListLen = item?.answerList?.length || 0;
@@ -239,6 +234,10 @@ const ErrorTableCellClassName = ({ row, column, rowIndex, columnIndex }) => {
     return "";
   }
 };
+// 初始化
+const pageInit = () => {
+  GetErrorQuestionAnalysis();
+};
 // 监听筛选条件
 watch(
   () => analysisStore.filterObject,

+ 2 - 2
src/views/analysis/index.vue

@@ -207,8 +207,8 @@ const buildAndSaveFilterParams = () => {
     schoolId: schoolObj.schoolId,
     schoolLevel: schoolObj.schoolLevel,
     schoolGroupId: schoolObj.schoolGroupId,
-    schoolGroupName: (schoolObj.schoolLevel === '0' || schoolObj.schoolLevel === '2') ? null : schoolObj.schoolName,
-    schoolName: schoolObj.schoolLevel === '2' ? schoolObj.schoolName : null,
+    schoolGroupName: (schoolObj.schoolLevel === 0 || schoolObj.schoolLevel === 2) ? null : schoolObj.schoolName,
+    schoolName: schoolObj.schoolLevel === 2 ? schoolObj.schoolName : null,
     schoolGroupNames: schoolObj.schoolGroupNames,
 
     registrationType: statusObj.statusGroupType,

+ 309 - 51
src/views/analysis/levelDistribution.vue

@@ -1,27 +1,43 @@
 <template>
   <ReportModule :titleList="['1、分数段图']" tableOrChart="chart" :showPrintBtn="false" :showExportBtn="false">
     <template #title_right>
-      <div :class="['right_item', { item_active: state.activeIndex == index }]"
-        v-for="(item, index) in state.sortRangeScore" :key="index">
+      <div :class="['right_item', { item_active: state.sectionScore == item }]" v-for="item in state.sortRangeScore"
+        :key="item" @click="TagClick(item)">
         {{ item }}分段
       </div>
       <div class="right_set">
         <span>设置分数段</span>
-        <el-input v-model.number="state.scoreInput" maxlength="3" style="width: 54px;" />
+        <el-input v-model="state.sectionScore" maxlength="3" style="width: 54px" @input="HandleInput"
+          @change="BlurSectionScore" />
         <span>分/段</span>
       </div>
       <div class="right_radio">
-        <el-select style="width:100px;">
-          <el-option :value="2" label="按年级"></el-option>
-          <el-option :value="3" label="按班级"></el-option>
+        <el-select v-model="state.radioRangeScore" v-if="analysisStore?.filterObject?.classLevel != 2">
+          <el-option :value="0" label="按年级"></el-option>
+          <el-option :value="1" label="按班级"></el-option>
         </el-select>
       </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>
     </template>
     <template #module_describe>
-      说明:信度是反应考试一致性或可靠性的指标,信度高,表示考试成绩较为准确,误差较小; 信度低,表明误差大;信度低的考试无法正确评价考生的知识水平与智能素质。
+      说明:信度是反应考试一致性或可靠性的指标,信度高,表示考试成绩较为准确,误差较小;
+      信度低,表明误差大;信度低的考试无法正确评价考生的知识水平与智能素质。
       效度是指测验的有效性或正确性,即测验能否准确测量出其所要测量的内容。效度高的测验能够确保测验内容与测验目的的一致性,反映测验的正确性和准确性。
       分析试题的难度和区分度可以确保试题的质量和有效性,从而更好地评估学生的知识掌握程度和区分不同水平的学生。
       难度指试题的难易程度,通常用P值表示。计算方式为P=X/M(P为难度,X为试题平均得分,M为试题满分)。P值在0到1之间,值越大表示试题越简单,试题通常分为容易题(P≥0.7)、中等题(0.4至0.7之间)和难题(P≤0.4)。
@@ -29,48 +45,288 @@
       图中展示了各科的命题分析明细,点击科目的柱可在下方查看该科所有小题的命题分析。
     </template>
   </ReportModule>
-  <ReportModule :titleList="['2、分数段表']" tableOrChart="table" :showPrintBtn="false" :showDescribe="false">
+  <ReportModule :titleList="['2、分数段表']" tableOrChart="table" :showPrintBtn="false" :showDescribe="false"
+    :currentPage="state.scoreSegmentData.pageNum" :pageSize="state.scoreSegmentData.pageSize"
+    :total="state.scoreSegmentData.total" @update:pageSize="handleSizeChange" @update:currentPage="handleCurrentChange">
     <template #module_table_chart>
-      <el-table :data="tableData" border style="width: 100%">
-        <el-table-column prop="date" label="Date" width="180" />
-        <el-table-column prop="name" label="Name" width="180" />
-        <el-table-column prop="address" label="Address" />
+      <el-table :data="scoreRangeTableData" border>
+        <template v-for="(header, headerIndex) in state.scoreSegmentData.headerData">
+          <el-table-column align="center" :label="header.name" v-if="header.child && header.child.length"
+            :key="`child_${headerIndex}`">
+            <el-table-column v-for="(child, childIndex) in header.child" align="center" :label="child.value"
+              :prop="child.prop" :key="`${headerIndex}_${childIndex}`" show-overflow-tooltip>
+              <template #default="scope">
+                <template v-for="(detailItem, detailKey) in scope.row.detailList">
+                  <span v-if="header.name == detailItem.schoolName" :key="`${headerIndex}_${childIndex}_${detailKey}`">
+                    <span :class="child.prop == 'doubleOnlineNum' ? 'table_row_blue' : ''"
+                      v-if="child.prop == 'doubleOnlineNum' && detailItem[child.prop] != 0 && detailItem[child.prop] != '-'">
+                      {{ detailItem[child.prop] }}
+                    </span>
+                    <span v-else>
+                      {{ detailItem[child.prop] }}
+                    </span>
+                  </span>
+                </template>
+              </template>
+            </el-table-column>
+          </el-table-column>
+          <el-table-column v-else align="center" width="100" :label="header.name" :prop="header.prop" :key="headerIndex"
+            fixed="left" show-overflow-tooltip>
+            <template #default="scope">
+              {{ scope.row[header.prop] }}
+            </template>
+          </el-table-column>
+        </template>
       </el-table>
     </template>
   </ReportModule>
 </template>
 <script lang="ts" setup>
-import ReportModule from '@/components/ReportModule.vue';
-import { onMounted, reactive } from "vue";
-const tableData = [
-  {
-    date: '2016-05-03',
-    name: 'Tom',
-    address: 'No. 189, Grove St, Los Angeles',
-  },
-  {
-    date: '2016-05-02',
-    name: 'Tom',
-    address: 'No. 189, Grove St, Los Angeles',
-  },
-  {
-    date: '2016-05-04',
-    name: 'Tom',
-    address: 'No. 189, Grove St, Los Angeles',
-  },
-  {
-    date: '2016-05-01',
-    name: 'Tom',
-    address: 'No. 189, Grove St, Los Angeles',
-  },
-]
+import ReportModule from "@/components/ReportModule.vue";
+import BarLineChart from "@/components/echarts/barLineChart.vue";//柱状图折线图组件
+import LineBarChart from "@/components/echarts/lineBarChart.vue";//折线图柱状图组件
+import { useAnalysisStore } from "@/store/analysis";
+import { onMounted, reactive, computed, watch } from "vue";
+import { scoreSegment } from "@/api/analysis";
+const analysisStore = useAnalysisStore();
 const state = reactive({
-  activeIndex: -1, // 设置分数段索引  默认第一个
-  sortRangeScore: [5, 10], // 设置分数段
-  scoreInput: "",//输入框的值
+  sectionScore: '', // 设置分数段输入框
+  sortRangeScore: ['5', '10'], // 设置分数段
+  radioRangeScore: 0, // 0按年级, 1按班级
+  scoreSegmentData: {
+    exportLoading: false,
+    datax: [],
+    datay: [],
+    fullScore: 100, //满分值
+    markLine: [], //辅助线
+    tooltipData: [], //悬浮弹窗的数据
+    title: ["年级", "年级"],
+    color: ["#995FB3", "#5470C6"],
+    unit: "人",
+    tooltipTitle: "及格率",
+    tableData: [],
+    headerData: [],
+    pageSize: 10, //每页显示数据
+    total: 0, //总数
+    pageNum: 1, //当前页
+  }, //分数段图数据
+  tableLoading: true,
+  loadingText: "加载中……",
+  scoreSegmentClassData: {
+    datax: [],
+    datay: [],
+    title: [],
+    legendList: [],
+    tooltipData: [],//悬浮弹窗的数据
+  }//分数段 按班级分析数据
 });
+const scoreRangeTableData = computed(() => {
+  const { tableData, pageSize, pageNum } = state.scoreSegmentData;
+  const start = (pageNum - 1) * pageSize;
+  const end = start + pageSize;
+  return tableData.slice(start, end);
+})
+//分数段
+const GetScoreSegment = async () => {
+  state.tableLoading = true;
+  const res = await scoreSegment({
+    ...analysisStore.filterObject,
+    scoreSegmentNum: state.sectionScore,
+  });
+  if (res.code === 200 && res.data && res.data.rowData && res.data.rowData.length) {
+    let chartData = res.data.chartData
+    let chartDataTotal = res.data.chartData.groupSchoolData // 联校/年级数据
+    let chartDataSingle = res.data.chartData.oneSchoolData // 单校/班级数据
+
+    let datax = []; // 分数段x轴数据
+    let datay = []; // 联校/年级分数段y轴数据
+    let count = []; // 联校/年级数据
+    let tooltipData = []; // 联校/年级悬浮弹窗数据
+    let markLine = []; // 辅助线数据
+
+    let classTooltipData = []; //班级悬浮弹窗数据
+    let classDatay = []; //分数段按班级y轴数据
+    let classTitle = []; //分数段按班级图例数据
+
+    let average = parseFloat(res.data.chartData.average); // 平均分
+    let averageIndex = 0;//平均分的索引
+    let standard = parseFloat(res.data.chartData.standard);//标准差
+    let standardIndex = 0;//标准差索引
+    let twoStandard = parseFloat(res.data.chartData.twoStandard);//2倍标准差
+    let twoStandardIndex = 0;//2倍标准差索引
+    let negativeOneStandard = parseFloat(res.data.chartData.negativeOneStandard);//-1倍标准差
+    let negativeOneStandardIndex = 0;//-1倍标准差索引
+    let negativeTwoStandard = parseFloat(res.data.chartData.negativeTwoStandard);//-2倍标准差
+    let negativeTwoStandardIndex = 0;//-2倍标准差索引
 
-onMounted(() => { });
+    chartDataTotal.forEach((item, index) => {
+      let list = item.name.split('-');
+      let num1 = parseInt(list[0].replace(/[\[\]()]/g, ''));
+      let num2 = parseInt(list[1].replace(/[\[\]()]/g, ''));
+
+      if (num2 < average && average < num1) { // 平均分
+        averageIndex = index
+      }
+      if (num2 < standard && standard < num1) { // 标准差
+        standardIndex = index
+      }
+      if (num2 < twoStandard && twoStandard < num1) { // 2倍标准差
+        twoStandardIndex = index
+      }
+      if (num2 < negativeOneStandard && negativeOneStandard < num1) { // -1倍标准差
+        negativeOneStandardIndex = index
+      }
+      if (num2 < negativeTwoStandard && negativeTwoStandard < num1) { // -2倍标准差
+        negativeTwoStandardIndex = index
+      }
+
+      datax.push(item.name)
+      count.push(item.doubleOnlineNum)
+      tooltipData.push({
+        name: '',
+        value: `${item.doubleOnlineNum}人,占比${item.doubleOnlineRate} ${item.studentUserName.length ? `(${item.studentUserName.join('、')})` : ''}`
+      })
+    });
+    datay.push(count, count)
+
+    markLine.push({
+      name: '平均分',
+      value: average,
+      color: '#FAC858',
+      xAxis: averageIndex,
+    });
+    markLine.push({
+      name: '标准差',
+      color: '#3BA272',
+      value: standard,
+      xAxis: standardIndex,
+    });
+
+    markLine.push({
+      name: '-标准差',
+      color: '#EE6666',
+      value: negativeOneStandard,
+      xAxis: negativeOneStandardIndex,
+    });
+    markLine.push({
+      name: '-2倍标准差',
+      color: '#EE6666',
+      value: negativeTwoStandard,
+      xAxis: negativeTwoStandardIndex,
+    });
+    markLine.push({
+      name: '2倍标准差',
+      color: '#3BA272',
+      value: twoStandard,
+      xAxis: twoStandardIndex,
+    });
+    //判断人数是否可点击
+    const rowData = res.data.rowData || [];
+    state.scoreSegmentData = {
+      datax: datax,
+      datay: datay,
+      fullScore: parseFloat(chartData.fullScore),
+      markLine: markLine,//辅助线数据
+      tooltipData: tooltipData,//悬浮弹窗数据
+      title: ["年级", "1班"],
+      color: ["#995FB3", "#5470C6"],
+      unit: '人',
+      tooltipTitle: '及格率',
+      tableData: rowData,
+      headerData: res.data.titleData || [],
+      pageSize: 10,//每页显示数据
+      total: rowData.length,//总数
+      pageNum: 1,//当前页
+    };//分数段图数据
+
+    // 单校/班级图表数据处理
+    chartDataSingle.forEach(item => {
+      classTitle.push(item.name)
+      let singleItem = []
+      let tootlipItem = []
+      item.detailList.forEach(scoreItem => {
+        singleItem.push(scoreItem.doubleOnlineNum)
+        tootlipItem.push(`${scoreItem.doubleOnlineNum}人,占比${scoreItem.doubleOnlineRate}`);
+      })
+      classDatay.push(singleItem)
+      classTooltipData.push(tootlipItem)
+    })
+    state.scoreSegmentClassData = {
+      datax: datax,
+      datay: classDatay,
+      title: classTitle,//不会修改原数组
+      legendList: classTitle.slice(0, 3),//默认显示全部
+      tooltipData: classTooltipData,
+    };
+  } else {
+    state.scoreSegmentData = {
+      datax: [],
+      datay: [],
+      fullScore: 100,//满分值
+      markLine: [],//辅助线
+      tooltipData: [],//悬浮弹窗的数据
+      title: ["年级", "年级"],
+      color: ["#995FB3", "#5470C6"],
+      unit: '人',
+      tooltipTitle: '及格率',
+      tableData: [],
+      headerData: [],
+      pageSize: 10,//每页显示数据
+      total: 0,//总数
+      pageNum: 1,//当前页
+    };//分数段图数据
+
+    state.scoreSegmentClassData = {
+      datax: [],
+      datay: [],
+      title: [],
+      legendList: [],
+      tooltipData: [],//悬浮弹窗的数据
+    };//分数段 按班级分析数据
+  }
+  state.tableLoading = false;
+};
+//分数段切换
+const TagClick = (item) => {
+  state.sectionScore = item;
+  GetScoreSegment();//获取分数段数据
+}
+const HandleInput = (value: string) => {
+  state.sectionScore = value.replace(/[^\d]/g, '');
+}
+// 设置分数段失去焦点
+const BlurSectionScore = (value: string) => {
+  if (value == '0') {
+    state.sectionScore = '';
+  }
+  GetScoreSegment();//获取分数段数据
+}
+// 分页
+const handleCurrentChange = (val: number) => {
+  state.scoreSegmentData.pageNum = val;
+}
+
+const handleSizeChange = (val: number) => {
+  state.scoreSegmentData.pageSize = val;
+  state.scoreSegmentData.pageNum = 1;
+}
+// 初始化
+const pageInit = () => {
+  GetScoreSegment();
+};
+// 监听筛选条件
+watch(
+  () => analysisStore.filterObject,
+  async () => {
+    state.sectionScore = '';
+    pageInit();
+  },
+  { deep: true },
+);
+
+onMounted(() => {
+  pageInit();
+});
 </script>
 
 <style lang="scss" scoped>
@@ -87,34 +343,36 @@ onMounted(() => { });
   color: #999;
   font-weight: 400;
   cursor: pointer;
-  height: 30px;
-  line-height: 32px;
   margin-right: 5px;
   border-bottom: 2px solid #ffffff;
 
   &.item_active {
     font-size: 14px;
-    color: #2E64FA;
+    color: #2e64fa;
     font-weight: 500;
     cursor: pointer;
-    border-bottom: 2px solid #2E64FA;
+    border-bottom: 2px solid #2e64fa;
   }
 }
 
-
-
 .right_set {
   font-weight: 400;
   font-size: 14px;
   color: #333333;
 
-  .el-input {
-    text-align: center;
+  :deep(.el-input) {
     padding: 0 5px;
     margin: 0 2px;
-  }
 
+    .el-input__inner {
+      text-align: center;
+    }
+  }
 }
 
-.right_radio {}
+.right_radio {
+  :deep(.el-select) {
+    width: 100px;
+  }
+}
 </style>