优化考试管理页面UI和移动端响应式设计

主要改进:
- 重新设计试卷卡片布局,使用蓝色渐变头部
- 优化操作按钮样式,简化为初版设计
- 调整按钮顺序,将考试按钮放在第一位
- 优化统计数据显示,使用垂直标签布局
- 修复移动端布局问题,确保按钮在一行显示
- 调整按钮尺寸,适配不同屏幕大小
- 完善试卷分享功能的UI集成
- 修复打印页面移动端按钮布局问题

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
燕陇琪 2025-11-18 21:58:34 +08:00
parent ebf7c8890a
commit 1107d5d81c
7 changed files with 937 additions and 188 deletions

View File

@ -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,
},
})
}

View File

@ -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 考试记录

View File

@ -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需要管理员权限

View File

@ -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 ===
// 生成考试 // 生成考试

View File

@ -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;
}
}
}
} }
} }

View File

@ -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>
) )
} }

View File

@ -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;
} }
} }
} }