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