diff --git a/frontend/src/app/changelog/page.tsx b/frontend/src/app/changelog/page.tsx
index b50c301..30f3084 100644
--- a/frontend/src/app/changelog/page.tsx
+++ b/frontend/src/app/changelog/page.tsx
@@ -30,6 +30,8 @@ const LOGS = [
"新增饮食饮水注意事项",
"修复生产环境 CSS 解析失败导致的页面异常",
"部署改为原生 systemd(非 Docker 优先),增量构建更快",
+ "首页视口内按需挂载下方模块,加快首屏;粒子动画减负;Loader 更快消失",
+ "专注时钟增加累计专注块统计",
],
},
{
diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css
index a54b8b1..feb56a9 100644
--- a/frontend/src/app/globals.css
+++ b/frontend/src/app/globals.css
@@ -1169,6 +1169,49 @@ img { max-width: 100%; display: block; }
transform: translateY(0);
}
+.section {
+ content-visibility: auto;
+ contain-intrinsic-size: auto 480px;
+}
+
+.deferred-skeleton {
+ height: 120px;
+ margin: 24px auto;
+ max-width: 1200px;
+ border-radius: var(--radius-xl);
+ background: linear-gradient(90deg, rgba(255,255,255,0.03), rgba(255,255,255,0.07), rgba(255,255,255,0.03));
+ background-size: 200% 100%;
+ animation: deferredShimmer 1.2s ease-in-out infinite;
+}
+
+@keyframes deferredShimmer {
+ 0% { background-position: 100% 0; }
+ 100% { background-position: -100% 0; }
+}
+
+.focus-streak {
+ text-align: center;
+ color: var(--text-secondary);
+ font-size: 0.9rem;
+ margin: -4px 0 12px;
+}
+
+.focus-streak b {
+ color: var(--accent-3);
+ font-size: 1.1rem;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .reveal {
+ opacity: 1;
+ transform: none;
+ transition: none;
+ }
+ .deferred-skeleton { animation: none; }
+ .loader-ring,
+ .loader-progress { animation: none !important; }
+}
+
/* ===== Responsive ===== */
@media (max-width: 1024px) {
.hero-top {
diff --git a/frontend/src/components/FocusTimer.tsx b/frontend/src/components/FocusTimer.tsx
index d13c713..c3d5275 100644
--- a/frontend/src/components/FocusTimer.tsx
+++ b/frontend/src/components/FocusTimer.tsx
@@ -3,18 +3,32 @@
import { useEffect, useState } from "react";
const PRESETS = [25, 45, 60];
+const STORAGE_KEY = "nomadro-focus-streak";
export default function FocusTimer() {
const [minutes, setMinutes] = useState(25);
const [secondsLeft, setSecondsLeft] = useState(25 * 60);
const [running, setRunning] = useState(false);
const [done, setDone] = useState(false);
+ const [sessions, setSessions] = useState(0);
+
+ useEffect(() => {
+ try {
+ const n = Number(localStorage.getItem(STORAGE_KEY) || "0");
+ if (!Number.isNaN(n)) setSessions(n);
+ } catch { /* ignore */ }
+ }, []);
useEffect(() => {
if (!running) return;
if (secondsLeft <= 0) {
setRunning(false);
setDone(true);
+ setSessions((prev) => {
+ const next = prev + 1;
+ try { localStorage.setItem(STORAGE_KEY, String(next)); } catch { /* ignore */ }
+ return next;
+ });
return;
}
const t = setTimeout(() => setSecondsLeft((s) => s - 1), 1000);
@@ -55,6 +69,10 @@ export default function FocusTimer() {
+
+ 今日累计专注块 {sessions}
+
+
{PRESETS.map((m) => (
- {done && 🎉 完成一个专注块,起来走走吧
}
+ {done && 完成一个专注块,起来走走吧
}
diff --git a/frontend/src/components/HomeClient.tsx b/frontend/src/components/HomeClient.tsx
index 330ac2d..05ca004 100644
--- a/frontend/src/components/HomeClient.tsx
+++ b/frontend/src/components/HomeClient.tsx
@@ -28,6 +28,7 @@ import BackToTop from "./BackToTop";
import TripPlanner from "./TripPlanner";
import Calculator from "./Calculator";
import SpinGlobe from "./SpinGlobe";
+import WhenVisible from "./WhenVisible";
const lazy = (loader: () => Promise<{ default: ComponentType
}>) =>
dynamic(loader, { ssr: false, loading: () => null });
@@ -167,49 +168,49 @@ export default function HomeClient(props: Props) {
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/Loader.tsx b/frontend/src/components/Loader.tsx
index b22f685..0da8ec9 100644
--- a/frontend/src/components/Loader.tsx
+++ b/frontend/src/components/Loader.tsx
@@ -4,22 +4,35 @@ import { useEffect, useState } from "react";
export default function Loader() {
const [done, setDone] = useState(false);
+ const [gone, setGone] = useState(false);
useEffect(() => {
const hide = () => setDone(true);
- const onLoad = () => setTimeout(hide, 900);
+ const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
+ const delay = reduced ? 0 : 380;
+
if (document.readyState === "complete") {
- const t = setTimeout(hide, 900);
+ const t = setTimeout(hide, delay);
return () => clearTimeout(t);
}
+
+ const onLoad = () => setTimeout(hide, delay);
window.addEventListener("load", onLoad);
- const fallback = setTimeout(hide, 2200);
+ const fallback = setTimeout(hide, reduced ? 200 : 1200);
return () => {
window.removeEventListener("load", onLoad);
clearTimeout(fallback);
};
}, []);
+ useEffect(() => {
+ if (!done) return;
+ const t = setTimeout(() => setGone(true), 500);
+ return () => clearTimeout(t);
+ }, [done]);
+
+ if (gone) return null;
+
return (
diff --git a/frontend/src/components/ParticleCanvas.tsx b/frontend/src/components/ParticleCanvas.tsx
index a7065fd..a516cf4 100644
--- a/frontend/src/components/ParticleCanvas.tsx
+++ b/frontend/src/components/ParticleCanvas.tsx
@@ -11,7 +11,14 @@ export default function ParticleCanvas() {
const ctx = canvas.getContext("2d");
if (!ctx) return;
- let animId: number;
+ const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
+ if (reduced) {
+ canvas.style.display = "none";
+ return;
+ }
+
+ let animId = 0;
+ let running = true;
let particles: {
x: number; y: number; size: number;
speedX: number; speedY: number; opacity: number; hue: number;
@@ -23,20 +30,27 @@ export default function ParticleCanvas() {
};
const create = () => {
- const count = Math.min(Math.floor(window.innerWidth / 12), 120);
+ const count = Math.min(Math.floor(window.innerWidth / 28), 48);
particles = Array.from({ length: count }, () => ({
x: Math.random() * canvas.width,
y: Math.random() * canvas.height,
size: Math.random() * 2 + 0.5,
- speedX: (Math.random() - 0.5) * 0.3,
- speedY: (Math.random() - 0.5) * 0.3,
- opacity: Math.random() * 0.5 + 0.1,
+ speedX: (Math.random() - 0.5) * 0.25,
+ speedY: (Math.random() - 0.5) * 0.25,
+ opacity: Math.random() * 0.4 + 0.1,
hue: [0, 45, 170, 280][Math.floor(Math.random() * 4)],
}));
};
+ const onResize = () => {
+ resize();
+ create();
+ };
+
const draw = () => {
+ if (!running) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
+ const linkDist = window.innerWidth < 768 ? 0 : 90;
particles.forEach((p, i) => {
p.x += p.speedX;
p.y += p.speedY;
@@ -50,28 +64,42 @@ export default function ParticleCanvas() {
ctx.fillStyle = `hsla(${p.hue}, 70%, 60%, ${p.opacity})`;
ctx.fill();
- particles.slice(i + 1).forEach((p2) => {
- const dx = p.x - p2.x;
- const dy = p.y - p2.y;
- const dist = Math.sqrt(dx * dx + dy * dy);
- if (dist < 120) {
- ctx.beginPath();
- ctx.moveTo(p.x, p.y);
- ctx.lineTo(p2.x, p2.y);
- ctx.strokeStyle = `hsla(${p.hue}, 70%, 60%, ${0.08 * (1 - dist / 120)})`;
- ctx.lineWidth = 0.5;
- ctx.stroke();
+ if (linkDist > 0) {
+ for (let j = i + 1; j < particles.length; j++) {
+ const p2 = particles[j];
+ const dx = p.x - p2.x;
+ const dy = p.y - p2.y;
+ const dist = Math.hypot(dx, dy);
+ if (dist < linkDist) {
+ ctx.beginPath();
+ ctx.moveTo(p.x, p.y);
+ ctx.lineTo(p2.x, p2.y);
+ ctx.strokeStyle = `hsla(${p.hue}, 70%, 60%, ${0.06 * (1 - dist / linkDist)})`;
+ ctx.lineWidth = 0.5;
+ ctx.stroke();
+ }
}
- });
+ }
});
animId = requestAnimationFrame(draw);
};
+ const onVis = () => {
+ running = document.visibilityState === "visible";
+ if (running) draw();
+ else cancelAnimationFrame(animId);
+ };
+
resize();
create();
draw();
- window.addEventListener("resize", () => { resize(); create(); });
- return () => { cancelAnimationFrame(animId); };
+ window.addEventListener("resize", onResize);
+ document.addEventListener("visibilitychange", onVis);
+ return () => {
+ cancelAnimationFrame(animId);
+ window.removeEventListener("resize", onResize);
+ document.removeEventListener("visibilitychange", onVis);
+ };
}, []);
return
;
diff --git a/frontend/src/components/WhenVisible.tsx b/frontend/src/components/WhenVisible.tsx
new file mode 100644
index 0000000..b0c97c2
--- /dev/null
+++ b/frontend/src/components/WhenVisible.tsx
@@ -0,0 +1,43 @@
+"use client";
+
+import { useEffect, useRef, useState, type ReactNode } from "react";
+
+/** Mount children only when near the viewport — defers heavy below-fold chunks. */
+export default function WhenVisible({
+ children,
+ rootMargin = "640px 0px",
+ minHeight = 160,
+}: {
+ children: ReactNode;
+ rootMargin?: string;
+ minHeight?: number;
+}) {
+ const ref = useRef
(null);
+ const [show, setShow] = useState(false);
+
+ useEffect(() => {
+ const el = ref.current;
+ if (!el || show) return;
+ if (typeof IntersectionObserver === "undefined") {
+ setShow(true);
+ return;
+ }
+ const io = new IntersectionObserver(
+ ([entry]) => {
+ if (entry.isIntersecting) {
+ setShow(true);
+ io.disconnect();
+ }
+ },
+ { rootMargin, threshold: 0.01 }
+ );
+ io.observe(el);
+ return () => io.disconnect();
+ }, [rootMargin, show]);
+
+ return (
+
+ );
+}