优化答题页面导航系统和UI体验
主要改进: - 新增题目导航抽屉组件,支持快速跳转到任意题目 - 新增悬浮球导航按钮,实时显示答题进度和统计信息 - 优化顶部导航栏,移除进度条,简化为返回、标题和设置三个按钮 - 将答题设置改为弹窗模式,提供更好的交互体验 - 优化题目列表卡片设计,减小高度使其更紧凑 - 题目列表显示题号、分类标签、题目内容和答题状态 - 支持答题进度持久化,刷新页面不丢失进度 技术细节: - 使用 Ant Design 的 Drawer、Modal、Tag 等组件 - 采用 CSS Modules 实现样式隔离 - 使用 LocalStorage 保存答题进度和设置 - 响应式设计,适配移动端和PC端 - 修复 TypeScript 编译错误 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
3b7133d9de
commit
fabc5c8f3e
136
web/src/components/QuestionDrawer.module.less
Normal file
136
web/src/components/QuestionDrawer.module.less
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
108
web/src/components/QuestionDrawer.tsx
Normal file
108
web/src/components/QuestionDrawer.tsx
Normal file
@ -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<number, boolean | null> // null: 未答, true: 正确, false: 错误
|
||||
}
|
||||
|
||||
const QuestionDrawer: React.FC<QuestionDrawerProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
questions,
|
||||
currentIndex,
|
||||
onQuestionSelect,
|
||||
answeredStatus,
|
||||
}) => {
|
||||
// 获取题目状态
|
||||
const getQuestionStatus = (index: number) => {
|
||||
const status = answeredStatus.get(index)
|
||||
if (status === null || status === undefined) {
|
||||
return { icon: <MinusCircleOutlined />, color: '#d9d9d9', text: '未答' }
|
||||
}
|
||||
if (status) {
|
||||
return { icon: <CheckCircleOutlined />, color: '#52c41a', text: '正确' }
|
||||
}
|
||||
return { icon: <CloseCircleOutlined />, color: '#ff4d4f', text: '错误' }
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={
|
||||
<Space>
|
||||
<BookOutlined />
|
||||
<span>题目导航</span>
|
||||
<Text type="secondary" style={{ fontSize: 14, fontWeight: 'normal' }}>
|
||||
共 {questions.length} 题
|
||||
</Text>
|
||||
</Space>
|
||||
}
|
||||
placement="right"
|
||||
onClose={onClose}
|
||||
open={visible}
|
||||
width={450}
|
||||
className={styles.drawer}
|
||||
>
|
||||
<List
|
||||
dataSource={questions}
|
||||
renderItem={(question, index) => {
|
||||
const status = getQuestionStatus(index)
|
||||
const isCurrent = index === currentIndex
|
||||
|
||||
return (
|
||||
<List.Item
|
||||
key={question.id}
|
||||
className={`${styles.listItem} ${isCurrent ? styles.current : ''}`}
|
||||
onClick={() => {
|
||||
onQuestionSelect(index)
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<div className={styles.questionItem}>
|
||||
{/* 题号 */}
|
||||
<div className={styles.questionNumber}>
|
||||
{index + 1}
|
||||
</div>
|
||||
|
||||
{/* 分类标签 */}
|
||||
<Tag color="blue" style={{ margin: 0, flexShrink: 0 }}>
|
||||
{question.category}
|
||||
</Tag>
|
||||
|
||||
{/* 题目内容 */}
|
||||
<div className={styles.questionContent}>
|
||||
<Text ellipsis={{ tooltip: question.content }} style={{ fontSize: 14, color: '#262626' }}>
|
||||
{question.content}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* 右侧状态 */}
|
||||
<div className={styles.questionStatus}>
|
||||
<div style={{ color: status.color, fontSize: 20 }}>
|
||||
{status.icon}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</List.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
export default QuestionDrawer
|
||||
184
web/src/components/QuestionFloatButton.module.less
Normal file
184
web/src/components/QuestionFloatButton.module.less
Normal file
@ -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;
|
||||
}
|
||||
|
||||
89
web/src/components/QuestionFloatButton.tsx
Normal file
89
web/src/components/QuestionFloatButton.tsx
Normal file
@ -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<QuestionFloatButtonProps> = ({
|
||||
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 (
|
||||
<div className={styles.floatButtonWrapper}>
|
||||
{/* 统计信息卡片 */}
|
||||
<div className={styles.statsCard}>
|
||||
<div className={styles.statsRow}>
|
||||
<span className={styles.statsLabel}>进度</span>
|
||||
<span className={styles.statsValue}>
|
||||
{currentIndex + 1}/{totalQuestions}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.statsDivider} />
|
||||
<div className={styles.statsRow}>
|
||||
<span className={styles.statsLabel}>正确</span>
|
||||
<span className={styles.statsValue} style={{ color: '#52c41a' }}>
|
||||
{correctCount}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.statsDivider} />
|
||||
<div className={styles.statsRow}>
|
||||
<span className={styles.statsLabel}>错误</span>
|
||||
<span className={styles.statsValue} style={{ color: '#ff4d4f' }}>
|
||||
{wrongCount}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 悬浮球 */}
|
||||
<div className={styles.floatButton} onClick={onClick}>
|
||||
{/* 进度环 */}
|
||||
<svg className={styles.progressRing} width="64" height="64">
|
||||
<circle
|
||||
className={styles.progressRingCircle}
|
||||
strokeWidth="3"
|
||||
fill="transparent"
|
||||
r={radius}
|
||||
cx="32"
|
||||
cy="32"
|
||||
/>
|
||||
<circle
|
||||
className={styles.progressRingCircleProgress}
|
||||
strokeWidth="3"
|
||||
fill="transparent"
|
||||
r={radius}
|
||||
cx="32"
|
||||
cy="32"
|
||||
style={{
|
||||
strokeDasharray: `${circumference}`,
|
||||
strokeDashoffset: `${circumference * (1 - progress / 100)}`,
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* 中心图标 */}
|
||||
<div className={styles.floatButtonContent}>
|
||||
<UnorderedListOutlined className={styles.icon} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default QuestionFloatButton
|
||||
@ -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<string>('') // 临时存储用户选择的类型
|
||||
// const [userType, setUserType] = useState<string>('') // 临时存储用户选择的类型
|
||||
const [loginForm] = Form.useForm()
|
||||
const [registerForm] = Form.useForm()
|
||||
const [userTypeForm] = Form.useForm()
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<Map<number, boolean | null>>(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<number, boolean | null>) => {
|
||||
const key = getStorageKey();
|
||||
const answeredStatusObj: Record<number, boolean | null> = {};
|
||||
|
||||
// 将 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<number, boolean | null>();
|
||||
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 = () => {
|
||||
<Title level={3} className={styles.title}>
|
||||
AnKao 刷题
|
||||
</Title>
|
||||
<div className={styles.statsGroup}>
|
||||
<div className={styles.statItem}>
|
||||
<span className={styles.statLabel}>正确</span>
|
||||
<span className={styles.statValue} style={{ color: '#52c41a' }}>{correctCount}</span>
|
||||
</div>
|
||||
<div className={styles.statItem}>
|
||||
<span className={styles.statLabel}>错误</span>
|
||||
<span className={styles.statValue} style={{ color: '#ff4d4f' }}>{wrongCount}</span>
|
||||
</div>
|
||||
{/* 设置按钮 */}
|
||||
<Popover
|
||||
content={
|
||||
<div style={{ width: 200 }}>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<div>
|
||||
<div style={{ marginBottom: 8, fontWeight: 500 }}>自动下一题</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Switch
|
||||
checked={autoNext}
|
||||
onChange={toggleAutoNext}
|
||||
size="small"
|
||||
/>
|
||||
<span style={{ fontSize: 14 }}>
|
||||
{autoNext ? '已开启' : '已关闭'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{autoNext && (
|
||||
<div>
|
||||
<div style={{ marginBottom: 8, fontWeight: 500 }}>延迟时间</div>
|
||||
<Space>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={10}
|
||||
value={autoNextDelay}
|
||||
onChange={handleDelayChange}
|
||||
size="small"
|
||||
style={{ width: 60 }}
|
||||
/>
|
||||
<span style={{ fontSize: 14 }}>秒后跳转</span>
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
title="答题设置"
|
||||
trigger="click"
|
||||
placement="bottomRight"
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<SettingOutlined />}
|
||||
style={{ marginLeft: 8 }}
|
||||
/>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 进度条 */}
|
||||
<div className={styles.progressWrapper}>
|
||||
<QuestionProgress
|
||||
currentIndex={currentIndex}
|
||||
totalQuestions={allQuestions.length}
|
||||
correctCount={correctCount}
|
||||
wrongCount={wrongCount}
|
||||
/>
|
||||
onClick={() => setSettingsVisible(true)}
|
||||
className={styles.settingsButton}
|
||||
>
|
||||
设置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -400,6 +393,77 @@ const QuestionPage: React.FC = () => {
|
||||
}}
|
||||
onRetry={handleRetry}
|
||||
/>
|
||||
|
||||
{/* 题目导航抽屉 */}
|
||||
<QuestionDrawer
|
||||
visible={drawerVisible}
|
||||
onClose={() => setDrawerVisible(false)}
|
||||
questions={allQuestions}
|
||||
currentIndex={currentIndex}
|
||||
onQuestionSelect={handleQuestionSelect}
|
||||
answeredStatus={answeredStatus}
|
||||
/>
|
||||
|
||||
{/* 悬浮球 - 题目导航 */}
|
||||
{allQuestions.length > 0 && (
|
||||
<QuestionFloatButton
|
||||
currentIndex={currentIndex}
|
||||
totalQuestions={allQuestions.length}
|
||||
correctCount={correctCount}
|
||||
wrongCount={wrongCount}
|
||||
onClick={() => setDrawerVisible(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 设置弹窗 */}
|
||||
<Modal
|
||||
title="答题设置"
|
||||
open={settingsVisible}
|
||||
onCancel={() => setSettingsVisible(false)}
|
||||
footer={null}
|
||||
width={400}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
||||
<div>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<span style={{ fontSize: 15, fontWeight: 500 }}>自动下一题</span>
|
||||
<span style={{ marginLeft: 8, fontSize: 13, color: '#8c8c8c' }}>
|
||||
答对题目后自动跳转到下一题
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, paddingLeft: 8 }}>
|
||||
<Switch
|
||||
checked={autoNext}
|
||||
onChange={toggleAutoNext}
|
||||
/>
|
||||
<span style={{ fontSize: 14, color: autoNext ? '#52c41a' : '#8c8c8c' }}>
|
||||
{autoNext ? '已开启' : '已关闭'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{autoNext && (
|
||||
<div>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<span style={{ fontSize: 15, fontWeight: 500 }}>延迟时间</span>
|
||||
<span style={{ marginLeft: 8, fontSize: 13, color: '#8c8c8c' }}>
|
||||
设置答对后等待多久跳转到下一题
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, paddingLeft: 8 }}>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={10}
|
||||
value={autoNextDelay}
|
||||
onChange={handleDelayChange}
|
||||
style={{ width: 80 }}
|
||||
/>
|
||||
<span style={{ fontSize: 14 }}>秒后自动跳转</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -93,9 +93,9 @@ export const fetchWithAuth = async (
|
||||
const token = localStorage.getItem('token')
|
||||
|
||||
// 合并 headers
|
||||
const headers: HeadersInit = {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
...(options.headers as Record<string, string>),
|
||||
}
|
||||
|
||||
// 如果有 token,添加到请求头
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user