fix: 修復搜尋框失去焦點問題並優化搜尋體驗

- 分離輸入顯示狀態(searchInput)和查詢狀態(debouncedSearchTerm)
- 新增 isSearching 狀態區分初始載入和搜尋載入,避免搜尋時觸發 loading 狀態
- 使用 useRef 追蹤輸入框 DOM 元素並實現智能焦點恢復機制
- 修復每次輸入後輸入框失去焦點需要重新點擊的 UX 問題
- 保持游標位置在正確的輸入位置,確保連續輸入體驗

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
鄭沛軒 2025-09-24 15:13:38 +08:00
parent 989e92ce85
commit 75f81f3e2e
1 changed files with 70 additions and 34 deletions

View File

@ -1,6 +1,6 @@
'use client'
import { useState, useEffect } from 'react'
import { useState, useEffect, useCallback, useRef } from 'react'
import Link from 'next/link'
import { ProtectedRoute } from '@/components/ProtectedRoute'
import { Navigation } from '@/components/Navigation'
@ -17,7 +17,8 @@ function FlashcardsContent() {
const toast = useToast()
const [activeTab, setActiveTab] = useState('all-cards')
// const [selectedSet, setSelectedSet] = useState<string | null>(null) // 移除 CardSets 相關
const [searchTerm, setSearchTerm] = useState('')
const [searchInput, setSearchInput] = useState('') // 輸入框顯示用狀態
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState('') // API 查詢用狀態
const [showAdvancedSearch, setShowAdvancedSearch] = useState(false)
const [searchFilters, setSearchFilters] = useState({
cefrLevel: '',
@ -29,10 +30,14 @@ function FlashcardsContent() {
// Real data from API
// const [cardSets, setCardSets] = useState<CardSet[]>([]) // 移除 CardSets 狀態
const [flashcards, setFlashcards] = useState<Flashcard[]>([])
const [loading, setLoading] = useState(true)
const [loading, setLoading] = useState(true) // 初始載入狀態
const [isSearching, setIsSearching] = useState(false) // 搜尋載入狀態
const [error, setError] = useState<string | null>(null)
const [totalCounts, setTotalCounts] = useState({ all: 0, favorites: 0 })
// useRef 追蹤輸入框 DOM 元素
const searchInputRef = useRef<HTMLInputElement>(null)
// 臨時使用學習功能的例句圖片作為測試
const getExampleImage = (word: string): string => {
const availableImages = [
@ -91,32 +96,55 @@ function FlashcardsContent() {
// Load data from API
useEffect(() => {
// 載入詞卡和統計
loadFlashcards()
loadFlashcards(true) // 初始載入
loadTotalCounts()
}, [])
// 監聽搜尋和篩選條件變化,重新載入資料
// 防抖邏輯:將輸入轉換為查詢詞
useEffect(() => {
const timeoutId = setTimeout(() => {
loadFlashcards()
}, 300) // 300ms 防抖
const timer = setTimeout(() => {
setDebouncedSearchTerm(searchInput)
}, 300)
return () => clearTimeout(timeoutId)
}, [searchTerm, searchFilters, activeTab])
return () => clearTimeout(timer)
}, [searchInput])
// 當查詢條件變更時重新載入資料
useEffect(() => {
loadFlashcards(false) // 搜尋載入,不觸發 loading 狀態
}, [debouncedSearchTerm, searchFilters, activeTab])
// 在搜尋完成後恢復焦點
useEffect(() => {
if (!isSearching && searchInputRef.current && document.activeElement !== searchInputRef.current) {
// 只有當搜尋框失去焦點且用戶正在輸入時才恢復焦點
const wasFocused = searchInput.length > 0 && !loading
if (wasFocused) {
const currentPosition = searchInputRef.current.selectionStart || searchInput.length
searchInputRef.current.focus()
searchInputRef.current.setSelectionRange(currentPosition, currentPosition)
}
}
}, [isSearching, loading, searchInput])
// 暫時移除 CardSets 功能,直接設定空陣列
// const loadCardSets = async () => {
// setCardSets([])
// }
const loadFlashcards = async () => {
const loadFlashcards = useCallback(async (isInitialLoad = false) => {
try {
setLoading(true)
// 區分初始載入和搜尋載入狀態
if (isInitialLoad) {
setLoading(true)
} else {
setIsSearching(true)
}
setError(null) // 清除之前的錯誤
// 使用進階篩選參數呼叫 API
const result = await flashcardsService.getFlashcards(
searchTerm || undefined,
debouncedSearchTerm || undefined,
activeTab === 'favorites',
searchFilters.cefrLevel || undefined,
searchFilters.partOfSpeech || undefined,
@ -135,9 +163,14 @@ function FlashcardsContent() {
setError(errorMessage)
console.error('❌ 詞卡載入異常:', err)
} finally {
setLoading(false)
// 清除對應的載入狀態
if (isInitialLoad) {
setLoading(false)
} else {
setIsSearching(false)
}
}
}
}, [debouncedSearchTerm, activeTab, searchFilters])
// 移除 selectedSet 依賴的 useEffect
// useEffect(() => {
@ -148,7 +181,7 @@ function FlashcardsContent() {
const handleFormSuccess = async () => {
setShowForm(false)
setEditingCard(null)
await loadFlashcards()
await loadFlashcards(false) // 表單操作後重新載入
await loadTotalCounts()
// 移除 loadCardSets() 調用
}
@ -166,7 +199,7 @@ function FlashcardsContent() {
try {
const result = await flashcardsService.deleteFlashcard(card.id)
if (result.success) {
await loadFlashcards()
await loadFlashcards(false) // 刪除操作後重新載入
await loadTotalCounts()
toast.success(`詞卡「${card.word}」已刪除`)
} else {
@ -190,7 +223,7 @@ function FlashcardsContent() {
const result = await flashcardsService.toggleFavorite(card.id)
if (result.success) {
// 重新載入詞卡以反映最新的收藏狀態
await loadFlashcards()
await loadFlashcards(false) // 收藏操作後重新載入
// 重新載入統計數量
await loadTotalCounts()
toast.success(`${card.isFavorite ? '已取消收藏' : '已加入收藏'}${card.word}`)
@ -221,7 +254,8 @@ function FlashcardsContent() {
// 清除所有篩選
const clearAllFilters = () => {
setSearchTerm('')
setSearchInput('')
setDebouncedSearchTerm('')
setSearchFilters({
cefrLevel: '',
partOfSpeech: '',
@ -231,17 +265,17 @@ function FlashcardsContent() {
}
// 檢查是否有活動篩選
const hasActiveFilters = searchTerm ||
const hasActiveFilters = debouncedSearchTerm ||
searchFilters.cefrLevel ||
searchFilters.partOfSpeech ||
searchFilters.masteryLevel ||
searchFilters.onlyFavorites
// 搜尋結果高亮函數
const highlightSearchTerm = (text: string, searchTerm: string) => {
if (!searchTerm || !text) return text
const highlightSearchTerm = (text: string, debouncedSearchTerm: string) => {
if (!debouncedSearchTerm || !text) return text
const regex = new RegExp(`(${searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi')
const regex = new RegExp(`(${debouncedSearchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi')
const parts = text.split(regex)
return parts.map((part, index) =>
@ -345,14 +379,16 @@ function FlashcardsContent() {
{/* 主要搜尋框 */}
<div className="relative mb-4">
<input
ref={searchInputRef}
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="搜尋詞彙、翻譯或定義..."
className="w-full pl-12 pr-20 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent text-base"
onKeyDown={(e) => {
if (e.key === 'Escape') {
setSearchTerm('')
setSearchInput('')
setDebouncedSearchTerm('')
}
}}
/>
@ -361,7 +397,7 @@ function FlashcardsContent() {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
{(searchTerm || hasActiveFilters) && (
{(searchInput || hasActiveFilters) && (
<div className="absolute inset-y-0 right-0 pr-3 flex items-center gap-2">
<span className="text-xs text-gray-500 bg-blue-100 px-2 py-1 rounded-full">
{filteredCards.length}
@ -493,7 +529,7 @@ function FlashcardsContent() {
)}
{/* 搜尋結果統計 */}
{(searchTerm || hasActiveFilters) && (
{(debouncedSearchTerm || hasActiveFilters) && (
<div className="flex items-center justify-between text-sm text-gray-600 bg-blue-50 px-4 py-2 rounded-lg">
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -501,8 +537,8 @@ function FlashcardsContent() {
</svg>
<span>
<strong className="text-blue-700">{filteredCards.length}</strong>
{searchTerm && (
<span> "<strong className="text-blue-700">{searchTerm}</strong>"</span>
{debouncedSearchTerm && (
<span> "<strong className="text-blue-700">{debouncedSearchTerm}</strong>"</span>
)}
</span>
</div>
@ -569,7 +605,7 @@ function FlashcardsContent() {
<div className="flex-1">
<div className="flex items-center gap-3">
<h3 className="text-xl font-bold text-gray-900">
{searchTerm ? highlightSearchTerm(card.word || '未設定', searchTerm) : (card.word || '未設定')}
{debouncedSearchTerm ? highlightSearchTerm(card.word || '未設定', debouncedSearchTerm) : (card.word || '未設定')}
</h3>
<span className="text-sm bg-gray-100 text-gray-700 px-2 py-1 rounded">
{card.partOfSpeech || 'unknown'}
@ -578,7 +614,7 @@ function FlashcardsContent() {
<div className="flex items-center gap-4 mt-1">
<span className="text-lg text-gray-900 font-medium">
{searchTerm ? highlightSearchTerm(card.translation || '未設定', searchTerm) : (card.translation || '未設定')}
{debouncedSearchTerm ? highlightSearchTerm(card.translation || '未設定', debouncedSearchTerm) : (card.translation || '未設定')}
</span>
{card.pronunciation && (
<div className="flex items-center gap-2">
@ -738,7 +774,7 @@ function FlashcardsContent() {
<div className="flex-1">
<div className="flex items-center gap-3">
<h3 className="text-xl font-bold text-gray-900">
{searchTerm ? highlightSearchTerm(card.word || '未設定', searchTerm) : (card.word || '未設定')}
{debouncedSearchTerm ? highlightSearchTerm(card.word || '未設定', debouncedSearchTerm) : (card.word || '未設定')}
</h3>
<span className="text-sm bg-gray-100 text-gray-700 px-2 py-1 rounded">
{card.partOfSpeech || 'unknown'}
@ -747,7 +783,7 @@ function FlashcardsContent() {
<div className="flex items-center gap-4 mt-1">
<span className="text-lg text-gray-900 font-medium">
{searchTerm ? highlightSearchTerm(card.translation || '未設定', searchTerm) : (card.translation || '未設定')}
{debouncedSearchTerm ? highlightSearchTerm(card.translation || '未設定', debouncedSearchTerm) : (card.translation || '未設定')}
</span>
{card.pronunciation && (
<div className="flex items-center gap-2">