实验成绩计算
This commit is contained in:
@@ -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 =
|
||||||
[
|
[
|
||||||
"""
|
"""
|
||||||
|
|||||||
+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");
|
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()
|
||||||
{
|
{
|
||||||
|
|||||||
Vendored
+1
@@ -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']
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user