dramaling-vocab-learning/詞卡保存功能技術規格.md

70 KiB
Raw Blame History

詞卡保存功能技術規格

1. 功能概述

1.1 問題定義

當前系統生成詞卡後只能預覽,無法保存到資料庫。用戶點擊"💾 保存詞卡"時顯示"保存功能開發中...",影響核心學習流程。

1.2 解決方案

實現完整的詞卡保存流程,包括:選擇性保存、重複檢測、進度追蹤、錯誤處理和用戶回饋。

1.3 功能目標

  • 批量保存生成的詞卡
  • 智能檢測重複詞卡
  • 提供直觀的用戶介面
  • 完善的錯誤處理機制
  • 保存進度實時回饋

2. 用戶故事與使用場景

2.1 主要用戶故事

US-01: 基本詞卡保存

作為 學習者 我想要 將AI生成的詞卡保存到我的詞庫 以便 後續進行學習和複習

驗收標準:

  • 可以選擇要保存的詞卡
  • 可以指定保存到特定卡組
  • 保存成功後有明確提示
  • 可以跳轉到詞卡列表查看

US-02: 重複詞卡處理

作為 學習者 我想要 系統自動檢測重複的詞卡 以便 避免創建冗餘內容

驗收標準:

  • 系統自動檢測重複詞卡
  • 提供重複處理選項(合併/跳過/替換)
  • 顯示重複詞卡的對比資訊
  • 允許用戶手動決定處理方式

US-03: 批量操作

作為 學習者 我想要 一次選擇多張詞卡進行保存 以便 提高操作效率

驗收標準:

  • 支援全選/取消全選
  • 支援單個詞卡選擇/取消
  • 顯示選中詞卡數量
  • 批量保存進度提示

2.2 使用場景流程

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 基本規格

POST /api/flashcards/batch-save
Authorization: Bearer {token}
Content-Type: application/json

4.1.2 請求格式

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 回應格式

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 基本規格

POST /api/flashcards/check-duplicates
Authorization: Bearer {token}
Content-Type: application/json

4.2.2 請求與回應

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 控制器實現

[ApiController]
[Route("api/[controller]")]
[Authorize]
public class FlashcardsController : ControllerBase
{
    private readonly IBatchSaveService _batchSaveService;
    private readonly IDuplicateDetectionService _duplicateService;
    private readonly ILogger<FlashcardsController> _logger;

    public FlashcardsController(
        IBatchSaveService batchSaveService,
        IDuplicateDetectionService duplicateService,
        ILogger<FlashcardsController> logger)
    {
        _batchSaveService = batchSaveService;
        _duplicateService = duplicateService;
        _logger = logger;
    }

    [HttpPost("batch-save")]
    public async Task<ActionResult<BatchSaveResponse>> 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<ActionResult<DuplicateCheckResponse>> 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 批量保存服務

public interface IBatchSaveService
{
    Task<BatchSaveResult> BatchSaveAsync(Guid userId, BatchSaveRequest request);
}

public class BatchSaveService : IBatchSaveService
{
    private readonly DramaLingDbContext _context;
    private readonly IDuplicateDetectionService _duplicateService;
    private readonly ICardSetService _cardSetService;
    private readonly ILogger<BatchSaveService> _logger;

    public BatchSaveService(
        DramaLingDbContext context,
        IDuplicateDetectionService duplicateService,
        ICardSetService cardSetService,
        ILogger<BatchSaveService> logger)
    {
        _context = context;
        _duplicateService = duplicateService;
        _cardSetService = cardSetService;
        _logger = logger;
    }

    public async Task<BatchSaveResult> 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<Guid> 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<Flashcard> 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<DuplicateCardResult> 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 重複檢測服務

public interface IDuplicateDetectionService
{
    Task<List<DuplicateInfo>> DetectDuplicatesAsync(Guid userId, List<CardSaveRequest> cards);
    Task<DuplicateCheckResult> CheckDuplicatesAsync(Guid userId, List<DuplicateCheckCard> cards);
}

public class DuplicateDetectionService : IDuplicateDetectionService
{
    private readonly DramaLingDbContext _context;
    private readonly ILogger<DuplicateDetectionService> _logger;

    public DuplicateDetectionService(
        DramaLingDbContext context,
        ILogger<DuplicateDetectionService> logger)
    {
        _context = context;
        _logger = logger;
    }

    public async Task<List<DuplicateInfo>> DetectDuplicatesAsync(
        Guid userId,
        List<CardSaveRequest> cards)
    {
        var duplicates = new List<DuplicateInfo>();

        // 取得用戶現有詞卡
        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 詞卡選擇對話框

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<void>;
}

interface SelectedCard extends GeneratedCard {
  tempId: string;
  isSelected: boolean;
}

export const CardSelectionDialog: React.FC<CardSelectionDialogProps> = ({
  isOpen,
  generatedCards,
  cardSets,
  onClose,
  onSave
}) => {
  const [selectedCards, setSelectedCards] = useState<SelectedCard[]>(() =>
    generatedCards.map((card, index) => ({
      ...card,
      tempId: `temp_${index}_${Date.now()}`,
      isSelected: true // 預設全選
    }))
  );

  const [targetCardSetId, setTargetCardSetId] = useState<string>('');
  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 (
    <Dialog open={isOpen} onOpenChange={onClose}>
      <DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden flex flex-col">
        <DialogHeader>
          <DialogTitle>選擇要保存的詞卡</DialogTitle>
        </DialogHeader>

        <div className="flex-1 overflow-hidden flex flex-col space-y-4">
          {/* 操作工具列 */}
          <div className="flex justify-between items-center p-4 bg-gray-50 rounded-lg">
            <div className="flex items-center space-x-4">
              <Checkbox
                id="select-all"
                checked={selectedCount === generatedCards.length}
                onCheckedChange={handleSelectAll}
              />
              <label htmlFor="select-all" className="text-sm font-medium">
                全選 ({selectedCount}/{generatedCards.length})
              </label>
            </div>

            <div className="flex items-center space-x-2">
              <span className="text-sm text-gray-600">保存到:</span>
              <Select value={targetCardSetId} onValueChange={setTargetCardSetId}>
                <SelectTrigger className="w-48">
                  <SelectValue placeholder="選擇卡組" />
                </SelectTrigger>
                <SelectContent>
                  <SelectItem value="">預設卡組</SelectItem>
                  {cardSets.map(set => (
                    <SelectItem key={set.id} value={set.id}>
                      {set.name}
                    </SelectItem>
                  ))}
                </SelectContent>
              </Select>
            </div>
          </div>

          {/* 詞卡列表 */}
          <div className="flex-1 overflow-y-auto space-y-2 pr-2">
            {selectedCards.map((card) => (
              <CardPreviewItem
                key={card.tempId}
                card={card}
                isSelected={card.isSelected}
                onToggle={(checked) => handleCardToggle(card.tempId, checked)}
              />
            ))}
          </div>

          {/* 底部操作按鈕 */}
          <div className="flex justify-end space-x-2 pt-4 border-t">
            <Button variant="outline" onClick={onClose} disabled={isSaving}>
              取消
            </Button>
            <Button
              onClick={handleSave}
              disabled={selectedCount === 0 || isSaving}
              className="min-w-24"
            >
              {isSaving ? (
                <div className="flex items-center space-x-2">
                  <div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
                  <span>保存中...</span>
                </div>
              ) : (
                `保存 ${selectedCount} 張詞卡`
              )}
            </Button>
          </div>
        </div>
      </DialogContent>
    </Dialog>
  );
};

// 詞卡預覽項目組件
interface CardPreviewItemProps {
  card: SelectedCard;
  isSelected: boolean;
  onToggle: (checked: boolean) => void;
}

const CardPreviewItem: React.FC<CardPreviewItemProps> = ({
  card,
  isSelected,
  onToggle
}) => {
  return (
    <div className={`
      border rounded-lg p-4 transition-all duration-200
      ${isSelected
        ? 'border-blue-500 bg-blue-50 shadow-sm'
        : 'border-gray-200 bg-white hover:border-gray-300'
      }
    `}>
      <div className="flex items-start space-x-3">
        <Checkbox
          checked={isSelected}
          onCheckedChange={onToggle}
          className="mt-1"
        />

        <div className="flex-1 space-y-2">
          <div className="flex items-center justify-between">
            <h3 className="text-lg font-semibold text-gray-900">
              {card.word}
            </h3>
            {card.difficultyLevel && (
              <span className="px-2 py-1 text-xs font-medium bg-gray-100 text-gray-600 rounded">
                {card.difficultyLevel}
              </span>
            )}
          </div>

          <div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
            <div>
              <span className="font-medium text-gray-700">翻譯:</span>
              <span className="text-gray-900">{card.translation}</span>
            </div>

            {card.partOfSpeech && (
              <div>
                <span className="font-medium text-gray-700">詞性:</span>
                <span className="text-gray-900">{card.partOfSpeech}</span>
              </div>
            )}
          </div>

          <div>
            <span className="font-medium text-gray-700">定義:</span>
            <p className="text-gray-900 leading-relaxed">{card.definition}</p>
          </div>

          {card.example && (
            <div>
              <span className="font-medium text-gray-700">例句:</span>
              <p className="text-gray-900 italic">"{card.example}"</p>
              {card.exampleTranslation && (
                <p className="text-gray-600 text-sm mt-1">{card.exampleTranslation}</p>
              )}
            </div>
          )}
        </div>
      </div>
    </div>
  );
};

6.3 重複處理對話框

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<string, 'merge' | 'skip' | 'replace' | 'keep_both'>;
}

export const DuplicateHandlingDialog: React.FC<DuplicateHandlingDialogProps> = ({
  isOpen,
  duplicates,
  onResolve,
  onClose
}) => {
  const [globalStrategy, setGlobalStrategy] = useState<string>('');
  const [applyToAll, setApplyToAll] = useState(false);
  const [individualDecisions, setIndividualDecisions] = useState<Record<string, string>>({});

  const handleResolve = () => {
    const resolution: DuplicateResolution = {
      strategy: globalStrategy as any,
      applyToAll,
      decisions: applyToAll
        ? {}
        : individualDecisions as Record<string, 'merge' | 'skip' | 'replace' | 'keep_both'>
    };

    onResolve(resolution);
  };

  const handleIndividualDecision = (tempId: string, decision: string) => {
    setIndividualDecisions(prev => ({
      ...prev,
      [tempId]: decision
    }));
  };

  const allDecisionsMade = applyToAll
    ? globalStrategy !== ''
    : duplicates.every(dup => individualDecisions[dup.tempId]);

  return (
    <Dialog open={isOpen} onOpenChange={onClose}>
      <DialogContent className="max-w-6xl max-h-[90vh] overflow-hidden flex flex-col">
        <DialogHeader>
          <DialogTitle>
            發現重複詞卡 ({duplicates.length} )
          </DialogTitle>
        </DialogHeader>

        <div className="flex-1 overflow-hidden flex flex-col space-y-4">
          <Alert>
            <AlertDescription>
              系統檢測到一些詞卡可能與您現有的詞卡重複。請選擇處理方式:
            </AlertDescription>
          </Alert>

          {/* 全局處理選項 */}
          <div className="p-4 bg-gray-50 rounded-lg">
            <div className="flex items-center space-x-2 mb-3">
              <input
                type="checkbox"
                id="apply-to-all"
                checked={applyToAll}
                onChange={(e) => setApplyToAll(e.target.checked)}
              />
              <Label htmlFor="apply-to-all" className="font-medium">
                對所有重複項目使用相同處理方式
              </Label>
            </div>

            {applyToAll && (
              <RadioGroup value={globalStrategy} onValueChange={setGlobalStrategy}>
                <div className="grid grid-cols-2 gap-4">
                  <div className="flex items-center space-x-2">
                    <RadioGroupItem value="skip" id="skip-all" />
                    <Label htmlFor="skip-all">跳過重複項目</Label>
                  </div>
                  <div className="flex items-center space-x-2">
                    <RadioGroupItem value="merge" id="merge-all" />
                    <Label htmlFor="merge-all">合併到現有詞卡</Label>
                  </div>
                  <div className="flex items-center space-x-2">
                    <RadioGroupItem value="replace" id="replace-all" />
                    <Label htmlFor="replace-all">替換現有詞卡</Label>
                  </div>
                  <div className="flex items-center space-x-2">
                    <RadioGroupItem value="keep_both" id="keep-both-all" />
                    <Label htmlFor="keep-both-all">保留兩者</Label>
                  </div>
                </div>
              </RadioGroup>
            )}
          </div>

          {/* 個別處理選項 */}
          {!applyToAll && (
            <div className="flex-1 overflow-y-auto space-y-4">
              <h3 className="font-medium text-gray-900">個別處理每個重複項目:</h3>

              {duplicates.map((duplicate) => (
                <DuplicateComparisonCard
                  key={duplicate.tempId}
                  duplicate={duplicate}
                  decision={individualDecisions[duplicate.tempId] || ''}
                  onDecisionChange={(decision) =>
                    handleIndividualDecision(duplicate.tempId, decision)
                  }
                />
              ))}
            </div>
          )}

          {/* 底部操作按鈕 */}
          <div className="flex justify-end space-x-2 pt-4 border-t">
            <Button variant="outline" onClick={onClose}>
              取消
            </Button>
            <Button
              onClick={handleResolve}
              disabled={!allDecisionsMade}
            >
              確認處理
            </Button>
          </div>
        </div>
      </DialogContent>
    </Dialog>
  );
};

// 重複詞卡對比卡片
interface DuplicateComparisonCardProps {
  duplicate: DuplicateMatch;
  decision: string;
  onDecisionChange: (decision: string) => void;
}

const DuplicateComparisonCard: React.FC<DuplicateComparisonCardProps> = ({
  duplicate,
  decision,
  onDecisionChange
}) => {
  const bestMatch = duplicate.matches[0]; // 取最佳匹配

  return (
    <div className="border rounded-lg p-4 space-y-4">
      <div className="flex items-center justify-between">
        <h4 className="font-medium text-gray-900">
          重複詞卡: {duplicate.word}
        </h4>
        <div className="flex items-center space-x-2">
          <span className="text-sm text-gray-500">相似度:</span>
          <span className={`px-2 py-1 text-xs font-medium rounded ${
            bestMatch.confidence === 'high'
              ? 'bg-red-100 text-red-700'
              : bestMatch.confidence === 'medium'
              ? 'bg-yellow-100 text-yellow-700'
              : 'bg-green-100 text-green-700'
          }`}>
            {Math.round(bestMatch.similarityScore * 100)}% ({bestMatch.confidence})
          </span>
        </div>
      </div>

      {/* 詞卡對比 */}
      <div className="grid grid-cols-2 gap-4">
        <div className="space-y-2">
          <h5 className="font-medium text-blue-600">新詞卡</h5>
          <div className="bg-blue-50 p-3 rounded border">
            <p><strong>單字:</strong> {duplicate.word}</p>
            <p><strong>翻譯:</strong> {duplicate.translation}</p>
            <p><strong>定義:</strong> {duplicate.definition}</p>
          </div>
        </div>

        <div className="space-y-2">
          <h5 className="font-medium text-gray-600">現有詞卡</h5>
          <div className="bg-gray-50 p-3 rounded border">
            <p><strong>單字:</strong> {bestMatch.word}</p>
            <p><strong>翻譯:</strong> {bestMatch.translation}</p>
            <p><strong>定義:</strong> {bestMatch.definition}</p>
          </div>
        </div>
      </div>

      {/* 處理選項 */}
      <RadioGroup value={decision} onValueChange={onDecisionChange}>
        <div className="grid grid-cols-2 gap-4">
          <div className="flex items-center space-x-2">
            <RadioGroupItem value="skip" id={`skip-${duplicate.tempId}`} />
            <Label htmlFor={`skip-${duplicate.tempId}`}>跳過新詞卡</Label>
          </div>
          <div className="flex items-center space-x-2">
            <RadioGroupItem value="merge" id={`merge-${duplicate.tempId}`} />
            <Label htmlFor={`merge-${duplicate.tempId}`}>合併信息</Label>
          </div>
          <div className="flex items-center space-x-2">
            <RadioGroupItem value="replace" id={`replace-${duplicate.tempId}`} />
            <Label htmlFor={`replace-${duplicate.tempId}`}>替換現有</Label>
          </div>
          <div className="flex items-center space-x-2">
            <RadioGroupItem value="keep_both" id={`keep-both-${duplicate.tempId}`} />
            <Label htmlFor={`keep-both-${duplicate.tempId}`}>保留兩者</Label>
          </div>
        </div>
      </RadioGroup>
    </div>
  );
};

6.4 保存進度指示器

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<SaveProgressIndicatorProps> = ({
  isOpen,
  progress,
  onClose
}) => {
  const progressPercentage = (progress.currentStep / progress.totalSteps) * 100;

  const getStageIcon = () => {
    switch (progress.stage) {
      case 'checking':
      case 'saving':
        return <Loader2 className="w-6 h-6 animate-spin text-blue-500" />;
      case 'completed':
        return <CheckCircle className="w-6 h-6 text-green-500" />;
      case 'error':
        return <XCircle className="w-6 h-6 text-red-500" />;
      default:
        return <AlertCircle className="w-6 h-6 text-yellow-500" />;
    }
  };

  const getStageTitle = () => {
    switch (progress.stage) {
      case 'checking':
        return '檢查重複詞卡...';
      case 'saving':
        return '保存詞卡中...';
      case 'completed':
        return '保存完成';
      case 'error':
        return '保存失敗';
      default:
        return '處理中...';
    }
  };

  return (
    <Dialog open={isOpen} onOpenChange={onClose}>
      <DialogContent className="max-w-2xl">
        <DialogHeader>
          <DialogTitle className="flex items-center space-x-2">
            {getStageIcon()}
            <span>{getStageTitle()}</span>
          </DialogTitle>
        </DialogHeader>

        <div className="space-y-4">
          {/* 總體進度 */}
          <div className="space-y-2">
            <div className="flex justify-between text-sm">
              <span>{progress.message}</span>
              <span>{progress.currentStep}/{progress.totalSteps}</span>
            </div>
            <Progress value={progressPercentage} className="w-full" />
          </div>

          {/* 詳細進度 */}
          {progress.details && progress.details.length > 0 && (
            <div className="max-h-60 overflow-y-auto space-y-2">
              <h4 className="font-medium text-gray-900">詳細進度:</h4>
              {progress.details.map((detail) => (
                <div
                  key={detail.id}
                  className="flex items-center justify-between p-2 bg-gray-50 rounded"
                >
                  <div className="flex items-center space-x-2">
                    {getDetailStatusIcon(detail.status)}
                    <span className="font-medium">{detail.word}</span>
                  </div>
                  <div className="text-sm text-gray-600">
                    {getDetailStatusText(detail.status)}
                  </div>
                </div>
              ))}
            </div>
          )}

          {/* 結果摘要 */}
          {progress.summary && progress.stage === 'completed' && (
            <div className="bg-green-50 border border-green-200 rounded-lg p-4">
              <h4 className="font-medium text-green-800 mb-2">保存結果摘要</h4>
              <div className="grid grid-cols-2 gap-4 text-sm">
                <div>
                  <span className="text-green-700">成功保存:</span>
                  <span className="font-medium ml-2">{progress.summary.totalSaved} </span>
                </div>
                <div>
                  <span className="text-yellow-700">跳過:</span>
                  <span className="font-medium ml-2">{progress.summary.totalSkipped} </span>
                </div>
                <div>
                  <span className="text-blue-700">重複處理:</span>
                  <span className="font-medium ml-2">{progress.summary.totalDuplicates} </span>
                </div>
                <div>
                  <span className="text-gray-700">總計:</span>
                  <span className="font-medium ml-2">{progress.summary.totalRequested} </span>
                </div>
              </div>
            </div>
          )}

          {/* 錯誤信息 */}
          {progress.error && progress.stage === 'error' && (
            <div className="bg-red-50 border border-red-200 rounded-lg p-4">
              <h4 className="font-medium text-red-800 mb-2">錯誤信息</h4>
              <p className="text-red-700 text-sm">{progress.error}</p>
            </div>
          )}

          {/* 操作按鈕 */}
          {(progress.stage === 'completed' || progress.stage === 'error') && onClose && (
            <div className="flex justify-end">
              <button
                onClick={onClose}
                className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
              >
                {progress.stage === 'completed' ? '完成' : '關閉'}
              </button>
            </div>
          )}
        </div>
      </DialogContent>
    </Dialog>
  );
};

// 詳細狀態圖標
const getDetailStatusIcon = (status: string) => {
  switch (status) {
    case 'pending':
      return <div className="w-4 h-4 rounded-full bg-gray-300" />;
    case 'saving':
      return <Loader2 className="w-4 h-4 animate-spin text-blue-500" />;
    case 'saved':
      return <CheckCircle className="w-4 h-4 text-green-500" />;
    case 'skipped':
      return <AlertCircle className="w-4 h-4 text-yellow-500" />;
    case 'error':
      return <XCircle className="w-4 h-4 text-red-500" />;
    default:
      return <div className="w-4 h-4 rounded-full bg-gray-300" />;
  }
};

// 詳細狀態文字
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 主流程組件整合

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<SaveCardsFlowProps> = ({
  generatedCards,
  cardSets,
  onSaveComplete,
  onError
}) => {
  const [currentStep, setCurrentStep] = useState<'selection' | 'duplicates' | 'progress' | 'completed'>('selection');
  const [selectedCards, setSelectedCards] = useState<SelectedCard[]>([]);
  const [targetCardSetId, setTargetCardSetId] = useState<string>('');
  const [duplicates, setDuplicates] = useState<DuplicateMatch[]>([]);
  const [saveProgress, setSaveProgress] = useState<SaveProgress>({
    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 (
    <>
      {/* 詞卡選擇對話框 */}
      <CardSelectionDialog
        isOpen={currentStep === 'selection'}
        generatedCards={generatedCards}
        cardSets={cardSets}
        onClose={handleClose}
        onSave={handleCardSelection}
      />

      {/* 重複處理對話框 */}
      <DuplicateHandlingDialog
        isOpen={currentStep === 'duplicates'}
        duplicates={duplicates}
        onResolve={handleDuplicateResolution}
        onClose={handleClose}
      />

      {/* 保存進度指示器 */}
      <SaveProgressIndicator
        isOpen={currentStep === 'progress'}
        progress={saveProgress}
        onClose={saveProgress.stage === 'completed' || saveProgress.stage === 'error' ? handleClose : undefined}
      />
    </>
  );
};

// 修改原有的保存按鈕點擊處理
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 前端服務層更新

// 擴展現有的 flashcardsService
class FlashcardsService {
  // ... 現有方法

  /**
   * 檢查重複詞卡
   */
  async checkDuplicates(cards: DuplicateCheckCard[]): Promise<ApiResponse<DuplicateCheckResult>> {
    try {
      const response = await this.makeRequest<ApiResponse<DuplicateCheckResult>>('/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<ApiResponse<BatchSaveResult>> {
    try {
      const response = await this.makeRequest<ApiResponse<BatchSaveResult>>('/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<ApiResponse<{sets: CardSet[]}>> {
    try {
      return await this.makeRequest<ApiResponse<{sets: CardSet[]}>>('/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 後端測試

[TestClass]
public class DuplicateDetectionServiceTests
{
    private DuplicateDetectionService _service;
    private Mock<DramaLingDbContext> _mockContext;

    [TestInitialize]
    public void Setup()
    {
        _mockContext = new Mock<DramaLingDbContext>();
        _service = new DuplicateDetectionService(_mockContext.Object, Mock.Of<ILogger<DuplicateDetectionService>>());
    }

    [TestMethod]
    public async Task DetectDuplicates_ExactMatch_ShouldReturnHighSimilarity()
    {
        // Arrange
        var userId = Guid.NewGuid();
        var existingCards = new List<Flashcard>
        {
            new() { Id = Guid.NewGuid(), Word = "Hello", Translation = "你好", Definition = "A greeting" }
        };

        var newCards = new List<CardSaveRequest>
        {
            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 前端測試

// 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(
      <CardSelectionDialog
        isOpen={true}
        generatedCards={mockGeneratedCards}
        cardSets={mockCardSets}
        onClose={jest.fn()}
        onSave={jest.fn()}
      />
    );

    expect(screen.getByText('hello')).toBeInTheDocument();
    expect(screen.getByText('你好')).toBeInTheDocument();
  });

  test('should handle select all functionality', () => {
    const mockOnSave = jest.fn();

    render(
      <CardSelectionDialog
        isOpen={true}
        generatedCards={mockGeneratedCards}
        cardSets={mockCardSets}
        onClose={jest.fn()}
        onSave={mockOnSave}
      />
    );

    // 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(
      <CardSelectionDialog
        isOpen={true}
        generatedCards={mockGeneratedCards}
        cardSets={mockCardSets}
        onClose={jest.fn()}
        onSave={mockOnSave}
      />
    );

    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整合測試

[TestClass]
public class FlashcardsControllerIntegrationTests : TestBase
{
    [TestMethod]
    public async Task BatchSave_EndToEnd_ShouldWork()
    {
        // Arrange
        var client = CreateAuthenticatedClient();
        var request = new BatchSaveRequest
        {
            Cards = new List<CardSaveRequest>
            {
                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<BatchSaveResponse>();

        Assert.IsTrue(result.Success);
        Assert.AreEqual(1, result.Data.Summary.TotalSaved);
    }

    [TestMethod]
    public async Task CheckDuplicates_WithExistingCard_ShouldDetect()
    {
        // 測試重複檢測API的完整流程
    }
}

8.2.2 前端整合測試

// 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測試

// 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 資料庫遷移腳本

-- 新增批量保存相關索引
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 設定檔更新

// 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 前端環境變數

// 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 日誌記錄

public class BatchSaveLogger
{
    private readonly ILogger<BatchSaveLogger> _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 效能監控

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個工作天 負責團隊: 前端團隊 + 後端團隊 優先級: 🔴 高優先級(立即開始)