Skip to content

Commit ec791ba

Browse files
sunnylqmclaude
andcommitted
feat(home): LiveStats 流量曲线 + 数位滚轮,Bento 卡片鼠标光斑
- LiveStats:接口新字段 today.hourly 渲染为渐变面积曲线(Catmull-Rom 平滑,截掉当前小时避免尾部跳水),字段缺失时整体隐藏兼容旧接口 - LIVE 大数字改为 odometer 数位滚轮,列 key 自个位向高位编号保证低位 连续滚动;prefers-reduced-motion 下禁用过渡 - Page1 六张卡片增加鼠标跟随光斑(容器级 onMouseMove 写入 CSS 变量) - 计划与进度见 home-visual-plan-2026-07-05.md Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112yBeEEYT8tyywNbypwXkN
1 parent 2d4ffdb commit ec791ba

4 files changed

Lines changed: 271 additions & 9 deletions

File tree

home-visual-plan-2026-07-05.md

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# 首页视觉增强计划(2026-07-05)
2+
3+
目标:在现有暗色首页基础上,用低成本改动继续提升「实时感」与质感。
4+
本轮做 1–3,4–5 为备选。边做边更新状态。
5+
6+
状态图例:⬜ 未开始 · 🔨 进行中 · ✅ 完成 · ⏸ 暂缓
7+
8+
## 1. ✅ LiveStats 增加今日 24 小时流量曲线
9+
10+
服务端 `computePublicMetricsSummary` 已逐小时读取当天全部 bucket,只需
11+
在返回中补 `today.hourly: number[]`(0 点起每小时的检查次数,长度 =
12+
当前小时 + 1)。前端在大数字下方渲染渐变填充的内联 SVG 面积曲线,
13+
尾端当前小时点加发光标记。
14+
15+
- [x] pushy-server:`metricsSummary.ts` 返回 `today.hourly` + 测试(master `3e5c715`,4/4 测试、lint 通过)
16+
- [x] cresc-server:同步同样改动(main `416c225`
17+
- [x] 前端 `LiveStats.tsx`:SVG 面积曲线组件(Catmull-Rom 平滑)
18+
- [x] 兼容:线上接口尚未部署新字段时曲线整体隐藏,不影响其余内容(真实页已验证 hasTrend=false 且无报错)
19+
20+
## 2. ✅ Bento 卡片鼠标跟随光斑(spotlight hover)
21+
22+
`Page1.tsx` 六张卡:mousemove 把相对坐标写入 `--spot-x/--spot-y`
23+
卡片内加一层 `radial-gradient(240px at var(--spot-x) var(--spot-y), …)`
24+
的覆盖层,hover 淡入。触屏设备无 hover 自动无效,无需特判。
25+
26+
- [x] Page1 卡片接入 spotlight(grid 容器上单个 onMouseMove 委托)
27+
- [x] home.scss 增加 `.pushy-spotlight` 样式
28+
29+
## 3. ✅ LIVE 大数字改为数位滚轮(odometer)
30+
31+
每个数字位是一列 0–9 的竖向数字带,通过 `translateY(-N em)` +
32+
transition 滚动到目标数字;列 key 从个位向高位编号,保证低位稳定。
33+
逗号原样渲染。`prefers-reduced-motion` 下关闭过渡。
34+
35+
- [x] `LiveStats.tsx` 内实现 `RollingNumber` 组件替换纯文本
36+
- [x] home.scss 增加 odometer 样式 + reduced-motion 分支
37+
38+
## 4. ⏸(备选)「极速下载」卡片补中国点阵地图 + CDN 节点脉冲
39+
40+
点阵 SVG 中国轮廓 + 主要城市呼吸光点,为后续地理热力图做铺垫。
41+
等第 2 步地理数据方案启动时一起做。
42+
43+
## 5. ⏸(备选)CTA 卡片背景复用弱化 aurora + hero 加「HarmonyOS 已支持」
44+
45+
顺手项,本轮时间富余则做。
46+
47+
## 验收
48+
49+
- [x] `bun run build` 通过
50+
- [x] pushy-server / cresc-server 测试与 lint 通过(各 4/4)
51+
- [x] 无头浏览器截图核对 1–3 的实际效果(mock 曲线/滚轮/光斑 OK;真实页滚轮 OK、曲线待服务端部署后自动出现)
52+
53+
## 进度日志
54+
55+
- 2026-07-05 计划创建。
56+
- 2026-07-05 任务 1 服务端完成:两仓库均已提交(未推送/未部署),
57+
曲线末位为当前小时的部分数据,前端绘制时截掉末位避免「跳水」观感。
58+
- 2026-07-05 任务 1–3 前端完成并验证:mock 页曲线 + 滚轮 + 光斑正常,
59+
计数器 3 秒内 8,412,855 → 8,413,196 平滑滚动;真实页优雅降级。
60+
待办:部署 pushy-server(3e5c715)/ cresc-server(416c225)后曲线自动出现。

site/components/home/LiveStats.tsx

Lines changed: 132 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,12 @@ const POLL_INTERVAL_MS = 60_000;
1414

1515
interface Summary {
1616
updatedAt: string;
17-
today: { checks: number; os: Record<string, number> };
17+
today: {
18+
checks: number;
19+
os: Record<string, number>;
20+
/** Checks per hour since midnight; last entry = current partial hour. */
21+
hourly?: number[];
22+
};
1823
lastHour: { checks: number; devices: number };
1924
currentHour: { checks: number; devices: number };
2025
totals: { apps: number; developers: number; hotUpdateVersions: number };
@@ -35,6 +40,11 @@ const MOCK_SUMMARY: Summary = {
3540
today: {
3641
checks: 8_412_530,
3742
os: { ios: 3_182_400, android: 4_930_130, harmony: 300_000 },
43+
hourly: [
44+
180_000, 95_000, 60_000, 48_000, 52_000, 88_000, 210_000, 380_000,
45+
520_000, 610_000, 660_000, 690_000, 705_000, 640_000, 600_000, 630_000,
46+
655_000, 480_000,
47+
],
3848
},
3949
lastHour: { checks: 421_800, devices: 262_140 },
4050
currentHour: { checks: 208_000, devices: 152_000 },
@@ -138,6 +148,123 @@ function formatInt(value: number) {
138148
return Math.floor(value).toLocaleString("zh-CN");
139149
}
140150

151+
const DIGITS = "0123456789";
152+
153+
/**
154+
* Odometer-style number: each digit is a vertical reel of 0-9 rolled to the
155+
* target via translateY. Columns are keyed from the units digit up, so low
156+
* digits keep their identity (and animate) as the number grows.
157+
*/
158+
function RollingNumber({ value }: { value: number }) {
159+
const text = formatInt(value);
160+
const chars = Array.from(text);
161+
return (
162+
<span className="pushy-odometer" aria-label={text}>
163+
{chars.map((char, index) => {
164+
const key = chars.length - index;
165+
if (char < "0" || char > "9") {
166+
return (
167+
<span key={`sep-${key}`} className="pushy-odometer__sep">
168+
{char}
169+
</span>
170+
);
171+
}
172+
return (
173+
<span key={`digit-${key}`} className="pushy-odometer__digit">
174+
<span
175+
className="pushy-odometer__reel"
176+
style={{ transform: `translateY(-${Number(char)}em)` }}
177+
>
178+
{DIGITS.split("").map((digit) => (
179+
<span key={digit}>{digit}</span>
180+
))}
181+
</span>
182+
</span>
183+
);
184+
})}
185+
</span>
186+
);
187+
}
188+
189+
const TREND_W = 100;
190+
const TREND_H = 32;
191+
192+
/** Catmull-Rom spline through the points, as an SVG cubic-bezier path. */
193+
function smoothPath(points: Array<[number, number]>) {
194+
let d = `M ${points[0][0]} ${points[0][1]}`;
195+
for (let i = 0; i < points.length - 1; i++) {
196+
const p0 = points[Math.max(0, i - 1)];
197+
const p1 = points[i];
198+
const p2 = points[i + 1];
199+
const p3 = points[Math.min(points.length - 1, i + 2)];
200+
const c1x = p1[0] + (p2[0] - p0[0]) / 6;
201+
const c1y = p1[1] + (p2[1] - p0[1]) / 6;
202+
const c2x = p2[0] - (p3[0] - p1[0]) / 6;
203+
const c2y = p2[1] - (p3[1] - p1[1]) / 6;
204+
d += ` C ${c1x} ${c1y}, ${c2x} ${c2y}, ${p2[0]} ${p2[1]}`;
205+
}
206+
return d;
207+
}
208+
209+
function TrendLine({ hourly }: { hourly: number[] }) {
210+
// Drop the current partial hour so the curve doesn't nosedive at the end.
211+
const series = hourly.slice(0, -1);
212+
if (series.length < 3) {
213+
return null;
214+
}
215+
const max = Math.max(...series);
216+
if (max <= 0) {
217+
return null;
218+
}
219+
220+
const points = series.map((count, index): [number, number] => [
221+
(index / (series.length - 1)) * TREND_W,
222+
TREND_H - 3 - (count / max) * (TREND_H - 8),
223+
]);
224+
const line = smoothPath(points);
225+
const [endX, endY] = points[points.length - 1];
226+
227+
return (
228+
<div className="relative mt-7 max-w-md">
229+
<svg
230+
viewBox={`0 0 ${TREND_W} ${TREND_H}`}
231+
preserveAspectRatio="none"
232+
className="block w-full h-14"
233+
aria-hidden="true"
234+
>
235+
<defs>
236+
<linearGradient id="pushy-trend-fill" x1="0" y1="0" x2="0" y2="1">
237+
<stop offset="0%" stopColor="#38bdf8" stopOpacity="0.26" />
238+
<stop offset="100%" stopColor="#38bdf8" stopOpacity="0" />
239+
</linearGradient>
240+
</defs>
241+
<path
242+
d={`${line} L ${TREND_W} ${TREND_H} L 0 ${TREND_H} Z`}
243+
fill="url(#pushy-trend-fill)"
244+
/>
245+
<path
246+
d={line}
247+
fill="none"
248+
stroke="#38bdf8"
249+
strokeOpacity="0.85"
250+
strokeWidth="1.5"
251+
vectorEffect="non-scaling-stroke"
252+
strokeLinecap="round"
253+
strokeLinejoin="round"
254+
/>
255+
</svg>
256+
<span
257+
className="pushy-live-dot absolute w-1.5 h-1.5 rounded-full bg-sky-300 -translate-x-1/2 -translate-y-1/2"
258+
style={{ left: `${endX}%`, top: `${(endY / TREND_H) * 100}%` }}
259+
aria-hidden="true"
260+
/>
261+
<span className="mt-1.5 block text-xs text-slate-500">
262+
今日每小时更新检查
263+
</span>
264+
</div>
265+
);
266+
}
267+
141268
const PLATFORM_META: Array<{ key: string; label: string; color: string }> = [
142269
{ key: "android", label: "Android", color: "#34d399" },
143270
{ key: "ios", label: "iOS", color: "#38bdf8" },
@@ -230,14 +357,17 @@ function LiveStats() {
230357
</span>
231358
</div>
232359
<div className="font-mono text-4xl sm:text-5xl lg:text-[3.4rem] font-bold tracking-tight text-slate-50 tabular-nums">
233-
{formatInt(tickingChecks)}
360+
<RollingNumber value={tickingChecks} />
234361
<span className="ml-3 text-base font-sans font-medium text-slate-500">
235362
236363
</span>
237364
</div>
238365
<div className="mt-5 max-w-sm">
239366
<PlatformBar os={summary.today.os} />
240367
</div>
368+
{Array.isArray(summary.today.hourly) && (
369+
<TrendLine hourly={summary.today.hourly} />
370+
)}
241371
</div>
242372

243373
{/* Side stats */}

site/components/home/Page1.tsx

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import type { CSSProperties, ReactNode } from "react";
1+
import type {
2+
CSSProperties,
3+
MouseEvent as ReactMouseEvent,
4+
ReactNode,
5+
} from "react";
26

37
interface FeatureIconProps {
48
children: ReactNode;
@@ -27,15 +31,31 @@ function FeatureIcon({ children, tint }: FeatureIconProps) {
2731
}
2832

2933
const cardBase =
30-
"group relative rounded-3xl border border-white/[0.07] bg-white/[0.025] backdrop-blur-sm p-8 overflow-hidden transition-all duration-300 hover:border-white/[0.16] hover:bg-white/[0.045] hover:-translate-y-1";
34+
"pushy-spot group relative rounded-3xl border border-white/[0.07] bg-white/[0.025] backdrop-blur-sm p-8 overflow-hidden transition-all duration-300 hover:border-white/[0.16] hover:bg-white/[0.045] hover:-translate-y-1";
3135

3236
const cardGlow = (
33-
<div
34-
className="absolute -top-24 -right-24 w-56 h-56 rounded-full bg-blue-500/[0.07] blur-3xl opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none"
35-
aria-hidden="true"
36-
/>
37+
<>
38+
<div
39+
className="absolute -top-24 -right-24 w-56 h-56 rounded-full bg-blue-500/[0.07] blur-3xl opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none"
40+
aria-hidden="true"
41+
/>
42+
<div className="pushy-spotlight" aria-hidden="true" />
43+
</>
3744
);
3845

46+
/** Track the cursor inside the hovered card for the spotlight gradient. */
47+
function trackSpotlight(event: ReactMouseEvent<HTMLDivElement>) {
48+
const card = (event.target as HTMLElement).closest<HTMLElement>(
49+
".pushy-spot",
50+
);
51+
if (!card) {
52+
return;
53+
}
54+
const rect = card.getBoundingClientRect();
55+
card.style.setProperty("--spot-x", `${event.clientX - rect.left}px`);
56+
card.style.setProperty("--spot-y", `${event.clientY - rect.top}px`);
57+
}
58+
3959
function Page1() {
4060
return (
4161
<section className="relative py-28 overflow-hidden">
@@ -73,7 +93,7 @@ function Page1() {
7393
</p>
7494
</div>
7595

76-
<div className="grid gap-5 lg:grid-cols-3">
96+
<div className="grid gap-5 lg:grid-cols-3" onMouseMove={trackSpotlight}>
7797
{/* 1 — 增量更新(大卡,含差量对比可视化) */}
7898
<div data-reveal className={`${cardBase} lg:col-span-2`}>
7999
{cardGlow}

site/components/home/home.scss

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,53 @@ body:has(.home-wrapper) {
364364
}
365365
}
366366

367+
/* ---------- Card spotlight (cursor-following glow) ---------- */
368+
369+
.pushy-spotlight {
370+
position: absolute;
371+
inset: 0;
372+
pointer-events: none;
373+
opacity: 0;
374+
transition: opacity 0.4s ease;
375+
background: radial-gradient(
376+
260px circle at var(--spot-x, 50%) var(--spot-y, 50%),
377+
rgba(56, 189, 248, 0.1),
378+
transparent 65%
379+
);
380+
}
381+
382+
.pushy-spot:hover .pushy-spotlight {
383+
opacity: 1;
384+
}
385+
386+
/* ---------- Odometer (rolling digits) ---------- */
387+
388+
.pushy-odometer {
389+
display: inline-flex;
390+
overflow: hidden;
391+
height: 1em;
392+
line-height: 1;
393+
vertical-align: bottom;
394+
}
395+
396+
.pushy-odometer__sep,
397+
.pushy-odometer__digit {
398+
display: block;
399+
height: 1em;
400+
}
401+
402+
.pushy-odometer__reel {
403+
display: block;
404+
transition: transform 0.9s cubic-bezier(0.22, 1, 0.36, 1);
405+
will-change: transform;
406+
407+
span {
408+
display: block;
409+
height: 1em;
410+
line-height: 1;
411+
}
412+
}
413+
367414
/* ---------- Logo marquee ---------- */
368415

369416
.pushy-marquee {
@@ -491,4 +538,9 @@ body:has(.home-wrapper) {
491538
.pushy-progress::after {
492539
transform: scaleX(1);
493540
}
541+
542+
.pushy-odometer__reel,
543+
.pushy-spotlight {
544+
transition: none !important;
545+
}
494546
}

0 commit comments

Comments
 (0)