From f08d798aa40e4e82aa18298c2455697a1d589618 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=84=AD=E6=B2=9B=E8=BB=92?= Date: Tue, 7 Oct 2025 18:09:06 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BF=AE=E5=BE=A9=20AI=20=E7=94=9F?= =?UTF-8?q?=E6=88=90=E5=90=8C=E7=BE=A9=E8=A9=9E=E5=AE=8C=E6=95=B4=E4=BF=9D?= =?UTF-8?q?=E5=AD=98=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 Synonyms 屬性到 Flashcard 實體模型並配置 DbContext - 執行 FixSynonymsColumn migration 在資料庫中添加 synonyms 欄位 - 更新前後端 CreateFlashcardRequest DTO 支援同義詞傳輸 - 修復前端 generate page 包含 AI 生成的同義詞資料 - 添加前端安全 JSON 解析,正確顯示同義詞標籤 - 修復完整資料流程:AI 分析 → 前端處理 → API 傳輸 → 資料庫儲存 現在 AI 生成的同義詞不再被浪費,完整保存並在詞卡詳細頁面顯示。 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../Controllers/FlashcardsController.cs | 4 + .../DramaLing.Api/Data/DramaLingDbContext.cs | 1 + ...251007093605_FixSynonymsColumn.Designer.cs | 1346 +++++++++++++++++ .../20251007093605_FixSynonymsColumn.cs | 29 + .../DramaLingDbContextModelSnapshot.cs | 5 + .../Models/Entities/Flashcard.cs | 3 + frontend/app/generate/page.tsx | 1 + .../flashcards/FlashcardContentBlocks.tsx | 21 +- .../components/flashcards/SearchControls.tsx | 1 - frontend/lib/services/flashcards.ts | 1 + 10 files changed, 1409 insertions(+), 3 deletions(-) create mode 100644 backend/DramaLing.Api/Migrations/20251007093605_FixSynonymsColumn.Designer.cs create mode 100644 backend/DramaLing.Api/Migrations/20251007093605_FixSynonymsColumn.cs diff --git a/backend/DramaLing.Api/Controllers/FlashcardsController.cs b/backend/DramaLing.Api/Controllers/FlashcardsController.cs index 8c29bc0..10887f0 100644 --- a/backend/DramaLing.Api/Controllers/FlashcardsController.cs +++ b/backend/DramaLing.Api/Controllers/FlashcardsController.cs @@ -62,6 +62,7 @@ public class FlashcardsController : BaseController f.Example, f.ExampleTranslation, f.IsFavorite, + f.Synonyms, DifficultyLevelNumeric = f.DifficultyLevelNumeric, CEFR = CEFRHelper.ToString(f.DifficultyLevelNumeric), f.CreatedAt, @@ -117,6 +118,7 @@ public class FlashcardsController : BaseController Pronunciation = request.Pronunciation, Example = request.Example, ExampleTranslation = request.ExampleTranslation, + Synonyms = request.Synonyms, // 儲存 AI 生成的同義詞 DifficultyLevelNumeric = CEFRHelper.ToNumeric(request.CEFR ?? "A0"), CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow @@ -168,6 +170,7 @@ public class FlashcardsController : BaseController flashcard.Example, flashcard.ExampleTranslation, flashcard.IsFavorite, + flashcard.Synonyms, DifficultyLevelNumeric = flashcard.DifficultyLevelNumeric, CEFR = CEFRHelper.ToString(flashcard.DifficultyLevelNumeric), flashcard.CreatedAt, @@ -402,5 +405,6 @@ public class CreateFlashcardRequest public string Pronunciation { get; set; } = string.Empty; public string Example { get; set; } = string.Empty; public string? ExampleTranslation { get; set; } + public string? Synonyms { get; set; } // AI 生成的同義詞 (JSON 字串) public string? CEFR { get; set; } = string.Empty; } \ No newline at end of file diff --git a/backend/DramaLing.Api/Data/DramaLingDbContext.cs b/backend/DramaLing.Api/Data/DramaLingDbContext.cs index a9c7eab..9a3e174 100644 --- a/backend/DramaLing.Api/Data/DramaLingDbContext.cs +++ b/backend/DramaLing.Api/Data/DramaLingDbContext.cs @@ -129,6 +129,7 @@ public class DramaLingDbContext : DbContext flashcardEntity.Property(f => f.Pronunciation).HasColumnName("pronunciation"); flashcardEntity.Property(f => f.Example).HasColumnName("example"); flashcardEntity.Property(f => f.ExampleTranslation).HasColumnName("example_translation"); + flashcardEntity.Property(f => f.Synonyms).HasColumnName("synonyms"); // 已刪除的復習相關屬性配置 // EasinessFactor, IntervalDays, NextReviewDate, MasteryLevel, // TimesReviewed, TimesCorrect, LastReviewedAt 已移除 diff --git a/backend/DramaLing.Api/Migrations/20251007093605_FixSynonymsColumn.Designer.cs b/backend/DramaLing.Api/Migrations/20251007093605_FixSynonymsColumn.Designer.cs new file mode 100644 index 0000000..74063a7 --- /dev/null +++ b/backend/DramaLing.Api/Migrations/20251007093605_FixSynonymsColumn.Designer.cs @@ -0,0 +1,1346 @@ +// +using System; +using DramaLing.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace DramaLing.Api.Migrations +{ + [DbContext(typeof(DramaLingDbContext))] + [Migration("20251007093605_FixSynonymsColumn")] + partial class FixSynonymsColumn + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.10"); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.AudioCache", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Accent") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("TEXT") + .HasColumnName("accent"); + + b.Property("AccessCount") + .HasColumnType("INTEGER") + .HasColumnName("access_count"); + + b.Property("AudioUrl") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("audio_url"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("DurationMs") + .HasColumnType("INTEGER") + .HasColumnName("duration_ms"); + + b.Property("FileSize") + .HasColumnType("INTEGER") + .HasColumnName("file_size"); + + b.Property("LastAccessed") + .HasColumnType("TEXT") + .HasColumnName("last_accessed"); + + b.Property("TextContent") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("text_content"); + + b.Property("TextHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT") + .HasColumnName("text_hash"); + + b.Property("VoiceId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("voice_id"); + + b.HasKey("Id"); + + b.HasIndex("LastAccessed") + .HasDatabaseName("IX_AudioCache_LastAccessed"); + + b.HasIndex("TextHash") + .IsUnique() + .HasDatabaseName("IX_AudioCache_TextHash"); + + b.ToTable("audio_cache", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.DailyStats", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AiApiCalls") + .HasColumnType("INTEGER") + .HasColumnName("ai_api_calls"); + + b.Property("CardsGenerated") + .HasColumnType("INTEGER") + .HasColumnName("cards_generated"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Date") + .HasColumnType("TEXT") + .HasColumnName("date"); + + b.Property("SessionCount") + .HasColumnType("INTEGER") + .HasColumnName("session_count"); + + b.Property("StudyTimeSeconds") + .HasColumnType("INTEGER") + .HasColumnName("study_time_seconds"); + + b.Property("UserId") + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.Property("WordsCorrect") + .HasColumnType("INTEGER") + .HasColumnName("words_correct"); + + b.Property("WordsStudied") + .HasColumnType("INTEGER") + .HasColumnName("words_studied"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Date") + .IsUnique(); + + b.ToTable("daily_stats", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.ErrorReport", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AdminNotes") + .HasColumnType("TEXT") + .HasColumnName("admin_notes"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Description") + .HasColumnType("TEXT") + .HasColumnName("description"); + + b.Property("FlashcardId") + .HasColumnType("TEXT") + .HasColumnName("flashcard_id"); + + b.Property("ReportType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("report_type"); + + b.Property("ResolvedAt") + .HasColumnType("TEXT") + .HasColumnName("resolved_at"); + + b.Property("ResolvedBy") + .HasColumnType("TEXT") + .HasColumnName("resolved_by"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("status"); + + b.Property("StudyMode") + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("study_mode"); + + b.Property("UserId") + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("FlashcardId"); + + b.HasIndex("ResolvedBy"); + + b.HasIndex("UserId"); + + b.ToTable("error_reports", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.ExampleImage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AccessCount") + .HasColumnType("INTEGER") + .HasColumnName("access_count"); + + b.Property("AltText") + .HasMaxLength(200) + .HasColumnType("TEXT") + .HasColumnName("alt_text"); + + b.Property("ContentHash") + .HasMaxLength(64) + .HasColumnType("TEXT") + .HasColumnName("content_hash"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("FileSize") + .HasColumnType("INTEGER") + .HasColumnName("file_size"); + + b.Property("GeminiCost") + .HasColumnType("TEXT") + .HasColumnName("gemini_cost"); + + b.Property("GeminiDescription") + .HasColumnType("TEXT") + .HasColumnName("gemini_description"); + + b.Property("GeminiPrompt") + .HasColumnType("TEXT") + .HasColumnName("gemini_prompt"); + + b.Property("ImageHeight") + .HasColumnType("INTEGER") + .HasColumnName("image_height"); + + b.Property("ImageWidth") + .HasColumnType("INTEGER") + .HasColumnName("image_width"); + + b.Property("ModerationNotes") + .HasColumnType("TEXT") + .HasColumnName("moderation_notes"); + + b.Property("ModerationStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT") + .HasColumnName("moderation_status"); + + b.Property("QualityScore") + .HasColumnType("TEXT") + .HasColumnName("quality_score"); + + b.Property("RelativePath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("relative_path"); + + b.Property("ReplicateCost") + .HasColumnType("TEXT") + .HasColumnName("replicate_cost"); + + b.Property("ReplicateModel") + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("replicate_model"); + + b.Property("ReplicatePrompt") + .HasColumnType("TEXT") + .HasColumnName("replicate_prompt"); + + b.Property("ReplicateVersion") + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("replicate_version"); + + b.Property("TotalGenerationCost") + .HasColumnType("TEXT") + .HasColumnName("total_generation_cost"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated_at"); + + b.HasKey("Id"); + + b.HasIndex("AccessCount"); + + b.HasIndex("ContentHash") + .IsUnique(); + + b.ToTable("example_images", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.Flashcard", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Definition") + .HasColumnType("TEXT") + .HasColumnName("definition"); + + b.Property("DifficultyLevelNumeric") + .HasColumnType("INTEGER") + .HasColumnName("difficulty_level_numeric"); + + b.Property("Example") + .HasColumnType("TEXT") + .HasColumnName("example"); + + b.Property("ExampleTranslation") + .HasColumnType("TEXT") + .HasColumnName("example_translation"); + + b.Property("IsArchived") + .HasColumnType("INTEGER") + .HasColumnName("is_archived"); + + b.Property("IsFavorite") + .HasColumnType("INTEGER") + .HasColumnName("is_favorite"); + + b.Property("PartOfSpeech") + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("part_of_speech"); + + b.Property("Pronunciation") + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("pronunciation"); + + b.Property("Synonyms") + .HasMaxLength(2000) + .HasColumnType("TEXT") + .HasColumnName("synonyms"); + + b.Property("Translation") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("translation"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.Property("Word") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("word"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("flashcards", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.FlashcardExampleImage", b => + { + b.Property("FlashcardId") + .HasColumnType("TEXT") + .HasColumnName("flashcard_id"); + + b.Property("ExampleImageId") + .HasColumnType("TEXT") + .HasColumnName("example_image_id"); + + b.Property("ContextRelevance") + .HasColumnType("TEXT") + .HasColumnName("context_relevance"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("DisplayOrder") + .HasColumnType("INTEGER") + .HasColumnName("display_order"); + + b.Property("IsPrimary") + .HasColumnType("INTEGER") + .HasColumnName("is_primary"); + + b.HasKey("FlashcardId", "ExampleImageId"); + + b.HasIndex("ExampleImageId"); + + b.ToTable("flashcard_example_images", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.FlashcardReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("FlashcardId") + .HasColumnType("TEXT") + .HasColumnName("flashcard_id"); + + b.Property("LastReviewDate") + .HasColumnType("TEXT") + .HasColumnName("last_review_date"); + + b.Property("LastSuccessDate") + .HasColumnType("TEXT") + .HasColumnName("last_success_date"); + + b.Property("NextReviewDate") + .HasColumnType("TEXT") + .HasColumnName("next_review_date"); + + b.Property("SuccessCount") + .HasColumnType("INTEGER") + .HasColumnName("success_count"); + + b.Property("TotalCorrectCount") + .HasColumnType("INTEGER") + .HasColumnName("total_correct_count"); + + b.Property("TotalSkipCount") + .HasColumnType("INTEGER") + .HasColumnName("total_skip_count"); + + b.Property("TotalWrongCount") + .HasColumnType("INTEGER") + .HasColumnName("total_wrong_count"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("NextReviewDate") + .HasDatabaseName("IX_FlashcardReviews_NextReviewDate"); + + b.HasIndex("FlashcardId", "UserId") + .IsUnique(); + + b.HasIndex("UserId", "NextReviewDate") + .HasDatabaseName("IX_FlashcardReviews_UserId_NextReviewDate"); + + b.ToTable("flashcard_reviews", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.FlashcardTag", b => + { + b.Property("FlashcardId") + .HasColumnType("TEXT") + .HasColumnName("flashcard_id"); + + b.Property("TagId") + .HasColumnType("TEXT") + .HasColumnName("tag_id"); + + b.HasKey("FlashcardId", "TagId"); + + b.HasIndex("TagId"); + + b.ToTable("flashcard_tags", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.ImageGenerationRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("TEXT") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("FinalReplicatePrompt") + .HasColumnType("TEXT") + .HasColumnName("final_replicate_prompt"); + + b.Property("FlashcardId") + .HasColumnType("TEXT") + .HasColumnName("flashcard_id"); + + b.Property("GeminiCompletedAt") + .HasColumnType("TEXT") + .HasColumnName("gemini_completed_at"); + + b.Property("GeminiCost") + .HasColumnType("TEXT") + .HasColumnName("gemini_cost"); + + b.Property("GeminiErrorMessage") + .HasColumnType("TEXT") + .HasColumnName("gemini_error_message"); + + b.Property("GeminiProcessingTimeMs") + .HasColumnType("INTEGER") + .HasColumnName("gemini_processing_time_ms"); + + b.Property("GeminiPrompt") + .HasColumnType("TEXT") + .HasColumnName("gemini_prompt"); + + b.Property("GeminiStartedAt") + .HasColumnType("TEXT") + .HasColumnName("gemini_started_at"); + + b.Property("GeminiStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT") + .HasColumnName("gemini_status"); + + b.Property("GeneratedDescription") + .HasColumnType("TEXT") + .HasColumnName("generated_description"); + + b.Property("GeneratedImageId") + .HasColumnType("TEXT") + .HasColumnName("generated_image_id"); + + b.Property("OriginalRequest") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("original_request"); + + b.Property("OverallStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT") + .HasColumnName("overall_status"); + + b.Property("ReplicateCompletedAt") + .HasColumnType("TEXT") + .HasColumnName("replicate_completed_at"); + + b.Property("ReplicateCost") + .HasColumnType("TEXT") + .HasColumnName("replicate_cost"); + + b.Property("ReplicateErrorMessage") + .HasColumnType("TEXT") + .HasColumnName("replicate_error_message"); + + b.Property("ReplicateProcessingTimeMs") + .HasColumnType("INTEGER") + .HasColumnName("replicate_processing_time_ms"); + + b.Property("ReplicateStartedAt") + .HasColumnType("TEXT") + .HasColumnName("replicate_started_at"); + + b.Property("ReplicateStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT") + .HasColumnName("replicate_status"); + + b.Property("TotalCost") + .HasColumnType("TEXT") + .HasColumnName("total_cost"); + + b.Property("TotalProcessingTimeMs") + .HasColumnType("INTEGER") + .HasColumnName("total_processing_time_ms"); + + b.Property("UserId") + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("FlashcardId"); + + b.HasIndex("GeneratedImageId"); + + b.HasIndex("UserId"); + + b.ToTable("image_generation_requests", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.OptionsVocabulary", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CEFRLevel") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("TEXT") + .HasColumnName("cefr_level"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true) + .HasColumnName("is_active"); + + b.Property("PartOfSpeech") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT") + .HasColumnName("part_of_speech"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("updated_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Word") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("word"); + + b.Property("WordLength") + .HasColumnType("INTEGER") + .HasColumnName("word_length"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "IsActive" }, "IX_OptionsVocabulary_Active"); + + b.HasIndex(new[] { "CEFRLevel" }, "IX_OptionsVocabulary_CEFR"); + + b.HasIndex(new[] { "CEFRLevel", "PartOfSpeech", "WordLength" }, "IX_OptionsVocabulary_Core_Matching"); + + b.HasIndex(new[] { "PartOfSpeech" }, "IX_OptionsVocabulary_PartOfSpeech"); + + b.HasIndex(new[] { "Word" }, "IX_OptionsVocabulary_Word") + .IsUnique(); + + b.HasIndex(new[] { "WordLength" }, "IX_OptionsVocabulary_WordLength"); + + b.ToTable("options_vocabularies", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.PronunciationAssessment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AccuracyScore") + .HasColumnType("TEXT") + .HasColumnName("accuracy_score"); + + b.Property("AudioUrl") + .HasColumnType("TEXT") + .HasColumnName("audio_url"); + + b.Property("CompletenessScore") + .HasColumnType("TEXT") + .HasColumnName("completeness_score"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("FlashcardId") + .HasColumnType("TEXT") + .HasColumnName("flashcard_id"); + + b.Property("FluencyScore") + .HasColumnType("TEXT") + .HasColumnName("fluency_score"); + + b.Property("OverallScore") + .HasColumnType("INTEGER") + .HasColumnName("overall_score"); + + b.Property("PhonemeScores") + .HasColumnType("TEXT") + .HasColumnName("phoneme_scores"); + + b.Property("PracticeMode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT") + .HasColumnName("practice_mode"); + + b.Property("ProsodyScore") + .HasColumnType("TEXT") + .HasColumnName("prosody_score"); + + b.Property("Suggestions") + .HasColumnType("TEXT") + .HasColumnName("suggestions"); + + b.Property("TargetText") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("target_text"); + + b.Property("UserId") + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("FlashcardId"); + + b.HasIndex("UserId", "FlashcardId") + .HasDatabaseName("IX_PronunciationAssessment_UserFlashcard"); + + b.ToTable("pronunciation_assessments", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.SentenceAnalysisCache", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AccessCount") + .HasColumnType("INTEGER") + .HasColumnName("access_count"); + + b.Property("AnalysisResult") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("analysis_result"); + + b.Property("CorrectedText") + .HasMaxLength(1000) + .HasColumnType("TEXT") + .HasColumnName("corrected_text"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT") + .HasColumnName("expires_at"); + + b.Property("GrammarCorrections") + .HasColumnType("TEXT") + .HasColumnName("grammar_corrections"); + + b.Property("HasGrammarErrors") + .HasColumnType("INTEGER") + .HasColumnName("has_grammar_errors"); + + b.Property("HighValueWords") + .HasColumnType("TEXT") + .HasColumnName("high_value_words"); + + b.Property("IdiomsDetected") + .HasColumnType("TEXT") + .HasColumnName("idioms_detected"); + + b.Property("InputText") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT") + .HasColumnName("input_text"); + + b.Property("InputTextHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT") + .HasColumnName("input_text_hash"); + + b.Property("LastAccessedAt") + .HasColumnType("TEXT") + .HasColumnName("last_accessed_at"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt") + .HasDatabaseName("IX_SentenceAnalysisCache_Expires"); + + b.HasIndex("InputTextHash") + .HasDatabaseName("IX_SentenceAnalysisCache_Hash"); + + b.HasIndex("InputTextHash", "ExpiresAt") + .HasDatabaseName("IX_SentenceAnalysisCache_Hash_Expires"); + + b.ToTable("sentence_analysis_cache", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Color") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("color"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("UsageCount") + .HasColumnType("INTEGER") + .HasColumnName("usage_count"); + + b.Property("UserId") + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("tags", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AvatarUrl") + .HasColumnType("TEXT") + .HasColumnName("avatar_url"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("DisplayName") + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("display_name"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("email"); + + b.Property("EnglishLevel") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT") + .HasColumnName("english_level"); + + b.Property("EnglishLevelNumeric") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(2) + .HasColumnName("english_level_numeric"); + + b.Property("IsLevelVerified") + .HasColumnType("INTEGER") + .HasColumnName("is_level_verified"); + + b.Property("LevelNotes") + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("level_notes"); + + b.Property("LevelUpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("level_updated_at"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("password_hash"); + + b.Property("Preferences") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("preferences"); + + b.Property("SubscriptionType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT") + .HasColumnName("subscription_type"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated_at"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("username"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("user_profiles", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.UserAudioPreferences", b => + { + b.Property("UserId") + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.Property("AutoPlayEnabled") + .HasColumnType("INTEGER") + .HasColumnName("auto_play_enabled"); + + b.Property("DefaultSpeed") + .HasColumnType("TEXT") + .HasColumnName("default_speed"); + + b.Property("EnableDetailedFeedback") + .HasColumnType("INTEGER") + .HasColumnName("enable_detailed_feedback"); + + b.Property("PreferredAccent") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("TEXT") + .HasColumnName("preferred_accent"); + + b.Property("PreferredVoiceFemale") + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("preferred_voice_female"); + + b.Property("PreferredVoiceMale") + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("preferred_voice_male"); + + b.Property("PronunciationDifficulty") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT") + .HasColumnName("pronunciation_difficulty"); + + b.Property("TargetScoreThreshold") + .HasColumnType("INTEGER") + .HasColumnName("target_score_threshold"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated_at"); + + b.HasKey("UserId"); + + b.ToTable("user_audio_preferences", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.UserSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AutoPlayAudio") + .HasColumnType("INTEGER") + .HasColumnName("auto_play_audio"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("DailyGoal") + .HasColumnType("INTEGER") + .HasColumnName("daily_goal"); + + b.Property("DifficultyPreference") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT") + .HasColumnName("difficulty_preference"); + + b.Property("ReminderEnabled") + .HasColumnType("INTEGER") + .HasColumnName("reminder_enabled"); + + b.Property("ReminderTime") + .HasColumnType("TEXT") + .HasColumnName("reminder_time"); + + b.Property("ShowPronunciation") + .HasColumnType("INTEGER") + .HasColumnName("show_pronunciation"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("user_settings", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.WordQueryUsageStats", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Date") + .HasColumnType("TEXT") + .HasColumnName("date"); + + b.Property("HighValueWordClicks") + .HasColumnType("INTEGER") + .HasColumnName("high_value_word_clicks"); + + b.Property("LowValueWordClicks") + .HasColumnType("INTEGER") + .HasColumnName("low_value_word_clicks"); + + b.Property("SentenceAnalysisCount") + .HasColumnType("INTEGER") + .HasColumnName("sentence_analysis_count"); + + b.Property("TotalApiCalls") + .HasColumnType("INTEGER") + .HasColumnName("total_api_calls"); + + b.Property("UniqueWordsQueried") + .HasColumnType("INTEGER") + .HasColumnName("unique_words_queried"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("IX_WordQueryUsageStats_CreatedAt"); + + b.HasIndex("UserId", "Date") + .IsUnique() + .HasDatabaseName("IX_WordQueryUsageStats_UserDate"); + + b.ToTable("word_query_usage_stats", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.DailyStats", b => + { + b.HasOne("DramaLing.Api.Models.Entities.User", "User") + .WithMany("DailyStats") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.ErrorReport", b => + { + b.HasOne("DramaLing.Api.Models.Entities.Flashcard", "Flashcard") + .WithMany("ErrorReports") + .HasForeignKey("FlashcardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DramaLing.Api.Models.Entities.User", "ResolvedByUser") + .WithMany() + .HasForeignKey("ResolvedBy") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("DramaLing.Api.Models.Entities.User", "User") + .WithMany("ErrorReports") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Flashcard"); + + b.Navigation("ResolvedByUser"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.Flashcard", b => + { + b.HasOne("DramaLing.Api.Models.Entities.User", "User") + .WithMany("Flashcards") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.FlashcardExampleImage", b => + { + b.HasOne("DramaLing.Api.Models.Entities.ExampleImage", "ExampleImage") + .WithMany("FlashcardExampleImages") + .HasForeignKey("ExampleImageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DramaLing.Api.Models.Entities.Flashcard", "Flashcard") + .WithMany("FlashcardExampleImages") + .HasForeignKey("FlashcardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ExampleImage"); + + b.Navigation("Flashcard"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.FlashcardReview", b => + { + b.HasOne("DramaLing.Api.Models.Entities.Flashcard", "Flashcard") + .WithMany() + .HasForeignKey("FlashcardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DramaLing.Api.Models.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Flashcard"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.FlashcardTag", b => + { + b.HasOne("DramaLing.Api.Models.Entities.Flashcard", "Flashcard") + .WithMany("FlashcardTags") + .HasForeignKey("FlashcardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DramaLing.Api.Models.Entities.Tag", "Tag") + .WithMany("FlashcardTags") + .HasForeignKey("TagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Flashcard"); + + b.Navigation("Tag"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.ImageGenerationRequest", b => + { + b.HasOne("DramaLing.Api.Models.Entities.Flashcard", "Flashcard") + .WithMany() + .HasForeignKey("FlashcardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DramaLing.Api.Models.Entities.ExampleImage", "GeneratedImage") + .WithMany() + .HasForeignKey("GeneratedImageId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("DramaLing.Api.Models.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Flashcard"); + + b.Navigation("GeneratedImage"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.PronunciationAssessment", b => + { + b.HasOne("DramaLing.Api.Models.Entities.Flashcard", "Flashcard") + .WithMany() + .HasForeignKey("FlashcardId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("DramaLing.Api.Models.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Flashcard"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.Tag", b => + { + b.HasOne("DramaLing.Api.Models.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.UserAudioPreferences", b => + { + b.HasOne("DramaLing.Api.Models.Entities.User", "User") + .WithOne() + .HasForeignKey("DramaLing.Api.Models.Entities.UserAudioPreferences", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.UserSettings", b => + { + b.HasOne("DramaLing.Api.Models.Entities.User", "User") + .WithOne("Settings") + .HasForeignKey("DramaLing.Api.Models.Entities.UserSettings", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.WordQueryUsageStats", b => + { + b.HasOne("DramaLing.Api.Models.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.ExampleImage", b => + { + b.Navigation("FlashcardExampleImages"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.Flashcard", b => + { + b.Navigation("ErrorReports"); + + b.Navigation("FlashcardExampleImages"); + + b.Navigation("FlashcardTags"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.Tag", b => + { + b.Navigation("FlashcardTags"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.User", b => + { + b.Navigation("DailyStats"); + + b.Navigation("ErrorReports"); + + b.Navigation("Flashcards"); + + b.Navigation("Settings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/DramaLing.Api/Migrations/20251007093605_FixSynonymsColumn.cs b/backend/DramaLing.Api/Migrations/20251007093605_FixSynonymsColumn.cs new file mode 100644 index 0000000..9139a94 --- /dev/null +++ b/backend/DramaLing.Api/Migrations/20251007093605_FixSynonymsColumn.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DramaLing.Api.Migrations +{ + /// + public partial class FixSynonymsColumn : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "synonyms", + table: "flashcards", + type: "TEXT", + maxLength: 2000, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "synonyms", + table: "flashcards"); + } + } +} diff --git a/backend/DramaLing.Api/Migrations/DramaLingDbContextModelSnapshot.cs b/backend/DramaLing.Api/Migrations/DramaLingDbContextModelSnapshot.cs index 34e212f..717fbad 100644 --- a/backend/DramaLing.Api/Migrations/DramaLingDbContextModelSnapshot.cs +++ b/backend/DramaLing.Api/Migrations/DramaLingDbContextModelSnapshot.cs @@ -348,6 +348,11 @@ namespace DramaLing.Api.Migrations .HasColumnType("TEXT") .HasColumnName("pronunciation"); + b.Property("Synonyms") + .HasMaxLength(2000) + .HasColumnType("TEXT") + .HasColumnName("synonyms"); + b.Property("Translation") .IsRequired() .HasColumnType("TEXT") diff --git a/backend/DramaLing.Api/Models/Entities/Flashcard.cs b/backend/DramaLing.Api/Models/Entities/Flashcard.cs index 12c8b74..d93918c 100644 --- a/backend/DramaLing.Api/Models/Entities/Flashcard.cs +++ b/backend/DramaLing.Api/Models/Entities/Flashcard.cs @@ -34,6 +34,9 @@ public class Flashcard public string? ExampleTranslation { get; set; } + [MaxLength(2000)] + public string? Synonyms { get; set; } + // 基本狀態 public bool IsFavorite { get; set; } = false; diff --git a/frontend/app/generate/page.tsx b/frontend/app/generate/page.tsx index 52e2e57..613da61 100644 --- a/frontend/app/generate/page.tsx +++ b/frontend/app/generate/page.tsx @@ -202,6 +202,7 @@ function GenerateContent() { partOfSpeech: analysis.partOfSpeech || analysis.PartOfSpeech || 'noun', example: analysis.example || `Example sentence with ${word}.`, // 使用分析結果的例句 exampleTranslation: analysis.exampleTranslation, + synonyms: analysis.synonyms ? JSON.stringify(analysis.synonyms) : undefined, // 轉換為 JSON 字串 cefr: cefrValue } diff --git a/frontend/components/flashcards/FlashcardContentBlocks.tsx b/frontend/components/flashcards/FlashcardContentBlocks.tsx index 97cc2db..71b8c25 100644 --- a/frontend/components/flashcards/FlashcardContentBlocks.tsx +++ b/frontend/components/flashcards/FlashcardContentBlocks.tsx @@ -22,6 +22,23 @@ export const FlashcardContentBlocks: React.FC = ({ generationProgress, onGenerateImage }) => { + // 安全解析同義詞 JSON 字串 + const parseSynonyms = (synonymsData: any): string[] => { + if (!synonymsData) return [] + if (Array.isArray(synonymsData)) return synonymsData + if (typeof synonymsData === 'string') { + try { + const parsed = JSON.parse(synonymsData) + return Array.isArray(parsed) ? parsed : [] + } catch { + return [] + } + } + return [] + } + + const synonymsList = parseSynonyms((flashcard as any).synonyms) + return (
{/* 翻譯區塊 */} @@ -173,11 +190,11 @@ export const FlashcardContentBlocks: React.FC = ({
{/* 同義詞區塊 */} - {(flashcard as any).synonyms && (flashcard as any).synonyms.length > 0 && ( + {synonymsList.length > 0 && (

同義詞

- {(flashcard as any).synonyms.map((synonym: string, index: number) => ( + {synonymsList.map((synonym: string, index: number) => ( = ({ className="text-sm border border-gray-300 rounded-md px-3 py-1 focus:ring-2 focus:ring-primary focus:border-primary" > - diff --git a/frontend/lib/services/flashcards.ts b/frontend/lib/services/flashcards.ts index d06bce9..34189fd 100644 --- a/frontend/lib/services/flashcards.ts +++ b/frontend/lib/services/flashcards.ts @@ -43,6 +43,7 @@ export interface CreateFlashcardRequest { partOfSpeech: string; example: string; exampleTranslation?: string; + synonyms?: string; // AI 生成的同義詞 (JSON 字串格式) cefr?: string; // A1, A2, B1, B2, C1, C2 }