From 50cf813400c020e1585f6cd7d017baedf4392349 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=84=AD=E6=B2=9B=E8=BB=92?= Date: Fri, 26 Sep 2025 13:37:07 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=87=8D=E6=A7=8B=E8=A4=87=E7=BF=92?= =?UTF-8?q?=E7=B3=BB=E7=B5=B1=E7=82=BA=E5=BE=8C=E7=AB=AF=E9=A9=85=E5=8B=95?= =?UTF-8?q?=E6=9E=B6=E6=A7=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增StudyCard和TestResult實體支持詞卡內測驗追蹤 - 擴展StudySession實體添加會話狀態和進度管理 - 修改前端邏輯確保完成所有測驗才標記詞卡完成 - 創建完整重構計劃文檔規劃後續開發 - 改進進度條UI為雙層顯示測驗和詞卡進度 - 添加任務清單彈出模態框提升用戶體驗 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../DramaLing.Api/Data/DramaLingDbContext.cs | 4 + ...3105_AddStudyCardAndTestResult.Designer.cs | 1565 +++++++++++++++++ ...0250926053105_AddStudyCardAndTestResult.cs | 162 ++ .../DramaLingDbContextModelSnapshot.cs | 138 ++ .../Models/Entities/StudyCard.cs | 95 + .../Models/Entities/StudySession.cs | 46 + 6 files changed, 2010 insertions(+) create mode 100644 backend/DramaLing.Api/Migrations/20250926053105_AddStudyCardAndTestResult.Designer.cs create mode 100644 backend/DramaLing.Api/Migrations/20250926053105_AddStudyCardAndTestResult.cs create mode 100644 backend/DramaLing.Api/Models/Entities/StudyCard.cs diff --git a/backend/DramaLing.Api/Data/DramaLingDbContext.cs b/backend/DramaLing.Api/Data/DramaLingDbContext.cs index 02d000b..a38721e 100644 --- a/backend/DramaLing.Api/Data/DramaLingDbContext.cs +++ b/backend/DramaLing.Api/Data/DramaLingDbContext.cs @@ -19,6 +19,8 @@ public class DramaLingDbContext : DbContext public DbSet FlashcardTags { get; set; } public DbSet StudySessions { get; set; } public DbSet StudyRecords { get; set; } + public DbSet StudyCards { get; set; } + public DbSet TestResults { get; set; } public DbSet ErrorReports { get; set; } public DbSet DailyStats { get; set; } public DbSet SentenceAnalysisCache { get; set; } @@ -43,6 +45,8 @@ public class DramaLingDbContext : DbContext modelBuilder.Entity().ToTable("flashcard_tags"); modelBuilder.Entity().ToTable("study_sessions"); modelBuilder.Entity().ToTable("study_records"); + modelBuilder.Entity().ToTable("study_cards"); + modelBuilder.Entity().ToTable("test_results"); modelBuilder.Entity().ToTable("error_reports"); modelBuilder.Entity().ToTable("daily_stats"); modelBuilder.Entity().ToTable("audio_cache"); diff --git a/backend/DramaLing.Api/Migrations/20250926053105_AddStudyCardAndTestResult.Designer.cs b/backend/DramaLing.Api/Migrations/20250926053105_AddStudyCardAndTestResult.Designer.cs new file mode 100644 index 0000000..f8d0d2c --- /dev/null +++ b/backend/DramaLing.Api/Migrations/20250926053105_AddStudyCardAndTestResult.Designer.cs @@ -0,0 +1,1565 @@ +// +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("20250926053105_AddStudyCardAndTestResult")] + partial class AddStudyCardAndTestResult + { + /// + 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"); + + b.Property("Accent") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("TEXT"); + + 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.CardSet", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CardCount") + .HasColumnType("INTEGER"); + + b.Property("Color") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("card_sets", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.DailyStats", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + 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"); + + 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"); + + b.Property("AdminNotes") + .HasColumnType("TEXT") + .HasColumnName("admin_notes"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Description") + .HasColumnType("TEXT"); + + 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"); + + 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"); + + 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"); + + b.Property("CardSetId") + .HasColumnType("TEXT") + .HasColumnName("card_set_id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DifficultyLevel") + .HasMaxLength(10) + .HasColumnType("TEXT") + .HasColumnName("difficulty_level"); + + b.Property("EasinessFactor") + .HasColumnType("REAL") + .HasColumnName("easiness_factor"); + + b.Property("Example") + .HasColumnType("TEXT"); + + b.Property("ExampleTranslation") + .HasColumnType("TEXT") + .HasColumnName("example_translation"); + + b.Property("IntervalDays") + .HasColumnType("INTEGER") + .HasColumnName("interval_days"); + + b.Property("IsArchived") + .HasColumnType("INTEGER") + .HasColumnName("is_archived"); + + b.Property("IsFavorite") + .HasColumnType("INTEGER") + .HasColumnName("is_favorite"); + + b.Property("LastQuestionType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("LastReviewedAt") + .HasColumnType("TEXT") + .HasColumnName("last_reviewed_at"); + + b.Property("MasteryLevel") + .HasColumnType("INTEGER") + .HasColumnName("mastery_level"); + + b.Property("NextReviewDate") + .HasColumnType("TEXT") + .HasColumnName("next_review_date"); + + b.Property("PartOfSpeech") + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("part_of_speech"); + + b.Property("Pronunciation") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Repetitions") + .HasColumnType("INTEGER"); + + b.Property("ReviewHistory") + .HasColumnType("TEXT"); + + b.Property("TimesCorrect") + .HasColumnType("INTEGER") + .HasColumnName("times_correct"); + + b.Property("TimesReviewed") + .HasColumnType("INTEGER") + .HasColumnName("times_reviewed"); + + b.Property("Translation") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.Property("Word") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CardSetId"); + + 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.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"); + + 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.PronunciationAssessment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + 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("StudySessionId") + .HasColumnType("TEXT") + .HasColumnName("study_session_id"); + + 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("StudySessionId") + .HasDatabaseName("IX_PronunciationAssessment_Session"); + + 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"); + + b.Property("AccessCount") + .HasColumnType("INTEGER"); + + b.Property("AnalysisResult") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CorrectedText") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("GrammarCorrections") + .HasColumnType("TEXT"); + + b.Property("HasGrammarErrors") + .HasColumnType("INTEGER"); + + b.Property("HighValueWords") + .HasColumnType("TEXT"); + + b.Property("IdiomsDetected") + .HasColumnType("TEXT"); + + b.Property("InputText") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("InputTextHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastAccessedAt") + .HasColumnType("TEXT"); + + 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("SentenceAnalysisCache"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.StudyCard", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("FlashcardId") + .HasColumnType("TEXT"); + + b.Property("IsCompleted") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("PlannedTests") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PlannedTestsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("StudySessionId") + .HasColumnType("TEXT"); + + b.Property("Word") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("FlashcardId"); + + b.HasIndex("StudySessionId"); + + b.ToTable("study_cards", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.StudyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("FlashcardId") + .HasColumnType("TEXT") + .HasColumnName("flashcard_id"); + + b.Property("IsCorrect") + .HasColumnType("INTEGER") + .HasColumnName("is_correct"); + + b.Property("NewEasinessFactor") + .HasColumnType("REAL"); + + b.Property("NewIntervalDays") + .HasColumnType("INTEGER"); + + b.Property("NewRepetitions") + .HasColumnType("INTEGER"); + + b.Property("NextReviewDate") + .HasColumnType("TEXT"); + + b.Property("PreviousEasinessFactor") + .HasColumnType("REAL"); + + b.Property("PreviousIntervalDays") + .HasColumnType("INTEGER"); + + b.Property("PreviousRepetitions") + .HasColumnType("INTEGER"); + + b.Property("QualityRating") + .HasColumnType("INTEGER") + .HasColumnName("quality_rating"); + + b.Property("ResponseTimeMs") + .HasColumnType("INTEGER") + .HasColumnName("response_time_ms"); + + b.Property("SessionId") + .HasColumnType("TEXT") + .HasColumnName("session_id"); + + b.Property("StudiedAt") + .HasColumnType("TEXT") + .HasColumnName("studied_at"); + + b.Property("StudyMode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("study_mode"); + + b.Property("UserAnswer") + .HasColumnType("TEXT") + .HasColumnName("user_answer"); + + b.Property("UserId") + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("FlashcardId"); + + b.HasIndex("SessionId"); + + b.HasIndex("UserId"); + + b.ToTable("study_records", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.StudySession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AverageResponseTimeMs") + .HasColumnType("INTEGER") + .HasColumnName("average_response_time_ms"); + + b.Property("CompletedCards") + .HasColumnType("INTEGER"); + + b.Property("CompletedTests") + .HasColumnType("INTEGER"); + + b.Property("CorrectCount") + .HasColumnType("INTEGER") + .HasColumnName("correct_count"); + + b.Property("CurrentCardIndex") + .HasColumnType("INTEGER"); + + b.Property("CurrentTestType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("DurationSeconds") + .HasColumnType("INTEGER") + .HasColumnName("duration_seconds"); + + b.Property("EndedAt") + .HasColumnType("TEXT") + .HasColumnName("ended_at"); + + b.Property("SessionType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("session_type"); + + b.Property("StartedAt") + .HasColumnType("TEXT") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TotalCards") + .HasColumnType("INTEGER") + .HasColumnName("total_cards"); + + b.Property("TotalTests") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("study_sessions", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Color") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + 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.TestResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("ConfidenceLevel") + .HasColumnType("INTEGER"); + + b.Property("IsCorrect") + .HasColumnType("INTEGER"); + + b.Property("ResponseTimeMs") + .HasColumnType("INTEGER"); + + b.Property("StudyCardId") + .HasColumnType("TEXT"); + + b.Property("TestType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("UserAnswer") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("StudyCardId"); + + b.ToTable("test_results", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + 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("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"); + + 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"); + + b.Property("AutoPlayAudio") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DailyGoal") + .HasColumnType("INTEGER"); + + b.Property("DifficultyPreference") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("ReminderEnabled") + .HasColumnType("INTEGER"); + + b.Property("ReminderTime") + .HasColumnType("TEXT"); + + b.Property("ShowPronunciation") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + 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"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("HighValueWordClicks") + .HasColumnType("INTEGER"); + + b.Property("LowValueWordClicks") + .HasColumnType("INTEGER"); + + b.Property("SentenceAnalysisCount") + .HasColumnType("INTEGER"); + + b.Property("TotalApiCalls") + .HasColumnType("INTEGER"); + + b.Property("UniqueWordsQueried") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("IX_WordQueryUsageStats_CreatedAt"); + + b.HasIndex("UserId", "Date") + .IsUnique() + .HasDatabaseName("IX_WordQueryUsageStats_UserDate"); + + b.ToTable("WordQueryUsageStats"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.CardSet", b => + { + b.HasOne("DramaLing.Api.Models.Entities.User", "User") + .WithMany("CardSets") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + 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.CardSet", "CardSet") + .WithMany("Flashcards") + .HasForeignKey("CardSetId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("DramaLing.Api.Models.Entities.User", "User") + .WithMany("Flashcards") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CardSet"); + + 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.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.StudySession", "StudySession") + .WithMany() + .HasForeignKey("StudySessionId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("DramaLing.Api.Models.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Flashcard"); + + b.Navigation("StudySession"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.StudyCard", b => + { + b.HasOne("DramaLing.Api.Models.Entities.Flashcard", "Flashcard") + .WithMany() + .HasForeignKey("FlashcardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DramaLing.Api.Models.Entities.StudySession", "StudySession") + .WithMany("StudyCards") + .HasForeignKey("StudySessionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Flashcard"); + + b.Navigation("StudySession"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.StudyRecord", b => + { + b.HasOne("DramaLing.Api.Models.Entities.Flashcard", "Flashcard") + .WithMany("StudyRecords") + .HasForeignKey("FlashcardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DramaLing.Api.Models.Entities.StudySession", "Session") + .WithMany("StudyRecords") + .HasForeignKey("SessionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DramaLing.Api.Models.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Flashcard"); + + b.Navigation("Session"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.StudySession", b => + { + b.HasOne("DramaLing.Api.Models.Entities.User", "User") + .WithMany("StudySessions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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.TestResult", b => + { + b.HasOne("DramaLing.Api.Models.Entities.StudyCard", "StudyCard") + .WithMany("TestResults") + .HasForeignKey("StudyCardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("StudyCard"); + }); + + 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.CardSet", b => + { + b.Navigation("Flashcards"); + }); + + 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"); + + b.Navigation("StudyRecords"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.StudyCard", b => + { + b.Navigation("TestResults"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.StudySession", b => + { + b.Navigation("StudyCards"); + + b.Navigation("StudyRecords"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.Tag", b => + { + b.Navigation("FlashcardTags"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.User", b => + { + b.Navigation("CardSets"); + + b.Navigation("DailyStats"); + + b.Navigation("ErrorReports"); + + b.Navigation("Flashcards"); + + b.Navigation("Settings"); + + b.Navigation("StudySessions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/DramaLing.Api/Migrations/20250926053105_AddStudyCardAndTestResult.cs b/backend/DramaLing.Api/Migrations/20250926053105_AddStudyCardAndTestResult.cs new file mode 100644 index 0000000..67dad25 --- /dev/null +++ b/backend/DramaLing.Api/Migrations/20250926053105_AddStudyCardAndTestResult.cs @@ -0,0 +1,162 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DramaLing.Api.Migrations +{ + /// + public partial class AddStudyCardAndTestResult : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CompletedCards", + table: "study_sessions", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "CompletedTests", + table: "study_sessions", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "CurrentCardIndex", + table: "study_sessions", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "CurrentTestType", + table: "study_sessions", + type: "TEXT", + maxLength: 50, + nullable: true); + + migrationBuilder.AddColumn( + name: "Status", + table: "study_sessions", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "TotalTests", + table: "study_sessions", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.CreateTable( + name: "study_cards", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + StudySessionId = table.Column(type: "TEXT", nullable: false), + FlashcardId = table.Column(type: "TEXT", nullable: false), + Word = table.Column(type: "TEXT", maxLength: 100, nullable: false), + PlannedTestsJson = table.Column(type: "TEXT", nullable: false), + Order = table.Column(type: "INTEGER", nullable: false), + IsCompleted = table.Column(type: "INTEGER", nullable: false), + StartedAt = table.Column(type: "TEXT", nullable: false), + CompletedAt = table.Column(type: "TEXT", nullable: true), + PlannedTests = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_study_cards", x => x.Id); + table.ForeignKey( + name: "FK_study_cards_flashcards_FlashcardId", + column: x => x.FlashcardId, + principalTable: "flashcards", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_study_cards_study_sessions_StudySessionId", + column: x => x.StudySessionId, + principalTable: "study_sessions", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "test_results", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + StudyCardId = table.Column(type: "TEXT", nullable: false), + TestType = table.Column(type: "TEXT", maxLength: 50, nullable: false), + IsCorrect = table.Column(type: "INTEGER", nullable: false), + UserAnswer = table.Column(type: "TEXT", nullable: true), + ConfidenceLevel = table.Column(type: "INTEGER", nullable: true), + ResponseTimeMs = table.Column(type: "INTEGER", nullable: false), + CompletedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_test_results", x => x.Id); + table.ForeignKey( + name: "FK_test_results_study_cards_StudyCardId", + column: x => x.StudyCardId, + principalTable: "study_cards", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_study_cards_FlashcardId", + table: "study_cards", + column: "FlashcardId"); + + migrationBuilder.CreateIndex( + name: "IX_study_cards_StudySessionId", + table: "study_cards", + column: "StudySessionId"); + + migrationBuilder.CreateIndex( + name: "IX_test_results_StudyCardId", + table: "test_results", + column: "StudyCardId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "test_results"); + + migrationBuilder.DropTable( + name: "study_cards"); + + migrationBuilder.DropColumn( + name: "CompletedCards", + table: "study_sessions"); + + migrationBuilder.DropColumn( + name: "CompletedTests", + table: "study_sessions"); + + migrationBuilder.DropColumn( + name: "CurrentCardIndex", + table: "study_sessions"); + + migrationBuilder.DropColumn( + name: "CurrentTestType", + table: "study_sessions"); + + migrationBuilder.DropColumn( + name: "Status", + table: "study_sessions"); + + migrationBuilder.DropColumn( + name: "TotalTests", + table: "study_sessions"); + } + } +} diff --git a/backend/DramaLing.Api/Migrations/DramaLingDbContextModelSnapshot.cs b/backend/DramaLing.Api/Migrations/DramaLingDbContextModelSnapshot.cs index 3fbfc87..9096171 100644 --- a/backend/DramaLing.Api/Migrations/DramaLingDbContextModelSnapshot.cs +++ b/backend/DramaLing.Api/Migrations/DramaLingDbContextModelSnapshot.cs @@ -756,6 +756,52 @@ namespace DramaLing.Api.Migrations b.ToTable("SentenceAnalysisCache"); }); + modelBuilder.Entity("DramaLing.Api.Models.Entities.StudyCard", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("FlashcardId") + .HasColumnType("TEXT"); + + b.Property("IsCompleted") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("PlannedTests") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PlannedTestsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("StudySessionId") + .HasColumnType("TEXT"); + + b.Property("Word") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("FlashcardId"); + + b.HasIndex("StudySessionId"); + + b.ToTable("study_cards", (string)null); + }); + modelBuilder.Entity("DramaLing.Api.Models.Entities.StudyRecord", b => { b.Property("Id") @@ -842,10 +888,23 @@ namespace DramaLing.Api.Migrations .HasColumnType("INTEGER") .HasColumnName("average_response_time_ms"); + b.Property("CompletedCards") + .HasColumnType("INTEGER"); + + b.Property("CompletedTests") + .HasColumnType("INTEGER"); + b.Property("CorrectCount") .HasColumnType("INTEGER") .HasColumnName("correct_count"); + b.Property("CurrentCardIndex") + .HasColumnType("INTEGER"); + + b.Property("CurrentTestType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + b.Property("DurationSeconds") .HasColumnType("INTEGER") .HasColumnName("duration_seconds"); @@ -864,10 +923,16 @@ namespace DramaLing.Api.Migrations .HasColumnType("TEXT") .HasColumnName("started_at"); + b.Property("Status") + .HasColumnType("INTEGER"); + b.Property("TotalCards") .HasColumnType("INTEGER") .HasColumnName("total_cards"); + b.Property("TotalTests") + .HasColumnType("INTEGER"); + b.Property("UserId") .HasColumnType("TEXT") .HasColumnName("user_id"); @@ -914,6 +979,42 @@ namespace DramaLing.Api.Migrations b.ToTable("tags", (string)null); }); + modelBuilder.Entity("DramaLing.Api.Models.Entities.TestResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("ConfidenceLevel") + .HasColumnType("INTEGER"); + + b.Property("IsCorrect") + .HasColumnType("INTEGER"); + + b.Property("ResponseTimeMs") + .HasColumnType("INTEGER"); + + b.Property("StudyCardId") + .HasColumnType("TEXT"); + + b.Property("TestType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("UserAnswer") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("StudyCardId"); + + b.ToTable("test_results", (string)null); + }); + modelBuilder.Entity("DramaLing.Api.Models.Entities.User", b => { b.Property("Id") @@ -1291,6 +1392,25 @@ namespace DramaLing.Api.Migrations b.Navigation("User"); }); + modelBuilder.Entity("DramaLing.Api.Models.Entities.StudyCard", b => + { + b.HasOne("DramaLing.Api.Models.Entities.Flashcard", "Flashcard") + .WithMany() + .HasForeignKey("FlashcardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DramaLing.Api.Models.Entities.StudySession", "StudySession") + .WithMany("StudyCards") + .HasForeignKey("StudySessionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Flashcard"); + + b.Navigation("StudySession"); + }); + modelBuilder.Entity("DramaLing.Api.Models.Entities.StudyRecord", b => { b.HasOne("DramaLing.Api.Models.Entities.Flashcard", "Flashcard") @@ -1340,6 +1460,17 @@ namespace DramaLing.Api.Migrations b.Navigation("User"); }); + modelBuilder.Entity("DramaLing.Api.Models.Entities.TestResult", b => + { + b.HasOne("DramaLing.Api.Models.Entities.StudyCard", "StudyCard") + .WithMany("TestResults") + .HasForeignKey("StudyCardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("StudyCard"); + }); + modelBuilder.Entity("DramaLing.Api.Models.Entities.UserAudioPreferences", b => { b.HasOne("DramaLing.Api.Models.Entities.User", "User") @@ -1394,8 +1525,15 @@ namespace DramaLing.Api.Migrations b.Navigation("StudyRecords"); }); + modelBuilder.Entity("DramaLing.Api.Models.Entities.StudyCard", b => + { + b.Navigation("TestResults"); + }); + modelBuilder.Entity("DramaLing.Api.Models.Entities.StudySession", b => { + b.Navigation("StudyCards"); + b.Navigation("StudyRecords"); }); diff --git a/backend/DramaLing.Api/Models/Entities/StudyCard.cs b/backend/DramaLing.Api/Models/Entities/StudyCard.cs new file mode 100644 index 0000000..03d6044 --- /dev/null +++ b/backend/DramaLing.Api/Models/Entities/StudyCard.cs @@ -0,0 +1,95 @@ +using System.ComponentModel.DataAnnotations; + +namespace DramaLing.Api.Models.Entities; + +/// +/// 學習會話中的詞卡進度追蹤 +/// +public class StudyCard +{ + public Guid Id { get; set; } + + public Guid StudySessionId { get; set; } + + public Guid FlashcardId { get; set; } + + [Required] + [MaxLength(100)] + public string Word { get; set; } = string.Empty; + + /// + /// 該詞卡預定的測驗類型列表 (JSON序列化) + /// 例如: ["flip-memory", "vocab-choice", "sentence-fill"] + /// + [Required] + public string PlannedTestsJson { get; set; } = string.Empty; + + /// + /// 詞卡在會話中的順序 + /// + public int Order { get; set; } + + /// + /// 是否已完成所有測驗 + /// + public bool IsCompleted { get; set; } = false; + + /// + /// 詞卡學習開始時間 + /// + public DateTime StartedAt { get; set; } = DateTime.UtcNow; + + /// + /// 詞卡學習完成時間 + /// + public DateTime? CompletedAt { get; set; } + + // Navigation Properties + public virtual StudySession StudySession { get; set; } = null!; + public virtual Flashcard Flashcard { get; set; } = null!; + public virtual ICollection TestResults { get; set; } = new List(); + + // Helper Properties (不映射到資料庫) + public List PlannedTests + { + get => string.IsNullOrEmpty(PlannedTestsJson) + ? new List() + : System.Text.Json.JsonSerializer.Deserialize>(PlannedTestsJson) ?? new List(); + set => PlannedTestsJson = System.Text.Json.JsonSerializer.Serialize(value); + } + + public int CompletedTestsCount => TestResults?.Count ?? 0; + public int PlannedTestsCount => PlannedTests.Count; + public bool AllTestsCompleted => CompletedTestsCount >= PlannedTestsCount; +} + +/// +/// 詞卡內的測驗結果記錄 +/// +public class TestResult +{ + public Guid Id { get; set; } + + public Guid StudyCardId { get; set; } + + [Required] + [MaxLength(50)] + public string TestType { get; set; } = string.Empty; // flip-memory, vocab-choice, etc. + + public bool IsCorrect { get; set; } + + public string? UserAnswer { get; set; } + + /// + /// 信心等級 (1-5, 主要用於翻卡記憶測驗) + /// + [Range(1, 5)] + public int? ConfidenceLevel { get; set; } + + public int ResponseTimeMs { get; set; } + + public DateTime CompletedAt { get; set; } = DateTime.UtcNow; + + // Navigation Properties + public virtual StudyCard StudyCard { get; set; } = null!; +} \ No newline at end of file diff --git a/backend/DramaLing.Api/Models/Entities/StudySession.cs b/backend/DramaLing.Api/Models/Entities/StudySession.cs index d4e3d80..ee4e547 100644 --- a/backend/DramaLing.Api/Models/Entities/StudySession.cs +++ b/backend/DramaLing.Api/Models/Entities/StudySession.cs @@ -2,6 +2,20 @@ using System.ComponentModel.DataAnnotations; namespace DramaLing.Api.Models.Entities; +/// +/// 會話狀態枚舉 +/// +public enum SessionStatus +{ + Active, // 進行中 + Completed, // 已完成 + Paused, // 暫停 + Abandoned // 放棄 +} + +/// +/// 學習會話實體 (擴展版本) +/// public class StudySession { public Guid Id { get; set; } @@ -24,9 +38,41 @@ public class StudySession public int AverageResponseTimeMs { get; set; } = 0; + /// + /// 會話狀態 + /// + public SessionStatus Status { get; set; } = SessionStatus.Active; + + /// + /// 當前詞卡索引 (從0開始) + /// + public int CurrentCardIndex { get; set; } = 0; + + /// + /// 當前測驗類型 + /// + [MaxLength(50)] + public string? CurrentTestType { get; set; } + + /// + /// 總測驗數量 (所有詞卡的測驗總和) + /// + public int TotalTests { get; set; } = 0; + + /// + /// 已完成測驗數量 + /// + public int CompletedTests { get; set; } = 0; + + /// + /// 已完成詞卡數量 + /// + public int CompletedCards { get; set; } = 0; + // Navigation Properties public virtual User User { get; set; } = null!; public virtual ICollection StudyRecords { get; set; } = new List(); + public virtual ICollection StudyCards { get; set; } = new List(); } public class StudyRecord