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,
CEFR = CEFRHelper.ToString(f.DifficultyLevelNumeric),
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()
};
@ -121,7 +127,37 @@ public class FlashcardsController : BaseController
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)
{

View File

@ -30,6 +30,8 @@ public class FlashcardRepository : BaseRepository<Flashcard>, IFlashcardReposito
}
return await query
.Include(f => f.FlashcardExampleImages)
.ThenInclude(fei => fei.ExampleImage)
.OrderByDescending(f => f.CreatedAt)
.ToListAsync();
}
@ -37,6 +39,8 @@ public class FlashcardRepository : BaseRepository<Flashcard>, IFlashcardReposito
public async Task<Flashcard?> GetByUserIdAndFlashcardIdAsync(Guid userId, Guid flashcardId)
{
return await _context.Flashcards
.Include(f => f.FlashcardExampleImages)
.ThenInclude(fei => fei.ExampleImage)
.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}",
generationRequest.Id, flashcardId);
// 後台執行生成流程
// 後台執行生成流程 - 使用獨立的 Scope 避免 ObjectDisposedException
_ = Task.Run(async () =>
{
using var backgroundScope = _serviceProvider.CreateScope();
var backgroundPipelineService = backgroundScope.ServiceProvider.GetRequiredService<IGenerationPipelineService>();
try
{
await _pipelineService.ExecuteGenerationPipelineAsync(generationRequest.Id);
await backgroundPipelineService.ExecuteGenerationPipelineAsync(generationRequest.Id);
}
catch (Exception ex)
{

View File

@ -46,14 +46,15 @@ export const FlashcardCard: React.FC<FlashcardCardProps> = ({
</div>
<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) ? (
// 有例句圖片時顯示圖片
<img
src={getExampleImage(flashcard)!}
alt={`${flashcard.word} example`}
className="w-full h-full object-cover"
style={{ imageRendering: 'auto' }}
onError={(e) => {
const target = e.target as HTMLImageElement
target.style.display = 'none'

View File

@ -1,7 +1,6 @@
import React from 'react'
import type { Flashcard } from '@/lib/services/flashcards'
import { getFlashcardImageUrl } from '@/lib/utils/flashcardUtils'
import { TTSButton } from '@/components/shared/TTSButton'
interface FlashcardContentBlocksProps {
flashcard: Flashcard
@ -72,11 +71,14 @@ export const FlashcardContentBlocks: React.FC<FlashcardContentBlocksProps> = ({
{/* 例句圖片 */}
<div className="mb-4 relative">
{getFlashcardImageUrl(flashcard) ? (
<img
src={getFlashcardImageUrl(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-sm mx-auto">
<img
src={getFlashcardImageUrl(flashcard)!}
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="text-center text-gray-500">
@ -140,14 +142,40 @@ export const FlashcardContentBlocks: React.FC<FlashcardContentBlocksProps> = ({
"{flashcard.example}"
</p>
<div className="absolute bottom-0 right-0">
<TTSButton
text={flashcard.example}
lang="en-US"
isPlaying={isPlayingExample}
onToggle={onToggleExampleTTS}
size="lg"
className={isPlayingWord ? 'cursor-not-allowed opacity-50' : ''}
/>
<button
onClick={() => onToggleExampleTTS(flashcard.example, 'en-US')}
disabled={isPlayingWord}
title={isPlayingExample ? "點擊停止播放" : "點擊聽例句發音"}
className={`group relative w-10 h-10 rounded-full shadow-lg transform transition-all duration-200
${isPlayingExample
? '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>
<p className="text-blue-700 text-left text-base">

View File

@ -1,7 +1,6 @@
import React from 'react'
import type { Flashcard } from '@/lib/services/flashcards'
import { getPartOfSpeechDisplay, getCEFRColor } from '@/lib/utils/flashcardUtils'
import { TTSButton } from '@/components/shared/TTSButton'
interface FlashcardDetailHeaderProps {
flashcard: Flashcard
@ -26,13 +25,13 @@ export const FlashcardDetailHeader: React.FC<FlashcardDetailHeaderProps> = ({
</span>
<span className="text-lg text-gray-600">{flashcard.pronunciation}</span>
{/* TTS播放按鈕 - 使用新的TTSButton組件 */}
{/* TTS播放按鈕 - 藍底漸層設計 */}
<button
onClick={() => onToggleWordTTS(flashcard.word, 'en-US')}
disabled={isPlayingExample}
title={isPlayingWord ? "點擊停止播放" : "點擊聽詞彙發音"}
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
? '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'
@ -47,11 +46,11 @@ export const FlashcardDetailHeader: React.FC<FlashcardDetailHeaderProps> = ({
{/* 按鈕圖標 */}
<div className="relative z-10 flex items-center justify-center w-full h-full">
{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"/>
</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"/>
</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="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>
<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>
<div className="bg-white bg-opacity-60 rounded-lg p-3">
<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 className="text-sm text-gray-600"></div>
</div>

View File

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