From 75f81f3e2e0344fdd63395ab6522414ac2683341 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=84=AD=E6=B2=9B=E8=BB=92?= Date: Wed, 24 Sep 2025 15:13:38 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=BE=A9=E6=90=9C=E5=B0=8B?= =?UTF-8?q?=E6=A1=86=E5=A4=B1=E5=8E=BB=E7=84=A6=E9=BB=9E=E5=95=8F=E9=A1=8C?= =?UTF-8?q?=E4=B8=A6=E5=84=AA=E5=8C=96=E6=90=9C=E5=B0=8B=E9=AB=94=E9=A9=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 分離輸入顯示狀態(searchInput)和查詢狀態(debouncedSearchTerm) - 新增 isSearching 狀態區分初始載入和搜尋載入,避免搜尋時觸發 loading 狀態 - 使用 useRef 追蹤輸入框 DOM 元素並實現智能焦點恢復機制 - 修復每次輸入後輸入框失去焦點需要重新點擊的 UX 問題 - 保持游標位置在正確的輸入位置,確保連續輸入體驗 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- frontend/app/flashcards/page.tsx | 104 +++++++++++++++++++++---------- 1 file changed, 70 insertions(+), 34 deletions(-) diff --git a/frontend/app/flashcards/page.tsx b/frontend/app/flashcards/page.tsx index 21fcc98..9e81f7b 100644 --- a/frontend/app/flashcards/page.tsx +++ b/frontend/app/flashcards/page.tsx @@ -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(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([]) // 移除 CardSets 狀態 const [flashcards, setFlashcards] = useState([]) - const [loading, setLoading] = useState(true) + const [loading, setLoading] = useState(true) // 初始載入狀態 + const [isSearching, setIsSearching] = useState(false) // 搜尋載入狀態 const [error, setError] = useState(null) const [totalCounts, setTotalCounts] = useState({ all: 0, favorites: 0 }) + // useRef 追蹤輸入框 DOM 元素 + const searchInputRef = useRef(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() { {/* 主要搜尋框 */}
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() {
- {(searchTerm || hasActiveFilters) && ( + {(searchInput || hasActiveFilters) && (
{filteredCards.length} 結果 @@ -493,7 +529,7 @@ function FlashcardsContent() { )} {/* 搜尋結果統計 */} - {(searchTerm || hasActiveFilters) && ( + {(debouncedSearchTerm || hasActiveFilters) && (
@@ -501,8 +537,8 @@ function FlashcardsContent() { 找到 {filteredCards.length} 個詞卡 - {searchTerm && ( - ,包含 "{searchTerm}" + {debouncedSearchTerm && ( + ,包含 "{debouncedSearchTerm}" )}
@@ -569,7 +605,7 @@ function FlashcardsContent() {

- {searchTerm ? highlightSearchTerm(card.word || '未設定', searchTerm) : (card.word || '未設定')} + {debouncedSearchTerm ? highlightSearchTerm(card.word || '未設定', debouncedSearchTerm) : (card.word || '未設定')}

{card.partOfSpeech || 'unknown'} @@ -578,7 +614,7 @@ function FlashcardsContent() {
- {searchTerm ? highlightSearchTerm(card.translation || '未設定', searchTerm) : (card.translation || '未設定')} + {debouncedSearchTerm ? highlightSearchTerm(card.translation || '未設定', debouncedSearchTerm) : (card.translation || '未設定')} {card.pronunciation && (
@@ -738,7 +774,7 @@ function FlashcardsContent() {

- {searchTerm ? highlightSearchTerm(card.word || '未設定', searchTerm) : (card.word || '未設定')} + {debouncedSearchTerm ? highlightSearchTerm(card.word || '未設定', debouncedSearchTerm) : (card.word || '未設定')}

{card.partOfSpeech || 'unknown'} @@ -747,7 +783,7 @@ function FlashcardsContent() {
- {searchTerm ? highlightSearchTerm(card.translation || '未設定', searchTerm) : (card.translation || '未設定')} + {debouncedSearchTerm ? highlightSearchTerm(card.translation || '未設定', debouncedSearchTerm) : (card.translation || '未設定')} {card.pronunciation && (