feat: 修復圖片生成服務 + 統一播放按鈕設計 + API 完善

後端修復:
• 修復圖片生成 DI Scope 問題 - 解決 ObjectDisposedException
• FlashcardsController 統一 API 格式 - 添加圖片和複習屬性
• Repository 正確載入圖片關聯數據

前端優化:
• 統一播放按鈕為藍底漸層設計 (w-10 h-10)
• 修復圖片顯示邏輯 - 正確構建完整 URL
• FlashcardDetailHeader 防護性編程 - 避免 NaN 錯誤
• 優化圖片顯示比例 - 正方形容器避免變形

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
鄭沛軒 2025-10-02 03:58:03 +08:00
parent b7e7a723bf
commit 5167d91090
7 changed files with 115 additions and 30 deletions

View File

@ -45,7 +45,13 @@ public class FlashcardsController : BaseController
DifficultyLevelNumeric = f.DifficultyLevelNumeric, DifficultyLevelNumeric = f.DifficultyLevelNumeric,
CEFR = CEFRHelper.ToString(f.DifficultyLevelNumeric), CEFR = CEFRHelper.ToString(f.DifficultyLevelNumeric),
f.CreatedAt, f.CreatedAt,
f.UpdatedAt f.UpdatedAt,
// 添加圖片相關屬性
HasExampleImage = f.FlashcardExampleImages.Any(),
PrimaryImageUrl = f.FlashcardExampleImages
.Where(fei => fei.IsPrimary)
.Select(fei => $"/images/examples/{fei.ExampleImage.RelativePath}")
.FirstOrDefault()
}), }),
Count = flashcards.Count() Count = flashcards.Count()
}; };
@ -121,7 +127,37 @@ public class FlashcardsController : BaseController
return ErrorResponse("NOT_FOUND", "詞卡不存在", null, 404); return ErrorResponse("NOT_FOUND", "詞卡不存在", null, 404);
} }
return SuccessResponse(flashcard); // 格式化返回數據,保持與列表 API 一致
var flashcardData = new
{
flashcard.Id,
flashcard.Word,
flashcard.Translation,
flashcard.Definition,
flashcard.PartOfSpeech,
flashcard.Pronunciation,
flashcard.Example,
flashcard.ExampleTranslation,
flashcard.IsFavorite,
DifficultyLevelNumeric = flashcard.DifficultyLevelNumeric,
CEFR = CEFRHelper.ToString(flashcard.DifficultyLevelNumeric),
flashcard.CreatedAt,
flashcard.UpdatedAt,
// 添加圖片相關屬性
HasExampleImage = flashcard.FlashcardExampleImages.Any(),
PrimaryImageUrl = flashcard.FlashcardExampleImages
.Where(fei => fei.IsPrimary)
.Select(fei => $"/images/examples/{fei.ExampleImage.RelativePath}")
.FirstOrDefault(),
// 添加複習相關屬性 (暫時預設值)
TimesReviewed = 0,
MasteryLevel = 0,
NextReviewDate = (DateTime?)null,
// 保留完整的圖片關聯數據供前端使用
FlashcardExampleImages = flashcard.FlashcardExampleImages
};
return SuccessResponse(flashcardData);
} }
catch (UnauthorizedAccessException) catch (UnauthorizedAccessException)
{ {

View File

@ -30,6 +30,8 @@ public class FlashcardRepository : BaseRepository<Flashcard>, IFlashcardReposito
} }
return await query return await query
.Include(f => f.FlashcardExampleImages)
.ThenInclude(fei => fei.ExampleImage)
.OrderByDescending(f => f.CreatedAt) .OrderByDescending(f => f.CreatedAt)
.ToListAsync(); .ToListAsync();
} }
@ -37,6 +39,8 @@ public class FlashcardRepository : BaseRepository<Flashcard>, IFlashcardReposito
public async Task<Flashcard?> GetByUserIdAndFlashcardIdAsync(Guid userId, Guid flashcardId) public async Task<Flashcard?> GetByUserIdAndFlashcardIdAsync(Guid userId, Guid flashcardId)
{ {
return await _context.Flashcards return await _context.Flashcards
.Include(f => f.FlashcardExampleImages)
.ThenInclude(fei => fei.ExampleImage)
.FirstOrDefaultAsync(f => f.Id == flashcardId && f.UserId == userId && !f.IsArchived); .FirstOrDefaultAsync(f => f.Id == flashcardId && f.UserId == userId && !f.IsArchived);
} }

View File

@ -56,12 +56,15 @@ public class ImageGenerationWorkflow : IImageGenerationWorkflow
_logger.LogInformation("Created generation request {RequestId} for flashcard {FlashcardId}", _logger.LogInformation("Created generation request {RequestId} for flashcard {FlashcardId}",
generationRequest.Id, flashcardId); generationRequest.Id, flashcardId);
// 後台執行生成流程 // 後台執行生成流程 - 使用獨立的 Scope 避免 ObjectDisposedException
_ = Task.Run(async () => _ = Task.Run(async () =>
{ {
using var backgroundScope = _serviceProvider.CreateScope();
var backgroundPipelineService = backgroundScope.ServiceProvider.GetRequiredService<IGenerationPipelineService>();
try try
{ {
await _pipelineService.ExecuteGenerationPipelineAsync(generationRequest.Id); await backgroundPipelineService.ExecuteGenerationPipelineAsync(generationRequest.Id);
} }
catch (Exception ex) catch (Exception ex)
{ {

View File

@ -46,14 +46,15 @@ export const FlashcardCard: React.FC<FlashcardCardProps> = ({
</div> </div>
<div className="flex flex-col md:flex-row md:items-center gap-3 md:gap-4 flex-1"> <div className="flex flex-col md:flex-row md:items-center gap-3 md:gap-4 flex-1">
{/* 例句圖片區域 - 響應式設計 */} {/* 例句圖片區域 - 響應式設計,保持正方形比例 */}
<div className="w-32 h-20 sm:w-40 sm:h-24 md:w-48 md:h-32 lg:w-54 lg:h-36 bg-gray-100 rounded-lg overflow-hidden border border-gray-200 flex items-center justify-center flex-shrink-0"> <div className="w-20 h-20 sm:w-24 sm:h-24 md:w-32 md:h-32 lg:w-36 lg:h-36 bg-gray-100 rounded-lg overflow-hidden border border-gray-200 flex items-center justify-center flex-shrink-0">
{hasExampleImage(flashcard) ? ( {hasExampleImage(flashcard) ? (
// 有例句圖片時顯示圖片 // 有例句圖片時顯示圖片
<img <img
src={getExampleImage(flashcard)!} src={getExampleImage(flashcard)!}
alt={`${flashcard.word} example`} alt={`${flashcard.word} example`}
className="w-full h-full object-cover" className="w-full h-full object-cover"
style={{ imageRendering: 'auto' }}
onError={(e) => { onError={(e) => {
const target = e.target as HTMLImageElement const target = e.target as HTMLImageElement
target.style.display = 'none' target.style.display = 'none'

View File

@ -1,7 +1,6 @@
import React from 'react' import React from 'react'
import type { Flashcard } from '@/lib/services/flashcards' import type { Flashcard } from '@/lib/services/flashcards'
import { getFlashcardImageUrl } from '@/lib/utils/flashcardUtils' import { getFlashcardImageUrl } from '@/lib/utils/flashcardUtils'
import { TTSButton } from '@/components/shared/TTSButton'
interface FlashcardContentBlocksProps { interface FlashcardContentBlocksProps {
flashcard: Flashcard flashcard: Flashcard
@ -72,11 +71,14 @@ export const FlashcardContentBlocks: React.FC<FlashcardContentBlocksProps> = ({
{/* 例句圖片 */} {/* 例句圖片 */}
<div className="mb-4 relative"> <div className="mb-4 relative">
{getFlashcardImageUrl(flashcard) ? ( {getFlashcardImageUrl(flashcard) ? (
<img <div className="w-full max-w-sm mx-auto">
src={getFlashcardImageUrl(flashcard)!} <img
alt={`${flashcard.word} example`} src={getFlashcardImageUrl(flashcard)!}
className="w-full max-w-md mx-auto rounded-lg border border-blue-300" alt={`${flashcard.word} example`}
/> className="w-full aspect-square object-cover rounded-lg border border-blue-300"
style={{ imageRendering: 'auto' }}
/>
</div>
) : ( ) : (
<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="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"> <div className="text-center text-gray-500">
@ -140,14 +142,40 @@ export const FlashcardContentBlocks: React.FC<FlashcardContentBlocksProps> = ({
"{flashcard.example}" "{flashcard.example}"
</p> </p>
<div className="absolute bottom-0 right-0"> <div className="absolute bottom-0 right-0">
<TTSButton <button
text={flashcard.example} onClick={() => onToggleExampleTTS(flashcard.example, 'en-US')}
lang="en-US" disabled={isPlayingWord}
isPlaying={isPlayingExample} title={isPlayingExample ? "點擊停止播放" : "點擊聽例句發音"}
onToggle={onToggleExampleTTS} className={`group relative w-10 h-10 rounded-full shadow-lg transform transition-all duration-200
size="lg" ${isPlayingExample
className={isPlayingWord ? 'cursor-not-allowed opacity-50' : ''} ? 'bg-gradient-to-br from-green-500 to-green-600 shadow-green-200 scale-105'
/> : 'bg-gradient-to-br from-blue-500 to-blue-600 hover:shadow-xl hover:scale-105 shadow-blue-200'
} ${isPlayingWord ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}
`}
>
{/* 播放中波紋效果 */}
{isPlayingExample && (
<div className="absolute inset-0 bg-blue-400 rounded-full animate-ping opacity-40"></div>
)}
{/* 按鈕圖標 */}
<div className="relative z-10 flex items-center justify-center w-full h-full">
{isPlayingExample ? (
<svg className="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
</svg>
) : (
<svg className="w-5 h-5 text-white group-hover:scale-110 transition-transform" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
)}
</div>
{/* 懸停提示光環 */}
{!isPlayingExample && !isPlayingWord && (
<div className="absolute inset-0 rounded-full bg-white opacity-0 group-hover:opacity-20 transition-opacity duration-200"></div>
)}
</button>
</div> </div>
</div> </div>
<p className="text-blue-700 text-left text-base"> <p className="text-blue-700 text-left text-base">

View File

@ -1,7 +1,6 @@
import React from 'react' import React from 'react'
import type { Flashcard } from '@/lib/services/flashcards' import type { Flashcard } from '@/lib/services/flashcards'
import { getPartOfSpeechDisplay, getCEFRColor } from '@/lib/utils/flashcardUtils' import { getPartOfSpeechDisplay, getCEFRColor } from '@/lib/utils/flashcardUtils'
import { TTSButton } from '@/components/shared/TTSButton'
interface FlashcardDetailHeaderProps { interface FlashcardDetailHeaderProps {
flashcard: Flashcard flashcard: Flashcard
@ -26,13 +25,13 @@ export const FlashcardDetailHeader: React.FC<FlashcardDetailHeaderProps> = ({
</span> </span>
<span className="text-lg text-gray-600">{flashcard.pronunciation}</span> <span className="text-lg text-gray-600">{flashcard.pronunciation}</span>
{/* TTS播放按鈕 - 使用新的TTSButton組件 */} {/* TTS播放按鈕 - 藍底漸層設計 */}
<button <button
onClick={() => onToggleWordTTS(flashcard.word, 'en-US')} onClick={() => onToggleWordTTS(flashcard.word, 'en-US')}
disabled={isPlayingExample} disabled={isPlayingExample}
title={isPlayingWord ? "點擊停止播放" : "點擊聽詞彙發音"} title={isPlayingWord ? "點擊停止播放" : "點擊聽詞彙發音"}
aria-label={isPlayingWord ? `停止播放詞彙:${flashcard.word}` : `播放詞彙發音:${flashcard.word}`} aria-label={isPlayingWord ? `停止播放詞彙:${flashcard.word}` : `播放詞彙發音:${flashcard.word}`}
className={`group relative w-12 h-12 rounded-full shadow-lg transform transition-all duration-200 className={`group relative w-10 h-10 rounded-full shadow-lg transform transition-all duration-200
${isPlayingWord ${isPlayingWord
? 'bg-gradient-to-br from-green-500 to-green-600 shadow-green-200 scale-105' ? 'bg-gradient-to-br from-green-500 to-green-600 shadow-green-200 scale-105'
: 'bg-gradient-to-br from-blue-500 to-blue-600 hover:shadow-xl hover:scale-105 shadow-blue-200' : 'bg-gradient-to-br from-blue-500 to-blue-600 hover:shadow-xl hover:scale-105 shadow-blue-200'
@ -47,11 +46,11 @@ export const FlashcardDetailHeader: React.FC<FlashcardDetailHeaderProps> = ({
{/* 按鈕圖標 */} {/* 按鈕圖標 */}
<div className="relative z-10 flex items-center justify-center w-full h-full"> <div className="relative z-10 flex items-center justify-center w-full h-full">
{isPlayingWord ? ( {isPlayingWord ? (
<svg className="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/> <path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
</svg> </svg>
) : ( ) : (
<svg className="w-6 h-6 text-white group-hover:scale-110 transition-transform" fill="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5 text-white group-hover:scale-110 transition-transform" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/> <path d="M8 5v14l11-7z"/>
</svg> </svg>
)} )}
@ -68,16 +67,22 @@ export const FlashcardDetailHeader: React.FC<FlashcardDetailHeaderProps> = ({
{/* 學習統計 */} {/* 學習統計 */}
<div className="grid grid-cols-3 gap-4 text-center mt-4"> <div className="grid grid-cols-3 gap-4 text-center mt-4">
<div className="bg-white bg-opacity-60 rounded-lg p-3"> <div className="bg-white bg-opacity-60 rounded-lg p-3">
<div className="text-2xl font-bold text-gray-900">{flashcard.masteryLevel}%</div> <div className="text-2xl font-bold text-gray-900">{flashcard.masteryLevel || 0}%</div>
<div className="text-sm text-gray-600"></div> <div className="text-sm text-gray-600"></div>
</div> </div>
<div className="bg-white bg-opacity-60 rounded-lg p-3"> <div className="bg-white bg-opacity-60 rounded-lg p-3">
<div className="text-2xl font-bold text-gray-900">{flashcard.timesReviewed}</div> <div className="text-2xl font-bold text-gray-900">{flashcard.timesReviewed || 0}</div>
<div className="text-sm text-gray-600"></div> <div className="text-sm text-gray-600"></div>
</div> </div>
<div className="bg-white bg-opacity-60 rounded-lg p-3"> <div className="bg-white bg-opacity-60 rounded-lg p-3">
<div className="text-2xl font-bold text-gray-900"> <div className="text-2xl font-bold text-gray-900">
{Math.ceil((new Date(flashcard.nextReviewDate).getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24))} {(() => {
if (!flashcard.nextReviewDate) return 0
const reviewDate = new Date(flashcard.nextReviewDate)
if (isNaN(reviewDate.getTime())) return 0
const days = Math.ceil((reviewDate.getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24))
return Math.max(0, days)
})()}
</div> </div>
<div className="text-sm text-gray-600"></div> <div className="text-sm text-gray-600"></div>
</div> </div>

View File

@ -75,14 +75,22 @@ export const formatCreatedDate = (dateString: string): string => {
export const getFlashcardImageUrl = (flashcard: any): string | null => { export const getFlashcardImageUrl = (flashcard: any): string | null => {
// 優先使用 primaryImageUrl // 優先使用 primaryImageUrl
if (flashcard.primaryImageUrl) { if (flashcard.primaryImageUrl) {
// 如果是相對路徑,加上後端基礎 URL
if (flashcard.primaryImageUrl.startsWith('/')) {
return `http://localhost:5008${flashcard.primaryImageUrl}`
}
return flashcard.primaryImageUrl return flashcard.primaryImageUrl
} }
// 然後檢查 exampleImages 陣列 // 然後檢查 exampleImages 陣列
if (flashcard.exampleImages && flashcard.exampleImages.length > 0) { if (flashcard.exampleImages && flashcard.exampleImages.length > 0) {
const primaryImage = flashcard.exampleImages.find((img: any) => img.isPrimary) const primaryImage = flashcard.exampleImages.find((img: any) => img.isPrimary)
if (primaryImage) return primaryImage.imageUrl if (primaryImage) {
return flashcard.exampleImages[0].imageUrl const imageUrl = primaryImage.imageUrl
return imageUrl?.startsWith('/') ? `http://localhost:5008${imageUrl}` : imageUrl
}
const firstImageUrl = flashcard.exampleImages[0].imageUrl
return firstImageUrl?.startsWith('/') ? `http://localhost:5008${firstImageUrl}` : firstImageUrl
} }
return null return null