frontend: FE-032 操作反馈与微交互,轻量toast接管动作通知+日志滑入+按钮按压与勾选动画
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 ? (
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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
@@ -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 回归(需真实操作链路)
|
||||||
- 遗留问题:
|
- 遗留问题:
|
||||||
|
- 无
|
||||||
|
|||||||
Reference in New Issue
Block a user