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:
parent
f3d1f358d6
commit
1661eccf24
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
Loading…
Reference in New Issue