feat(web): 前端共享基础(focusRing/proseBody/text-2xs + useBodyScrollLock + Toast撤销 + error/not-found/loading路由边界)
This commit is contained in:
37
apps/web/app/error.tsx
Normal file
37
apps/web/app/error.tsx
Normal 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
9
apps/web/app/loading.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
apps/web/app/not-found.tsx
Normal file
24
apps/web/app/not-found.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,19 +11,51 @@ import {
|
|||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
|
||||||
|
import { buttonClass } from "@/lib/ui/variants";
|
||||||
|
|
||||||
type ToastKind = "info" | "error" | "success";
|
type ToastKind = "info" | "error" | "success";
|
||||||
|
|
||||||
|
interface ToastAction {
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToastOptions {
|
||||||
|
action?: ToastAction;
|
||||||
|
durationMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface ToastItem {
|
interface ToastItem {
|
||||||
id: number;
|
id: number;
|
||||||
message: string;
|
message: string;
|
||||||
kind: ToastKind;
|
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);
|
const ToastContext = createContext<ShowToast | null>(null);
|
||||||
|
|
||||||
|
// 普通提示 4s 自动消失;错误更需被看到,默认延长到 7s。
|
||||||
const TOAST_TTL_MS = 4000;
|
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 }) {
|
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||||
const [items, setItems] = useState<ToastItem[]>([]);
|
const [items, setItems] = useState<ToastItem[]>([]);
|
||||||
@@ -32,13 +64,20 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
|||||||
// 在途定时器集合:卸载时统一清理,避免 setState-after-unmount 泄漏。
|
// 在途定时器集合:卸载时统一清理,避免 setState-after-unmount 泄漏。
|
||||||
const timersRef = useRef<Set<ReturnType<typeof setTimeout>>>(new Set());
|
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++;
|
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(() => {
|
const timer = setTimeout(() => {
|
||||||
timersRef.current.delete(timer);
|
timersRef.current.delete(timer);
|
||||||
setItems((prev) => prev.filter((t) => t.id !== id));
|
setItems((prev) => prev.filter((t) => t.id !== id));
|
||||||
}, TOAST_TTL_MS);
|
}, ttl);
|
||||||
timersRef.current.add(timer);
|
timersRef.current.add(timer);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -53,28 +92,46 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
const value = useMemo(() => show, [show]);
|
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 (
|
return (
|
||||||
<ToastContext.Provider value={value}>
|
<ToastContext.Provider value={value}>
|
||||||
{children}
|
{children}
|
||||||
<div
|
{/* error 用 assertive 立即播报;其余 info/success 用 polite 礼貌播报。两区共用同一锚点容器避免重叠。 */}
|
||||||
className="pointer-events-none fixed bottom-6 right-6 z-50 flex flex-col gap-2"
|
<div className="pointer-events-none fixed bottom-6 right-6 z-50 flex flex-col gap-2">
|
||||||
aria-live="polite"
|
<div className="flex flex-col gap-2" aria-live="assertive" role="alert">
|
||||||
role="status"
|
{errorItems.map(renderItem)}
|
||||||
>
|
</div>
|
||||||
{items.map((t) => (
|
<div className="flex flex-col gap-2" aria-live="polite" role="status">
|
||||||
<div
|
{politeItems.map(renderItem)}
|
||||||
key={t.id}
|
</div>
|
||||||
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>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</ToastContext.Provider>
|
</ToastContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
41
apps/web/lib/ui/useBodyScrollLock.test.ts
Normal file
41
apps/web/lib/ui/useBodyScrollLock.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
20
apps/web/lib/ui/useBodyScrollLock.ts
Normal file
20
apps/web/lib/ui/useBodyScrollLock.ts
Normal 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]);
|
||||||
|
}
|
||||||
@@ -4,7 +4,10 @@ import {
|
|||||||
badgeClass,
|
badgeClass,
|
||||||
buttonClass,
|
buttonClass,
|
||||||
cn,
|
cn,
|
||||||
|
focusRing,
|
||||||
inputClass,
|
inputClass,
|
||||||
|
overlayScrim,
|
||||||
|
proseBody,
|
||||||
segmentedClass,
|
segmentedClass,
|
||||||
statusNoteClass,
|
statusNoteClass,
|
||||||
} from "./variants";
|
} from "./variants";
|
||||||
@@ -49,4 +52,22 @@ describe("ui variants", () => {
|
|||||||
expect(klass).toContain("inline-flex");
|
expect(klass).toContain("inline-flex");
|
||||||
expect(klass).toContain("border-line");
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,6 +23,16 @@ export function cn(...classes: Array<string | false | null | undefined>): string
|
|||||||
return classes.filter(Boolean).join(" ");
|
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 =
|
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";
|
"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";
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ const config: Config = {
|
|||||||
sans: ['"Noto Sans SC"', '"PingFang SC"', "system-ui", "sans-serif"],
|
sans: ['"Noto Sans SC"', '"PingFang SC"', "system-ui", "sans-serif"],
|
||||||
mono: ['"JetBrains Mono"', "ui-monospace"],
|
mono: ['"JetBrains Mono"', "ui-monospace"],
|
||||||
},
|
},
|
||||||
|
fontSize: {
|
||||||
|
"2xs": ["0.6875rem", { lineHeight: "1rem" }],
|
||||||
|
},
|
||||||
borderRadius: { DEFAULT: "6px" },
|
borderRadius: { DEFAULT: "6px" },
|
||||||
boxShadow: { paper: "0 1px 3px var(--shadow-paper)" },
|
boxShadow: { paper: "0 1px 3px var(--shadow-paper)" },
|
||||||
maxWidth: { prose: "720px" },
|
maxWidth: { prose: "720px" },
|
||||||
|
|||||||
Reference in New Issue
Block a user