Переглянути джерело

业务监控、学校管理、问题修改

吴朋磊 1 тиждень тому
батько
коміт
6c0a1ca52f

+ 19 - 2
src/baseComponents/LineChart.vue

@@ -25,6 +25,7 @@ export default {
             legenSelectList: [],//选择的图例
             legenAllList: this.title,//所有的图例数据
             _tooltipDetailHandler: null,//tooltip详情按钮点击处理器
+            _lastTooltipTitle: '',//最近一次 tooltip 渲染的 title(用于详情按钮点击时直接传递)
 
         }
     },
@@ -302,6 +303,7 @@ export default {
                 gridTop = 70;
             }
                 const hasDetailData = this.tooltipDetailData.length > 0
+            const hasDetailBtn = hasDetailData && this.showTooltipDetail // 是否展示"详情"按钮
             let option = {
 
                 tooltip: {
@@ -320,10 +322,24 @@ export default {
                     confine: true,//是否将 tooltip 框限制在图表的区域内。
                     extraCssText: 'border-radius: 4px;padding:5px 0px 5px 5px;white-space:normal;word-warp:break-word;max-width: 400px;', // 设置最大宽度和高度
                     enterable: true,
-                    hideDelay: 800,
+                    // 含"详情"按钮的 tooltip:加长隐藏延时,便于鼠标移入点击
+                    hideDelay: hasDetailBtn ? 2000 : 800,
+                    // 数据密集时 tooltip 会随鼠标横向跳动,导致难以移入点击"详情"按钮。
+                    // 这里把 tooltip 固定到所悬数据点的正上方(y 固定在顶部,x 居中于数据点并夹紧在图表内),
+                    // 用户只需沿竖直方向上移鼠标即可进入 tooltip,避免横向跳动。
+                    position: hasDetailBtn ? function (point, params, dom, rect, size) {
+                        const chartWidth = size.viewSize[0];
+                        const tooltipWidth = dom.offsetWidth || 0;
+                        // 数据点对齐到 tooltip 左侧 1/4 处,整体偏右,便于从数据点向右上移进入
+                        let x = point[0] - tooltipWidth / 200;
+                        if (x < 4) x = 4;
+                        if (x + tooltipWidth > chartWidth - 4) x = chartWidth - tooltipWidth - 4;
+                        return [x, 8];
+                    } : undefined,
                     formatter: (params) => {
                         let tooltip = `<div class='tooltip_content'>`;
                         let title = params[0].name;
+                        this._lastTooltipTitle = title;
                         const dataIndex = params[0].dataIndex;
                         // showTooltipDetail 控制是否显示详情按钮和学校列表
                         if (this.tooltipDetailData.length > 0 && this.showTooltipDetail) {
@@ -558,7 +574,8 @@ export default {
                         this.$emit('dataPointClick', {
                             dataIndex,
                             schoolList,
-                            rawData: detail
+                            rawData: detail,
+                            title: this._lastTooltipTitle
                         });
                     }
                 }

+ 2 - 3
src/baseComponents/MultipleBarCharts.vue

@@ -687,8 +687,7 @@ import * as echarts from 'echarts';
                             ]
                         });
                         
-                        let pixelPosition= this.echart.convertToPixel({ xAxisIndex: 0 }, params.name);
-                        // console.log("pixelPosition",pixelPosition);
+                        let pixelPosition= gridRect.x + (params.dataIndex + 0.5) * singleLabelWidth;
                         this.echart.setOption({
                             graphic:{
                                 id: 'highlight-box',
@@ -713,7 +712,7 @@ import * as echarts from 'echarts';
                     }
                         
                 })
-                let defaultPixelPosition= this.echart.convertToPixel({ xAxisIndex: 0 }, this.datax[0]);
+                let defaultPixelPosition= gridRect.x + (0 + 0.5) * singleLabelWidth;
                 this.echart.setOption({
                     graphic: {
                         id: "highlight-box",

+ 2 - 1
src/views/logMonitor/formSearchGroup.ts

@@ -21,11 +21,12 @@ export const differentTypeLogSearchList = ref<SearchItem[]>([
         label: '',
         placeholder: '全部学校',
         hidden: false,
+        filterable: true,
         // defaultValue: 0,
         options: [],
     },
     {
-        key: 'accountTypeCode',
+        key: 'accountType',
         type: 'select',
         label: '',
         placeholder: '全部类型',

+ 39 - 23
src/views/performanceMonitor/index.vue

@@ -64,7 +64,7 @@
             <div class="page_jg_16"></div>
             <div class="chart_panel">
                 <div class="panel_title">
-                    {{ currentHourLabel }}时 {{ currentOnlineLabel }}在线人数统计
+                    {{ currentAxisLabel }}{{ currentAxisLabel ? ' ' : '' }}{{ currentOnlineLabel }}人数统计
                 </div>
                 <MultipleBarCharts
                     :datax="barChartData.datax"
@@ -189,9 +189,24 @@ const currentOnlineLabel = computed(() => {
 // ==================== 自定义日期范围 ====================
 const customDateRange = ref<[string, string] | null>(null)
 
+// 给接口传递的日期加上固定时分秒
+const customDateRangeForApi = computed(() => {
+    if (customDateRange.value && customDateRange.value.length === 2) {
+        return [
+            customDateRange.value[0] + ' 00:00:00',
+            customDateRange.value[1] + ' 23:59:59'
+        ]
+    }
+    return ['', '']
+})
+
 const handleCustomDateChange = (val: [string, string] | null) => {
     if (val && val.length === 2) {
         fetchChartData()
+    } else {
+        // 清空日期选择器
+        resetBarAndSchoolChart()
+        fetchChartData()
     }
 }
 
@@ -223,13 +238,9 @@ const handleDataPointClick = async (payload: { dataIndex: number; schoolList: an
         console.warn('schoolList 为空,无法更新柱状图')
     }
 
-    // 更新时间标签
-    if (payload.dataIndex >= 0 && mainLineChartData.datax[payload.dataIndex]) {
-        const xname = mainLineChartData.datax[payload.dataIndex]
-        const hourMatch = xname.match(/(\d{1,2})时/)
-        if (hourMatch) {
-            onLineTime.value = parseInt(hourMatch[1], 10)
-        }
+    // 直接使用 tooltip 中已经渲染正确的 title 作为当前轴标签
+    if (payload.title) {
+        currentAxisLabel.value = payload.title
     }
 }
 
@@ -241,8 +252,7 @@ const barChartData = reactive({
     datay: [] as number[],
 })
 
-const onLineTime = ref(0)
-const currentHourLabel = computed(() => String(onLineTime.value))
+const currentAxisLabel = ref('') // 当前点击的折线图数据点 x 轴标签(含"时"),直接取自 tooltip title
 
 const selectedSchoolName = ref('')
 
@@ -272,16 +282,29 @@ const handleTablePageChange = (payload: { type: string; val: number }) => {
 }
 
 // ==================== 交互处理 ====================
+const resetBarAndSchoolChart = () => {
+    barChartData.datax = []
+    barChartData.datay = []
+    barChartSchoolList.value = []
+    schoolLineChartData.datax = []
+    schoolLineChartData.datay = []
+    schoolChartDetailDataList.value = []
+    selectedSchoolName.value = ''
+    currentAxisLabel.value = ''
+}
+
 const HandleTabChange = (type: string, val: string) => {
     switch (type) {
         case 'onlineType':
             defaultOnlineTypeTab.value = val
+            resetBarAndSchoolChart()
             fetchChartData()
             break
         case 'dateRange':
             defaultDateRangeTab.value = val
             if (val !== 'custom') {
                 customDateRange.value = null
+                resetBarAndSchoolChart()
                 fetchChartData()
             }
             break
@@ -307,8 +330,8 @@ const handleBarClick = async (index: number, name: string) => {
             schoolId,
             timeType,
             moduleCode: Number(defaultOnlineTypeTab.value) || 0,
-            startTime: customDateRange.value?.[0] ?? '',
-            endTime: customDateRange.value?.[1] ?? '',
+            startTime: customDateRangeForApi.value[0],
+            endTime: customDateRangeForApi.value[1],
         })
         console.log('点击柱子 getChartData 响应:', res)
 
@@ -407,8 +430,8 @@ const fetchSchoolLineChartData = async (schoolId: number) => {
             schoolId,
             timeType,
             moduleCode: Number(defaultOnlineTypeTab.value) || 0,
-            startTime: customDateRange.value?.[0] ?? '',
-            endTime: customDateRange.value?.[1] ?? '',
+            startTime: customDateRangeForApi.value[0],
+            endTime: customDateRangeForApi.value[1],
         })
         const data = res.data || res
         if (Array.isArray(data)) {
@@ -436,8 +459,8 @@ const fetchChartData = async () => {
             schoolId: 0,
             timeType,
             moduleCode: Number(defaultOnlineTypeTab.value) || 0,
-            startTime: customDateRange.value?.[0] ?? '',
-            endTime: customDateRange.value?.[1] ?? '',
+            startTime: customDateRangeForApi.value[0],
+            endTime: customDateRangeForApi.value[1],
         })
 
         const data = res.data || res
@@ -462,13 +485,6 @@ const fetchChartData = async () => {
                     barChartData.datay = schoolList.map((item: any) => item.count ?? 0)
                     barChartSchoolList.value = schoolList
                 }
-                // 初始化时间标签
-                if (firstItem.xname) {
-                    const hourMatch = String(firstItem.xname).match(/(\d{1,2})时/)
-                    if (hourMatch) {
-                        onLineTime.value = parseInt(hourMatch[1], 10)
-                    }
-                }
             }
         }
     } catch (err) {

+ 55 - 17
src/views/school/index.vue

@@ -42,7 +42,7 @@
             </div>
           </div>
         </div>
-        <div class="tag_group" v-if="cityNode.length > 0">
+        <div class="tag_group" v-if="cityNode.length > 0 && !isMunicipality">
           <div class="tag_group_title">城市:</div>
           <div class="tag_group_content">
             <div class="tag_item" v-for="item in cityNode" :key="item.cityCode" @click="selectItem(item.cityCode, 'cityCode', item)" :class="item.cityCode === searchData.cityCode ? 'active' : ''">
@@ -88,12 +88,12 @@
     <div class="page_item">
       <div class="page_search">
         <div class="search_content" ref="searchContentRef">
-          <div class="content_left">
+          <div class="content_left" style="flex: 1;">
             <div class="simple_filter" v-if="isShowSimpleFilter">
               <el-select v-model="searchData.provinceCode" @change="ChangeRegion('provinceCode')" v-if="searchList.provinceNode && searchList.provinceNode.length > 1">
                 <el-option v-for="item in searchList.provinceNode" :key="item.provinceCode" :value="item.provinceCode" :label="item.provinceName == '全部' ? '全部省份' : item.provinceName"></el-option>
               </el-select>
-              <el-select v-model="searchData.cityCode" @change="ChangeRegion('cityCode')" v-if="cityNode && cityNode.length > 1">
+              <el-select v-model="searchData.cityCode" @change="ChangeRegion('cityCode')" v-if="cityNode && cityNode.length > 1 && !isMunicipality">
                 <el-option v-for="item in cityNode" :key="item.cityCode" :value="item.cityCode" :label="item.cityName == '全部' ? '全部城市' : item.cityName"></el-option>
               </el-select>
               <el-select v-model="searchData.districtCode" @change="ChangeRegion('districtCode')" v-if="districtNode && districtNode.length > 1">
@@ -260,6 +260,8 @@ interface District {
 }
 const cityNode = ref<City[]>([])
 const districtNode = ref<District[]>([])
+const isMunicipality = ref(false) // 是否为直辖市
+const municipalityList = ['北京', '上海', '天津', '重庆']
 
 interface TypeNode {
   type: string;
@@ -299,16 +301,31 @@ const searchList = ref<SearchList>({
 type SearchDataKey = 'provinceCode' | 'cityCode' | 'districtCode' | 'type' | 'userId' | 'status'
 const selectItem = (value: string, str: SearchDataKey, item: any) => {
   searchData.value[str] = value
-  let arr = []
+  let arr: any[] = []
   if(str == 'provinceCode') {
     if(value && value != 'all') {
+      // 判断是否为直辖市
+      isMunicipality.value = municipalityList.some(m => item.provinceName.includes(m))
       arr = [...item.children]
       arr.splice(0, 0, { cityCode: "all", cityName: "全部" })
       cityNode.value = arr
-      searchData.value.cityCode = 'all'
       districtNode.value = []
       searchData.value.districtCode = 'all'
+
+      if(isMunicipality.value) {
+        // 直辖市:自动选中第一个非"全部"的城市,加载区县
+        const firstCity = item.children[0]
+        if(firstCity) {
+          searchData.value.cityCode = firstCity.cityCode
+          const districtArr = [...(firstCity.children || [])]
+          districtArr.splice(0, 0, { districtCode: "all", districtName: "全部" })
+          districtNode.value = districtArr
+        }
+      } else {
+        searchData.value.cityCode = 'all'
+      }
     }else {
+      isMunicipality.value = false
       cityNode.value = []
       searchData.value.cityCode = 'all'
       districtNode.value = []
@@ -331,17 +348,31 @@ const selectItem = (value: string, str: SearchDataKey, item: any) => {
   // emit('FnSearch', searchData);
 }
 const ChangeRegion = (str: SearchDataKey) => {
-  let arr = []
+  let arr: any[] = []
   if(str == 'provinceCode') {
     if(searchData.value.provinceCode && searchData.value.provinceCode != 'all') {
-      arr = [...searchList.value.provinceNode.find(item => item.provinceCode == searchData.value.provinceCode).children]
+      const provinceItem = searchList.value.provinceNode.find(item => item.provinceCode == searchData.value.provinceCode)
+      isMunicipality.value = provinceItem ? municipalityList.some(m => provinceItem.provinceName.includes(m)) : false
+      arr = [...(provinceItem?.children || [])]
       arr.splice(0, 0, { cityCode: "all", cityName: "全部" })
       cityNode.value = arr
-      console.log(cityNode.value)
-      searchData.value.cityCode = 'all'
       districtNode.value = []
       searchData.value.districtCode = 'all'
+
+      if(isMunicipality.value && provinceItem) {
+        // 直辖市:自动选中第一个城市,加载区县
+        const firstCity = provinceItem.children?.[0]
+        if(firstCity) {
+          searchData.value.cityCode = firstCity.cityCode
+          const districtArr = [...(firstCity.children || [])]
+          districtArr.splice(0, 0, { districtCode: "all", districtName: "全部" })
+          districtNode.value = districtArr
+        }
+      } else {
+        searchData.value.cityCode = 'all'
+      }
     } else {
+      isMunicipality.value = false
       cityNode.value = []
       searchData.value.cityCode = 'all'
       districtNode.value = []
@@ -350,7 +381,7 @@ const ChangeRegion = (str: SearchDataKey) => {
   }
   if(str == 'cityCode') {
     if(searchData.value.cityCode && searchData.value.cityCode != 'all') {
-      arr = [...cityNode.value.find(item => item.cityCode == searchData.value.cityCode).children]
+      arr = [...(cityNode.value.find(item => item.cityCode == searchData.value.cityCode)?.children || [])]
       arr.splice(0, 0, { districtCode: "all", districtName: "全部" })
       districtNode.value = arr
       searchData.value.districtCode = 'all'
@@ -436,14 +467,14 @@ const passwordDialog = reactive({
   label: '', // 名称label
   remark: '' // 提示语
 }) // 重置密码弹窗数据
-const permissions = ref([]) // 权限列表
+const permissions = ref<string[]>([]) // 权限列表
 provide('showDialog', showDialog) // 新增用户弹窗是否展示
 provide('dialogData', dialogData) // 新增用户弹窗数据
 provide('authData', authData) // 设置权限弹窗数据
 provide('subjectShowDialog', subjectShowDialog) // 开通科目弹窗数据
 provide('passwordDialog', passwordDialog) // 重置密码弹窗数据
 onMounted(() => {
-  permissions.value = JSON.parse(localStorage.getItem('permissions'))
+  permissions.value = JSON.parse(localStorage.getItem('permissions') || '[]')
 
   GetSearchList()
   GetSchoolNumber()
@@ -531,11 +562,11 @@ const GetSchoolList = () => {
   })
 }
 // 切换分页页码
-const HandleCurrentChange = (val) => {
+const HandleCurrentChange = (val: number) => {
   pageNum.value = val
   GetSchoolList()
 }
-const HandleSizeChange = (val) => {
+const HandleSizeChange = (val: number) => {
   pageSize.value = val
   pageNum.value = 1
   GetSchoolList()
@@ -636,7 +667,7 @@ const isShowSimpleFilter = ref(false);
 // 监听页面滚动
 const OnScroll = () => {
   nextTick(() => {
-    const filterElement = filterRef.value.$el || filterRef.value // 获取 DOM 元素
+    const filterElement = (filterRef.value as any)?.$el || filterRef.value // 获取 DOM 元素
     if (!filterElement) return
 
     const filterElementHeight = filterElement.getBoundingClientRect().height
@@ -670,11 +701,18 @@ const HandleResize = throttle(() => {
 <style lang="scss" scoped>
 .simple_filter {
   display: flex;
-  flex-wrap: wrap;
+  flex-wrap: nowrap;
   gap: 10px;
+  flex-shrink: 0;
 }
 .search_content {
-  align-items: flex-start !important;
+  align-items: center !important;
+}
+.content_left {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  flex-wrap: wrap;
 }
 .user_num_content {
   width: 100%;

+ 6 - 0
src/views/school/subjectList.vue

@@ -35,6 +35,7 @@ let subjectShowDialog = inject('subjectShowDialog')
 interface subjectList {
   id: number
   courseName: string
+  canDelete?: number
 }
 const subjectList = ref<subjectList[]>([])
 const inputValue = ref('')
@@ -60,6 +61,11 @@ const InputBlur = () => {
 }
 const handleInputConfirm = () => {
   if (inputValue.value) {
+    const isDuplicate = subjectList.value.some(item => item.courseName === inputValue.value.trim())
+    if (isDuplicate) {
+      ElMessage.warning('当前科目已存在,请勿重复添加')
+      return
+    }
     addSubject({ courseName: inputValue.value }).then((res: any) => {
       if (res.code === 200) {
         ElMessage.success(res.msg)