feat: 改進詞卡編輯流程,從列表導航到詳細頁面編輯

 UX改進:
- 點擊列表中的編輯按鈕直接導航到詳細頁面
- 詳細頁面自動開啟編輯模式,提供專注的編輯環境
- 移除列表頁面底部的編輯表單,簡化界面

🔧 技術實作:
- 使用URL參數(?edit=true)傳遞編輯狀態
- 詳細頁面檢查URL參數自動開啟編輯模式
- 清理不必要的編輯表單狀態管理

🚀 編輯體驗提升:
- 在詳細頁面編輯,享有完整功能(圖片生成、統計資訊等)
- 避免在列表頁面編輯時的干擾和空間限制
- 統一所有編輯操作在同一位置進行

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
鄭沛軒 2025-09-25 09:09:17 +08:00
parent f3d1f358d6
commit 1661eccf24
2 changed files with 112 additions and 49 deletions

View File

@ -1,7 +1,7 @@
'use client'
import { useState, useEffect, use } from 'react'
import { useRouter } from 'next/navigation'
import { useRouter, useSearchParams } from 'next/navigation'
import { Navigation } from '@/components/Navigation'
import { ProtectedRoute } from '@/components/ProtectedRoute'
import { useToast } from '@/components/Toast'
@ -25,6 +25,7 @@ export default function FlashcardDetailPage({ params }: FlashcardDetailPageProps
function FlashcardDetailContent({ cardId }: { cardId: string }) {
const router = useRouter()
const searchParams = useSearchParams()
const toast = useToast()
const [flashcard, setFlashcard] = useState<Flashcard | null>(null)
const [loading, setLoading] = useState(true)
@ -32,6 +33,10 @@ function FlashcardDetailContent({ cardId }: { cardId: string }) {
const [isEditing, setIsEditing] = useState(false)
const [editedCard, setEditedCard] = useState<any>(null)
// 圖片生成狀態
const [isGeneratingImage, setIsGeneratingImage] = useState(false)
const [generationProgress, setGenerationProgress] = useState<string>('')
// 假資料 - 用於展示效果
const mockCards: {[key: string]: any} = {
'mock1': {
@ -94,18 +99,13 @@ function FlashcardDetailContent({ cardId }: { cardId: string }) {
return
}
// 載入真實詞卡 - 使用列表 API 然後找到對應詞卡 (因為列表 API 有圖片資訊)
const result = await flashcardsService.getFlashcards()
// 載入真實詞卡 - 使用直接 API 調用
const result = await flashcardsService.getFlashcard(cardId)
if (result.success && result.data) {
const targetCard = result.data.flashcards.find(card => card.id === cardId)
if (targetCard) {
setFlashcard(targetCard)
setEditedCard(targetCard)
} else {
throw new Error('詞卡不存在')
}
setFlashcard(result.data)
setEditedCard(result.data)
} else {
throw new Error(result.error || '載入詞卡失敗')
throw new Error(result.error || '詞卡不存在')
}
} catch (err) {
setError('載入詞卡時發生錯誤')
@ -117,6 +117,15 @@ function FlashcardDetailContent({ cardId }: { cardId: string }) {
loadFlashcard()
}, [cardId])
// 檢查 URL 參數,自動開啟編輯模式
useEffect(() => {
if (searchParams.get('edit') === 'true' && flashcard) {
setIsEditing(true)
// 清理 URL 參數,保持 URL 乾淨
router.replace(`/flashcards/${cardId}`)
}
}, [flashcard, searchParams, cardId, router])
// 獲取CEFR等級顏色
const getCEFRColor = (level: string) => {
switch (level) {
@ -251,6 +260,53 @@ function FlashcardDetailContent({ cardId }: { cardId: string }) {
}
}
// 處理圖片生成
const handleGenerateImage = async () => {
if (!flashcard || isGeneratingImage) return
try {
setIsGeneratingImage(true)
setGenerationProgress('啟動生成中...')
toast.info(`開始為「${flashcard.word}」生成例句圖片...`)
const generateResult = await imageGenerationService.generateImage(flashcard.id)
if (!generateResult.success || !generateResult.data) {
throw new Error(generateResult.error || '啟動生成失敗')
}
const requestId = generateResult.data.requestId
setGenerationProgress('Gemini 生成描述中...')
const finalStatus = await imageGenerationService.pollUntilComplete(
requestId,
(status) => {
const stage = status.stages.gemini.status === 'completed'
? 'Replicate 生成圖片中...' : 'Gemini 生成描述中...'
setGenerationProgress(stage)
},
5
)
if (finalStatus.overallStatus === 'completed') {
setGenerationProgress('生成完成,載入中...')
// 重新載入詞卡資料
const result = await flashcardsService.getFlashcard(cardId)
if (result.success && result.data) {
setFlashcard(result.data)
setEditedCard(result.data)
}
toast.success(`${flashcard.word}」的例句圖片生成完成!`)
} else {
throw new Error('圖片生成未完成')
}
} catch (error: any) {
toast.error(`圖片生成失敗: ${error.message || '未知錯誤'}`)
} finally {
setIsGeneratingImage(false)
setGenerationProgress('')
}
}
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
@ -382,12 +438,50 @@ function FlashcardDetailContent({ cardId }: { cardId: string }) {
<h3 className="font-semibold text-blue-900 mb-3 text-left"></h3>
{/* 例句圖片 */}
<div className="mb-4">
<img
src={getExampleImage(flashcard) || '/images/examples/bring_up.png'}
alt={`${flashcard.word} example`}
className="w-full max-w-md mx-auto rounded-lg border border-blue-300"
/>
<div className="mb-4 relative">
{getExampleImage(flashcard) ? (
<img
src={getExampleImage(flashcard)!}
alt={`${flashcard.word} example`}
className="w-full max-w-md mx-auto rounded-lg border border-blue-300"
/>
) : (
<div className="w-full max-w-md mx-auto h-48 bg-gray-100 rounded-lg border-2 border-dashed border-gray-300 flex items-center justify-center">
<div className="text-center text-gray-500">
<svg className="w-12 h-12 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<p className="text-sm"></p>
<button
onClick={handleGenerateImage}
disabled={isGeneratingImage}
className="mt-2 px-3 py-1 text-sm bg-blue-100 text-blue-700 rounded-full hover:bg-blue-200 transition-colors disabled:opacity-50"
>
{isGeneratingImage ? generationProgress : '生成圖片'}
</button>
</div>
</div>
)}
{/* 圖片上的生成按鈕 */}
{getExampleImage(flashcard) && !isGeneratingImage && (
<button
onClick={handleGenerateImage}
className="absolute top-2 right-2 px-2 py-1 text-xs bg-white bg-opacity-90 text-gray-700 rounded-md hover:bg-opacity-100 transition-all shadow-sm"
>
</button>
)}
{/* 生成進度覆蓋 */}
{isGeneratingImage && getExampleImage(flashcard) && (
<div className="absolute inset-0 bg-white bg-opacity-90 rounded-lg flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-2"></div>
<p className="text-sm text-gray-600">{generationProgress}</p>
</div>
</div>
)}
</div>
<div className="space-y-3">

View File

@ -36,8 +36,6 @@ function FlashcardsContent() {
const router = useRouter()
const toast = useToast()
const [activeTab, setActiveTab] = useState<'all-cards' | 'favorites'>('all-cards')
const [showForm, setShowForm] = useState(false)
const [editingCard, setEditingCard] = useState<Flashcard | null>(null)
const [showAdvancedSearch, setShowAdvancedSearch] = useState(false)
const [totalCounts, setTotalCounts] = useState({ all: 0, favorites: 0 })
@ -150,17 +148,9 @@ function FlashcardsContent() {
}
}
// 處理表單操作
const handleFormSuccess = async () => {
setShowForm(false)
setEditingCard(null)
await searchActions.refresh()
await loadTotalCounts()
}
const handleEdit = (card: Flashcard) => {
setEditingCard(card)
setShowForm(true)
router.push(`/flashcards/${card.id}?edit=true`)
}
const handleDelete = async (card: Flashcard) => {
@ -345,27 +335,6 @@ function FlashcardsContent() {
/>
</div>
{/* Form Modal */}
{showForm && (
<FlashcardForm
cardSets={[]}
initialData={editingCard ? {
id: editingCard.id,
word: editingCard.word,
translation: editingCard.translation,
definition: editingCard.definition,
pronunciation: editingCard.pronunciation,
partOfSpeech: editingCard.partOfSpeech,
example: editingCard.example,
} : undefined}
isEdit={!!editingCard}
onSuccess={handleFormSuccess}
onCancel={() => {
setShowForm(false)
setEditingCard(null)
}}
/>
)}
{/* Toast */}
<toast.ToastContainer />