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

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

View File

@ -44,7 +44,6 @@ func main() {
// 练习题相关API需要登录
auth.GET("/practice/questions", handlers.GetPracticeQuestions) // 获取练习题目列表
auth.GET("/practice/questions/random", handlers.GetRandomPracticeQuestion) // 获取随机练习题目
auth.GET("/practice/questions/:id", handlers.GetPracticeQuestionByID) // 获取指定练习题目
auth.POST("/practice/explain", handlers.ExplainQuestion) // 生成题目解析AI

View File

@ -2,10 +2,12 @@
<html lang="zh-CN">
<head>
<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="description" content="AnKao 移动端应用" />
<title>AnKao</title>
<meta name="description" content="AnKao - 安全保密考试系统" />
<meta name="keywords" content="安全考试,保密考试,在线答题,考试系统" />
<meta name="theme-color" content="#1890ff" />
<title>AnKao - 安全保密考试</title>
</head>
<body>
<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 })
}
// 获取随机题目
export const getRandomQuestion = () => {
return request.get<ApiResponse<Question>>('/practice/questions/random')
}
// 获取指定题目
export const getQuestionById = (id: number) => {
return request.get<ApiResponse<Question>>(`/practice/questions/${id}`)

View File

@ -15,6 +15,20 @@
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 {
color: #1d1d1f !important;
margin-bottom: 4px !important;
@ -183,6 +197,15 @@
margin-bottom: 12px;
}
.logoArea {
gap: 12px;
}
.logo {
width: 48px;
height: 48px;
}
.title {
font-size: 22px !important;
}

View File

@ -200,8 +200,13 @@ const Home: React.FC = () => {
{/* 头部 */}
<div className={styles.header}>
<div className={styles.headerLeft}>
<div className={styles.logoArea}>
<img src="/icon.svg" alt="AnKao Logo" className={styles.logo} />
<div>
<Title level={2} className={styles.title}>AnKao </Title>
<Paragraph className={styles.subtitle}></Paragraph>
<Paragraph className={styles.subtitle}></Paragraph>
</div>
</div>
</div>
{/* 用户信息 */}
{userInfo && (
@ -300,24 +305,6 @@ const Home: React.FC = () => {
<RocketOutlined />
</Title>
<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}>
<Card
hoverable

View File

@ -48,6 +48,12 @@ const QuestionPage: React.FC = () => {
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 newValue = !autoNext;
@ -55,6 +61,13 @@ const QuestionPage: React.FC = () => {
localStorage.setItem('autoNextEnabled', String(newValue));
};
// 切换随机模式开关
const toggleRandomMode = () => {
const newValue = !randomMode;
setRandomMode(newValue);
localStorage.setItem('randomModeEnabled', String(newValue));
};
// 修改自动跳转延迟时间
const handleDelayChange = (value: number | null) => {
if (value !== null && value >= 1 && value <= 10) {
@ -121,16 +134,11 @@ const QuestionPage: React.FC = () => {
return 0;
};
// 加载随机
const loadRandomQuestion = async () => {
// 加载随机
const loadRandomWrongQuestion = async () => {
setLoading(true);
try {
// 检查是否是错题练习模式
const mode = searchParams.get("mode");
const res =
mode === "wrong"
? await questionApi.getRandomWrongQuestion()
: await questionApi.getRandomQuestion();
const res = await questionApi.getRandomWrongQuestion();
if (res.success && res.data) {
setCurrentQuestion(res.data);
@ -258,7 +266,16 @@ const QuestionPage: React.FC = () => {
// 下一题
const handleNext = () => {
if (allQuestions.length > 0) {
// 检查是否完成所有题目
let nextIndex: number;
// 随机模式:从题库中随机选择一题
if (randomMode) {
// 生成一个不等于当前索引的随机索引
do {
nextIndex = Math.floor(Math.random() * allQuestions.length);
} while (nextIndex === currentIndex && allQuestions.length > 1);
} else {
// 顺序模式:检查是否完成所有题目
if (currentIndex + 1 >= allQuestions.length) {
// 显示统计摘要
setShowSummary(true);
@ -266,8 +283,9 @@ const QuestionPage: React.FC = () => {
localStorage.removeItem(getStorageKey());
return;
}
nextIndex = currentIndex + 1;
}
const nextIndex = currentIndex + 1;
setCurrentIndex(nextIndex);
setCurrentQuestion(allQuestions[nextIndex]);
setSelectedAnswer(
@ -310,7 +328,7 @@ const QuestionPage: React.FC = () => {
// 错题练习模式
if (mode === "wrong") {
loadRandomQuestion();
loadRandomWrongQuestion();
return;
}
@ -462,6 +480,24 @@ const QuestionPage: React.FC = () => {
</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>
</Modal>
</div>

View File

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