在Day18中,我們成功完成了Prisma後端分頁、多欄位搜尋以及URLSearchParams雙向同步,讓VibePulse具備了可分享、高效能的資料檢索架構。
然而,現有的使用者操作反饋依然偏向冷冰冰:當使用者刪除技能時,沒有二次確認提示(可能導致誤刪);當新增或更新成功時,畫面上缺少明確的視覺通知動畫;當異步API發生錯誤時,也沒有全域層級的錯誤彈窗提醒。
今天(Day19),我們將進行全站UI/UX體驗的大躍升:
導入SonnerToast系統:提供高效能、動畫流暢的全域通知卡片(Success,Error,Info,Loading)。
封裝彈性Modal對話框(DialogComponent):以Accessible(無障礙)標準打造全站可複用的Modal。
實作危險操作安全防護網(ConfirmDialog):為技能刪除等不破壞性操作提供優雅的二次確認彈窗,提升產品成熟度!
1.架構藍圖:全域UI狀態與操作反饋機制
我們將在全站架構最頂層(app/layout.tsx)掛載SonnerToaster,讓任何ClientComponent(甚至是ReactQueryMutations的onSuccess/onError回調)都能以極簡的toast.success()API發送質感通知:
2.實戰步驟1:安裝與配置SonnerToast通知系統
步驟A:安裝Sonner與LucideReact圖標庫
在Terminal執行:
npm install sonner lucide-react
步驟B:在RootLayout部署Toaster(app/layout.tsx)
開啟@app/layout.tsx,加入具備深色主題(DarkMode)與玻璃擬物質感的Toaster配置:
// app/layout.tsx
import { Toaster } from 'sonner';
import QueryProvider from '@/components/providers/QueryProvider';
import './globals.css';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="zh-TW" className="dark">
<body className="bg-slate-950 text-slate-50 antialiased selection:bg-cyan-500 selection:text-white">
<QueryProvider>
{children}
{/* 全域 Toast 通知掛載點 */}
<Toaster
theme="dark"
position="top-right"
richColors
closeButton
toastOptions={{
style: {
background: 'rgba(15, 23, 42, 0.9)',
border: '1px solid rgba(51, 65, 85, 0.8)',
backdropFilter: 'blur(12px)',
color: '#f8fafc',
borderRadius: '0.75rem',
},
}}
/>
</QueryProvider>
</body>
</html>
);
}
3.實戰步驟2:封裝可複用Modal對話框(components/ui/Modal.tsx)
現在,我們請AI撰寫一個輕量、支援鍵盤Esc鍵關閉與點擊背景(Overlay)關閉的全功能Modal組件。
開啟Cursor,對著@components/ui/Modal.tsx發送Prompt:
請幫我建立一個高質感、通用的ReactModal對話框組件`@components/ui/Modal.tsx`。
需求細節:
1.Props介面:
-`isOpen`:boolean(控制顯示與否)
-`onClose`:()=>void(關閉回調)
-`title`:string(標題)
-`description`?:string(副標題/說明)
-`children`:React.ReactNode(Modal內部內容)
-`maxWidth`?:'sm'|'md'|'lg'|'xl'(預設'md')
2.互動體驗:
-點擊背景Backdrop觸發`onClose`。
-監聽`KeyboardEvent`,按`Escape`鍵觸發`onClose`。
-開啟時鎖定`document.body`滾動。
3.視覺樣式:
-使用TailwindCSS打造半透明深色毛玻璃背景(`backdrop-blur-mdbg-black/60`)。
-卡片邊框選用`borderborder-slate-800bg-slate-900`。
-右上角包含Lucide`X`關閉按鈕。
AI產出的components/ui/Modal.tsx:
// components/ui/Modal.tsx
'use client';
import { useEffect, useCallback } from 'react';
import { X } from 'lucide-react';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
description?: string;
children: React.ReactNode;
maxWidth?: 'sm' | 'md' | 'lg' | 'xl';
}
const maxWidthMap = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-xl',
};
export function Modal({
isOpen,
onClose,
title,
description,
children,
maxWidth = 'md',
}: ModalProps) {
// 按下 Esc 鍵關閉
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
},
[onClose]
);
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
window.addEventListener('keydown', handleKeyDown);
} else {
document.body.style.overflow = 'unset';
}
return () => {
document.body.style.overflow = 'unset';
window.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen, handleKeyDown]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6 animate-in fade-in duration-200">
{/* 1. 毛玻璃 Backdrop */}
<div
className="fixed inset-0 bg-black/70 backdrop-blur-md transition-opacity"
onClick={onClose}
/>
{/* 2. Modal 卡片主體 */}
<div
className={`relative w-full ${maxWidthMap[maxWidth]} z-10 rounded-2xl border border-slate-800 bg-slate-900/95 p-6 shadow-2xl transition-all`}
role="dialog"
aria-modal="true"
>
{/* Header */}
<div className="flex items-start justify-between pb-4 border-b border-slate-800">
<div>
<h3 className="text-lg font-bold text-slate-100">{title}</h3>
{description && (
<p className="mt-1 text-sm text-slate-400">{description}</p>
)}
</div>
<button
onClick={onClose}
className="rounded-lg p-1 text-slate-400 hover:bg-slate-800 hover:text-slate-200 transition"
>
<X className="h-5 w-5" />
</button>
</div>
{/* Content */}
<div className="pt-4">{children}</div>
</div>
</div>
);
}
4.實戰步驟3:封裝危險操作SecondaryConfirmation(components/ui/ConfirmDialog.tsx)
專門針對刪除技能、清除資料等破壞性動作,我們基於剛才的Modal封裝一個ConfirmDialog:
// components/ui/ConfirmDialog.tsx
'use client';
import { Modal } from './Modal';
import { AlertTriangle } from 'lucide-react';
interface ConfirmDialogProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title?: string;
message: string;
confirmText?: string;
cancelText?: string;
isLoading?: boolean;
}
export function ConfirmDialog({
isOpen,
onClose,
onConfirm,
title = '確認執行此操作?',
message,
confirmText = '確定刪除',
cancelText = '取消',
isLoading = false,
}: ConfirmDialogProps) {
return (
<Modal isOpen={isOpen} onClose={onClose} title={title} maxWidth="sm">
<div className="flex flex-col items-center text-center space-y-4">
<div className="rounded-full bg-rose-500/10 p-3 text-rose-400 border border-rose-500/20">
<AlertTriangle className="h-8 w-8" />
</div>
<p className="text-sm text-slate-300 leading-relaxed">{message}</p>
<div className="flex w-full items-center justify-end gap-3 pt-4 border-t border-slate-800">
<button
onClick={onClose}
disabled={isLoading}
className="w-1/2 rounded-xl border border-slate-700 bg-slate-800 py-2.5 text-sm font-medium text-slate-300 hover:bg-slate-700 disabled:opacity-50 transition"
>
{cancelText}
</button>
<button
onClick={onConfirm}
disabled={isLoading}
className="w-1/2 rounded-xl bg-rose-600 py-2.5 text-sm font-medium text-white hover:bg-rose-500 disabled:opacity-50 transition shadow-lg shadow-rose-600/20 flex items-center justify-center gap-2"
>
{isLoading ? (
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
) : null}
{confirmText}
</button>
</div>
</div>
</Modal>
);
}
5.實戰步驟4:全面升級CustomHooks與UI回應機制
最後,我們將SonnerToast整合至Day17的ReactQueryCustomHooks中,實現無感、流暢且具備視覺說服力的操作反饋!
更新@hooks/useSkills.ts中的Mutations:
// hooks/useSkills.ts (精簡升級範例)
import { toast } from 'sonner';
// 新增技能 Mutation
export function useCreateSkill() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createSkill,
onSuccess: (newSkill) => {
queryClient.invalidateQueries({ queryKey: SKILLS_QUERY_KEY });
toast.success(`技能「${newSkill.title}」已成功建立!`, {
description: '已同步持久化至 PostgreSQL 資料庫',
});
},
onError: (err: Error) => {
toast.error('建立技能失敗', { description: err.message });
},
});
}
// 刪除技能 Mutation
export function useDeleteSkill() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: deleteSkill,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: SKILLS_QUERY_KEY });
toast.success('技能已成功刪除');
},
onError: (err: Error) => {
toast.error('刪除失敗', { description: err.message });
},
});
}
今天我們完成了VibePulse前端介面品質的華麗蛻變:
SonnerToast系統:實現了跨組件、跨異步狀態的全域質感Toast反饋。
通用Modal與ConfirmDialog封裝:補齊了刪除防誤觸與無障礙防護最後一塊拼圖。
ReactQuery+Toast徹底融合:讓伺服器端異步操作(CRUD)的每一步,都有極致順暢的視覺回應!