fix: 修復圖片生成後前端未即時顯示的問題

- 修復 FlashcardsController 中變數引用和型別匹配錯誤
- 統一 GetFlashcards 和 GetFlashcard API 的圖片資料結構
- 更新前端使用 refetch() 清除快取確保載入最新圖片資料
- 完善圖片生成後的狀態更新和資料刷新機制

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
鄭沛軒 2025-09-25 08:00:07 +08:00
parent 561ffd8e13
commit 48bbfb867b
3 changed files with 281 additions and 25 deletions

View File

@ -116,23 +116,18 @@ public class FlashcardsController : ControllerBase
var flashcardDtos = new List<object>();
foreach (var flashcard in flashcards)
{
var exampleImages = new List<ExampleImageDto>();
// 處理關聯的圖片
foreach (var flashcardImage in flashcard.FlashcardExampleImages)
{
var imageUrl = await _imageStorageService.GetImageUrlAsync(flashcardImage.ExampleImage.RelativePath);
exampleImages.Add(new ExampleImageDto
// 獲取例句圖片資料 (與 GetFlashcard 方法保持一致)
var exampleImages = flashcard.FlashcardExampleImages?
.Select(fei => new
{
Id = flashcardImage.ExampleImage.Id.ToString(),
ImageUrl = imageUrl,
IsPrimary = flashcardImage.IsPrimary,
QualityScore = flashcardImage.ExampleImage.QualityScore,
FileSize = flashcardImage.ExampleImage.FileSize,
CreatedAt = flashcardImage.ExampleImage.CreatedAt
});
}
Id = fei.ExampleImage.Id,
ImageUrl = $"http://localhost:5008/images/examples/{fei.ExampleImage.RelativePath}",
IsPrimary = fei.IsPrimary,
QualityScore = fei.ExampleImage.QualityScore,
FileSize = fei.ExampleImage.FileSize,
CreatedAt = fei.ExampleImage.CreatedAt
})
.ToList();
flashcardDtos.Add(new
{
@ -152,9 +147,9 @@ public class FlashcardsController : ControllerBase
flashcard.CreatedAt,
flashcard.UpdatedAt,
// 新增圖片相關欄位
ExampleImages = exampleImages,
HasExampleImage = exampleImages.Any(),
PrimaryImageUrl = exampleImages.FirstOrDefault(img => img.IsPrimary)?.ImageUrl
ExampleImages = exampleImages ?? (object)new List<object>(),
HasExampleImage = exampleImages?.Any() ?? false,
PrimaryImageUrl = exampleImages?.FirstOrDefault(img => img.IsPrimary)?.ImageUrl
});
}
@ -283,6 +278,8 @@ public class FlashcardsController : ControllerBase
var userId = GetUserId();
var flashcard = await _context.Flashcards
.Include(f => f.FlashcardExampleImages)
.ThenInclude(fei => fei.ExampleImage)
.FirstOrDefaultAsync(f => f.Id == id && f.UserId == userId);
if (flashcard == null)
@ -290,6 +287,19 @@ public class FlashcardsController : ControllerBase
return NotFound(new { Success = false, Error = "Flashcard not found" });
}
// 獲取例句圖片資料
var exampleImages = flashcard.FlashcardExampleImages
?.Select(fei => new
{
Id = fei.ExampleImage.Id,
ImageUrl = $"http://localhost:5008/images/examples/{fei.ExampleImage.RelativePath}",
IsPrimary = fei.IsPrimary,
QualityScore = fei.ExampleImage.QualityScore,
FileSize = fei.ExampleImage.FileSize,
CreatedAt = fei.ExampleImage.CreatedAt
})
.ToList();
return Ok(new
{
Success = true,
@ -309,7 +319,14 @@ public class FlashcardsController : ControllerBase
flashcard.NextReviewDate,
flashcard.DifficultyLevel,
flashcard.CreatedAt,
flashcard.UpdatedAt
flashcard.UpdatedAt,
// 新增圖片相關欄位
ExampleImages = exampleImages ?? (object)new List<object>(),
HasExampleImage = exampleImages?.Any() ?? false,
PrimaryImageUrl = flashcard.FlashcardExampleImages?
.Where(fei => fei.IsPrimary)
.Select(fei => $"http://localhost:5008/images/examples/{fei.ExampleImage.RelativePath}")
.FirstOrDefault()
}
});
}

View File

@ -8,6 +8,7 @@ import { Navigation } from '@/components/Navigation'
import { FlashcardForm } from '@/components/FlashcardForm'
import { useToast } from '@/components/Toast'
import { flashcardsService, type Flashcard } from '@/lib/services/flashcards'
import { imageGenerationService } from '@/lib/services/imageGeneration'
import { useFlashcardSearch, type SearchActions } from '@/hooks/useFlashcardSearch'
// 詞性簡寫轉換 (全域函數)
@ -43,6 +44,10 @@ function FlashcardsContent() {
// 使用新的搜尋Hook
const [searchState, searchActions] = useFlashcardSearch(activeTab)
// 圖片生成狀態管理
const [generatingCards, setGeneratingCards] = useState<Set<string>>(new Set())
const [generationProgress, setGenerationProgress] = useState<{[cardId: string]: string}>({})
// 例句圖片邏輯 - 使用 API 資料
const getExampleImage = (card: Flashcard): string | null => {
return card.primaryImageUrl || null
@ -54,11 +59,73 @@ function FlashcardsContent() {
}
// 處理AI生成例句圖片 (預留接口)
const handleGenerateExampleImage = (card: Flashcard) => {
console.log('準備為詞彙生成例句圖片:', card.word)
// TODO: 下階段實現AI生成流程
// router.push(`/generate-image?word=${encodeURIComponent(card.word)}`)
// 處理AI生成例句圖片 - 完整實現
const handleGenerateExampleImage = async (card: Flashcard) => {
try {
// 檢查是否已在生成中
if (generatingCards.has(card.id)) {
toast.error('該詞卡正在生成圖片中,請稍候...')
return
}
// 標記為生成中
setGeneratingCards(prev => new Set([...prev, card.id]))
setGenerationProgress(prev => ({ ...prev, [card.id]: '啟動生成中...' }))
toast.info(`開始為「${card.word}」生成例句圖片...`)
// 1. 啟動圖片生成
const generateResult = await imageGenerationService.generateImage(card.id)
if (!generateResult.success || !generateResult.data) {
throw new Error(generateResult.error || '啟動生成失敗')
}
const requestId = generateResult.data.requestId
setGenerationProgress(prev => ({ ...prev, [card.id]: 'Gemini 生成描述中...' }))
// 2. 輪詢生成進度
const finalStatus = await imageGenerationService.pollUntilComplete(
requestId,
(status) => {
// 更新進度顯示
const stage = status.stages.gemini.status === 'completed'
? 'Replicate 生成圖片中...'
: 'Gemini 生成描述中...'
setGenerationProgress(prev => ({ ...prev, [card.id]: stage }))
},
5 // 5分鐘超時
)
// 3. 生成成功,刷新資料
if (finalStatus.overallStatus === 'completed') {
setGenerationProgress(prev => ({ ...prev, [card.id]: '生成完成,載入中...' }))
// 清除快取並重新載入詞卡列表以顯示新圖片
await searchActions.refetch()
toast.success(`${card.word}」的例句圖片生成完成!`)
} else {
throw new Error('圖片生成未完成')
}
} catch (error: any) {
console.error('圖片生成失敗:', error)
toast.error(`圖片生成失敗: ${error.message || '未知錯誤'}`)
} finally {
// 清理狀態
setGeneratingCards(prev => {
const newSet = new Set(prev)
newSet.delete(card.id)
return newSet
})
setGenerationProgress(prev => {
const newProgress = { ...prev }
delete newProgress[card.id]
return newProgress
})
}
}
// 初始化數據載入
@ -266,6 +333,8 @@ function FlashcardsContent() {
getExampleImage={getExampleImage}
hasExampleImage={hasExampleImage}
onGenerateExampleImage={handleGenerateExampleImage}
generatingCards={generatingCards}
generationProgress={generationProgress}
router={router}
/>

View File

@ -0,0 +1,170 @@
// Image Generation API service
export interface ImageGenerationRequest {
style: 'cartoon' | 'realistic' | 'minimal'
priority: 'normal' | 'high' | 'low'
width: number
height: number
replicateModel: string
options: {
useGeminiCache: boolean
useImageCache: boolean
maxRetries: number
learnerLevel: string
scenario: string
visualPreferences: string[]
}
}
export interface GenerationStatus {
requestId: string
overallStatus: string
currentStage?: string
stages: {
gemini: {
status: string
startedAt?: string
completedAt?: string
processingTimeMs?: number
cost?: number
generatedDescription?: string
}
replicate: {
status: string
startedAt?: string
completedAt?: string
processingTimeMs?: number
cost?: number
model?: string
modelVersion?: string
progress?: string
}
}
totalCost?: number
completedAt?: string
result?: {
imageUrl: string
imageId: string
qualityScore?: number
dimensions?: {
width: number
height: number
}
fileSize?: number
}
}
export interface ApiResponse<T> {
success: boolean
data?: T
error?: string
details?: string
}
class ImageGenerationService {
private baseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5008'
private async makeRequest<T>(url: string, options: RequestInit = {}): Promise<ApiResponse<T>> {
const token = localStorage.getItem('token')
const response = await fetch(`${this.baseUrl}${url}`, {
headers: {
'Content-Type': 'application/json',
'Authorization': token ? `Bearer ${token}` : '',
...options.headers,
},
...options,
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: 'Network error' }))
throw new Error(errorData.error || errorData.details || `HTTP ${response.status}`)
}
return response.json()
}
// 啟動圖片生成
async generateImage(flashcardId: string, request?: Partial<ImageGenerationRequest>): Promise<ApiResponse<{ requestId: string }>> {
const defaultRequest: ImageGenerationRequest = {
style: 'cartoon',
priority: 'normal',
width: 512,
height: 512,
replicateModel: 'ideogram-v2a-turbo',
options: {
useGeminiCache: true,
useImageCache: true,
maxRetries: 3,
learnerLevel: 'B1',
scenario: 'daily',
visualPreferences: ['colorful', 'simple']
},
...request
}
return this.makeRequest(`/api/imagegeneration/flashcards/${flashcardId}/generate`, {
method: 'POST',
body: JSON.stringify(defaultRequest)
})
}
// 查詢生成狀態
async getGenerationStatus(requestId: string): Promise<ApiResponse<GenerationStatus>> {
return this.makeRequest(`/api/imagegeneration/requests/${requestId}/status`)
}
// 取消生成
async cancelGeneration(requestId: string): Promise<ApiResponse<{ message: string }>> {
return this.makeRequest(`/api/imagegeneration/requests/${requestId}/cancel`, {
method: 'POST'
})
}
// 輪詢直到完成
async pollUntilComplete(
requestId: string,
onProgress?: (status: GenerationStatus) => void,
timeoutMinutes = 5
): Promise<GenerationStatus> {
const startTime = Date.now()
const timeout = timeoutMinutes * 60 * 1000
while (Date.now() - startTime < timeout) {
try {
const result = await this.getGenerationStatus(requestId)
if (!result.success || !result.data) {
throw new Error(result.error || 'Failed to get status')
}
const status = result.data
// 呼叫進度回調
if (onProgress) {
onProgress(status)
}
// 檢查是否完成
if (status.overallStatus === 'completed') {
return status
}
// 檢查是否失敗
if (status.overallStatus === 'failed') {
throw new Error('圖片生成失敗')
}
// 等待 2 秒後再次檢查
await new Promise(resolve => setTimeout(resolve, 2000))
} catch (error) {
console.error('輪詢狀態時發生錯誤:', error)
throw error
}
}
throw new Error('圖片生成超時')
}
}
export const imageGenerationService = new ImageGenerationService()