重构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:
parent
a7ede7692f
commit
e722180c07
33
CLAUDE.md
33
CLAUDE.md
@ -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端优化**:
|
||||||
|
- 内容居中,最大宽度限制
|
||||||
|
- 隐藏移动端特有的底部导航栏
|
||||||
|
- 更大的字体和间距
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"` // 选择题选项数组
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
63
web/src/components/TabBarLayout.module.less
Normal file
63
web/src/components/TabBarLayout.module.less
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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[]
|
||||||
|
|||||||
@ -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', // 组件圆角
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
1072
web/yarn.lock
1072
web/yarn.lock
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user