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:
parent
452cdef641
commit
715b735c4d
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
// 驗證卡組是否屬於用戶
|
||||
// 確定要使用的卡組ID
|
||||
Guid cardSetId;
|
||||
if (request.CardSetId.HasValue)
|
||||
{
|
||||
// 如果指定了卡組,驗證是否屬於用戶
|
||||
var cardSet = await _context.CardSets
|
||||
.FirstOrDefaultAsync(cs => cs.Id == request.CardSetId && cs.UserId == userId);
|
||||
.FirstOrDefaultAsync(cs => cs.Id == request.CardSetId.Value && cs.UserId == userId);
|
||||
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -32,7 +32,23 @@ function FlashcardsContent() {
|
|||
try {
|
||||
const result = await flashcardsService.getCardSets()
|
||||
if (result.success && result.data) {
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
{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
|
||||
>
|
||||
{cardSets.map(set => (
|
||||
{/* 如果沒有選中任何卡組,顯示提示 */}
|
||||
{!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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
Loading…
Reference in New Issue