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 { TopBar, type PanelVisibility } from "~/components/app/top-bar"
import { WorkspaceDialogs } from "~/components/app/workspace-dialogs" 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 { WorkspaceManageDialog } from "~/components/explorer/workspace-manage-dialog"
import { ChangesPanel } from "~/components/inspector/changes-panel" import { ChangesPanel } from "~/components/inspector/changes-panel"
import { ConsolePanel } from "~/components/logs/console-panel" import { ConsolePanel } from "~/components/logs/console-panel"
import { Toaster, showToast } from "~/components/ui/toaster"
import { TooltipProvider } from "~/components/ui/tooltip" import { TooltipProvider } from "~/components/ui/tooltip"
import { useFileActions } from "~/hooks/use-file-actions" import { useFileActions } from "~/hooks/use-file-actions"
import { usePendingChanges } from "~/hooks/use-pending-changes" import { usePendingChanges } from "~/hooks/use-pending-changes"
@@ -71,6 +72,19 @@ export function WorkspaceShell({
refreshLogs, 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 ( return (
<TooltipProvider> <TooltipProvider>
<div className="flex h-svh flex-col overflow-hidden bg-background"> <div className="flex h-svh flex-col overflow-hidden bg-background">
@@ -82,22 +96,6 @@ export function WorkspaceShell({
onManageWorkspace={() => setManageOpen(true)} 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 className="flex min-h-0 flex-1">
{/* 左右面板保持挂载,通过宽度过渡实现折叠 / 展开动画 */} {/* 左右面板保持挂载,通过宽度过渡实现折叠 / 展开动画 */}
<div <div
@@ -186,6 +184,8 @@ export function WorkspaceShell({
}} }}
/> />
)} )}
<Toaster />
</div> </div>
</TooltipProvider> </TooltipProvider>
) )
@@ -248,8 +248,9 @@ function PendingRow({
</span> </span>
<Badge <Badge
variant="secondary" 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)} {statusLabel(change.status)}
</Badge> </Badge>
</div> </div>
@@ -97,7 +97,8 @@ export function ConsolePanel({
{ordered.map((entry, index) => ( {ordered.map((entry, index) => (
<li <li
key={`${entry.startedAt}-${entry.operation}-${index}`} 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} title={entry.errorMessage ?? entry.summary}
> >
{entry.success ? ( {entry.success ? (
+1 -1
View File
@@ -5,7 +5,7 @@ import { Slot } from "radix-ui"
import { cn } from "~/lib/utils" import { cn } from "~/lib/utils"
const buttonVariants = cva( 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: { variants: {
variant: { variant: {
+1 -1
View File
@@ -19,7 +19,7 @@ function Checkbox({
> >
<CheckboxPrimitive.Indicator <CheckboxPrimitive.Indicator
data-slot="checkbox-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 <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>
)
}
+4 -4
View File
@@ -31,10 +31,10 @@ blocked
| Core | 12 | 12 | 100% | | Core | 12 | 12 | 100% |
| Server API | 10 | 10 | 100% | | Server API | 10 | 10 | 100% |
| CLI | 2 | 2 | 100% | | CLI | 2 | 2 | 100% |
| Frontend | 31 | 34 | 91% | | Frontend | 32 | 34 | 94% |
| Feature E2E | 0 | 5 | 0% | | Feature E2E | 0 | 5 | 0% |
| Release | 0 | 4 | 0% | | Release | 0 | 4 | 0% |
| Overall | 55 | 67 | 82% | | Overall | 56 | 67 | 84% |
## 当前阶段 ## 当前阶段
@@ -46,7 +46,7 @@ UI 风格改版(分支 `feature/ui-modern-restyle`,FE-026 ~ FE-034,需求
## 下一步任务 ## 下一步任务
- [FE-032-操作反馈与微交互.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-032-操作反馈与微交互.md) - [FE-033-明暗主题跟随系统.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-033-明暗主题跟随系统.md)
- UI 改版完成后再回到 [FEATURE-001-连接与目录浏览端到端验收.md](/Users/fenghp/Desktop/DEV/project/mydev/task/feature/FEATURE-001-连接与目录浏览端到端验收.md)(需要真实 TFS 环境) - UI 改版完成后再回到 [FEATURE-001-连接与目录浏览端到端验收.md](/Users/fenghp/Desktop/DEV/project/mydev/task/feature/FEATURE-001-连接与目录浏览端到端验收.md)(需要真实 TFS 环境)
## Core ## Core
@@ -123,7 +123,7 @@ UI 风格改版(分支 `feature/ui-modern-restyle`,FE-026 ~ FE-034,需求
| [FE-029-工作台外壳与顶栏改版.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-029-工作台外壳与顶栏改版.md) | done | P0 | | [FE-029-工作台外壳与顶栏改版.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-029-工作台外壳与顶栏改版.md) | done | P0 |
| [FE-030-目录树与文件列表精致化.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-030-目录树与文件列表精致化.md) | done | P0 | | [FE-030-目录树与文件列表精致化.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-030-目录树与文件列表精致化.md) | done | P0 |
| [FE-031-弹窗体系统一现代风格.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-031-弹窗体系统一现代风格.md) | done | P1 | | [FE-031-弹窗体系统一现代风格.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-031-弹窗体系统一现代风格.md) | done | P1 |
| [FE-032-操作反馈与微交互.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-032-操作反馈与微交互.md) | todo | P1 | | [FE-032-操作反馈与微交互.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-032-操作反馈与微交互.md) | done | P1 |
| [FE-033-明暗主题跟随系统.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-033-明暗主题跟随系统.md) | todo | P1 | | [FE-033-明暗主题跟随系统.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-033-明暗主题跟随系统.md) | todo | P1 |
| [FE-034-UI改版回归验收.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-034-UI改版回归验收.md) | todo | P0 | | [FE-034-UI改版回归验收.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-034-UI改版回归验收.md) | todo | P0 |
@@ -2,7 +2,7 @@
## 状态 ## 状态
todo done
## 优先级 ## 优先级
@@ -59,9 +59,21 @@ cd mactfsui && pnpm typecheck && pnpm electron:dev
## 完成记录 ## 完成记录
待完成后填写:
- 实际修改文件: - 实际修改文件:
- 新增 `mactfsui/app/components/ui/toaster.tsx`(无依赖的轻量 toast:模块级单例 + 订阅渲染)
- `mactfsui/app/components/app/workspace-shell.tsx`(notice → toast 接线,移除原顶部通知条,挂载 Toaster)
- `mactfsui/app/components/logs/console-panel.tsx`(日志条目滑入动画)
- `mactfsui/app/components/inspector/changes-panel.tsx`(状态徽标加圆点,与文件列表统一)
- `mactfsui/app/components/ui/button.tsx`(按压缩放 0.98 + 统一 150ms 过渡)
- `mactfsui/app/components/ui/checkbox.tsx`(勾选指示 zoom+fade 入场动画)
- 实际实现内容: - 实际实现内容:
- toast 体系:success / error 3.2 秒自动消失,loading(「正在…」文案)常驻并被同 id 结果替换;右下角滑入、点击关闭、最多同屏 4 条
- 动作编排零改动:在 workspace-shell 监听 `actions.notice`,按 kind 与「正在」前缀映射为 loading / success / error toast(GetLatest、签出、签入、目录对比等长操作天然获得进行中→结果的进度反馈)
- 日志面板新条目 fade+slide 滑入(fill-mode-backwards 阶梯延迟)
- 控件微交互:按钮按压缩放、复选框勾选动画;输入框聚焦光环沿用既有 focus-visible ring
- 测试结果: - 测试结果:
- `pnpm typecheck` 通过、`pnpm build` 通过
- 产物 CSS 确认 zoom-in-50 / slide-in-from-bottom-2 已编译
- toast 实际触发流程留待 FE-034 回归(需真实操作链路)
- 遗留问题: - 遗留问题:
- 无