优化考试管理页面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()
|
||||
|
||||
// 查询用户创建的试卷
|
||||
// 查询用户创建的试卷(包括被分享的试卷)
|
||||
var exams []models.Exam
|
||||
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").
|
||||
Find(&exams).Error; err != nil {
|
||||
log.Printf("查询试卷列表失败: %v", err)
|
||||
@ -810,3 +813,129 @@ func GetExamUserAnswers(c *gin.Context) {
|
||||
"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"` // 及格分数
|
||||
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
|
||||
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 考试记录
|
||||
|
||||
2
main.go
2
main.go
@ -79,6 +79,8 @@ func main() {
|
||||
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) // 分享试卷
|
||||
}
|
||||
|
||||
// 题库管理API(需要管理员权限)
|
||||
|
||||
@ -66,6 +66,16 @@ 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 ===
|
||||
|
||||
// 生成考试
|
||||
|
||||
@ -1,23 +1,327 @@
|
||||
.container {
|
||||
padding: 20px;
|
||||
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: 24px;
|
||||
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: 24px;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
flex: 1;
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -68,113 +372,150 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 试卷卡片样式优化
|
||||
.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;
|
||||
}
|
||||
// 旧版兼容样式 - divider已合并,不再重复定义
|
||||
|
||||
// 响应式适配
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 12px;
|
||||
padding: 16px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.examGrid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.header {
|
||||
flex-direction: column;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px 20px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
font-size: 22px;
|
||||
text-align: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
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 {
|
||||
font-size: 15px;
|
||||
margin-bottom: 8px;
|
||||
@ -206,7 +547,6 @@
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
// 移动端卡片操作按钮优化
|
||||
:global(.ant-card-actions) {
|
||||
li {
|
||||
button {
|
||||
@ -215,11 +555,61 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 移动端样式调整
|
||||
.examCard::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,7 +14,9 @@ import {
|
||||
Spin,
|
||||
Drawer,
|
||||
Descriptions,
|
||||
Divider
|
||||
Divider,
|
||||
Checkbox,
|
||||
Avatar
|
||||
} from 'antd'
|
||||
import {
|
||||
PlusOutlined,
|
||||
@ -26,7 +28,9 @@ import {
|
||||
TrophyOutlined,
|
||||
HistoryOutlined,
|
||||
PrinterOutlined,
|
||||
ArrowLeftOutlined
|
||||
ArrowLeftOutlined,
|
||||
ShareAltOutlined,
|
||||
UserOutlined
|
||||
} from '@ant-design/icons'
|
||||
import * as examApi from '../api/exam'
|
||||
import styles from './ExamManagement.module.less'
|
||||
@ -43,6 +47,18 @@ interface ExamListItem {
|
||||
has_in_progress_exam: boolean
|
||||
in_progress_record_id?: number
|
||||
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 = () => {
|
||||
@ -57,6 +73,14 @@ const ExamManagement: React.FC = () => {
|
||||
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)
|
||||
@ -172,6 +196,64 @@ const ExamManagement: React.FC = () => {
|
||||
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>
|
||||
@ -197,90 +279,131 @@ const ExamManagement: React.FC = () => {
|
||||
<Empty
|
||||
description="暂无试卷,点击上方按钮创建"
|
||||
style={{ marginTop: 40 }}
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
/>
|
||||
) : (
|
||||
<List
|
||||
grid={{ gutter: 16, xs: 1, sm: 1, md: 1, lg: 2, xl: 2, xxl: 2 }}
|
||||
dataSource={exams}
|
||||
renderItem={(exam) => (
|
||||
<List.Item>
|
||||
<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}>{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={[
|
||||
<Button
|
||||
type="link"
|
||||
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 ? '继续考试' : '开始考试'}
|
||||
{exam.has_in_progress_exam ? '继续' : '考试'}
|
||||
</Button>,
|
||||
<Button
|
||||
type="link"
|
||||
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="link"
|
||||
type="text"
|
||||
icon={<FileTextOutlined />}
|
||||
onClick={() => navigate(`/exam/${exam.id}/answer`)}
|
||||
className={styles.actionButton}
|
||||
>
|
||||
查看答案
|
||||
答案
|
||||
</Button>,
|
||||
<Button
|
||||
type="link"
|
||||
type="text"
|
||||
icon={<PrinterOutlined />}
|
||||
onClick={() => navigate(`/exam/${exam.id}/print`)}
|
||||
className={styles.actionButton}
|
||||
>
|
||||
打印试卷
|
||||
打印
|
||||
</Button>,
|
||||
<Button
|
||||
type="link"
|
||||
type="text"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => handleDeleteExam(exam.id)}
|
||||
className={styles.actionButton}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
]}
|
||||
>
|
||||
<Card.Meta
|
||||
title={
|
||||
<div className={styles.cardTitle}>
|
||||
<FileTextOutlined />
|
||||
<span>{exam.title}</span>
|
||||
</div>
|
||||
}
|
||||
description={
|
||||
<div className={styles.cardContent}>
|
||||
<div className={styles.infoRow}>
|
||||
<div className={styles.examInfo}>
|
||||
<div className={styles.infoItem}>
|
||||
<ClockCircleOutlined />
|
||||
<span>{exam.duration} 分钟</span>
|
||||
<ClockCircleOutlined className={styles.infoIcon} />
|
||||
<span className={styles.infoText}>{exam.duration} 分钟</span>
|
||||
</div>
|
||||
<div className={styles.infoItem}>
|
||||
<CheckCircleOutlined />
|
||||
<span>及格 {exam.pass_score} 分</span>
|
||||
<CheckCircleOutlined className={styles.infoIcon} />
|
||||
<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 className={styles.stats}>
|
||||
<Tag icon={<TrophyOutlined />} color="gold" className={styles.statTag}>
|
||||
最高分: {exam.best_score || 0}
|
||||
|
||||
<Divider className={styles.divider} />
|
||||
|
||||
<div className={styles.examStats}>
|
||||
<div className={styles.statItem}>
|
||||
<Tag className={styles.valueTag}>
|
||||
<TrophyOutlined />
|
||||
<span>最高分 {exam.best_score || 0}</span>
|
||||
</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 && (
|
||||
<Tag color="processing" className={styles.statTag}>进行中</Tag>
|
||||
<div className={styles.statItem}>
|
||||
<Tag color="processing" className={styles.progressTag}>
|
||||
<PlayCircleOutlined />
|
||||
<span>进行中</span>
|
||||
</Tag>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Spin>
|
||||
</Card>
|
||||
@ -417,6 +540,91 @@ const ExamManagement: React.FC = () => {
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -388,14 +388,19 @@
|
||||
|
||||
.actionBar {
|
||||
padding: 12px 0;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
:global(.ant-space) {
|
||||
width: 100%;
|
||||
width: auto;
|
||||
|
||||
button {
|
||||
flex: 1;
|
||||
flex: none;
|
||||
width: auto;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user