# 詞卡保存功能技術規格 ## 1. 功能概述 ### 1.1 問題定義 當前系統生成詞卡後只能預覽,無法保存到資料庫。用戶點擊"💾 保存詞卡"時顯示"保存功能開發中...",影響核心學習流程。 ### 1.2 解決方案 實現完整的詞卡保存流程,包括:選擇性保存、重複檢測、進度追蹤、錯誤處理和用戶回饋。 ### 1.3 功能目標 - ✅ 批量保存生成的詞卡 - ✅ 智能檢測重複詞卡 - ✅ 提供直觀的用戶介面 - ✅ 完善的錯誤處理機制 - ✅ 保存進度實時回饋 --- ## 2. 用戶故事與使用場景 ### 2.1 主要用戶故事 #### US-01: 基本詞卡保存 **作為** 學習者 **我想要** 將AI生成的詞卡保存到我的詞庫 **以便** 後續進行學習和複習 **驗收標準:** - [ ] 可以選擇要保存的詞卡 - [ ] 可以指定保存到特定卡組 - [ ] 保存成功後有明確提示 - [ ] 可以跳轉到詞卡列表查看 #### US-02: 重複詞卡處理 **作為** 學習者 **我想要** 系統自動檢測重複的詞卡 **以便** 避免創建冗餘內容 **驗收標準:** - [ ] 系統自動檢測重複詞卡 - [ ] 提供重複處理選項(合併/跳過/替換) - [ ] 顯示重複詞卡的對比資訊 - [ ] 允許用戶手動決定處理方式 #### US-03: 批量操作 **作為** 學習者 **我想要** 一次選擇多張詞卡進行保存 **以便** 提高操作效率 **驗收標準:** - [ ] 支援全選/取消全選 - [ ] 支援單個詞卡選擇/取消 - [ ] 顯示選中詞卡數量 - [ ] 批量保存進度提示 ### 2.2 使用場景流程 ```mermaid graph TD A[用戶生成詞卡] --> B[預覽生成結果] B --> C[點擊保存按鈕] C --> D[彈出詞卡選擇對話框] D --> E[用戶選擇要保存的詞卡] E --> F[選擇目標卡組] F --> G[系統檢測重複詞卡] G --> H{發現重複?} H -->|是| I[彈出重複處理對話框] H -->|否| J[開始保存流程] I --> K[用戶選擇處理方式] K --> J J --> L[顯示保存進度] L --> M[保存完成] M --> N[成功提示] N --> O[跳轉到詞卡列表] ``` --- ## 3. 技術架構設計 ### 3.1 系統架構圖 ``` ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ 前端組件層 │ │ API控制層 │ │ 數據服務層 │ ├─────────────────┤ ├─────────────────┤ ├─────────────────┤ │ CardSelection │ │ FlashcardsAPI │ │ FlashcardRepo │ │ Dialog │◄──►│ │◄──►│ │ ├─────────────────┤ ├─────────────────┤ ├─────────────────┤ │ DuplicateHandle │ │ DuplicateAPI │ │ DuplicateDetect │ │ Dialog │ │ │ │ Service │ ├─────────────────┤ ├─────────────────┤ ├─────────────────┤ │ SaveProgress │ │ BatchSaveAPI │ │ BatchSave │ │ Indicator │ │ │ │ Service │ └─────────────────┘ └─────────────────┘ └─────────────────┘ ``` ### 3.2 數據流向設計 ``` 生成詞卡 → 用戶選擇 → 重複檢測 → 批量保存 → 結果回饋 ↓ ↓ ↓ ↓ ↓ 前端狀態 API請求 後端分析 數據庫寫入 UI更新 ``` --- ## 4. API設計規格 ### 4.1 批量保存詞卡API #### 4.1.1 基本規格 ```http POST /api/flashcards/batch-save Authorization: Bearer {token} Content-Type: application/json ``` #### 4.1.2 請求格式 ```typescript interface BatchSaveRequest { cardSetId?: string; // 目標卡組ID,可選 cards: CardSaveRequest[]; // 要保存的詞卡列表 duplicateHandling: DuplicateHandling; // 重複處理策略 } interface CardSaveRequest { tempId: string; // 臨時ID,用於前端追蹤 word: string; // 單字 translation: string; // 翻譯 definition: string; // 定義 partOfSpeech?: string; // 詞性 pronunciation?: string; // 發音 example?: string; // 例句 exampleTranslation?: string; // 例句翻譯 difficultyLevel?: string; // 難度等級 isSelected: boolean; // 是否選中保存 } interface DuplicateHandling { strategy: 'ask' | 'merge' | 'skip' | 'replace'; // 處理策略 autoApply: boolean; // 是否自動應用到所有重複 } ``` #### 4.1.3 回應格式 ```typescript interface BatchSaveResponse { success: boolean; data: { savedCards: SavedCardResult[]; // 成功保存的詞卡 skippedCards: SkippedCardResult[]; // 跳過的詞卡 duplicateCards: DuplicateCardResult[]; // 重複的詞卡 summary: { totalRequested: number; // 請求保存總數 totalSaved: number; // 實際保存數量 totalSkipped: number; // 跳過數量 totalDuplicates: number; // 重複數量 }; }; message?: string; errors?: SaveError[]; } interface SavedCardResult { tempId: string; // 對應請求中的tempId cardId: string; // 新創建的詞卡ID word: string; // 保存的單字 } interface SkippedCardResult { tempId: string; word: string; reason: 'duplicate' | 'error' | 'user_choice'; } interface DuplicateCardResult { tempId: string; word: string; existingCardId: string; // 現有詞卡ID similarityScore: number; // 相似度分數 action: 'merged' | 'skipped' | 'replaced'; // 執行的動作 } ``` ### 4.2 重複檢測API #### 4.2.1 基本規格 ```http POST /api/flashcards/check-duplicates Authorization: Bearer {token} Content-Type: application/json ``` #### 4.2.2 請求與回應 ```typescript interface DuplicateCheckRequest { cards: { tempId: string; word: string; translation: string; definition: string; }[]; } interface DuplicateCheckResponse { success: boolean; data: { duplicates: DuplicateMatch[]; noDuplicates: string[]; // 沒有重複的tempId列表 }; } interface DuplicateMatch { tempId: string; // 新詞卡臨時ID word: string; // 新詞卡單字 matches: ExistingCardMatch[]; // 匹配的現有詞卡 } interface ExistingCardMatch { cardId: string; // 現有詞卡ID word: string; // 現有詞卡單字 translation: string; // 現有詞卡翻譯 definition: string; // 現有詞卡定義 similarityScore: number; // 相似度分數(0-1) matchType: 'exact' | 'similar' | 'semantic'; // 匹配類型 confidence: 'high' | 'medium' | 'low'; // 信心等級 } ``` --- ## 5. 後端實現規格 ### 5.1 控制器實現 ```csharp [ApiController] [Route("api/[controller]")] [Authorize] public class FlashcardsController : ControllerBase { private readonly IBatchSaveService _batchSaveService; private readonly IDuplicateDetectionService _duplicateService; private readonly ILogger _logger; public FlashcardsController( IBatchSaveService batchSaveService, IDuplicateDetectionService duplicateService, ILogger logger) { _batchSaveService = batchSaveService; _duplicateService = duplicateService; _logger = logger; } [HttpPost("batch-save")] public async Task> BatchSaveCards( [FromBody] BatchSaveRequest request) { try { var userId = GetUserId(); // 驗證請求 if (!request.Cards.Any(c => c.IsSelected)) { return BadRequest(new { Success = false, Error = "No cards selected for saving" }); } // 執行批量保存 var result = await _batchSaveService.BatchSaveAsync(userId, request); // 記錄操作日誌 _logger.LogInformation( "User {UserId} batch saved {SavedCount}/{TotalCount} cards", userId, result.Summary.TotalSaved, result.Summary.TotalRequested); return Ok(new BatchSaveResponse { Success = true, Data = result, Message = $"Successfully saved {result.Summary.TotalSaved} cards" }); } catch (DuplicateDetectionException ex) { _logger.LogWarning(ex, "Duplicate detection failed during batch save"); return BadRequest(new { Success = false, Error = "Duplicate detection failed", Details = ex.Message }); } catch (Exception ex) { _logger.LogError(ex, "Unexpected error during batch save"); return StatusCode(500, new { Success = false, Error = "Internal server error" }); } } [HttpPost("check-duplicates")] public async Task> CheckDuplicates( [FromBody] DuplicateCheckRequest request) { try { var userId = GetUserId(); var result = await _duplicateService.CheckDuplicatesAsync(userId, request.Cards); return Ok(new DuplicateCheckResponse { Success = true, Data = result }); } catch (Exception ex) { _logger.LogError(ex, "Error checking duplicates"); return StatusCode(500, new { Success = false, Error = "Failed to check duplicates" }); } } private Guid GetUserId() { var userIdString = User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? User.FindFirst("sub")?.Value; if (Guid.TryParse(userIdString, out var userId)) return userId; throw new UnauthorizedAccessException("Invalid user ID"); } } ``` ### 5.2 批量保存服務 ```csharp public interface IBatchSaveService { Task BatchSaveAsync(Guid userId, BatchSaveRequest request); } public class BatchSaveService : IBatchSaveService { private readonly DramaLingDbContext _context; private readonly IDuplicateDetectionService _duplicateService; private readonly ICardSetService _cardSetService; private readonly ILogger _logger; public BatchSaveService( DramaLingDbContext context, IDuplicateDetectionService duplicateService, ICardSetService cardSetService, ILogger logger) { _context = context; _duplicateService = duplicateService; _cardSetService = cardSetService; _logger = logger; } public async Task BatchSaveAsync(Guid userId, BatchSaveRequest request) { using var transaction = await _context.Database.BeginTransactionAsync(); try { var result = new BatchSaveResult(); var selectedCards = request.Cards.Where(c => c.IsSelected).ToList(); // 1. 確保目標卡組存在 var cardSetId = await EnsureCardSetAsync(userId, request.CardSetId); // 2. 檢測重複詞卡 var duplicateInfo = await _duplicateService.DetectDuplicatesAsync( userId, selectedCards); // 3. 處理每張詞卡 foreach (var cardRequest in selectedCards) { try { var duplicateMatch = duplicateInfo.FirstOrDefault(d => d.TempId == cardRequest.TempId); if (duplicateMatch != null) { // 處理重複詞卡 var duplicateResult = await HandleDuplicateCardAsync( cardRequest, duplicateMatch, request.DuplicateHandling); if (duplicateResult.Action == DuplicateAction.Skip) { result.SkippedCards.Add(new SkippedCardResult { TempId = cardRequest.TempId, Word = cardRequest.Word, Reason = "duplicate" }); continue; } result.DuplicateCards.Add(duplicateResult); } // 4. 創建新詞卡 var flashcard = await CreateFlashcardAsync(userId, cardSetId, cardRequest); await _context.SaveChangesAsync(); result.SavedCards.Add(new SavedCardResult { TempId = cardRequest.TempId, CardId = flashcard.Id.ToString(), Word = flashcard.Word }); _logger.LogDebug("Successfully saved card: {Word}", flashcard.Word); } catch (Exception ex) { _logger.LogError(ex, "Failed to save card: {Word}", cardRequest.Word); result.SkippedCards.Add(new SkippedCardResult { TempId = cardRequest.TempId, Word = cardRequest.Word, Reason = "error" }); } } // 5. 更新統計 result.Summary = new SaveSummary { TotalRequested = selectedCards.Count, TotalSaved = result.SavedCards.Count, TotalSkipped = result.SkippedCards.Count, TotalDuplicates = result.DuplicateCards.Count }; await transaction.CommitAsync(); _logger.LogInformation( "Batch save completed for user {UserId}: {Saved}/{Total} cards saved", userId, result.Summary.TotalSaved, result.Summary.TotalRequested); return result; } catch (Exception ex) { await transaction.RollbackAsync(); _logger.LogError(ex, "Batch save transaction failed for user {UserId}", userId); throw; } } private async Task EnsureCardSetAsync(Guid userId, string? cardSetId) { if (!string.IsNullOrEmpty(cardSetId) && Guid.TryParse(cardSetId, out var setId)) { // 驗證卡組是否存在且屬於用戶 var cardSet = await _context.CardSets .FirstOrDefaultAsync(cs => cs.Id == setId && cs.UserId == userId); if (cardSet != null) return setId; } // 使用或創建預設卡組 return await _cardSetService.GetOrCreateDefaultCardSetAsync(userId); } private async Task CreateFlashcardAsync( Guid userId, Guid cardSetId, CardSaveRequest request) { var flashcard = new Flashcard { Id = Guid.NewGuid(), UserId = userId, CardSetId = cardSetId, Word = request.Word.Trim(), Translation = request.Translation.Trim(), Definition = request.Definition.Trim(), PartOfSpeech = request.PartOfSpeech?.Trim(), Pronunciation = request.Pronunciation?.Trim(), Example = request.Example?.Trim(), ExampleTranslation = request.ExampleTranslation?.Trim(), DifficultyLevel = request.DifficultyLevel?.Trim(), CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow }; _context.Flashcards.Add(flashcard); return flashcard; } private async Task HandleDuplicateCardAsync( CardSaveRequest newCard, DuplicateInfo duplicateInfo, DuplicateHandling handling) { switch (handling.Strategy) { case "merge": return await MergeDuplicateCardAsync(newCard, duplicateInfo); case "replace": return await ReplaceDuplicateCardAsync(newCard, duplicateInfo); case "skip": default: return new DuplicateCardResult { TempId = newCard.TempId, Word = newCard.Word, ExistingCardId = duplicateInfo.ExistingCardId, SimilarityScore = duplicateInfo.SimilarityScore, Action = "skipped" }; } } } ``` ### 5.3 重複檢測服務 ```csharp public interface IDuplicateDetectionService { Task> DetectDuplicatesAsync(Guid userId, List cards); Task CheckDuplicatesAsync(Guid userId, List cards); } public class DuplicateDetectionService : IDuplicateDetectionService { private readonly DramaLingDbContext _context; private readonly ILogger _logger; public DuplicateDetectionService( DramaLingDbContext context, ILogger logger) { _context = context; _logger = logger; } public async Task> DetectDuplicatesAsync( Guid userId, List cards) { var duplicates = new List(); // 取得用戶現有詞卡 var existingCards = await _context.Flashcards .Where(f => f.UserId == userId && !f.IsArchived) .Select(f => new { f.Id, f.Word, f.Translation, f.Definition }) .ToListAsync(); foreach (var newCard in cards) { foreach (var existing in existingCards) { var similarity = CalculateSimilarity(newCard, existing); if (similarity.Score > 0.7f) // 70%以上相似度認為重複 { duplicates.Add(new DuplicateInfo { TempId = newCard.TempId, ExistingCardId = existing.Id.ToString(), SimilarityScore = similarity.Score, MatchType = similarity.Type, Confidence = GetConfidenceLevel(similarity.Score) }); break; // 每張新卡只匹配一個最相似的現有卡 } } } return duplicates; } private SimilarityResult CalculateSimilarity(CardSaveRequest newCard, dynamic existingCard) { // 完全匹配檢查 if (string.Equals(newCard.Word.Trim(), existingCard.Word.Trim(), StringComparison.OrdinalIgnoreCase)) { return new SimilarityResult { Score = 1.0f, Type = "exact" }; } // 字串相似度檢查 var wordSimilarity = CalculateLevenshteinSimilarity(newCard.Word, existingCard.Word); if (wordSimilarity > 0.8f) { return new SimilarityResult { Score = wordSimilarity, Type = "similar" }; } // 語義相似度檢查 var semanticSimilarity = CalculateSemanticSimilarity(newCard.Definition, existingCard.Definition); if (semanticSimilarity > 0.6f) { return new SimilarityResult { Score = semanticSimilarity, Type = "semantic" }; } return new SimilarityResult { Score = 0f, Type = "none" }; } private float CalculateLevenshteinSimilarity(string str1, string str2) { if (string.IsNullOrEmpty(str1) && string.IsNullOrEmpty(str2)) return 1.0f; if (string.IsNullOrEmpty(str1) || string.IsNullOrEmpty(str2)) return 0.0f; var distance = LevenshteinDistance(str1.ToLower(), str2.ToLower()); var maxLength = Math.Max(str1.Length, str2.Length); return 1.0f - (float)distance / maxLength; } private int LevenshteinDistance(string s1, string s2) { int[,] matrix = new int[s1.Length + 1, s2.Length + 1]; for (int i = 0; i <= s1.Length; i++) matrix[i, 0] = i; for (int j = 0; j <= s2.Length; j++) matrix[0, j] = j; for (int i = 1; i <= s1.Length; i++) { for (int j = 1; j <= s2.Length; j++) { int cost = s1[i - 1] == s2[j - 1] ? 0 : 1; matrix[i, j] = Math.Min( Math.Min(matrix[i - 1, j] + 1, matrix[i, j - 1] + 1), matrix[i - 1, j - 1] + cost ); } } return matrix[s1.Length, s2.Length]; } private float CalculateSemanticSimilarity(string def1, string def2) { if (string.IsNullOrEmpty(def1) || string.IsNullOrEmpty(def2)) return 0f; // 簡單的詞彙重疊相似度 (Jaccard係數) var words1 = def1.ToLower() .Split(' ', StringSplitOptions.RemoveEmptyEntries) .Where(w => w.Length > 2) // 過濾短詞 .ToHashSet(); var words2 = def2.ToLower() .Split(' ', StringSplitOptions.RemoveEmptyEntries) .Where(w => w.Length > 2) .ToHashSet(); var intersection = words1.Intersect(words2).Count(); var union = words1.Union(words2).Count(); return union == 0 ? 0f : (float)intersection / union; } private string GetConfidenceLevel(float score) { return score switch { >= 0.9f => "high", >= 0.7f => "medium", _ => "low" }; } } // 輔助類型定義 public class SimilarityResult { public float Score { get; set; } public string Type { get; set; } = string.Empty; } public class DuplicateInfo { public string TempId { get; set; } = string.Empty; public string ExistingCardId { get; set; } = string.Empty; public float SimilarityScore { get; set; } public string MatchType { get; set; } = string.Empty; public string Confidence { get; set; } = string.Empty; } ``` --- ## 6. 前端實現規格 ### 6.1 主要組件架構 ``` SaveCardsFlow (主流程組件) ├── CardSelectionDialog (詞卡選擇對話框) │ ├── CardPreviewList (詞卡預覽列表) │ ├── CardSetSelector (卡組選擇器) │ └── BatchOperations (批量操作工具) ├── DuplicateHandlingDialog (重複處理對話框) │ ├── DuplicateComparison (重複詞卡對比) │ └── DuplicateActions (處理動作選擇) └── SaveProgressIndicator (保存進度指示器) ├── ProgressBar (進度條) ├── StatusMessages (狀態訊息) └── ResultSummary (結果摘要) ``` ### 6.2 詞卡選擇對話框 ```typescript import React, { useState, useCallback, useMemo } from 'react'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; interface CardSelectionDialogProps { isOpen: boolean; generatedCards: GeneratedCard[]; cardSets: CardSet[]; onClose: () => void; onSave: (selectedCards: SelectedCard[], targetCardSetId?: string) => Promise; } interface SelectedCard extends GeneratedCard { tempId: string; isSelected: boolean; } export const CardSelectionDialog: React.FC = ({ isOpen, generatedCards, cardSets, onClose, onSave }) => { const [selectedCards, setSelectedCards] = useState(() => generatedCards.map((card, index) => ({ ...card, tempId: `temp_${index}_${Date.now()}`, isSelected: true // 預設全選 })) ); const [targetCardSetId, setTargetCardSetId] = useState(''); const [isSaving, setIsSaving] = useState(false); // 計算選中數量 const selectedCount = useMemo(() => selectedCards.filter(card => card.isSelected).length, [selectedCards] ); // 全選/取消全選 const handleSelectAll = useCallback((checked: boolean) => { setSelectedCards(prev => prev.map(card => ({ ...card, isSelected: checked })) ); }, []); // 單個詞卡選擇切換 const handleCardToggle = useCallback((tempId: string, checked: boolean) => { setSelectedCards(prev => prev.map(card => card.tempId === tempId ? { ...card, isSelected: checked } : card ) ); }, []); // 處理保存 const handleSave = async () => { if (selectedCount === 0) { alert('請至少選擇一張詞卡'); return; } setIsSaving(true); try { await onSave(selectedCards.filter(card => card.isSelected), targetCardSetId); } finally { setIsSaving(false); } }; return ( 選擇要保存的詞卡
{/* 操作工具列 */}
保存到:
{/* 詞卡列表 */}
{selectedCards.map((card) => ( handleCardToggle(card.tempId, checked)} /> ))}
{/* 底部操作按鈕 */}
); }; // 詞卡預覽項目組件 interface CardPreviewItemProps { card: SelectedCard; isSelected: boolean; onToggle: (checked: boolean) => void; } const CardPreviewItem: React.FC = ({ card, isSelected, onToggle }) => { return (

{card.word}

{card.difficultyLevel && ( {card.difficultyLevel} )}
翻譯: {card.translation}
{card.partOfSpeech && (
詞性: {card.partOfSpeech}
)}
定義:

{card.definition}

{card.example && (
例句:

"{card.example}"

{card.exampleTranslation && (

{card.exampleTranslation}

)}
)}
); }; ``` ### 6.3 重複處理對話框 ```typescript import React, { useState } from 'react'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import { Label } from '@/components/ui/label'; import { Alert, AlertDescription } from '@/components/ui/alert'; interface DuplicateHandlingDialogProps { isOpen: boolean; duplicates: DuplicateMatch[]; onResolve: (resolution: DuplicateResolution) => void; onClose: () => void; } interface DuplicateResolution { strategy: 'merge' | 'skip' | 'replace' | 'keep_both'; applyToAll: boolean; decisions: Record; } export const DuplicateHandlingDialog: React.FC = ({ isOpen, duplicates, onResolve, onClose }) => { const [globalStrategy, setGlobalStrategy] = useState(''); const [applyToAll, setApplyToAll] = useState(false); const [individualDecisions, setIndividualDecisions] = useState>({}); const handleResolve = () => { const resolution: DuplicateResolution = { strategy: globalStrategy as any, applyToAll, decisions: applyToAll ? {} : individualDecisions as Record }; onResolve(resolution); }; const handleIndividualDecision = (tempId: string, decision: string) => { setIndividualDecisions(prev => ({ ...prev, [tempId]: decision })); }; const allDecisionsMade = applyToAll ? globalStrategy !== '' : duplicates.every(dup => individualDecisions[dup.tempId]); return ( 發現重複詞卡 ({duplicates.length} 項)
系統檢測到一些詞卡可能與您現有的詞卡重複。請選擇處理方式: {/* 全局處理選項 */}
setApplyToAll(e.target.checked)} />
{applyToAll && (
)}
{/* 個別處理選項 */} {!applyToAll && (

個別處理每個重複項目:

{duplicates.map((duplicate) => ( handleIndividualDecision(duplicate.tempId, decision) } /> ))}
)} {/* 底部操作按鈕 */}
); }; // 重複詞卡對比卡片 interface DuplicateComparisonCardProps { duplicate: DuplicateMatch; decision: string; onDecisionChange: (decision: string) => void; } const DuplicateComparisonCard: React.FC = ({ duplicate, decision, onDecisionChange }) => { const bestMatch = duplicate.matches[0]; // 取最佳匹配 return (

重複詞卡: {duplicate.word}

相似度: {Math.round(bestMatch.similarityScore * 100)}% ({bestMatch.confidence})
{/* 詞卡對比 */}
新詞卡

單字: {duplicate.word}

翻譯: {duplicate.translation}

定義: {duplicate.definition}

現有詞卡

單字: {bestMatch.word}

翻譯: {bestMatch.translation}

定義: {bestMatch.definition}

{/* 處理選項 */}
); }; ``` ### 6.4 保存進度指示器 ```typescript import React from 'react'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Progress } from '@/components/ui/progress'; import { CheckCircle, XCircle, AlertCircle, Loader2 } from 'lucide-react'; interface SaveProgressIndicatorProps { isOpen: boolean; progress: SaveProgress; onClose?: () => void; } interface SaveProgress { stage: 'checking' | 'saving' | 'completed' | 'error'; currentStep: number; totalSteps: number; message: string; details?: SaveProgressDetail[]; summary?: SaveSummary; error?: string; } interface SaveProgressDetail { id: string; word: string; status: 'pending' | 'saving' | 'saved' | 'skipped' | 'error'; message?: string; } export const SaveProgressIndicator: React.FC = ({ isOpen, progress, onClose }) => { const progressPercentage = (progress.currentStep / progress.totalSteps) * 100; const getStageIcon = () => { switch (progress.stage) { case 'checking': case 'saving': return ; case 'completed': return ; case 'error': return ; default: return ; } }; const getStageTitle = () => { switch (progress.stage) { case 'checking': return '檢查重複詞卡...'; case 'saving': return '保存詞卡中...'; case 'completed': return '保存完成'; case 'error': return '保存失敗'; default: return '處理中...'; } }; return ( {getStageIcon()} {getStageTitle()}
{/* 總體進度 */}
{progress.message} {progress.currentStep}/{progress.totalSteps}
{/* 詳細進度 */} {progress.details && progress.details.length > 0 && (

詳細進度:

{progress.details.map((detail) => (
{getDetailStatusIcon(detail.status)} {detail.word}
{getDetailStatusText(detail.status)}
))}
)} {/* 結果摘要 */} {progress.summary && progress.stage === 'completed' && (

保存結果摘要

成功保存: {progress.summary.totalSaved} 張
跳過: {progress.summary.totalSkipped} 張
重複處理: {progress.summary.totalDuplicates} 張
總計: {progress.summary.totalRequested} 張
)} {/* 錯誤信息 */} {progress.error && progress.stage === 'error' && (

錯誤信息

{progress.error}

)} {/* 操作按鈕 */} {(progress.stage === 'completed' || progress.stage === 'error') && onClose && (
)}
); }; // 詳細狀態圖標 const getDetailStatusIcon = (status: string) => { switch (status) { case 'pending': return
; case 'saving': return ; case 'saved': return ; case 'skipped': return ; case 'error': return ; default: return
; } }; // 詳細狀態文字 const getDetailStatusText = (status: string) => { switch (status) { case 'pending': return '等待中'; case 'saving': return '保存中'; case 'saved': return '已保存'; case 'skipped': return '已跳過'; case 'error': return '保存失敗'; default: return '未知狀態'; } }; ``` ### 6.5 主流程組件整合 ```typescript import React, { useState, useCallback } from 'react'; import { CardSelectionDialog } from './CardSelectionDialog'; import { DuplicateHandlingDialog } from './DuplicateHandlingDialog'; import { SaveProgressIndicator } from './SaveProgressIndicator'; import { flashcardsService } from '@/lib/services/flashcards'; interface SaveCardsFlowProps { generatedCards: GeneratedCard[]; cardSets: CardSet[]; onSaveComplete: () => void; onError: (error: string) => void; } export const SaveCardsFlow: React.FC = ({ generatedCards, cardSets, onSaveComplete, onError }) => { const [currentStep, setCurrentStep] = useState<'selection' | 'duplicates' | 'progress' | 'completed'>('selection'); const [selectedCards, setSelectedCards] = useState([]); const [targetCardSetId, setTargetCardSetId] = useState(''); const [duplicates, setDuplicates] = useState([]); const [saveProgress, setSaveProgress] = useState({ stage: 'checking', currentStep: 0, totalSteps: 0, message: '' }); // 處理詞卡選擇和保存 const handleCardSelection = useCallback(async ( selectedCards: SelectedCard[], cardSetId?: string ) => { try { setSelectedCards(selectedCards); setTargetCardSetId(cardSetId || ''); setCurrentStep('progress'); // 初始化進度 setSaveProgress({ stage: 'checking', currentStep: 0, totalSteps: selectedCards.length + 1, message: '檢查重複詞卡...' }); // 檢查重複詞卡 const duplicateCheckResult = await flashcardsService.checkDuplicates( selectedCards.map(card => ({ tempId: card.tempId, word: card.word, translation: card.translation, definition: card.definition })) ); if (!duplicateCheckResult.success) { throw new Error(duplicateCheckResult.error || '重複檢查失敗'); } // 更新進度 setSaveProgress(prev => ({ ...prev, currentStep: 1, message: '重複檢查完成' })); if (duplicateCheckResult.data.duplicates.length > 0) { // 發現重複,需要用戶處理 setDuplicates(duplicateCheckResult.data.duplicates); setCurrentStep('duplicates'); } else { // 沒有重複,直接保存 await performBatchSave(selectedCards, cardSetId, { strategy: 'skip', applyToAll: false, decisions: {} }); } } catch (error) { console.error('Card selection error:', error); setSaveProgress({ stage: 'error', currentStep: 0, totalSteps: 0, message: '處理失敗', error: error instanceof Error ? error.message : '未知錯誤' }); onError(error instanceof Error ? error.message : '保存過程出現錯誤'); } }, [onError]); // 處理重複詞卡決策 const handleDuplicateResolution = useCallback(async (resolution: DuplicateResolution) => { setCurrentStep('progress'); await performBatchSave(selectedCards, targetCardSetId, resolution); }, [selectedCards, targetCardSetId]); // 執行批量保存 const performBatchSave = async ( cards: SelectedCard[], cardSetId: string, duplicateHandling: any ) => { try { setSaveProgress({ stage: 'saving', currentStep: 1, totalSteps: cards.length + 1, message: '開始保存詞卡...', details: cards.map(card => ({ id: card.tempId, word: card.word, status: 'pending' })) }); // 調用批量保存API const saveResult = await flashcardsService.batchSave({ cardSetId: cardSetId || undefined, cards: cards.map(card => ({ tempId: card.tempId, word: card.word, translation: card.translation, definition: card.definition, partOfSpeech: card.partOfSpeech, pronunciation: card.pronunciation, example: card.example, exampleTranslation: card.exampleTranslation, difficultyLevel: card.difficultyLevel, isSelected: true })), duplicateHandling }); if (!saveResult.success) { throw new Error(saveResult.error || '保存失敗'); } // 更新最終進度 setSaveProgress({ stage: 'completed', currentStep: cards.length + 1, totalSteps: cards.length + 1, message: '所有詞卡保存完成', summary: saveResult.data.summary, details: cards.map(card => { const savedCard = saveResult.data.savedCards.find(s => s.tempId === card.tempId); const skippedCard = saveResult.data.skippedCards.find(s => s.tempId === card.tempId); return { id: card.tempId, word: card.word, status: savedCard ? 'saved' : skippedCard ? 'skipped' : 'error', message: skippedCard?.reason }; }) }); // 延遲關閉,讓用戶看到結果 setTimeout(() => { setCurrentStep('completed'); onSaveComplete(); }, 2000); } catch (error) { console.error('Batch save error:', error); setSaveProgress({ stage: 'error', currentStep: 0, totalSteps: 0, message: '保存失敗', error: error instanceof Error ? error.message : '未知錯誤' }); } }; const handleClose = () => { setCurrentStep('selection'); setSelectedCards([]); setDuplicates([]); }; return ( <> {/* 詞卡選擇對話框 */} {/* 重複處理對話框 */} {/* 保存進度指示器 */} ); }; // 修改原有的保存按鈕點擊處理 export const useSaveCardsFlow = () => { const [showSaveFlow, setShowSaveFlow] = useState(false); const handleSaveClick = useCallback(() => { setShowSaveFlow(true); }, []); const handleSaveComplete = useCallback(() => { setShowSaveFlow(false); // 可以在這裡添加成功後的處理邏輯,如跳轉到詞卡列表 window.location.href = '/flashcards'; // 或使用 Next.js router }, []); const handleSaveError = useCallback((error: string) => { console.error('Save error:', error); alert(`保存失敗: ${error}`); }, []); return { showSaveFlow, handleSaveClick, handleSaveComplete, handleSaveError, setShowSaveFlow }; }; ``` --- ## 7. 服務層擴展 ### 7.1 前端服務層更新 ```typescript // 擴展現有的 flashcardsService class FlashcardsService { // ... 現有方法 /** * 檢查重複詞卡 */ async checkDuplicates(cards: DuplicateCheckCard[]): Promise> { try { const response = await this.makeRequest>('/flashcards/check-duplicates', { method: 'POST', body: JSON.stringify({ cards }), }); return response; } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'Failed to check duplicates', }; } } /** * 批量保存詞卡 */ async batchSave(request: BatchSaveRequest): Promise> { try { const response = await this.makeRequest>('/flashcards/batch-save', { method: 'POST', body: JSON.stringify(request), }); return response; } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'Failed to save cards', }; } } /** * 取得用戶卡組列表 */ async getUserCardSets(): Promise> { try { return await this.makeRequest>('/cardsets'); } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'Failed to fetch card sets', }; } } } // 新增類型定義 interface DuplicateCheckCard { tempId: string; word: string; translation: string; definition: string; } interface DuplicateCheckResult { duplicates: DuplicateMatch[]; noDuplicates: string[]; } interface DuplicateMatch { tempId: string; word: string; matches: ExistingCardMatch[]; } interface ExistingCardMatch { cardId: string; word: string; translation: string; definition: string; similarityScore: number; matchType: 'exact' | 'similar' | 'semantic'; confidence: 'high' | 'medium' | 'low'; } interface BatchSaveRequest { cardSetId?: string; cards: CardSaveRequest[]; duplicateHandling: DuplicateHandling; } interface BatchSaveResult { savedCards: SavedCardResult[]; skippedCards: SkippedCardResult[]; duplicateCards: DuplicateCardResult[]; summary: SaveSummary; } interface SaveSummary { totalRequested: number; totalSaved: number; totalSkipped: number; totalDuplicates: number; } ``` --- ## 8. 測試策略 ### 8.1 單元測試 #### 8.1.1 後端測試 ```csharp [TestClass] public class DuplicateDetectionServiceTests { private DuplicateDetectionService _service; private Mock _mockContext; [TestInitialize] public void Setup() { _mockContext = new Mock(); _service = new DuplicateDetectionService(_mockContext.Object, Mock.Of>()); } [TestMethod] public async Task DetectDuplicates_ExactMatch_ShouldReturnHighSimilarity() { // Arrange var userId = Guid.NewGuid(); var existingCards = new List { new() { Id = Guid.NewGuid(), Word = "Hello", Translation = "你好", Definition = "A greeting" } }; var newCards = new List { new() { TempId = "temp1", Word = "hello", Translation = "您好", Definition = "A greeting word" } }; // Mock database _mockContext.Setup(c => c.Flashcards).Returns(MockDbSet(existingCards)); // Act var result = await _service.DetectDuplicatesAsync(userId, newCards); // Assert Assert.AreEqual(1, result.Count); Assert.AreEqual("temp1", result[0].TempId); Assert.IsTrue(result[0].SimilarityScore > 0.9f); } [TestMethod] public async Task BatchSave_WithDuplicates_ShouldHandleCorrectly() { // 測試批量保存時的重複處理邏輯 } [TestMethod] public void CalculateLevenshteinSimilarity_SimilarWords_ShouldReturnHighScore() { // 測試字串相似度計算 } } [TestClass] public class BatchSaveServiceTests { [TestMethod] public async Task BatchSave_AllValid_ShouldSaveAllCards() { // 測試正常批量保存流程 } [TestMethod] public async Task BatchSave_WithTransaction_ShouldRollbackOnError() { // 測試事務回滾機制 } } ``` #### 8.1.2 前端測試 ```typescript // DuplicateDetectionService.test.ts import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { CardSelectionDialog } from './CardSelectionDialog'; describe('CardSelectionDialog', () => { const mockGeneratedCards = [ { word: 'hello', translation: '你好', definition: 'A greeting', partOfSpeech: 'interjection' } ]; const mockCardSets = [ { id: '1', name: '基礎詞彙', description: '', color: 'blue' } ]; test('should render all generated cards', () => { render( ); expect(screen.getByText('hello')).toBeInTheDocument(); expect(screen.getByText('你好')).toBeInTheDocument(); }); test('should handle select all functionality', () => { const mockOnSave = jest.fn(); render( ); // Test select all const selectAllCheckbox = screen.getByLabelText(/全選/); fireEvent.click(selectAllCheckbox); expect(selectAllCheckbox).toBeChecked(); }); test('should call onSave with selected cards', async () => { const mockOnSave = jest.fn(); render( ); const saveButton = screen.getByText(/保存/); fireEvent.click(saveButton); await waitFor(() => { expect(mockOnSave).toHaveBeenCalledWith( expect.arrayContaining([ expect.objectContaining({ word: 'hello', isSelected: true }) ]), '' ); }); }); }); // DuplicateHandlingDialog.test.ts describe('DuplicateHandlingDialog', () => { test('should display duplicate comparisons', () => { // 測試重複詞卡對比顯示 }); test('should handle global strategy selection', () => { // 測試全局策略選擇 }); test('should validate all decisions made', () => { // 測試決策完整性驗證 }); }); ``` ### 8.2 整合測試 #### 8.2.1 API整合測試 ```csharp [TestClass] public class FlashcardsControllerIntegrationTests : TestBase { [TestMethod] public async Task BatchSave_EndToEnd_ShouldWork() { // Arrange var client = CreateAuthenticatedClient(); var request = new BatchSaveRequest { Cards = new List { new() { TempId = "temp1", Word = "test", Translation = "測試", Definition = "A test word", IsSelected = true } }, DuplicateHandling = new DuplicateHandling { Strategy = "skip", AutoApply = true } }; // Act var response = await client.PostAsJsonAsync("/api/flashcards/batch-save", request); // Assert response.EnsureSuccessStatusCode(); var result = await response.Content.ReadFromJsonAsync(); Assert.IsTrue(result.Success); Assert.AreEqual(1, result.Data.Summary.TotalSaved); } [TestMethod] public async Task CheckDuplicates_WithExistingCard_ShouldDetect() { // 測試重複檢測API的完整流程 } } ``` #### 8.2.2 前端整合測試 ```typescript // SaveCardsFlow.integration.test.ts describe('SaveCardsFlow Integration', () => { test('complete save flow without duplicates', async () => { // 模擬完整的保存流程 // 1. 選擇詞卡 // 2. 檢查重複(無重複) // 3. 保存成功 }); test('complete save flow with duplicates', async () => { // 模擬有重複的保存流程 // 1. 選擇詞卡 // 2. 檢查重複(發現重複) // 3. 處理重複 // 4. 保存成功 }); test('error handling during save', async () => { // 測試保存過程中的錯誤處理 }); }); ``` ### 8.3 E2E測試 ```typescript // e2e/save-cards.spec.ts import { test, expect } from '@playwright/test'; test.describe('Save Cards Flow', () => { test('user can save generated cards successfully', async ({ page }) => { // 登入用戶 await page.goto('/login'); await page.fill('[data-testid="email"]', 'test@example.com'); await page.fill('[data-testid="password"]', 'password'); await page.click('[data-testid="login-button"]'); // 生成詞卡 await page.goto('/generate'); await page.fill('[data-testid="text-input"]', 'Hello world'); await page.click('[data-testid="analyze-button"]'); await page.waitForSelector('[data-testid="generated-cards"]'); // 保存詞卡 await page.click('[data-testid="save-cards-button"]'); // 在選擇對話框中操作 await expect(page.locator('[data-testid="card-selection-dialog"]')).toBeVisible(); await page.click('[data-testid="save-selected-button"]'); // 等待保存完成 await expect(page.locator('[data-testid="save-progress"]')).toBeVisible(); await expect(page.locator('[data-testid="save-success"]')).toBeVisible(); // 驗證跳轉到詞卡列表 await expect(page).toHaveURL('/flashcards'); }); test('user can handle duplicate cards', async ({ page }) => { // 測試重複詞卡處理流程 }); }); ``` --- ## 9. 部署與配置 ### 9.1 資料庫遷移腳本 ```sql -- 新增批量保存相關索引 CREATE INDEX IF NOT EXISTS IX_Flashcards_UserId_Word ON flashcards(user_id, word); CREATE INDEX IF NOT EXISTS IX_Flashcards_UserId_Translation ON flashcards(user_id, translation); -- 新增批量操作日誌表 CREATE TABLE IF NOT EXISTS batch_operation_logs ( id UUID PRIMARY KEY, user_id UUID NOT NULL, operation_type VARCHAR(50) NOT NULL, request_data TEXT, response_data TEXT, success BOOLEAN NOT NULL, error_message TEXT, duration_ms INTEGER, created_at TIMESTAMP DEFAULT NOW(), FOREIGN KEY (user_id) REFERENCES user_profiles(id) ON DELETE CASCADE ); -- 新增效能監控視圖 CREATE VIEW IF NOT EXISTS duplicate_detection_stats AS SELECT DATE(created_at) as date, COUNT(*) as total_operations, AVG(duration_ms) as avg_duration, COUNT(CASE WHEN success THEN 1 END) as success_count, COUNT(CASE WHEN NOT success THEN 1 END) as error_count FROM batch_operation_logs WHERE operation_type = 'duplicate_detection' GROUP BY DATE(created_at); ``` ### 9.2 設定檔更新 ```json // appsettings.json { "BatchSave": { "MaxCardsPerBatch": 50, "DuplicateDetectionTimeout": 30, "TransactionTimeout": 60, "EnableDetailedLogging": true }, "DuplicateDetection": { "ExactMatchThreshold": 1.0, "SimilarMatchThreshold": 0.8, "SemanticMatchThreshold": 0.6, "MaxCandidatesPerCard": 5, "EnableAIEnhancement": false } } ``` ### 9.3 前端環境變數 ```typescript // next.config.js const nextConfig = { env: { MAX_CARDS_PER_BATCH: process.env.MAX_CARDS_PER_BATCH || '20', DUPLICATE_CHECK_ENABLED: process.env.DUPLICATE_CHECK_ENABLED || 'true', SAVE_PROGRESS_POLLING_INTERVAL: process.env.SAVE_PROGRESS_POLLING_INTERVAL || '1000' } } ``` --- ## 10. 監控與維護 ### 10.1 日誌記錄 ```csharp public class BatchSaveLogger { private readonly ILogger _logger; public void LogBatchSaveStart(Guid userId, int cardCount) { _logger.LogInformation( "Batch save started: UserId={UserId}, CardCount={CardCount}", userId, cardCount); } public void LogDuplicateDetection(Guid userId, int duplicatesFound, TimeSpan duration) { _logger.LogInformation( "Duplicate detection completed: UserId={UserId}, DuplicatesFound={DuplicatesFound}, Duration={Duration}ms", userId, duplicatesFound, duration.TotalMilliseconds); } public void LogBatchSaveComplete(Guid userId, BatchSaveResult result, TimeSpan duration) { _logger.LogInformation( "Batch save completed: UserId={UserId}, Saved={Saved}, Skipped={Skipped}, Duration={Duration}ms", userId, result.Summary.TotalSaved, result.Summary.TotalSkipped, duration.TotalMilliseconds); } } ``` ### 10.2 效能監控 ```csharp public class BatchSaveMetrics { private readonly IMetrics _metrics; public void RecordBatchSaveOperation(BatchSaveResult result, TimeSpan duration) { _metrics.Counter("batch_save_operations_total") .WithTag("status", "success") .Increment(); _metrics.Histogram("batch_save_duration_ms") .Record(duration.TotalMilliseconds); _metrics.Gauge("cards_saved_per_operation") .Set(result.Summary.TotalSaved); } public void RecordDuplicateDetection(int candidatesChecked, int duplicatesFound, TimeSpan duration) { _metrics.Counter("duplicate_detection_operations_total").Increment(); _metrics.Histogram("duplicate_detection_duration_ms").Record(duration.TotalMilliseconds); _metrics.Histogram("duplicates_found_per_operation").Record(duplicatesFound); } } ``` --- ## 11. 開發檢查清單 ### 11.1 後端開發檢查清單 - [ ] **API端點實現** - [ ] `POST /api/flashcards/batch-save` 端點 - [ ] `POST /api/flashcards/check-duplicates` 端點 - [ ] 請求驗證和參數檢查 - [ ] 統一錯誤回應格式 - [ ] **重複檢測服務** - [ ] 實現 `DuplicateDetectionService` - [ ] 字串相似度算法(Levenshtein距離) - [ ] 語義相似度算法(Jaccard係數) - [ ] 相似度閾值設定和調整 - [ ] **批量保存服務** - [ ] 實現 `BatchSaveService` - [ ] 資料庫事務處理 - [ ] 錯誤處理和回滾機制 - [ ] 保存結果統計 - [ ] **資料庫變更** - [ ] 新增相關索引提升查詢效能 - [ ] 批量操作日誌表 - [ ] 資料遷移腳本 - [ ] **測試覆蓋** - [ ] 單元測試(90%以上覆蓋率) - [ ] 整合測試 - [ ] 效能測試 ### 11.2 前端開發檢查清單 - [ ] **主要組件開發** - [ ] `CardSelectionDialog` 組件 - [ ] `DuplicateHandlingDialog` 組件 - [ ] `SaveProgressIndicator` 組件 - [ ] `SaveCardsFlow` 主流程組件 - [ ] **狀態管理** - [ ] 詞卡選擇狀態管理 - [ ] 重複處理狀態管理 - [ ] 保存進度狀態管理 - [ ] 錯誤狀態處理 - [ ] **API整合** - [ ] 擴展 `flashcardsService` - [ ] 重複檢測API調用 - [ ] 批量保存API調用 - [ ] 錯誤處理和重試機制 - [ ] **使用者體驗** - [ ] 響應式設計 - [ ] 載入狀態指示 - [ ] 錯誤訊息顯示 - [ ] 無障礙支援 - [ ] **測試覆蓋** - [ ] 組件單元測試 - [ ] 使用者互動測試 - [ ] E2E測試 ### 11.3 整合測試檢查清單 - [ ] **功能流程測試** - [ ] 正常保存流程 - [ ] 重複詞卡處理流程 - [ ] 錯誤處理流程 - [ ] 邊界情況測試 - [ ] **效能測試** - [ ] 大量詞卡批量保存 - [ ] 重複檢測效能 - [ ] 並發用戶測試 - [ ] 記憶體使用測試 - [ ] **相容性測試** - [ ] 不同瀏覽器測試 - [ ] 行動裝置測試 - [ ] 網路狀況測試 --- **文件版本**: 1.0 **建立日期**: 2025-09-20 **預估開發時間**: 13-19個工作天 **負責團隊**: 前端團隊 + 後端團隊 **優先級**: 🔴 高優先級(立即開始)