实验成绩计算

This commit is contained in:
2026-08-09 22:40:21 +08:00 Unverified
parent d4abdd3db4
commit c25f16ec20
12 changed files with 7653 additions and 290 deletions
@@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Data;
using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Auth;
@@ -138,7 +139,7 @@ public sealed class ExperimentGradesController(
if (student is null) if (student is null)
return ConflictProblem("当前账号未关联有效学生档案。"); return ConflictProblem("当前账号未关联有效学生档案。");
return Ok(await db.ExperimentGradeRecords.AsNoTracking() var results = await db.ExperimentGradeRecords.AsNoTracking()
.Where(x => .Where(x =>
x.StudentId == student.Id && x.StudentId == student.Id &&
x.ExperimentGradeSheet!.Status == x.ExperimentGradeSheet!.Status ==
@@ -157,12 +158,19 @@ public sealed class ExperimentGradesController(
ProjectName = ProjectName =
x.ExperimentGradeSheet.ExperimentProject.Name, x.ExperimentGradeSheet.ExperimentProject.Name,
x.ExperimentGradeSheet.ExperimentProject.ArrangementMode, x.ExperimentGradeSheet.ExperimentProject.ArrangementMode,
TeachingTaskId = x.ExperimentGradeSheet.ExperimentProject
.TeachingTaskId,
AcademicTermId = x.ExperimentGradeSheet.ExperimentProject
.TeachingTask!.AcademicTermId,
TaskNumber = x.ExperimentGradeSheet.ExperimentProject
.TeachingTask.TaskNumber,
CourseCode = x.ExperimentGradeSheet.ExperimentProject CourseCode = x.ExperimentGradeSheet.ExperimentProject
.TeachingTask!.Course!.Code, .TeachingTask!.Course!.Code,
CourseName = x.ExperimentGradeSheet.ExperimentProject CourseName = x.ExperimentGradeSheet.ExperimentProject
.TeachingTask.Course.Name, .TeachingTask.Course.Name,
TermName = x.ExperimentGradeSheet.ExperimentProject TermName = x.ExperimentGradeSheet.ExperimentProject
.TeachingTask.AcademicTerm!.Name, .TeachingTask.AcademicTerm!.Name,
x.ExperimentGradeSheet.ContributionWeight,
x.ExperimentGradeSheet.PassScore, x.ExperimentGradeSheet.PassScore,
x.ParticipationStatus, x.ParticipationStatus,
x.TotalScore, x.TotalScore,
@@ -187,7 +195,40 @@ public sealed class ExperimentGradesController(
}), }),
x.ExperimentGradeSheet.PublishedAt x.ExperimentGradeSheet.PublishedAt
}) })
.ToListAsync(cancellationToken)); .ToListAsync(cancellationToken);
var taskIds = results.Select(x => x.TeachingTaskId).Distinct().ToArray();
var aggregates = await db.ExperimentCourseGrades.AsNoTracking()
.Where(x =>
x.StudentId == student.Id &&
taskIds.Contains(x.TeachingTaskId))
.ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken);
var courses = results
.GroupBy(x => x.TeachingTaskId)
.Select(group =>
{
var first = group.First();
aggregates.TryGetValue(group.Key, out var aggregate);
return new
{
TeachingTaskId = group.Key,
first.AcademicTermId,
first.TermName,
first.TaskNumber,
first.CourseCode,
first.CourseName,
ExperimentCourseScore = aggregate?.WeightedAverageScore,
PublishedProjectCount =
aggregate?.PublishedProjectCount ?? group.Count(),
TotalWeight = aggregate?.TotalWeight ??
group.Sum(x => x.ContributionWeight),
RefreshedAt = aggregate?.RefreshedAt,
Projects = group.OrderBy(x => x.ProjectCode).ToList()
};
})
.OrderByDescending(x => x.Projects.Max(project => project.PublishedAt))
.ThenBy(x => x.CourseCode)
.ToList();
return Ok(courses);
} }
[HttpPost("sheets")] [HttpPost("sheets")]
@@ -744,7 +785,15 @@ public sealed class ExperimentGradesController(
sheet.Status = ExperimentGradeSheetStatus.Published; sheet.Status = ExperimentGradeSheetStatus.Published;
sheet.PublishedAt = DateTime.UtcNow; sheet.PublishedAt = DateTime.UtcNow;
await db.SaveChangesAsync(cancellationToken); await db.ExecuteInRetriableTransactionAsync(async transaction =>
{
await db.SaveChangesAsync(cancellationToken);
await ExperimentGradeAggregationService.RefreshTeachingTaskAsync(
db,
sheet.ExperimentProject!.TeachingTaskId,
cancellationToken);
await transaction.CommitAsync(cancellationToken);
}, cancellationToken, IsolationLevel.Serializable);
var userIds = await db.ExperimentGradeRecords var userIds = await db.ExperimentGradeRecords
.Where(x => x.ExperimentGradeSheetId == sheet.Id) .Where(x => x.ExperimentGradeSheetId == sheet.Id)
.Select(x => x.Student!.UserId) .Select(x => x.Student!.UserId)
@@ -51,6 +51,22 @@ public sealed class ExperimentGradeRecord : EntityBase
public ICollection<ExperimentGradeItemScore> ItemScores { get; set; } = []; public ICollection<ExperimentGradeItemScore> ItemScores { get; set; } = [];
} }
/// <summary>
/// A student's persisted experiment-part score for one teaching task.
/// The score is the weighted average of every published experiment project.
/// </summary>
public sealed class ExperimentCourseGrade : EntityBase
{
public Guid TeachingTaskId { get; set; }
public TeachingTask? TeachingTask { get; set; }
public Guid StudentId { get; set; }
public Student? Student { get; set; }
public decimal? WeightedAverageScore { get; set; }
public decimal TotalWeight { get; set; }
public int PublishedProjectCount { get; set; }
public DateTime RefreshedAt { get; set; }
}
public sealed class ExperimentGradeItemScore public sealed class ExperimentGradeItemScore
{ {
public Guid ExperimentGradeRecordId { get; set; } public Guid ExperimentGradeRecordId { get; set; }
@@ -5,13 +5,85 @@ namespace Jiaowu.Api.Infrastructure.Grades;
public static class ExperimentGradeAggregationService public static class ExperimentGradeAggregationService
{ {
public static async Task RefreshTeachingTaskAsync(
AppDbContext db,
Guid teachingTaskId,
CancellationToken cancellationToken)
{
var sheets = await LoadPublishedSheetsAsync(
db,
teachingTaskId,
cancellationToken);
var existing = await db.ExperimentCourseGrades
.Where(x => x.TeachingTaskId == teachingTaskId)
.ToDictionaryAsync(x => x.StudentId, cancellationToken);
var studentIds = sheets
.SelectMany(x => x.Scores.Select(score => score.StudentId))
.Distinct()
.ToArray();
var refreshedAt = DateTime.UtcNow;
var totalWeight = sheets.Sum(x => x.ContributionWeight);
foreach (var studentId in studentIds)
{
var score = CalculateStudentScore(sheets, studentId);
if (!existing.Remove(studentId, out var aggregate))
{
aggregate = new Domain.Academic.ExperimentCourseGrade
{
TeachingTaskId = teachingTaskId,
StudentId = studentId
};
db.ExperimentCourseGrades.Add(aggregate);
}
aggregate.WeightedAverageScore = score;
aggregate.TotalWeight = totalWeight;
aggregate.PublishedProjectCount = sheets.Count;
aggregate.RefreshedAt = refreshedAt;
}
db.ExperimentCourseGrades.RemoveRange(existing.Values);
await db.SaveChangesAsync(cancellationToken);
}
public static async Task<ExperimentGradeAggregateResult> CalculateAsync( public static async Task<ExperimentGradeAggregateResult> CalculateAsync(
AppDbContext db, AppDbContext db,
Guid teachingTaskId, Guid teachingTaskId,
IReadOnlyCollection<Guid> studentIds, IReadOnlyCollection<Guid> studentIds,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var sheets = await db.ExperimentGradeSheets.AsNoTracking() var sheets = await LoadPublishedSheetsAsync(
db,
teachingTaskId,
cancellationToken);
var requestedStudentIds = studentIds.Distinct().ToArray();
var persistedScores = await db.ExperimentCourseGrades.AsNoTracking()
.Where(x =>
x.TeachingTaskId == teachingTaskId &&
requestedStudentIds.Contains(x.StudentId))
.ToDictionaryAsync(
x => x.StudentId,
x => x.WeightedAverageScore,
cancellationToken);
var scores = requestedStudentIds.ToDictionary(
studentId => studentId,
studentId => persistedScores.GetValueOrDefault(studentId));
return new ExperimentGradeAggregateResult(
sheets.Count,
sheets.Select(x => new ExperimentGradeAggregateProject(
x.Id,
x.Code,
x.Name,
x.ContributionWeight)).ToList(),
scores);
}
private static Task<List<PublishedExperimentSheet>> LoadPublishedSheetsAsync(
AppDbContext db,
Guid teachingTaskId,
CancellationToken cancellationToken) =>
db.ExperimentGradeSheets.AsNoTracking()
.Where(x => .Where(x =>
x.Status == x.Status ==
Domain.Academic.ExperimentGradeSheetStatus.Published && Domain.Academic.ExperimentGradeSheetStatus.Published &&
@@ -28,40 +100,27 @@ public static class ExperimentGradeAggregationService
.AsSplitQuery() .AsSplitQuery()
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
var scores = new Dictionary<Guid, decimal?>(); private static decimal? CalculateStudentScore(
foreach (var studentId in studentIds.Distinct()) IReadOnlyCollection<PublishedExperimentSheet> sheets,
Guid studentId)
{
decimal weightedTotal = 0;
decimal totalWeight = 0;
if (sheets.Count == 0) return null;
foreach (var sheet in sheets)
{ {
decimal weightedTotal = 0; var score = sheet.Scores.FirstOrDefault(x =>
decimal totalWeight = 0; x.StudentId == studentId);
var complete = sheets.Count > 0; if (score?.TotalScore is not decimal totalScore) return null;
foreach (var sheet in sheets) weightedTotal += totalScore * sheet.ContributionWeight;
{ totalWeight += sheet.ContributionWeight;
var score = sheet.Scores.FirstOrDefault(x =>
x.StudentId == studentId);
if (score?.TotalScore is not decimal totalScore)
{
complete = false;
break;
}
weightedTotal += totalScore * sheet.ContributionWeight;
totalWeight += sheet.ContributionWeight;
}
scores[studentId] = complete && totalWeight > 0
? Math.Round(
weightedTotal / totalWeight,
1,
MidpointRounding.AwayFromZero)
: null;
} }
return totalWeight > 0
return new ExperimentGradeAggregateResult( ? Math.Round(
sheets.Count, weightedTotal / totalWeight,
sheets.Select(x => new ExperimentGradeAggregateProject( 1,
x.Id, MidpointRounding.AwayFromZero)
x.Code, : null;
x.Name,
x.ContributionWeight)).ToList(),
scores);
} }
private sealed record PublishedExperimentSheet( private sealed record PublishedExperimentSheet(
@@ -58,6 +58,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
Set<ExperimentGradeItem>(); Set<ExperimentGradeItem>();
public DbSet<ExperimentGradeRecord> ExperimentGradeRecords => public DbSet<ExperimentGradeRecord> ExperimentGradeRecords =>
Set<ExperimentGradeRecord>(); Set<ExperimentGradeRecord>();
public DbSet<ExperimentCourseGrade> ExperimentCourseGrades =>
Set<ExperimentCourseGrade>();
public DbSet<ExperimentGradeItemScore> ExperimentGradeItemScores => public DbSet<ExperimentGradeItemScore> ExperimentGradeItemScores =>
Set<ExperimentGradeItemScore>(); Set<ExperimentGradeItemScore>();
public DbSet<CourseSelectionRound> CourseSelectionRounds => public DbSet<CourseSelectionRound> CourseSelectionRounds =>
@@ -795,6 +797,21 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
}); });
builder.Entity<ExperimentCourseGrade>(entity =>
{
entity.Property(x => x.WeightedAverageScore).HasPrecision(5, 1);
entity.Property(x => x.TotalWeight).HasPrecision(8, 1);
entity.HasIndex(x => new { x.TeachingTaskId, x.StudentId })
.IsUnique();
entity.HasIndex(x => x.StudentId);
entity.HasOne(x => x.TeachingTask).WithMany()
.HasForeignKey(x => x.TeachingTaskId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.Student).WithMany()
.HasForeignKey(x => x.StudentId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<ExperimentGradeItemScore>(entity => builder.Entity<ExperimentGradeItemScore>(entity =>
{ {
entity.HasKey(x => new entity.HasKey(x => new
@@ -100,6 +100,8 @@ public sealed class DevelopmentSqliteMigrator(
"20260809_52_reusable_course_groups"; "20260809_52_reusable_course_groups";
private const string CourseGradeStatisticsRefreshSettingsMigration = private const string CourseGradeStatisticsRefreshSettingsMigration =
"20260809_53_course_grade_statistics_refresh_settings"; "20260809_53_course_grade_statistics_refresh_settings";
private const string ExperimentCourseGradesMigration =
"20260809_54_experiment_course_grades";
public async Task MigrateAsync(CancellationToken cancellationToken = default) public async Task MigrateAsync(CancellationToken cancellationToken = default)
{ {
@@ -721,6 +723,14 @@ public sealed class DevelopmentSqliteMigrator(
ReusableCourseGroupsMigration, ReusableCourseGroupsMigration,
courseGroupsExist ? [] : ReusableCourseGroupsStatements, courseGroupsExist ? [] : ReusableCourseGroupsStatements,
cancellationToken); cancellationToken);
var experimentCourseGradesExist = await db.Database
.SqlQueryRaw<int>(
"SELECT COUNT(*) AS \"Value\" FROM sqlite_master WHERE type = 'table' AND name = 'ExperimentCourseGrades'")
.AnyAsync(value => value > 0, cancellationToken);
await ApplyMigrationAsync(
ExperimentCourseGradesMigration,
experimentCourseGradesExist ? [] : ExperimentCourseGradesStatements,
cancellationToken);
} }
private async Task ApplyMigrationAsync( private async Task ApplyMigrationAsync(
@@ -2989,6 +2999,80 @@ public sealed class DevelopmentSqliteMigrator(
"""CREATE UNIQUE INDEX IF NOT EXISTS "IX_CourseGradeStatisticsRefreshSettings_Key" ON "CourseGradeStatisticsRefreshSettings" ("Key");""" """CREATE UNIQUE INDEX IF NOT EXISTS "IX_CourseGradeStatisticsRefreshSettings_Key" ON "CourseGradeStatisticsRefreshSettings" ("Key");"""
]; ];
private static readonly string[] ExperimentCourseGradesStatements =
[
"""
CREATE TABLE "ExperimentCourseGrades" (
"Id" TEXT NOT NULL CONSTRAINT "PK_ExperimentCourseGrades" PRIMARY KEY,
"TeachingTaskId" TEXT NOT NULL,
"StudentId" TEXT NOT NULL,
"WeightedAverageScore" TEXT NULL,
"TotalWeight" TEXT NOT NULL,
"PublishedProjectCount" INTEGER NOT NULL,
"RefreshedAt" TEXT NOT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_ExperimentCourseGrades_TeachingTasks_TeachingTaskId"
FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id") ON DELETE CASCADE,
CONSTRAINT "FK_ExperimentCourseGrades_Students_StudentId"
FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT
);
""",
"""CREATE INDEX "IX_ExperimentCourseGrades_StudentId" ON "ExperimentCourseGrades" ("StudentId");""",
"""CREATE UNIQUE INDEX "IX_ExperimentCourseGrades_TeachingTaskId_StudentId" ON "ExperimentCourseGrades" ("TeachingTaskId", "StudentId");""",
"""
INSERT INTO "ExperimentCourseGrades"
("Id", "TeachingTaskId", "StudentId", "WeightedAverageScore",
"TotalWeight", "PublishedProjectCount", "RefreshedAt",
"CreatedAt", "UpdatedAt")
SELECT
lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-' ||
'4' || substr(lower(hex(randomblob(2))), 2) || '-' ||
substr('89ab', abs(random()) % 4 + 1, 1) ||
substr(lower(hex(randomblob(2))), 2) || '-' || lower(hex(randomblob(6))),
project."TeachingTaskId",
record."StudentId",
CASE
WHEN COUNT(*) = (
SELECT COUNT(*)
FROM "ExperimentGradeSheets" AS all_sheet
INNER JOIN "ExperimentProjects" AS all_project
ON all_project."Id" = all_sheet."ExperimentProjectId"
WHERE all_sheet."Status" = 4
AND all_project."TeachingTaskId" = project."TeachingTaskId")
AND SUM(CASE WHEN record."TotalScore" IS NULL THEN 1 ELSE 0 END) = 0
THEN ROUND(
SUM(record."TotalScore" * sheet."ContributionWeight") /
SUM(sheet."ContributionWeight"), 1)
ELSE NULL
END,
(
SELECT COALESCE(SUM(all_sheet."ContributionWeight"), 0)
FROM "ExperimentGradeSheets" AS all_sheet
INNER JOIN "ExperimentProjects" AS all_project
ON all_project."Id" = all_sheet."ExperimentProjectId"
WHERE all_sheet."Status" = 4
AND all_project."TeachingTaskId" = project."TeachingTaskId"),
(
SELECT COUNT(*)
FROM "ExperimentGradeSheets" AS all_sheet
INNER JOIN "ExperimentProjects" AS all_project
ON all_project."Id" = all_sheet."ExperimentProjectId"
WHERE all_sheet."Status" = 4
AND all_project."TeachingTaskId" = project."TeachingTaskId"),
strftime('%Y-%m-%d %H:%M:%f', 'now'),
strftime('%Y-%m-%d %H:%M:%f', 'now'),
strftime('%Y-%m-%d %H:%M:%f', 'now')
FROM "ExperimentGradeRecords" AS record
INNER JOIN "ExperimentGradeSheets" AS sheet
ON sheet."Id" = record."ExperimentGradeSheetId"
INNER JOIN "ExperimentProjects" AS project
ON project."Id" = sheet."ExperimentProjectId"
WHERE sheet."Status" = 4
GROUP BY project."TeachingTaskId", record."StudentId";
"""
];
private static readonly string[] ExperimentClassroomConstraintStatements = private static readonly string[] ExperimentClassroomConstraintStatements =
[ [
""" """
@@ -0,0 +1,113 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class PersistExperimentCourseGrades : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ExperimentCourseGrades",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
TeachingTaskId = table.Column<Guid>(type: "char(36)", nullable: false),
StudentId = table.Column<Guid>(type: "char(36)", nullable: false),
WeightedAverageScore = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: true),
TotalWeight = table.Column<decimal>(type: "decimal(8,1)", precision: 8, scale: 1, nullable: false),
PublishedProjectCount = table.Column<int>(type: "int", nullable: false),
RefreshedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ExperimentCourseGrades", x => x.Id);
table.ForeignKey(
name: "FK_ExperimentCourseGrades_Students_StudentId",
column: x => x.StudentId,
principalTable: "Students",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_ExperimentCourseGrades_TeachingTasks_TeachingTaskId",
column: x => x.TeachingTaskId,
principalTable: "TeachingTasks",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_ExperimentCourseGrades_StudentId",
table: "ExperimentCourseGrades",
column: "StudentId");
migrationBuilder.CreateIndex(
name: "IX_ExperimentCourseGrades_TeachingTaskId_StudentId",
table: "ExperimentCourseGrades",
columns: new[] { "TeachingTaskId", "StudentId" },
unique: true);
migrationBuilder.Sql(
"""
INSERT INTO `ExperimentCourseGrades`
(`Id`, `TeachingTaskId`, `StudentId`, `WeightedAverageScore`,
`TotalWeight`, `PublishedProjectCount`, `RefreshedAt`,
`CreatedAt`, `UpdatedAt`)
SELECT
UUID(),
project.`TeachingTaskId`,
record.`StudentId`,
CASE
WHEN COUNT(*) = (
SELECT COUNT(*)
FROM `ExperimentGradeSheets` AS all_sheet
INNER JOIN `ExperimentProjects` AS all_project
ON all_project.`Id` = all_sheet.`ExperimentProjectId`
WHERE all_sheet.`Status` = 4
AND all_project.`TeachingTaskId` = project.`TeachingTaskId`)
AND SUM(CASE WHEN record.`TotalScore` IS NULL THEN 1 ELSE 0 END) = 0
THEN ROUND(
SUM(record.`TotalScore` * sheet.`ContributionWeight`) /
SUM(sheet.`ContributionWeight`), 1)
ELSE NULL
END,
(
SELECT COALESCE(SUM(all_sheet.`ContributionWeight`), 0)
FROM `ExperimentGradeSheets` AS all_sheet
INNER JOIN `ExperimentProjects` AS all_project
ON all_project.`Id` = all_sheet.`ExperimentProjectId`
WHERE all_sheet.`Status` = 4
AND all_project.`TeachingTaskId` = project.`TeachingTaskId`),
(
SELECT COUNT(*)
FROM `ExperimentGradeSheets` AS all_sheet
INNER JOIN `ExperimentProjects` AS all_project
ON all_project.`Id` = all_sheet.`ExperimentProjectId`
WHERE all_sheet.`Status` = 4
AND all_project.`TeachingTaskId` = project.`TeachingTaskId`),
UTC_TIMESTAMP(6), UTC_TIMESTAMP(6), UTC_TIMESTAMP(6)
FROM `ExperimentGradeRecords` AS record
INNER JOIN `ExperimentGradeSheets` AS sheet
ON sheet.`Id` = record.`ExperimentGradeSheetId`
INNER JOIN `ExperimentProjects` AS project
ON project.`Id` = sheet.`ExperimentProjectId`
WHERE sheet.`Status` = 4
GROUP BY project.`TeachingTaskId`, record.`StudentId`;
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ExperimentCourseGrades");
}
}
}
@@ -2263,6 +2263,48 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.ToTable("ExperimentBookings"); b.ToTable("ExperimentBookings");
}); });
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExperimentCourseGrade", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("PublishedProjectCount")
.HasColumnType("int");
b.Property<DateTime>("RefreshedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("StudentId")
.HasColumnType("char(36)");
b.Property<Guid>("TeachingTaskId")
.HasColumnType("char(36)");
b.Property<decimal>("TotalWeight")
.HasPrecision(8, 1)
.HasColumnType("decimal(8,1)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<decimal?>("WeightedAverageScore")
.HasPrecision(5, 1)
.HasColumnType("decimal(5,1)");
b.HasKey("Id");
b.HasIndex("StudentId");
b.HasIndex("TeachingTaskId", "StudentId")
.IsUnique();
b.ToTable("ExperimentCourseGrades");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExperimentGradeItem", b => modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExperimentGradeItem", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -2494,11 +2536,11 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.HasIndex("ScheduleEntryId"); b.HasIndex("ScheduleEntryId");
b.HasIndex("Status", "StartDate", "EndDate");
b.HasIndex("TeachingTaskId", "Code", "ScheduleEntryId", "ScheduleWeek") b.HasIndex("TeachingTaskId", "Code", "ScheduleEntryId", "ScheduleWeek")
.IsUnique(); .IsUnique();
b.HasIndex("Status", "StartDate", "EndDate");
b.ToTable("ExperimentProjects"); b.ToTable("ExperimentProjects");
}); });
@@ -5803,6 +5845,25 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("Student"); b.Navigation("Student");
}); });
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExperimentCourseGrade", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student")
.WithMany()
.HasForeignKey("StudentId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask")
.WithMany()
.HasForeignKey("TeachingTaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Student");
b.Navigation("TeachingTask");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExperimentGradeItem", b => modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExperimentGradeItem", b =>
{ {
b.HasOne("Jiaowu.Api.Domain.Academic.ExperimentGradeSheet", "ExperimentGradeSheet") b.HasOne("Jiaowu.Api.Domain.Academic.ExperimentGradeSheet", "ExperimentGradeSheet")
@@ -2,6 +2,7 @@ using Jiaowu.Api.Controllers;
using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Grades;
using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
@@ -262,7 +263,17 @@ public sealed class ExperimentGradesControllerTests
var studentResult = await fixture var studentResult = await fixture
.ExperimentGrades(fixture.StudentScope) .ExperimentGrades(fixture.StudentScope)
.GetMine(CancellationToken.None); .GetMine(CancellationToken.None);
Assert.IsType<OkObjectResult>(studentResult); var studentResultOk = Assert.IsType<OkObjectResult>(studentResult);
var studentCourse = Assert.Single(
Assert.IsAssignableFrom<System.Collections.IEnumerable>(
studentResultOk.Value).Cast<object>());
Assert.Equal(85m, Property<decimal>(
studentCourse,
"ExperimentCourseScore"));
var persistedExperimentCourseGrade = await fixture.Db
.ExperimentCourseGrades.SingleAsync();
Assert.Equal(85m, persistedExperimentCourseGrade.WeightedAverageScore);
Assert.Equal(1, persistedExperimentCourseGrade.PublishedProjectCount);
var courseSheet = new GradeSheet var courseSheet = new GradeSheet
{ {
@@ -330,6 +341,83 @@ public sealed class ExperimentGradesControllerTests
Assert.IsType<ConflictObjectResult>(manualOverride); Assert.IsType<ConflictObjectResult>(manualOverride);
} }
[Fact]
public async Task ExperimentCourseGrade_IsPersistedAsWeightedAverage()
{
await using var fixture = await ExperimentGradeFixture.CreateAsync();
var firstProject = new ExperimentProject
{
TeachingTaskId = fixture.Task.Id,
Code = "LAB-W1",
Name = "低权重实验",
ArrangementMode = ExperimentArrangementMode.Centralized,
StartDate = fixture.Term.StartDate,
EndDate = fixture.Term.EndDate,
Status = ExperimentProjectStatus.Published,
GradeSheet = new ExperimentGradeSheet
{
ContributionWeight = 1,
Status = ExperimentGradeSheetStatus.Published,
Records =
[
new ExperimentGradeRecord
{
StudentId = fixture.Student.Id,
ParticipationStatus = ExperimentParticipationStatus.Completed,
TotalScore = 80,
IsPassed = true
}
]
}
};
var secondProject = new ExperimentProject
{
TeachingTaskId = fixture.Task.Id,
Code = "LAB-W2",
Name = "高权重实验",
ArrangementMode = ExperimentArrangementMode.Centralized,
StartDate = fixture.Term.StartDate,
EndDate = fixture.Term.EndDate,
Status = ExperimentProjectStatus.Published,
GradeSheet = new ExperimentGradeSheet
{
ContributionWeight = 3,
Status = ExperimentGradeSheetStatus.Published,
Records =
[
new ExperimentGradeRecord
{
StudentId = fixture.Student.Id,
ParticipationStatus = ExperimentParticipationStatus.Completed,
TotalScore = 90,
IsPassed = true
}
]
}
};
fixture.Db.AddRange(firstProject, secondProject);
await fixture.Db.SaveChangesAsync();
await ExperimentGradeAggregationService.RefreshTeachingTaskAsync(
fixture.Db,
fixture.Task.Id,
CancellationToken.None);
var persisted = await fixture.Db.ExperimentCourseGrades.SingleAsync();
Assert.Equal(87.5m, persisted.WeightedAverageScore);
Assert.Equal(4m, persisted.TotalWeight);
Assert.Equal(2, persisted.PublishedProjectCount);
firstProject.GradeSheet!.Records.Single().TotalScore = 0;
await fixture.Db.SaveChangesAsync();
var referenced = await ExperimentGradeAggregationService.CalculateAsync(
fixture.Db,
fixture.Task.Id,
[fixture.Student.Id],
CancellationToken.None);
Assert.Equal(87.5m, referenced.Scores[fixture.Student.Id]);
}
[Fact] [Fact]
public async Task SelfScheduledSheet_SyncsOnlyActiveBookings() public async Task SelfScheduledSheet_SyncsOnlyActiveBookings()
{ {
+1
View File
@@ -36,6 +36,7 @@ declare module 'vue' {
ElMenuItem: typeof import('element-plus/es')['ElMenuItem'] ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElOption: typeof import('element-plus/es')['ElOption'] ElOption: typeof import('element-plus/es')['ElOption']
ElPagination: typeof import('element-plus/es')['ElPagination'] ElPagination: typeof import('element-plus/es')['ElPagination']
ElPopover: typeof import('element-plus/es')['ElPopover']
ElProgress: typeof import('element-plus/es')['ElProgress'] ElProgress: typeof import('element-plus/es')['ElProgress']
ElRadioButton: typeof import('element-plus/es')['ElRadioButton'] ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup'] ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
+94 -44
View File
@@ -376,7 +376,7 @@ async function publishSheet() {
} }
} }
function scoreTone(result: any) { function projectScoreTone(result: any) {
if (result.totalScore == null) return 'is-pending' if (result.totalScore == null) return 'is-pending'
return result.isPassed ? 'is-passed' : 'is-failed' return result.isPassed ? 'is-passed' : 'is-failed'
} }
@@ -404,13 +404,13 @@ onMounted(async () => {
<div> <div>
<span class="section-kicker">LAB ASSESSMENT LEDGER</span> <span class="section-kicker">LAB ASSESSMENT LEDGER</span>
<h2>{{ isStudent ? '我的实验成绩' : '实验成绩管理' }}</h2> <h2>{{ isStudent ? '我的实验成绩' : '实验成绩管理' }}</h2>
<p v-if="isStudent">实验成绩独立记录操作结果报告和到场情况不与课程总评混在一起</p> <p v-if="isStudent">按课程查看各实验项目实验课总分为项目贡献权重加权平均也是课程成绩引用的实验部分分数</p>
<p v-else>按实验项目建立独立评分方案审核发布后可选择汇总到课程成绩单</p> <p v-else>按实验项目建立独立评分方案审核发布后可选择汇总到课程成绩单</p>
</div> </div>
<el-button :icon="Refresh" @click="load">刷新</el-button> <el-button :icon="Refresh" @click="load">刷新</el-button>
</section> </section>
<section class="assessment-flow" aria-label="实验成绩流程"> <section v-if="!isStudent" class="assessment-flow" aria-label="实验成绩流程">
<div><span>PROJECT</span><b>实验项目</b><small>集中安排 / 自主预约</small></div> <div><span>PROJECT</span><b>实验项目</b><small>集中安排 / 自主预约</small></div>
<i></i> <i></i>
<div><span>RUBRIC</span><b>独立评分</b><small>操作 · 结果 · 报告 · 规范</small></div> <div><span>RUBRIC</span><b>独立评分</b><small>操作 · 结果 · 报告 · 规范</small></div>
@@ -419,39 +419,76 @@ onMounted(async () => {
</section> </section>
<template v-if="isStudent"> <template v-if="isStudent">
<section v-loading="loading" class="student-result-grid"> <section v-loading="loading" class="student-course-results">
<article <article v-for="course in studentResults" :key="course.teachingTaskId" class="student-course-result">
v-for="result in studentResults" <header class="student-course-head">
:key="result.id"
class="student-result-card"
:class="scoreTone(result)"
>
<header>
<div> <div>
<span>{{ result.projectCode }} · {{ modeLabels[result.arrangementMode] }}</span> <span>{{ course.termName }} · {{ course.taskNumber }}</span>
<h3>{{ result.projectName }}</h3> <h3>{{ course.courseCode }} · {{ course.courseName }}</h3>
<p>{{ result.courseCode }} · {{ result.courseName }} · {{ result.termName }}</p> <p>{{ course.publishedProjectCount }} 个已发布实验项目 · 按项目贡献权重计算</p>
</div> </div>
<div class="score-seal"> <div class="course-score-seal" :class="{ 'has-score': course.experimentCourseScore != null }">
<strong>{{ result.totalScore ?? '—' }}</strong> <span>实验课总分</span>
<small>{{ result.totalScore == null ? participationLabels[result.participationStatus] : result.isPassed ? '通过' : '未通过' }}</small> <strong>{{ course.experimentCourseScore ?? '—' }}</strong>
<small>{{ course.experimentCourseScore == null ? '成绩尚未完整' : '加权平均分' }}</small>
</div> </div>
</header> </header>
<div class="result-facts"> <el-table :data="course.projects" class="student-grade-table" size="small">
<span>参与状态<b>{{ participationLabels[result.participationStatus] }}</b></span> <el-table-column type="expand" width="44">
<span>合格线<b>{{ result.passScore }}</b></span> <template #default="{ row }">
<span>实验次数<b>{{ result.attemptNumber }}</b></span> <div class="student-grade-detail" :class="projectScoreTone(row)">
<span>提交情况<b>{{ result.isLate ? '迟交' : result.submittedAt ? '已提交' : '未登记' }}</b></span> <div class="result-facts">
</div> <span>参与状态<b>{{ participationLabels[row.participationStatus] }}</b></span>
<div class="result-rubric"> <span>合格线<b>{{ row.passScore }}</b></span>
<div v-for="item in result.items" :key="item.experimentGradeItemId"> <span>实验次数<b>{{ row.attemptNumber }}</b></span>
<span>{{ item.name }} · {{ item.weight }}%</span> <span>提交情况<b>{{ row.isLate ? '迟交' : row.submittedAt ? '已提交' : '未登记' }}</b></span>
<b>{{ item.score ?? '—' }}</b> </div>
<small v-if="item.comment">{{ item.comment }}</small> <div class="result-rubric">
</div> <div v-for="item in row.items" :key="item.experimentGradeItemId">
</div> <span>{{ item.name }} · {{ item.weight }}%</span>
<p v-if="result.safetyViolation" class="safety-alert">已登记安全违规本次实验按规则判定为 0 </p> <b>{{ item.score ?? '—' }}</b>
<p v-if="result.teacherComment" class="teacher-comment">{{ result.teacherComment }}</p> <small v-if="item.comment">{{ item.comment }}</small>
</div>
</div>
<p v-if="row.safetyViolation" class="safety-alert">已登记安全违规本次实验按规则判定为 0 </p>
<p v-if="row.teacherComment" class="teacher-comment">{{ row.teacherComment }}</p>
</div>
</template>
</el-table-column>
<el-table-column label="实验项目" min-width="220">
<template #default="{ row }">
<div class="student-grade-project">
<b>{{ row.projectCode }} · {{ row.projectName }}</b>
<small>{{ modeLabels[row.arrangementMode] }} · 贡献权重 {{ row.contributionWeight }}</small>
</div>
</template>
</el-table-column>
<el-table-column label="分项成绩" min-width="300">
<template #default="{ row }">
<div class="rubric-inline">
<span v-for="item in row.items" :key="item.experimentGradeItemId">
{{ item.name }} <b>{{ item.score ?? '—' }}</b>
</span>
</div>
</template>
</el-table-column>
<el-table-column label="参与状态" width="100" align="center">
<template #default="{ row }">{{ participationLabels[row.participationStatus] }}</template>
</el-table-column>
<el-table-column label="项目成绩" width="100" align="center">
<template #default="{ row }">
<strong class="project-total" :class="projectScoreTone(row)">{{ row.totalScore ?? '—' }}</strong>
</template>
</el-table-column>
<el-table-column label="结果" width="88" align="center">
<template #default="{ row }">
<el-tag v-if="row.totalScore != null" :type="row.isPassed ? 'success' : 'danger'" size="small">
{{ row.isPassed ? '通过' : '未通过' }}
</el-tag>
<span v-else class="table-muted">待完整</span>
</template>
</el-table-column>
</el-table>
</article> </article>
<el-empty v-if="!loading && !studentResults.length" description="暂无已发布的实验成绩" /> <el-empty v-if="!loading && !studentResults.length" description="暂无已发布的实验成绩" />
</section> </section>
@@ -785,18 +822,31 @@ onMounted(async () => {
.sheet-progress, .sheet-empty { display: grid; gap: 5px; color: #607681; font-size: 12px; } .sheet-progress, .sheet-empty { display: grid; gap: 5px; color: #607681; font-size: 12px; }
.sheet-progress b { color: var(--grade-teal); } .sheet-progress b { color: var(--grade-teal); }
.sheet-empty b { color: #5d7480; } .sheet-empty b { color: #5d7480; }
.student-result-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); gap: 15px; min-height: 180px; } .student-course-results { display: grid; gap: 12px; min-height: 180px; }
.student-result-card { overflow: hidden; border: 1px solid #d8e4e6; border-top: 5px solid #80939b; background: #fff; } .student-course-result { overflow: hidden; border: 1px solid #cfdee3; background: #fff; }
.student-result-card.is-passed { border-top-color: var(--grade-teal); } .student-course-head { min-height: 76px; padding: 11px 14px 11px 18px; display: flex; align-items: center; justify-content: space-between; gap: 18px; border-left: 5px solid var(--grade-blue); background: #edf4f6; }
.student-result-card.is-failed { border-top-color: #b5543d; } .student-course-head > div:first-child { min-width: 0; }
.student-result-card > header { padding: 18px; display: flex; justify-content: space-between; gap: 16px; border-bottom: 1px solid #e4eaed; } .student-course-head span { color: #617b87; font: 700 10px/1.3 Consolas, monospace; letter-spacing: .05em; }
.student-result-card header span { color: #607d89; font: 700 10px/1.3 Consolas, monospace; } .student-course-head h3 { margin: 4px 0; color: var(--grade-ink); font-size: 16px; }
.student-result-card h3 { margin: 5px 0; color: var(--grade-ink); font-size: 18px; } .student-course-head p { margin: 0; color: #647b85; font-size: 11px; }
.student-result-card header p { margin: 0; color: var(--muted); font-size: 12px; } .course-score-seal { min-width: 132px; display: grid; grid-template-columns: auto auto; align-items: center; gap: 2px 12px; padding: 7px 11px; border-left: 1px solid #cbdade; text-align: right; }
.score-seal { width: 72px; flex: 0 0 72px; display: grid; place-content: center; border-left: 1px solid #dce6e8; text-align: center; } .course-score-seal span { grid-column: 1; color: #59717d; font: 600 11px/1.2 inherit; letter-spacing: 0; }
.score-seal strong { color: var(--grade-teal); font: 750 28px/1 Consolas, monospace; } .course-score-seal strong { grid-column: 2; grid-row: 1 / span 2; color: #768b94; font: 750 28px/1 Consolas, monospace; }
.is-failed .score-seal strong { color: #b5543d; } .course-score-seal.has-score strong { color: var(--grade-teal); }
.score-seal small { margin-top: 5px; color: var(--muted); } .course-score-seal small { grid-column: 1; color: var(--muted); font-size: 10px; }
.student-grade-table { --el-table-border-color: #dce5e8; --el-table-header-bg-color: #f7fafb; width: 100%; }
.student-grade-table :deep(th.el-table__cell) { padding: 7px 0; color: #637985; font-size: 11px; }
.student-grade-table :deep(td.el-table__cell) { padding: 8px 0; }
.student-grade-project { display: grid; gap: 3px; }
.student-grade-project b { color: var(--grade-ink); font-size: 12px; }
.student-grade-project small { color: #6a8089; font-size: 10px; }
.rubric-inline { display: flex; flex-wrap: wrap; gap: 4px 10px; }
.rubric-inline span { color: #6a7f88; font-size: 10px; }
.rubric-inline b { color: #294e5d; font-size: 11px; }
.project-total { font: 750 15px/1 Consolas, monospace; color: #74858c; }
.project-total.is-passed { color: var(--grade-teal); }
.project-total.is-failed { color: #b5543d; }
.student-grade-detail { padding: 12px 16px 14px; background: #f7faf9; }
.result-facts { padding: 12px 18px; display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; background: #f5f8f8; } .result-facts { padding: 12px 18px; display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; background: #f5f8f8; }
.result-facts span { display: grid; gap: 3px; color: #758991; font-size: 10px; } .result-facts span { display: grid; gap: 3px; color: #758991; font-size: 10px; }
.result-facts b { color: #365762; font-size: 12px; } .result-facts b { color: #365762; font-size: 12px; }
+98 -207
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { Calendar, Check, Plus, Refresh, Tickets, User } from '@element-plus/icons-vue' import { Calendar, Check, Plus, Refresh, User } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import http, { apiErrorMessage } from '../api/http' import http, { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
@@ -665,7 +665,7 @@ onMounted(async () => {
</div> </div>
</section> </section>
<section class="lab-rail" aria-label="实验安排方式说明"> <section v-if="!isStudent" class="lab-rail" aria-label="实验安排方式说明">
<article class="rail-lane centralized"> <article class="rail-lane centralized">
<span class="rail-code">FIXED / 集中</span> <span class="rail-code">FIXED / 集中</span>
<b>统一课次</b> <b>统一课次</b>
@@ -707,7 +707,7 @@ onMounted(async () => {
<el-option label="已关闭" value="Closed" /> <el-option label="已关闭" value="Closed" />
</el-select> </el-select>
</template> </template>
<span class="result-note"> {{ total }} 个教学班课程</span> <span class="result-note">{{ isStudent ? `${projectGroups.length} 门课程、${total} 个实验项目` : `${total} 个教学班课程` }}</span>
</section> </section>
<section v-if="!isStudent" class="experiment-batch-bar"> <section v-if="!isStudent" class="experiment-batch-bar">
@@ -760,6 +760,16 @@ onMounted(async () => {
<div v-else-if="row.sessions.length" class="table-schedule"> <div v-else-if="row.sessions.length" class="table-schedule">
<b>{{ row.sessions.length }} 个开放场次</b> <b>{{ row.sessions.length }} 个开放场次</b>
<small>{{ row.sessions.map((session: any) => formatSessionTime(session)).join('') }}</small> <small>{{ row.sessions.map((session: any) => formatSessionTime(session)).join('') }}</small>
<el-popover placement="bottom" :width="520" trigger="click">
<template #reference><el-button size="small" text :icon="User">查看场次与名单</el-button></template>
<div class="student-session-menu">
<div v-for="session in row.sessions" :key="session.id" class="student-session-option">
<div><b>{{ formatSessionTime(session) }}</b><span>{{ session.campusName }} · {{ session.buildingName }} {{ session.classroomName }}</span></div>
<el-button size="small" text @click="showParticipants(row, session)">预约名单</el-button>
<el-button v-if="session.status !== 'Cancelled'" size="small" text type="danger" @click="cancelSession(row, session)">{{ row.status === 'Draft' ? '删除' : '取消' }}</el-button>
</div>
</div>
</el-popover>
</div> </div>
<span v-else class="table-muted">尚未安排</span> <span v-else class="table-muted">尚未安排</span>
</template> </template>
@@ -793,211 +803,77 @@ onMounted(async () => {
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<article <el-table v-else :data="group.projects" class="project-table student-project-table" size="small">
v-else <el-table-column type="expand" width="44">
v-for="project in group.projects" <template #default="{ row }">
:key="project.id" <div class="student-project-detail">
class="project-card" <p v-if="row.description"><b>项目说明</b>{{ row.description }}</p>
:class="[ <p v-if="row.requirements"><b>实验要求</b>{{ row.requirements }}</p>
project.arrangementMode === 'Centralized' ? 'is-centralized' : 'is-flexible', <p><b>开放日期</b>{{ row.startDate }} {{ row.endDate }}</p>
`is-${project.status.toLowerCase()}`, <p><b>任课教师</b>{{ row.teacherNames.join('、') || '待定' }}</p>
]" </div>
> </template>
<header class="project-head"> </el-table-column>
<div class="project-identity"> <el-table-column label="实验项目" min-width="210">
<span class="project-code">{{ project.courseCode }} · {{ project.code }}</span> <template #default="{ row }">
<h3>{{ project.name }}</h3> <div class="table-project-name">
<p>{{ project.arrangementMode === 'Centralized' ? '统一到场实验' : '开放预约实验' }}</p> <b>{{ row.code }} · {{ row.name }}</b>
</div> <small>{{ row.description || modeMeta[row.arrangementMode].note }}</small>
<div class="project-tags"> </div>
<el-checkbox </template>
v-if="!isStudent && project.status === 'Draft'" </el-table-column>
:model-value="selectedProjectIds.includes(project.id)" <el-table-column label="实验安排" min-width="300">
@change="toggleProjectSelection(project.id, $event)" <template #default="{ row }">
aria-label="选择实验项目" <div v-if="row.arrangementMode === 'Centralized' && row.scheduleEntry" class="table-schedule">
/> <b> {{ row.scheduleEntry.projectWeek ?? `${row.scheduleEntry.startWeek}${row.scheduleEntry.endWeek}` }} · 星期 {{ row.scheduleEntry.dayOfWeek }} · {{ row.scheduleEntry.startPeriod }}{{ row.scheduleEntry.startPeriod + row.scheduleEntry.periodCount - 1 }} </b>
<el-tag <small>{{ row.scheduleEntry.campusName }} · {{ row.scheduleEntry.buildingName }} {{ row.scheduleEntry.classroomName }}</small>
:type="project.arrangementMode === 'Centralized' ? 'primary' : 'success'" </div>
effect="plain" <div v-else-if="row.myBooking && selectedSession(row)" class="table-schedule is-booked">
> <b>{{ formatSessionTime(selectedSession(row)) }}</b>
{{ modeMeta[project.arrangementMode].label }} <small>{{ selectedSession(row).campusName }} · {{ selectedSession(row).buildingName }} {{ selectedSession(row).classroomName }}</small>
</el-tag> </div>
<el-tag <div v-else-if="row.sessions.length" class="table-schedule">
:type="project.status === 'Published' ? 'success' : project.status === 'Closed' ? 'info' : 'warning'" <b>{{ row.sessions.length }} 个可选场次</b>
> <small>尚未预约请在右侧选择实验时间</small>
{{ statusLabels[project.status] }} </div>
</el-tag> <span v-else class="table-muted">暂无有效实验安排</span>
</div> </template>
</header> </el-table-column>
<el-table-column label="方式" width="100" align="center">
<div class="project-context"> <template #default="{ row }"><el-tag :type="row.arrangementMode === 'Centralized' ? 'primary' : 'success'" effect="plain" size="small">{{ modeMeta[row.arrangementMode].label }}</el-tag></template>
<span><b>开放日期</b>{{ project.startDate }} {{ project.endDate }}</span> </el-table-column>
<span><b>任课教师</b>{{ project.teacherNames.join('、') || '待定' }}</span> <el-table-column label="状态" width="88" align="center">
<span v-if="!isStudent"><b>面向班级</b>{{ project.classNames.join('、') || '选课学生' }}</span> <template #default="{ row }"><el-tag :type="row.status === 'Published' ? 'success' : 'info'" size="small">{{ statusLabels[row.status] }}</el-tag></template>
</div> </el-table-column>
<el-table-column label="预约操作" width="190" fixed="right" align="right">
<p v-if="project.description" class="project-description">{{ project.description }}</p> <template #default="{ row }">
<div v-if="project.requirements" class="requirement-strip"> <template v-if="row.arrangementMode === 'SelfScheduled'">
<b>实验要求</b> <el-popover placement="bottom-end" :width="540" trigger="click">
<span>{{ project.requirements }}</span> <template #reference>
</div> <el-button size="small" :type="row.myBooking ? 'success' : 'primary'" plain>
{{ row.myBooking ? '查看场次' : '选择场次' }}
<div class="session-board"> </el-button>
<div class="session-board-head"> </template>
<div> <div class="student-session-menu">
<span>{{ project.arrangementMode === 'Centralized' ? '统一实验课次' : '可选实验场次' }}</span> <header><b>{{ row.code }} · {{ row.name }}</b><span>每个实验项目限约一个场次</span></header>
<b>{{ modeMeta[project.arrangementMode].note }}</b> <div v-for="session in row.sessions" :key="session.id" class="student-session-option" :class="{ 'is-selected': row.myBooking?.experimentSessionId === session.id }">
</div> <div>
<el-button <b>{{ formatSessionTime(session) }}</b>
v-if="!isStudent && project.status !== 'Closed' && project.arrangementMode === 'SelfScheduled'" <span>{{ session.campusName }} · {{ session.buildingName }} {{ session.classroomName }}</span>
size="small" </div>
:icon="Calendar" <el-tag :type="sessionTagType(session)" size="small" effect="plain"> {{ session.remainingCount }} / {{ session.capacity }}</el-tag>
@click="openSession(project)" <el-button v-if="row.myBooking?.experimentSessionId === session.id" size="small" type="success" :icon="Check" disabled>已预约</el-button>
> <el-button v-else size="small" type="primary" plain :disabled="row.status !== 'Published' || !!row.myBooking || session.remainingCount <= 0" @click="book(row, session)">预约</el-button>
安排场次 </div>
</el-button> <el-empty v-if="!row.sessions.length" :image-size="42" description="暂无有效实验场次" />
</div> </div>
</el-popover>
<div v-if="project.arrangementMode === 'Centralized' && project.scheduleEntry" class="session-grid"> <el-button v-if="row.myBooking && row.status === 'Published'" size="small" text type="danger" @click="cancelBooking(row)">取消</el-button>
<article class="session-ticket">
<div class="ticket-date"><strong>课表</strong><span>固定</span></div>
<div class="ticket-body">
<b> {{ project.scheduleEntry.projectWeek ?? `${project.scheduleEntry.startWeek}${project.scheduleEntry.endWeek}` }} · 星期 {{ project.scheduleEntry.dayOfWeek }} · {{ project.scheduleEntry.startPeriod }}{{ project.scheduleEntry.startPeriod + project.scheduleEntry.periodCount - 1 }} </b>
<span>{{ project.scheduleEntry.campusName }} · {{ project.scheduleEntry.buildingName }} {{ project.scheduleEntry.classroomName }}</span>
</div>
<div class="ticket-action"><el-tag type="primary" size="small" effect="plain">课表已安排</el-tag></div>
</article>
</div>
<div v-else-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> </template>
<el-tag v-else type="primary" size="small" effect="plain"> <span v-else class="table-muted">无需预约</span>
统一到场 </template>
</el-tag> </el-table-column>
</el-table>
<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.arrangementMode === 'Centralized' ? '尚未绑定已发布课表中的实验课' : (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.arrangementMode === 'Centralized'
? !project.scheduleEntry
: !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>
</div> </div>
</section> </section>
@@ -1390,11 +1266,25 @@ onMounted(async () => {
.project-table { --el-table-border-color: #dce5ea; --el-table-header-bg-color: #f7fafb; --el-table-row-hover-bg-color: #f6fafb; width: 100%; } .project-table { --el-table-border-color: #dce5ea; --el-table-header-bg-color: #f7fafb; --el-table-row-hover-bg-color: #f6fafb; width: 100%; }
.project-table :deep(th.el-table__cell) { padding: 8px 0; color: #617986; font-size: 11px; font-weight: 650; } .project-table :deep(th.el-table__cell) { padding: 8px 0; color: #617986; font-size: 11px; font-weight: 650; }
.project-table :deep(td.el-table__cell) { padding: 9px 0; vertical-align: top; } .project-table :deep(td.el-table__cell) { padding: 9px 0; vertical-align: top; }
.student-project-table :deep(td.el-table__cell) { vertical-align: middle; }
.table-project-name, .table-schedule { display: grid; gap: 4px; min-width: 0; } .table-project-name, .table-schedule { display: grid; gap: 4px; min-width: 0; }
.table-project-name b { color: var(--lab-ink); font-size: 13px; } .table-project-name b { color: var(--lab-ink); font-size: 13px; }
.table-project-name small, .table-schedule small { overflow: hidden; color: #667f8d; font-size: 11px; line-height: 1.45; text-overflow: ellipsis; white-space: nowrap; } .table-project-name small, .table-schedule small { overflow: hidden; color: #667f8d; font-size: 11px; line-height: 1.45; text-overflow: ellipsis; white-space: nowrap; }
.table-schedule b { color: #365364; font-size: 12px; font-weight: 600; } .table-schedule b { color: #365364; font-size: 12px; font-weight: 600; }
.table-schedule.is-booked b { color: var(--lab-teal); }
.table-muted { color: #8b9aa4; font-size: 12px; } .table-muted { color: #8b9aa4; font-size: 12px; }
.student-project-detail { display: grid; grid-template-columns: repeat(2, minmax(220px, 1fr)); gap: 7px 20px; padding: 11px 18px 13px; background: #f7fafb; }
.student-project-detail p { margin: 0; display: grid; grid-template-columns: 70px 1fr; gap: 8px; color: #5f7682; font-size: 11px; line-height: 1.55; }
.student-project-detail b { color: #304d5d; }
.student-session-menu { display: grid; gap: 7px; }
.student-session-menu > header { display: flex; justify-content: space-between; gap: 12px; padding-bottom: 8px; border-bottom: 1px solid #dfe7ea; }
.student-session-menu > header b { color: var(--lab-ink); font-size: 12px; }
.student-session-menu > header span { color: #71858e; font-size: 10px; }
.student-session-option { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; gap: 9px; padding: 8px 9px; border: 1px solid #e0e7ea; background: #fff; }
.student-session-option.is-selected { border-color: #79afa5; box-shadow: inset 3px 0 0 var(--lab-teal); background: #f3faf8; }
.student-session-option > div { display: grid; gap: 2px; min-width: 0; }
.student-session-option > div b { color: #304d5d; font-size: 11px; }
.student-session-option > div span { overflow: hidden; color: #6a808b; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
.project-card { .project-card {
overflow: hidden; overflow: hidden;
border: 0; border: 0;
@@ -1521,6 +1411,7 @@ onMounted(async () => {
.session-ticket { grid-template-columns: 54px minmax(0, 1fr); } .session-ticket { grid-template-columns: 54px minmax(0, 1fr); }
.ticket-action { grid-column: 1 / -1; justify-content: flex-start; padding: 0 10px 10px; } .ticket-action { grid-column: 1 / -1; justify-content: flex-start; padding: 0 10px 10px; }
.project-actions, .student-booking-summary { padding-inline: 14px; } .project-actions, .student-booking-summary { padding-inline: 14px; }
.student-project-detail { grid-template-columns: 1fr; }
.mode-choice { grid-template-columns: 1fr; } .mode-choice { grid-template-columns: 1fr; }
.intro-actions { flex-wrap: wrap; justify-content: flex-end; } .intro-actions { flex-wrap: wrap; justify-content: flex-end; }
} }