P1-6 useStyleLearn/useKimiOauth 修 stale-closure 依赖。 P1-7 CommandPalette/Drawer 加 focus trap(新 lib/a11y/focusTrap)。 P1-8 SSE 解析与 server.ts 去 as 强转改类型守卫/运行时校验。 P2 删冗余强转、开 noUncheckedIndexedAccess、Toast 清理+稳定 key、列表 key 稳定化、 rel=noopener、useMemo 大文本、ConflictCard role=group、useInjection 加锁、 client.test 真断言。 codegen 重生成 schema.d.ts + 消费 DimensionEntry/ReviewConflictView 强类型。
91 lines
2.4 KiB
TypeScript
91 lines
2.4 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
createContext,
|
||
useCallback,
|
||
useContext,
|
||
useEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
type ReactNode,
|
||
} from "react";
|
||
|
||
type ToastKind = "info" | "error" | "success";
|
||
|
||
interface ToastItem {
|
||
id: number;
|
||
message: string;
|
||
kind: ToastKind;
|
||
}
|
||
|
||
type ShowToast = (message: string, kind?: ToastKind) => void;
|
||
|
||
const ToastContext = createContext<ShowToast | null>(null);
|
||
|
||
const TOAST_TTL_MS = 4000;
|
||
|
||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||
const [items, setItems] = useState<ToastItem[]>([]);
|
||
// 单调计数器作 id(不再用 Date.now()+random,避免极小概率碰撞且稳定可测)。
|
||
const nextIdRef = useRef(0);
|
||
// 在途定时器集合:卸载时统一清理,避免 setState-after-unmount 泄漏。
|
||
const timersRef = useRef<Set<ReturnType<typeof setTimeout>>>(new Set());
|
||
|
||
const show = useCallback<ShowToast>((message, kind = "info") => {
|
||
const id = nextIdRef.current++;
|
||
setItems((prev) => [...prev, { id, message, kind }]);
|
||
const timer = setTimeout(() => {
|
||
timersRef.current.delete(timer);
|
||
setItems((prev) => prev.filter((t) => t.id !== id));
|
||
}, TOAST_TTL_MS);
|
||
timersRef.current.add(timer);
|
||
}, []);
|
||
|
||
// 卸载清理所有在途定时器。
|
||
useEffect(() => {
|
||
const timers = timersRef.current;
|
||
return () => {
|
||
timers.forEach((t) => clearTimeout(t));
|
||
timers.clear();
|
||
};
|
||
}, []);
|
||
|
||
const value = useMemo(() => show, [show]);
|
||
|
||
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>
|
||
))}
|
||
</div>
|
||
</ToastContext.Provider>
|
||
);
|
||
}
|
||
|
||
export function useToast(): ShowToast {
|
||
const ctx = useContext(ToastContext);
|
||
if (!ctx) {
|
||
// 容错:未挂 Provider 时退化为 no-op(不应在生产路径发生)。
|
||
return () => undefined;
|
||
}
|
||
return ctx;
|
||
}
|