347 lines
12 KiB
Vue
347 lines
12 KiB
Vue
<template>
|
||
<view class="income-curve-card">
|
||
<text class="curve-title">七日收益曲线</text>
|
||
<image
|
||
class="chart-bg"
|
||
src="/static/dashboard/ucharts-bj.png"
|
||
mode="scaleToFill"
|
||
/>
|
||
<!-- 错误态 -->
|
||
<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" @mousemove="onCanvasMouseMove">
|
||
<qiun-data-charts
|
||
type="area"
|
||
:opts="chartOpts"
|
||
:chartData="chartData"
|
||
:ontouch="true"
|
||
:ontap="true"
|
||
:onmouse="true"
|
||
:onmovetip="true"
|
||
:in-scroll-view="true"
|
||
:tooltipShow="true"
|
||
:tooltipFormat="'incomeFormatter'"
|
||
:canvas2d="false"
|
||
canvasId="incomeCurveCanvas"
|
||
:canvasHeight="240"
|
||
@complete="onChartComplete"
|
||
/>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { computed, getCurrentInstance, nextTick, ref } from "vue";
|
||
// 显式 import 兜底,避免 easycom 漏注册
|
||
import QiunDataCharts from "@/uni_modules/qiun-data-charts/components/qiun-data-charts/qiun-data-charts.vue";
|
||
// [新增] 共享 u-charts config 拿到 cfu.instance[canvasId] 直接调 showToolTip
|
||
// (绕开 qiun-data-charts 的私有 _showTooltip:它通过 Vue ref 不可达)
|
||
import cfu from "@/uni_modules/qiun-data-charts/js_sdk/u-charts/config-ucharts.js";
|
||
|
||
const props = defineProps({
|
||
points: { type: Array, default: () => [] },
|
||
loading: { type: Boolean, default: false },
|
||
error: { type: String, default: null },
|
||
});
|
||
defineEmits(["retry"]);
|
||
|
||
// [新增] 上次已展示 tooltip 的数据点数量,避免重复触发
|
||
const lastShownLen = ref(0);
|
||
|
||
// [新增] 与 template <qiun-data-charts> 的 canvasId prop 保持一致
|
||
// 用于从 cfu.instance 中拿到对应的 uCharts 实例
|
||
const CANVAS_ID = "incomeCurveCanvas";
|
||
|
||
// [新增] 自定义 tooltip 文本格式:把 series.data 包成 "+xxx"
|
||
// 说明:qiun-data-charts 的 tooltipCustom prop 类型是 Object 而非 Function
|
||
// (它期望 {x, y, index, textList} 字段,不会被当函数调用),所以走原生
|
||
// formatter 通道:注册到 cfu.formatter,再用 :tooltipFormat="..." 让 chart
|
||
// 组件 _showTooltip / renderjs showTooltip 用我们注册的函数。
|
||
// 我们的 direct showToolTip 调用(默认显示、鼠标移动)也显式传这个 formatter。
|
||
// 日期通过 extra.tooltip.showCategory=true 自动作为第一行(来自 opts.categories)。
|
||
const incomeFormatter = (item, _category, _index, _opts) => `+${item.data}`;
|
||
cfu.formatter.incomeFormatter = incomeFormatter;
|
||
|
||
// [新增] 图表渲染完成回调:
|
||
// 拿最后一点的真实屏幕坐标,构造 fake event 喂给 u-charts 的 showToolTip,
|
||
// 让 tooltip 默认显示在最后一点。后续用户触摸其他点仍由 qiun-data-charts
|
||
// 内部 _tap → _showTooltip 流程处理(不走我们这里)。
|
||
//
|
||
// 实现说明:
|
||
// 1. calPoints 是 canvas 像素 (=CSS像素 × opts.pix);showToolTip 内部
|
||
// getTouches 会再乘一次 opts.pix 转换为 canvas 像素,所以先除回去
|
||
// 避免双倍缩放。真实触摸路径里 e.detail.x 是 CSS 像素,getTouches
|
||
// 一次乘 pix 转换到 canvas 像素。
|
||
// 2. 通过共享 config-ucharts.js 拿到 cfu.instance[CANVAS_ID] 直接调
|
||
// showToolTip,绕开 qiun-data-charts 私有 _showTooltip(Vue ref 不可达)
|
||
// 3. option.index 显式传最后一点索引,不再依赖 getCurrentDataIndex 推断
|
||
const onChartComplete = async (e) => {
|
||
console.log("mouse move", e)
|
||
const len = props.points.length;
|
||
if (!len) return;
|
||
// 数据点数量未变化则不重复触发(防 complete 事件多次触发)
|
||
if (lastShownLen.value === len) return;
|
||
await nextTick();
|
||
const series = e?.opts?.chartData?.calPoints?.[0];
|
||
if (!series || !series[len - 1]) return;
|
||
const pix = e?.opts?.pix || 1;
|
||
const { x, y } = series[len - 1];
|
||
const fakeE = { changedTouches: [{ x: x / pix, y: y / pix }] };
|
||
try {
|
||
const instance = cfu.instance[CANVAS_ID];
|
||
if (!instance) {
|
||
console.warn("[IncomeCurve] uCharts instance not found for canvas:", CANVAS_ID);
|
||
return;
|
||
}
|
||
// [H5 fix] qiun-data-charts 的 cfu.option[cid].onmouse 在 HBuilderX H5 预览下
|
||
// 保持 undefined(看 uni_modules 源码 mounted() 第 488-490 行:仅 inWin 时赋值)。
|
||
// 它的 renderjs.mouseMove 第一行 `if(onmouse == false) return;` 因
|
||
// `undefined == false` 为 true 而直接退出,导致 H5 鼠标移动不切换 tooltip。
|
||
// 这里显式设为 true 开启 H5 鼠标响应。
|
||
if (cfu.option[CANVAS_ID]) {
|
||
cfu.option[CANVAS_ID].onmouse = true;
|
||
}
|
||
// [诊断日志] 验证 onmouse 修复 + showToolTip 调用
|
||
console.log("[IncomeCurve] onmouse patched to:", cfu.option[CANVAS_ID]?.onmouse, "instance:", !!instance, "len:", len);
|
||
// 直接调 u-charts 的 showToolTip,option.index 显式传最后一点;
|
||
// formatter 显式传 incomeFormatter 让 income 显示成 "+xxx"
|
||
instance.showToolTip(fakeE, { index: len - 1, formatter: incomeFormatter });
|
||
} catch (err) {
|
||
console.warn("[IncomeCurve] show default tooltip failed:", err);
|
||
}
|
||
lastShownLen.value = len;
|
||
};
|
||
|
||
// [新增] H5 鼠标移动响应(绕开 qiun-data-charts renderjs 路径)
|
||
// 问题:uni-app 的 renderjs 层不把 mousemove 事件转发到 renderjs 脚本,
|
||
// 所以 rdcharts.mouseMove 永远不会被调用,H5 桌面端鼠标移动不能切换 tooltip。
|
||
// 解决:直接在 IncomeCurve 上挂 @mousemove 监听,用 cfu.instance 直接调
|
||
// u-charts 原生 showToolTip(同样支持 getCurrentDataIndex 自动找最近数据点)。
|
||
// 非 H5 平台无 mousemove 事件,本 handler 是 no-op。
|
||
// 节流到 60ms 一次,避免每次 mousemove 都重绘 canvas。
|
||
let lastMouseMoveTime = 0;
|
||
let mouseMoveCallCount = 0;
|
||
const onCanvasMouseMove = (e) => {
|
||
mouseMoveCallCount++;
|
||
if (mouseMoveCallCount === 1) {
|
||
// [诊断日志] 第一次触发时打印,证明事件到了
|
||
console.log("[IncomeCurve] onCanvasMouseMove FIRST CALL — event reached Vue layer, clientX:", e.clientX, "clientY:", e.clientY);
|
||
}
|
||
const uchartsInstance = cfu.instance[CANVAS_ID];
|
||
if (!uchartsInstance) return;
|
||
const now = Date.now();
|
||
if (now - lastMouseMoveTime < 60) return; // 节流 60ms
|
||
lastMouseMoveTime = now;
|
||
// 捕获事件坐标(callback 是异步的,那时 e 可能已变)
|
||
const clientX = e.clientX;
|
||
const clientY = e.clientY;
|
||
// qiun-data-charts 内部 view id 是 'UC' + canvasId
|
||
const viewSel = "#UC" + CANVAS_ID;
|
||
const query = uni.createSelectorQuery();
|
||
const inst = getCurrentInstance();
|
||
if (inst) query.in(inst.proxy);
|
||
query.select(viewSel).boundingClientRect((rect) => {
|
||
if (!rect) return;
|
||
const localX = clientX - rect.left;
|
||
const localY = clientY - rect.top;
|
||
// 只在图表范围内才响应
|
||
if (localX < 0 || localX > rect.width || localY < 0 || localY > rect.height) return;
|
||
const fakeE = { changedTouches: [{ x: localX, y: localY }] };
|
||
try {
|
||
// 不传 index,让 u-charts 内部 getCurrentDataIndex 自动找最近点;
|
||
// formatter 显式传 incomeFormatter 让 income 显示成 "+xxx"
|
||
uchartsInstance.showToolTip(fakeE, { formatter: incomeFormatter });
|
||
} catch (err) {
|
||
// 鼠标在某些区域可能让 getCurrentDataIndex 返回 -1,showToolTip 内部会忽略
|
||
}
|
||
}).exec();
|
||
};
|
||
|
||
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",
|
||
// [自定义] u-charts.js 已 patch:series.linearColor 启用横向多色渐变
|
||
// 对应 CSS: linear-gradient(90deg, #D1EFFFBC 0%, #E7E8A9D8 49.52%, #FF9CE5FA 100%)
|
||
linearColor: [
|
||
[0, "rgba(209, 239, 255, 0.735)"],
|
||
[0.4952, "rgba(231, 232, 169, 0.846)"],
|
||
[1, "rgba(255, 156, 229, 0.98)"],
|
||
],
|
||
linearDirection: "horizontal", // 'horizontal'(默认) | 'vertical'
|
||
},
|
||
],
|
||
};
|
||
});
|
||
|
||
const PADDING = [16, -16, 8, -16]; // top, right, bottom, left
|
||
|
||
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 让日期(来自 opts.categories)作为 tooltip 第一行;
|
||
// qiun-data-charts 的 tooltipCustom prop 是 Object 不是 Function,函数版根本不被调用。
|
||
// 所以改用 u-charts 原生 showCategory 通道 + cfu.formatter 自定义收入格式。
|
||
showCategory: true,
|
||
bgColor: "#000000",
|
||
bgOpacity: 0.6,
|
||
// [修复] fontColor 控制第一行(日期)的颜色:灰色
|
||
// 第二行(收入)由 series.color 控制(#1BAFEE 蓝色),不受 fontColor 影响
|
||
fontColor: "#999999",
|
||
fontSize: 11,
|
||
splitLine: true,
|
||
horizentalLine: { type: "dash", width: 1, color: "#FFFFFF" },
|
||
},
|
||
// area 类型曲线区域图,搭配 series.linearType=custom + linearColor 实现自定义多色渐变
|
||
// addLine:false 不绘制曲线(曲线透明),仅保留渐变面积填充
|
||
area: {
|
||
type: "curve",
|
||
opacity: 1,
|
||
addLine: false,
|
||
width: 2,
|
||
gradient: true,
|
||
activeType: "hollow",
|
||
},
|
||
},
|
||
};
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.income-curve-card {
|
||
background: linear-gradient(
|
||
107.96deg,
|
||
rgba(255, 223, 119, 0.59) -28.33%,
|
||
rgba(142, 149, 226, 0.59) 44.4%,
|
||
rgba(244, 140, 255, 0.59) 117.77%
|
||
);
|
||
border-radius: 22rpx;
|
||
padding: 24rpx;
|
||
box-sizing: border-box; // padding 计入总高度,避免内部撑爆
|
||
box-shadow: 0px 4px 4px 0px #BD323240;
|
||
position: relative;
|
||
height: 256rpx;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden; // 裁掉超出圆角的部分(包括背景图)
|
||
}
|
||
|
||
.curve-title {
|
||
display: block;
|
||
font-size: 28rpx;
|
||
font-weight: 600;
|
||
color: #ffffff;
|
||
margin-bottom: 4rpx; // 卡片高度有限,缩小标题与图表间距
|
||
text-shadow: 0px 2px 2px rgba(0, 0, 0, 0.2);
|
||
position: relative;
|
||
z-index: 2; // 标题在背景图和图表之上
|
||
}
|
||
|
||
.chart-wrap {
|
||
position: relative;
|
||
flex: 1; // 撑满标题下方的剩余空间
|
||
min-height: 0; // flex 子项防止溢出的兜底
|
||
border-radius: 17rpx;
|
||
overflow: hidden;
|
||
z-index: 1;
|
||
// backdrop-filter: blur(10px);
|
||
}
|
||
|
||
.chart-bg {
|
||
position: absolute;
|
||
bottom: 0;
|
||
left: 0;
|
||
width: 224rpx;
|
||
height: 100%;
|
||
z-index: 0; // 在标题(z:2)和图表(z:1)之下
|
||
pointer-events: none; // 不拦截 tap,事件穿透到 canvas
|
||
opacity: 0.4;
|
||
}
|
||
|
||
.chart-canvas {
|
||
position: relative;
|
||
width: 100%;
|
||
height: 100%; // 跟随 .chart-wrap 撑满
|
||
// 阴影:drop-shadow 沿 canvas 内容 alpha 通道生效,跟随曲线形状
|
||
// 对应 CSS: box-shadow: -1px -5px 4px 0px #CF232338 (#CF2323 + alpha 0x38 ≈ 0.22)
|
||
filter: drop-shadow(-1px -5px 4px rgba(207, 35, 35, 0.22));
|
||
}
|
||
|
||
.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>
|