1. 小程序端

2. 视频优化2
This commit is contained in:
李琦
2026-07-30 14:14:28 +08:00
parent a9eb20f0e1
commit 4e245f789b
115 changed files with 24053 additions and 1 deletions

View File

@@ -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>