fix: 修復前端認證 token 發送和用戶資料隔離問題

- 啟用前端 API client 的認證 header 發送(修復關鍵問題)
- 添加 401 錯誤自動清除過期 token 機制
- 設計兩種空狀態畫面:新用戶歡迎 vs 完成慶祝
- 改進錯誤處理:區分認證錯誤和一般錯誤
- 添加詳細除錯日誌追蹤 API 調用過程
- 修復前端條件判斷邏輯,確保正確顯示空狀態

現在用戶資料完全隔離,認證過期會自動處理並引導重新登入。

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
鄭沛軒 2025-10-07 06:21:21 +08:00
parent 1eb28e83c5
commit 4a7c3aec92
4 changed files with 348 additions and 29 deletions

View File

@ -5,11 +5,12 @@ using DramaLing.Api.Repositories;
using DramaLing.Api.Services.Review;
using Microsoft.AspNetCore.Authorization;
using DramaLing.Api.Utils;
using DramaLing.Api.Services;
namespace DramaLing.Api.Controllers;
[Route("api/flashcards")]
[AllowAnonymous]
[Authorize] // 恢復認證要求,確保用戶資料隔離
public class FlashcardsController : BaseController
{
private readonly IFlashcardRepository _flashcardRepository;
@ -18,7 +19,8 @@ public class FlashcardsController : BaseController
public FlashcardsController(
IFlashcardRepository flashcardRepository,
IReviewService reviewService,
ILogger<FlashcardsController> logger) : base(logger)
IAuthService authService,
ILogger<FlashcardsController> logger) : base(logger, authService)
{
_flashcardRepository = flashcardRepository;
_reviewService = reviewService;

View File

@ -23,9 +23,21 @@ export default function ReviewPage() {
handleRestart,
isLoading,
error,
flashcards
flashcards,
totalFlashcardsCount
} = useReviewSession()
// 除錯日誌 - 檢查狀態
console.log('🔍 Review Page 狀態檢查:', {
isLoading,
error,
flashcardsLength: flashcards.length,
totalFlashcardsCount,
currentQuizItem: currentQuizItem?.id,
currentCard: currentCard?.word,
isComplete
})
// 顯示載入狀態
if (isLoading) {
return (
@ -46,21 +58,221 @@ export default function ReviewPage() {
// 顯示錯誤狀態
if (error) {
const isAuthError = error.includes('登入已過期') || error.includes('認證')
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
<Navigation />
<div className="py-8">
<div className="max-w-4xl mx-auto px-4">
<div className="bg-white rounded-xl shadow-lg p-8 text-center">
<div className="text-red-500 text-4xl mb-4"></div>
<h2 className="text-xl font-semibold text-red-700 mb-2"></h2>
<div className={`text-4xl mb-4 ${isAuthError ? 'text-yellow-500' : 'text-red-500'}`}>
{isAuthError ? '🔒' : '⚠️'}
</div>
<h2 className={`text-xl font-semibold mb-2 ${isAuthError ? 'text-yellow-700' : 'text-red-700'}`}>
{isAuthError ? '需要重新登入' : '載入失敗'}
</h2>
<p className="text-gray-600 mb-6">{error}</p>
<div className="flex flex-col sm:flex-row gap-3 justify-center">
{isAuthError ? (
<button
onClick={() => window.location.href = '/login'}
className="px-8 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-semibold"
>
</button>
) : (
<button
onClick={handleRestart}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
</button>
)}
<button
onClick={() => window.location.href = '/'}
className="px-6 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors"
>
</button>
</div>
</div>
</div>
</div>
</div>
)
}
// 情境 1: 新用戶 - 一張詞卡都沒有
if (!isLoading && !error && totalFlashcardsCount === 0) {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
<Navigation />
<div className="py-8">
<div className="max-w-4xl mx-auto px-4">
<div className="bg-white rounded-xl shadow-lg p-8 text-center">
{/* 歡迎圖標 */}
<div className="mb-6">
<div className="w-24 h-24 bg-gradient-to-br from-blue-400 to-blue-600 rounded-full flex items-center justify-center mx-auto mb-4">
<span className="text-4xl text-white">👋</span>
</div>
</div>
{/* 歡迎標題 */}
<h1 className="text-3xl font-bold text-gray-900 mb-4">
DramaLing
</h1>
{/* 說明文字 */}
<p className="text-lg text-gray-600 mb-8">
</p>
{/* 功能介紹卡片 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
<div className="bg-gradient-to-br from-green-50 to-green-100 rounded-lg p-6">
<div className="text-2xl text-green-600 mb-2">🎯</div>
<h3 className="font-semibold text-gray-900"></h3>
<p className="text-sm text-gray-600 mt-2">
AI
</p>
</div>
<div className="bg-gradient-to-br from-yellow-50 to-yellow-100 rounded-lg p-6">
<div className="text-2xl text-yellow-600 mb-2">🧠</div>
<h3 className="font-semibold text-gray-900"></h3>
<p className="text-sm text-gray-600 mt-2">
</p>
</div>
</div>
{/* 主要行動按鈕 */}
<div className="mb-6">
<button
onClick={() => window.location.href = '/generate'}
className="px-10 py-4 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-lg hover:from-blue-700 hover:to-blue-800 transition-all font-semibold text-lg flex items-center justify-center gap-3 mx-auto"
>
<span className="text-xl">🚀</span>
</button>
</div>
{/* 次要功能 */}
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<button
onClick={() => window.location.href = '/flashcards'}
className="px-6 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors font-medium"
>
</button>
</div>
{/* 學習提示 */}
<div className="mt-8 p-4 bg-blue-50 rounded-lg border border-blue-200">
<p className="text-sm text-blue-800">
💡 <strong></strong>:
AI
</p>
</div>
</div>
</div>
</div>
</div>
)
}
// 情境 2: 有詞卡但都已訓練完成
if (!isLoading && !error && totalFlashcardsCount && totalFlashcardsCount > 0 && flashcards.length === 0) {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
<Navigation />
<div className="py-8">
<div className="max-w-4xl mx-auto px-4">
<div className="bg-white rounded-xl shadow-lg p-8 text-center">
{/* 慶祝圖標 */}
<div className="mb-6">
<div className="w-24 h-24 bg-gradient-to-br from-green-400 to-green-600 rounded-full flex items-center justify-center mx-auto mb-4">
<span className="text-4xl text-white">🎉</span>
</div>
</div>
{/* 主要標題 */}
<h1 className="text-3xl font-bold text-gray-900 mb-4">
</h1>
{/* 次標題 */}
<p className="text-lg text-gray-600 mb-4">
<span className="font-semibold text-green-600">{totalFlashcardsCount}</span>
</p>
<p className="text-md text-gray-500 mb-8">
</p>
{/* 統計卡片 */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div className="bg-gradient-to-br from-blue-50 to-blue-100 rounded-lg p-6">
<div className="text-2xl text-blue-600 mb-2">📚</div>
<h3 className="font-semibold text-gray-900"></h3>
<p className="text-sm text-gray-600 mt-2">
</p>
</div>
<div className="bg-gradient-to-br from-purple-50 to-purple-100 rounded-lg p-6">
<div className="text-2xl text-purple-600 mb-2">📈</div>
<h3 className="font-semibold text-gray-900"></h3>
<p className="text-sm text-gray-600 mt-2">
</p>
</div>
<div className="bg-gradient-to-br from-green-50 to-green-100 rounded-lg p-6">
<div className="text-2xl text-green-600 mb-2"></div>
<h3 className="font-semibold text-gray-900"></h3>
<p className="text-sm text-gray-600 mt-2">
</p>
</div>
</div>
{/* 行動按鈕 */}
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<button
onClick={() => window.location.href = '/generate'}
className="px-8 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-semibold flex items-center justify-center gap-2"
>
<span></span>
</button>
<button
onClick={() => window.location.href = '/stats'}
className="px-8 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors font-semibold flex items-center justify-center gap-2"
>
<span>📊</span>
</button>
<button
onClick={() => window.location.href = '/flashcards'}
className="px-8 py-3 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors font-semibold flex items-center justify-center gap-2"
>
<span>📋</span>
</button>
</div>
{/* 提示文字 */}
<div className="mt-8 p-4 bg-yellow-50 rounded-lg border border-yellow-200">
<p className="text-sm text-yellow-800">
💡 <strong></strong>:
</p>
</div>
</div>
</div>
</div>
@ -108,6 +320,8 @@ export default function ReviewPage() {
}
// 主要線性測驗頁面
// 只有在有可用測驗項目時才顯示測驗界面
if (!isLoading && !error && totalFlashcardsCount !== null && totalFlashcardsCount > 0 && flashcards.length > 0 && currentQuizItem && currentCard) {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
<Navigation />
@ -147,4 +361,32 @@ export default function ReviewPage() {
</div>
</div>
)
}
// Fallback 狀態 - 處理其他未預期情況
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
<Navigation />
<div className="py-8">
<div className="max-w-4xl mx-auto px-4">
<div className="bg-white rounded-xl shadow-lg p-8 text-center">
<div className="text-4xl mb-4">🤔</div>
<h2 className="text-xl font-semibold text-gray-700 mb-2"></h2>
<p className="text-gray-600 mb-4">
</p>
<p className="text-sm text-gray-500 mb-6">
</p>
<button
onClick={handleRestart}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
</button>
</div>
</div>
</div>
</div>
)
}

View File

@ -34,6 +34,7 @@ interface ReviewState {
error: string | null
pendingWordSubmission: string | null // 等待提交的詞彙ID
submittingWords: Set<string> // 正在提交的詞彙ID集合
totalFlashcardsCount: number | null // 用戶總詞卡數(用於區分空狀態)
}
type ReviewAction =
@ -42,7 +43,7 @@ type ReviewAction =
| { type: 'SKIP_TEST_ITEM'; payload: { quizItemId: string } }
| { type: 'RESTART' }
| { type: 'LOAD_FLASHCARDS_START' }
| { type: 'LOAD_FLASHCARDS_SUCCESS'; payload: { flashcards: Flashcard[]; quizItems: QuizItem[] } }
| { type: 'LOAD_FLASHCARDS_SUCCESS'; payload: { flashcards: Flashcard[]; quizItems: QuizItem[]; totalCount: number } }
| { type: 'LOAD_FLASHCARDS_ERROR'; payload: { error: string } }
| { type: 'WORD_SUBMIT_START'; payload: { cardId: string } }
| { type: 'WORD_SUBMIT_SUCCESS'; payload: { cardId: string; nextReviewDate: string } }
@ -165,7 +166,8 @@ const reviewReducer = (state: ReviewState, action: ReviewAction): ReviewState =>
isLoading: false,
error: null,
flashcards: action.payload.flashcards,
quizItems: action.payload.quizItems
quizItems: action.payload.quizItems,
totalFlashcardsCount: action.payload.totalCount
}
case 'LOAD_FLASHCARDS_ERROR':
@ -301,10 +303,11 @@ export function useReviewSession() {
isLoading: false,
error: null,
pendingWordSubmission: null,
submittingWords: new Set<string>()
submittingWords: new Set<string>(),
totalFlashcardsCount: null
})
const { quizItems, score, isComplete, flashcards, isLoading, error, pendingWordSubmission, submittingWords } = state
const { quizItems, score, isComplete, flashcards, isLoading, error, pendingWordSubmission, submittingWords, totalFlashcardsCount } = state
// 智能排序獲取當前測驗項目 - 使用 useMemo 優化性能
const sortedQuizItems = useMemo(() => sortQuizItemsByPriority(quizItems), [quizItems])
@ -321,29 +324,95 @@ export function useReviewSession() {
dispatch({ type: 'LOAD_FLASHCARDS_START' })
try {
const response = await flashcardsService.getDueFlashcards(10)
console.log('🚀 開始載入詞卡資料...')
if (response.success && response.data) {
const flashcards = response.data
const quizItems = generateQuizItemsFromFlashcards(flashcards)
// 同時獲取待複習詞卡和總詞卡數
const [dueResponse, totalResponse] = await Promise.all([
flashcardsService.getDueFlashcards(10),
flashcardsService.getFlashcards() // 獲取所有詞卡來計算總數
])
console.log('📥 API 回應:', {
dueResponse: {
success: dueResponse.success,
dataType: typeof dueResponse.data,
dataLength: Array.isArray(dueResponse.data) ? dueResponse.data.length : 'not array',
error: dueResponse.error
},
totalResponse: {
success: totalResponse.success,
dataType: typeof totalResponse.data,
dataLength: Array.isArray(totalResponse.data) ? totalResponse.data.length : 'not array',
error: totalResponse.error
}
})
if (dueResponse.success && totalResponse.success) {
// 正確的資料結構處理
const dueFlashcards = dueResponse.data || [] // getDueFlashcards 返回 Flashcard[]
const totalFlashcardsData = totalResponse.data || { flashcards: [], count: 0 } // getFlashcards 返回 { flashcards: [], count: number }
const totalFlashcards = totalFlashcardsData.flashcards || []
const totalCount = totalFlashcardsData.count || totalFlashcards.length
const quizItems = generateQuizItemsFromFlashcards(dueFlashcards)
console.log('📊 資料統計:', {
dueFlashcards: dueFlashcards.length,
totalFlashcards: totalFlashcards.length,
totalCount: totalCount,
quizItems: quizItems.length
})
dispatch({
type: 'LOAD_FLASHCARDS_SUCCESS',
payload: { flashcards, quizItems }
payload: {
flashcards: dueFlashcards,
quizItems,
totalCount: totalCount
}
})
console.log('✅ 成功載入', flashcards.length, '張詞卡')
console.log('✅ 成功載入', dueFlashcards.length, '張待複習詞卡 / 總共', totalCount, '張詞卡')
console.log('🎯 生成', quizItems.length, '個測驗項目')
} else {
console.error('❌ API 調用失敗:', {
dueError: dueResponse.error,
totalError: totalResponse.error,
dueSuccess: dueResponse.success,
totalSuccess: totalResponse.success
})
// 檢查是否為認證錯誤
const authError = dueResponse.error?.includes('登入已過期') ||
totalResponse.error?.includes('登入已過期') ||
dueResponse.error?.includes('認證') ||
totalResponse.error?.includes('認證')
dispatch({
type: 'LOAD_FLASHCARDS_ERROR',
payload: { error: response.error || '載入詞卡失敗' }
payload: {
error: authError ?
'登入已過期,請重新登入' :
(dueResponse.error || totalResponse.error || '載入詞卡失敗')
}
})
}
} catch (error) {
console.error('❌ API 調用異常:', error)
// 檢查是否為認證錯誤
const errorMessage = error instanceof Error ? error.message : '載入詞卡失敗'
const isAuthError = errorMessage.includes('登入已過期') ||
errorMessage.includes('認證') ||
errorMessage.includes('Unauthorized')
dispatch({
type: 'LOAD_FLASHCARDS_ERROR',
payload: { error: error instanceof Error ? error.message : '載入詞卡失敗' }
payload: {
error: isAuthError ?
'登入已過期,請重新登入' :
errorMessage
}
})
}
}
@ -467,6 +536,7 @@ export function useReviewSession() {
isLoading,
error,
flashcards,
totalFlashcardsCount,
// 計算屬性
totalQuizItems: quizItems.length,

View File

@ -66,6 +66,12 @@ export class ApiClient {
const apiError = await createApiErrorFromResponse(response, context)
lastError = apiError
// 處理認證錯誤 - 自動清除過期token
if (response.status === 401) {
localStorage.removeItem('auth_token')
console.log('🔒 認證過期已清除token')
}
// 如果不可重試,直接返回錯誤
if (attempt === retries || !this.isRetryableStatus(response.status)) {
return createErrorResponse(apiError)
@ -138,12 +144,11 @@ export class ApiClient {
private getDefaultHeaders(): Record<string, string> {
const headers: Record<string, string> = {}
// 開發階段不添加認證Token
// TODO: 生產環境需要添加認證邏輯
// const token = localStorage.getItem('auth_token')
// if (token) {
// headers.Authorization = `Bearer ${token}`
// }
// 添加認證Token如果存在
const token = localStorage.getItem('auth_token')
if (token) {
headers.Authorization = `Bearer ${token}`
}
return headers
}