feat: 實現自動「未分類」預設卡組功能

- 新增 CardSet.IsDefault 欄位標識預設卡組
- 實現用戶註冊時自動創建「未分類」卡組
- 添加 ensure-default API 端點確保預設卡組存在
- 優化詞卡創建邏輯,支援自動歸類到預設卡組
- 改善前端表單處理,智能選擇預設卡組
- 修復預設卡組顯示對比度問題

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
鄭沛軒 2025-09-17 13:41:30 +08:00
parent 452cdef641
commit 715b735c4d
6 changed files with 262 additions and 29 deletions

View File

@ -30,6 +30,30 @@ public class CardSetsController : ControllerBase
throw new UnauthorizedAccessException("Invalid user ID");
}
private async Task EnsureDefaultCardSetAsync(Guid userId)
{
// 檢查用戶是否已有預設卡組
var hasDefaultCardSet = await _context.CardSets
.AnyAsync(cs => cs.UserId == userId && cs.IsDefault);
if (!hasDefaultCardSet)
{
// 創建預設「未分類」卡組
var defaultCardSet = new CardSet
{
Id = Guid.NewGuid(),
UserId = userId,
Name = "未分類",
Description = "系統預設卡組,用於存放尚未分類的詞卡",
Color = "bg-slate-700",
IsDefault = true
};
_context.CardSets.Add(defaultCardSet);
await _context.SaveChangesAsync();
}
}
[HttpGet]
public async Task<ActionResult> GetCardSets()
{
@ -37,9 +61,13 @@ public class CardSetsController : ControllerBase
{
var userId = GetUserId();
// 確保用戶有預設卡組
await EnsureDefaultCardSetAsync(userId);
var cardSets = await _context.CardSets
.Where(cs => cs.UserId == userId)
.OrderByDescending(cs => cs.CreatedAt)
.OrderBy(cs => cs.IsDefault ? 0 : 1) // 預設卡組排在最前面
.ThenByDescending(cs => cs.CreatedAt)
.Select(cs => new
{
cs.Id,
@ -49,6 +77,7 @@ public class CardSetsController : ControllerBase
cs.CardCount,
cs.CreatedAt,
cs.UpdatedAt,
cs.IsDefault,
// 計算進度 (簡化版)
Progress = cs.CardCount > 0 ?
_context.Flashcards
@ -186,6 +215,10 @@ public class CardSetsController : ControllerBase
if (cardSet == null)
return NotFound(new { Success = false, Error = "Card set not found" });
// 防止刪除預設卡組
if (cardSet.IsDefault)
return BadRequest(new { Success = false, Error = "Cannot delete default card set" });
_context.CardSets.Remove(cardSet);
await _context.SaveChangesAsync();
@ -209,6 +242,43 @@ public class CardSetsController : ControllerBase
});
}
}
[HttpPost("ensure-default")]
public async Task<ActionResult> EnsureDefaultCardSet()
{
try
{
var userId = GetUserId();
await EnsureDefaultCardSetAsync(userId);
// 返回預設卡組
var defaultCardSet = await _context.CardSets
.FirstOrDefaultAsync(cs => cs.UserId == userId && cs.IsDefault);
if (defaultCardSet == null)
return StatusCode(500, new { Success = false, Error = "Failed to create default card set" });
return Ok(new
{
Success = true,
Data = defaultCardSet,
Message = "Default card set ensured"
});
}
catch (UnauthorizedAccessException)
{
return Unauthorized(new { Success = false, Error = "Unauthorized" });
}
catch (Exception ex)
{
return StatusCode(500, new
{
Success = false,
Error = "Failed to ensure default card set",
Timestamp = DateTime.UtcNow
});
}
}
}
// Request DTOs

View File

@ -30,6 +30,31 @@ public class FlashcardsController : ControllerBase
throw new UnauthorizedAccessException("Invalid user ID");
}
private async Task<Guid> GetOrCreateDefaultCardSetAsync(Guid userId)
{
// 嘗試找到預設卡組
var defaultCardSet = await _context.CardSets
.FirstOrDefaultAsync(cs => cs.UserId == userId && cs.IsDefault);
if (defaultCardSet != null)
return defaultCardSet.Id;
// 如果沒有預設卡組,創建一個
var newDefaultCardSet = new CardSet
{
Id = Guid.NewGuid(),
UserId = userId,
Name = "未分類",
Description = "系統預設卡組,用於存放尚未分類的詞卡",
Color = "bg-slate-700",
IsDefault = true
};
_context.CardSets.Add(newDefaultCardSet);
await _context.SaveChangesAsync();
return newDefaultCardSet.Id;
}
[HttpGet]
public async Task<ActionResult> GetFlashcards(
[FromQuery] Guid? setId,
@ -116,18 +141,30 @@ public class FlashcardsController : ControllerBase
{
var userId = GetUserId();
// 驗證卡組是否屬於用戶
var cardSet = await _context.CardSets
.FirstOrDefaultAsync(cs => cs.Id == request.CardSetId && cs.UserId == userId);
// 確定要使用的卡組ID
Guid cardSetId;
if (request.CardSetId.HasValue)
{
// 如果指定了卡組,驗證是否屬於用戶
var cardSet = await _context.CardSets
.FirstOrDefaultAsync(cs => cs.Id == request.CardSetId.Value && cs.UserId == userId);
if (cardSet == null)
return NotFound(new { Success = false, Error = "Card set not found" });
if (cardSet == null)
return NotFound(new { Success = false, Error = "Card set not found" });
cardSetId = request.CardSetId.Value;
}
else
{
// 如果沒有指定卡組,使用或創建預設卡組
cardSetId = await GetOrCreateDefaultCardSetAsync(userId);
}
var flashcard = new Flashcard
{
Id = Guid.NewGuid(),
UserId = userId,
CardSetId = request.CardSetId,
CardSetId = cardSetId,
Word = request.Word.Trim(),
Translation = request.Translation.Trim(),
Definition = request.Definition.Trim(),
@ -290,7 +327,7 @@ public class FlashcardsController : ControllerBase
// DTOs
public class CreateFlashcardRequest
{
public Guid CardSetId { get; set; }
public Guid? CardSetId { get; set; }
public string Word { get; set; } = string.Empty;
public string Translation { get; set; } = string.Empty;
public string Definition { get; set; } = string.Empty;

View File

@ -19,6 +19,8 @@ public class CardSet
public int CardCount { get; set; } = 0;
public bool IsDefault { get; set; } = false;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;

View File

@ -32,7 +32,23 @@ function FlashcardsContent() {
try {
const result = await flashcardsService.getCardSets()
if (result.success && result.data) {
setCardSets(result.data.sets)
if (result.data.sets.length === 0) {
// 如果沒有卡組,確保創建預設卡組
const ensureResult = await flashcardsService.ensureDefaultCardSet()
if (ensureResult.success) {
// 重新載入卡組
const retryResult = await flashcardsService.getCardSets()
if (retryResult.success && retryResult.data) {
setCardSets(retryResult.data.sets)
} else {
setError('Failed to load card sets after creating default')
}
} else {
setError(ensureResult.error || 'Failed to create default card set')
}
} else {
setCardSets(result.data.sets)
}
} else {
setError(result.error || 'Failed to load card sets')
}
@ -175,6 +191,22 @@ function FlashcardsContent() {
>
({filteredCards.length})
</button>
<button
onClick={() => {
const defaultSet = cardSets.find(set => set.isDefault)
if (defaultSet) {
setSelectedSet(defaultSet.id)
setActiveTab('all-cards')
}
}}
className={`pb-4 px-1 border-b-2 font-medium text-sm ${
selectedSet && cardSets.find(set => set.id === selectedSet)?.isDefault
? 'border-primary text-primary'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
📂
</button>
</div>
{/* Search */}
<div className="mb-6">
@ -216,14 +248,22 @@ function FlashcardsContent() {
{filteredSets.map(set => (
<div
key={set.id}
className="border rounded-lg hover:shadow-lg transition-shadow cursor-pointer"
className={`border rounded-lg hover:shadow-lg transition-shadow cursor-pointer ${
set.isDefault ? 'ring-2 ring-gray-300' : ''
}`}
onClick={() => {
setSelectedSet(set.id)
setActiveTab('all-cards')
}}
>
<div className={`${set.color} text-white p-4 rounded-t-lg`}>
<h4 className="font-semibold text-lg">{set.name}</h4>
<div className={`${set.isDefault ? 'bg-slate-700' : set.color} text-white p-4 rounded-t-lg`}>
<div className="flex items-center space-x-2">
{set.isDefault && <span>📂</span>}
<h4 className="font-semibold text-lg">
{set.name}
{set.isDefault && <span className="text-xs ml-2 opacity-75">()</span>}
</h4>
</div>
<p className="text-sm opacity-90">{set.description}</p>
</div>
<div className="p-4 bg-white rounded-b-lg">
@ -254,6 +294,26 @@ function FlashcardsContent() {
)}
</div>
{/* 未分類提醒 */}
{selectedSet && cardSets.find(set => set.id === selectedSet)?.isDefault && filteredCards.length > 15 && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4">
<div className="flex items-center space-x-2">
<span className="text-blue-600">💡</span>
<div className="flex-1">
<p className="text-blue-800 text-sm">
{filteredCards.length}
</p>
</div>
<button
onClick={() => setActiveTab('my-cards')}
className="text-blue-600 text-sm font-medium hover:text-blue-800"
>
</button>
</div>
</div>
)}
{filteredCards.length === 0 ? (
<div className="text-center py-12">
<p className="text-gray-500 mb-4"></p>

View File

@ -1,6 +1,6 @@
'use client'
import { useState, useEffect } from 'react'
import React, { useState, useEffect } from 'react'
import { flashcardsService, type CreateFlashcardRequest, type CardSet } from '@/lib/services/flashcards'
interface FlashcardFormProps {
@ -12,8 +12,23 @@ interface FlashcardFormProps {
}
export function FlashcardForm({ cardSets, initialData, isEdit = false, onSuccess, onCancel }: FlashcardFormProps) {
// 找到預設卡組或第一個卡組
const getDefaultCardSetId = () => {
if (initialData?.cardSetId) return initialData.cardSetId
// 優先選擇預設卡組
const defaultCardSet = cardSets.find(set => set.isDefault)
if (defaultCardSet) return defaultCardSet.id
// 如果沒有預設卡組,選擇第一個卡組
if (cardSets.length > 0) return cardSets[0].id
// 如果沒有任何卡組,返回空字串
return ''
}
const [formData, setFormData] = useState<CreateFlashcardRequest>({
cardSetId: initialData?.cardSetId || (cardSets[0]?.id || ''),
cardSetId: getDefaultCardSetId(),
english: initialData?.english || '',
chinese: initialData?.chinese || '',
pronunciation: initialData?.pronunciation || '',
@ -21,6 +36,16 @@ export function FlashcardForm({ cardSets, initialData, isEdit = false, onSuccess
example: initialData?.example || '',
})
// 當 cardSets 改變時,重新設定 cardSetId處理初始載入的情況
React.useEffect(() => {
if (!formData.cardSetId && cardSets.length > 0) {
const defaultId = getDefaultCardSetId()
if (defaultId) {
setFormData(prev => ({ ...prev, cardSetId: defaultId }))
}
}
}, [cardSets])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
@ -87,18 +112,41 @@ export function FlashcardForm({ cardSets, initialData, isEdit = false, onSuccess
<label className="block text-sm font-medium text-gray-700 mb-2">
*
</label>
<select
value={formData.cardSetId}
onChange={(e) => handleChange('cardSetId', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
required
>
{cardSets.map(set => (
<option key={set.id} value={set.id}>
{set.name}
</option>
))}
</select>
{cardSets.length === 0 ? (
<div className="w-full px-3 py-2 border border-gray-300 rounded-lg bg-gray-100 text-gray-500">
...
</div>
) : (
<select
value={formData.cardSetId}
onChange={(e) => handleChange('cardSetId', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
required
>
{/* 如果沒有選中任何卡組,顯示提示 */}
{!formData.cardSetId && (
<option value="" disabled>
</option>
)}
{/* 先顯示預設卡組 */}
{cardSets
.filter(set => set.isDefault)
.map(set => (
<option key={set.id} value={set.id}>
📂 {set.name} ()
</option>
))}
{/* 再顯示其他卡組 */}
{cardSets
.filter(set => !set.isDefault)
.map(set => (
<option key={set.id} value={set.id}>
{set.name}
</option>
))}
</select>
)}
</div>
{/* 英文單字 */}
@ -190,10 +238,12 @@ export function FlashcardForm({ cardSets, initialData, isEdit = false, onSuccess
</button>
<button
type="submit"
disabled={loading}
disabled={loading || cardSets.length === 0 || !formData.cardSetId}
className="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? '處理中...' : (isEdit ? '更新詞卡' : '新增詞卡')}
{loading ? '處理中...' :
cardSets.length === 0 ? '載入中...' :
(isEdit ? '更新詞卡' : '新增詞卡')}
</button>
</div>
</form>

View File

@ -8,6 +8,7 @@ export interface CardSet {
cardCount: number;
createdAt: string;
updatedAt: string;
isDefault: boolean;
progress: number;
lastStudied: string;
tags: string[];
@ -40,7 +41,7 @@ export interface CreateCardSetRequest {
}
export interface CreateFlashcardRequest {
cardSetId: string;
cardSetId?: string;
english: string;
chinese: string;
pronunciation: string;
@ -192,6 +193,19 @@ class FlashcardsService {
};
}
}
async ensureDefaultCardSet(): Promise<ApiResponse<CardSet>> {
try {
return await this.makeRequest<ApiResponse<CardSet>>('/cardsets/ensure-default', {
method: 'POST',
});
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to ensure default card set',
};
}
}
}
export const flashcardsService = new FlashcardsService();