实验预约

This commit is contained in:
2026-07-28 21:04:18 +08:00 Unverified
parent 865378dbdf
commit ec979f640d
12 changed files with 8750 additions and 0 deletions
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"
);
"""
];
}
@@ -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");
}
}
}
@@ -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;
}
}
+15
View File
@@ -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: '组织与权限',
+8
View File
@@ -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',
+940
View File
@@ -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>