355 lines
9.1 KiB
TypeScript
355 lines
9.1 KiB
TypeScript
"use client";
|
|
|
|
import { AlertTriangle, CheckCircle2, Info, Loader2, X, XCircle } from "lucide-react";
|
|
import type { ComponentProps, ReactNode } from "react";
|
|
import { useEffect } from "react";
|
|
import { toast, Toaster } from "sonner";
|
|
import { cn } from "@/lib/utils";
|
|
import {
|
|
MAP_COMPACT_RADIUS_CLASS_NAME,
|
|
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
|
MAP_READABLE_RADIUS_CLASS_NAME
|
|
} from "./map-control-styles";
|
|
|
|
export type MapNoticeTone = "info" | "success" | "warning" | "error" | "loading";
|
|
export type MapNoticePosition = "top-center" | "top-right" | "bottom-left" | "bottom-right";
|
|
|
|
export type MapNoticeOptions = {
|
|
id?: string | number;
|
|
title?: string;
|
|
message: string;
|
|
tone?: MapNoticeTone;
|
|
duration?: number;
|
|
position?: MapNoticePosition;
|
|
actionLabel?: string;
|
|
onAction?: () => void;
|
|
dismissible?: boolean;
|
|
};
|
|
|
|
type SonnerPosition = NonNullable<ComponentProps<typeof Toaster>["position"]>;
|
|
|
|
const DEFAULT_POSITION: MapNoticePosition = "top-center";
|
|
|
|
const positionClassNames: Record<MapNoticePosition, string> = {
|
|
"top-center": "left-1/2! right-auto! top-[84px] [translate:-50%_0]",
|
|
"top-right": "top-[84px] right-[72px]",
|
|
"bottom-left": "bottom-12 left-4",
|
|
"bottom-right": "bottom-12 right-4"
|
|
};
|
|
|
|
const sonnerPositions: Record<MapNoticePosition, SonnerPosition> = {
|
|
"top-center": "top-center",
|
|
"top-right": "top-right",
|
|
"bottom-left": "bottom-left",
|
|
"bottom-right": "bottom-right"
|
|
};
|
|
|
|
const toneClassNames: Record<MapNoticeTone, string> = {
|
|
info: "acrylic-panel text-slate-900",
|
|
success: "acrylic-panel text-slate-900",
|
|
warning: "acrylic-panel text-slate-900",
|
|
error: "acrylic-panel text-slate-900",
|
|
loading: "acrylic-panel text-slate-900"
|
|
};
|
|
|
|
const iconClassNames: Record<MapNoticeTone, string> = {
|
|
info: "text-blue-600",
|
|
success: "text-green-600",
|
|
warning: "text-orange-500",
|
|
error: "text-red-500",
|
|
loading: "text-blue-600"
|
|
};
|
|
|
|
const noticeAccentClassNames: Record<MapNoticeTone, string> = {
|
|
info: "bg-blue-600",
|
|
success: "bg-green-500",
|
|
warning: "bg-orange-500",
|
|
error: "bg-red-500",
|
|
loading: "bg-blue-600"
|
|
};
|
|
|
|
const noticeIconSurfaceClassNames: Record<MapNoticeTone, string> = {
|
|
info: "border-blue-100 bg-blue-50",
|
|
success: "border-green-100 bg-green-50",
|
|
warning: "border-orange-100 bg-orange-50",
|
|
error: "border-red-100 bg-red-50",
|
|
loading: "border-blue-100 bg-blue-50"
|
|
};
|
|
|
|
export function MapToaster() {
|
|
return (
|
|
<Toaster
|
|
closeButton={false}
|
|
expand
|
|
visibleToasts={4}
|
|
position={sonnerPositions[DEFAULT_POSITION]}
|
|
toastOptions={{
|
|
unstyled: true,
|
|
classNames: {
|
|
toast: "group pointer-events-auto",
|
|
closeButton:
|
|
"absolute right-2 top-2 grid h-6 w-6 place-items-center rounded-lg border border-transparent text-slate-400 transition hover:bg-white/70 hover:text-slate-700"
|
|
}
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export function showMapNotice(options: MapNoticeOptions) {
|
|
const tone = options.tone ?? "info";
|
|
const position = options.position ?? DEFAULT_POSITION;
|
|
const duration = options.duration ?? (tone === "info" || tone === "success" ? 3200 : Infinity);
|
|
const dismissible = options.dismissible ?? tone !== "loading";
|
|
|
|
return toast.custom(
|
|
() => <MapNoticeContent {...options} tone={tone} />,
|
|
{
|
|
id: options.id,
|
|
duration,
|
|
position: sonnerPositions[position],
|
|
dismissible,
|
|
cancel: dismissible
|
|
? {
|
|
label: <MapNoticeCloseIcon />,
|
|
onClick: (event) => event.stopPropagation()
|
|
}
|
|
: undefined,
|
|
classNames: {
|
|
cancelButton: cn(
|
|
"absolute right-2 top-2 z-10 grid h-7 w-7 place-items-center border border-transparent bg-transparent p-0 text-slate-400 transition hover:border-slate-200 hover:bg-slate-50 hover:text-slate-700 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-600/25",
|
|
MAP_ICON_CELL_RADIUS_CLASS_NAME
|
|
)
|
|
},
|
|
className: cn("fixed! z-[60]! w-[min(420px,calc(100vw-24px))]!", positionClassNames[position])
|
|
}
|
|
);
|
|
}
|
|
|
|
export function dismissMapNotice(id?: string | number) {
|
|
toast.dismiss(id);
|
|
}
|
|
|
|
export function MapErrorNotice({ message, id = "map-error" }: { message: string; id?: string | number }) {
|
|
useEffect(() => {
|
|
showMapNotice({
|
|
id,
|
|
tone: "error",
|
|
title: "地图异常",
|
|
message,
|
|
duration: Infinity
|
|
});
|
|
|
|
return () => dismissMapNotice(id);
|
|
}, [id, message]);
|
|
|
|
return null;
|
|
}
|
|
|
|
export function MapLoadingNotice({
|
|
message = "正在初始化地图...",
|
|
title = "地图加载中",
|
|
id = "map-loading"
|
|
}: {
|
|
message?: string;
|
|
title?: string;
|
|
id?: string | number;
|
|
}) {
|
|
useEffect(() => {
|
|
showMapNotice({
|
|
id,
|
|
tone: "loading",
|
|
title,
|
|
message,
|
|
duration: Infinity,
|
|
dismissible: false
|
|
});
|
|
|
|
return () => dismissMapNotice(id);
|
|
}, [id, message, title]);
|
|
|
|
return null;
|
|
}
|
|
|
|
export type MapSourceStatus = "online" | "degraded" | "offline";
|
|
|
|
export type MapSourceStatusNoticeProps = {
|
|
sourceName: string;
|
|
status: MapSourceStatus;
|
|
message?: string;
|
|
id?: string | number;
|
|
actionLabel?: string;
|
|
onAction?: () => void;
|
|
};
|
|
|
|
export function MapSourceStatusNotice({
|
|
sourceName,
|
|
status,
|
|
message,
|
|
id = `map-source-${sourceName}`,
|
|
actionLabel,
|
|
onAction
|
|
}: MapSourceStatusNoticeProps) {
|
|
useEffect(() => {
|
|
showMapNotice({
|
|
id,
|
|
tone: getSourceStatusTone(status),
|
|
title: getSourceStatusTitle(sourceName, status),
|
|
message: message ?? getSourceStatusMessage(status),
|
|
duration: status === "online" ? 2600 : Infinity,
|
|
actionLabel,
|
|
onAction
|
|
});
|
|
|
|
return () => dismissMapNotice(id);
|
|
}, [actionLabel, id, message, onAction, sourceName, status]);
|
|
|
|
return null;
|
|
}
|
|
|
|
function MapNoticeContent({
|
|
title,
|
|
message,
|
|
tone = "info",
|
|
actionLabel,
|
|
onAction
|
|
}: MapNoticeOptions) {
|
|
const Icon = getNoticeIcon(tone);
|
|
|
|
return (
|
|
<MapNoticeFrame tone={tone}>
|
|
<MapNoticeIcon tone={tone} icon={Icon} />
|
|
<MapNoticeBody
|
|
title={title}
|
|
message={message}
|
|
action={actionLabel && onAction ? <MapNoticeAction label={actionLabel} onClick={onAction} /> : null}
|
|
/>
|
|
</MapNoticeFrame>
|
|
);
|
|
}
|
|
|
|
function MapNoticeFrame({ tone, children }: { tone: MapNoticeTone; children: ReactNode }) {
|
|
return (
|
|
<div
|
|
role={tone === "error" || tone === "warning" ? "alert" : "status"}
|
|
className={cn(
|
|
"relative flex w-full items-start gap-2.5 overflow-hidden border py-2.5 pl-3 pr-10 text-sm",
|
|
MAP_READABLE_RADIUS_CLASS_NAME,
|
|
toneClassNames[tone]
|
|
)}
|
|
>
|
|
<span className={cn("absolute bottom-0 left-0 top-0 w-1", noticeAccentClassNames[tone])} aria-hidden="true" />
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MapNoticeIcon({ tone, icon: Icon }: { tone: MapNoticeTone; icon: ReturnType<typeof getNoticeIcon> }) {
|
|
return (
|
|
<span
|
|
className={cn(
|
|
"mt-0.5 grid h-7 w-7 shrink-0 place-items-center border",
|
|
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
|
iconClassNames[tone],
|
|
noticeIconSurfaceClassNames[tone]
|
|
)}
|
|
>
|
|
<Icon size={17} aria-hidden="true" className={tone === "loading" ? "animate-spin" : undefined} />
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function MapNoticeBody({
|
|
title,
|
|
message,
|
|
action
|
|
}: {
|
|
title?: string;
|
|
message: string;
|
|
action?: ReactNode;
|
|
}) {
|
|
return (
|
|
<span className="min-w-0 flex-1">
|
|
{title ? <span className="block text-sm font-semibold leading-5 text-slate-950">{title}</span> : null}
|
|
<span className={cn("block text-sm leading-5 text-slate-600", title && "mt-0.5")}>{message}</span>
|
|
{action}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function MapNoticeAction({ label, onClick }: { label: string; onClick: () => void }) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
className={cn("mt-2 inline-flex h-7 items-center justify-center border border-blue-100 bg-blue-50 px-2.5 text-xs font-semibold text-blue-700 transition hover:bg-blue-100", MAP_COMPACT_RADIUS_CLASS_NAME)}
|
|
>
|
|
{label}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function MapNoticeCloseIcon() {
|
|
return (
|
|
<>
|
|
<span className="sr-only">关闭通知</span>
|
|
<X size={15} aria-hidden="true" />
|
|
</>
|
|
);
|
|
}
|
|
|
|
function getNoticeIcon(tone: MapNoticeTone) {
|
|
if (tone === "success") {
|
|
return CheckCircle2;
|
|
}
|
|
|
|
if (tone === "warning") {
|
|
return AlertTriangle;
|
|
}
|
|
|
|
if (tone === "error") {
|
|
return XCircle;
|
|
}
|
|
|
|
if (tone === "loading") {
|
|
return Loader2;
|
|
}
|
|
|
|
return Info;
|
|
}
|
|
|
|
function getSourceStatusTone(status: MapSourceStatus): MapNoticeTone {
|
|
if (status === "online") {
|
|
return "success";
|
|
}
|
|
|
|
if (status === "degraded") {
|
|
return "warning";
|
|
}
|
|
|
|
return "error";
|
|
}
|
|
|
|
function getSourceStatusTitle(sourceName: string, status: MapSourceStatus) {
|
|
if (status === "online") {
|
|
return `${sourceName} 已连接`;
|
|
}
|
|
|
|
if (status === "degraded") {
|
|
return `${sourceName} 降级运行`;
|
|
}
|
|
|
|
return `${sourceName} 不可用`;
|
|
}
|
|
|
|
function getSourceStatusMessage(status: MapSourceStatus) {
|
|
if (status === "online") {
|
|
return "地图数据源已恢复正常。";
|
|
}
|
|
|
|
if (status === "degraded") {
|
|
return "部分地图能力不可用,系统已使用可用替代方案。";
|
|
}
|
|
|
|
return "地图数据源请求失败,请检查网络或稍后重试。";
|
|
}
|