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

2273 lines
70 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 詞卡保存功能技術規格
## 1. 功能概述
### 1.1 問題定義
當前系統生成詞卡後只能預覽,無法保存到資料庫。用戶點擊"💾 保存詞卡"時顯示"保存功能開發中...",影響核心學習流程。
### 1.2 解決方案
實現完整的詞卡保存流程,包括:選擇性保存、重複檢測、進度追蹤、錯誤處理和用戶回饋。
### 1.3 功能目標
- ✅ 批量保存生成的詞卡
- ✅ 智能檢測重複詞卡
- ✅ 提供直觀的用戶介面
- ✅ 完善的錯誤處理機制
- ✅ 保存進度實時回饋
---
## 2. 用戶故事與使用場景
### 2.1 主要用戶故事
#### US-01: 基本詞卡保存
**作為** 學習者
**我想要** 將AI生成的詞卡保存到我的詞庫
**以便** 後續進行學習和複習
**驗收標準:**
- [ ] 可以選擇要保存的詞卡
- [ ] 可以指定保存到特定卡組
- [ ] 保存成功後有明確提示
- [ ] 可以跳轉到詞卡列表查看
#### US-02: 重複詞卡處理
**作為** 學習者
**我想要** 系統自動檢測重複的詞卡
**以便** 避免創建冗餘內容
**驗收標準:**
- [ ] 系統自動檢測重複詞卡
- [ ] 提供重複處理選項(合併/跳過/替換)
- [ ] 顯示重複詞卡的對比資訊
- [ ] 允許用戶手動決定處理方式
#### US-03: 批量操作
**作為** 學習者
**我想要** 一次選擇多張詞卡進行保存
**以便** 提高操作效率
**驗收標準:**
- [ ] 支援全選/取消全選
- [ ] 支援單個詞卡選擇/取消
- [ ] 顯示選中詞卡數量
- [ ] 批量保存進度提示
### 2.2 使用場景流程
```mermaid
graph TD
A[用戶生成詞卡] --> B[預覽生成結果]
B --> C[點擊保存按鈕]
C --> D[彈出詞卡選擇對話框]
D --> E[用戶選擇要保存的詞卡]
E --> F[選擇目標卡組]
F --> G[系統檢測重複詞卡]
G --> H{發現重複?}
H -->|是| I[彈出重複處理對話框]
H -->|否| J[開始保存流程]
I --> K[用戶選擇處理方式]
K --> J
J --> L[顯示保存進度]
L --> M[保存完成]
M --> N[成功提示]
N --> O[跳轉到詞卡列表]
```
---
## 3. 技術架構設計
### 3.1 系統架構圖
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ 前端組件層 │ │ API控制層 │ │ 數據服務層 │
├─────────────────┤ ├─────────────────┤ ├─────────────────┤
│ CardSelection │ │ FlashcardsAPI │ │ FlashcardRepo │
│ Dialog │◄──►│ │◄──►│ │
├─────────────────┤ ├─────────────────┤ ├─────────────────┤
│ DuplicateHandle │ │ DuplicateAPI │ │ DuplicateDetect │
│ Dialog │ │ │ │ Service │
├─────────────────┤ ├─────────────────┤ ├─────────────────┤
│ SaveProgress │ │ BatchSaveAPI │ │ BatchSave │
│ Indicator │ │ │ │ Service │
└─────────────────┘ └─────────────────┘ └─────────────────┘
```
### 3.2 數據流向設計
```
生成詞卡 → 用戶選擇 → 重複檢測 → 批量保存 → 結果回饋
↓ ↓ ↓ ↓ ↓
前端狀態 API請求 後端分析 數據庫寫入 UI更新
```
---
## 4. API設計規格
### 4.1 批量保存詞卡API
#### 4.1.1 基本規格
```http
POST /api/flashcards/batch-save
Authorization: Bearer {token}
Content-Type: application/json
```
#### 4.1.2 請求格式
```typescript
interface BatchSaveRequest {
cardSetId?: string; // 目標卡組ID可選
cards: CardSaveRequest[]; // 要保存的詞卡列表
duplicateHandling: DuplicateHandling; // 重複處理策略
}
interface CardSaveRequest {
tempId: string; // 臨時ID用於前端追蹤
word: string; // 單字
translation: string; // 翻譯
definition: string; // 定義
partOfSpeech?: string; // 詞性
pronunciation?: string; // 發音
example?: string; // 例句
exampleTranslation?: string; // 例句翻譯
difficultyLevel?: string; // 難度等級
isSelected: boolean; // 是否選中保存
}
interface DuplicateHandling {
strategy: 'ask' | 'merge' | 'skip' | 'replace'; // 處理策略
autoApply: boolean; // 是否自動應用到所有重複
}
```
#### 4.1.3 回應格式
```typescript
interface BatchSaveResponse {
success: boolean;
data: {
savedCards: SavedCardResult[]; // 成功保存的詞卡
skippedCards: SkippedCardResult[]; // 跳過的詞卡
duplicateCards: DuplicateCardResult[]; // 重複的詞卡
summary: {
totalRequested: number; // 請求保存總數
totalSaved: number; // 實際保存數量
totalSkipped: number; // 跳過數量
totalDuplicates: number; // 重複數量
};
};
message?: string;
errors?: SaveError[];
}
interface SavedCardResult {
tempId: string; // 對應請求中的tempId
cardId: string; // 新創建的詞卡ID
word: string; // 保存的單字
}
interface SkippedCardResult {
tempId: string;
word: string;
reason: 'duplicate' | 'error' | 'user_choice';
}
interface DuplicateCardResult {
tempId: string;
word: string;
existingCardId: string; // 現有詞卡ID
similarityScore: number; // 相似度分數
action: 'merged' | 'skipped' | 'replaced'; // 執行的動作
}
```
### 4.2 重複檢測API
#### 4.2.1 基本規格
```http
POST /api/flashcards/check-duplicates
Authorization: Bearer {token}
Content-Type: application/json
```
#### 4.2.2 請求與回應
```typescript
interface DuplicateCheckRequest {
cards: {
tempId: string;
word: string;
translation: string;
definition: string;
}[];
}
interface DuplicateCheckResponse {
success: boolean;
data: {
duplicates: DuplicateMatch[];
noDuplicates: string[]; // 沒有重複的tempId列表
};
}
interface DuplicateMatch {
tempId: string; // 新詞卡臨時ID
word: string; // 新詞卡單字
matches: ExistingCardMatch[]; // 匹配的現有詞卡
}
interface ExistingCardMatch {
cardId: string; // 現有詞卡ID
word: string; // 現有詞卡單字
translation: string; // 現有詞卡翻譯
definition: string; // 現有詞卡定義
similarityScore: number; // 相似度分數(0-1)
matchType: 'exact' | 'similar' | 'semantic'; // 匹配類型
confidence: 'high' | 'medium' | 'low'; // 信心等級
}
```
---
## 5. 後端實現規格
### 5.1 控制器實現
```csharp
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class FlashcardsController : ControllerBase
{
private readonly IBatchSaveService _batchSaveService;
private readonly IDuplicateDetectionService _duplicateService;
private readonly ILogger<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 批量保存服務
```csharp
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 重複檢測服務
```csharp
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 詞卡選擇對話框
```typescript
import React, { useState, useCallback, useMemo } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
interface CardSelectionDialogProps {
isOpen: boolean;
generatedCards: GeneratedCard[];
cardSets: CardSet[];
onClose: () => void;
onSave: (selectedCards: SelectedCard[], targetCardSetId?: string) => Promise<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 重複處理對話框
```typescript
import React, { useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Label } from '@/components/ui/label';
import { Alert, AlertDescription } from '@/components/ui/alert';
interface DuplicateHandlingDialogProps {
isOpen: boolean;
duplicates: DuplicateMatch[];
onResolve: (resolution: DuplicateResolution) => void;
onClose: () => void;
}
interface DuplicateResolution {
strategy: 'merge' | 'skip' | 'replace' | 'keep_both';
applyToAll: boolean;
decisions: Record<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 保存進度指示器
```typescript
import React from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Progress } from '@/components/ui/progress';
import { CheckCircle, XCircle, AlertCircle, Loader2 } from 'lucide-react';
interface SaveProgressIndicatorProps {
isOpen: boolean;
progress: SaveProgress;
onClose?: () => void;
}
interface SaveProgress {
stage: 'checking' | 'saving' | 'completed' | 'error';
currentStep: number;
totalSteps: number;
message: string;
details?: SaveProgressDetail[];
summary?: SaveSummary;
error?: string;
}
interface SaveProgressDetail {
id: string;
word: string;
status: 'pending' | 'saving' | 'saved' | 'skipped' | 'error';
message?: string;
}
export const SaveProgressIndicator: React.FC<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 主流程組件整合
```typescript
import React, { useState, useCallback } from 'react';
import { CardSelectionDialog } from './CardSelectionDialog';
import { DuplicateHandlingDialog } from './DuplicateHandlingDialog';
import { SaveProgressIndicator } from './SaveProgressIndicator';
import { flashcardsService } from '@/lib/services/flashcards';
interface SaveCardsFlowProps {
generatedCards: GeneratedCard[];
cardSets: CardSet[];
onSaveComplete: () => void;
onError: (error: string) => void;
}
export const SaveCardsFlow: React.FC<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 前端服務層更新
```typescript
// 擴展現有的 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 後端測試
```csharp
[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 前端測試
```typescript
// DuplicateDetectionService.test.ts
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { CardSelectionDialog } from './CardSelectionDialog';
describe('CardSelectionDialog', () => {
const mockGeneratedCards = [
{
word: 'hello',
translation: '你好',
definition: 'A greeting',
partOfSpeech: 'interjection'
}
];
const mockCardSets = [
{ id: '1', name: '基礎詞彙', description: '', color: 'blue' }
];
test('should render all generated cards', () => {
render(
<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整合測試
```csharp
[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 前端整合測試
```typescript
// SaveCardsFlow.integration.test.ts
describe('SaveCardsFlow Integration', () => {
test('complete save flow without duplicates', async () => {
// 模擬完整的保存流程
// 1. 選擇詞卡
// 2. 檢查重複(無重複)
// 3. 保存成功
});
test('complete save flow with duplicates', async () => {
// 模擬有重複的保存流程
// 1. 選擇詞卡
// 2. 檢查重複(發現重複)
// 3. 處理重複
// 4. 保存成功
});
test('error handling during save', async () => {
// 測試保存過程中的錯誤處理
});
});
```
### 8.3 E2E測試
```typescript
// e2e/save-cards.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Save Cards Flow', () => {
test('user can save generated cards successfully', async ({ page }) => {
// 登入用戶
await page.goto('/login');
await page.fill('[data-testid="email"]', 'test@example.com');
await page.fill('[data-testid="password"]', 'password');
await page.click('[data-testid="login-button"]');
// 生成詞卡
await page.goto('/generate');
await page.fill('[data-testid="text-input"]', 'Hello world');
await page.click('[data-testid="analyze-button"]');
await page.waitForSelector('[data-testid="generated-cards"]');
// 保存詞卡
await page.click('[data-testid="save-cards-button"]');
// 在選擇對話框中操作
await expect(page.locator('[data-testid="card-selection-dialog"]')).toBeVisible();
await page.click('[data-testid="save-selected-button"]');
// 等待保存完成
await expect(page.locator('[data-testid="save-progress"]')).toBeVisible();
await expect(page.locator('[data-testid="save-success"]')).toBeVisible();
// 驗證跳轉到詞卡列表
await expect(page).toHaveURL('/flashcards');
});
test('user can handle duplicate cards', async ({ page }) => {
// 測試重複詞卡處理流程
});
});
```
---
## 9. 部署與配置
### 9.1 資料庫遷移腳本
```sql
-- 新增批量保存相關索引
CREATE INDEX IF NOT EXISTS IX_Flashcards_UserId_Word
ON flashcards(user_id, word);
CREATE INDEX IF NOT EXISTS IX_Flashcards_UserId_Translation
ON flashcards(user_id, translation);
-- 新增批量操作日誌表
CREATE TABLE IF NOT EXISTS batch_operation_logs (
id UUID PRIMARY KEY,
user_id UUID NOT NULL,
operation_type VARCHAR(50) NOT NULL,
request_data TEXT,
response_data TEXT,
success BOOLEAN NOT NULL,
error_message TEXT,
duration_ms INTEGER,
created_at TIMESTAMP DEFAULT NOW(),
FOREIGN KEY (user_id) REFERENCES user_profiles(id) ON DELETE CASCADE
);
-- 新增效能監控視圖
CREATE VIEW IF NOT EXISTS duplicate_detection_stats AS
SELECT
DATE(created_at) as date,
COUNT(*) as total_operations,
AVG(duration_ms) as avg_duration,
COUNT(CASE WHEN success THEN 1 END) as success_count,
COUNT(CASE WHEN NOT success THEN 1 END) as error_count
FROM batch_operation_logs
WHERE operation_type = 'duplicate_detection'
GROUP BY DATE(created_at);
```
### 9.2 設定檔更新
```json
// appsettings.json
{
"BatchSave": {
"MaxCardsPerBatch": 50,
"DuplicateDetectionTimeout": 30,
"TransactionTimeout": 60,
"EnableDetailedLogging": true
},
"DuplicateDetection": {
"ExactMatchThreshold": 1.0,
"SimilarMatchThreshold": 0.8,
"SemanticMatchThreshold": 0.6,
"MaxCandidatesPerCard": 5,
"EnableAIEnhancement": false
}
}
```
### 9.3 前端環境變數
```typescript
// next.config.js
const nextConfig = {
env: {
MAX_CARDS_PER_BATCH: process.env.MAX_CARDS_PER_BATCH || '20',
DUPLICATE_CHECK_ENABLED: process.env.DUPLICATE_CHECK_ENABLED || 'true',
SAVE_PROGRESS_POLLING_INTERVAL: process.env.SAVE_PROGRESS_POLLING_INTERVAL || '1000'
}
}
```
---
## 10. 監控與維護
### 10.1 日誌記錄
```csharp
public class BatchSaveLogger
{
private readonly ILogger<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 效能監控
```csharp
public class BatchSaveMetrics
{
private readonly IMetrics _metrics;
public void RecordBatchSaveOperation(BatchSaveResult result, TimeSpan duration)
{
_metrics.Counter("batch_save_operations_total")
.WithTag("status", "success")
.Increment();
_metrics.Histogram("batch_save_duration_ms")
.Record(duration.TotalMilliseconds);
_metrics.Gauge("cards_saved_per_operation")
.Set(result.Summary.TotalSaved);
}
public void RecordDuplicateDetection(int candidatesChecked, int duplicatesFound, TimeSpan duration)
{
_metrics.Counter("duplicate_detection_operations_total").Increment();
_metrics.Histogram("duplicate_detection_duration_ms").Record(duration.TotalMilliseconds);
_metrics.Histogram("duplicates_found_per_operation").Record(duplicatesFound);
}
}
```
---
## 11. 開發檢查清單
### 11.1 後端開發檢查清單
- [ ] **API端點實現**
- [ ] `POST /api/flashcards/batch-save` 端點
- [ ] `POST /api/flashcards/check-duplicates` 端點
- [ ] 請求驗證和參數檢查
- [ ] 統一錯誤回應格式
- [ ] **重複檢測服務**
- [ ] 實現 `DuplicateDetectionService`
- [ ] 字串相似度算法Levenshtein距離
- [ ] 語義相似度算法Jaccard係數
- [ ] 相似度閾值設定和調整
- [ ] **批量保存服務**
- [ ] 實現 `BatchSaveService`
- [ ] 資料庫事務處理
- [ ] 錯誤處理和回滾機制
- [ ] 保存結果統計
- [ ] **資料庫變更**
- [ ] 新增相關索引提升查詢效能
- [ ] 批量操作日誌表
- [ ] 資料遷移腳本
- [ ] **測試覆蓋**
- [ ] 單元測試90%以上覆蓋率)
- [ ] 整合測試
- [ ] 效能測試
### 11.2 前端開發檢查清單
- [ ] **主要組件開發**
- [ ] `CardSelectionDialog` 組件
- [ ] `DuplicateHandlingDialog` 組件
- [ ] `SaveProgressIndicator` 組件
- [ ] `SaveCardsFlow` 主流程組件
- [ ] **狀態管理**
- [ ] 詞卡選擇狀態管理
- [ ] 重複處理狀態管理
- [ ] 保存進度狀態管理
- [ ] 錯誤狀態處理
- [ ] **API整合**
- [ ] 擴展 `flashcardsService`
- [ ] 重複檢測API調用
- [ ] 批量保存API調用
- [ ] 錯誤處理和重試機制
- [ ] **使用者體驗**
- [ ] 響應式設計
- [ ] 載入狀態指示
- [ ] 錯誤訊息顯示
- [ ] 無障礙支援
- [ ] **測試覆蓋**
- [ ] 組件單元測試
- [ ] 使用者互動測試
- [ ] E2E測試
### 11.3 整合測試檢查清單
- [ ] **功能流程測試**
- [ ] 正常保存流程
- [ ] 重複詞卡處理流程
- [ ] 錯誤處理流程
- [ ] 邊界情況測試
- [ ] **效能測試**
- [ ] 大量詞卡批量保存
- [ ] 重複檢測效能
- [ ] 並發用戶測試
- [ ] 記憶體使用測試
- [ ] **相容性測試**
- [ ] 不同瀏覽器測試
- [ ] 行動裝置測試
- [ ] 網路狀況測試
---
**文件版本**: 1.0
**建立日期**: 2025-09-20
**預估開發時間**: 13-19個工作天
**負責團隊**: 前端團隊 + 後端團隊
**優先級**: 🔴 高優先級(立即開始)