From c545f908df4f9385842c1890d34a8ca3d2dc6fbd Mon Sep 17 00:00:00 2001 From: yanlongqi Date: Fri, 7 Nov 2025 18:15:50 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E9=A2=98=E7=9B=AE=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E9=A1=B5=E9=9D=A2=E5=AF=BC=E8=88=AA=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E5=92=8C=E6=82=AC=E6=B5=AE=E6=8C=89=E9=92=AE=E7=BB=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增题目导航抽屉组件,按题型分组显示题目编号 - 实现点击题号平滑滚动并高亮定位功能 - 添加悬浮按钮组:返回顶部和题目导航 - 美化悬浮按钮样式,采用渐变色和阴影效果 - 题号使用圆形小巧设计,提升视觉效果 - 适配移动端和PC端响应式布局 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../components/QuestionListDrawer.module.less | 173 ++++++++++++++++++ web/src/components/QuestionListDrawer.tsx | 116 ++++++++++++ web/src/pages/QuestionList.module.less | 65 +++++++ web/src/pages/QuestionList.tsx | 66 ++++++- 4 files changed, 418 insertions(+), 2 deletions(-) create mode 100644 web/src/components/QuestionListDrawer.module.less create mode 100644 web/src/components/QuestionListDrawer.tsx diff --git a/web/src/components/QuestionListDrawer.module.less b/web/src/components/QuestionListDrawer.module.less new file mode 100644 index 0000000..f1b0496 --- /dev/null +++ b/web/src/components/QuestionListDrawer.module.less @@ -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; + } +} + diff --git a/web/src/components/QuestionListDrawer.tsx b/web/src/components/QuestionListDrawer.tsx new file mode 100644 index 0000000..2ab7021 --- /dev/null +++ b/web/src/components/QuestionListDrawer.tsx @@ -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 = { + '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 = ({ + visible, + onClose, + questions, + onQuestionSelect, +}) => { + // 按题型分组 + const groupedQuestions = useMemo(() => { + const groups: Record = {} + + 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 ( + + + 题目导航 + + 共 {questions.length} 题 + + + } + placement="right" + onClose={onClose} + open={visible} + width={500} + className={styles.drawer} + > +
+ {groupedQuestions.map((group, groupIndex) => ( +
+ {/* 题型标题 */} +
+ + {group.typeLabel} + + 共 {group.items.length} 题 +
+ + {/* 题号列表 */} +
+ {group.items.map(({ index, question }) => ( +
{ + onQuestionSelect(index) + onClose() + }} + title={`题目 ${question.question_id}`} + > + {question.question_id} +
+ ))} +
+ + {/* 分隔线 */} + {groupIndex < groupedQuestions.length - 1 && ( + + )} +
+ ))} +
+
+ ) +} + +export default QuestionListDrawer diff --git a/web/src/pages/QuestionList.module.less b/web/src/pages/QuestionList.module.less index fe61904..2cdd85c 100644 --- a/web/src/pages/QuestionList.module.less +++ b/web/src/pages/QuestionList.module.less @@ -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; + } + } + } + } } diff --git a/web/src/pages/QuestionList.tsx b/web/src/pages/QuestionList.tsx index cfda0b9..d9028f8 100644 --- a/web/src/pages/QuestionList.tsx +++ b/web/src/pages/QuestionList.tsx @@ -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([]) const [selectedType, setSelectedType] = useState('all') const [searchKeyword, setSearchKeyword] = useState('') + const [drawerVisible, setDrawerVisible] = useState(false) + + // 用于存储每个题目卡片的ref + const questionRefs = useRef>(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 (
{/* 头部 */} @@ -232,7 +258,15 @@ const QuestionList: React.FC = () => { renderItem={(question, index) => { const typeConfig = questionTypeConfig[question.type] return ( - + { + if (el) { + questionRefs.current.set(index, el) + } + }} + > {/* 题目头部 */}
@@ -282,6 +316,34 @@ const QuestionList: React.FC = () => { locale={{ emptyText: '暂无题目' }} /> + + {/* 题目导航抽屉 */} + setDrawerVisible(false)} + questions={filteredQuestions} + onQuestionSelect={handleQuestionSelect} + /> + + {/* 悬浮按钮组 */} + {filteredQuestions.length > 0 && ( + + } + tooltip="返回顶部" + onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })} + /> + } + type="primary" + tooltip="题目导航" + onClick={() => setDrawerVisible(true)} + /> + + )}
) }