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:
parent
b7e7a723bf
commit
5167d91090
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue