70 KiB
70 KiB
詞卡保存功能技術規格
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個工作天 負責團隊: 前端團隊 + 後端團隊 優先級: 🔴 高優先級(立即開始)