487 lines
16 KiB
TypeScript
487 lines
16 KiB
TypeScript
// Flashcards API service
|
||
|
||
export interface ExampleImage {
|
||
id: string;
|
||
imageUrl: string;
|
||
isPrimary: boolean;
|
||
qualityScore?: number;
|
||
fileSize?: number;
|
||
createdAt: string;
|
||
}
|
||
|
||
export interface Flashcard {
|
||
id: string;
|
||
word: string;
|
||
translation: string;
|
||
definition: string;
|
||
partOfSpeech: string;
|
||
pronunciation: string;
|
||
example: string;
|
||
exampleTranslation?: string;
|
||
masteryLevel: number;
|
||
timesReviewed: number;
|
||
isFavorite: boolean;
|
||
nextReviewDate: string;
|
||
cefr: string;
|
||
createdAt: string;
|
||
updatedAt?: string;
|
||
|
||
// 新增圖片相關欄位
|
||
exampleImages: ExampleImage[];
|
||
hasExampleImage: boolean;
|
||
primaryImageUrl?: string;
|
||
|
||
// 同義詞欄位 (AI 生成)
|
||
synonyms?: string[];
|
||
|
||
// 測驗選項 (後端提供的混淆選項)
|
||
quizOptions?: string[];
|
||
}
|
||
|
||
export interface CreateFlashcardRequest {
|
||
word: string;
|
||
translation: string;
|
||
definition: string;
|
||
pronunciation: string;
|
||
partOfSpeech: string;
|
||
example: string;
|
||
exampleTranslation?: string;
|
||
synonyms?: string; // AI 生成的同義詞 (JSON 字串格式)
|
||
cefr?: string; // A1, A2, B1, B2, C1, C2
|
||
}
|
||
|
||
export interface ApiResponse<T> {
|
||
success: boolean;
|
||
data?: T;
|
||
error?: string;
|
||
message?: string;
|
||
}
|
||
|
||
import { flashcardsApiClient } from '@/lib/api/client'
|
||
import { getUserFriendlyErrorMessage } from '@/lib/api/errorHandler'
|
||
|
||
class FlashcardsService {
|
||
/**
|
||
* 統一的API請求處理 - 保持現有介面相容性
|
||
*/
|
||
private async makeRequest<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||
const response = await flashcardsApiClient.request<T>(endpoint, {
|
||
...options,
|
||
context: 'FlashcardsService'
|
||
})
|
||
|
||
if (!response.success) {
|
||
// 統一錯誤處理,但拋出Error以保持現有介面相容
|
||
const friendlyMessage = response.error ? getUserFriendlyErrorMessage(response.error) : '操作失敗'
|
||
throw new Error(friendlyMessage)
|
||
}
|
||
|
||
return response.data as T
|
||
}
|
||
|
||
// 詞卡查詢方法 (支援進階篩選、排序和分頁)
|
||
async getFlashcards(
|
||
search?: string,
|
||
favoritesOnly: boolean = false,
|
||
cefrLevel?: string,
|
||
partOfSpeech?: string,
|
||
masteryLevel?: string,
|
||
sortBy?: string,
|
||
sortOrder?: 'asc' | 'desc',
|
||
page?: number,
|
||
limit?: number
|
||
): Promise<ApiResponse<{ flashcards: Flashcard[], count: number }>> {
|
||
try {
|
||
const params = new URLSearchParams();
|
||
if (search) params.append('search', search);
|
||
if (favoritesOnly) params.append('favoritesOnly', 'true');
|
||
if (cefrLevel) params.append('cefrLevel', cefrLevel);
|
||
if (partOfSpeech) params.append('partOfSpeech', partOfSpeech);
|
||
if (masteryLevel) params.append('masteryLevel', masteryLevel);
|
||
|
||
// 排序和分頁參數
|
||
if (sortBy) params.append('sortBy', sortBy);
|
||
if (sortOrder) params.append('sortOrder', sortOrder);
|
||
if (page) params.append('page', page.toString());
|
||
if (limit) params.append('limit', limit.toString());
|
||
|
||
const queryString = params.toString();
|
||
const endpoint = `/flashcards${queryString ? `?${queryString}` : ''}`;
|
||
|
||
const response = await this.makeRequest<any>(endpoint);
|
||
|
||
// 轉換後端格式為前端標準 Flashcard 格式
|
||
if (response.success && response.data && response.data.flashcards) {
|
||
const flashcards = response.data.flashcards.map((card: any): Flashcard => ({
|
||
id: card.id,
|
||
word: card.word,
|
||
translation: card.translation,
|
||
definition: card.definition || '',
|
||
partOfSpeech: card.partOfSpeech || 'noun',
|
||
pronunciation: card.pronunciation || '',
|
||
example: card.example || '',
|
||
exampleTranslation: card.exampleTranslation,
|
||
masteryLevel: card.masteryLevel || 0,
|
||
timesReviewed: card.timesReviewed || 0,
|
||
isFavorite: card.isFavorite || false,
|
||
nextReviewDate: card.nextReviewDate || new Date().toISOString(),
|
||
cefr: card.cefr || 'A1', // 使用後端提供的 CEFR 字串
|
||
createdAt: card.createdAt,
|
||
updatedAt: card.updatedAt,
|
||
exampleImages: card.exampleImages || [],
|
||
hasExampleImage: card.hasExampleImage || false,
|
||
primaryImageUrl: card.primaryImageUrl
|
||
}));
|
||
|
||
return {
|
||
success: true,
|
||
data: {
|
||
flashcards,
|
||
count: response.data.count || flashcards.length
|
||
}
|
||
};
|
||
}
|
||
|
||
return response;
|
||
} catch (error) {
|
||
return {
|
||
success: false,
|
||
error: error instanceof Error ? error.message : 'Failed to fetch flashcards',
|
||
};
|
||
}
|
||
}
|
||
|
||
async createFlashcard(data: CreateFlashcardRequest): Promise<ApiResponse<Flashcard>> {
|
||
try {
|
||
return await this.makeRequest<ApiResponse<Flashcard>>('/flashcards', {
|
||
method: 'POST',
|
||
body: JSON.stringify(data),
|
||
});
|
||
} catch (error) {
|
||
return {
|
||
success: false,
|
||
error: error instanceof Error ? error.message : 'Failed to create flashcard',
|
||
};
|
||
}
|
||
}
|
||
|
||
async deleteFlashcard(id: string): Promise<ApiResponse<void>> {
|
||
try {
|
||
return await this.makeRequest<ApiResponse<void>>(`/flashcards/${id}`, {
|
||
method: 'DELETE',
|
||
});
|
||
} catch (error) {
|
||
return {
|
||
success: false,
|
||
error: error instanceof Error ? error.message : 'Failed to delete flashcard',
|
||
};
|
||
}
|
||
}
|
||
|
||
async getFlashcard(id: string): Promise<ApiResponse<Flashcard>> {
|
||
try {
|
||
return await this.makeRequest<ApiResponse<Flashcard>>(`/flashcards/${id}`);
|
||
} catch (error) {
|
||
return {
|
||
success: false,
|
||
error: error instanceof Error ? error.message : 'Failed to get flashcard',
|
||
};
|
||
}
|
||
}
|
||
|
||
async updateFlashcard(id: string, data: CreateFlashcardRequest): Promise<ApiResponse<Flashcard>> {
|
||
try {
|
||
return await this.makeRequest<ApiResponse<Flashcard>>(`/flashcards/${id}`, {
|
||
method: 'PUT',
|
||
body: JSON.stringify(data),
|
||
});
|
||
} catch (error) {
|
||
return {
|
||
success: false,
|
||
error: error instanceof Error ? error.message : 'Failed to update flashcard',
|
||
};
|
||
}
|
||
}
|
||
|
||
async toggleFavorite(id: string): Promise<ApiResponse<void>> {
|
||
try {
|
||
return await this.makeRequest<ApiResponse<void>>(`/flashcards/${id}/favorite`, {
|
||
method: 'POST',
|
||
});
|
||
} catch (error) {
|
||
return {
|
||
success: false,
|
||
error: error instanceof Error ? error.message : 'Failed to toggle favorite',
|
||
};
|
||
}
|
||
}
|
||
|
||
// =====================================================
|
||
// 智能複習系統相關方法
|
||
// =====================================================
|
||
|
||
async getDueFlashcards(limit = 50): Promise<ApiResponse<Flashcard[]>> {
|
||
try {
|
||
console.log('🚀 API調用開始:', `/flashcards/due?limit=${limit}`);
|
||
|
||
const response = await this.makeRequest<{ success: boolean; data: any; count: number }>(`/flashcards/due?limit=${limit}`);
|
||
console.log('🔍 makeRequest回應:', response);
|
||
|
||
// 處理新的後端資料結構:{ flashcards: [...], count: number, metadata: {...} }
|
||
const flashcardsArray = response?.data?.flashcards || response?.data || [];
|
||
console.log('📊 flashcards資料:', typeof flashcardsArray, '長度:', flashcardsArray?.length);
|
||
|
||
if (!Array.isArray(flashcardsArray)) {
|
||
console.log('❌ flashcards不是數組:', flashcardsArray);
|
||
return {
|
||
success: false,
|
||
error: 'Invalid response data format',
|
||
};
|
||
}
|
||
|
||
// 轉換後端格式為前端期望格式
|
||
const flashcards = flashcardsArray.map((card: any) => ({
|
||
id: card.id,
|
||
word: card.word,
|
||
translation: card.translation,
|
||
definition: card.definition,
|
||
partOfSpeech: card.partOfSpeech,
|
||
pronunciation: card.pronunciation,
|
||
example: card.example,
|
||
exampleTranslation: card.exampleTranslation,
|
||
masteryLevel: card.masteryLevel || card.currentMasteryLevel || 0,
|
||
timesReviewed: card.timesReviewed || 0,
|
||
isFavorite: card.isFavorite || false,
|
||
nextReviewDate: card.nextReviewDate || new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), // 預設明天
|
||
cefr: card.cefr || 'A2',
|
||
createdAt: card.createdAt,
|
||
updatedAt: card.updatedAt,
|
||
// 智能複習擴展欄位 (數值欄位已移除,改用即時CEFR轉換)
|
||
baseMasteryLevel: card.baseMasteryLevel || card.masteryLevel || 0,
|
||
lastReviewDate: card.lastReviewDate || card.lastReviewedAt,
|
||
currentInterval: card.currentInterval || card.intervalDays || 1,
|
||
isOverdue: card.isOverdue || false,
|
||
overdueDays: card.overdueDays || 0,
|
||
// 圖片相關欄位
|
||
exampleImages: card.exampleImages || [],
|
||
hasExampleImage: card.hasExampleImage || false,
|
||
primaryImageUrl: card.primaryImageUrl,
|
||
// 同義詞欄位 (新增)
|
||
synonyms: card.synonyms || [],
|
||
// 測驗選項(新增:來自後端的 AI 生成混淆選項)
|
||
quizOptions: card.quizOptions || []
|
||
}));
|
||
|
||
console.log('✅ 數據轉換完成:', flashcards.length, '張詞卡');
|
||
console.log('🎯 首張詞卡的quizOptions:', flashcards[0]?.quizOptions);
|
||
|
||
return {
|
||
success: true,
|
||
data: flashcards
|
||
};
|
||
} catch (error) {
|
||
console.error('💥 API request failed:', error);
|
||
return {
|
||
success: false,
|
||
error: error instanceof Error ? error.message : 'Failed to get due flashcards',
|
||
};
|
||
}
|
||
}
|
||
|
||
async getNextReviewCard(): Promise<ApiResponse<Flashcard & { userLevel: number; wordLevel: number }>> {
|
||
try {
|
||
return await this.makeRequest<ApiResponse<Flashcard & { userLevel: number; wordLevel: number }>>('/flashcards/next-review');
|
||
} catch (error) {
|
||
return {
|
||
success: false,
|
||
error: error instanceof Error ? error.message : 'Failed to get next review card',
|
||
};
|
||
}
|
||
}
|
||
|
||
async getOptimalReviewMode(cardId: string, userCEFRLevel: string, wordCEFRLevel: string): Promise<ApiResponse<{ selectedMode: string }>> {
|
||
try {
|
||
const response = await this.makeRequest<{ success: boolean; data: any }>(`/flashcards/${cardId}/optimal-review-mode`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
userCEFRLevel,
|
||
wordCEFRLevel,
|
||
includeHistory: true
|
||
}),
|
||
});
|
||
|
||
return {
|
||
success: response.success,
|
||
data: {
|
||
selectedMode: response.data.selectedMode
|
||
}
|
||
};
|
||
} catch (error) {
|
||
console.error('Optimal review mode API failed, using fallback:', error);
|
||
return {
|
||
success: false,
|
||
error: error instanceof Error ? error.message : 'Failed to get optimal review mode',
|
||
};
|
||
}
|
||
}
|
||
|
||
async submitReview(id: string, reviewData: {
|
||
isCorrect: boolean;
|
||
confidenceLevel?: number;
|
||
questionType: string;
|
||
userAnswer?: string;
|
||
timeTaken?: number;
|
||
}): Promise<ApiResponse<{ newInterval: number; nextReviewDate: string; masteryLevel: number }>> {
|
||
try {
|
||
const response = await this.makeRequest<{ success: boolean; data: any }>(`/flashcards/${id}/review`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
...reviewData,
|
||
timestamp: Date.now()
|
||
}),
|
||
});
|
||
|
||
return {
|
||
success: response.success,
|
||
data: {
|
||
newInterval: response.data.newInterval || response.data.newIntervalDays || 1,
|
||
nextReviewDate: response.data.nextReviewDate,
|
||
masteryLevel: response.data.masteryLevel || response.data.newMasteryLevel || 0
|
||
}
|
||
};
|
||
} catch (error) {
|
||
console.error('Submit review API failed, using fallback:', error);
|
||
return {
|
||
success: false,
|
||
error: error instanceof Error ? error.message : 'Failed to submit review',
|
||
};
|
||
}
|
||
}
|
||
|
||
async generateQuestionOptions(cardId: string, questionType: string): Promise<ApiResponse<{
|
||
options?: string[];
|
||
correctAnswer: string;
|
||
audioUrl?: string;
|
||
sentence?: string;
|
||
blankedSentence?: string;
|
||
scrambledWords?: string[];
|
||
}>> {
|
||
try {
|
||
return await this.makeRequest<ApiResponse<any>>(`/flashcards/${cardId}/question`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({ questionType }),
|
||
});
|
||
} catch (error) {
|
||
return {
|
||
success: false,
|
||
error: error instanceof Error ? error.message : 'Failed to generate question options',
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 標記詞彙為已掌握(簡化版API)
|
||
*/
|
||
async markWordMastered(id: string): Promise<ApiResponse<{ nextReviewDate: string; intervalDays: number; successCount: number }>> {
|
||
try {
|
||
console.log('🎯 標記詞彙為已掌握:', id)
|
||
|
||
const response = await this.makeRequest<{ success: boolean; data: any }>(`/flashcards/${id}/mastered`, {
|
||
method: 'POST',
|
||
});
|
||
|
||
if (response.success && response.data) {
|
||
return {
|
||
success: true,
|
||
data: {
|
||
nextReviewDate: response.data.nextReviewDate,
|
||
intervalDays: response.data.intervalDays,
|
||
successCount: response.data.successCount
|
||
}
|
||
};
|
||
} else {
|
||
throw new Error('API 回應格式錯誤');
|
||
}
|
||
} catch (error) {
|
||
console.error('❌ 標記詞彙掌握失敗:', error);
|
||
return {
|
||
success: false,
|
||
error: error instanceof Error ? error.message : 'Failed to mark word as mastered',
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 獲取已完成的測驗記錄
|
||
*/
|
||
async getCompletedTests(cardIds?: string[]): Promise<{
|
||
success: boolean;
|
||
data: Array<{
|
||
flashcardId: string;
|
||
testType: string;
|
||
isCorrect: boolean;
|
||
completedAt: string;
|
||
userAnswer?: string;
|
||
}> | null;
|
||
error?: string;
|
||
}> {
|
||
try {
|
||
const params = cardIds && cardIds.length > 0 ? `?cardIds=${cardIds.join(',')}` : '';
|
||
const result = await this.makeRequest(`/review/completed-tests${params}`);
|
||
|
||
return {
|
||
success: true,
|
||
data: (result as any).data || [],
|
||
error: undefined
|
||
};
|
||
} catch (error) {
|
||
console.warn('Failed to get completed tests:', error);
|
||
return {
|
||
success: false,
|
||
data: [],
|
||
error: error instanceof Error ? error.message : 'Failed to get completed tests'
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 記錄測驗完成狀態 (立即保存到ReviewRecord表)
|
||
*/
|
||
async recordTestCompletion(request: {
|
||
flashcardId: string;
|
||
testType: string;
|
||
isCorrect: boolean;
|
||
userAnswer?: string;
|
||
confidenceLevel?: number;
|
||
responseTimeMs?: number;
|
||
}): Promise<{ success: boolean; data: any | null; error?: string }> {
|
||
try {
|
||
const result = await this.makeRequest('/review/record-test', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
flashcardId: request.flashcardId,
|
||
testType: request.testType,
|
||
isCorrect: request.isCorrect,
|
||
userAnswer: request.userAnswer,
|
||
confidenceLevel: request.confidenceLevel,
|
||
responseTimeMs: request.responseTimeMs || 2000
|
||
})
|
||
});
|
||
|
||
return {
|
||
success: true,
|
||
data: (result as any).data || result,
|
||
error: undefined
|
||
};
|
||
} catch (error) {
|
||
console.warn('Failed to record test completion:', error);
|
||
return {
|
||
success: false,
|
||
data: null,
|
||
error: error instanceof Error ? error.message : 'Failed to record test completion'
|
||
};
|
||
}
|
||
}
|
||
}
|
||
|
||
export const flashcardsService = new FlashcardsService(); |