250 lines
6.6 KiB
Vue
250 lines
6.6 KiB
Vue
<template>
|
||
<view class="income-curve-card">
|
||
<text class="curve-title">七日收益曲线</text>
|
||
|
||
<!-- 错误态 -->
|
||
<view v-if="error" class="error-box" @tap="$emit('retry')">
|
||
<text class="error-text">加载失败,点击重试</text>
|
||
</view>
|
||
|
||
<!-- 骨架态 -->
|
||
<view v-else-if="loading || !points || points.length === 0" class="skeleton-chart"></view>
|
||
|
||
<!-- 图表 -->
|
||
<view v-else class="chart-wrap">
|
||
<view class="chart-canvas">
|
||
<qiun-data-charts
|
||
type="area"
|
||
:opts="chartOpts"
|
||
:chartData="chartData"
|
||
:ontouch="true"
|
||
:onmovetip="true"
|
||
canvas2d
|
||
ontap
|
||
@complete="handleChartComplete"
|
||
/>
|
||
<!-- 默认指向最后一天的指示器(位置来自 uCharts calPoints,跟图表完全对齐) -->
|
||
<view
|
||
v-if="latestPoint && latestCalPoint"
|
||
class="latest-indicator"
|
||
:style="{
|
||
left: latestCalPoint.x / pixel + 'px',
|
||
top: latestCalPoint.y / pixel + 'px',
|
||
}"
|
||
>
|
||
<view class="latest-line"></view>
|
||
<view class="latest-dot"></view>
|
||
<view class="latest-tooltip">
|
||
<text class="latest-value">+{{ latestPoint.income }}</text>
|
||
<text class="latest-date">{{ latestPoint.date.slice(5) }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { computed, ref } from 'vue'
|
||
// 显式 import 兜底,避免 easycom 漏注册
|
||
import QiunDataCharts from '@/uni_modules/qiun-data-charts/components/qiun-data-charts/qiun-data-charts.vue'
|
||
|
||
const props = defineProps({
|
||
points: { type: Array, default: () => [] },
|
||
loading: { type: Boolean, default: false },
|
||
error: { type: String, default: null },
|
||
})
|
||
defineEmits(['retry'])
|
||
|
||
// canvas2d 模式 opts.pix=1,非 canvas2d 模式 opts.pix=systemInfo.pixelRatio
|
||
// 用 emit 回来的 opts 拿真实值,兜底 1
|
||
const pixel = ref(1)
|
||
const chartOptsRef = ref(null)
|
||
const calPoints = ref([])
|
||
|
||
// 监听 @complete:uCharts 渲染完成后会 emit 一次,opts.chartData.calPoints 是每个点的真实 canvas 坐标
|
||
function handleChartComplete(e) {
|
||
const opts = e?.opts
|
||
if (!opts) return
|
||
chartOptsRef.value = opts
|
||
pixel.value = opts.pix || 1
|
||
calPoints.value = opts.chartData?.calPoints || []
|
||
}
|
||
|
||
// 跟 chartOpts.padding / chart-canvas 高度保持一致,单位 rpx
|
||
const CHART_HEIGHT = 240
|
||
const PADDING = [16, 16, 8, 16] // top, right, bottom, left
|
||
|
||
const peak = computed(() => props.points.find((p) => p.is_peak) || null)
|
||
const latestPoint = computed(() =>
|
||
props.points.length > 0 ? props.points[props.points.length - 1] : null
|
||
)
|
||
|
||
// 最后一点的真实 canvas 坐标(来自 uCharts @complete 事件的 calPoints)
|
||
// calPoints 结构:calPoints[seriesIndex][dataIndex] = { x, y },y 是 canvas 像素
|
||
const latestCalPoint = computed(() => {
|
||
if (!latestPoint.value || calPoints.value.length === 0) return null
|
||
const seriesPoints = calPoints.value[0]
|
||
if (!seriesPoints || seriesPoints.length === 0) return null
|
||
return seriesPoints[seriesPoints.length - 1] || null
|
||
})
|
||
|
||
const chartData = computed(() => {
|
||
const categories = props.points.map((p) => p.date.slice(5))
|
||
const lineData = props.points.map((p) => p.income)
|
||
return {
|
||
categories,
|
||
series: [
|
||
{ name: '收益', type: 'area', data: lineData, color: '#1BAFEE' },
|
||
],
|
||
}
|
||
})
|
||
|
||
const chartOpts = {
|
||
color: ['#1BAFEE'],
|
||
padding: PADDING,
|
||
dataLabel: false,
|
||
legend: { show: false },
|
||
// 关闭曲线上每个数据点的小圆圈
|
||
dataPointShape: false,
|
||
xAxis: { disabled: true, disableGrid: true, axisLine: false, fontColor: '#FFFFFF', fontSize: 9 },
|
||
yAxis: {
|
||
disabled: true,
|
||
showTitle: false,
|
||
data: [{ min: 0, disabled: true, axisLine: false }],
|
||
disableGrid: true,
|
||
fontColor: '#FFFFFF',
|
||
fontSize: 9,
|
||
},
|
||
extra: {
|
||
tooltip: {
|
||
showBox: true,
|
||
showArrow: true,
|
||
showCategory: true,
|
||
bgColor: '#000000',
|
||
bgOpacity: 0.6,
|
||
fontColor: '#FFFFFF',
|
||
fontSize: 11,
|
||
splitLine: true,
|
||
horizentalLine: { type: 'dash', width: 1, color: '#FFFFFF' },
|
||
},
|
||
// area 类型曲线区域图,gradient: true 启用从线色到透明的渐变填充
|
||
area: { type: 'curve', opacity: 0.3, addLine: true, width: 2, gradient: true, activeType: 'hollow' },
|
||
},
|
||
}
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.income-curve-card {
|
||
background: linear-gradient(135deg, #FFDF77 0%, #8E95E2 40%, #F48CFF 100%);
|
||
border-radius: 22rpx;
|
||
padding: 24rpx;
|
||
margin: 24rpx 0;
|
||
box-shadow: 0px 4px 4px rgba(189, 50, 50, 0.25);
|
||
min-height: 360rpx;
|
||
}
|
||
|
||
.curve-title {
|
||
display: block;
|
||
font-size: 28rpx;
|
||
font-weight: 600;
|
||
color: #ffffff;
|
||
margin-bottom: 16rpx;
|
||
text-shadow: 0px 2px 2px rgba(0, 0, 0, 0.2);
|
||
}
|
||
|
||
.chart-wrap {
|
||
background: rgba(255, 255, 255, 0.15);
|
||
border-radius: 17rpx;
|
||
padding: 16rpx;
|
||
backdrop-filter: blur(10px);
|
||
}
|
||
|
||
.chart-canvas {
|
||
position: relative;
|
||
width: 100%;
|
||
height: 240rpx;
|
||
}
|
||
|
||
.latest-indicator {
|
||
position: absolute;
|
||
z-index: 2;
|
||
// left/top 是数据点的中心,translate(-50%, -100%) 让 tooltip 在点的正上方、点对齐 indicator 中心
|
||
transform: translate(-50%, -100%);
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.latest-line {
|
||
width: 1rpx;
|
||
height: 60rpx;
|
||
background: rgba(255, 255, 255, 0.7);
|
||
margin-bottom: -2rpx;
|
||
align-self: center;
|
||
}
|
||
|
||
.latest-dot {
|
||
width: 12rpx;
|
||
height: 12rpx;
|
||
border-radius: 50%;
|
||
background: #1BAFEE;
|
||
border: 2rpx solid #ffffff;
|
||
align-self: center;
|
||
margin-bottom: 6rpx;
|
||
}
|
||
|
||
.latest-tooltip {
|
||
background: rgba(0, 0, 0, 0.6);
|
||
border-radius: 8rpx;
|
||
padding: 6rpx 12rpx;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
margin-bottom: 4rpx;
|
||
}
|
||
|
||
.latest-value {
|
||
font-size: 24rpx;
|
||
font-weight: 700;
|
||
color: #FFFABD;
|
||
line-height: 1.2;
|
||
}
|
||
|
||
.latest-date {
|
||
font-size: 18rpx;
|
||
color: rgba(255, 255, 255, 0.85);
|
||
line-height: 1.2;
|
||
margin-top: 2rpx;
|
||
}
|
||
|
||
.skeleton-chart {
|
||
height: 360rpx;
|
||
border-radius: 17rpx;
|
||
background: linear-gradient(90deg, rgba(255, 255, 255, 0.1) 25%, rgba(255, 255, 255, 0.2) 50%, rgba(255, 255, 255, 0.1) 75%);
|
||
background-size: 200% 100%;
|
||
animation: skeleton-shimmer 1.5s infinite;
|
||
}
|
||
|
||
.error-box {
|
||
height: 360rpx;
|
||
border-radius: 17rpx;
|
||
background: rgba(255, 100, 100, 0.15);
|
||
border: 2rpx solid rgba(255, 100, 100, 0.4);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.error-text {
|
||
color: #ff8080;
|
||
font-size: 28rpx;
|
||
}
|
||
|
||
@keyframes skeleton-shimmer {
|
||
0% { background-position: 200% 0; }
|
||
100% { background-position: -200% 0; }
|
||
}
|
||
</style>
|