实验成绩计算
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Data;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
@@ -138,7 +139,7 @@ public sealed class ExperimentGradesController(
|
||||
if (student is null)
|
||||
return ConflictProblem("当前账号未关联有效学生档案。");
|
||||
|
||||
return Ok(await db.ExperimentGradeRecords.AsNoTracking()
|
||||
var results = await db.ExperimentGradeRecords.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.StudentId == student.Id &&
|
||||
x.ExperimentGradeSheet!.Status ==
|
||||
@@ -157,12 +158,19 @@ public sealed class ExperimentGradesController(
|
||||
ProjectName =
|
||||
x.ExperimentGradeSheet.ExperimentProject.Name,
|
||||
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
|
||||
.TeachingTask!.Course!.Code,
|
||||
CourseName = x.ExperimentGradeSheet.ExperimentProject
|
||||
.TeachingTask.Course.Name,
|
||||
TermName = x.ExperimentGradeSheet.ExperimentProject
|
||||
.TeachingTask.AcademicTerm!.Name,
|
||||
x.ExperimentGradeSheet.ContributionWeight,
|
||||
x.ExperimentGradeSheet.PassScore,
|
||||
x.ParticipationStatus,
|
||||
x.TotalScore,
|
||||
@@ -187,7 +195,40 @@ public sealed class ExperimentGradesController(
|
||||
}),
|
||||
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")]
|
||||
@@ -744,7 +785,15 @@ public sealed class ExperimentGradesController(
|
||||
|
||||
sheet.Status = ExperimentGradeSheetStatus.Published;
|
||||
sheet.PublishedAt = DateTime.UtcNow;
|
||||
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
|
||||
.Where(x => x.ExperimentGradeSheetId == sheet.Id)
|
||||
.Select(x => x.Student!.UserId)
|
||||
|
||||
@@ -51,6 +51,22 @@ public sealed class ExperimentGradeRecord : EntityBase
|
||||
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 Guid ExperimentGradeRecordId { get; set; }
|
||||
|
||||
@@ -5,13 +5,85 @@ namespace Jiaowu.Api.Infrastructure.Grades;
|
||||
|
||||
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(
|
||||
AppDbContext db,
|
||||
Guid teachingTaskId,
|
||||
IReadOnlyCollection<Guid> studentIds,
|
||||
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 =>
|
||||
x.Status ==
|
||||
Domain.Academic.ExperimentGradeSheetStatus.Published &&
|
||||
@@ -28,25 +100,22 @@ public static class ExperimentGradeAggregationService
|
||||
.AsSplitQuery()
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var scores = new Dictionary<Guid, decimal?>();
|
||||
foreach (var studentId in studentIds.Distinct())
|
||||
private static decimal? CalculateStudentScore(
|
||||
IReadOnlyCollection<PublishedExperimentSheet> sheets,
|
||||
Guid studentId)
|
||||
{
|
||||
decimal weightedTotal = 0;
|
||||
decimal totalWeight = 0;
|
||||
var complete = sheets.Count > 0;
|
||||
if (sheets.Count == 0) return null;
|
||||
foreach (var sheet in sheets)
|
||||
{
|
||||
var score = sheet.Scores.FirstOrDefault(x =>
|
||||
x.StudentId == studentId);
|
||||
if (score?.TotalScore is not decimal totalScore)
|
||||
{
|
||||
complete = false;
|
||||
break;
|
||||
}
|
||||
if (score?.TotalScore is not decimal totalScore) return null;
|
||||
weightedTotal += totalScore * sheet.ContributionWeight;
|
||||
totalWeight += sheet.ContributionWeight;
|
||||
}
|
||||
scores[studentId] = complete && totalWeight > 0
|
||||
return totalWeight > 0
|
||||
? Math.Round(
|
||||
weightedTotal / totalWeight,
|
||||
1,
|
||||
@@ -54,16 +123,6 @@ public static class ExperimentGradeAggregationService
|
||||
: null;
|
||||
}
|
||||
|
||||
return new ExperimentGradeAggregateResult(
|
||||
sheets.Count,
|
||||
sheets.Select(x => new ExperimentGradeAggregateProject(
|
||||
x.Id,
|
||||
x.Code,
|
||||
x.Name,
|
||||
x.ContributionWeight)).ToList(),
|
||||
scores);
|
||||
}
|
||||
|
||||
private sealed record PublishedExperimentSheet(
|
||||
Guid Id,
|
||||
string Code,
|
||||
|
||||
@@ -58,6 +58,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
Set<ExperimentGradeItem>();
|
||||
public DbSet<ExperimentGradeRecord> ExperimentGradeRecords =>
|
||||
Set<ExperimentGradeRecord>();
|
||||
public DbSet<ExperimentCourseGrade> ExperimentCourseGrades =>
|
||||
Set<ExperimentCourseGrade>();
|
||||
public DbSet<ExperimentGradeItemScore> ExperimentGradeItemScores =>
|
||||
Set<ExperimentGradeItemScore>();
|
||||
public DbSet<CourseSelectionRound> CourseSelectionRounds =>
|
||||
@@ -795,6 +797,21 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
.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 =>
|
||||
{
|
||||
entity.HasKey(x => new
|
||||
|
||||
@@ -100,6 +100,8 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
"20260809_52_reusable_course_groups";
|
||||
private const string CourseGradeStatisticsRefreshSettingsMigration =
|
||||
"20260809_53_course_grade_statistics_refresh_settings";
|
||||
private const string ExperimentCourseGradesMigration =
|
||||
"20260809_54_experiment_course_grades";
|
||||
|
||||
public async Task MigrateAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -721,6 +723,14 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
ReusableCourseGroupsMigration,
|
||||
courseGroupsExist ? [] : ReusableCourseGroupsStatements,
|
||||
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(
|
||||
@@ -2989,6 +2999,80 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
"""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 =
|
||||
[
|
||||
"""
|
||||
|
||||
+6934
File diff suppressed because it is too large
Load Diff
+113
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+63
-2
@@ -2263,6 +2263,48 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
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 =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -2494,11 +2536,11 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
|
||||
b.HasIndex("ScheduleEntryId");
|
||||
|
||||
b.HasIndex("Status", "StartDate", "EndDate");
|
||||
|
||||
b.HasIndex("TeachingTaskId", "Code", "ScheduleEntryId", "ScheduleWeek")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("Status", "StartDate", "EndDate");
|
||||
|
||||
b.ToTable("ExperimentProjects");
|
||||
});
|
||||
|
||||
@@ -5803,6 +5845,25 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
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 =>
|
||||
{
|
||||
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.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Grades;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Data.Sqlite;
|
||||
@@ -262,7 +263,17 @@ public sealed class ExperimentGradesControllerTests
|
||||
var studentResult = await fixture
|
||||
.ExperimentGrades(fixture.StudentScope)
|
||||
.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
|
||||
{
|
||||
@@ -330,6 +341,83 @@ public sealed class ExperimentGradesControllerTests
|
||||
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]
|
||||
public async Task SelfScheduledSheet_SyncsOnlyActiveBookings()
|
||||
{
|
||||
|
||||
Vendored
+1
@@ -36,6 +36,7 @@ declare module 'vue' {
|
||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||
ElOption: typeof import('element-plus/es')['ElOption']
|
||||
ElPagination: typeof import('element-plus/es')['ElPagination']
|
||||
ElPopover: typeof import('element-plus/es')['ElPopover']
|
||||
ElProgress: typeof import('element-plus/es')['ElProgress']
|
||||
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
|
||||
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
||||
|
||||
@@ -376,7 +376,7 @@ async function publishSheet() {
|
||||
}
|
||||
}
|
||||
|
||||
function scoreTone(result: any) {
|
||||
function projectScoreTone(result: any) {
|
||||
if (result.totalScore == null) return 'is-pending'
|
||||
return result.isPassed ? 'is-passed' : 'is-failed'
|
||||
}
|
||||
@@ -404,13 +404,13 @@ onMounted(async () => {
|
||||
<div>
|
||||
<span class="section-kicker">LAB ASSESSMENT LEDGER</span>
|
||||
<h2>{{ isStudent ? '我的实验成绩' : '实验成绩管理' }}</h2>
|
||||
<p v-if="isStudent">实验成绩独立记录操作、结果、报告和到场情况,不与课程总评混在一起。</p>
|
||||
<p v-if="isStudent">按课程查看各实验项目;实验课总分为项目贡献权重加权平均,也是课程成绩引用的实验部分分数。</p>
|
||||
<p v-else>按实验项目建立独立评分方案,审核发布后可选择汇总到课程成绩单。</p>
|
||||
</div>
|
||||
<el-button :icon="Refresh" @click="load">刷新</el-button>
|
||||
</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>
|
||||
<i>→</i>
|
||||
<div><span>RUBRIC</span><b>独立评分</b><small>操作 · 结果 · 报告 · 规范</small></div>
|
||||
@@ -419,39 +419,76 @@ onMounted(async () => {
|
||||
</section>
|
||||
|
||||
<template v-if="isStudent">
|
||||
<section v-loading="loading" class="student-result-grid">
|
||||
<article
|
||||
v-for="result in studentResults"
|
||||
:key="result.id"
|
||||
class="student-result-card"
|
||||
:class="scoreTone(result)"
|
||||
>
|
||||
<header>
|
||||
<section v-loading="loading" class="student-course-results">
|
||||
<article v-for="course in studentResults" :key="course.teachingTaskId" class="student-course-result">
|
||||
<header class="student-course-head">
|
||||
<div>
|
||||
<span>{{ result.projectCode }} · {{ modeLabels[result.arrangementMode] }}</span>
|
||||
<h3>{{ result.projectName }}</h3>
|
||||
<p>{{ result.courseCode }} · {{ result.courseName }} · {{ result.termName }}</p>
|
||||
<span>{{ course.termName }} · {{ course.taskNumber }}</span>
|
||||
<h3>{{ course.courseCode }} · {{ course.courseName }}</h3>
|
||||
<p>{{ course.publishedProjectCount }} 个已发布实验项目 · 按项目贡献权重计算</p>
|
||||
</div>
|
||||
<div class="score-seal">
|
||||
<strong>{{ result.totalScore ?? '—' }}</strong>
|
||||
<small>{{ result.totalScore == null ? participationLabels[result.participationStatus] : result.isPassed ? '通过' : '未通过' }}</small>
|
||||
<div class="course-score-seal" :class="{ 'has-score': course.experimentCourseScore != null }">
|
||||
<span>实验课总分</span>
|
||||
<strong>{{ course.experimentCourseScore ?? '—' }}</strong>
|
||||
<small>{{ course.experimentCourseScore == null ? '成绩尚未完整' : '加权平均分' }}</small>
|
||||
</div>
|
||||
</header>
|
||||
<el-table :data="course.projects" class="student-grade-table" size="small">
|
||||
<el-table-column type="expand" width="44">
|
||||
<template #default="{ row }">
|
||||
<div class="student-grade-detail" :class="projectScoreTone(row)">
|
||||
<div class="result-facts">
|
||||
<span>参与状态<b>{{ participationLabels[result.participationStatus] }}</b></span>
|
||||
<span>合格线<b>{{ result.passScore }}</b></span>
|
||||
<span>实验次数<b>{{ result.attemptNumber }}</b></span>
|
||||
<span>提交情况<b>{{ result.isLate ? '迟交' : result.submittedAt ? '已提交' : '未登记' }}</b></span>
|
||||
<span>参与状态<b>{{ participationLabels[row.participationStatus] }}</b></span>
|
||||
<span>合格线<b>{{ row.passScore }}</b></span>
|
||||
<span>实验次数<b>{{ row.attemptNumber }}</b></span>
|
||||
<span>提交情况<b>{{ row.isLate ? '迟交' : row.submittedAt ? '已提交' : '未登记' }}</b></span>
|
||||
</div>
|
||||
<div class="result-rubric">
|
||||
<div v-for="item in result.items" :key="item.experimentGradeItemId">
|
||||
<div v-for="item in row.items" :key="item.experimentGradeItemId">
|
||||
<span>{{ item.name }} · {{ item.weight }}%</span>
|
||||
<b>{{ item.score ?? '—' }}</b>
|
||||
<small v-if="item.comment">{{ item.comment }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="result.safetyViolation" class="safety-alert">已登记安全违规,本次实验按规则判定为 0 分。</p>
|
||||
<p v-if="result.teacherComment" class="teacher-comment">{{ result.teacherComment }}</p>
|
||||
<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>
|
||||
<el-empty v-if="!loading && !studentResults.length" description="暂无已发布的实验成绩" />
|
||||
</section>
|
||||
@@ -785,18 +822,31 @@ onMounted(async () => {
|
||||
.sheet-progress, .sheet-empty { display: grid; gap: 5px; color: #607681; font-size: 12px; }
|
||||
.sheet-progress b { color: var(--grade-teal); }
|
||||
.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-result-card { overflow: hidden; border: 1px solid #d8e4e6; border-top: 5px solid #80939b; background: #fff; }
|
||||
.student-result-card.is-passed { border-top-color: var(--grade-teal); }
|
||||
.student-result-card.is-failed { border-top-color: #b5543d; }
|
||||
.student-result-card > header { padding: 18px; display: flex; justify-content: space-between; gap: 16px; border-bottom: 1px solid #e4eaed; }
|
||||
.student-result-card header span { color: #607d89; font: 700 10px/1.3 Consolas, monospace; }
|
||||
.student-result-card h3 { margin: 5px 0; color: var(--grade-ink); font-size: 18px; }
|
||||
.student-result-card header p { margin: 0; color: var(--muted); font-size: 12px; }
|
||||
.score-seal { width: 72px; flex: 0 0 72px; display: grid; place-content: center; border-left: 1px solid #dce6e8; text-align: center; }
|
||||
.score-seal strong { color: var(--grade-teal); font: 750 28px/1 Consolas, monospace; }
|
||||
.is-failed .score-seal strong { color: #b5543d; }
|
||||
.score-seal small { margin-top: 5px; color: var(--muted); }
|
||||
.student-course-results { display: grid; gap: 12px; min-height: 180px; }
|
||||
.student-course-result { overflow: hidden; border: 1px solid #cfdee3; background: #fff; }
|
||||
.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-course-head > div:first-child { min-width: 0; }
|
||||
.student-course-head span { color: #617b87; font: 700 10px/1.3 Consolas, monospace; letter-spacing: .05em; }
|
||||
.student-course-head h3 { margin: 4px 0; color: var(--grade-ink); font-size: 16px; }
|
||||
.student-course-head p { margin: 0; color: #647b85; font-size: 11px; }
|
||||
.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; }
|
||||
.course-score-seal span { grid-column: 1; color: #59717d; font: 600 11px/1.2 inherit; letter-spacing: 0; }
|
||||
.course-score-seal strong { grid-column: 2; grid-row: 1 / span 2; color: #768b94; font: 750 28px/1 Consolas, monospace; }
|
||||
.course-score-seal.has-score strong { color: var(--grade-teal); }
|
||||
.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 span { display: grid; gap: 3px; color: #758991; font-size: 10px; }
|
||||
.result-facts b { color: #365762; font-size: 12px; }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
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 http, { apiErrorMessage } from '../api/http'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
@@ -665,7 +665,7 @@ onMounted(async () => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="lab-rail" aria-label="实验安排方式说明">
|
||||
<section v-if="!isStudent" class="lab-rail" aria-label="实验安排方式说明">
|
||||
<article class="rail-lane centralized">
|
||||
<span class="rail-code">FIXED / 集中</span>
|
||||
<b>统一课次</b>
|
||||
@@ -707,7 +707,7 @@ onMounted(async () => {
|
||||
<el-option label="已关闭" value="Closed" />
|
||||
</el-select>
|
||||
</template>
|
||||
<span class="result-note">共 {{ total }} 个教学班课程</span>
|
||||
<span class="result-note">{{ isStudent ? `共 ${projectGroups.length} 门课程、${total} 个实验项目` : `共 ${total} 个教学班课程` }}</span>
|
||||
</section>
|
||||
|
||||
<section v-if="!isStudent" class="experiment-batch-bar">
|
||||
@@ -760,6 +760,16 @@ onMounted(async () => {
|
||||
<div v-else-if="row.sessions.length" class="table-schedule">
|
||||
<b>{{ row.sessions.length }} 个开放场次</b>
|
||||
<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>
|
||||
<span v-else class="table-muted">尚未安排</span>
|
||||
</template>
|
||||
@@ -793,211 +803,77 @@ onMounted(async () => {
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<article
|
||||
v-else
|
||||
v-for="project in group.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.arrangementMode === 'Centralized' ? '统一到场实验' : '开放预约实验' }}</p>
|
||||
<el-table v-else :data="group.projects" class="project-table student-project-table" size="small">
|
||||
<el-table-column type="expand" width="44">
|
||||
<template #default="{ row }">
|
||||
<div class="student-project-detail">
|
||||
<p v-if="row.description"><b>项目说明</b>{{ row.description }}</p>
|
||||
<p v-if="row.requirements"><b>实验要求</b>{{ row.requirements }}</p>
|
||||
<p><b>开放日期</b>{{ row.startDate }} — {{ row.endDate }}</p>
|
||||
<p><b>任课教师</b>{{ row.teacherNames.join('、') || '待定' }}</p>
|
||||
</div>
|
||||
<div class="project-tags">
|
||||
<el-checkbox
|
||||
v-if="!isStudent && project.status === 'Draft'"
|
||||
:model-value="selectedProjectIds.includes(project.id)"
|
||||
@change="toggleProjectSelection(project.id, $event)"
|
||||
aria-label="选择实验项目"
|
||||
/>
|
||||
<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>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="实验项目" min-width="210">
|
||||
<template #default="{ row }">
|
||||
<div class="table-project-name">
|
||||
<b>{{ row.code }} · {{ row.name }}</b>
|
||||
<small>{{ row.description || modeMeta[row.arrangementMode].note }}</small>
|
||||
</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>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="实验安排" min-width="300">
|
||||
<template #default="{ row }">
|
||||
<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>
|
||||
<small>{{ row.scheduleEntry.campusName }} · {{ row.scheduleEntry.buildingName }} {{ row.scheduleEntry.classroomName }}</small>
|
||||
</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 v-else-if="row.myBooking && selectedSession(row)" class="table-schedule is-booked">
|
||||
<b>{{ formatSessionTime(selectedSession(row)) }}</b>
|
||||
<small>{{ selectedSession(row).campusName }} · {{ selectedSession(row).buildingName }} {{ selectedSession(row).classroomName }}</small>
|
||||
</div>
|
||||
|
||||
<div class="session-board">
|
||||
<div class="session-board-head">
|
||||
<div>
|
||||
<span>{{ project.arrangementMode === 'Centralized' ? '统一实验课次' : '可选实验场次' }}</span>
|
||||
<b>{{ modeMeta[project.arrangementMode].note }}</b>
|
||||
<div v-else-if="row.sessions.length" class="table-schedule">
|
||||
<b>{{ row.sessions.length }} 个可选场次</b>
|
||||
<small>尚未预约,请在右侧选择实验时间</small>
|
||||
</div>
|
||||
<el-button
|
||||
v-if="!isStudent && project.status !== 'Closed' && project.arrangementMode === 'SelfScheduled'"
|
||||
size="small"
|
||||
:icon="Calendar"
|
||||
@click="openSession(project)"
|
||||
>
|
||||
安排场次
|
||||
<span v-else class="table-muted">暂无有效实验安排</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="方式" width="100" align="center">
|
||||
<template #default="{ row }"><el-tag :type="row.arrangementMode === 'Centralized' ? 'primary' : 'success'" effect="plain" size="small">{{ modeMeta[row.arrangementMode].label }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="88" align="center">
|
||||
<template #default="{ row }"><el-tag :type="row.status === 'Published' ? 'success' : 'info'" size="small">{{ statusLabels[row.status] }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="预约操作" width="190" fixed="right" align="right">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.arrangementMode === 'SelfScheduled'">
|
||||
<el-popover placement="bottom-end" :width="540" trigger="click">
|
||||
<template #reference>
|
||||
<el-button size="small" :type="row.myBooking ? 'success' : 'primary'" plain>
|
||||
{{ row.myBooking ? '查看场次' : '选择场次' }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="project.arrangementMode === 'Centralized' && project.scheduleEntry" class="session-grid">
|
||||
<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>
|
||||
</template>
|
||||
<div class="student-session-menu">
|
||||
<header><b>{{ row.code }} · {{ row.name }}</b><span>每个实验项目限约一个场次</span></header>
|
||||
<div v-for="session in row.sessions" :key="session.id" class="student-session-option" :class="{ 'is-selected': row.myBooking?.experimentSessionId === session.id }">
|
||||
<div>
|
||||
<b>{{ formatSessionTime(session) }}</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>
|
||||
<el-tag :type="sessionTagType(session)" size="small" effect="plain">余 {{ session.remainingCount }} / {{ session.capacity }}</el-tag>
|
||||
<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-empty v-if="!row.sessions.length" :image-size="42" description="暂无有效实验场次" />
|
||||
</div>
|
||||
</el-popover>
|
||||
<el-button v-if="row.myBooking && row.status === 'Published'" size="small" text type="danger" @click="cancelBooking(row)">取消</el-button>
|
||||
</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>
|
||||
<span v-else class="table-muted">无需预约</span>
|
||||
</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>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</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 :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; }
|
||||
.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 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-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; }
|
||||
.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 {
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
@@ -1521,6 +1411,7 @@ onMounted(async () => {
|
||||
.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; }
|
||||
.student-project-detail { grid-template-columns: 1fr; }
|
||||
.mode-choice { grid-template-columns: 1fr; }
|
||||
.intro-actions { flex-wrap: wrap; justify-content: flex-end; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user