优化加载动画
This commit is contained in:
parent
70d42e6792
commit
5cbb7217f0
@ -12,12 +12,30 @@
|
|||||||
--copper-deep: #964722;
|
--copper-deep: #964722;
|
||||||
--olive: #76845e;
|
--olive: #76845e;
|
||||||
--olive-deep: #596946;
|
--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 {
|
@theme inline {
|
||||||
--color-background: var(--paper);
|
--color-background: var(--paper);
|
||||||
--color-foreground: var(--ink);
|
--color-foreground: var(--ink);
|
||||||
--font-sans: var(--font-body);
|
--font-sans: var(--font-body);
|
||||||
|
--font-serif: var(--font-display);
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@ -52,6 +70,26 @@ select {
|
|||||||
background: rgba(199, 103, 51, 0.18);
|
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 {
|
.paper-panel {
|
||||||
background:
|
background:
|
||||||
linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(255, 249, 241, 0.84)),
|
linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(255, 249, 241, 0.84)),
|
||||||
|
|||||||
@ -1,18 +1,7 @@
|
|||||||
import type { Metadata, Viewport } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import { Noto_Sans_SC, Noto_Serif_SC } from "next/font/google";
|
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const bodyFont = Noto_Sans_SC({
|
import { NavigationProgressProvider } from "@/components/navigation-progress";
|
||||||
variable: "--font-body",
|
|
||||||
subsets: ["latin"],
|
|
||||||
weight: ["400", "500", "700"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const displayFont = Noto_Serif_SC({
|
|
||||||
variable: "--font-display",
|
|
||||||
subsets: ["latin"],
|
|
||||||
weight: ["400", "600", "700"],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
metadataBase: new URL("https://digital-human.xn--876a.net"),
|
metadataBase: new URL("https://digital-human.xn--876a.net"),
|
||||||
@ -58,8 +47,10 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="zh-CN" className={`${bodyFont.variable} ${displayFont.variable}`}>
|
<html lang="zh-CN">
|
||||||
<body className="min-h-screen">{children}</body>
|
<body className="min-h-screen">
|
||||||
|
<NavigationProgressProvider>{children}</NavigationProgressProvider>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,40 +1,59 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { PanelShell } from "@/components/panel-shell";
|
||||||
import { ScanClient } from "@/components/scan-client";
|
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 (
|
return (
|
||||||
<main className="mx-auto min-h-screen max-w-5xl px-4 py-8 sm:px-6 lg:px-8">
|
<PanelShell
|
||||||
<section className="grid gap-6 lg:grid-cols-[0.82fr_1.18fr]">
|
currentPath="/scan"
|
||||||
<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">
|
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 className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
||||||
扫码连接
|
扫码步骤
|
||||||
</p>
|
</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 className="mt-4 text-base leading-8 text-[var(--muted)]">
|
||||||
扫描成功后,系统会自动将长辈的设备连接到您的账号。之后就能随时查看长辈的留言和使用动态。
|
扫描成功后,系统会自动把长辈的设备连接到当前账号。连接完成后,就能继续查看留言、动态和设备详情。
|
||||||
</p>
|
</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>1. 让长辈打开手机上的「家人连接」页面。</p>
|
||||||
<p>2. 将镜头对准屏幕上的二维码,保持一两秒即可。</p>
|
<p>2. 将镜头对准屏幕上的二维码,保持一两秒即可。</p>
|
||||||
<p>3. 连接成功后会自动跳转。</p>
|
<p>3. 连接成功后会自动跳转。</p>
|
||||||
</div>
|
</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">
|
<div className="mt-6 flex flex-wrap gap-3">
|
||||||
<Link
|
<Link
|
||||||
href="/devices"
|
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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</article>
|
||||||
|
|
||||||
<ScanClient />
|
<ScanClient />
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</PanelShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -15,7 +15,7 @@ export default async function SettingsPage() {
|
|||||||
<PanelShell
|
<PanelShell
|
||||||
currentPath="/settings"
|
currentPath="/settings"
|
||||||
title="设置"
|
title="设置"
|
||||||
description="桌面版安装、消息提醒和推送测试统一放在这里,不再分散显示在各个页面。"
|
description="PWA 桌面版安装,安装后即可接收实时消息推送。"
|
||||||
caregiverLabel={getCaregiverDisplayName(caregiver)}
|
caregiverLabel={getCaregiverDisplayName(caregiver)}
|
||||||
>
|
>
|
||||||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
|||||||
@ -3,6 +3,8 @@
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { startTransition, useState } from "react";
|
import { startTransition, useState } from "react";
|
||||||
|
|
||||||
|
import { useNavigationProgress } from "@/components/navigation-progress";
|
||||||
|
|
||||||
type AccountAuthFormProps = {
|
type AccountAuthFormProps = {
|
||||||
redirectTo: string;
|
redirectTo: string;
|
||||||
};
|
};
|
||||||
@ -11,6 +13,7 @@ type AuthMode = "login" | "register";
|
|||||||
|
|
||||||
export function AccountAuthForm({ redirectTo }: AccountAuthFormProps) {
|
export function AccountAuthForm({ redirectTo }: AccountAuthFormProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { beginNavigationProgress } = useNavigationProgress();
|
||||||
const [mode, setMode] = useState<AuthMode>("login");
|
const [mode, setMode] = useState<AuthMode>("login");
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
@ -70,6 +73,7 @@ export function AccountAuthForm({ redirectTo }: AccountAuthFormProps) {
|
|||||||
|
|
||||||
setNotice(mode === "login" ? "登录成功,正在进入……" : "账号已创建,正在进入……");
|
setNotice(mode === "login" ? "登录成功,正在进入……" : "账号已创建,正在进入……");
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
|
beginNavigationProgress();
|
||||||
router.replace(payload?.redirectTo || redirectTo);
|
router.replace(payload?.redirectTo || redirectTo);
|
||||||
router.refresh();
|
router.refresh();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -2,10 +2,13 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
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() {
|
export function BindDeviceForm() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { beginNavigationProgress } = useNavigationProgress();
|
||||||
const [rawCode, setRawCode] = useState("");
|
const [rawCode, setRawCode] = useState("");
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [notice, setNotice] = useState<string | null>(null);
|
const [notice, setNotice] = useState<string | null>(null);
|
||||||
@ -22,6 +25,7 @@ export function BindDeviceForm() {
|
|||||||
setNotice("正在前往绑定页面,请稍候……");
|
setNotice("正在前往绑定页面,请稍候……");
|
||||||
|
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
|
beginNavigationProgress();
|
||||||
router.push(`/bind?deviceUuid=${encodeURIComponent(rawCode.trim())}`);
|
router.push(`/bind?deviceUuid=${encodeURIComponent(rawCode.trim())}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -210,17 +214,15 @@ export function ManageDeviceForm({
|
|||||||
deviceLabel,
|
deviceLabel,
|
||||||
}: ManageDeviceFormProps) {
|
}: ManageDeviceFormProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [alias, setAlias] = useState(initialAlias ?? "");
|
const { beginNavigationProgress } = useNavigationProgress();
|
||||||
|
const aliasInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [unbinding, setUnbinding] = useState(false);
|
const [unbinding, setUnbinding] = useState(false);
|
||||||
const [notice, setNotice] = useState<string | null>(null);
|
const [notice, setNotice] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setAlias(initialAlias ?? "");
|
|
||||||
}, [initialAlias]);
|
|
||||||
|
|
||||||
async function handleSaveAlias(event: React.FormEvent<HTMLFormElement>) {
|
async function handleSaveAlias(event: React.FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
const alias = aliasInputRef.current?.value ?? "";
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setNotice("正在保存设备别名……");
|
setNotice("正在保存设备别名……");
|
||||||
|
|
||||||
@ -244,7 +246,9 @@ export function ManageDeviceForm({
|
|||||||
throw new Error(payload?.error || "设备别名保存失败。");
|
throw new Error(payload?.error || "设备别名保存失败。");
|
||||||
}
|
}
|
||||||
|
|
||||||
setAlias(payload?.alias?.trim() || "");
|
if (aliasInputRef.current) {
|
||||||
|
aliasInputRef.current.value = payload?.alias?.trim() || "";
|
||||||
|
}
|
||||||
setNotice(
|
setNotice(
|
||||||
payload?.alias?.trim()
|
payload?.alias?.trim()
|
||||||
? `已将这台设备备注为“${payload.alias.trim()}”。\n`
|
? `已将这台设备备注为“${payload.alias.trim()}”。\n`
|
||||||
@ -290,6 +294,7 @@ export function ManageDeviceForm({
|
|||||||
}
|
}
|
||||||
|
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
|
beginNavigationProgress();
|
||||||
router.push("/devices");
|
router.push("/devices");
|
||||||
router.refresh();
|
router.refresh();
|
||||||
});
|
});
|
||||||
@ -315,8 +320,9 @@ export function ManageDeviceForm({
|
|||||||
|
|
||||||
<form className="mt-4 flex flex-col gap-3 sm:flex-row" onSubmit={handleSaveAlias}>
|
<form className="mt-4 flex flex-col gap-3 sm:flex-row" onSubmit={handleSaveAlias}>
|
||||||
<input
|
<input
|
||||||
value={alias}
|
key={`${deviceUuid}:${initialAlias ?? ""}`}
|
||||||
onChange={(event) => setAlias(event.target.value)}
|
ref={aliasInputRef}
|
||||||
|
defaultValue={initialAlias ?? ""}
|
||||||
maxLength={24}
|
maxLength={24}
|
||||||
placeholder="给这台设备起个好记的名字"
|
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"
|
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"
|
||||||
|
|||||||
209
components/navigation-progress.tsx
Normal file
209
components/navigation-progress.tsx
Normal 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;
|
||||||
|
}
|
||||||
@ -71,7 +71,7 @@ export function PanelShell({
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Icon size={16} />
|
<Icon size={16} />
|
||||||
{item.label}
|
<span>{item.label}</span>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@ -10,6 +10,8 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
|
||||||
|
import { useNavigationProgress } from "@/components/navigation-progress";
|
||||||
|
|
||||||
function extractDeviceUuid(rawValue: string) {
|
function extractDeviceUuid(rawValue: string) {
|
||||||
const trimmedValue = rawValue.trim();
|
const trimmedValue = rawValue.trim();
|
||||||
|
|
||||||
@ -31,6 +33,7 @@ function extractDeviceUuid(rawValue: string) {
|
|||||||
|
|
||||||
export function ScanClient() {
|
export function ScanClient() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { beginNavigationProgress } = useNavigationProgress();
|
||||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||||
const scannerRef = useRef<QrScanner | null>(null);
|
const scannerRef = useRef<QrScanner | null>(null);
|
||||||
const [statusText, setStatusText] = useState("正在准备镜头……");
|
const [statusText, setStatusText] = useState("正在准备镜头……");
|
||||||
@ -48,6 +51,7 @@ export function ScanClient() {
|
|||||||
setStatusText("识别到了设备,正在打开绑定页面……");
|
setStatusText("识别到了设备,正在打开绑定页面……");
|
||||||
|
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
|
beginNavigationProgress();
|
||||||
router.replace(
|
router.replace(
|
||||||
`/bind?deviceUuid=${encodeURIComponent(scannedValue)}&source=scan`,
|
`/bind?deviceUuid=${encodeURIComponent(scannedValue)}&source=scan`,
|
||||||
);
|
);
|
||||||
@ -111,8 +115,18 @@ export function ScanClient() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
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="rounded-[32px] border border-white/75 bg-white/82 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)]">
|
||||||
<div className="relative overflow-hidden rounded-[28px] border border-[var(--line)] bg-[var(--ink)]/95">
|
<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
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
muted
|
muted
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user