feat(web): 前端共享基础(focusRing/proseBody/text-2xs + useBodyScrollLock + Toast撤销 + error/not-found/loading路由边界)

This commit is contained in:
Yaojia Wang
2026-06-30 08:35:52 +02:00
parent 69f3c2a29b
commit a22a16c9c4
9 changed files with 245 additions and 23 deletions

37
apps/web/app/error.tsx Normal file
View File

@@ -0,0 +1,37 @@
"use client";
import Link from "next/link";
import { TriangleAlert } from "lucide-react";
import { EmptyState } from "@/components/ui/EmptyState";
import { Button } from "@/components/ui/Button";
import { buttonClass } from "@/lib/ui/variants";
interface ErrorBoundaryProps {
error: Error & { digest?: string };
reset: () => void;
}
export default function Error({ reset }: ErrorBoundaryProps) {
return (
<main className="flex min-h-screen items-center justify-center bg-bg px-6 py-16">
<div className="w-full max-w-md">
<EmptyState
icon={TriangleAlert}
title="出错了"
description="刚才的操作没能完成。可以重试,或先回到作品库。"
action={
<div className="flex items-center justify-center gap-2">
<Button variant="primary" onClick={reset}>
</Button>
<Link href="/" className={buttonClass({ variant: "secondary" })}>
</Link>
</div>
}
/>
</div>
</main>
);
}

9
apps/web/app/loading.tsx Normal file
View File

@@ -0,0 +1,9 @@
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
export default function Loading() {
return (
<main className="flex min-h-screen items-center justify-center bg-bg px-6 py-16">
<ThinkingIndicator label="加载中…" className="text-ink-soft" />
</main>
);
}

View File

@@ -0,0 +1,24 @@
import Link from "next/link";
import { FileQuestion } from "lucide-react";
import { EmptyState } from "@/components/ui/EmptyState";
import { buttonClass } from "@/lib/ui/variants";
export default function NotFound() {
return (
<main className="flex min-h-screen items-center justify-center bg-bg px-6 py-16">
<div className="w-full max-w-md">
<EmptyState
icon={FileQuestion}
title="页面不存在"
description="你要找的内容可能已被移动或从未存在。回到作品库继续创作吧。"
action={
<Link href="/" className={buttonClass({ variant: "primary" })}>
</Link>
}
/>
</div>
</main>
);
}

View File

@@ -11,19 +11,51 @@ import {
type ReactNode,
} from "react";
import { buttonClass } from "@/lib/ui/variants";
type ToastKind = "info" | "error" | "success";
interface ToastAction {
label: string;
onClick: () => void;
}
interface ToastOptions {
action?: ToastAction;
durationMs?: number;
}
interface ToastItem {
id: number;
message: string;
kind: ToastKind;
action?: ToastAction;
}
type ShowToast = (message: string, kind?: ToastKind) => void;
type ShowToast = (
message: string,
kind?: ToastKind,
options?: ToastOptions,
) => void;
const ToastContext = createContext<ShowToast | null>(null);
// 普通提示 4s 自动消失;错误更需被看到,默认延长到 7s。
const TOAST_TTL_MS = 4000;
const ERROR_TOAST_TTL_MS = 7000;
// 屏读前缀:让无视觉的用户也能区分提示性质。
const SR_PREFIX: Record<ToastKind, string> = {
error: "错误:",
success: "成功:",
info: "",
};
function toastShellClass(kind: ToastKind): string {
if (kind === "error") return "border-conflict bg-panel text-conflict";
if (kind === "success") return "border-pass bg-panel text-pass";
return "border-line bg-panel text-ink";
}
export function ToastProvider({ children }: { children: ReactNode }) {
const [items, setItems] = useState<ToastItem[]>([]);
@@ -32,13 +64,20 @@ export function ToastProvider({ children }: { children: ReactNode }) {
// 在途定时器集合:卸载时统一清理,避免 setState-after-unmount 泄漏。
const timersRef = useRef<Set<ReturnType<typeof setTimeout>>>(new Set());
const show = useCallback<ShowToast>((message, kind = "info") => {
const dismiss = useCallback((id: number) => {
setItems((prev) => prev.filter((t) => t.id !== id));
}, []);
const show = useCallback<ShowToast>((message, kind = "info", options) => {
const id = nextIdRef.current++;
setItems((prev) => [...prev, { id, message, kind }]);
setItems((prev) => [...prev, { id, message, kind, action: options?.action }]);
const ttl =
options?.durationMs ??
(kind === "error" ? ERROR_TOAST_TTL_MS : TOAST_TTL_MS);
const timer = setTimeout(() => {
timersRef.current.delete(timer);
setItems((prev) => prev.filter((t) => t.id !== id));
}, TOAST_TTL_MS);
}, ttl);
timersRef.current.add(timer);
}, []);
@@ -53,28 +92,46 @@ export function ToastProvider({ children }: { children: ReactNode }) {
const value = useMemo(() => show, [show]);
const errorItems = items.filter((t) => t.kind === "error");
const politeItems = items.filter((t) => t.kind !== "error");
const renderItem = (t: ToastItem) => (
<div
key={t.id}
className={`pointer-events-auto flex items-center gap-3 rounded border px-4 py-2 text-sm shadow-paper ${toastShellClass(
t.kind,
)}`}
>
<span className="min-w-0">
<span className="sr-only">{SR_PREFIX[t.kind]}</span>
{t.message}
</span>
{t.action ? (
<button
type="button"
className={buttonClass({ variant: "ghost", size: "sm" })}
onClick={() => {
t.action?.onClick();
dismiss(t.id);
}}
>
{t.action.label}
</button>
) : null}
</div>
);
return (
<ToastContext.Provider value={value}>
{children}
<div
className="pointer-events-none fixed bottom-6 right-6 z-50 flex flex-col gap-2"
aria-live="polite"
role="status"
>
{items.map((t) => (
<div
key={t.id}
className={`pointer-events-auto rounded border px-4 py-2 text-sm shadow-paper ${
t.kind === "error"
? "border-conflict bg-panel text-conflict"
: t.kind === "success"
? "border-pass bg-panel text-pass"
: "border-line bg-panel text-ink"
}`}
>
{t.message}
</div>
))}
{/* error 用 assertive 立即播报;其余 info/success 用 polite 礼貌播报。两区共用同一锚点容器避免重叠。 */}
<div className="pointer-events-none fixed bottom-6 right-6 z-50 flex flex-col gap-2">
<div className="flex flex-col gap-2" aria-live="assertive" role="alert">
{errorItems.map(renderItem)}
</div>
<div className="flex flex-col gap-2" aria-live="polite" role="status">
{politeItems.map(renderItem)}
</div>
</div>
</ToastContext.Provider>
);

View File

@@ -0,0 +1,41 @@
// @vitest-environment jsdom
import { renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { useBodyScrollLock } from "./useBodyScrollLock";
describe("useBodyScrollLock", () => {
afterEach(() => {
document.body.style.overflow = "";
});
it("active 时锁定 body 滚动", () => {
renderHook(() => useBodyScrollLock(true));
expect(document.body.style.overflow).toBe("hidden");
});
it("active 为假时不改动 overflow", () => {
document.body.style.overflow = "scroll";
renderHook(() => useBodyScrollLock(false));
expect(document.body.style.overflow).toBe("scroll");
});
it("卸载时恢复原 overflow 值", () => {
document.body.style.overflow = "auto";
const { unmount } = renderHook(() => useBodyScrollLock(true));
expect(document.body.style.overflow).toBe("hidden");
unmount();
expect(document.body.style.overflow).toBe("auto");
});
it("active 由真转假时恢复原值", () => {
document.body.style.overflow = "visible";
const { rerender } = renderHook(
({ active }: { active: boolean }) => useBodyScrollLock(active),
{ initialProps: { active: true } },
);
expect(document.body.style.overflow).toBe("hidden");
rerender({ active: false });
expect(document.body.style.overflow).toBe("visible");
});
});

View File

@@ -0,0 +1,20 @@
"use client";
import { useEffect } from "react";
// 弹层Drawer / CommandPalette / Modal打开时锁定 body 滚动,关闭后恢复原值。
// SSR 守卫:服务端无 document直接跳过。
export function useBodyScrollLock(active: boolean): void {
useEffect(() => {
if (!active) return;
if (typeof document === "undefined") return;
const { body } = document;
const previousOverflow = body.style.overflow;
body.style.overflow = "hidden";
return () => {
body.style.overflow = previousOverflow;
};
}, [active]);
}

View File

@@ -4,7 +4,10 @@ import {
badgeClass,
buttonClass,
cn,
focusRing,
inputClass,
overlayScrim,
proseBody,
segmentedClass,
statusNoteClass,
} from "./variants";
@@ -49,4 +52,22 @@ describe("ui variants", () => {
expect(klass).toContain("inline-flex");
expect(klass).toContain("border-line");
});
it("shares the cinnabar focus ring with the button base", () => {
expect(focusRing).toContain("focus-visible:ring-2");
expect(focusRing).toContain("focus-visible:ring-cinnabar/35");
expect(buttonClass({ variant: "primary" })).toContain(focusRing);
});
it("renders manuscript body as serif 18px with airy leading", () => {
expect(proseBody).toContain("font-serif");
expect(proseBody).toContain("text-[18px]");
expect(proseBody).toContain("leading-[1.9]");
});
it("provides a full-screen overlay scrim below dialog chrome", () => {
expect(overlayScrim).toContain("fixed");
expect(overlayScrim).toContain("inset-0");
expect(overlayScrim).toContain("z-40");
});
});

View File

@@ -23,6 +23,16 @@ export function cn(...classes: Array<string | false | null | undefined>): string
return classes.filter(Boolean).join(" ");
}
// 统一焦点环(与 buttonBase 现有焦点环一致),供导航/输入等可聚焦元素复用。
export const focusRing =
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cinnabar/35";
// 手稿正文统一排版:衬线 18px、行高 1.9。
export const proseBody = "font-serif text-[18px] leading-[1.9]";
// Drawer / CommandPalette 共用的全屏遮罩。
export const overlayScrim = "fixed inset-0 z-40 bg-black/30";
const buttonBase =
"inline-flex items-center justify-center gap-1.5 rounded border text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cinnabar/35 disabled:cursor-not-allowed disabled:opacity-45";

View File

@@ -26,6 +26,9 @@ const config: Config = {
sans: ['"Noto Sans SC"', '"PingFang SC"', "system-ui", "sans-serif"],
mono: ['"JetBrains Mono"', "ui-monospace"],
},
fontSize: {
"2xs": ["0.6875rem", { lineHeight: "1rem" }],
},
borderRadius: { DEFAULT: "6px" },
boxShadow: { paper: "0 1px 3px var(--shadow-paper)" },
maxWidth: { prose: "720px" },