frontend: FE-032 轻量toast接管动作通知+日志滑入+按钮按压与勾选动画

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
fenghp
2026-06-11 20:24:57 +08:00
co-authored by Cursor
parent 261778732d
commit 4703a03f21
8 changed files with 158 additions and 28 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ import { Slot } from "radix-ui"
import { cn } from "~/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all duration-150 outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px active:not-aria-[haspopup]:scale-[0.98] disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
+1 -1
View File
@@ -19,7 +19,7 @@ function Checkbox({
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
className="animate-in zoom-in-50 fade-in grid place-content-center text-current duration-150 [&>svg]:size-3.5"
>
<CheckIcon
/>
+118
View File
@@ -0,0 +1,118 @@
import { useEffect, useState } from "react"
import { AlertCircle, CheckCircle2, Loader2 } from "lucide-react"
import { cn } from "~/lib/utils"
// 轻量 toast:success / error 自动消失,loading 常驻直到被同 id 更新或手动关闭。
export type ToastKind = "success" | "error" | "loading"
export interface ToastItem {
id: string
kind: ToastKind
text: string
}
type Listener = (toasts: ToastItem[]) => void
// 模块级单例状态,让任意调用方无需 context 即可弹出 toast。
let currentToasts: ToastItem[] = []
const listeners = new Set<Listener>()
const dismissTimers = new Map<string, ReturnType<typeof setTimeout>>()
const AUTO_DISMISS_MS = 3200
const MAX_VISIBLE = 4
/**
* 通知所有挂载中的 Toaster 重新渲染。
*/
function emit() {
for (const listener of listeners) {
listener(currentToasts)
}
}
/**
* 关闭指定 toast。
*/
export function dismissToast(id: string) {
const timer = dismissTimers.get(id)
if (timer) {
clearTimeout(timer)
dismissTimers.delete(id)
}
currentToasts = currentToasts.filter((item) => item.id !== id)
emit()
}
/**
* 展示或更新一条 toast:同 id 复用同一条目并重置自动消失计时;
* loading 类型不自动消失,等待后续同 id 的 success / error 替换。
*/
export function showToast(input: { id?: string; kind: ToastKind; text: string }): string {
const id = input.id ?? `toast-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
const existing = currentToasts.find((item) => item.id === id)
const next: ToastItem = { id, kind: input.kind, text: input.text }
currentToasts = existing
? currentToasts.map((item) => (item.id === id ? next : item))
: [...currentToasts.slice(-(MAX_VISIBLE - 1)), next]
const timer = dismissTimers.get(id)
if (timer) {
clearTimeout(timer)
dismissTimers.delete(id)
}
if (input.kind !== "loading") {
dismissTimers.set(
id,
setTimeout(() => dismissToast(id), AUTO_DISMISS_MS),
)
}
emit()
return id
}
// toast 类型对应的图标与配色。
const KIND_VISUALS: Record<ToastKind, { icon: typeof CheckCircle2; className: string }> = {
success: { icon: CheckCircle2, className: "text-emerald-600 dark:text-emerald-400" },
error: { icon: AlertCircle, className: "text-destructive" },
loading: { icon: Loader2, className: "animate-spin text-primary" },
}
/**
* toast 挂载点:固定在窗口右下角,新条目滑入,点击可关闭。
*/
export function Toaster() {
const [items, setItems] = useState<ToastItem[]>([])
useEffect(() => {
const listener: Listener = (toasts) => setItems([...toasts])
listeners.add(listener)
setItems([...currentToasts])
return () => {
listeners.delete(listener)
}
}, [])
if (items.length === 0) {
return null
}
return (
<div className="pointer-events-none fixed right-4 bottom-4 z-[60] flex w-80 flex-col gap-2">
{items.map((item) => {
const visual = KIND_VISUALS[item.kind]
const Icon = visual.icon
return (
<button
key={item.id}
type="button"
onClick={() => dismissToast(item.id)}
className="animate-in fade-in slide-in-from-bottom-2 ease-out-quart pointer-events-auto flex items-start gap-2.5 rounded-lg border bg-popover p-3 text-left text-sm shadow-overlay duration-200"
>
<Icon className={cn("mt-0.5 size-4 shrink-0", visual.className)} />
<span className="min-w-0 break-words">{item.text}</span>
</button>
)
})}
</div>
)
}