重构题目练习模式,优化用户体验

主要变更:
- 移除独立的随机题目 API 和快速开始卡片
- 添加应用图标 (icon.svg) 和品牌标识
- 优化首页布局,添加 logo 和"安全保密考试题库"标语
- 将随机模式改为答题页面内的可选开关(默认关闭)
- 改进错题练习逻辑,单独处理随机错题功能
- 同步更新 README.md 文档

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
燕陇琪 2025-11-07 22:45:44 +08:00
parent ac3249a4a4
commit b30647d81b
9 changed files with 214 additions and 58 deletions

View File

@ -64,7 +64,6 @@ yarn dev
#### 练习题相关 #### 练习题相关
- `GET /api/practice/questions` - 获取练习题目列表 (支持分页和类型过滤) - `GET /api/practice/questions` - 获取练习题目列表 (支持分页和类型过滤)
- `GET /api/practice/questions/random` - 获取随机练习题目
- `GET /api/practice/questions/:id` - 获取指定练习题目 - `GET /api/practice/questions/:id` - 获取指定练习题目
- `POST /api/practice/submit` - 提交练习答案 (简答题自动AI评分) - `POST /api/practice/submit` - 提交练习答案 (简答题自动AI评分)
- `GET /api/practice/types` - 获取题型列表 - `GET /api/practice/types` - 获取题型列表
@ -224,7 +223,7 @@ yarn build
## 页面结构 ## 页面结构
- **登录页** (`/login`) - 用户登录和注册,支持密码可见性切换 - **登录页** (`/login`) - 用户登录和注册,支持密码可见性切换
- **首页** (`/`) - 题目练习、随机题目、题目列表、筛选等功能 - **首页** (`/`) - 题型选择、错题本、题目列表等功能
- **我的** (`/profile`) - 用户信息、退出登录 - **我的** (`/profile`) - 用户信息、退出登录
## 特性 ## 特性
@ -238,7 +237,6 @@ yarn build
- 密码bcrypt加密存储 - 密码bcrypt加密存储
- 练习题管理系统236道练习题5种题型 - 练习题管理系统236道练习题5种题型
- 支持分页查询和题型筛选 - 支持分页查询和题型筛选
- 随机题目推送功能
- **AI智能评分系统** - 使用deepseek-v3对简答题进行智能评分和反馈 - **AI智能评分系统** - 使用deepseek-v3对简答题进行智能评分和反馈
### 前端特性 ### 前端特性

17
main.go
View File

@ -43,22 +43,21 @@ func main() {
auth.PUT("/user/type", handlers.UpdateUserType) // 更新用户类型 auth.PUT("/user/type", handlers.UpdateUserType) // 更新用户类型
// 练习题相关API需要登录 // 练习题相关API需要登录
auth.GET("/practice/questions", handlers.GetPracticeQuestions) // 获取练习题目列表 auth.GET("/practice/questions", handlers.GetPracticeQuestions) // 获取练习题目列表
auth.GET("/practice/questions/random", handlers.GetRandomPracticeQuestion) // 获取随机练习题目 auth.GET("/practice/questions/:id", handlers.GetPracticeQuestionByID) // 获取指定练习题目
auth.GET("/practice/questions/:id", handlers.GetPracticeQuestionByID) // 获取指定练习题目 auth.POST("/practice/explain", handlers.ExplainQuestion) // 生成题目解析AI
auth.POST("/practice/explain", handlers.ExplainQuestion) // 生成题目解析AI
// 练习题提交(需要登录才能记录错题) // 练习题提交(需要登录才能记录错题)
auth.POST("/practice/submit", handlers.SubmitPracticeAnswer) // 提交练习答案 auth.POST("/practice/submit", handlers.SubmitPracticeAnswer) // 提交练习答案
auth.GET("/practice/statistics", handlers.GetStatistics) // 获取统计数据 auth.GET("/practice/statistics", handlers.GetStatistics) // 获取统计数据
// 错题本相关API // 错题本相关API
auth.GET("/wrong-questions", handlers.GetWrongQuestions) // 获取错题列表 auth.GET("/wrong-questions", handlers.GetWrongQuestions) // 获取错题列表
auth.GET("/wrong-questions/stats", handlers.GetWrongQuestionStats) // 获取错题统计 auth.GET("/wrong-questions/stats", handlers.GetWrongQuestionStats) // 获取错题统计
auth.GET("/wrong-questions/random", handlers.GetRandomWrongQuestion) // 获取随机错题 auth.GET("/wrong-questions/random", handlers.GetRandomWrongQuestion) // 获取随机错题
auth.DELETE("/wrong-questions/:id", handlers.DeleteWrongQuestion) // 删除单个错题 auth.DELETE("/wrong-questions/:id", handlers.DeleteWrongQuestion) // 删除单个错题
auth.PUT("/wrong-questions/:id/mastered", handlers.MarkWrongQuestionMastered) // 标记已掌握 auth.PUT("/wrong-questions/:id/mastered", handlers.MarkWrongQuestionMastered) // 标记已掌握
auth.DELETE("/wrong-questions", handlers.ClearWrongQuestions) // 清空错题本 auth.DELETE("/wrong-questions", handlers.ClearWrongQuestions) // 清空错题本
} }
// 题库管理API需要管理员权限 // 题库管理API需要管理员权限

View File

@ -2,10 +2,12 @@
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/icon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="description" content="AnKao 移动端应用" /> <meta name="description" content="AnKao - 安全保密考试系统" />
<title>AnKao</title> <meta name="keywords" content="安全考试,保密考试,在线答题,考试系统" />
<meta name="theme-color" content="#1890ff" />
<title>AnKao - 安全保密考试</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

117
web/public/icon.svg Normal file
View File

@ -0,0 +1,117 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" width="200" height="200">
<defs>
<linearGradient id="shieldGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#1890ff;stop-opacity:1" />
<stop offset="100%" style="stop-color:#096dd9;stop-opacity:1" />
</linearGradient>
<linearGradient id="lockGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#52c41a;stop-opacity:1" />
<stop offset="100%" style="stop-color:#389e0d;stop-opacity:1" />
</linearGradient>
<filter id="shadow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur in="SourceAlpha" stdDeviation="3"/>
<feOffset dx="0" dy="2" result="offsetblur"/>
<feComponentTransfer>
<feFuncA type="linear" slope="0.3"/>
</feComponentTransfer>
<feMerge>
<feMergeNode/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<!-- 背景圆形 -->
<circle cx="100" cy="100" r="95" fill="#f0f5ff" stroke="#d6e4ff" stroke-width="2"/>
<!-- 主盾牌形状 -->
<path d="M 100 25
C 80 25, 60 30, 45 40
L 45 85
C 45 120, 65 145, 100 165
C 135 145, 155 120, 155 85
L 155 40
C 140 30, 120 25, 100 25 Z"
fill="url(#shieldGradient)"
stroke="#0050b3"
stroke-width="2.5"
filter="url(#shadow)"/>
<!-- 盾牌内部高光 -->
<path d="M 100 30
C 82 30, 65 34, 52 42
L 52 85
C 52 115, 70 138, 100 156"
fill="none"
stroke="rgba(255,255,255,0.4)"
stroke-width="2.5"
stroke-linecap="round"/>
<!-- 文档/试卷图标 -->
<rect x="75" y="60" width="50" height="65" rx="3" ry="3"
fill="#ffffff"
stroke="#0050b3"
stroke-width="2"/>
<!-- 试卷标题线 -->
<line x1="82" y1="70" x2="118" y2="70"
stroke="#1890ff"
stroke-width="2.5"
stroke-linecap="round"/>
<!-- 试卷内容线条 -->
<line x1="82" y1="80" x2="112" y2="80"
stroke="#8cc5ff"
stroke-width="2"
stroke-linecap="round"/>
<line x1="82" y1="88" x2="115" y2="88"
stroke="#8cc5ff"
stroke-width="2"
stroke-linecap="round"/>
<line x1="82" y1="96" x2="108" y2="96"
stroke="#8cc5ff"
stroke-width="2"
stroke-linecap="round"/>
<!-- 锁的主体 -->
<rect x="88" y="105" width="24" height="15" rx="2" ry="2"
fill="url(#lockGradient)"
stroke="#237804"
stroke-width="1.5"/>
<!-- 锁的U形环 -->
<path d="M 93 105
L 93 98
A 7 7 0 0 1 107 98
L 107 105"
fill="none"
stroke="url(#lockGradient)"
stroke-width="3"
stroke-linecap="round"/>
<!-- 锁孔 -->
<circle cx="100" cy="110" r="2" fill="#ffffff"/>
<rect x="99" y="110" width="2" height="4" rx="1" fill="#ffffff"/>
<!-- 装饰性星星(表示重要性) -->
<path d="M 135 45 l 2 6 l 6 1 l -5 4 l 2 6 l -5 -3 l -5 3 l 2 -6 l -5 -4 l 6 -1 Z"
fill="#faad14"
stroke="#d48806"
stroke-width="0.5"
opacity="0.9"/>
<!-- 感叹号(警示标志) -->
<g opacity="0.9">
<rect x="140" y="130" width="3.5" height="15" rx="1.5" fill="#ff4d4f"/>
<circle cx="141.75" cy="148" r="2" fill="#ff4d4f"/>
</g>
<!-- 对勾标记(考试通过) -->
<path d="M 60 135 L 65 142 L 75 128"
fill="none"
stroke="#52c41a"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
opacity="0.85"/>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

@ -6,11 +6,6 @@ export const getQuestions = (params?: { type?: string; search?: string }) => {
return request.get<ApiResponse<Question[]>>('/practice/questions', { params }) return request.get<ApiResponse<Question[]>>('/practice/questions', { params })
} }
// 获取随机题目
export const getRandomQuestion = () => {
return request.get<ApiResponse<Question>>('/practice/questions/random')
}
// 获取指定题目 // 获取指定题目
export const getQuestionById = (id: number) => { export const getQuestionById = (id: number) => {
return request.get<ApiResponse<Question>>(`/practice/questions/${id}`) return request.get<ApiResponse<Question>>(`/practice/questions/${id}`)

View File

@ -15,6 +15,20 @@
flex: 1; flex: 1;
} }
.logoArea {
display: flex;
align-items: flex-start;
gap: 16px;
}
.logo {
width: 64px;
height: 64px;
object-fit: contain;
flex-shrink: 0;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.08));
}
.title { .title {
color: #1d1d1f !important; color: #1d1d1f !important;
margin-bottom: 4px !important; margin-bottom: 4px !important;
@ -183,6 +197,15 @@
margin-bottom: 12px; margin-bottom: 12px;
} }
.logoArea {
gap: 12px;
}
.logo {
width: 48px;
height: 48px;
}
.title { .title {
font-size: 22px !important; font-size: 22px !important;
} }

View File

@ -200,8 +200,13 @@ const Home: React.FC = () => {
{/* 头部 */} {/* 头部 */}
<div className={styles.header}> <div className={styles.header}>
<div className={styles.headerLeft}> <div className={styles.headerLeft}>
<Title level={2} className={styles.title}>AnKao </Title> <div className={styles.logoArea}>
<Paragraph className={styles.subtitle}></Paragraph> <img src="/icon.svg" alt="AnKao Logo" className={styles.logo} />
<div>
<Title level={2} className={styles.title}>AnKao </Title>
<Paragraph className={styles.subtitle}></Paragraph>
</div>
</div>
</div> </div>
{/* 用户信息 */} {/* 用户信息 */}
{userInfo && ( {userInfo && (
@ -300,24 +305,6 @@ const Home: React.FC = () => {
<RocketOutlined /> <RocketOutlined />
</Title> </Title>
<Row gutter={[12, 12]}> <Row gutter={[12, 12]}>
<Col xs={24} sm={24} md={12} lg={8}>
<Card
hoverable
className={styles.quickCard}
onClick={() => navigate('/question')}
>
<Space align="center" size="middle" style={{ width: '100%' }}>
<div className={styles.quickIcon}>
<RocketOutlined style={{ fontSize: '32px', color: '#722ed1' }} />
</div>
<div style={{ flex: 1 }}>
<Title level={5} style={{ margin: 0 }}></Title>
<Paragraph type="secondary" style={{ margin: 0, fontSize: '13px' }}></Paragraph>
</div>
</Space>
</Card>
</Col>
<Col xs={24} sm={24} md={12} lg={8}> <Col xs={24} sm={24} md={12} lg={8}>
<Card <Card
hoverable hoverable

View File

@ -48,6 +48,12 @@ const QuestionPage: React.FC = () => {
return saved !== null ? parseInt(saved, 10) : 2; return saved !== null ? parseInt(saved, 10) : 2;
}); });
// 随机题目开关(默认关闭)
const [randomMode, setRandomMode] = useState(() => {
const saved = localStorage.getItem('randomModeEnabled');
return saved !== null ? saved === 'true' : false;
});
// 切换自动跳转开关 // 切换自动跳转开关
const toggleAutoNext = () => { const toggleAutoNext = () => {
const newValue = !autoNext; const newValue = !autoNext;
@ -55,6 +61,13 @@ const QuestionPage: React.FC = () => {
localStorage.setItem('autoNextEnabled', String(newValue)); localStorage.setItem('autoNextEnabled', String(newValue));
}; };
// 切换随机模式开关
const toggleRandomMode = () => {
const newValue = !randomMode;
setRandomMode(newValue);
localStorage.setItem('randomModeEnabled', String(newValue));
};
// 修改自动跳转延迟时间 // 修改自动跳转延迟时间
const handleDelayChange = (value: number | null) => { const handleDelayChange = (value: number | null) => {
if (value !== null && value >= 1 && value <= 10) { if (value !== null && value >= 1 && value <= 10) {
@ -121,16 +134,11 @@ const QuestionPage: React.FC = () => {
return 0; return 0;
}; };
// 加载随机 // 加载随机
const loadRandomQuestion = async () => { const loadRandomWrongQuestion = async () => {
setLoading(true); setLoading(true);
try { try {
// 检查是否是错题练习模式 const res = await questionApi.getRandomWrongQuestion();
const mode = searchParams.get("mode");
const res =
mode === "wrong"
? await questionApi.getRandomWrongQuestion()
: await questionApi.getRandomQuestion();
if (res.success && res.data) { if (res.success && res.data) {
setCurrentQuestion(res.data); setCurrentQuestion(res.data);
@ -258,16 +266,26 @@ const QuestionPage: React.FC = () => {
// 下一题 // 下一题
const handleNext = () => { const handleNext = () => {
if (allQuestions.length > 0) { if (allQuestions.length > 0) {
// 检查是否完成所有题目 let nextIndex: number;
if (currentIndex + 1 >= allQuestions.length) {
// 显示统计摘要 // 随机模式:从题库中随机选择一题
setShowSummary(true); if (randomMode) {
// 清除进度 // 生成一个不等于当前索引的随机索引
localStorage.removeItem(getStorageKey()); do {
return; nextIndex = Math.floor(Math.random() * allQuestions.length);
} while (nextIndex === currentIndex && allQuestions.length > 1);
} else {
// 顺序模式:检查是否完成所有题目
if (currentIndex + 1 >= allQuestions.length) {
// 显示统计摘要
setShowSummary(true);
// 清除进度
localStorage.removeItem(getStorageKey());
return;
}
nextIndex = currentIndex + 1;
} }
const nextIndex = currentIndex + 1;
setCurrentIndex(nextIndex); setCurrentIndex(nextIndex);
setCurrentQuestion(allQuestions[nextIndex]); setCurrentQuestion(allQuestions[nextIndex]);
setSelectedAnswer( setSelectedAnswer(
@ -310,7 +328,7 @@ const QuestionPage: React.FC = () => {
// 错题练习模式 // 错题练习模式
if (mode === "wrong") { if (mode === "wrong") {
loadRandomQuestion(); loadRandomWrongQuestion();
return; return;
} }
@ -462,6 +480,24 @@ const QuestionPage: React.FC = () => {
</div> </div>
</div> </div>
)} )}
<div>
<div style={{ marginBottom: 12 }}>
<span style={{ fontSize: 15, fontWeight: 500 }}></span>
<span style={{ marginLeft: 8, fontSize: 13, color: '#8c8c8c' }}>
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, paddingLeft: 8 }}>
<Switch
checked={randomMode}
onChange={toggleRandomMode}
/>
<span style={{ fontSize: 14, color: randomMode ? '#52c41a' : '#8c8c8c' }}>
{randomMode ? '已开启' : '已关闭'}
</span>
</div>
</div>
</Space> </Space>
</Modal> </Modal>
</div> </div>

View File

@ -277,7 +277,6 @@ const QuestionList: React.FC = () => {
<List <List
dataSource={filteredQuestions} dataSource={filteredQuestions}
renderItem={(question, index) => { renderItem={(question, index) => {
const typeConfig = questionTypeConfig[question.type]
return ( return (
<Card <Card
key={question.id} key={question.id}