mirror of
https://github.com/handsomezhuzhu/QQuiz.git
synced 2026-04-18 14:32:54 +00:00
241 lines
6.7 KiB
JavaScript
241 lines
6.7 KiB
JavaScript
/**
|
|
* API Client for QQuiz Backend
|
|
*/
|
|
import axios from 'axios'
|
|
import toast from 'react-hot-toast'
|
|
|
|
export const API_BASE_URL = import.meta.env.VITE_API_URL || '/api'
|
|
export const AUTH_TOKEN_STORAGE_KEY = 'access_token'
|
|
|
|
const AUTH_USER_STORAGE_KEY = 'user'
|
|
const PUBLIC_REQUEST_PATHS = ['/auth/login', '/auth/register']
|
|
|
|
const getRequestPath = (config) => {
|
|
const url = config?.url || ''
|
|
|
|
if (!url) return ''
|
|
|
|
if (url.startsWith('http://') || url.startsWith('https://')) {
|
|
try {
|
|
return new URL(url).pathname
|
|
} catch (error) {
|
|
return url
|
|
}
|
|
}
|
|
|
|
return url.startsWith('/') ? url : `/${url}`
|
|
}
|
|
|
|
const isPublicRequest = (config) => {
|
|
if (config?.skipAuthHandling === true) {
|
|
return true
|
|
}
|
|
|
|
const path = getRequestPath(config)
|
|
return PUBLIC_REQUEST_PATHS.some((publicPath) => path.endsWith(publicPath))
|
|
}
|
|
|
|
export const buildApiUrl = (path) => {
|
|
const base = API_BASE_URL.endsWith('/') ? API_BASE_URL.slice(0, -1) : API_BASE_URL
|
|
const normalizedPath = path.startsWith('/') ? path : `/${path}`
|
|
return `${base}${normalizedPath}`
|
|
}
|
|
|
|
export const getAccessToken = () => localStorage.getItem(AUTH_TOKEN_STORAGE_KEY)
|
|
|
|
export const clearAuthStorage = () => {
|
|
localStorage.removeItem(AUTH_TOKEN_STORAGE_KEY)
|
|
localStorage.removeItem(AUTH_USER_STORAGE_KEY)
|
|
}
|
|
|
|
// Create axios instance
|
|
const api = axios.create({
|
|
baseURL: API_BASE_URL,
|
|
timeout: 30000,
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
}
|
|
})
|
|
|
|
// Request interceptor - Add auth token
|
|
api.interceptors.request.use(
|
|
(config) => {
|
|
const token = getAccessToken()
|
|
if (token && !isPublicRequest(config)) {
|
|
config.headers = config.headers || {}
|
|
config.headers.Authorization = `Bearer ${token}`
|
|
}
|
|
return config
|
|
},
|
|
(error) => {
|
|
return Promise.reject(error)
|
|
}
|
|
)
|
|
|
|
// Response interceptor - Handle errors
|
|
api.interceptors.response.use(
|
|
(response) => response,
|
|
(error) => {
|
|
const status = error.response?.status
|
|
const message = error.response?.data?.detail || 'An error occurred'
|
|
const requestConfig = error.config || {}
|
|
const hasAuthHeader = Boolean(
|
|
requestConfig.headers?.Authorization || requestConfig.headers?.authorization
|
|
)
|
|
|
|
if (status === 401 && !isPublicRequest(requestConfig) && hasAuthHeader) {
|
|
clearAuthStorage()
|
|
if (window.location.pathname !== '/login') {
|
|
window.location.href = '/login'
|
|
}
|
|
toast.error('Session expired. Please login again.')
|
|
} else if (status === 401) {
|
|
toast.error(message)
|
|
} else if (status === 403) {
|
|
toast.error('Permission denied')
|
|
} else if (status === 429) {
|
|
toast.error(message)
|
|
} else if (status >= 500) {
|
|
toast.error('Server error. Please try again later.')
|
|
} else {
|
|
toast.error(message)
|
|
}
|
|
|
|
return Promise.reject(error)
|
|
}
|
|
)
|
|
|
|
// ============ Auth APIs ============
|
|
export const authAPI = {
|
|
register: (username, password) =>
|
|
api.post('/auth/register', { username, password }, { skipAuthHandling: true }),
|
|
|
|
login: (username, password) =>
|
|
api.post('/auth/login', { username, password }, { skipAuthHandling: true }),
|
|
|
|
getCurrentUser: () =>
|
|
api.get('/auth/me'),
|
|
|
|
changePassword: (oldPassword, newPassword) =>
|
|
api.post('/auth/change-password', null, {
|
|
params: { old_password: oldPassword, new_password: newPassword }
|
|
})
|
|
}
|
|
|
|
// ============ Exam APIs ============
|
|
export const examAPI = {
|
|
// Create exam with first document
|
|
create: (title, file, isRandom = false) => {
|
|
const formData = new FormData()
|
|
formData.append('title', title)
|
|
formData.append('file', file)
|
|
formData.append('is_random', isRandom)
|
|
return api.post('/exams/create', formData, {
|
|
headers: { 'Content-Type': 'multipart/form-data' }
|
|
})
|
|
},
|
|
|
|
// Append document to existing exam
|
|
appendDocument: (examId, file) => {
|
|
const formData = new FormData()
|
|
formData.append('file', file)
|
|
return api.post(`/exams/${examId}/append`, formData, {
|
|
headers: { 'Content-Type': 'multipart/form-data' }
|
|
})
|
|
},
|
|
|
|
// Get user's exam list
|
|
getList: (skip = 0, limit = 20) =>
|
|
api.get('/exams/', { params: { skip, limit } }),
|
|
|
|
// Get exam detail
|
|
getDetail: (examId) =>
|
|
api.get(`/exams/${examId}`),
|
|
|
|
// Delete exam
|
|
delete: (examId) =>
|
|
api.delete(`/exams/${examId}`),
|
|
|
|
// Update quiz progress
|
|
updateProgress: (examId, currentIndex) =>
|
|
api.put(`/exams/${examId}/progress`, { current_index: currentIndex })
|
|
}
|
|
|
|
// ============ Question APIs ============
|
|
export const questionAPI = {
|
|
// Get all questions (Question Bank)
|
|
getAll: (skip = 0, limit = 50, examId = null) => {
|
|
const params = { skip, limit }
|
|
if (examId) params.exam_id = examId
|
|
return api.get('/questions/', { params })
|
|
},
|
|
|
|
// Get all questions for an exam
|
|
getExamQuestions: (examId, skip = 0, limit = 50) =>
|
|
api.get(`/questions/exam/${examId}/questions`, { params: { skip, limit } }),
|
|
|
|
// Get current question (based on exam's current_index)
|
|
getCurrentQuestion: (examId) =>
|
|
api.get(`/questions/exam/${examId}/current`),
|
|
|
|
// Get question by ID
|
|
getById: (questionId) =>
|
|
api.get(`/questions/${questionId}`),
|
|
|
|
// Check answer
|
|
checkAnswer: (questionId, userAnswer) =>
|
|
api.post('/questions/check', {
|
|
question_id: questionId,
|
|
user_answer: userAnswer
|
|
})
|
|
}
|
|
|
|
// ============ Mistake APIs ============
|
|
export const mistakeAPI = {
|
|
// Get user's mistake book
|
|
getList: (skip = 0, limit = 50, examId = null) => {
|
|
const params = { skip, limit }
|
|
if (examId) params.exam_id = examId
|
|
return api.get('/mistakes/', { params })
|
|
},
|
|
|
|
// Add to mistake book
|
|
add: (questionId) =>
|
|
api.post('/mistakes/add', { question_id: questionId }),
|
|
|
|
// Remove from mistake book by mistake ID
|
|
remove: (mistakeId) =>
|
|
api.delete(`/mistakes/${mistakeId}`),
|
|
|
|
// Remove from mistake book by question ID
|
|
removeByQuestionId: (questionId) =>
|
|
api.delete(`/mistakes/question/${questionId}`)
|
|
}
|
|
|
|
// ============ Admin APIs ============
|
|
export const adminAPI = {
|
|
// Config
|
|
getConfig: () => api.get('/admin/config'),
|
|
updateConfig: (config) => api.put('/admin/config', config),
|
|
|
|
// Users
|
|
getUsers: (skip = 0, limit = 50, search = null) =>
|
|
api.get('/admin/users', { params: { skip, limit, search } }),
|
|
createUser: (username, password, is_admin = false) =>
|
|
api.post('/admin/users', { username, password, is_admin }),
|
|
updateUser: (userId, data) =>
|
|
api.put(`/admin/users/${userId}`, data),
|
|
deleteUser: (userId) =>
|
|
api.delete(`/admin/users/${userId}`),
|
|
|
|
// Statistics
|
|
getStatistics: () => api.get('/admin/statistics'),
|
|
getHealth: () => api.get('/admin/health'),
|
|
|
|
// Export
|
|
exportUsers: () => api.get('/admin/export/users', { responseType: 'blob' }),
|
|
exportStatistics: () => api.get('/admin/export/statistics', { responseType: 'blob' })
|
|
}
|
|
|
|
export default api
|