添加题目列表页面导航系统和悬浮按钮组

- 新增题目导航抽屉组件,按题型分组显示题目编号
- 实现点击题号平滑滚动并高亮定位功能
- 添加悬浮按钮组:返回顶部和题目导航
- 美化悬浮按钮样式,采用渐变色和阴影效果
- 题号使用圆形小巧设计,提升视觉效果
- 适配移动端和PC端响应式布局

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
yanlongqi 2025-11-07 18:15:50 +08:00
parent fabc5c8f3e
commit c545f908df
4 changed files with 418 additions and 2 deletions

View File

@ -0,0 +1,173 @@
.drawer {
:global {
.ant-drawer-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-bottom: none;
padding: 16px 24px;
.ant-drawer-title {
color: white;
font-weight: 600;
font-size: 16px;
}
.ant-drawer-close {
color: rgba(255, 255, 255, 0.85);
&:hover {
color: white;
background: rgba(255, 255, 255, 0.2);
}
}
}
.ant-drawer-body {
padding: 16px 24px;
// 自定义滚动条样式
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: #f0f0f0;
border-radius: 3px;
}
&::-webkit-scrollbar-thumb {
background: #bfbfbf;
border-radius: 3px;
&:hover {
background: #999;
}
}
}
}
}
.groupsContainer {
display: flex;
flex-direction: column;
gap: 12px;
}
.questionGroup {
display: flex;
flex-direction: column;
gap: 8px;
}
.groupHeader {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.typeTag {
margin: 0;
font-size: 13px;
padding: 4px 12px;
border-radius: 6px;
font-weight: 600;
}
.groupCount {
font-size: 12px;
color: #8c8c8c;
}
.numbersGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(36px, 1fr));
gap: 10px;
row-gap: 10px;
}
.numberItem {
width: 36px;
height: 36px;
border-radius: 50%; // 圆形
background: linear-gradient(135deg, #f5f7fa 0%, #e8ecf1 100%);
border: 2px solid transparent;
color: #1d1d1f;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 12px;
cursor: pointer;
transition: all 0.2s ease;
user-select: none;
overflow: hidden;
text-overflow: ellipsis;
&:hover {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
transform: scale(1.1);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
border-color: #667eea;
}
&:active {
transform: scale(1.05);
}
}
.groupDivider {
margin: 8px 0;
border-color: #e8e8e8;
}
// 移动端适配
@media (max-width: 768px) {
.drawer {
:global {
.ant-drawer-content-wrapper {
width: 85vw !important;
max-width: 380px !important;
}
.ant-drawer-header {
padding: 14px 20px;
}
.ant-drawer-body {
padding: 14px 20px;
}
}
}
.groupsContainer {
gap: 10px;
}
.typeTag {
font-size: 12px;
padding: 3px 10px;
}
.groupCount {
font-size: 11px;
}
.numbersGrid {
grid-template-columns: repeat(auto-fill, minmax(34px, 1fr));
gap: 8px;
row-gap: 8px;
}
.numberItem {
width: 34px;
height: 34px;
font-size: 11px;
}
.groupDivider {
margin: 6px 0;
}
}

View File

@ -0,0 +1,116 @@
import React, { useMemo } from 'react'
import { Drawer, Tag, Divider, Space, Typography } from 'antd'
import { BookOutlined } from '@ant-design/icons'
import type { Question } from '../types/question'
import styles from './QuestionListDrawer.module.less'
const { Text } = Typography
// 题型配置
const questionTypeConfig: Record<string, { label: string; color: string }> = {
'multiple-choice': { label: '选择题', color: '#1677ff' },
'multiple-selection': { label: '多选题', color: '#52c41a' },
'true-false': { label: '判断题', color: '#fa8c16' },
'fill-in-blank': { label: '填空题', color: '#722ed1' },
'short-answer': { label: '简答题', color: '#eb2f96' },
'ordinary-essay': { label: '普通涉密人员论述题', color: '#f759ab' },
'management-essay': { label: '保密管理人员论述题', color: '#d4380d' },
}
interface QuestionListDrawerProps {
visible: boolean
onClose: () => void
questions: Question[]
onQuestionSelect: (index: number) => void
}
interface QuestionGroup {
type: string
typeLabel: string
typeColor: string
items: Array<{ index: number; question: Question }>
}
const QuestionListDrawer: React.FC<QuestionListDrawerProps> = ({
visible,
onClose,
questions,
onQuestionSelect,
}) => {
// 按题型分组
const groupedQuestions = useMemo(() => {
const groups: Record<string, QuestionGroup> = {}
questions.forEach((question, index) => {
const typeConfig = questionTypeConfig[question.type]
if (!groups[question.type]) {
groups[question.type] = {
type: question.type,
typeLabel: typeConfig?.label || question.type,
typeColor: typeConfig?.color || 'default',
items: [],
}
}
groups[question.type].items.push({ index, question })
})
return Object.values(groups)
}, [questions])
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={500}
className={styles.drawer}
>
<div className={styles.groupsContainer}>
{groupedQuestions.map((group, groupIndex) => (
<div key={group.type} className={styles.questionGroup}>
{/* 题型标题 */}
<div className={styles.groupHeader}>
<Tag color={group.typeColor} className={styles.typeTag}>
{group.typeLabel}
</Tag>
<span className={styles.groupCount}> {group.items.length} </span>
</div>
{/* 题号列表 */}
<div className={styles.numbersGrid}>
{group.items.map(({ index, question }) => (
<div
key={question.id}
className={styles.numberItem}
onClick={() => {
onQuestionSelect(index)
onClose()
}}
title={`题目 ${question.question_id}`}
>
{question.question_id}
</div>
))}
</div>
{/* 分隔线 */}
{groupIndex < groupedQuestions.length - 1 && (
<Divider className={styles.groupDivider} />
)}
</div>
))}
</div>
</Drawer>
)
}
export default QuestionListDrawer

View File

@ -118,6 +118,54 @@
white-space: pre-wrap; white-space: pre-wrap;
} }
// 悬浮按钮组样式美化
:global {
.ant-float-btn-group {
.ant-float-btn {
width: 44px;
height: 44px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&:hover {
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.18);
transform: translateY(-2px);
}
&:active {
transform: translateY(0);
}
.ant-float-btn-body {
width: 100%;
height: 100%;
.ant-float-btn-icon {
font-size: 18px;
}
}
// 置顶按钮样式
&:not(.ant-float-btn-primary) {
background: white;
&:hover {
background: linear-gradient(135deg, #f5f7fa 0%, #e8ecf1 100%);
}
}
// 主按钮样式(题目导航)
&.ant-float-btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
&:hover {
background: linear-gradient(135deg, #5568d3 0%, #6a3f8f 100%);
}
}
}
}
}
/* 响应式设计 */ /* 响应式设计 */
@media (max-width: 768px) { @media (max-width: 768px) {
.container { .container {
@ -133,4 +181,21 @@
.questionNumber { .questionNumber {
align-self: flex-end; align-self: flex-end;
} }
// 移动端悬浮按钮适配
:global {
.ant-float-btn-group {
right: 16px !important;
bottom: 76px !important; // 避开底部导航栏
.ant-float-btn {
width: 40px;
height: 40px;
.ant-float-btn-body .ant-float-btn-icon {
font-size: 16px;
}
}
}
}
} }

View File

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useState, useRef } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { import {
Card, Card,
@ -12,6 +12,7 @@ import {
Spin, Spin,
message, message,
Input, Input,
FloatButton,
} from 'antd' } from 'antd'
import { import {
BookOutlined, BookOutlined,
@ -21,9 +22,11 @@ import {
UnorderedListOutlined, UnorderedListOutlined,
SearchOutlined, SearchOutlined,
ArrowLeftOutlined, ArrowLeftOutlined,
VerticalAlignTopOutlined,
} from '@ant-design/icons' } from '@ant-design/icons'
import * as questionApi from '../api/question' import * as questionApi from '../api/question'
import type { Question } from '../types/question' import type { Question } from '../types/question'
import QuestionListDrawer from '../components/QuestionListDrawer'
import styles from './QuestionList.module.less' import styles from './QuestionList.module.less'
const { Title, Text, Paragraph } = Typography const { Title, Text, Paragraph } = Typography
@ -46,6 +49,10 @@ const QuestionList: React.FC = () => {
const [filteredQuestions, setFilteredQuestions] = useState<Question[]>([]) const [filteredQuestions, setFilteredQuestions] = useState<Question[]>([])
const [selectedType, setSelectedType] = useState<string>('all') const [selectedType, setSelectedType] = useState<string>('all')
const [searchKeyword, setSearchKeyword] = useState<string>('') const [searchKeyword, setSearchKeyword] = useState<string>('')
const [drawerVisible, setDrawerVisible] = useState(false)
// 用于存储每个题目卡片的ref
const questionRefs = useRef<Map<number, HTMLDivElement>>(new Map())
// 加载题目列表 // 加载题目列表
const loadQuestions = async () => { const loadQuestions = async () => {
@ -165,6 +172,25 @@ const QuestionList: React.FC = () => {
) )
} }
// 滚动到指定题目
const handleQuestionSelect = (index: number) => {
const questionCard = questionRefs.current.get(index)
if (questionCard) {
questionCard.scrollIntoView({
behavior: 'smooth',
block: 'center',
})
// 高亮效果
questionCard.style.transition = 'all 0.3s ease'
questionCard.style.transform = 'scale(1.02)'
questionCard.style.boxShadow = '0 8px 24px rgba(102, 126, 234, 0.3)'
setTimeout(() => {
questionCard.style.transform = 'scale(1)'
questionCard.style.boxShadow = ''
}, 600)
}
}
return ( return (
<div className={styles.container}> <div className={styles.container}>
{/* 头部 */} {/* 头部 */}
@ -232,7 +258,15 @@ const QuestionList: React.FC = () => {
renderItem={(question, index) => { renderItem={(question, index) => {
const typeConfig = questionTypeConfig[question.type] const typeConfig = questionTypeConfig[question.type]
return ( return (
<Card key={question.id} className={styles.questionCard}> <Card
key={question.id}
className={styles.questionCard}
ref={(el) => {
if (el) {
questionRefs.current.set(index, el)
}
}}
>
{/* 题目头部 */} {/* 题目头部 */}
<div className={styles.questionHeader}> <div className={styles.questionHeader}>
<Space size="small"> <Space size="small">
@ -282,6 +316,34 @@ const QuestionList: React.FC = () => {
locale={{ emptyText: '暂无题目' }} locale={{ emptyText: '暂无题目' }}
/> />
</Spin> </Spin>
{/* 题目导航抽屉 */}
<QuestionListDrawer
visible={drawerVisible}
onClose={() => setDrawerVisible(false)}
questions={filteredQuestions}
onQuestionSelect={handleQuestionSelect}
/>
{/* 悬浮按钮组 */}
{filteredQuestions.length > 0 && (
<FloatButton.Group
shape="circle"
style={{ right: 20, bottom: 20 }}
>
<FloatButton
icon={<VerticalAlignTopOutlined />}
tooltip="返回顶部"
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
/>
<FloatButton
icon={<UnorderedListOutlined />}
type="primary"
tooltip="题目导航"
onClick={() => setDrawerVisible(true)}
/>
</FloatButton.Group>
)}
</div> </div>
) )
} }