1. 小程序端
2. 视频优化2
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user