280 lines
14 KiB
Markdown
280 lines
14 KiB
Markdown
# 七日收益曲线默认显示最后一条记录的提示 — 设计文档
|
||
|
||
- **日期**: 2026-06-08
|
||
- **作者**: Claude
|
||
- **状态**: 已批准,待实施
|
||
- **范围**: 前端单组件改动
|
||
|
||
## 背景与目标
|
||
|
||
### 现状
|
||
`frontend/pages/dashboard/components/IncomeCurve.vue` 是数据看板的"七日收益曲线"组件,使用 `qiun-data-charts`(基于 u-charts)渲染 area 类型渐变面积图。组件已有完整的 `tooltipCustom` 渲染逻辑、tap 切换、错误态/骨架态。
|
||
|
||
### 问题
|
||
图表加载完成后默认没有任何 tooltip,需要用户触摸/点击图表才会显示。**期望**:进入页面后,tooltip 默认显示在最后一个数据点(最近一天)上;用户触摸其他点时,tooltip 跟随到对应点。
|
||
|
||
### 范围
|
||
- **范围内**: `IncomeCurve.vue` 一个文件
|
||
- **范围外**: 其他 dashboard 图表、其他业务模块、qiun-data-charts 公共 API
|
||
|
||
## 设计
|
||
|
||
### 核心机制
|
||
|
||
u-charts 在每帧渲染完成后会把每个数据点的真实屏幕坐标写入 `opts.chartData.calPoints[seriesIndex][dataIndex].x/y`。`qiun-data-charts` 组件在 `drawCharts` 完成后通过 `@complete` 事件把这个 opts 抛给父组件。
|
||
|
||
利用这两点:
|
||
1. 父组件监听 `@complete`,从 `e.opts.chartData.calPoints[0]` 取最后一点的 `{x, y}`
|
||
2. 构造一个 fake event `{ changedTouches: [{ x, y }] }`
|
||
3. 调 `chartRef.value._showTooltip(fakeE)` —— 该方法内部用 `getCurrentDataIndex(e)` 按 fake 坐标找最近的数据点(在最后一点上调用,最近的就是它自己),再走 u-charts 的 `showToolTip` 渲染流程,`tooltipCustom` 自然会用真实 index 渲染正确的文字
|
||
|
||
### 方案选择
|
||
|
||
| 方案 | 结论 |
|
||
|------|------|
|
||
| A. Ref + fake event 调 `_showTooltip` ⭐ | **采用**。只改一个文件;不动 uni_modules;改动小 |
|
||
| B. 给 qiun-data-charts 加 `defaultTooltipIndex` prop | 拒绝。改 uni_modules 带来升级合并冲突,YAGNI |
|
||
| C. 模拟真实 tap 事件 | 拒绝。跨端兼容差(H5/小程序/App 行为不一致) |
|
||
|
||
### 实现要点
|
||
|
||
#### 1. IncomeCurve.vue 改动
|
||
|
||
**Template**:
|
||
```vue
|
||
<qiun-data-charts
|
||
ref="chartRef"
|
||
...
|
||
@complete="onChartComplete"
|
||
@tap="onChartTap"
|
||
/>
|
||
```
|
||
|
||
**Script**:
|
||
```js
|
||
import { ref, nextTick } from "vue";
|
||
|
||
const chartRef = ref(null);
|
||
// 防止首次 complete 和后续重复触发叠加
|
||
const lastShownLen = ref(0);
|
||
|
||
const onChartComplete = async (e) => {
|
||
const len = props.points.length;
|
||
if (!len || !chartRef.value) return;
|
||
// 数据没变就不重复触发
|
||
if (lastShownLen.value === len) return;
|
||
await nextTick();
|
||
const series = e?.opts?.chartData?.calPoints?.[0];
|
||
if (!series || !series[len - 1]) return;
|
||
const { x, y } = series[len - 1];
|
||
// 构造 fake event,模拟在最后一点"触摸"
|
||
const fakeE = { changedTouches: [{ x, y }] };
|
||
// ⚠️ 调用 qiun-data-charts 的私有方法(下划线开头)
|
||
// 风险与回退见下方"风险与回退"表
|
||
try {
|
||
chartRef.value._showTooltip(fakeE);
|
||
} catch (err) {
|
||
console.warn("[IncomeCurve] show default tooltip failed:", err);
|
||
}
|
||
lastShownLen.value = len;
|
||
};
|
||
|
||
// 清理:原 onChartTap 仅更新 currentIndex(死代码),删除
|
||
// 原 watch + currentIndex 逻辑删除(YAGNI)
|
||
```
|
||
|
||
#### 2. 关键边界条件
|
||
|
||
| 情况 | 处理 |
|
||
|------|------|
|
||
| `points.length === 0` | 组件渲染骨架态,不进入 chart 分支,`onChartComplete` 不会触发 |
|
||
| `calPoints` 未填充 | u-charts 在 `process === 1` 阶段才填充 calPoints;`@complete` 事件在 `drawCharts` 之后 emit,可信 |
|
||
| props 从 0 条变 7 条 | `lastShownLen` 从 0 变 7,下次 complete 触发新 tooltip |
|
||
| props 从 7 条变 0 条 | 进入 skeleton 分支,不会触发;下次有数据时正常触发 |
|
||
| 多次 complete 事件(重渲染) | `lastShownLen` 判断跳过 |
|
||
| `_showTooltip` 在某些端不可用 | 用 `chartRef.value?._showTooltip?.(...)` 兜底;不可用时静默失败(用户依然能 tap 触发) |
|
||
|
||
#### 3. 删除的死代码
|
||
|
||
- `const currentIndex = ref(0);`
|
||
- 监听 `props.points.length` 的 `watch`
|
||
- `onChartTap` 函数(未做任何事,因为 `tooltipCustom` 用 u-charts 内部 index)
|
||
- 模板上的 `@tap="onChartTap"` —— u-charts 内部 `_tap` 仍会处理 tap 事件,父组件不需要再监听
|
||
|
||
> ⚠️ 实际上 `onChartTap` 现在的实现是死代码,因为 `tooltipCustom` 接收的 index 来自 u-charts 内部,不受父组件状态影响。tap 时图表自然会用触摸点的 index 渲染。所以**只删父组件的监听和函数,qiun-data-charts 内部 tap 行为不变**。
|
||
|
||
### 架构 / 组件 / 数据流
|
||
|
||
```
|
||
IncomeCurve.vue
|
||
├─ 模板
|
||
│ └─ <qiun-data-charts @complete="onChartComplete" />
|
||
└─ 脚本
|
||
├─ import cfu from "uni_modules/.../config-ucharts.js" — 共享 u-charts config
|
||
├─ CANVAS_ID = "incomeCurveCanvas" — 与 template canvasId prop 一致
|
||
├─ lastShownLen — 防止重复触发的游标
|
||
└─ onChartComplete(e) — 解析 calPoints,构造 fake event,直接调 cfu.instance[CANVAS_ID].showToolTip
|
||
|
||
数据流:
|
||
points 变化
|
||
→ chartData computed 更新
|
||
→ qiun-data-charts 重渲染
|
||
→ 内部 u-charts 填充 calPoints
|
||
→ emit 'complete' 事件(带 opts)
|
||
→ onChartComplete 解析 calPoints[0][last]
|
||
→ 构造 fakeE (除以 opts.pix 避免双倍缩放)
|
||
→ 拿 cfu.instance[CANVAS_ID].showToolTip(fakeE, { index: lastIndex })
|
||
→ u-charts 内部用显式 index 渲染 tooltip
|
||
→ tooltipCustom(opts, categories, lastIndex) 返回 +收益/日期
|
||
```
|
||
|
||
### 错误处理
|
||
|
||
| 失败点 | 行为 |
|
||
|--------|------|
|
||
| `chartRef.value` 为空 | 函数开头 `if` 守卫,静默退出 |
|
||
| `e.opts.chartData.calPoints[0]` 为 undefined | 守卫后退出;用户可手动 tap 触发 |
|
||
| `_showTooltip` 抛异常 | try/catch 包裹,记录 console.warn 不打断主流程 |
|
||
| 平台不支持 canvas 内部访问 | 静默失败(极端情况,uniapp 内不会出现) |
|
||
|
||
### 测试
|
||
|
||
#### 手动验证(必做)
|
||
|
||
1. **默认显示**:
|
||
- 进入 dashboard 页面,等待图表加载完成
|
||
- 验证最后一个数据点(最右)上方出现 tooltip(`+XXX` 和 `MM-DD`)
|
||
- 验证没有 hollow 圆点(因为 `tooltipCustom` 走的是真实触摸路径,u-charts 不会画 active point;如有需要可在 `extra.area.activeType` 控制)
|
||
|
||
2. **触摸切换**:
|
||
- 在图表中间点 tap
|
||
- 验证 tooltip 移动到该点
|
||
- 释放后再 tap 最右点
|
||
- 验证 tooltip 回到最右点(实际是 u-charts 内部逻辑,行为已存在)
|
||
|
||
3. **数据切换**:
|
||
- 切换 star_id(dashboard 顶部下拉)
|
||
- 验证新数据加载完成后,tooltip 显示在新数据最后一点
|
||
|
||
4. **空态/错误态**:
|
||
- 接口返回空数据 → 不应触发 `_showTooltip`,不应报错
|
||
- 接口报错 → 错误态显示正常
|
||
|
||
#### 跨端验证
|
||
|
||
- 微信小程序(主战场)
|
||
- H5(开发预览)
|
||
- App(如果有时间)
|
||
|
||
### 风险与回退
|
||
|
||
| 风险 | 影响 | 回退方案 |
|
||
|------|------|----------|
|
||
| **`_showTooltip` 是 qiun-data-charts 私有方法**(下划线开头),未在公共 API 中保证 | default tooltip 失效,但 tap 仍可用 | 已用 try/catch + `?.()` 兜底;最差情况下回退为 `<qiun-data-charts v-if="ready">` 延后首帧渲染(体验略差) |
|
||
| `calPoints` 在某些情况下不写入 | default tooltip 不显示 | 已加 `series[len-1]` 守卫;如未触发用户仍可 tap |
|
||
| 多次 complete 事件堆积 | 多次绘制 | `lastShownLen` 游标防抖 |
|
||
|
||
### 改动清单
|
||
|
||
- **修改**: `frontend/pages/dashboard/components/IncomeCurve.vue`(+~15 行 / -~10 行)
|
||
- **不动**: `qiun-data-charts` 及其依赖、任何后端代码、其他前端组件
|
||
|
||
### 实施步骤概要
|
||
|
||
1. 给 `<qiun-data-charts>` 加 `ref="chartRef"` 和 `@complete="onChartComplete"`
|
||
2. 实现 `onChartComplete` 函数(解析 calPoints + fake event + 调 _showTooltip)
|
||
3. 删除死代码 `currentIndex` / `watch` / `onChartTap`(可选清理)
|
||
4. 手测三种情况:默认显示 / 触摸切换 / 数据切换
|
||
5. 跨端验证(小程序 + H5)
|
||
|
||
|
||
---
|
||
|
||
## 实施过程变更(追加于 2026-06-09)
|
||
|
||
实施过程中发现两个 v1 设计假设不成立,相应做了调整:
|
||
|
||
### 变更 1:移除 `ref="chartRef"`,改走 `cfu.instance[CANVAS_ID]`
|
||
|
||
**问题**:v1 设计用 `chartRef.value._showTooltip(fakeE)` 触发默认 tooltip。运行时发现:
|
||
|
||
```
|
||
[IncomeCurve] show default tooltip failed: TypeError: chartRef.value._showTooltip is not a function
|
||
```
|
||
|
||
qiun-data-charts 组件的 `_showTooltip` 是下划线前缀私有方法,在 Options API + 跨平台编译(uni-app vue3 编译器)后,**父组件 ref 上拿不到这个方法**。
|
||
|
||
**修复**:qiun-data-charts 通过 `import cfu from "uni_modules/qiun-data-charts/js_sdk/u-charts/config-ucharts.js"` 共享一个 `cfu` 对象,其中 `cfu.instance[canvasId]` 直接持有 uCharts 实例。我们在 IncomeCurve.vue 里也 import 同一个 `cfu`,绕过 qiun-data-charts 组件层,直接调 uCharts 原生的 `showToolTip(e, {index})`。
|
||
|
||
**附带收益**:
|
||
- 不再依赖 qiun-data-charts 私有 API(更稳)
|
||
- 可以显式传 `option.index`,不依赖 `getCurrentDataIndex(fakeE)` 推断(更准)
|
||
- 移除了 `chartRef` ref、模板上的 `ref="chartRef"`、`QiunDataCharts` 显式 import 也保留作为 easycom 兜底
|
||
|
||
### 变更 2:fake event 坐标除以 `opts.pix`(像素比双倍缩放修复)
|
||
|
||
**问题**:v1 设计的 fake event 直接传 `calPoints[0][last].x, .y`。但 `calPoints` 已是 canvas 像素(u-charts 内部 `opts.area = padding * opts.pix`),而 `getTouches`(`u-charts.js:505-506`)会再乘一次 `opts.pix`。在 WeChat MP / retina H5(`opts.pix = 2`)上坐标会双倍缩放。
|
||
|
||
**修复**:传入 fake event 前先 `x / pix, y / pix`,让 `getTouches` 一次乘法抵消,恢复正确坐标。
|
||
|
||
此变更 v1 设计阶段未识别,由 code review subagent 在 Task 2 review 时发现。修复后 round-trip 验证:`((x / pix) * pix) = x`,对所有 `pix ≥ 1` 成立。
|
||
|
||
### 变更 3:H5 桌面端鼠标移动不切换 tooltip(双层 fallback)
|
||
|
||
**问题 1:renderjs `onmouse` 默认 undefined**。qiun-data-charts `mounted()` 第 488-490 行:
|
||
```js
|
||
if(this.inWin === true){
|
||
this.openmouse = this.onmouse;
|
||
}
|
||
```
|
||
HBuilderX H5 预览(iframe 嵌入)下 `inWin === false`,`openmouse` 一直 `undefined`,传到 `cfu.option[cid].onmouse`。`rdcharts.mouseMove` 第一行 `if(onmouse == false) return;` 因 `undefined == false` 为 true 立刻退出。
|
||
|
||
**问题 2:uni-app renderjs 不转发 `mousemove`**。即使显式设 `onmouse=true`,`rdcharts.mouseMove` 仍不会被调用 —— uni-app renderjs 在 HBuilderX 某些版本下不把 `mousemove` 事件转发到 renderjs 脚本(虽然 `tap`/`click` 是转发的)。
|
||
|
||
**修复**:放弃 renderjs 路径,直接在 IncomeCurve.vue 的 `.chart-canvas` 上挂 `@mousemove` 监听,绕开 renderjs,用 cfu.instance 直接调 `showToolTip(fakeE, {})`:
|
||
1. 60ms 节流,避免每次 mousemove 都重绘 canvas
|
||
2. `getCurrentInstance().proxy` + `uni.createSelectorQuery().select('#UC' + canvasId)` 拿 chart view 的 boundingClientRect
|
||
3. 转换 e.clientX/Y 为相对坐标,构造 fakeE
|
||
4. 不传 `option.index`,让 u-charts 内部 `getCurrentDataIndex(fakeE)` 自动找最近数据点
|
||
5. 边界守卫:localX/Y 超出 rect 范围直接 return
|
||
|
||
### 变更 4:`tooltipCustom` 是 Object prop 不是 Function prop,必须改用 `showCategory` + `cfu.formatter`
|
||
|
||
**问题**:用户反馈"初始显示的数据需要加上日期" — 我们用 `tooltipCustom: (opts, cats, idx) => { return { textList: [...] } }` 期望函数被调用,但 tooltip 一直只显示 u-charts 默认的 "收益: 1234",没有日期也没有 + 号。
|
||
|
||
**根因**:qiun-data-charts 的 `tooltipCustom` prop 类型声明是 `Object`(`type: Object, default: undefined`),不是 `Function`。chiart 内部 `_showTooltip` / renderjs `showTooltip` 的代码是:
|
||
```js
|
||
let tc = cfu.option[cid].tooltipCustom
|
||
if (tc && tc !== undefined && tc !== null) {
|
||
...
|
||
cfu.instance[cid].showToolTip(e, {
|
||
index: tc.index, // 函数没有 .index → undefined
|
||
offset: offset, // tc.x >= 0 永远 false(undefined >= 0)
|
||
textList: tc.textList, // 函数没有 .textList → undefined
|
||
formatter: ... // 用 chart 默认的 _tooltipDefault
|
||
});
|
||
}
|
||
```
|
||
函数也是 Object 所以 `if (tc && tc !== undefined && tc !== null)` 判定通过,但 `tc.index`、`tc.textList` 都是 `undefined`,最后 `formatter` 是默认的 `_tooltipDefault`,返回 `item.name + ': ' + item.data`。
|
||
|
||
**修复**:
|
||
1. **`extra.tooltip.showCategory: true`**:u-charts 会把 `opts.categories[index]` 作为 textList 第一行 unshift 进去(u-charts.js:2806-2808)。我们的 categories 已经是 `["06-02", ..., "06-08"]`,所以日期会自动出现。
|
||
2. **`extra.tooltip.fontColor: "#999999"`**:第一行(日期)color 为 null,会 fallback 到 fontColor;灰色。
|
||
3. **`cfu.formatter.incomeFormatter`**:注册自定义 formatter 返回 `+${item.data}`,让第二行(收入)显示成 `+1234`。
|
||
4. **`:tooltipFormat="'incomeFormatter'"`**:让 chart 组件的 `_showTooltip` / renderjs `showTooltip` 用我们注册的 formatter。
|
||
5. **直接 `showToolTip` 调用也传 `formatter: incomeFormatter`**:确保默认 tooltip(onChartComplete)和鼠标移动(onCanvasMouseMove)也用同一格式。
|
||
6. **删掉原 `extra.tooltip.tooltipCustom`**:彻底无效,留着误导后人。
|
||
|
||
效果:
|
||
- Line 1: "06-08" — 灰色(fontColor)
|
||
- Line 2: "+1234" — 蓝色(series.color,不受 fontColor 影响)
|
||
- 默认显示、点击切换、鼠标移动三种触发方式格式一致
|
||
|
||
**适用范围**:
|
||
- H5 桌面浏览器:✅ 工作(mousemove 事件正常)
|
||
- H5 移动浏览器:mousemove 不触发,依赖 chart 内部 touchMove(已有 `:ontouch=true`)
|
||
- 微信小程序:依赖 chart 内部 touchMove(已有 `:ontouch=true`)
|
||
- App:依赖 chart 内部 touchMove(已有 `:ontouch=true`)
|
||
|
||
非 H5 平台 `@mousemove` 是 no-op,handler 不会被调用。
|