topfans/frontend/pages/dashboard/components/IncomeCurve.vue
2026-06-09 00:37:42 +08:00

347 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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 私有 _showTooltipVue 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 的 showToolTipoption.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 返回 -1showToolTip 内部会忽略
}
}).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 已 patchseries.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>