添加题目列表页面导航系统和悬浮按钮组
- 新增题目导航抽屉组件,按题型分组显示题目编号 - 实现点击题号平滑滚动并高亮定位功能 - 添加悬浮按钮组:返回顶部和题目导航 - 美化悬浮按钮样式,采用渐变色和阴影效果 - 题号使用圆形小巧设计,提升视觉效果 - 适配移动端和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;
|
||||
}
|
||||
|
||||
// 悬浮按钮组样式美化
|
||||
: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) {
|
||||
.container {
|
||||
@ -133,4 +181,21 @@
|
||||
.questionNumber {
|
||||
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 {
|
||||
Card,
|
||||
@ -12,6 +12,7 @@ import {
|
||||
Spin,
|
||||
message,
|
||||
Input,
|
||||
FloatButton,
|
||||
} from 'antd'
|
||||
import {
|
||||
BookOutlined,
|
||||
@ -21,9 +22,11 @@ import {
|
||||
UnorderedListOutlined,
|
||||
SearchOutlined,
|
||||
ArrowLeftOutlined,
|
||||
VerticalAlignTopOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import * as questionApi from '../api/question'
|
||||
import type { Question } from '../types/question'
|
||||
import QuestionListDrawer from '../components/QuestionListDrawer'
|
||||
import styles from './QuestionList.module.less'
|
||||
|
||||
const { Title, Text, Paragraph } = Typography
|
||||
@ -46,6 +49,10 @@ const QuestionList: React.FC = () => {
|
||||
const [filteredQuestions, setFilteredQuestions] = useState<Question[]>([])
|
||||
const [selectedType, setSelectedType] = useState<string>('all')
|
||||
const [searchKeyword, setSearchKeyword] = useState<string>('')
|
||||
const [drawerVisible, setDrawerVisible] = useState(false)
|
||||
|
||||
// 用于存储每个题目卡片的ref
|
||||
const questionRefs = useRef<Map<number, HTMLDivElement>>(new Map())
|
||||
|
||||
// 加载题目列表
|
||||
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 (
|
||||
<div className={styles.container}>
|
||||
{/* 头部 */}
|
||||
@ -232,7 +258,15 @@ const QuestionList: React.FC = () => {
|
||||
renderItem={(question, index) => {
|
||||
const typeConfig = questionTypeConfig[question.type]
|
||||
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}>
|
||||
<Space size="small">
|
||||
@ -282,6 +316,34 @@ const QuestionList: React.FC = () => {
|
||||
locale={{ emptyText: '暂无题目' }}
|
||||
/>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user