优化加载动画

This commit is contained in:
feie9456 2026-04-18 11:03:34 +08:00
parent 70d42e6792
commit 5cbb7217f0
9 changed files with 320 additions and 39 deletions

View File

@ -12,12 +12,30 @@
--copper-deep: #964722;
--olive: #76845e;
--olive-deep: #596946;
--font-body:
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
"PingFang SC",
"Hiragino Sans GB",
"Microsoft YaHei UI",
"Microsoft YaHei",
sans-serif;
--font-display:
ui-serif,
"Songti SC",
"STSong",
"SimSun",
"Times New Roman",
serif;
}
@theme inline {
--color-background: var(--paper);
--color-foreground: var(--ink);
--font-sans: var(--font-body);
--font-serif: var(--font-display);
}
* {
@ -52,6 +70,26 @@ select {
background: rgba(199, 103, 51, 0.18);
}
@keyframes route-progress-slide {
0% {
transform: translateX(-140%) scaleX(0.72);
}
55% {
transform: translateX(78%) scaleX(1);
}
100% {
transform: translateX(240%) scaleX(0.86);
}
}
.route-progress-bar {
animation: route-progress-slide 1.15s ease-in-out infinite;
transform-origin: left center;
box-shadow: 0 0 18px rgba(199, 103, 51, 0.28);
}
.paper-panel {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(255, 249, 241, 0.84)),

View File

@ -1,18 +1,7 @@
import type { Metadata, Viewport } from "next";
import { Noto_Sans_SC, Noto_Serif_SC } from "next/font/google";
import "./globals.css";
const bodyFont = Noto_Sans_SC({
variable: "--font-body",
subsets: ["latin"],
weight: ["400", "500", "700"],
});
const displayFont = Noto_Serif_SC({
variable: "--font-display",
subsets: ["latin"],
weight: ["400", "600", "700"],
});
import { NavigationProgressProvider } from "@/components/navigation-progress";
export const metadata: Metadata = {
metadataBase: new URL("https://digital-human.xn--876a.net"),
@ -58,8 +47,10 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="zh-CN" className={`${bodyFont.variable} ${displayFont.variable}`}>
<body className="min-h-screen">{children}</body>
<html lang="zh-CN">
<body className="min-h-screen">
<NavigationProgressProvider>{children}</NavigationProgressProvider>
</body>
</html>
);
}

View File

@ -1,40 +1,59 @@
import Link from "next/link";
import { PanelShell } from "@/components/panel-shell";
import { ScanClient } from "@/components/scan-client";
import { requireCaregiverSession } from "@/lib/page-auth";
import { getCaregiverDisplayName } from "@/lib/session";
export const dynamic = "force-dynamic";
export default async function ScanPage() {
const caregiver = await requireCaregiverSession("/scan");
export default function ScanPage() {
return (
<main className="mx-auto min-h-screen max-w-5xl px-4 py-8 sm:px-6 lg:px-8">
<section className="grid gap-6 lg:grid-cols-[0.82fr_1.18fr]">
<div className="rounded-[36px] border border-white/70 bg-white/78 p-6 shadow-[0_30px_70px_rgba(115,76,42,0.14)] backdrop-blur">
<PanelShell
currentPath="/scan"
title="扫码连接"
description="把镜头对准长辈手机上的二维码,识别后会自动完成连接并跳转到绑定结果。"
caregiverLabel={getCaregiverDisplayName(caregiver)}
>
<section className="grid gap-6 xl:grid-cols-[0.88fr_1.12fr]">
<article className="rounded-[32px] border border-white/75 bg-white/82 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)]">
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
</p>
<h1 className="mt-4 font-[family-name:var(--font-display)] text-4xl leading-[1.15] text-[var(--ink)]">
<h2 className="mt-2 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
</h1>
</h2>
<p className="mt-4 text-base leading-8 text-[var(--muted)]">
使
</p>
<div className="mt-6 space-y-3 rounded-[28px] bg-[var(--paper-soft)] p-5 text-sm leading-7 text-[var(--muted)]">
<div className="mt-4 space-y-3 rounded-[24px] bg-[var(--paper-soft)] p-5 text-sm leading-7 text-[var(--muted)]">
<p>1. </p>
<p>2. </p>
<p>3. </p>
</div>
<div className="mt-5 rounded-[24px] bg-[var(--paper-soft)] px-5 py-4">
<p className="text-sm font-semibold text-[var(--ink)]"></p>
<p className="mt-2 text-sm leading-7 text-[var(--muted)]">
</p>
</div>
<div className="mt-6 flex flex-wrap gap-3">
<Link
href="/devices"
className="inline-flex h-12 items-center justify-center rounded-full border border-[var(--line)] bg-white px-5 text-sm font-semibold text-[var(--ink)] transition hover:bg-[var(--paper-soft)]"
className="inline-flex h-12 items-center justify-center rounded-full bg-[var(--olive)] px-5 text-sm font-semibold text-white transition hover:bg-[var(--olive-deep)]"
>
</Link>
</div>
</div>
</article>
<ScanClient />
</section>
</main>
</PanelShell>
);
}

View File

@ -15,7 +15,7 @@ export default async function SettingsPage() {
<PanelShell
currentPath="/settings"
title="设置"
description="桌面版安装、消息提醒和推送测试统一放在这里,不再分散显示在各个页面。"
description="PWA 桌面版安装,安装后即可接收实时消息推送。"
caregiverLabel={getCaregiverDisplayName(caregiver)}
>
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">

View File

@ -3,6 +3,8 @@
import { useRouter } from "next/navigation";
import { startTransition, useState } from "react";
import { useNavigationProgress } from "@/components/navigation-progress";
type AccountAuthFormProps = {
redirectTo: string;
};
@ -11,6 +13,7 @@ type AuthMode = "login" | "register";
export function AccountAuthForm({ redirectTo }: AccountAuthFormProps) {
const router = useRouter();
const { beginNavigationProgress } = useNavigationProgress();
const [mode, setMode] = useState<AuthMode>("login");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
@ -70,6 +73,7 @@ export function AccountAuthForm({ redirectTo }: AccountAuthFormProps) {
setNotice(mode === "login" ? "登录成功,正在进入……" : "账号已创建,正在进入……");
startTransition(() => {
beginNavigationProgress();
router.replace(payload?.redirectTo || redirectTo);
router.refresh();
});

View File

@ -2,10 +2,13 @@
import Link from "next/link";
import { useRouter } from "next/navigation";
import { startTransition, useEffect, useState } from "react";
import { startTransition, useRef, useState } from "react";
import { useNavigationProgress } from "@/components/navigation-progress";
export function BindDeviceForm() {
const router = useRouter();
const { beginNavigationProgress } = useNavigationProgress();
const [rawCode, setRawCode] = useState("");
const [submitting, setSubmitting] = useState(false);
const [notice, setNotice] = useState<string | null>(null);
@ -22,6 +25,7 @@ export function BindDeviceForm() {
setNotice("正在前往绑定页面,请稍候……");
startTransition(() => {
beginNavigationProgress();
router.push(`/bind?deviceUuid=${encodeURIComponent(rawCode.trim())}`);
});
}
@ -210,17 +214,15 @@ export function ManageDeviceForm({
deviceLabel,
}: ManageDeviceFormProps) {
const router = useRouter();
const [alias, setAlias] = useState(initialAlias ?? "");
const { beginNavigationProgress } = useNavigationProgress();
const aliasInputRef = useRef<HTMLInputElement | null>(null);
const [saving, setSaving] = useState(false);
const [unbinding, setUnbinding] = useState(false);
const [notice, setNotice] = useState<string | null>(null);
useEffect(() => {
setAlias(initialAlias ?? "");
}, [initialAlias]);
async function handleSaveAlias(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const alias = aliasInputRef.current?.value ?? "";
setSaving(true);
setNotice("正在保存设备别名……");
@ -244,7 +246,9 @@ export function ManageDeviceForm({
throw new Error(payload?.error || "设备别名保存失败。");
}
setAlias(payload?.alias?.trim() || "");
if (aliasInputRef.current) {
aliasInputRef.current.value = payload?.alias?.trim() || "";
}
setNotice(
payload?.alias?.trim()
? `已将这台设备备注为“${payload.alias.trim()}”。\n`
@ -290,6 +294,7 @@ export function ManageDeviceForm({
}
startTransition(() => {
beginNavigationProgress();
router.push("/devices");
router.refresh();
});
@ -315,8 +320,9 @@ export function ManageDeviceForm({
<form className="mt-4 flex flex-col gap-3 sm:flex-row" onSubmit={handleSaveAlias}>
<input
value={alias}
onChange={(event) => setAlias(event.target.value)}
key={`${deviceUuid}:${initialAlias ?? ""}`}
ref={aliasInputRef}
defaultValue={initialAlias ?? ""}
maxLength={24}
placeholder="给这台设备起个好记的名字"
className="h-12 flex-1 rounded-full border border-[var(--line)] bg-[var(--paper-soft)] px-5 text-sm text-[var(--ink)] outline-none transition focus:border-[var(--copper)] focus:bg-white"

View File

@ -0,0 +1,209 @@
"use client";
import {
createContext,
useContext,
useCallback,
useEffect,
useMemo,
useRef,
useState,
type MutableRefObject,
type ReactNode,
} from "react";
import { usePathname, useSearchParams } from "next/navigation";
const SHOW_DELAY_MS = 120;
const MIN_VISIBLE_MS = 180;
const FAILSAFE_MS = 12000;
type NavigationProgressContextValue = {
beginNavigationProgress: () => void;
};
const NavigationProgressContext = createContext<NavigationProgressContextValue | null>(
null,
);
function clearTimer(timerRef: MutableRefObject<number | null>) {
if (timerRef.current !== null) {
window.clearTimeout(timerRef.current);
timerRef.current = null;
}
}
export function NavigationProgressProvider({
children,
}: {
children: ReactNode;
}) {
const pathname = usePathname();
const searchParams = useSearchParams();
const [visible, setVisible] = useState(false);
const visibleRef = useRef(false);
const shownAtRef = useRef(0);
const showTimerRef = useRef<number | null>(null);
const hideTimerRef = useRef<number | null>(null);
const failsafeTimerRef = useRef<number | null>(null);
const search = searchParams.toString();
const setProgressVisible = useCallback((nextVisible: boolean) => {
visibleRef.current = nextVisible;
setVisible(nextVisible);
if (nextVisible) {
shownAtRef.current = Date.now();
}
}, []);
const beginNavigationProgress = useCallback(() => {
clearTimer(hideTimerRef);
clearTimer(failsafeTimerRef);
if (visibleRef.current) {
failsafeTimerRef.current = window.setTimeout(() => {
setProgressVisible(false);
}, FAILSAFE_MS);
return;
}
clearTimer(showTimerRef);
showTimerRef.current = window.setTimeout(() => {
setProgressVisible(true);
failsafeTimerRef.current = window.setTimeout(() => {
setProgressVisible(false);
}, FAILSAFE_MS);
}, SHOW_DELAY_MS);
}, [setProgressVisible]);
const endNavigationProgress = useCallback(() => {
clearTimer(showTimerRef);
clearTimer(failsafeTimerRef);
if (!visibleRef.current) {
return;
}
const remainingVisibleMs = Math.max(
0,
MIN_VISIBLE_MS - (Date.now() - shownAtRef.current),
);
clearTimer(hideTimerRef);
hideTimerRef.current = window.setTimeout(() => {
setProgressVisible(false);
}, remainingVisibleMs);
}, [setProgressVisible]);
useEffect(() => {
endNavigationProgress();
}, [pathname, search, endNavigationProgress]);
useEffect(() => {
function handleDocumentClick(event: MouseEvent) {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
) {
return;
}
const target = event.target;
if (!(target instanceof Element)) {
return;
}
const anchor = target.closest("a[href]");
if (!(anchor instanceof HTMLAnchorElement)) {
return;
}
const href = anchor.getAttribute("href");
if (!href || href.startsWith("#") || anchor.hasAttribute("download")) {
return;
}
if (anchor.target && anchor.target !== "_self") {
return;
}
const nextUrl = new URL(anchor.href, window.location.href);
const currentUrl = new URL(window.location.href);
if (nextUrl.origin !== currentUrl.origin) {
return;
}
if (
nextUrl.pathname === currentUrl.pathname &&
nextUrl.search === currentUrl.search
) {
return;
}
beginNavigationProgress();
}
window.addEventListener("click", handleDocumentClick, true);
return () => {
window.removeEventListener("click", handleDocumentClick, true);
};
}, [beginNavigationProgress]);
useEffect(() => {
function handlePopState() {
beginNavigationProgress();
}
window.addEventListener("popstate", handlePopState);
return () => {
window.removeEventListener("popstate", handlePopState);
};
}, [beginNavigationProgress]);
useEffect(() => {
return () => {
clearTimer(showTimerRef);
clearTimer(hideTimerRef);
clearTimer(failsafeTimerRef);
};
}, []);
const contextValue = useMemo(
() => ({ beginNavigationProgress }),
[beginNavigationProgress],
);
return (
<NavigationProgressContext.Provider value={contextValue}>
{children}
<div
aria-live="polite"
className={`pointer-events-none fixed inset-x-0 top-0 z-[100] transition-opacity duration-150 ${
visible ? "opacity-100" : "opacity-0"
}`}
>
<div aria-hidden="true" className="h-1 w-full bg-[rgba(36,27,20,0.08)]">
<div className="route-progress-bar h-full w-2/5 rounded-full bg-[linear-gradient(90deg,rgba(53,84,141,0.2),rgba(199,103,51,0.95),rgba(53,84,141,0.75))]" />
</div>
{visible ? <span className="sr-only"></span> : null}
</div>
</NavigationProgressContext.Provider>
);
}
export function useNavigationProgress() {
const context = useContext(NavigationProgressContext);
if (!context) {
throw new Error("useNavigationProgress 必须在 NavigationProgressProvider 内使用。");
}
return context;
}

View File

@ -71,7 +71,7 @@ export function PanelShell({
}`}
>
<Icon size={16} />
{item.label}
<span>{item.label}</span>
</Link>
);
})}

View File

@ -10,6 +10,8 @@ import {
useState,
} from "react";
import { useNavigationProgress } from "@/components/navigation-progress";
function extractDeviceUuid(rawValue: string) {
const trimmedValue = rawValue.trim();
@ -31,6 +33,7 @@ function extractDeviceUuid(rawValue: string) {
export function ScanClient() {
const router = useRouter();
const { beginNavigationProgress } = useNavigationProgress();
const videoRef = useRef<HTMLVideoElement | null>(null);
const scannerRef = useRef<QrScanner | null>(null);
const [statusText, setStatusText] = useState("正在准备镜头……");
@ -48,6 +51,7 @@ export function ScanClient() {
setStatusText("识别到了设备,正在打开绑定页面……");
startTransition(() => {
beginNavigationProgress();
router.replace(
`/bind?deviceUuid=${encodeURIComponent(scannedValue)}&source=scan`,
);
@ -111,8 +115,18 @@ export function ScanClient() {
}, []);
return (
<div className="rounded-[32px] border border-white/70 bg-white/82 p-5 shadow-[0_30px_70px_rgba(115,72,44,0.14)] backdrop-blur">
<div className="relative overflow-hidden rounded-[28px] border border-[var(--line)] bg-[var(--ink)]/95">
<div className="rounded-[32px] border border-white/75 bg-white/82 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)]">
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
</p>
<h2 className="mt-2 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
</h2>
<p className="mt-3 text-sm leading-7 text-[var(--muted)]">
</p>
<div className="relative mt-5 overflow-hidden rounded-[28px] border border-[var(--line)] bg-[var(--ink)]/95">
<video
ref={videoRef}
muted