完善文档与前端迁移,补充开源协议

This commit is contained in:
2026-04-17 19:48:13 +08:00
parent 466fa50aa8
commit 31916e68a6
94 changed files with 7019 additions and 480 deletions

View File

@@ -6,14 +6,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="QQuiz - 智能刷题与题库管理平台" />
<title>QQuiz - 智能刷题平台</title>
<!-- ESA 人机认证配置 -->
<script>
window.AliyunCaptchaConfig = {
region: "cn",
prefix: "%VITE_ESA_PREFIX%",
};
</script>
<script src="https://o.alicdn.com/captcha-frontend/aliyunCaptcha/AliyunCaptcha.js" async></script>
</head>
<body>
<div id="root"></div>

View File

@@ -4,9 +4,53 @@
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: import.meta.env.VITE_API_URL || '/api',
baseURL: API_BASE_URL,
timeout: 30000,
headers: {
'Content-Type': 'application/json'
@@ -16,8 +60,9 @@ const api = axios.create({
// Request interceptor - Add auth token
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('access_token')
if (token) {
const token = getAccessToken()
if (token && !isPublicRequest(config)) {
config.headers = config.headers || {}
config.headers.Authorization = `Bearer ${token}`
}
return config
@@ -31,19 +76,26 @@ api.interceptors.request.use(
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 (error.response?.status === 401) {
// Unauthorized - Clear token and redirect to login
localStorage.removeItem('access_token')
localStorage.removeItem('user')
window.location.href = '/login'
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 (error.response?.status === 403) {
toast.error('Permission denied')
} else if (error.response?.status === 429) {
} else if (status === 401) {
toast.error(message)
} else if (error.response?.status >= 500) {
} 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)
@@ -56,10 +108,10 @@ api.interceptors.response.use(
// ============ Auth APIs ============
export const authAPI = {
register: (username, password) =>
api.post('/auth/register', { username, password }),
api.post('/auth/register', { username, password }, { skipAuthHandling: true }),
login: (username, password) =>
api.post('/auth/login', { username, password }),
api.post('/auth/login', { username, password }, { skipAuthHandling: true }),
getCurrentUser: () =>
api.get('/auth/me'),

View File

@@ -2,7 +2,7 @@
* Authentication Context
*/
import React, { createContext, useContext, useState, useEffect } from 'react'
import { authAPI } from '../api/client'
import { authAPI, AUTH_TOKEN_STORAGE_KEY, clearAuthStorage } from '../api/client'
import toast from 'react-hot-toast'
const AuthContext = createContext(null)
@@ -22,15 +22,14 @@ export const AuthProvider = ({ children }) => {
// Load user from localStorage on mount
useEffect(() => {
const loadUser = async () => {
const token = localStorage.getItem('access_token')
const token = localStorage.getItem(AUTH_TOKEN_STORAGE_KEY)
if (token) {
try {
const response = await authAPI.getCurrentUser()
setUser(response.data)
} catch (error) {
console.error('Failed to load user:', error)
localStorage.removeItem('access_token')
localStorage.removeItem('user')
clearAuthStorage()
}
}
setLoading(false)
@@ -45,7 +44,7 @@ export const AuthProvider = ({ children }) => {
const { access_token } = response.data
// Save token
localStorage.setItem('access_token', access_token)
localStorage.setItem(AUTH_TOKEN_STORAGE_KEY, access_token)
// Get user info
const userResponse = await authAPI.getCurrentUser()
@@ -71,8 +70,7 @@ export const AuthProvider = ({ children }) => {
}
const logout = () => {
localStorage.removeItem('access_token')
localStorage.removeItem('user')
clearAuthStorage()
setUser(null)
toast.success('Logged out successfully')
}

View File

@@ -3,7 +3,7 @@
*/
import React, { useState, useEffect, useRef } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { examAPI, questionAPI } from '../api/client'
import { examAPI, buildApiUrl, getAccessToken } from '../api/client'
import ParsingProgress from '../components/ParsingProgress'
import {
ArrowLeft, Upload, Play, Loader, FileText, AlertCircle, RefreshCw, ArrowRight
@@ -51,6 +51,8 @@ export const ExamDetail = () => {
// Connect to SSE if exam is processing
if (examRes.data.status === 'processing') {
connectSSE()
} else {
setProgress(null)
}
} catch (error) {
console.error('Failed to load exam:', error)
@@ -68,8 +70,14 @@ export const ExamDetail = () => {
console.log('[SSE] Connecting to progress stream for exam', examId)
const token = localStorage.getItem('token')
const url = `/api/exams/${examId}/progress?token=${encodeURIComponent(token)}`
const token = getAccessToken()
if (!token) {
console.error('[SSE] Missing access token')
return
}
const url = `${buildApiUrl(`/exams/${examId}/progress`)}?token=${encodeURIComponent(token)}`
const eventSource = new EventSource(url)
eventSourceRef.current = eventSource
@@ -173,6 +181,9 @@ export const ExamDetail = () => {
const isReady = exam.status === 'ready'
const isFailed = exam.status === 'failed'
const quizProgress = calculateProgress(exam.current_index, exam.total_questions)
const completionProgress = isProcessing
? Math.round(Number(progress?.progress ?? 0))
: quizProgress
return (
<>
@@ -252,17 +263,17 @@ export const ExamDetail = () => {
</div>
<div className="bg-gray-50 rounded-lg p-4">
<p className="text-sm text-gray-600 mb-1">完成度</p>
<p className="text-2xl font-bold text-green-600">{isProcessing ? progress : quizProgress}%</p>
<p className="text-2xl font-bold text-green-600">{completionProgress}%</p>
</div>
</div>
{/* Progress Bar */}
{exam.total_questions > 0 && (
{(isProcessing || exam.total_questions > 0) && (
<div className="mt-6">
<div className="w-full bg-gray-200 rounded-full h-3">
<div
className="bg-primary-600 h-3 rounded-full transition-all"
style={{ width: `${quizProgress}%` }}
style={{ width: `${completionProgress}%` }}
></div>
</div>
</div>

View File

@@ -1,7 +1,7 @@
/**
* Login Page
*/
import React, { useState, useEffect } from 'react'
import React, { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import { BookOpen } from 'lucide-react'
@@ -15,110 +15,21 @@ export const Login = () => {
password: ''
})
const [loading, setLoading] = useState(false)
const [captchaInstance, setCaptchaInstance] = useState(null)
useEffect(() => {
// 确保 window.initAliyunCaptcha 存在且 DOM 元素已渲染
const initCaptcha = () => {
if (window.initAliyunCaptcha && document.getElementById('captcha-element')) {
try {
window.initAliyunCaptcha({
SceneId: import.meta.env.VITE_ESA_SCENE_ID, // 从环境变量读取场景ID
mode: "popup", // 弹出式
element: "#captcha-element", // 渲染验证码的元素
button: "#login-btn", // 触发验证码的按钮ID
success: async function (captchaVerifyParam) {
// 验证成功后的回调
// 这里我们获取到了验证参数,虽然文档说要发给后端,
// 但 ESA 边缘拦截其实是在请求发出时检查 Cookie/Header
// 对于“一点即过”或“滑块”SDK 会自动处理验证逻辑
// 这里的 verifiedParam 是用来回传给服务端做二次校验的
// 由于我们此时还没有登录逻辑,我们可以在这里直接提交表单
// 即把 verifyParam 存下来,或者直接调用 login
// 注意:由于是 form 的 onSubmit 触发,这里我们其实是在 form 提交被阻止(preventDefault)后
// 由用户点击按钮触发了验证码,验证码成功后再执行真正的登录
// 但 React 的 form 处理通常是 onSubmit
// 我们可以让按钮类型为 button 而不是 submit点击触发验证码
// 验证码成功后手动调用 handleSubmit 的逻辑
console.log('Captcha Success:', captchaVerifyParam);
handleLoginSubmit(captchaVerifyParam);
},
fail: function (result) {
console.error('Captcha Failed:', result);
},
getInstance: function (instance) {
setCaptchaInstance(instance);
},
slideStyle: {
width: 360,
height: 40,
}
});
} catch (error) {
console.error("Captcha init error:", error);
}
}
};
// 如果脚本还没加载完,可能需要等待。为了简单起见,且我们在 index.html 加了 async
// 我们做一个简单的轮询或者依赖 script onload但在 index.html 比较难控制)
// 或者直接延迟一下初始化
const timer = setTimeout(initCaptcha, 500);
return () => clearTimeout(timer);
}, []);
const handleSubmit = async (e) => {
e.preventDefault()
const handleLoginSubmit = async (captchaParam) => {
setLoading(true)
try {
// 这里的 login 可能需要改造以接受验证码参数,或者利用 fetch 的拦截器
// 如果是 ESA 边缘拦截,通常它会看请求里带不带特定的 Header/Cookie
// 文档示例里是手动 fetch 并且带上了 header: 'captcha-Verify-param'
// 暂时我们假设 login 函数内部不需要显式传参(通过 ESA 自动拦截),或者 ESA 需要 headers
// 为了安全,建议把 captchaParam 传给 login让 login 放到 headers 里
// 但现在我们先维持原样,或者您可以把 captchaParam 放到 sessionStorage 里由 axios 拦截器读取
// 注意:上面的 success 回调里我们直接调用了这个,说明验证通过了
const success = await login(formData.username, formData.password)
if (success) {
navigate('/dashboard')
}
} finally {
setLoading(false)
if(captchaInstance) captchaInstance.refresh(); // 失败或完成后刷新验证码
}
}
// 这里的 handleSubmit 变成只是触发验证码(如果也没通过验证的话)
// 但 ESA 示例是绑定 button点击 button 直接出验证码
// 所以我们可以把 type="submit" 变成 type="button" 且 id="login-btn"
const handlePreSubmit = (e) => {
e.preventDefault();
// 此时不需要做任何事,因为按钮被 ESA 接管了,点击会自动弹窗
// 只有验证成功了才会走 success -> handleLoginSubmit
// 但是!如果没填用户名密码怎么办?
// 最好在点击前校验表单。
// ESA 的 button 参数会劫持点击事件。
// 我们可以不绑定 button 参数,而是手动验证表单后,调用 captchaInstance.show() (如果是无痕或弹窗)
// 官方文档说绑定 button 是“触发验证码弹窗或无痕验证的元素”
// 如果我们保留 form submit拦截它如果表单有效则手动 captchaInstance.show() (如果 SDK 支持)
// 文档说“无痕模式首次验证不支持 show/hide”。
// 咱们还是按官方推荐绑定 button但是这会导致校验逻辑变复杂
// 简化方案:为了不破坏现有逻辑,我们不绑定 button ?
// 不,必须绑定。那我们把“登录”按钮作为触发器。
// 可是如果不填表单直接点登录 -> 验证码 -> 成功 -> 提交空表单 -> 报错。流程不太对。
// 更好的流程:
// 用户填表 -> 点击登录 -> 校验表单 -> (有效) -> 弹出验证码 -> (成功) -> 提交后端
// 我们可以做一个不可见的 button 绑定给 ESA验证表单通过后用代码模拟点击这个 button
// 或者直接用 id="login-btn" 绑定当前的登录按钮,
// 但是在 success 回调里检查 formData 是否为空?
}
const handleChange = (e) => {
setFormData({
...formData,
@@ -144,8 +55,7 @@ export const Login = () => {
<div className="bg-white rounded-2xl shadow-xl p-8">
<h2 className="text-2xl font-bold text-gray-900 mb-6">登录</h2>
{/* 为了能正确使用 ESA我们将 form 的 onSubmit 移除,改由按钮触发,或者保留 form 但不做提交 */}
<form className="space-y-6" onSubmit={(e) => e.preventDefault()}>
<form className="space-y-6" onSubmit={handleSubmit}>
{/* Username */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
@@ -179,14 +89,8 @@ export const Login = () => {
/>
</div>
{/* ESA Captcha Container */}
<div id="captcha-element"></div>
{/* Submit Button */}
{/* 绑定 id="login-btn" 供 ESA 使用 */}
<button
type="button"
id="login-btn"
type="submit"
disabled={loading}
className="w-full bg-primary-600 text-white py-3 rounded-lg font-medium hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>

View File

@@ -2,13 +2,15 @@
* Question Bank Page - View all questions
*/
import React, { useState, useEffect } from 'react'
import { useSearchParams } from 'react-router-dom'
import { questionAPI } from '../api/client'
import Pagination from '../components/Pagination'
import { FileText, Loader, Search } from 'lucide-react'
import { FileText, Loader } from 'lucide-react'
import toast from 'react-hot-toast'
import { getQuestionTypeText, formatRelativeTime } from '../utils/helpers'
export const QuestionBank = () => {
const [searchParams] = useSearchParams()
const [questions, setQuestions] = useState([])
const [loading, setLoading] = useState(true)
const [expandedId, setExpandedId] = useState(null)
@@ -17,16 +19,23 @@ export const QuestionBank = () => {
const [page, setPage] = useState(1)
const [limit, setLimit] = useState(10)
const [total, setTotal] = useState(0)
const examIdParam = searchParams.get('examId')
const examIdFilter = /^\d+$/.test(examIdParam || '') ? Number(examIdParam) : null
useEffect(() => {
setPage(1)
setExpandedId(null)
}, [examIdFilter])
useEffect(() => {
loadQuestions()
}, [page, limit])
}, [page, limit, examIdFilter])
const loadQuestions = async () => {
try {
setLoading(true)
const skip = (page - 1) * limit
const response = await questionAPI.getAll(skip, limit)
const response = await questionAPI.getAll(skip, limit, examIdFilter)
setQuestions(response.data.questions)
setTotal(response.data.total)
} catch (error) {
@@ -41,6 +50,11 @@ export const QuestionBank = () => {
setExpandedId(expandedId === id ? null : id)
}
const title = examIdFilter ? `题库 ${examIdFilter} 题目` : '全站题库'
const subtitle = examIdFilter
? `当前仅显示该题库下的 ${total} 道题目`
: `${total} 道题目`
if (loading && questions.length === 0) {
return (
<div className="flex items-center justify-center h-screen">
@@ -54,8 +68,8 @@ export const QuestionBank = () => {
<div className="p-4 md:p-8">
{/* Header */}
<div className="mb-6">
<h1 className="text-2xl md:text-3xl font-bold text-gray-900">全站题库</h1>
<p className="text-gray-600 mt-1"> {total} 道题目</p>
<h1 className="text-2xl md:text-3xl font-bold text-gray-900">{title}</h1>
<p className="text-gray-600 mt-1">{subtitle}</p>
</div>
{/* List */}

View File

@@ -9,15 +9,7 @@ export default defineConfig(({ mode }) => {
return {
envDir, // Tell Vite to look for .env files in the project root
plugins: [
react(),
{
name: 'html-transform',
transformIndexHtml(html) {
return html.replace(/%VITE_ESA_PREFIX%/g, env.VITE_ESA_PREFIX || '')
},
}
],
plugins: [react()],
server: {
host: '0.0.0.0',
port: 3000,