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>();
|
var flashcardDtos = new List<object>();
|
||||||
foreach (var flashcard in flashcards)
|
foreach (var flashcard in flashcards)
|
||||||
{
|
{
|
||||||
var exampleImages = new List<ExampleImageDto>();
|
// 獲取例句圖片資料 (與 GetFlashcard 方法保持一致)
|
||||||
|
var exampleImages = flashcard.FlashcardExampleImages?
|
||||||
// 處理關聯的圖片
|
.Select(fei => new
|
||||||
foreach (var flashcardImage in flashcard.FlashcardExampleImages)
|
|
||||||
{
|
|
||||||
var imageUrl = await _imageStorageService.GetImageUrlAsync(flashcardImage.ExampleImage.RelativePath);
|
|
||||||
|
|
||||||
exampleImages.Add(new ExampleImageDto
|
|
||||||
{
|
{
|
||||||
Id = flashcardImage.ExampleImage.Id.ToString(),
|
Id = fei.ExampleImage.Id,
|
||||||
ImageUrl = imageUrl,
|
ImageUrl = $"http://localhost:5008/images/examples/{fei.ExampleImage.RelativePath}",
|
||||||
IsPrimary = flashcardImage.IsPrimary,
|
IsPrimary = fei.IsPrimary,
|
||||||
QualityScore = flashcardImage.ExampleImage.QualityScore,
|
QualityScore = fei.ExampleImage.QualityScore,
|
||||||
FileSize = flashcardImage.ExampleImage.FileSize,
|
FileSize = fei.ExampleImage.FileSize,
|
||||||
CreatedAt = flashcardImage.ExampleImage.CreatedAt
|
CreatedAt = fei.ExampleImage.CreatedAt
|
||||||
});
|
})
|
||||||
}
|
.ToList();
|
||||||
|
|
||||||
flashcardDtos.Add(new
|
flashcardDtos.Add(new
|
||||||
{
|
{
|
||||||
|
|
@ -152,9 +147,9 @@ public class FlashcardsController : ControllerBase
|
||||||
flashcard.CreatedAt,
|
flashcard.CreatedAt,
|
||||||
flashcard.UpdatedAt,
|
flashcard.UpdatedAt,
|
||||||
// 新增圖片相關欄位
|
// 新增圖片相關欄位
|
||||||
ExampleImages = exampleImages,
|
ExampleImages = exampleImages ?? (object)new List<object>(),
|
||||||
HasExampleImage = exampleImages.Any(),
|
HasExampleImage = exampleImages?.Any() ?? false,
|
||||||
PrimaryImageUrl = exampleImages.FirstOrDefault(img => img.IsPrimary)?.ImageUrl
|
PrimaryImageUrl = exampleImages?.FirstOrDefault(img => img.IsPrimary)?.ImageUrl
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -283,6 +278,8 @@ public class FlashcardsController : ControllerBase
|
||||||
var userId = GetUserId();
|
var userId = GetUserId();
|
||||||
|
|
||||||
var flashcard = await _context.Flashcards
|
var flashcard = await _context.Flashcards
|
||||||
|
.Include(f => f.FlashcardExampleImages)
|
||||||
|
.ThenInclude(fei => fei.ExampleImage)
|
||||||
.FirstOrDefaultAsync(f => f.Id == id && f.UserId == userId);
|
.FirstOrDefaultAsync(f => f.Id == id && f.UserId == userId);
|
||||||
|
|
||||||
if (flashcard == null)
|
if (flashcard == null)
|
||||||
|
|
@ -290,6 +287,19 @@ public class FlashcardsController : ControllerBase
|
||||||
return NotFound(new { Success = false, Error = "Flashcard not found" });
|
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
|
return Ok(new
|
||||||
{
|
{
|
||||||
Success = true,
|
Success = true,
|
||||||
|
|
@ -309,7 +319,14 @@ public class FlashcardsController : ControllerBase
|
||||||
flashcard.NextReviewDate,
|
flashcard.NextReviewDate,
|
||||||
flashcard.DifficultyLevel,
|
flashcard.DifficultyLevel,
|
||||||
flashcard.CreatedAt,
|
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 { FlashcardForm } from '@/components/FlashcardForm'
|
||||||
import { useToast } from '@/components/Toast'
|
import { useToast } from '@/components/Toast'
|
||||||
import { flashcardsService, type Flashcard } from '@/lib/services/flashcards'
|
import { flashcardsService, type Flashcard } from '@/lib/services/flashcards'
|
||||||
|
import { imageGenerationService } from '@/lib/services/imageGeneration'
|
||||||
import { useFlashcardSearch, type SearchActions } from '@/hooks/useFlashcardSearch'
|
import { useFlashcardSearch, type SearchActions } from '@/hooks/useFlashcardSearch'
|
||||||
|
|
||||||
// 詞性簡寫轉換 (全域函數)
|
// 詞性簡寫轉換 (全域函數)
|
||||||
|
|
@ -43,6 +44,10 @@ function FlashcardsContent() {
|
||||||
// 使用新的搜尋Hook
|
// 使用新的搜尋Hook
|
||||||
const [searchState, searchActions] = useFlashcardSearch(activeTab)
|
const [searchState, searchActions] = useFlashcardSearch(activeTab)
|
||||||
|
|
||||||
|
// 圖片生成狀態管理
|
||||||
|
const [generatingCards, setGeneratingCards] = useState<Set<string>>(new Set())
|
||||||
|
const [generationProgress, setGenerationProgress] = useState<{[cardId: string]: string}>({})
|
||||||
|
|
||||||
// 例句圖片邏輯 - 使用 API 資料
|
// 例句圖片邏輯 - 使用 API 資料
|
||||||
const getExampleImage = (card: Flashcard): string | null => {
|
const getExampleImage = (card: Flashcard): string | null => {
|
||||||
return card.primaryImageUrl || null
|
return card.primaryImageUrl || null
|
||||||
|
|
@ -54,11 +59,73 @@ function FlashcardsContent() {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// 處理AI生成例句圖片 (預留接口)
|
// 處理AI生成例句圖片 - 完整實現
|
||||||
const handleGenerateExampleImage = (card: Flashcard) => {
|
const handleGenerateExampleImage = async (card: Flashcard) => {
|
||||||
console.log('準備為詞彙生成例句圖片:', card.word)
|
try {
|
||||||
// TODO: 下階段實現AI生成流程
|
// 檢查是否已在生成中
|
||||||
// router.push(`/generate-image?word=${encodeURIComponent(card.word)}`)
|
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}
|
getExampleImage={getExampleImage}
|
||||||
hasExampleImage={hasExampleImage}
|
hasExampleImage={hasExampleImage}
|
||||||
onGenerateExampleImage={handleGenerateExampleImage}
|
onGenerateExampleImage={handleGenerateExampleImage}
|
||||||
|
generatingCards={generatingCards}
|
||||||
|
generationProgress={generationProgress}
|
||||||
router={router}
|
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