frontend: FE-032 操作反馈与微交互,轻量toast接管动作通知+日志滑入+按钮按压与勾选动画

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
fenghp
2026-06-11 17:34:52 +08:00
co-authored by Cursor
parent 45eeb10283
commit 9f9899b1d1
8 changed files with 160 additions and 28 deletions
+17 -17
View File
@@ -1,4 +1,4 @@
import { useCallback, useState } from "react"
import { useCallback, useEffect, useState } from "react"
import { TopBar, type PanelVisibility } from "~/components/app/top-bar"
import { WorkspaceDialogs } from "~/components/app/workspace-dialogs"
@@ -7,6 +7,7 @@ import { SourceTreePanel } from "~/components/explorer/source-tree-panel"
import { WorkspaceManageDialog } from "~/components/explorer/workspace-manage-dialog"
import { ChangesPanel } from "~/components/inspector/changes-panel"
import { ConsolePanel } from "~/components/logs/console-panel"
import { Toaster, showToast } from "~/components/ui/toaster"
import { TooltipProvider } from "~/components/ui/tooltip"
import { useFileActions } from "~/hooks/use-file-actions"
import { usePendingChanges } from "~/hooks/use-pending-changes"
@@ -71,6 +72,19 @@ export function WorkspaceShell({
refreshLogs,
})
// 把动作编排的 notice 转成右下角 toast:固定 id 让「正在…」与结果复用同一条目。
useEffect(() => {
if (!actions.notice) {
return
}
const { kind, text } = actions.notice
showToast({
id: "action",
kind: kind === "error" ? "error" : text.startsWith("正在") ? "loading" : "success",
text,
})
}, [actions.notice])
return (
<TooltipProvider>
<div className="flex h-svh flex-col overflow-hidden bg-background">
@@ -82,22 +96,6 @@ export function WorkspaceShell({
onManageWorkspace={() => setManageOpen(true)}
/>
{actions.notice && (
<div
className={cn(
"flex shrink-0 items-center gap-2 border-b px-3 py-1 text-xs",
actions.notice.kind === "error"
? "bg-destructive/10 text-destructive"
: "bg-primary/5 text-foreground",
)}
>
{actions.actionBusy && (
<span className="size-2 animate-pulse rounded-full bg-primary" />
)}
{actions.notice.text}
</div>
)}
<div className="flex min-h-0 flex-1">
{/* 左右面板保持挂载,通过宽度过渡实现折叠 / 展开动画 */}
<div
@@ -186,6 +184,8 @@ export function WorkspaceShell({
}}
/>
)}
<Toaster />
</div>
</TooltipProvider>
)
@@ -248,8 +248,9 @@ function PendingRow({
</span>
<Badge
variant="secondary"
className={cn("shrink-0 rounded-md", statusBadgeClass(change.status))}
className={cn("shrink-0 gap-1.5 rounded-md", statusBadgeClass(change.status))}
>
<span className="size-1.5 rounded-full bg-current opacity-60" />
{statusLabel(change.status)}
</Badge>
</div>
@@ -97,7 +97,8 @@ export function ConsolePanel({
{ordered.map((entry, index) => (
<li
key={`${entry.startedAt}-${entry.operation}-${index}`}
className="flex h-7 items-center gap-2 rounded px-1.5 text-xs hover:bg-muted"
className="animate-in fade-in slide-in-from-top-1 flex h-7 items-center gap-2 rounded fill-mode-backwards px-1.5 text-xs duration-200 transition-colors hover:bg-muted"
style={{ animationDelay: `${Math.min(index, 6) * 25}ms` }}
title={entry.errorMessage ?? entry.summary}
>
{entry.success ? (
+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>
)
}