407 lines
13 KiB
TypeScript
407 lines
13 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;
|
||
difficultyLevel: string;
|
||
createdAt: string;
|
||
updatedAt?: string;
|
||
|
||
// 新增圖片相關欄位
|
||
exampleImages: ExampleImage[];
|
||
hasExampleImage: boolean;
|
||
primaryImageUrl?: string;
|
||
}
|
||
|
||
export interface CreateFlashcardRequest {
|
||
word: string;
|
||
translation: string;
|
||
definition: string;
|
||
pronunciation: string;
|
||
partOfSpeech: string;
|
||
example: string;
|
||
exampleTranslation?: string;
|
||
difficultyLevel?: string; // A1, A2, B1, B2, C1, C2
|
||
}
|
||
|
||
export interface ApiResponse<T> {
|
||
success: boolean;
|
||
data?: T;
|
||
error?: string;
|
||
message?: string;
|
||
}
|
||
|
||
class FlashcardsService {
|
||
private readonly baseURL = `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5008'}/api`;
|
||
|
||
private async makeRequest<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||
const token = localStorage.getItem('auth_token');
|
||
|
||
const response = await fetch(`${this.baseURL}${endpoint}`, {
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': token ? `Bearer ${token}` : '',
|
||
...options.headers,
|
||
},
|
||
...options,
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const errorData = await response.json().catch(() => ({ error: 'Network error' }));
|
||
throw new Error(errorData.error || errorData.details || `HTTP ${response.status}`);
|
||
}
|
||
|
||
return response.json();
|
||
}
|
||
|
||
// 詞卡查詢方法 (支援進階篩選、排序和分頁)
|
||
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}` : ''}`;
|
||
|
||
return await this.makeRequest<ApiResponse<{ flashcards: Flashcard[], count: number }>>(endpoint);
|
||
} 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);
|
||
console.log('📊 response.data類型:', typeof response.data, '長度:', response.data?.length);
|
||
|
||
if (!response.data || !Array.isArray(response.data)) {
|
||
console.log('❌ response.data不是數組:', response.data);
|
||
return {
|
||
success: false,
|
||
error: 'Invalid response data format',
|
||
};
|
||
}
|
||
|
||
// 轉換後端格式為前端期望格式
|
||
const flashcards = response.data.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,
|
||
difficultyLevel: card.difficultyLevel || '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
|
||
}));
|
||
|
||
console.log('✅ 數據轉換完成:', flashcards.length, '張詞卡');
|
||
|
||
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',
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 獲取已完成的測驗記錄
|
||
*/
|
||
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(`/study/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'
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 記錄測驗完成狀態 (立即保存到StudyRecord表)
|
||
*/
|
||
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('/study/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(); |