CC Switch 的跨年折线图为什么会错位
用量看板里那张折线图,在选到跨年的时间区间时会出一个很别扭的问题。docs/release-notes/v3.20.0-zh.md:197 记录的症状是:跨多年的区间上,被高亮的点属于一个年份,提示框描述的却是另一个年份。同一节还专门补了一句——绘制出来的点本身从来没错,错的只是「高亮」与「提示」这两件事的配对。
这句补充其实是整条线索的关键。如果连点都画错了,那是数据层的问题;点是对的而配对错了,说明问题出在图表库用来认点的那把「钥匙」上。
先说清这篇的依据
下面所有结论都来自静态阅读源码:仓库快照对应 3217f725(仓库内版本号 3.20.1),核对日 2026-08-31,同时拿上一版 v3.19.2 的工作区做逐行 diff。我们没有安装、没有编译、也没有运行过这个桌面应用,所以不会出现任何关于界面长相、操作手感或者刷新快慢的描述。凡是涉及「用户看到什么」的部分,一律标明出自发布说明或提交说明的原话,而不是我们自己看到的。
根因:拿展示文本当唯一键
v3.19.2 时,src/components/usage/UsageTrendChart.tsx 的图表数据还是在组件体里现算的,X 轴直接以那个本地化的 MM/DD 文本字段为分类键。也就是说,用来给图表库区分「这是哪个点」的东西,和显示在刻度上给人读的东西,是同一个字符串。
问题到这里就闭合了。MM/DD 里没有年份,跨年区间上「去年 4 月 27 日」和「今年 4 月 27 日」会格式化出同一个字符串。图表库拿这个字符串当分类值,两个桶就成了同一类。后果是:图表库的活动点查找返回第一个匹配,于是高亮吸到早年份那个点上,而提示框是跟着光标走的,两者自然对不上。
值得注意的是,这个 bug 在单年区间里完全不会现身。只要选中的范围没跨年,MM/DD 就是唯一的,键撞不上。这也是它能存活到跨年时刻才被发现的原因。
v3.20.1 的改法:键、刻度、提示三件事拆开
v3.20.1 把这段计算从组件体里提了出来,变成一个导出的纯函数 buildUsageTrendChartData(src/components/usage/UsageTrendChart.tsx:59),函数上方注释直接写了 Exported for unit tests。改完之后每个数据点带三个与文本有关的字段,各管各的:
| 字段 | 用途 | 取值方式 |
|---|---|---|
xKey | Recharts 的分类键 | stat.date,后端返回的桶时间戳原串 |
label | X 轴刻度文案 | 跨年时带 2 位年份,不跨年只有 MM/DD |
tooltipLabel | 提示框标题 | 永远带 4 位年份 |
三者的分工在类型定义上就被钉死了。xKey 那一行(同一文件 :44)的注释原文是 Unique category key for Recharts — must not collide across years.——这句话本身就是修复留下的护栏,后来人想把它换回格式化文本,得先跨过这条注释。
具体到实现:
:81一句const xKey = stat.date;,直接用后端返回的原串,不做任何格式化。上方注释(:78-80)把理由写清楚了:后端返回的稀疏点即使在不同年份共享同一个本地 MM/DD,用原串当分类键仍然唯一。spansMultipleYears = startYear !== endYear(:72附近),由区间的起止年份算出。只有它为真时,刻度标签才补上year: "2-digit";单年区间的刻度形态与改动前一致,不受影响。tooltipLabel无条件带year: "numeric"。也就是说提示框里永远能看出是哪一年的 4 月 27 日,不管区间跨不跨年。- 渲染侧
:257的dataKey从"label"换成了"xKey",并配了allowDuplicatedCategory={false};:183附近,提示框标题改成从 point payload 里读point?.tooltipLabel ?? point?.label ?? "",不再依赖图表库回传的label参数。
一个容易被忽略的连带修正
X 轴现在以时间戳原串为键,可键是给机器看的,刻度上不能直接显示这么一长串。所以还需要一个从键反查文案的函数:formatUsageTrendTickLabel(xKey, chartData)(:130),实现是用 find 匹配 row.xKey(:134),反查出这一行对应的刻度文案。
这个函数的注释里有一句相当有价值的提醒:not by filtered tick index。意思是不能按刻度下标去数组里取——图表库在刻度太密时会抽稀,抽稀之后的下标和原数组的下标不是一回事,按下标查会得到另一条记录,等于把刚修好的错位换个地方再犯一遍。
另一处连带改动更隐蔽:v3.19.2 里 durationSeconds 与 chartData 的计算写在 if (isLoading) 早退之后,v3.20.1 把它包进 useMemo 并挪到了早退之前。这不是风格偏好,是 React Hooks 的硬性规则——hook 不能出现在条件早退之后。原来那段是普通计算所以无所谓,一旦要用 useMemo 缓存,位置就必须往上提。
这个修复是怎么被锁住的
回归测试落在 tests/components/UsageTrendChart.test.ts,describe 的名字直接带上了 issue 编号(describe("buildUsageTrendChartData (#6302)"))。v3.20.1 的这个 describe 下有三个用例,分别守住三件事:跨年出现同一个 MM/DD 时两个点的 xKey 必须不相等、跨年区间的刻度标签要带年份、单年区间的刻度要保持短的 MM/DD 形态。另有一个 formatUsageTrendTickLabel 的用例,专门测「刻度被抽稀时仍然按键解析」。
把纯函数从组件里抠出来,正是为了让这些断言不必渲染任何 DOM 就能跑。这也是为什么函数上方那句 Exported for unit tests 不算多余注释——它解释了这个导出存在的唯一理由。
怎么确认你遇到的是这一类问题
如果你在别的项目里见到类似的「高亮和提示对不上」,可以按这个顺序判断,判定动作都不需要跑起来:
- 看 X 轴的
dataKey指向哪个字段。 如果它指向一个经过toLocaleDateString/toLocaleString之类格式化出来的字段,基本就是这一类。 - 看这个格式化里有没有丢掉区分度。 MM/DD 丢了年份、HH:mm 丢了日期,都属于「同一份数据在不同时刻会输出同一个串」。
- 换一个区间试判定:这类 bug 只在会撞键的区间里出现。跨年区间出问题、单年区间正常,指向就很明确了。
- 验证方式:改完之后,检查是否存在一个与展示无关的稳定键(时间戳、ID),以及刻度文案是不是按键反查、而不是按下标取。
什么情况说明不是这个原因:如果绘制出来的点位置本身就不对、或者数据缺了若干天,那问题在数据层而不是键的选择上——v3.20.0-zh.md:197 特意写明「绘制的点本身从来没错」,就是在把这两类问题划开。用量看板的数据有两条来源、写进同一张表,那条链路我们在《CC Switch 的用量数字从哪来》里单独讲过,属于另一个排查方向。同样,如果单年区间也错位,那也不是撞键,得往别处找。
一句可以带走的判据
这条 bug 的形状,跟「拿格式化后的字符串当数据库主键」是同一类错误:展示文本是给人看的,它的设计目标是好读,不是唯一。 一旦把它拿去承担标识职责,就等于把「好读」和「唯一」两个互相冲突的要求压在一个字段上,迟早会在某个边界上崩掉——在这里,那个边界就是跨年。
v3.20.1 的解法也没什么花哨,就是把一个字段拆成三个,各自只承担一件事。代价是数据点多带两个字符串,收益是键的唯一性不再依赖格式化函数的行为。
本文依据 CC Switch 官方仓库(github.com/farion1231/cc-switch)的 README、docs/ 下的用户手册、
路由指南与发布说明,以及 src/、src-tauri/、tests/ 的源码整理,
核对日 2026-08-31,对应仓库快照 3217f725(仓库内版本号 3.20.1)。
本文内容为仓库源码与文档口径,我们没有安装或运行过这个桌面应用,
因此不涉及界面外观、操作手感与切换速度的任何描述。
文中出现的阈值与默认值均为源码中的默认配置,不构成对实际运行结果的保证。
该项目仍在快速迭代,版本与默认值随时可能变动,请以仓库最新内容为准。
留言讨论
评论发布后会被人工复核,违规内容将被删除。
如果发表没有反应,可以前往联系我们告诉我们。