重构UI框架并实现响应式设计

- UI框架:从 antd-mobile 迁移到 Ant Design,支持更好的跨平台体验
- 响应式设计:实现移动端、平板、PC端全方位适配
  - 移动端:保留底部导航栏,优化触摸交互
  - PC端:隐藏底部导航,采用居中布局
- 样式重构:所有组件样式迁移到 CSS Modules(.module.less)
- 功能优化:
  - 练习题答题改进:始终返回正确答案便于用户学习
  - 添加题目编号字段(question_id)
  - 修复判断题选项:由 A/B 改为 true/false
- 组件优化:
  - TabBarLayout 重构,支持响应式显示/隐藏
  - 所有页面组件采用 Ant Design 组件替换原 antd-mobile 组件
  - 统一使用 @ant-design/icons 图标库
- 文档同步:更新 CLAUDE.md 中 UI 组件使用规范和响应式设计说明

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
yanlongqi 2025-11-04 13:03:59 +08:00
parent a7ede7692f
commit e722180c07
21 changed files with 1927 additions and 1422 deletions

View File

@ -213,25 +213,40 @@ go test -v ./...
### UI 组件使用原则 ### UI 组件使用原则
**重要**: 在开发前端页面时,必须优先使用 UI 框架的组件。 **重要**: 在开发前端页面时,必须优先使用 UI 框架的组件。
- **优先使用 antd-mobile 组件**: 项目使用 antd-mobile 作为 UI 框架,开发时应优先查找并使用框架提供的组件 - **优先使用 Ant Design 组件**: 项目使用 **antd (Ant Design)** 作为 UI 框架,开发时应优先查找并使用框架提供的组件
- **常用组件示例**: - **常用组件示例**:
- 表单输入: 使用 `Input` 组件,而非原生 `<input>` - 表单输入: 使用 `Input` 组件,而非原生 `<input>`
- 按钮: 使用 `Button` 组件,而非原生 `<button>` - 按钮: 使用 `Button` 组件,而非原生 `<button>`
- 表单: 使用 `Form``Form.Item` 组件 - 表单: 使用 `Form``Form.Item` 组件
- 提示信息: 使用 `Toast` 组件,而非自定义提示框 - 提示信息: 使用 `message` 组件,而非自定义提示框
- 对话框: 使用 `Dialog` 组件 - 对话框: 使用 `Modal` 组件
- 列表: 使用 `List` 组件 - 列表: 使用 `List` 组件
- **仅在必要时自定义**: 只有当 antd-mobile 没有提供对应组件时,才使用自定义组件 - 布局: 使用 `Row``Col``Layout` 等布局组件
- 图标: 使用 `@ant-design/icons` 包中的图标
- **仅在必要时自定义**: 只有当 antd 没有提供对应组件时,才使用自定义组件
- **样式处理**: 使用 CSS Modules (`.module.less`) 编写组件样式,避免全局样式污染 - **样式处理**: 使用 CSS Modules (`.module.less`) 编写组件样式,避免全局样式污染
- **主题定制**: 在 [web/vite.config.ts](web/vite.config.ts) 中通过 `modifyVars` 定制 antd 主题
### 前端项目结构 ### 前端项目结构
- **web/src/pages/** - 页面组件 - **web/src/pages/** - 页面组件
- **web/src/components/** - 可复用组件 - **web/src/components/** - 可复用组件
- **web/src/pages/*.module.less** - 页面样式文件 (CSS Modules) - **web/src/pages/*.module.less** - 页面样式文件 (CSS Modules)
- **web/vite.config.ts** - Vite 配置文件(包含代理配置) - **web/vite.config.ts** - Vite 配置文件(包含代理配置和 antd 主题定制)
### 移动端适配 ### 响应式设计
- **禁止缩放**: 应用已配置防止移动端双指缩放 **重要**: 应用采用响应式设计,同时适配移动端和PC端。
- **响应式设计**: 优先考虑移动端布局和交互
- **触摸优化**: 使用合适的触摸区域大小(最小 44x44px) - **响应式断点**:
- 移动端: `max-width: 768px`
- 平板: `769px ~ 1024px`
- PC端: `min-width: 1025px`
- **布局适配**: 使用 antd 的 Grid 系统 (`Row``Col`) 实现响应式布局
- **移动端优化**:
- 底部导航栏仅在移动端显示
- 触摸区域大小适中(最小 44x44px)
- 禁止双指缩放
- **PC端优化**:
- 内容居中,最大宽度限制
- 隐藏移动端特有的底部导航栏
- 更大的字体和间距

View File

@ -170,11 +170,7 @@ func SubmitPracticeAnswer(c *gin.Context) {
result := models.PracticeAnswerResult{ result := models.PracticeAnswerResult{
Correct: correct, Correct: correct,
UserAnswer: submit.Answer, UserAnswer: submit.Answer,
} CorrectAnswer: correctAnswer, // 始终返回正确答案
// 如果答案错误,返回正确答案
if !correct {
result.CorrectAnswer = correctAnswer
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
@ -331,6 +327,7 @@ func mapBackendToFrontendType(backendType models.PracticeQuestionType) string {
func convertToDTO(question models.PracticeQuestion) models.PracticeQuestionDTO { func convertToDTO(question models.PracticeQuestion) models.PracticeQuestionDTO {
dto := models.PracticeQuestionDTO{ dto := models.PracticeQuestionDTO{
ID: question.ID, ID: question.ID,
QuestionID: question.QuestionID, // 添加题目编号
Type: mapBackendToFrontendType(question.Type), Type: mapBackendToFrontendType(question.Type),
Content: question.Question, Content: question.Question,
Category: question.TypeName, Category: question.TypeName,
@ -340,8 +337,8 @@ func convertToDTO(question models.PracticeQuestion) models.PracticeQuestionDTO {
// 判断题自动生成选项 // 判断题自动生成选项
if question.Type == models.TrueFalseType { if question.Type == models.TrueFalseType {
dto.Options = []models.Option{ dto.Options = []models.Option{
{Key: "A", Value: "对"}, {Key: "true", Value: "正确"},
{Key: "B", Value: "错"}, {Key: "false", Value: "错"},
} }
return dto return dto
} }

View File

@ -31,7 +31,8 @@ func (PracticeQuestion) TableName() string {
// PracticeQuestionDTO 用于前端返回的数据传输对象 // PracticeQuestionDTO 用于前端返回的数据传输对象
type PracticeQuestionDTO struct { type PracticeQuestionDTO struct {
ID uint `json:"id"` ID uint `json:"id"` // 数据库自增ID
QuestionID string `json:"question_id"` // 题目编号(原JSON中的id)
Type string `json:"type"` // 前端使用的简化类型: single, multiple, judge, fill Type string `json:"type"` // 前端使用的简化类型: single, multiple, judge, fill
Content string `json:"content"` // 题目内容 Content string `json:"content"` // 题目内容
Options []Option `json:"options"` // 选择题选项数组 Options []Option `json:"options"` // 选择题选项数组

View File

@ -10,8 +10,8 @@
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
}, },
"dependencies": { "dependencies": {
"antd-mobile": "^5.37.1", "@ant-design/icons": "^6.1.0",
"antd-mobile-icons": "^0.3.0", "antd": "^5.28.0",
"axios": "^1.6.5", "axios": "^1.6.5",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",

View File

@ -1,5 +1,5 @@
import React from 'react' import React from 'react'
import { Button, Space } from 'antd-mobile' import { Button, Space } from 'antd'
import styles from './DemoButton.module.less' import styles from './DemoButton.module.less'
interface DemoButtonProps { interface DemoButtonProps {
@ -13,17 +13,17 @@ const DemoButton: React.FC<DemoButtonProps> = ({
}) => { }) => {
return ( return (
<div className={styles.container}> <div className={styles.container}>
<Space direction="vertical" block> <Space direction="vertical" style={{ width: '100%' }}>
<Button color="primary" block onClick={onClick}> <Button type="primary" block onClick={onClick}>
{text} - Primary {text} - Primary
</Button> </Button>
<Button color="success" block onClick={onClick}> <Button type="default" block onClick={onClick}>
{text} - Success {text} - Default
</Button> </Button>
<Button color="warning" block onClick={onClick}> <Button type="dashed" block onClick={onClick}>
{text} - Warning {text} - Dashed
</Button> </Button>
<Button color="danger" block onClick={onClick}> <Button type="primary" danger block onClick={onClick}>
{text} - Danger {text} - Danger
</Button> </Button>
</Space> </Space>

View File

@ -1,21 +0,0 @@
// 布局容器
.tab-bar-layout {
display: flex;
flex-direction: column;
height: 100vh;
}
// 内容区域
.tab-bar-content {
flex: 1;
overflow-y: auto;
}
// 底部导航栏
.tab-bar-footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 1000;
}

View File

@ -0,0 +1,63 @@
.layout {
min-height: 100vh;
}
.content {
flex: 1;
}
.footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 1000;
padding: 0;
background: white;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
}
.menu {
display: flex;
justify-content: space-around;
border-bottom: none;
:global {
.ant-menu-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 60px;
margin: 0;
padding: 8px 0;
.anticon {
font-size: 20px;
}
}
.ant-menu-item-selected {
background-color: transparent;
&::after {
display: none;
}
}
}
}
// 响应式设计 - 移动端
@media (max-width: 768px) {
.footer {
display: block;
}
}
// 响应式设计 - PC端
@media (min-width: 769px) {
.footer {
display: none;
}
}

View File

@ -1,52 +1,68 @@
import React from 'react' import React, { useState, useEffect } from 'react'
import { useNavigate, useLocation, Outlet } from 'react-router-dom' import { useNavigate, useLocation, Outlet } from 'react-router-dom'
import { TabBar } from 'antd-mobile' import { Layout, Menu } from 'antd'
import { import {
AppOutline, HomeOutlined,
UserOutline, FileTextOutlined,
UnorderedListOutline, UserOutlined,
} from 'antd-mobile-icons' } from '@ant-design/icons'
import './TabBarLayout.less' import styles from './TabBarLayout.module.less'
const { Footer, Content } = Layout
const TabBarLayout: React.FC = () => { const TabBarLayout: React.FC = () => {
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const [isMobile, setIsMobile] = useState(window.innerWidth <= 768)
const tabs = [ useEffect(() => {
const handleResize = () => {
setIsMobile(window.innerWidth <= 768)
}
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [])
const menuItems = [
{ {
key: '/', key: '/',
title: '首页', icon: <HomeOutlined />,
icon: <AppOutline />, label: '首页',
}, },
{ {
key: '/question', key: '/question',
title: '答题', icon: <FileTextOutlined />,
icon: <UnorderedListOutline />, label: '答题',
}, },
{ {
key: '/profile', key: '/profile',
title: '我的', icon: <UserOutlined />,
icon: <UserOutline />, label: '我的',
}, },
] ]
const setRouteActive = (value: string) => { const handleMenuClick = (key: string) => {
navigate(value) navigate(key)
} }
return ( return (
<div className="tab-bar-layout"> <Layout className={styles.layout}>
<div className="tab-bar-content"> <Content className={styles.content}>
<Outlet /> <Outlet />
</div> </Content>
<div className="tab-bar-footer"> {isMobile && (
<TabBar activeKey={location.pathname} onChange={setRouteActive}> <Footer className={styles.footer}>
{tabs.map(item => ( <Menu
<TabBar.Item key={item.key} icon={item.icon} title={item.title} /> mode="horizontal"
))} selectedKeys={[location.pathname]}
</TabBar> items={menuItems}
</div> onClick={({ key }) => handleMenuClick(key)}
</div> className={styles.menu}
/>
</Footer>
)}
</Layout>
) )
} }

View File

@ -1,17 +1,58 @@
// 变量
@bg-color: #f5f5f5;
// 容器
.container { .container {
display: flex; min-height: 100vh;
flex-direction: column; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
height: 100vh; padding: 0;
background-color: @bg-color;
} }
// 内容区域
.content { .content {
flex: 1; padding: 20px;
overflow-y: auto; padding-bottom: 40px;
max-width: 800px;
margin: 0 auto;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
padding: 16px 0;
}
.card {
border-radius: 16px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
margin-bottom: 20px;
}
.homeButton {
height: 48px;
font-size: 16px;
font-weight: 500;
border-radius: 12px;
margin-top: 16px;
}
// 响应式设计 - 移动端
@media (max-width: 768px) {
.content {
padding: 16px; padding: 16px;
} }
.card {
border-radius: 12px;
margin-bottom: 16px;
}
.homeButton {
height: 44px;
font-size: 15px;
}
}
// 响应式设计 - PC端
@media (min-width: 769px) {
.content {
padding: 32px;
}
}

View File

@ -1,45 +1,61 @@
import React from 'react' import React from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { NavBar, Card, List, Space, Button } from 'antd-mobile' import { Card, List, Button, Typography, Descriptions, Space } from 'antd'
import { LeftOutlined } from '@ant-design/icons'
import styles from './About.module.less' import styles from './About.module.less'
const { Title } = Typography
const About: React.FC = () => { const About: React.FC = () => {
const navigate = useNavigate() const navigate = useNavigate()
return ( return (
<div className={styles.container}> <div className={styles.container}>
<NavBar onBack={() => navigate(-1)}></NavBar>
<div className={styles.content}> <div className={styles.content}>
<Card title="项目信息"> <div className={styles.header}>
<Space direction="vertical" block> <Button
<List> type="text"
<List.Item extra="1.0.0"></List.Item> icon={<LeftOutlined />}
<List.Item extra="AnKao Team"></List.Item> onClick={() => navigate(-1)}
<List.Item extra="MIT"></List.Item> style={{ color: 'white' }}
</List> >
</Button>
<Title level={3} style={{ color: 'white', margin: 0 }}></Title>
<div style={{ width: 48 }} />
</div>
<Card title="功能特性"> <Card title="项目信息" className={styles.card}>
<List> <Descriptions column={1}>
<List.Item> </List.Item> <Descriptions.Item label="版本">1.0.0</Descriptions.Item>
<List.Item> TypeScript </List.Item> <Descriptions.Item label="开发者">AnKao Team</Descriptions.Item>
<List.Item> Vite </List.Item> <Descriptions.Item label="许可证">MIT</Descriptions.Item>
<List.Item> antd-mobile </List.Item> </Descriptions>
<List.Item> React Router </List.Item> </Card>
<List.Item> API </List.Item>
</List> <Card title="功能特性" className={styles.card}>
<List
dataSource={[
'✅ 响应式设计支持移动端和PC端',
'✅ TypeScript 类型安全',
'✅ Vite 快速构建',
'✅ Ant Design 组件库',
'✅ React Router 路由',
'✅ API 代理配置',
]}
renderItem={(item) => <List.Item>{item}</List.Item>}
/>
</Card> </Card>
<Button <Button
color="primary" type="primary"
size="large" size="large"
block block
onClick={() => navigate('/')} onClick={() => navigate('/')}
className={styles.homeButton}
> >
</Button> </Button>
</Space>
</Card>
</div> </div>
</div> </div>
) )

View File

@ -1,200 +1,130 @@
.container { .container {
min-height: 100vh; min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 20px 16px; padding: 24px;
padding-bottom: 80px; padding-bottom: 80px;
} }
.header { .header {
text-align: center; text-align: center;
margin-bottom: 24px; margin-bottom: 32px;
color: white; color: white;
.title { .title {
font-size: 32px; color: white !important;
font-weight: bold; margin-bottom: 8px !important;
margin: 0; text-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
margin-bottom: 8px;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
} }
.subtitle { .subtitle {
font-size: 14px; color: rgba(255, 255, 255, 0.9);
margin: 0;
opacity: 0.9;
} }
} }
.statsCard { .statsCard {
margin-bottom: 24px; margin-bottom: 32px;
border-radius: 16px; border-radius: 16px;
overflow: hidden; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
:global {
.adm-card-body {
padding: 20px 16px;
}
}
}
.statsContent {
display: flex;
justify-content: space-around;
align-items: center;
}
.statItem {
flex: 1;
text-align: center;
.statValue {
font-size: 28px;
font-weight: bold;
color: #1677ff;
margin-bottom: 4px;
}
.statLabel {
font-size: 13px;
color: #666;
}
}
.statDivider {
width: 1px;
height: 40px;
background: #e5e5e5;
} }
.typeSection { .typeSection {
margin-bottom: 24px; margin-bottom: 32px;
.sectionTitle { .sectionTitle {
font-size: 18px; color: white !important;
font-weight: bold; margin-bottom: 16px !important;
color: white;
margin: 0 0 16px 0;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
} }
.typeGrid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
} }
.typeCard { .typeCard {
border-radius: 16px; border-radius: 16px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease; transition: all 0.3s ease;
cursor: pointer; height: 100%;
&:active { &:hover {
transform: scale(0.95); transform: translateY(-4px);
} box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
:global {
.adm-card-body {
padding: 24px 16px;
text-align: center;
}
} }
.typeIcon { .typeIcon {
margin-bottom: 12px; margin-bottom: 12px;
display: flex;
justify-content: center;
align-items: center;
} }
.typeTitle { .typeTitle {
font-size: 18px; margin: 8px 0 !important;
font-weight: bold;
color: #333;
margin-bottom: 8px;
} }
.typeDesc { .typeDesc {
font-size: 13px; margin: 0 !important;
color: #999;
} }
} }
.quickStart { .quickStart {
.quickCard { .quickCard {
border-radius: 16px; border-radius: 16px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
cursor: pointer;
transition: all 0.3s ease; transition: all 0.3s ease;
background: linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%); background: linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%);
&:active { &:hover {
transform: scale(0.98); transform: translateY(-4px);
} box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
:global {
.adm-card-body {
padding: 20px;
}
}
.quickIcon {
font-size: 48px;
line-height: 1;
}
.quickTitle {
font-size: 18px;
font-weight: bold;
color: #333;
margin-bottom: 4px;
}
.quickDesc {
font-size: 13px;
color: #666;
} }
} }
} }
// 移动端适配 // 响应式设计 - 移动端
@media (max-width: 768px) { @media (max-width: 768px) {
.container { .container {
padding: 16px 12px; padding: 16px;
padding-bottom: 70px; padding-bottom: 70px;
} }
.header { .header {
margin-bottom: 24px;
.title { .title {
font-size: 28px; font-size: 24px !important;
} }
} }
.statItem { .statsCard {
.statValue { margin-bottom: 24px;
font-size: 24px; }
.typeSection {
margin-bottom: 24px;
.sectionTitle {
font-size: 18px !important;
}
} }
} }
.typeCard { // 响应式设计 - 平板
:global { @media (min-width: 769px) and (max-width: 1024px) {
.adm-card-body { .container {
padding: 20px 12px; padding: 32px;
padding-bottom: 80px;
} }
} }
.typeIcon { // 响应式设计 - PC端
font-size: 40px; @media (min-width: 1025px) {
.container {
max-width: 1200px;
margin: 0 auto;
padding: 40px;
padding-bottom: 80px;
} }
.typeTitle { .header {
font-size: 16px; .title {
font-size: 36px !important;
} }
} }
} }

View File

@ -1,54 +1,53 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { Card, Statistic, Row, Col, Typography, message, Space } from 'antd'
import { import {
Card, FileTextOutlined,
Toast, CheckCircleOutlined,
Space, UnorderedListOutlined,
} from 'antd-mobile' EditOutlined,
import { RocketOutlined,
FileOutline, } from '@ant-design/icons'
CheckCircleOutline,
UnorderedListOutline,
EditSOutline,
} from 'antd-mobile-icons'
import * as questionApi from '../api/question' import * as questionApi from '../api/question'
import type { Statistics } from '../types/question' import type { Statistics } from '../types/question'
import styles from './Home.module.less' import styles from './Home.module.less'
const { Title, Paragraph } = Typography
// 题型配置 // 题型配置
const questionTypes = [ const questionTypes = [
{ {
key: 'single', key: 'single',
title: '单选题', title: '单选题',
icon: <CheckCircleOutline fontSize={48} />, icon: <CheckCircleOutlined />,
color: '#1677ff', color: '#1677ff',
description: '基础知识考察', description: '基础知识考察',
}, },
{ {
key: 'multiple', key: 'multiple',
title: '多选题', title: '多选题',
icon: <UnorderedListOutline fontSize={48} />, icon: <UnorderedListOutlined />,
color: '#52c41a', color: '#52c41a',
description: '综合能力提升', description: '综合能力提升',
}, },
{ {
key: 'judge', key: 'judge',
title: '判断题', title: '判断题',
icon: <CheckCircleOutline fontSize={48} />, icon: <CheckCircleOutlined />,
color: '#fa8c16', color: '#fa8c16',
description: '快速判断训练', description: '快速判断训练',
}, },
{ {
key: 'fill', key: 'fill',
title: '填空题', title: '填空题',
icon: <FileOutline fontSize={48} />, icon: <FileTextOutlined />,
color: '#722ed1', color: '#722ed1',
description: '填空补充练习', description: '填空补充练习',
}, },
{ {
key: 'short', key: 'short',
title: '简答题', title: '简答题',
icon: <EditSOutline fontSize={48} />, icon: <EditOutlined />,
color: '#eb2f96', color: '#eb2f96',
description: '深度理解练习', description: '深度理解练习',
}, },
@ -87,21 +86,12 @@ const Home: React.FC = () => {
if (res.success && res.data && res.data.length > 0) { if (res.success && res.data && res.data.length > 0) {
// 跳转到答题页面,并传递题型参数 // 跳转到答题页面,并传递题型参数
navigate(`/question?type=${type}`) navigate(`/question?type=${type}`)
Toast.show({ message.success(`开始${questionTypes.find(t => t.key === type)?.title}练习`)
icon: 'success',
content: `开始${questionTypes.find(t => t.key === type)?.title}练习`,
})
} else { } else {
Toast.show({ message.warning('该题型暂无题目')
icon: 'fail',
content: '该题型暂无题目',
})
} }
} catch (error) { } catch (error) {
Toast.show({ message.error('加载题目失败')
icon: 'fail',
content: '加载题目失败',
})
} }
} }
@ -109,63 +99,82 @@ const Home: React.FC = () => {
<div className={styles.container}> <div className={styles.container}>
{/* 头部 */} {/* 头部 */}
<div className={styles.header}> <div className={styles.header}>
<h1 className={styles.title}>AnKao </h1> <Title level={2} className={styles.title}>AnKao </Title>
<p className={styles.subtitle}></p> <Paragraph className={styles.subtitle}></Paragraph>
</div> </div>
{/* 统计卡片 */} {/* 统计卡片 */}
<Card className={styles.statsCard}> <Card className={styles.statsCard}>
<div className={styles.statsContent}> <Row gutter={[16, 16]}>
<div className={styles.statItem}> <Col xs={8} sm={8} md={8}>
<div className={styles.statValue}>{statistics.total_questions}</div> <Statistic
<div className={styles.statLabel}></div> title="题库总数"
</div> value={statistics.total_questions}
<div className={styles.statDivider}></div> valueStyle={{ color: '#1677ff', fontSize: '24px' }}
<div className={styles.statItem}> />
<div className={styles.statValue}>{statistics.answered_questions}</div> </Col>
<div className={styles.statLabel}></div> <Col xs={8} sm={8} md={8}>
</div> <Statistic
<div className={styles.statDivider}></div> title="已答题数"
<div className={styles.statItem}> value={statistics.answered_questions}
<div className={styles.statValue}>{statistics.accuracy.toFixed(0)}%</div> valueStyle={{ color: '#52c41a', fontSize: '24px' }}
<div className={styles.statLabel}></div> />
</div> </Col>
</div> <Col xs={8} sm={8} md={8}>
<Statistic
title="正确率"
value={statistics.accuracy.toFixed(0)}
suffix="%"
valueStyle={{ color: '#fa8c16', fontSize: '24px' }}
/>
</Col>
</Row>
</Card> </Card>
{/* 题型选择 */} {/* 题型选择 */}
<div className={styles.typeSection}> <div className={styles.typeSection}>
<h2 className={styles.sectionTitle}> <Title level={4} className={styles.sectionTitle}>
<FileOutline /> <FileTextOutlined />
</h2> </Title>
<div className={styles.typeGrid}> <Row gutter={[16, 16]}>
{questionTypes.map(type => ( {questionTypes.map(type => (
<Col xs={24} sm={12} md={8} lg={8} key={type.key}>
<Card <Card
key={type.key} hoverable
className={styles.typeCard} className={styles.typeCard}
onClick={() => handleTypeClick(type.key)} onClick={() => handleTypeClick(type.key)}
styles={{
body: {
textAlign: 'center',
padding: '24px',
}
}}
> >
<div className={styles.typeIcon} style={{ color: type.color }}> <div className={styles.typeIcon} style={{ color: type.color, fontSize: '48px' }}>
{type.icon} {type.icon}
</div> </div>
<div className={styles.typeTitle}>{type.title}</div> <Title level={5} className={styles.typeTitle}>{type.title}</Title>
<div className={styles.typeDesc}>{type.description}</div> <Paragraph type="secondary" className={styles.typeDesc}>{type.description}</Paragraph>
</Card> </Card>
</Col>
))} ))}
</div> </Row>
</div> </div>
{/* 快速开始 */} {/* 快速开始 */}
<div className={styles.quickStart}> <div className={styles.quickStart}>
<Card <Card
hoverable
className={styles.quickCard} className={styles.quickCard}
onClick={() => navigate('/question')} onClick={() => navigate('/question')}
> >
<Space align="center"> <Space align="center" size="large">
<div className={styles.quickIcon}>🎲</div> <div className={styles.quickIcon}>
<RocketOutlined style={{ fontSize: '32px', color: '#722ed1' }} />
</div>
<div> <div>
<div className={styles.quickTitle}></div> <Title level={5} style={{ margin: 0 }}></Title>
<div className={styles.quickDesc}></div> <Paragraph type="secondary" style={{ margin: 0 }}></Paragraph>
</div> </div>
</Space> </Space>
</Card> </Card>

View File

@ -1,181 +1,83 @@
.container { .container {
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 40px 20px; padding: 20px;
}
.content {
width: 100%;
max-width: 500px;
}
.card {
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
border-radius: 16px;
:global {
.ant-card-body {
padding: 40px 32px;
}
}
} }
.header { .header {
text-align: center; text-align: center;
margin-bottom: 48px; margin-bottom: 32px;
} }
.title { .title {
font-size: 56px; margin: 0 0 8px 0 !important;
color: #667eea !important;
font-weight: 800; font-weight: 800;
color: #ffffff; letter-spacing: 2px;
margin: 0 0 12px 0; }
letter-spacing: 3px;
text-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); .subtitle {
font-size: 15px;
display: block;
}
// 响应式设计 - 移动端
@media (max-width: 768px) {
.container {
padding: 12px;
}
.content {
max-width: 100%;
}
.card {
:global {
.ant-card-body {
padding: 24px 20px;
}
}
}
.header {
margin-bottom: 24px;
}
.title {
font-size: 28px !important;
}
.subtitle {
font-size: 14px;
}
}
// 响应式设计 - 平板和PC端
@media (min-width: 769px) {
.title {
font-size: 36px !important;
} }
.subtitle { .subtitle {
font-size: 16px; font-size: 16px;
color: rgba(255, 255, 255, 0.9);
margin: 0;
font-weight: 400;
}
.form {
width: 100%;
max-width: 400px;
}
.inputGroup {
margin-bottom: 24px;
}
.label {
display: block;
margin-bottom: 10px;
font-size: 15px;
font-weight: 600;
color: #ffffff;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.input {
width: 100%;
padding: 14px 16px;
font-size: 15px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 12px;
background: rgba(255, 255, 255, 0.95);
color: #333;
transition: all 0.3s ease;
outline: none;
box-sizing: border-box;
&::placeholder {
color: #aaa;
}
&:hover:not(:disabled) {
border-color: rgba(255, 255, 255, 0.6);
background: #ffffff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
&:focus {
border-color: #ffffff;
background: #ffffff;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.passwordWrapper {
position: relative;
}
.passwordWrapper .input {
padding-right: 48px;
}
.eyeButton {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: #999;
cursor: pointer;
padding: 8px;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.2s ease;
font-size: 20px;
&:hover {
color: #667eea;
}
}
.submitButton {
margin-top: 32px;
height: 52px;
font-size: 17px;
font-weight: 700;
border-radius: 12px;
background: #ffffff;
color: #667eea;
border: none;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
transition: all 0.3s ease;
&:not(:disabled):hover {
transform: translateY(-2px);
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.25);
}
&:not(:disabled):active {
transform: translateY(0);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.2);
}
:global {
.adm-button {
background: transparent !important;
border: none !important;
color: #667eea !important;
}
}
}
.switchMode {
margin-top: 32px;
text-align: center;
p {
margin: 0;
font-size: 15px;
color: rgba(255, 255, 255, 0.95);
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.linkBtn {
background: none;
border: none;
color: #ffffff;
font-weight: 700;
cursor: pointer;
padding: 0 0 0 8px;
transition: all 0.2s ease;
font-size: 15px;
text-decoration: underline;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
&:hover:not(:disabled) {
opacity: 0.85;
transform: scale(1.05);
}
&:active:not(:disabled) {
opacity: 0.7;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
} }
} }

View File

@ -1,9 +1,11 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { Button, Toast } from 'antd-mobile' import { Form, Input, Button, Card, Tabs, message, Typography } from 'antd'
import { EyeInvisibleOutline, EyeOutline } from 'antd-mobile-icons' import { UserOutlined, LockOutlined, IdcardOutlined } from '@ant-design/icons'
import styles from './Login.module.less' import styles from './Login.module.less'
const { Title, Text } = Typography
interface LoginResponse { interface LoginResponse {
success: boolean success: boolean
message: string message: string
@ -19,14 +21,10 @@ interface LoginResponse {
const Login: React.FC = () => { const Login: React.FC = () => {
const navigate = useNavigate() const navigate = useNavigate()
const [isLogin, setIsLogin] = useState(true) const [activeTab, setActiveTab] = useState('login')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [showPassword, setShowPassword] = useState(false) const [loginForm] = Form.useForm()
const [registerForm] = Form.useForm()
// 表单字段
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [nickname, setNickname] = useState('')
// 如果已登录,重定向到首页 // 如果已登录,重定向到首页
useEffect(() => { useEffect(() => {
@ -37,15 +35,7 @@ const Login: React.FC = () => {
}, [navigate]) }, [navigate])
// 处理登录 // 处理登录
const handleLogin = async () => { const handleLogin = async (values: { username: string; password: string }) => {
if (!username || !password) {
Toast.show({
icon: 'fail',
content: '请输入用户名和密码',
})
return
}
setLoading(true) setLoading(true)
try { try {
const response = await fetch('/api/login', { const response = await fetch('/api/login', {
@ -53,7 +43,7 @@ const Login: React.FC = () => {
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ username, password }), body: JSON.stringify(values),
}) })
const data: LoginResponse = await response.json() const data: LoginResponse = await response.json()
@ -62,23 +52,13 @@ const Login: React.FC = () => {
localStorage.setItem('token', data.data.token) localStorage.setItem('token', data.data.token)
localStorage.setItem('user', JSON.stringify(data.data.user)) localStorage.setItem('user', JSON.stringify(data.data.user))
Toast.show({ message.success('登录成功')
icon: 'success',
content: '登录成功',
})
navigate('/') navigate('/')
} else { } else {
Toast.show({ message.error(data.message || '登录失败')
icon: 'fail',
content: data.message || '登录失败',
})
} }
} catch (err) { } catch (err) {
Toast.show({ message.error('网络错误,请稍后重试')
icon: 'fail',
content: '网络错误,请稍后重试',
})
console.error('登录错误:', err) console.error('登录错误:', err)
} finally { } finally {
setLoading(false) setLoading(false)
@ -86,23 +66,7 @@ const Login: React.FC = () => {
} }
// 处理注册 // 处理注册
const handleRegister = async () => { const handleRegister = async (values: { username: string; password: string; nickname?: string }) => {
if (!username || !password) {
Toast.show({
icon: 'fail',
content: '请输入用户名和密码',
})
return
}
if (password.length < 6) {
Toast.show({
icon: 'fail',
content: '密码长度至少6位',
})
return
}
setLoading(true) setLoading(true)
try { try {
const response = await fetch('/api/register', { const response = await fetch('/api/register', {
@ -110,7 +74,7 @@ const Login: React.FC = () => {
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ username, password, nickname }), body: JSON.stringify(values),
}) })
const data: LoginResponse = await response.json() const data: LoginResponse = await response.json()
@ -119,131 +83,134 @@ const Login: React.FC = () => {
localStorage.setItem('token', data.data.token) localStorage.setItem('token', data.data.token)
localStorage.setItem('user', JSON.stringify(data.data.user)) localStorage.setItem('user', JSON.stringify(data.data.user))
Toast.show({ message.success('注册成功')
icon: 'success',
content: '注册成功',
})
navigate('/') navigate('/')
} else { } else {
Toast.show({ message.error(data.message || '注册失败')
icon: 'fail',
content: data.message || '注册失败',
})
} }
} catch (err) { } catch (err) {
Toast.show({ message.error('网络错误,请稍后重试')
icon: 'fail',
content: '网络错误,请稍后重试',
})
console.error('注册错误:', err) console.error('注册错误:', err)
} finally { } finally {
setLoading(false) setLoading(false)
} }
} }
const handleSubmit = (e: React.FormEvent) => { const loginTabContent = (
e.preventDefault() <Form
if (isLogin) { form={loginForm}
handleLogin() name="login"
} else { onFinish={handleLogin}
handleRegister() autoComplete="off"
} size="large"
} >
<Form.Item
name="username"
rules={[{ required: true, message: '请输入用户名' }]}
>
<Input
prefix={<UserOutlined />}
placeholder="请输入用户名"
/>
</Form.Item>
<Form.Item
name="password"
rules={[{ required: true, message: '请输入密码' }]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="请输入密码"
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" block loading={loading}>
</Button>
</Form.Item>
</Form>
)
const registerTabContent = (
<Form
form={registerForm}
name="register"
onFinish={handleRegister}
autoComplete="off"
size="large"
>
<Form.Item
name="username"
rules={[
{ required: true, message: '请输入用户名' },
{ min: 3, message: '用户名至少3个字符' }
]}
>
<Input
prefix={<UserOutlined />}
placeholder="请输入用户名"
/>
</Form.Item>
<Form.Item
name="password"
rules={[
{ required: true, message: '请输入密码' },
{ min: 6, message: '密码长度至少6位' }
]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="请输入密码至少6位"
/>
</Form.Item>
<Form.Item
name="nickname"
rules={[{ max: 20, message: '昵称最多20个字符' }]}
>
<Input
prefix={<IdcardOutlined />}
placeholder="请输入昵称(可选)"
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" block loading={loading}>
</Button>
</Form.Item>
</Form>
)
return ( return (
<div className={styles.container}> <div className={styles.container}>
<div className={styles.content}>
<Card className={styles.card}>
<div className={styles.header}> <div className={styles.header}>
<h1 className={styles.title}>AnKao</h1> <Title level={2} className={styles.title}>AnKao</Title>
<p className={styles.subtitle}>使</p> <Text type="secondary" className={styles.subtitle}>使</Text>
</div> </div>
<form onSubmit={handleSubmit} className={styles.form}> <Tabs
<div className={styles.inputGroup}> activeKey={activeTab}
<label className={styles.label}></label> onChange={setActiveTab}
<input centered
type="text" items={[
value={username} {
onChange={(e) => setUsername(e.target.value)} key: 'login',
placeholder="请输入用户名" label: '登录',
disabled={loading} children: loginTabContent,
className={styles.input} },
{
key: 'register',
label: '注册',
children: registerTabContent,
},
]}
/> />
</div> </Card>
<div className={styles.inputGroup}>
<label className={styles.label}></label>
<div className={styles.passwordWrapper}>
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={isLogin ? '请输入密码' : '请输入密码至少6位'}
disabled={loading}
className={styles.input}
/>
<button
type="button"
className={styles.eyeButton}
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? <EyeOutline /> : <EyeInvisibleOutline />}
</button>
</div>
</div>
{!isLogin && (
<div className={styles.inputGroup}>
<label className={styles.label}></label>
<input
type="text"
value={nickname}
onChange={(e) => setNickname(e.target.value)}
placeholder="请输入昵称"
disabled={loading}
className={styles.input}
/>
</div>
)}
<Button
type="submit"
color="primary"
block
loading={loading}
disabled={loading}
className={styles.submitButton}
>
{isLogin ? '登录' : '注册'}
</Button>
</form>
<div className={styles.switchMode}>
{isLogin ? (
<p>
<button
type="button"
onClick={() => setIsLogin(false)}
className={styles.linkBtn}
disabled={loading}
>
</button>
</p>
) : (
<p>
<button
type="button"
onClick={() => setIsLogin(true)}
className={styles.linkBtn}
disabled={loading}
>
</button>
</p>
)}
</div> </div>
</div> </div>
) )

View File

@ -1,24 +1,25 @@
// 变量定义 .container {
@bg-color: #f5f5f5;
@text-secondary: #999;
// 页面容器
.page {
min-height: 100vh; min-height: 100vh;
background-color: @bg-color; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 16px; padding: 24px;
padding-bottom: 70px; padding-bottom: 80px;
}
.content {
max-width: 600px;
margin: 0 auto;
} }
// 用户卡片
.userCard { .userCard {
margin-bottom: 16px; border-radius: 16px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
margin-bottom: 24px;
} }
.userInfo { .userInfo {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 16px; gap: 20px;
} }
.userDetails { .userDetails {
@ -26,17 +27,53 @@
} }
.userNickname { .userNickname {
font-size: 20px; margin: 0 !important;
font-weight: bold;
margin-bottom: 4px;
} }
.userUsername { .userUsername {
font-size: 14px; display: block;
color: @text-secondary; margin-top: 4px;
} }
// 登出容器 .menuCard {
.logoutContainer { border-radius: 16px;
margin-top: 24px; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
margin-bottom: 24px;
}
.logoutButton {
height: 48px;
font-size: 16px;
font-weight: 500;
border-radius: 12px;
}
// 响应式设计 - 移动端
@media (max-width: 768px) {
.container {
padding: 16px;
padding-bottom: 70px;
}
.userCard,
.menuCard {
border-radius: 12px;
margin-bottom: 16px;
}
.userInfo {
gap: 16px;
}
.logoutButton {
height: 44px;
font-size: 15px;
}
}
// 响应式设计 - PC端
@media (min-width: 769px) {
.container {
padding: 32px;
}
} }

View File

@ -5,17 +5,21 @@ import {
Avatar, Avatar,
List, List,
Button, Button,
Dialog, Modal,
Toast, message,
} from 'antd-mobile' Typography,
Space,
} from 'antd'
import { import {
RightOutline, RightOutlined,
SetOutline, SettingOutlined,
FileOutline, FileTextOutlined,
UserOutline, UserOutlined,
} from 'antd-mobile-icons' } from '@ant-design/icons'
import styles from './Profile.module.less' import styles from './Profile.module.less'
const { Title, Text } = Typography
interface UserInfo { interface UserInfo {
username: string username: string
nickname: string nickname: string
@ -40,18 +44,17 @@ const Profile: React.FC = () => {
} }
}, []) }, [])
const handleLogout = async () => { const handleLogout = () => {
const result = await Dialog.confirm({ Modal.confirm({
content: '确定要退出登录吗?', title: '确定要退出登录吗?',
}) onOk: () => {
if (result) {
localStorage.removeItem('token') localStorage.removeItem('token')
localStorage.removeItem('user') localStorage.removeItem('user')
setUserInfo(null) setUserInfo(null)
Toast.show('已退出登录') message.success('已退出登录')
navigate('/login') navigate('/login')
} },
})
} }
const handleLogin = () => { const handleLogin = () => {
@ -59,35 +62,28 @@ const Profile: React.FC = () => {
} }
return ( return (
<div className={styles.page}> <div className={styles.container}>
<div className={styles.content}>
{/* 用户信息卡片 */} {/* 用户信息卡片 */}
<Card className={styles.userCard}> <Card className={styles.userCard}>
{userInfo ? ( {userInfo ? (
<div className={styles.userInfo}> <div className={styles.userInfo}>
<Avatar <Avatar
src={userInfo.avatar || undefined} src={userInfo.avatar || undefined}
style={{ '--size': '64px' }} size={80}
> icon={<UserOutlined />}
{!userInfo.avatar && <UserOutline fontSize={32} />} />
</Avatar>
<div className={styles.userDetails}> <div className={styles.userDetails}>
<div className={styles.userNickname}>{userInfo.nickname}</div> <Title level={4} className={styles.userNickname}>{userInfo.nickname}</Title>
<div className={styles.userUsername}>@{userInfo.username}</div> <Text type="secondary" className={styles.userUsername}>@{userInfo.username}</Text>
</div> </div>
</div> </div>
) : ( ) : (
<div className={styles.userInfo}> <div className={styles.userInfo}>
<Avatar style={{ '--size': '64px' }}> <Avatar size={80} icon={<UserOutlined />} />
<UserOutline fontSize={32} />
</Avatar>
<div className={styles.userDetails}> <div className={styles.userDetails}>
<div className={styles.userNickname}></div> <Title level={4} className={styles.userNickname}></Title>
<Button <Button type="primary" onClick={handleLogin} style={{ marginTop: 8 }}>
color="primary"
size="small"
onClick={handleLogin}
style={{ marginTop: 8 }}
>
</Button> </Button>
</div> </div>
@ -96,34 +92,45 @@ const Profile: React.FC = () => {
</Card> </Card>
{/* 功能列表 */} {/* 功能列表 */}
<List header="功能"> <Card title="功能" className={styles.menuCard}>
<List>
<List.Item <List.Item
prefix={<FileOutline />} onClick={() => message.info('功能开发中')}
onClick={() => Toast.show('功能开发中')} style={{ cursor: 'pointer' }}
clickable
> >
<Space>
<RightOutline /> <FileTextOutlined />
<span></span>
</Space>
<RightOutlined />
</List.Item> </List.Item>
<List.Item <List.Item
prefix={<SetOutline />} onClick={() => message.info('功能开发中')}
onClick={() => Toast.show('功能开发中')} style={{ cursor: 'pointer' }}
clickable
> >
<Space>
<RightOutline /> <SettingOutlined />
<span></span>
</Space>
<RightOutlined />
</List.Item> </List.Item>
</List> </List>
</Card>
{/* 退出登录按钮 */} {/* 退出登录按钮 */}
{userInfo && ( {userInfo && (
<div className={styles.logoutContainer}> <Button
<Button block color="danger" onClick={handleLogout}> danger
block
size="large"
onClick={handleLogout}
className={styles.logoutButton}
>
退 退
</Button> </Button>
</div>
)} )}
</div> </div>
</div>
) )
} }

View File

@ -1,240 +1,102 @@
// 变量定义 .container {
@bg-color: #f5f5f5;
@white: #fff;
@primary-color: #1677ff;
@text-color: #333;
@text-secondary: #666;
@text-tertiary: #888;
@border-color: #e8e8e8;
@shadow-light: 0 2px 8px rgba(0, 0, 0, 0.06);
@shadow-medium: 0 2px 8px rgba(0, 0, 0, 0.08);
@success-bg: #f6ffed;
@success-border: #b7eb8f;
@success-color: #52c41a;
@error-bg: #fff2f0;
@error-border: #ffccc7;
@error-color: #ff4d4f;
// 使用 :global 包裹所有样式,因为 Question 组件使用直接类名
:global {
// 页面容器
.question-page {
min-height: 100vh; min-height: 100vh;
background: @bg-color; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 20px;
padding-bottom: 80px; padding-bottom: 80px;
} }
// 头部 .content {
max-width: 900px;
margin: 0 auto;
}
.header { .header {
background: @white;
padding: 16px 20px;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
box-shadow: @shadow-light; margin-bottom: 24px;
position: sticky;
top: 0;
z-index: 100;
h1 { .title {
margin: 0; color: white !important;
font-size: 20px; margin: 0 !important;
font-weight: 600;
color: @primary-color;
} }
} }
// 内容区域 .questionCard {
.content { border-radius: 16px;
padding: 16px; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
margin-bottom: 24px;
} }
// 题目部分 .questionNumber {
.question-header { color: #666;
display: flex; margin: 8px 0 16px 0 !important;
gap: 8px;
margin-bottom: 12px;
} }
.question-number { .questionContent {
font-size: 14px;
color: @text-secondary;
margin-bottom: 12px;
}
.question-content {
font-size: 18px; font-size: 18px;
font-weight: 500;
color: @text-color;
line-height: 1.6; line-height: 1.6;
margin-bottom: 8px; color: #333;
margin-bottom: 16px;
// 填空题样式
&.fill-content {
display: flex;
flex-wrap: wrap;
align-items: center;
span {
line-height: 2;
} }
.fill-input { .fillInput {
:global(.adm-input-element) { border-bottom: 2px solid #1677ff !important;
border: none; border-radius: 0 !important;
border-bottom: 2px solid @primary-color;
border-radius: 0;
padding: 4px 8px;
text-align: center;
font-weight: 600;
color: @primary-color;
&::placeholder {
color: #bfbfbf;
font-weight: normal;
}
}
}
}
} }
// 答案结果 .buttonGroup {
.answer-result {
margin-top: 20px;
padding: 16px;
border-radius: 8px;
background: @success-bg;
border: 1px solid @success-border;
&.wrong {
background: @error-bg;
border: 1px solid @error-border;
}
.result-icon {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 8px;
}
&.correct .result-icon {
color: @success-color;
}
&.wrong .result-icon {
color: @error-color;
}
.result-text {
font-size: 16px;
font-weight: 600;
margin-bottom: 12px;
text-align: center;
}
.correct-answer {
font-size: 14px;
color: @text-secondary;
margin-bottom: 8px;
}
.explanation {
font-size: 14px;
color: @text-tertiary;
line-height: 1.5;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid @border-color;
}
}
// 按钮组
.button-group {
margin-top: 24px; margin-top: 24px;
} }
.action-buttons { .actionButtons {
display: flex; display: flex;
gap: 8px; gap: 12px;
padding: 16px; flex-wrap: wrap;
background: @white; justify-content: center;
position: sticky; }
bottom: 0;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06);
// 响应式设计 - 移动端
@media (max-width: 768px) {
.container {
padding: 12px;
padding-bottom: 70px;
}
.header {
flex-direction: column;
gap: 12px;
align-items: flex-start;
.title {
font-size: 20px !important;
}
}
.questionCard {
border-radius: 12px;
margin-bottom: 16px;
}
.questionContent {
font-size: 16px;
}
.actionButtons {
button { button {
flex: 1; flex: 1;
min-width: 100px;
}
} }
} }
// 统计内容 // 响应式设计 - PC端
.stats-content { @media (min-width: 769px) {
padding: 20px; .container {
padding: 32px;
h2 {
margin: 0 0 20px 0;
text-align: center;
font-size: 20px;
}
} }
.stat-item { .header {
display: flex; margin-bottom: 32px;
justify-content: space-between;
align-items: center;
padding: 16px 0;
border-bottom: 1px solid #f0f0f0;
&:last-child {
border-bottom: none;
}
span {
font-size: 16px;
color: @text-secondary;
}
strong {
font-size: 20px;
color: @primary-color;
}
}
// 筛选内容
.filter-content {
padding: 20px;
h2 {
margin: 0 0 20px 0;
text-align: center;
font-size: 20px;
}
}
.filter-group {
margin-bottom: 20px;
p {
margin: 0 0 12px 0;
font-size: 14px;
color: @text-secondary;
font-weight: 500;
}
}
// 覆盖 antd-mobile 样式
.adm-card {
border-radius: 12px;
box-shadow: @shadow-medium;
}
.adm-list-item {
padding: 12px 16px;
}
.adm-modal-body {
max-height: 70vh;
overflow-y: auto;
} }
} }

View File

@ -7,23 +7,31 @@ import {
Radio, Radio,
Checkbox, Checkbox,
Input, Input,
Toast, message,
Dialog,
Modal, Modal,
List, List,
Tag, Tag,
Selector, Select,
} from 'antd-mobile' Statistic,
Alert,
Typography,
Row,
Col,
} from 'antd'
import { import {
RightOutline, CheckOutlined,
CloseOutline, CloseOutlined,
PieOutline, PieChartOutlined,
UnorderedListOutline, UnorderedListOutlined,
FilterOutline, FilterOutlined,
} from 'antd-mobile-icons' ReloadOutlined,
} 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 './Question.module.less' import styles from './Question.module.less'
const { TextArea } = Input
const { Title, Text } = Typography
const QuestionPage: React.FC = () => { const QuestionPage: React.FC = () => {
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams()
@ -34,7 +42,7 @@ const QuestionPage: React.FC = () => {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [allQuestions, setAllQuestions] = useState<Question[]>([]) const [allQuestions, setAllQuestions] = useState<Question[]>([])
const [currentIndex, setCurrentIndex] = useState(0) const [currentIndex, setCurrentIndex] = useState(0)
const [fillAnswers, setFillAnswers] = useState<string[]>([]) // 填空题答案数组 const [fillAnswers, setFillAnswers] = useState<string[]>([])
// 统计弹窗 // 统计弹窗
const [statsVisible, setStatsVisible] = useState(false) const [statsVisible, setStatsVisible] = useState(false)
@ -50,8 +58,8 @@ const QuestionPage: React.FC = () => {
// 筛选弹窗 // 筛选弹窗
const [filterVisible, setFilterVisible] = useState(false) const [filterVisible, setFilterVisible] = useState(false)
const [filterType, setFilterType] = useState('') const [filterType, setFilterType] = useState<string | undefined>(undefined)
const [filterCategory, setFilterCategory] = useState('') const [filterCategory, setFilterCategory] = useState<string | undefined>(undefined)
// 加载随机题目 // 加载随机题目
const loadRandomQuestion = async () => { const loadRandomQuestion = async () => {
@ -66,7 +74,7 @@ const QuestionPage: React.FC = () => {
setAnswerResult(null) setAnswerResult(null)
} }
} catch (error) { } catch (error) {
Toast.show('加载题目失败') message.error('加载题目失败')
} finally { } finally {
setLoading(false) setLoading(false)
} }
@ -89,7 +97,7 @@ const QuestionPage: React.FC = () => {
} }
} }
} catch (error) { } catch (error) {
Toast.show('加载题目列表失败') message.error('加载题目列表失败')
} finally { } finally {
setLoading(false) setLoading(false)
} }
@ -103,7 +111,7 @@ const QuestionPage: React.FC = () => {
setStatistics(res.data) setStatistics(res.data)
} }
} catch (error) { } catch (error) {
Toast.show('加载统计失败') message.error('加载统计失败')
} }
} }
@ -113,21 +121,18 @@ const QuestionPage: React.FC = () => {
// 检查是否选择了答案 // 检查是否选择了答案
if (currentQuestion.type === 'multiple') { if (currentQuestion.type === 'multiple') {
// 多选题:检查数组长度
if ((selectedAnswer as string[]).length === 0) { if ((selectedAnswer as string[]).length === 0) {
Toast.show('请选择答案') message.warning('请选择答案')
return return
} }
} else if (currentQuestion.type === 'fill') { } else if (currentQuestion.type === 'fill') {
// 填空题:检查所有填空是否都已填写
if (fillAnswers.length === 0 || fillAnswers.some(a => !a || a.trim() === '')) { if (fillAnswers.length === 0 || fillAnswers.some(a => !a || a.trim() === '')) {
Toast.show('请填写所有空格') message.warning('请填写所有空格')
return return
} }
} else { } else {
// 单选题、判断题、简答题:检查是否有值
if (!selectedAnswer || (typeof selectedAnswer === 'string' && selectedAnswer.trim() === '')) { if (!selectedAnswer || (typeof selectedAnswer === 'string' && selectedAnswer.trim() === '')) {
Toast.show('请填写答案') message.warning('请填写答案')
return return
} }
} }
@ -144,13 +149,13 @@ const QuestionPage: React.FC = () => {
setShowResult(true) setShowResult(true)
if (res.data.correct) { if (res.data.correct) {
Toast.show({ icon: 'success', content: '回答正确!' }) message.success('回答正确!')
} else { } else {
Toast.show({ icon: 'fail', content: '回答错误' }) message.error('回答错误')
} }
} }
} catch (error) { } catch (error) {
Toast.show('提交失败') message.error('提交失败')
} finally { } finally {
setLoading(false) setLoading(false)
} }
@ -190,32 +195,28 @@ const QuestionPage: React.FC = () => {
// 重置进度 // 重置进度
const handleReset = async () => { const handleReset = async () => {
const result = await Dialog.confirm({ Modal.confirm({
content: '确定要重置答题进度吗?', title: '确定要重置答题进度吗?',
}) onOk: async () => {
if (result) {
try { try {
await questionApi.resetProgress() await questionApi.resetProgress()
Toast.show('重置成功') message.success('重置成功')
loadStatistics() loadStatistics()
} catch (error) { } catch (error) {
Toast.show('重置失败') message.error('重置失败')
}
} }
},
})
} }
// 初始化 // 初始化
useEffect(() => { useEffect(() => {
// 从URL参数获取题型
const typeParam = searchParams.get('type') const typeParam = searchParams.get('type')
const categoryParam = searchParams.get('category') const categoryParam = searchParams.get('category')
if (typeParam || categoryParam) { if (typeParam || categoryParam) {
// 如果有筛选参数,加载筛选后的题目列表
loadQuestions(typeParam || undefined, categoryParam || undefined) loadQuestions(typeParam || undefined, categoryParam || undefined)
} else { } else {
// 否则加载随机题目
loadRandomQuestion() loadRandomQuestion()
loadQuestions() loadQuestions()
} }
@ -233,36 +234,34 @@ const QuestionPage: React.FC = () => {
return typeMap[type] || type return typeMap[type] || type
} }
// 渲染填空题内容(将****替换为输入框) // 渲染填空题内容
const renderFillContent = () => { const renderFillContent = () => {
if (!currentQuestion) return null if (!currentQuestion) return null
const content = currentQuestion.content const content = currentQuestion.content
const parts = content.split('****') const parts = content.split('****')
// 如果没有****,说明不是填空题格式
if (parts.length === 1) { if (parts.length === 1) {
return <div className="question-content">{content}</div> return <div className={styles.questionContent}>{content}</div>
} }
// 初始化填空答案数组
if (fillAnswers.length === 0) { if (fillAnswers.length === 0) {
setFillAnswers(new Array(parts.length - 1).fill('')) setFillAnswers(new Array(parts.length - 1).fill(''))
} }
return ( return (
<div className="question-content fill-content"> <div className={styles.questionContent}>
{parts.map((part, index) => ( {parts.map((part, index) => (
<React.Fragment key={index}> <React.Fragment key={index}>
<span>{part}</span> <span>{part}</span>
{index < parts.length - 1 && ( {index < parts.length - 1 && (
<Input <Input
className="fill-input" className={styles.fillInput}
placeholder={`填空${index + 1}`} placeholder={`填空${index + 1}`}
value={fillAnswers[index] || ''} value={fillAnswers[index] || ''}
onChange={(val) => { onChange={(e) => {
const newAnswers = [...fillAnswers] const newAnswers = [...fillAnswers]
newAnswers[index] = val newAnswers[index] = e.target.value
setFillAnswers(newAnswers) setFillAnswers(newAnswers)
setSelectedAnswer(newAnswers) setSelectedAnswer(newAnswers)
}} }}
@ -271,8 +270,6 @@ const QuestionPage: React.FC = () => {
display: 'inline-block', display: 'inline-block',
width: '120px', width: '120px',
margin: '0 8px', margin: '0 8px',
borderBottom: '2px solid #1677ff',
borderRadius: 0,
}} }}
/> />
)} )}
@ -286,34 +283,39 @@ const QuestionPage: React.FC = () => {
const renderOptions = () => { const renderOptions = () => {
if (!currentQuestion) return null if (!currentQuestion) return null
// 填空题:使用特殊渲染(在题目内容中嵌入输入框)
if (currentQuestion.type === 'fill') { if (currentQuestion.type === 'fill') {
return null // 填空题的输入框已在题目内容中渲染 return null
} }
// 简答题:使用大文本框
if (currentQuestion.type === 'short') { if (currentQuestion.type === 'short') {
return ( return (
<Input <TextArea
placeholder="请输入答案" placeholder="请输入答案"
value={selectedAnswer as string} value={selectedAnswer as string}
onChange={(val) => setSelectedAnswer(val)} onChange={(e) => setSelectedAnswer(e.target.value)}
disabled={showResult} disabled={showResult}
style={{ marginTop: 20, minHeight: 100 }} rows={4}
style={{ marginTop: 20 }}
/> />
) )
} }
if (currentQuestion.type === 'multiple') { if (currentQuestion.type === 'multiple') {
// 按ABCD顺序排序选项
const sortedOptions = [...currentQuestion.options].sort((a, b) =>
a.key.localeCompare(b.key)
)
return ( return (
<Checkbox.Group <Checkbox.Group
value={selectedAnswer as string[]} value={selectedAnswer as string[]}
onChange={(val) => setSelectedAnswer(val as string[])} onChange={(val) => setSelectedAnswer(val as string[])}
disabled={showResult} disabled={showResult}
style={{ width: '100%', marginTop: 20 }}
> >
<Space direction="vertical" style={{ width: '100%', marginTop: 20 }}> <Space direction="vertical" style={{ width: '100%' }}>
{currentQuestion.options.map((option) => ( {sortedOptions.map((option) => (
<Checkbox key={option.key} value={option.key} style={{ '--icon-size': '20px' }}> <Checkbox key={option.key} value={option.key}>
<span style={{ fontSize: 16 }}> <span style={{ fontSize: 16 }}>
{option.key}. {option.value} {option.key}. {option.value}
</span> </span>
@ -324,18 +326,24 @@ const QuestionPage: React.FC = () => {
) )
} }
// 单选和判断题 // 按ABCD顺序排序选项
const sortedOptions = [...currentQuestion.options].sort((a, b) =>
a.key.localeCompare(b.key)
)
return ( return (
<Radio.Group <Radio.Group
value={selectedAnswer as string} value={selectedAnswer as string}
onChange={(val) => setSelectedAnswer(val as string)} onChange={(e) => setSelectedAnswer(e.target.value)}
disabled={showResult} disabled={showResult}
style={{ width: '100%', marginTop: 20 }}
> >
<Space direction="vertical" style={{ width: '100%', marginTop: 20 }}> <Space direction="vertical" style={{ width: '100%' }}>
{currentQuestion.options.map((option) => ( {sortedOptions.map((option) => (
<Radio key={option.key} value={option.key} style={{ '--icon-size': '20px' }}> <Radio key={option.key} value={option.key}>
<span style={{ fontSize: 16 }}> <span style={{ fontSize: 16 }}>
{option.key}. {option.value} {/* 判断题不显示A、B只显示选项内容 */}
{currentQuestion.type === 'judge' ? option.value : `${option.key}. ${option.value}`}
</span> </span>
</Radio> </Radio>
))} ))}
@ -345,67 +353,112 @@ const QuestionPage: React.FC = () => {
} }
return ( return (
<div className="question-page"> <div className={styles.container}>
<div className={styles.content}>
{/* 头部 */} {/* 头部 */}
<div className="header"> <div className={styles.header}>
<h1>AnKao </h1> <Title level={3} className={styles.title}>AnKao </Title>
<Button <Button
size="small" type="primary"
color="primary" icon={<PieChartOutlined />}
fill="outline"
onClick={() => { onClick={() => {
loadStatistics() loadStatistics()
setStatsVisible(true) setStatsVisible(true)
}} }}
> >
<PieOutline />
</Button> </Button>
</div> </div>
{/* 题目卡片 */} {/* 题目卡片 */}
<div className="content"> <Card className={styles.questionCard}>
<Card>
{currentQuestion && ( {currentQuestion && (
<> <>
<div className="question-header"> <Space size="small" style={{ marginBottom: 16 }}>
<Tag color="primary">{getTypeName(currentQuestion.type)}</Tag> <Tag color="blue">{getTypeName(currentQuestion.type)}</Tag>
<Tag color="success">{currentQuestion.category}</Tag> <Tag color="green">{currentQuestion.category}</Tag>
</div> </Space>
<div className="question-number"> {currentQuestion.id} </div> <Title level={5} className={styles.questionNumber}>
{currentQuestion.question_id || currentQuestion.id}
</Title>
{/* 填空题使用特殊渲染 */}
{currentQuestion.type === 'fill' ? renderFillContent() : ( {currentQuestion.type === 'fill' ? renderFillContent() : (
<div className="question-content">{currentQuestion.content}</div> <div className={styles.questionContent}>{currentQuestion.content}</div>
)} )}
{renderOptions()} {renderOptions()}
{/* 答案结果 */} {/* 答案结果 */}
{showResult && answerResult && ( {showResult && answerResult && (
<div className={`answer-result ${answerResult.correct ? 'correct' : 'wrong'}`}> <Alert
<div className="result-icon"> type={answerResult.correct ? 'success' : 'error'}
{answerResult.correct ? <RightOutline fontSize={24} /> : <CloseOutline fontSize={24} />} icon={answerResult.correct ? <CheckOutlined /> : <CloseOutlined />}
message={
<div>
<strong>{answerResult.correct ? '回答正确!' : '回答错误'}</strong>
</div> </div>
<div className="result-text">{answerResult.correct ? '回答正确!' : '回答错误'}</div> }
<div className="correct-answer"> description={
<div>
{Array.isArray(answerResult.correct_answer) <div style={{ marginBottom: 8 }}>
? answerResult.correct_answer.join(', ') <Text type="secondary"></Text>
: answerResult.correct_answer} <Text strong={answerResult.correct} type={answerResult.correct ? undefined : 'danger'}>
{(() => {
const answer = Array.isArray(selectedAnswer)
? selectedAnswer.join(', ')
: selectedAnswer;
// 判断题显示文字而不是 true/false
if (currentQuestion?.type === 'judge') {
return answer === 'true' ? '正确' : answer === 'false' ? '错误' : answer;
}
return answer;
})()}
</Text>
</div> </div>
{answerResult.explanation && <div className="explanation">{answerResult.explanation}</div>} <div style={{ marginBottom: 8 }}>
<Text strong style={{ color: '#52c41a' }}></Text>
<Text strong style={{ color: '#52c41a' }}>
{(() => {
const correctAnswer = answerResult.correct_answer || (answerResult.correct ? selectedAnswer : '');
let displayAnswer = Array.isArray(correctAnswer)
? correctAnswer.join(', ')
: correctAnswer || '暂无';
// 判断题显示文字而不是 true/false
if (currentQuestion?.type === 'judge') {
displayAnswer = displayAnswer === 'true' ? '正确' : displayAnswer === 'false' ? '错误' : displayAnswer;
}
return displayAnswer;
})()}
</Text>
</div> </div>
{answerResult.explanation && (
<div>
<Text type="secondary"></Text>
<div style={{ marginTop: 4 }}>{answerResult.explanation}</div>
</div>
)}
</div>
}
style={{ marginTop: 20 }}
/>
)} )}
{/* 按钮 */} {/* 按钮 */}
<div className="button-group"> <div className={styles.buttonGroup}>
{!showResult ? ( {!showResult ? (
<Button block color="primary" size="large" onClick={handleSubmit} loading={loading}> <Button
type="primary"
size="large"
block
onClick={handleSubmit}
loading={loading}
>
</Button> </Button>
) : ( ) : (
<Button block color="primary" size="large" onClick={handleNext}> <Button type="primary" size="large" block onClick={handleNext}>
</Button> </Button>
)} )}
@ -413,147 +466,126 @@ const QuestionPage: React.FC = () => {
</> </>
)} )}
</Card> </Card>
</div>
{/* 功能按钮 */} {/* 功能按钮 */}
<div className="action-buttons"> <div className={styles.actionButtons}>
<Button <Button icon={<ReloadOutlined />} onClick={loadRandomQuestion}>
onClick={loadRandomQuestion}
color="primary"
fill="outline"
size="small"
>
🎲
</Button> </Button>
<Button <Button icon={<UnorderedListOutlined />} onClick={() => setListVisible(true)}>
onClick={() => setListVisible(true)}
color="primary"
fill="outline"
size="small"
>
<UnorderedListOutline />
</Button> </Button>
<Button <Button icon={<FilterOutlined />} onClick={() => setFilterVisible(true)}>
onClick={() => setFilterVisible(true)}
color="primary"
fill="outline"
size="small"
>
<FilterOutline />
</Button> </Button>
</div> </div>
</div>
{/* 统计弹窗 */} {/* 统计弹窗 */}
<Modal <Modal
visible={statsVisible} title="答题统计"
content={ open={statsVisible}
<div className="stats-content"> onCancel={() => setStatsVisible(false)}
<h2></h2> footer={[
<div className="stat-item"> <Button key="reset" danger onClick={handleReset}>
<span></span>
<strong>{statistics.total_questions}</strong>
</div>
<div className="stat-item">
<span></span>
<strong>{statistics.answered_questions}</strong>
</div>
<div className="stat-item">
<span></span>
<strong>{statistics.correct_answers}</strong>
</div>
<div className="stat-item">
<span></span>
<strong>{statistics.accuracy.toFixed(1)}%</strong>
</div>
<Button block color="danger" onClick={handleReset} style={{ marginTop: 20 }}>
</Button> </Button>,
</div> <Button key="close" onClick={() => setStatsVisible(false)}>
}
closeOnAction </Button>,
onClose={() => setStatsVisible(false)} ]}
actions={[{ key: 'close', text: '关闭' }]} >
<Row gutter={16}>
<Col span={12}>
<Statistic title="题库总数" value={statistics.total_questions} />
</Col>
<Col span={12}>
<Statistic title="已答题数" value={statistics.answered_questions} />
</Col>
<Col span={12} style={{ marginTop: 20 }}>
<Statistic title="正确数" value={statistics.correct_answers} />
</Col>
<Col span={12} style={{ marginTop: 20 }}>
<Statistic
title="正确率"
value={statistics.accuracy.toFixed(1)}
suffix="%"
/> />
</Col>
</Row>
</Modal>
{/* 题目列表弹窗 */} {/* 题目列表弹窗 */}
<Modal <Modal
visible={listVisible} title="题目列表"
content={ open={listVisible}
<div> onCancel={() => setListVisible(false)}
<h2></h2> footer={null}
<List> width={600}
{allQuestions.map((q, index) => ( >
<List
dataSource={allQuestions}
renderItem={(q, index) => (
<List.Item <List.Item
key={q.id}
onClick={() => handleSelectQuestion(q, index)} onClick={() => handleSelectQuestion(q, index)}
arrow={false} style={{ cursor: 'pointer' }}
>
<List.Item.Meta
title={`${q.id}. ${q.content}`}
description={ description={
<Space> <Space>
<Tag color="primary" fill="outline"> <Tag color="blue">{getTypeName(q.type)}</Tag>
{getTypeName(q.type)} <Tag color="green">{q.category}</Tag>
</Tag>
<Tag color="success" fill="outline">
{q.category}
</Tag>
</Space> </Space>
} }
>
{q.id}. {q.content}
</List.Item>
))}
</List>
</div>
}
closeOnAction
onClose={() => setListVisible(false)}
actions={[{ key: 'close', text: '关闭' }]}
bodyStyle={{ maxHeight: '60vh', overflow: 'auto' }}
/> />
</List.Item>
)}
style={{ maxHeight: '400px', overflow: 'auto' }}
/>
</Modal>
{/* 筛选弹窗 */} {/* 筛选弹窗 */}
<Modal <Modal
visible={filterVisible} title="筛选题目"
content={ open={filterVisible}
<div className="filter-content"> onCancel={() => setFilterVisible(false)}
<h2></h2> onOk={handleApplyFilter}
<div className="filter-group"> >
<p></p> <Space direction="vertical" style={{ width: '100%' }}>
<Selector <div>
options={[ <Text></Text>
{ label: '全部', value: '' }, <Select
{ label: '单选题', value: 'single' }, placeholder="选择题目类型"
{ label: '多选题', value: 'multiple' }, value={filterType}
{ label: '填空题', value: 'fill' }, onChange={setFilterType}
{ label: '判断题', value: 'judge' }, style={{ width: '100%', marginTop: 8 }}
{ label: '简答题', value: 'short' }, allowClear
]} >
value={[filterType]} <Select.Option value="single"></Select.Option>
onChange={(arr) => setFilterType(arr[0] || '')} <Select.Option value="multiple"></Select.Option>
/> <Select.Option value="fill"></Select.Option>
<Select.Option value="judge"></Select.Option>
<Select.Option value="short"></Select.Option>
</Select>
</div> </div>
<div className="filter-group"> <div>
<p></p> <Text></Text>
<Selector <Select
options={[ placeholder="选择分类"
{ label: '全部', value: '' }, value={filterCategory}
{ label: 'Go语言基础', value: 'Go语言基础' }, onChange={setFilterCategory}
{ label: '前端开发', value: '前端开发' }, style={{ width: '100%', marginTop: 8 }}
{ label: '计算机网络', value: '计算机网络' }, allowClear
{ label: '计算机基础', value: '计算机基础' }, >
]} <Select.Option value="Go语言基础">Go语言基础</Select.Option>
value={[filterCategory]} <Select.Option value="前端开发"></Select.Option>
onChange={(arr) => setFilterCategory(arr[0] || '')} <Select.Option value="计算机网络"></Select.Option>
/> <Select.Option value="计算机基础"></Select.Option>
</Select>
</div> </div>
<Button block color="primary" onClick={handleApplyFilter}> </Space>
</Modal>
</Button>
</div>
}
closeOnAction
onClose={() => setFilterVisible(false)}
actions={[{ key: 'close', text: '关闭' }]}
/>
</div> </div>
) )
} }

View File

@ -10,6 +10,7 @@ export interface Option {
// 题目 // 题目
export interface Question { export interface Question {
id: number id: number
question_id: string // 题目编号
type: QuestionType type: QuestionType
content: string content: string
options: Option[] options: Option[]

View File

@ -14,9 +14,11 @@ export default defineConfig({
preprocessorOptions: { preprocessorOptions: {
less: { less: {
javascriptEnabled: true, javascriptEnabled: true,
// 可以在这里添加全局 Less 变量 // antd 主题定制
modifyVars: { modifyVars: {
// 例如: '@primary-color': '#1DA57A', '@primary-color': '#1677ff', // 主色调
'@link-color': '#1677ff', // 链接色
'@border-radius-base': '8px', // 组件圆角
}, },
}, },
}, },

File diff suppressed because it is too large Load Diff