zjk
2026-07-31 8665daa7aa863f96d0d8f27c167edae1efc38022
ruoyi-ui/src/views/visitor-admin/dashboard/index.vue
@@ -55,7 +55,7 @@
    <section class="lower-grid">
      <article class="panel trend-panel">
        <h2>登记趋势</h2>
        <canvas ref="trendChart" width="760" height="310" aria-label="登记趋势折线图" />
        <canvas ref="trendChart" width="760" height="310" aria-label="登记趋势折线图" @click="handleTrendChartClick" />
        <div class="chart-legend">
          <span><i class="legend-dot blue" />现场登记</span>
          <span><i class="legend-dot green" />预约登记</span>
@@ -69,9 +69,13 @@
            v-for="item in quickEntries"
            :key="item.title"
            class="quick-entry"
            :class="{ active: activeQuickEntry === item.path }"
            type="button"
            :style="{ '--entry-color': item.color }"
            @click="$router.push(item.path)"
            @mousedown="activeQuickEntry = item.path"
            @focus="activeQuickEntry = item.path"
            @blur="activeQuickEntry = ''"
            @click="handleQuickEntry(item)"
          >
            <span class="quick-icon"><i :class="item.icon" /></span>
            <span>{{ item.title }}</span>
@@ -91,6 +95,8 @@
  data() {
    return {
      activeDateFilter: '最近7天',
      activeTrendIndex: null,
      activeQuickEntry: '',
      dateRange: [],
      dateFilters: ['今日', '最近7天', '最近30天'],
      dateFilterMap: {
@@ -146,14 +152,14 @@
        }
      ],
      quickEntries: [
        { title: '部门管理', color: '#2580ff', icon: 'el-icon-office-building', path: '/visitor/organization/dept' },
        { title: '员工管理', color: '#ff7545', icon: 'el-icon-user', path: '/visitor/organization/employee' },
        { title: '设备管理', color: '#ff4c5d', icon: 'el-icon-monitor', path: '/visitor/device/machine' },
        { title: '预约记录', color: '#ff9e33', icon: 'el-icon-notebook-2', path: '/visitor/record/list' },
        { title: '区域管理', color: '#43bd19', icon: 'el-icon-location-outline', path: '/visitor/area' },
        { title: '账号管理', color: '#5b77f1', icon: 'el-icon-setting', path: '/visitor/account/admin' },
        { title: '系统配置', color: '#416bea', icon: 'el-icon-s-tools', path: '/visitor/system/config' },
        { title: '系统更新', color: '#22bfc4', icon: 'el-icon-refresh', path: '/visitor/system/version' }
        { title: '部门管理', color: '#78a7ff', icon: 'el-icon-office-building', path: '/visitor/organization/dept' },
        { title: '员工管理', color: '#a979f5', icon: 'el-icon-user', path: '/visitor/organization/employee' },
        { title: '设备管理', color: '#5bd5ad', icon: 'el-icon-mobile-phone', path: '/visitor/device/machine' },
        { title: '预约记录', color: '#ffad7b', icon: 'el-icon-reading', path: '/visitor/record/list' },
        { title: '区域管理', color: '#4ccbd5', icon: 'el-icon-map-location', path: '/visitor/area' },
        { title: '账号管理', color: '#7b88f5', icon: 'el-icon-lock', path: '/visitor/account/admin' },
        { title: '系统配置', color: '#9aa9bd', icon: 'el-icon-setting', path: '/visitor/system/config' },
        { title: '系统更新', color: '#6da8f7', icon: 'el-icon-refresh', path: '/visitor/system/version' }
      ],
      chartData: {
        labels: ['09-08', '09-09', '09-10', '09-11', '09-12', '09-13', '09-14'],
@@ -286,6 +292,7 @@
        appointment: this.normalizeSeries(trend.appointment, labels.length),
        vehicle: this.normalizeSeries(trend.vehicle, labels.length)
      }
      this.activeTrendIndex = null
    },
    // 目的:把趋势序列补齐为指定长度,避免图表绘制时数组缺位。
    normalizeSeries(series, length) {
@@ -301,6 +308,32 @@
    // 目的:格式化统计数值,空值按 0 展示。
    formatCount(value) {
      return String(value == null ? 0 : value)
    },
    // 目的:记录快捷入口选中态并进入对应管理页面。
    handleQuickEntry(item) {
      this.activeQuickEntry = item.path
      this.$router.push(item.path)
    },
    // 目的:根据点击位置选中最近的趋势日期并重绘图表。
    handleTrendChartClick(event) {
      const canvas = this.$refs.trendChart
      const labelCount = this.chartData.labels.length
      if (!canvas || !labelCount) {
        return
      }
      const rect = canvas.getBoundingClientRect()
      const dpr = window.devicePixelRatio || 1
      const logicalWidth = canvas.width / dpr
      const logicalX = (event.clientX - rect.left) * (logicalWidth / rect.width)
      const padding = { left: 42, right: 16 }
      const chartWidth = logicalWidth - padding.left - padding.right
      if (logicalX < padding.left || logicalX > logicalWidth - padding.right) {
        return
      }
      const divider = Math.max(labelCount - 1, 1)
      const index = Math.round(((logicalX - padding.left) / chartWidth) * divider)
      this.activeTrendIndex = Math.max(0, Math.min(labelCount - 1, index))
      this.drawTrendChart()
    },
    // 目的:按当前趋势数据重绘 canvas 折线图。
    drawTrendChart() {
@@ -327,6 +360,7 @@
      const ticks = [0, step, step * 2, step * 3, step * 4, step * 5, max]
      ctx.clearRect(0, 0, width, height)
      this.drawTrendSelection(ctx, padding, chartWidth, chartHeight)
      ctx.font = '10px Microsoft YaHei, Arial'
      ctx.textAlign = 'right'
      ctx.textBaseline = 'middle'
@@ -346,17 +380,71 @@
      ctx.textAlign = 'center'
      ctx.textBaseline = 'top'
      const labelDivider = Math.max(this.chartData.labels.length - 1, 1)
      const visibleLabelIndexes = this.getTrendLabelIndexes(this.chartData.labels.length, chartWidth)
      this.chartData.labels.forEach((label, index) => {
        if (!visibleLabelIndexes.has(index)) {
          return
        }
        const x = padding.left + (chartWidth / labelDivider) * index
        ctx.fillStyle = '#9aabc0'
        ctx.fillStyle = index === this.activeTrendIndex ? '#7b4dff' : '#9aabc0'
        ctx.font = index === this.activeTrendIndex ? '700 10px Microsoft YaHei, Arial' : '10px Microsoft YaHei, Arial'
        ctx.fillText(label, x, height - 20)
      })
      this.drawSeries(ctx, this.chartData.onsite, '#3c7cff', padding, chartWidth, chartHeight, max, true)
      this.drawSeries(ctx, this.chartData.appointment, '#23bd8e', padding, chartWidth, chartHeight, max, true)
      this.drawSeries(ctx, this.chartData.onsite, '#3c7cff', padding, chartWidth, chartHeight, max, true, this.activeTrendIndex)
      this.drawSeries(ctx, this.chartData.appointment, '#23bd8e', padding, chartWidth, chartHeight, max, true, this.activeTrendIndex)
    },
    // 目的:根据图表宽度均匀抽样日期标签,避免长时间范围下横轴文字重叠。
    getTrendLabelIndexes(labelCount, chartWidth) {
      const indexes = new Set()
      if (labelCount <= 0) {
        return indexes
      }
      const maxLabelCount = Math.max(2, Math.floor(chartWidth / 54) + 1)
      const visibleCount = Math.min(labelCount, maxLabelCount)
      const divider = Math.max(visibleCount - 1, 1)
      for (let position = 0; position < visibleCount; position++) {
        indexes.add(Math.round(((labelCount - 1) * position) / divider))
      }
      if (this.activeTrendIndex != null) {
        indexes.add(this.activeTrendIndex)
      }
      return indexes
    },
    // 目的:为当前选中日期绘制紫色圆角背景,与快捷入口选中态保持一致。
    drawTrendSelection(ctx, padding, chartWidth, chartHeight) {
      if (this.activeTrendIndex == null || !this.chartData.labels.length) {
        return
      }
      const divider = Math.max(this.chartData.labels.length - 1, 1)
      const spacing = chartWidth / divider
      const width = Math.min(74, Math.max(42, spacing * 0.72))
      const x = padding.left + spacing * this.activeTrendIndex - width / 2
      const y = 10
      const height = padding.top + chartHeight - y + 4
      const radius = 14
      ctx.save()
      ctx.beginPath()
      ctx.moveTo(x + radius, y)
      ctx.lineTo(x + width - radius, y)
      ctx.quadraticCurveTo(x + width, y, x + width, y + radius)
      ctx.lineTo(x + width, y + height - radius)
      ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height)
      ctx.lineTo(x + radius, y + height)
      ctx.quadraticCurveTo(x, y + height, x, y + height - radius)
      ctx.lineTo(x, y + radius)
      ctx.quadraticCurveTo(x, y, x + radius, y)
      ctx.closePath()
      ctx.fillStyle = 'rgba(148, 91, 255, 0.08)'
      ctx.strokeStyle = 'rgba(148, 91, 255, 0.28)'
      ctx.lineWidth = 1
      ctx.fill()
      ctx.stroke()
      ctx.restore()
    },
    // 目的:绘制单条趋势线和可选面积填充。
    drawSeries(ctx, data, color, padding, chartWidth, chartHeight, max, fill) {
    drawSeries(ctx, data, color, padding, chartWidth, chartHeight, max, fill, activeIndex) {
      const pointDivider = Math.max(data.length - 1, 1)
      const points = data.map((value, index) => ({
        x: padding.left + (chartWidth / pointDivider) * index,
@@ -388,10 +476,16 @@
        ctx.fill()
      }
      points.forEach(point => {
      points.forEach((point, index) => {
        if (index === activeIndex) {
          ctx.fillStyle = '#fff'
          ctx.beginPath()
          ctx.arc(point.x, point.y, 5.2, 0, Math.PI * 2)
          ctx.fill()
        }
        ctx.fillStyle = color
        ctx.beginPath()
        ctx.arc(point.x, point.y, 2.2, 0, Math.PI * 2)
        ctx.arc(point.x, point.y, index === activeIndex ? 3.6 : 2.2, 0, Math.PI * 2)
        ctx.fill()
      })
    }