添加题目列表页面导航系统和悬浮按钮组
- 新增题目导航抽屉组件,按题型分组显示题目编号 - 实现点击题号平滑滚动并高亮定位功能 - 添加悬浮按钮组:返回顶部和题目导航 - 美化悬浮按钮样式,采用渐变色和阴影效果 - 题号使用圆形小巧设计,提升视觉效果 - 适配移动端和PC端响应式布局 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
fabc5c8f3e
commit
c545f908df
173
web/src/components/QuestionListDrawer.module.less
Normal file
173
web/src/components/QuestionListDrawer.module.less
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
116
web/src/components/QuestionListDrawer.tsx
Normal file
116
web/src/components/QuestionListDrawer.tsx
Normal 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
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user