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