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"); 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] [HttpGet]
public async Task<ActionResult> GetCardSets() public async Task<ActionResult> GetCardSets()
{ {
@ -37,9 +61,13 @@ public class CardSetsController : ControllerBase
{ {
var userId = GetUserId(); var userId = GetUserId();
// 確保用戶有預設卡組
await EnsureDefaultCardSetAsync(userId);
var cardSets = await _context.CardSets var cardSets = await _context.CardSets
.Where(cs => cs.UserId == userId) .Where(cs => cs.UserId == userId)
.OrderByDescending(cs => cs.CreatedAt) .OrderBy(cs => cs.IsDefault ? 0 : 1) // 預設卡組排在最前面
.ThenByDescending(cs => cs.CreatedAt)
.Select(cs => new .Select(cs => new
{ {
cs.Id, cs.Id,
@ -49,6 +77,7 @@ public class CardSetsController : ControllerBase
cs.CardCount, cs.CardCount,
cs.CreatedAt, cs.CreatedAt,
cs.UpdatedAt, cs.UpdatedAt,
cs.IsDefault,
// 計算進度 (簡化版) // 計算進度 (簡化版)
Progress = cs.CardCount > 0 ? Progress = cs.CardCount > 0 ?
_context.Flashcards _context.Flashcards
@ -186,6 +215,10 @@ public class CardSetsController : ControllerBase
if (cardSet == null) if (cardSet == null)
return NotFound(new { Success = false, Error = "Card set not found" }); 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); _context.CardSets.Remove(cardSet);
await _context.SaveChangesAsync(); 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 // Request DTOs

View File

@ -30,6 +30,31 @@ public class FlashcardsController : ControllerBase
throw new UnauthorizedAccessException("Invalid user ID"); 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] [HttpGet]
public async Task<ActionResult> GetFlashcards( public async Task<ActionResult> GetFlashcards(
[FromQuery] Guid? setId, [FromQuery] Guid? setId,
@ -116,18 +141,30 @@ public class FlashcardsController : ControllerBase
{ {
var userId = GetUserId(); var userId = GetUserId();
// 驗證卡組是否屬於用戶 // 確定要使用的卡組ID
var cardSet = await _context.CardSets Guid cardSetId;
.FirstOrDefaultAsync(cs => cs.Id == request.CardSetId && cs.UserId == userId); if (request.CardSetId.HasValue)
{
// 如果指定了卡組,驗證是否屬於用戶
var cardSet = await _context.CardSets
.FirstOrDefaultAsync(cs => cs.Id == request.CardSetId.Value && cs.UserId == userId);
if (cardSet == null) if (cardSet == null)
return NotFound(new { Success = false, Error = "Card set not found" }); return NotFound(new { Success = false, Error = "Card set not found" });
cardSetId = request.CardSetId.Value;
}
else
{
// 如果沒有指定卡組,使用或創建預設卡組
cardSetId = await GetOrCreateDefaultCardSetAsync(userId);
}
var flashcard = new Flashcard var flashcard = new Flashcard
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
UserId = userId, UserId = userId,
CardSetId = request.CardSetId, CardSetId = cardSetId,
Word = request.Word.Trim(), Word = request.Word.Trim(),
Translation = request.Translation.Trim(), Translation = request.Translation.Trim(),
Definition = request.Definition.Trim(), Definition = request.Definition.Trim(),
@ -290,7 +327,7 @@ public class FlashcardsController : ControllerBase
// DTOs // DTOs
public class CreateFlashcardRequest public class CreateFlashcardRequest
{ {
public Guid CardSetId { get; set; } public Guid? CardSetId { get; set; }
public string Word { get; set; } = string.Empty; public string Word { get; set; } = string.Empty;
public string Translation { get; set; } = string.Empty; public string Translation { get; set; } = string.Empty;
public string Definition { 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 int CardCount { get; set; } = 0;
public bool IsDefault { get; set; } = false;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;

View File

@ -32,7 +32,23 @@ function FlashcardsContent() {
try { try {
const result = await flashcardsService.getCardSets() const result = await flashcardsService.getCardSets()
if (result.success && result.data) { 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 { } else {
setError(result.error || 'Failed to load card sets') setError(result.error || 'Failed to load card sets')
} }
@ -175,6 +191,22 @@ function FlashcardsContent() {
> >
({filteredCards.length}) ({filteredCards.length})
</button> </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> </div>
{/* Search */} {/* Search */}
<div className="mb-6"> <div className="mb-6">
@ -216,14 +248,22 @@ function FlashcardsContent() {
{filteredSets.map(set => ( {filteredSets.map(set => (
<div <div
key={set.id} 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={() => { onClick={() => {
setSelectedSet(set.id) setSelectedSet(set.id)
setActiveTab('all-cards') setActiveTab('all-cards')
}} }}
> >
<div className={`${set.color} text-white p-4 rounded-t-lg`}> <div className={`${set.isDefault ? 'bg-slate-700' : set.color} text-white p-4 rounded-t-lg`}>
<h4 className="font-semibold text-lg">{set.name}</h4> <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> <p className="text-sm opacity-90">{set.description}</p>
</div> </div>
<div className="p-4 bg-white rounded-b-lg"> <div className="p-4 bg-white rounded-b-lg">
@ -254,6 +294,26 @@ function FlashcardsContent() {
)} )}
</div> </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 ? ( {filteredCards.length === 0 ? (
<div className="text-center py-12"> <div className="text-center py-12">
<p className="text-gray-500 mb-4"></p> <p className="text-gray-500 mb-4"></p>

View File

@ -1,6 +1,6 @@
'use client' 'use client'
import { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { flashcardsService, type CreateFlashcardRequest, type CardSet } from '@/lib/services/flashcards' import { flashcardsService, type CreateFlashcardRequest, type CardSet } from '@/lib/services/flashcards'
interface FlashcardFormProps { interface FlashcardFormProps {
@ -12,8 +12,23 @@ interface FlashcardFormProps {
} }
export function FlashcardForm({ cardSets, initialData, isEdit = false, onSuccess, onCancel }: 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>({ const [formData, setFormData] = useState<CreateFlashcardRequest>({
cardSetId: initialData?.cardSetId || (cardSets[0]?.id || ''), cardSetId: getDefaultCardSetId(),
english: initialData?.english || '', english: initialData?.english || '',
chinese: initialData?.chinese || '', chinese: initialData?.chinese || '',
pronunciation: initialData?.pronunciation || '', pronunciation: initialData?.pronunciation || '',
@ -21,6 +36,16 @@ export function FlashcardForm({ cardSets, initialData, isEdit = false, onSuccess
example: initialData?.example || '', 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 [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null) 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 className="block text-sm font-medium text-gray-700 mb-2">
* *
</label> </label>
<select {cardSets.length === 0 ? (
value={formData.cardSetId} <div className="w-full px-3 py-2 border border-gray-300 rounded-lg bg-gray-100 text-gray-500">
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" </div>
required ) : (
> <select
{cardSets.map(set => ( value={formData.cardSetId}
<option key={set.id} value={set.id}> onChange={(e) => handleChange('cardSetId', e.target.value)}
{set.name} className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
</option> required
))} >
</select> {/* 如果沒有選中任何卡組,顯示提示 */}
{!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> </div>
{/* 英文單字 */} {/* 英文單字 */}
@ -190,10 +238,12 @@ export function FlashcardForm({ cardSets, initialData, isEdit = false, onSuccess
</button> </button>
<button <button
type="submit" 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" 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> </button>
</div> </div>
</form> </form>

View File

@ -8,6 +8,7 @@ export interface CardSet {
cardCount: number; cardCount: number;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
isDefault: boolean;
progress: number; progress: number;
lastStudied: string; lastStudied: string;
tags: string[]; tags: string[];
@ -40,7 +41,7 @@ export interface CreateCardSetRequest {
} }
export interface CreateFlashcardRequest { export interface CreateFlashcardRequest {
cardSetId: string; cardSetId?: string;
english: string; english: string;
chinese: string; chinese: string;
pronunciation: 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(); export const flashcardsService = new FlashcardsService();