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'
|
||||
|
||||
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">
|
||||
|
|
|
|||
Loading…
Reference in New Issue