diff --git a/internal/handlers/exam_handler.go b/internal/handlers/exam_handler.go index b67ef93..600c521 100644 --- a/internal/handlers/exam_handler.go +++ b/internal/handlers/exam_handler.go @@ -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, + }, + }) +} diff --git a/internal/models/exam.go b/internal/models/exam.go index 68530ce..7b88fe1 100644 --- a/internal/models/exam.go +++ b/internal/models/exam.go @@ -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 考试记录 diff --git a/main.go b/main.go index 036af67..4b29415 100644 --- a/main.go +++ b/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(需要管理员权限) diff --git a/web/src/api/exam.ts b/web/src/api/exam.ts index 0aba9c1..3155df3 100644 --- a/web/src/api/exam.ts +++ b/web/src/api/exam.ts @@ -66,6 +66,16 @@ export const getExamUserAnswers = (recordId: number) => { return request.get>>(`/exam-records/${recordId}/answers`) } +// 获取可分享的用户列表 +export const getShareableUsers = () => { + return request.get>>('/users/shareable') +} + +// 分享试卷 +export const shareExam = (examId: number, userIds: number[]) => { + return request.post>(`/exams/${examId}/share`, { user_ids: userIds }) +} + // === 兼容旧版API === // 生成考试 diff --git a/web/src/pages/ExamManagement.module.less b/web/src/pages/ExamManagement.module.less index 73eae95..00158c8 100644 --- a/web/src/pages/ExamManagement.module.less +++ b/web/src/pages/ExamManagement.module.less @@ -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; + } + } + } } } diff --git a/web/src/pages/ExamManagement.tsx b/web/src/pages/ExamManagement.tsx index 06463fa..2bb346a 100644 --- a/web/src/pages/ExamManagement.tsx +++ b/web/src/pages/ExamManagement.tsx @@ -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([]) const [loadingRecords, setLoadingRecords] = useState(false) + // 分享相关状态 + const [shareModalVisible, setShareModalVisible] = useState(false) + const [shareableUsers, setShareableUsers] = useState([]) + const [selectedUserIds, setSelectedUserIds] = useState([]) + const [loadingUsers, setLoadingUsers] = useState(false) + const [sharingExamId, setSharingExamId] = useState(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 (
@@ -197,90 +279,131 @@ const ExamManagement: React.FC = () => { ) : ( - ( - - } - onClick={() => handleStartExam(exam.id, exam.has_in_progress_exam, exam.in_progress_record_id)} - > - {exam.has_in_progress_exam ? '继续考试' : '开始考试'} - , - , - , - , - - ]} - > - - - {exam.title} +
+ {exams.map((exam) => ( + +
+ +
+
+

{exam.title}

+ {exam.is_shared && exam.shared_by && ( + } + color="purple" + size="small" + className={styles.shareTag} + > + 来自 {exam.shared_by.nickname || exam.shared_by.username} + + )} +
+
+ } + actions={[ + , + , + , + , + , + + ]} + > +
+
+
+ + {exam.duration} 分钟 +
+
+ + 及格 {exam.pass_score} 分 +
+
+ + {exam.question_count || 0} 题 +
+
+ + + +
+
+ + + 最高分 {exam.best_score || 0} + +
+
+ + + 考试次数 {exam.attempt_count} + +
+ {exam.has_in_progress_exam && ( +
+ + + 进行中 +
- } - description={ -
-
-
- - {exam.duration} 分钟 -
-
- - 及格 {exam.pass_score} 分 -
-
-
- } color="gold" className={styles.statTag}> - 最高分: {exam.best_score || 0} - - 已考 {exam.attempt_count} 次 - {exam.has_in_progress_exam && ( - 进行中 - )} -
-
- } - /> - - - )} - /> + )} +
+
+
+ ))} +
)} @@ -417,6 +540,91 @@ const ExamManagement: React.FC = () => { + + {/* 分享试卷模态框 */} + { + setShareModalVisible(false) + setSelectedUserIds([]) + setSharingExamId(null) + }} + onOk={handleShareExam} + confirmLoading={sharingLoading} + okText="确认分享" + cancelText="取消" + width={500} + > + +
+ 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([]) + } + }} + > + + 全选 ({selectedUserIds.length}/{shareableUsers.length}) + + +
+
+ {shareableUsers.length === 0 ? ( + + ) : ( + + {shareableUsers.map((user) => ( + + handleUserSelectionChange(user.id, e.target.checked)} + style={{ width: '100%' }} + > +
+ } + /> +
+
+ {user.nickname || user.username} +
+
+ 账号: {user.username} +
+
+
+
+
+ ))} +
+ )} +
+ {shareableUsers.length > 0 && ( +
+ 已选择 {selectedUserIds.length} 个用户 +
+ )} +
+
) } diff --git a/web/src/pages/ExamPrint.module.less b/web/src/pages/ExamPrint.module.less index 1eb6501..ac7ee1d 100644 --- a/web/src/pages/ExamPrint.module.less +++ b/web/src/pages/ExamPrint.module.less @@ -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; } } }