优化考试管理页面UI和移动端响应式设计
主要改进: - 重新设计试卷卡片布局,使用蓝色渐变头部 - 优化操作按钮样式,简化为初版设计 - 调整按钮顺序,将考试按钮放在第一位 - 优化统计数据显示,使用垂直标签布局 - 修复移动端布局问题,确保按钮在一行显示 - 调整按钮尺寸,适配不同屏幕大小 - 完善试卷分享功能的UI集成 - 修复打印页面移动端按钮布局问题 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
ebf7c8890a
commit
1107d5d81c
@ -152,9 +152,12 @@ func GetExamList(c *gin.Context) {
|
|||||||
|
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
|
|
||||||
// 查询用户创建的试卷
|
// 查询用户创建的试卷(包括被分享的试卷)
|
||||||
var exams []models.Exam
|
var exams []models.Exam
|
||||||
if err := db.Where("user_id = ? AND status = ?", userID, "active").
|
if err := db.Where("user_id = ? AND status = ?", userID, "active").
|
||||||
|
Preload("SharedBy", func(db *gorm.DB) *gorm.DB {
|
||||||
|
return db.Select("id", "username", "nickname")
|
||||||
|
}).
|
||||||
Order("created_at DESC").
|
Order("created_at DESC").
|
||||||
Find(&exams).Error; err != nil {
|
Find(&exams).Error; err != nil {
|
||||||
log.Printf("查询试卷列表失败: %v", err)
|
log.Printf("查询试卷列表失败: %v", err)
|
||||||
@ -810,3 +813,129 @@ func GetExamUserAnswers(c *gin.Context) {
|
|||||||
"data": answers,
|
"data": answers,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetShareableUsers 获取可分享的用户列表(排除当前用户)
|
||||||
|
func GetShareableUsers(c *gin.Context) {
|
||||||
|
userID, exists := c.Get("user_id")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "未登录"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
db := database.GetDB()
|
||||||
|
|
||||||
|
// 查询所有用户(排除当前用户)
|
||||||
|
var users []models.User
|
||||||
|
if err := db.Where("id != ?", userID).Select("id", "username", "nickname", "avatar").Find(&users).Error; err != nil {
|
||||||
|
log.Printf("查询用户列表失败: %v", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "查询用户列表失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"data": users,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShareExamRequest 分享试卷请求
|
||||||
|
type ShareExamRequest struct {
|
||||||
|
UserIDs []uint `json:"user_ids" binding:"required,min=1"` // 分享给哪些用户
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShareExam 分享试卷
|
||||||
|
func ShareExam(c *gin.Context) {
|
||||||
|
userID, exists := c.Get("user_id")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "未登录"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
examIDStr := c.Param("id")
|
||||||
|
examID, err := strconv.ParseUint(examIDStr, 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的试卷ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req ShareExamRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的请求数据: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
db := database.GetDB()
|
||||||
|
|
||||||
|
// 查询原始试卷
|
||||||
|
var originalExam models.Exam
|
||||||
|
if err := db.Where("id = ? AND user_id = ?", examID, userID).First(&originalExam).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "试卷不存在或无权限"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 开始事务
|
||||||
|
tx := db.Begin()
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
sharedByID := uint(userID.(int64))
|
||||||
|
sharedCount := 0
|
||||||
|
|
||||||
|
// 为每个用户创建分享副本
|
||||||
|
for _, targetUserID := range req.UserIDs {
|
||||||
|
// 检查是否已经分享给该用户
|
||||||
|
var existingExam models.Exam
|
||||||
|
err := tx.Where("user_id = ? AND shared_by_id = ? AND question_ids = ? AND deleted_at IS NULL",
|
||||||
|
targetUserID, sharedByID, originalExam.QuestionIDs).First(&existingExam).Error
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
// 已存在,跳过
|
||||||
|
continue
|
||||||
|
} else if err != gorm.ErrRecordNotFound {
|
||||||
|
tx.Rollback()
|
||||||
|
log.Printf("检查分享记录失败: %v", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "分享失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建试卷副本
|
||||||
|
sharedExam := models.Exam{
|
||||||
|
UserID: targetUserID,
|
||||||
|
Title: originalExam.Title,
|
||||||
|
TotalScore: originalExam.TotalScore,
|
||||||
|
Duration: originalExam.Duration,
|
||||||
|
PassScore: originalExam.PassScore,
|
||||||
|
QuestionIDs: originalExam.QuestionIDs,
|
||||||
|
Status: "active",
|
||||||
|
IsShared: true,
|
||||||
|
SharedByID: &sharedByID,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Create(&sharedExam).Error; err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
log.Printf("创建分享试卷失败: %v", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "分享失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sharedCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提交事务
|
||||||
|
if err := tx.Commit().Error; err != nil {
|
||||||
|
log.Printf("提交事务失败: %v", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "分享失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": fmt.Sprintf("成功分享给 %d 个用户", sharedCount),
|
||||||
|
"data": gin.H{
|
||||||
|
"shared_count": sharedCount,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@ -20,6 +20,11 @@ type Exam struct {
|
|||||||
PassScore int `gorm:"not null;default:60" json:"pass_score"` // 及格分数
|
PassScore int `gorm:"not null;default:60" json:"pass_score"` // 及格分数
|
||||||
QuestionIDs datatypes.JSON `gorm:"type:json" json:"question_ids"` // 题目ID列表 (JSON数组)
|
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
|
Status string `gorm:"type:varchar(20);not null;default:'active'" json:"status"` // 状态: active, archived
|
||||||
|
IsShared bool `gorm:"default:false" json:"is_shared"` // 是否为分享的试卷
|
||||||
|
SharedByID *uint `gorm:"index" json:"shared_by_id,omitempty"` // 分享人ID (如果是分享的试卷)
|
||||||
|
|
||||||
|
// 关联
|
||||||
|
SharedBy *User `gorm:"foreignKey:SharedByID" json:"shared_by,omitempty"` // 分享人信息
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExamRecord 考试记录
|
// ExamRecord 考试记录
|
||||||
|
|||||||
2
main.go
2
main.go
@ -79,6 +79,8 @@ func main() {
|
|||||||
auth.DELETE("/exams/:id", handlers.DeleteExam) // 删除试卷
|
auth.DELETE("/exams/:id", handlers.DeleteExam) // 删除试卷
|
||||||
auth.POST("/exam-records/:record_id/progress", handlers.SaveExamProgress) // 保存考试进度
|
auth.POST("/exam-records/:record_id/progress", handlers.SaveExamProgress) // 保存考试进度
|
||||||
auth.GET("/exam-records/:record_id/answers", handlers.GetExamUserAnswers) // 获取用户答案
|
auth.GET("/exam-records/:record_id/answers", handlers.GetExamUserAnswers) // 获取用户答案
|
||||||
|
auth.GET("/users/shareable", handlers.GetShareableUsers) // 获取可分享的用户列表
|
||||||
|
auth.POST("/exams/:id/share", handlers.ShareExam) // 分享试卷
|
||||||
}
|
}
|
||||||
|
|
||||||
// 题库管理API(需要管理员权限)
|
// 题库管理API(需要管理员权限)
|
||||||
|
|||||||
@ -66,6 +66,16 @@ export const getExamUserAnswers = (recordId: number) => {
|
|||||||
return request.get<ApiResponse<Record<string, any>>>(`/exam-records/${recordId}/answers`)
|
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 ===
|
// === 兼容旧版API ===
|
||||||
|
|
||||||
// 生成考试
|
// 生成考试
|
||||||
|
|||||||
@ -1,23 +1,327 @@
|
|||||||
.container {
|
.container {
|
||||||
padding: 20px;
|
padding: 24px;
|
||||||
max-width: 1400px;
|
max-width: 1400px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
background: #f5f7fa;
|
||||||
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 32px;
|
||||||
|
padding: 20px 24px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 24px;
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1a1a1a;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 试卷网格布局
|
||||||
|
.examGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||||
|
gap: 24px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 试卷卡片样式重构
|
||||||
|
.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;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15);
|
||||||
|
transform: translateY(-8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.ant-card-body) {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 卡片头部样式 - 使用蓝色主题
|
||||||
|
.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 0 12px 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: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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: column;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
.statItem {
|
||||||
|
.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%;
|
||||||
|
|
||||||
|
.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%;
|
||||||
|
|
||||||
|
.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 {
|
.cardTitle {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -68,113 +372,150 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 试卷卡片样式优化
|
// 旧版兼容样式 - divider已合并,不再重复定义
|
||||||
.examCard {
|
|
||||||
border-radius: 12px;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
|
||||||
border: 1px solid #f0f0f0;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.ant-card-body) {
|
|
||||||
padding: 16px;
|
|
||||||
position: relative;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 右侧背景图片
|
|
||||||
&::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 35px;
|
|
||||||
right: 15px;
|
|
||||||
width: 50px;
|
|
||||||
height: 50px;
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23b8d4f1' stroke-width='1' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z'%3E%3C/path%3E%3Cpolyline points='14,2 14,8 20,8'%3E%3C/polyline%3E%3Cline x1='16' y1='13' x2='8' y2='13'%3E%3C/line%3E%3Cline x1='16' y1='17' x2='8' y2='17'%3E%3C/line%3E%3Cpolyline points='10,9 9,9 8,9'%3E%3C/polyline%3E%3C/svg%3E");
|
|
||||||
background-size: contain;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-position: center;
|
|
||||||
opacity: 0.12;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 卡片操作按钮样式优化
|
|
||||||
:global(.ant-card-actions) {
|
|
||||||
background: #fafafa;
|
|
||||||
|
|
||||||
li {
|
|
||||||
margin: 0 !important;
|
|
||||||
|
|
||||||
button {
|
|
||||||
height: auto;
|
|
||||||
padding: 8px 12px;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.4;
|
|
||||||
white-space: nowrap;
|
|
||||||
|
|
||||||
.anticon {
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: rgba(24, 144, 255, 0.05);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
button.ant-btn-link {
|
|
||||||
color: rgba(0, 0, 0, 0.65);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: #1890ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.ant-btn-dangerous {
|
|
||||||
color: #ff4d4f;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: #ff7875;
|
|
||||||
background: rgba(255, 77, 79, 0.05);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.divider {
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 16px;
|
|
||||||
margin: 24px 0 16px 0;
|
|
||||||
padding-bottom: 8px;
|
|
||||||
border-bottom: 1px solid #f0f0f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 响应式适配
|
// 响应式适配
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.container {
|
.container {
|
||||||
padding: 12px;
|
padding: 16px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.examGrid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
flex-direction: column;
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
font-size: 20px;
|
font-size: 22px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
width: 100%;
|
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: 10px;
|
||||||
|
|
||||||
|
.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 {
|
.cardTitle {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
@ -206,7 +547,6 @@
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 移动端卡片操作按钮优化
|
|
||||||
:global(.ant-card-actions) {
|
:global(.ant-card-actions) {
|
||||||
li {
|
li {
|
||||||
button {
|
button {
|
||||||
@ -215,11 +555,61 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 移动端样式调整
|
@media (max-width: 480px) {
|
||||||
.examCard::after {
|
.container {
|
||||||
display: none;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,7 +14,9 @@ import {
|
|||||||
Spin,
|
Spin,
|
||||||
Drawer,
|
Drawer,
|
||||||
Descriptions,
|
Descriptions,
|
||||||
Divider
|
Divider,
|
||||||
|
Checkbox,
|
||||||
|
Avatar
|
||||||
} from 'antd'
|
} from 'antd'
|
||||||
import {
|
import {
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
@ -26,7 +28,9 @@ import {
|
|||||||
TrophyOutlined,
|
TrophyOutlined,
|
||||||
HistoryOutlined,
|
HistoryOutlined,
|
||||||
PrinterOutlined,
|
PrinterOutlined,
|
||||||
ArrowLeftOutlined
|
ArrowLeftOutlined,
|
||||||
|
ShareAltOutlined,
|
||||||
|
UserOutlined
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import * as examApi from '../api/exam'
|
import * as examApi from '../api/exam'
|
||||||
import styles from './ExamManagement.module.less'
|
import styles from './ExamManagement.module.less'
|
||||||
@ -43,6 +47,18 @@ interface ExamListItem {
|
|||||||
has_in_progress_exam: boolean
|
has_in_progress_exam: boolean
|
||||||
in_progress_record_id?: number
|
in_progress_record_id?: number
|
||||||
created_at: string
|
created_at: string
|
||||||
|
is_shared?: boolean
|
||||||
|
shared_by?: {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShareableUser {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
nickname?: string
|
||||||
|
avatar?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const ExamManagement: React.FC = () => {
|
const ExamManagement: React.FC = () => {
|
||||||
@ -57,6 +73,14 @@ const ExamManagement: React.FC = () => {
|
|||||||
const [examRecords, setExamRecords] = useState<any[]>([])
|
const [examRecords, setExamRecords] = useState<any[]>([])
|
||||||
const [loadingRecords, setLoadingRecords] = useState(false)
|
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 () => {
|
const loadExams = async () => {
|
||||||
setLoadingExams(true)
|
setLoadingExams(true)
|
||||||
@ -172,6 +196,64 @@ const ExamManagement: React.FC = () => {
|
|||||||
navigate(`/exam/result/${recordId}`)
|
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 (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<Card>
|
<Card>
|
||||||
@ -197,90 +279,131 @@ const ExamManagement: React.FC = () => {
|
|||||||
<Empty
|
<Empty
|
||||||
description="暂无试卷,点击上方按钮创建"
|
description="暂无试卷,点击上方按钮创建"
|
||||||
style={{ marginTop: 40 }}
|
style={{ marginTop: 40 }}
|
||||||
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<List
|
<div className={styles.examGrid}>
|
||||||
grid={{ gutter: 16, xs: 1, sm: 1, md: 1, lg: 2, xl: 2, xxl: 2 }}
|
{exams.map((exam) => (
|
||||||
dataSource={exams}
|
|
||||||
renderItem={(exam) => (
|
|
||||||
<List.Item>
|
|
||||||
<Card
|
<Card
|
||||||
|
key={exam.id}
|
||||||
className={styles.examCard}
|
className={styles.examCard}
|
||||||
hoverable
|
hoverable
|
||||||
|
cover={
|
||||||
|
<div className={styles.cardCover}>
|
||||||
|
<div className={styles.coverIcon}>
|
||||||
|
<FileTextOutlined />
|
||||||
|
</div>
|
||||||
|
<div className={styles.coverInfo}>
|
||||||
|
<h3 className={styles.examTitle}>{exam.title}</h3>
|
||||||
|
{exam.is_shared && exam.shared_by && (
|
||||||
|
<Tag
|
||||||
|
icon={<ShareAltOutlined />}
|
||||||
|
color="purple"
|
||||||
|
size="small"
|
||||||
|
className={styles.shareTag}
|
||||||
|
>
|
||||||
|
来自 {exam.shared_by.nickname || exam.shared_by.username}
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
actions={[
|
actions={[
|
||||||
<Button
|
<Button
|
||||||
type="link"
|
type="text"
|
||||||
icon={<PlayCircleOutlined />}
|
icon={<PlayCircleOutlined />}
|
||||||
onClick={() => handleStartExam(exam.id, exam.has_in_progress_exam, exam.in_progress_record_id)}
|
onClick={() => handleStartExam(exam.id, exam.has_in_progress_exam, exam.in_progress_record_id)}
|
||||||
|
className={styles.actionButton}
|
||||||
>
|
>
|
||||||
{exam.has_in_progress_exam ? '继续考试' : '开始考试'}
|
{exam.has_in_progress_exam ? '继续' : '考试'}
|
||||||
</Button>,
|
</Button>,
|
||||||
<Button
|
<Button
|
||||||
type="link"
|
type="text"
|
||||||
|
icon={<ShareAltOutlined />}
|
||||||
|
onClick={() => handleOpenShareModal(exam.id)}
|
||||||
|
className={styles.actionButton}
|
||||||
|
>
|
||||||
|
分享
|
||||||
|
</Button>,
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
icon={<HistoryOutlined />}
|
icon={<HistoryOutlined />}
|
||||||
onClick={() => handleViewRecords(exam.id)}
|
onClick={() => handleViewRecords(exam.id)}
|
||||||
|
className={styles.actionButton}
|
||||||
>
|
>
|
||||||
考试记录
|
记录
|
||||||
</Button>,
|
</Button>,
|
||||||
<Button
|
<Button
|
||||||
type="link"
|
type="text"
|
||||||
icon={<FileTextOutlined />}
|
icon={<FileTextOutlined />}
|
||||||
onClick={() => navigate(`/exam/${exam.id}/answer`)}
|
onClick={() => navigate(`/exam/${exam.id}/answer`)}
|
||||||
|
className={styles.actionButton}
|
||||||
>
|
>
|
||||||
查看答案
|
答案
|
||||||
</Button>,
|
</Button>,
|
||||||
<Button
|
<Button
|
||||||
type="link"
|
type="text"
|
||||||
icon={<PrinterOutlined />}
|
icon={<PrinterOutlined />}
|
||||||
onClick={() => navigate(`/exam/${exam.id}/print`)}
|
onClick={() => navigate(`/exam/${exam.id}/print`)}
|
||||||
|
className={styles.actionButton}
|
||||||
>
|
>
|
||||||
打印试卷
|
打印
|
||||||
</Button>,
|
</Button>,
|
||||||
<Button
|
<Button
|
||||||
type="link"
|
type="text"
|
||||||
danger
|
danger
|
||||||
icon={<DeleteOutlined />}
|
icon={<DeleteOutlined />}
|
||||||
onClick={() => handleDeleteExam(exam.id)}
|
onClick={() => handleDeleteExam(exam.id)}
|
||||||
|
className={styles.actionButton}
|
||||||
>
|
>
|
||||||
删除
|
删除
|
||||||
</Button>
|
</Button>
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Card.Meta
|
|
||||||
title={
|
|
||||||
<div className={styles.cardTitle}>
|
|
||||||
<FileTextOutlined />
|
|
||||||
<span>{exam.title}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
description={
|
|
||||||
<div className={styles.cardContent}>
|
<div className={styles.cardContent}>
|
||||||
<div className={styles.infoRow}>
|
<div className={styles.examInfo}>
|
||||||
<div className={styles.infoItem}>
|
<div className={styles.infoItem}>
|
||||||
<ClockCircleOutlined />
|
<ClockCircleOutlined className={styles.infoIcon} />
|
||||||
<span>{exam.duration} 分钟</span>
|
<span className={styles.infoText}>{exam.duration} 分钟</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.infoItem}>
|
<div className={styles.infoItem}>
|
||||||
<CheckCircleOutlined />
|
<CheckCircleOutlined className={styles.infoIcon} />
|
||||||
<span>及格 {exam.pass_score} 分</span>
|
<span className={styles.infoText}>及格 {exam.pass_score} 分</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.infoItem}>
|
||||||
|
<FileTextOutlined className={styles.infoIcon} />
|
||||||
|
<span className={styles.infoText}>{exam.question_count || 0} 题</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.stats}>
|
|
||||||
<Tag icon={<TrophyOutlined />} color="gold" className={styles.statTag}>
|
<Divider className={styles.divider} />
|
||||||
最高分: {exam.best_score || 0}
|
|
||||||
|
<div className={styles.examStats}>
|
||||||
|
<div className={styles.statItem}>
|
||||||
|
<Tag className={styles.valueTag}>
|
||||||
|
<TrophyOutlined />
|
||||||
|
<span>最高分 {exam.best_score || 0}</span>
|
||||||
</Tag>
|
</Tag>
|
||||||
<Tag color="blue" className={styles.statTag}>已考 {exam.attempt_count} 次</Tag>
|
</div>
|
||||||
|
<div className={styles.statItem}>
|
||||||
|
<Tag className={styles.valueTag}>
|
||||||
|
<HistoryOutlined />
|
||||||
|
<span>考试次数 {exam.attempt_count}</span>
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
{exam.has_in_progress_exam && (
|
{exam.has_in_progress_exam && (
|
||||||
<Tag color="processing" className={styles.statTag}>进行中</Tag>
|
<div className={styles.statItem}>
|
||||||
|
<Tag color="processing" className={styles.progressTag}>
|
||||||
|
<PlayCircleOutlined />
|
||||||
|
<span>进行中</span>
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Card>
|
</Card>
|
||||||
</List.Item>
|
))}
|
||||||
)}
|
</div>
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</Spin>
|
</Spin>
|
||||||
</Card>
|
</Card>
|
||||||
@ -417,6 +540,91 @@ const ExamManagement: React.FC = () => {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -388,14 +388,19 @@
|
|||||||
|
|
||||||
.actionBar {
|
.actionBar {
|
||||||
padding: 12px 0;
|
padding: 12px 0;
|
||||||
flex-direction: column;
|
flex-direction: row;
|
||||||
gap: 12px;
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
:global(.ant-space) {
|
:global(.ant-space) {
|
||||||
width: 100%;
|
width: auto;
|
||||||
|
|
||||||
button {
|
button {
|
||||||
flex: 1;
|
flex: none;
|
||||||
|
width: auto;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 12px;
|
||||||
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user