iT邦幫忙

2026 iThome 鐵人賽

DAY 19
0
Vibe Coding

Vibe Coding的30天,自然語言與AI共舞,從Prompt到高品質原型落地系列 第 19

Day 19|全站UI體驗升級Sonner Toast通知系統、Modal對話框與Confirm Dialog封裝

  • 分享至 

  • xImage
  •  

在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)的每一步,都有極致順暢的視覺回應!


上一篇
Day 18|進階功能實作多欄位搜尋、Prisma分頁與URL狀態同步
系列文
Vibe Coding的30天,自然語言與AI共舞,從Prompt到高品質原型落地19
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言