feat: 實現優雅的角落通知系統取代惱人的 alert 彈窗

🎨 新增 Toast 通知組件:
- 位置:右上角固定位置,不阻擋主要內容
- 動畫:優雅的滑入/滑出動畫 (300ms)
- 自動消失:3秒後自動消失,無需手動點擊
- 手動關閉:可點擊 X 按鈕立即關閉
- 響應式設計:在手機上自動調整寬度

🎯 支援多種通知類型:
- success:  綠色成功通知(收藏、保存、刪除成功)
- error:  紅色錯誤通知(操作失敗、網路錯誤)
- warning: ⚠️ 黃色警告通知(重複詞卡、數據警告)
- info: ℹ️ 藍色資訊通知(提示信息)

🔄 全面替換所有 alert 調用:
- 詞卡管理頁面:收藏切換、創建、刪除、編輯操作
- AI 生成頁面:詞卡保存成功、重複檢測、錯誤處理
- 詞卡詳細頁面:更新、刪除、收藏操作反饋

🚀 用戶體驗大幅提升:
- 移除煩人的阻擋式 alert 彈窗
- 實現非侵入式的操作反饋
- 保持操作流程的連續性和流暢度
- 提供視覺上更加現代和優雅的交互體驗

修復編譯問題並重新啟動前端,確保所有功能正常運作。

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
鄭沛軒 2025-09-24 04:55:31 +08:00
parent bfa353bd6b
commit 724ba391b2
4 changed files with 153 additions and 25 deletions

View File

@ -4,6 +4,7 @@ import { useState, useEffect, use } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { Navigation } from '@/components/Navigation' import { Navigation } from '@/components/Navigation'
import { ProtectedRoute } from '@/components/ProtectedRoute' import { ProtectedRoute } from '@/components/ProtectedRoute'
import { useToast } from '@/components/Toast'
import { flashcardsService, type Flashcard } from '@/lib/services/flashcards' import { flashcardsService, type Flashcard } from '@/lib/services/flashcards'
interface FlashcardDetailPageProps { interface FlashcardDetailPageProps {
@ -24,6 +25,7 @@ export default function FlashcardDetailPage({ params }: FlashcardDetailPageProps
function FlashcardDetailContent({ cardId }: { cardId: string }) { function FlashcardDetailContent({ cardId }: { cardId: string }) {
const router = useRouter() const router = useRouter()
const toast = useToast()
const [flashcard, setFlashcard] = useState<Flashcard | null>(null) const [flashcard, setFlashcard] = useState<Flashcard | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@ -135,7 +137,7 @@ function FlashcardDetailContent({ cardId }: { cardId: string }) {
const updated = { ...flashcard, isFavorite: !flashcard.isFavorite } const updated = { ...flashcard, isFavorite: !flashcard.isFavorite }
setFlashcard(updated) setFlashcard(updated)
setEditedCard(updated) setEditedCard(updated)
alert(`${flashcard.isFavorite ? '已取消收藏' : '已加入收藏'}${flashcard.word}`) toast.success(`${flashcard.isFavorite ? '已取消收藏' : '已加入收藏'}${flashcard.word}`)
return return
} }
@ -143,10 +145,10 @@ function FlashcardDetailContent({ cardId }: { cardId: string }) {
const result = await flashcardsService.toggleFavorite(flashcard.id) const result = await flashcardsService.toggleFavorite(flashcard.id)
if (result.success) { if (result.success) {
setFlashcard(prev => prev ? { ...prev, isFavorite: !prev.isFavorite } : null) setFlashcard(prev => prev ? { ...prev, isFavorite: !prev.isFavorite } : null)
alert(`${flashcard.isFavorite ? '已取消收藏' : '已加入收藏'}${flashcard.word}`) toast.success(`${flashcard.isFavorite ? '已取消收藏' : '已加入收藏'}${flashcard.word}`)
} }
} catch (error) { } catch (error) {
alert('操作失敗,請重試') toast.error('操作失敗,請重試')
} }
} }
@ -159,7 +161,7 @@ function FlashcardDetailContent({ cardId }: { cardId: string }) {
if (flashcard.id.startsWith('mock')) { if (flashcard.id.startsWith('mock')) {
setFlashcard(editedCard) setFlashcard(editedCard)
setIsEditing(false) setIsEditing(false)
alert('詞卡更新成功!') toast.success('詞卡更新成功!')
return return
} }
@ -178,12 +180,12 @@ function FlashcardDetailContent({ cardId }: { cardId: string }) {
if (result.success) { if (result.success) {
setFlashcard(editedCard) setFlashcard(editedCard)
setIsEditing(false) setIsEditing(false)
alert('詞卡更新成功!') toast.success('詞卡更新成功!')
} else { } else {
alert(result.error || '更新失敗') toast.error(result.error || '更新失敗')
} }
} catch (error) { } catch (error) {
alert('更新失敗,請重試') toast.error('更新失敗,請重試')
} }
} }
@ -198,7 +200,7 @@ function FlashcardDetailContent({ cardId }: { cardId: string }) {
try { try {
// 假資料處理 // 假資料處理
if (flashcard.id.startsWith('mock')) { if (flashcard.id.startsWith('mock')) {
alert('詞卡已刪除(模擬)') toast.success('詞卡已刪除(模擬)')
router.push('/flashcards') router.push('/flashcards')
return return
} }
@ -206,13 +208,13 @@ function FlashcardDetailContent({ cardId }: { cardId: string }) {
// 真實API調用 // 真實API調用
const result = await flashcardsService.deleteFlashcard(flashcard.id) const result = await flashcardsService.deleteFlashcard(flashcard.id)
if (result.success) { if (result.success) {
alert('詞卡已刪除') toast.success('詞卡已刪除')
router.push('/flashcards') router.push('/flashcards')
} else { } else {
alert(result.error || '刪除失敗') toast.error(result.error || '刪除失敗')
} }
} catch (error) { } catch (error) {
alert('刪除失敗,請重試') toast.error('刪除失敗,請重試')
} }
} }
@ -507,6 +509,9 @@ function FlashcardDetailContent({ cardId }: { cardId: string }) {
</div> </div>
</button> </button>
</div> </div>
{/* Toast 通知系統 */}
<toast.ToastContainer />
</div> </div>
</div> </div>
) )

View File

@ -5,6 +5,7 @@ import Link from 'next/link'
import { ProtectedRoute } from '@/components/ProtectedRoute' import { ProtectedRoute } from '@/components/ProtectedRoute'
import { Navigation } from '@/components/Navigation' import { Navigation } from '@/components/Navigation'
import { FlashcardForm } from '@/components/FlashcardForm' import { FlashcardForm } from '@/components/FlashcardForm'
import { useToast } from '@/components/Toast'
// import { flashcardsService, type CardSet, type Flashcard } from '@/lib/services/flashcards' // import { flashcardsService, type CardSet, type Flashcard } from '@/lib/services/flashcards'
import { flashcardsService, type Flashcard } from '@/lib/services/flashcards' import { flashcardsService, type Flashcard } from '@/lib/services/flashcards'
@ -21,6 +22,7 @@ import { useRouter } from 'next/navigation'
function FlashcardsContent() { function FlashcardsContent() {
const router = useRouter() const router = useRouter()
const toast = useToast()
const [activeTab, setActiveTab] = useState('all-cards') const [activeTab, setActiveTab] = useState('all-cards')
// const [selectedSet, setSelectedSet] = useState<string | null>(null) // 移除 CardSets 相關 // const [selectedSet, setSelectedSet] = useState<string | null>(null) // 移除 CardSets 相關
const [searchTerm, setSearchTerm] = useState('') const [searchTerm, setSearchTerm] = useState('')
@ -182,12 +184,12 @@ function FlashcardsContent() {
if (result.success) { if (result.success) {
await loadFlashcards() await loadFlashcards()
await loadTotalCounts() await loadTotalCounts()
alert(`詞卡「${card.word}」已刪除`) toast.success(`詞卡「${card.word}」已刪除`)
} else { } else {
alert(result.error || '刪除失敗') toast.error(result.error || '刪除失敗')
} }
} catch (err) { } catch (err) {
alert('刪除失敗,請重試') toast.error('刪除失敗,請重試')
} }
} }
@ -196,7 +198,7 @@ function FlashcardsContent() {
// 如果是假資料,只更新本地狀態 // 如果是假資料,只更新本地狀態
if (card.id.startsWith('mock')) { if (card.id.startsWith('mock')) {
// 模擬資料暫時只顯示提示,實際狀態更新需要實作 // 模擬資料暫時只顯示提示,實際狀態更新需要實作
alert(`${card.isFavorite ? '已取消收藏' : '已加入收藏'}${card.word}`) toast.success(`${card.isFavorite ? '已取消收藏' : '已加入收藏'}${card.word}`)
return return
} }
@ -207,12 +209,12 @@ function FlashcardsContent() {
await loadFlashcards() await loadFlashcards()
// 重新載入統計數量 // 重新載入統計數量
await loadTotalCounts() await loadTotalCounts()
alert(`${card.isFavorite ? '已取消收藏' : '已加入收藏'}${card.word}`) toast.success(`${card.isFavorite ? '已取消收藏' : '已加入收藏'}${card.word}`)
} else { } else {
alert(result.error || '操作失敗') toast.error(result.error || '操作失敗')
} }
} catch (err) { } catch (err) {
alert('操作失敗,請重試') toast.error('操作失敗,請重試')
} }
} }
@ -890,6 +892,9 @@ function FlashcardsContent() {
}} }}
/> />
)} )}
{/* Toast 通知系統 */}
<toast.ToastContainer />
</div> </div>
) )
} }

View File

@ -4,6 +4,7 @@ import { useState, useMemo, useCallback } from 'react'
import { ProtectedRoute } from '@/components/ProtectedRoute' import { ProtectedRoute } from '@/components/ProtectedRoute'
import { Navigation } from '@/components/Navigation' import { Navigation } from '@/components/Navigation'
import { ClickableTextV2 } from '@/components/ClickableTextV2' import { ClickableTextV2 } from '@/components/ClickableTextV2'
import { useToast } from '@/components/Toast'
import { flashcardsService } from '@/lib/services/flashcards' import { flashcardsService } from '@/lib/services/flashcards'
import { Play } from 'lucide-react' import { Play } from 'lucide-react'
import Link from 'next/link' import Link from 'next/link'
@ -62,6 +63,7 @@ interface IdiomPopup {
} }
function GenerateContent() { function GenerateContent() {
const toast = useToast()
const [textInput, setTextInput] = useState('') const [textInput, setTextInput] = useState('')
const [isAnalyzing, setIsAnalyzing] = useState(false) const [isAnalyzing, setIsAnalyzing] = useState(false)
const [showAnalysisView, setShowAnalysisView] = useState(false) const [showAnalysisView, setShowAnalysisView] = useState(false)
@ -236,15 +238,15 @@ function GenerateContent() {
if (response.success) { if (response.success) {
// 顯示成功提示 // 顯示成功提示
const successMessage = `已成功將「${word}」保存到詞卡庫!` const successMessage = `已成功將「${word}」保存到詞卡庫!`
alert(successMessage) toast.success(successMessage)
console.log(successMessage) console.log('✅', successMessage)
return { success: true } return { success: true }
} else if (response.error && response.error.includes('已存在')) { } else if (response.error && response.error.includes('已存在')) {
// 顯示重複提示 // 顯示重複提示
const duplicateMessage = `⚠️ 詞卡「${word}」已經存在於詞卡庫中` const duplicateMessage = `詞卡「${word}」已經存在於詞卡庫中`
alert(duplicateMessage) toast.warning(duplicateMessage)
console.log(duplicateMessage) console.log('⚠️', duplicateMessage)
return { success: false, error: 'duplicate', message: duplicateMessage } return { success: false, error: 'duplicate', message: duplicateMessage }
} else { } else {
throw new Error(response.error || '保存失敗') throw new Error(response.error || '保存失敗')
@ -252,7 +254,7 @@ function GenerateContent() {
} catch (error) { } catch (error) {
console.error('Save word error:', error) console.error('Save word error:', error)
const errorMessage = error instanceof Error ? error.message : '保存失敗' const errorMessage = error instanceof Error ? error.message : '保存失敗'
alert(`保存詞卡失敗: ${errorMessage}`) toast.error(`保存詞卡失敗: ${errorMessage}`)
return { success: false, error: errorMessage } return { success: false, error: errorMessage }
} }
}, []) }, [])
@ -635,6 +637,9 @@ function GenerateContent() {
</div> </div>
</> </>
)} )}
{/* Toast 通知系統 */}
<toast.ToastContainer />
</div> </div>
</div> </div>
) )

View File

@ -0,0 +1,113 @@
'use client'
import { useState, useEffect } from 'react'
import { createPortal } from 'react-dom'
export interface ToastProps {
message: string
type: 'success' | 'error' | 'warning' | 'info'
duration?: number
onClose: () => void
}
const TOAST_ICONS = {
success: '✅',
error: '❌',
warning: '⚠️',
info: ''
} as const
const TOAST_STYLES = {
success: 'bg-green-50 border-green-200 text-green-800',
error: 'bg-red-50 border-red-200 text-red-800',
warning: 'bg-yellow-50 border-yellow-200 text-yellow-800',
info: 'bg-blue-50 border-blue-200 text-blue-800'
} as const
export function Toast({ message, type, duration = 3000, onClose }: ToastProps) {
const [isVisible, setIsVisible] = useState(false)
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
// 出現動畫
const showTimer = setTimeout(() => setIsVisible(true), 50)
// 自動消失
const hideTimer = setTimeout(() => {
setIsVisible(false)
// 等待動畫完成後關閉
setTimeout(onClose, 300)
}, duration)
return () => {
clearTimeout(showTimer)
clearTimeout(hideTimer)
}
}, [duration, onClose])
if (!mounted) return null
return createPortal(
<div
className={`fixed top-4 right-4 z-50 max-w-sm w-full transform transition-all duration-300 ease-in-out ${
isVisible ? 'translate-x-0 opacity-100' : 'translate-x-full opacity-0'
}`}
>
<div className={`rounded-lg border p-4 shadow-lg backdrop-blur-sm ${TOAST_STYLES[type]}`}>
<div className="flex items-center gap-3">
<span className="text-lg flex-shrink-0">{TOAST_ICONS[type]}</span>
<p className="font-medium text-sm leading-relaxed flex-1">{message}</p>
<button
onClick={() => {
setIsVisible(false)
setTimeout(onClose, 300)
}}
className="text-gray-400 hover:text-gray-600 transition-colors flex-shrink-0"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>,
document.body
)
}
// Toast 管理 Hook
export function useToast() {
const [toasts, setToasts] = useState<Array<{ id: string; props: Omit<ToastProps, 'onClose'> }>>([])
const showToast = (props: Omit<ToastProps, 'onClose'>) => {
const id = Math.random().toString(36).substring(2, 11)
setToasts(prev => [...prev, { id, props }])
}
const hideToast = (id: string) => {
setToasts(prev => prev.filter(toast => toast.id !== id))
}
const ToastContainer = () => (
<>
{toasts.map(({ id, props }) => (
<Toast
key={id}
{...props}
onClose={() => hideToast(id)}
/>
))}
</>
)
return {
showToast,
ToastContainer,
// 便捷方法
success: (message: string, duration?: number) => showToast({ message, type: 'success', duration }),
error: (message: string, duration?: number) => showToast({ message, type: 'error', duration }),
warning: (message: string, duration?: number) => showToast({ message, type: 'warning', duration }),
info: (message: string, duration?: number) => showToast({ message, type: 'info', duration })
}
}