Files
nl-blogs/blog-wot-uniapp/src/subPackages/admin/components/AdminLineChart.vue
李琦 4e245f789b 1. 小程序端
2. 视频优化2
2026-07-30 14:14:28 +08:00

227 lines
6.5 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
/**
* 后台折线图:旧版 canvas,金色描边,适配暗色工作台。
* 多系列时按同色相不同透明度区分;下方 HTML 图例标明发文/UV,避免「UV 没展示」的误解。
* 小程序里 v-if 挂载后首帧 boundingClientRect 常为 0,需重试并设置原生宽高。
* getCurrentInstance 仅在 setup/同步生命周期有效,必须在此处缓存 proxy。
*/
import { computed, getCurrentInstance, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
const props = withDefaults(defineProps<{
/** 横轴标签 */
categories?: string[]
/** 系列:{ name, data } */
series?: { name: string, data: number[] }[]
height?: number
}>(), {
categories: () => [],
series: () => [],
height: 220,
})
const SERIES_COLORS = ['#d4b383', 'rgba(120,180,255,0.9)', 'rgba(160,220,160,0.9)']
/** setup 阶段同步缓存,供异步 draw 使用 */
const compProxy = getCurrentInstance()?.proxy as never
const canvasId = `line-${Math.random().toString(36).slice(2, 8)}`
/** 原生 canvas 缓冲像素宽(含 dpr),绑定到 width 属性才能在小程序上正常绘制 */
const canvasW = ref(300)
const canvasH = ref(220)
let retryTimer: ReturnType<typeof setTimeout> | null = null
let retryCount = 0
const MAX_RETRY = 5
const legendItems = computed(() =>
(props.series || []).map((s, i) => ({
name: s.name || `系列${i + 1}`,
color: SERIES_COLORS[i % SERIES_COLORS.length],
})),
)
/** 优先新 API,降级 getSystemInfoSync,减少弃用警告 */
function fallbackWidth(): number {
try {
const win = typeof uni.getWindowInfo === 'function' ? uni.getWindowInfo() : null
if (win?.windowWidth) return Math.max(200, win.windowWidth - 48)
const sys = uni.getSystemInfoSync()
return Math.max(200, (sys.windowWidth || 375) - 48)
} catch {
return 300
}
}
function getDpr(): number {
try {
const device = typeof uni.getDeviceInfo === 'function' ? uni.getDeviceInfo() : null
if (device && 'pixelRatio' in device && Number(device.pixelRatio)) {
return Number(device.pixelRatio)
}
return uni.getSystemInfoSync().pixelRatio || 2
} catch {
return 2
}
}
/**
* 构建选择器:有组件实例则限定在组件内,否则页面级查询(canvasId 已全局唯一)。
* 禁止对 null 调用 .in(),否则 mp-weixin 读 $scope 直接报错。
*/
function createQuery() {
const q = uni.createSelectorQuery()
if (compProxy) return q.in(compProxy)
return q
}
/**
* 在逻辑像素坐标下绘制折线(调用前需已设好原生宽高并 scale)。
*/
function paint(ctx: UniApp.CanvasContext, w: number, h: number, dpr: number) {
const cats = props.categories
const series = props.series
ctx.scale(dpr, dpr)
const padL = 36
const padR = 12
const padT = 16
const padB = 28
const plotW = w - padL - padR
const plotH = h - padT - padB
ctx.clearRect(0, 0, w, h)
const all = series.flatMap(s => s.data)
const max = Math.max(1, ...all)
const min = Math.min(0, ...all)
const span = max - min || 1
ctx.setStrokeStyle('rgba(255,255,255,0.08)')
ctx.setLineWidth(1)
for (let i = 0; i <= 3; i++) {
const y = padT + (plotH * i) / 3
ctx.beginPath()
ctx.moveTo(padL, y)
ctx.lineTo(w - padR, y)
ctx.stroke()
}
series.forEach((s, si) => {
const color = SERIES_COLORS[si % SERIES_COLORS.length]
ctx.setStrokeStyle(color)
ctx.setLineWidth(2)
ctx.beginPath()
s.data.forEach((v, i) => {
const x = padL + (cats.length <= 1 ? plotW / 2 : (plotW * i) / (cats.length - 1))
const y = padT + plotH - ((v - min) / span) * plotH
if (i === 0) ctx.moveTo(x, y)
else ctx.lineTo(x, y)
})
ctx.stroke()
s.data.forEach((v, i) => {
const x = padL + (cats.length <= 1 ? plotW / 2 : (plotW * i) / (cats.length - 1))
const y = padT + plotH - ((v - min) / span) * plotH
ctx.beginPath()
ctx.arc(x, y, 3, 0, Math.PI * 2)
ctx.setFillStyle(color)
ctx.fill()
})
})
ctx.setFillStyle('rgba(136,136,136,1)')
ctx.setFontSize(10)
const step = Math.max(1, Math.ceil(cats.length / 5))
cats.forEach((c, i) => {
if (i % step !== 0 && i !== cats.length - 1) return
const x = padL + (cats.length <= 1 ? plotW / 2 : (plotW * i) / (cats.length - 1))
ctx.fillText(String(c).slice(-5), x - 12, h - 8)
})
ctx.draw()
}
/**
* 测量并绘制。宽度为 0 时用系统宽度兜底并短延迟重试;
* 先写原生宽高再 nextTick 绘制,避免属性未同步导致空白。
*/
const draw = (isRetry = false) => {
const cats = props.categories
const series = props.series
if (!cats.length || !series.length) return
if (!isRetry) retryCount = 0
createQuery()
.select(`#${canvasId}`)
.boundingClientRect((rect) => {
const box = Array.isArray(rect) ? rect[0] : rect
let w = box?.width || 0
const measuredOk = !!w
if (!w) w = fallbackWidth()
if (!measuredOk && retryCount < MAX_RETRY) {
retryCount += 1
if (retryTimer) clearTimeout(retryTimer)
retryTimer = setTimeout(() => draw(true), 80)
} else if (measuredOk) {
retryCount = 0
}
if (!w) return
const h = props.height
const dpr = getDpr()
canvasW.value = Math.floor(w * dpr)
canvasH.value = Math.floor(h * dpr)
nextTick(() => {
const ctx = uni.createCanvasContext(canvasId, compProxy)
paint(ctx, w, h, dpr)
})
})
.exec()
}
onMounted(() => nextTick(() => draw()))
onUnmounted(() => {
if (retryTimer) clearTimeout(retryTimer)
})
watch(() => [props.categories, props.series], () => nextTick(() => draw()), { deep: true })
</script>
<template>
<view class="line-wrap">
<canvas
:id="canvasId"
:canvas-id="canvasId"
class="chart"
:width="canvasW"
:height="canvasH"
:style="{ width: '100%', height: `${height}px` }"
/>
<view v-if="legendItems.length" class="legend">
<view v-for="(item, i) in legendItems" :key="i" class="legend-item">
<view class="dot" :style="{ background: item.color }" />
<text class="legend-text">{{ item.name }}</text>
</view>
</view>
</view>
</template>
<style scoped>
.line-wrap { width: 100%; }
.chart { width: 100%; display: block; }
.legend {
display: flex;
flex-wrap: wrap;
gap: 20rpx;
margin-top: 12rpx;
padding: 0 8rpx;
}
.legend-item {
display: flex;
align-items: center;
gap: 8rpx;
}
.dot {
width: 16rpx;
height: 16rpx;
border-radius: 4rpx;
}
.legend-text {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.55);
}
</style>