实验预约
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>();
|
||||
public DbSet<ClassroomReservation> ClassroomReservations =>
|
||||
Set<ClassroomReservation>();
|
||||
public DbSet<ExperimentProject> ExperimentProjects => Set<ExperimentProject>();
|
||||
public DbSet<ExperimentSession> ExperimentSessions => Set<ExperimentSession>();
|
||||
public DbSet<ExperimentBooking> ExperimentBookings => Set<ExperimentBooking>();
|
||||
public DbSet<CourseSelectionRound> CourseSelectionRounds =>
|
||||
Set<CourseSelectionRound>();
|
||||
public DbSet<CourseSelectionRoundGrade> CourseSelectionRoundGrades =>
|
||||
@@ -547,6 +550,64 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
.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 =>
|
||||
{
|
||||
entity.Property(x => x.Name).HasMaxLength(120);
|
||||
|
||||
@@ -68,6 +68,10 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
"20260727_36_exam_room_mixing";
|
||||
private const string AttendanceCheckInAuditMigration =
|
||||
"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)
|
||||
{
|
||||
@@ -513,6 +517,32 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
ExamRoomMixingMigration,
|
||||
examRoomsExist ? [] : ExamRoomMixingStatements,
|
||||
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(
|
||||
@@ -2351,4 +2381,134 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
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");
|
||||
});
|
||||
|
||||
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 =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -4630,6 +4785,63 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
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 =>
|
||||
{
|
||||
b.HasOne("Jiaowu.Api.Domain.Academic.GradeSheet", "GradeSheet")
|
||||
@@ -5338,6 +5550,18 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
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 =>
|
||||
{
|
||||
b.Navigation("Scores");
|
||||
|
||||
@@ -81,6 +81,20 @@ public sealed class ClassroomReservationAvailabilityService(AppDbContext db)
|
||||
.ToListAsync(cancellationToken);
|
||||
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()
|
||||
.Where(reservation =>
|
||||
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[]) {
|
||||
return roles.value.some((role) => allowedRoles.includes(role))
|
||||
}
|
||||
@@ -72,6 +76,17 @@ const navigationGroups = computed<NavigationGroup[]>(() => [
|
||||
items: [{ path: '/statistics', label: '统计报表' }],
|
||||
} as NavigationGroup]
|
||||
: []),
|
||||
...(canUseExperiments.value
|
||||
? [{
|
||||
key: 'experiments',
|
||||
label: isStudent.value ? '我的实验' : '实验管理',
|
||||
direct: true,
|
||||
items: [{
|
||||
path: '/experiments',
|
||||
label: isStudent.value ? '我的实验' : '实验管理',
|
||||
}],
|
||||
} as NavigationGroup]
|
||||
: []),
|
||||
{
|
||||
key: 'organization',
|
||||
label: '组织与权限',
|
||||
|
||||
@@ -192,6 +192,14 @@ const router = createRouter({
|
||||
name: 'classroom-reservations',
|
||||
component: () => import('../views/ClassroomReservationsView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'experiments',
|
||||
name: 'experiments',
|
||||
component: () => import('../views/ExperimentsView.vue'),
|
||||
meta: {
|
||||
roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher', 'Student'],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '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