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:
parent
561ffd8e13
commit
48bbfb867b
|
|
@ -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)
|
||||
// 獲取例句圖片資料 (與 GetFlashcard 方法保持一致)
|
||||
var exampleImages = flashcard.FlashcardExampleImages?
|
||||
.Select(fei => new
|
||||
{
|
||||
var imageUrl = await _imageStorageService.GetImageUrlAsync(flashcardImage.ExampleImage.RelativePath);
|
||||
|
||||
exampleImages.Add(new ExampleImageDto
|
||||
{
|
||||
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()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
Loading…
Reference in New Issue