227 lines
6.5 KiB
Vue
227 lines
6.5 KiB
Vue
<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>
|