| | |
| | | <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> |
| | |
| | | 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> |
| | |
| | | data() { |
| | | return { |
| | | activeDateFilter: '最近7天', |
| | | activeTrendIndex: null, |
| | | activeQuickEntry: '', |
| | | dateRange: [], |
| | | dateFilters: ['今日', '最近7天', '最近30天'], |
| | | dateFilterMap: { |
| | |
| | | } |
| | | ], |
| | | 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'], |
| | |
| | | appointment: this.normalizeSeries(trend.appointment, labels.length), |
| | | vehicle: this.normalizeSeries(trend.vehicle, labels.length) |
| | | } |
| | | this.activeTrendIndex = null |
| | | }, |
| | | // 目的:把趋势序列补齐为指定长度,避免图表绘制时数组缺位。 |
| | | normalizeSeries(series, length) { |
| | |
| | | // 目的:格式化统计数值,空值按 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() { |
| | |
| | | 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' |
| | |
| | | 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, |
| | |
| | | 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() |
| | | }) |
| | | } |