实验预约
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,79 @@
|
|||||||
|
using Jiaowu.Api.Domain.Common;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Domain.Academic;
|
||||||
|
|
||||||
|
public sealed class ExperimentProject : EntityBase
|
||||||
|
{
|
||||||
|
public Guid TeachingTaskId { get; set; }
|
||||||
|
public TeachingTask? TeachingTask { get; set; }
|
||||||
|
public required string Code { get; set; }
|
||||||
|
public required string Name { get; set; }
|
||||||
|
public ExperimentArrangementMode ArrangementMode { get; set; }
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public string? Requirements { get; set; }
|
||||||
|
public DateOnly StartDate { get; set; }
|
||||||
|
public DateOnly EndDate { get; set; }
|
||||||
|
public ExperimentProjectStatus Status { get; set; } =
|
||||||
|
ExperimentProjectStatus.Draft;
|
||||||
|
public DateTime? PublishedAt { get; set; }
|
||||||
|
public DateTime? ClosedAt { get; set; }
|
||||||
|
public ICollection<ExperimentSession> Sessions { get; set; } = [];
|
||||||
|
public ICollection<ExperimentBooking> Bookings { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ExperimentSession : EntityBase
|
||||||
|
{
|
||||||
|
public Guid ExperimentProjectId { get; set; }
|
||||||
|
public ExperimentProject? ExperimentProject { get; set; }
|
||||||
|
public Guid ClassroomId { get; set; }
|
||||||
|
public Classroom? Classroom { get; set; }
|
||||||
|
public DateOnly SessionDate { get; set; }
|
||||||
|
public int StartPeriod { get; set; }
|
||||||
|
public int PeriodCount { get; set; }
|
||||||
|
public int Capacity { get; set; }
|
||||||
|
public int ReservedCount { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
public ExperimentSessionStatus Status { get; set; } =
|
||||||
|
ExperimentSessionStatus.Scheduled;
|
||||||
|
public DateTime? CancelledAt { get; set; }
|
||||||
|
public ICollection<ExperimentBooking> Bookings { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ExperimentBooking : EntityBase
|
||||||
|
{
|
||||||
|
public Guid ExperimentProjectId { get; set; }
|
||||||
|
public ExperimentProject? ExperimentProject { get; set; }
|
||||||
|
public Guid ExperimentSessionId { get; set; }
|
||||||
|
public ExperimentSession? ExperimentSession { get; set; }
|
||||||
|
public Guid StudentId { get; set; }
|
||||||
|
public Student? Student { get; set; }
|
||||||
|
public ExperimentBookingStatus Status { get; set; } =
|
||||||
|
ExperimentBookingStatus.Booked;
|
||||||
|
public DateTime BookedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
public DateTime? CancelledAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ExperimentArrangementMode
|
||||||
|
{
|
||||||
|
Centralized = 1,
|
||||||
|
SelfScheduled = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ExperimentProjectStatus
|
||||||
|
{
|
||||||
|
Draft = 1,
|
||||||
|
Published = 2,
|
||||||
|
Closed = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ExperimentSessionStatus
|
||||||
|
{
|
||||||
|
Scheduled = 1,
|
||||||
|
Cancelled = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ExperimentBookingStatus
|
||||||
|
{
|
||||||
|
Booked = 1,
|
||||||
|
Cancelled = 2
|
||||||
|
}
|
||||||
@@ -44,6 +44,9 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
Set<SchedulePublishJob>();
|
Set<SchedulePublishJob>();
|
||||||
public DbSet<ClassroomReservation> ClassroomReservations =>
|
public DbSet<ClassroomReservation> ClassroomReservations =>
|
||||||
Set<ClassroomReservation>();
|
Set<ClassroomReservation>();
|
||||||
|
public DbSet<ExperimentProject> ExperimentProjects => Set<ExperimentProject>();
|
||||||
|
public DbSet<ExperimentSession> ExperimentSessions => Set<ExperimentSession>();
|
||||||
|
public DbSet<ExperimentBooking> ExperimentBookings => Set<ExperimentBooking>();
|
||||||
public DbSet<CourseSelectionRound> CourseSelectionRounds =>
|
public DbSet<CourseSelectionRound> CourseSelectionRounds =>
|
||||||
Set<CourseSelectionRound>();
|
Set<CourseSelectionRound>();
|
||||||
public DbSet<CourseSelectionRoundGrade> CourseSelectionRoundGrades =>
|
public DbSet<CourseSelectionRoundGrade> CourseSelectionRoundGrades =>
|
||||||
@@ -547,6 +550,64 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
.OnDelete(DeleteBehavior.SetNull);
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
builder.Entity<ExperimentProject>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(x => x.Code).HasMaxLength(40);
|
||||||
|
entity.Property(x => x.Name).HasMaxLength(120);
|
||||||
|
entity.Property(x => x.Description).HasMaxLength(1000);
|
||||||
|
entity.Property(x => x.Requirements).HasMaxLength(1000);
|
||||||
|
entity.HasIndex(x => new { x.TeachingTaskId, x.Code }).IsUnique();
|
||||||
|
entity.HasIndex(x => new { x.Status, x.StartDate, x.EndDate });
|
||||||
|
entity.HasOne(x => x.TeachingTask).WithMany()
|
||||||
|
.HasForeignKey(x => x.TeachingTaskId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Entity<ExperimentSession>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(x => x.Notes).HasMaxLength(500);
|
||||||
|
entity.HasIndex(x => new
|
||||||
|
{
|
||||||
|
x.ExperimentProjectId,
|
||||||
|
x.SessionDate,
|
||||||
|
x.StartPeriod
|
||||||
|
});
|
||||||
|
entity.HasIndex(x => new
|
||||||
|
{
|
||||||
|
x.ClassroomId,
|
||||||
|
x.SessionDate,
|
||||||
|
x.Status,
|
||||||
|
x.StartPeriod
|
||||||
|
});
|
||||||
|
entity.HasOne(x => x.ExperimentProject).WithMany(x => x.Sessions)
|
||||||
|
.HasForeignKey(x => x.ExperimentProjectId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
entity.HasOne(x => x.Classroom).WithMany()
|
||||||
|
.HasForeignKey(x => x.ClassroomId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Entity<ExperimentBooking>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasIndex(x => new { x.ExperimentProjectId, x.StudentId })
|
||||||
|
.IsUnique();
|
||||||
|
entity.HasIndex(x => new
|
||||||
|
{
|
||||||
|
x.ExperimentSessionId,
|
||||||
|
x.Status,
|
||||||
|
x.BookedAt
|
||||||
|
});
|
||||||
|
entity.HasOne(x => x.ExperimentProject).WithMany(x => x.Bookings)
|
||||||
|
.HasForeignKey(x => x.ExperimentProjectId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
entity.HasOne(x => x.ExperimentSession).WithMany(x => x.Bookings)
|
||||||
|
.HasForeignKey(x => x.ExperimentSessionId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
entity.HasOne(x => x.Student).WithMany()
|
||||||
|
.HasForeignKey(x => x.StudentId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
builder.Entity<CourseSelectionRound>(entity =>
|
builder.Entity<CourseSelectionRound>(entity =>
|
||||||
{
|
{
|
||||||
entity.Property(x => x.Name).HasMaxLength(120);
|
entity.Property(x => x.Name).HasMaxLength(120);
|
||||||
|
|||||||
@@ -68,6 +68,10 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
"20260727_36_exam_room_mixing";
|
"20260727_36_exam_room_mixing";
|
||||||
private const string AttendanceCheckInAuditMigration =
|
private const string AttendanceCheckInAuditMigration =
|
||||||
"20260728_37_attendance_check_in_audit";
|
"20260728_37_attendance_check_in_audit";
|
||||||
|
private const string ExamPublishJobsMigration =
|
||||||
|
"20260728_38_exam_publish_jobs";
|
||||||
|
private const string ExperimentManagementMigration =
|
||||||
|
"20260728_39_experiment_management";
|
||||||
|
|
||||||
public async Task MigrateAsync(CancellationToken cancellationToken = default)
|
public async Task MigrateAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
@@ -513,6 +517,32 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
ExamRoomMixingMigration,
|
ExamRoomMixingMigration,
|
||||||
examRoomsExist ? [] : ExamRoomMixingStatements,
|
examRoomsExist ? [] : ExamRoomMixingStatements,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
|
var examPublishJobsExist = await db.Database
|
||||||
|
.SqlQueryRaw<int>(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) AS "Value"
|
||||||
|
FROM sqlite_master
|
||||||
|
WHERE type = 'table' AND name = 'ExamPublishJobs'
|
||||||
|
""")
|
||||||
|
.AnyAsync(value => value > 0, cancellationToken);
|
||||||
|
await ApplyMigrationAsync(
|
||||||
|
ExamPublishJobsMigration,
|
||||||
|
examPublishJobsExist ? [] : ExamPublishJobStatements,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
var experimentProjectsExist = await db.Database
|
||||||
|
.SqlQueryRaw<int>(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) AS "Value"
|
||||||
|
FROM sqlite_master
|
||||||
|
WHERE type = 'table' AND name = 'ExperimentProjects'
|
||||||
|
""")
|
||||||
|
.AnyAsync(value => value > 0, cancellationToken);
|
||||||
|
await ApplyMigrationAsync(
|
||||||
|
ExperimentManagementMigration,
|
||||||
|
experimentProjectsExist ? [] : ExperimentManagementStatements,
|
||||||
|
cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ApplyMigrationAsync(
|
private async Task ApplyMigrationAsync(
|
||||||
@@ -2351,4 +2381,134 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
ON "ExamRoomInvigilators" ("TeacherId");
|
ON "ExamRoomInvigilators" ("TeacherId");
|
||||||
"""
|
"""
|
||||||
];
|
];
|
||||||
|
|
||||||
|
private static readonly string[] ExamPublishJobStatements =
|
||||||
|
[
|
||||||
|
"""
|
||||||
|
CREATE TABLE "ExamPublishJobs" (
|
||||||
|
"Id" TEXT NOT NULL CONSTRAINT "PK_ExamPublishJobs" PRIMARY KEY,
|
||||||
|
"Kind" INTEGER NOT NULL,
|
||||||
|
"PlanId" TEXT NOT NULL,
|
||||||
|
"ActivePlanId" TEXT NULL,
|
||||||
|
"RequestedByUserId" TEXT NULL,
|
||||||
|
"Status" INTEGER NOT NULL,
|
||||||
|
"CurrentStep" TEXT NULL,
|
||||||
|
"ErrorMessage" TEXT NULL,
|
||||||
|
"StartedAt" TEXT NULL,
|
||||||
|
"CompletedAt" TEXT NULL,
|
||||||
|
"CreatedAt" TEXT NOT NULL,
|
||||||
|
"UpdatedAt" TEXT NOT NULL
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly string[] ExperimentManagementStatements =
|
||||||
|
[
|
||||||
|
"""
|
||||||
|
CREATE TABLE "ExperimentProjects" (
|
||||||
|
"Id" TEXT NOT NULL CONSTRAINT "PK_ExperimentProjects" PRIMARY KEY,
|
||||||
|
"TeachingTaskId" TEXT NOT NULL,
|
||||||
|
"Code" TEXT NOT NULL,
|
||||||
|
"Name" TEXT NOT NULL,
|
||||||
|
"ArrangementMode" INTEGER NOT NULL,
|
||||||
|
"Description" TEXT NULL,
|
||||||
|
"Requirements" TEXT NULL,
|
||||||
|
"StartDate" TEXT NOT NULL,
|
||||||
|
"EndDate" TEXT NOT NULL,
|
||||||
|
"Status" INTEGER NOT NULL,
|
||||||
|
"PublishedAt" TEXT NULL,
|
||||||
|
"ClosedAt" TEXT NULL,
|
||||||
|
"CreatedAt" TEXT NOT NULL,
|
||||||
|
"UpdatedAt" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "FK_ExperimentProjects_TeachingTasks_TeachingTaskId"
|
||||||
|
FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id")
|
||||||
|
ON DELETE RESTRICT
|
||||||
|
);
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE UNIQUE INDEX "IX_ExperimentProjects_TeachingTaskId_Code"
|
||||||
|
ON "ExperimentProjects" ("TeachingTaskId", "Code");
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX "IX_ExperimentProjects_Status_StartDate_EndDate"
|
||||||
|
ON "ExperimentProjects" ("Status", "StartDate", "EndDate");
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE "ExperimentSessions" (
|
||||||
|
"Id" TEXT NOT NULL CONSTRAINT "PK_ExperimentSessions" PRIMARY KEY,
|
||||||
|
"ExperimentProjectId" TEXT NOT NULL,
|
||||||
|
"ClassroomId" TEXT NOT NULL,
|
||||||
|
"SessionDate" TEXT NOT NULL,
|
||||||
|
"StartPeriod" INTEGER NOT NULL,
|
||||||
|
"PeriodCount" INTEGER NOT NULL,
|
||||||
|
"Capacity" INTEGER NOT NULL,
|
||||||
|
"ReservedCount" INTEGER NOT NULL,
|
||||||
|
"Notes" TEXT NULL,
|
||||||
|
"Status" INTEGER NOT NULL,
|
||||||
|
"CancelledAt" TEXT NULL,
|
||||||
|
"CreatedAt" TEXT NOT NULL,
|
||||||
|
"UpdatedAt" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "FK_ExperimentSessions_ExperimentProjects_ExperimentProjectId"
|
||||||
|
FOREIGN KEY ("ExperimentProjectId")
|
||||||
|
REFERENCES "ExperimentProjects" ("Id")
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT "FK_ExperimentSessions_Classrooms_ClassroomId"
|
||||||
|
FOREIGN KEY ("ClassroomId") REFERENCES "Classrooms" ("Id")
|
||||||
|
ON DELETE RESTRICT
|
||||||
|
);
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX "IX_ExperimentSessions_ExperimentProjectId_SessionDate_StartPeriod"
|
||||||
|
ON "ExperimentSessions" (
|
||||||
|
"ExperimentProjectId",
|
||||||
|
"SessionDate",
|
||||||
|
"StartPeriod"
|
||||||
|
);
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX "IX_ExperimentSessions_ClassroomId_SessionDate_Status_StartPeriod"
|
||||||
|
ON "ExperimentSessions" (
|
||||||
|
"ClassroomId",
|
||||||
|
"SessionDate",
|
||||||
|
"Status",
|
||||||
|
"StartPeriod"
|
||||||
|
);
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE "ExperimentBookings" (
|
||||||
|
"Id" TEXT NOT NULL CONSTRAINT "PK_ExperimentBookings" PRIMARY KEY,
|
||||||
|
"ExperimentProjectId" TEXT NOT NULL,
|
||||||
|
"ExperimentSessionId" TEXT NOT NULL,
|
||||||
|
"StudentId" TEXT NOT NULL,
|
||||||
|
"Status" INTEGER NOT NULL,
|
||||||
|
"BookedAt" TEXT NOT NULL,
|
||||||
|
"CancelledAt" TEXT NULL,
|
||||||
|
"CreatedAt" TEXT NOT NULL,
|
||||||
|
"UpdatedAt" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "FK_ExperimentBookings_ExperimentProjects_ExperimentProjectId"
|
||||||
|
FOREIGN KEY ("ExperimentProjectId")
|
||||||
|
REFERENCES "ExperimentProjects" ("Id")
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT "FK_ExperimentBookings_ExperimentSessions_ExperimentSessionId"
|
||||||
|
FOREIGN KEY ("ExperimentSessionId")
|
||||||
|
REFERENCES "ExperimentSessions" ("Id")
|
||||||
|
ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT "FK_ExperimentBookings_Students_StudentId"
|
||||||
|
FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id")
|
||||||
|
ON DELETE RESTRICT
|
||||||
|
);
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE UNIQUE INDEX "IX_ExperimentBookings_ExperimentProjectId_StudentId"
|
||||||
|
ON "ExperimentBookings" ("ExperimentProjectId", "StudentId");
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX "IX_ExperimentBookings_ExperimentSessionId_Status_BookedAt"
|
||||||
|
ON "ExperimentBookings" (
|
||||||
|
"ExperimentSessionId",
|
||||||
|
"Status",
|
||||||
|
"BookedAt"
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
+5641
File diff suppressed because it is too large
Load Diff
+170
@@ -0,0 +1,170 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class ExperimentManagement : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ExperimentProjects",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
TeachingTaskId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
Code = table.Column<string>(type: "varchar(40)", maxLength: 40, nullable: false),
|
||||||
|
Name = table.Column<string>(type: "varchar(120)", maxLength: 120, nullable: false),
|
||||||
|
ArrangementMode = table.Column<int>(type: "int", nullable: false),
|
||||||
|
Description = table.Column<string>(type: "varchar(1000)", maxLength: 1000, nullable: true),
|
||||||
|
Requirements = table.Column<string>(type: "varchar(1000)", maxLength: 1000, nullable: true),
|
||||||
|
StartDate = table.Column<DateTime>(type: "date", nullable: false),
|
||||||
|
EndDate = table.Column<DateTime>(type: "date", nullable: false),
|
||||||
|
Status = table.Column<int>(type: "int", nullable: false),
|
||||||
|
PublishedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
ClosedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_ExperimentProjects", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ExperimentProjects_TeachingTasks_TeachingTaskId",
|
||||||
|
column: x => x.TeachingTaskId,
|
||||||
|
principalTable: "TeachingTasks",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
})
|
||||||
|
.Annotation("MySQL:Charset", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ExperimentSessions",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
ExperimentProjectId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
ClassroomId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
SessionDate = table.Column<DateTime>(type: "date", nullable: false),
|
||||||
|
StartPeriod = table.Column<int>(type: "int", nullable: false),
|
||||||
|
PeriodCount = table.Column<int>(type: "int", nullable: false),
|
||||||
|
Capacity = table.Column<int>(type: "int", nullable: false),
|
||||||
|
ReservedCount = table.Column<int>(type: "int", nullable: false),
|
||||||
|
Notes = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
|
||||||
|
Status = table.Column<int>(type: "int", nullable: false),
|
||||||
|
CancelledAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_ExperimentSessions", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ExperimentSessions_Classrooms_ClassroomId",
|
||||||
|
column: x => x.ClassroomId,
|
||||||
|
principalTable: "Classrooms",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ExperimentSessions_ExperimentProjects_ExperimentProjectId",
|
||||||
|
column: x => x.ExperimentProjectId,
|
||||||
|
principalTable: "ExperimentProjects",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
})
|
||||||
|
.Annotation("MySQL:Charset", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ExperimentBookings",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
ExperimentProjectId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
ExperimentSessionId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
StudentId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
Status = table.Column<int>(type: "int", nullable: false),
|
||||||
|
BookedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
CancelledAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_ExperimentBookings", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ExperimentBookings_ExperimentProjects_ExperimentProjectId",
|
||||||
|
column: x => x.ExperimentProjectId,
|
||||||
|
principalTable: "ExperimentProjects",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ExperimentBookings_ExperimentSessions_ExperimentSessionId",
|
||||||
|
column: x => x.ExperimentSessionId,
|
||||||
|
principalTable: "ExperimentSessions",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ExperimentBookings_Students_StudentId",
|
||||||
|
column: x => x.StudentId,
|
||||||
|
principalTable: "Students",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
})
|
||||||
|
.Annotation("MySQL:Charset", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExperimentBookings_ExperimentProjectId_StudentId",
|
||||||
|
table: "ExperimentBookings",
|
||||||
|
columns: new[] { "ExperimentProjectId", "StudentId" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExperimentBookings_ExperimentSessionId_Status_BookedAt",
|
||||||
|
table: "ExperimentBookings",
|
||||||
|
columns: new[] { "ExperimentSessionId", "Status", "BookedAt" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExperimentBookings_StudentId",
|
||||||
|
table: "ExperimentBookings",
|
||||||
|
column: "StudentId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExperimentProjects_Status_StartDate_EndDate",
|
||||||
|
table: "ExperimentProjects",
|
||||||
|
columns: new[] { "Status", "StartDate", "EndDate" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExperimentProjects_TeachingTaskId_Code",
|
||||||
|
table: "ExperimentProjects",
|
||||||
|
columns: new[] { "TeachingTaskId", "Code" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExperimentSessions_ClassroomId_SessionDate_Status_StartPeriod",
|
||||||
|
table: "ExperimentSessions",
|
||||||
|
columns: new[] { "ClassroomId", "SessionDate", "Status", "StartPeriod" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExperimentSessions_ExperimentProjectId_SessionDate_StartPeri~",
|
||||||
|
table: "ExperimentSessions",
|
||||||
|
columns: new[] { "ExperimentProjectId", "SessionDate", "StartPeriod" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ExperimentBookings");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ExperimentSessions");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ExperimentProjects");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+224
@@ -2001,6 +2001,161 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.ToTable("ExamSignInExportJobs");
|
b.ToTable("ExamSignInExportJobs");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExperimentBooking", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("BookedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("CancelledAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<Guid>("ExperimentProjectId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<Guid>("ExperimentSessionId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<Guid>("StudentId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("StudentId");
|
||||||
|
|
||||||
|
b.HasIndex("ExperimentProjectId", "StudentId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("ExperimentSessionId", "Status", "BookedAt");
|
||||||
|
|
||||||
|
b.ToTable("ExperimentBookings");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExperimentProject", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<int>("ArrangementMode")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("ClosedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<string>("Code")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(40)
|
||||||
|
.HasColumnType("varchar(40)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("varchar(1000)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("EndDate")
|
||||||
|
.HasColumnType("date");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(120)
|
||||||
|
.HasColumnType("varchar(120)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("PublishedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<string>("Requirements")
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("varchar(1000)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("StartDate")
|
||||||
|
.HasColumnType("date");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<Guid>("TeachingTaskId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TeachingTaskId", "Code")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("Status", "StartDate", "EndDate");
|
||||||
|
|
||||||
|
b.ToTable("ExperimentProjects");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExperimentSession", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("CancelledAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<int>("Capacity")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<Guid>("ClassroomId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<Guid>("ExperimentProjectId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<string>("Notes")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("varchar(500)");
|
||||||
|
|
||||||
|
b.Property<int>("PeriodCount")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("ReservedCount")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime>("SessionDate")
|
||||||
|
.HasColumnType("date");
|
||||||
|
|
||||||
|
b.Property<int>("StartPeriod")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ExperimentProjectId", "SessionDate", "StartPeriod");
|
||||||
|
|
||||||
|
b.HasIndex("ClassroomId", "SessionDate", "Status", "StartPeriod");
|
||||||
|
|
||||||
|
b.ToTable("ExperimentSessions");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeItem", b =>
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeItem", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -4630,6 +4785,63 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.Navigation("Teacher");
|
b.Navigation("Teacher");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExperimentBooking", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Jiaowu.Api.Domain.Academic.ExperimentProject", "ExperimentProject")
|
||||||
|
.WithMany("Bookings")
|
||||||
|
.HasForeignKey("ExperimentProjectId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Jiaowu.Api.Domain.Academic.ExperimentSession", "ExperimentSession")
|
||||||
|
.WithMany("Bookings")
|
||||||
|
.HasForeignKey("ExperimentSessionId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("StudentId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("ExperimentProject");
|
||||||
|
|
||||||
|
b.Navigation("ExperimentSession");
|
||||||
|
|
||||||
|
b.Navigation("Student");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExperimentProject", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("TeachingTaskId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("TeachingTask");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExperimentSession", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ClassroomId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Jiaowu.Api.Domain.Academic.ExperimentProject", "ExperimentProject")
|
||||||
|
.WithMany("Sessions")
|
||||||
|
.HasForeignKey("ExperimentProjectId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Classroom");
|
||||||
|
|
||||||
|
b.Navigation("ExperimentProject");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeItem", b =>
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeItem", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Jiaowu.Api.Domain.Academic.GradeSheet", "GradeSheet")
|
b.HasOne("Jiaowu.Api.Domain.Academic.GradeSheet", "GradeSheet")
|
||||||
@@ -5338,6 +5550,18 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.Navigation("SeatAssignments");
|
b.Navigation("SeatAssignments");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExperimentProject", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Bookings");
|
||||||
|
|
||||||
|
b.Navigation("Sessions");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExperimentSession", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Bookings");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeItem", b =>
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeItem", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Scores");
|
b.Navigation("Scores");
|
||||||
|
|||||||
@@ -81,6 +81,20 @@ public sealed class ClassroomReservationAvailabilityService(AppDbContext db)
|
|||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
occupiedIds.UnionWith(makeupExamRoomIds);
|
occupiedIds.UnionWith(makeupExamRoomIds);
|
||||||
|
|
||||||
|
var experimentRoomIds = await db.ExperimentSessions.AsNoTracking()
|
||||||
|
.Where(session =>
|
||||||
|
session.ExperimentProject!.TeachingTask!.AcademicTermId ==
|
||||||
|
term.Id &&
|
||||||
|
session.ExperimentProject.Status !=
|
||||||
|
ExperimentProjectStatus.Closed &&
|
||||||
|
session.Status == ExperimentSessionStatus.Scheduled &&
|
||||||
|
session.SessionDate == reservationDate &&
|
||||||
|
session.StartPeriod < startPeriod + periodCount &&
|
||||||
|
startPeriod < session.StartPeriod + session.PeriodCount)
|
||||||
|
.Select(session => session.ClassroomId)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
occupiedIds.UnionWith(experimentRoomIds);
|
||||||
|
|
||||||
var reservationQuery = db.ClassroomReservations.AsNoTracking()
|
var reservationQuery = db.ClassroomReservations.AsNoTracking()
|
||||||
.Where(reservation =>
|
.Where(reservation =>
|
||||||
reservation.AcademicTermId == term.Id &&
|
reservation.AcademicTermId == term.Id &&
|
||||||
|
|||||||
@@ -0,0 +1,394 @@
|
|||||||
|
using Jiaowu.Api.Controllers;
|
||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Domain.Identity;
|
||||||
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Jiaowu.Api.Infrastructure.Timetables;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Tests;
|
||||||
|
|
||||||
|
public sealed class ExperimentsControllerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task CentralizedProject_PublishesAndUsesTeachingTaskRoster()
|
||||||
|
{
|
||||||
|
await using var fixture = await ExperimentFixture.CreateAsync();
|
||||||
|
var controller = fixture.Controller(fixture.ManagerScope);
|
||||||
|
var created = await controller.CreateProject(
|
||||||
|
fixture.ProjectRequest(ExperimentArrangementMode.Centralized),
|
||||||
|
CancellationToken.None);
|
||||||
|
Assert.IsType<CreatedResult>(created);
|
||||||
|
var project = await fixture.Db.ExperimentProjects.SingleAsync();
|
||||||
|
|
||||||
|
var sessionResult = await controller.CreateSession(
|
||||||
|
project.Id,
|
||||||
|
fixture.SessionRequest(1, 2, 10),
|
||||||
|
CancellationToken.None);
|
||||||
|
Assert.IsType<CreatedResult>(sessionResult);
|
||||||
|
Assert.Equal(
|
||||||
|
1,
|
||||||
|
(await fixture.Db.ExperimentSessions.SingleAsync()).Capacity);
|
||||||
|
|
||||||
|
var published = await controller.PublishProject(
|
||||||
|
project.Id,
|
||||||
|
CancellationToken.None);
|
||||||
|
Assert.IsType<NoContentResult>(published);
|
||||||
|
Assert.Equal(
|
||||||
|
ExperimentProjectStatus.Published,
|
||||||
|
(await fixture.Db.ExperimentProjects.SingleAsync()).Status);
|
||||||
|
|
||||||
|
var session = await fixture.Db.ExperimentSessions.SingleAsync();
|
||||||
|
var participants = await controller.GetParticipants(
|
||||||
|
session.Id,
|
||||||
|
CancellationToken.None);
|
||||||
|
var ok = Assert.IsType<OkObjectResult>(participants);
|
||||||
|
var rows = Assert.IsAssignableFrom<IEnumerable<object>>(ok.Value);
|
||||||
|
Assert.Single(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SelfScheduledBooking_IsSinglePerProjectAndCanBeChanged()
|
||||||
|
{
|
||||||
|
await using var fixture = await ExperimentFixture.CreateAsync();
|
||||||
|
var manager = fixture.Controller(fixture.ManagerScope);
|
||||||
|
await manager.CreateProject(
|
||||||
|
fixture.ProjectRequest(ExperimentArrangementMode.SelfScheduled),
|
||||||
|
CancellationToken.None);
|
||||||
|
var project = await fixture.Db.ExperimentProjects.SingleAsync();
|
||||||
|
await manager.CreateSession(
|
||||||
|
project.Id,
|
||||||
|
fixture.SessionRequest(3, 2),
|
||||||
|
CancellationToken.None);
|
||||||
|
await manager.CreateSession(
|
||||||
|
project.Id,
|
||||||
|
fixture.SessionRequest(5, 2),
|
||||||
|
CancellationToken.None);
|
||||||
|
await manager.PublishProject(project.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
var sessions = await fixture.Db.ExperimentSessions
|
||||||
|
.OrderBy(x => x.StartPeriod)
|
||||||
|
.ToListAsync();
|
||||||
|
var student = fixture.Controller(fixture.StudentScope);
|
||||||
|
Assert.IsType<NoContentResult>(
|
||||||
|
await student.Book(sessions[0].Id, CancellationToken.None));
|
||||||
|
Assert.IsType<ConflictObjectResult>(
|
||||||
|
await student.Book(sessions[1].Id, CancellationToken.None));
|
||||||
|
|
||||||
|
var booking = await fixture.Db.ExperimentBookings.SingleAsync();
|
||||||
|
Assert.IsType<NoContentResult>(
|
||||||
|
await student.CancelBooking(booking.Id, CancellationToken.None));
|
||||||
|
Assert.IsType<NoContentResult>(
|
||||||
|
await student.Book(sessions[1].Id, CancellationToken.None));
|
||||||
|
|
||||||
|
var active = await fixture.Db.ExperimentBookings.SingleAsync();
|
||||||
|
Assert.Equal(ExperimentBookingStatus.Booked, active.Status);
|
||||||
|
Assert.Equal(sessions[1].Id, active.ExperimentSessionId);
|
||||||
|
Assert.Equal(0, (await fixture.Db.ExperimentSessions.FindAsync(
|
||||||
|
sessions[0].Id))!.ReservedCount);
|
||||||
|
Assert.Equal(1, (await fixture.Db.ExperimentSessions.FindAsync(
|
||||||
|
sessions[1].Id))!.ReservedCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StudentBooking_RejectsPublishedTimetableConflict()
|
||||||
|
{
|
||||||
|
await using var fixture = await ExperimentFixture.CreateAsync();
|
||||||
|
var manager = fixture.Controller(fixture.ManagerScope);
|
||||||
|
await manager.CreateProject(
|
||||||
|
fixture.ProjectRequest(ExperimentArrangementMode.SelfScheduled),
|
||||||
|
CancellationToken.None);
|
||||||
|
var project = await fixture.Db.ExperimentProjects.SingleAsync();
|
||||||
|
await manager.CreateSession(
|
||||||
|
project.Id,
|
||||||
|
fixture.SessionRequest(1, 2),
|
||||||
|
CancellationToken.None);
|
||||||
|
await manager.PublishProject(project.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
fixture.Db.SchedulePlans.Add(new SchedulePlan
|
||||||
|
{
|
||||||
|
AcademicTermId = fixture.Term.Id,
|
||||||
|
Name = "正式课表",
|
||||||
|
Version = "V1",
|
||||||
|
Status = SchedulePlanStatus.Published,
|
||||||
|
Entries =
|
||||||
|
[
|
||||||
|
new ScheduleEntry
|
||||||
|
{
|
||||||
|
TeachingTaskId = fixture.Task.Id,
|
||||||
|
ClassroomId = fixture.SecondClassroom.Id,
|
||||||
|
DayOfWeek = 1,
|
||||||
|
StartPeriod = 1,
|
||||||
|
PeriodCount = 2,
|
||||||
|
StartWeek = 1,
|
||||||
|
EndWeek = 18,
|
||||||
|
WeekPattern = WeekPattern.All
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
await fixture.Db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var session = await fixture.Db.ExperimentSessions.SingleAsync();
|
||||||
|
var result = await fixture.Controller(fixture.StudentScope)
|
||||||
|
.Book(session.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.IsType<ConflictObjectResult>(result);
|
||||||
|
Assert.Empty(fixture.Db.ExperimentBookings);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ExperimentFixture : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private ExperimentFixture(
|
||||||
|
SqliteConnection connection,
|
||||||
|
AppDbContext db,
|
||||||
|
AcademicTerm term,
|
||||||
|
TeachingTask task,
|
||||||
|
Classroom classroom,
|
||||||
|
Classroom secondClassroom,
|
||||||
|
ICurrentUserDataScope managerScope,
|
||||||
|
ICurrentUserDataScope studentScope)
|
||||||
|
{
|
||||||
|
Connection = connection;
|
||||||
|
Db = db;
|
||||||
|
Term = term;
|
||||||
|
Task = task;
|
||||||
|
Classroom = classroom;
|
||||||
|
SecondClassroom = secondClassroom;
|
||||||
|
ManagerScope = managerScope;
|
||||||
|
StudentScope = studentScope;
|
||||||
|
}
|
||||||
|
|
||||||
|
private SqliteConnection Connection { get; }
|
||||||
|
public AppDbContext Db { get; }
|
||||||
|
public AcademicTerm Term { get; }
|
||||||
|
public TeachingTask Task { get; }
|
||||||
|
public Classroom Classroom { get; }
|
||||||
|
public Classroom SecondClassroom { get; }
|
||||||
|
public ICurrentUserDataScope ManagerScope { get; }
|
||||||
|
public ICurrentUserDataScope StudentScope { get; }
|
||||||
|
|
||||||
|
public static async Task<ExperimentFixture> CreateAsync()
|
||||||
|
{
|
||||||
|
var connection = new SqliteConnection("Data Source=:memory:");
|
||||||
|
await connection.OpenAsync();
|
||||||
|
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseSqlite(connection)
|
||||||
|
.Options;
|
||||||
|
var db = new AppDbContext(options);
|
||||||
|
await db.Database.EnsureCreatedAsync();
|
||||||
|
|
||||||
|
var manager = User("manager", "学院实验管理员");
|
||||||
|
var studentUser = User("student", "实验学生");
|
||||||
|
var campus = new Campus { Code = "MAIN", Name = "主校区" };
|
||||||
|
var building = new Building
|
||||||
|
{
|
||||||
|
Code = "LAB",
|
||||||
|
Name = "实验中心",
|
||||||
|
CampusId = campus.Id
|
||||||
|
};
|
||||||
|
var classroom = new Classroom
|
||||||
|
{
|
||||||
|
Code = "LAB101",
|
||||||
|
Name = "实验室 101",
|
||||||
|
BuildingId = building.Id,
|
||||||
|
Capacity = 40
|
||||||
|
};
|
||||||
|
var secondClassroom = new Classroom
|
||||||
|
{
|
||||||
|
Code = "LAB102",
|
||||||
|
Name = "实验室 102",
|
||||||
|
BuildingId = building.Id,
|
||||||
|
Capacity = 40
|
||||||
|
};
|
||||||
|
var college = new College { Code = "CS", Name = "计算机学院" };
|
||||||
|
manager.CollegeId = college.Id;
|
||||||
|
var major = new Major
|
||||||
|
{
|
||||||
|
Code = "CS",
|
||||||
|
Name = "计算机科学与技术",
|
||||||
|
CollegeId = college.Id,
|
||||||
|
DegreeType = "工学"
|
||||||
|
};
|
||||||
|
var administrativeClass = new AdministrativeClass
|
||||||
|
{
|
||||||
|
Code = "CS2099",
|
||||||
|
Name = "计科 2099",
|
||||||
|
MajorId = major.Id,
|
||||||
|
Grade = 2099
|
||||||
|
};
|
||||||
|
var student = new Student
|
||||||
|
{
|
||||||
|
StudentNumber = "20990001",
|
||||||
|
Name = "实验学生",
|
||||||
|
AdministrativeClassId = administrativeClass.Id,
|
||||||
|
EnrollmentYear = 2099,
|
||||||
|
EnrollmentDate = new DateOnly(2099, 9, 1),
|
||||||
|
UserId = studentUser.Id
|
||||||
|
};
|
||||||
|
var teacher = new Teacher
|
||||||
|
{
|
||||||
|
TeacherNumber = "T2099",
|
||||||
|
Name = "实验教师",
|
||||||
|
CollegeId = college.Id
|
||||||
|
};
|
||||||
|
var term = new AcademicTerm
|
||||||
|
{
|
||||||
|
Code = "2099-1",
|
||||||
|
Name = "2099—2100 学年第一学期",
|
||||||
|
AcademicYear = "2099-2100",
|
||||||
|
Season = TermSeason.Autumn,
|
||||||
|
StartDate = new DateOnly(2099, 9, 7),
|
||||||
|
EndDate = new DateOnly(2100, 1, 17),
|
||||||
|
IsCurrent = true
|
||||||
|
};
|
||||||
|
var course = new Course
|
||||||
|
{
|
||||||
|
Code = "CSLAB",
|
||||||
|
Name = "系统实验",
|
||||||
|
CollegeId = college.Id,
|
||||||
|
Credits = 2,
|
||||||
|
TotalHours = 32,
|
||||||
|
LectureHours = 16,
|
||||||
|
PracticeHours = 16,
|
||||||
|
Nature = CourseNature.Practice,
|
||||||
|
AssessmentMethod = AssessmentMethod.Assessment
|
||||||
|
};
|
||||||
|
var task = new TeachingTask
|
||||||
|
{
|
||||||
|
TaskNumber = "2099-1-CSLAB-01",
|
||||||
|
Name = "系统实验教学班",
|
||||||
|
AcademicTermId = term.Id,
|
||||||
|
CourseId = course.Id,
|
||||||
|
Capacity = 40,
|
||||||
|
Status = TeachingTaskStatus.Published,
|
||||||
|
Teachers =
|
||||||
|
[
|
||||||
|
new TeachingTaskTeacher
|
||||||
|
{
|
||||||
|
TeacherId = teacher.Id,
|
||||||
|
IsPrimary = true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
Classes =
|
||||||
|
[
|
||||||
|
new TeachingTaskClass
|
||||||
|
{
|
||||||
|
AdministrativeClassId = administrativeClass.Id
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
db.AddRange(
|
||||||
|
manager,
|
||||||
|
studentUser,
|
||||||
|
campus,
|
||||||
|
building,
|
||||||
|
classroom,
|
||||||
|
secondClassroom,
|
||||||
|
college,
|
||||||
|
major,
|
||||||
|
administrativeClass,
|
||||||
|
student,
|
||||||
|
teacher,
|
||||||
|
term,
|
||||||
|
course,
|
||||||
|
task);
|
||||||
|
for (var period = 1; period <= 12; period++)
|
||||||
|
{
|
||||||
|
db.ScheduleTimeSlots.Add(new ScheduleTimeSlot
|
||||||
|
{
|
||||||
|
AcademicTermId = term.Id,
|
||||||
|
PeriodNumber = period,
|
||||||
|
Name = $"第 {period} 节",
|
||||||
|
StartsAt = new TimeOnly(8, 0).AddMinutes((period - 1) * 50),
|
||||||
|
EndsAt = new TimeOnly(8, 45).AddMinutes((period - 1) * 50)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return new ExperimentFixture(
|
||||||
|
connection,
|
||||||
|
db,
|
||||||
|
term,
|
||||||
|
task,
|
||||||
|
classroom,
|
||||||
|
secondClassroom,
|
||||||
|
Scope(
|
||||||
|
manager,
|
||||||
|
SystemRoles.CollegeAdmin,
|
||||||
|
DataScope.College),
|
||||||
|
Scope(studentUser, SystemRoles.Student, DataScope.Self));
|
||||||
|
}
|
||||||
|
|
||||||
|
public ExperimentsController Controller(
|
||||||
|
ICurrentUserDataScope currentScope) =>
|
||||||
|
new(
|
||||||
|
Db,
|
||||||
|
currentScope,
|
||||||
|
new ClassroomReservationAvailabilityService(Db));
|
||||||
|
|
||||||
|
public ExperimentProjectRequest ProjectRequest(
|
||||||
|
ExperimentArrangementMode mode) =>
|
||||||
|
new(
|
||||||
|
Task.Id,
|
||||||
|
mode == ExperimentArrangementMode.Centralized
|
||||||
|
? "LAB-C"
|
||||||
|
: "LAB-S",
|
||||||
|
mode == ExperimentArrangementMode.Centralized
|
||||||
|
? "集中上机实验"
|
||||||
|
: "自主上机实验",
|
||||||
|
mode,
|
||||||
|
"完成规定实验项目。",
|
||||||
|
"携带校园卡。",
|
||||||
|
Term.StartDate,
|
||||||
|
Term.StartDate.AddDays(14));
|
||||||
|
|
||||||
|
public ExperimentSessionRequest SessionRequest(
|
||||||
|
int startPeriod,
|
||||||
|
int periodCount,
|
||||||
|
int capacity = 1) =>
|
||||||
|
new(
|
||||||
|
Classroom.Id,
|
||||||
|
Term.StartDate,
|
||||||
|
startPeriod,
|
||||||
|
periodCount,
|
||||||
|
capacity,
|
||||||
|
null);
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await Db.DisposeAsync();
|
||||||
|
await Connection.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ApplicationUser User(
|
||||||
|
string userName,
|
||||||
|
string displayName) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserName = userName,
|
||||||
|
NormalizedUserName = userName.ToUpperInvariant(),
|
||||||
|
DisplayName = displayName,
|
||||||
|
IsEnabled = true
|
||||||
|
};
|
||||||
|
|
||||||
|
private static ICurrentUserDataScope Scope(
|
||||||
|
ApplicationUser user,
|
||||||
|
string role,
|
||||||
|
DataScope dataScope) =>
|
||||||
|
new FixedScope(new CurrentUserScope(
|
||||||
|
user.Id,
|
||||||
|
user.DisplayName,
|
||||||
|
user.CollegeId,
|
||||||
|
dataScope,
|
||||||
|
new HashSet<string>([role])));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FixedScope(CurrentUserScope current)
|
||||||
|
: ICurrentUserDataScope
|
||||||
|
{
|
||||||
|
public CurrentUserScope Current { get; } = current;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,6 +49,10 @@ const isStatisticsViewer = computed(() =>
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const canUseExperiments = computed(() =>
|
||||||
|
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher', 'Student']),
|
||||||
|
)
|
||||||
|
|
||||||
function hasAnyRole(allowedRoles: string[]) {
|
function hasAnyRole(allowedRoles: string[]) {
|
||||||
return roles.value.some((role) => allowedRoles.includes(role))
|
return roles.value.some((role) => allowedRoles.includes(role))
|
||||||
}
|
}
|
||||||
@@ -72,6 +76,17 @@ const navigationGroups = computed<NavigationGroup[]>(() => [
|
|||||||
items: [{ path: '/statistics', label: '统计报表' }],
|
items: [{ path: '/statistics', label: '统计报表' }],
|
||||||
} as NavigationGroup]
|
} as NavigationGroup]
|
||||||
: []),
|
: []),
|
||||||
|
...(canUseExperiments.value
|
||||||
|
? [{
|
||||||
|
key: 'experiments',
|
||||||
|
label: isStudent.value ? '我的实验' : '实验管理',
|
||||||
|
direct: true,
|
||||||
|
items: [{
|
||||||
|
path: '/experiments',
|
||||||
|
label: isStudent.value ? '我的实验' : '实验管理',
|
||||||
|
}],
|
||||||
|
} as NavigationGroup]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
key: 'organization',
|
key: 'organization',
|
||||||
label: '组织与权限',
|
label: '组织与权限',
|
||||||
|
|||||||
@@ -192,6 +192,14 @@ const router = createRouter({
|
|||||||
name: 'classroom-reservations',
|
name: 'classroom-reservations',
|
||||||
component: () => import('../views/ClassroomReservationsView.vue'),
|
component: () => import('../views/ClassroomReservationsView.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'experiments',
|
||||||
|
name: 'experiments',
|
||||||
|
component: () => import('../views/ExperimentsView.vue'),
|
||||||
|
meta: {
|
||||||
|
roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher', 'Student'],
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'course-selections',
|
path: 'course-selections',
|
||||||
name: 'course-selections',
|
name: 'course-selections',
|
||||||
|
|||||||
@@ -0,0 +1,940 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { Calendar, Check, Plus, Refresh, Tickets, User } from '@element-plus/icons-vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import http, { apiErrorMessage } from '../api/http'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
import {
|
||||||
|
academicTermLabel,
|
||||||
|
academicTermOptionClass,
|
||||||
|
defaultAcademicTermId,
|
||||||
|
} from '../utils/academicTerms'
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const isStudent = computed(() => auth.user?.roles.includes('Student') ?? false)
|
||||||
|
const loading = ref(false)
|
||||||
|
const terms = ref<any[]>([])
|
||||||
|
const termId = ref('')
|
||||||
|
const projects = ref<any[]>([])
|
||||||
|
const options = reactive({
|
||||||
|
tasks: [] as any[],
|
||||||
|
classrooms: [] as any[],
|
||||||
|
periods: [] as any[],
|
||||||
|
})
|
||||||
|
const modeFilter = ref('')
|
||||||
|
const statusFilter = ref('')
|
||||||
|
|
||||||
|
const projectDialog = ref(false)
|
||||||
|
const editingProjectId = ref('')
|
||||||
|
const projectForm = reactive({
|
||||||
|
teachingTaskId: '',
|
||||||
|
code: '',
|
||||||
|
name: '',
|
||||||
|
arrangementMode: 'Centralized',
|
||||||
|
description: '',
|
||||||
|
requirements: '',
|
||||||
|
dates: [] as string[],
|
||||||
|
})
|
||||||
|
|
||||||
|
const sessionDialog = ref(false)
|
||||||
|
const selectedProject = ref<any>(null)
|
||||||
|
const sessionForm = reactive({
|
||||||
|
classroomId: '',
|
||||||
|
sessionDate: '',
|
||||||
|
startPeriod: undefined as number | undefined,
|
||||||
|
periodCount: 2,
|
||||||
|
capacity: 30,
|
||||||
|
notes: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const participantsDialog = ref(false)
|
||||||
|
const participantSession = ref<any>(null)
|
||||||
|
const participants = ref<any[]>([])
|
||||||
|
const participantsLoading = ref(false)
|
||||||
|
|
||||||
|
const selectedTask = computed(() =>
|
||||||
|
options.tasks.find((task) => task.id === projectForm.teachingTaskId),
|
||||||
|
)
|
||||||
|
const activePeriods = computed(() =>
|
||||||
|
options.periods.filter((period) =>
|
||||||
|
!selectedProject.value
|
||||||
|
|| period.academicTermId === selectedProject.value.academicTermId,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const modeMeta: Record<string, { label: string; note: string }> = {
|
||||||
|
Centralized: {
|
||||||
|
label: '集中安排',
|
||||||
|
note: '像课程一样统一到场,无需学生预约',
|
||||||
|
},
|
||||||
|
SelfScheduled: {
|
||||||
|
label: '自行安排',
|
||||||
|
note: '规定实验项目,学生从开放场次中预约',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusLabels: Record<string, string> = {
|
||||||
|
Draft: '草稿',
|
||||||
|
Published: '进行中',
|
||||||
|
Closed: '已关闭',
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetProjectForm() {
|
||||||
|
editingProjectId.value = ''
|
||||||
|
Object.assign(projectForm, {
|
||||||
|
teachingTaskId: '',
|
||||||
|
code: '',
|
||||||
|
name: '',
|
||||||
|
arrangementMode: 'Centralized',
|
||||||
|
description: '',
|
||||||
|
requirements: '',
|
||||||
|
dates: [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTaskChange() {
|
||||||
|
const task = selectedTask.value
|
||||||
|
if (!task) return
|
||||||
|
projectForm.dates = [task.termStartDate, task.termEndDate]
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateProject() {
|
||||||
|
resetProjectForm()
|
||||||
|
projectDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditProject(project: any) {
|
||||||
|
editingProjectId.value = project.id
|
||||||
|
Object.assign(projectForm, {
|
||||||
|
teachingTaskId: project.teachingTaskId,
|
||||||
|
code: project.code,
|
||||||
|
name: project.name,
|
||||||
|
arrangementMode: project.arrangementMode,
|
||||||
|
description: project.description ?? '',
|
||||||
|
requirements: project.requirements ?? '',
|
||||||
|
dates: [project.startDate, project.endDate],
|
||||||
|
})
|
||||||
|
projectDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveProject() {
|
||||||
|
if (!projectForm.teachingTaskId || !projectForm.code.trim()
|
||||||
|
|| !projectForm.name.trim() || projectForm.dates.length !== 2) {
|
||||||
|
ElMessage.warning('请填写教学任务、项目编码、名称和开放日期')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const payload = {
|
||||||
|
teachingTaskId: projectForm.teachingTaskId,
|
||||||
|
code: projectForm.code,
|
||||||
|
name: projectForm.name,
|
||||||
|
arrangementMode: projectForm.arrangementMode,
|
||||||
|
description: projectForm.description || null,
|
||||||
|
requirements: projectForm.requirements || null,
|
||||||
|
startDate: projectForm.dates[0],
|
||||||
|
endDate: projectForm.dates[1],
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (editingProjectId.value) {
|
||||||
|
await http.put(`/experiments/${editingProjectId.value}`, payload)
|
||||||
|
ElMessage.success('实验项目已更新')
|
||||||
|
} else {
|
||||||
|
await http.post('/experiments', payload)
|
||||||
|
ElMessage.success('实验项目已创建')
|
||||||
|
}
|
||||||
|
projectDialog.value = false
|
||||||
|
await load()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(apiErrorMessage(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSession(project: any) {
|
||||||
|
selectedProject.value = project
|
||||||
|
const firstPeriod = options.periods.find((item) =>
|
||||||
|
item.academicTermId === project.academicTermId,
|
||||||
|
)
|
||||||
|
Object.assign(sessionForm, {
|
||||||
|
classroomId: '',
|
||||||
|
sessionDate: project.startDate,
|
||||||
|
startPeriod: firstPeriod?.periodNumber,
|
||||||
|
periodCount: 2,
|
||||||
|
capacity: 30,
|
||||||
|
notes: '',
|
||||||
|
})
|
||||||
|
sessionDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSession() {
|
||||||
|
if (!selectedProject.value || !sessionForm.classroomId
|
||||||
|
|| !sessionForm.sessionDate || !sessionForm.startPeriod) {
|
||||||
|
ElMessage.warning('请选择实验日期、节次和教室')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await http.post(`/experiments/${selectedProject.value.id}/sessions`, sessionForm)
|
||||||
|
sessionDialog.value = false
|
||||||
|
ElMessage.success('实验场次已安排')
|
||||||
|
await load()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(apiErrorMessage(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publishProject(project: any) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
project.arrangementMode === 'Centralized'
|
||||||
|
? '发布后,教学任务名单中的学生将直接看到全部集中实验场次。'
|
||||||
|
: '发布后,教学任务名单中的学生可以立即预约开放场次。',
|
||||||
|
`发布“${project.name}”`,
|
||||||
|
{ confirmButtonText: '确认发布', cancelButtonText: '暂不发布', type: 'warning' },
|
||||||
|
)
|
||||||
|
await http.post(`/experiments/${project.id}/publish`)
|
||||||
|
ElMessage.success('实验项目已发布')
|
||||||
|
await load()
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error === 'cancel' || error === 'close') return
|
||||||
|
ElMessage.error(apiErrorMessage(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeProject(project: any) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
'关闭后保留已有安排和预约记录,但学生不能再预约新场次。',
|
||||||
|
`关闭“${project.name}”`,
|
||||||
|
{ confirmButtonText: '确认关闭', cancelButtonText: '取消', type: 'warning' },
|
||||||
|
)
|
||||||
|
await http.post(`/experiments/${project.id}/close`)
|
||||||
|
ElMessage.success('实验项目已关闭')
|
||||||
|
await load()
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error === 'cancel' || error === 'close') return
|
||||||
|
ElMessage.error(apiErrorMessage(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteProject(project: any) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
'草稿项目及其未发布场次将一并删除。',
|
||||||
|
`删除“${project.name}”`,
|
||||||
|
{ confirmButtonText: '删除', cancelButtonText: '取消', type: 'error' },
|
||||||
|
)
|
||||||
|
await http.delete(`/experiments/${project.id}`)
|
||||||
|
ElMessage.success('实验项目已删除')
|
||||||
|
await load()
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error === 'cancel' || error === 'close') return
|
||||||
|
ElMessage.error(apiErrorMessage(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelSession(project: any, session: any) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
project.status === 'Draft'
|
||||||
|
? '该未发布场次将直接删除。'
|
||||||
|
: '该场次将被取消;已有学生预约也会取消并收到通知。',
|
||||||
|
`${project.status === 'Draft' ? '删除' : '取消'} ${formatSessionTime(session)}`,
|
||||||
|
{ confirmButtonText: '确认', cancelButtonText: '返回', type: 'warning' },
|
||||||
|
)
|
||||||
|
await http.delete(`/experiments/sessions/${session.id}`)
|
||||||
|
ElMessage.success(project.status === 'Draft' ? '场次已删除' : '场次已取消')
|
||||||
|
await load()
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error === 'cancel' || error === 'close') return
|
||||||
|
ElMessage.error(apiErrorMessage(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showParticipants(project: any, session: any) {
|
||||||
|
participantSession.value = { ...session, project }
|
||||||
|
participantsDialog.value = true
|
||||||
|
participantsLoading.value = true
|
||||||
|
try {
|
||||||
|
participants.value = (
|
||||||
|
await http.get(`/experiments/sessions/${session.id}/participants`)
|
||||||
|
).data
|
||||||
|
} catch (error) {
|
||||||
|
participants.value = []
|
||||||
|
ElMessage.error(apiErrorMessage(error))
|
||||||
|
} finally {
|
||||||
|
participantsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function book(project: any, session: any) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`${formatSessionTime(session)} · ${session.campusName} ${session.buildingName} ${session.classroomName}`,
|
||||||
|
`预约“${project.name}”`,
|
||||||
|
{ confirmButtonText: '确认预约', cancelButtonText: '再看看', type: 'info' },
|
||||||
|
)
|
||||||
|
await http.post(`/experiments/sessions/${session.id}/book`)
|
||||||
|
ElMessage.success('实验场次已预约')
|
||||||
|
await load()
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error === 'cancel' || error === 'close') return
|
||||||
|
ElMessage.error(apiErrorMessage(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelBooking(project: any) {
|
||||||
|
if (!project.myBooking) return
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
'取消后名额会立即释放,您可以重新选择该项目的其他场次。',
|
||||||
|
`取消“${project.name}”预约`,
|
||||||
|
{ confirmButtonText: '取消预约', cancelButtonText: '保留预约', type: 'warning' },
|
||||||
|
)
|
||||||
|
await http.delete(`/experiments/bookings/${project.myBooking.id}`)
|
||||||
|
ElMessage.success('预约已取消')
|
||||||
|
await load()
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error === 'cancel' || error === 'close') return
|
||||||
|
ElMessage.error(apiErrorMessage(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSessionTime(session: any) {
|
||||||
|
return `${session.sessionDate} · 第 ${session.startPeriod}–${session.startPeriod + session.periodCount - 1} 节`
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedSession(project: any) {
|
||||||
|
return project.sessions.find((session: any) =>
|
||||||
|
session.id === project.myBooking?.experimentSessionId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionTagType(session: any) {
|
||||||
|
const remaining = session.capacity - session.reservedCount
|
||||||
|
if (remaining <= 0) return 'danger'
|
||||||
|
if (remaining <= Math.max(2, Math.floor(session.capacity * 0.2))) return 'warning'
|
||||||
|
return 'success'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOptions() {
|
||||||
|
if (isStudent.value) return
|
||||||
|
try {
|
||||||
|
const { data } = await http.get('/experiments/options', {
|
||||||
|
params: { academicTermId: termId.value || undefined },
|
||||||
|
})
|
||||||
|
options.tasks = data.tasks
|
||||||
|
options.classrooms = data.classrooms
|
||||||
|
options.periods = data.periods
|
||||||
|
} catch (error) {
|
||||||
|
options.tasks = []
|
||||||
|
options.classrooms = []
|
||||||
|
options.periods = []
|
||||||
|
ElMessage.error(apiErrorMessage(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
if (isStudent.value) {
|
||||||
|
projects.value = (await http.get('/experiments/student', {
|
||||||
|
params: { academicTermId: termId.value || undefined },
|
||||||
|
})).data
|
||||||
|
} else {
|
||||||
|
projects.value = (await http.get('/experiments/management', {
|
||||||
|
params: {
|
||||||
|
academicTermId: termId.value || undefined,
|
||||||
|
arrangementMode: modeFilter.value || undefined,
|
||||||
|
status: statusFilter.value || undefined,
|
||||||
|
},
|
||||||
|
})).data
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
projects.value = []
|
||||||
|
ElMessage.error(apiErrorMessage(error))
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changeTerm() {
|
||||||
|
await loadOptions()
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
terms.value = (await http.get('/base-data/terms')).data
|
||||||
|
termId.value = defaultAcademicTermId(terms.value) ?? ''
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(apiErrorMessage(error))
|
||||||
|
}
|
||||||
|
await loadOptions()
|
||||||
|
await load()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page-stack experiment-page">
|
||||||
|
<section class="page-intro experiment-intro">
|
||||||
|
<div>
|
||||||
|
<span class="section-kicker">LABORATORY OPERATIONS</span>
|
||||||
|
<h2>{{ isStudent ? '我的实验' : '实验管理' }}</h2>
|
||||||
|
<p v-if="isStudent">查看统一安排的实验课次,或为规定实验项目选择适合自己的开放时段。</p>
|
||||||
|
<p v-else>把实验项目分成两条运行轨道:集中排入固定课次,或开放场次供学生自主预约。</p>
|
||||||
|
</div>
|
||||||
|
<div class="intro-actions">
|
||||||
|
<el-button v-if="!isStudent" type="primary" :icon="Plus" @click="openCreateProject">
|
||||||
|
新建实验项目
|
||||||
|
</el-button>
|
||||||
|
<el-button :icon="Refresh" @click="load">刷新</el-button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="lab-rail" aria-label="实验安排方式说明">
|
||||||
|
<article class="rail-lane centralized">
|
||||||
|
<span class="rail-code">FIXED / 集中</span>
|
||||||
|
<b>统一课次</b>
|
||||||
|
<p>面向整个教学任务名单,时间和实验室固定,像上课一样按安排到场。</p>
|
||||||
|
</article>
|
||||||
|
<div class="rail-switch" aria-hidden="true"><i /><i /></div>
|
||||||
|
<article class="rail-lane flexible">
|
||||||
|
<span class="rail-code">OPEN / 自主</span>
|
||||||
|
<b>开放预约</b>
|
||||||
|
<p>实验内容保持一致,学生在容量允许的场次中选择时间,每个项目限约一次。</p>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="experiment-toolbar">
|
||||||
|
<el-select v-model="termId" clearable placeholder="全部学期" @change="changeTerm">
|
||||||
|
<el-option
|
||||||
|
v-for="term in terms"
|
||||||
|
:key="term.id"
|
||||||
|
:label="academicTermLabel(term)"
|
||||||
|
:value="term.id"
|
||||||
|
:class="academicTermOptionClass(term)"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<template v-if="!isStudent">
|
||||||
|
<el-select v-model="modeFilter" clearable placeholder="全部安排方式" @change="load">
|
||||||
|
<el-option label="集中安排" value="Centralized" />
|
||||||
|
<el-option label="自行安排" value="SelfScheduled" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="statusFilter" clearable placeholder="全部状态" @change="load">
|
||||||
|
<el-option label="草稿" value="Draft" />
|
||||||
|
<el-option label="进行中" value="Published" />
|
||||||
|
<el-option label="已关闭" value="Closed" />
|
||||||
|
</el-select>
|
||||||
|
</template>
|
||||||
|
<span class="result-note">共 {{ projects.length }} 个实验项目</span>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-loading="loading" class="project-list">
|
||||||
|
<article
|
||||||
|
v-for="project in projects"
|
||||||
|
:key="project.id"
|
||||||
|
class="project-card"
|
||||||
|
:class="[
|
||||||
|
project.arrangementMode === 'Centralized' ? 'is-centralized' : 'is-flexible',
|
||||||
|
`is-${project.status.toLowerCase()}`,
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<header class="project-head">
|
||||||
|
<div class="project-identity">
|
||||||
|
<span class="project-code">{{ project.courseCode }} · {{ project.code }}</span>
|
||||||
|
<h3>{{ project.name }}</h3>
|
||||||
|
<p>{{ project.courseName }} · {{ project.taskNumber }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="project-tags">
|
||||||
|
<el-tag
|
||||||
|
:type="project.arrangementMode === 'Centralized' ? 'primary' : 'success'"
|
||||||
|
effect="plain"
|
||||||
|
>
|
||||||
|
{{ modeMeta[project.arrangementMode].label }}
|
||||||
|
</el-tag>
|
||||||
|
<el-tag
|
||||||
|
:type="project.status === 'Published' ? 'success' : project.status === 'Closed' ? 'info' : 'warning'"
|
||||||
|
>
|
||||||
|
{{ statusLabels[project.status] }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="project-context">
|
||||||
|
<span><b>开放日期</b>{{ project.startDate }} — {{ project.endDate }}</span>
|
||||||
|
<span><b>任课教师</b>{{ project.teacherNames.join('、') || '待定' }}</span>
|
||||||
|
<span v-if="!isStudent"><b>面向班级</b>{{ project.classNames.join('、') || '选课学生' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="project.description" class="project-description">{{ project.description }}</p>
|
||||||
|
<div v-if="project.requirements" class="requirement-strip">
|
||||||
|
<b>实验要求</b>
|
||||||
|
<span>{{ project.requirements }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="session-board">
|
||||||
|
<div class="session-board-head">
|
||||||
|
<div>
|
||||||
|
<span>{{ project.arrangementMode === 'Centralized' ? '统一实验课次' : '可选实验场次' }}</span>
|
||||||
|
<b>{{ modeMeta[project.arrangementMode].note }}</b>
|
||||||
|
</div>
|
||||||
|
<el-button
|
||||||
|
v-if="!isStudent && project.status !== 'Closed'"
|
||||||
|
size="small"
|
||||||
|
:icon="Calendar"
|
||||||
|
@click="openSession(project)"
|
||||||
|
>
|
||||||
|
安排场次
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="project.sessions.length" class="session-grid">
|
||||||
|
<article
|
||||||
|
v-for="session in project.sessions"
|
||||||
|
:key="session.id"
|
||||||
|
class="session-ticket"
|
||||||
|
:class="{
|
||||||
|
'is-selected': project.myBooking?.experimentSessionId === session.id,
|
||||||
|
'is-cancelled': session.status === 'Cancelled',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="ticket-date">
|
||||||
|
<strong>{{ session.sessionDate.slice(8, 10) }}</strong>
|
||||||
|
<span>{{ session.sessionDate.slice(5, 7) }} 月</span>
|
||||||
|
</div>
|
||||||
|
<div class="ticket-body">
|
||||||
|
<b>第 {{ session.startPeriod }}–{{ session.startPeriod + session.periodCount - 1 }} 节</b>
|
||||||
|
<span>{{ session.campusName }} · {{ session.buildingName }} {{ session.classroomName }}</span>
|
||||||
|
<small v-if="session.notes">{{ session.notes }}</small>
|
||||||
|
</div>
|
||||||
|
<div class="ticket-action">
|
||||||
|
<template v-if="project.arrangementMode === 'SelfScheduled'">
|
||||||
|
<el-tag
|
||||||
|
v-if="session.status !== 'Cancelled'"
|
||||||
|
:type="sessionTagType(session)"
|
||||||
|
size="small"
|
||||||
|
effect="plain"
|
||||||
|
>
|
||||||
|
余 {{ session.capacity - session.reservedCount }} / {{ session.capacity }}
|
||||||
|
</el-tag>
|
||||||
|
<el-tag v-else type="info" size="small">已取消</el-tag>
|
||||||
|
</template>
|
||||||
|
<el-tag v-else type="primary" size="small" effect="plain">
|
||||||
|
统一到场
|
||||||
|
</el-tag>
|
||||||
|
|
||||||
|
<template v-if="isStudent && project.arrangementMode === 'SelfScheduled'">
|
||||||
|
<el-button
|
||||||
|
v-if="project.myBooking?.experimentSessionId === session.id"
|
||||||
|
size="small"
|
||||||
|
type="success"
|
||||||
|
:icon="Check"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
已预约
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-else
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
plain
|
||||||
|
:disabled="project.status !== 'Published' || !!project.myBooking || session.remainingCount <= 0"
|
||||||
|
@click="book(project, session)"
|
||||||
|
>
|
||||||
|
预约此场
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
<template v-if="!isStudent">
|
||||||
|
<el-button
|
||||||
|
size="small"
|
||||||
|
text
|
||||||
|
:icon="User"
|
||||||
|
@click="showParticipants(project, session)"
|
||||||
|
>
|
||||||
|
{{ project.arrangementMode === 'Centralized' ? '应到名单' : '预约名单' }}
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="session.status !== 'Cancelled'"
|
||||||
|
size="small"
|
||||||
|
text
|
||||||
|
type="danger"
|
||||||
|
@click="cancelSession(project, session)"
|
||||||
|
>
|
||||||
|
{{ project.status === 'Draft' ? '删除' : '取消' }}
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<el-empty
|
||||||
|
v-else
|
||||||
|
:image-size="58"
|
||||||
|
:description="project.status === 'Draft' ? '尚未安排场次,安排后才能发布' : '暂无有效实验场次'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer v-if="!isStudent" class="project-actions">
|
||||||
|
<template v-if="project.status === 'Draft'">
|
||||||
|
<el-button size="small" @click="openEditProject(project)">编辑项目</el-button>
|
||||||
|
<el-button size="small" type="danger" plain @click="deleteProject(project)">删除</el-button>
|
||||||
|
<el-button
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
:disabled="!project.sessions.some((item: any) => item.status !== 'Cancelled')"
|
||||||
|
@click="publishProject(project)"
|
||||||
|
>
|
||||||
|
发布给学生
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
<el-button
|
||||||
|
v-else-if="project.status === 'Published'"
|
||||||
|
size="small"
|
||||||
|
@click="closeProject(project)"
|
||||||
|
>
|
||||||
|
关闭项目
|
||||||
|
</el-button>
|
||||||
|
</footer>
|
||||||
|
<footer
|
||||||
|
v-else-if="project.arrangementMode === 'SelfScheduled' && project.myBooking"
|
||||||
|
class="student-booking-summary"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<Tickets />
|
||||||
|
<span>
|
||||||
|
<b>当前预约</b>
|
||||||
|
{{ formatSessionTime(selectedSession(project)) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<el-button
|
||||||
|
v-if="project.status === 'Published'"
|
||||||
|
size="small"
|
||||||
|
type="danger"
|
||||||
|
plain
|
||||||
|
@click="cancelBooking(project)"
|
||||||
|
>
|
||||||
|
取消并重选
|
||||||
|
</el-button>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<el-empty
|
||||||
|
v-if="!loading && !projects.length"
|
||||||
|
:description="isStudent ? '当前学期没有已发布的实验项目' : '当前筛选条件下暂无实验项目'"
|
||||||
|
>
|
||||||
|
<el-button v-if="!isStudent" type="primary" @click="openCreateProject">
|
||||||
|
新建第一个实验项目
|
||||||
|
</el-button>
|
||||||
|
</el-empty>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="projectDialog"
|
||||||
|
:title="editingProjectId ? '编辑实验项目' : '新建实验项目'"
|
||||||
|
width="720px"
|
||||||
|
top="5vh"
|
||||||
|
>
|
||||||
|
<el-form label-position="top" class="experiment-form">
|
||||||
|
<div class="form-section">
|
||||||
|
<header><span>PROJECT</span><b>规定实验项目</b></header>
|
||||||
|
<el-form-item label="所属教学任务" required>
|
||||||
|
<el-select
|
||||||
|
v-model="projectForm.teachingTaskId"
|
||||||
|
filterable
|
||||||
|
:disabled="!!editingProjectId"
|
||||||
|
placeholder="选择已发布教学任务"
|
||||||
|
@change="onTaskChange"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="task in options.tasks"
|
||||||
|
:key="task.id"
|
||||||
|
:label="`${task.courseCode} · ${task.courseName} · ${task.taskNumber}`"
|
||||||
|
:value="task.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<div class="form-grid two">
|
||||||
|
<el-form-item label="项目编码" required>
|
||||||
|
<el-input v-model="projectForm.code" maxlength="40" placeholder="如 LAB-01" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="项目名称" required>
|
||||||
|
<el-input v-model="projectForm.name" maxlength="120" placeholder="如 数据库事务与并发实验" />
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-section">
|
||||||
|
<header><span>ROUTE</span><b>选择运行轨道</b></header>
|
||||||
|
<el-radio-group v-model="projectForm.arrangementMode" class="mode-choice">
|
||||||
|
<el-radio-button value="Centralized">
|
||||||
|
<b>集中安排</b><small>统一时间,像上课一样到场</small>
|
||||||
|
</el-radio-button>
|
||||||
|
<el-radio-button value="SelfScheduled">
|
||||||
|
<b>自行安排</b><small>开放多个场次,学生自主预约</small>
|
||||||
|
</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
<el-form-item label="项目开放日期" required>
|
||||||
|
<el-date-picker
|
||||||
|
v-model="projectForm.dates"
|
||||||
|
type="daterange"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
range-separator="至"
|
||||||
|
start-placeholder="开始日期"
|
||||||
|
end-placeholder="结束日期"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-section">
|
||||||
|
<header><span>BRIEF</span><b>说明学生要完成什么</b></header>
|
||||||
|
<el-form-item label="实验内容">
|
||||||
|
<el-input
|
||||||
|
v-model="projectForm.description"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
maxlength="1000"
|
||||||
|
show-word-limit
|
||||||
|
placeholder="说明实验目标、任务和成果"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="到场与准备要求">
|
||||||
|
<el-input
|
||||||
|
v-model="projectForm.requirements"
|
||||||
|
type="textarea"
|
||||||
|
:rows="2"
|
||||||
|
maxlength="1000"
|
||||||
|
show-word-limit
|
||||||
|
placeholder="如携带材料、安装软件、分组要求"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="projectDialog = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="saveProject">保存项目</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="sessionDialog"
|
||||||
|
:title="`安排场次 · ${selectedProject?.name ?? ''}`"
|
||||||
|
width="650px"
|
||||||
|
>
|
||||||
|
<el-form label-position="top" class="experiment-form">
|
||||||
|
<el-alert
|
||||||
|
:title="selectedProject?.arrangementMode === 'Centralized'
|
||||||
|
? '系统会检查实验室、任课教师和行政班的课表冲突。'
|
||||||
|
: '系统会检查实验室占用;学生预约时再检查个人课表和其他实验。'"
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
/>
|
||||||
|
<div class="form-grid two">
|
||||||
|
<el-form-item label="实验日期" required>
|
||||||
|
<el-date-picker
|
||||||
|
v-model="sessionForm.sessionDate"
|
||||||
|
type="date"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
placeholder="选择日期"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="起始节次" required>
|
||||||
|
<el-select v-model="sessionForm.startPeriod" placeholder="选择节次">
|
||||||
|
<el-option
|
||||||
|
v-for="period in activePeriods"
|
||||||
|
:key="period.periodNumber"
|
||||||
|
:label="period.startsAt
|
||||||
|
? `${period.name} · ${period.startsAt}–${period.endsAt}`
|
||||||
|
: period.name"
|
||||||
|
:value="period.periodNumber"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="连续节数" required>
|
||||||
|
<el-input-number v-model="sessionForm.periodCount" :min="1" :max="12" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="场次容量" required>
|
||||||
|
<el-input-number v-model="sessionForm.capacity" :min="1" :max="10000" />
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
<el-form-item label="实验室" required>
|
||||||
|
<el-select v-model="sessionForm.classroomId" filterable placeholder="选择实验室或教学场所">
|
||||||
|
<el-option
|
||||||
|
v-for="room in options.classrooms"
|
||||||
|
:key="room.id"
|
||||||
|
:label="`${room.campusName} · ${room.buildingName} ${room.name} · ${room.capacity} 人`"
|
||||||
|
:value="room.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="场次备注">
|
||||||
|
<el-input
|
||||||
|
v-model="sessionForm.notes"
|
||||||
|
maxlength="500"
|
||||||
|
placeholder="如分组、设备或材料说明"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="sessionDialog = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="saveSession">保存场次</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="participantsDialog"
|
||||||
|
:title="`${participantSession?.project?.arrangementMode === 'Centralized' ? '应到名单' : '预约名单'} · ${participantSession ? formatSessionTime(participantSession) : ''}`"
|
||||||
|
width="680px"
|
||||||
|
>
|
||||||
|
<el-table v-loading="participantsLoading" :data="participants" max-height="480">
|
||||||
|
<el-table-column prop="studentNumber" label="学号" min-width="130" />
|
||||||
|
<el-table-column prop="name" label="姓名" min-width="100" />
|
||||||
|
<el-table-column prop="className" label="行政班" min-width="160" />
|
||||||
|
<el-table-column label="参与方式" width="110">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag
|
||||||
|
:type="row.participationType === 'Centralized' ? 'primary' : 'success'"
|
||||||
|
size="small"
|
||||||
|
effect="plain"
|
||||||
|
>
|
||||||
|
{{ row.participationType === 'Centralized' ? '统一安排' : '学生预约' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<el-empty v-if="!participantsLoading && !participants.length" description="当前没有学生名单" />
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.experiment-page { --lab-blue: #245d82; --lab-teal: #168276; --lab-ink: #173041; }
|
||||||
|
.experiment-intro { align-items: flex-start; }
|
||||||
|
.intro-actions { display: flex; gap: 8px; align-items: center; }
|
||||||
|
.lab-rail {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 78px minmax(0, 1fr);
|
||||||
|
gap: 0;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border: 1px solid #ccdce5;
|
||||||
|
background: #f7fafc;
|
||||||
|
}
|
||||||
|
.rail-lane { position: relative; padding: 17px 20px 18px 24px; overflow: hidden; }
|
||||||
|
.rail-lane::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 auto 0 0;
|
||||||
|
width: 5px;
|
||||||
|
background: var(--lab-blue);
|
||||||
|
}
|
||||||
|
.rail-lane.flexible::before { background: var(--lab-teal); }
|
||||||
|
.rail-lane b { display: block; margin: 4px 0; color: var(--lab-ink); font-size: 15px; }
|
||||||
|
.rail-lane p { margin: 0; color: var(--muted); font-size: 12px; line-height: 1.65; }
|
||||||
|
.rail-code { color: #607987; font: 700 10px/1.2 Consolas, monospace; letter-spacing: .08em; }
|
||||||
|
.rail-switch {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
place-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: linear-gradient(90deg, transparent 49%, #b9cbd5 49%, #b9cbd5 51%, transparent 51%);
|
||||||
|
}
|
||||||
|
.rail-switch i { width: 30px; height: 7px; border: 2px solid #7794a5; border-radius: 999px; background: #fff; }
|
||||||
|
.experiment-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-bottom: 16px; }
|
||||||
|
.experiment-toolbar .el-select { width: 210px; }
|
||||||
|
.result-note { margin-left: auto; color: var(--muted); font-size: 12px; }
|
||||||
|
.project-list { display: grid; gap: 14px; min-height: 160px; }
|
||||||
|
.project-card {
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #dce5ea;
|
||||||
|
border-top: 4px solid var(--lab-blue);
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 7px 22px rgb(35 67 85 / 5%);
|
||||||
|
}
|
||||||
|
.project-card.is-flexible { border-top-color: var(--lab-teal); }
|
||||||
|
.project-card.is-closed { opacity: .82; }
|
||||||
|
.project-head { display: flex; justify-content: space-between; gap: 16px; padding: 18px 20px 10px; }
|
||||||
|
.project-code { color: #667f8d; font: 700 10px/1.2 Consolas, monospace; letter-spacing: .06em; text-transform: uppercase; }
|
||||||
|
.project-identity h3 { margin: 5px 0 4px; color: var(--lab-ink); font-size: 18px; }
|
||||||
|
.project-identity p { margin: 0; color: var(--muted); font-size: 12px; }
|
||||||
|
.project-tags { display: flex; gap: 6px; align-items: flex-start; flex-shrink: 0; }
|
||||||
|
.project-context { display: flex; flex-wrap: wrap; gap: 7px 22px; padding: 0 20px 12px; color: #5c7481; font-size: 12px; }
|
||||||
|
.project-context span { display: inline-flex; gap: 7px; }
|
||||||
|
.project-context b { color: #304b5a; font-weight: 600; }
|
||||||
|
.project-description { margin: 0; padding: 10px 20px; border-top: 1px solid #edf1f3; color: #415b69; font-size: 13px; line-height: 1.7; }
|
||||||
|
.requirement-strip {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 74px 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 0 20px 14px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-left: 3px solid #e3a13c;
|
||||||
|
background: #fff9ef;
|
||||||
|
color: #6d5b3e;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.session-board { margin: 0 20px 16px; border: 1px solid #dfe8ec; background: #f8fafb; }
|
||||||
|
.session-board-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 11px 13px; border-bottom: 1px solid #dfe8ec; }
|
||||||
|
.session-board-head div { display: grid; gap: 2px; }
|
||||||
|
.session-board-head span { color: #617986; font: 700 10px/1.2 Consolas, monospace; letter-spacing: .06em; text-transform: uppercase; }
|
||||||
|
.session-board-head b { color: #304d5d; font-size: 11px; font-weight: 500; }
|
||||||
|
.session-grid { display: grid; gap: 8px; padding: 10px; }
|
||||||
|
.session-ticket {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 58px minmax(0, 1fr) auto;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 74px;
|
||||||
|
border: 1px solid #dce5e9;
|
||||||
|
background: #fff;
|
||||||
|
transition: border-color .18s ease, box-shadow .18s ease;
|
||||||
|
}
|
||||||
|
.session-ticket:hover { border-color: #9fb7c4; box-shadow: 0 5px 14px rgb(40 76 96 / 7%); }
|
||||||
|
.session-ticket.is-selected { border-color: var(--lab-teal); box-shadow: inset 4px 0 0 var(--lab-teal); }
|
||||||
|
.session-ticket.is-cancelled { opacity: .58; }
|
||||||
|
.ticket-date { display: grid; place-content: center; align-self: stretch; border-right: 1px dashed #cddbe2; background: #f0f5f7; text-align: center; }
|
||||||
|
.ticket-date strong { color: var(--lab-ink); font: 700 22px/1 Consolas, monospace; }
|
||||||
|
.ticket-date span { margin-top: 4px; color: #657d89; font-size: 10px; }
|
||||||
|
.ticket-body { display: grid; gap: 3px; min-width: 0; }
|
||||||
|
.ticket-body b { color: #243f4f; font-size: 13px; }
|
||||||
|
.ticket-body span { color: #5c7481; font-size: 12px; }
|
||||||
|
.ticket-body small { overflow: hidden; color: #81929b; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.ticket-action { display: flex; align-items: center; justify-content: flex-end; gap: 5px; padding-right: 10px; }
|
||||||
|
.project-actions { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 20px; border-top: 1px solid #e5ecef; background: #fbfcfd; }
|
||||||
|
.student-booking-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-top: 1px solid #cce3de;
|
||||||
|
background: #f0faf7;
|
||||||
|
}
|
||||||
|
.student-booking-summary > div { display: flex; align-items: center; gap: 10px; color: var(--lab-teal); }
|
||||||
|
.student-booking-summary svg { width: 22px; }
|
||||||
|
.student-booking-summary span { display: grid; gap: 2px; color: #365f5b; font-size: 12px; }
|
||||||
|
.student-booking-summary b { font-size: 10px; text-transform: uppercase; letter-spacing: .06em; }
|
||||||
|
.experiment-form { display: grid; gap: 13px; }
|
||||||
|
.form-section { padding: 14px 15px 1px; border: 1px solid var(--line); background: #fbfcfd; }
|
||||||
|
.form-section > header { display: flex; align-items: baseline; gap: 9px; margin-bottom: 13px; }
|
||||||
|
.form-section > header span { color: var(--lab-teal); font: 700 10px/1 Consolas, monospace; letter-spacing: .08em; }
|
||||||
|
.form-section > header b { color: var(--lab-ink); font-size: 14px; }
|
||||||
|
.mode-choice { display: grid; grid-template-columns: 1fr 1fr; margin-bottom: 16px; }
|
||||||
|
.mode-choice :deep(.el-radio-button__inner) { display: grid; gap: 5px; width: 100%; padding: 13px; }
|
||||||
|
.mode-choice b { font-size: 13px; }
|
||||||
|
.mode-choice small { font-size: 10px; font-weight: 400; }
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.session-ticket { transition: none; }
|
||||||
|
}
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.lab-rail { grid-template-columns: 1fr; }
|
||||||
|
.rail-switch { display: none; }
|
||||||
|
.rail-lane { border-bottom: 1px solid #d5e1e7; }
|
||||||
|
.experiment-toolbar .el-select { width: 100%; }
|
||||||
|
.result-note { width: 100%; margin-left: 0; }
|
||||||
|
.project-head { align-items: flex-start; padding: 15px 14px 9px; }
|
||||||
|
.project-context, .project-description { padding-inline: 14px; }
|
||||||
|
.requirement-strip, .session-board { margin-inline: 14px; }
|
||||||
|
.session-ticket { grid-template-columns: 54px minmax(0, 1fr); }
|
||||||
|
.ticket-action { grid-column: 1 / -1; justify-content: flex-start; padding: 0 10px 10px; }
|
||||||
|
.project-actions, .student-booking-summary { padding-inline: 14px; }
|
||||||
|
.mode-choice { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user