diff --git a/app/globals.css b/app/globals.css index b50d60c..595d8de 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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)), diff --git a/app/layout.tsx b/app/layout.tsx index 5171711..c440f11 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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 ( - - {children} + + + {children} + ); } diff --git a/app/scan/page.tsx b/app/scan/page.tsx index fb10b40..54cbe35 100644 --- a/app/scan/page.tsx +++ b/app/scan/page.tsx @@ -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 ( -
-
-
+ +
+

- 扫码连接 + 扫码步骤

-

+

对准长辈手机上的二维码 -

+

- 扫描成功后,系统会自动将长辈的设备连接到您的账号。之后就能随时查看长辈的留言和使用动态。 + 扫描成功后,系统会自动把长辈的设备连接到当前账号。连接完成后,就能继续查看留言、动态和设备详情。

-
+

1. 让长辈打开手机上的「家人连接」页面。

2. 将镜头对准屏幕上的二维码,保持一两秒即可。

3. 连接成功后会自动跳转。

+
+

小提醒

+

+ 如果当前设备没有摄像头,或者浏览器没有拿到拍照权限,可以回到设备页,改用粘贴设备码的方式继续连接。 +

+
+
返回设备页
-
+
-
+ ); } \ No newline at end of file diff --git a/app/settings/page.tsx b/app/settings/page.tsx index 0bd09fa..c93f817 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -15,7 +15,7 @@ export default async function SettingsPage() {
diff --git a/components/account-auth-form.tsx b/components/account-auth-form.tsx index cb53d04..7c6a801 100644 --- a/components/account-auth-form.tsx +++ b/components/account-auth-form.tsx @@ -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("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(); }); diff --git a/components/dashboard-actions.tsx b/components/dashboard-actions.tsx index 4da908b..3102dbe 100644 --- a/components/dashboard-actions.tsx +++ b/components/dashboard-actions.tsx @@ -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(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(null); const [saving, setSaving] = useState(false); const [unbinding, setUnbinding] = useState(false); const [notice, setNotice] = useState(null); - useEffect(() => { - setAlias(initialAlias ?? ""); - }, [initialAlias]); - async function handleSaveAlias(event: React.FormEvent) { 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({
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" diff --git a/components/navigation-progress.tsx b/components/navigation-progress.tsx new file mode 100644 index 0000000..ade5d5a --- /dev/null +++ b/components/navigation-progress.tsx @@ -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( + null, +); + +function clearTimer(timerRef: MutableRefObject) { + 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(null); + const hideTimerRef = useRef(null); + const failsafeTimerRef = useRef(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 ( + + {children} +
+ + + ); +} + +export function useNavigationProgress() { + const context = useContext(NavigationProgressContext); + + if (!context) { + throw new Error("useNavigationProgress 必须在 NavigationProgressProvider 内使用。"); + } + + return context; +} \ No newline at end of file diff --git a/components/panel-shell.tsx b/components/panel-shell.tsx index 979a99a..9eb1660 100644 --- a/components/panel-shell.tsx +++ b/components/panel-shell.tsx @@ -71,7 +71,7 @@ export function PanelShell({ }`} > - {item.label} + {item.label} ); })} diff --git a/components/scan-client.tsx b/components/scan-client.tsx index ef26bed..9e73a0f 100644 --- a/components/scan-client.tsx +++ b/components/scan-client.tsx @@ -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(null); const scannerRef = useRef(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 ( -
-
+
+

+ 扫码画面 +

+

+ 将二维码放入取景框 +

+

+ 保持画面稳定一两秒,识别成功后会自动继续,不需要额外点击。 +

+ +