topfans/docs/superpowers/specs/2026-06-08-income-curve-default-tooltip-design.md
2026-06-09 00:37:42 +08:00

280 lines
14 KiB
Markdown
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.

# 七日收益曲线默认显示最后一条记录的提示 — 设计文档
- **日期**: 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_iddashboard 顶部下拉)
- 验证新数据加载完成后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 兜底
### 变更 2fake 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` 成立。
### 变更 3H5 桌面端鼠标移动不切换 tooltip双层 fallback
**问题 1renderjs `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 立刻退出。
**问题 2uni-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 永远 falseundefined >= 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`**:确保默认 tooltiponChartComplete和鼠标移动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-ophandler 不会被调用。