优化答题页面导航系统和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 [loading, setLoading] = useState(false)
|
||||||
const [registerModalVisible, setRegisterModalVisible] = useState(false)
|
const [registerModalVisible, setRegisterModalVisible] = useState(false)
|
||||||
const [userTypeModalVisible, setUserTypeModalVisible] = useState(false) // 用户类型补充模态框
|
const [userTypeModalVisible, setUserTypeModalVisible] = useState(false) // 用户类型补充模态框
|
||||||
const [userType, setUserType] = useState<string>('') // 临时存储用户选择的类型
|
// const [userType, setUserType] = useState<string>('') // 临时存储用户选择的类型
|
||||||
const [loginForm] = Form.useForm()
|
const [loginForm] = Form.useForm()
|
||||||
const [registerForm] = Form.useForm()
|
const [registerForm] = Form.useForm()
|
||||||
const [userTypeForm] = Form.useForm()
|
const [userTypeForm] = Form.useForm()
|
||||||
|
|||||||
@ -31,14 +31,13 @@
|
|||||||
max-width: 900px;
|
max-width: 900px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
padding-top: 140px; // 为固定顶栏留出空间,减少距离
|
padding-top: 80px; // 减少顶部空间,因为去掉了进度条
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 16px;
|
|
||||||
|
|
||||||
.backButton {
|
.backButton {
|
||||||
color: #007aff;
|
color: #007aff;
|
||||||
@ -60,31 +59,15 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.statsGroup {
|
.settingsButton {
|
||||||
display: flex;
|
color: #8c8c8c;
|
||||||
gap: 16px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.statItem {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.statLabel {
|
|
||||||
font-size: 11px;
|
|
||||||
color: #86868b;
|
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-transform: uppercase;
|
padding: 4px 12px;
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.statValue {
|
&:hover {
|
||||||
font-size: 20px;
|
color: #1d1d1f;
|
||||||
font-weight: 700;
|
background: rgba(0, 0, 0, 0.04);
|
||||||
line-height: 1;
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -145,49 +128,22 @@
|
|||||||
|
|
||||||
.content {
|
.content {
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
padding-top: 160px; // 移动端顶栏更高,调整距离
|
padding-top: 70px; // 移动端减少顶部距离
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 10px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
|
|
||||||
.backButton {
|
.backButton {
|
||||||
order: 1;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
order: 2;
|
|
||||||
flex: 1;
|
|
||||||
text-align: left;
|
|
||||||
font-size: 16px !important;
|
font-size: 16px !important;
|
||||||
margin: 0 !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.statsGroup {
|
.settingsButton {
|
||||||
order: 3;
|
font-size: 14px;
|
||||||
flex: 1 1 100%;
|
padding: 4px 8px;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -216,7 +172,7 @@
|
|||||||
|
|
||||||
.content {
|
.content {
|
||||||
padding: 0 24px;
|
padding: 0 24px;
|
||||||
padding-top: 135px;
|
padding-top: 75px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
@ -234,16 +190,12 @@
|
|||||||
|
|
||||||
.content {
|
.content {
|
||||||
padding: 0 32px;
|
padding: 0 32px;
|
||||||
padding-top: 135px;
|
padding-top: 85px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
.title {
|
.title {
|
||||||
font-size: 22px !important;
|
font-size: 22px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.statValue {
|
|
||||||
font-size: 24px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,13 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { useSearchParams, useNavigate } from "react-router-dom";
|
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 { ArrowLeftOutlined, SettingOutlined } from "@ant-design/icons";
|
||||||
import type { Question, AnswerResult } from "../types/question";
|
import type { Question, AnswerResult } from "../types/question";
|
||||||
import * as questionApi from "../api/question";
|
import * as questionApi from "../api/question";
|
||||||
import QuestionProgress from "../components/QuestionProgress";
|
|
||||||
import QuestionCard from "../components/QuestionCard";
|
import QuestionCard from "../components/QuestionCard";
|
||||||
import CompletionSummary from "../components/CompletionSummary";
|
import CompletionSummary from "../components/CompletionSummary";
|
||||||
|
import QuestionDrawer from "../components/QuestionDrawer";
|
||||||
|
import QuestionFloatButton from "../components/QuestionFloatButton";
|
||||||
import styles from "./Question.module.less";
|
import styles from "./Question.module.less";
|
||||||
|
|
||||||
const { Title } = Typography;
|
const { Title } = Typography;
|
||||||
@ -28,6 +29,13 @@ const QuestionPage: React.FC = () => {
|
|||||||
const [wrongCount, setWrongCount] = useState(0);
|
const [wrongCount, setWrongCount] = useState(0);
|
||||||
const [showSummary, setShowSummary] = useState(false);
|
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 [autoNext, setAutoNext] = useState(() => {
|
||||||
const saved = localStorage.getItem('autoNextEnabled');
|
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 key = getStorageKey();
|
||||||
|
const answeredStatusObj: Record<number, boolean | null> = {};
|
||||||
|
|
||||||
|
// 将 Map 转换为普通对象以便 JSON 序列化
|
||||||
|
const mapToSave = statusMap || answeredStatus;
|
||||||
|
mapToSave.forEach((value, key) => {
|
||||||
|
answeredStatusObj[key] = value;
|
||||||
|
});
|
||||||
|
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
key,
|
key,
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
currentIndex: index,
|
currentIndex: index,
|
||||||
correctCount: correct,
|
correctCount: correct,
|
||||||
wrongCount: wrong,
|
wrongCount: wrong,
|
||||||
|
answeredStatus: answeredStatusObj,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@ -86,6 +103,16 @@ const QuestionPage: React.FC = () => {
|
|||||||
setCurrentIndex(progress.currentIndex || 0);
|
setCurrentIndex(progress.currentIndex || 0);
|
||||||
setCorrectCount(progress.correctCount || 0);
|
setCorrectCount(progress.correctCount || 0);
|
||||||
setWrongCount(progress.wrongCount || 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;
|
return progress.currentIndex || 0;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("恢复进度失败", e);
|
console.error("恢复进度失败", e);
|
||||||
@ -196,15 +223,20 @@ const QuestionPage: React.FC = () => {
|
|||||||
setAnswerResult(res.data);
|
setAnswerResult(res.data);
|
||||||
setShowResult(true);
|
setShowResult(true);
|
||||||
|
|
||||||
|
// 更新答题状态
|
||||||
|
const newStatusMap = new Map(answeredStatus);
|
||||||
|
newStatusMap.set(currentIndex, res.data.correct);
|
||||||
|
setAnsweredStatus(newStatusMap);
|
||||||
|
|
||||||
// 更新统计
|
// 更新统计
|
||||||
if (res.data.correct) {
|
if (res.data.correct) {
|
||||||
const newCorrect = correctCount + 1;
|
const newCorrect = correctCount + 1;
|
||||||
setCorrectCount(newCorrect);
|
setCorrectCount(newCorrect);
|
||||||
saveProgress(currentIndex, newCorrect, wrongCount);
|
saveProgress(currentIndex, newCorrect, wrongCount, newStatusMap);
|
||||||
} else {
|
} else {
|
||||||
const newWrong = wrongCount + 1;
|
const newWrong = wrongCount + 1;
|
||||||
setWrongCount(newWrong);
|
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(() => {
|
useEffect(() => {
|
||||||
const typeParam = searchParams.get("type");
|
const typeParam = searchParams.get("type");
|
||||||
@ -297,73 +348,15 @@ const QuestionPage: React.FC = () => {
|
|||||||
<Title level={3} className={styles.title}>
|
<Title level={3} className={styles.title}>
|
||||||
AnKao 刷题
|
AnKao 刷题
|
||||||
</Title>
|
</Title>
|
||||||
<div className={styles.statsGroup}>
|
{/* 设置按钮 */}
|
||||||
<div className={styles.statItem}>
|
<Button
|
||||||
<span className={styles.statLabel}>正确</span>
|
type="text"
|
||||||
<span className={styles.statValue} style={{ color: '#52c41a' }}>{correctCount}</span>
|
icon={<SettingOutlined />}
|
||||||
</div>
|
onClick={() => setSettingsVisible(true)}
|
||||||
<div className={styles.statItem}>
|
className={styles.settingsButton}
|
||||||
<span className={styles.statLabel}>错误</span>
|
>
|
||||||
<span className={styles.statValue} style={{ color: '#ff4d4f' }}>{wrongCount}</span>
|
设置
|
||||||
</div>
|
</Button>
|
||||||
{/* 设置按钮 */}
|
|
||||||
<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}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -400,6 +393,77 @@ const QuestionPage: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
onRetry={handleRetry}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -93,9 +93,9 @@ export const fetchWithAuth = async (
|
|||||||
const token = localStorage.getItem('token')
|
const token = localStorage.getItem('token')
|
||||||
|
|
||||||
// 合并 headers
|
// 合并 headers
|
||||||
const headers: HeadersInit = {
|
const headers: Record<string, string> = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...options.headers,
|
...(options.headers as Record<string, string>),
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果有 token,添加到请求头
|
// 如果有 token,添加到请求头
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user