diff --git a/web/src/components/QuestionDrawer.module.less b/web/src/components/QuestionDrawer.module.less new file mode 100644 index 0000000..b7b8f02 --- /dev/null +++ b/web/src/components/QuestionDrawer.module.less @@ -0,0 +1,136 @@ +.drawer { + :global { + .ant-drawer-body { + padding: 0; + background: #fafafa; + } + + .ant-drawer-header { + border-bottom: 1px solid #e8e8e8; + background: #fff; + padding: 16px 20px; + } + + .ant-drawer-title { + font-weight: 600; + font-size: 16px; + } + } +} + +.listItem { + padding: 0; + margin: 8px 12px; + cursor: pointer; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + border-radius: 12px; + background: #fff; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); + overflow: visible; + border: 1px solid transparent; + position: relative; + + &:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); + transform: translateY(-2px); + border-color: rgba(22, 119, 255, 0.2); + } + + &.current { + background: linear-gradient(135deg, #e6f7ff 0%, #f0f9ff 100%); + border-color: #1677ff; + box-shadow: 0 4px 16px rgba(22, 119, 255, 0.15); + + &:hover { + box-shadow: 0 6px 20px rgba(22, 119, 255, 0.2); + transform: translateY(-2px); + } + } +} + +.questionItem { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 10px 12px; +} + +.questionNumber { + flex-shrink: 0; + width: 26px; + height: 26px; + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; + font-weight: 600; + color: #595959; + background: rgba(0, 0, 0, 0.04); + border-radius: 6px; +} + +.current .questionNumber { + color: #1677ff; + background: rgba(22, 119, 255, 0.1); + font-weight: 700; +} + +.questionContent { + flex: 1; + min-width: 0; +} + +.questionStatus { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + + :global { + .anticon { + font-size: 20px; + } + } +} + +// 移动端适配 +@media (max-width: 768px) { + .drawer { + :global { + .ant-drawer { + width: 90vw !important; + max-width: 450px; + } + + .ant-drawer-header { + padding: 14px 16px; + } + } + } + + .listItem { + margin: 6px 10px; + border-radius: 10px; + } + + .questionItem { + padding: 8px 10px; + gap: 6px; + } + + .questionNumber { + width: 24px; + height: 24px; + font-size: 12px; + border-radius: 5px; + } + + .questionStatus { + :global { + .anticon { + font-size: 18px; + } + } + } +} diff --git a/web/src/components/QuestionDrawer.tsx b/web/src/components/QuestionDrawer.tsx new file mode 100644 index 0000000..f2a605a --- /dev/null +++ b/web/src/components/QuestionDrawer.tsx @@ -0,0 +1,108 @@ +import React from 'react' +import { Drawer, List, Tag, Typography, Space } from 'antd' +import { + CheckCircleOutlined, + CloseCircleOutlined, + MinusCircleOutlined, + BookOutlined, +} from '@ant-design/icons' +import type { Question } from '../types/question' +import styles from './QuestionDrawer.module.less' + +const { Text } = Typography + +interface QuestionDrawerProps { + visible: boolean + onClose: () => void + questions: Question[] + currentIndex: number + onQuestionSelect: (index: number) => void + answeredStatus: Map // null: 未答, true: 正确, false: 错误 +} + +const QuestionDrawer: React.FC = ({ + visible, + onClose, + questions, + currentIndex, + onQuestionSelect, + answeredStatus, +}) => { + // 获取题目状态 + const getQuestionStatus = (index: number) => { + const status = answeredStatus.get(index) + if (status === null || status === undefined) { + return { icon: , color: '#d9d9d9', text: '未答' } + } + if (status) { + return { icon: , color: '#52c41a', text: '正确' } + } + return { icon: , color: '#ff4d4f', text: '错误' } + } + + return ( + + + 题目导航 + + 共 {questions.length} 题 + + + } + placement="right" + onClose={onClose} + open={visible} + width={450} + className={styles.drawer} + > + { + const status = getQuestionStatus(index) + const isCurrent = index === currentIndex + + return ( + { + onQuestionSelect(index) + onClose() + }} + > +
+ {/* 题号 */} +
+ {index + 1} +
+ + {/* 分类标签 */} + + {question.category} + + + {/* 题目内容 */} +
+ + {question.content} + +
+ + {/* 右侧状态 */} +
+
+ {status.icon} +
+
+
+
+ ) + }} + /> +
+ ) +} + +export default QuestionDrawer diff --git a/web/src/components/QuestionFloatButton.module.less b/web/src/components/QuestionFloatButton.module.less new file mode 100644 index 0000000..c4ee41f --- /dev/null +++ b/web/src/components/QuestionFloatButton.module.less @@ -0,0 +1,184 @@ +.floatButtonWrapper { + position: fixed; + right: 20px; + bottom: 90px; + z-index: 999; + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; +} + +.statsCard { + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border-radius: 12px; + padding: 8px 16px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08), + 0 2px 6px rgba(0, 0, 0, 0.04); + display: flex; + align-items: center; + gap: 12px; + opacity: 0; + transform: translateY(10px); + animation: slideIn 0.3s ease forwards 0.2s; + border: 1px solid rgba(0, 0, 0, 0.06); +} + +@keyframes slideIn { + to { + opacity: 1; + transform: translateY(0); + } +} + +.statsRow { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; +} + +.statsLabel { + font-size: 11px; + color: #8c8c8c; + font-weight: 500; + line-height: 1; +} + +.statsValue { + font-size: 16px; + font-weight: 700; + color: #1d1d1f; + line-height: 1; +} + +.statsDivider { + width: 1px; + height: 20px; + background: rgba(0, 0, 0, 0.08); +} + +.floatButton { + width: 64px; + height: 64px; + border-radius: 50%; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + box-shadow: 0 4px 16px rgba(102, 126, 234, 0.4), + 0 8px 32px rgba(102, 126, 234, 0.25); + cursor: pointer; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + display: flex; + align-items: center; + justify-content: center; + user-select: none; + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + position: relative; + + &:hover { + transform: scale(1.1) translateY(-4px); + box-shadow: 0 6px 20px rgba(102, 126, 234, 0.5), + 0 12px 40px rgba(102, 126, 234, 0.35); + } + + &:active { + transform: scale(1.05) translateY(-2px); + transition: all 0.1s ease; + } +} + +.progressRing { + position: absolute; + top: 0; + left: 0; + width: 64px; + height: 64px; + transform: rotate(-90deg); + pointer-events: none; +} + +.progressRingCircle { + opacity: 0.15; + stroke: #fff; +} + +.progressRingCircleProgress { + transition: stroke-dashoffset 0.6s cubic-bezier(0.4, 0, 0.2, 1); + stroke-linecap: round; + stroke: #fff; + filter: drop-shadow(0 0 4px rgba(255, 255, 255, 0.5)); +} + +.floatButtonContent { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: #fff; + z-index: 1; + position: relative; +} + +.icon { + font-size: 24px; + filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2)); +} + +// 移动端适配 +@media (max-width: 768px) { + .floatButtonWrapper { + right: 16px; + bottom: 80px; + } + + .statsCard { + padding: 6px 12px; + gap: 10px; + border-radius: 10px; + } + + .statsLabel { + font-size: 10px; + } + + .statsValue { + font-size: 14px; + } + + .statsDivider { + height: 18px; + } + + .floatButton { + width: 58px; + height: 58px; + } + + .progressRing { + width: 58px; + height: 58px; + } + + .icon { + font-size: 22px; + } +} + +// 添加微妙的脉冲动画 +@keyframes subtlePulse { + 0%, 100% { + box-shadow: 0 4px 16px rgba(102, 126, 234, 0.4), + 0 8px 32px rgba(102, 126, 234, 0.25); + } + 50% { + box-shadow: 0 4px 16px rgba(102, 126, 234, 0.5), + 0 8px 32px rgba(102, 126, 234, 0.35); + } +} + +.floatButton { + animation: subtlePulse 3s ease-in-out infinite; +} + diff --git a/web/src/components/QuestionFloatButton.tsx b/web/src/components/QuestionFloatButton.tsx new file mode 100644 index 0000000..b0b0ed4 --- /dev/null +++ b/web/src/components/QuestionFloatButton.tsx @@ -0,0 +1,89 @@ +import React from 'react' +import { UnorderedListOutlined } from '@ant-design/icons' +import styles from './QuestionFloatButton.module.less' + +interface QuestionFloatButtonProps { + currentIndex: number + totalQuestions: number + onClick: () => void + correctCount: number + wrongCount: number +} + +const QuestionFloatButton: React.FC = ({ + currentIndex, + totalQuestions, + onClick, + correctCount, + wrongCount, +}) => { + if (totalQuestions === 0) return null + + const answeredCount = correctCount + wrongCount + const progress = Math.round((answeredCount / totalQuestions) * 100) + + const radius = 28.5 + const circumference = 2 * Math.PI * radius + + return ( +
+ {/* 统计信息卡片 */} +
+
+ 进度 + + {currentIndex + 1}/{totalQuestions} + +
+
+
+ 正确 + + {correctCount} + +
+
+
+ 错误 + + {wrongCount} + +
+
+ + {/* 悬浮球 */} +
+ {/* 进度环 */} + + + + + + {/* 中心图标 */} +
+ +
+
+
+ ) +} + +export default QuestionFloatButton diff --git a/web/src/pages/Login.tsx b/web/src/pages/Login.tsx index b2f08bf..924874e 100644 --- a/web/src/pages/Login.tsx +++ b/web/src/pages/Login.tsx @@ -27,7 +27,7 @@ const Login: React.FC = () => { const [loading, setLoading] = useState(false) const [registerModalVisible, setRegisterModalVisible] = useState(false) const [userTypeModalVisible, setUserTypeModalVisible] = useState(false) // 用户类型补充模态框 - const [userType, setUserType] = useState('') // 临时存储用户选择的类型 + // const [userType, setUserType] = useState('') // 临时存储用户选择的类型 const [loginForm] = Form.useForm() const [registerForm] = Form.useForm() const [userTypeForm] = Form.useForm() diff --git a/web/src/pages/Question.module.less b/web/src/pages/Question.module.less index 9004c7e..03f4e15 100644 --- a/web/src/pages/Question.module.less +++ b/web/src/pages/Question.module.less @@ -31,14 +31,13 @@ max-width: 900px; margin: 0 auto; padding: 0 20px; - padding-top: 140px; // 为固定顶栏留出空间,减少距离 + padding-top: 80px; // 减少顶部空间,因为去掉了进度条 } .header { display: flex; justify-content: space-between; align-items: center; - margin-bottom: 16px; .backButton { color: #007aff; @@ -60,31 +59,15 @@ text-align: center; } - .statsGroup { - display: flex; - gap: 16px; - align-items: center; - } - - .statItem { - display: flex; - flex-direction: column; - align-items: center; - gap: 2px; - } - - .statLabel { - font-size: 11px; - color: #86868b; + .settingsButton { + color: #8c8c8c; font-weight: 500; - text-transform: uppercase; - letter-spacing: 0.5px; - } + padding: 4px 12px; - .statValue { - font-size: 20px; - font-weight: 700; - line-height: 1; + &:hover { + color: #1d1d1f; + background: rgba(0, 0, 0, 0.04); + } } } @@ -145,49 +128,22 @@ .content { padding: 0 12px; - padding-top: 160px; // 移动端顶栏更高,调整距离 + padding-top: 70px; // 移动端减少顶部距离 } .header { - flex-wrap: wrap; - gap: 10px; - margin-bottom: 12px; - .backButton { - order: 1; - flex: 0 0 auto; font-size: 14px; padding: 4px 8px; } .title { - order: 2; - flex: 1; - text-align: left; font-size: 16px !important; - margin: 0 !important; } - .statsGroup { - order: 3; - flex: 1 1 100%; - justify-content: center; - gap: 32px; - margin-top: 4px; - } - - .statItem { - flex-direction: row; - gap: 6px; - align-items: center; - } - - .statLabel { - font-size: 12px; - } - - .statValue { - font-size: 18px; + .settingsButton { + font-size: 14px; + padding: 4px 8px; } } @@ -216,7 +172,7 @@ .content { padding: 0 24px; - padding-top: 135px; + padding-top: 75px; } .header { @@ -234,16 +190,12 @@ .content { padding: 0 32px; - padding-top: 135px; + padding-top: 85px; } .header { .title { font-size: 22px !important; } - - .statValue { - font-size: 24px; - } } } diff --git a/web/src/pages/Question.tsx b/web/src/pages/Question.tsx index 1a6433f..d1b4192 100644 --- a/web/src/pages/Question.tsx +++ b/web/src/pages/Question.tsx @@ -1,12 +1,13 @@ import React, { useState, useEffect } from "react"; import { useSearchParams, useNavigate } from "react-router-dom"; -import { Button, message, Typography, Switch, InputNumber, Popover, Space } from "antd"; +import { Button, message, Typography, Switch, InputNumber, Modal, Space } from "antd"; import { ArrowLeftOutlined, SettingOutlined } from "@ant-design/icons"; import type { Question, AnswerResult } from "../types/question"; import * as questionApi from "../api/question"; -import QuestionProgress from "../components/QuestionProgress"; import QuestionCard from "../components/QuestionCard"; import CompletionSummary from "../components/CompletionSummary"; +import QuestionDrawer from "../components/QuestionDrawer"; +import QuestionFloatButton from "../components/QuestionFloatButton"; import styles from "./Question.module.less"; const { Title } = Typography; @@ -28,6 +29,13 @@ const QuestionPage: React.FC = () => { const [wrongCount, setWrongCount] = useState(0); const [showSummary, setShowSummary] = useState(false); + // 题目导航抽屉 + const [drawerVisible, setDrawerVisible] = useState(false); + const [answeredStatus, setAnsweredStatus] = useState>(new Map()); + + // 设置弹窗 + const [settingsVisible, setSettingsVisible] = useState(false); + // 自动跳转开关(默认开启) const [autoNext, setAutoNext] = useState(() => { const saved = localStorage.getItem('autoNextEnabled'); @@ -63,14 +71,23 @@ const QuestionPage: React.FC = () => { }; // 保存答题进度 - const saveProgress = (index: number, correct: number, wrong: number) => { + const saveProgress = (index: number, correct: number, wrong: number, statusMap?: Map) => { const key = getStorageKey(); + const answeredStatusObj: Record = {}; + + // 将 Map 转换为普通对象以便 JSON 序列化 + const mapToSave = statusMap || answeredStatus; + mapToSave.forEach((value, key) => { + answeredStatusObj[key] = value; + }); + localStorage.setItem( key, JSON.stringify({ currentIndex: index, correctCount: correct, wrongCount: wrong, + answeredStatus: answeredStatusObj, timestamp: Date.now(), }) ); @@ -86,6 +103,16 @@ const QuestionPage: React.FC = () => { setCurrentIndex(progress.currentIndex || 0); setCorrectCount(progress.correctCount || 0); setWrongCount(progress.wrongCount || 0); + + // 恢复答题状态 + if (progress.answeredStatus) { + const statusMap = new Map(); + Object.entries(progress.answeredStatus).forEach(([index, status]) => { + statusMap.set(Number(index), status as boolean | null); + }); + setAnsweredStatus(statusMap); + } + return progress.currentIndex || 0; } catch (e) { console.error("恢复进度失败", e); @@ -196,15 +223,20 @@ const QuestionPage: React.FC = () => { setAnswerResult(res.data); setShowResult(true); + // 更新答题状态 + const newStatusMap = new Map(answeredStatus); + newStatusMap.set(currentIndex, res.data.correct); + setAnsweredStatus(newStatusMap); + // 更新统计 if (res.data.correct) { const newCorrect = correctCount + 1; setCorrectCount(newCorrect); - saveProgress(currentIndex, newCorrect, wrongCount); + saveProgress(currentIndex, newCorrect, wrongCount, newStatusMap); } else { const newWrong = wrongCount + 1; setWrongCount(newWrong); - saveProgress(currentIndex, correctCount, newWrong); + saveProgress(currentIndex, correctCount, newWrong, newStatusMap); } // 如果答案正确且开启自动跳转,根据设置的延迟时间后自动进入下一题 @@ -252,6 +284,25 @@ const QuestionPage: React.FC = () => { } }; + // 跳转到指定题目 + const handleQuestionSelect = (index: number) => { + if (index >= 0 && index < allQuestions.length) { + setCurrentIndex(index); + setCurrentQuestion(allQuestions[index]); + setSelectedAnswer( + allQuestions[index].type === "multiple-selection" ? [] : "" + ); + setShowResult(false); + setAnswerResult(null); + + // 保存进度 + saveProgress(index, correctCount, wrongCount); + + // 滚动到页面顶部 + window.scrollTo({ top: 0, behavior: 'smooth' }); + } + }; + // 初始化 useEffect(() => { const typeParam = searchParams.get("type"); @@ -297,73 +348,15 @@ const QuestionPage: React.FC = () => { AnKao 刷题 -
-
- 正确 - {correctCount} -
-
- 错误 - {wrongCount} -
- {/* 设置按钮 */} - - -
-
自动下一题
-
- - - {autoNext ? '已开启' : '已关闭'} - -
-
- {autoNext && ( -
-
延迟时间
- - - 秒后跳转 - -
- )} -
-
- } - title="答题设置" - trigger="click" - placement="bottomRight" - > -
-
- - {/* 进度条 */} -
- + {/* 设置按钮 */} +
@@ -400,6 +393,77 @@ const QuestionPage: React.FC = () => { }} onRetry={handleRetry} /> + + {/* 题目导航抽屉 */} + setDrawerVisible(false)} + questions={allQuestions} + currentIndex={currentIndex} + onQuestionSelect={handleQuestionSelect} + answeredStatus={answeredStatus} + /> + + {/* 悬浮球 - 题目导航 */} + {allQuestions.length > 0 && ( + setDrawerVisible(true)} + /> + )} + + {/* 设置弹窗 */} + setSettingsVisible(false)} + footer={null} + width={400} + > + +
+
+ 自动下一题 + + 答对题目后自动跳转到下一题 + +
+
+ + + {autoNext ? '已开启' : '已关闭'} + +
+
+ + {autoNext && ( +
+
+ 延迟时间 + + 设置答对后等待多久跳转到下一题 + +
+
+ + 秒后自动跳转 +
+
+ )} +
+
); }; diff --git a/web/src/utils/request.ts b/web/src/utils/request.ts index 8f7a698..7b8ec6a 100644 --- a/web/src/utils/request.ts +++ b/web/src/utils/request.ts @@ -93,9 +93,9 @@ export const fetchWithAuth = async ( const token = localStorage.getItem('token') // 合并 headers - const headers: HeadersInit = { + const headers: Record = { 'Content-Type': 'application/json', - ...options.headers, + ...(options.headers as Record), } // 如果有 token,添加到请求头