Compare commits

..

No commits in common. "master-backup" and "master" have entirely different histories.

96 changed files with 3747 additions and 23940 deletions

View File

@ -1,49 +0,0 @@
# Git 相关
.git
.gitignore
.github
# 文档
*.md
LICENSE
# IDE 配置
.vscode
.idea
*.swp
*.swo
# Go 相关
bin/
*.exe
*.test
*.out
# 前端相关
web/node_modules/
web/dist/
web/.vite/
web/yarn-error.log
web/package-lock.json
# 环境变量和配置
.env
.env.local
*.local
# 日志
*.log
# 临时文件
tmp/
temp/
*.tmp
# OS 相关
.DS_Store
Thumbs.db
# Docker 相关
Dockerfile
.dockerignore
docker-compose.yml

449
CLAUDE.md
View File

@ -62,10 +62,10 @@ func MiddlewareName() gin.HandlerFunc {
- **internal/models/** - 数据模型
- [user.go](internal/models/user.go) - 用户模型
- [practice_question.go](internal/models/practice_question.go) - 练习题模型
- **internal/services/** - 业务服务
- [ai_grading.go](internal/services/ai_grading.go) - AI评分服务
- **internal/database/** - 数据库连接和初始化
- **pkg/config/** - 配置管理(数据库配置、AI配置等)
- **pkg/config/** - 配置管理(数据库配置等)
- **scripts/** - 工具脚本
- [import_questions.go](scripts/import_questions.go) - 题目数据导入脚本
## 常用命令
@ -82,6 +82,9 @@ go fmt ./...
# 检查代码常见问题
go vet ./...
# 导入练习题数据(首次运行需要)
go run scripts/import_questions.go
```
### 构建
@ -116,7 +119,6 @@ go test -v ./...
- **框架**: 使用 Gin v1.11.0
- **ORM**: 使用 GORM v1.31.1
- **数据库**: PostgreSQL (配置在 [pkg/config/config.go](pkg/config/config.go))
- **AI服务**: 使用 go-openai SDK v1.41.2,配置在 [pkg/config/config.go](pkg/config/config.go)
- **服务器端口**: :8080 (在 [main.go:42](main.go#L42) 中配置)
- **处理器签名**: 所有处理器使用 `func(c *gin.Context)` 模式
- **JSON 响应**: 使用 `c.JSON()` 方法配合 `gin.H{}` 或结构体
@ -124,7 +126,6 @@ go test -v ./...
- **路由注册**: 路由在 [main.go](main.go) 中使用 `r.GET()``r.POST()` 等注册
- **中间件**: 使用 `r.Use()` 全局应用或通过路由分组应用到特定路由
- **密码加密**: 使用 bcrypt 加密存储用户密码
- **AI评分**: 简答题使用AI智能评分提供分数、评语和改进建议
## 添加新功能
@ -151,394 +152,33 @@ go test -v ./...
3. 在 [internal/database/database.go](internal/database/database.go) 的 `InitDB()` 中添加 `AutoMigrate`
4. 在处理器中使用 `database.GetDB()` 进行数据库操作
### 导入数据到数据库
**示例**: 练习题数据导入
## AI评分系统
1. **准备JSON数据文件**: 如 [practice_question_pool.json](practice_question_pool.json)
2. **创建数据模型**: 在 `internal/models/` 中定义数据结构
3. **创建导入脚本**: 在 `scripts/` 目录创建导入脚本,如 [import_questions.go](scripts/import_questions.go)
4. **解析JSON并插入**:
```go
// 读取JSON文件
data, _ := os.ReadFile("data.json")
项目集成了AI智能评分功能专门用于对简答题进行评分和反馈。
// 解析JSON
var items []YourStruct
json.Unmarshal(data, &items)
### AI评分配置
AI服务配置位于 [pkg/config/config.go](pkg/config/config.go)
```go
type AIConfig struct {
BaseURL string // AI API地址
APIKey string // API密钥
Model string // 使用的模型名称
}
```
**配置方式**
1. **默认配置**: 直接在代码中设置默认值
2. **环境变量**: 通过环境变量覆盖默认配置
```bash
export AI_BASE_URL="https://ai.yuchat.top"
export AI_API_KEY="你的API密钥"
export AI_MODEL="deepseek-v3"
// 插入数据库
db := database.GetDB()
for _, item := range items {
db.Create(&item)
}
```
5. **运行导入脚本**: `go run scripts/import_questions.go`
### AI评分服务
AI评分服务实现位于 [internal/services/ai_grading.go](internal/services/ai_grading.go)
**主要功能**
- `NewAIGradingService()` - 创建AI评分服务实例
- `GradeShortAnswer(question, standardAnswer, userAnswer)` - 对简答题进行AI评分
**返回结果**
```go
type AIGradingResult struct {
Score float64 // 得分 (0-100)
IsCorrect bool // 是否正确 (Score >= 60 视为正确)
Feedback string // 评语
Suggestion string // 改进建议
}
```
### 集成方式
在 [practice_handler.go](internal/handlers/practice_handler.go) 的 `SubmitPracticeAnswer` 函数中:
```go
// 对简答题使用AI评分
if question.Type == "short-answer" {
aiService := services.NewAIGradingService()
aiResult, err := aiService.GradeShortAnswer(
question.Question,
standardAnswerStr,
userAnswerStr,
)
// 使用AI评分结果
correct = aiResult.IsCorrect
aiGrading = &models.AIGrading{
Score: aiResult.Score,
Feedback: aiResult.Feedback,
Suggestion: aiResult.Suggestion,
}
}
```
### API响应格式
简答题提交后,响应中会包含 `ai_grading` 字段:
```json
{
"success": true,
"data": {
"correct": true,
"user_answer": "用户的答案",
"correct_answer": "标准答案",
"ai_grading": {
"score": 85,
"feedback": "答案基本正确,要点全面",
"suggestion": "可以补充一些具体的例子"
}
}
}
```
### 注意事项
- AI评分仅对 `short-answer` 类型的题目生效
- 其他题型(填空题、判断题、选择题)仍使用传统的精确匹配方式
- AI评分失败时不影响主流程会记录日志并使用传统评分方式
- 评分采用温度参数 0.3,确保评分结果稳定可靠
### 自定义AI评分提示词
如需调整评分标准,修改 [ai_grading.go](internal/services/ai_grading.go) 中的 `prompt` 变量:
```go
prompt := fmt.Sprintf(`你是一位专业的阅卷老师,请对以下简答题进行评分。
题目:%s
标准答案:%s
学生答案:%s
请按照以下要求进行评分:
1. 给出一个0-100的分数
2. 判断答案是否正确60分及以上为正确
3. 给出简短的评语不超过50字
4. 如果答案不完善给出改进建议不超过50字
...
`, question, standardAnswer, userAnswer)
```
## 错题本系统
**重要更新**: 错题本系统已全面重构为 新版本,提供更强大的功能和更好的用户体验。
### 核心特性
1. **多次错误记录历史** - 保存每次答错的完整记录,而非仅保留最后一次
2. **智能复习系统** - 基于艾宾浩斯遗忘曲线的间隔重复算法
3. **标签管理系统** - 支持自定义标签,灵活分类错题
4. **智能推荐引擎** - 优先推荐需要复习的高频错题
5. **掌握度追踪** - 自动计算和更新每道题的掌握度0-100%
6. **详细统计分析** - 错题趋势、掌握度分布、薄弱知识点分析
### 数据模型设计
#### 错题记录表 (`wrong_questions`)
```go
type WrongQuestion struct {
ID uint // 主键
UserID uint // 用户ID
QuestionID uint // 题目ID
FirstWrongTime time.Time // 首次错误时间
LastWrongTime time.Time // 最后错误时间
TotalWrongCount int // 总错误次数
MasteryLevel int // 掌握度 (0-100)
NextReviewTime *time.Time // 下次复习时间
ConsecutiveCorrect int // 连续答对次数
IsMastered bool // 是否已掌握
}
```
#### 错误历史表 (`wrong_question_history`)
```go
type WrongQuestionHistory struct {
ID uint // 主键
WrongQuestionID uint // 关联错题记录
UserAnswer string // 用户答案 (JSON)
CorrectAnswer string // 正确答案 (JSON)
AnsweredAt time.Time // 答题时间
TimeSpent int // 答题用时(秒)
IsCorrect bool // 本次是否正确
}
```
### 间隔重复算法(艾宾浩斯遗忘曲线)
系统采用科学的间隔重复算法,根据用户答题情况自动安排复习时间:
**复习间隔策略**: `[1天, 3天, 7天, 15天, 30天, 60天]`
- 答错时:重置连续答对次数,重新从第一个间隔开始
- 答对时:连续答对次数+1进入下一个复习间隔
- 完全掌握连续答对6次后标记为"已掌握"
**实现位置**: [internal/models/wrong_question_v2.go](internal/models/wrong_question_v2.go#L156)
```go
// 默认复习策略
var DefaultReviewStrategy = ReviewStrategy{
Intervals: []int{1, 3, 7, 15, 30, 60},
}
// 自动计算下次复习时间
func (wq *WrongQuestion) CalculateNextReviewTime() {
strategy := DefaultReviewStrategy
level := wq.ConsecutiveCorrect
if level >= len(strategy.Intervals) {
wq.IsMastered = true // 已完全掌握
wq.MasteryLevel = 100
wq.NextReviewTime = nil
return
}
days := strategy.Intervals[level]
nextTime := time.Now().Add(time.Duration(days) * 24 * time.Hour)
wq.NextReviewTime = &nextTime
wq.MasteryLevel = (level * 100) / len(strategy.Intervals)
}
```
### API 接口
所有 API 都在 `/api/v2` 路径下,与旧版 API 共存以保持向后兼容。
#### 错题管理 API
| 方法 | 路由 | 功能 | 查询参数 |
|------|------|------|----------|
| GET | `/api/wrong-questions` | 获取错题列表 | `is_mastered`, `tag`, `type`, `sort` |
| GET | `/api/wrong-questions/:id` | 获取错题详情 | - |
| GET | `/api/wrong-questions/stats` | 获取错题统计 | - |
| GET | `/api/wrong-questions/recommended` | 获取推荐错题 | `limit` |
| DELETE | `/api/wrong-questions/:id` | 删除错题 | - |
| DELETE | `/api/wrong-questions` | 清空错题本 | - |
**排序选项** (`sort` 参数):
- `review_time` - 按复习时间排序(最需要复习的在前)
- `wrong_count` - 按错误次数排序(错误最多的在前)
- `mastery_level` - 按掌握度排序(掌握度最低的在前)
- `time` - 按最后错误时间排序(默认)
### 智能推荐算法
推荐系统采用三级策略,优先推荐最需要复习的题目:
1. **优先级 1**: 到期需要复习的题目(`next_review_time <= NOW()`
2. **优先级 2**: 高频错题且掌握度低(`wrong_count DESC, mastery_level ASC`
3. **优先级 3**: 最近答错的题目(`last_wrong_time DESC`
**实现位置**: [internal/services/wrong_question_service.go](internal/services/wrong_question_service.go#L228)
### 统计数据
错题统计 提供更丰富的数据:
```json
{
"total_wrong": 50,
"mastered": 10,
"not_mastered": 40,
"need_review": 15,
"type_stats": { "single-choice": 20, "multiple-choice": 15, "fill-in-blank": 15 },
"category_stats": { "数学": 25, "语文": 15, "英语": 10 },
"mastery_level_dist": { "很差": 10, "较差": 15, "一般": 10, "良好": 10, "优秀": 5 },
"tag_stats": { "重点": 20, "难点": 15 },
"trend_data": [
{ "date": "01-01", "count": 5 },
{ "date": "01-02", "count": 3 },
...
]
}
```
### 前端集成
前端 TypeScript 类型定义位于 [web/src/types/question.ts](web/src/types/question.ts)
```typescript
// 错题记录
interface WrongQuestion {
id: number
question_id: number
question?: Question
first_wrong_time: string
last_wrong_time: string
total_wrong_count: number
mastery_level: number // 0-100
next_review_time?: string
consecutive_correct: number
is_mastered: boolean
recent_history?: WrongQuestionHistory[]
}
```
API 调用方法位于 [web/src/api/question.ts](web/src/api/question.ts)
```typescript
// 获取错题列表(支持筛选和排序)
getWrongQuestionsV2(filter?: WrongQuestionFilter)
// 获取推荐错题
getRecommendedWrongQuestions(limit: number = 10)
// 获取错题统计
getWrongQuestionStats()
```
### 注意事项
- API 与旧版 API 完全兼容,可以同时使用
- 答题时会自动使用 API 记录错题
- 答对错题本中的题目时,会自动更新连续答对次数
- 掌握度达到 100% 时,题目会被标记为"已掌握"
- 标签功能支持自定义,建议按知识点或难度分类
## AI评分系统
项目集成了AI智能评分功能专门用于对简答题进行评分和反馈。
### AI评分配置
AI服务配置位于 [pkg/config/config.go](pkg/config/config.go)
```go
type AIConfig struct {
BaseURL string // AI API地址
APIKey string // API密钥
Model string // 使用的模型名称
}
```
**配置方式**
1. **默认配置**: 直接在代码中设置默认值
2. **环境变量**: 通过环境变量覆盖默认配置
```bash
export AI_BASE_URL="https://ai.yuchat.top"
export AI_API_KEY="你的API密钥"
export AI_MODEL="deepseek-v3"
```
### AI评分服务
AI评分服务实现位于 [internal/services/ai_grading.go](internal/services/ai_grading.go)
**主要功能**
- `NewAIGradingService()` - 创建AI评分服务实例
- `GradeShortAnswer(question, standardAnswer, userAnswer)` - 对简答题进行AI评分
**返回结果**
```go
type AIGradingResult struct {
Score float64 // 得分 (0-100)
IsCorrect bool // 是否正确 (Score >= 60 视为正确)
Feedback string // 评语
Suggestion string // 改进建议
}
```
### 集成方式
在 [practice_handler.go](internal/handlers/practice_handler.go) 的 `SubmitPracticeAnswer` 函数中:
```go
// 对简答题使用AI评分
if question.Type == "short-answer" {
aiService := services.NewAIGradingService()
aiResult, err := aiService.GradeShortAnswer(
question.Question,
standardAnswerStr,
userAnswerStr,
)
// 使用AI评分结果
correct = aiResult.IsCorrect
aiGrading = &models.AIGrading{
Score: aiResult.Score,
Feedback: aiResult.Feedback,
Suggestion: aiResult.Suggestion,
}
}
```
### API响应格式
简答题提交后,响应中会包含 `ai_grading` 字段:
```json
{
"success": true,
"data": {
"correct": true,
"user_answer": "用户的答案",
"correct_answer": "标准答案",
"ai_grading": {
"score": 85,
"feedback": "答案基本正确,要点全面",
"suggestion": "可以补充一些具体的例子"
}
}
}
```
### 注意事项
- AI评分仅对 `short-answer` 类型的题目生效
- 其他题型(填空题、判断题、选择题)仍使用传统的精确匹配方式
- AI评分失败时不影响主流程会记录日志并使用传统评分方式
- 评分采用温度参数 0.3,确保评分结果稳定可靠
**注意事项**:
- JSON中复杂数据(如数组、对象)需要序列化为字符串存储
- 使用唯一索引防止重复导入
- 大批量导入建议使用事务提高性能
## 前端开发规范
@ -573,40 +213,25 @@ if question.Type == "short-answer" {
### UI 组件使用原则
**重要**: 在开发前端页面时,必须优先使用 UI 框架的组件。
- **优先使用 Ant Design 组件**: 项目使用 **antd (Ant Design)** 作为 UI 框架,开发时应优先查找并使用框架提供的组件
- **优先使用 antd-mobile 组件**: 项目使用 antd-mobile 作为 UI 框架,开发时应优先查找并使用框架提供的组件
- **常用组件示例**:
- 表单输入: 使用 `Input` 组件,而非原生 `<input>`
- 按钮: 使用 `Button` 组件,而非原生 `<button>`
- 表单: 使用 `Form``Form.Item` 组件
- 提示信息: 使用 `message` 组件,而非自定义提示框
- 对话框: 使用 `Modal` 组件
- 提示信息: 使用 `Toast` 组件,而非自定义提示框
- 对话框: 使用 `Dialog` 组件
- 列表: 使用 `List` 组件
- 布局: 使用 `Row``Col``Layout` 等布局组件
- 图标: 使用 `@ant-design/icons` 包中的图标
- **仅在必要时自定义**: 只有当 antd 没有提供对应组件时,才使用自定义组件
- **仅在必要时自定义**: 只有当 antd-mobile 没有提供对应组件时,才使用自定义组件
- **样式处理**: 使用 CSS Modules (`.module.less`) 编写组件样式,避免全局样式污染
- **主题定制**: 在 [web/vite.config.ts](web/vite.config.ts) 中通过 `modifyVars` 定制 antd 主题
### 前端项目结构
- **web/src/pages/** - 页面组件
- **web/src/components/** - 可复用组件
- **web/src/pages/*.module.less** - 页面样式文件 (CSS Modules)
- **web/vite.config.ts** - Vite 配置文件(包含代理配置和 antd 主题定制)
- **web/vite.config.ts** - Vite 配置文件(包含代理配置)
### 响应式设计
**重要**: 应用采用响应式设计,同时适配移动端和PC端。
- **响应式断点**:
- 移动端: `max-width: 768px`
- 平板: `769px ~ 1024px`
- PC端: `min-width: 1025px`
- **布局适配**: 使用 antd 的 Grid 系统 (`Row``Col`) 实现响应式布局
- **移动端优化**:
- 底部导航栏仅在移动端显示
- 触摸区域大小适中(最小 44x44px)
- 禁止双指缩放
- **PC端优化**:
- 内容居中,最大宽度限制
- 隐藏移动端特有的底部导航栏
- 更大的字体和间距
### 移动端适配
- **禁止缩放**: 应用已配置防止移动端双指缩放
- **响应式设计**: 优先考虑移动端布局和交互
- **触摸优化**: 使用合适的触摸区域大小(最小 44x44px)

253
DOCKER.md
View File

@ -1,253 +0,0 @@
# Docker 部署指南
本文档说明如何使用 Docker 部署 AnKao 应用。
## 快速开始
### 使用 Docker Compose (推荐)
最简单的方式是使用 docker-compose它会自动启动数据库和应用
```bash
# 构建并启动所有服务
docker-compose up -d
# 查看日志
docker-compose logs -f
# 停止服务
docker-compose down
# 停止并删除数据
docker-compose down -v
```
访问应用: http://localhost:8080
### 手动使用 Docker
如果你想单独构建和运行容器:
#### 1. 构建镜像
```bash
docker build -t ankao:latest .
```
#### 2. 启动 PostgreSQL 数据库
```bash
docker run -d \
--name ankao-postgres \
-e POSTGRES_DB=ankao \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=postgres \
-p 5432:5432 \
postgres:16-alpine
```
#### 3. 启动应用
```bash
docker run -d \
--name ankao-app \
-p 8080:8080 \
-e DB_HOST=host.docker.internal \
-e DB_PORT=5432 \
-e DB_USER=postgres \
-e DB_PASSWORD=postgres \
-e DB_NAME=ankao \
-e DB_SSLMODE=disable \
ankao:latest
```
**注意**: 在 Windows 和 Mac 上使用 `host.docker.internal` 连接宿主机的数据库。在 Linux 上需要使用 `--network host` 或创建自定义网络。
## 环境变量配置
应用支持以下环境变量配置:
### 数据库配置
| 环境变量 | 描述 | 默认值 |
|---------|------|--------|
| `DB_HOST` | 数据库主机地址 | pgsql.yuchat.top |
| `DB_PORT` | 数据库端口 | 5432 |
| `DB_USER` | 数据库用户名 | postgres |
| `DB_PASSWORD` | 数据库密码 | longqi@1314 |
| `DB_NAME` | 数据库名称 | ankao |
| `DB_SSLMODE` | SSL 模式 | disable |
### 应用配置
| 环境变量 | 描述 | 默认值 |
|---------|------|--------|
| `GIN_MODE` | Gin 运行模式 (debug/release) | debug |
## Docker Compose 配置说明
`docker-compose.yml` 文件定义了两个服务:
1. **postgres**: PostgreSQL 16 数据库
- 端口: 5432
- 数据持久化: 使用 Docker volume `postgres_data`
- 健康检查: 确保数据库就绪后再启动应用
2. **app**: AnKao 应用
- 端口: 8080
- 依赖 postgres 服务
- 自动重启策略
## 生产环境部署建议
### 1. 修改默认密码
**重要**: 生产环境中务必修改数据库密码!
编辑 `docker-compose.yml`:
```yaml
environment:
POSTGRES_PASSWORD: 你的强密码
DB_PASSWORD: 你的强密码
```
### 2. 使用外部数据库
如果使用外部 PostgreSQL 数据库,只需启动 app 服务:
```bash
docker-compose up -d app
```
并配置相应的环境变量。
### 3. 配置反向代理
生产环境建议使用 Nginx 作为反向代理:
```nginx
server {
listen 80;
server_name your-domain.com;
location / {
proxy_pass http://localhost:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
### 4. 启用 HTTPS
使用 Let's Encrypt 免费 SSL 证书:
```bash
# 安装 certbot
apt-get install certbot python3-certbot-nginx
# 获取证书
certbot --nginx -d your-domain.com
```
### 5. 备份数据
定期备份 PostgreSQL 数据:
```bash
# 备份数据库
docker exec ankao-postgres pg_dump -U postgres ankao > backup.sql
# 恢复数据库
cat backup.sql | docker exec -i ankao-postgres psql -U postgres ankao
```
## 故障排查
### 查看应用日志
```bash
# 查看所有服务日志
docker-compose logs
# 查看应用日志
docker-compose logs app
# 查看数据库日志
docker-compose logs postgres
# 实时查看日志
docker-compose logs -f app
```
### 进入容器调试
```bash
# 进入应用容器
docker exec -it ankao-app sh
# 进入数据库容器
docker exec -it ankao-postgres sh
```
### 常见问题
#### 1. 应用无法连接数据库
检查:
- 数据库是否已启动: `docker-compose ps`
- 网络配置是否正确
- 环境变量配置是否正确
#### 2. 端口被占用
修改 `docker-compose.yml` 中的端口映射:
```yaml
ports:
- "8081:8080" # 将宿主机端口改为 8081
```
#### 3. 构建失败
清理缓存重新构建:
```bash
docker-compose build --no-cache
```
## 镜像优化
当前 Dockerfile 使用多阶段构建,最终镜像基于 Alpine Linux体积小且安全。
镜像大小约:
- 前端构建阶段: ~200MB (不包含在最终镜像)
- 后端构建阶段: ~300MB (不包含在最终镜像)
- 最终运行镜像: ~30MB
## 更新应用
```bash
# 拉取最新代码
git pull
# 重新构建并启动
docker-compose up -d --build
# 或者分步执行
docker-compose build
docker-compose up -d
```
## 清理资源
```bash
# 停止并删除容器
docker-compose down
# 删除容器和数据卷
docker-compose down -v
# 删除镜像
docker rmi ankao:latest
```

View File

@ -1,71 +0,0 @@
# ============================================
# 第一阶段: 构建前端应用
# ============================================
FROM node:22-alpine AS frontend-builder
WORKDIR /app/web
# 复制前端依赖文件
COPY web/package.json web/yarn.lock ./
# 安装依赖
RUN yarn install --frozen-lockfile
# 复制前端源代码
COPY web/ ./
# 构建前端应用
RUN yarn build
# ============================================
# 第二阶段: 构建 Go 后端应用
# ============================================
FROM golang:1.25.1-alpine AS backend-builder
WORKDIR /app
# 安装必要的构建工具
RUN apk add --no-cache git
# 配置 Go 代理(使用国内镜像加速)
ENV GOPROXY=https://goproxy.cn,https://goproxy.io,https://mirrors.aliyun.com/goproxy/,direct
ENV GOSUMDB=off
# 复制 Go 依赖文件
COPY go.mod go.sum ./
# 下载依赖
RUN go mod download
# 复制后端源代码
COPY . .
# 构建 Go 应用
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o server main.go
# ============================================
# 第三阶段: 创建最终运行镜像
# ============================================
FROM alpine:latest
WORKDIR /app
# 安装运行时依赖
RUN apk --no-cache add ca-certificates tzdata
# 设置时区为上海
ENV TZ=Asia/Shanghai
# 从后端构建阶段复制可执行文件
COPY --from=backend-builder /app/server ./server
# 从前端构建阶段复制构建产物
# 直接将整个 dist 目录复制为 web 目录
# dist 目录包含index.html, assets/, icon.svg 等所有构建产物
COPY --from=frontend-builder /app/web/dist ./web
# 暴露端口
EXPOSE 8080
# 启动应用
CMD ["./server"]

View File

@ -64,8 +64,10 @@ yarn dev
#### 练习题相关
- `GET /api/practice/questions` - 获取练习题目列表 (支持分页和类型过滤)
- `GET /api/practice/questions/random` - 获取随机练习题目
- `GET /api/practice/questions/:id` - 获取指定练习题目
- `POST /api/practice/submit` - 提交练习答案 (简答题自动AI评分)
- `POST /api/practice/submit` - 提交练习答案
- `GET /api/practice/types` - 获取题型列表
#### 其他
- `GET /api/health` - 健康检查端点
@ -103,7 +105,6 @@ go build -o bin/server.exe main.go
- 用户登录系统基于PostgreSQL数据库
- 题目练习功能
- 答题统计功能
- **AI智能评分** - 简答题使用AI进行智能评分和反馈
- React + TypeScript + Vite 前端
- Ant Design Mobile UI组件库
@ -143,41 +144,21 @@ go build -o bin/server.exe main.go
- `updated_at` - 更新时间
- `deleted_at` - 软删除时间
### 数据导入
## AI评分配置
首次运行项目需要导入练习题数据:
项目使用AI对简答题进行智能评分。AI服务配置位于 [pkg/config/config.go](pkg/config/config.go)
### 默认配置
- **API地址**: https://ai.yuchat.top
- **模型**: deepseek-v3
- **评分方式**: 基于题目和标准答案AI会给出分数(0-100)、评语和改进建议
### 环境变量配置(可选)
可以通过环境变量覆盖默认配置:
```bash
export AI_BASE_URL="你的API地址"
export AI_API_KEY="你的API密钥"
export AI_MODEL="你的模型名称"
# 确保 practice_question_pool.json 文件在项目根目录
go run scripts/import_questions.go
```
### AI评分返回格式
对简答题提交答案时,响应会包含 `ai_grading` 字段:
```json
{
"success": true,
"data": {
"correct": true,
"user_answer": "用户的答案",
"correct_answer": "标准答案",
"ai_grading": {
"score": 85,
"feedback": "答案基本正确,要点全面",
"suggestion": "可以补充一些具体的例子"
}
}
}
```
导入脚本会读取 [practice_question_pool.json](practice_question_pool.json) 文件并导入到数据库共包含236道练习题涵盖
- 填空题 (80道)
- 判断题 (80道)
- 单选题 (40道)
- 多选题 (30道)
- 简答题 (6道)
## 前端开发
@ -207,7 +188,7 @@ yarn build
## 页面结构
- **登录页** (`/login`) - 用户登录和注册,支持密码可见性切换
- **首页** (`/`) - 题型选择、错题本、题目列表等功能
- **首页** (`/`) - 题目练习、随机题目、题目列表、筛选等功能
- **我的** (`/profile`) - 用户信息、退出登录
## 特性
@ -221,7 +202,7 @@ yarn build
- 密码bcrypt加密存储
- 练习题管理系统236道练习题5种题型
- 支持分页查询和题型筛选
- **AI智能评分系统** - 使用deepseek-v3对简答题进行智能评分和反馈
- 随机题目推送功能
### 前端特性
- React + TypeScript + Vite 技术栈
@ -239,7 +220,6 @@ yarn build
- **GORM** v1.31.1 - ORM框架
- **PostgreSQL** - 数据库
- **bcrypt** - 密码加密
- **go-openai** v1.41.2 - OpenAI SDK (用于AI评分)
### 前端
- **React** 18 - UI框架

View File

@ -1,38 +0,0 @@
package main
import (
"ankao/pkg/config"
"fmt"
"log"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func main() {
// 直接连接数据库,不使用 InitDB
cfg := config.GetDatabaseConfig()
dsn := cfg.GetDSN()
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Info),
})
if err != nil {
log.Fatal("数据库连接失败:", err)
}
log.Println("开始清理 exam_shares 表...")
// 删除 exam_shares 表(如果存在)
if err := db.Exec("DROP TABLE IF EXISTS exam_shares CASCADE").Error; err != nil {
log.Fatal("删除 exam_shares 表失败:", err)
}
log.Println("✓ 已删除 exam_shares 表")
log.Println("\n清理完成现在可以重新运行主程序。")
fmt.Println("\n执行步骤:")
fmt.Println("1. go run main.go # 这会自动创建正确的表结构")
fmt.Println("2. 如果有旧的分享数据需要迁移,运行:")
fmt.Println(" go run cmd/migrate_exam_shares.go")
}

View File

@ -1,189 +0,0 @@
package main
import (
"ankao/internal/database"
"ankao/internal/models"
"fmt"
"log"
"time"
"gorm.io/gorm"
)
// OldExam 旧的试卷模型(用于迁移)
type OldExam struct {
ID uint `gorm:"primaryKey"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
UserID uint `gorm:"not null;index"`
Title string `gorm:"type:varchar(200);default:''"`
TotalScore int `gorm:"not null;default:100"`
Duration int `gorm:"not null;default:60"`
PassScore int `gorm:"not null;default:60"`
QuestionIDs []byte `gorm:"type:json"`
Status string `gorm:"type:varchar(20);not null;default:'active'"`
IsShared bool `gorm:"default:false"`
SharedByID *uint `gorm:"index"`
}
func (OldExam) TableName() string {
return "exams"
}
func main() {
// 初始化数据库
if err := database.InitDB(); err != nil {
log.Fatal("数据库初始化失败:", err)
}
db := database.GetDB()
log.Println("开始迁移试卷分享数据...")
// 1. 查找所有被分享的试卷副本
var sharedExams []OldExam
if err := db.Where("is_shared = ? AND shared_by_id IS NOT NULL", true).
Find(&sharedExams).Error; err != nil {
log.Fatal("查询分享试卷失败:", err)
}
log.Printf("找到 %d 份分享试卷副本", len(sharedExams))
if len(sharedExams) == 0 {
log.Println("没有需要迁移的数据,退出。")
return
}
// 2. 按 shared_by_id + question_ids 分组
type ShareGroup struct {
SharedByID uint
QuestionIDs string
Exams []OldExam
}
groupMap := make(map[string]*ShareGroup)
for _, exam := range sharedExams {
if exam.SharedByID == nil {
continue
}
key := fmt.Sprintf("%d_%s", *exam.SharedByID, string(exam.QuestionIDs))
if group, exists := groupMap[key]; exists {
group.Exams = append(group.Exams, exam)
} else {
groupMap[key] = &ShareGroup{
SharedByID: *exam.SharedByID,
QuestionIDs: string(exam.QuestionIDs),
Exams: []OldExam{exam},
}
}
}
log.Printf("分组后共 %d 组", len(groupMap))
successCount := 0
failCount := 0
// 3. 处理每个分组
for _, group := range groupMap {
// 查找原始试卷
var originalExam OldExam
if err := db.Where("user_id = ? AND question_ids = ? AND is_shared = ?",
group.SharedByID, group.QuestionIDs, false).
First(&originalExam).Error; err != nil {
log.Printf("未找到原始试卷: shared_by_id=%d, 跳过该组", group.SharedByID)
failCount += len(group.Exams)
continue
}
// 开始事务
tx := db.Begin()
migrationSuccess := true
// 4. 为每个分享副本创建关联记录
for _, sharedExam := range group.Exams {
// 创建分享记录
share := models.ExamShare{
ExamID: originalExam.ID,
SharedByID: group.SharedByID,
SharedToID: sharedExam.UserID,
SharedAt: sharedExam.CreatedAt,
}
if err := tx.Create(&share).Error; err != nil {
log.Printf("创建分享记录失败: %v", err)
tx.Rollback()
failCount += len(group.Exams)
migrationSuccess = false
break
}
// 5. 更新考试记录,将 exam_id 指向原始试卷
if err := tx.Model(&models.ExamRecord{}).
Where("exam_id = ?", sharedExam.ID).
Update("exam_id", originalExam.ID).Error; err != nil {
log.Printf("更新考试记录失败: %v", err)
tx.Rollback()
failCount += len(group.Exams)
migrationSuccess = false
break
}
// 6. 软删除分享副本
if err := tx.Delete(&sharedExam).Error; err != nil {
log.Printf("删除分享副本失败: %v", err)
tx.Rollback()
failCount += len(group.Exams)
migrationSuccess = false
break
}
if migrationSuccess {
successCount++
}
}
// 提交事务
if migrationSuccess {
if err := tx.Commit().Error; err != nil {
log.Printf("提交事务失败: %v", err)
failCount += len(group.Exams)
}
}
}
log.Printf("迁移完成: 成功 %d, 失败 %d", successCount, failCount)
// 7. 验证迁移结果
log.Println("\n开始验证迁移结果...")
// 统计 exam_shares 表记录数
var shareCount int64
db.Model(&models.ExamShare{}).Count(&shareCount)
log.Printf("exam_shares 表记录数: %d", shareCount)
// 统计剩余的分享副本数应该为0
var remainingSharedExams int64
db.Model(&OldExam{}).Where("is_shared = ?", true).Count(&remainingSharedExams)
log.Printf("剩余分享副本数: %d (应该为0)", remainingSharedExams)
// 检查是否有孤立的考试记录
var orphanRecords int64
db.Raw(`
SELECT COUNT(*) FROM exam_records er
WHERE NOT EXISTS (
SELECT 1 FROM exams e WHERE e.id = er.exam_id AND e.deleted_at IS NULL
)
`).Scan(&orphanRecords)
log.Printf("孤立的考试记录数: %d (应该为0)", orphanRecords)
if remainingSharedExams == 0 && orphanRecords == 0 {
log.Println("\n✓ 迁移验证通过!")
} else {
log.Println("\n✗ 迁移验证失败,请检查数据!")
}
log.Println("\n注意: 如果验证通过可以考虑在未来某个时间点执行以下SQL移除旧字段:")
log.Println(" ALTER TABLE exams DROP COLUMN is_shared;")
log.Println(" ALTER TABLE exams DROP COLUMN shared_by_id;")
}

View File

@ -1,185 +0,0 @@
# 修复 practice_progress 表索引问题
## 问题描述
在测试中发现除了单选题multiple-choice能正常插入进度数据外其他类型的题目无法插入数据到 `practice_progress` 表。
## 问题原因
### 错误的索引定义
**之前的模型定义**(错误):
```go
type PracticeProgress struct {
ID int64 `gorm:"primarykey" json:"id"`
CurrentQuestionID int64 `gorm:"not null" json:"current_question_id"`
UserID int64 `gorm:"not null;uniqueIndex:idx_user_question" json:"user_id"` // ❌ 只在 user_id 上建索引
Type string `gorm:"type:varchar(255);not null" json:"type"`
UserAnswerRecords datatypes.JSON `gorm:"type:jsonp" json:"answers"`
}
```
**问题**
- 唯一索引 `idx_user_question` 只在 `user_id` 字段上
- 同一用户只能有一条进度记录
- 当用户答第二种题型时,因为 `user_id` 重复,插入失败
- 日志中应该会看到类似错误:`duplicate key value violates unique constraint "idx_user_question"`
### 正确的索引定义
**修复后的模型定义**(正确):
```go
type PracticeProgress struct {
ID int64 `gorm:"primarykey" json:"id"`
CurrentQuestionID int64 `gorm:"not null" json:"current_question_id"`
UserID int64 `gorm:"not null;uniqueIndex:idx_user_type" json:"user_id"` // ✅ 联合索引
Type string `gorm:"type:varchar(255);not null;uniqueIndex:idx_user_type" json:"type"` // ✅ 联合索引
UserAnswerRecords datatypes.JSON `gorm:"type:jsonb" json:"answers"`
}
```
**改进**
- 唯一索引 `idx_user_type``(user_id, type)` 的联合索引
- 同一用户可以有多条进度记录(每种题型一条)
- 例如用户1 可以有 `(1, "multiple-choice")``(1, "true-false")` 两条记录
## 解决方案
### 方案1使用 GORM 自动迁移(推荐)
1. **停止当前服务**
2. **删除旧表并重建**(谨慎:会丢失所有进度数据)
连接到 PostgreSQL 数据库:
```bash
psql -U your_username -d your_database
```
执行:
```sql
DROP TABLE IF EXISTS practice_progress;
```
3. **重启服务GORM 会自动创建正确的表结构**
```bash
.\bin\server.exe
```
### 方案2手动修复索引保留数据
1. **连接到 PostgreSQL 数据库**
```bash
psql -U your_username -d your_database
```
2. **手动执行 SQL**
```sql
-- 删除旧索引
DROP INDEX IF EXISTS idx_user_question;
-- 创建新索引
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_type ON practice_progress(user_id, type);
```
3. **验证索引**
```sql
SELECT indexname, indexdef
FROM pg_indexes
WHERE tablename = 'practice_progress';
```
应该看到:
```
indexname | indexdef
--------------|--------------------------------------------------
idx_user_type | CREATE UNIQUE INDEX idx_user_type ON practice_progress USING btree (user_id, type)
```
4. **检查现有数据是否有冲突**
```sql
SELECT user_id, type, COUNT(*)
FROM practice_progress
GROUP BY user_id, type
HAVING COUNT(*) > 1;
```
如果有重复数据,需要手动清理:
```sql
-- 保留每组的最新记录,删除旧记录
DELETE FROM practice_progress a
WHERE id NOT IN (
SELECT MAX(id)
FROM practice_progress b
WHERE a.user_id = b.user_id AND a.type = b.type
);
```
## 验证修复
### 1. 检查表结构
```sql
\d practice_progress
```
应该看到:
```
Indexes:
"practice_progress_pkey" PRIMARY KEY, btree (id)
"idx_user_type" UNIQUE, btree (user_id, type)
```
### 2. 测试插入不同题型
**测试步骤**
1. 登录系统
2. 选择"单选题",答几道题
3. 切换到"多选题",答几道题
4. 切换到"判断题",答几道题
**检查数据库**
```sql
SELECT id, user_id, type, current_question_id
FROM practice_progress
WHERE user_id = 1; -- 替换为你的用户ID
```
应该看到多条记录:
```
id | user_id | type | current_question_id
---|---------|---------------------|--------------------
1 | 1 | multiple-choice | 157
2 | 1 | multiple-selection | 45
3 | 1 | true-false | 10
```
### 3. 检查后端日志
如果之前有错误,应该不再看到类似日志:
```
保存练习进度失败: duplicate key value violates unique constraint "idx_user_question"
```
## 其他注意事项
1. **JSONB vs JSONP**
- 修改了 `UserAnswerRecords` 的类型从 `jsonp` 改为 `jsonb`
- `jsonb` 是正确的 PostgreSQL JSON 类型
- 性能更好,支持索引
2. **数据备份**
- 在修改表结构前,建议备份数据:
```bash
pg_dump -U your_username -d your_database -t practice_progress > backup_practice_progress.sql
```
3. **回滚方案**
- 如果需要回滚,可以恢复备份:
```bash
psql -U your_username -d your_database < backup_practice_progress.sql
```
## 相关文件
- 模型定义:`internal/models/practice_progress.go`
- 写入逻辑:`internal/handlers/practice_handler.go:356-387`

9
go.mod
View File

@ -10,8 +10,6 @@ require (
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/baidubce/app-builder/go/appbuilder v1.1.1 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.14.2 // indirect
github.com/bytedance/sonic/loader v0.4.0 // indirect
@ -21,10 +19,8 @@ require (
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.28.0 // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.7.6 // indirect
@ -34,15 +30,12 @@ require (
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.55.0 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/sashabaranov/go-openai v1.41.2 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
go.uber.org/mock v0.6.0 // indirect
@ -54,6 +47,4 @@ require (
golang.org/x/text v0.30.0 // indirect
golang.org/x/tools v0.38.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gorm.io/datatypes v1.2.7 // indirect
gorm.io/driver/mysql v1.5.6 // indirect
)

20
go.sum
View File

@ -1,7 +1,3 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/baidubce/app-builder/go/appbuilder v1.1.1 h1:mPfUGmQU/Vi4KRJca6m34rWH/YWuQWOiPLmjtVjPhuA=
github.com/baidubce/app-builder/go/appbuilder v1.1.1/go.mod h1:mHOdSd9TJ52aiUbRE2rW1omu4A0U7H32xN39ED+etmE=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE=
@ -27,9 +23,6 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
@ -37,8 +30,6 @@ github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@ -57,8 +48,6 @@ github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzh
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@ -74,10 +63,6 @@ github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk=
github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/sashabaranov/go-openai v1.41.2 h1:vfPRBZNMpnqu8ELsclWcAvF19lDNgh1t6TVfFFOPiSM=
github.com/sashabaranov/go-openai v1.41.2/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@ -119,12 +104,7 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/datatypes v1.2.7 h1:ww9GAhF1aGXZY3EB3cJPJ7//JiuQo7DlQA7NNlVaTdk=
gorm.io/datatypes v1.2.7/go.mod h1:M2iO+6S3hhi4nAyYe444Pcb0dcIiOMJ7QHaUXxyiNZY=
gorm.io/driver/mysql v1.5.6 h1:Ld4mkIickM+EliaQZQx3uOJDJHtrd70MxAUqWqlx3Y8=
gorm.io/driver/mysql v1.5.6/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=

View File

@ -21,7 +21,6 @@ func InitDB() error {
var err error
DB, err = gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Info), // 开启SQL日志
DisableForeignKeyConstraintWhenMigrating: true, // 迁移时禁用外键约束
})
if err != nil {
return fmt.Errorf("failed to connect to database: %w", err)
@ -33,14 +32,6 @@ func InitDB() error {
err = DB.AutoMigrate(
&models.User{},
&models.PracticeQuestion{},
&models.PracticeProgress{}, // 练习进度表
&models.WrongQuestion{}, // 错题表
&models.WrongQuestionHistory{}, // 错题历史表
&models.UserAnswerRecord{}, // 用户答题记录表
&models.Exam{}, // 考试表(试卷)
&models.ExamShare{}, // 试卷分享关联表
&models.ExamRecord{}, // 考试记录表
&models.ExamUserAnswer{}, // 用户答案表
)
if err != nil {
return fmt.Errorf("failed to migrate database: %w", err)

View File

@ -1,143 +0,0 @@
package handlers
import (
"ankao/internal/database"
"ankao/internal/models"
"net/http"
"github.com/gin-gonic/gin"
)
// GetAllUsersWithStats 获取所有用户及其答题统计(仅管理员可访问)
func GetAllUsersWithStats(c *gin.Context) {
db := database.GetDB()
// 查询所有用户及其答题统计
var userStats []models.UserStats
// SQL查询联合查询用户表和答题记录表
query := `
SELECT
u.id as user_id,
u.username,
u.nickname,
u.avatar,
u.user_type,
COALESCE(COUNT(ar.id), 0) as total_answers,
COALESCE(SUM(CASE WHEN ar.is_correct = true THEN 1 ELSE 0 END), 0) as correct_count,
COALESCE(SUM(CASE WHEN ar.is_correct = false THEN 1 ELSE 0 END), 0) as wrong_count,
CASE
WHEN COUNT(ar.id) > 0 THEN
ROUND(CAST(CAST(SUM(CASE WHEN ar.is_correct = true THEN 1 ELSE 0 END) AS FLOAT) / COUNT(ar.id) * 100 AS NUMERIC), 2)
ELSE 0
END as accuracy,
u.created_at,
MAX(ar.answered_at) as last_answer_at
FROM users u
LEFT JOIN user_answer_records ar ON u.id = ar.user_id AND ar.deleted_at IS NULL
WHERE u.deleted_at IS NULL
GROUP BY u.id, u.username, u.nickname, u.avatar, u.user_type, u.created_at
ORDER BY total_answers DESC, accuracy DESC
`
if err := db.Raw(query).Scan(&userStats).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "获取用户统计数据失败",
"error": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": userStats,
})
}
// GetUserDetailStats 获取指定用户的详细统计信息
func GetUserDetailStats(c *gin.Context) {
userID := c.Param("id")
db := database.GetDB()
// 查询用户基本信息
var user models.User
if err := db.First(&user, userID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"message": "用户不存在",
})
return
}
// 查询用户答题统计
var stats models.UserStats
query := `
SELECT
u.id as user_id,
u.username,
u.nickname,
u.avatar,
u.user_type,
COALESCE(COUNT(ar.id), 0) as total_answers,
COALESCE(SUM(CASE WHEN ar.is_correct = true THEN 1 ELSE 0 END), 0) as correct_count,
COALESCE(SUM(CASE WHEN ar.is_correct = false THEN 1 ELSE 0 END), 0) as wrong_count,
CASE
WHEN COUNT(ar.id) > 0 THEN
ROUND(CAST(CAST(SUM(CASE WHEN ar.is_correct = true THEN 1 ELSE 0 END) AS FLOAT) / COUNT(ar.id) * 100 AS NUMERIC), 2)
ELSE 0
END as accuracy,
u.created_at,
MAX(ar.answered_at) as last_answer_at
FROM users u
LEFT JOIN user_answer_records ar ON u.id = ar.user_id AND ar.deleted_at IS NULL
WHERE u.id = ? AND u.deleted_at IS NULL
GROUP BY u.id, u.username, u.nickname, u.avatar, u.user_type, u.created_at
`
if err := db.Raw(query, userID).Scan(&stats).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "获取用户统计数据失败",
"error": err.Error(),
})
return
}
// 查询按题型分类的统计
var typeStats []struct {
QuestionType string `json:"question_type"`
QuestionTypeName string `json:"question_type_name"`
TotalAnswers int `json:"total_answers"`
CorrectCount int `json:"correct_count"`
Accuracy float64 `json:"accuracy"`
}
typeQuery := `
SELECT
pq.type as question_type,
pq.type_name as question_type_name,
COUNT(ar.id) as total_answers,
SUM(CASE WHEN ar.is_correct = true THEN 1 ELSE 0 END) as correct_count,
CASE
WHEN COUNT(ar.id) > 0 THEN
ROUND(CAST(CAST(SUM(CASE WHEN ar.is_correct = true THEN 1 ELSE 0 END) AS FLOAT) / COUNT(ar.id) * 100 AS NUMERIC), 2)
ELSE 0
END as accuracy
FROM user_answer_records ar
JOIN practice_questions pq ON ar.question_id = pq.id
WHERE ar.user_id = ? AND ar.deleted_at IS NULL
GROUP BY pq.type, pq.type_name
ORDER BY total_answers DESC
`
db.Raw(typeQuery, userID).Scan(&typeStats)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"user_info": stats,
"type_stats": typeStats,
},
})
}

View File

@ -1,269 +0,0 @@
package handlers
import (
"ankao/internal/database"
"ankao/internal/models"
"ankao/internal/services"
"encoding/json"
"fmt"
"log"
"gorm.io/datatypes"
)
// ReGradeExam 公开的重新阅卷函数,可被外部调用
func ReGradeExam(recordID uint, examID uint, userID uint) {
gradeExam(recordID, examID, userID)
}
// gradeExam 异步阅卷函数
func gradeExam(recordID uint, examID uint, userID uint) {
db := database.GetDB()
// 查询考试记录
var record models.ExamRecord
if err := db.Where("id = ?", recordID).First(&record).Error; err != nil {
log.Printf("查询考试记录失败: %v", err)
return
}
// 查询试卷
var exam models.Exam
if err := db.Where("id = ?", examID).First(&exam).Error; err != nil {
log.Printf("查询试卷失败: %v", err)
return
}
// 从 ExamUserAnswer 表读取所有答案
var userAnswers []models.ExamUserAnswer
if err := db.Where("exam_record_id = ?", recordID).Find(&userAnswers).Error; err != nil {
log.Printf("查询用户答案失败: %v", err)
return
}
// 转换为 map 格式方便查找
answersMap := make(map[int64]interface{})
for _, ua := range userAnswers {
var answer interface{}
if err := json.Unmarshal(ua.Answer, &answer); err != nil {
log.Printf("解析答案失败: %v", err)
continue
}
answersMap[ua.QuestionID] = answer
}
// 解析题目ID列表
var questionIDs []uint
if err := json.Unmarshal(exam.QuestionIDs, &questionIDs); err != nil {
log.Printf("解析题目ID失败: %v", err)
return
}
// 查询题目详情
var questions []models.PracticeQuestion
if err := db.Where("id IN ?", questionIDs).Find(&questions).Error; err != nil {
log.Printf("查询题目失败: %v", err)
return
}
// 使用固定的题型分值映射
scoreMap := map[string]float64{
"fill-in-blank": 2.0, // 填空题每题2分
"true-false": 1.0, // 判断题每题1分
"multiple-choice": 1.0, // 单选题每题1分
"multiple-selection": 2.0, // 多选题每题2分
"short-answer": 10.0, // 简答题10分
"ordinary-essay": 10.0, // 论述题10分
"management-essay": 10.0, // 论述题10分
}
// 评分
totalScore := 0.0
aiService, err := services.NewAIGradingService()
if err != nil {
log.Printf("AI服务初始化失败: %v将跳过AI评分", err)
// 不返回错误继续评分流程只是跳过AI评分
}
for _, question := range questions {
userAnswerRaw, answered := answersMap[question.ID]
if !answered {
// 更新数据库中的 ExamUserAnswer 记录为未作答
var userAnswer models.ExamUserAnswer
result := db.Where("exam_record_id = ? AND question_id = ?", recordID, question.ID).First(&userAnswer)
if result.Error == nil {
updates := map[string]interface{}{
"is_correct": false,
"score": 0.0,
}
db.Model(&userAnswer).Updates(updates)
}
continue
}
// 根据题型判断答案
var isCorrect bool
var score float64
var aiGrading *models.AIGrading
switch question.Type {
case "fill-in-blank":
// 填空题:比较数组
userAnswerArr, ok := userAnswerRaw.([]interface{})
if !ok {
isCorrect = false
score = 0
// 更新数据库
var userAnswer models.ExamUserAnswer
if result := db.Where("exam_record_id = ? AND question_id = ?", recordID, question.ID).First(&userAnswer); result.Error == nil {
db.Model(&userAnswer).Updates(map[string]interface{}{
"is_correct": false,
"score": 0.0,
})
}
continue
}
var correctAnswers []string
if err := json.Unmarshal([]byte(question.AnswerData), &correctAnswers); err != nil {
log.Printf("解析填空题答案失败: %v", err)
continue
}
isCorrect = len(userAnswerArr) == len(correctAnswers)
if isCorrect {
for i, ua := range userAnswerArr {
if i >= len(correctAnswers) || fmt.Sprintf("%v", ua) != correctAnswers[i] {
isCorrect = false
break
}
}
}
if isCorrect {
score = scoreMap["fill-in-blank"]
}
case "true-false":
// 判断题 - AnswerData 直接存储 "true" 或 "false" 字符串
correctAnswer := question.AnswerData
isCorrect = fmt.Sprintf("%v", userAnswerRaw) == correctAnswer
if isCorrect {
score = scoreMap["true-false"]
}
case "multiple-choice":
correctAnswer := question.AnswerData
isCorrect = fmt.Sprintf("\"%v\"", userAnswerRaw) == correctAnswer
if isCorrect {
score = scoreMap["multiple-choice"]
}
case "multiple-selection":
// 多选题:比较数组(顺序无关)
userAnswerArr, ok := userAnswerRaw.([]interface{})
if !ok {
isCorrect = false
score = 0
// 更新数据库
var userAnswer models.ExamUserAnswer
if result := db.Where("exam_record_id = ? AND question_id = ?", recordID, question.ID).First(&userAnswer); result.Error == nil {
db.Model(&userAnswer).Updates(map[string]interface{}{
"is_correct": false,
"score": 0.0,
})
}
continue
}
var correctAnswers []string
if err := json.Unmarshal([]byte(question.AnswerData), &correctAnswers); err != nil {
log.Printf("解析多选题答案失败: %v", err)
continue
}
userAnswerSet := make(map[string]bool)
for _, ua := range userAnswerArr {
userAnswerSet[fmt.Sprintf("%v", ua)] = true
}
isCorrect = len(userAnswerSet) == len(correctAnswers)
if isCorrect {
for _, ca := range correctAnswers {
if !userAnswerSet[ca] {
isCorrect = false
break
}
}
}
if isCorrect {
score = scoreMap["multiple-selection"]
}
case "short-answer", "ordinary-essay", "management-essay":
// 简答题和论述题使用AI评分
// AnswerData 直接存储答案文本
correctAnswer := question.AnswerData
userAnswerStr := fmt.Sprintf("%v", userAnswerRaw)
// 检查AI服务是否可用
if aiService == nil {
log.Printf("AI服务不可用无法评分问题 %d", question.ID)
isCorrect = false
score = 0
} else {
aiResult, aiErr := aiService.GradeShortAnswer(question.Question, correctAnswer, userAnswerStr)
if aiErr != nil {
log.Printf("AI评分失败: %v", aiErr)
isCorrect = false
score = 0
} else {
isCorrect = aiResult.IsCorrect
// 按AI评分比例计算
var questionScore float64
if question.Type == "short-answer" {
questionScore = scoreMap["short-answer"]
} else if question.Type == "ordinary-essay" {
questionScore = scoreMap["ordinary-essay"]
} else if question.Type == "management-essay" {
questionScore = scoreMap["management-essay"]
}
score = questionScore * (aiResult.Score / 100.0)
aiGrading = &models.AIGrading{
Score: aiResult.Score,
Feedback: aiResult.Feedback,
Suggestion: aiResult.Suggestion,
}
}
}
}
totalScore += score
// 更新数据库中的 ExamUserAnswer 记录
var userAnswer models.ExamUserAnswer
result := db.Where("exam_record_id = ? AND question_id = ?", recordID, question.ID).First(&userAnswer)
if result.Error == nil {
// 序列化 AI 评分数据
var aiGradingJSON datatypes.JSON
if aiGrading != nil {
aiGradingData, _ := json.Marshal(aiGrading)
aiGradingJSON = datatypes.JSON(aiGradingData)
}
// 更新评分结果
updates := map[string]interface{}{
"is_correct": isCorrect,
"score": score,
"ai_grading_data": aiGradingJSON,
}
db.Model(&userAnswer).Updates(updates)
}
}
// 保存分数和状态到考试记录
record.Score = totalScore
record.Status = "graded"
record.IsPassed = totalScore >= float64(exam.PassScore)
if err := db.Save(&record).Error; err != nil {
log.Printf("保存考试记录失败: %v", err)
return
}
log.Printf("阅卷完成: 考试记录ID=%d, 总分=%.2f, 是否通过=%v", recordID, totalScore, record.IsPassed)
}

File diff suppressed because it is too large Load Diff

View File

@ -1,13 +1,7 @@
package handlers
import (
"ankao/internal/database"
"ankao/internal/models"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/gin-gonic/gin"
)
@ -26,182 +20,3 @@ func HealthCheckHandler(c *gin.Context) {
"status": "healthy",
})
}
// GetDailyRanking 获取今日排行榜
func GetDailyRanking(c *gin.Context) {
db := database.GetDB()
// 获取查询参数
limitStr := c.DefaultQuery("limit", "10")
limit, err := strconv.Atoi(limitStr)
if err != nil || limit <= 0 || limit > 100 {
limit = 10
}
// 查询今日排行榜(按答题数量和正确率排序)
var rankings []models.UserStats
query := `
SELECT
u.id as user_id,
u.username,
u.nickname,
u.avatar,
u.user_type,
COALESCE(COUNT(ar.id), 0) as total_answers,
COALESCE(SUM(CASE WHEN ar.is_correct = true THEN 1 ELSE 0 END), 0) as correct_count,
COALESCE(SUM(CASE WHEN ar.is_correct = false THEN 1 ELSE 0 END), 0) as wrong_count,
CASE
WHEN COUNT(ar.id) > 0 THEN
ROUND(CAST(CAST(SUM(CASE WHEN ar.is_correct = true THEN 1 ELSE 0 END) AS FLOAT) / COUNT(ar.id) * 100 AS NUMERIC), 2)
ELSE 0
END as accuracy,
u.created_at,
MAX(ar.answered_at) as last_answer_at
FROM users u
LEFT JOIN user_answer_records ar ON u.id = ar.user_id
AND ar.deleted_at IS NULL
AND DATE(ar.answered_at) = CURRENT_DATE
WHERE u.deleted_at IS NULL
GROUP BY u.id, u.username, u.nickname, u.avatar, u.user_type, u.created_at
HAVING COUNT(ar.id) > 0
ORDER BY total_answers DESC, accuracy DESC, correct_count DESC
LIMIT ?
`
if err := db.Raw(query, limit).Scan(&rankings).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "获取排行榜数据失败",
"error": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": rankings,
})
}
// GetTotalRanking 获取总排行榜
func GetTotalRanking(c *gin.Context) {
db := database.GetDB()
// 获取查询参数
limitStr := c.DefaultQuery("limit", "10")
limit, err := strconv.Atoi(limitStr)
if err != nil || limit <= 0 || limit > 100 {
limit = 10
}
// 查询总排行榜(按总答题数量和正确率排序)
var rankings []models.UserStats
query := `
SELECT
u.id as user_id,
u.username,
u.nickname,
u.avatar,
u.user_type,
COALESCE(COUNT(ar.id), 0) as total_answers,
COALESCE(SUM(CASE WHEN ar.is_correct = true THEN 1 ELSE 0 END), 0) as correct_count,
COALESCE(SUM(CASE WHEN ar.is_correct = false THEN 1 ELSE 0 END), 0) as wrong_count,
CASE
WHEN COUNT(ar.id) > 0 THEN
ROUND(CAST(CAST(SUM(CASE WHEN ar.is_correct = true THEN 1 ELSE 0 END) AS FLOAT) / COUNT(ar.id) * 100 AS NUMERIC), 2)
ELSE 0
END as accuracy,
u.created_at,
MAX(ar.answered_at) as last_answer_at
FROM users u
LEFT JOIN user_answer_records ar ON u.id = ar.user_id AND ar.deleted_at IS NULL
WHERE u.deleted_at IS NULL
GROUP BY u.id, u.username, u.nickname, u.avatar, u.user_type, u.created_at
HAVING COUNT(ar.id) > 0
ORDER BY total_answers DESC, accuracy DESC, correct_count DESC
LIMIT ?
`
if err := db.Raw(query, limit).Scan(&rankings).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "获取总排行榜数据失败",
"error": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": rankings,
})
}
// StaticFileHandler 静态文件处理器,用于服务前端静态资源
// 使用 NoRoute 避免与 API 路由冲突
func StaticFileHandler(root string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 获取请求路径
path := r.URL.Path
// 构建完整文件路径
fullPath := filepath.Join(root, path)
// 检查文件是否存在
info, err := os.Stat(fullPath)
if err != nil {
// 文件不存在,尝试返回 index.htmlSPA 应用)
indexPath := filepath.Join(root, "index.html")
if _, err := os.Stat(indexPath); err == nil {
http.ServeFile(w, r, indexPath)
return
}
http.NotFound(w, r)
return
}
// 如果是目录,尝试返回目录下的 index.html
if info.IsDir() {
indexPath := filepath.Join(fullPath, "index.html")
if _, err := os.Stat(indexPath); err == nil {
http.ServeFile(w, r, indexPath)
return
}
http.NotFound(w, r)
return
}
// 设置正确的 Content-Type
setContentType(w, fullPath)
// 返回文件
http.ServeFile(w, r, fullPath)
})
}
// setContentType 根据文件扩展名设置正确的 Content-Type
func setContentType(w http.ResponseWriter, filePath string) {
ext := strings.ToLower(filepath.Ext(filePath))
contentTypes := map[string]string{
".html": "text/html; charset=utf-8",
".css": "text/css; charset=utf-8",
".js": "application/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".woff": "font/woff",
".woff2": "font/woff2",
".ttf": "font/ttf",
".eot": "application/vnd.ms-fontobject",
}
if contentType, ok := contentTypes[ext]; ok {
w.Header().Set("Content-Type", contentType)
}
}

File diff suppressed because it is too large Load Diff

View File

@ -66,27 +66,13 @@ func Login(c *gin.Context) {
// 生成token
token := generateToken(req.Username)
// 保存token到数据库
user.Token = token
if err := db.Save(&user).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "token保存失败",
})
return
}
// 返回用户信息(不包含密码)
userInfo := models.UserInfoResponse{
Username: user.Username,
Avatar: user.Avatar,
Nickname: user.Nickname,
UserType: user.UserType, // 返回用户类型
}
// 检查用户类型是否为空,如果为空,标识需要补充
needUserType := user.UserType == ""
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "登录成功",
@ -94,7 +80,6 @@ func Login(c *gin.Context) {
Token: token,
User: userInfo,
},
"need_user_type": needUserType, // 添加标识,前端根据此标识显示补充弹窗
})
}
@ -128,7 +113,6 @@ func Register(c *gin.Context) {
newUser := models.User{
Username: req.Username,
Nickname: req.Nickname,
UserType: req.UserType, // 保存用户类型
Avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=" + req.Username, // 使用用户名生成默认头像
}
@ -147,12 +131,6 @@ func Register(c *gin.Context) {
return
}
// 生成token
token := generateToken(req.Username)
// 设置token
newUser.Token = token
// 保存到数据库
if err := db.Create(&newUser).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
@ -163,12 +141,14 @@ func Register(c *gin.Context) {
return
}
// 生成token
token := generateToken(req.Username)
// 返回用户信息
userInfo := models.UserInfoResponse{
Username: newUser.Username,
Avatar: newUser.Avatar,
Nickname: newUser.Nickname,
UserType: newUser.UserType, // 返回用户类型
}
c.JSON(http.StatusOK, gin.H{
@ -180,209 +160,3 @@ func Register(c *gin.Context) {
},
})
}
// UpdateUserTypeRequest 更新用户类型请求
type UpdateUserTypeRequest struct {
UserType string `json:"user_type" binding:"required,oneof=ordinary-person management-person"`
}
// UpdateUserType 更新用户类型
func UpdateUserType(c *gin.Context) {
var req UpdateUserTypeRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "请求参数错误,用户类型必须是 ordinary-person 或 management-person",
"error": err.Error(),
})
return
}
// 从上下文获取用户信息(由认证中间件设置)
username, exists := c.Get("username")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "未授权访问",
})
return
}
db := database.GetDB()
var user models.User
if err := db.Where("username = ?", username).First(&user).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"message": "用户不存在",
})
return
}
// 更新用户类型
user.UserType = req.UserType
if err := db.Save(&user).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "更新用户类型失败",
"error": err.Error(),
})
return
}
// 返回更新后的用户信息
userInfo := models.UserInfoResponse{
Username: user.Username,
Avatar: user.Avatar,
Nickname: user.Nickname,
UserType: user.UserType,
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "用户类型更新成功",
"data": userInfo,
})
}
// UpdateProfileRequest 更新用户信息请求
type UpdateProfileRequest struct {
Nickname string `json:"nickname" binding:"required"`
UserType string `json:"user_type" binding:"required,oneof=ordinary-person management-person"`
}
// UpdateProfile 更新用户信息
func UpdateProfile(c *gin.Context) {
var req UpdateProfileRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "请求参数错误",
"error": err.Error(),
})
return
}
// 从上下文获取用户信息(由认证中间件设置)
username, exists := c.Get("username")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "未授权访问",
})
return
}
db := database.GetDB()
var user models.User
if err := db.Where("username = ?", username).First(&user).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"message": "用户不存在",
})
return
}
// 更新用户信息
user.Nickname = req.Nickname
user.UserType = req.UserType
if err := db.Save(&user).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "更新用户信息失败",
"error": err.Error(),
})
return
}
// 返回更新后的用户信息
userInfo := models.UserInfoResponse{
Username: user.Username,
Avatar: user.Avatar,
Nickname: user.Nickname,
UserType: user.UserType,
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "用户信息更新成功",
"data": userInfo,
})
}
// ChangePasswordRequest 修改密码请求
type ChangePasswordRequest struct {
OldPassword string `json:"old_password" binding:"required"`
NewPassword string `json:"new_password" binding:"required,min=6"`
}
// ChangePassword 修改密码
func ChangePassword(c *gin.Context) {
var req ChangePasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "请求参数错误新密码长度至少为6位",
"error": err.Error(),
})
return
}
// 从上下文获取用户信息(由认证中间件设置)
username, exists := c.Get("username")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "未授权访问",
})
return
}
db := database.GetDB()
var user models.User
if err := db.Where("username = ?", username).First(&user).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"message": "用户不存在",
})
return
}
// 验证旧密码
if !user.CheckPassword(req.OldPassword) {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "当前密码错误",
})
return
}
// 更新密码
if err := user.HashPassword(req.NewPassword); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "密码加密失败",
"error": err.Error(),
})
return
}
// 清除旧的token强制重新登录
user.Token = ""
if err := db.Save(&user).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "密码更新失败",
"error": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "密码修改成功,请重新登录",
})
}

View File

@ -1,323 +0,0 @@
package handlers
import (
"ankao/internal/database"
"ankao/internal/models"
"ankao/internal/services"
"encoding/json"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// ==================== 错题管理 API ====================
// GetWrongQuestions 获取错题列表(新版)
// GET /api/v2/wrong-questions?is_mastered=false&type=single-choice&tag=数学&sort=review_time
func GetWrongQuestions(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "未授权"})
return
}
db := database.GetDB()
query := db.Model(&models.WrongQuestion{}).Where("user_id = ?", userID)
// 筛选条件
if isMastered := c.Query("is_mastered"); isMastered != "" {
query = query.Where("is_mastered = ?", isMastered == "true")
}
// 排序
switch c.Query("sort") {
case "wrong_count":
// 按错误次数排序(错误最多的在前)
query = query.Order("total_wrong_count DESC")
case "mastery_level":
// 按掌握度排序(掌握度最低的在前)
query = query.Order("mastery_level ASC")
default:
// 默认按最后错误时间排序
query = query.Order("last_wrong_time DESC")
}
var wrongQuestions []models.WrongQuestion
// 先查询错题记录
if err := query.Find(&wrongQuestions).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询错题失败"})
return
}
// 手动加载关联数据
for i := range wrongQuestions {
// 加载题目信息(确保使用正确的关联)
var practiceQuestion models.PracticeQuestion
if err := db.Where("id = ?", wrongQuestions[i].QuestionID).First(&practiceQuestion).Error; err == nil {
wrongQuestions[i].PracticeQuestion = &practiceQuestion
}
// 加载最近3次历史
var history []models.WrongQuestionHistory
if err := db.Where("wrong_question_id = ?", wrongQuestions[i].ID).
Order("answered_at DESC").
Limit(3).
Find(&history).Error; err == nil {
wrongQuestions[i].History = history
}
}
// 转换为 DTO
dtos := make([]models.WrongQuestionDTO, len(wrongQuestions))
for i, wq := range wrongQuestions {
dtos[i] = convertWrongQuestionToDTO(&wq, true) // 包含最近历史
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": dtos,
})
}
// GetWrongQuestionDetail 获取错题详情(包含完整历史)
// GET /api/v2/wrong-questions/:id
func GetWrongQuestionDetail(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "未授权"})
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的错题ID"})
return
}
db := database.GetDB()
var wrongQuestion models.WrongQuestion
if err := db.Where("id = ? AND user_id = ?", id, userID).
Preload("PracticeQuestion").
Preload("History", func(db *gorm.DB) *gorm.DB {
return db.Order("answered_at DESC")
}).
First(&wrongQuestion).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "错题不存在"})
return
}
// 转换为 DTO包含完整历史
dto := convertToDetailDTO(&wrongQuestion)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": dto,
})
}
// GetWrongQuestionStats 获取错题统计(新版)
// GET /api/v2/wrong-questions/stats
func GetWrongQuestionStats(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "未授权"})
return
}
stats, err := services.GetWrongQuestionStats(userID.(int64))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取统计失败"})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": stats,
})
}
// GetRecommendedWrongQuestions 获取推荐练习的错题(智能推荐)
// GET /api/v2/wrong-questions/recommended?limit=10&exclude=123
func GetRecommendedWrongQuestions(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "未授权"})
return
}
limit := 10
if l := c.Query("limit"); l != "" {
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
limit = parsed
}
}
// 获取要排除的题目ID前端传递当前题目ID避免重复推荐
excludeQuestionID := int64(0)
if e := c.Query("exclude"); e != "" {
if parsed, err := strconv.ParseUint(e, 10, 64); err == nil {
excludeQuestionID = int64(parsed)
}
}
questions, err := services.GetRecommendedWrongQuestions(userID.(int64), limit, excludeQuestionID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取推荐错题失败"})
return
}
// 转换为 DTO
dtos := make([]models.WrongQuestionDTO, len(questions))
for i, wq := range questions {
dtos[i] = convertWrongQuestionToDTO(&wq, false)
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": dtos,
})
}
// DeleteWrongQuestion 删除错题(新版)
// DELETE /api/v2/wrong-questions/:id
func DeleteWrongQuestion(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "未授权"})
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的错题ID"})
return
}
db := database.GetDB()
// 删除历史记录
db.Where("wrong_question_id = ?", id).Delete(&models.WrongQuestionHistory{})
// 删除错题记录
result := db.Where("id = ? AND user_id = ?", id, userID).Delete(&models.WrongQuestion{})
if result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除错题失败"})
return
}
if result.RowsAffected == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "错题不存在"})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "删除成功",
})
}
// ClearWrongQuestions 清空错题本(新版)
// DELETE /api/v2/wrong-questions
func ClearWrongQuestions(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "未授权"})
return
}
db := database.GetDB()
// 获取所有错题ID
var wrongQuestionIDs []uint
db.Model(&models.WrongQuestion{}).Where("user_id = ?", userID).Pluck("id", &wrongQuestionIDs)
// 删除历史记录
if len(wrongQuestionIDs) > 0 {
db.Where("wrong_question_id IN ?", wrongQuestionIDs).Delete(&models.WrongQuestionHistory{})
}
// 删除错题记录
if err := db.Where("user_id = ?", userID).Delete(&models.WrongQuestion{}).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "清空错题本失败"})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "错题本已清空",
})
}
// ==================== 辅助函数 ====================
// convertWrongQuestionToDTO 转换为 DTO V2可选是否包含最近历史
func convertWrongQuestionToDTO(wq *models.WrongQuestion, includeHistory bool) models.WrongQuestionDTO {
dto := models.WrongQuestionDTO{
ID: wq.ID,
QuestionID: wq.QuestionID,
FirstWrongTime: wq.FirstWrongTime,
LastWrongTime: wq.LastWrongTime,
TotalWrongCount: wq.TotalWrongCount,
MasteryLevel: wq.MasteryLevel,
ConsecutiveCorrect: wq.ConsecutiveCorrect,
IsMastered: wq.IsMastered,
}
// 转换题目信息
if wq.PracticeQuestion != nil {
questionDTO := convertToDTO(*wq.PracticeQuestion)
dto.Question = &questionDTO
}
// 包含最近3次历史
if includeHistory && len(wq.History) > 0 {
count := 3
if len(wq.History) < count {
count = len(wq.History)
}
dto.RecentHistory = make([]models.WrongQuestionHistoryDTO, count)
for i := 0; i < count; i++ {
dto.RecentHistory[i] = convertWrongHistoryToDTO(&wq.History[i])
}
}
return dto
}
// convertToDetailDTO 转换为详情 DTO包含完整历史
func convertToDetailDTO(wq *models.WrongQuestion) models.WrongQuestionDTO {
dto := convertWrongQuestionToDTO(wq, false)
// 包含完整历史
if len(wq.History) > 0 {
dto.RecentHistory = make([]models.WrongQuestionHistoryDTO, len(wq.History))
for i, h := range wq.History {
dto.RecentHistory[i] = convertWrongHistoryToDTO(&h)
}
}
return dto
}
// convertWrongHistoryToDTO 转换历史记录为 DTO
func convertWrongHistoryToDTO(h *models.WrongQuestionHistory) models.WrongQuestionHistoryDTO {
return models.WrongQuestionHistoryDTO{
ID: h.ID,
UserAnswer: parseJSONAnswer(h.UserAnswer),
CorrectAnswer: parseJSONAnswer(h.CorrectAnswer),
AnsweredAt: h.AnsweredAt,
TimeSpent: h.TimeSpent,
IsCorrect: h.IsCorrect,
}
}
// parseJSONAnswer 解析 JSON 答案
func parseJSONAnswer(answerStr string) interface{} {
var answer interface{}
if err := json.Unmarshal([]byte(answerStr), &answer); err != nil {
return answerStr
}
return answer
}

View File

@ -1,36 +0,0 @@
package middleware
import (
"net/http"
"github.com/gin-gonic/gin"
)
// AdminOnly 管理员权限验证中间件仅yanlongqi用户可访问
func AdminOnly() gin.HandlerFunc {
return func(c *gin.Context) {
// 从上下文中获取用户名需要先通过Auth中间件
username, exists := c.Get("username")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "未登录",
})
c.Abort()
return
}
// 检查是否是管理员用户仅yanlongqi
if username != "yanlongqi" {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": "无权访问,该功能仅限管理员使用",
})
c.Abort()
return
}
// 权限验证通过,继续处理请求
c.Next()
}
}

View File

@ -1,85 +0,0 @@
package middleware
import (
"ankao/internal/database"
"ankao/internal/models"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
// Auth 认证中间件
func Auth() gin.HandlerFunc {
return func(c *gin.Context) {
// 从请求头获取token
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "未登录",
})
c.Abort()
return
}
// 解析Bearer token
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "token格式错误",
})
c.Abort()
return
}
token := parts[1]
// 从数据库查找token对应的用户
db := database.GetDB()
var user models.User
if err := db.Where("token = ?", token).First(&user).Error; err != nil {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "token无效或已过期",
})
c.Abort()
return
}
// 将用户ID设置到上下文
c.Set("user_id", user.ID)
c.Set("username", user.Username)
c.Next()
}
}
// AdminAuth 管理员认证中间件必须在Auth中间件之后使用
func AdminAuth() gin.HandlerFunc {
return func(c *gin.Context) {
// 获取用户名(由 Auth 中间件设置)
username, exists := c.Get("username")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "未登录",
})
c.Abort()
return
}
// 检查是否为管理员用户
if username != "yanlongqi" {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": "无权限访问",
})
c.Abort()
return
}
c.Next()
}
}

View File

@ -1,35 +0,0 @@
package models
import (
"gorm.io/datatypes"
"time"
)
// UserAnswerRecord 用户答题记录
type UserAnswerRecord struct {
ID int64 `gorm:"primarykey"`
UserID int64 `gorm:"index;not null" json:"user_id"` // 用户ID
QuestionID int64 `gorm:"index;not null" json:"question_id"` // 题目ID
IsCorrect bool `gorm:"not null" json:"is_correct"` // 是否答对
AnsweredAt time.Time `gorm:"not null" json:"answered_at"` // 答题时间
UserAnswer datatypes.JSON `gorm:"json" json:"user_answer"`
// AI 评分相关字段(仅简答题有值)
AIScore *float64 `gorm:"type:decimal(5,2)" json:"ai_score,omitempty"` // AI 评分 (0-100)
AIFeedback *string `gorm:"type:text" json:"ai_feedback,omitempty"` // AI 评语
AISuggestion *string `gorm:"type:text" json:"ai_suggestion,omitempty"` // AI 改进建议
}
// TableName 指定表名
func (UserAnswerRecord) TableName() string {
return "user_answer_records"
}
// UserStatistics 用户统计数据
type UserStatistics struct {
TotalQuestions int `json:"total_questions"` // 题库总数
AnsweredQuestions int `json:"answered_questions"` // 已答题数
CorrectAnswers int `json:"correct_answers"` // 答对题数
WrongQuestions int `json:"wrong_questions"` // 错题数量
Accuracy float64 `json:"accuracy"` // 正确率
}

View File

@ -1,162 +0,0 @@
package models
import (
"time"
"gorm.io/datatypes"
"gorm.io/gorm"
)
// Exam 试卷模型
type Exam struct {
ID uint `gorm:"primaryKey" json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
UserID uint `gorm:"not null;index" json:"user_id"` // 创建者ID
Title string `gorm:"type:varchar(200);default:''" json:"title"` // 试卷标题
TotalScore int `gorm:"not null;default:100" json:"total_score"` // 总分
Duration int `gorm:"not null;default:60" json:"duration"` // 考试时长(分钟)
PassScore int `gorm:"not null;default:60" json:"pass_score"` // 及格分数
QuestionIDs datatypes.JSON `gorm:"type:json" json:"question_ids"` // 题目ID列表 (JSON数组)
Status string `gorm:"type:varchar(20);not null;default:'active'" json:"status"` // 状态: active, archived
IsSystem bool `gorm:"default:false;index" json:"is_system"` // 是否为系统试卷
// 关联关系
Shares []ExamShare `gorm:"foreignKey:ExamID" json:"-"` // 该试卷的分享记录(作为被分享试卷)
SharedToMe []ExamShare `gorm:"foreignKey:SharedToID" json:"-"` // 分享给我的记录(作为接收者)
}
// IsAccessibleBy 检查用户是否有权限访问试卷
func (e *Exam) IsAccessibleBy(userID int64, db *gorm.DB) bool {
// 用户是试卷创建者
if int64(e.UserID) == userID {
return true
}
// 检查是否被分享给该用户
var count int64
db.Model(&ExamShare{}).Where("exam_id = ? AND shared_to_id = ?", e.ID, userID).Count(&count)
return count > 0
}
// GetAccessibleExams 获取用户可访问的所有试卷(拥有的+被分享的)
func GetAccessibleExams(userID int64, db *gorm.DB) ([]Exam, error) {
var exams []Exam
// 子查询被分享的试卷ID
subQuery := db.Model(&ExamShare{}).Select("exam_id").Where("shared_to_id = ?", userID)
// 查询:用户拥有的 OR 被分享的试卷
err := db.Where("user_id = ? OR id IN (?)", uint(userID), subQuery).
Order("created_at DESC").
Find(&exams).Error
return exams, err
}
// ExamRecord 考试记录
type ExamRecord struct {
ID uint `gorm:"primaryKey" json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
ExamID uint `gorm:"not null;index" json:"exam_id"` // 试卷ID
UserID uint `gorm:"not null;index" json:"user_id"` // 考生ID
StartTime *time.Time `json:"start_time"` // 开始时间
SubmitTime *time.Time `json:"submit_time"` // 提交时间
TimeSpent int `json:"time_spent"` // 实际用时(秒)
Score float64 `gorm:"type:decimal(5,2)" json:"score"` // 得分
TotalScore int `json:"total_score"` // 总分
Status string `gorm:"type:varchar(20);not null;default:'in_progress'" json:"status"` // 状态: in_progress, submitted, graded
IsPassed bool `json:"is_passed"` // 是否通过
// 关联
Exam *Exam `gorm:"foreignKey:ExamID" json:"exam,omitempty"`
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
}
// ExamUserAnswer 用户答案表(记录每道题的答案)
type ExamUserAnswer struct {
ID int64 `gorm:"primaryKey" json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
ExamRecordID int64 `gorm:"not null;index:idx_record_question" json:"exam_record_id"` // 考试记录ID
QuestionID int64 `gorm:"not null;index:idx_record_question" json:"question_id"` // 题目ID
UserID int64 `gorm:"not null;index" json:"user_id"` // 用户ID
Answer datatypes.JSON `gorm:"type:json" json:"answer"` // 用户答案 (JSON格式支持各种题型)
IsCorrect *bool `json:"is_correct,omitempty"` // 是否正确(提交后评分)
Score float64 `gorm:"type:decimal(5,2);default:0" json:"score"` // 得分
AIGradingData datatypes.JSON `gorm:"type:json" json:"ai_grading_data,omitempty"` // AI评分数据
AnsweredAt *time.Time `json:"answered_at"` // 答题时间
LastModifiedAt time.Time `json:"last_modified_at"` // 最后修改时间
// 关联
ExamRecord *ExamRecord `gorm:"foreignKey:ExamRecordID" json:"-"`
Question *PracticeQuestion `gorm:"foreignKey:QuestionID" json:"-"`
}
// ExamConfig 试卷配置结构
type ExamConfig struct {
QuestionTypes []QuestionTypeConfig `json:"question_types"` // 题型配置
Categories []string `json:"categories"` // 题目分类筛选
Difficulty []string `json:"difficulty"` // 难度筛选
RandomOrder bool `json:"random_order"` // 是否随机顺序
}
// QuestionTypeConfig 题型配置
type QuestionTypeConfig struct {
Type string `json:"type"` // 题目类型
Count int `json:"count"` // 题目数量
Score float64 `json:"score"` // 每题分数
}
// ExamAnswer 考试答案结构
type ExamAnswer struct {
QuestionID int64 `json:"question_id"`
Answer interface{} `json:"answer"` // 用户答案
CorrectAnswer interface{} `json:"correct_answer"` // 正确答案
IsCorrect bool `json:"is_correct"`
Score float64 `json:"score"`
AIGrading *AIGrading `json:"ai_grading,omitempty"`
}
// ExamQuestionConfig 考试题目配置
type ExamQuestionConfig struct {
FillInBlank int `json:"fill_in_blank"` // 填空题数量
TrueFalse int `json:"true_false"` // 判断题数量
MultipleChoice int `json:"multiple_choice"` // 单选题数量
MultipleSelection int `json:"multiple_selection"` // 多选题数量
ShortAnswer int `json:"short_answer"` // 简答题数量
OrdinaryEssay int `json:"ordinary_essay"` // 普通涉密人员论述题数量
ManagementEssay int `json:"management_essay"` // 保密管理人员论述题数量
}
// DefaultExamConfig 默认考试配置
var DefaultExamConfig = ExamQuestionConfig{
FillInBlank: 10, // 填空题10道
TrueFalse: 10, // 判断题10道
MultipleChoice: 10, // 单选题10道
MultipleSelection: 10, // 多选题10道
ShortAnswer: 2, // 简答题2道
OrdinaryEssay: 1, // 普通论述题1道
ManagementEssay: 1, // 管理论述题1道
}
// ExamScoreConfig 考试分值配置
type ExamScoreConfig struct {
FillInBlank float64 `json:"fill_in_blank"` // 填空题分值
TrueFalse float64 `json:"true_false"` // 判断题分值
MultipleChoice float64 `json:"multiple_choice"` // 单选题分值
MultipleSelection float64 `json:"multiple_selection"` // 多选题分值
Essay float64 `json:"essay"` // 论述题分值
}
// DefaultScoreConfig 默认分值配置
var DefaultScoreConfig = ExamScoreConfig{
FillInBlank: 2.0, // 填空题每题2分 (共20分)
TrueFalse: 2.0, // 判断题每题2分 (共20分)
MultipleChoice: 1.0, // 单选题每题1分 (共10分)
MultipleSelection: 2.5, // 多选题每题2.5分 (共25分)
Essay: 25.0, // 论述题25分
}

View File

@ -1,30 +0,0 @@
package models
import (
"time"
"gorm.io/gorm"
)
// ExamShare 试卷分享关联表
type ExamShare struct {
ID uint `gorm:"primaryKey" json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
ExamID uint `gorm:"not null;uniqueIndex:uk_exam_shared_to" json:"exam_id"`
SharedByID int64 `gorm:"not null;index" json:"shared_by_id"`
SharedToID int64 `gorm:"not null;uniqueIndex:uk_exam_shared_to" json:"shared_to_id"`
SharedAt time.Time `gorm:"not null;default:CURRENT_TIMESTAMP" json:"shared_at"`
// 关联关系
Exam *Exam `gorm:"foreignKey:ExamID" json:"exam,omitempty"`
SharedBy *User `gorm:"foreignKey:SharedByID;references:ID" json:"shared_by,omitempty"`
SharedTo *User `gorm:"foreignKey:SharedToID;references:ID" json:"shared_to,omitempty"`
}
// TableName 指定表名
func (ExamShare) TableName() string {
return "exam_shares"
}

View File

@ -1,19 +0,0 @@
package models
import (
"gorm.io/datatypes"
)
// PracticeProgress 练习进度记录(每个用户每种题型一条记录)
type PracticeProgress struct {
ID int64 `gorm:"primarykey" json:"id"`
CurrentQuestionID int64 `gorm:"not null" json:"current_question_id"`
UserID int64 `gorm:"not null;uniqueIndex:idx_user_type" json:"user_id"`
Type string `gorm:"type:varchar(255);not null;uniqueIndex:idx_user_type" json:"type"`
UserAnswerRecords datatypes.JSON `gorm:"type:jsonb" json:"answers"`
}
// TableName 指定表名
func (PracticeProgress) TableName() string {
return "practice_progress"
}

View File

@ -1,14 +1,27 @@
package models
import "gorm.io/gorm"
// PracticeQuestionType 题目类型
type PracticeQuestionType string
const (
FillInBlank PracticeQuestionType = "fill-in-blank" // 填空题
TrueFalseType PracticeQuestionType = "true-false" // 判断题
MultipleChoiceQ PracticeQuestionType = "multiple-choice" // 单选题
MultipleSelection PracticeQuestionType = "multiple-selection" // 多选题
ShortAnswer PracticeQuestionType = "short-answer" // 简答题
)
// PracticeQuestion 练习题目模型
type PracticeQuestion struct {
ID int64 `gorm:"primarykey"`
gorm.Model
QuestionID string `gorm:"size:50;not null" json:"question_id"` // 题目ID (原JSON中的id字段)
Type string `gorm:"index;size:30;not null" json:"type"` // 题目类型
Type PracticeQuestionType `gorm:"index;size:30;not null" json:"type"` // 题目类型
TypeName string `gorm:"size:50" json:"type_name"` // 题型名称(中文)
Question string `gorm:"type:text;not null" json:"question"` // 题目内容
AnswerData string `gorm:"type:jsonb" json:"-"` // 答案数据(JSON格式存储)
OptionsData string `gorm:"type:jsonb" json:"-"` // 选项数据(JSON格式存储,用于选择题)
AnswerData string `gorm:"type:text;not null" json:"-"` // 答案数据(JSON格式存储)
OptionsData string `gorm:"type:text" json:"-"` // 选项数据(JSON格式存储,用于选择题)
}
// TableName 指定表名
@ -18,19 +31,16 @@ func (PracticeQuestion) TableName() string {
// PracticeQuestionDTO 用于前端返回的数据传输对象
type PracticeQuestionDTO struct {
ID int64 `json:"id"` // 数据库自增ID
QuestionID string `json:"question_id"` // 题目编号(原JSON中的id)
ID uint `json:"id"`
Type string `json:"type"` // 前端使用的简化类型: single, multiple, judge, fill
Content string `json:"content"` // 题目内容
Options []Option `json:"options"` // 选择题选项数组
Category string `json:"category"` // 题目分类
Answer interface{} `json:"answer"` // 正确答案(用于题目管理编辑)
AnswerLengths []int `json:"answer_lengths,omitempty"` // 答案长度数组(用于打印时计算横线长度)
}
// PracticeAnswerSubmit 练习题答案提交
type PracticeAnswerSubmit struct {
QuestionID int64 `json:"question_id" binding:"required"` // 数据库ID
QuestionID uint `json:"question_id" binding:"required"` // 数据库ID
Answer interface{} `json:"answer" binding:"required"` // 用户答案
}
@ -39,14 +49,4 @@ type PracticeAnswerResult struct {
Correct bool `json:"correct"` // 是否正确
UserAnswer interface{} `json:"user_answer"` // 用户答案
CorrectAnswer interface{} `json:"correct_answer,omitempty"` // 正确答案(仅在错误时返回)
AIGrading *AIGrading `json:"ai_grading,omitempty"` // AI评分结果(仅简答题)
}
// AIGrading AI评分结果
type AIGrading struct {
Score float64 `json:"score"` // 得分 (0-100)
Feedback string `json:"feedback"` // 评语
Suggestion string `json:"suggestion"` // 改进建议
ReferenceAnswer string `json:"reference_answer,omitempty"` // 参考答案(论述题)
ScoringRationale string `json:"scoring_rationale,omitempty"` // 评分依据
}

View File

@ -9,13 +9,11 @@ import (
// User 用户结构
type User struct {
ID int64 `gorm:"primaryKey" json:"id"`
ID uint `gorm:"primaryKey" json:"id"`
Username string `gorm:"uniqueIndex;not null;size:50" json:"username"`
Password string `gorm:"not null;size:255" json:"-"` // json:"-" 表示在JSON响应中不返回密码
Token string `gorm:"size:255;index" json:"-"` // 用户登录token
Avatar string `gorm:"size:255" json:"avatar"`
Nickname string `gorm:"size:50" json:"nickname"`
UserType string `gorm:"size:50" json:"user_type"` // 用户类型: ordinary-person 或 management-person
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
@ -57,7 +55,6 @@ type UserInfoResponse struct {
Username string `json:"username"`
Avatar string `json:"avatar"`
Nickname string `json:"nickname"`
UserType string `json:"user_type"` // 用户类型
}
// RegisterRequest 注册请求
@ -65,5 +62,4 @@ type RegisterRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required,min=6"`
Nickname string `json:"nickname"`
UserType string `json:"user_type" binding:"required,oneof=ordinary-person management-person"` // 用户类型,必填
}

View File

@ -1,16 +0,0 @@
package models
// UserStats 用户统计信息
type UserStats struct {
UserID uint `json:"user_id"`
Username string `json:"username"`
Nickname string `json:"nickname"` // 姓名
Avatar string `json:"avatar"` // 头像
UserType string `json:"user_type"` // 用户类型
TotalAnswers int `json:"total_answers"` // 总答题数
CorrectCount int `json:"correct_count"` // 答对数量
WrongCount int `json:"wrong_count"` // 答错数量
Accuracy float64 `json:"accuracy"` // 正确率(百分比)
CreatedAt string `json:"created_at"` // 用户创建时间
LastAnswerAt *string `json:"last_answer_at"` // 最后答题时间
}

View File

@ -1,121 +0,0 @@
package models
import (
"time"
"gorm.io/gorm"
)
// WrongQuestion 错题记录
type WrongQuestion struct {
ID int64 `gorm:"primarykey" json:"id"`
UserID int64 `gorm:"index;not null" json:"user_id"`
QuestionID int64 `gorm:"index;not null" json:"question_id"`
FirstWrongTime time.Time `json:"first_wrong_time"`
LastWrongTime time.Time `json:"last_wrong_time"`
TotalWrongCount int `gorm:"default:1" json:"total_wrong_count"`
MasteryLevel int `gorm:"default:0" json:"mastery_level"` // 0-100
ConsecutiveCorrect int `gorm:"default:0" json:"consecutive_correct"`
IsMastered bool `gorm:"default:false" json:"is_mastered"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
// 关联
PracticeQuestion *PracticeQuestion `gorm:"foreignKey:QuestionID;references:ID" json:"question,omitempty"`
History []WrongQuestionHistory `gorm:"foreignKey:WrongQuestionID" json:"history,omitempty"`
}
// WrongQuestionHistory 错误历史记录
type WrongQuestionHistory struct {
ID int64 `gorm:"primarykey" json:"id"`
WrongQuestionID int64 `gorm:"index;not null" json:"wrong_question_id"`
UserAnswer string `gorm:"type:jsonb;not null" json:"user_answer"` // JSON 存储
CorrectAnswer string `gorm:"type:jsonb;not null" json:"correct_answer"` // JSON 存储
AnsweredAt time.Time `gorm:"index" json:"answered_at"`
TimeSpent int `json:"time_spent"` // 答题用时(秒)
IsCorrect bool `json:"is_correct"`
}
// TableName 指定表名
func (WrongQuestion) TableName() string {
return "wrong_questions"
}
// TableName 指定表名
func (WrongQuestionHistory) TableName() string {
return "wrong_question_history"
}
// WrongQuestionDTO 错题数据传输对象
type WrongQuestionDTO struct {
ID int64 `json:"id"`
QuestionID int64 `json:"question_id"`
Question *PracticeQuestionDTO `json:"question"`
FirstWrongTime time.Time `json:"first_wrong_time"`
LastWrongTime time.Time `json:"last_wrong_time"`
TotalWrongCount int `json:"total_wrong_count"`
MasteryLevel int `json:"mastery_level"`
ConsecutiveCorrect int `json:"consecutive_correct"`
IsMastered bool `json:"is_mastered"`
RecentHistory []WrongQuestionHistoryDTO `json:"recent_history,omitempty"` // 最近3次历史
}
// WrongQuestionHistoryDTO 错误历史 DTO
type WrongQuestionHistoryDTO struct {
ID int64 `json:"id"`
UserAnswer interface{} `json:"user_answer"`
CorrectAnswer interface{} `json:"correct_answer"`
AnsweredAt time.Time `json:"answered_at"`
TimeSpent int `json:"time_spent"`
IsCorrect bool `json:"is_correct"`
}
// WrongQuestionStats 错题统计
type WrongQuestionStats struct {
TotalWrong int `json:"total_wrong"` // 总错题数
Mastered int `json:"mastered"` // 已掌握数
NotMastered int `json:"not_mastered"` // 未掌握数
NeedReview int `json:"need_review"` // 需要复习数
TypeStats map[string]int `json:"type_stats"` // 按题型统计
CategoryStats map[string]int `json:"category_stats"` // 按分类统计
MasteryLevelDist map[string]int `json:"mastery_level_dist"` // 掌握度分布
TrendData []TrendPoint `json:"trend_data"` // 错题趋势
}
// TrendPoint 趋势数据点
type TrendPoint struct {
Date string `json:"date"`
Count int `json:"count"`
}
// RecordWrongAnswer 记录错误答案
func (wq *WrongQuestion) RecordWrongAnswer() {
now := time.Now()
if wq.FirstWrongTime.IsZero() {
wq.FirstWrongTime = now
}
wq.LastWrongTime = now
wq.TotalWrongCount++
wq.ConsecutiveCorrect = 0 // 重置连续答对次数
wq.IsMastered = false // 重新标记为未掌握
wq.MasteryLevel = 0 // 重置掌握度
}
// RecordCorrectAnswer 记录正确答案
func (wq *WrongQuestion) RecordCorrectAnswer() {
wq.ConsecutiveCorrect++
// 根据连续答对次数更新掌握度每答对一次增加16.67%
// 连续答对6次即达到100%
wq.MasteryLevel = (wq.ConsecutiveCorrect * 100) / 6
if wq.MasteryLevel > 100 {
wq.MasteryLevel = 100
}
// 连续答对6次标记为已掌握
if wq.ConsecutiveCorrect >= 6 {
wq.IsMastered = true
wq.MasteryLevel = 100
}
}

View File

@ -1,131 +0,0 @@
package services
import (
"encoding/json"
"fmt"
"net/http"
"strings"
)
// AIGradingService AI评分服务接口(使用百度云AppBuilder)
type AIGradingService struct {
baiduService *BaiduAIGradingService
}
// NewAIGradingService 创建AI评分服务实例
func NewAIGradingService() (*AIGradingService, error) {
baiduService, err := NewBaiduAIGradingService()
if err != nil {
return nil, fmt.Errorf("创建百度云AI服务失败: %w", err)
}
return &AIGradingService{
baiduService: baiduService,
}, nil
}
// AIGradingResult AI评分结果
type AIGradingResult struct {
Score float64 `json:"score"` // 得分 (0-100)
IsCorrect bool `json:"is_correct"` // 是否正确 (Score >= 60 视为正确)
Feedback string `json:"feedback"` // 评语
Suggestion string `json:"suggestion"` // 改进建议
ReferenceAnswer string `json:"reference_answer"` // 参考答案(论述题)
ScoringRationale string `json:"scoring_rationale"` // 评分依据
}
// GradeEssay 对论述题进行AI评分(不需要标准答案)
// question: 题目内容
// userAnswer: 用户答案
func (s *AIGradingService) GradeEssay(question, userAnswer string) (*AIGradingResult, error) {
if s.baiduService == nil {
return nil, fmt.Errorf("百度云AI服务未初始化")
}
return s.baiduService.GradeEssay(question, userAnswer)
}
// GradeShortAnswer 对简答题进行AI评分
// question: 题目内容
// standardAnswer: 标准答案
// userAnswer: 用户答案
func (s *AIGradingService) GradeShortAnswer(question, standardAnswer, userAnswer string) (*AIGradingResult, error) {
if s.baiduService == nil {
return nil, fmt.Errorf("百度云AI服务未初始化")
}
return s.baiduService.GradeShortAnswer(question, standardAnswer, userAnswer)
}
// AIExplanationResult AI解析结果
type AIExplanationResult struct {
Explanation string `json:"explanation"` // 题目解析
}
// ExplainQuestionStream 生成题目解析(流式输出)
// writer: HTTP响应写入器
// question: 题目内容
// standardAnswer: 标准答案
// questionType: 题目类型
func (s *AIGradingService) ExplainQuestionStream(writer http.ResponseWriter, question, standardAnswer, questionType string) error {
if s.baiduService == nil {
return fmt.Errorf("百度云AI服务未初始化")
}
return s.baiduService.ExplainQuestionStream(writer, question, standardAnswer, questionType)
}
// ExplainQuestion 生成题目解析
// question: 题目内容
// standardAnswer: 标准答案
// questionType: 题目类型
func (s *AIGradingService) ExplainQuestion(question, standardAnswer, questionType string) (*AIExplanationResult, error) {
if s.baiduService == nil {
return nil, fmt.Errorf("百度云AI服务未初始化")
}
return s.baiduService.ExplainQuestion(question, standardAnswer, questionType)
}
// parseAIResponse 解析AI返回的JSON响应
func parseAIResponse(content string, result interface{}) error {
// 移除可能的markdown代码块标记
jsonStr := removeMarkdownCodeBlock(content)
// 使用json包解析
if err := json.Unmarshal([]byte(jsonStr), result); err != nil {
return fmt.Errorf("JSON解析失败: %w, 原始内容: %s", err, content)
}
return nil
}
// removeMarkdownCodeBlock 移除markdown代码块标记
func removeMarkdownCodeBlock(s string) string {
// 去除可能的```json和```标记
s = strings.TrimSpace(s)
// 移除开头的```json或```
if strings.HasPrefix(s, "```json") {
s = s[7:]
} else if strings.HasPrefix(s, "```") {
s = s[3:]
}
// 移除结尾的```
if strings.HasSuffix(s, "```") {
s = s[:len(s)-3]
}
s = strings.TrimSpace(s)
// 查找第一个{的位置
startIdx := strings.Index(s, "{")
if startIdx == -1 {
return s
}
// 查找最后一个}的位置
endIdx := strings.LastIndex(s, "}")
if endIdx == -1 || endIdx <= startIdx {
return s
}
return s[startIdx : endIdx+1]
}

View File

@ -1,388 +0,0 @@
package services
import (
"ankao/pkg/config"
"errors"
"fmt"
"io"
"log"
"net/http"
"strings"
"sync"
"time"
"github.com/baidubce/app-builder/go/appbuilder"
)
// BaiduAIGradingService 百度云AI评分服务
type BaiduAIGradingService struct {
client *appbuilder.AppBuilderClient
config *config.AIConfig
conversationID string // 会话ID,用于保持上下文
}
// 全局单例和锁
var (
globalBaiduService *BaiduAIGradingService
serviceMutex sync.Mutex
serviceInitialized bool
)
// NewBaiduAIGradingService 创建百度云AI评分服务实例(使用单例模式)
func NewBaiduAIGradingService() (*BaiduAIGradingService, error) {
serviceMutex.Lock()
defer serviceMutex.Unlock()
// 如果已经初始化过,直接返回
if serviceInitialized && globalBaiduService != nil {
return globalBaiduService, nil
}
cfg := config.GetAIConfig()
// 设置百度云AppBuilder Token
clientConfig, err := appbuilder.NewSDKConfig("", cfg.APIKey)
if err != nil {
return nil, fmt.Errorf("创建SDK配置失败: %w", err)
}
// 创建AppBuilder客户端
client, err := appbuilder.NewAppBuilderClient(cfg.BaiduAppID, clientConfig)
if err != nil {
return nil, fmt.Errorf("创建AppBuilder客户端失败: %w", err)
}
// 创建会话
conversationID, err := client.CreateConversation()
if err != nil {
return nil, fmt.Errorf("创建会话失败: %w", err)
}
log.Printf("百度云AI服务初始化成功会话ID: %s", conversationID)
globalBaiduService = &BaiduAIGradingService{
client: client,
config: cfg,
conversationID: conversationID,
}
serviceInitialized = true
return globalBaiduService, nil
}
// GradeEssay 对论述题进行AI评分(不需要标准答案)
func (s *BaiduAIGradingService) GradeEssay(question, userAnswer string) (*AIGradingResult, error) {
prompt := fmt.Sprintf(`你是一位专业的保密领域阅卷老师,请对以下论述题进行评分
题目:%s
学生答案:%s
评分标准(论述题没有固定标准答案,请根据答题质量和法规符合度评分):
1. 论点是否明确,是否符合保密法规要求(30)
2. 内容是否充实,论据是否引用相关法规条文(30)
3. 逻辑是否严密,分析是否符合保密工作实际(25)
4. 语言表达是否准确专业(15)
评分等级:
- 85-100:论述优秀,论点明确论据充分符合法规要求分析专业
- 70-84:论述良好,基本要素齐全,符合保密工作要求
- 60-69:论述基本合格,要点基本涵盖但不够深入
- 40-59:论述不够完整,缺乏法规支撑或逻辑性较差
- 0-39:论述严重缺失或完全离题
判断标准:60分及以上为正确(is_correct: true),否则为错误(is_correct: false)
评分要求:
1. 给出一个0-100的精确分数
2. 判断答案是否正确(is_correct: 60分及以上为true,否则为false)
3. 生成一个专业的参考答案(reference_answer,150-300,必须引用相关法规条文)
4. 给出评分依据(scoring_rationale,说明依据了哪些法规和条文,80-150)
5. 给出简短的评语(feedback,说明得分情况,不超过80字)
6. 给出具体的改进建议(suggestion,如果分数在90分以上可以简短,否则必须指出具体改进方向,不超过80字)
请按照以下JSON格式返回结果:
{
"score": 75,
"is_correct": true,
"reference_answer": "根据《中华人民共和国保守国家秘密法》第XX条...",
"scoring_rationale": "依据《保密法》第XX条、《保密法实施条例》第XX条...",
"feedback": "论述较为完整,论点明确,但论据不够充分,缺少具体法规引用",
"suggestion": "建议补充《保密法》相关条文,加强论点之间的逻辑联系"
}
注意:
1. 只返回JSON格式的结果,不要有其他内容
2. 参考答案必须专业准确,体现保密法规要求
3. 评分依据必须具体引用法规条文`, question, userAnswer)
// 调用百度云AI
answer, err := s.runAppBuilder(prompt)
if err != nil {
return nil, err
}
// 解析返回结果
var result AIGradingResult
if err := parseAIResponse(answer, &result); err != nil {
return nil, fmt.Errorf("解析AI响应失败: %w", err)
}
return &result, nil
}
// GradeShortAnswer 对简答题进行AI评分
func (s *BaiduAIGradingService) GradeShortAnswer(question, standardAnswer, userAnswer string) (*AIGradingResult, error) {
prompt := fmt.Sprintf(`你是一位专业的保密领域阅卷老师,请严格按照标准答案对以下简答题进行评分
题目:%s
标准答案:%s
学生答案:%s
评分依据:
请依据以下保密法律法规和管理制度进行分析和评分:
1. 中华人民共和国保守国家秘密法
2. 中华人民共和国保守国家秘密法实施条例
3. 保密工作管理制度2025.9.9
4. 软件开发管理制度
5. 涉密信息系统集成资质保密标准
6. 涉密信息系统集成资质管理办法
评分标准(请严格遵守):
1. 必须与标准答案进行逐项对比
2. 答案要点完全覆盖标准答案且表述准确符合法规要求的,给85-100
3. 答案要点基本覆盖但有缺漏或表述不够准确的,给60-84
4. 答案要点缺失较多或有明显错误的,给40-59
5. 答案完全错误或离题的,给0-39
6. 判断标准:60分及以上为正确(is_correct: true),否则为错误(is_correct: false)
评分要求:
1. 给出一个0-100的精确分数
2. 判断答案是否正确(is_correct: 60分及以上为true,否则为false)
3. 给出评分依据(scoring_rationale,说明依据了哪些法规和标准答案的哪些要点,80-150)
4. 给出简短的评语(feedback,说明得分和失分原因,不超过80字)
5. 给出具体的改进建议(suggestion,如果答案满分可以为空,否则必须指出具体改进方向,不超过80字)
请按照以下JSON格式返回结果:
{
"score": 85,
"is_correct": true,
"scoring_rationale": "依据《保密法》第XX条和标准答案要点分析...",
"feedback": "答案覆盖了主要要点,但XXX部分描述不够准确",
"suggestion": "建议补充XXX内容,并完善XXX的描述"
}
注意:
1. 只返回JSON格式的结果,不要有其他内容
2. 必须严格对照标准答案评分,不要过于宽松
3. 评分依据必须说明符合或违反了哪些法规要求`, question, standardAnswer, userAnswer)
// 调用百度云AI
answer, err := s.runAppBuilder(prompt)
if err != nil {
return nil, err
}
// 解析返回结果
var result AIGradingResult
if err := parseAIResponse(answer, &result); err != nil {
return nil, fmt.Errorf("解析AI响应失败: %w", err)
}
return &result, nil
}
// ExplainQuestionStream 生成题目解析(流式输出)
func (s *BaiduAIGradingService) ExplainQuestionStream(writer http.ResponseWriter, question, standardAnswer, questionType string) error {
prompt := fmt.Sprintf(`你是一位严谨专业的保密领域专家老师,请对以下题目进行详细解析
题目类型:%s
题目内容:%s
标准答案:%s
请提供一个详细的解析,要求:
1. **必须基于保密法规**:解析时必须引用相关法规条文,说明依据哪些具体法律法规
2. **必须实事求是**:只基于题目内容标准答案和实际法规进行解析
3. **不要胡编乱造**:如果某些信息不确定或题目没有提供,请如实说明,不要编造法规条文
解析内容要求:
- **知识点**:说明题目考查的核心知识点,指出涉及哪些保密法规
- **法规依据**:明确引用相关法律法规的具体条文(:保密法第X条保密法实施条例第X条等)
- **解题思路**:提供清晰的解题步骤和方法,结合保密工作实际
%s
示例输出格式:
## 知识点
本题考查的是[知识点名称],涉及XX法规第XX条...
## 法规依据
- 中华人民共和国保守国家秘密法第XX条规定:...
- 保密工作管理制度2025.9.9第X章第X节:...
## 解题思路
1. 首先根据XX法规第XX条,我们可以判断...
2. 然后结合保密工作实际,分析...
%s
## 总结
%s
**重要提醒**:请务必在解析中引用具体的法规条文,不要空泛地提及法规名称如果不确定具体条文编号,可以说明法规的精神和要求
请使用markdown格式输出解析内容`,
questionType,
question,
standardAnswer,
// 根据题目类型添加特定要求
func() string {
if questionType == "single-selection" || questionType == "multiple-selection" {
return `- **选项分析**:对于选择题,必须逐项分析每个选项的对错及原因,并说明依据哪些法规
- **记忆口诀**:如果适用,提供便于记忆的口诀或技巧`
}
return "- **答案解析**:详细说明为什么这个答案是正确的,并引用相关法规依据"
}(),
// 根据题目类型添加示例格式
func() string {
if questionType == "single-selection" || questionType == "multiple-selection" {
return `## 选项分析
- **A选项**:[分析该选项],根据XX法规第XX条...
- **B选项**:[分析该选项],根据XX法规第XX条...
- **C选项**:[分析该选项],根据XX法规第XX条...
- **D选项**:[分析该选项],根据XX法规第XX条...
## 正确答案
正确答案是... 因为根据XX法规第XX条规定...`
}
return `## 答案解析
正确答案是... 根据XX法规第XX条的规定...`
}(),
// 根据题目类型添加总结要求
func() string {
if questionType == "single-selection" || questionType == "multiple-selection" {
return "对于选择题,可以提供记忆口诀或关键要点总结,并总结涉及的主要法规要求"
}
return "总结本题的关键要点、涉及的主要法规要求和在保密工作中的实际应用"
}(),
)
// 调用百度云AI(流式)
return s.runAppBuilderStream(writer, prompt)
}
// ExplainQuestion 生成题目解析
func (s *BaiduAIGradingService) ExplainQuestion(question, standardAnswer, questionType string) (*AIExplanationResult, error) {
prompt := fmt.Sprintf(`你是一位经验丰富的老师,请对以下题目进行详细解析
题目类型:%s
题目内容:%s
标准答案:%s
请提供一个详细的解析,包括:
1. 题目考查的知识点
2. 解题思路和方法
3. 为什么选择这个答案
4. 相关的重要概念或注意事项
请按照以下JSON格式返回结果:
{
"explanation": "这道题考查的是...(200字以内的详细解析)"
}
注意:只返回JSON格式的结果,不要有其他内容`, questionType, question, standardAnswer)
// 调用百度云AI
answer, err := s.runAppBuilder(prompt)
if err != nil {
return nil, err
}
// 解析返回结果
var result AIExplanationResult
if err := parseAIResponse(answer, &result); err != nil {
return nil, fmt.Errorf("解析AI响应失败: %w", err)
}
return &result, nil
}
// runAppBuilder 调用百度云AppBuilder(非流式)
func (s *BaiduAIGradingService) runAppBuilder(query string) (string, error) {
startTime := time.Now()
log.Printf("[百度云AI] 开始调用会话ID: %s", s.conversationID)
// 调用AppBuilder Run方法 - 注意:即使stream=false,依然需要迭代读取
iterator, err := s.client.Run(s.conversationID, query, nil, false)
if err != nil {
return "", fmt.Errorf("调用AppBuilder失败: %w", err)
}
// 收集所有返回内容
var fullAnswer strings.Builder
// 只读取一次 - 非流式模式下SDK应该一次性返回完整结果
answer, err := iterator.Next()
if err != nil {
if errors.Is(err, io.EOF) {
return "", fmt.Errorf("AI未返回任何内容")
}
return "", fmt.Errorf("读取AppBuilder响应失败: %w", err)
}
if answer != nil && answer.Answer != "" {
fullAnswer.WriteString(answer.Answer)
}
result := fullAnswer.String()
if result == "" {
return "", fmt.Errorf("AI未返回任何内容")
}
elapsed := time.Since(startTime)
log.Printf("[百度云AI] 调用完成,耗时: %v, 返回长度: %d", elapsed, len(result))
return result, nil
}
// runAppBuilderStream 调用百度云AppBuilder(流式)
func (s *BaiduAIGradingService) runAppBuilderStream(writer http.ResponseWriter, query string) error {
// 调用AppBuilder Run方法(流式)
iterator, err := s.client.Run(s.conversationID, query, nil, true)
if err != nil {
return fmt.Errorf("创建流式请求失败: %w", err)
}
flusher, ok := writer.(http.Flusher)
if !ok {
return fmt.Errorf("响应写入器不支持Flush")
}
// 读取流式响应并发送给客户端 - 参考百度云SDK示例代码的方式
var answer *appbuilder.AppBuilderClientAnswer
for answer, err = iterator.Next(); err == nil; answer, err = iterator.Next() {
// 发送增量内容
if answer != nil && answer.Answer != "" {
// 使用SSE格式发送
fmt.Fprintf(writer, "data: %s\n\n", answer.Answer)
flusher.Flush()
}
}
// 检查是否因为EOF之外的错误退出 - 使用errors.Is更安全
if err != nil && !errors.Is(err, io.EOF) {
return fmt.Errorf("接收流式响应失败: %w", err)
}
// 流结束
fmt.Fprintf(writer, "data: [DONE]\n\n")
flusher.Flush()
return nil
}

View File

@ -1,201 +0,0 @@
package services
import (
"ankao/internal/database"
"ankao/internal/models"
"encoding/json"
"fmt"
"log"
"math/rand"
"time"
"gorm.io/gorm"
)
type DailyExamService struct {
db *gorm.DB
}
func NewDailyExamService() *DailyExamService {
return &DailyExamService{
db: database.GetDB(),
}
}
// GenerateDailyExam 生成每日一练试卷
func (s *DailyExamService) GenerateDailyExam() error {
// 1. 获取分布式锁使用日期作为锁ID
today := time.Now().Format("20060102")
lockID := hashString(today) // 使用日期哈希作为锁ID
var locked bool
if err := s.db.Raw("SELECT pg_try_advisory_lock(?)", lockID).Scan(&locked).Error; err != nil {
return fmt.Errorf("获取锁失败: %w", err)
}
if !locked {
log.Println("其他实例正在生成每日一练,跳过")
return nil
}
defer s.db.Exec("SELECT pg_advisory_unlock(?)", lockID)
// 2. 检查今天是否已生成
todayStart := time.Now().Truncate(24 * time.Hour)
todayEnd := todayStart.Add(24 * time.Hour)
var count int64
s.db.Model(&models.Exam{}).
Where("is_system = ? AND created_at >= ? AND created_at < ?",
true, todayStart, todayEnd).
Count(&count)
if count > 0 {
log.Println("今日每日一练已生成,跳过")
return nil
}
// 3. 生成试卷标题
now := time.Now()
title := fmt.Sprintf("%d年%02d月%02d日的每日一练",
now.Year(), now.Month(), now.Day())
// 4. 随机选择题目(使用与创建试卷相同的逻辑)
questionIDs, totalScore, err := s.selectQuestions()
if err != nil {
return fmt.Errorf("选择题目失败: %w", err)
}
questionIDsJSON, _ := json.Marshal(questionIDs)
// 5. 创建试卷(使用第一个用户作为创建者,但标记为系统试卷)
// 获取第一个用户ID
var firstUser models.User
if err := s.db.Order("id ASC").First(&firstUser).Error; err != nil {
return fmt.Errorf("查询用户失败: %w", err)
}
exam := models.Exam{
UserID: uint(firstUser.ID), // 使用第一个用户作为创建者
Title: title,
TotalScore: int(totalScore),
Duration: 60,
PassScore: 80,
QuestionIDs: questionIDsJSON,
Status: "active",
IsSystem: true, // 标记为系统试卷
}
if err := s.db.Create(&exam).Error; err != nil {
return fmt.Errorf("创建试卷失败: %w", err)
}
log.Printf("成功创建每日一练试卷: ID=%d, Title=%s", exam.ID, exam.Title)
// 6. 分享给所有用户
if err := s.shareToAllUsers(exam.ID, uint(firstUser.ID)); err != nil {
log.Printf("分享试卷失败: %v", err)
// 不返回错误,因为试卷已创建成功
}
return nil
}
// selectQuestions 选择题目(复用现有逻辑)
func (s *DailyExamService) selectQuestions() ([]int64, float64, error) {
questionTypes := []struct {
Type string
Count int
Score float64
}{
{Type: "fill-in-blank", Count: 20, Score: 2.0}, // 40分
{Type: "true-false", Count: 10, Score: 1.0}, // 10分
{Type: "multiple-choice", Count: 10, Score: 1.0}, // 10分
{Type: "multiple-selection", Count: 10, Score: 2.0}, // 20分
{Type: "short-answer", Count: 1, Score: 10.0}, // 10分
{Type: "ordinary-essay", Count: 1, Score: 10.0}, // 10分普通涉密人员论述题
{Type: "management-essay", Count: 1, Score: 10.0}, // 10分保密管理人员论述题
}
var allQuestionIDs []int64
var totalScore float64
for _, qt := range questionTypes {
var questions []models.PracticeQuestion
if err := s.db.Where("type = ?", qt.Type).Find(&questions).Error; err != nil {
return nil, 0, err
}
// 检查题目数量是否足够
if len(questions) < qt.Count {
return nil, 0, fmt.Errorf("题型 %s 题目数量不足,需要 %d 道,实际 %d 道",
qt.Type, qt.Count, len(questions))
}
// 随机抽取 (Fisher-Yates 洗牌算法)
rand.Seed(time.Now().UnixNano())
for i := len(questions) - 1; i > 0; i-- {
j := rand.Intn(i + 1)
questions[i], questions[j] = questions[j], questions[i]
}
selectedQuestions := questions[:qt.Count]
for _, q := range selectedQuestions {
allQuestionIDs = append(allQuestionIDs, q.ID)
}
totalScore += float64(qt.Count) * qt.Score
}
// 随机打乱题目ID顺序
rand.Seed(time.Now().UnixNano())
for i := len(allQuestionIDs) - 1; i > 0; i-- {
j := rand.Intn(i + 1)
allQuestionIDs[i], allQuestionIDs[j] = allQuestionIDs[j], allQuestionIDs[i]
}
return allQuestionIDs, totalScore, nil
}
// shareToAllUsers 分享给所有用户
func (s *DailyExamService) shareToAllUsers(examID uint, sharedByID uint) error {
// 查询所有用户(排除创建者)
var users []models.User
if err := s.db.Where("id != ?", sharedByID).Find(&users).Error; err != nil {
return err
}
// 批量创建分享记录
now := time.Now()
shares := make([]models.ExamShare, 0, len(users))
for _, user := range users {
shares = append(shares, models.ExamShare{
ExamID: examID,
SharedByID: int64(sharedByID),
SharedToID: int64(user.ID),
SharedAt: now,
})
}
if len(shares) > 0 {
// 批量插入
if err := s.db.Create(&shares).Error; err != nil {
return err
}
log.Printf("成功分享给 %d 个用户", len(shares))
}
return nil
}
// hashString 计算字符串哈希值用于生成锁ID
func hashString(s string) int64 {
var hash int64
for _, c := range s {
hash = hash*31 + int64(c)
}
// 确保返回正数
if hash < 0 {
hash = -hash
}
return hash
}

View File

@ -1,289 +0,0 @@
package services
import (
"ankao/internal/database"
"ankao/internal/models"
"encoding/json"
"fmt"
"log"
"time"
"gorm.io/gorm"
)
// ==================== 错题服务 ====================
// RecordWrongAnswer 记录错误答案
func RecordWrongAnswer(userID, questionID int64, userAnswer, correctAnswer interface{}, timeSpent int) error {
db := database.GetDB()
log.Printf("[错题记录] 开始记录错题 (userID: %d, questionID: %d)", userID, questionID)
// 序列化答案
userAnswerJSON, _ := json.Marshal(userAnswer)
correctAnswerJSON, _ := json.Marshal(correctAnswer)
// 查找或创建错题记录
var wrongQuestion models.WrongQuestion
err := db.Where("user_id = ? AND question_id = ?", userID, questionID).First(&wrongQuestion).Error
if err != nil {
// 不存在,创建新记录
log.Printf("[错题记录] 创建新错题记录 (userID: %d, questionID: %d)", userID, questionID)
wrongQuestion = models.WrongQuestion{
UserID: userID,
QuestionID: questionID,
}
wrongQuestion.RecordWrongAnswer()
if err := db.Create(&wrongQuestion).Error; err != nil {
log.Printf("[错题记录] 创建错题记录失败: %v", err)
return fmt.Errorf("创建错题记录失败: %v", err)
}
log.Printf("[错题记录] 成功创建错题记录 (ID: %d)", wrongQuestion.ID)
} else {
// 已存在,更新记录
log.Printf("[错题记录] 更新已存在的错题记录 (ID: %d)", wrongQuestion.ID)
wrongQuestion.RecordWrongAnswer()
if err := db.Save(&wrongQuestion).Error; err != nil {
log.Printf("[错题记录] 更新错题记录失败: %v", err)
return fmt.Errorf("更新错题记录失败: %v", err)
}
log.Printf("[错题记录] 成功更新错题记录 (ID: %d, 错误次数: %d)", wrongQuestion.ID, wrongQuestion.TotalWrongCount)
}
// 创建历史记录
history := models.WrongQuestionHistory{
WrongQuestionID: wrongQuestion.ID,
UserAnswer: string(userAnswerJSON),
CorrectAnswer: string(correctAnswerJSON),
AnsweredAt: time.Now(),
TimeSpent: timeSpent,
IsCorrect: false,
}
if err := db.Create(&history).Error; err != nil {
log.Printf("[错题记录] 创建错题历史失败: %v", err)
} else {
log.Printf("[错题记录] 成功创建历史记录 (ID: %d, WrongQuestionID: %d)", history.ID, history.WrongQuestionID)
}
return nil
}
// RecordCorrectAnswer 记录正确答案(用于错题练习)
func RecordCorrectAnswer(userID, questionID int64, userAnswer, correctAnswer interface{}, timeSpent int) error {
db := database.GetDB()
// 查找错题记录
var wrongQuestion models.WrongQuestion
err := db.Where("user_id = ? AND question_id = ?", userID, questionID).First(&wrongQuestion).Error
if err != nil {
// 不存在错题记录,无需处理
return nil
}
// 序列化答案
userAnswerJSON, _ := json.Marshal(userAnswer)
correctAnswerJSON, _ := json.Marshal(correctAnswer)
// 更新连续答对次数
wrongQuestion.RecordCorrectAnswer()
if err := db.Save(&wrongQuestion).Error; err != nil {
return fmt.Errorf("更新错题记录失败: %v", err)
}
// 创建历史记录
history := models.WrongQuestionHistory{
WrongQuestionID: wrongQuestion.ID,
UserAnswer: string(userAnswerJSON),
CorrectAnswer: string(correctAnswerJSON),
AnsweredAt: time.Now(),
TimeSpent: timeSpent,
IsCorrect: true,
}
if err := db.Create(&history).Error; err != nil {
log.Printf("创建错题历史失败: %v", err)
}
return nil
}
// GetWrongQuestionStats 获取错题统计
func GetWrongQuestionStats(userID int64) (*models.WrongQuestionStats, error) {
db := database.GetDB()
stats := &models.WrongQuestionStats{
TypeStats: make(map[string]int),
CategoryStats: make(map[string]int),
MasteryLevelDist: make(map[string]int),
}
// 基础统计
var totalWrong, mastered int64
db.Model(&models.WrongQuestion{}).Where("user_id = ?", userID).Count(&totalWrong)
db.Model(&models.WrongQuestion{}).Where("user_id = ? AND is_mastered = ?", userID, true).Count(&mastered)
stats.TotalWrong = int(totalWrong)
stats.Mastered = int(mastered)
stats.NotMastered = int(totalWrong) - int(mastered)
stats.NeedReview = 0 // 不再使用复习时间设置为0
// 按题型统计
var typeStats []struct {
Type string
Count int
}
db.Model(&models.WrongQuestion{}).
Select("practice_questions.type, COUNT(*) as count").
Joins("LEFT JOIN practice_questions ON practice_questions.id = wrong_questions.question_id").
Where("wrong_questions.user_id = ?", userID).
Group("practice_questions.type").
Scan(&typeStats)
for _, ts := range typeStats {
stats.TypeStats[ts.Type] = ts.Count
}
// 按分类统计
var categoryStats []struct {
Category string
Count int
}
db.Model(&models.WrongQuestion{}).
Select("practice_questions.category, COUNT(*) as count").
Joins("LEFT JOIN practice_questions ON practice_questions.id = wrong_questions.question_id").
Where("wrong_questions.user_id = ?", userID).
Group("practice_questions.category").
Scan(&categoryStats)
for _, cs := range categoryStats {
stats.CategoryStats[cs.Category] = cs.Count
}
// 掌握度分布
var masteryDist []struct {
Level string
Count int
}
db.Model(&models.WrongQuestion{}).
Select(`
CASE
WHEN mastery_level >= 80 THEN '优秀'
WHEN mastery_level >= 60 THEN '良好'
WHEN mastery_level >= 40 THEN '一般'
WHEN mastery_level >= 20 THEN '较差'
ELSE '很差'
END as level,
COUNT(*) as count
`).
Where("user_id = ?", userID).
Group("level").
Scan(&masteryDist)
for _, md := range masteryDist {
stats.MasteryLevelDist[md.Level] = md.Count
}
// 错题趋势最近7天
stats.TrendData = calculateTrendData(db, userID, 7)
return stats, nil
}
// calculateTrendData 计算错题趋势数据
func calculateTrendData(db *gorm.DB, userID int64, days int) []models.TrendPoint {
trendData := make([]models.TrendPoint, days)
now := time.Now()
for i := days - 1; i >= 0; i-- {
date := now.AddDate(0, 0, -i)
dateStr := date.Format("01-02")
var count int64
startOfDay := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
endOfDay := startOfDay.Add(24 * time.Hour)
db.Model(&models.WrongQuestion{}).
Where("user_id = ? AND last_wrong_time >= ? AND last_wrong_time < ?", userID, startOfDay, endOfDay).
Count(&count)
trendData[days-1-i] = models.TrendPoint{
Date: dateStr,
Count: int(count),
}
}
return trendData
}
// GetRecommendedWrongQuestions 获取推荐练习的错题(智能推荐)
// 推荐策略(按优先级):
// 1. 最优先推荐掌握度为0的题目从未答对过
// 2. 其次推荐掌握度低的题目mastery_level 从低到高)
// 3. 最后推荐最近答错的题目
func GetRecommendedWrongQuestions(userID int64, limit int, excludeQuestionID int64) ([]models.WrongQuestion, error) {
db := database.GetDB()
var questions []models.WrongQuestion
// 策略1: 最优先推荐掌握度为0的题目从未答对过
var zeroMastery []models.WrongQuestion
query1 := db.Where("user_id = ? AND is_mastered = ? AND mastery_level = 0", userID, false)
if excludeQuestionID > 0 {
query1 = query1.Where("question_id != ?", excludeQuestionID)
}
query1.Order("total_wrong_count DESC, last_wrong_time DESC").
Limit(limit).
Preload("PracticeQuestion").
Find(&zeroMastery)
questions = append(questions, zeroMastery...)
// 如果已经够了,直接返回
if len(questions) >= limit {
return questions[:limit], nil
}
// 策略2: 推荐掌握度低的题目mastery_level 从低到高)
var lowMastery []models.WrongQuestion
query2 := db.Where("user_id = ? AND is_mastered = ? AND mastery_level > 0 AND id NOT IN ?", userID, false, getIDs(questions))
if excludeQuestionID > 0 {
query2 = query2.Where("question_id != ?", excludeQuestionID)
}
query2.Order("mastery_level ASC, total_wrong_count DESC").
Limit(limit - len(questions)).
Preload("PracticeQuestion").
Find(&lowMastery)
questions = append(questions, lowMastery...)
if len(questions) >= limit {
return questions[:limit], nil
}
// 策略3: 最近答错的题目(填充剩余,以防万一)
var recent []models.WrongQuestion
query3 := db.Where("user_id = ? AND is_mastered = ? AND id NOT IN ?", userID, false, getIDs(questions))
if excludeQuestionID > 0 {
query3 = query3.Where("question_id != ?", excludeQuestionID)
}
query3.Order("last_wrong_time DESC").
Limit(limit - len(questions)).
Preload("PracticeQuestion").
Find(&recent)
questions = append(questions, recent...)
return questions, nil
}
// getIDs 获取错题记录的ID列表
func getIDs(questions []models.WrongQuestion) []int64 {
if len(questions) == 0 {
return []int64{0} // 避免 SQL 错误
}
ids := make([]int64, len(questions))
for i, q := range questions {
ids[i] = q.ID
}
return ids
}

View File

@ -1,123 +0,0 @@
kind: Deployment
apiVersion: apps/v1
metadata:
name: ankao
namespace: default
spec:
selector:
matchLabels:
app: ankao
replicas: 1
template:
metadata:
labels:
app: ankao
spec:
imagePullSecrets:
- name: aliyun
containers:
- name: ankao
image: registry.cn-qingdao.aliyuncs.com/yuchat/ankao:0.0.9
env:
- name: DB_HOST
value: pgsql
- name: DB_USERNAME
value: postgres
- name: DB_PASSWORD
value: longqi@1314
- name: AI_BASE_URL
value: http://new-api
- name: AI_API_KEY
value: sk-OKBmOpJx855juSOPU14cWG6Iz87tZQuv3Xg9PiaJYXdHoKcN
- name: AI_MODEL
value: deepseek-v3
ports:
- containerPort: 8080
name: tcp-8080
# 存活探针 - 检测容器是否正在运行
# 如果失败Kubernetes 会重启容器
livenessProbe:
httpGet:
path: /api/health
port: 8080
scheme: HTTP
initialDelaySeconds: 30 # 容器启动后等待30秒再开始探测
periodSeconds: 10 # 每10秒探测一次
timeoutSeconds: 5 # 探测超时时间5秒
successThreshold: 1 # 成功1次即认为成功
failureThreshold: 3 # 连续失败3次后重启容器
# 就绪探针 - 检测容器是否准备好接收流量
# 如果失败,会从 Service 负载均衡中移除
readinessProbe:
httpGet:
path: /api/health
port: 8080
scheme: HTTP
initialDelaySeconds: 10 # 容器启动后等待10秒再开始探测
periodSeconds: 5 # 每5秒探测一次
timeoutSeconds: 3 # 探测超时时间3秒
successThreshold: 1 # 成功1次即认为就绪
failureThreshold: 3 # 连续失败3次后标记为未就绪
# 启动探针 - 检测容器应用是否已经启动(可选)
# 启动探针成功后,存活探针和就绪探针才会接管
startupProbe:
httpGet:
path: /api/health
port: 8080
scheme: HTTP
initialDelaySeconds: 0 # 立即开始探测
periodSeconds: 5 # 每5秒探测一次
timeoutSeconds: 3 # 探测超时时间3秒
successThreshold: 1 # 成功1次即认为启动完成
failureThreshold: 12 # 最多失败12次60秒才判定启动失败
# 资源限制(建议配置)
resources:
requests:
memory: "256Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
volumeMounts:
- name: timezone
mountPath: /etc/timezone
readOnly: true
- name: localtime
mountPath: /etc/localtime
readOnly: true
volumes:
- name: timezone
hostPath:
path: /etc/timezone
type: File
- name: localtime
hostPath:
path: /etc/localtime
type: File
restartPolicy: Always
---
kind: Service
apiVersion: v1
metadata:
name: ankao
namespace: default
spec:
selector:
app: ankao
ports:
- name: tcp-80
port: 80
targetPort: 8080
type: LoadBalancer
ipFamilyPolicy: PreferDualStack
ipFamilies:
- IPv4
- IPv6

151
main.go
View File

@ -4,13 +4,9 @@ import (
"ankao/internal/database"
"ankao/internal/handlers"
"ankao/internal/middleware"
"ankao/internal/services"
"log"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/robfig/cron/v3"
)
func main() {
@ -27,7 +23,11 @@ func main() {
r.Use(middleware.CORS())
r.Use(middleware.Logger())
// API路由组必须在静态文件服务之前注册
// 静态文件服务
r.Static("/static", "./web/static")
r.StaticFile("/", "./web/index.html")
// API路由组
api := r.Group("/api")
{
// 健康检查
@ -37,142 +37,17 @@ func main() {
api.POST("/login", handlers.Login) // 用户登录
api.POST("/register", handlers.Register) // 用户注册
// 需要认证的路由
auth := api.Group("", middleware.Auth())
{
// 用户相关API
auth.PUT("/user/type", handlers.UpdateUserType) // 更新用户类型
auth.PUT("/user/profile", handlers.UpdateProfile) // 更新用户信息
auth.PUT("/user/password", handlers.ChangePassword) // 修改密码
// 排行榜API
auth.GET("/ranking/daily", handlers.GetDailyRanking) // 获取今日排行榜
auth.GET("/ranking/total", handlers.GetTotalRanking) // 获取总排行榜
// 练习题相关API需要登录
auth.GET("/practice/questions", handlers.GetPracticeQuestions) // 获取练习题目列表
auth.GET("/practice/questions/:id", handlers.GetPracticeQuestionByID) // 获取指定练习题目
auth.POST("/practice/explain", handlers.ExplainQuestion) // 生成题目解析AI
// 练习题提交(需要登录才能记录错题)
auth.POST("/practice/submit", handlers.SubmitPracticeAnswer) // 提交练习答案
auth.GET("/practice/statistics", handlers.GetStatistics) // 获取统计数据
// 练习进度相关API
auth.GET("/practice/progress", handlers.GetPracticeProgress) // 获取练习进度
auth.DELETE("/practice/progress", handlers.ClearPracticeProgress) // 清除练习进度
// 错题本相关API
auth.GET("/wrong-questions", handlers.GetWrongQuestions) // 获取错题列表(支持筛选和排序)
auth.GET("/wrong-questions/stats", handlers.GetWrongQuestionStats) // 获取错题统计(含趋势)
auth.GET("/wrong-questions/recommended", handlers.GetRecommendedWrongQuestions) // 获取推荐错题
auth.GET("/wrong-questions/:id", handlers.GetWrongQuestionDetail) // 获取错题详情
auth.DELETE("/wrong-questions/:id", handlers.DeleteWrongQuestion) // 删除错题
auth.DELETE("/wrong-questions", handlers.ClearWrongQuestions) // 清空错题本
// 模拟考试相关API
auth.POST("/exams", handlers.CreateExam) // 创建试卷
auth.GET("/exams", handlers.GetExamList) // 获取试卷列表
auth.GET("/exams/:id", handlers.GetExamDetail) // 获取试卷详情
auth.POST("/exams/:id/start", handlers.StartExam) // 开始考试
auth.POST("/exam-records/:record_id/submit", handlers.SubmitExam) // 提交试卷答案
auth.GET("/exam-records/:record_id", handlers.GetExamRecord) // 获取考试记录详情
auth.GET("/exam-records", handlers.GetExamRecordList) // 获取考试记录列表
auth.DELETE("/exams/:id", handlers.DeleteExam) // 删除试卷
auth.POST("/exam-records/:record_id/progress", handlers.SaveExamProgress) // 保存考试进度
auth.GET("/exam-records/:record_id/answers", handlers.GetExamUserAnswers) // 获取用户答案
auth.GET("/users/shareable", handlers.GetShareableUsers) // 获取可分享的用户列表
auth.POST("/exams/:id/share", handlers.ShareExam) // 分享试卷
auth.GET("/daily-exam/ranking", handlers.GetDailyExamRanking) // 获取每日一练排行榜
// 练习题相关API
api.GET("/practice/questions", handlers.GetPracticeQuestions) // 获取练习题目列表
api.GET("/practice/questions/random", handlers.GetRandomPracticeQuestion) // 获取随机练习题目
api.GET("/practice/questions/:id", handlers.GetPracticeQuestionByID) // 获取指定练习题目
api.POST("/practice/submit", handlers.SubmitPracticeAnswer) // 提交练习答案
api.GET("/practice/types", handlers.GetPracticeQuestionTypes) // 获取题型列表
}
// 题库管理API需要管理员权限
admin := api.Group("", middleware.Auth(), middleware.AdminAuth())
{
admin.POST("/practice/questions", handlers.CreatePracticeQuestion) // 创建题目
admin.PUT("/practice/questions/:id", handlers.UpdatePracticeQuestion) // 更新题目
admin.DELETE("/practice/questions/:id", handlers.DeletePracticeQuestion) // 删除题目
}
// 用户管理API仅yanlongqi用户可访问
userAdmin := api.Group("", middleware.Auth(), middleware.AdminOnly())
{
userAdmin.GET("/admin/users", handlers.GetAllUsersWithStats) // 获取所有用户及统计
userAdmin.GET("/admin/users/:id", handlers.GetUserDetailStats) // 获取用户详细统计
}
}
// 静态文件服务(使用 NoRoute 避免路由冲突)
// 当没有匹配到任何 API 路由时,尝试提供静态文件
r.NoRoute(gin.WrapH(handlers.StaticFileHandler("./web")))
// 创建自定义HTTP服务器设置超时时间
port := ":8080"
server := &http.Server{
Addr: port,
Handler: r,
ReadTimeout: 5 * time.Minute, // 读取超时5分钟
WriteTimeout: 5 * time.Minute, // 写入超时5分钟
IdleTimeout: 10 * time.Minute, // 空闲连接超时10分钟
MaxHeaderBytes: 1 << 20, // 最大请求头1MB
}
// 启动定时任务
startCronJobs()
// 应用启动时检测并生成今日每日一练
go checkAndGenerateDailyExam()
log.Printf("服务器启动在端口 %s超时配置读/写 5分钟", port)
// 启动服务器
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
port := ":8080"
if err := r.Run(port); err != nil {
panic("服务器启动失败: " + err.Error())
}
}
// startCronJobs 启动定时任务
func startCronJobs() {
// 创建定时任务调度器(使用中国时区 UTC+8
c := cron.New(cron.WithLocation(time.FixedZone("CST", 8*3600)))
// 每天凌晨1点执行
_, err := c.AddFunc("0 1 * * *", func() {
log.Println("开始生成每日一练...")
service := services.NewDailyExamService()
if err := service.GenerateDailyExam(); err != nil {
log.Printf("生成每日一练失败: %v", err)
} else {
log.Println("每日一练生成成功")
}
})
if err != nil {
log.Printf("添加定时任务失败: %v", err)
return
}
// 启动调度器
c.Start()
log.Println("定时任务已启动每天凌晨1点生成每日一练")
// 可选:应用启动时立即生成一次(用于测试)
// go func() {
// log.Println("应用启动,立即生成一次每日一练...")
// service := services.NewDailyExamService()
// if err := service.GenerateDailyExam(); err != nil {
// log.Printf("生成每日一练失败: %v", err)
// }
// }()
}
// checkAndGenerateDailyExam 检测并生成今日每日一练
func checkAndGenerateDailyExam() {
log.Println("检测今日每日一练是否已生成...")
service := services.NewDailyExamService()
if err := service.GenerateDailyExam(); err != nil {
log.Printf("生成每日一练失败: %v", err)
} else {
log.Println("每日一练检测完成")
}
}

View File

@ -2,8 +2,6 @@ package config
import (
"fmt"
"os"
"strconv"
)
// DatabaseConfig 数据库配置结构
@ -16,51 +14,18 @@ type DatabaseConfig struct {
SSLMode string
}
// AIConfig AI服务配置结构
type AIConfig struct {
APIKey string
BaiduAppID string // 百度云AppBuilder应用ID
}
// GetDatabaseConfig 获取数据库配置
// 优先使用环境变量,如果没有设置则使用默认值
func GetDatabaseConfig() *DatabaseConfig {
// 从环境变量获取配置,如果未设置则使用默认值
host := getEnv("DB_HOST", "pgsql.yuchat.top")
port := getEnvAsInt("DB_PORT", 5432)
user := getEnv("DB_USER", "postgres")
password := getEnv("DB_PASSWORD", "longqi@1314")
dbname := getEnv("DB_NAME", "ankao")
sslmode := getEnv("DB_SSLMODE", "disable")
return &DatabaseConfig{
Host: host,
Port: port,
User: user,
Password: password,
DBName: dbname,
SSLMode: sslmode,
Host: "pgsql.yuchat.top",
Port: 5432,
User: "postgres",
Password: "longqi@1314",
DBName: "ankao",
SSLMode: "disable",
}
}
// getEnv 获取环境变量,如果不存在则返回默认值
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
// getEnvAsInt 获取整型环境变量,如果不存在或转换失败则返回默认值
func getEnvAsInt(key string, defaultValue int) int {
if value := os.Getenv(key); value != "" {
if intValue, err := strconv.Atoi(value); err == nil {
return intValue
}
}
return defaultValue
}
// GetDSN 返回数据库连接字符串
func (c *DatabaseConfig) GetDSN() string {
return fmt.Sprintf(
@ -73,15 +38,3 @@ func (c *DatabaseConfig) GetDSN() string {
c.SSLMode,
)
}
// GetAIConfig 获取AI服务配置
// 优先使用环境变量,如果没有设置则使用默认值
func GetAIConfig() *AIConfig {
apiKey := getEnv("AI_API_KEY", "bce-v3/ALTAK-TgZ1YSBmbwNXo3BIuzNZ2/768b777896453e820a2c46f38614c8e9bf43f845")
baiduAppID := getEnv("BAIDU_APP_ID", "7b336aaf-f448-46d6-9e5f-bb9e38a1167c")
return &AIConfig{
APIKey: apiKey,
BaiduAppID: baiduAppID,
}
}

1587
practice_question_pool.json Normal file

File diff suppressed because it is too large Load Diff

113
scripts/import_questions.go Normal file
View File

@ -0,0 +1,113 @@
package main
import (
"ankao/internal/database"
"ankao/internal/models"
"encoding/json"
"log"
"os"
)
// JSONQuestion JSON中的题目结构
type JSONQuestion struct {
ID string `json:"id"`
Question string `json:"question"`
Answers interface{} `json:"answers"`
Options interface{} `json:"options,omitempty"`
}
// JSONQuestionGroup JSON中的题目组结构
type JSONQuestionGroup struct {
Type string `json:"type"`
TypeName string `json:"typeName"`
List []JSONQuestion `json:"list"`
}
func main() {
log.Println("开始导入题目数据...")
// 初始化数据库
if err := database.InitDB(); err != nil {
log.Fatal("数据库初始化失败:", err)
}
// 读取JSON文件
data, err := os.ReadFile("practice_question_pool.json")
if err != nil {
log.Fatal("读取JSON文件失败:", err)
}
// 解析JSON
var groups []JSONQuestionGroup
if err := json.Unmarshal(data, &groups); err != nil {
log.Fatal("解析JSON失败:", err)
}
// 导入数据
db := database.GetDB()
totalCount := 0
for _, group := range groups {
log.Printf("导入题型: %s (%s), 题目数量: %d", group.TypeName, group.Type, len(group.List))
for _, q := range group.List {
// 将答案转换为JSON字符串存储
answerJSON, err := json.Marshal(q.Answers)
if err != nil {
log.Printf("序列化答案失败 (ID: %s): %v", q.ID, err)
continue
}
// 将选项转换为JSON字符串存储
optionsJSON := ""
if q.Options != nil {
optJSON, err := json.Marshal(q.Options)
if err != nil {
log.Printf("序列化选项失败 (ID: %s): %v", q.ID, err)
continue
}
optionsJSON = string(optJSON)
}
// 处理题型映射
questionType := mapQuestionType(group.Type)
// 创建题目记录
question := models.PracticeQuestion{
QuestionID: q.ID,
Type: questionType,
TypeName: group.TypeName,
Question: q.Question,
AnswerData: string(answerJSON),
OptionsData: optionsJSON,
}
// 插入数据库
if err := db.Create(&question).Error; err != nil {
log.Printf("插入题目失败 (ID: %s): %v", q.ID, err)
continue
}
totalCount++
}
}
log.Printf("数据导入完成! 共导入 %d 道题目", totalCount)
}
// mapQuestionType 映射题型
func mapQuestionType(jsonType string) models.PracticeQuestionType {
switch jsonType {
case "fill-in-blank":
return models.FillInBlank
case "true-false":
return models.TrueFalseType
case "multiple-choice":
return models.MultipleChoiceQ
case "multiple-selection":
return models.MultipleSelection
case "short-answer":
return models.ShortAnswer
default:
return models.PracticeQuestionType(jsonType)
}
}

View File

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

View File

@ -10,14 +10,12 @@
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
},
"dependencies": {
"@ant-design/icons": "^6.1.0",
"antd": "^5.28.0",
"antd-mobile": "^5.37.1",
"antd-mobile-icons": "^0.3.0",
"axios": "^1.6.5",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.21.3",
"remark-gfm": "^4.0.1"
"react-router-dom": "^6.21.3"
},
"devDependencies": {
"@types/node": "^20.11.5",

View File

@ -1,117 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 3.6 KiB

View File

@ -1,82 +1,29 @@
import React from 'react'
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'
import { ConfigProvider } from 'antd'
import zhCN from 'antd/locale/zh_CN'
import TabBarLayout from './components/TabBarLayout'
import ProtectedRoute from './components/ProtectedRoute'
import AdminRoute from './components/AdminRoute'
import QuestionPage from './pages/Question'
import Profile from './pages/Profile'
import Login from './pages/Login'
import Home from './pages/Home'
import About from './pages/About'
import WrongQuestions from './pages/WrongQuestions'
import QuestionManagement from './pages/QuestionManagement'
import QuestionList from './pages/QuestionList'
import UserManagement from './pages/UserManagement'
import UserDetail from './pages/UserDetail'
import ExamOnline from './pages/ExamOnline'
import ExamPrint from './pages/ExamPrint'
import ExamManagement from './pages/ExamManagement'
import ExamTaking from './pages/ExamTaking'
import ExamResultNew from './pages/ExamResultNew'
import ExamAnswerView from './pages/ExamAnswerView'
const App: React.FC = () => {
return (
<ConfigProvider locale={zhCN}>
<Router>
<Routes>
{/* 带TabBar的页面需要登录保护 */}
<Route element={<ProtectedRoute><TabBarLayout /></ProtectedRoute>}>
<Route path="/" element={<Home />} />
<Route path="/question" element={<QuestionPage />} />
<Route path="/profile" element={<Profile />} />
</Route>
{/* 不带TabBar的页面但需要登录保护 */}
<Route path="/wrong-questions" element={<ProtectedRoute><WrongQuestions /></ProtectedRoute>} />
<Route path="/question-list" element={<ProtectedRoute><QuestionList /></ProtectedRoute>} />
{/* 考试相关页面,需要登录保护 */}
<Route path="/exam/management" element={<ProtectedRoute><ExamManagement /></ProtectedRoute>} />
<Route path="/exam/:examId/online" element={<ProtectedRoute><ExamOnline /></ProtectedRoute>} />
<Route path="/exam/:examId/taking/:recordId" element={<ProtectedRoute><ExamTaking /></ProtectedRoute>} />
<Route path="/exam/:examId/print" element={<ProtectedRoute><ExamPrint /></ProtectedRoute>} />
<Route path="/exam/result/:recordId" element={<ProtectedRoute><ExamResultNew /></ProtectedRoute>} />
<Route path="/exam/:examId/answer" element={<ProtectedRoute><ExamAnswerView /></ProtectedRoute>} />
{/* 题库管理页面,需要管理员权限 */}
<Route path="/question-management" element={
<ProtectedRoute>
<AdminRoute>
<QuestionManagement />
</AdminRoute>
</ProtectedRoute>
} />
{/* 用户管理页面仅yanlongqi用户可访问 */}
<Route path="/user-management" element={
<ProtectedRoute>
<AdminRoute>
<UserManagement />
</AdminRoute>
</ProtectedRoute>
} />
{/* 用户详情页面仅yanlongqi用户可访问 */}
<Route path="/user-management/:id" element={
<ProtectedRoute>
<AdminRoute>
<UserDetail />
</AdminRoute>
</ProtectedRoute>
} />
{/* 不带TabBar的页面不需要登录保护 */}
{/* 不带TabBar的页面 */}
<Route path="/login" element={<Login />} />
<Route path="/about" element={<About />} />
</Routes>
</Router>
</ConfigProvider>
)
}

View File

@ -1,117 +0,0 @@
import { request } from '../utils/request'
import type {
GenerateExamResponse,
GetExamResponse,
SubmitExamRequest,
SubmitExamResponse,
CreateExamRequest,
CreateExamResponse,
ExamListResponse,
ExamDetailResponse,
StartExamResponse,
ExamRecordResponse,
ExamRecordListResponse
} from '../types/exam'
import type { ApiResponse } from '../types/question'
// 创建试卷
export const createExam = (data: CreateExamRequest) => {
return request.post<ApiResponse<CreateExamResponse>>('/exams', data)
}
// 获取试卷列表
export const getExamList = () => {
return request.get<ApiResponse<ExamListResponse>>('/exams')
}
// 获取试卷详情
export const getExamDetail = (examId: number) => {
return request.get<ApiResponse<ExamDetailResponse>>(`/exams/${examId}`)
}
// 开始考试
export const startExam = (examId: number) => {
return request.post<ApiResponse<StartExamResponse>>(`/exams/${examId}/start`)
}
// 提交试卷答案
export const submitExamAnswer = (recordId: number, data: SubmitExamRequest) => {
return request.post<ApiResponse<SubmitExamResponse>>(`/exam-records/${recordId}/submit`, data)
}
// 获取考试记录详情
export const getExamRecord = (recordId: number) => {
return request.get<ApiResponse<ExamRecordResponse>>(`/exam-records/${recordId}`)
}
// 获取考试记录列表
export const getExamRecordList = (examId?: number) => {
return request.get<ApiResponse<ExamRecordListResponse>>('/exam-records', {
params: examId ? { exam_id: examId } : undefined
})
}
// 删除试卷
export const deleteExam = (examId: number) => {
return request.delete<ApiResponse<void>>(`/exams/${examId}`)
}
// 保存考试进度(单题答案)
export const saveExamProgress = (recordId: number, data: { question_id: number; answer: any }) => {
return request.post<ApiResponse<void>>(`/exam-records/${recordId}/progress`, data)
}
// 获取用户答案
export const getExamUserAnswers = (recordId: number) => {
return request.get<ApiResponse<Record<string, any>>>(`/exam-records/${recordId}/answers`)
}
// 获取可分享的用户列表
export const getShareableUsers = () => {
return request.get<ApiResponse<Array<{ id: number; username: string; nickname?: string; avatar?: string }>>>('/users/shareable')
}
// 分享试卷
export const shareExam = (examId: number, userIds: number[]) => {
return request.post<ApiResponse<{ shared_count: number }>>(`/exams/${examId}/share`, { user_ids: userIds })
}
// === 兼容旧版API ===
// 生成考试
export const generateExam = () => {
return request.post<ApiResponse<GenerateExamResponse>>('/exam/generate')
}
// 获取考试详情
export const getExam = (examId: number, showAnswer?: boolean) => {
return request.get<ApiResponse<GetExamResponse>>(`/exams/${examId}`, {
params: { show_answer: showAnswer },
})
}
// 提交考试
export const submitExam = (examId: number, data: SubmitExamRequest) => {
return request.post<ApiResponse<SubmitExamResponse>>(`/exam/${examId}/submit`, data)
}
// 获取每日一练排行榜
export const getDailyExamRanking = () => {
return request.get<{
success: boolean
data: {
exam_id: number
exam_title: string
rankings: Array<{
user_id: number
username: string
nickname: string
avatar: string
score: number
time_spent: number
rank: number
}>
total: number
}
}>('/daily-exam/ranking')
}

View File

@ -1,20 +1,16 @@
import { request } from '../utils/request'
import type {
Question,
SubmitAnswer,
AnswerResult,
Statistics,
ApiResponse,
WrongQuestion,
WrongQuestionStats,
WrongQuestionFilter
} from '../types/question'
import type { Question, SubmitAnswer, AnswerResult, Statistics, ApiResponse } from '../types/question'
// 获取题目列表
export const getQuestions = (params?: { type?: string; search?: string }) => {
export const getQuestions = (params?: { type?: string; category?: 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}`)
@ -25,44 +21,18 @@ export const submitAnswer = (data: SubmitAnswer) => {
return request.post<ApiResponse<AnswerResult>>('/practice/submit', data)
}
// 获取统计数据
export const getStatistics = () => {
return request.get<ApiResponse<Statistics>>('/practice/statistics')
}
// ========== 练习进度相关 API ==========
// 答题记录
export interface AnsweredQuestion {
question_id: number
record_id: number
is_correct: boolean
user_answer: any
correct_answer: any // 正确答案
answered_at: string
// AI 评分相关(仅简答题有值)
ai_score?: number
ai_feedback?: string
ai_suggestion?: string
}
// 进度数据(按题目类型)
export interface PracticeProgressData {
type: string
current_question_id: number
answered_questions: AnsweredQuestion[]
}
// 获取练习进度可选type参数
export const getPracticeProgress = (type?: string) => {
const params = type ? { type } : undefined
return request.get<ApiResponse<PracticeProgressData[]>>('/practice/progress', { params })
}
// 清除练习进度可选type参数指定类型则只清除该类型的进度
export const clearPracticeProgress = (type?: string) => {
const params = type ? { type } : undefined
return request.delete<ApiResponse<null>>('/practice/progress', { params })
// 获取统计数据 (暂时返回模拟数据,后续实现)
export const getStatistics = async () => {
// TODO: 实现真实的统计接口
return {
success: true,
data: {
total_questions: 0,
answered_questions: 0,
correct_answers: 0,
accuracy: 0,
}
}
}
// 重置进度 (暂时返回模拟数据,后续实现)
@ -73,134 +43,3 @@ export const resetProgress = async () => {
data: null
}
}
// ========== 错题本相关 API ==========
// 获取错题列表(支持筛选和排序)
export const getWrongQuestions = (filter?: WrongQuestionFilter) => {
const params: Record<string, string> = {}
if (filter?.is_mastered !== undefined) {
params.is_mastered = filter.is_mastered ? 'true' : 'false'
}
if (filter?.tag) params.tag = filter.tag
if (filter?.type) params.type = filter.type
if (filter?.sort) params.sort = filter.sort
return request.get<ApiResponse<WrongQuestion[]>>('/wrong-questions', { params })
}
// 获取错题详情(包含完整历史)
export const getWrongQuestionDetail = (id: number) => {
return request.get<ApiResponse<WrongQuestion>>(`/wrong-questions/${id}`)
}
// 获取错题统计(含趋势数据)
export const getWrongQuestionStats = () => {
return request.get<ApiResponse<WrongQuestionStats>>('/wrong-questions/stats')
}
// 获取推荐练习的错题(智能推荐)
export const getRecommendedWrongQuestions = (limit: number = 10, excludeQuestionID?: number) => {
const params: Record<string, any> = { limit }
if (excludeQuestionID) {
params.exclude = excludeQuestionID
}
return request.get<ApiResponse<WrongQuestion[]>>('/wrong-questions/recommended', {
params
})
}
// 删除错题
export const deleteWrongQuestion = (id: number) => {
return request.delete<ApiResponse<null>>(`/wrong-questions/${id}`)
}
// 清空错题本
export const clearWrongQuestions = () => {
return request.delete<ApiResponse<null>>('/wrong-questions')
}
// ========== 题库管理相关 API ==========
// 创建题目
export const createQuestion = (data: {
type: string
type_name?: string
question: string
answer: any
options?: Record<string, string>
}) => {
return request.post<ApiResponse<Question>>('/practice/questions', data)
}
// 更新题目
export const updateQuestion = (id: number, data: {
type?: string
type_name?: string
question?: string
answer?: any
options?: Record<string, string>
}) => {
return request.put<ApiResponse<Question>>(`/practice/questions/${id}`, data)
}
// 删除题目
export const deleteQuestion = (id: number) => {
return request.delete<ApiResponse<null>>(`/practice/questions/${id}`)
}
// 获取题目解析AI
export const explainQuestion = (questionId: number) => {
return request.post<ApiResponse<{ explanation: string }>>('/practice/explain', { question_id: questionId })
}
// ========== 用户管理相关 API仅管理员 ==========
// 用户统计信息
export interface UserStats {
user_id: number
username: string
nickname: string
avatar: string
user_type: string
total_answers: number
correct_count: number
wrong_count: number
accuracy: number
created_at: string
last_answer_at?: string
}
// 用户详细统计
export interface UserDetailStats {
user_info: UserStats
type_stats: Array<{
question_type: string
question_type_name: string
total_answers: number
correct_count: number
accuracy: number
}>
}
// 获取所有用户及统计仅yanlongqi用户可访问
export const getAllUsersWithStats = () => {
return request.get<ApiResponse<UserStats[]>>('/admin/users')
}
// 获取用户详细统计仅yanlongqi用户可访问
export const getUserDetailStats = (userId: number) => {
return request.get<ApiResponse<UserDetailStats>>(`/admin/users/${userId}`)
}
// ========== 排行榜相关 API ==========
// 获取今日排行榜
export const getDailyRanking = (limit: number = 10) => {
return request.get<ApiResponse<UserStats[]>>('/ranking/daily', { params: { limit } })
}
// 获取总排行榜
export const getTotalRanking = (limit: number = 10) => {
return request.get<ApiResponse<UserStats[]>>('/ranking/total', { params: { limit } })
}

View File

@ -1,47 +0,0 @@
import React, { useEffect, useState } from 'react'
import { Navigate } from 'react-router-dom'
import { message } from 'antd'
interface AdminRouteProps {
children: React.ReactNode
}
const AdminRoute: React.FC<AdminRouteProps> = ({ children }) => {
const [isAdmin, setIsAdmin] = useState<boolean | null>(null)
useEffect(() => {
// 检查用户信息
const userStr = localStorage.getItem('user')
if (!userStr) {
setIsAdmin(false)
return
}
try {
const user = JSON.parse(userStr)
if (user.username === 'yanlongqi') {
setIsAdmin(true)
} else {
setIsAdmin(false)
message.error('无权限访问该页面')
}
} catch (e) {
setIsAdmin(false)
}
}, [])
// 正在检查权限时,不显示任何内容
if (isAdmin === null) {
return null
}
// 如果不是管理员,重定向到首页
if (!isAdmin) {
return <Navigate to="/" replace />
}
// 是管理员,显示子组件
return <>{children}</>
}
export default AdminRoute

View File

@ -1,530 +0,0 @@
import React, { useState } from 'react'
import { Alert, Typography, Card, Space, Progress, Button, Spin } from 'antd'
import { CheckOutlined, CloseOutlined, TrophyOutlined, CommentOutlined, BulbOutlined, FileTextOutlined, ReloadOutlined } from '@ant-design/icons'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { fetchWithAuth } from '../utils/request'
import type { AnswerResult as AnswerResultType } from '../types/question'
const { Text, Paragraph } = Typography
interface AnswerResultProps {
answerResult: AnswerResultType
selectedAnswer: string | string[]
questionType: string
questionId: number
}
const AnswerResult: React.FC<AnswerResultProps> = ({
answerResult,
selectedAnswer,
questionType,
questionId,
}) => {
const [explanation, setExplanation] = useState<string>('')
const [showExplanation, setShowExplanation] = useState(false)
const [loadingExplanation, setLoadingExplanation] = useState(false)
// 获取AI解析流式
const fetchExplanation = async () => {
console.log('开始获取AI解析流式题目ID:', questionId)
setLoadingExplanation(true)
setExplanation('') // 清空之前的内容
try {
console.log('发送请求到 /api/practice/explain')
const response = await fetchWithAuth('/api/practice/explain', {
method: 'POST',
body: JSON.stringify({ question_id: questionId }),
})
console.log('收到响应,状态码:', response.status)
if (!response.ok) {
throw new Error('请求失败')
}
const reader = response.body?.getReader()
const decoder = new TextDecoder()
let buffer = ''
if (!reader) {
throw new Error('无法读取响应流')
}
console.log('开始读取流式数据...')
let chunkCount = 0
while (true) {
const { done, value } = await reader.read()
if (done) {
console.log('流读取完成,共接收', chunkCount, '个数据块')
break
}
chunkCount++
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6)
if (data === '[DONE]') {
console.log('收到完成信号 [DONE]')
break
}
// 追加内容
console.log('接收数据片段:', data.substring(0, 20) + '...')
setExplanation(prev => prev + data)
}
}
}
console.log('AI解析获取成功')
} catch (error) {
console.error('获取解析失败', error)
setExplanation('获取解析失败,请重试')
} finally {
setLoadingExplanation(false)
}
}
// 获取AI解析
const handleGetExplanation = async () => {
if (explanation) {
// 如果已经有解析,直接显示/隐藏
setShowExplanation(!showExplanation)
return
}
setShowExplanation(true)
await fetchExplanation()
}
// 重新生成解析
const handleRegenerateExplanation = async () => {
console.log('点击重新生成解析按钮')
await fetchExplanation()
}
// 格式化答案显示(判断题特殊处理)
const formatAnswer = (answer: string | string[] | boolean) => {
// 处理判断题的布尔值和字符串
if (questionType === 'true-false') {
if (typeof answer === 'boolean') {
return answer ? '正确' : '错误'
}
if (typeof answer === 'string') {
return answer === 'true' ? '正确' : answer === 'false' ? '错误' : answer
}
}
// 处理数组答案
if (Array.isArray(answer)) {
// 填空题:保持原顺序,不排序(因为每个空格的位置是固定的)
if (questionType === 'fill-in-blank') {
return answer.join(', ')
}
// 多选题按照ABCD顺序排序
return [...answer].sort((a, b) => a.localeCompare(b)).join(', ')
}
return String(answer)
}
// 获取评分等级颜色
const getScoreColor = (score: number) => {
if (score >= 90) return '#52c41a' // 优秀 - 绿色
if (score >= 80) return '#1890ff' // 良好 - 蓝色
if (score >= 60) return '#faad14' // 及格 - 橙色
return '#ff4d4f' // 不及格 - 红色
}
// 获取评分等级
const getScoreLevel = (score: number) => {
if (score >= 90) return '优秀'
if (score >= 80) return '良好'
if (score >= 60) return '及格'
return '不及格'
}
return (
<div style={{ marginTop: 20 }}>
{/* 答题结果 */}
<Alert
type={answerResult.correct ? 'success' : 'error'}
icon={answerResult.correct ? <CheckOutlined /> : <CloseOutlined />}
message={
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<strong>{answerResult.correct ? '回答正确!' : '回答错误'}</strong>
{/* AI解析按钮 - 放在答题结果的右上角 */}
<Button
type="link"
icon={<FileTextOutlined />}
onClick={handleGetExplanation}
loading={loadingExplanation}
size="small"
>
{showExplanation ? '隐藏解析' : 'AI解析'}
</Button>
</div>
}
description={
<div>
<div style={{ marginBottom: 8 }}>
<Text type="secondary"></Text>
<Text strong={answerResult.correct} type={answerResult.correct ? undefined : 'danger'}>
{formatAnswer(answerResult.user_answer || selectedAnswer)}
</Text>
</div>
{/* 论述题不显示正确答案,因为没有标准答案 */}
{questionType !== 'ordinary-essay' && questionType !== 'management-essay' && (
<div style={{ marginBottom: 8 }}>
<Text strong style={{ color: '#52c41a' }}>
</Text>
<Text strong style={{ color: '#52c41a' }}>
{formatAnswer(
answerResult.correct_answer !== undefined && answerResult.correct_answer !== null
? answerResult.correct_answer
: (answerResult.correct ? selectedAnswer : '暂无')
)}
</Text>
</div>
)}
{answerResult.explanation && (
<div>
<Text type="secondary"></Text>
<div style={{ marginTop: 4 }}>{answerResult.explanation}</div>
</div>
)}
</div>
}
/>
{/* AI解析内容 */}
{showExplanation && (
<Card
style={{ marginTop: 16 }}
title={
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Space>
<FileTextOutlined style={{ color: '#1890ff' }} />
<Text strong></Text>
</Space>
{explanation && !loadingExplanation && (
<Button
type="text"
icon={<ReloadOutlined />}
onClick={handleRegenerateExplanation}
size="small"
style={{ color: '#1890ff' }}
>
</Button>
)}
</div>
}
>
<div style={{ color: '#595959', lineHeight: '1.8' }}>
{explanation ? (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
// 自定义markdown组件样式
p: ({ children }) => (
<p style={{
marginBottom: '1em',
lineHeight: '1.8',
wordWrap: 'break-word',
whiteSpace: 'pre-wrap'
}}>
{children}
</p>
),
h1: ({ children }) => (
<h1 style={{
fontSize: '1.75em',
fontWeight: 'bold',
marginTop: '1em',
marginBottom: '0.6em',
borderBottom: '2px solid #e8e8e8',
paddingBottom: '0.3em'
}}>
{children}
</h1>
),
h2: ({ children }) => (
<h2 style={{
fontSize: '1.5em',
fontWeight: 'bold',
marginTop: '1em',
marginBottom: '0.5em',
color: '#262626'
}}>
{children}
</h2>
),
h3: ({ children }) => (
<h3 style={{
fontSize: '1.25em',
fontWeight: 'bold',
marginTop: '0.8em',
marginBottom: '0.5em',
color: '#262626'
}}>
{children}
</h3>
),
ul: ({ children }) => (
<ul style={{
marginLeft: '1.5em',
marginBottom: '1em',
paddingLeft: '0.5em',
lineHeight: '1.8'
}}>
{children}
</ul>
),
ol: ({ children }) => (
<ol style={{
marginLeft: '1.5em',
marginBottom: '1em',
paddingLeft: '0.5em',
lineHeight: '1.8'
}}>
{children}
</ol>
),
li: ({ children }) => (
<li style={{
marginBottom: '0.4em',
lineHeight: '1.8'
}}>
{children}
</li>
),
code: ({ children, className }) => {
const isInline = !className
return isInline ? (
<code style={{
backgroundColor: '#f5f5f5',
padding: '2px 6px',
borderRadius: '3px',
fontSize: '0.9em',
fontFamily: 'Consolas, Monaco, "Courier New", monospace',
color: '#c7254e'
}}>
{children}
</code>
) : (
<code className={className} style={{
display: 'block',
fontFamily: 'Consolas, Monaco, "Courier New", monospace'
}}>
{children}
</code>
)
},
pre: ({ children }) => (
<pre style={{
backgroundColor: '#f5f5f5',
padding: '12px 16px',
borderRadius: '4px',
overflow: 'auto',
marginBottom: '1em',
border: '1px solid #e8e8e8'
}}>
{children}
</pre>
),
blockquote: ({ children }) => (
<blockquote style={{
borderLeft: '4px solid #1890ff',
paddingLeft: '16px',
margin: '1em 0',
color: '#666',
fontStyle: 'italic',
backgroundColor: '#f0f9ff',
padding: '12px 16px',
borderRadius: '0 4px 4px 0'
}}>
{children}
</blockquote>
),
table: ({ children }) => (
<div style={{ overflowX: 'auto', marginBottom: '1em' }}>
<table style={{
borderCollapse: 'collapse',
width: '100%',
border: '1px solid #e8e8e8'
}}>
{children}
</table>
</div>
),
th: ({ children }) => (
<th style={{
border: '1px solid #e8e8e8',
padding: '8px 12px',
backgroundColor: '#fafafa',
textAlign: 'left',
fontWeight: 'bold'
}}>
{children}
</th>
),
td: ({ children }) => (
<td style={{
border: '1px solid #e8e8e8',
padding: '8px 12px'
}}>
{children}
</td>
),
strong: ({ children }) => (
<strong style={{ fontWeight: 'bold', color: '#262626' }}>{children}</strong>
),
em: ({ children }) => (
<em style={{ fontStyle: 'italic', color: '#595959' }}>{children}</em>
),
hr: () => (
<hr style={{
border: 'none',
borderTop: '1px solid #e8e8e8',
margin: '1.5em 0'
}} />
),
}}
>
{explanation}
</ReactMarkdown>
) : (
loadingExplanation ? (
<div style={{ textAlign: 'center', padding: '20px 0' }}>
<Spin tip="AI正在生成解析中..." />
</div>
) : (
<Paragraph style={{ marginBottom: 0 }}></Paragraph>
)
)}
{/* 流式输出时的loading提示 */}
{loadingExplanation && explanation && (
<div style={{ marginTop: '10px', color: '#1890ff', fontSize: '14px' }}>
<Spin size="small" style={{ marginRight: '8px' }} />
...
</div>
)}
</div>
</Card>
)}
{/* AI评分结果 - 简答题和论述题显示 */}
{answerResult.ai_grading && (
<Card
style={{
marginTop: 16,
borderColor: getScoreColor(answerResult.ai_grading.score),
borderWidth: 2,
}}
title={
<Space>
<TrophyOutlined style={{ color: getScoreColor(answerResult.ai_grading.score) }} />
<Text strong>AI智能评分</Text>
</Space>
}
>
{/* 分数和进度条 */}
<div style={{ marginBottom: 20 }}>
<Space align="center" size="large">
<div>
<Text type="secondary" style={{ fontSize: 14 }}></Text>
<div>
<Text
strong
style={{
fontSize: 32,
color: getScoreColor(answerResult.ai_grading.score),
}}
>
{answerResult.ai_grading.score}
</Text>
<Text type="secondary" style={{ fontSize: 18 }}> / 100</Text>
</div>
</div>
<div style={{ flex: 1, minWidth: 200 }}>
<Progress
percent={answerResult.ai_grading.score}
strokeColor={getScoreColor(answerResult.ai_grading.score)}
format={(percent) => `${getScoreLevel(percent || 0)}`}
/>
</div>
</Space>
</div>
{/* 参考答案(论述题) */}
{answerResult.ai_grading.reference_answer && (
<div style={{ marginBottom: 16, padding: 12, backgroundColor: '#f0f9ff', borderRadius: 4 }}>
<Space align="start">
<FileTextOutlined style={{ fontSize: 16, color: '#1890ff', marginTop: 2 }} />
<div style={{ flex: 1 }}>
<Text strong style={{ fontSize: 14, color: '#1890ff' }}></Text>
<Paragraph style={{ marginTop: 4, marginBottom: 0, color: '#262626', whiteSpace: 'pre-wrap' }}>
{answerResult.ai_grading.reference_answer}
</Paragraph>
</div>
</Space>
</div>
)}
{/* 评分依据 */}
{answerResult.ai_grading.scoring_rationale && (
<div style={{ marginBottom: 16, padding: 12, backgroundColor: '#f6ffed', borderRadius: 4 }}>
<Space align="start">
<CheckOutlined style={{ fontSize: 16, color: '#52c41a', marginTop: 2 }} />
<div style={{ flex: 1 }}>
<Text strong style={{ fontSize: 14, color: '#52c41a' }}></Text>
<Paragraph style={{ marginTop: 4, marginBottom: 0, color: '#262626', whiteSpace: 'pre-wrap' }}>
{answerResult.ai_grading.scoring_rationale}
</Paragraph>
</div>
</Space>
</div>
)}
{/* 评语 */}
{answerResult.ai_grading.feedback && (
<div style={{ marginBottom: 16 }}>
<Space align="start">
<CommentOutlined style={{ fontSize: 16, color: '#1890ff', marginTop: 2 }} />
<div style={{ flex: 1 }}>
<Text strong style={{ fontSize: 14 }}></Text>
<Paragraph style={{ marginTop: 4, marginBottom: 0, color: '#595959' }}>
{answerResult.ai_grading.feedback}
</Paragraph>
</div>
</Space>
</div>
)}
{/* 改进建议 */}
{answerResult.ai_grading.suggestion && (
<div>
<Space align="start">
<BulbOutlined style={{ fontSize: 16, color: '#faad14', marginTop: 2 }} />
<div style={{ flex: 1 }}>
<Text strong style={{ fontSize: 14 }}></Text>
<Paragraph style={{ marginTop: 4, marginBottom: 0, color: '#595959' }}>
{answerResult.ai_grading.suggestion}
</Paragraph>
</div>
</Space>
</div>
)}
</Card>
)}
</div>
)
}
export default AnswerResult

View File

@ -1,105 +0,0 @@
import React from 'react'
import { Modal, Button, Space, Typography } from 'antd'
import { TrophyOutlined } from '@ant-design/icons'
const { Title, Text } = Typography
interface CompletionSummaryProps {
visible: boolean
totalQuestions: number
correctCount: number
wrongCount: number
category?: string
onClose: () => void
onRetry: () => void
}
const CompletionSummary: React.FC<CompletionSummaryProps> = ({
visible,
totalQuestions,
correctCount,
wrongCount,
category,
onClose,
onRetry,
}) => {
const accuracy = totalQuestions > 0 ? Math.round((correctCount / totalQuestions) * 100) : 0
return (
<Modal
title={
<div style={{ textAlign: 'center' }}>
<TrophyOutlined style={{ fontSize: 48, color: '#faad14', marginBottom: 16 }} />
<Title level={3} style={{ margin: 0 }}>
</Title>
</div>
}
open={visible}
onCancel={onClose}
footer={[
<Button key="home" type="primary" onClick={onClose}>
</Button>,
<Button key="retry" onClick={onRetry}>
</Button>,
]}
width={500}
>
<div style={{ textAlign: 'center', padding: '20px 0' }}>
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<div>
<Title level={4} style={{ marginBottom: 16 }}>
</Title>
<Text type="secondary">{category || '全部题型'}</Text>
</div>
<div style={{ display: 'flex', justifyContent: 'space-around', padding: '20px 0' }}>
<div>
<div style={{ fontSize: 36, fontWeight: 'bold', color: '#1890ff' }}>
{totalQuestions}
</div>
<Text type="secondary"></Text>
</div>
<div>
<div style={{ fontSize: 36, fontWeight: 'bold', color: '#52c41a' }}>
{correctCount}
</div>
<Text type="secondary"></Text>
</div>
<div>
<div style={{ fontSize: 36, fontWeight: 'bold', color: '#ff4d4f' }}>
{wrongCount}
</div>
<Text type="secondary"></Text>
</div>
</div>
<div
style={{
padding: 20,
background: '#f0f2f5',
borderRadius: 8,
fontSize: 24,
fontWeight: 'bold',
}}
>
<Text></Text>
<Text
style={{
color: accuracy >= 60 ? '#52c41a' : '#ff4d4f',
fontSize: 32,
}}
>
{accuracy}%
</Text>
</div>
</Space>
</div>
</Modal>
)
}
export default CompletionSummary

View File

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

View File

@ -1,231 +0,0 @@
import React, { useState, useEffect } from 'react'
import { Space, Tag, Typography, Radio, Checkbox, Input, Button } from 'antd'
import { ReloadOutlined } from '@ant-design/icons'
import type { Question, AnswerResult as AnswerResultType } from '../types/question'
import AnswerResult from './AnswerResult'
import styles from '../pages/Question.module.less'
const { TextArea } = Input
const { Title } = Typography
interface QuestionCardProps {
question: Question
selectedAnswer: string | string[]
showResult: boolean
answerResult: AnswerResultType | null
loading: boolean
autoNextLoading: boolean
onAnswerChange: (answer: string | string[]) => void
onSubmit: () => void
onNext: () => void
onRetry?: () => void // 新增:重新答题回调
mode?: string // 新增:答题模式(用于判断是否是错题练习)
answerSequence?: number // 新增:答题序号(第几次答题)
hasHistory?: boolean // 新增:是否有历史答案
}
const QuestionCard: React.FC<QuestionCardProps> = ({
question,
selectedAnswer,
showResult,
answerResult,
loading,
autoNextLoading,
onAnswerChange,
onSubmit,
onNext,
onRetry,
mode,
answerSequence,
hasHistory,
}) => {
const [fillAnswers, setFillAnswers] = useState<string[]>([])
// 当题目或答案变化时,同步填空题答案
useEffect(() => {
if (question.type === 'fill-in-blank') {
// 如果 selectedAnswer 是数组且有内容,使用它;否则重置为空数组
if (Array.isArray(selectedAnswer) && selectedAnswer.length > 0) {
setFillAnswers(selectedAnswer)
} else {
setFillAnswers([])
}
}
}, [question.id, question.type, selectedAnswer])
// 渲染填空题内容
const renderFillContent = () => {
const content = question.content
const parts = content.split('****')
if (parts.length === 1) {
return <div className={styles.questionContent}>{content}</div>
}
if (fillAnswers.length === 0) {
setFillAnswers(new Array(parts.length - 1).fill(''))
}
return (
<div className={styles.questionContent}>
{parts.map((part, index) => (
<React.Fragment key={index}>
<span>{part}</span>
{index < parts.length - 1 && (
<Input
className={styles.fillInput}
placeholder={`填空${index + 1}`}
value={fillAnswers[index] || ''}
onChange={(e) => {
const newAnswers = [...fillAnswers]
newAnswers[index] = e.target.value
setFillAnswers(newAnswers)
// 提交时去掉每个答案的前后空白字符
const trimmedAnswers = newAnswers.map(ans => ans.trim())
onAnswerChange(trimmedAnswers)
}}
disabled={showResult}
style={{
display: 'inline-block',
width: '120px',
margin: '0 8px',
}}
/>
)}
</React.Fragment>
))}
</div>
)
}
// 渲染题目选项
const renderOptions = () => {
if (question.type === 'fill-in-blank') {
return null
}
// 简答题和论述题都显示文本框
if (question.type === 'short-answer' || question.type === 'ordinary-essay' || question.type === 'management-essay') {
return (
<TextArea
placeholder="请输入答案"
value={selectedAnswer as string}
onChange={(e) => onAnswerChange(e.target.value)}
disabled={showResult}
rows={6}
style={{ marginTop: 20 }}
/>
)
}
if (question.type === 'multiple-selection') {
const sortedOptions = [...question.options].sort((a, b) => a.key.localeCompare(b.key))
return (
<Checkbox.Group
value={selectedAnswer as string[]}
onChange={(val) => onAnswerChange(val as string[])}
disabled={showResult}
style={{ width: '100%', marginTop: 20 }}
>
<Space direction="vertical" style={{ width: '100%' }}>
{sortedOptions.map((option) => (
<Checkbox key={option.key} value={option.key}>
<span style={{ fontSize: 16 }}>
{option.key}. {option.value}
</span>
</Checkbox>
))}
</Space>
</Checkbox.Group>
)
}
// 单选题和判断题
// 判断题不排序,保持后端返回的顺序(正确在前,错误在后)
const sortedOptions = question.type === 'true-false'
? question.options
: [...question.options].sort((a, b) => a.key.localeCompare(b.key))
return (
<Radio.Group
value={selectedAnswer as string}
onChange={(e) => onAnswerChange(e.target.value)}
disabled={showResult}
style={{ width: '100%', marginTop: 20 }}
>
<Space direction="vertical" style={{ width: '100%' }}>
{sortedOptions.map((option) => (
<Radio key={option.key} value={option.key}>
<span style={{ fontSize: 16 }}>
{question.type === 'true-false' ? option.value : `${option.key}. ${option.value}`}
</span>
</Radio>
))}
</Space>
</Radio.Group>
)
}
return (
<div className={styles.questionCard}>
<Space size="small" style={{ marginBottom: 16, alignItems: 'center' }}>
<Title level={5} style={{ margin: 0, display: 'inline' }}>
{question.question_id}
</Title>
<Tag color="blue">{question.category}</Tag>
{/* 显示答题历史提示 */}
{hasHistory && answerSequence && answerSequence > 1 && (
<Tag color="orange"> {answerSequence} </Tag>
)}
</Space>
{question.type === 'fill-in-blank' ? (
renderFillContent()
) : (
<div className={styles.questionContent}>{question.content}</div>
)}
{renderOptions()}
{/* 答案结果 */}
{showResult && answerResult && (
<AnswerResult
answerResult={answerResult}
selectedAnswer={selectedAnswer}
questionType={question.type}
questionId={question.id}
/>
)}
{/* 按钮 */}
<div className={styles.buttonGroup}>
{!showResult ? (
<Button type="primary" size="large" block onClick={onSubmit} loading={loading}>
</Button>
) : (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
{/* 如果是错题练习模式且答案错误,显示重新答题按钮 */}
{mode === 'wrong' && !answerResult?.correct && onRetry && (
<Button
type="default"
size="large"
block
icon={<ReloadOutlined />}
onClick={onRetry}
>
</Button>
)}
<Button type="primary" size="large" block onClick={onNext} loading={autoNextLoading}>
{autoNextLoading ? '正在跳转到下一题...' : '下一题'}
</Button>
</Space>
)}
</div>
</div>
)
}
export default QuestionCard

View File

@ -1,136 +0,0 @@
.drawer {
:global {
.ant-drawer-body {
padding: 0;
background: #fafafa;
}
.ant-drawer-header {
border-bottom: 1px solid #e8e8e8;
background: #fff;
padding: 16px 20px;
}
.ant-drawer-title {
font-weight: 600;
font-size: 16px;
}
}
}
.listItem {
padding: 0;
margin: 8px 12px;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
border-radius: 12px;
background: #fff;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
overflow: visible;
border: 1px solid transparent;
position: relative;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
transform: translateY(-2px);
border-color: rgba(22, 119, 255, 0.2);
}
&.current {
background: linear-gradient(135deg, #e6f7ff 0%, #f0f9ff 100%);
border-color: #1677ff;
box-shadow: 0 4px 16px rgba(22, 119, 255, 0.15);
&:hover {
box-shadow: 0 6px 20px rgba(22, 119, 255, 0.2);
transform: translateY(-2px);
}
}
}
.questionItem {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 10px 12px;
}
.questionNumber {
flex-shrink: 0;
width: 26px;
height: 26px;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 600;
color: #595959;
background: rgba(0, 0, 0, 0.04);
border-radius: 6px;
}
.current .questionNumber {
color: #1677ff;
background: rgba(22, 119, 255, 0.1);
font-weight: 700;
}
.questionContent {
flex: 1;
min-width: 0;
}
.questionStatus {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
:global {
.anticon {
font-size: 20px;
}
}
}
// 移动端适配
@media (max-width: 768px) {
.drawer {
:global {
.ant-drawer {
width: 90vw !important;
max-width: 450px;
}
.ant-drawer-header {
padding: 14px 16px;
}
}
}
.listItem {
margin: 6px 10px;
border-radius: 10px;
}
.questionItem {
padding: 8px 10px;
gap: 6px;
}
.questionNumber {
width: 24px;
height: 24px;
font-size: 12px;
border-radius: 5px;
}
.questionStatus {
:global {
.anticon {
font-size: 18px;
}
}
}
}

View File

@ -1,138 +0,0 @@
import React from 'react'
import { Drawer, List, Tag, Typography, Space } from 'antd'
import {
CheckCircleOutlined,
CloseCircleOutlined,
MinusCircleOutlined,
BookOutlined,
} from '@ant-design/icons'
import type { Question } from '../types/question'
import styles from './QuestionDrawer.module.less'
const { Text } = Typography
interface QuestionDrawerProps {
visible: boolean
onClose: () => void
questions: Question[]
currentIndex: number
onQuestionSelect: (index: number) => void
answeredStatus: Map<number, boolean | null> // null: 未答, true: 正确, false: 错误
}
const QuestionDrawer: React.FC<QuestionDrawerProps> = ({
visible,
onClose,
questions,
currentIndex,
onQuestionSelect,
answeredStatus,
}) => {
// 获取题目状态
const getQuestionStatus = (index: number) => {
const status = answeredStatus.get(index)
if (status === null || status === undefined) {
return { icon: <MinusCircleOutlined />, color: '#d9d9d9', text: '未答' }
}
if (status) {
return { icon: <CheckCircleOutlined />, color: '#52c41a', text: '正确' }
}
return { icon: <CloseCircleOutlined />, color: '#ff4d4f', text: '错误' }
}
// 渲染填空题内容:将 **** 替换为下划线
const renderQuestionContent = (question: Question) => {
if (question.type === 'fill-in-blank') {
const parts = question.content.split('****')
return (
<span>
{parts.map((part, index) => (
<React.Fragment key={index}>
{part}
{index < parts.length - 1 && (
<span
style={{
display: 'inline-block',
minWidth: '40px',
borderBottom: '2px solid #d9d9d9',
margin: '0 4px',
textAlign: 'center',
}}
>
&nbsp;&nbsp;&nbsp;&nbsp;
</span>
)}
</React.Fragment>
))}
</span>
)
}
return question.content
}
return (
<Drawer
title={
<Space>
<BookOutlined />
<span></span>
<Text type="secondary" style={{ fontSize: 14, fontWeight: 'normal' }}>
{questions.length}
</Text>
</Space>
}
placement="right"
onClose={onClose}
open={visible}
width={450}
className={styles.drawer}
>
<List
dataSource={questions}
renderItem={(question, index) => {
const status = getQuestionStatus(index)
const isCurrent = index === currentIndex
return (
<List.Item
key={question.id}
className={`${styles.listItem} ${isCurrent ? styles.current : ''}`}
onClick={() => {
onQuestionSelect(index)
onClose()
}}
>
<div className={styles.questionItem}>
{/* 题号 */}
<div className={styles.questionNumber}>
{index + 1}
</div>
{/* 分类标签 */}
<Tag color="blue" style={{ margin: 0, flexShrink: 0 }}>
{question.category}
</Tag>
{/* 题目内容 */}
<div className={styles.questionContent}>
<Text ellipsis={{ tooltip: question.content }} style={{ fontSize: 14, color: '#262626' }}>
{renderQuestionContent(question)}
</Text>
</div>
{/* 右侧状态 */}
<div className={styles.questionStatus}>
<div style={{ color: status.color, fontSize: 20 }}>
{status.icon}
</div>
</div>
</div>
</List.Item>
)
}}
/>
</Drawer>
)
}
export default QuestionDrawer

View File

@ -1,215 +0,0 @@
.floatButtonWrapper {
position: fixed;
right: 20px;
bottom: 90px;
z-index: 999;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.statsCard {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-radius: 12px;
padding: 8px 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08),
0 2px 6px rgba(0, 0, 0, 0.04);
display: flex;
align-items: center;
gap: 12px;
opacity: 0;
transform: translateY(10px);
animation: slideIn 0.3s ease forwards 0.2s;
border: 1px solid rgba(0, 0, 0, 0.06);
}
@keyframes slideIn {
to {
opacity: 1;
transform: translateY(0);
}
}
.statsRow {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
}
.statsLabel {
font-size: 11px;
color: #8c8c8c;
font-weight: 500;
line-height: 1;
}
.statsValue {
font-size: 16px;
font-weight: 700;
color: #1d1d1f;
line-height: 1;
}
.statsDivider {
width: 1px;
height: 20px;
background: rgba(0, 0, 0, 0.08);
}
.floatButton {
width: 64px;
height: 64px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.4),
0 8px 32px rgba(102, 126, 234, 0.25);
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
align-items: center;
justify-content: center;
user-select: none;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
position: relative;
&:hover {
transform: scale(1.1) translateY(-4px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.5),
0 12px 40px rgba(102, 126, 234, 0.35);
}
&:active {
transform: scale(1.05) translateY(-2px);
transition: all 0.1s ease;
}
}
.progressRing {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
transform: rotate(-90deg);
pointer-events: none;
}
.progressRingCircle {
opacity: 0.15;
stroke: #fff;
}
.progressRingCircleProgress {
transition: stroke-dashoffset 0.6s cubic-bezier(0.4, 0, 0.2, 1);
stroke-linecap: round;
stroke: #fff;
filter: drop-shadow(0 0 4px rgba(255, 255, 255, 0.5));
}
.floatButtonContent {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #fff;
z-index: 1;
position: relative;
}
.icon {
font-size: 24px;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2));
}
// 移动端适配
@media (max-width: 768px) {
.floatButtonWrapper {
right: 16px;
bottom: 80px;
}
.statsCard {
padding: 6px 12px;
gap: 8px;
border-radius: 10px;
font-size: 12px;
}
.statsLabel {
font-size: 10px;
}
.statsValue {
font-size: 13px;
}
.statsDivider {
height: 16px;
}
.floatButton {
width: 58px;
height: 58px;
}
.icon {
font-size: 22px;
}
}
// 超小屏幕优化
@media (max-width: 380px) {
.floatButtonWrapper {
right: 12px;
bottom: 75px;
}
.statsCard {
padding: 5px 10px;
gap: 6px;
border-radius: 8px;
}
.statsLabel {
font-size: 9px;
}
.statsValue {
font-size: 12px;
}
.statsDivider {
height: 14px;
}
.floatButton {
width: 54px;
height: 54px;
}
.icon {
font-size: 20px;
}
}
// 添加微妙的脉冲动画
@keyframes subtlePulse {
0%, 100% {
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.4),
0 8px 32px rgba(102, 126, 234, 0.25);
}
50% {
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.5),
0 8px 32px rgba(102, 126, 234, 0.35);
}
}
.floatButton {
animation: subtlePulse 3s ease-in-out infinite;
}

View File

@ -1,89 +0,0 @@
import React from 'react'
import { UnorderedListOutlined } from '@ant-design/icons'
import styles from './QuestionFloatButton.module.less'
interface QuestionFloatButtonProps {
currentIndex: number
totalQuestions: number
onClick: () => void
correctCount: number
wrongCount: number
}
const QuestionFloatButton: React.FC<QuestionFloatButtonProps> = ({
currentIndex: _currentIndex, // 保留参数但表示未使用
totalQuestions,
onClick,
correctCount,
wrongCount,
}) => {
if (totalQuestions === 0) return null
const answeredCount = correctCount + wrongCount
const progress = Math.round((answeredCount / totalQuestions) * 100)
const radius = 28.5
const circumference = 2 * Math.PI * radius
return (
<div className={styles.floatButtonWrapper}>
{/* 统计信息卡片 */}
<div className={styles.statsCard}>
<div className={styles.statsRow}>
<span className={styles.statsLabel}></span>
<span className={styles.statsValue}>
{answeredCount}/{totalQuestions}
</span>
</div>
<div className={styles.statsDivider} />
<div className={styles.statsRow}>
<span className={styles.statsLabel}></span>
<span className={styles.statsValue} style={{ color: '#52c41a' }}>
{correctCount}
</span>
</div>
<div className={styles.statsDivider} />
<div className={styles.statsRow}>
<span className={styles.statsLabel}></span>
<span className={styles.statsValue} style={{ color: '#ff4d4f' }}>
{wrongCount}
</span>
</div>
</div>
{/* 悬浮球 */}
<div className={styles.floatButton} onClick={onClick}>
{/* 进度环 */}
<svg className={styles.progressRing} viewBox="0 0 64 64">
<circle
className={styles.progressRingCircle}
strokeWidth="3"
fill="transparent"
r={radius}
cx="32"
cy="32"
/>
<circle
className={styles.progressRingCircleProgress}
strokeWidth="3"
fill="transparent"
r={radius}
cx="32"
cy="32"
style={{
strokeDasharray: `${circumference}`,
strokeDashoffset: `${circumference * (1 - progress / 100)}`,
}}
/>
</svg>
{/* 中心图标 */}
<div className={styles.floatButtonContent}>
<UnorderedListOutlined className={styles.icon} />
</div>
</div>
</div>
)
}
export default QuestionFloatButton

View File

@ -1,173 +0,0 @@
.drawer {
:global {
.ant-drawer-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-bottom: none;
padding: 16px 24px;
.ant-drawer-title {
color: white;
font-weight: 600;
font-size: 16px;
}
.ant-drawer-close {
color: rgba(255, 255, 255, 0.85);
&:hover {
color: white;
background: rgba(255, 255, 255, 0.2);
}
}
}
.ant-drawer-body {
padding: 16px 24px;
// 自定义滚动条样式
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: #f0f0f0;
border-radius: 3px;
}
&::-webkit-scrollbar-thumb {
background: #bfbfbf;
border-radius: 3px;
&:hover {
background: #999;
}
}
}
}
}
.groupsContainer {
display: flex;
flex-direction: column;
gap: 12px;
}
.questionGroup {
display: flex;
flex-direction: column;
gap: 8px;
}
.groupHeader {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.typeTag {
margin: 0;
font-size: 13px;
padding: 4px 12px;
border-radius: 6px;
font-weight: 600;
}
.groupCount {
font-size: 12px;
color: #8c8c8c;
}
.numbersGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(36px, 1fr));
gap: 10px;
row-gap: 10px;
}
.numberItem {
width: 36px;
height: 36px;
border-radius: 50%; // 圆形
background: linear-gradient(135deg, #f5f7fa 0%, #e8ecf1 100%);
border: 2px solid transparent;
color: #1d1d1f;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 12px;
cursor: pointer;
transition: all 0.2s ease;
user-select: none;
overflow: hidden;
text-overflow: ellipsis;
&:hover {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
transform: scale(1.1);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
border-color: #667eea;
}
&:active {
transform: scale(1.05);
}
}
.groupDivider {
margin: 8px 0;
border-color: #e8e8e8;
}
// 移动端适配
@media (max-width: 768px) {
.drawer {
:global {
.ant-drawer-content-wrapper {
width: 85vw !important;
max-width: 380px !important;
}
.ant-drawer-header {
padding: 14px 20px;
}
.ant-drawer-body {
padding: 14px 20px;
}
}
}
.groupsContainer {
gap: 10px;
}
.typeTag {
font-size: 12px;
padding: 3px 10px;
}
.groupCount {
font-size: 11px;
}
.numbersGrid {
grid-template-columns: repeat(auto-fill, minmax(34px, 1fr));
gap: 8px;
row-gap: 8px;
}
.numberItem {
width: 34px;
height: 34px;
font-size: 11px;
}
.groupDivider {
margin: 6px 0;
}
}

View File

@ -1,100 +0,0 @@
import React, { useMemo } from 'react'
import { Drawer, Tag, Divider, Space, Typography } from 'antd'
import { BookOutlined } from '@ant-design/icons'
import type { Question } from '../types/question'
import styles from './QuestionListDrawer.module.less'
const { Text } = Typography
interface QuestionListDrawerProps {
visible: boolean
onClose: () => void
questions: Question[]
onQuestionSelect: (index: number) => void
}
interface QuestionGroup {
category: string
items: Array<{ index: number; question: Question }>
}
const QuestionListDrawer: React.FC<QuestionListDrawerProps> = ({
visible,
onClose,
questions,
onQuestionSelect,
}) => {
// 按分类分组
const groupedQuestions = useMemo(() => {
const groups: Record<string, QuestionGroup> = {}
questions.forEach((question, index) => {
if (!groups[question.category]) {
groups[question.category] = {
category: question.category,
items: [],
}
}
groups[question.category].items.push({ index, question })
})
return Object.values(groups)
}, [questions])
return (
<Drawer
title={
<Space>
<BookOutlined />
<span></span>
<Text type="secondary" style={{ fontSize: 14, fontWeight: 'normal' }}>
{questions.length}
</Text>
</Space>
}
placement="right"
onClose={onClose}
open={visible}
width={500}
className={styles.drawer}
>
<div className={styles.groupsContainer}>
{groupedQuestions.map((group, groupIndex) => (
<div key={group.category} className={styles.questionGroup}>
{/* 分类标题 */}
<div className={styles.groupHeader}>
<Tag color="blue" className={styles.typeTag}>
{group.category}
</Tag>
<span className={styles.groupCount}> {group.items.length} </span>
</div>
{/* 题号列表 */}
<div className={styles.numbersGrid}>
{group.items.map(({ index, question }) => (
<div
key={question.id}
className={styles.numberItem}
onClick={() => {
onQuestionSelect(index)
onClose()
}}
title={`${index + 1}`}
>
{index + 1}
</div>
))}
</div>
{/* 分隔线 */}
{groupIndex < groupedQuestions.length - 1 && (
<Divider className={styles.groupDivider} />
)}
</div>
))}
</div>
</Drawer>
)
}
export default QuestionListDrawer

View File

@ -1,38 +0,0 @@
import React from 'react'
import { Progress } from 'antd'
interface QuestionProgressProps {
currentIndex: number
totalQuestions: number
correctCount: number
wrongCount: number
}
const QuestionProgress: React.FC<QuestionProgressProps> = ({
currentIndex,
totalQuestions,
}) => {
if (totalQuestions === 0) return null
const percent = Math.round(((currentIndex + 1) / totalQuestions) * 100)
return (
<Progress
percent={percent}
status="active"
strokeColor={{
'0%': '#007aff',
'100%': '#52c41a',
}}
trailColor="rgba(0, 0, 0, 0.06)"
strokeWidth={12}
format={() => `${currentIndex + 1} / ${totalQuestions}`}
style={{
fontSize: '14px',
fontWeight: 600,
}}
/>
)
}
export default QuestionProgress

View File

@ -1,135 +0,0 @@
# 答题组件说明
本目录包含答题功能的子组件,从原来的 `Question.tsx` 大组件拆分而来。
## 组件列表
### 1. QuestionProgress.tsx
**功能**: 显示答题进度条和统计信息
**Props**:
- `currentIndex`: number - 当前题目索引
- `totalQuestions`: number - 总题目数
- `correctCount`: number - 正确题目数
- `wrongCount`: number - 错误题目数
**职责**:
- 显示当前答题进度(百分比和题号)
- 显示正确和错误的统计数量
- 使用渐变色进度条增强视觉效果
---
### 2. QuestionCard.tsx
**功能**: 显示单个题目的卡片,包含题目内容、选项和答案提交
**Props**:
- `question`: Question - 题目对象
- `selectedAnswer`: string | string[] - 选中的答案
- `showResult`: boolean - 是否显示答题结果
- `answerResult`: AnswerResult | null - 答题结果
- `loading`: boolean - 提交加载状态
- `autoNextLoading`: boolean - 自动下一题加载状态
- `onAnswerChange`: (answer: string | string[]) => void - 答案变更回调
- `onSubmit`: () => void - 提交答案回调
- `onNext`: () => void - 下一题回调
**职责**:
- 根据题目类型渲染不同的答题界面(单选、多选、填空、简答、判断)
- 处理填空题的特殊渲染逻辑
- 显示题目编号和分类标签
- 显示答案结果(使用 AnswerResult 组件)
- 提供提交和下一题按钮
---
### 3. AnswerResult.tsx
**功能**: 显示答题结果的Alert组件
**Props**:
- `answerResult`: AnswerResult - 答题结果对象
- `selectedAnswer`: string | string[] - 用户选择的答案
- `questionType`: string - 题目类型
**职责**:
- 显示正确或错误的提示图标和颜色
- 显示用户答案和正确答案
- 显示答案解析(如果有)
- 特殊处理判断题的答案显示true/false → 正确/错误)
---
### 4. CompletionSummary.tsx
**功能**: 完成所有题目后的统计摘要弹窗
**Props**:
- `visible`: boolean - 弹窗是否可见
- `totalQuestions`: number - 总题目数
- `correctCount`: number - 正确数
- `wrongCount`: number - 错误数
- `category`: string | undefined - 题目类型分类
- `onClose`: () => void - 关闭回调(返回首页)
- `onRetry`: () => void - 重新开始回调
**职责**:
- 显示完成奖杯图标
- 展示本次答题的完整统计数据
- 计算并显示正确率(根据正确率显示不同颜色)
- 提供返回首页和重新开始两个操作
---
## 组件拆分的优势
1. **单一职责**: 每个组件只负责一个特定的功能
2. **可维护性**: 更容易定位和修改问题
3. **可测试性**: 每个组件可以独立测试
4. **可复用性**: 组件可以在其他页面复用
5. **代码清晰**: 主组件 Question.tsx 从 600+ 行缩减到 300 行左右
## 主组件 Question.tsx
**保留职责**:
- 状态管理(题目、答案、进度等)
- 业务逻辑(加载题目、提交答案、保存进度等)
- API 调用
- 组件组合和布局
**文件大小变化**:
- 重构前: ~605 行
- 重构后: ~303 行
- 减少: ~50%
## 使用示例
```tsx
// 在 Question.tsx 中使用
<QuestionProgress
currentIndex={currentIndex}
totalQuestions={allQuestions.length}
correctCount={correctCount}
wrongCount={wrongCount}
/>
<QuestionCard
question={currentQuestion}
selectedAnswer={selectedAnswer}
showResult={showResult}
answerResult={answerResult}
loading={loading}
autoNextLoading={autoNextLoading}
onAnswerChange={setSelectedAnswer}
onSubmit={handleSubmit}
onNext={handleNext}
/>
<CompletionSummary
visible={showSummary}
totalQuestions={allQuestions.length}
correctCount={correctCount}
wrongCount={wrongCount}
category={currentQuestion?.category}
onClose={() => navigate("/")}
onRetry={handleRetry}
/>
```

View File

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

View File

@ -1,70 +0,0 @@
.layout {
min-height: 100vh;
background: #fafafa;
}
.content {
flex: 1;
}
.footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 1000;
padding: 0;
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(40px) saturate(180%);
-webkit-backdrop-filter: blur(40px) saturate(180%);
box-shadow:
0 -2px 8px rgba(0, 0, 0, 0.04),
0 -1px 4px rgba(0, 0, 0, 0.02),
0 0 0 1px rgba(0, 0, 0, 0.03);
border-top: 0.5px solid rgba(0, 0, 0, 0.04);
}
.menu {
display: flex;
justify-content: space-around;
border-bottom: none;
:global {
.ant-menu-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 60px;
margin: 0;
padding: 8px 0;
.anticon {
font-size: 20px;
}
}
.ant-menu-item-selected {
background-color: transparent;
&::after {
display: none;
}
}
}
}
// 响应式设计 - 移动端
@media (max-width: 768px) {
.footer {
display: block;
}
}
// 响应式设计 - PC端
@media (min-width: 769px) {
.footer {
display: none;
}
}

View File

@ -1,18 +1,52 @@
import React from 'react'
import { Outlet } from 'react-router-dom'
import { Layout } from 'antd'
import styles from './TabBarLayout.module.less'
const { Content } = Layout
import { useNavigate, useLocation, Outlet } from 'react-router-dom'
import { TabBar } from 'antd-mobile'
import {
AppOutline,
UserOutline,
UnorderedListOutline,
} from 'antd-mobile-icons'
import './TabBarLayout.less'
const TabBarLayout: React.FC = () => {
const navigate = useNavigate()
const location = useLocation()
const tabs = [
{
key: '/',
title: '首页',
icon: <AppOutline />,
},
{
key: '/question',
title: '答题',
icon: <UnorderedListOutline />,
},
{
key: '/profile',
title: '我的',
icon: <UserOutline />,
},
]
const setRouteActive = (value: string) => {
navigate(value)
}
return (
<Layout className={styles.layout}>
<Content className={styles.content}>
<div className="tab-bar-layout">
<div className="tab-bar-content">
<Outlet />
</Content>
</Layout>
</div>
<div className="tab-bar-footer">
<TabBar activeKey={location.pathname} onChange={setRouteActive}>
{tabs.map(item => (
<TabBar.Item key={item.key} icon={item.icon} title={item.title} />
))}
</TabBar>
</div>
</div>
)
}

View File

@ -1,65 +1,17 @@
// 变量
@bg-color: #f5f5f5;
// 容器
.container {
min-height: 100vh;
background: #fafafa;
padding: 0;
}
.content {
padding: 20px;
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;
flex-direction: column;
height: 100vh;
background-color: @bg-color;
}
.card {
border-radius: 20px;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(40px) saturate(180%);
-webkit-backdrop-filter: blur(40px) saturate(180%);
box-shadow:
0 2px 16px rgba(0, 0, 0, 0.06),
0 1px 8px rgba(0, 0, 0, 0.04),
0 0 0 1px rgba(0, 0, 0, 0.03);
border: 0.5px solid rgba(0, 0, 0, 0.04);
margin-bottom: 20px;
}
.homeButton {
height: 48px;
font-size: 16px;
font-weight: 500;
border-radius: 12px;
margin-top: 16px;
}
// 响应式设计 - 移动端
@media (max-width: 768px) {
.content {
// 内容区域
.content {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.card {
border-radius: 16px;
margin-bottom: 16px;
}
.homeButton {
height: 44px;
font-size: 15px;
}
}
// 响应式设计 - PC端
@media (min-width: 769px) {
.content {
padding: 32px;
}
}

View File

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

View File

@ -1,378 +0,0 @@
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
font-size: 14px; // 设置基础字体大小
@media print {
padding: 0;
font-size: 12px;
margin: 0;
}
}
.loadingContainer {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 300px;
}
.statsCard {
margin-bottom: 24px;
}
.detailCard {
margin-bottom: 24px;
// 适合打印的样式
@media print {
box-shadow: none;
border: 1px solid #000;
}
}
.actionsCard {
text-align: center;
@media print {
display: none;
}
}
.questionCard {
margin-bottom: 16px;
@media print {
box-shadow: none;
border: 1px solid #ccc;
page-break-inside: avoid;
}
}
.answerDetail {
padding: 16px 0;
}
.questionContent {
margin-bottom: 16px;
}
.answerSection {
background: #f8f9fa;
padding: 16px;
border-radius: 4px;
@media print {
background: none;
border: 1px solid #ccc;
}
}
.answerItem {
margin-bottom: 12px;
}
.correct {
color: #52c41a;
}
.incorrect {
color: #ff4d4f;
}
.typeScoreCard {
margin-bottom: 24px;
}
.typeScoreItem {
text-align: center;
padding: 16px;
background: #fafafa;
border-radius: 4px;
}
.typeScoreHeader {
margin-bottom: 8px;
}
.typeScoreContent {
margin-bottom: 8px;
}
.typeScoreProgress {
height: 6px;
background: #f0f0f0;
border-radius: 3px;
overflow: hidden;
}
.typeScoreBar {
height: 100%;
border-radius: 3px;
}
// 打印样式优化
@media print {
.container {
max-width: 100%;
padding: 0;
margin: 0;
}
body {
font-size: 12pt;
line-height: 1.4;
margin: 0;
padding: 0;
}
h1, h2, h3, h4, h5, h6 {
page-break-after: avoid;
}
p {
page-break-inside: avoid;
}
.no-print {
display: none !important;
}
// 优化打印时的卡片显示
.detailCard {
box-shadow: none;
border: none;
margin-bottom: 0;
@media print {
// 打印时移除卡片头部的内边距
.ant-card-head {
padding: 0;
min-height: 0;
margin-bottom: 0;
}
// 移除卡片内容区域的内边距
.ant-card-body {
padding: 0;
margin: 0;
}
// 避免在卡片前后分页
page-break-inside: avoid;
page-break-after: avoid;
}
}
// 打印时优化Card显示
.detailCard {
@media print {
.ant-card-body {
padding: 0;
}
}
}
// 确保题型标题在打印时不会被分页打断
.typeTitle {
page-break-after: avoid;
page-break-inside: avoid;
font-size: 16px;
margin-bottom: 8px;
@media print {
font-size: 14px;
margin-bottom: 4px;
}
}
// 优化打印时的分隔线
.ant-divider {
display: none;
}
// 打印时隐藏分隔线容器的外边距
.ant-divider-horizontal {
margin: 16px 0;
@media print {
margin: 0;
display: none;
}
}
// 专门用于隐藏打印时分隔线的类
.noPrintDivider {
@media print {
display: none;
margin: 0;
}
}
// 隐藏打印按钮所在的卡片
.statsCard {
box-shadow: none;
border: none;
margin-bottom: 0;
@media print {
margin-bottom: 0;
padding: 0;
display: none;
}
// 隐藏返回按钮和打印按钮
button {
display: none !important;
}
}
// 防止最后一页出现空白页
.fillBlankContainer,
.fillBlankItem,
.optionsTable {
page-break-inside: avoid;
page-break-after: avoid;
}
// 移除页面底部可能的空白
.container:last-child {
page-break-after: auto;
}
}
// 填空题容器样式
.fillBlankContainer {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 14px;
@media print {
gap: 2px;
font-size: 12px;
margin: 0;
padding: 0;
}
}
// 填空题项目样式
.fillBlankItem {
page-break-inside: avoid;
padding: 4px 0;
@media print {
page-break-inside: avoid;
padding: 2px 0;
margin: 0;
}
}
// 表格样式用于选择题
.optionsTable {
width: 100%;
border-collapse: collapse;
margin-top: 8px;
table-layout: fixed; // 固定表格布局,确保列宽一致
th, td {
border: none;
padding: 8px 4px;
text-align: center;
vertical-align: top;
}
th {
background-color: #f5f5f5;
font-weight: bold;
}
@media print {
th, td {
border: none;
padding: 6px 2px;
}
th {
background-color: #eee;
}
// 打印时增加分页控制
tr {
page-break-inside: avoid;
}
}
}
// 选择题答案显示
.choiceAnswer {
font-weight: bold;
color: #1890ff;
margin-top: 8px;
}
// 题号和答案的显示样式
.answerDetail {
text-align: left;
padding: 4px 0;
font-size: 14px; // 调整字体大小以适应打印
line-height: 1.4;
word-wrap: break-word; // 自动换行
word-break: break-all;
@media print {
font-size: 12px; // 打印时使用稍小的字体
padding: 2px 0;
line-height: 1.3;
}
}
// 表格样式优化
.optionsTable {
width: 100%;
border-collapse: collapse;
margin-top: 8px;
table-layout: fixed; // 固定表格布局,确保列宽一致
font-size: 14px;
margin: 0;
th, td {
border: none;
padding: 12px 8px;
text-align: center;
vertical-align: top;
}
th {
background-color: #f5f5f5;
font-weight: bold;
}
@media print {
font-size: 12px;
margin: 0;
padding: 0;
th, td {
border: none;
padding: 8px 4px;
margin: 0;
}
th {
background-color: #eee;
}
// 打印时增加分页控制
tr {
page-break-inside: avoid;
}
// 避免表格后产生空白页
tbody:last-child {
page-break-after: auto;
}
}
}

View File

@ -1,291 +0,0 @@
import React, { useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import {
Card,
Button,
Typography,
Space,
Spin,
Row,
Col,
Divider,
message
} from 'antd'
import {
LeftOutlined
} from '@ant-design/icons'
import * as examApi from '../api/exam'
import type { Question } from '../types/question'
import type { GetExamResponse } from '../types/exam'
import styles from './ExamAnswerView.module.less'
const { Text } = Typography
// 题型名称映射
const TYPE_NAME: Record<string, string> = {
'fill-in-blank': '填空题',
'true-false': '判断题',
'multiple-choice': '单选题',
'multiple-selection': '多选题',
'short-answer': '简答题',
'ordinary-essay': '论述题',
'management-essay': '论述题',
'essay': '论述题' // 合并后的论述题类型
}
// 题型顺序定义
const TYPE_ORDER: Record<string, number> = {
'fill-in-blank': 1,
'true-false': 2,
'multiple-choice': 3,
'multiple-selection': 4,
'short-answer': 5,
'ordinary-essay': 6,
'management-essay': 6,
'essay': 6 // 合并后的论述题顺序
}
const ExamAnswerView: React.FC = () => {
const { examId } = useParams<{ examId: string }>()
const navigate = useNavigate()
const [loading, setLoading] = useState(true)
const [examData, setExamData] = useState<GetExamResponse | null>(null)
const [questions, setQuestions] = useState<Question[]>([])
// 处理打印功能
const handlePrint = () => {
// 设置打印标题
document.title = `试卷答案_打印版`
// 触发打印
window.print()
// 打印完成后恢复标题
setTimeout(() => {
document.title = 'AnKao - 智能考试系统'
}, 1000)
}
useEffect(() => {
if (!examId) {
message.error('参数错误')
navigate('/exam/management')
return
}
loadExamData()
}, [examId])
const loadExamData = async () => {
setLoading(true)
try {
// 获取带答案的试卷详情
const res = await examApi.getExam(Number(examId), true)
if (res.success && res.data) {
setExamData(res.data)
setQuestions(res.data.questions)
} else {
message.error('加载试卷失败')
navigate('/exam/management')
}
} catch (error: any) {
message.error(error.response?.data?.message || '加载试卷失败')
navigate('/exam/management')
} finally {
setLoading(false)
}
}
if (loading) {
return (
<div className={styles.loadingContainer}>
<Spin size="large" />
<Text style={{ marginTop: 16 }}>...</Text>
</div>
)
}
if (!examData) {
return null
}
// 渲染答案详情
const renderAnswerDetail = (question: Question, index: number) => {
// 格式化答案显示
const formatAnswer = (answer: any, type: string): string => {
if (answer === null || answer === undefined || answer === '') {
return '未设置答案'
}
if (Array.isArray(answer)) {
if (answer.length === 0) return '未设置答案'
return answer.filter(a => a !== null && a !== undefined && a !== '').join('、')
}
if (type === 'true-false') {
// 处理判断题:支持字符串和布尔值
const answerStr = String(answer).toLowerCase()
return answerStr === 'true' ? '正确' : '错误'
}
return String(answer)
}
// 特殊处理填空题,按要求格式显示
if (question.type === 'fill-in-blank') {
const answers = Array.isArray(question.answer) ? question.answer : [question.answer];
// 过滤掉空答案并转换为字符串
const validAnswers = answers
.filter(answer => answer !== null && answer !== undefined && answer !== '')
.map(answer => String(answer));
// 如果没有有效答案,显示提示
if (validAnswers.length === 0) {
return (
<div className={styles.answerDetail}>
{index + 1}.
</div>
);
}
// 使用逗号分隔显示答案
return (
<div className={styles.answerDetail}>
{index + 1}. {validAnswers.join('')}
</div>
);
}
// 特殊处理判断题显示对号或X
if (question.type === 'true-false') {
const answerStr = String(question.answer).toLowerCase();
const isCorrect = answerStr === 'true';
return (
<div className={styles.answerDetail}>
{index + 1}. {isCorrect ? '√' : '×'}
</div>
);
}
// 其他题型显示题号和答案
return (
<div className={styles.answerDetail}>
{index + 1}. {formatAnswer(question.answer, question.type)}
</div>
);
}
// 汉字数字映射
const chineseNumbers = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十'];
// 按题型分组(合并两种论述题)
const groupedQuestions = questions.reduce((acc, q) => {
// 将两种论述题统一为 'essay'
const displayType = (q.type === 'ordinary-essay' || q.type === 'management-essay') ? 'essay' : q.type
if (!acc[displayType]) {
acc[displayType] = []
}
acc[displayType].push(q)
return acc
}, {} as Record<string, Question[]>)
return (
<div className={styles.container}>
{/* 试卷标题和返回按钮 */}
<Card className={styles.statsCard}>
<Row align="middle" justify="space-between">
<Col>
<Button
icon={<LeftOutlined />}
onClick={() => navigate('/exam/management')}
>
</Button>
</Col>
<Col flex="auto" style={{ textAlign: 'center' }}>
<Text strong style={{ fontSize: 20 }}>
</Text>
</Col>
<Col>
<Button
type="primary"
onClick={handlePrint}
>
</Button>
</Col>
</Row>
</Card>
{/* 答题详情 - 使用表格展示 */}
<Card className={styles.detailCard}>
{Object.entries(groupedQuestions)
.sort(([typeA], [typeB]) => {
const orderA = TYPE_ORDER[typeA] || 999
const orderB = TYPE_ORDER[typeB] || 999
return orderA - orderB
})
.map(([type, qs], typeIndex) => (
<div key={type} style={{ marginBottom: 16 }}>
{/* 题型标题 */}
<div className={styles.typeTitle} style={{
padding: '8px 12px',
marginBottom: 8
}}>
<Space>
<Text strong style={{ fontSize: 16 }}>
{chineseNumbers[typeIndex] || typeIndex + 1}{TYPE_NAME[type] || type}
</Text>
<Text type="secondary"> {qs.length} </Text>
</Space>
</div>
{/* 填空题、简答题和论述题特殊处理:每行一个答案,不使用表格 */}
{type === 'fill-in-blank' || type === 'short-answer' || type === 'essay' || type === 'ordinary-essay' || type === 'management-essay' ? (
<div className={styles.fillBlankContainer}>
{qs.map((q, index) => (
<div key={q.id} className={styles.fillBlankItem}>
{renderAnswerDetail(q, index)}
</div>
))}
</div>
) : (
/* 其他题型使用表格显示答案每行5列确保题号和答案分行显示 */
<table className={styles.optionsTable}>
<tbody>
{/* 每5个题目为一行确保更好的打印效果 */}
{Array.from({ length: Math.ceil(qs.length / 5) }).map((_, rowIndex) => (
<tr key={rowIndex}>
{qs.slice(rowIndex * 5, (rowIndex + 1) * 5).map((q, colIndex) => {
const globalIndex = rowIndex * 5 + colIndex;
return (
<td key={q.id} style={{ width: '20%' }}>
{renderAnswerDetail(q, globalIndex)}
</td>
);
})}
{/* 如果最后一行不足5列用空单元格填充 */}
{qs.length - rowIndex * 5 < 5 &&
Array.from({ length: 5 - (qs.length - rowIndex * 5) }).map((_, emptyIndex) => (
<td key={`empty-${emptyIndex}`} style={{ width: '20%' }}></td>
))
}
</tr>
))}
</tbody>
</table>
)}
{/* 题型之间的分隔线 */}
<Divider className={styles.noPrintDivider} />
</div>
))}
</Card>
</div>
)
}
export default ExamAnswerView

View File

@ -1,647 +0,0 @@
.container {
padding: 24px;
max-width: 1400px;
margin: 0 auto;
background: #f5f7fa;
min-height: 100vh;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 32px;
padding: 20px 24px;
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
h2 {
margin: 0;
font-size: 28px;
font-weight: 700;
color: #1a1a1a;
flex: 1;
text-align: center;
}
}
// 试卷网格布局
.examGrid {
display: grid;
grid-template-columns: repeat(3, 1fr); // 固定显示3列
gap: 24px;
align-items: stretch; // 确保所有卡片等高
}
// 试卷卡片样式重构
.examCard {
border-radius: 16px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
border: 1px solid #e8eaed;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
background: white;
height: 100%; // 确保卡片占满网格单元格高度
display: flex;
flex-direction: column;
&:hover {
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15);
transform: translateY(-8px);
}
:global(.ant-card-body) {
padding: 0;
flex: 1; // 让卡片内容自动伸展
display: flex;
flex-direction: column;
}
}
// 卡片头部样式 - 使用蓝色主题
.cardCover {
position: relative;
padding: 28px 24px;
color: white;
min-height: 140px;
display: flex;
flex-direction: column;
justify-content: space-between;
background: linear-gradient(135deg, #4A90E2 0%, #357ABD 100%);
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg,
rgba(74, 144, 226, 0.95) 0%,
rgba(53, 122, 189, 0.95) 50%,
rgba(41, 98, 155, 0.95) 100%
);
z-index: 1;
}
&::after {
content: '';
position: absolute;
bottom: -20px;
left: -20px;
width: 120px;
height: 120px;
background: rgba(255, 255, 255, 0.1);
border-radius: 50%;
z-index: 1;
}
}
.coverInfo {
position: relative;
z-index: 2;
text-align: left;
.examTitle {
margin: 0;
font-size: 20px;
font-weight: 700;
line-height: 1.3;
color: white;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.shareTag {
background: rgba(255, 255, 255, 0.25);
border: 1px solid rgba(255, 255, 255, 0.4);
color: white;
backdrop-filter: blur(12px);
font-weight: 500;
border-radius: 20px;
padding: 4px 12px;
font-size: 12px;
}
}
.coverIcon {
position: absolute;
top: 28px;
right: 24px;
z-index: 2;
font-size: 36px;
opacity: 0.95;
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.2));
}
// 卡片内容样式
.cardContent {
padding: 8px;
}
.examInfo {
display: flex;
justify-content: space-between;
margin-bottom: 20px;
gap: 12px;
.infoItem {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
padding: 16px 12px;
background: #f8fafc;
border-radius: 12px;
border: 1px solid #e2e8f0;
transition: all 0.2s ease;
&:hover {
background: #e2e8f0;
transform: translateY(-2px);
}
.infoIcon {
font-size: 18px;
color: #4A90E2;
margin-bottom: 8px;
}
.infoText {
font-size: 13px;
color: #64748b;
font-weight: 600;
text-align: center;
}
}
}
.divider {
margin: 16px 0;
border-color: #e2e8f0;
border-width: 1px;
}
// 统计数据样式
.examStats {
display: flex;
flex-direction: row; // 改为横向排列
flex-wrap: wrap; // 允许换行
gap: 12px;
.statItem {
flex: 1; // 让每个项目平均分配空间
min-width: 0; // 允许flex收缩
.valueTag {
font-size: 13px;
font-weight: 500;
padding: 8px 12px;
border-radius: 8px;
background: #f8fafc;
color: #475569;
border: 1px solid #e2e8f0;
display: flex;
align-items: center;
gap: 8px;
line-height: 1.4;
width: 100%;
justify-content: center; // 内容居中
// 覆盖 antd Tag 的默认图标间距
:global(.anticon) + span,
span + :global(.anticon) {
margin-inline-start: 0 !important;
}
.anticon {
font-size: 14px;
color: #64748b;
flex-shrink: 0;
}
span {
font-size: 13px;
line-height: 1.4;
}
}
.progressTag {
font-size: 13px;
padding: 8px 12px;
border-radius: 8px;
display: flex;
align-items: center;
gap: 8px;
line-height: 1.4;
width: 100%;
justify-content: center; // 内容居中
// 覆盖 antd Tag 的默认图标间距
:global(.anticon) + span,
span + :global(.anticon) {
margin-inline-start: 0 !important;
}
.anticon {
font-size: 14px;
flex-shrink: 0;
}
span {
font-size: 13px;
line-height: 1.4;
}
}
}
}
// 操作按钮样式 - 初版
.actionButton {
width: 100%;
height: 50px !important;
border-radius: 6px !important;
font-size: 13px !important;
font-weight: 500 !important;
display: flex !important;
flex-direction: column !important;
align-items: center !important;
justify-content: center !important;
gap: 6px !important;
border: 1px solid #d9d9d9 !important;
background: #ffffff !important;
color: #666666 !important;
transition: all 0.3s ease !important;
margin: 0 !important;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05) !important;
&:hover {
background: #1890ff !important;
color: #ffffff !important;
border-color: #1890ff !important;
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.15) !important;
transform: translateY(-2px);
}
&:active {
transform: translateY(0);
}
.anticon {
font-size: 18px !important;
margin: 0 !important;
}
&.ant-btn-dangerous {
color: #ff4d4f !important;
border-color: #ff4d4f !important;
&:hover {
background: #ff4d4f !important;
color: #ffffff !important;
border-color: #ff4d4f !important;
box-shadow: 0 4px 12px rgba(255, 77, 79, 0.15) !important;
}
}
}
// 卡片操作区域样式 - 初版
:global(.ant-card-actions) {
background: #fafafa;
border-top: 1px solid #f0f0f0;
padding: 12px 16px;
li {
margin: 0 !important;
padding: 8px 6px !important;
border-right: none !important;
.actionButton {
border-radius: 6px !important;
background: #ffffff !important;
border-color: #d9d9d9 !important;
color: #666666 !important;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05) !important;
&:hover {
background: #1890ff !important;
border-color: #1890ff !important;
color: #ffffff !important;
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.15) !important;
}
&.ant-btn-dangerous {
color: #ff4d4f !important;
border-color: #ff4d4f !important;
&:hover {
background: #ff4d4f !important;
color: #ffffff !important;
border-color: #ff4d4f !important;
box-shadow: 0 4px 12px rgba(255, 77, 79, 0.15) !important;
}
}
}
}
}
// 旧版兼容样式
.cardTitle {
display: flex;
align-items: center;
gap: 8px;
font-size: 16px;
font-weight: 600;
svg {
font-size: 18px;
color: #1890ff;
}
span {
line-height: 1.4;
}
}
.cardContent {
.infoRow {
display: flex;
gap: 16px;
margin-bottom: 12px;
}
.infoItem {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
color: rgba(0, 0, 0, 0.65);
svg {
color: #1890ff;
font-size: 14px;
}
}
.stats {
display: flex;
gap: 8px;
margin-top: 8px;
}
.statTag {
font-size: 12px;
}
}
// 旧版兼容样式 - divider已合并不再重复定义
// 响应式适配
// 移动端1列
@media (max-width: 768px) {
.container {
padding: 16px;
background: #ffffff;
}
.examGrid {
grid-template-columns: 1fr; // 移动端显示1列
gap: 16px;
}
.header {
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 16px;
padding: 16px 20px;
margin-bottom: 24px;
h2 {
font-size: 22px;
text-align: center;
flex: 1;
}
button {
width: auto;
height: 32px;
flex-shrink: 0;
padding: 0 12px;
}
}
.examCard {
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
}
.cardCover {
padding: 24px 20px;
min-height: 120px;
.coverIcon {
position: absolute;
top: 24px;
right: 20px;
font-size: 32px;
}
.examTitle {
font-size: 18px;
}
}
.cardContent {
padding: 16px;
}
.examInfo {
flex-direction: column;
gap: 12px;
margin-bottom: 16px;
.infoItem {
flex-direction: row;
justify-content: flex-start;
padding: 12px 16px;
gap: 12px;
.infoIcon {
font-size: 16px;
margin-bottom: 0;
margin-right: 0;
}
.infoText {
font-size: 13px;
text-align: left;
}
}
}
.examStats {
padding: 12px 0;
gap: 6px;
.statItem {
.valueTag {
font-size: 12px;
padding: 6px 10px;
gap: 6px;
.anticon {
font-size: 12px;
}
span {
font-size: 12px;
}
}
.progressTag {
font-size: 12px;
padding: 6px 10px;
gap: 6px;
.anticon {
font-size: 12px;
}
span {
font-size: 12px;
}
}
}
}
.actionButton {
height: 46px !important;
font-size: 12px !important;
.anticon {
font-size: 16px !important;
}
}
// 移动端操作区域
:global(.ant-card-actions) {
padding: 10px 12px;
li {
padding: 6px 4px !important;
.actionButton {
height: 46px !important;
font-size: 12px !important;
}
}
}
// 移动端旧版样式
.cardTitle {
font-size: 15px;
margin-bottom: 8px;
svg {
font-size: 16px;
}
}
.cardContent {
margin-top: 8px;
.infoRow {
flex-direction: column;
gap: 8px;
margin-bottom: 8px;
}
.infoItem {
font-size: 13px;
}
.stats {
margin-top: 6px;
flex-wrap: wrap;
}
.statTag {
font-size: 11px;
}
:global(.ant-card-actions) {
li {
button {
padding: 6px 8px;
font-size: 11px;
}
}
}
}
}
// 平板端2列
@media (min-width: 769px) and (max-width: 1024px) {
.examGrid {
grid-template-columns: repeat(2, 1fr); // 平板显示2列
gap: 12px;
}
}
@media (max-width: 480px) {
.container {
padding: 12px;
}
.examGrid {
gap: 12px;
}
.header {
padding: 12px 16px;
h2 {
font-size: 20px;
}
button {
height: 32px;
padding: 0 12px;
}
}
.cardCover {
padding: 20px 16px;
min-height: 100px;
.coverIcon {
position: absolute;
top: 20px;
right: 16px;
font-size: 28px;
}
.examTitle {
font-size: 16px;
}
}
.cardContent {
padding: 16px;
}
:global(.ant-card-actions) {
li {
.actionButton {
height: 42px !important;
font-size: 11px !important;
.anticon {
font-size: 14px !important;
}
}
}
}
}

View File

@ -1,648 +0,0 @@
import React, { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import {
Card,
Button,
Form,
Input,
Space,
message,
List,
Tag,
Modal,
Empty,
Spin,
Drawer,
Descriptions,
Divider,
Checkbox,
Avatar
} from 'antd'
import {
PlusOutlined,
DeleteOutlined,
PlayCircleOutlined,
FileTextOutlined,
TrophyOutlined,
HistoryOutlined,
PrinterOutlined,
ArrowLeftOutlined,
ShareAltOutlined,
UserOutlined,
TeamOutlined,
CrownOutlined
} from '@ant-design/icons'
import * as examApi from '../api/exam'
import styles from './ExamManagement.module.less'
interface ExamListItem {
id: number
title: string
total_score: number
duration: number
pass_score: number
question_count: number
attempt_count: number
best_score: number
has_in_progress_exam: boolean
in_progress_record_id?: number
participant_count: number // 共享试卷的参与人数
created_at: string
is_shared?: boolean
is_system?: boolean // 是否为系统试卷
shared_by?: {
id: number
username: string
nickname?: string
}
}
interface ShareableUser {
id: number
username: string
nickname?: string
avatar?: string
}
const ExamManagement: React.FC = () => {
const navigate = useNavigate()
const [form] = Form.useForm()
const [loading, setLoading] = useState(false)
const [exams, setExams] = useState<ExamListItem[]>([])
const [loadingExams, setLoadingExams] = useState(false)
const [createModalVisible, setCreateModalVisible] = useState(false)
const [recordsDrawerVisible, setRecordsDrawerVisible] = useState(false)
const [, setCurrentExamId] = useState<number | null>(null)
const [examRecords, setExamRecords] = useState<any[]>([])
const [loadingRecords, setLoadingRecords] = useState(false)
// 分享相关状态
const [shareModalVisible, setShareModalVisible] = useState(false)
const [shareableUsers, setShareableUsers] = useState<ShareableUser[]>([])
const [selectedUserIds, setSelectedUserIds] = useState<number[]>([])
const [loadingUsers, setLoadingUsers] = useState(false)
const [sharingExamId, setSharingExamId] = useState<number | null>(null)
const [sharingLoading, setSharingLoading] = useState(false)
// 加载试卷列表
const loadExams = async () => {
setLoadingExams(true)
try {
const res = await examApi.getExamList()
if (res.success) {
setExams(res.data || [])
}
} catch (error) {
message.error('加载试卷列表失败')
} finally {
setLoadingExams(false)
}
}
useEffect(() => {
loadExams()
}, [])
// 创建试卷
const handleCreateExam = async (values: any) => {
setLoading(true)
try {
const params = {
title: values.title,
duration: 60, // 默认60分钟
question_types: [] // 空配置,后端会使用默认值
}
const res = await examApi.createExam(params)
if (res.success) {
message.success('试卷创建成功')
setCreateModalVisible(false)
form.resetFields()
loadExams()
} else {
message.error(res.message || '创建失败')
}
} catch (error: any) {
if (error.response?.data?.message) {
message.error(error.response.data.message)
} else {
message.error('创建失败,请稍后重试')
}
} finally {
setLoading(false)
}
}
// 删除试卷
const handleDeleteExam = async (examId: number) => {
Modal.confirm({
title: '确认删除',
content: '删除试卷后将无法恢复,是否确认删除?',
okText: '确认',
cancelText: '取消',
okType: 'danger',
onOk: async () => {
try {
const res = await examApi.deleteExam(examId)
if (res.success) {
message.success('删除成功')
loadExams()
} else {
message.error(res.message || '删除失败')
}
} catch (error) {
message.error('删除失败')
}
}
})
}
// 开始考试
const handleStartExam = async (examId: number, hasInProgressExam: boolean, inProgressRecordId?: number) => {
try {
if (hasInProgressExam && inProgressRecordId) {
// 有未完成的考试,直接跳转继续答题
navigate(`/exam/${examId}/taking/${inProgressRecordId}`)
} else {
// 没有未完成的考试,调用开始考试API创建新记录
const res = await examApi.startExam(examId)
if (res.success && res.data) {
navigate(`/exam/${examId}/taking/${res.data.record_id}`)
} else {
message.error(res.message || '开始考试失败')
}
}
} catch (error) {
message.error('开始考试失败')
}
}
// 查看考试记录
const handleViewRecords = async (examId: number) => {
setCurrentExamId(examId)
setRecordsDrawerVisible(true)
setLoadingRecords(true)
try {
const res = await examApi.getExamRecordList(examId)
if (res.success && res.data) {
setExamRecords(res.data)
}
} catch (error) {
message.error('加载考试记录失败')
} finally {
setLoadingRecords(false)
}
}
// 查看记录详情
const handleViewRecordDetail = (recordId: number) => {
navigate(`/exam/result/${recordId}`)
}
// 打开分享弹窗
const handleOpenShareModal = async (examId: number) => {
setSharingExamId(examId)
setShareModalVisible(true)
setSelectedUserIds([])
// 加载可分享的用户列表
setLoadingUsers(true)
try {
const res = await examApi.getShareableUsers()
if (res.success && res.data) {
setShareableUsers(res.data)
}
} catch (error) {
message.error('加载用户列表失败')
} finally {
setLoadingUsers(false)
}
}
// 处理分享
const handleShareExam = async () => {
if (!sharingExamId || selectedUserIds.length === 0) {
message.warning('请至少选择一个用户')
return
}
setSharingLoading(true)
try {
const res = await examApi.shareExam(sharingExamId, selectedUserIds)
if (res.success) {
message.success(res.message || `成功分享给 ${res.data?.shared_count || selectedUserIds.length} 个用户`)
setShareModalVisible(false)
setSelectedUserIds([])
setSharingExamId(null)
} else {
message.error(res.message || '分享失败')
}
} catch (error: any) {
if (error.response?.data?.message) {
message.error(error.response.data.message)
} else {
message.error('分享失败,请稍后重试')
}
} finally {
setSharingLoading(false)
}
}
// 处理用户选择变化
const handleUserSelectionChange = (userId: number, checked: boolean) => {
if (checked) {
setSelectedUserIds([...selectedUserIds, userId])
} else {
setSelectedUserIds(selectedUserIds.filter(id => id !== userId))
}
}
return (
<div className={styles.container}>
<Card>
<div className={styles.header}>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/')}
>
</Button>
<h2></h2>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setCreateModalVisible(true)}
>
</Button>
</div>
<Spin spinning={loadingExams}>
{exams.length === 0 ? (
<Empty
description="暂无试卷,点击上方按钮创建"
style={{ marginTop: 40 }}
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
) : (
<div className={styles.examGrid}>
{exams.map((exam) => (
<Card
key={exam.id}
className={styles.examCard}
hoverable
cover={
<div className={styles.cardCover}>
<div className={styles.coverIcon}>
<FileTextOutlined />
</div>
<div className={styles.coverInfo}>
<h3
className={styles.examTitle}
style={{ marginBottom: (exam.is_system || (exam.is_shared && exam.shared_by)) ? '12px' : '0' }}
>
{exam.title}
</h3>
{exam.is_system && (
<Tag
icon={<CrownOutlined />}
color="orange"
className={styles.shareTag}
>
</Tag>
)}
{exam.is_shared && exam.shared_by && (
<Tag
icon={<ShareAltOutlined />}
color="purple"
className={styles.shareTag}
>
{exam.shared_by.nickname || exam.shared_by.username}
</Tag>
)}
</div>
</div>
}
actions={[
<Button
type="text"
icon={<PlayCircleOutlined />}
onClick={() => handleStartExam(exam.id, exam.has_in_progress_exam, exam.in_progress_record_id)}
className={styles.actionButton}
>
{exam.has_in_progress_exam ? '继续' : '考试'}
</Button>,
// 只有非系统且自己创建的试卷才能分享
!exam.is_system && !exam.is_shared && (
<Button
type="text"
icon={<ShareAltOutlined />}
onClick={() => handleOpenShareModal(exam.id)}
className={styles.actionButton}
>
</Button>
),
<Button
type="text"
icon={<HistoryOutlined />}
onClick={() => handleViewRecords(exam.id)}
className={styles.actionButton}
>
</Button>,
<Button
type="text"
icon={<FileTextOutlined />}
onClick={() => navigate(`/exam/${exam.id}/answer`)}
className={styles.actionButton}
>
</Button>,
<Button
type="text"
icon={<PrinterOutlined />}
onClick={() => navigate(`/exam/${exam.id}/print`)}
className={styles.actionButton}
>
</Button>,
// 只有非系统且自己创建的试卷才能删除
!exam.is_system && !exam.is_shared && (
<Button
type="text"
danger
icon={<DeleteOutlined />}
onClick={() => handleDeleteExam(exam.id)}
className={styles.actionButton}
>
</Button>
)
].filter(Boolean)}
>
<div className={styles.cardContent}>
<div className={styles.examStats}>
<div className={styles.statItem}>
<Tag className={styles.valueTag}>
<TrophyOutlined />
<span> {exam.best_score || 0}</span>
</Tag>
</div>
<div className={styles.statItem}>
<Tag className={styles.valueTag}>
<HistoryOutlined />
<span> {exam.attempt_count}</span>
</Tag>
</div>
{exam.participant_count > 0 && (
<div className={styles.statItem}>
<Tag className={styles.valueTag}>
<TeamOutlined />
<span>{exam.participant_count} </span>
</Tag>
</div>
)}
{exam.has_in_progress_exam && (
<div className={styles.statItem}>
<Tag color="processing" className={styles.progressTag}>
<PlayCircleOutlined />
<span></span>
</Tag>
</div>
)}
</div>
</div>
</Card>
))}
</div>
)}
</Spin>
</Card>
{/* 考试记录抽屉 */}
<Drawer
title="考试记录"
placement="right"
width={600}
open={recordsDrawerVisible}
onClose={() => setRecordsDrawerVisible(false)}
>
<Spin spinning={loadingRecords}>
{examRecords.length === 0 ? (
<Empty description="暂无考试记录" />
) : (
<List
dataSource={examRecords}
renderItem={(record: any) => (
<Card
key={record.id}
style={{ marginBottom: 16 }}
size="small"
>
{record.user && (
<div style={{ marginBottom: 12, display: 'flex', alignItems: 'center', gap: 8 }}>
<Avatar src={record.user.avatar} icon={<UserOutlined />} />
<span style={{ fontWeight: 'bold' }}>
{record.user.nickname || record.user.username}
</span>
</div>
)}
<Descriptions column={1} size="small">
<Descriptions.Item label="状态">
{record.status === 'in_progress' && <Tag color="processing"></Tag>}
{record.status === 'submitted' && <Tag color="warning"></Tag>}
{record.status === 'graded' && (
<Tag color={record.is_passed ? 'success' : 'error'}>
{record.is_passed ? '已通过' : '未通过'}
</Tag>
)}
</Descriptions.Item>
<Descriptions.Item label="分数">
{record.status === 'in_progress' ? (
<span>-</span>
) : (
<span style={{
fontSize: 18,
fontWeight: 'bold',
color: record.is_passed ? '#52c41a' : '#ff4d4f',
lineHeight: 1,
verticalAlign: 'middle'
}}>
{record.score}
</span>
)}
</Descriptions.Item>
<Descriptions.Item label="开始时间">
{record.start_time ? new Date(record.start_time).toLocaleString() : '-'}
</Descriptions.Item>
<Descriptions.Item label="提交时间">
{record.submit_time ? new Date(record.submit_time).toLocaleString() : '-'}
</Descriptions.Item>
<Descriptions.Item label="用时">
{record.time_spent ? `${Math.floor(record.time_spent / 60)}${record.time_spent % 60}` : '-'}
</Descriptions.Item>
</Descriptions>
<Divider style={{ margin: '12px 0' }} />
<Space>
{record.status === 'in_progress' && (
<Button
type="primary"
size="small"
icon={<PlayCircleOutlined />}
onClick={() => {
setRecordsDrawerVisible(false)
navigate(`/exam/${record.exam_id}/taking/${record.id}`)
}}
>
</Button>
)}
{record.status !== 'in_progress' && (
<Button
size="small"
icon={<FileTextOutlined />}
onClick={() => handleViewRecordDetail(record.id)}
>
</Button>
)}
</Space>
</Card>
)}
/>
)}
</Spin>
</Drawer>
{/* 创建试卷模态框 */}
<Modal
title="创建试卷"
open={createModalVisible}
onCancel={() => {
setCreateModalVisible(false)
form.resetFields()
}}
footer={null}
width={500}
>
<Form
form={form}
layout="vertical"
onFinish={handleCreateExam}
>
<Form.Item
label="试卷标题"
name="title"
rules={[{ required: true, message: '请输入试卷标题' }]}
>
<Input placeholder="例如:保密知识测试卷(一)" />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit" loading={loading}>
</Button>
<Button onClick={() => {
setCreateModalVisible(false)
form.resetFields()
}}>
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
{/* 分享试卷模态框 */}
<Modal
title="分享试卷"
open={shareModalVisible}
onCancel={() => {
setShareModalVisible(false)
setSelectedUserIds([])
setSharingExamId(null)
}}
onOk={handleShareExam}
confirmLoading={sharingLoading}
okText="确认分享"
cancelText="取消"
width={500}
>
<Spin spinning={loadingUsers}>
<div style={{ marginBottom: 16, borderBottom: '1px solid #f0f0f0', paddingBottom: 12 }}>
<Checkbox
indeterminate={
selectedUserIds.length > 0 && selectedUserIds.length < shareableUsers.length
}
checked={shareableUsers.length > 0 && selectedUserIds.length === shareableUsers.length}
onChange={(e) => {
if (e.target.checked) {
// 全选
setSelectedUserIds(shareableUsers.map(user => user.id))
} else {
// 取消全选
setSelectedUserIds([])
}
}}
>
<span style={{ fontWeight: 500 }}>
({selectedUserIds.length}/{shareableUsers.length})
</span>
</Checkbox>
</div>
<div style={{ maxHeight: 400, overflowY: 'auto' }}>
{shareableUsers.length === 0 ? (
<Empty description="暂无可分享的用户" />
) : (
<Space direction="vertical" style={{ width: '100%' }}>
{shareableUsers.map((user) => (
<Card
key={user.id}
size="small"
hoverable
style={{
backgroundColor: selectedUserIds.includes(user.id) ? '#f0f5ff' : undefined
}}
>
<Checkbox
checked={selectedUserIds.includes(user.id)}
onChange={(e) => handleUserSelectionChange(user.id, e.target.checked)}
style={{ width: '100%' }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Avatar
size={40}
src={user.avatar}
icon={<UserOutlined />}
/>
<div>
<div style={{ fontWeight: 500, fontSize: 14 }}>
{user.nickname || user.username}
</div>
<div style={{ fontSize: 12, color: '#999' }}>
: {user.username}
</div>
</div>
</div>
</Checkbox>
</Card>
))}
</Space>
)}
</div>
{shareableUsers.length > 0 && (
<div style={{ marginTop: 16, color: '#999', fontSize: 12 }}>
{selectedUserIds.length}
</div>
)}
</Spin>
</Modal>
</div>
)
}
export default ExamManagement

View File

@ -1,391 +0,0 @@
.container {
min-height: 100vh;
background: #fafafa;
padding: 0;
padding-bottom: 80px;
}
// 固定顶栏
.fixedTopBar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
background: rgba(250, 250, 250, 0.85);
backdrop-filter: blur(40px) saturate(180%);
-webkit-backdrop-filter: blur(40px) saturate(180%);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.topBarContent {
max-width: 1000px;
margin: 0 auto;
padding: 16px 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.backButton {
color: #007aff;
font-weight: 500;
padding: 4px 12px;
&:hover {
color: #0051d5;
background: rgba(0, 122, 255, 0.08);
}
}
.title {
color: #1d1d1f !important;
margin: 0 !important;
font-weight: 700;
font-size: 18px !important;
flex: 1;
text-align: center;
}
.content {
max-width: 1000px;
margin: 0 auto;
padding: 0 20px;
padding-top: 80px;
}
// 加载状态
.loadingContainer {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
min-height: 100vh;
background: #fafafa;
}
// 考试说明卡片
.examInfoCard {
margin-bottom: 24px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
:global(.ant-card-body) {
padding: 24px;
}
h4 {
margin-bottom: 16px;
color: #1d1d1f;
font-weight: 600;
}
ul {
margin: 0;
padding-left: 20px;
color: #6e6e73;
line-height: 1.8;
li {
margin-bottom: 8px;
}
}
}
// 题目组
.questionGroup {
margin-bottom: 32px;
:global(.ant-divider) {
margin: 24px 0;
border-color: #e5e5ea;
}
h3 {
color: #1d1d1f;
font-weight: 600;
font-size: 20px;
margin: 0;
}
}
// 题目卡片
.questionCard {
margin-bottom: 16px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
transition: all 0.3s ease;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
:global(.ant-card-body) {
padding: 24px;
}
}
.questionHeader {
margin-bottom: 16px;
display: flex;
align-items: center;
flex-wrap: wrap;
span {
font-size: 16px;
line-height: 1.6;
color: #1d1d1f;
}
}
// 论述题选择区域
.essaySection {
.essayChoiceCard {
margin-bottom: 24px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
:global(.ant-card-body) {
padding: 24px;
}
h4 {
margin-bottom: 8px;
color: #1d1d1f;
font-weight: 600;
}
:global(.ant-typography) {
color: #6e6e73;
}
}
.essayRadioGroup {
width: 100%;
margin-top: 16px;
:global(.ant-space) {
width: 100%;
}
}
.essayOptionCard {
border-radius: 12px;
cursor: pointer;
transition: all 0.3s ease;
border: 2px solid transparent;
&:hover {
border-color: #007aff;
box-shadow: 0 4px 12px rgba(0, 122, 255, 0.15);
}
&.selected {
border-color: #007aff;
background: rgba(0, 122, 255, 0.05);
box-shadow: 0 4px 12px rgba(0, 122, 255, 0.15);
}
:global(.ant-card-body) {
padding: 20px;
}
:global(.ant-radio) {
align-items: flex-start;
}
.essayContent {
margin-top: 12px;
margin-left: 24px;
:global(.ant-typography) {
color: #1d1d1f;
font-size: 15px;
line-height: 1.6;
margin-bottom: 0;
}
}
}
}
// 提交按钮区域
.submitSection {
margin-top: 48px;
margin-bottom: 48px;
display: flex;
justify-content: center;
padding: 24px 0;
}
.submitButton {
min-width: 200px;
height: 48px;
font-size: 16px;
font-weight: 600;
border-radius: 24px;
box-shadow: 0 4px 12px rgba(0, 122, 255, 0.3);
&:hover {
box-shadow: 0 6px 16px rgba(0, 122, 255, 0.4);
transform: translateY(-2px);
}
}
// 表单项样式
:global {
.ant-form-item {
margin-bottom: 16px;
}
.ant-input,
.ant-input-textarea {
border-radius: 8px;
font-size: 15px;
&:focus,
&:hover {
border-color: #007aff;
box-shadow: 0 0 0 2px rgba(0, 122, 255, 0.1);
}
}
.ant-radio-wrapper,
.ant-checkbox-wrapper {
font-size: 15px;
line-height: 1.8;
color: #1d1d1f;
}
.ant-radio-checked .ant-radio-inner,
.ant-checkbox-checked .ant-checkbox-inner {
background-color: #007aff;
border-color: #007aff;
}
}
// 响应式设计 - 移动端
@media (max-width: 768px) {
.container {
padding-bottom: 70px;
}
.topBarContent {
padding: 12px;
}
.title {
font-size: 16px !important;
}
.content {
padding: 0 12px;
padding-top: 70px;
}
.examInfoCard,
.questionCard {
border-radius: 8px;
:global(.ant-card-body) {
padding: 16px;
}
}
.questionHeader span {
font-size: 14px;
}
.questionGroup {
margin-bottom: 24px;
h3 {
font-size: 18px;
}
}
.essaySection {
.essayChoiceCard {
:global(.ant-card-body) {
padding: 16px;
}
h4 {
font-size: 16px;
}
}
.essayOptionCard {
:global(.ant-card-body) {
padding: 16px;
}
.essayContent {
margin-left: 20px;
:global(.ant-typography) {
font-size: 14px;
}
}
}
}
.submitSection {
margin-top: 32px;
margin-bottom: 32px;
}
.submitButton {
width: 100%;
height: 44px;
font-size: 15px;
}
}
// 响应式设计 - 平板
@media (min-width: 769px) and (max-width: 1024px) {
.topBarContent {
padding: 14px 24px;
}
.content {
padding: 0 24px;
padding-top: 75px;
}
.title {
font-size: 20px !important;
}
.questionCard {
:global(.ant-card-body) {
padding: 20px;
}
}
}
// 响应式设计 - PC端
@media (min-width: 1025px) {
.topBarContent {
padding: 18px 32px;
}
.content {
padding: 0 32px;
padding-top: 85px;
}
.title {
font-size: 22px !important;
}
.questionCard {
:global(.ant-card-body) {
padding: 28px;
}
}
.questionHeader span {
font-size: 17px;
}
}

View File

@ -1,592 +0,0 @@
import React, { useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import {
Card,
Form,
Input,
Radio,
Checkbox,
Button,
Typography,
message,
Spin,
Space,
Divider,
Modal,
} from 'antd'
import {
ArrowLeftOutlined,
CheckCircleOutlined,
ExclamationCircleOutlined,
} from '@ant-design/icons'
import * as examApi from '../api/exam'
import type { Question } from '../types/question'
import type { GetExamResponse, SubmitExamRequest } from '../types/exam'
import styles from './ExamOnline.module.less'
const { Title, Paragraph, Text } = Typography
const { TextArea } = Input
// 题型顺序映射
const TYPE_ORDER: Record<string, number> = {
'fill-in-blank': 1,
'true-false': 2,
'multiple-choice': 3,
'multiple-selection': 4,
'short-answer': 5,
'ordinary-essay': 6,
'management-essay': 6,
}
// 题型名称映射
const TYPE_NAME: Record<string, string> = {
'fill-in-blank': '填空题',
'true-false': '判断题',
'multiple-choice': '单选题',
'multiple-selection': '多选题',
'short-answer': '简答题',
'ordinary-essay': '论述题(普通涉密人员)',
'management-essay': '论述题(保密管理人员)',
}
const ExamOnline: React.FC = () => {
const { examId } = useParams<{ examId: string }>()
const navigate = useNavigate()
const [form] = Form.useForm()
const [loading, setLoading] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [examData, setExamData] = useState<GetExamResponse | null>(null)
const [groupedQuestions, setGroupedQuestions] = useState<
Record<string, Question[]>
>({})
const [essayChoice, setEssayChoice] = useState<'ordinary' | 'management' | null>(null)
// 加载考试详情
useEffect(() => {
if (!examId) {
message.error('考试ID不存在')
navigate('/exam/management')
return
}
const loadExam = async () => {
setLoading(true)
try {
const res = await examApi.getExam(Number(examId), false)
if (res.success && res.data) {
setExamData(res.data)
// 按题型分组
const grouped = groupQuestionsByType(res.data.questions)
setGroupedQuestions(grouped)
// 恢复答题进度
loadProgress(res.data.questions)
} else {
message.error('加载考试失败')
navigate('/exam/management')
}
} catch (error: any) {
message.error(error.response?.data?.message || '加载考试失败')
navigate('/exam/management')
} finally {
setLoading(false)
}
}
loadExam()
}, [examId, navigate])
// 按题型分组题目
const groupQuestionsByType = (questions: Question[]) => {
const grouped: Record<string, Question[]> = {}
questions.forEach((q) => {
if (!grouped[q.type]) {
grouped[q.type] = []
}
grouped[q.type].push(q)
})
return grouped
}
// 保存答题进度到 localStorage
const saveProgress = () => {
if (!examId) return
const values = form.getFieldsValue()
const progress = {
answers: values,
essayChoice,
timestamp: Date.now(),
}
localStorage.setItem(`exam_progress_${examId}`, JSON.stringify(progress))
}
// 从 localStorage 恢复答题进度
const loadProgress = (_questions: Question[]) => {
if (!examId) return
const saved = localStorage.getItem(`exam_progress_${examId}`)
if (saved) {
try {
const progress = JSON.parse(saved)
// 恢复表单值
if (progress.answers) {
form.setFieldsValue(progress.answers)
}
// 恢复论述题选择
if (progress.essayChoice) {
setEssayChoice(progress.essayChoice)
}
message.success('已恢复上次答题进度')
} catch (e) {
console.error('恢复进度失败', e)
}
}
}
// 监听表单变化,自动保存进度
useEffect(() => {
const timer = setInterval(() => {
saveProgress()
}, 5000) // 每5秒自动保存一次
return () => clearInterval(timer)
}, [examId, essayChoice])
// 提交考试
const handleSubmit = async () => {
// 验证论述题选择
if (!essayChoice) {
message.warning('请选择要作答的论述题')
return
}
// 验证表单
try {
await form.validateFields()
} catch (error) {
message.warning('请完成所有题目的作答')
return
}
Modal.confirm({
title: '确认提交',
icon: <ExclamationCircleOutlined />,
content: '提交后将无法修改答案,确定要提交吗?',
okText: '确定提交',
cancelText: '再检查一下',
onOk: async () => {
setSubmitting(true)
try {
const values = form.getFieldsValue()
const answers: Record<string, any> = {}
// 转换答案格式
Object.keys(values).forEach((key) => {
const questionId = key.replace('question_', '')
answers[questionId] = values[key]
})
const submitData: SubmitExamRequest = {
answers,
essay_choice: essayChoice!,
}
const res = await examApi.submitExam(Number(examId), submitData)
if (res.success) {
message.success('提交成功')
// 清除进度
localStorage.removeItem(`exam_progress_${examId}`)
// 跳转到成绩页,传递提交结果
navigate(`/exam/result/${res.data?.record_id}`, {
state: { submitResult: res.data }
})
} else {
message.error(res.message || '提交失败')
}
} catch (error: any) {
message.error(error.response?.data?.message || '提交失败,请稍后重试')
} finally {
setSubmitting(false)
}
},
})
}
// 渲染填空题
const renderFillInBlank = (question: Question, index: number) => {
// 获取答案数组
const answers = question.answer && Array.isArray(question.answer)
? question.answer
: question.answer
? [String(question.answer)]
: []
// 计算实际需要填空的数量
const blankCount = question.content ? (question.content.match(/\*{4,}/g) || []).length : answers.length
// 处理题目内容,将 **** 替换为输入框占位符
const renderQuestionContent = (content: string) => {
if (!content) return content
let processedContent = content
let inputIndex = 0
// 将所有的 **** 替换为输入框标识
processedContent = processedContent.replace(/\*{4,}/g, () => {
const id = `blank_${inputIndex}`
inputIndex++
return `[INPUT:${id}]`
})
return processedContent
}
// 渲染包含输入框的题目内容
const renderContentWithInputs = () => {
const processedContent = renderQuestionContent(question.content || '')
const parts = processedContent.split(/\[INPUT:([^\]]+)\]/)
return (
<div>
{parts.map((part, index) => {
if (index % 2 === 1) {
// 这是一个输入框标识符
const inputIndex = Math.floor(index / 2)
return (
<Input
key={`input_${inputIndex}`}
style={{
width: 120,
margin: '0 4px',
display: 'inline-block',
verticalAlign: 'middle'
}}
placeholder={`${inputIndex + 1}`}
onChange={(e) => {
const currentValue = form.getFieldValue(`question_${question.id}`) || []
currentValue[inputIndex] = e.target.value
form.setFieldValue(`question_${question.id}`, currentValue)
}}
/>
)
} else {
// 这是普通文本
return part
}
})}
</div>
)
}
return (
<Card className={styles.questionCard} key={question.id}>
<div className={styles.questionHeader}>
<Text strong style={{ marginBottom: 12, display: 'block' }}>
{index + 1}.
</Text>
<div style={{ marginBottom: 16, lineHeight: '1.8' }}>
{renderContentWithInputs()}
</div>
</div>
<Form.Item
name={`question_${question.id}`}
rules={[{ required: true, message: '请填写所有空格' }]}
initialValue={Array(blankCount).fill('')}
style={{ display: 'none' }} // 隐藏原来的表单项,因为我们用内联输入框了
>
<Input />
</Form.Item>
</Card>
)
}
// 渲染判断题
const renderTrueFalse = (question: Question, index: number) => {
return (
<Card className={styles.questionCard} key={question.id}>
<div className={styles.questionHeader}>
<Text strong>
{index + 1}. {question.content}
</Text>
</div>
<Form.Item
name={`question_${question.id}`}
rules={[{ required: true, message: '请选择答案' }]}
>
<Radio.Group>
<Space direction="vertical">
<Radio value="true"></Radio>
<Radio value="false"></Radio>
</Space>
</Radio.Group>
</Form.Item>
</Card>
)
}
// 渲染单选题
const renderMultipleChoice = (question: Question, index: number) => {
return (
<Card className={styles.questionCard} key={question.id}>
<div className={styles.questionHeader}>
<Text strong>
{index + 1}. {question.content}
</Text>
</div>
<Form.Item
name={`question_${question.id}`}
rules={[{ required: true, message: '请选择答案' }]}
>
<Radio.Group>
<Space direction="vertical">
{(question.options || []).map((opt) => (
<Radio key={opt.key} value={opt.key}>
{opt.key}. {opt.value}
</Radio>
))}
</Space>
</Radio.Group>
</Form.Item>
</Card>
)
}
// 渲染多选题
const renderMultipleSelection = (question: Question, index: number) => {
return (
<Card className={styles.questionCard} key={question.id}>
<div className={styles.questionHeader}>
<Text strong>
{index + 1}. {question.content}
</Text>
</div>
<Form.Item
name={`question_${question.id}`}
rules={[{ required: true, message: '请选择答案' }]}
>
<Checkbox.Group>
<Space direction="vertical">
{(question.options || []).map((opt) => (
<Checkbox key={opt.key} value={opt.key}>
{opt.key}. {opt.value}
</Checkbox>
))}
</Space>
</Checkbox.Group>
</Form.Item>
</Card>
)
}
// 渲染简答题
const renderShortAnswer = (question: Question, index: number) => {
return (
<Card className={styles.questionCard} key={question.id}>
<div className={styles.questionHeader}>
<Text strong>
{index + 1}. {question.content}
</Text>
<Text type="secondary" style={{ fontSize: '13px', marginLeft: '8px' }}>
</Text>
</div>
<Form.Item name={`question_${question.id}`}>
<TextArea rows={4} placeholder="请输入答案" />
</Form.Item>
</Card>
)
}
// 渲染论述题
const renderEssay = (questions: Question[]) => {
const ordinaryEssay = questions.find((q) => q.type === 'ordinary-essay')
const managementEssay = questions.find((q) => q.type === 'management-essay')
if (!ordinaryEssay || !managementEssay) return null
return (
<div className={styles.essaySection}>
<Card className={styles.essayChoiceCard}>
<Title level={4}></Title>
<Paragraph type="secondary">
</Paragraph>
<Radio.Group
value={essayChoice}
onChange={(e) => setEssayChoice(e.target.value)}
className={styles.essayRadioGroup}
>
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<Card
className={`${styles.essayOptionCard} ${
essayChoice === 'ordinary' ? styles.selected : ''
}`}
>
<Radio value="ordinary">
<Text strong></Text>
</Radio>
<div className={styles.essayContent}>
<Paragraph>{ordinaryEssay.content}</Paragraph>
</div>
</Card>
<Card
className={`${styles.essayOptionCard} ${
essayChoice === 'management' ? styles.selected : ''
}`}
>
<Radio value="management">
<Text strong></Text>
</Radio>
<div className={styles.essayContent}>
<Paragraph>{managementEssay.content}</Paragraph>
</div>
</Card>
</Space>
</Radio.Group>
</Card>
{essayChoice && (
<Card className={styles.questionCard}>
<div className={styles.questionHeader}>
<Text strong>
{essayChoice === 'ordinary'
? '普通涉密人员论述题'
: '保密管理人员论述题'}
</Text>
</div>
<Paragraph>{essayChoice === 'ordinary' ? ordinaryEssay.content : managementEssay.content}</Paragraph>
<Form.Item
name={`question_${essayChoice === 'ordinary' ? ordinaryEssay.id : managementEssay.id}`}
rules={[{ required: true, message: '请完成论述题作答' }]}
>
<TextArea rows={8} placeholder="请输入您的答案建议300字以上" showCount />
</Form.Item>
</Card>
)}
</div>
)
}
// 渲染题目组
const renderQuestionGroup = (type: string, questions: Question[]) => {
let startIndex = 0
// 计算该题型的起始序号
Object.keys(groupedQuestions)
.filter((t) => TYPE_ORDER[t] < TYPE_ORDER[type])
.forEach((t) => {
startIndex += groupedQuestions[t].length
})
return (
<div key={type} className={styles.questionGroup}>
<Divider orientation="left">
<Title level={3}>{TYPE_NAME[type]}</Title>
</Divider>
{questions.map((question, index) => {
switch (type) {
case 'fill-in-blank':
return renderFillInBlank(question, startIndex + index)
case 'true-false':
return renderTrueFalse(question, startIndex + index)
case 'multiple-choice':
return renderMultipleChoice(question, startIndex + index)
case 'multiple-selection':
return renderMultipleSelection(question, startIndex + index)
case 'short-answer':
return renderShortAnswer(question, startIndex + index)
default:
return null
}
})}
</div>
)
}
if (loading) {
return (
<div className={styles.loadingContainer}>
<Spin size="large" />
<Text style={{ marginTop: 16 }}>...</Text>
</div>
)
}
if (!examData) {
return null
}
// 获取论述题
const essayQuestions = [
...(groupedQuestions['ordinary-essay'] || []),
...(groupedQuestions['management-essay'] || []),
]
return (
<div className={styles.container}>
{/* 固定顶栏 */}
<div className={styles.fixedTopBar}>
<div className={styles.topBarContent}>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/exam/management')}
className={styles.backButton}
type="text"
>
</Button>
<Title level={3} className={styles.title}>
线
</Title>
<div style={{ width: 80 }} />
</div>
</div>
{/* 主内容区 */}
<div className={styles.content}>
<Card className={styles.examInfoCard}>
<Title level={4}></Title>
<ul>
<li></li>
<li></li>
<li></li>
<li></li>
<li></li>
</ul>
</Card>
<Form form={form} layout="vertical">
{/* 按题型顺序渲染题目 */}
{Object.keys(groupedQuestions)
.filter((type) => type !== 'ordinary-essay' && type !== 'management-essay')
.sort((a, b) => TYPE_ORDER[a] - TYPE_ORDER[b])
.map((type) => renderQuestionGroup(type, groupedQuestions[type]))}
{/* 渲染论述题(二选一) */}
{essayQuestions.length > 0 && (
<div className={styles.questionGroup}>
<Divider orientation="left">
<Title level={3}></Title>
</Divider>
{renderEssay(essayQuestions)}
</div>
)}
{/* 提交按钮 */}
<div className={styles.submitSection}>
<Button
type="primary"
size="large"
icon={<CheckCircleOutlined />}
onClick={handleSubmit}
loading={submitting}
className={styles.submitButton}
>
</Button>
</div>
</Form>
</div>
</div>
)
}
export default ExamOnline

View File

@ -1,580 +0,0 @@
.container {
min-height: 100vh;
background: #fff;
padding: 20px;
}
// 操作按钮区(打印时隐藏)
.actionBar {
position: sticky;
top: 0;
z-index: 1000;
background: #fff;
padding: 16px 0;
margin-bottom: 24px;
border-bottom: 1px solid #e5e5ea;
display: flex;
justify-content: space-between;
align-items: center;
.backButton {
color: #007aff;
border-color: #007aff;
&:hover {
color: #0051d5;
border-color: #0051d5;
}
}
}
// 加载状态
.loadingContainer {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
min-height: 100vh;
background: #fafafa;
}
// 打印内容区
.printContent {
max-width: 210mm; // A4纸宽度
margin: 0 auto;
background: #fff;
padding: 0;
}
// 试卷头部
.paperHeader {
text-align: center;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 2px solid #1d1d1f;
.paperTitle {
margin: 0 0 16px 0 !important;
color: #1d1d1f !important;
font-weight: 700 !important;
font-size: 28px !important;
}
.examInfo {
display: flex;
justify-content: space-around;
margin-top: 16px;
.infoItem {
font-size: 16px;
color: #1d1d1f;
font-weight: 500;
}
}
}
// 考试说明卡片
.instructionCard {
margin-bottom: 24px;
border-radius: 8px;
border: 1px solid #d1d1d6;
:global(.ant-card-body) {
padding: 20px;
}
h4 {
margin-bottom: 12px;
color: #1d1d1f;
font-weight: 600;
font-size: 16px;
}
ul {
margin: 0;
padding-left: 20px;
color: #1d1d1f;
line-height: 1.8;
li {
margin-bottom: 6px;
font-size: 14px;
}
}
}
// 题目组
.questionGroup {
margin-bottom: 32px;
page-break-inside: avoid;
.groupHeader {
display: flex;
align-items: baseline;
margin-bottom: 16px;
padding-bottom: 8px;
border-bottom: 1px solid #d1d1d6;
.groupTitle {
font-size: 18px;
color: #1d1d1f;
font-weight: 700;
}
.groupScore {
font-size: 14px;
margin-left: 8px;
color: #6e6e73;
}
}
.questionsList {
padding-left: 0;
}
}
// 题目项
.questionItem {
margin-bottom: 24px;
page-break-inside: avoid;
.questionContent {
margin-bottom: 12px;
line-height: 1.8;
span {
font-size: 15px;
color: #1d1d1f;
}
}
.optionsList {
margin: 12px 0;
padding-left: 20px;
.optionItem {
margin-bottom: 8px;
line-height: 1.6;
font-size: 14px;
color: #1d1d1f;
}
}
.answerArea {
margin-top: 12px;
padding: 8px 0;
span {
font-size: 14px;
color: #1d1d1f;
}
.blankLine {
margin-bottom: 8px;
line-height: 2;
font-size: 14px;
}
.answerLines {
margin-top: 8px;
.answerLine {
line-height: 2;
margin-bottom: 4px;
font-size: 14px;
color: #1d1d1f;
}
}
.essayAnswer {
:global(.ant-typography) {
margin-bottom: 8px;
line-height: 1.8;
font-size: 14px;
color: #1d1d1f;
}
}
}
}
// 打印样式
@media print {
// 隐藏不需要打印的元素
.noPrint,
:global(.noPrint) {
display: none !important;
}
// A4纸张设置
@page {
size: A4;
margin: 1cm;
}
.container {
background: #fff;
padding: 0;
}
.printContent {
max-width: 100%;
margin: 0;
padding: 0;
}
.paperHeader {
margin-bottom: 6px;
padding-bottom: 4px;
// 防止试卷标题和考试说明分页
page-break-after: avoid;
page-break-inside: avoid;
.paperTitle {
font-size: 16pt !important;
margin-bottom: 4px !important;
}
.examInfo {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 4px;
width: 100%;
font-family: 'SimSun', '宋体', serif;
.infoItem {
font-size: 9pt;
white-space: nowrap;
flex: 1;
text-align: center;
}
}
}
.instructionCard {
margin-bottom: 6px;
border: 1px solid #000;
// 确保圆角不被裁剪
border-radius: 8px;
overflow: visible;
// 添加一些内边距确保圆角有空间显示
padding: 2px;
// 防止考试说明和填空题分页
page-break-after: avoid;
page-break-inside: avoid;
:global(.ant-card-body) {
padding: 6px;
}
h4 {
font-size: 10pt;
margin-bottom: 2px;
}
ul {
margin: 0;
padding-left: 12px;
li {
font-size: 8pt;
margin-bottom: 1px;
line-height: 1.2;
}
}
}
.questionGroup {
margin-bottom: 8px;
// 防止题型组内部分页
page-break-inside: avoid;
// 尽量让下一个题型紧接着显示
page-break-after: avoid;
.groupHeader {
display: flex;
align-items: baseline;
margin-bottom: 4px;
padding-bottom: 2px;
// 确保题型标题和第一道题在同一页
page-break-after: avoid;
.groupTitle {
font-size: 12pt;
}
.groupScore {
font-size: 10pt;
margin-left: 8px;
}
}
}
.questionItem {
margin-bottom: 6px;
// 防止题目内部分页,保持题目完整性
page-break-inside: avoid;
.questionContent {
margin-bottom: 3px;
line-height: 1.3;
span {
font-size: 10pt;
font-family: 'SimSun', '宋体', serif !important;
font-weight: normal !important;
}
}
.optionsList {
margin: 4px 0;
padding-left: 14px;
.optionItem {
margin-bottom: 1px;
font-size: 9pt;
font-family: 'SimSun', '宋体', serif !important;
font-weight: normal !important;
line-height: 1.2;
}
}
.answerArea {
margin-top: 4px;
span {
font-size: 9pt;
font-family: 'SimSun', '宋体', serif !important;
font-weight: normal !important;
}
.blankLine {
font-size: 11pt;
font-family: 'SimSun', '宋体', serif !important;
font-weight: normal !important;
}
.answerLines {
.answerLine {
font-size: 11pt;
font-family: 'SimSun', '宋体', serif !important;
font-weight: normal !important;
}
}
.essayAnswer {
:global(.ant-typography) {
font-size: 11pt;
font-family: 'SimSun', '宋体', serif !important;
font-weight: normal !important;
}
}
}
}
// 强制分页
.pageBreak {
page-break-after: always;
}
// 移除分页限制,允许更紧密的排版
// 黑白打印优化和字体设置
* {
color: #000 !important;
background: #fff !important;
box-shadow: none !important;
text-shadow: none !important;
font-family: 'SimSun', '宋体', serif !important;
font-weight: normal !important;
}
// 保留边框
.paperHeader,
.instructionCard,
.groupHeader {
border-color: #000 !important;
}
}
// 响应式设计 - 移动端
@media (max-width: 768px) {
.container {
padding: 12px;
}
.actionBar {
padding: 12px 0;
flex-direction: row;
justify-content: space-between;
align-items: center;
:global(.ant-space) {
width: auto;
button {
flex: none;
width: auto;
height: 32px;
padding: 0 12px;
font-size: 12px;
}
}
}
.printContent {
max-width: 100%;
}
.paperHeader {
.paperTitle {
font-size: 22px !important;
}
.examInfo {
flex-direction: column;
gap: 8px;
.infoItem {
font-size: 14px;
}
}
}
.instructionCard {
:global(.ant-card-body) {
padding: 16px;
}
h4 {
font-size: 14px;
}
ul {
li {
font-size: 13px;
}
}
}
.questionGroup {
.groupHeader {
flex-direction: column;
align-items: flex-start;
gap: 4px;
.groupTitle {
font-size: 16px;
}
.groupScore {
font-size: 13px;
}
}
}
.questionItem {
.questionContent {
span {
font-size: 14px;
}
}
.optionsList {
padding-left: 16px;
.optionItem {
font-size: 13px;
}
}
.answerArea {
span {
font-size: 13px;
}
.blankLine {
font-size: 13px;
}
.answerLines {
.answerLine {
font-size: 13px;
}
}
.essayAnswer {
:global(.ant-typography) {
font-size: 13px;
}
}
}
}
}
// 响应式设计 - 平板
@media (min-width: 769px) and (max-width: 1024px) {
.container {
padding: 16px;
}
.printContent {
max-width: 190mm;
}
.paperHeader {
.paperTitle {
font-size: 26px !important;
}
.examInfo {
.infoItem {
font-size: 15px;
}
}
}
.questionGroup {
.groupHeader {
.groupTitle {
font-size: 17px;
}
}
}
.questionItem {
.questionContent {
span {
font-size: 14px;
}
}
}
}
// 响应式设计 - PC端
@media (min-width: 1025px) {
.container {
padding: 24px;
}
.printContent {
max-width: 210mm;
padding: 0 20px;
}
.actionBar {
padding: 20px 0;
}
.paperHeader {
.paperTitle {
font-size: 28px !important;
}
.examInfo {
.infoItem {
font-size: 16px;
}
}
}
}

View File

@ -1,484 +0,0 @@
import React, { useState, useEffect } from 'react'
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
import { Card, Button, Typography, message, Spin } from 'antd'
import { ArrowLeftOutlined, FileTextOutlined } from '@ant-design/icons'
import * as examApi from '../api/exam'
import type { Question } from '../types/question'
import type { GetExamResponse } from '../types/exam'
import styles from './ExamPrint.module.less'
const { Title, Paragraph, Text } = Typography
// 日期格式化函数
const formatDate = (date: Date): string => {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
// 题型顺序映射
const TYPE_ORDER: Record<string, number> = {
'fill-in-blank': 1,
'true-false': 2,
'multiple-choice': 3,
'multiple-selection': 4,
'short-answer': 5,
'ordinary-essay': 6,
'management-essay': 6,
}
// 题型名称映射
const TYPE_NAME: Record<string, string> = {
'fill-in-blank': '一、填空题',
'true-false': '二、判断题',
'multiple-choice': '三、单选题',
'multiple-selection': '四、多选题',
'short-answer': '五、简答题',
'ordinary-essay': '六、论述题',
'management-essay': '六、论述题',
}
// 题型分值映射
const TYPE_SCORE: Record<string, number> = {
'fill-in-blank': 2.0,
'true-false': 1.0,
'multiple-choice': 1.0,
'multiple-selection': 2.0,
'short-answer': 0, // 不计分
'ordinary-essay': 10.0,
'management-essay': 10.0,
}
const ExamPrint: React.FC = () => {
const { examId } = useParams<{ examId: string }>()
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const showAnswer = searchParams.get('show_answer') === 'true'
const [loading, setLoading] = useState(false)
const [examData, setExamData] = useState<GetExamResponse | null>(null)
const [groupedQuestions, setGroupedQuestions] = useState<Record<string, Question[]>>({})
// 加载考试详情
useEffect(() => {
if (!examId) {
message.error('考试ID不存在')
navigate('/exam/management')
return
}
const loadExam = async () => {
setLoading(true)
try {
const res = await examApi.getExam(Number(examId), showAnswer)
if (res.success && res.data) {
setExamData(res.data)
// 按题型分组
const grouped = groupQuestionsByType(res.data.questions)
setGroupedQuestions(grouped)
} else {
message.error('加载考试失败')
navigate('/exam/management')
}
} catch (error: any) {
message.error(error.response?.data?.message || '加载考试失败')
navigate('/exam/management')
} finally {
setLoading(false)
}
}
loadExam()
}, [examId, showAnswer, navigate])
// 按题型分组题目
const groupQuestionsByType = (questions: Question[]) => {
const grouped: Record<string, Question[]> = {}
questions.forEach((q) => {
if (!grouped[q.type]) {
grouped[q.type] = []
}
grouped[q.type].push(q)
})
return grouped
}
// 打印试卷
const handlePrint = () => {
window.print()
}
// 格式化答案显示
const formatAnswer = (question: Question): string => {
if (!question.answer) return ''
switch (question.type) {
case 'fill-in-blank':
if (Array.isArray(question.answer)) {
return question.answer.join('、')
}
return String(question.answer)
case 'true-false':
return question.answer === 'true' || question.answer === true ? '正确' : '错误'
case 'multiple-choice':
return String(question.answer)
case 'multiple-selection':
if (Array.isArray(question.answer)) {
return question.answer.sort().join('')
}
return String(question.answer)
case 'short-answer':
case 'ordinary-essay':
case 'management-essay':
return String(question.answer)
default:
return String(question.answer)
}
}
// 渲染填空题
const renderFillInBlank = (question: Question, index: number) => {
// 获取答案数组
const answers = question.answer && Array.isArray(question.answer)
? question.answer
: question.answer
? [String(question.answer)]
: []
// 计算下划线字符数量
const calculateUnderscoreCount = (blankIndex: number, totalBlanks: number) => {
// 优先使用 answer_lengths 字段(在 show_answer=false 时也会返回)
if (question.answer_lengths && question.answer_lengths[blankIndex] !== undefined) {
const answerLength = question.answer_lengths[blankIndex]
// 最少8个下划线字符
return Math.max(answerLength, 8)
}
// 如果有实际的答案数据,使用答案长度
if (answers[blankIndex]) {
const answerText = String(answers[blankIndex])
// 最少8个下划线字符
return Math.max(answerText.length, 8)
}
// 如果没有任何答案数据,使用默认策略
if (totalBlanks === 1) {
return 8 // 单个填空8个下划线字符
} else {
// 多个填空8-12个下划线字符循环
const counts = [8, 10, 12, 9, 11]
return counts[blankIndex % counts.length]
}
}
// 处理题目内容,将 **** 替换为下划线字符
const renderQuestionContent = (content: string) => {
if (!content) return content
let processedContent = content
let blankIndex = 0
// 先计算出总共有多少个填空
const totalBlanks = (content.match(/\*{4,}/g) || []).length
// 将所有的 **** 替换为下划线字符
processedContent = processedContent.replace(/\*{4,}/g, () => {
const underscoreCount = calculateUnderscoreCount(blankIndex, totalBlanks)
blankIndex++
return '_'.repeat(underscoreCount)
})
return processedContent
}
return (
<div key={question.id} className={styles.questionItem}>
<div className={styles.questionContent}>
<Text>
{index + 1}.{' '}
</Text>
<span
dangerouslySetInnerHTML={{
__html: renderQuestionContent(question.content || '')
}}
/>
</div>
{showAnswer && answers.length > 0 && (
<div className={styles.answerArea}>
<Text>{formatAnswer(question)}</Text>
</div>
)}
</div>
)
}
// 渲染判断题
const renderTrueFalse = (question: Question, index: number) => {
return (
<div key={question.id} className={styles.questionItem}>
<div className={styles.questionContent}>
<Text>
{index + 1}. {question.content}
</Text>
</div>
</div>
)
}
// 渲染单选题
const renderMultipleChoice = (question: Question, index: number) => {
return (
<div key={question.id} className={styles.questionItem}>
<div className={styles.questionContent}>
<Text>
{index + 1}. {question.content}
</Text>
</div>
<div className={styles.optionsList}>
{(question.options || []).map((opt) => (
<div key={opt.key} className={styles.optionItem}>
{opt.key}. {opt.value}
</div>
))}
</div>
</div>
)
}
// 渲染多选题
const renderMultipleSelection = (question: Question, index: number) => {
return (
<div key={question.id} className={styles.questionItem}>
<div className={styles.questionContent}>
<Text>
{index + 1}. {question.content}
</Text>
</div>
<div className={styles.optionsList}>
{(question.options || []).map((opt) => (
<div key={opt.key} className={styles.optionItem}>
{opt.key}. {opt.value}
</div>
))}
</div>
</div>
)
}
// 渲染简答题
const renderShortAnswer = (question: Question, index: number) => {
return (
<div key={question.id} className={styles.questionItem}>
<div className={styles.questionContent}>
<Text>
{index + 1}. {question.content}
</Text>
</div>
<div className={styles.answerArea}>
{showAnswer ? (
<div className={styles.essayAnswer}>
<Text></Text>
<Paragraph>{formatAnswer(question)}</Paragraph>
</div>
) : (
<div className={styles.answerLines}>
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className={styles.answerLine} style={{ height: '30px' }}>
&nbsp;
</div>
))}
</div>
)}
</div>
</div>
)
}
// 渲染论述题
const renderEssay = (question: Question, index: number) => {
const getUserTypeHint = () => {
if (question.type === 'ordinary-essay') {
return '(普通涉密人员作答)'
} else if (question.type === 'management-essay') {
return '(保密管理人员作答)'
}
return ''
}
return (
<div key={question.id} className={styles.questionItem}>
<div className={styles.questionContent}>
<Text>
{index + 1}. {question.content}
</Text>
<Text type="secondary" style={{ fontSize: '12px', marginLeft: '8px' }}>
{getUserTypeHint()}
</Text>
</div>
<div className={styles.answerArea}>
{showAnswer ? (
<div className={styles.essayAnswer}>
<Text></Text>
<Paragraph>{formatAnswer(question)}</Paragraph>
</div>
) : (
<div className={styles.answerLines}>
{Array.from({ length: 10 }).map((_, i) => (
<div key={i} className={styles.answerLine} style={{ height: '35px' }}>
&nbsp;
</div>
))}
</div>
)}
</div>
</div>
)
}
// 渲染题目组
const renderQuestionGroup = (type: string, questions: Question[]) => {
// 每个题型分类都从1开始编号
const startIndex = 0
// 计算该题型总分
const totalScore = questions.length * TYPE_SCORE[type]
return (
<div key={type} className={styles.questionGroup}>
<div className={styles.groupHeader}>
<span className={styles.groupTitle}>
{TYPE_NAME[type]}
{TYPE_SCORE[type] > 0 && (
<span className={styles.groupScore} style={{ marginLeft: '8px' }}>
{questions.length}{TYPE_SCORE[type]}{totalScore}
</span>
)}
</span>
</div>
<div className={styles.questionsList}>
{questions.map((question, index) => {
switch (type) {
case 'fill-in-blank':
return renderFillInBlank(question, startIndex + index)
case 'true-false':
return renderTrueFalse(question, startIndex + index)
case 'multiple-choice':
return renderMultipleChoice(question, startIndex + index)
case 'multiple-selection':
return renderMultipleSelection(question, startIndex + index)
case 'short-answer':
return renderShortAnswer(question, startIndex + index)
case 'ordinary-essay':
case 'management-essay':
return renderEssay(question, startIndex + index)
default:
return null
}
})}
</div>
</div>
)
}
if (loading) {
return (
<div className={styles.loadingContainer}>
<Spin size="large" />
<Text style={{ marginTop: 16 }}>...</Text>
</div>
)
}
if (!examData) {
return null
}
// 获取论述题(合并普通和管理两类)
const essayQuestions = [
...(groupedQuestions['ordinary-essay'] || []),
...(groupedQuestions['management-essay'] || []),
]
return (
<div className={styles.container}>
{/* 操作按钮区 - 打印时隐藏 */}
<div className={`${styles.actionBar} noPrint`}>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/exam/management')}
className={styles.backButton}
>
</Button>
<Button
type="primary"
icon={<FileTextOutlined />}
onClick={handlePrint}
>
</Button>
</div>
{/* 打印内容区 */}
<div className={styles.printContent}>
{/* 试卷头部 */}
<div className={styles.paperHeader}>
<Title level={2} className={styles.paperTitle}>
{showAnswer ? '(答案)' : ''}
</Title>
<div style={{ fontFamily: 'SimSun, 宋体, serif', fontSize: '9pt' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', width: '100%' }}>
<span style={{ flex: '0 0 auto', textAlign: 'left' }}>{formatDate(new Date())}</span>
<span style={{ flex: '0 0 auto', textAlign: 'center' }}>________________</span>
<span style={{ flex: '0 0 auto', textAlign: 'center' }}>________________</span>
<span style={{ flex: '0 0 auto', textAlign: 'right' }}>________________</span>
</div>
</div>
</div>
{/* 考试说明 */}
{!showAnswer && (
<Card className={styles.instructionCard}>
<Title level={4}></Title>
<ul>
<li>10060</li>
<li></li>
<li>21</li>
</ul>
</Card>
)}
{/* 按题型渲染题目 */}
{Object.keys(groupedQuestions)
.filter((type) => type !== 'ordinary-essay' && type !== 'management-essay')
.sort((a, b) => TYPE_ORDER[a] - TYPE_ORDER[b])
.map((type) => renderQuestionGroup(type, groupedQuestions[type]))}
{/* 论述题部分 */}
{essayQuestions.length > 0 && (
<div className={styles.questionGroup}>
<div className={styles.groupHeader}>
<span className={styles.groupTitle}>
{TYPE_NAME['ordinary-essay']}
<span className={styles.groupScore} style={{ marginLeft: '8px' }}>
2110
</span>
</span>
</div>
<div className={styles.questionsList}>
{essayQuestions.map((question, index) => renderEssay(question, index))}
</div>
</div>
)}
</div>
</div>
)
}
export default ExamPrint

View File

@ -1,221 +0,0 @@
.loadingContainer {
display: flex;
justify-content: center;
align-items: center;
min-height: 400px;
}
.container {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
:global(.ant-result) {
padding: 40px 32px;
}
}
.statsCard {
margin-bottom: 20px;
.passMark {
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid #f0f0f0;
text-align: center;
}
}
.typeScoreCard {
margin-bottom: 20px;
.typeScoreItem {
padding: 16px;
background: #fafafa;
border-radius: 8px;
transition: all 0.3s ease;
&:hover {
background: #f0f5ff;
box-shadow: 0 2px 8px rgba(24, 144, 255, 0.15);
}
.typeScoreHeader {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 12px;
}
.typeScoreContent {
display: flex;
align-items: baseline;
margin-bottom: 12px;
}
.typeScoreProgress {
width: 100%;
height: 8px;
background: #e8e8e8;
border-radius: 4px;
overflow: hidden;
.typeScoreBar {
height: 100%;
border-radius: 4px;
transition: width 0.6s ease;
}
}
}
}
.detailCard {
margin-bottom: 20px;
.panelHeader {
display: flex;
align-items: center;
gap: 8px;
}
.questionItem {
.questionNumber {
display: inline-block;
background: #1890ff;
color: white;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
margin-bottom: 8px;
}
.answerDetail {
.questionContent {
padding: 12px;
background: #fafafa;
border-radius: 4px;
margin-bottom: 16px;
}
.answerSection {
padding-left: 12px;
.answerItem {
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 8px;
.correct {
color: #52c41a;
font-weight: 500;
}
.incorrect {
color: #ff4d4f;
font-weight: 500;
}
}
.aiGrading {
margin-top: 16px;
padding: 12px;
background: #f6ffed;
border: 1px solid #b7eb8f;
border-radius: 4px;
}
}
}
}
}
.actionsCard {
text-align: center;
}
// 响应式适配
@media (max-width: 768px) {
.container {
padding: 12px;
:global(.ant-result) {
padding: 24px 16px;
:global(.ant-result-title) {
font-size: 18px;
}
}
}
.statsCard {
:global(.ant-col) {
margin-bottom: 16px;
}
:global(.ant-statistic) {
:global(.ant-statistic-title) {
font-size: 13px;
}
:global(.ant-statistic-content) {
font-size: 20px;
}
}
}
.typeScoreCard {
.typeScoreItem {
padding: 12px;
.typeScoreHeader {
gap: 2px;
margin-bottom: 8px;
:global(.ant-typography) {
font-size: 14px !important;
}
}
.typeScoreContent {
margin-bottom: 8px;
:global(.ant-typography) {
&:first-child {
font-size: 22px !important;
}
&:last-child {
font-size: 14px !important;
}
}
}
.typeScoreProgress {
height: 6px;
}
}
}
.detailCard {
.questionItem {
.answerSection {
padding-left: 0;
.answerItem {
flex-wrap: wrap;
font-size: 14px;
}
}
}
}
.actionsCard {
:global(.ant-space) {
width: 100%;
flex-direction: column;
button {
width: 100%;
}
}
}
}

View File

@ -1,588 +0,0 @@
import React, { useState, useEffect } from "react";
import { useParams, useNavigate } from "react-router-dom";
import {
Card,
Result,
Button,
Typography,
Tag,
Space,
Spin,
message,
Row,
Col,
Statistic,
Divider,
} from "antd";
import {
CheckCircleOutlined,
CloseCircleOutlined,
TrophyOutlined,
ClockCircleOutlined,
FileTextOutlined,
LeftOutlined,
} from "@ant-design/icons";
import * as examApi from "../api/exam";
import type { ExamRecordResponse, ExamAnswer } from "../types/exam";
import type { Question } from "../types/question";
import styles from "./ExamResultNew.module.less";
const { Text, Paragraph } = Typography;
// 题型名称映射
const TYPE_NAME: Record<string, string> = {
"fill-in-blank": "填空题",
"true-false": "判断题",
"multiple-choice": "单选题",
"multiple-selection": "多选题",
"short-answer": "简答题",
"ordinary-essay": "论述题",
"management-essay": "论述题",
essay: "论述题", // 合并后的论述题类型
};
// 题型顺序定义
const TYPE_ORDER: Record<string, number> = {
"fill-in-blank": 1,
"true-false": 2,
"multiple-choice": 3,
"multiple-selection": 4,
"short-answer": 5,
"ordinary-essay": 6,
"management-essay": 6,
essay: 6, // 合并后的论述题顺序
};
const ExamResultNew: React.FC = () => {
const { recordId } = useParams<{ recordId: string }>();
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [data, setData] = useState<ExamRecordResponse | null>(null);
const [questions, setQuestions] = useState<Question[]>([]);
useEffect(() => {
if (!recordId) {
message.error("参数错误");
navigate("/exam/management");
return;
}
loadResult();
}, [recordId]);
const loadResult = async () => {
setLoading(true);
try {
const recordRes = await examApi.getExamRecord(Number(recordId));
if (recordRes.success && recordRes.data) {
setData(recordRes.data);
// 获取试卷详情
if (recordRes.data.record.exam?.id) {
const examRes = await examApi.getExamDetail(
recordRes.data.record.exam.id
);
if (examRes.success && examRes.data) {
setQuestions(examRes.data.questions);
}
}
} else {
message.error("加载结果失败");
navigate("/exam/management");
}
} catch (error: any) {
message.error(error.response?.data?.message || "加载结果失败");
navigate("/exam/management");
} finally {
setLoading(false);
}
};
if (loading) {
return (
<div className={styles.loadingContainer}>
<Spin size="large" />
</div>
);
}
if (!data) {
return null;
}
const { record, answers } = data;
const isPassed = record.is_passed;
// 总分统一为100分
const scorePercent = record.score;
// 构建答案映射
const answerMap = new Map<number, ExamAnswer>();
answers.forEach((ans) => {
answerMap.set(ans.question_id, ans);
});
// 统计正确率
const correctCount = answers.filter((a) => a.is_correct).length;
const totalCount = answers.length;
const correctRate = totalCount > 0 ? (correctCount / totalCount) * 100 : 0;
// 格式化时间
const formatTime = (seconds: number) => {
const totalSeconds = Math.floor(seconds); // 确保是整数
const minutes = Math.floor(totalSeconds / 60);
const secs = totalSeconds % 60;
return `${minutes}${secs}`;
};
// 渲染答案详情
const renderAnswerDetail = (question: Question, answer: ExamAnswer) => {
const isCorrect = answer.is_correct;
return (
<div className={styles.answerDetail}>
{/* 题目内容 - 填空题特殊处理 */}
<div className={styles.questionContent}>
{question.type === "fill-in-blank" ? (
<Paragraph strong style={{ fontSize: 16, marginBottom: 16 }}>
{renderFillInBlankQuestion(question.content)}
</Paragraph>
) : (
<Paragraph strong style={{ fontSize: 16, marginBottom: 16 }}>
{question.content}
</Paragraph>
)}
</div>
<div className={styles.answerSection}>
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
{/* 用户答案 */}
<div className={styles.answerItem}>
<Space>
<Text type="secondary"></Text>
<Text
strong
className={isCorrect ? styles.correct : styles.incorrect}
>
{formatAnswer(answer.answer, question.type)}
</Text>
{isCorrect ? (
<CheckCircleOutlined style={{ color: "#52c41a" }} />
) : (
<CloseCircleOutlined style={{ color: "#ff4d4f" }} />
)}
</Space>
</div>
{/* 正确答案 */}
<div className={styles.answerItem}>
<Space>
<Text type="secondary"></Text>
<Text strong style={{ color: "#52c41a" }}>
{formatAnswer(answer.correct_answer, question.type)}
</Text>
</Space>
</div>
{/* 得分 */}
<div className={styles.answerItem}>
<Space>
<Text type="secondary"></Text>
<Text
strong
style={{
color: isCorrect ? "#52c41a" : "#ff4d4f",
fontSize: 16,
}}
>
{answer.score.toFixed(1)}
</Text>
</Space>
</div>
{/* AI评分详情 */}
{answer.ai_grading && (
<div
className={styles.aiGrading}
style={{
marginTop: 12,
padding: 16,
background: "#f0f5ff",
borderRadius: 8,
}}
>
<div style={{ marginBottom: 8 }}>
<Text strong style={{ color: "#1890ff" }}>
AI评分详情
</Text>
</div>
<Space
direction="vertical"
size="small"
style={{ width: "100%" }}
>
<div>
<Text type="secondary">AI得分</Text>
<Text strong>{answer.ai_grading.score} / 100</Text>
</div>
<div>
<Text type="secondary"></Text>
<Text>{answer.ai_grading.feedback}</Text>
</div>
{answer.ai_grading.suggestion && (
<div>
<Text type="secondary"></Text>
<Text>{answer.ai_grading.suggestion}</Text>
</div>
)}
</Space>
</div>
)}
</Space>
</div>
</div>
);
};
// 渲染填空题题目(将 **** 替换为下划线)
const renderFillInBlankQuestion = (content: string) => {
const parts = content.split("****");
return (
<span>
{parts.map((part, i) => (
<React.Fragment key={i}>
{part}
{i < parts.length - 1 && (
<span
style={{
display: "inline-block",
minWidth: "120px",
borderBottom: "2px solid #1890ff",
marginLeft: 8,
marginRight: 8,
}}
>
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
</span>
)}
</React.Fragment>
))}
</span>
);
};
// 格式化用户答案
const formatAnswer = (answer: any, type: string): string => {
if (answer === null || answer === undefined || answer === "") {
return "未作答";
}
if (Array.isArray(answer)) {
if (answer.length === 0) return "未作答";
return answer
.filter((a) => a !== null && a !== undefined && a !== "")
.join("、");
}
if (type === "true-false") {
// 处理判断题:支持字符串和布尔值
const answerStr = String(answer).toLowerCase();
return answerStr === "true" ? "正确" : "错误";
}
return String(answer);
};
// 按题型分组(合并两种论述题)
const groupedQuestions = questions.reduce((acc, q) => {
// 将两种论述题统一为 'essay'
const displayType =
q.type === "ordinary-essay" || q.type === "management-essay"
? "essay"
: q.type;
if (!acc[displayType]) {
acc[displayType] = [];
}
acc[displayType].push(q);
return acc;
}, {} as Record<string, Question[]>);
// 计算各题型得分(已在 groupedQuestions 中合并论述题)
const typeScores = Object.entries(groupedQuestions)
.map(([type, qs]) => {
const typeAnswers = qs
.map((q) => answerMap.get(q.id))
.filter(Boolean) as ExamAnswer[];
const totalScore = typeAnswers.reduce((sum, ans) => sum + ans.score, 0);
const maxScore =
typeAnswers.length *
(type === "fill-in-blank"
? 2.0
: type === "true-false"
? 2.0
: type === "multiple-choice"
? 1.0
: type === "multiple-selection"
? 2.5
: type === "short-answer"
? 10.0
: type === "essay" ||
type === "ordinary-essay" ||
type === "management-essay"
? 5.0
: 0);
const correctCount = typeAnswers.filter((ans) => ans.is_correct).length;
return {
type,
typeName: TYPE_NAME[type] || type,
totalScore,
maxScore,
correctCount,
totalCount: typeAnswers.length,
order: TYPE_ORDER[type] || 999,
};
})
.sort((a, b) => a.order - b.order);
return (
<div className={styles.container}>
{/* 顶部返回按钮 */}
<div style={{ marginBottom: 16 }}>
<Button
icon={<LeftOutlined />}
onClick={() => navigate(-1)}
>
</Button>
</div>
{/* 成绩展示 */}
<Result
status={isPassed ? "success" : "warning"}
title={isPassed ? "恭喜你,考试通过!" : "很遗憾,未通过考试"}
subTitle={
<Space direction="vertical" size="large">
<Text style={{ fontSize: 16 }}>
{record.exam?.title || "模拟考试"}
</Text>
</Space>
}
extra={
!isPassed && record.exam?.id ? (
<Button
type="primary"
size="large"
icon={<FileTextOutlined />}
onClick={async () => {
try {
const res = await examApi.startExam(record.exam!.id);
if (res.success && res.data) {
navigate(`/exam/${record.exam!.id}/taking/${res.data.record_id}`);
} else {
message.error(res.message || "开始考试失败");
}
} catch (error) {
message.error("开始考试失败");
}
}}
>
</Button>
) : undefined
}
/>
{/* 成绩统计 */}
<Card className={styles.statsCard}>
<Row gutter={[32, 16]}>
<Col xs={24} sm={12} md={6}>
<Statistic
title="总分"
value={scorePercent.toFixed(1)}
suffix="/ 100"
prefix={<TrophyOutlined />}
valueStyle={{
color: isPassed ? "#52c41a" : "#ff4d4f",
fontSize: 32,
}}
/>
</Col>
<Col xs={24} sm={12} md={6}>
<Statistic
title="正确率"
value={correctRate.toFixed(1)}
suffix="%"
prefix={<CheckCircleOutlined />}
valueStyle={{ color: "#1890ff", fontSize: 32 }}
/>
</Col>
<Col xs={24} sm={12} md={6}>
<Statistic
title="用时"
value={formatTime(record.time_spent)}
prefix={<ClockCircleOutlined />}
valueStyle={{ fontSize: 32 }}
/>
</Col>
<Col xs={24} sm={12} md={6}>
<div style={{ textAlign: "center" }}>
<div
style={{
color: "rgba(0, 0, 0, 0.45)",
fontSize: 14,
marginBottom: 8,
}}
>
</div>
<Tag
color={isPassed ? "success" : "error"}
style={{ fontSize: 16, padding: "4px 16px" }}
>
{isPassed ? "已通过" : "未通过"}
</Tag>
<div
style={{
marginTop: 8,
color: "rgba(0, 0, 0, 0.45)",
fontSize: 12,
}}
>
{record.exam?.pass_score || 60}
</div>
</div>
</Col>
</Row>
</Card>
{/* 各题型得分情况 */}
<Card
title={
<Text strong style={{ fontSize: 18 }}>
</Text>
}
className={styles.typeScoreCard}
>
<Row gutter={[16, 16]}>
{typeScores.map((ts) => (
<Col xs={24} sm={12} md={8} lg={6} key={ts.type}>
<div className={styles.typeScoreItem}>
<div className={styles.typeScoreHeader}>
<Text strong style={{ fontSize: 16 }}>
{ts.typeName}
</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
{ts.correctCount}/{ts.totalCount}
</Text>
</div>
<div className={styles.typeScoreContent}>
<Text
strong
style={{
fontSize: 28,
color:
ts.totalScore === ts.maxScore ? "#52c41a" : "#1890ff",
}}
>
{ts.totalScore.toFixed(1)}
</Text>
<Text
type="secondary"
style={{ fontSize: 16, marginLeft: 4 }}
>
/ {ts.maxScore.toFixed(1)}
</Text>
</div>
<div className={styles.typeScoreProgress}>
<div
className={styles.typeScoreBar}
style={{
width: `${(ts.totalScore / ts.maxScore) * 100}%`,
background:
ts.totalScore === ts.maxScore ? "#52c41a" : "#1890ff",
}}
/>
</div>
</div>
</Col>
))}
</Row>
</Card>
{/* 答题详情 - 直接展示,不使用折叠 */}
<Card
title={
<Text strong style={{ fontSize: 18 }}>
</Text>
}
className={styles.detailCard}
>
{Object.entries(groupedQuestions)
.sort(([typeA], [typeB]) => {
const orderA = TYPE_ORDER[typeA] || 999;
const orderB = TYPE_ORDER[typeB] || 999;
return orderA - orderB;
})
.map(([type, qs]) => (
<div key={type} style={{ marginBottom: 32 }}>
{/* 题型标题 */}
<div
style={{
padding: "12px 16px",
background: "#fafafa",
borderLeft: "4px solid #1890ff",
marginBottom: 16,
}}
>
<Space>
<Text strong style={{ fontSize: 16 }}>
{TYPE_NAME[type] || type}
</Text>
<Text type="secondary"> {qs.length} </Text>
</Space>
</div>
{/* 题目列表 */}
<Space
direction="vertical"
size="large"
style={{ width: "100%" }}
>
{qs.map((q, idx) => {
const ans = answerMap.get(q.id);
if (!ans) return null;
return (
<Card
key={q.id}
size="small"
className={styles.questionCard}
style={{
borderLeft: ans.is_correct
? "4px solid #52c41a"
: "4px solid #ff4d4f",
background: ans.is_correct ? "#f6ffed" : "#fff2f0",
}}
>
<div style={{ marginBottom: 12 }}>
<Tag color="blue"> {idx + 1} </Tag>
</div>
{renderAnswerDetail(q, ans)}
</Card>
);
})}
</Space>
{/* 题型之间的分隔线 */}
<Divider />
</div>
))}
</Card>
</div>
);
};
export default ExamResultNew;

View File

@ -1,400 +0,0 @@
.loadingContainer {
display: flex;
justify-content: center;
align-items: center;
min-height: 400px;
}
.container {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
background: #f5f5f5;
min-height: 100vh;
}
.header {
margin-bottom: 20px;
position: sticky;
top: 0;
z-index: 100;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
border-radius: 8px;
overflow: hidden;
background: #fff;
:global(.ant-card-body) {
padding: 20px 24px;
}
.headerContent {
display: flex;
justify-content: space-between;
align-items: center;
gap: 24px;
h3 {
margin: 0 0 8px 0;
color: #1f2937;
font-weight: 600;
}
> div:first-child {
:global(.ant-typography) {
color: rgba(0, 0, 0, 0.65);
}
}
}
.stats {
display: flex;
align-items: center;
gap: 24px;
:global(.ant-statistic-title) {
color: rgba(0, 0, 0, 0.65);
}
:global(.ant-statistic-content) {
color: #1f2937;
font-weight: 600;
}
:global(.anticon) {
color: #1890ff;
}
:global(.ant-divider-vertical) {
background: #e5e7eb;
}
.statCard {
display: flex;
flex-direction: column;
min-width: 120px;
.statLabel {
font-size: 14px;
margin-bottom: 8px;
}
.statValue {
display: flex;
align-items: center;
gap: 8px;
:global(.ant-typography) {
color: #1f2937;
}
}
}
.progressInfo {
display: flex;
flex-direction: column;
align-items: center;
margin-top: 8px;
:global(.ant-typography) {
color: #1f2937;
}
}
}
}
.content {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
border-radius: 8px;
overflow: hidden;
background: #fff;
:global(.ant-card-body) {
padding: 32px;
}
.questionContainer {
min-height: 400px;
padding: 24px 0;
.questionHeader {
margin-bottom: 20px;
:global(.ant-tag) {
padding: 4px 16px;
font-size: 14px;
border-radius: 4px;
}
}
:global(.ant-form-item-label > label) {
font-size: 16px;
font-weight: 500;
color: #1f2937;
line-height: 1.6;
}
:global(.ant-input),
:global(.ant-input-textarea) {
border-radius: 4px;
border: 1px solid #d9d9d9;
&:hover {
border-color: #40a9ff;
}
&:focus {
border-color: #40a9ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.1);
}
}
:global(.ant-radio-wrapper),
:global(.ant-checkbox-wrapper) {
font-size: 15px;
padding: 4px 0;
}
}
.navigation {
display: flex;
justify-content: center;
align-items: center;
padding: 24px 0;
border-top: 1px solid #f0f0f0;
}
.questionGroup {
margin-bottom: 32px;
.groupHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
h4 {
margin: 0;
}
}
:global {
.ant-form-item-label > label {
font-size: 15px;
font-weight: 500;
color: rgba(0, 0, 0, 0.85);
}
}
}
.actions {
display: flex;
justify-content: center;
padding: 32px 0;
border-top: 1px solid #f0f0f0;
}
}
// 抽屉样式
.drawerContent {
.questionTypeSection {
margin-bottom: 12px;
.typeHeader {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 10px;
background: #fafafa;
border-radius: 4px;
margin-bottom: 8px;
:global(.ant-typography) {
margin: 0;
}
}
}
.questionGrid {
display: grid;
grid-template-columns: repeat(10, 1fr);
gap: 8px;
padding: 8px 0;
.questionItem {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #d9d9d9;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
font-weight: 500;
font-size: 13px;
background: #fff;
&:hover {
border-color: #1890ff;
color: #1890ff;
}
&.current {
border-color: #1890ff;
background: #1890ff;
color: #fff;
}
&.answered:not(.current) {
border-color: #52c41a;
background: #f6ffed;
color: #52c41a;
}
}
}
.legend {
display: flex;
justify-content: space-around;
align-items: center;
padding: 16px 0 0 0;
margin-top: 12px;
border-top: 1px solid #f0f0f0;
.legendItem {
display: flex;
align-items: center;
gap: 8px;
.legendBox {
width: 20px;
height: 20px;
border: 1px solid #d9d9d9;
border-radius: 4px;
background: #fff;
flex-shrink: 0;
&.current {
border-color: #1890ff;
background: #1890ff;
}
&.answered {
border-color: #52c41a;
background: #f6ffed;
}
&.unanswered {
border-color: #d9d9d9;
background: #fff;
}
}
:global(.ant-typography) {
font-size: 13px;
color: rgba(0, 0, 0, 0.65);
}
}
}
}
// 响应式适配
@media (max-width: 768px) {
.container {
padding: 12px;
}
.header {
.headerContent {
flex-direction: column;
align-items: flex-start;
gap: 16px;
h3 {
font-size: 18px;
}
}
.stats {
width: 100%;
justify-content: space-between;
gap: 16px;
:global(.ant-divider) {
display: none;
}
:global(.ant-statistic) {
:global(.ant-statistic-title) {
font-size: 13px;
}
:global(.ant-statistic-content) {
font-size: 20px;
}
}
.progressInfo {
:global(.ant-typography) {
font-size: 20px;
}
}
}
}
.content {
.questionContainer {
min-height: 300px;
padding: 16px 0;
}
.navigation {
padding: 16px 0;
:global(.ant-space) {
width: 100%;
justify-content: space-between;
}
}
.questionGroup {
.groupHeader {
h4 {
font-size: 16px;
}
}
:global {
.ant-form-item-label > label {
font-size: 14px;
}
}
}
.actions {
padding: 20px 0;
:global(.ant-space) {
width: 100%;
flex-direction: column;
button {
width: 100%;
}
}
}
}
// 抽屉响应式
.drawerContent {
.questionGrid {
grid-template-columns: repeat(10, 1fr);
gap: 8px;
.questionItem {
width: 36px;
height: 36px;
}
}
}
}

View File

@ -1,728 +0,0 @@
import React, { useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import {
Card,
Form,
Input,
Radio,
Checkbox,
Button,
Typography,
message,
Spin,
Space,
Divider,
Modal,
Statistic,
FloatButton,
Drawer,
Tag
} from 'antd'
import {
ArrowLeftOutlined,
CheckCircleOutlined,
ClockCircleOutlined,
ExclamationCircleOutlined,
LeftOutlined,
RightOutlined,
UnorderedListOutlined,
} from '@ant-design/icons'
import * as examApi from '../api/exam'
import type { Question } from '../types/question'
import type { ExamDetailResponse } from '../types/exam'
import styles from './ExamTaking.module.less'
const { Title, Text } = Typography
const { TextArea } = Input
const { Countdown } = Statistic
// 题型名称映射
const TYPE_NAME: Record<string, string> = {
'fill-in-blank': '填空题',
'true-false': '判断题',
'multiple-choice': '单选题',
'multiple-selection': '多选题',
'short-answer': '简答题',
'ordinary-essay': '论述题',
'management-essay': '论述题'
}
// 题型顺序定义
const TYPE_ORDER: Record<string, number> = {
'fill-in-blank': 1,
'true-false': 2,
'multiple-choice': 3,
'multiple-selection': 4,
'short-answer': 5,
'ordinary-essay': 6,
'management-essay': 6
}
const ExamTaking: React.FC = () => {
const { examId, recordId } = useParams<{ examId: string; recordId: string }>()
const navigate = useNavigate()
const [form] = Form.useForm()
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
const [examData, setExamData] = useState<ExamDetailResponse | null>(null)
const [groupedQuestions, setGroupedQuestions] = useState<Record<string, Question[]>>({})
const [answeredCount, setAnsweredCount] = useState(0)
const [endTime, setEndTime] = useState<number>(0)
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0)
const [drawerVisible, setDrawerVisible] = useState(false)
// 加载考试详情
useEffect(() => {
if (!examId || !recordId) {
message.error('参数错误')
navigate('/exam/management')
return
}
const loadExam = async () => {
setLoading(true)
try {
const res = await examApi.getExamDetail(Number(examId))
if (res.success && res.data) {
// 获取用户信息,判断用户类型
const userStr = localStorage.getItem('user')
const user = userStr ? JSON.parse(userStr) : null
const userType = user?.user_type // 'ordinary-person' 或 'management-person'
// 过滤题目:根据用户类型只保留对应的论述题
let filteredQuestions = res.data.questions.filter(q => {
if (q.type === 'ordinary-essay') {
return userType === 'ordinary-person'
}
if (q.type === 'management-essay') {
return userType === 'management-person'
}
return true // 其他题目全部保留
})
// 按照题型顺序排序题目
filteredQuestions.sort((a, b) => {
const orderA = TYPE_ORDER[a.type] || 999
const orderB = TYPE_ORDER[b.type] || 999
return orderA - orderB
})
setExamData({
...res.data,
questions: filteredQuestions
})
// 按题型分组
const grouped = groupQuestionsByType(filteredQuestions)
setGroupedQuestions(grouped)
// 检查是否有保存的剩余时间(暂停状态)
const savedProgress = localStorage.getItem(`exam_progress_${recordId}`)
if (savedProgress) {
try {
const progress = JSON.parse(savedProgress)
if (progress.remainingTime) {
// 恢复暂停时的剩余时间
setEndTime(Date.now() + progress.remainingTime)
} else {
// 没有暂停记录,使用完整考试时长
const duration = res.data.exam.duration * 60 * 1000
setEndTime(Date.now() + duration)
}
} catch (e) {
console.error('解析进度失败', e)
const duration = res.data.exam.duration * 60 * 1000
setEndTime(Date.now() + duration)
}
} else {
// 首次进入,使用完整考试时长
const duration = res.data.exam.duration * 60 * 1000
setEndTime(Date.now() + duration)
}
// 恢复答题进度先从服务器再从localStorage
await loadProgressFromServer()
} else {
message.error('加载考试失败')
navigate('/exam/management')
}
} catch (error: any) {
message.error(error.response?.data?.message || '加载考试失败')
navigate('/exam/management')
} finally {
setLoading(false)
}
}
loadExam()
}, [examId, recordId, navigate])
// 定时保存进度每30秒
useEffect(() => {
if (!recordId) return
const interval = setInterval(() => {
saveCurrentQuestionToServer()
}, 30000) // 30秒
return () => clearInterval(interval)
}, [recordId, currentQuestionIndex, examData]) // 依赖当前题目索引
// 按题型分组题目
const groupQuestionsByType = (questions: Question[]) => {
const grouped: Record<string, Question[]> = {}
questions.forEach((q) => {
if (!grouped[q.type]) {
grouped[q.type] = []
}
grouped[q.type].push(q)
})
return grouped
}
// 保存当前题目答案到数据库
const saveCurrentQuestionToServer = async () => {
if (!recordId || !examData || currentQuestionIndex < 0) return
try {
const currentQuestion = examData.questions[currentQuestionIndex]
const fieldName = `q_${currentQuestion.id}`
const answer = form.getFieldValue(fieldName)
// 只有当答案不为空时才保存
if (answer !== undefined && answer !== null && answer !== '') {
await examApi.saveExamProgress(Number(recordId), {
question_id: currentQuestion.id,
answer: answer
})
}
} catch (error: any) {
console.error('保存答案失败:', error)
// 静默失败,不打扰用户
}
}
// 保存答题进度仅localStorage用于快速保存
const saveProgress = () => {
if (!recordId) return
const values = form.getFieldsValue()
const remaining = endTime - Date.now()
const progress = {
answers: values,
remainingTime: remaining > 0 ? remaining : 0,
timestamp: Date.now(),
}
localStorage.setItem(`exam_progress_${recordId}`, JSON.stringify(progress))
}
// 从服务器恢复答题进度
const loadProgressFromServer = async () => {
if (!recordId) return
try {
// 1. 先尝试从服务器加载答案
const res = await examApi.getExamUserAnswers(Number(recordId))
if (res.success && res.data && Object.keys(res.data).length > 0) {
form.setFieldsValue(res.data)
updateAnsweredCount(res.data)
message.success('已恢复服务器保存的答题进度')
return
}
} catch (error: any) {
console.error('从服务器恢复进度失败:', error)
}
// 2. 如果服务器没有数据尝试从localStorage恢复
const saved = localStorage.getItem(`exam_progress_${recordId}`)
if (saved) {
try {
const progress = JSON.parse(saved)
if (progress.answers) {
form.setFieldsValue(progress.answers)
updateAnsweredCount(progress.answers)
message.success('已恢复本地保存的答题进度')
}
} catch (e) {
console.error('恢复本地进度失败', e)
}
}
}
// 更新已答题目数量
const updateAnsweredCount = (values: any) => {
let count = 0
Object.values(values).forEach((val: any) => {
if (val !== undefined && val !== null && val !== '') {
if (Array.isArray(val) && val.length > 0) {
count++
} else if (!Array.isArray(val)) {
count++
}
}
})
setAnsweredCount(count)
}
// 监听表单变化
const handleFormChange = () => {
const values = form.getFieldsValue()
updateAnsweredCount(values)
saveProgress()
}
// 提交考试
const handleSubmit = async () => {
const totalQuestions = examData?.questions.length || 0
const unanswered = totalQuestions - answeredCount
// 先保存当前题目答案
await saveCurrentQuestionToServer()
if (unanswered > 0) {
Modal.confirm({
title: '确认提交',
icon: <ExclamationCircleOutlined />,
content: `还有 ${unanswered} 道题未作答,确认提交吗?`,
okText: '确认提交',
cancelText: '继续答题',
onOk: () => submitExam()
})
} else {
Modal.confirm({
title: '确认提交',
icon: <CheckCircleOutlined />,
content: '已完成所有题目,确认提交吗?',
okText: '确认提交',
cancelText: '检查答案',
onOk: () => submitExam()
})
}
}
// 提交答案并触发阅卷
const submitExam = async () => {
setSubmitting(true)
try {
// 直接提交,后端会从数据库读取答案并阅卷
const res = await examApi.submitExamAnswer(Number(recordId), {})
if (res.success) {
message.success('提交成功,正在阅卷...')
// 清除进度
localStorage.removeItem(`exam_progress_${recordId}`)
// 跳转到试卷列表页面
navigate('/exam/management')
} else {
message.error(res.message || '提交失败')
}
} catch (error: any) {
message.error(error.response?.data?.message || '提交失败')
} finally {
setSubmitting(false)
}
}
// 时间到自动提交
const handleTimeFinish = async () => {
message.warning('考试时间已到,系统将自动提交')
await saveCurrentQuestionToServer() // 先保存当前答案
submitExam()
}
// 上一题
const handlePrevQuestion = async () => {
if (currentQuestionIndex > 0) {
await saveCurrentQuestionToServer() // 保存当前题目答案到服务器
setCurrentQuestionIndex(currentQuestionIndex - 1)
}
}
// 下一题
const handleNextQuestion = async () => {
if (examData && currentQuestionIndex < examData.questions.length - 1) {
await saveCurrentQuestionToServer() // 保存当前题目答案到服务器
setCurrentQuestionIndex(currentQuestionIndex + 1)
}
}
// 跳转到指定题目
const handleJumpToQuestion = (index: number) => {
setCurrentQuestionIndex(index)
setDrawerVisible(false)
}
// 检查题目是否已答
const isQuestionAnswered = (question: Question): boolean => {
const fieldName = `q_${question.id}`
const value = form.getFieldValue(fieldName)
if (value === undefined || value === null || value === '') {
return false
}
if (Array.isArray(value)) {
// 处理多选题和填空题
return value.length > 0 && value.some(v => v !== undefined && v !== null && v !== '')
}
return true
}
// 渲染题目
const renderQuestion = (question: Question) => {
const fieldName = `q_${question.id}`
switch (question.type) {
case 'fill-in-blank':
// 将题目按 **** 分割,在占位符位置插入输入框
const parts = question.content.split('****')
const blankCount = parts.length - 1 // 填空数量 = 分割后的部分数 - 1
if (blankCount === 0) {
// 如果没有 ****,显示警告并提供一个文本框
return (
<Form.Item
key={question.id}
label={question.content}
required={false}
>
<div style={{ marginBottom: 12, padding: '12px', background: '#fff7e6', border: '1px solid #ffd591', borderRadius: '4px' }}>
<Text type="warning" style={{ fontSize: 14 }}>
****
</Text>
</div>
<Form.Item
name={[fieldName, 0]}
rules={[{ required: true, message: '请填写答案' }]}
noStyle
>
<Input.TextArea
placeholder="请在此处填写答案"
autoSize={{ minRows: 2, maxRows: 6 }}
style={{
border: 'none',
borderBottom: '2px solid #1890ff',
borderRadius: 0,
padding: '4px 0',
boxShadow: 'none'
}}
/>
</Form.Item>
</Form.Item>
)
}
return (
<Form.Item
key={question.id}
required={false}
style={{ marginBottom: 24 }}
>
<div style={{ fontSize: 16, fontWeight: 500, color: '#1f2937', lineHeight: 2, display: 'flex', flexWrap: 'wrap', alignItems: 'center' }}>
{parts.map((part, i) => (
<React.Fragment key={i}>
<span>{part}</span>
{i < blankCount && (
<Form.Item
name={[fieldName, i]}
rules={[{ required: true, message: '请填写' }]}
style={{ display: 'inline-block', margin: '0 8px 0 8px' }}
>
<Input
placeholder={`填空 ${i + 1}`}
style={{
width: 180,
border: 'none',
borderBottom: '2px solid #1890ff',
borderRadius: 0,
padding: '4px 0',
boxShadow: 'none'
}}
/>
</Form.Item>
)}
</React.Fragment>
))}
</div>
</Form.Item>
)
case 'true-false':
return (
<Form.Item
key={question.id}
name={fieldName}
label={question.content}
rules={[{ required: true, message: '请选择答案' }]}
required={false}
>
<Radio.Group>
<Space direction="vertical">
<Radio value="true"></Radio>
<Radio value="false"></Radio>
</Space>
</Radio.Group>
</Form.Item>
)
case 'multiple-choice':
return (
<Form.Item
key={question.id}
name={fieldName}
label={question.content}
rules={[{ required: true, message: '请选择答案' }]}
required={false}
>
<Radio.Group>
<Space direction="vertical">
{question.options?.map((opt) => (
<Radio key={opt.key} value={opt.key}>
{opt.key}. {opt.value}
</Radio>
))}
</Space>
</Radio.Group>
</Form.Item>
)
case 'multiple-selection':
return (
<Form.Item
key={question.id}
name={fieldName}
label={question.content}
rules={[{ required: true, message: '请选择答案' }]}
required={false}
>
<Checkbox.Group>
<Space direction="vertical">
{question.options?.map((opt) => (
<Checkbox key={opt.key} value={opt.key}>
{opt.key}. {opt.value}
</Checkbox>
))}
</Space>
</Checkbox.Group>
</Form.Item>
)
case 'short-answer':
case 'ordinary-essay':
case 'management-essay':
return (
<Form.Item
key={question.id}
name={fieldName}
label={question.content}
rules={[{ required: true, message: '请作答' }]}
required={false}
>
<TextArea rows={6} placeholder="请输入你的答案" />
</Form.Item>
)
default:
return null
}
}
if (loading) {
return (
<div className={styles.loadingContainer}>
<Spin size="large" />
</div>
)
}
if (!examData) {
return null
}
const totalQuestions = examData.questions.length
const currentQuestion = examData.questions[currentQuestionIndex]
return (
<div className={styles.container}>
{/* 顶部信息栏 */}
<Card className={styles.header}>
<div className={styles.headerContent}>
<div>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => {
Modal.confirm({
title: '确认退出',
content: '退出将保存当前答题进度,确认退出吗?',
onOk: () => {
saveProgress()
navigate('/exam/management')
}
})
}}
style={{ marginBottom: 8 }}
>
退
</Button>
<Title level={3}>{examData.exam.title}</Title>
<Text type="secondary">
{currentQuestionIndex + 1}/{totalQuestions}
</Text>
</div>
<div className={styles.stats}>
<div className={styles.statCard}>
<Text type="secondary" className={styles.statLabel}></Text>
<div className={styles.statValue}>
<ClockCircleOutlined style={{ color: '#1890ff', fontSize: 20 }} />
<Countdown
value={endTime}
format="mm:ss"
onFinish={handleTimeFinish}
style={{ fontSize: 24, fontWeight: 600, color: '#1f2937', lineHeight: 1 }}
/>
</div>
</div>
<Divider type="vertical" style={{ height: 60 }} />
<div className={styles.statCard}>
<Text type="secondary" className={styles.statLabel}></Text>
<div className={styles.statValue}>
<CheckCircleOutlined style={{ color: '#1890ff', fontSize: 20 }} />
<Text strong style={{ fontSize: 24, lineHeight: 1 }}>
{answeredCount}/{totalQuestions}
</Text>
</div>
</div>
</div>
</div>
</Card>
{/* 答题区域 - 单题显示 */}
<Card className={styles.content}>
<Form
form={form}
layout="vertical"
onValuesChange={handleFormChange}
>
{currentQuestion && (
<div className={styles.questionContainer}>
<div className={styles.questionHeader}>
<Text strong style={{ fontSize: 16, marginRight: 8 }}>
{(() => {
const typeQuestions = groupedQuestions[currentQuestion.type] || []
const typeIndex = typeQuestions.findIndex(q => q.id === currentQuestion.id)
return typeIndex + 1
})()}
</Text>
<Tag color="blue">{TYPE_NAME[currentQuestion.type] || currentQuestion.type}</Tag>
</div>
{renderQuestion(currentQuestion)}
</div>
)}
{/* 导航按钮 */}
<div className={styles.navigation}>
<Space size="large">
<Button
icon={<LeftOutlined />}
onClick={handlePrevQuestion}
disabled={currentQuestionIndex === 0}
>
</Button>
<Text type="secondary">
{currentQuestionIndex + 1} / {totalQuestions}
</Text>
<Button
icon={<RightOutlined />}
onClick={handleNextQuestion}
disabled={currentQuestionIndex === totalQuestions - 1}
>
</Button>
<Button
type="primary"
icon={<CheckCircleOutlined />}
onClick={handleSubmit}
loading={submitting}
>
</Button>
</Space>
</div>
</Form>
</Card>
{/* 悬浮球 */}
<FloatButton
icon={<UnorderedListOutlined />}
type="primary"
style={{ right: 24, bottom: 24 }}
onClick={() => setDrawerVisible(true)}
badge={{ count: answeredCount, overflowCount: 999 }}
/>
{/* 答题情况抽屉 */}
<Drawer
title="答题情况"
placement="right"
width={500}
open={drawerVisible}
onClose={() => setDrawerVisible(false)}
>
<div className={styles.drawerContent}>
{/* 按题型分组显示 */}
{Object.entries(groupedQuestions).sort(([typeA], [typeB]) => {
const orderA = TYPE_ORDER[typeA] || 999
const orderB = TYPE_ORDER[typeB] || 999
return orderA - orderB
}).map(([type, questions]) => {
return (
<div key={type} className={styles.questionTypeSection}>
<div className={styles.typeHeader}>
<Text strong>{TYPE_NAME[type] || type}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
{questions.length}
</Text>
</div>
<div className={styles.questionGrid}>
{questions.map((q, idx) => {
const globalIndex = examData.questions.findIndex(eq => eq.id === q.id)
const isAnswered = isQuestionAnswered(q)
const isCurrent = globalIndex === currentQuestionIndex
return (
<div
key={q.id}
className={`${styles.questionItem} ${isCurrent ? styles.current : ''} ${isAnswered ? styles.answered : ''}`}
onClick={() => handleJumpToQuestion(globalIndex)}
title={`${idx + 1}`}
>
{idx + 1}
</div>
)
})}
</div>
</div>
)
})}
<div className={styles.legend}>
<div className={styles.legendItem}>
<div className={`${styles.legendBox} ${styles.current}`}></div>
<Text></Text>
</div>
<div className={styles.legendItem}>
<div className={`${styles.legendBox} ${styles.answered}`}></div>
<Text></Text>
</div>
<div className={styles.legendItem}>
<div className={`${styles.legendBox} ${styles.unanswered}`}></div>
<Text></Text>
</div>
</div>
</div>
</Drawer>
</div>
)
}
export default ExamTaking

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -1,54 +1,34 @@
import React, { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Form, Input, Button, Card, Modal, message, Typography, Radio, Alert, Checkbox } from 'antd'
import { UserOutlined, LockOutlined, IdcardOutlined } from '@ant-design/icons'
import { fetchWithAuth } from '../utils/request'
import { Button, Toast } from 'antd-mobile'
import { EyeInvisibleOutline, EyeOutline } from 'antd-mobile-icons'
import styles from './Login.module.less'
const { Title, Text, Link } = Typography
interface LoginResponse {
success: boolean
message: string
need_user_type?: boolean // 是否需要补充用户类型
data?: {
token: string
user: {
username: string
avatar: string
nickname: string
user_type?: string // 用户类型
}
}
}
const Login: React.FC = () => {
const navigate = useNavigate()
const [isLogin, setIsLogin] = useState(true)
const [loading, setLoading] = useState(false)
const [registerModalVisible, setRegisterModalVisible] = useState(false)
const [userTypeModalVisible, setUserTypeModalVisible] = useState(false) // 用户类型补充模态框
const [rememberMe, setRememberMe] = useState(false) // 记住密码状态
// const [userType, setUserType] = useState<string>('') // 临时存储用户选择的类型
const [loginForm] = Form.useForm()
const [registerForm] = Form.useForm()
const [userTypeForm] = Form.useForm()
const [showPassword, setShowPassword] = useState(false)
// 页面加载时读取保存的登录信息
useEffect(() => {
const savedUsername = localStorage.getItem('savedUsername')
const savedPassword = localStorage.getItem('savedPassword')
const savedRememberMe = localStorage.getItem('rememberMe') === 'true'
// 表单字段
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [nickname, setNickname] = useState('')
if (savedRememberMe && savedUsername && savedPassword) {
loginForm.setFieldsValue({
username: savedUsername,
password: savedPassword
})
setRememberMe(true)
}
}, [loginForm])
// 如果已登录,重定向到首页
// 如果已登录,重定向到首页
useEffect(() => {
const token = localStorage.getItem('token')
if (token) {
@ -57,46 +37,48 @@ const Login: React.FC = () => {
}, [navigate])
// 处理登录
const handleLogin = async (values: { username: string; password: string }) => {
const handleLogin = async () => {
if (!username || !password) {
Toast.show({
icon: 'fail',
content: '请输入用户名和密码',
})
return
}
setLoading(true)
try {
const response = await fetchWithAuth('/api/login', {
const response = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify(values),
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, password }),
})
const data: LoginResponse = await response.json()
if (data.success && data.data) {
// 保存登录凭证
localStorage.setItem('token', data.data.token)
localStorage.setItem('user', JSON.stringify(data.data.user))
// 处理记住密码
if (rememberMe) {
localStorage.setItem('savedUsername', values.username)
localStorage.setItem('savedPassword', values.password)
localStorage.setItem('rememberMe', 'true')
} else {
// 取消记住密码时清除保存的信息
localStorage.removeItem('savedUsername')
localStorage.removeItem('savedPassword')
localStorage.removeItem('rememberMe')
}
Toast.show({
icon: 'success',
content: '登录成功',
})
// 检查是否需要补充用户类型
if (data.need_user_type) {
setUserTypeModalVisible(true)
navigate('/')
} else {
message.success('登录成功')
// 使用 window.location 跳转,刷新页面以正确加载首页的聊天插件
window.location.href = '/'
}
} else {
message.error(data.message || '登录失败')
Toast.show({
icon: 'fail',
content: data.message || '登录失败',
})
}
} catch (err) {
message.error('网络错误,请稍后重试')
Toast.show({
icon: 'fail',
content: '网络错误,请稍后重试',
})
console.error('登录错误:', err)
} finally {
setLoading(false)
@ -104,12 +86,31 @@ const Login: React.FC = () => {
}
// 处理注册
const handleRegister = async (values: { username: string; password: string; nickname?: string; user_type: string }) => {
const handleRegister = async () => {
if (!username || !password) {
Toast.show({
icon: 'fail',
content: '请输入用户名和密码',
})
return
}
if (password.length < 6) {
Toast.show({
icon: 'fail',
content: '密码长度至少6位',
})
return
}
setLoading(true)
try {
const response = await fetchWithAuth('/api/register', {
const response = await fetch('/api/register', {
method: 'POST',
body: JSON.stringify(values),
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, password, nickname }),
})
const data: LoginResponse = await response.json()
@ -118,230 +119,132 @@ const Login: React.FC = () => {
localStorage.setItem('token', data.data.token)
localStorage.setItem('user', JSON.stringify(data.data.user))
message.success('注册成功')
setRegisterModalVisible(false)
// 使用 window.location 跳转,刷新页面以正确加载首页的聊天插件
window.location.href = '/'
Toast.show({
icon: 'success',
content: '注册成功',
})
navigate('/')
} else {
message.error(data.message || '注册失败')
Toast.show({
icon: 'fail',
content: data.message || '注册失败',
})
}
} catch (err) {
message.error('网络错误,请稍后重试')
Toast.show({
icon: 'fail',
content: '网络错误,请稍后重试',
})
console.error('注册错误:', err)
} finally {
setLoading(false)
}
}
// 处理用户类型更新
const handleUpdateUserType = async (values: { user_type: string }) => {
setLoading(true)
try {
const response = await fetchWithAuth('/api/user/type', {
method: 'PUT',
body: JSON.stringify(values),
})
const data = await response.json()
if (data.success) {
// 更新本地存储的用户信息
const user = JSON.parse(localStorage.getItem('user') || '{}')
user.user_type = data.data.user_type
localStorage.setItem('user', JSON.stringify(user))
message.success('身份类型设置成功')
setUserTypeModalVisible(false)
// 使用 window.location 跳转,刷新页面以正确加载首页的聊天插件
window.location.href = '/'
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (isLogin) {
handleLogin()
} else {
message.error(data.message || '更新失败')
}
} catch (err) {
message.error('网络错误,请稍后重试')
console.error('更新用户类型错误:', err)
} finally {
setLoading(false)
handleRegister()
}
}
return (
<div className={styles.container}>
<div className={styles.content}>
<Card className={styles.card}>
<div className={styles.header}>
<Title level={2} className={styles.title}>AnKao</Title>
<Text type="secondary" className={styles.subtitle}>使</Text>
<h1 className={styles.title}>AnKao</h1>
<p className={styles.subtitle}>使</p>
</div>
<Form
form={loginForm}
name="login"
onFinish={handleLogin}
autoComplete="off"
size="large"
>
<Form.Item
name="username"
rules={[{ required: true, message: '请输入用户名' }]}
>
<Input
prefix={<UserOutlined />}
<form onSubmit={handleSubmit} className={styles.form}>
<div className={styles.inputGroup}>
<label className={styles.label}></label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="请输入用户名"
disabled={loading}
className={styles.input}
/>
</Form.Item>
<Form.Item
name="password"
rules={[{ required: true, message: '请输入密码' }]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="请输入密码"
/>
</Form.Item>
<Form.Item>
<Checkbox
checked={rememberMe}
onChange={(e) => setRememberMe(e.target.checked)}
>
</Checkbox>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" block loading={loading}>
</Button>
</Form.Item>
<div className={styles.registerTip}>
<Text type="secondary"></Text>
<Link onClick={() => setRegisterModalVisible(true)}></Link>
</div>
</Form>
</Card>
</div>
{/* 注册模态框 */}
<Modal
title="注册新账号"
open={registerModalVisible}
onCancel={() => {
setRegisterModalVisible(false)
registerForm.resetFields()
}}
footer={null}
destroyOnClose
>
<Form
form={registerForm}
name="register"
onFinish={handleRegister}
autoComplete="off"
size="large"
style={{ marginTop: 24 }}
>
<Form.Item
name="username"
rules={[
{ required: true, message: '请输入用户名' },
{ min: 3, message: '用户名至少3个字符' }
]}
>
<Input
prefix={<UserOutlined />}
placeholder="请输入用户名"
<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}
/>
</Form.Item>
<Form.Item
name="password"
rules={[
{ required: true, message: '请输入密码' },
{ min: 6, message: '密码长度至少6位' }
]}
<button
type="button"
className={styles.eyeButton}
onClick={() => setShowPassword(!showPassword)}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="请输入密码至少6位"
{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}
/>
</Form.Item>
</div>
)}
<Form.Item
name="nickname"
rules={[
{ required: true, message: '请输入姓名' },
{ max: 20, message: '姓名最多20个字符' }
]}
<Button
type="submit"
color="primary"
block
loading={loading}
disabled={loading}
className={styles.submitButton}
>
<Input
prefix={<IdcardOutlined />}
placeholder="请输入姓名"
/>
</Form.Item>
<Form.Item
name="user_type"
label="身份类型"
rules={[{ required: true, message: '请选择身份类型' }]}
>
<Radio.Group>
<Radio value="ordinary-person"></Radio>
<Radio value="management-person"></Radio>
</Radio.Group>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" block loading={loading}>
{isLogin ? '登录' : '注册'}
</Button>
</Form.Item>
</Form>
</Modal>
</form>
{/* 用户类型补充模态框 */}
<Modal
title="请选择您的身份类型"
open={userTypeModalVisible}
closable={false}
maskClosable={false}
footer={null}
destroyOnClose
<div className={styles.switchMode}>
{isLogin ? (
<p>
<button
type="button"
onClick={() => setIsLogin(false)}
className={styles.linkBtn}
disabled={loading}
>
<Alert
message="论述题需要使用"
description="为了更好地为您提供相应的论述题内容,请选择您的身份类型。"
type="info"
showIcon
style={{ marginBottom: 24 }}
/>
<Form
form={userTypeForm}
name="userType"
onFinish={handleUpdateUserType}
autoComplete="off"
size="large"
</button>
</p>
) : (
<p>
<button
type="button"
onClick={() => setIsLogin(true)}
className={styles.linkBtn}
disabled={loading}
>
<Form.Item
name="user_type"
label="身份类型"
rules={[{ required: true, message: '请选择身份类型' }]}
>
<Radio.Group>
<Radio value="ordinary-person"></Radio>
<Radio value="management-person"></Radio>
</Radio.Group>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" block loading={loading}>
</Button>
</Form.Item>
</Form>
</Modal>
</button>
</p>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,42 @@
// 变量定义
@bg-color: #f5f5f5;
@text-secondary: #999;
// 页面容器
.page {
min-height: 100vh;
background-color: @bg-color;
padding: 16px;
padding-bottom: 70px;
}
// 用户卡片
.userCard {
margin-bottom: 16px;
}
.userInfo {
display: flex;
align-items: center;
gap: 16px;
}
.userDetails {
flex: 1;
}
.userNickname {
font-size: 20px;
font-weight: bold;
margin-bottom: 4px;
}
.userUsername {
font-size: 14px;
color: @text-secondary;
}
// 登出容器
.logoutContainer {
margin-top: 24px;
}

130
web/src/pages/Profile.tsx Normal file
View File

@ -0,0 +1,130 @@
import React, { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import {
Card,
Avatar,
List,
Button,
Dialog,
Toast,
} from 'antd-mobile'
import {
RightOutline,
SetOutline,
FileOutline,
UserOutline,
} from 'antd-mobile-icons'
import styles from './Profile.module.less'
interface UserInfo {
username: string
nickname: string
avatar: string
}
const Profile: React.FC = () => {
const navigate = useNavigate()
const [userInfo, setUserInfo] = useState<UserInfo | null>(null)
useEffect(() => {
// 从 localStorage 获取用户信息
const token = localStorage.getItem('token')
const savedUserInfo = localStorage.getItem('user')
if (token && savedUserInfo) {
try {
setUserInfo(JSON.parse(savedUserInfo))
} catch (e) {
console.error('解析用户信息失败', e)
}
}
}, [])
const handleLogout = async () => {
const result = await Dialog.confirm({
content: '确定要退出登录吗?',
})
if (result) {
localStorage.removeItem('token')
localStorage.removeItem('user')
setUserInfo(null)
Toast.show('已退出登录')
navigate('/login')
}
}
const handleLogin = () => {
navigate('/login')
}
return (
<div className={styles.page}>
{/* 用户信息卡片 */}
<Card className={styles.userCard}>
{userInfo ? (
<div className={styles.userInfo}>
<Avatar
src={userInfo.avatar || undefined}
style={{ '--size': '64px' }}
>
{!userInfo.avatar && <UserOutline fontSize={32} />}
</Avatar>
<div className={styles.userDetails}>
<div className={styles.userNickname}>{userInfo.nickname}</div>
<div className={styles.userUsername}>@{userInfo.username}</div>
</div>
</div>
) : (
<div className={styles.userInfo}>
<Avatar style={{ '--size': '64px' }}>
<UserOutline fontSize={32} />
</Avatar>
<div className={styles.userDetails}>
<div className={styles.userNickname}></div>
<Button
color="primary"
size="small"
onClick={handleLogin}
style={{ marginTop: 8 }}
>
</Button>
</div>
</div>
)}
</Card>
{/* 功能列表 */}
<List header="功能">
<List.Item
prefix={<FileOutline />}
onClick={() => Toast.show('功能开发中')}
clickable
>
<RightOutline />
</List.Item>
<List.Item
prefix={<SetOutline />}
onClick={() => Toast.show('功能开发中')}
clickable
>
<RightOutline />
</List.Item>
</List>
{/* 退出登录按钮 */}
{userInfo && (
<div className={styles.logoutContainer}>
<Button block color="danger" onClick={handleLogout}>
退
</Button>
</div>
)}
</div>
)
}
export default Profile

View File

@ -1,201 +1,240 @@
.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;
background: #fafafa;
padding: 0;
background: @bg-color;
padding-bottom: 80px;
}
}
// 固定顶栏
.fixedTopBar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
background: rgba(250, 250, 250, 0.85);
backdrop-filter: blur(40px) saturate(180%);
-webkit-backdrop-filter: blur(40px) saturate(180%);
}
.topBarContent {
max-width: 900px;
margin: 0 auto;
// 头部
.header {
background: @white;
padding: 16px 20px;
}
.topBarCard {
// 移除卡片样式
}
.content {
max-width: 900px;
margin: 0 auto;
padding: 0 20px;
padding-top: 80px; // 减少顶部空间,因为去掉了进度条
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: @shadow-light;
position: sticky;
top: 0;
z-index: 100;
.backButton {
color: #007aff;
h1 {
margin: 0;
font-size: 20px;
font-weight: 600;
color: @primary-color;
}
}
// 内容区域
.content {
padding: 16px;
}
// 题目部分
.question-header {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.question-number {
font-size: 14px;
color: @text-secondary;
margin-bottom: 12px;
}
.question-content {
font-size: 18px;
font-weight: 500;
padding: 4px 12px;
color: @text-color;
line-height: 1.6;
margin-bottom: 8px;
&:hover {
color: #0051d5;
background: rgba(0, 122, 255, 0.08);
// 填空题样式
&.fill-content {
display: flex;
flex-wrap: wrap;
align-items: center;
span {
line-height: 2;
}
.fill-input {
:global(.adm-input-element) {
border: none;
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;
}
}
}
}
}
.title {
color: #1d1d1f !important;
margin: 0 !important;
font-weight: 700;
font-size: 18px !important;
flex: 1;
// 答案结果
.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;
}
.settingsButton {
color: #8c8c8c;
font-weight: 500;
padding: 4px 12px;
.correct-answer {
font-size: 14px;
color: @text-secondary;
margin-bottom: 8px;
}
&:hover {
color: #1d1d1f;
background: rgba(0, 0, 0, 0.04);
.explanation {
font-size: 14px;
color: @text-tertiary;
line-height: 1.5;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid @border-color;
}
}
}
.progressWrapper {
// 进度条容器
}
.questionCard {
// 去掉卡片样式,但添加左右内边距
padding: 0 20px;
margin-bottom: 24px;
}
.questionNumber {
color: #6e6e73;
margin: 8px 0 16px 0 !important;
}
.questionContent {
font-size: 18px;
line-height: 1.6;
color: #1d1d1f;
margin-bottom: 16px;
}
.fillInput {
border-bottom: 2px solid #007aff !important;
border-radius: 0 !important;
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.buttonGroup {
// 按钮组
.button-group {
margin-top: 24px;
}
}
.actionButtons {
.action-buttons {
display: flex;
gap: 12px;
flex-wrap: wrap;
justify-content: center;
}
gap: 8px;
padding: 16px;
background: @white;
position: sticky;
bottom: 0;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06);
// 响应式设计 - 移动端
@media (max-width: 768px) {
.container {
padding-bottom: 70px;
}
.topBarContent {
padding: 12px;
}
.topBarCard {
// 移除卡片样式
}
.content {
padding: 0 12px;
padding-top: 70px; // 移动端减少顶部距离
}
.header {
.backButton {
font-size: 14px;
padding: 4px 8px;
}
.title {
font-size: 16px !important;
}
.settingsButton {
font-size: 14px;
padding: 4px 8px;
}
}
.questionCard {
padding: 0 12px;
margin-bottom: 16px;
}
.questionContent {
font-size: 16px;
}
.actionButtons {
button {
flex: 1;
min-width: 100px;
}
}
}
// 响应式设计 - 平板
@media (min-width: 769px) and (max-width: 1024px) {
.topBarContent {
padding: 14px 24px;
}
.content {
padding: 0 24px;
padding-top: 75px;
}
.header {
.title {
font-size: 20px !important;
}
}
}
// 响应式设计 - PC端
@media (min-width: 1025px) {
.topBarContent {
padding: 18px 32px;
}
.content {
padding: 0 32px;
padding-top: 85px;
}
.header {
.title {
font-size: 22px !important;
}
}
// 统计内容
.stats-content {
padding: 20px;
h2 {
margin: 0 0 20px 0;
text-align: center;
font-size: 20px;
}
}
.stat-item {
display: flex;
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;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,342 +0,0 @@
.container {
min-height: 100vh;
background-color: #f5f5f5;
padding: 16px;
padding-bottom: 24px;
}
.header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.backButton {
font-size: 16px;
}
.placeholder {
width: 64px; // 与返回按钮宽度保持一致
}
.title {
margin: 0 !important;
flex: 1;
text-align: center;
}
.filterCard {
margin-bottom: 12px;
:global {
.ant-card-body {
padding: 12px 16px;
}
}
}
.searchInput {
width: 100%;
}
.tabsCard {
margin-bottom: 12px;
:global {
.ant-card-body {
padding: 12px 16px 16px 16px;
}
.ant-tabs-nav {
margin-bottom: 0;
}
}
}
.questionCard {
margin-bottom: 8px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
transition: all 0.2s ease;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
:global {
.ant-card-body {
padding: 16px;
}
}
}
.questionHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.questionId {
font-size: 12px;
}
.questionNumber {
font-size: 13px;
}
.questionContent {
margin-bottom: 8px;
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
}
.contentText {
font-size: 15px;
line-height: 1.6;
margin: 0 !important;
margin-bottom: 0 !important;
color: #262626;
flex: 1;
:global(.ant-typography) {
margin-bottom: 0 !important;
}
}
.trueFalseAnswer {
display: flex;
align-items: flex-start;
padding-top: 4px;
}
.trueFalseTag {
font-size: 14px;
padding: 4px 12px;
margin: 0;
display: flex;
align-items: center;
gap: 4px;
font-weight: 500;
}
.blankAnswer {
display: inline-block;
font-size: 15px;
font-weight: 600;
color: #52c41a;
border-bottom: 2px solid #52c41a;
padding: 0 4px 2px 4px;
margin: 0 4px;
min-width: 60px;
text-align: center;
}
.options {
background-color: #fafafa;
border-radius: 6px;
padding: 10px 12px;
margin-bottom: 12px;
}
.option {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 0;
&:not(:last-child) {
padding-bottom: 8px;
margin-bottom: 8px;
border-bottom: 1px solid #f0f0f0;
}
}
.correctOption {
background-color: #f6ffed;
padding: 8px;
margin: 0 -12px;
padding-left: 11px; // 精确补偿12(容器) - 12(margin) + 1(border) + 11 = 12px与普通选项对齐
padding-right: 12px;
border-radius: 4px;
border: 1px solid #b7eb8f;
border-bottom: 1px solid #b7eb8f !important;
&:not(:last-child) {
// 保持与父元素的 border-bottom 和 margin-bottom
}
}
.correctTag {
margin-left: auto;
font-weight: 500;
}
.answerSection {
background-color: #f6ffed;
border: 1px solid #b7eb8f;
border-radius: 6px;
padding: 10px 12px;
}
.answerText {
margin: 6px 0 0 0 !important;
margin-bottom: 0 !important;
font-size: 14px;
color: #262626;
white-space: pre-wrap;
line-height: 1.5;
:global(.ant-typography) {
margin-bottom: 0 !important;
}
}
// 悬浮按钮组样式美化
:global {
.ant-float-btn-group {
.ant-float-btn {
width: 44px;
height: 44px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&:hover {
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.18);
transform: translateY(-2px);
}
&:active {
transform: translateY(0);
}
.ant-float-btn-body {
width: 100%;
height: 100%;
.ant-float-btn-icon {
font-size: 18px;
}
}
// 置顶按钮样式
&:not(.ant-float-btn-primary) {
background: white;
&:hover {
background: linear-gradient(135deg, #f5f7fa 0%, #e8ecf1 100%);
}
}
// 主按钮样式(题目导航)
&.ant-float-btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
&:hover {
background: linear-gradient(135deg, #5568d3 0%, #6a3f8f 100%);
}
}
}
}
}
/* 响应式设计 */
@media (max-width: 768px) {
.container {
padding: 10px;
padding-bottom: 20px;
}
.header {
margin-bottom: 10px;
}
.filterCard {
margin-bottom: 10px;
:global {
.ant-card-body {
padding: 10px 12px;
}
}
}
.tabsCard {
margin-bottom: 10px;
:global {
.ant-card-body {
padding: 10px 12px 12px 12px;
}
}
}
.questionCard {
margin-bottom: 8px;
:global {
.ant-card-body {
padding: 12px;
}
}
}
.questionHeader {
flex-direction: column;
align-items: flex-start;
gap: 6px;
margin-bottom: 8px;
}
.questionNumber {
align-self: flex-end;
}
.questionContent {
flex-direction: column;
gap: 8px;
margin-bottom: 8px;
}
.trueFalseAnswer {
align-self: flex-end;
}
.options {
padding: 8px 10px;
margin-bottom: 10px;
}
.option {
padding: 6px 0;
}
.correctOption {
margin: 0 -10px; // 匹配移动端容器的padding
padding-left: 9px; // 精确补偿10(容器) - 10(margin) + 1(border) + 9 = 10px
}
.answerSection {
padding: 8px 10px;
}
// 移动端悬浮按钮适配
:global {
.ant-float-btn-group {
right: 16px !important;
bottom: 76px !important; // 避开底部导航栏
.ant-float-btn {
width: 40px;
height: 40px;
.ant-float-btn-body .ant-float-btn-icon {
font-size: 16px;
}
}
}
}
}

View File

@ -1,384 +0,0 @@
import React, { useEffect, useState, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import {
Card,
Tabs,
List,
Tag,
Typography,
Space,
Divider,
Button,
Spin,
message,
Input,
FloatButton,
} from 'antd'
import {
BookOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
EditOutlined,
FileTextOutlined,
UnorderedListOutlined,
SearchOutlined,
ArrowLeftOutlined,
VerticalAlignTopOutlined,
} from '@ant-design/icons'
import * as questionApi from '../api/question'
import type { Question } from '../types/question'
import QuestionListDrawer from '../components/QuestionListDrawer'
import styles from './QuestionList.module.less'
const { Title, Text, Paragraph } = Typography
// 题型配置
const questionTypeConfig: Record<string, { label: string; icon: React.ReactNode; color: string }> = {
'multiple-choice': { label: '选择题', icon: <CheckCircleOutlined />, color: '#1677ff' },
'multiple-selection': { label: '多选题', icon: <UnorderedListOutlined />, color: '#52c41a' },
'true-false': { label: '判断题', icon: <CheckCircleOutlined />, color: '#fa8c16' },
'fill-in-blank': { label: '填空题', icon: <FileTextOutlined />, color: '#722ed1' },
'short-answer': { label: '简答题', icon: <EditOutlined />, color: '#eb2f96' },
'ordinary-essay': { label: '普通涉密人员论述题', icon: <FileTextOutlined />, color: '#f759ab' },
'management-essay': { label: '保密管理人员论述题', icon: <FileTextOutlined />, color: '#d4380d' },
}
const QuestionList: React.FC = () => {
const navigate = useNavigate()
const [loading, setLoading] = useState(false)
const [questions, setQuestions] = useState<Question[]>([])
const [filteredQuestions, setFilteredQuestions] = useState<Question[]>([])
const [selectedType, setSelectedType] = useState<string>('all')
const [searchKeyword, setSearchKeyword] = useState<string>('')
const [drawerVisible, setDrawerVisible] = useState(false)
// 用于存储每个题目卡片的ref
const questionRefs = useRef<Map<number, HTMLDivElement>>(new Map())
// 加载题目列表
const loadQuestions = async () => {
setLoading(true)
try {
const res = await questionApi.getQuestions({})
if (res.success && res.data) {
setQuestions(res.data)
setFilteredQuestions(res.data)
}
} catch (error) {
message.error('加载题目列表失败')
console.error('加载题目失败:', error)
} finally {
setLoading(false)
}
}
useEffect(() => {
loadQuestions()
}, [])
// 筛选题目
useEffect(() => {
let filtered = questions
// 按题型筛选
if (selectedType !== 'all') {
filtered = filtered.filter(q => q.type === selectedType)
}
// 按关键词搜索
if (searchKeyword.trim()) {
const keyword = searchKeyword.trim().toLowerCase()
filtered = filtered.filter(q =>
q.content.toLowerCase().includes(keyword) ||
q.question_id.toLowerCase().includes(keyword)
)
}
setFilteredQuestions(filtered)
}, [selectedType, searchKeyword, questions])
// 渲染填空题内容,将****替换为答案(带下划线样式)
const renderFillInBlankContent = (content: string, answers: string[]): React.ReactNode => {
// 将 **** 替换为答案
const parts = content.split('****')
return (
<>
{parts.map((part, index) => (
<React.Fragment key={index}>
{part}
{index < parts.length - 1 && (
<span className={styles.blankAnswer}>{answers[index] || '______'}</span>
)}
</React.Fragment>
))}
</>
)
}
// 格式化答案显示
const formatAnswer = (question: Question): string => {
const { type, answer, options } = question
switch (type) {
case 'true-false':
return answer === true ? '正确' : '错误'
case 'multiple-choice':
const option = options?.find(opt => opt.key === answer)
return option ? `${answer}. ${option.value}` : String(answer)
case 'multiple-selection':
if (Array.isArray(answer)) {
return answer.map(key => {
const opt = options?.find(o => o.key === key)
return opt ? `${key}. ${opt.value}` : key
}).join('; ')
}
return String(answer)
case 'fill-in-blank':
if (Array.isArray(answer)) {
return answer.map((a, i) => `${i + 1}: ${a}`).join('; ')
}
return String(answer)
case 'short-answer':
return String(answer)
default:
return String(answer)
}
}
// 渲染选项(选择题和多选题)
const renderOptions = (question: Question) => {
// 判断题不显示选项
if (question.type === 'true-false') {
return null
}
if (!question.options || question.options.length === 0) {
return null
}
// 判断选项是否为正确答案
const isCorrectOption = (key: string): boolean => {
if (question.type === 'multiple-selection' && Array.isArray(question.answer)) {
return question.answer.includes(key)
}
return question.answer === key
}
return (
<div className={styles.options}>
{question.options.map(opt => {
const isCorrect = isCorrectOption(opt.key)
return (
<div
key={opt.key}
className={`${styles.option} ${isCorrect ? styles.correctOption : ''}`}
>
<Tag color={isCorrect ? 'success' : 'blue'}>{opt.key}</Tag>
<Text>{opt.value}</Text>
{isCorrect && (
<Tag color="success" className={styles.correctTag}>
<CheckCircleOutlined />
</Tag>
)}
</div>
)
})}
</div>
)
}
// 滚动到指定题目
const handleQuestionSelect = (index: number) => {
const questionCard = questionRefs.current.get(index)
if (questionCard) {
questionCard.scrollIntoView({
behavior: 'smooth',
block: 'center',
})
// 高亮效果
questionCard.style.transition = 'all 0.3s ease'
questionCard.style.transform = 'scale(1.02)'
questionCard.style.boxShadow = '0 8px 24px rgba(102, 126, 234, 0.3)'
setTimeout(() => {
questionCard.style.transform = 'scale(1)'
questionCard.style.boxShadow = ''
}, 600)
}
}
return (
<div className={styles.container}>
{/* 头部 */}
<div className={styles.header}>
<Button
type="text"
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/')}
className={styles.backButton}
>
</Button>
<Title level={5} className={styles.title}>
<BookOutlined />
</Title>
{/* 占位元素,保持标题居中 */}
<div className={styles.placeholder}></div>
</div>
{/* 筛选栏 */}
<Card className={styles.filterCard}>
<Input
placeholder="搜索题目内容或题目编号"
prefix={<SearchOutlined />}
value={searchKeyword}
onChange={e => setSearchKeyword(e.target.value)}
allowClear
size="large"
className={styles.searchInput}
/>
</Card>
{/* 题型选项卡 */}
<Card className={styles.tabsCard}>
<Tabs
activeKey={selectedType}
onChange={setSelectedType}
items={[
{
key: 'all',
label: (
<span>
<BookOutlined />
</span>
),
},
...Object.entries(questionTypeConfig).map(([type, config]) => ({
key: type,
label: (
<span>
{config.icon} {config.label}
</span>
),
})),
]}
/>
<Divider style={{ margin: '12px 0 0 0' }} />
<Text type="secondary">
{filteredQuestions.length}
</Text>
</Card>
{/* 题目列表 */}
<Spin spinning={loading}>
<List
dataSource={filteredQuestions}
renderItem={(question, index) => {
return (
<Card
key={question.id}
className={styles.questionCard}
ref={(el) => {
if (el) {
questionRefs.current.set(index, el)
}
}}
>
{/* 题目头部 */}
<div className={styles.questionHeader}>
<Space size="small">
<Text type="secondary" className={styles.questionNumber}>
{index + 1}
</Text>
<Tag color="blue">{question.category}</Tag>
</Space>
</div>
{/* 题目内容 */}
<div className={styles.questionContent}>
<Paragraph className={styles.contentText}>
{question.type === 'fill-in-blank'
? renderFillInBlankContent(
question.content,
Array.isArray(question.answer) ? question.answer : []
)
: question.content
}
</Paragraph>
{/* 判断题:在题目右侧显示答案标签 */}
{question.type === 'true-false' && (
<div className={styles.trueFalseAnswer}>
<Tag
color={question.answer === true ? 'success' : 'error'}
className={styles.trueFalseTag}
>
{question.answer === true ? <CheckCircleOutlined /> : <CloseCircleOutlined />} {question.answer === true ? '正确' : '错误'}
</Tag>
</div>
)}
</div>
{/* 选项 */}
{renderOptions(question)}
{/* 答案区域 */}
{/* 填空题:答案已经在横线上方,不需要显示 */}
{/* 选择题和多选题:答案已经标注在选项上,不需要显示 */}
{/* 判断题:答案已经在题目右侧,不需要显示 */}
{/* 论述题:不展示答案 */}
{/* 简答题:需要显示答案 */}
{question.type === 'short-answer' && (
<div className={styles.answerSection}>
<Space size="small">
<CheckCircleOutlined style={{ color: '#52c41a' }} />
<Text strong></Text>
</Space>
<Paragraph className={styles.answerText}>
{formatAnswer(question)}
</Paragraph>
</div>
)}
</Card>
)
}}
locale={{ emptyText: '暂无题目' }}
/>
</Spin>
{/* 题目导航抽屉 */}
<QuestionListDrawer
visible={drawerVisible}
onClose={() => setDrawerVisible(false)}
questions={filteredQuestions}
onQuestionSelect={handleQuestionSelect}
/>
{/* 悬浮按钮组 */}
{filteredQuestions.length > 0 && (
<FloatButton.Group
shape="circle"
style={{ right: 20, bottom: 20 }}
>
<FloatButton
icon={<VerticalAlignTopOutlined />}
tooltip="返回顶部"
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
/>
<FloatButton
icon={<UnorderedListOutlined />}
type="primary"
tooltip="题目导航"
onClick={() => setDrawerVisible(true)}
/>
</FloatButton.Group>
)}
</div>
)
}
export default QuestionList

View File

@ -1,61 +0,0 @@
.container {
min-height: 100vh;
background-color: #f5f5f5;
padding: 16px;
@media (min-width: 1025px) {
padding: 24px;
}
}
.header {
margin-bottom: 16px;
.headerContent {
display: flex;
justify-content: space-between;
align-items: center;
}
.title {
margin: 0;
font-size: 18px;
font-weight: 600;
}
}
.content {
:global {
.ant-table-wrapper {
overflow-x: auto;
}
}
}
// 移动端适配
@media (max-width: 768px) {
.container {
padding: 12px;
}
.header {
.headerContent {
flex-direction: column;
gap: 12px;
align-items: flex-start;
}
}
.content {
:global {
.ant-table {
font-size: 12px;
}
.ant-btn {
padding: 4px 8px;
font-size: 12px;
}
}
}
}

View File

@ -1,704 +0,0 @@
import React, { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import {
Table,
Button,
Space,
Modal,
Form,
Input,
Select,
message,
Popconfirm,
Tag,
Card,
Radio,
Divider,
} from 'antd'
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
ArrowLeftOutlined,
MinusCircleOutlined,
} from '@ant-design/icons'
import * as questionApi from '../api/question'
import type { Question } from '../types/question'
import styles from './QuestionManagement.module.less'
const { Option } = Select
const { TextArea } = Input
// 题型配置
const questionTypes = [
{ key: 'multiple-choice', label: '单选题' },
{ key: 'multiple-selection', label: '多选题' },
{ key: 'true-false', label: '判断题' },
{ key: 'fill-in-blank', label: '填空题' },
{ key: 'short-answer', label: '简答题' },
{ key: 'ordinary-essay', label: '普通涉密人员论述题' },
{ key: 'management-essay', label: '保密管理人员论述题' },
]
const QuestionManagement: React.FC = () => {
const navigate = useNavigate()
const [questions, setQuestions] = useState<Question[]>([])
const [loading, setLoading] = useState(false)
const [modalVisible, setModalVisible] = useState(false)
const [editingQuestion, setEditingQuestion] = useState<Question | null>(null)
const [form] = Form.useForm()
// 筛选和搜索状态
const [selectedType, setSelectedType] = useState<string>('')
const [searchText, setSearchText] = useState<string>('')
// 加载题目列表
const loadQuestions = async () => {
setLoading(true)
try {
const params: any = {}
if (selectedType) {
params.type = selectedType
}
if (searchText) {
params.search = searchText
}
const res = await questionApi.getQuestions(params)
if (res.success && res.data) {
setQuestions(res.data)
}
} catch (error) {
message.error('加载题目失败')
} finally {
setLoading(false)
}
}
useEffect(() => {
loadQuestions()
}, [selectedType, searchText])
// 打开新建/编辑弹窗
const handleOpenModal = (question?: Question) => {
if (question) {
setEditingQuestion(question)
// 直接使用后端返回的答案数据
let answerValue: any = question.answer
// 解析选项(单选题和多选题)
let optionsValue: Array<{ key: string; value: string }> = []
if (question.options && question.options.length > 0 &&
(question.type === 'multiple-choice' || question.type === 'multiple-selection')) {
optionsValue = question.options.map(opt => ({
key: opt.key,
value: opt.value,
}))
}
// 设置表单值
form.setFieldsValue({
type: question.type,
content: question.content,
answer: answerValue,
options: optionsValue,
})
} else {
setEditingQuestion(null)
form.resetFields()
// 新建时设置默认值 - 参照编辑逻辑
const defaultType = 'multiple-choice'
// 设置默认答案值
let defaultAnswer: any = ''
let defaultOptions: Array<{ key: string; value: string }> = []
// 为单选和多选题设置默认选项
if (defaultType === 'multiple-choice' || defaultType === 'multiple-selection') {
defaultOptions = [{ key: 'A', value: '' }, { key: 'B', value: '' }]
}
form.setFieldsValue({
type: defaultType,
content: '',
answer: defaultAnswer,
options: defaultOptions,
})
}
setModalVisible(true)
}
// 关闭弹窗
const handleCloseModal = () => {
setModalVisible(false)
setEditingQuestion(null)
form.resetFields()
}
// 保存题目
const handleSave = async () => {
try {
const values = await form.validateFields()
// 解析答案
let answer: any
if (values.type === 'true-false') {
answer = values.answer
} else if (values.type === 'multiple-choice') {
answer = values.answer
} else if (values.type === 'multiple-selection') {
// 多选题答案是数组
answer = values.answer
} else if (values.type === 'fill-in-blank') {
// 填空题答案是数组
answer = values.answer
} else if (values.type === 'short-answer' || values.type === 'ordinary-essay' || values.type === 'management-essay') {
answer = values.answer
} else {
answer = values.answer
}
// 解析选项(仅选择题和多选题需要)
let options: Record<string, string> | undefined
if (values.type === 'multiple-choice' || values.type === 'multiple-selection') {
if (values.options && values.options.length > 0) {
// 将数组格式转换为对象格式 { "A": "选项A", "B": "选项B" }
options = values.options.reduce((acc: Record<string, string>, opt: any) => {
if (opt && opt.key && opt.value) {
acc[opt.key] = opt.value
}
return acc
}, {})
}
}
// 构建请求数据
const data = {
type: values.type,
type_name: '', // 不再使用分类字段
question: values.content,
answer: answer,
options: options,
}
if (editingQuestion) {
// 更新
await questionApi.updateQuestion(editingQuestion.id, data)
message.success('更新成功')
} else {
// 创建
await questionApi.createQuestion(data)
message.success('创建成功')
}
handleCloseModal()
loadQuestions()
} catch (error) {
console.error('保存失败:', error)
message.error('保存失败')
}
}
// 删除题目
const handleDelete = async (id: number) => {
try {
await questionApi.deleteQuestion(id)
message.success('删除成功')
loadQuestions()
} catch (error) {
message.error('删除失败')
}
}
// 渲染填空题题目内容:将 **** 替换为带下划线的正确答案
const renderFillInBlankContent = (content: string, answer: string[] | any): React.ReactNode => {
// 确保答案是数组
const answers: string[] = Array.isArray(answer) ? answer : []
if (answers.length === 0) {
return content
}
// 找到所有的 **** 并替换为对应的答案
let answerIndex = 0
const parts = content.split('****')
return (
<span>
{parts.map((part, index) => (
<React.Fragment key={index}>
{part}
{index < parts.length - 1 && (
<span style={{
textDecoration: 'underline',
color: '#1890ff',
fontWeight: 500,
padding: '0 4px'
}}>
{answers[answerIndex++] || '____'}
</span>
)}
</React.Fragment>
))}
</span>
)
}
// 表格列定义
const columns = [
{
title: '题目编号',
dataIndex: 'question_id',
key: 'question_id',
width: 100,
},
{
title: '题型',
dataIndex: 'type',
key: 'type',
width: 180,
render: (type: string) => {
const typeConfig = questionTypes.find(t => t.key === type)
const colorMap: Record<string, string> = {
'multiple-choice': 'blue',
'multiple-selection': 'green',
'true-false': 'orange',
'fill-in-blank': 'purple',
'short-answer': 'magenta',
'ordinary-essay': 'pink',
'management-essay': 'red',
}
return <Tag color={colorMap[type]}>{typeConfig?.label || type}</Tag>
},
},
{
title: '题目内容',
dataIndex: 'content',
key: 'content',
ellipsis: true,
render: (content: string, record: Question) => {
// 如果是填空题,使用特殊渲染
if (record.type === 'fill-in-blank') {
return renderFillInBlankContent(content, record.answer)
}
return content
},
},
{
title: '操作',
key: 'action',
width: 150,
render: (_: any, record: Question) => (
<Space size="small">
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => handleOpenModal(record)}
>
</Button>
<Popconfirm
title="确定删除这道题目吗?"
onConfirm={() => handleDelete(record.id)}
okText="确定"
cancelText="取消"
>
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</Space>
),
},
]
// 根据题型动态渲染表单项
const renderFormByType = () => {
const type = form.getFieldValue('type')
switch (type) {
case 'true-false':
return (
<Form.Item
label="正确答案"
name="answer"
rules={[{ required: true, message: '请选择答案' }]}
>
<Radio.Group>
<Radio value="true"></Radio>
<Radio value="false"></Radio>
</Radio.Group>
</Form.Item>
)
case 'multiple-choice':
return (
<>
<Form.List
name="options"
rules={[
{
validator: async (_, options) => {
if (!options || options.length < 2) {
return Promise.reject(new Error('至少需要2个选项'))
}
},
},
]}
>
{(fields, { add, remove }, { errors }) => (
<>
<Form.Item label="选项" required>
{fields.map((field) => (
<Space key={field.key} style={{ display: 'flex', marginBottom: 8 }} align="baseline">
<Form.Item
{...field}
name={[field.name, 'key']}
rules={[{ required: true, message: '请输入选项键' }]}
noStyle
>
<Input placeholder="A" style={{ width: 60 }} />
</Form.Item>
<Form.Item
{...field}
name={[field.name, 'value']}
rules={[{ required: true, message: '请输入选项内容' }]}
noStyle
>
<Input placeholder="选项内容" style={{ width: 400 }} />
</Form.Item>
{fields.length > 2 && (
<MinusCircleOutlined onClick={() => remove(field.name)} />
)}
</Space>
))}
<Form.ErrorList errors={errors} />
<Button type="dashed" onClick={() => add()} block icon={<PlusOutlined />}>
</Button>
</Form.Item>
</>
)}
</Form.List>
<Form.Item noStyle shouldUpdate={(prevValues, currentValues) => prevValues.options !== currentValues.options}>
{({ getFieldValue }) => {
const options = getFieldValue('options') || []
const optionsList = options
.filter((opt: any) => opt && opt.key)
.map((opt: any) => ({
label: `${opt.key}. ${opt.value || '(请先填写选项内容)'}`,
value: opt.key,
}))
return (
<Form.Item
label="正确答案"
name="answer"
rules={[{ required: true, message: '请选择答案' }]}
>
<Select
placeholder="选择正确答案"
options={optionsList}
/>
</Form.Item>
)
}}
</Form.Item>
</>
)
case 'multiple-selection':
return (
<>
<Form.List
name="options"
rules={[
{
validator: async (_, options) => {
if (!options || options.length < 2) {
return Promise.reject(new Error('至少需要2个选项'))
}
},
},
]}
>
{(fields, { add, remove }, { errors }) => (
<>
<Form.Item label="选项" required>
{fields.map((field) => (
<Space key={field.key} style={{ display: 'flex', marginBottom: 8 }} align="baseline">
<Form.Item
{...field}
name={[field.name, 'key']}
rules={[{ required: true, message: '请输入选项键' }]}
noStyle
>
<Input placeholder="A" style={{ width: 60 }} />
</Form.Item>
<Form.Item
{...field}
name={[field.name, 'value']}
rules={[{ required: true, message: '请输入选项内容' }]}
noStyle
>
<Input placeholder="选项内容" style={{ width: 400 }} />
</Form.Item>
{fields.length > 2 && (
<MinusCircleOutlined onClick={() => remove(field.name)} />
)}
</Space>
))}
<Form.ErrorList errors={errors} />
<Button type="dashed" onClick={() => add()} block icon={<PlusOutlined />}>
</Button>
</Form.Item>
</>
)}
</Form.List>
<Form.Item noStyle shouldUpdate={(prevValues, currentValues) => prevValues.options !== currentValues.options}>
{({ getFieldValue }) => {
const options = getFieldValue('options') || []
const optionsList = options
.filter((opt: any) => opt && opt.key)
.map((opt: any) => ({
label: `${opt.key}. ${opt.value || '(请先填写选项内容)'}`,
value: opt.key,
}))
return (
<Form.Item
label="正确答案"
name="answer"
rules={[{ required: true, message: '请选择答案', type: 'array' }]}
tooltip="可以选择多个正确答案"
>
<Select
mode="multiple"
placeholder="选择正确答案(可多选)"
options={optionsList}
allowClear
showSearch
filterOption={(input, option) =>
String(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
}
/>
</Form.Item>
)
}}
</Form.Item>
</>
)
case 'fill-in-blank':
return (
<Form.List
name="answer"
rules={[
{
validator: async (_, answers) => {
if (!answers || answers.length < 1) {
return Promise.reject(new Error('至少需要1个答案'))
}
},
},
]}
>
{(fields, { add, remove }, { errors }) => (
<>
<Form.Item label="正确答案" required>
{fields.map((field, index) => (
<Space key={field.key} style={{ display: 'flex', marginBottom: 8 }} align="baseline">
<span>{['一', '二', '三', '四', '五', '六', '七', '八', '九', '十'][index] || (index + 1)}:</span>
<Form.Item
{...field}
rules={[{ required: true, message: '请输入答案' }]}
noStyle
>
<Input placeholder="填空答案" style={{ width: 400 }} />
</Form.Item>
{fields.length > 1 && (
<MinusCircleOutlined onClick={() => remove(field.name)} />
)}
</Space>
))}
<Form.ErrorList errors={errors} />
<Button type="dashed" onClick={() => add()} block icon={<PlusOutlined />}>
</Button>
</Form.Item>
</>
)}
</Form.List>
)
case 'short-answer':
case 'ordinary-essay':
case 'management-essay':
return (
<Form.Item
label="参考答案"
name="answer"
rules={[{ required: true, message: '请输入答案' }]}
>
<TextArea placeholder="输入参考答案" rows={4} />
</Form.Item>
)
default:
return (
<Form.Item
label="答案"
name="answer"
rules={[{ required: true, message: '请输入答案' }]}
>
<Input placeholder="输入答案" />
</Form.Item>
)
}
}
return (
<div className={styles.container}>
{/* 头部 */}
<Card className={styles.header}>
<div className={styles.headerContent}>
<Space>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/')}
>
</Button>
<Divider type="vertical" />
<h2 className={styles.title}></h2>
</Space>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => handleOpenModal()}
>
</Button>
</div>
</Card>
{/* 题目列表 */}
<Card className={styles.content}>
{/* 筛选和搜索 */}
<Space style={{ marginBottom: 16 }} size="middle">
<span></span>
<Select
style={{ width: 150 }}
placeholder="全部题型"
allowClear
value={selectedType || undefined}
onChange={(value) => setSelectedType(value || '')}
>
{questionTypes.map(type => (
<Option key={type.key} value={type.key}>
{type.label}
</Option>
))}
</Select>
<Input.Search
placeholder="搜索题目内容或编号"
style={{ width: 300 }}
allowClear
onSearch={(value) => setSearchText(value)}
onChange={(e) => {
if (!e.target.value) {
setSearchText('')
}
}}
/>
</Space>
<Table
columns={columns}
dataSource={questions}
loading={loading}
rowKey="id"
pagination={{
defaultPageSize: 20,
showSizeChanger: true,
showTotal: (total) => `${total} 道题目`,
}}
/>
</Card>
{/* 新建/编辑弹窗 */}
<Modal
title={editingQuestion ? '编辑题目' : '新建题目'}
open={modalVisible}
onOk={handleSave}
onCancel={handleCloseModal}
width={700}
okText="保存"
cancelText="取消"
>
<Form
form={form}
layout="vertical"
initialValues={{
type: 'multiple-choice',
options: [{ key: 'A', value: '' }, { key: 'B', value: '' }],
answer: '', // 添加默认answer字段
}}
>
<Form.Item
label="题型"
name="type"
rules={[{ required: true, message: '请选择题型' }]}
>
<Select
placeholder="选择题型"
onChange={(value) => {
// 切换题型时重置答案和选项
form.setFieldsValue({ answer: undefined, options: undefined })
// 为单选和多选题设置默认选项
if (value === 'multiple-choice' || value === 'multiple-selection') {
form.setFieldsValue({
options: [{ key: 'A', value: '' }, { key: 'B', value: '' }],
answer: value === 'multiple-choice' ? '' : [], // 单选空字符串,多选空数组
})
}
// 为判断题设置默认答案
else if (value === 'true-false') {
form.setFieldsValue({ answer: 'true' })
}
// 为填空题设置默认答案数组
else if (value === 'fill-in-blank') {
form.setFieldsValue({ answer: [''] })
}
// 为简答题和论述题设置默认空字符串
else if (value === 'short-answer' || value === 'ordinary-essay' || value === 'management-essay') {
form.setFieldsValue({ answer: '' })
}
}}
>
{questionTypes.map(type => (
<Option key={type.key} value={type.key}>
{type.label}
</Option>
))}
</Select>
</Form.Item>
<Form.Item
label="题目内容"
name="content"
rules={[{ required: true, message: '请输入题目内容' }]}
>
<TextArea rows={4} placeholder="输入题目内容" />
</Form.Item>
{renderFormByType()}
</Form>
</Modal>
</div>
)
}
export default QuestionManagement

View File

@ -1,92 +0,0 @@
.container {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
}
.loadingContainer {
display: flex;
justify-content: center;
align-items: center;
min-height: 400px;
}
.backButton {
margin-bottom: 20px;
}
.userInfoCard {
margin-bottom: 20px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.userHeader {
display: flex;
align-items: center;
gap: 20px;
padding-bottom: 24px;
border-bottom: 1px solid #f0f0f0;
}
.avatar {
border: 3px solid #f0f0f0;
flex-shrink: 0;
}
.userBasicInfo {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.statCard {
text-align: center;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
transition: all 0.3s ease;
&:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
transform: translateY(-2px);
}
}
.statCardContent {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 0;
}
.detailCard {
margin-bottom: 20px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.typeStatsCard {
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
// 移动端适配
@media (max-width: 768px) {
.container {
padding: 10px;
}
.userHeader {
flex-direction: column;
text-align: center;
}
.userBasicInfo {
align-items: center;
}
.backButton {
width: 100%;
}
}

View File

@ -1,290 +0,0 @@
import React, { useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import {
Card,
Button,
Typography,
message,
Tag,
Row,
Col,
Avatar,
Descriptions,
Progress,
Table,
Spin,
} from 'antd'
import {
ArrowLeftOutlined,
UserOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
} from '@ant-design/icons'
import * as questionApi from '../api/question'
import type { UserDetailStats } from '../api/question'
import styles from './UserDetail.module.less'
const { Title, Text } = Typography
const UserDetail: React.FC = () => {
const navigate = useNavigate()
const { id } = useParams<{ id: string }>()
const [loading, setLoading] = useState(false)
const [userDetail, setUserDetail] = useState<UserDetailStats | null>(null)
// 加载用户详情
const loadUserDetail = async () => {
if (!id) return
try {
setLoading(true)
const res = await questionApi.getUserDetailStats(Number(id))
if (res.success && res.data) {
setUserDetail(res.data)
}
} catch (error: any) {
console.error('加载用户详情失败:', error)
if (error.response?.status === 403) {
message.error('无权访问')
navigate('/user-management')
} else if (error.response?.status === 401) {
message.error('请先登录')
navigate('/login')
} else {
message.error('加载用户详情失败')
}
} finally {
setLoading(false)
}
}
useEffect(() => {
loadUserDetail()
}, [id])
// 格式化日期
const formatDate = (dateStr?: string) => {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
// 获取用户类型显示文本
const getUserTypeText = (type?: string) => {
if (!type) return '未设置'
return type === 'ordinary-person' ? '普通涉密人员' : '保密管理人员'
}
if (loading) {
return (
<div className={styles.loadingContainer}>
<Spin size="large" tip="加载中..." />
</div>
)
}
if (!userDetail) {
return null
}
const { user_info, type_stats } = userDetail
return (
<div className={styles.container}>
{/* 返回按钮 */}
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/user-management')}
className={styles.backButton}
>
</Button>
{/* 用户信息卡片 */}
<Card className={styles.userInfoCard}>
<div className={styles.userHeader}>
<Avatar
size={80}
src={user_info.avatar || undefined}
icon={<UserOutlined />}
className={styles.avatar}
/>
<div className={styles.userBasicInfo}>
<Title level={3} style={{ margin: 0 }}>
{user_info.nickname || user_info.username}
</Title>
<Text type="secondary" style={{ fontSize: 16 }}>
@{user_info.username}
</Text>
<Tag
color={user_info.user_type === 'ordinary-person' ? 'blue' : 'green'}
style={{ marginTop: 8 }}
>
{getUserTypeText(user_info.user_type)}
</Tag>
</div>
</div>
{/* 统计卡片 */}
<Row gutter={16} style={{ marginTop: 24 }}>
<Col xs={24} sm={12} md={6}>
<Card className={styles.statCard}>
<div className={styles.statCardContent}>
<Text type="secondary"></Text>
<Text strong style={{ fontSize: 24 }}>
{user_info.total_answers}
</Text>
</div>
</Card>
</Col>
<Col xs={24} sm={12} md={6}>
<Card className={styles.statCard}>
<div className={styles.statCardContent}>
<Text type="secondary"></Text>
<Text strong style={{ fontSize: 24, color: '#52c41a' }}>
<CheckCircleOutlined /> {user_info.correct_count}
</Text>
</div>
</Card>
</Col>
<Col xs={24} sm={12} md={6}>
<Card className={styles.statCard}>
<div className={styles.statCardContent}>
<Text type="secondary"></Text>
<Text strong style={{ fontSize: 24, color: '#ff4d4f' }}>
<CloseCircleOutlined /> {user_info.wrong_count}
</Text>
</div>
</Card>
</Col>
<Col xs={24} sm={12} md={6}>
<Card className={styles.statCard}>
<div className={styles.statCardContent}>
<Text type="secondary"></Text>
<Text
strong
style={{
fontSize: 24,
color:
user_info.accuracy >= 80
? '#52c41a'
: user_info.accuracy >= 60
? '#1890ff'
: '#faad14',
}}
>
{user_info.accuracy.toFixed(1)}%
</Text>
</div>
</Card>
</Col>
</Row>
{/* 正确率进度条 */}
<div style={{ marginTop: 24 }}>
<Text type="secondary" style={{ marginBottom: 8, display: 'block' }}>
</Text>
<Progress
percent={user_info.accuracy}
strokeColor={
user_info.accuracy >= 80
? '#52c41a'
: user_info.accuracy >= 60
? '#1890ff'
: '#faad14'
}
strokeWidth={12}
format={(percent) => `${percent?.toFixed(1)}%`}
/>
</div>
</Card>
{/* 详细信息 */}
<Card title="详细信息" className={styles.detailCard}>
<Descriptions bordered column={{ xs: 1, sm: 2 }}>
<Descriptions.Item label="用户名">{user_info.username}</Descriptions.Item>
<Descriptions.Item label="姓名">{user_info.nickname || '-'}</Descriptions.Item>
<Descriptions.Item label="用户类型">
<Tag color={user_info.user_type === 'ordinary-person' ? 'blue' : 'green'}>
{getUserTypeText(user_info.user_type)}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="总答题数">{user_info.total_answers}</Descriptions.Item>
<Descriptions.Item label="答对数">
<Tag color="success">{user_info.correct_count}</Tag>
</Descriptions.Item>
<Descriptions.Item label="答错数">
<Tag color="error">{user_info.wrong_count}</Tag>
</Descriptions.Item>
<Descriptions.Item label="注册时间">
{formatDate(user_info.created_at)}
</Descriptions.Item>
<Descriptions.Item label="最后答题">
{formatDate(user_info.last_answer_at)}
</Descriptions.Item>
</Descriptions>
</Card>
{/* 题型统计 */}
{type_stats.length > 0 && (
<Card title="题型统计" className={styles.typeStatsCard}>
<Table
dataSource={type_stats}
rowKey="question_type"
pagination={false}
columns={[
{
title: '题型',
dataIndex: 'question_type_name',
key: 'question_type_name',
render: (text: string) => <Text strong>{text}</Text>,
},
{
title: '答题数',
dataIndex: 'total_answers',
key: 'total_answers',
align: 'center',
sorter: (a, b) => a.total_answers - b.total_answers,
},
{
title: '答对数',
dataIndex: 'correct_count',
key: 'correct_count',
align: 'center',
render: (val: number) => <Tag color="success">{val}</Tag>,
},
{
title: '答错数',
key: 'wrong_count',
align: 'center',
render: (_, record) => (
<Tag color="error">{record.total_answers - record.correct_count}</Tag>
),
},
{
title: '正确率',
dataIndex: 'accuracy',
key: 'accuracy',
align: 'center',
sorter: (a, b) => a.accuracy - b.accuracy,
render: (val: number) => (
<Tag color={val >= 80 ? 'success' : val >= 60 ? 'processing' : 'warning'}>
{val.toFixed(1)}%
</Tag>
),
},
]}
/>
</Card>
)}
</div>
)
}
export default UserDetail

View File

@ -1,177 +0,0 @@
.container {
padding: 20px;
max-width: 1400px;
margin: 0 auto;
}
.headerCard {
margin-bottom: 20px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
:global {
.ant-card-body {
padding: 16px !important;
}
}
}
.header {
display: flex;
justify-content: center;
align-items: center;
position: relative;
margin-bottom: 8px;
.backButton {
color: #007aff;
font-weight: 500;
padding: 4px 12px;
transition: all 0.3s ease;
position: absolute;
left: 0;
&:hover {
color: #0051d5;
transform: translateX(-4px);
}
}
.title {
color: #1d1d1f !important;
margin: 0 !important;
font-weight: 700;
font-size: 18px !important;
text-align: center;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.02);
}
}
.statCard {
:global {
.ant-card-body {
padding: 16px !important;
}
}
}
.userCard {
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
transition: all 0.3s ease;
cursor: pointer;
&:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
transform: translateY(-2px);
}
}
.userCardHeader {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
margin-bottom: 16px;
}
.avatar {
margin-bottom: 12px;
border: 3px solid #f0f0f0;
}
.userInfo {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.statsSection {
display: flex;
justify-content: space-around;
padding: 16px 0;
border-top: 1px solid #f0f0f0;
border-bottom: 1px solid #f0f0f0;
margin-bottom: 16px;
}
.statItem {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.statLabel {
font-size: 12px;
}
.statValue {
font-size: 16px;
}
.progressSection {
margin-bottom: 12px;
}
.timeInfo {
display: flex;
flex-direction: column;
gap: 4px;
padding-top: 8px;
border-top: 1px solid #f0f0f0;
}
// 移动端适配
@media (max-width: 768px) {
.container {
padding: 10px 6px;
}
.headerCard {
margin-bottom: 10px;
:global {
.ant-space-item {
width: 100%;
}
}
}
.userCard {
margin-bottom: 8px;
.userCardHeader {
margin-bottom: 12px;
}
.avatar {
width: 48px;
height: 48px;
margin-bottom: 8px;
}
}
.statsSection {
flex-direction: row;
gap: 4px;
padding: 8px 0;
}
.statItem {
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
}
.statLabel {
font-size: 11px;
}
.statValue {
font-size: 14px;
}
}

View File

@ -1,484 +0,0 @@
import React, { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import {
Card,
Button,
Typography,
Space,
message,
Tag,
Statistic,
Row,
Col,
Avatar,
Progress,
Drawer,
Descriptions,
Table,
Spin,
} from 'antd'
import {
ArrowLeftOutlined,
UserOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
} from '@ant-design/icons'
import * as questionApi from '../api/question'
import type { UserStats, UserDetailStats } from '../api/question'
import styles from './UserManagement.module.less'
const { Title, Text } = Typography
const UserManagement: React.FC = () => {
const navigate = useNavigate()
const [loading, setLoading] = useState(false)
const [users, setUsers] = useState<UserStats[]>([])
const [drawerVisible, setDrawerVisible] = useState(false)
const [selectedUser, setSelectedUser] = useState<UserDetailStats | null>(null)
const [detailLoading, setDetailLoading] = useState(false)
// 加载用户列表
const loadUsers = async () => {
try {
setLoading(true)
const res = await questionApi.getAllUsersWithStats()
if (res.success && res.data) {
setUsers(res.data)
}
} catch (error: any) {
console.error('加载用户列表失败:', error)
if (error.response?.status === 403) {
message.error('无权访问,该功能仅限管理员使用')
navigate('/')
} else if (error.response?.status === 401) {
message.error('请先登录')
navigate('/login')
} else {
message.error('加载用户列表失败')
}
} finally {
setLoading(false)
}
}
useEffect(() => {
loadUsers()
}, [])
// 查看用户详情
const handleViewDetail = async (userId: number) => {
try {
setDetailLoading(true)
setDrawerVisible(true)
const res = await questionApi.getUserDetailStats(userId)
if (res.success && res.data) {
setSelectedUser(res.data)
}
} catch (error) {
message.error('加载用户详情失败')
setDrawerVisible(false)
} finally {
setDetailLoading(false)
}
}
// 格式化日期
const formatDate = (dateStr?: string) => {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
// 获取用户类型显示文本
const getUserTypeText = (type?: string) => {
if (!type) return '未设置'
return type === 'ordinary-person' ? '普通涉密人员' : '保密管理人员'
}
// 计算汇总统计
const totalStats = {
totalUsers: users.length,
totalAnswers: users.reduce((sum, u) => sum + u.total_answers, 0),
avgAccuracy:
users.length > 0
? users.reduce((sum, u) => sum + u.accuracy, 0) / users.length
: 0,
}
return (
<div className={styles.container}>
{/* 页面标题和统计 */}
<Card className={styles.headerCard}>
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<div className={styles.header}>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/')}
type="text"
className={styles.backButton}
>
</Button>
<Title level={3} className={styles.title}>
</Title>
</div>
<Row gutter={8}>
<Col xs={8} sm={8}>
<Card
className={styles.statCard}
style={{ padding: 0 }}
styles={{
body: {
padding: '16px',
}
}}
bodyStyle={{ padding: '16px' }}
>
<Statistic
title="总用户数"
value={totalStats.totalUsers}
prefix={<UserOutlined />}
valueStyle={{ color: '#1890ff' }}
/>
</Card>
</Col>
<Col xs={8} sm={8}>
<Card
className={styles.statCard}
style={{ padding: 0 }}
styles={{
body: {
padding: '16px',
}
}}
bodyStyle={{ padding: '16px' }}
>
<Statistic
title="总答题数"
value={totalStats.totalAnswers}
valueStyle={{ color: '#52c41a' }}
/>
</Card>
</Col>
<Col xs={8} sm={8}>
<Card
className={styles.statCard}
style={{ padding: 0 }}
styles={{
body: {
padding: '16px',
}
}}
bodyStyle={{ padding: '16px' }}
>
<Statistic
title="平均正确率"
value={totalStats.avgAccuracy.toFixed(1)}
suffix="%"
valueStyle={{ color: '#faad14' }}
/>
</Card>
</Col>
</Row>
</Space>
</Card>
{/* 用户卡片列表 */}
<Row gutter={[8, 8]}>
{users.map((user) => (
<Col xs={24} sm={12} md={12} lg={8} xl={6} key={user.user_id}>
<Card
className={styles.userCard}
styles={{
body: {
padding: '16px',
}
}}
hoverable
loading={loading}
onClick={() => handleViewDetail(user.user_id)}
>
{/* 用户基本信息 */}
<div className={styles.userCardHeader}>
<Avatar
size={64}
src={user.avatar || undefined}
icon={<UserOutlined />}
className={styles.avatar}
/>
<div className={styles.userInfo}>
<Title level={5} style={{ margin: 0 }}>
{user.nickname || user.username}
</Title>
<Text type="secondary">@{user.username}</Text>
<Tag
color={user.user_type === 'ordinary-person' ? 'blue' : 'green'}
style={{ marginTop: 4 }}
>
{getUserTypeText(user.user_type)}
</Tag>
</div>
</div>
{/* 统计数据 */}
<div className={styles.statsSection}>
<div className={styles.statItem}>
<Text type="secondary" className={styles.statLabel}>
</Text>
<Text strong className={styles.statValue}>
{user.total_answers}
</Text>
</div>
<div className={styles.statItem}>
<Text type="secondary" className={styles.statLabel}>
</Text>
<Text strong className={styles.statValue} style={{ color: '#52c41a' }}>
<CheckCircleOutlined /> {user.correct_count}
</Text>
</div>
<div className={styles.statItem}>
<Text type="secondary" className={styles.statLabel}>
</Text>
<Text strong className={styles.statValue} style={{ color: '#ff4d4f' }}>
<CloseCircleOutlined /> {user.wrong_count}
</Text>
</div>
</div>
{/* 正确率进度条 */}
<div className={styles.progressSection}>
<Text type="secondary" style={{ fontSize: 12 }}>
</Text>
<Progress
percent={user.accuracy}
strokeColor={
user.accuracy >= 80
? '#52c41a'
: user.accuracy >= 60
? '#1890ff'
: '#faad14'
}
format={(percent) => `${percent?.toFixed(1)}%`}
/>
</div>
{/* 时间信息 */}
<div className={styles.timeInfo}>
<Text type="secondary" style={{ fontSize: 12 }}>
{formatDate(user.created_at)}
</Text>
{user.last_answer_at && (
<Text type="secondary" style={{ fontSize: 12 }}>
{formatDate(user.last_answer_at)}
</Text>
)}
</div>
</Card>
</Col>
))}
</Row>
{/* 用户详情抽屉 */}
<Drawer
title="用户详细统计"
placement="right"
width={800}
open={drawerVisible}
onClose={() => setDrawerVisible(false)}
styles={{
body: { paddingTop: 12 }
}}
>
{detailLoading ? (
<div style={{ textAlign: 'center', padding: '50px 0' }}>
<Spin size="large">
<div style={{ paddingTop: 50 }}>...</div>
</Spin>
</div>
) : (
selectedUser && (
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
{/* 用户基本信息 */}
<div style={{ textAlign: 'center', paddingBottom: 24, borderBottom: '1px solid #f0f0f0' }}>
<Avatar
size={80}
src={selectedUser.user_info.avatar || undefined}
icon={<UserOutlined />}
style={{ marginBottom: 16 }}
/>
<Title level={4} style={{ margin: '0 0 8px 0' }}>
{selectedUser.user_info.nickname || selectedUser.user_info.username}
</Title>
<Text type="secondary">@{selectedUser.user_info.username}</Text>
<div style={{ marginTop: 8 }}>
<Tag color={selectedUser.user_info.user_type === 'ordinary-person' ? 'blue' : 'green'}>
{getUserTypeText(selectedUser.user_info.user_type)}
</Tag>
</div>
</div>
{/* 统计数据 */}
<div style={{
display: 'flex',
justifyContent: 'space-around',
padding: '16px 0',
borderBottom: '1px solid #f0f0f0'
}}>
<div style={{ textAlign: 'center' }}>
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
</Text>
<Text strong style={{ fontSize: 20, color: '#1890ff' }}>
{selectedUser.user_info.total_answers}
</Text>
</div>
<div style={{ textAlign: 'center' }}>
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
</Text>
<Text strong style={{ fontSize: 20, color: '#52c41a' }}>
<CheckCircleOutlined /> {selectedUser.user_info.correct_count}
</Text>
</div>
<div style={{ textAlign: 'center' }}>
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
</Text>
<Text strong style={{ fontSize: 20, color: '#ff4d4f' }}>
<CloseCircleOutlined /> {selectedUser.user_info.wrong_count}
</Text>
</div>
<div style={{ textAlign: 'center' }}>
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
</Text>
<Text
strong
style={{
fontSize: 20,
color: selectedUser.user_info.accuracy >= 80
? '#52c41a'
: selectedUser.user_info.accuracy >= 60
? '#1890ff'
: '#faad14'
}}
>
{selectedUser.user_info.accuracy.toFixed(1)}%
</Text>
</div>
</div>
{/* 正确率进度条 */}
<div>
<Text type="secondary" style={{ marginBottom: 8, display: 'block' }}>
</Text>
<Progress
percent={selectedUser.user_info.accuracy}
strokeColor={
selectedUser.user_info.accuracy >= 80
? '#52c41a'
: selectedUser.user_info.accuracy >= 60
? '#1890ff'
: '#faad14'
}
strokeWidth={12}
format={(percent) => `${percent?.toFixed(1)}%`}
/>
</div>
{/* 详细信息 */}
<Descriptions bordered column={1} size="small">
<Descriptions.Item label="用户名">
{selectedUser.user_info.username}
</Descriptions.Item>
<Descriptions.Item label="姓名">
{selectedUser.user_info.nickname || '-'}
</Descriptions.Item>
<Descriptions.Item label="用户类型">
<Tag color={selectedUser.user_info.user_type === 'ordinary-person' ? 'blue' : 'green'}>
{getUserTypeText(selectedUser.user_info.user_type)}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="注册时间">
{formatDate(selectedUser.user_info.created_at)}
</Descriptions.Item>
<Descriptions.Item label="最后答题">
{formatDate(selectedUser.user_info.last_answer_at)}
</Descriptions.Item>
</Descriptions>
{/* 题型统计 */}
{selectedUser.type_stats && selectedUser.type_stats.length > 0 && (
<>
<Title level={5}></Title>
<Table
dataSource={selectedUser.type_stats}
rowKey="question_type"
pagination={false}
size="small"
columns={[
{
title: '题型',
dataIndex: 'question_type_name',
key: 'question_type_name',
render: (text: string) => <Text strong>{text}</Text>,
},
{
title: '答题数',
dataIndex: 'total_answers',
key: 'total_answers',
align: 'center',
sorter: (a, b) => a.total_answers - b.total_answers,
},
{
title: '答对数',
dataIndex: 'correct_count',
key: 'correct_count',
align: 'center',
render: (val: number) => <Tag color="success">{val}</Tag>,
},
{
title: '答错数',
key: 'wrong_count',
align: 'center',
render: (_, record) => (
<Tag color="error">{record.total_answers - record.correct_count}</Tag>
),
},
{
title: '正确率',
dataIndex: 'accuracy',
key: 'accuracy',
align: 'center',
sorter: (a, b) => a.accuracy - b.accuracy,
render: (val: number) => (
<Tag color={val >= 80 ? 'success' : val >= 60 ? 'processing' : 'warning'}>
{val.toFixed(1)}%
</Tag>
),
},
]}
/>
</>
)}
</Space>
)
)}
</Drawer>
</div>
)
}
export default UserManagement

View File

@ -1,531 +0,0 @@
.container {
min-height: 100vh;
background: #fafafa;
padding: 0;
}
.header {
padding: 20px;
padding-bottom: 16px;
background: transparent;
display: flex;
justify-content: center;
align-items: center;
position: relative;
.backButton {
color: #007aff;
font-weight: 500;
padding: 4px 12px;
transition: all 0.3s ease;
position: absolute;
left: 20px;
&:hover {
color: #0051d5;
transform: translateX(-4px);
}
}
.title {
color: #1d1d1f !important;
margin: 0 !important;
font-weight: 700;
font-size: 18px !important;
text-align: center;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.02);
}
}
// 统计卡片容器
.statsContainer {
padding: 0 20px 16px;
}
.statCard {
border-radius: 16px;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(30px) saturate(180%);
-webkit-backdrop-filter: blur(30px) saturate(180%);
box-shadow:
0 2px 12px rgba(0, 0, 0, 0.05),
0 1px 4px rgba(0, 0, 0, 0.03),
0 0 0 1px rgba(0, 0, 0, 0.03);
border: 0.5px solid rgba(0, 0, 0, 0.04);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&:hover {
transform: translateY(-4px);
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.08),
0 4px 16px rgba(0, 0, 0, 0.04),
0 0 0 1px rgba(0, 0, 0, 0.04);
}
:global {
.ant-statistic-title {
font-size: 13px;
color: rgba(0, 0, 0, 0.45);
margin-bottom: 4px;
}
.ant-statistic-content {
font-size: 28px;
}
}
}
// 筛选卡片
.filterCard {
margin: 0 20px 16px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(30px) saturate(180%);
-webkit-backdrop-filter: blur(30px) saturate(180%);
box-shadow:
0 2px 12px rgba(0, 0, 0, 0.05),
0 1px 4px rgba(0, 0, 0, 0.03),
0 0 0 1px rgba(0, 0, 0, 0.03);
border: 0.5px solid rgba(0, 0, 0, 0.04);
}
.filterContent {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.filterLeft {
flex: 1;
min-width: 300px;
}
.filterRight {
flex-shrink: 0;
}
// 操作按钮卡片(已废弃,保留样式以防需要)
.actionCard {
margin: 0 20px 16px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(30px) saturate(180%);
-webkit-backdrop-filter: blur(30px) saturate(180%);
box-shadow:
0 2px 12px rgba(0, 0, 0, 0.05),
0 1px 4px rgba(0, 0, 0, 0.03),
0 0 0 1px rgba(0, 0, 0, 0.03);
border: 0.5px solid rgba(0, 0, 0, 0.04);
:global {
.ant-card-body {
padding: 16px 20px;
}
}
}
.primaryButton {
background: #1890ff;
border: none;
box-shadow: 0 2px 8px rgba(24, 144, 255, 0.3);
transition: all 0.3s ease;
min-width: 120px;
&:hover {
background: #40a9ff;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.4);
}
&:disabled {
background: #d9d9d9;
box-shadow: none;
transform: none;
}
}
.clearButton {
background: #fff;
border: 1px solid #d9d9d9;
color: rgba(0, 0, 0, 0.65);
transition: all 0.3s ease;
min-width: 110px;
&:hover {
color: #ff4d4f;
border-color: #ff4d4f;
background: #fff1f0;
transform: translateY(-2px);
box-shadow: 0 2px 8px rgba(255, 77, 79, 0.2);
}
&:disabled {
color: rgba(0, 0, 0, 0.25);
background: #f5f5f5;
border-color: #d9d9d9;
transform: none;
}
}
.listCard {
margin: 0 20px 20px;
border-radius: 20px;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(30px) saturate(180%);
-webkit-backdrop-filter: blur(30px) saturate(180%);
box-shadow:
0 2px 12px rgba(0, 0, 0, 0.05),
0 1px 4px rgba(0, 0, 0, 0.03),
0 0 0 1px rgba(0, 0, 0, 0.03);
border: 0.5px solid rgba(0, 0, 0, 0.04);
padding-bottom: 60px;
:global {
.ant-list-item {
border: none !important;
padding: 0 !important;
}
}
}
.listItem {
padding: 0 !important;
margin-bottom: 16px !important;
&:last-child {
margin-bottom: 0 !important;
}
}
// 题目卡片
.questionCard {
width: 100%;
padding: 20px;
border-radius: 16px;
background: #fff;
border: 1px solid rgba(0, 0, 0, 0.06);
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.03),
0 1px 6px -1px rgba(0, 0, 0, 0.02),
0 2px 4px rgba(0, 0, 0, 0.02);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
}
.questionHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
flex-wrap: wrap;
gap: 12px;
}
.questionId {
font-size: 16px;
color: #1d1d1f;
font-weight: 600;
}
.deleteButton {
color: #ff4d4f;
transition: all 0.3s ease;
&:hover {
color: #ff7875;
background: rgba(255, 77, 79, 0.08);
}
}
// 题目内容
.questionContent {
color: #1d1d1f;
font-size: 15px;
line-height: 1.6;
margin-bottom: 12px;
}
// 答案区域
.answerSection {
margin-bottom: 12px;
padding: 0;
}
.answerRow {
display: flex;
gap: 8px;
align-items: flex-start;
}
.answerLabel {
font-weight: 600;
white-space: nowrap;
font-size: 14px;
}
.answerValue {
flex: 1;
font-size: 14px;
word-break: break-word;
}
// 掌握度进度条区域
.masteryProgress {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid rgba(0, 0, 0, 0.06);
}
.progressHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.progressLabel {
font-size: 13px;
font-weight: 600;
color: #1d1d1f;
}
.masteryTag {
font-weight: 600;
border-radius: 6px;
font-size: 12px;
}
.questionFooter {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 16px;
margin-top: 16px;
border-top: 1px solid rgba(0, 0, 0, 0.06);
}
// 响应式设计 - 移动端
@media (max-width: 768px) {
.container {
padding: 0;
}
.header {
padding: 16px;
.backButton {
font-size: 14px;
padding: 4px 8px;
left: 16px;
}
.title {
font-size: 16px !important;
}
}
.statsContainer {
padding: 0 16px 12px;
}
.statCard {
border-radius: 12px;
:global {
.ant-statistic-title {
font-size: 12px;
}
.ant-statistic-content {
font-size: 24px;
}
}
}
.filterCard {
margin: 0 16px 12px;
border-radius: 12px;
}
.filterContent {
flex-direction: column;
align-items: stretch;
gap: 12px;
}
.filterLeft {
min-width: auto;
width: 100%;
}
.filterRight {
width: 100%;
:global {
.ant-space {
width: 100%;
justify-content: stretch;
.ant-space-item {
flex: 1;
button {
width: 100%;
}
}
}
}
}
.primaryButton,
.clearButton {
min-width: auto;
font-size: 14px;
}
.actionCard {
margin: 0 16px 12px;
border-radius: 12px;
:global {
.ant-card-body {
padding: 12px 16px;
}
}
}
.listCard {
margin: 0 16px 16px;
border-radius: 16px;
}
.questionCard {
padding: 16px;
border-radius: 12px;
}
.questionHeader {
gap: 8px;
margin-bottom: 12px;
}
.questionContent {
font-size: 14px;
}
.masteryProgress {
margin-top: 12px;
padding-top: 12px;
}
.progressHeader {
margin-bottom: 10px;
}
.progressLabel {
font-size: 12px;
}
.masteryTag {
font-size: 11px;
}
.questionFooter {
padding-top: 12px;
margin-top: 12px;
}
.questionId {
font-size: 15px;
}
}
// 响应式设计 - PC端
@media (min-width: 769px) {
.container {
max-width: 1400px;
margin: 0 auto;
}
.header {
padding: 32px 32px 24px;
.title {
font-size: 20px !important;
}
}
.statsContainer {
padding: 0 32px 20px;
}
.filterCard {
margin: 0 32px 20px;
}
.actionCard {
margin: 0 32px 20px;
}
.listCard {
margin: 0 32px 32px;
padding-bottom: 0;
}
.questionCard {
padding: 24px;
}
.questionHeader {
margin-bottom: 20px;
}
.questionContent {
font-size: 16px;
}
.answerSection {
margin-bottom: 16px;
}
.masteryProgress {
margin-top: 20px;
padding-top: 20px;
}
.progressHeader {
margin-bottom: 12px;
}
.progressLabel {
font-size: 14px;
}
.masteryTag {
font-size: 12px;
}
.questionFooter {
padding-top: 20px;
margin-top: 20px;
}
}
// 响应式设计 - 超宽屏
@media (min-width: 1600px) {
.container {
max-width: 1600px;
}
.header {
.title {
font-size: 22px !important;
}
}
}

View File

@ -1,415 +0,0 @@
import React, { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Card, List, Button, Tag, Typography, Space, message, Modal, Empty, Statistic, Progress, Select, Row, Col } from 'antd'
import {
CloseCircleOutlined,
ArrowLeftOutlined,
PlayCircleOutlined,
DeleteOutlined,
TrophyOutlined,
FireOutlined,
CheckCircleOutlined,
FilterOutlined,
} from '@ant-design/icons'
import * as questionApi from '../api/question'
import type { WrongQuestion, WrongQuestionStats, WrongQuestionFilter } from '../types/question'
import styles from './WrongQuestions.module.less'
const { Title, Text } = Typography
const { Option } = Select
const WrongQuestions: React.FC = () => {
const navigate = useNavigate()
const [loading, setLoading] = useState(false)
const [wrongQuestions, setWrongQuestions] = useState<WrongQuestion[]>([])
const [stats, setStats] = useState<WrongQuestionStats | null>(null)
const [filter, setFilter] = useState<WrongQuestionFilter>({})
// 加载错题列表
const loadWrongQuestions = async () => {
try {
setLoading(true)
const res = await questionApi.getWrongQuestions(filter)
if (res.success && res.data) {
setWrongQuestions(res.data)
}
} catch (error: any) {
console.error('加载错题列表失败:', error)
if (error.response?.status === 401) {
message.error('请先登录')
navigate('/login')
} else {
message.error('加载错题列表失败')
}
} finally {
setLoading(false)
}
}
// 加载统计数据
const loadStats = async () => {
try {
const res = await questionApi.getWrongQuestionStats()
if (res.success && res.data) {
setStats(res.data)
}
} catch (error) {
console.error('加载统计数据失败:', error)
}
}
useEffect(() => {
loadWrongQuestions()
loadStats()
}, [filter])
// 清空错题本
const handleClear = () => {
Modal.confirm({
title: '确认清空错题本?',
content: '清空后将无法恢复,请确认操作',
okText: '确认清空',
cancelText: '取消',
okType: 'danger',
onOk: async () => {
try {
const res = await questionApi.clearWrongQuestions()
if (res.success) {
message.success('已清空错题本')
loadWrongQuestions()
loadStats()
}
} catch (error) {
message.error('清空失败')
}
},
})
}
// 删除单个错题
const handleDelete = (id: number) => {
Modal.confirm({
title: '确定要删除这道错题吗?',
content: '删除后将无法恢复',
okText: '确定',
cancelText: '取消',
onOk: async () => {
try {
const res = await questionApi.deleteWrongQuestion(id)
if (res.success) {
message.success('已删除')
loadWrongQuestions()
loadStats()
}
} catch (error) {
message.error('删除失败')
}
},
})
}
// 开始错题练习
const handlePractice = () => {
// 跳转到答题页面,错题练习模式
navigate('/question?mode=wrong')
}
// 格式化答案显示
const formatAnswer = (answer: string | string[], questionType: string) => {
if (questionType === 'true-false') {
const strAnswer = String(answer)
return strAnswer === 'true' ? '正确' : '错误'
}
if (Array.isArray(answer)) {
return answer.join(', ')
}
return String(answer)
}
// 渲染填空题内容(将 **** 替换为下划线)
const renderFillInBlankContent = (content: string) => {
const parts = content.split('****')
if (parts.length === 1) {
return content
}
return (
<span>
{parts.map((part, index) => (
<React.Fragment key={index}>
{part}
{index < parts.length - 1 && (
<span> ________ </span>
)}
</React.Fragment>
))}
</span>
)
}
// 获取题型标签颜色
const getTypeColor = (type: string) => {
const colorMap: Record<string, string> = {
'multiple-choice': 'blue',
'multiple-selection': 'green',
'fill-in-blank': 'cyan',
'true-false': 'orange',
'short-answer': 'purple',
}
return colorMap[type] || 'default'
}
// 获取掌握度进度条颜色
const getMasteryColor = (level: number): string => {
if (level === 0) return '#ff4d4f'
if (level < 30) return '#ff7a45'
if (level < 60) return '#ffa940'
if (level < 100) return '#52c41a'
return '#1890ff'
}
// 获取掌握度标签
const getMasteryLabel = (level: number): { text: string; color: string } => {
if (level === 0) return { text: '未掌握', color: 'error' }
if (level < 30) return { text: '较差', color: 'error' }
if (level < 60) return { text: '一般', color: 'warning' }
if (level < 100) return { text: '良好', color: 'success' }
return { text: '已掌握', color: 'success' }
}
return (
<div className={styles.container}>
{/* 头部 */}
<div className={styles.header}>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/')}
type="text"
className={styles.backButton}
>
</Button>
<Title level={3} className={styles.title}>
</Title>
</div>
{/* 统计卡片 */}
<Row gutter={[16, 16]} className={styles.statsContainer}>
<Col xs={12} sm={6}>
<Card className={styles.statCard}>
<Statistic
title="错题总数"
value={stats?.total_wrong || 0}
valueStyle={{ color: '#ff4d4f', fontSize: '28px', fontWeight: 'bold' }}
prefix={<CloseCircleOutlined />}
/>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card className={styles.statCard}>
<Statistic
title="已掌握"
value={stats?.mastered || 0}
valueStyle={{ color: '#52c41a', fontSize: '28px', fontWeight: 'bold' }}
prefix={<CheckCircleOutlined />}
/>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card className={styles.statCard}>
<Statistic
title="未掌握"
value={stats?.not_mastered || 0}
valueStyle={{ color: '#faad14', fontSize: '28px', fontWeight: 'bold' }}
prefix={<FireOutlined />}
/>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card className={styles.statCard}>
<Statistic
title="掌握率"
value={stats?.total_wrong ? Math.round((stats.mastered / stats.total_wrong) * 100) : 0}
valueStyle={{ color: '#1890ff', fontSize: '28px', fontWeight: 'bold' }}
prefix={<TrophyOutlined />}
suffix="%"
/>
</Card>
</Col>
</Row>
{/* 筛选和操作区域 */}
<Card className={styles.filterCard}>
<div className={styles.filterContent}>
<Space wrap className={styles.filterLeft}>
<Space>
<FilterOutlined />
<Text strong></Text>
</Space>
<Select
placeholder="掌握状态"
style={{ width: 120 }}
allowClear
onChange={(value) => setFilter({ ...filter, is_mastered: value })}
>
<Option value={false}></Option>
<Option value={true}></Option>
</Select>
<Select
placeholder="排序方式"
style={{ width: 140 }}
defaultValue="time"
onChange={(value: 'time' | 'wrong_count' | 'mastery_level') => setFilter({ ...filter, sort: value })}
>
<Option value="time"></Option>
<Option value="wrong_count"></Option>
<Option value="mastery_level"></Option>
</Select>
</Space>
<Space size="middle" className={styles.filterRight}>
<Button
type="primary"
icon={<PlayCircleOutlined />}
onClick={handlePractice}
disabled={!wrongQuestions.length || (stats?.total_wrong === stats?.mastered && (stats?.total_wrong ?? 0) > 0)}
className={styles.primaryButton}
>
</Button>
<Button
icon={<DeleteOutlined />}
onClick={handleClear}
disabled={!wrongQuestions.length}
className={styles.clearButton}
>
</Button>
</Space>
</div>
</Card>
{/* 错题列表 */}
<Card className={styles.listCard}>
{wrongQuestions.length === 0 ? (
<Empty
description="暂无错题,继续加油!"
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
) : (
<List
loading={loading}
dataSource={wrongQuestions}
renderItem={(item) => {
const masteryLabel = getMasteryLabel(item.mastery_level)
return (
<List.Item
key={item.id}
className={styles.listItem}
>
<div className={styles.questionCard}>
{/* 题目头部 */}
<div className={styles.questionHeader}>
<Space wrap>
<Text strong className={styles.questionId}>
{item.question?.question_id || item.question?.id || item.question_id}
</Text>
{item.question && (
<Tag color={getTypeColor(item.question.type)}>
{item.question.category || item.question.type}
</Tag>
)}
<Tag color="error" icon={<CloseCircleOutlined />}>
{item.total_wrong_count}
</Tag>
{item.is_mastered && (
<Tag color="success" icon={<CheckCircleOutlined />}>
</Tag>
)}
</Space>
<Button
type="text"
danger
size="small"
icon={<DeleteOutlined />}
onClick={() => handleDelete(item.id)}
className={styles.deleteButton}
>
</Button>
</div>
{/* 题目内容 */}
{item.question && (
<div className={styles.questionContent}>
<Text>
{item.question.type === 'fill-in-blank'
? renderFillInBlankContent(item.question.content)
: item.question.content}
</Text>
</div>
)}
{/* 答案信息 */}
{item.recent_history && item.recent_history.length > 0 && (
<div className={styles.answerSection}>
<div className={styles.answerRow}>
<Text type="danger" className={styles.answerLabel}>
</Text>
<Text className={styles.answerValue}>
{formatAnswer(item.recent_history[0].user_answer, item.question?.type || '')}
</Text>
</div>
<div className={styles.answerRow}>
<Text type="success" className={styles.answerLabel}>
</Text>
<Text className={styles.answerValue}>
{formatAnswer(item.recent_history[0].correct_answer, item.question?.type || '')}
</Text>
</div>
</div>
)}
{/* 掌握度进度条 */}
<div className={styles.masteryProgress}>
<div className={styles.progressHeader}>
<Text className={styles.progressLabel}></Text>
<Tag color={masteryLabel.color} className={styles.masteryTag}>
{masteryLabel.text}
</Tag>
</div>
<Progress
percent={item.mastery_level}
strokeColor={getMasteryColor(item.mastery_level)}
strokeWidth={8}
showInfo={true}
format={(percent) => `${percent}%`}
/>
</div>
{/* 底部信息 */}
<div className={styles.questionFooter}>
<Text type="secondary" style={{ fontSize: '12px' }}>
{new Date(item.last_wrong_time).toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})}
</Text>
</div>
</div>
</List.Item>
)
}}
/>
)}
</Card>
</div>
)
}
export default WrongQuestions

View File

@ -1,248 +0,0 @@
import { Question } from './question'
// ========== 新版数据结构 ==========
// 题型配置
export interface QuestionTypeConfig {
type: string
count: number
score: number
}
// 试卷配置
export interface ExamConfig {
question_types: QuestionTypeConfig[]
categories?: string[]
random_order: boolean
}
// 试卷模型
export interface ExamModel {
id: number
user_id: number
title: string
total_score: number
duration: number // 分钟
pass_score: number
question_ids: number[]
status: 'active' | 'archived'
created_at: string
updated_at: string
}
// AI评分结果
export interface AIGrading {
score: number
feedback: string
suggestion: string
}
// 考试答案
export interface ExamAnswer {
question_id: number
answer: any
correct_answer: any
is_correct: boolean
score: number
ai_grading?: AIGrading
}
// 考试记录
export interface ExamRecord {
id: number
exam_id: number
user_id: number
start_time?: string
submit_time?: string
time_spent: number // 秒
score: number
total_score: number
answers: ExamAnswer[]
status: 'in_progress' | 'submitted' | 'graded'
is_passed: boolean
exam?: ExamModel
user?: { // 用户信息(共享试卷时返回)
id: number
username: string
nickname: string
avatar: string
}
created_at: string
updated_at: string
}
// 创建试卷请求
export interface CreateExamRequest {
title: string
duration: number
pass_score?: number
question_types: QuestionTypeConfig[]
categories?: string[]
random_order?: boolean
}
// 创建试卷响应
export interface CreateExamResponse {
id: number
title: string
total_score: number
duration: number
pass_score: number
question_count: number
created_at: string
}
// 试卷列表响应
export type ExamListResponse = Array<{
id: number
title: string
total_score: number
duration: number
pass_score: number
question_count: number
attempt_count: number
best_score: number
has_in_progress_exam: boolean
in_progress_record_id?: number
participant_count: number // 共享试卷的参与人数
is_shared: boolean // 是否为被分享的试卷(原:是否为分享副本)
shared_by_id?: number // 分享人ID已废弃使用 shared_by
shared_by?: { // 分享人信息(原:副本创建者)
id: number
username: string
nickname: string
}
created_at: string
}>
// 试卷详情响应
export interface ExamDetailResponse {
exam: ExamModel
questions: Question[]
}
// 开始考试响应
export interface StartExamResponse {
record_id: number
start_time: string
duration: number
}
// 提交试卷响应
export interface SubmitExamResponse {
record_id?: number // 后端返回的考试记录ID
score?: number
total_score?: number
is_passed?: boolean
time_spent?: number
status?: string
answers?: ExamAnswer[]
detailed_results?: Record<string, {
correct: boolean
score: number
message?: string
ai_grading?: {
score: number
feedback: string
suggestion: string
}
}>
}
// 考试记录响应
export interface ExamRecordResponse {
record: ExamRecord
answers: ExamAnswer[]
}
// 考试记录列表响应
export type ExamRecordListResponse = ExamRecord[]
// ========== 旧版数据结构(兼容) ==========
// 考试记录
export interface Exam {
id: number
user_id: number
question_ids: string
answers: string
score: number
status: 'draft' | 'submitted'
submitted_at?: string
created_at: string
updated_at: string
}
// 考试题目配置
export interface ExamQuestionConfig {
fill_in_blank: number // 填空题数量
true_false: number // 判断题数量
multiple_choice: number // 单选题数量
multiple_selection: number // 多选题数量
short_answer: number // 简答题数量
ordinary_essay: number // 普通涉密人员论述题数量
management_essay: number // 保密管理人员论述题数量
}
// 考试分值配置
export interface ExamScoreConfig {
fill_in_blank: number // 填空题分值
true_false: number // 判断题分值
multiple_choice: number // 单选题分值
multiple_selection: number // 多选题分值
essay: number // 论述题分值
}
// 生成考试响应
export interface GenerateExamResponse {
exam_id: number
question_ids: number[]
created_at: string
}
// 获取考试响应
export interface GetExamResponse {
exam: Exam
questions: Question[]
}
// 提交考试请求
export interface SubmitExamRequest {
answers?: Record<string, any> // question_id -> answer (可选,后端会从数据库读取)
essay_choice?: 'ordinary' | 'management' // 论述题选择
}
// 提交考试响应(旧版)
export interface SubmitExamResponseOld {
score: number
detailed_results: Record<string, {
correct: boolean
score: number
message?: string
ai_grading?: {
score: number
feedback: string
suggestion: string
}
}>
}
// 默认配置
export const DEFAULT_EXAM_CONFIG: ExamQuestionConfig = {
fill_in_blank: 10,
true_false: 10,
multiple_choice: 10,
multiple_selection: 10,
short_answer: 2,
ordinary_essay: 1,
management_essay: 1,
}
export const DEFAULT_SCORE_CONFIG: ExamScoreConfig = {
fill_in_blank: 2.0,
true_false: 2.0,
multiple_choice: 1.0,
multiple_selection: 2.5,
essay: 25.0,
}

View File

@ -1,5 +1,5 @@
// 题目类型 - 使用数据库中的实际类型
export type QuestionType = 'multiple-choice' | 'multiple-selection' | 'fill-in-blank' | 'true-false' | 'short-answer' | 'ordinary-essay' | 'management-essay'
// 题目类型
export type QuestionType = 'single' | 'multiple' | 'fill' | 'judge' | 'short'
// 选项
export interface Option {
@ -10,37 +10,23 @@ export interface Option {
// 题目
export interface Question {
id: number
question_id: string // 题目编号
type: QuestionType
content: string
options: Option[]
category: string
answer?: any // 正确答案(用于题目管理编辑)
answer_lengths?: number[] // 答案长度数组(用于打印时计算横线长度)
}
// 提交答案
export interface SubmitAnswer {
question_id: number
answer: string | string[] | boolean
}
// AI评分结果
export interface AIGrading {
score: number // 得分 (0-100)
feedback: string // 评语
suggestion: string // 改进建议
reference_answer?: string // 参考答案(论述题)
scoring_rationale?: string // 评分依据
answer: string | string[]
}
// 答案结果
export interface AnswerResult {
correct: boolean
user_answer: string | string[] | boolean
correct_answer: string | string[]
explanation?: string
ai_grading?: AIGrading // AI评分结果简答题和论述题
}
// 统计数据
@ -48,8 +34,6 @@ export interface Statistics {
total_questions: number
answered_questions: number
correct_answers: number
wrong_questions: number
total_answers: number // 刷题次数
accuracy: number
}
@ -60,53 +44,3 @@ export interface ApiResponse<T> {
message?: string
total?: number
}
// 错题历史记录
export interface WrongQuestionHistory {
id: number
user_answer: string | string[]
correct_answer: string | string[]
answered_at: string
time_spent: number // 答题用时(秒)
is_correct: boolean
}
// 错题记录
export interface WrongQuestion {
id: number
question_id: number
question?: Question
first_wrong_time: string // 首次错误时间
last_wrong_time: string // 最后错误时间
total_wrong_count: number // 总错误次数
mastery_level: number // 掌握度 (0-100)
consecutive_correct: number // 连续答对次数
is_mastered: boolean // 是否已掌握
recent_history?: WrongQuestionHistory[] // 最近的历史记录
}
// 错题趋势数据点
export interface TrendPoint {
date: string
count: number
}
// 错题统计
export interface WrongQuestionStats {
total_wrong: number // 总错题数
mastered: number // 已掌握数
not_mastered: number // 未掌握数
need_review: number // 需要复习数
type_stats: Record<string, number> // 按题型统计
category_stats: Record<string, number> // 按分类统计
mastery_level_dist: Record<string, number> // 掌握度分布
trend_data: TrendPoint[] // 错题趋势最近7天
}
// 错题筛选参数
export interface WrongQuestionFilter {
is_mastered?: boolean
tag?: string
type?: QuestionType
sort?: 'wrong_count' | 'mastery_level' | 'time'
}

View File

@ -3,7 +3,7 @@ import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
// 创建 axios 实例
const instance: AxiosInstance = axios.create({
baseURL: '/api', // 通过 Vite 代理转发到 Go 后端
timeout: 300000, // 5分钟超时300秒适应AI评分长时间处理
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
@ -34,12 +34,8 @@ instance.interceptors.response.use(
if (error.response) {
switch (error.response.status) {
case 401:
// 未授权,清除本地存储并跳转到登录页
console.error('Token已过期或未授权请重新登录')
localStorage.removeItem('token')
localStorage.removeItem('user')
// 跳转到登录页
window.location.href = '/login'
// 未授权,跳转到登录页
console.error('未授权,请登录')
break
case 403:
console.error('没有权限访问')
@ -76,53 +72,4 @@ export const request = {
},
}
// 统一的 fetch 请求工具(用于需要原生 fetch 的场景,如流式请求)
interface FetchOptions extends RequestInit {
// 扩展选项(如果需要)
}
/**
* fetch
* Authorization header
*/
export const fetchWithAuth = async (
url: string,
options: FetchOptions = {}
): Promise<Response> => {
// 获取 token
const token = localStorage.getItem('token')
// 合并 headers
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string>),
}
// 如果有 token添加到请求头
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
// 构建完整的请求配置
const fetchOptions: RequestInit = {
...options,
headers,
}
// 发送请求
const response = await fetch(url, fetchOptions)
// 统一处理 401 未授权错误
// 注意:如果已经在登录页面,不要跳转,让登录页面自己处理错误提示
if (response.status === 401 && !window.location.pathname.startsWith('/login')) {
console.error('Token已过期或未授权请重新登录')
localStorage.removeItem('token')
localStorage.removeItem('user')
window.location.href = '/login'
throw new Error('未授权,请重新登录')
}
return response
}
export default instance

View File

@ -5,7 +5,6 @@ import path from 'path'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
base: './', // 使用相对路径,确保打包后资源路径正确
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
@ -15,14 +14,9 @@ export default defineConfig({
preprocessorOptions: {
less: {
javascriptEnabled: true,
// antd 主题定制 - 白色毛玻璃风格
// 可以在这里添加全局 Less 变量
modifyVars: {
'@primary-color': '#007aff', // macOS 蓝色
'@link-color': '#007aff', // 链接色
'@border-radius-base': '12px', // 组件圆角
'@layout-body-background': '#ffffff', // 白色背景
'@component-background': 'rgba(255, 255, 255, 0.8)', // 半透明组件背景
'@border-color-base': 'rgba(0, 0, 0, 0.06)', // 边框色
// 例如: '@primary-color': '#1DA57A',
},
},
},
@ -31,7 +25,7 @@ export default defineConfig({
port: 3000,
proxy: {
'/api': {
target: 'http://127.0.0.1:8080',
target: 'http://localhost:8080',
changeOrigin: true,
},
},

File diff suppressed because it is too large Load Diff