18 Commits
82 changed files with 44637 additions and 123 deletions
+20 -1
View File
@@ -67,7 +67,26 @@ Operations__MySqlClientPath=mysql
Jwt__Issuer=Jiaowu.Api
Jwt__Audience=Jiaowu.Web
Jwt__Key=REPLACE_WITH_AT_LEAST_32_RANDOM_BYTES
Jwt__ExpireMinutes=60
Jwt__AccessTokenMinutes=10
Jwt__WebIdleMinutes=30
Jwt__AppIdleMinutes=4320
# Keycloak SSO(可选)。Authority 必须指向 realm,例如:
# https://sso.example.edu.cn/realms/mingxu
Sso__Enabled=false
# Sso__DisplayName=学校统一身份认证
# Sso__Authority=https://sso.example.edu.cn/realms/mingxu
# Sso__ClientId=jiaowu-web
# Sso__ClientSecret=REPLACE_WITH_KEYCLOAK_CLIENT_SECRET
# Sso__UserNameClaim=preferred_username
# Sso__RequireHttpsMetadata=true
# 首次 SSO 登录优先绑定同名本地账号;用户名不同时由用户输入现有账号密码完成绑定。
# 不会自动创建账号或授予角色。
# Sso__LinkExistingUsersByUserName=true
# 前后端同域部署时留空;开发或分离部署时填写前端公开根地址。
# Sso__FrontendBaseUrl=https://jiaowu.example.edu.cn
# 必须与 Keycloak 客户端的 Valid redirect URI 完全一致。
# Sso__CallbackUrl=https://jiaowu.example.edu.cn/signin-keycloak
AllowedHosts=jiaowu.example.edu.cn
Cors__Origins__0=https://jiaowu.example.edu.cn
+44
View File
@@ -144,6 +144,50 @@ Kubernetes 或密钥管理系统仍可覆盖文件中的值。
`chmod 600 .env`Windows 应通过 ACL 只允许服务账号和管理员读取。连接串中的证书
路径必须是运行服务器上的实际路径。
### Keycloak 单点登录(可选)
系统支持 Keycloak 的 OpenID Connect 授权码流程。Keycloak 只负责验证身份;账号是否
启用、角色和学院数据范围仍以本系统 Identity 数据为准。首次 SSO 登录会用
`preferred_username`(可通过 `Sso__UserNameClaim` 修改)优先匹配已有登录账号并记录
外部账号绑定。如果 Keycloak 用户名与教务系统账号不同,认证后会进入账户绑定页,用户
需要再输入一次现有教务系统账号和密码;验证成功后建立永久绑定并直接登录。绑定不会
自动创建本地账号、修改人员档案或从 Keycloak 导入高权限角色。同一 Keycloak 身份不能
绑定多个本地账号,同一本地账号也不能绑定多个 Keycloak 身份。原账号密码登录和学生
自助激活入口不受影响。
在 Keycloak 中创建 OpenID Connect 客户端,并至少配置:
- Valid redirect URI`https://jiaowu.example.edu.cn/signin-keycloak`
- Valid post logout redirect URI`https://jiaowu.example.edu.cn/*`(若后续启用 Keycloak 全局退出)
- Standard flow:开启;Implicit flow:关闭;PKCE`S256`
然后在 `.env` 中配置:
```dotenv
Sso__Enabled=true
Sso__DisplayName=学校统一身份认证
Sso__Authority=https://sso.example.edu.cn/realms/mingxu
Sso__ClientId=jiaowu-web
Sso__ClientSecret=REPLACE_WITH_KEYCLOAK_CLIENT_SECRET
Sso__UserNameClaim=preferred_username
Sso__RequireHttpsMetadata=true
Sso__LinkExistingUsersByUserName=true
Sso__FrontendBaseUrl=https://jiaowu.example.edu.cn
Sso__CallbackUrl=https://jiaowu.example.edu.cn/signin-keycloak
```
前后端同域时 `Sso__FrontendBaseUrl` 可以留空。本地 Vite 开发默认回到
`http://localhost:5173`,Keycloak 测试客户端需同时允许
`http://localhost:5255/signin-keycloak``Sso__CallbackUrl` 是应用实际发送给 Keycloak
`redirect_uri`,必须与客户端的 Valid redirect URI 完全一致;建议生产环境始终显式
配置它,避免反向代理导致 scheme 或 host 推导错误。个人账户页的“管理员配置参考”也会
显示当前生效的完整回调地址。多实例部署应配置 Redis,以便任意实例都能兑换两分钟内
有效、使用后即删除的 SSO 登录码及五分钟内有效的绑定意图。
用户登录后可从页面右上角进入“个人账户”,主动绑定或解除 Keycloak 账号。主动绑定先
使用当前 JWT 创建五分钟有效的一次性绑定意图,再跳转 Keycloak;回调只能绑定到发起该
意图的本地账号。解绑需要再次验证本地密码,避免仅凭未锁屏的登录会话解除身份关联。
### Linux systemd 服务
仓库提供 [`deploy/systemd/jiaowu.service`](deploy/systemd/jiaowu.service),适用于
+3 -1
View File
@@ -30,7 +30,9 @@ x-jiaowu-environment: &jiaowu-environment
Jwt__Issuer: Jiaowu.Api
Jwt__Audience: Jiaowu.Web
Jwt__Key: "${JWT_KEY:?请在 .env.docker 中设置 JWT_KEY}"
Jwt__ExpireMinutes: "60"
Jwt__AccessTokenMinutes: "10"
Jwt__WebIdleMinutes: "30"
Jwt__AppIdleMinutes: "4320"
AllowedHosts: "${ALLOWED_HOSTS:-localhost}"
Cors__Origins__0: "${CORS_ORIGIN:-http://localhost:8080}"
OfficialDocuments__PublicBaseUrl: "${OFFICIAL_DOCUMENTS_PUBLIC_BASE_URL:-http://localhost:8080}"
@@ -1,6 +1,7 @@
using System.ComponentModel.DataAnnotations;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Domain.System;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Grades;
using Jiaowu.Api.Infrastructure.Persistence;
@@ -250,6 +251,13 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
// Auto-apply: update grade record
gm.GradeRecord!.TotalScore = gm.RequestedScore;
gm.GradeRecord.GradePoint = GradeCalculator.CalculateGradePoint(gm.RequestedScore);
var statisticsJob = new CourseGradeStatisticsRefreshJob
{
GradeSheetId = gm.GradeRecord.GradeSheetId
};
db.CourseGradeStatisticsRefreshJobs.Add(statisticsJob);
db.BackgroundJobOutboxMessages.Add(BackgroundJobOutboxMessage.Create(
BackgroundJobKind.CourseGradeStatisticsRefresh, statisticsJob.Id));
await db.SaveChangesAsync(ct);
await NotificationService.SendAsync(
db,
+71 -13
View File
@@ -18,7 +18,7 @@ namespace Jiaowu.Api.Controllers;
public sealed class AuthController(
AppDbContext db,
UserManager<ApplicationUser> userManager,
ITokenService tokenService,
IAuthSessionService authSessionService,
IAppCache cache) : ControllerBase
{
[AllowAnonymous]
@@ -135,8 +135,11 @@ public sealed class AuthController(
}
[AllowAnonymous]
[EnableRateLimiting("public-auth")]
[HttpPost("login")]
public async Task<ActionResult<LoginResponse>> Login(LoginRequest request)
public async Task<ActionResult<LoginResponse>> Login(
LoginRequest request,
CancellationToken cancellationToken)
{
var user = await userManager.FindByNameAsync(request.UserName);
if (user is null || !user.IsEnabled)
@@ -166,15 +169,47 @@ public sealed class AuthController(
await userManager.UpdateAsync(user);
var roles = await userManager.GetRolesAsync(user);
return new LoginResponse(
tokenService.Create(user, roles),
new CurrentUserResponse(
user.Id,
user.UserName!,
user.DisplayName,
roles,
user.CollegeId,
EffectiveDataScopeResolver.Resolve(roles).ToString()));
var session = await authSessionService.CreateAsync(
user,
roles,
request.IsNativeApp
? AuthenticationClientType.App
: AuthenticationClientType.Web,
cancellationToken);
return CreateLoginResponse(session);
}
[AllowAnonymous]
[EnableRateLimiting("token-refresh")]
[HttpPost("refresh")]
public async Task<ActionResult<LoginResponse>> Refresh(
RefreshTokenRequest request,
CancellationToken cancellationToken)
{
var session = await authSessionService.RefreshAsync(
request.RefreshToken,
cancellationToken);
if (session is null)
{
return Unauthorized(new ProblemDetails
{
Title = "登录已过期",
Detail = "登录已过期或刷新令牌已失效,请重新登录。",
Status = StatusCodes.Status401Unauthorized
});
}
return CreateLoginResponse(session);
}
[AllowAnonymous]
[HttpPost("logout")]
public async Task<IActionResult> Logout(
RefreshTokenRequest request,
CancellationToken cancellationToken)
{
await authSessionService.RevokeAsync(request.RefreshToken, cancellationToken);
return NoContent();
}
[Authorize]
@@ -212,11 +247,29 @@ public sealed class AuthController(
Detail = detail,
Status = status
});
internal static LoginResponse CreateLoginResponse(AuthSessionResult session) =>
new(
session.AccessToken,
session.AccessTokenExpiresAt,
session.RefreshToken,
session.SessionExpiresAt,
new CurrentUserResponse(
session.User.Id,
session.User.UserName!,
session.User.DisplayName,
session.Roles,
session.User.CollegeId,
EffectiveDataScopeResolver.Resolve(session.Roles).ToString()));
}
public sealed record LoginRequest(
[Required, MaxLength(100)] string UserName,
[Required, MaxLength(100)] string Password);
[Required, MaxLength(100)] string Password,
bool IsNativeApp = false);
public sealed record RefreshTokenRequest(
[Required, MinLength(40), MaxLength(200)] string RefreshToken);
public sealed record StudentActivationRequest(
[Required, MaxLength(50)] string Name,
@@ -227,7 +280,12 @@ public sealed record StudentActivationRequest(
Guid AdministrativeClassId,
[Required, MinLength(8), MaxLength(100)] string Password);
public sealed record LoginResponse(string Token, CurrentUserResponse User);
public sealed record LoginResponse(
string Token,
DateTime AccessTokenExpiresAt,
string RefreshToken,
DateTime SessionExpiresAt,
CurrentUserResponse User);
public sealed record CurrentUserResponse(
Guid Id,
@@ -1639,13 +1639,25 @@ public sealed class CourseSelectionsController(
var students = await LoadTeachingTaskRosterAsync(id, cancellationToken);
var bytes = ExcelWorkbookHelper.Create(
"教学班名单",
["学号", "姓名", "班级", "专业", "进入方式", "选课时间"],
[
"学号", "姓名", "班级", "专业", "联系电话", "电子邮箱",
"微信", "紧急联系人", "与本人关系", "紧急联系电话",
"特殊标记", "特殊情况说明", "进入方式", "选课时间"
],
students.Select(student => new List<object?>
{
student.StudentNumber,
student.Name,
student.ClassName,
student.MajorName,
student.Phone,
student.Email,
student.WeChat,
student.EmergencyContactName,
student.EmergencyContactRelationship,
student.EmergencyContactPhone,
student.SpecialTags,
student.SpecialNeeds,
student.EnrolledAt.HasValue ? "选课" : "行政班关联",
student.EnrolledAt?.ToString("yyyy-MM-dd HH:mm") ?? "-"
}).ToList<IReadOnlyList<object?>>());
@@ -1754,6 +1766,14 @@ public sealed class CourseSelectionsController(
x.Name,
x.AdministrativeClass!.Name,
x.AdministrativeClass.Major!.Name,
x.Phone,
x.Email,
x.WeChat,
x.EmergencyContactName,
x.EmergencyContactRelationship,
x.EmergencyContactPhone,
x.SpecialTags,
x.SpecialNeeds,
db.CourseEnrollments
.Where(enrollment =>
enrollment.StudentId == x.Id &&
@@ -2154,6 +2174,14 @@ public sealed class CourseSelectionsController(
string Name,
string ClassName,
string MajorName,
string? Phone,
string? Email,
string? WeChat,
string? EmergencyContactName,
string? EmergencyContactRelationship,
string? EmergencyContactPhone,
string? SpecialTags,
string? SpecialNeeds,
DateTime? EnrolledAt);
}
@@ -313,9 +313,8 @@ public sealed class ExperimentsController(
var tasks = await AccessibleTeachingTasks().AsNoTracking()
.Include(x => x.AcademicTerm)
.Where(x =>
taskIds.Contains(x.Id) &&
x.Status == TeachingTaskStatus.Published)
.WhereIn(taskIds, x => x.Id)
.Where(x => x.Status == TeachingTaskStatus.Published)
.OrderBy(x => x.TaskNumber)
.ToListAsync(cancellationToken);
if (tasks.Count != taskIds.Count)
@@ -329,9 +328,8 @@ public sealed class ExperimentsController(
var code = request.Code.Trim();
var conflictingTaskNumbers = await db.ExperimentProjects.AsNoTracking()
.Where(x =>
taskIds.Contains(x.TeachingTaskId) &&
x.Code == code)
.WhereIn(taskIds, x => x.TeachingTaskId)
.Where(x => x.Code == code)
.Select(x => x.TeachingTask!.TaskNumber)
.OrderBy(x => x)
.ToListAsync(cancellationToken);
@@ -576,7 +574,7 @@ public sealed class ExperimentsController(
var projects = await ScopedProjects()
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.AcademicTerm)
.Where(x => projectIds.Contains(x.Id))
.WhereIn(projectIds, x => x.Id)
.ToDictionaryAsync(x => x.Id, cancellationToken);
if (projects.Count != projectIds.Count)
return ValidationProblem(
@@ -0,0 +1,477 @@
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Domain.System;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize(Roles = AnalyticsUsers)]
[Route("api/grade-analytics")]
public sealed class GradeAnalyticsController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope,
IAppCache? cache = null) : ControllerBase
{
private const string AnalyticsUsers =
SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin + "," +
SystemRoles.CollegeAdmin + "," +
SystemRoles.Leader + "," +
SystemRoles.Teacher;
[HttpGet("teaching-classes")]
public async Task<ActionResult> GetTeachingClasses(
Guid? academicTermId,
string? keyword,
int page = 1,
int pageSize = 20,
CancellationToken cancellationToken = default)
{
page = Math.Max(1, page);
pageSize = Math.Clamp(pageSize, 1, 100);
keyword = string.IsNullOrWhiteSpace(keyword) ? null : keyword.Trim();
var source = db.TeachingTaskGradeStatistics.AsNoTracking()
.Where(x => VisibleTeachingTasks().Any(task => task.Id == x.TeachingTaskId));
if (academicTermId.HasValue)
source = source.Where(x => x.AcademicTermId == academicTermId);
if (keyword is not null)
source = source.Where(x =>
x.TeachingTask!.TaskNumber.Contains(keyword) ||
x.TeachingTask.Name.Contains(keyword) ||
x.TeachingTask.Course!.Code.Contains(keyword) ||
x.TeachingTask.Course.Name.Contains(keyword));
var total = await source.CountAsync(cancellationToken);
var items = await source
.OrderByDescending(x => x.TeachingTask!.AcademicTerm!.StartDate)
.ThenBy(x => x.TeachingTask!.Course!.Code)
.ThenBy(x => x.TeachingTask!.TaskNumber)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(x => new
{
x.GradeSheetId,
x.TeachingTaskId,
x.TeachingTask!.TaskNumber,
TaskName = x.TeachingTask.Name,
CourseCode = x.TeachingTask.Course!.Code,
CourseName = x.TeachingTask.Course.Name,
TermName = x.TeachingTask.AcademicTerm!.Name,
x.AcademicTermId,
TeacherNames = x.TeachingTask.Teachers
.OrderByDescending(item => item.IsPrimary)
.Select(item => item.Teacher!.Name),
ClassNames = x.TeachingTask.Classes
.Select(item => item.AdministrativeClass!.Name),
x.StudentCount,
x.AverageScore,
x.PassRate,
x.ExcellentRate,
x.CalculatedAt
})
.ToListAsync(cancellationToken);
return Ok(new { Items = items, Total = total, Page = page, PageSize = pageSize });
}
[HttpGet("teaching-classes/{gradeSheetId:guid}")]
public async Task<ActionResult> GetTeachingClassAnalysis(
Guid gradeSheetId,
CancellationToken cancellationToken)
{
var sheet = await db.GradeSheets.AsNoTracking()
.Where(x => x.Id == gradeSheetId &&
VisibleTeachingTasks().Any(task => task.Id == x.TeachingTaskId))
.Select(x => new AnalysisTarget(
x.Id,
x.TeachingTaskId,
x.TeachingTask!.CourseId,
x.TeachingTask.AcademicTermId,
x.TeachingTask.Course!.CollegeId,
x.TeachingTask.TaskNumber,
x.TeachingTask.Name,
x.TeachingTask.Course.Code,
x.TeachingTask.Course.Name,
x.TeachingTask.AcademicTerm!.Name))
.FirstOrDefaultAsync(cancellationToken);
if (sheet is null) return NotFound();
var report = await (cache ?? NoOpAppCache.Instance).GetOrCreateAsync(
AppCacheKeys.TeachingTaskGradeAnalytics(gradeSheetId),
token => BuildReportAsync(sheet, token),
AppCacheProfile.Analytics,
[AppCacheTags.CourseGradeStatistics],
cancellationToken);
return Ok(report);
}
[HttpPost("teaching-classes/{gradeSheetId:guid}/refresh")]
public async Task<ActionResult> RefreshTeachingClassAnalysis(
Guid gradeSheetId,
CancellationToken cancellationToken)
{
var exists = await db.GradeSheets.AsNoTracking()
.AnyAsync(x => x.Id == gradeSheetId &&
VisibleTeachingTasks().Any(task => task.Id == x.TeachingTaskId),
cancellationToken);
if (!exists) return NotFound();
var job = new CourseGradeStatisticsRefreshJob { GradeSheetId = gradeSheetId };
db.CourseGradeStatisticsRefreshJobs.Add(job);
db.BackgroundJobOutboxMessages.Add(BackgroundJobOutboxMessage.Create(
BackgroundJobKind.CourseGradeStatisticsRefresh,
job.Id));
await db.SaveChangesAsync(cancellationToken);
return Accepted(new { job.Id });
}
private async Task<TeachingClassAnalysisReport> BuildReportAsync(
AnalysisTarget target,
CancellationToken cancellationToken)
{
var statistic = await db.TeachingTaskGradeStatistics.AsNoTracking()
.Where(x => x.GradeSheetId == target.GradeSheetId)
.Select(x => new TeachingClassMetrics(
x.StudentCount,
x.PassedCount,
x.ExcellentCount,
x.HighestScore,
x.AverageScore,
x.MedianScore,
x.LowestScore,
x.StandardDeviation,
x.PassRate,
x.ExcellentRate,
x.CalculatedAt,
x.ScoreBands.OrderBy(band => band.SortOrder)
.Select(band => new ScoreBand(
band.Label,
band.LowerBound,
band.UpperBound,
band.StudentCount))
.ToArray()))
.FirstOrDefaultAsync(cancellationToken);
if (statistic is null)
return new TeachingClassAnalysisReport(
true,
target.GradeSheetId,
target.TeachingTaskId,
target.TaskNumber,
target.TaskName,
target.CourseCode,
target.CourseName,
target.TermName,
null,
[],
[],
[],
null);
var peerRows = await db.TeachingTaskGradeStatistics.AsNoTracking()
.Where(x => x.CourseId == target.CourseId &&
x.AcademicTermId == target.AcademicTermId)
.OrderByDescending(x => x.AverageScore)
.Select(x => new
{
x.GradeSheetId,
x.TeachingTaskId,
x.TeachingTask!.TaskNumber,
TaskName = x.TeachingTask.Name,
TeacherNames = x.TeachingTask.Teachers
.OrderByDescending(item => item.IsPrimary)
.Select(item => item.Teacher!.Name).ToArray(),
ClassNames = x.TeachingTask.Classes
.Select(item => item.AdministrativeClass!.Name).ToArray(),
x.StudentCount,
x.HighestScore,
x.AverageScore,
x.MedianScore,
x.LowestScore,
x.StandardDeviation,
x.PassRate,
x.ExcellentRate
})
.ToListAsync(cancellationToken);
var peers = peerRows.Select(x => new TeachingClassComparison(
x.GradeSheetId,
x.TeachingTaskId,
x.TaskNumber,
x.TaskName,
string.Join("、", x.TeacherNames),
string.Join("、", x.ClassNames),
x.StudentCount,
x.HighestScore,
x.AverageScore,
x.MedianScore,
x.LowestScore,
x.StandardDeviation,
x.PassRate,
x.ExcellentRate,
x.GradeSheetId == target.GradeSheetId)).ToArray();
var classProfiles = await db.GradeRecords.AsNoTracking()
.Where(x => x.GradeSheetId == target.GradeSheetId)
.Select(x => new ClassProfile(
x.Student!.AdministrativeClassId,
x.Student.AdministrativeClass!.Name,
x.Student.AdministrativeClass.MajorId,
x.Student.AdministrativeClass.Major!.Name,
x.Student.AdministrativeClass.Major.CollegeId,
x.Student.AdministrativeClass.Major.College!.Name))
.Distinct()
.ToListAsync(cancellationToken);
var scopeStatistics = await db.CourseGradeStatistics.AsNoTracking()
.Where(x => x.CourseId == target.CourseId &&
x.AcademicTermId == target.AcademicTermId)
.ToListAsync(cancellationToken);
var benchmarks = BuildBenchmarks(classProfiles, scopeStatistics);
var selectedTeacherIds = await db.TeachingTaskTeachers.AsNoTracking()
.Where(x => x.TeachingTaskId == target.TeachingTaskId)
.Select(x => x.TeacherId)
.ToListAsync(cancellationToken);
var historicalTaskRows = await db.TeachingTaskGradeStatistics.AsNoTracking()
.Where(x => x.CourseId == target.CourseId &&
x.TeachingTask!.Teachers.Any(link =>
selectedTeacherIds.Contains(link.TeacherId)))
.Select(x => new
{
x.AcademicTermId,
TermName = x.TeachingTask!.AcademicTerm!.Name,
x.TeachingTask.AcademicTerm.StartDate,
x.StudentCount,
x.PassedCount,
x.ExcellentCount,
x.AverageScore
})
.ToListAsync(cancellationToken);
var courseHistory = await db.CourseGradeStatistics.AsNoTracking()
.Where(x => x.CourseId == target.CourseId &&
x.Scope == CourseGradeStatisticScope.University)
.Select(x => new
{
x.AcademicTermId,
TermName = db.AcademicTerms.Where(term => term.Id == x.AcademicTermId)
.Select(term => term.Name).First(),
StartDate = db.AcademicTerms.Where(term => term.Id == x.AcademicTermId)
.Select(term => term.StartDate).First(),
x.StudentCount,
x.AverageScore,
x.PassRate,
ExcellentRate = x.StudentCount == 0 ? 0m :
Math.Round((decimal)x.From90To100Count / x.StudentCount * 100m, 2)
})
.ToListAsync(cancellationToken);
var teacherByTerm = historicalTaskRows
.GroupBy(x => new { x.AcademicTermId, x.TermName, x.StartDate })
.ToDictionary(group => group.Key.AcademicTermId, group =>
{
var count = group.Sum(x => x.StudentCount);
return new HistoricalSeriesValue(
count,
count == 0 ? 0m : Math.Round(
group.Sum(x => x.AverageScore * x.StudentCount) / count, 1),
count == 0 ? 0m : Math.Round(
(decimal)group.Sum(x => x.PassedCount) / count * 100m, 2),
count == 0 ? 0m : Math.Round(
(decimal)group.Sum(x => x.ExcellentCount) / count * 100m, 2));
});
var history = courseHistory
.OrderBy(x => x.StartDate)
.Select(x => new HistoricalComparison(
x.AcademicTermId,
x.TermName,
x.StudentCount,
x.AverageScore,
x.PassRate,
x.ExcellentRate,
teacherByTerm.GetValueOrDefault(x.AcademicTermId)))
.ToArray();
var university = scopeStatistics.FirstOrDefault(x =>
x.Scope == CourseGradeStatisticScope.University);
return new TeachingClassAnalysisReport(
false,
target.GradeSheetId,
target.TeachingTaskId,
target.TaskNumber,
target.TaskName,
target.CourseCode,
target.CourseName,
target.TermName,
statistic,
peers,
benchmarks,
history,
university is null ? null : new ComparisonDelta(
Math.Round(statistic.AverageScore - university.AverageScore, 1),
Math.Round(statistic.PassRate - university.PassRate, 2),
university.AverageScore,
university.PassRate));
}
private static ScopeBenchmark[] BuildBenchmarks(
IEnumerable<ClassProfile> classProfiles,
IReadOnlyCollection<CourseGradeStatistic> statistics)
{
var profiles = classProfiles.ToArray();
var rows = new List<ScopeBenchmark>();
foreach (var profile in profiles)
AddBenchmark(rows, statistics, CourseGradeStatisticScope.AdministrativeClass,
profile.ClassId, "行政班", profile.ClassName);
foreach (var profile in profiles.GroupBy(x => x.MajorId).Select(x => x.First()))
AddBenchmark(rows, statistics, CourseGradeStatisticScope.Major,
profile.MajorId, "专业", profile.MajorName);
foreach (var profile in profiles.GroupBy(x => x.CollegeId).Select(x => x.First()))
AddBenchmark(rows, statistics, CourseGradeStatisticScope.College,
profile.CollegeId, "学院", profile.CollegeName);
AddBenchmark(rows, statistics, CourseGradeStatisticScope.University,
null, "全校", "全校同课程");
return rows.ToArray();
}
private static void AddBenchmark(
ICollection<ScopeBenchmark> target,
IEnumerable<CourseGradeStatistic> source,
CourseGradeStatisticScope scope,
Guid? entityId,
string scopeLabel,
string name)
{
var item = source.FirstOrDefault(x =>
x.Scope == scope && x.ScopeEntityId == entityId);
if (item is null || target.Any(x => x.Scope == scopeLabel && x.Name == name)) return;
target.Add(new ScopeBenchmark(
scopeLabel,
name,
item.StudentCount,
item.HighestScore,
item.AverageScore,
item.LowestScore,
item.PassRate));
}
private IQueryable<TeachingTask> VisibleTeachingTasks()
{
var scope = currentUserDataScope.Current;
var source = db.TeachingTasks.AsQueryable();
if (scope.Scope == DataScope.All) return source;
if (scope.Scope == DataScope.College)
return source.Where(x => x.Course!.CollegeId == scope.RestrictedCollegeId);
if (scope.IsInRole(SystemRoles.Teacher))
return source.Where(x =>
x.Teachers.Any(link => link.Teacher!.UserId == scope.UserId));
return source.Where(_ => false);
}
private sealed record AnalysisTarget(
Guid GradeSheetId,
Guid TeachingTaskId,
Guid CourseId,
Guid AcademicTermId,
Guid CourseCollegeId,
string TaskNumber,
string TaskName,
string CourseCode,
string CourseName,
string TermName);
private sealed record ClassProfile(
Guid ClassId,
string ClassName,
Guid MajorId,
string MajorName,
Guid CollegeId,
string CollegeName);
public sealed record ScoreBand(
string Label,
decimal LowerBound,
decimal? UpperBound,
int StudentCount);
public sealed record TeachingClassMetrics(
int StudentCount,
int PassedCount,
int ExcellentCount,
decimal HighestScore,
decimal AverageScore,
decimal MedianScore,
decimal LowestScore,
decimal StandardDeviation,
decimal PassRate,
decimal ExcellentRate,
DateTime CalculatedAt,
IReadOnlyList<ScoreBand> ScoreBands);
public sealed record TeachingClassComparison(
Guid GradeSheetId,
Guid TeachingTaskId,
string TaskNumber,
string TaskName,
string TeacherNames,
string ClassNames,
int StudentCount,
decimal HighestScore,
decimal AverageScore,
decimal MedianScore,
decimal LowestScore,
decimal StandardDeviation,
decimal PassRate,
decimal ExcellentRate,
bool IsSelected);
public sealed record ScopeBenchmark(
string Scope,
string Name,
int StudentCount,
decimal HighestScore,
decimal AverageScore,
decimal LowestScore,
decimal PassRate);
public sealed record HistoricalSeriesValue(
int StudentCount,
decimal AverageScore,
decimal PassRate,
decimal ExcellentRate);
public sealed record HistoricalComparison(
Guid AcademicTermId,
string TermName,
int CourseStudentCount,
decimal CourseAverageScore,
decimal CoursePassRate,
decimal CourseExcellentRate,
HistoricalSeriesValue? Instructor);
public sealed record ComparisonDelta(
decimal AverageScoreDifference,
decimal PassRateDifference,
decimal UniversityAverageScore,
decimal UniversityPassRate);
public sealed record TeachingClassAnalysisReport(
bool IsRefreshing,
Guid GradeSheetId,
Guid TeachingTaskId,
string TaskNumber,
string TaskName,
string CourseCode,
string CourseName,
string TermName,
TeachingClassMetrics? Summary,
IReadOnlyList<TeachingClassComparison> PeerTeachingClasses,
IReadOnlyList<ScopeBenchmark> ScopeBenchmarks,
IReadOnlyList<HistoricalComparison> History,
ComparisonDelta? UniversityDelta);
}
+198 -6
View File
@@ -3,7 +3,9 @@ using System.Globalization;
using Jiaowu.Api.Contracts;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Domain.System;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Excel;
using Jiaowu.Api.Infrastructure.Grades;
using Jiaowu.Api.Infrastructure.Persistence;
@@ -19,7 +21,8 @@ namespace Jiaowu.Api.Controllers;
[Route("api/grades")]
public sealed class GradesController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope) : ControllerBase
ICurrentUserDataScope currentUserDataScope,
IAppCache? cache = null) : ControllerBase
{
private const string SheetUsers =
SystemRoles.SuperAdmin + "," +
@@ -37,6 +40,9 @@ public sealed class GradesController(
SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin;
private const string StatisticsUsers =
SheetUsers + "," + SystemRoles.Leader + "," + SystemRoles.Student;
[HttpGet("sheets")]
[Authorize(Roles = SheetUsers)]
public async Task<ActionResult> GetSheets(
@@ -502,6 +508,7 @@ public sealed class GradesController(
}
targetItem.SourceType = GradeItemSourceType.ExperimentSummary;
targetItem.SourceSnapshotAt = DateTime.UtcNow;
QueueCourseStatisticsRefresh(sheet.Id);
await db.SaveChangesAsync(cancellationToken);
return Ok(new
{
@@ -661,6 +668,7 @@ public sealed class GradesController(
return ConflictProblem("只有审核通过的成绩单可以发布。");
sheet.Status = GradeSheetStatus.Published;
sheet.PublishedAt = DateTime.UtcNow;
QueueCourseStatisticsRefresh(sheet.Id);
// The grade sheet roster is authoritative at publication time. This also
// covers students added through approved roster corrections.
@@ -699,7 +707,7 @@ public sealed class GradesController(
var itemNames = sheet.Items.Select(i => i.Name).ToList();
var headers = new List<string> { "学号", "姓名", "班级", "平时成绩" };
headers.AddRange(itemNames);
headers.AddRange(["期末成绩", "考试状态", "备注"]);
headers.AddRange(["总分(自动计算)", "期末成绩", "考试状态", "备注"]);
var rows = sheet.Records.Select(record =>
{
@@ -716,6 +724,7 @@ public sealed class GradesController(
.FirstOrDefault(s => s.GradeItemId == item.Id)?.Score;
values.Add(score);
}
values.Add(null);
values.Add(record.FinalScore);
values.Add(record.ExamStatus == GradeExamStatus.Normal ? "正常" :
record.ExamStatus == GradeExamStatus.Absent ? "缺考" :
@@ -729,13 +738,56 @@ public sealed class GradesController(
{
"请勿修改第一行列名;学号、姓名、班级列请勿修改,用于匹配学生。",
"成绩列填写 0—100 的数值,留空表示暂未录入。",
"总分(自动计算)列由 Excel 按各部分比例自动计算,仅供填写时预览;上传时系统不会采用该列结果。",
"考试状态填写:正常、缺考、缓考 或 免修,留空默认为正常。",
$"本成绩单共 {sheet.Items.Count} 个分项:{string.Join("", itemNames)}。",
"导入后会自动重新计算总评成绩和绩点。"
};
var regularColumn = 4;
var itemColumns = Enumerable.Range(5, sheet.Items.Count).ToArray();
var totalColumn = regularColumn + itemColumns.Length + 1;
var finalColumn = totalColumn + 1;
var statusColumn = finalColumn + 1;
var weightedColumns = new List<(int Column, decimal Weight)> { (regularColumn, sheet.RegularWeight) };
weightedColumns.AddRange(sheet.Items.Select((item, index) =>
(itemColumns[index], item.Weight)));
weightedColumns.Add((finalColumn, sheet.FinalWeight));
var requiredColumns = weightedColumns.Where(x => x.Weight > 0).ToArray();
var scoreColumns = weightedColumns.Select(x => x.Column)
.Append(totalColumn)
.ToArray();
var bytes = ExcelWorkbookHelper.Create(
"成绩导入", headers, rows, instructions);
"成绩导入", headers, rows, instructions,
(worksheet, rowNumber) =>
{
var componentReferences = requiredColumns
.Select(x => $"{ColumnLetter(x.Column)}{rowNumber}")
.ToArray();
var weightedExpression = string.Join("+", weightedColumns.Select(x =>
$"{ColumnLetter(x.Column)}{rowNumber}*{x.Weight.ToString(CultureInfo.InvariantCulture)}/100"));
var statusReference = $"{ColumnLetter(statusColumn)}{rowNumber}";
var formula =
$"=IF(OR({statusReference}=\"\",{statusReference}=\"缓考\",{statusReference}=\"免修\"),\"\",IF(COUNT({string.Join(",", componentReferences)})={requiredColumns.Length},ROUND({weightedExpression},1),\"\"))";
var cell = worksheet.Cell(rowNumber, totalColumn);
cell.FormulaA1 = formula;
cell.Style.NumberFormat.Format = "0.0";
cell.Style.Font.Bold = true;
cell.Style.Fill.BackgroundColor = ClosedXML.Excel.XLColor.FromHtml("#E8F1FB");
foreach (var scoreColumn in scoreColumns)
{
var conditionalFormat = worksheet
.Range(rowNumber, scoreColumn, rowNumber, scoreColumn)
.AddConditionalFormat();
var failingScoreFormat = conditionalFormat.WhenLessThan(60);
failingScoreFormat.Fill.BackgroundColor =
ClosedXML.Excel.XLColor.FromHtml("#FDECEC");
failingScoreFormat.Font.FontColor =
ClosedXML.Excel.XLColor.FromHtml("#B42318");
}
});
var taskName = sheet.TeachingTask!.Name;
return File(bytes, ExcelWorkbookHelper.ContentType,
$"成绩导入模板-{taskName}.xlsx");
@@ -768,13 +820,15 @@ public sealed class GradesController(
var itemNames = sheet.Items.Select(i => i.Name).ToList();
var headers = new List<string> { "学号", "姓名", "班级", "平时成绩" };
headers.AddRange(itemNames);
headers.AddRange(["期末成绩", "考试状态", "备注"]);
headers.AddRange(["总分(自动计算)", "期末成绩", "考试状态", "备注"]);
IReadOnlyList<ExcelRow> rows;
try
{
rows = await ExcelWorkbookHelper.ReadAsync(
file, headers, cancellationToken);
file,
headers.Where(x => x != "总分(自动计算)").ToArray(),
cancellationToken);
}
catch (InvalidDataException exception)
{
@@ -810,7 +864,7 @@ public sealed class GradesController(
var regularScore = ParseOptionalDecimal(row, "平时成绩", 0, 100, errors);
if (errors.Count > 0 && errors[^1].Contains(row.RowNumber.ToString())) continue;
// Parse final score
// Parse final score. The formula-driven total column is intentionally ignored.
var finalScore = ParseOptionalDecimal(row, "期末成绩", 0, 100, errors);
if (errors.Count > 0 && errors[^1].Contains(row.RowNumber.ToString())) continue;
@@ -864,6 +918,7 @@ public sealed class GradesController(
if (errors.Count > 0)
return ImportValidationProblem(errors);
QueueCourseStatisticsRefresh(sheet.Id);
await db.SaveChangesAsync(cancellationToken);
return Ok(new { updated, total = rows.Count });
}
@@ -902,6 +957,7 @@ public sealed class GradesController(
.Select(x => new
{
x.Id,
GradeSheetId = x.GradeSheetId,
AcademicTermId = x.GradeSheet!.TeachingTask!.AcademicTermId,
TermName = x.GradeSheet.TeachingTask.AcademicTerm!.Name,
x.GradeSheet.TeachingTaskId,
@@ -918,6 +974,121 @@ public sealed class GradesController(
return Ok(new { Student = student, Records = records });
}
[HttpGet("sheets/{id:guid}/statistics")]
[Authorize(Roles = StatisticsUsers)]
public async Task<ActionResult> GetCourseStatistics(
Guid id,
CancellationToken cancellationToken)
{
var scope = currentUserDataScope.Current;
var sheet = await db.GradeSheets.AsNoTracking()
.Where(x => x.Id == id)
.Select(x => new
{
x.Id,
x.Status,
x.TeachingTask!.CourseId,
x.TeachingTask.AcademicTermId,
CourseName = x.TeachingTask.Course!.Name,
CourseCode = x.TeachingTask.Course.Code,
TermName = x.TeachingTask.AcademicTerm!.Name
})
.FirstOrDefaultAsync(cancellationToken);
if (sheet is null) return NotFound();
Guid? classId = null;
Guid? majorId = null;
Guid? collegeId = null;
if (scope.IsInRole(SystemRoles.Student))
{
if (sheet.Status != GradeSheetStatus.Published)
return NotFound();
var student = await db.GradeRecords.AsNoTracking()
.Where(x => x.GradeSheetId == id && x.Student!.UserId == scope.UserId)
.Select(x => new
{
x.Student!.AdministrativeClassId,
MajorId = x.Student.AdministrativeClass!.MajorId,
CollegeId = x.Student.AdministrativeClass.Major!.CollegeId
})
.FirstOrDefaultAsync(cancellationToken);
if (student is null) return Forbid();
classId = student.AdministrativeClassId;
majorId = student.MajorId;
collegeId = student.CollegeId;
}
else if (scope.Scope == DataScope.College)
{
collegeId = scope.RestrictedCollegeId;
if (collegeId == Guid.Empty || !scope.CanAccessCollege(
await db.Courses.Where(x => x.Id == sheet.CourseId)
.Select(x => x.CollegeId).FirstAsync(cancellationToken)))
return Forbid();
}
else if (scope.IsInRole(SystemRoles.Counselor))
{
var allowedClassIds = await db.AdministrativeClasses.AsNoTracking()
.Where(x => x.CounselorUserId == scope.UserId)
.Select(x => x.Id)
.ToListAsync(cancellationToken);
if (allowedClassIds.Count == 0) return Forbid();
// A counselor sees their classes plus the matching major/college
// benchmarks, never an unrelated class-level statistic.
classId = allowedClassIds.First();
majorId = await db.AdministrativeClasses.Where(x => x.Id == classId)
.Select(x => x.MajorId).FirstAsync(cancellationToken);
collegeId = await db.Majors.Where(x => x.Id == majorId)
.Select(x => x.CollegeId).FirstAsync(cancellationToken);
}
var cacheKey = AppCacheKeys.CourseGradeStatistics(id);
var statistics = await (cache ?? NoOpAppCache.Instance).GetOrCreateAsync(cacheKey, async token =>
{
var source = db.CourseGradeStatistics.AsNoTracking()
.Where(x => x.CourseId == sheet.CourseId &&
x.AcademicTermId == sheet.AcademicTermId);
return await source.Select(x => new
{
x.Scope, x.ScopeEntityId, x.StudentCount, x.PassedCount,
x.Below60Count, x.From60To69Count, x.From70To79Count,
x.From80To89Count, x.From90To100Count,
x.HighestScore, x.AverageScore, x.LowestScore, x.PassRate,
x.CalculatedAt
}).ToListAsync(token);
}, AppCacheProfile.Analytics,
[AppCacheTags.CourseGradeStatistics], cancellationToken);
object? Find(CourseGradeStatisticScope statisticScope, Guid? entityId)
{
var item = statistics.FirstOrDefault(x => x.Scope == statisticScope &&
x.ScopeEntityId == entityId);
return item is null ? null : new
{
item.Scope, item.ScopeEntityId, item.StudentCount, item.PassedCount,
item.HighestScore, item.AverageScore, item.LowestScore, item.PassRate,
item.CalculatedAt,
Distribution = new[]
{
new { Range = "059", Count = item.Below60Count },
new { Range = "6069", Count = item.From60To69Count },
new { Range = "7079", Count = item.From70To79Count },
new { Range = "8089", Count = item.From80To89Count },
new { Range = "90100", Count = item.From90To100Count }
}
};
}
return Ok(new
{
sheet.CourseName, sheet.CourseCode, sheet.TermName,
IsRefreshing = !statistics.Any(),
Class = classId.HasValue ? Find(CourseGradeStatisticScope.AdministrativeClass, classId) : null,
Major = majorId.HasValue ? Find(CourseGradeStatisticScope.Major, majorId) : null,
College = collegeId.HasValue ? Find(CourseGradeStatisticScope.College, collegeId) : null,
University = scope.Scope == DataScope.All || scope.IsInRole(SystemRoles.Student)
? Find(CourseGradeStatisticScope.University, null) : null
});
}
private IQueryable<TeachingTask> AccessibleTasks()
{
var source = db.TeachingTasks.AsQueryable();
@@ -990,6 +1161,7 @@ public sealed class GradesController(
{
try
{
QueueCourseStatisticsRefresh(id);
await db.SaveChangesAsync(cancellationToken);
return created ? Created(string.Empty, new { id }) : NoContent();
}
@@ -999,6 +1171,14 @@ public sealed class GradesController(
}
}
private void QueueCourseStatisticsRefresh(Guid gradeSheetId)
{
var job = new CourseGradeStatisticsRefreshJob { GradeSheetId = gradeSheetId };
db.CourseGradeStatisticsRefreshJobs.Add(job);
db.BackgroundJobOutboxMessages.Add(BackgroundJobOutboxMessage.Create(
BackgroundJobKind.CourseGradeStatisticsRefresh, job.Id));
}
private ActionResult ConflictProblem(string detail) =>
Conflict(new ProblemDetails
{
@@ -1041,6 +1221,18 @@ public sealed class GradesController(
private static string? Normalize(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static string ColumnLetter(int column)
{
var result = string.Empty;
while (column > 0)
{
column--;
result = (char)('A' + column % 26) + result;
column /= 26;
}
return result;
}
}
public sealed record GradeSheetRequest(
@@ -0,0 +1,238 @@
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Excel;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/other-exams")]
public sealed class OtherExamsController(AppDbContext db, ICurrentUserDataScope scope) : ControllerBase
{
private const string Managers = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;
[HttpGet("batches")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> GetBatches(CancellationToken ct) => Ok(await db.OtherExamBatches
.AsNoTracking().OrderByDescending(x => x.ExamDate).ThenByDescending(x => x.CreatedAt)
.Select(x => new { x.Id, x.ExamCode, x.Name, x.Organizer, x.ExamDate, x.MetricKind, x.MaxScore, x.LevelOptions, x.Status, x.PublicationCount, x.PublishedAt, ResultCount = x.Results.Count })
.ToListAsync(ct));
[HttpPost("batches")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> CreateBatch(CreateOtherExamRequest request, CancellationToken ct)
{
var code = Normalize(request.ExamCode)?.ToUpperInvariant();
var name = Normalize(request.Name);
if (code is null || name is null) return ValidationProblem("考试编码和考试名称不能为空。");
var error = ValidateDefinition(request.MetricKind, request.MaxScore, request.LevelOptions);
if (error is not null) return ValidationProblem(error);
var definitionConflict = await db.OtherExamBatches.AnyAsync(x =>
x.ExamCode == code && (x.MetricKind != request.MetricKind || x.MaxScore != request.MaxScore || x.LevelOptions != Normalize(request.LevelOptions)), ct);
if (definitionConflict) return ConflictProblem("同一考试编码已经使用了不同的评价方式或评价参数,请检查考试编码。");
var batch = new OtherExamBatch { ExamCode = code, Name = name, Organizer = Normalize(request.Organizer), ExamDate = request.ExamDate, MetricKind = request.MetricKind, MaxScore = request.MaxScore, LevelOptions = Normalize(request.LevelOptions) };
db.OtherExamBatches.Add(batch);
await db.SaveChangesAsync(ct);
return Ok(new { batch.Id });
}
[HttpGet("students/lookup")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> LookupStudent(string studentNumber, CancellationToken ct)
{
var number = Normalize(studentNumber);
if (number is null) return ValidationProblem("请输入学号。");
var student = await db.Students.AsNoTracking().Where(x => x.StudentNumber == number)
.Select(x => new { x.Id, x.StudentNumber, x.Name, CollegeName = x.AdministrativeClass!.Major!.College!.Name, ClassName = x.AdministrativeClass!.Name }).FirstOrDefaultAsync(ct);
return student is null ? NotFound(new ProblemDetails { Detail = "未找到该学号对应的学生档案。", Status = 404 }) : Ok(student);
}
[HttpGet("batches/{id:guid}")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> GetBatch(Guid id, CancellationToken ct)
{
var batch = await db.OtherExamBatches.AsNoTracking().Where(x => x.Id == id)
.Select(x => new { x.Id, x.ExamCode, x.Name, x.Organizer, x.ExamDate, x.MetricKind, x.MaxScore, x.LevelOptions, x.Status, x.PublicationCount, x.PublishedAt }).FirstOrDefaultAsync(ct);
if (batch is null) return NotFound();
var results = await db.OtherExamResults.AsNoTracking().Where(x => x.OtherExamBatchId == id)
.OrderBy(x => x.Student!.StudentNumber)
.Select(x => new { x.Id, x.StudentId, StudentNumber = x.Student!.StudentNumber, StudentName = x.Student.Name, CollegeName = x.Student.AdministrativeClass!.Major!.College!.Name, ClassName = x.Student.AdministrativeClass.Name, x.AttemptNumber, x.Score, x.Level, x.IsPassed, x.Notes }).ToListAsync(ct);
return Ok(new { Batch = batch, Results = results });
}
[HttpPut("batches/{id:guid}/results")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> ReplaceResults(Guid id, ReplaceOtherExamResultsRequest request, CancellationToken ct)
{
var batch = await db.OtherExamBatches.Include(x => x.Results).FirstOrDefaultAsync(x => x.Id == id, ct);
if (batch is null) return NotFound();
var result = await ReplaceResultsAsync(batch, request.Results, ct);
return result is null ? Ok(new { updated = batch.Results.Count }) : result;
}
[HttpGet("batches/{id:guid}/template")]
[Authorize(Roles = Managers)]
public async Task<IActionResult> DownloadTemplate(Guid id, CancellationToken ct)
{
var batch = await db.OtherExamBatches.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
if (batch is null) return NotFound();
var headers = HeadersFor(batch);
var bytes = ExcelWorkbookHelper.Create("其他考试成绩导入", headers, [], ["第一行为表头,请勿修改;每行填写一名学生。", "学号用于自动匹配姓名、学院和班级,参加次数由系统自动计算。"]);
return File(bytes, ExcelWorkbookHelper.ContentType, $"其他考试成绩导入模板-{batch.ExamCode ?? batch.Name}.xlsx");
}
[HttpPost("batches/{id:guid}/import")]
[Authorize(Roles = Managers)]
[RequestSizeLimit(10 * 1024 * 1024)]
public async Task<ActionResult> Import(Guid id, IFormFile file, CancellationToken ct)
{
var batch = await db.OtherExamBatches.Include(x => x.Results).FirstOrDefaultAsync(x => x.Id == id, ct);
if (batch is null) return NotFound();
IReadOnlyList<ExcelRow> rows;
try { rows = await ExcelWorkbookHelper.ReadAsync(file, HeadersFor(batch), ct); }
catch (InvalidDataException ex) { return ValidationProblem(ex.Message); }
if (rows.Count == 0) return ValidationProblem("Excel 中没有可导入的成绩数据。");
var inputs = new List<OtherExamResultRequest>();
var errors = new List<string>();
foreach (var row in rows)
{
var number = row["学号"].Trim();
if (number.Length == 0) { errors.Add($"第 {row.RowNumber} 行:学号不能为空。"); continue; }
var score = batch.MetricKind == OtherExamMetricKind.Score ? ParseScore(row, batch, errors) : null;
var level = batch.MetricKind == OtherExamMetricKind.Level ? Normalize(row["等级"]) : null;
var passed = batch.MetricKind == OtherExamMetricKind.PassFail ? ParsePass(row["是否合格"], row.RowNumber, errors) : null;
inputs.Add(new OtherExamResultRequest(number, score, level, passed, Normalize(row["备注"])));
}
if (errors.Count > 0) return ImportValidationProblem(errors);
var result = await ReplaceResultsAsync(batch, inputs, ct);
return result ?? Ok(new { updated = inputs.Count });
}
[HttpPost("batches/{id:guid}/publish")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> Publish(Guid id, CancellationToken ct)
{
var batch = await db.OtherExamBatches.Include(x => x.Results).FirstOrDefaultAsync(x => x.Id == id, ct);
if (batch is null) return NotFound();
if (batch.Results.Count == 0) return ConflictProblem("没有成绩记录,不能发布。");
batch.Status = OtherExamBatchStatus.Published;
batch.PublicationCount++;
batch.PublishedAt = DateTime.UtcNow;
await db.SaveChangesAsync(ct);
return Ok(new { batch.PublicationCount, batch.PublishedAt });
}
[HttpGet("mine")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> Mine(CancellationToken ct)
{
var studentId = await db.Students.Where(x => x.UserId == scope.Current.UserId).Select(x => (Guid?)x.Id).FirstOrDefaultAsync(ct);
if (studentId is null) return ConflictProblem("当前账号未关联有效学生档案。");
var history = await db.OtherExamResults.AsNoTracking().Where(x => x.StudentId == studentId && x.OtherExamBatch!.Status == OtherExamBatchStatus.Published)
.OrderByDescending(x => x.OtherExamBatch!.ExamDate).ThenByDescending(x => x.AttemptNumber)
.Select(x => new { x.Id, ExamCode = x.OtherExamBatch!.ExamCode ?? x.OtherExamBatch.Name, BatchId = x.OtherExamBatchId, ExamName = x.OtherExamBatch.Name, x.OtherExamBatch.ExamDate, x.OtherExamBatch.MetricKind, x.OtherExamBatch.MaxScore, x.OtherExamBatch.LevelOptions, x.AttemptNumber, x.Score, x.Level, x.IsPassed, x.OtherExamBatch.PublishedAt }).ToListAsync(ct);
var best = history.GroupBy(x => x.ExamCode).Select(g => g.OrderByDescending(x => Rank(x.MetricKind, x.Score, x.Level, x.IsPassed, x.LevelOptions)).ThenByDescending(x => x.ExamDate).First()).ToList();
return Ok(new { Best = best, History = history });
}
private async Task<ActionResult?> ReplaceResultsAsync(OtherExamBatch batch, IReadOnlyList<OtherExamResultRequest> inputs, CancellationToken ct)
{
var numbers = inputs.Select(x => x.StudentNumber.Trim()).ToList();
if (numbers.Count != numbers.Distinct(StringComparer.OrdinalIgnoreCase).Count()) return ValidationProblem("同一考试批次中学生不能重复出现。");
var studentRows = await db.Students
.Where(x => numbers.Contains(x.StudentNumber))
.ToListAsync(ct);
var students = studentRows.ToDictionary(x => x.StudentNumber, StringComparer.OrdinalIgnoreCase);
if (students.Count != numbers.Count) return ValidationProblem("存在不存在的学号,请先检查学生档案。");
foreach (var item in inputs)
{
var error = ValidateResult(batch, item.Score, item.Level, item.IsPassed);
if (error is not null) return ValidationProblem(error);
}
var studentIds = students.Values.Select(x => x.Id).ToList();
var beforeCount = await db.OtherExamResults.AsNoTracking()
.Where(x => x.OtherExamBatchId != batch.Id && studentIds.Contains(x.StudentId) && (x.OtherExamBatch!.ExamCode == batch.ExamCode || (x.OtherExamBatch.ExamCode == null && batch.ExamCode == null && x.OtherExamBatch.Name == batch.Name)) && (x.OtherExamBatch.ExamDate < batch.ExamDate || (x.OtherExamBatch.ExamDate == batch.ExamDate && x.OtherExamBatch.CreatedAt < batch.CreatedAt)))
.GroupBy(x => x.StudentId).Select(x => new { StudentId = x.Key, Count = x.Count() }).ToDictionaryAsync(x => x.StudentId, x => x.Count, ct);
return await db.ExecuteInRetriableTransactionAsync<ActionResult?>(async transaction =>
{
db.OtherExamResults.RemoveRange(batch.Results);
batch.Status = OtherExamBatchStatus.Draft;
await db.SaveChangesAsync(ct);
batch.Results = inputs.Select(x =>
{
var student = students[x.StudentNumber.Trim()];
return new OtherExamResult
{
OtherExamBatchId = batch.Id,
StudentId = student.Id,
AttemptNumber = beforeCount.GetValueOrDefault(student.Id) + 1,
Score = x.Score,
Level = Normalize(x.Level),
IsPassed = x.IsPassed,
Notes = Normalize(x.Notes)
};
}).ToList();
await db.SaveChangesAsync(ct);
await transaction.CommitAsync(ct);
return null;
}, ct);
}
private static string[] HeadersFor(OtherExamBatch batch) => batch.MetricKind switch
{
OtherExamMetricKind.Score => ["学号", "成绩", "备注"],
OtherExamMetricKind.Level => ["学号", "等级", "备注"],
_ => ["学号", "是否合格", "备注"]
};
private static decimal? ParseScore(ExcelRow row, OtherExamBatch batch, List<string> errors)
{
if (decimal.TryParse(row["成绩"], NumberStyles.Number, CultureInfo.InvariantCulture, out var value) && value >= 0 && value <= batch.MaxScore) return value;
errors.Add($"第 {row.RowNumber} 行:成绩必须在 0 到 {batch.MaxScore:0.##} 之间。"); return null;
}
private static bool? ParsePass(string value, int row, List<string> errors)
{
if (value is "合格" or "是" or "通过" or "true" or "True") return true;
if (value is "不合格" or "否" or "未通过" or "false" or "False") return false;
errors.Add($"第 {row} 行:是否合格请填写合格或不合格。"); return null;
}
private static string? ValidateDefinition(OtherExamMetricKind kind, decimal? max, string? levels) => kind switch
{
OtherExamMetricKind.Score when !max.HasValue || max <= 0 => "分数制必须填写大于 0 的满分。",
OtherExamMetricKind.Level when string.IsNullOrWhiteSpace(levels) => "等级制必须填写等级选项。",
_ => null
};
private static string? ValidateResult(OtherExamBatch b, decimal? score, string? level, bool? pass) => b.MetricKind switch
{
OtherExamMetricKind.Score when !score.HasValue || score < 0 || score > b.MaxScore => "分数必须在 0 到满分之间。",
OtherExamMetricKind.Level when string.IsNullOrWhiteSpace(level) => "等级制必须填写等级。",
OtherExamMetricKind.PassFail when !pass.HasValue => "合格/不合格考试必须填写结果。",
_ => null
};
private static int Rank(OtherExamMetricKind kind, decimal? score, string? level, bool? pass, string? options)
{
if (kind == OtherExamMetricKind.Score) return (int)((score ?? -1) * 1000);
if (kind == OtherExamMetricKind.PassFail) return pass == true ? 1 : 0;
var levels = (options ?? "").Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
var index = Array.IndexOf(levels, level ?? "");
return index >= 0 ? levels.Length - index : -1;
}
private ActionResult ImportValidationProblem(IReadOnlyList<string> errors)
{
foreach (var error in errors.Take(50)) ModelState.AddModelError("file", error);
return ValidationProblem(ModelState);
}
private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static ConflictObjectResult ConflictProblem(string message) => new(new ProblemDetails { Status = 409, Detail = message });
}
public sealed record CreateOtherExamRequest([Required] string ExamCode, [Required] string Name, DateOnly ExamDate, OtherExamMetricKind MetricKind, decimal? MaxScore, string? LevelOptions, string? Organizer);
public sealed record ReplaceOtherExamResultsRequest(List<OtherExamResultRequest> Results);
public sealed record OtherExamResultRequest([Required] string StudentNumber, decimal? Score, string? Level, bool? IsPassed, string? Notes);
@@ -224,6 +224,12 @@ public sealed class PersonnelController(
[FromQuery] PersonnelQuery query,
CancellationToken cancellationToken)
{
var roles = currentUserDataScope.Current.Roles;
var canReadFullProfile = roles.Any(role =>
role is SystemRoles.SuperAdmin or
SystemRoles.AcademicAdmin or
SystemRoles.CollegeAdmin or
SystemRoles.Counselor);
var page = NormalizePage(query.Page);
var pageSize = NormalizePageSize(query.PageSize);
var source = ApplyStudentScope(db.Students.AsNoTracking());
@@ -268,9 +274,26 @@ public sealed class PersonnelController(
x.EnrollmentDate,
x.Status,
x.DateOfBirth,
EnglishName = canReadFullProfile ? x.EnglishName : null,
IdCardNumber = canReadFullProfile ? x.IdCardNumber : null,
Nationality = canReadFullProfile ? x.Nationality : null,
Ethnicity = canReadFullProfile ? x.Ethnicity : null,
PoliticalStatus = canReadFullProfile ? x.PoliticalStatus : null,
NativePlace = canReadFullProfile ? x.NativePlace : null,
HouseholdAddress = canReadFullProfile ? x.HouseholdAddress : null,
CurrentAddress = canReadFullProfile ? x.CurrentAddress : null,
PostalCode = canReadFullProfile ? x.PostalCode : null,
x.Phone,
x.Email,
x.Notes,
Qq = canReadFullProfile ? x.Qq : null,
x.WeChat,
x.EmergencyContactName,
x.EmergencyContactRelationship,
x.EmergencyContactPhone,
x.SpecialTags,
x.SpecialNeeds,
Biography = canReadFullProfile ? x.Biography : null,
Notes = canReadFullProfile ? x.Notes : null,
x.UserId,
x.CreatedAt
})
@@ -304,8 +327,26 @@ public sealed class PersonnelController(
EnrollmentDate = request.EnrollmentDate,
Status = request.Status,
DateOfBirth = request.DateOfBirth,
EnglishName = Normalize(request.EnglishName),
IdCardNumber = Normalize(request.IdCardNumber),
Nationality = Normalize(request.Nationality),
Ethnicity = Normalize(request.Ethnicity),
PoliticalStatus = Normalize(request.PoliticalStatus),
NativePlace = Normalize(request.NativePlace),
HouseholdAddress = Normalize(request.HouseholdAddress),
CurrentAddress = Normalize(request.CurrentAddress),
PostalCode = Normalize(request.PostalCode),
Phone = Normalize(request.Phone),
Email = Normalize(request.Email),
Qq = Normalize(request.Qq),
WeChat = Normalize(request.WeChat),
EmergencyContactName = Normalize(request.EmergencyContactName),
EmergencyContactRelationship = Normalize(
request.EmergencyContactRelationship),
EmergencyContactPhone = Normalize(request.EmergencyContactPhone),
SpecialTags = Normalize(request.SpecialTags),
SpecialNeeds = Normalize(request.SpecialNeeds),
Biography = Normalize(request.Biography),
Notes = Normalize(request.Notes)
};
db.Students.Add(entity);
@@ -343,8 +384,26 @@ public sealed class PersonnelController(
entity.EnrollmentDate = request.EnrollmentDate;
entity.Status = request.Status;
entity.DateOfBirth = request.DateOfBirth;
entity.EnglishName = Normalize(request.EnglishName);
entity.IdCardNumber = Normalize(request.IdCardNumber);
entity.Nationality = Normalize(request.Nationality);
entity.Ethnicity = Normalize(request.Ethnicity);
entity.PoliticalStatus = Normalize(request.PoliticalStatus);
entity.NativePlace = Normalize(request.NativePlace);
entity.HouseholdAddress = Normalize(request.HouseholdAddress);
entity.CurrentAddress = Normalize(request.CurrentAddress);
entity.PostalCode = Normalize(request.PostalCode);
entity.Phone = Normalize(request.Phone);
entity.Email = Normalize(request.Email);
entity.Qq = Normalize(request.Qq);
entity.WeChat = Normalize(request.WeChat);
entity.EmergencyContactName = Normalize(request.EmergencyContactName);
entity.EmergencyContactRelationship = Normalize(
request.EmergencyContactRelationship);
entity.EmergencyContactPhone = Normalize(request.EmergencyContactPhone);
entity.SpecialTags = Normalize(request.SpecialTags);
entity.SpecialNeeds = Normalize(request.SpecialNeeds);
entity.Biography = Normalize(request.Biography);
entity.Notes = Normalize(request.Notes);
return await SaveNoContentAsync(cancellationToken);
}
@@ -522,8 +581,25 @@ public sealed record StudentRequest(
DateOnly EnrollmentDate,
StudentStatus Status,
DateOnly? DateOfBirth,
[MaxLength(100)] string? EnglishName,
[MaxLength(30)] string? IdCardNumber,
[MaxLength(50)] string? Nationality,
[MaxLength(50)] string? Ethnicity,
[MaxLength(50)] string? PoliticalStatus,
[MaxLength(100)] string? NativePlace,
[MaxLength(300)] string? HouseholdAddress,
[MaxLength(300)] string? CurrentAddress,
[MaxLength(20)] string? PostalCode,
[MaxLength(30)] string? Phone,
[EmailAddress, MaxLength(100)] string? Email,
[MaxLength(30)] string? Qq,
[MaxLength(60)] string? WeChat,
[MaxLength(50)] string? EmergencyContactName,
[MaxLength(30)] string? EmergencyContactRelationship,
[MaxLength(30)] string? EmergencyContactPhone,
[MaxLength(300)] string? SpecialTags,
[MaxLength(1000)] string? SpecialNeeds,
[MaxLength(1000)] string? Biography,
[MaxLength(500)] string? Notes);
public sealed record TeacherAccountActivationRequest(
@@ -31,6 +31,9 @@ public sealed class PersonnelExcelController(
SystemRoles.AcademicAdmin + "," +
SystemRoles.CollegeAdmin;
private const string ExportRoles =
WriteRoles + "," + SystemRoles.Counselor;
private static readonly string[] TeacherHeaders =
[
"工号", "姓名", "性别", "学院编码", "职称", "任职状态",
@@ -40,7 +43,10 @@ public sealed class PersonnelExcelController(
private static readonly string[] StudentHeaders =
[
"学号", "姓名", "性别", "行政班编码", "入学年级", "入学日期",
"学籍状态", "出生日期", "联系电话", "电子邮箱", "备注"
"学籍状态", "出生日期", "英文姓名", "证件号码", "国籍", "民族",
"政治面貌", "籍贯", "户籍地址", "现居住地址", "邮政编码",
"联系电话", "电子邮箱", "QQ", "微信", "紧急联系人", "与本人关系",
"紧急联系电话", "特殊标记", "特殊情况说明", "个人简介", "备注"
];
[HttpGet("{kind}/template")]
@@ -66,6 +72,7 @@ public sealed class PersonnelExcelController(
}
[HttpGet("{kind}/export")]
[Authorize(Roles = ExportRoles)]
public async Task<IActionResult> Export(
string kind,
[FromQuery] PersonnelQuery query,
@@ -96,7 +103,12 @@ public sealed class PersonnelExcelController(
.Select(x => Row(
x.StudentNumber, x.Name, GenderName(x.Gender),
x.AdministrativeClass!.Code, x.EnrollmentYear, x.EnrollmentDate,
StudentStatusName(x.Status), x.DateOfBirth, x.Phone, x.Email, x.Notes))
StudentStatusName(x.Status), x.DateOfBirth, x.EnglishName,
x.IdCardNumber, x.Nationality, x.Ethnicity, x.PoliticalStatus,
x.NativePlace, x.HouseholdAddress, x.CurrentAddress, x.PostalCode,
x.Phone, x.Email, x.Qq, x.WeChat, x.EmergencyContactName,
x.EmergencyContactRelationship, x.EmergencyContactPhone,
x.SpecialTags, x.SpecialNeeds, x.Biography, x.Notes))
.ToList();
}
@@ -296,8 +308,25 @@ public sealed class PersonnelExcelController(
entity.EnrollmentDate = enrollmentDate.Value;
entity.Status = status.Value;
entity.DateOfBirth = dateOfBirth;
entity.EnglishName = Optional(row, "英文姓名");
entity.IdCardNumber = Optional(row, "证件号码");
entity.Nationality = Optional(row, "国籍");
entity.Ethnicity = Optional(row, "民族");
entity.PoliticalStatus = Optional(row, "政治面貌");
entity.NativePlace = Optional(row, "籍贯");
entity.HouseholdAddress = Optional(row, "户籍地址");
entity.CurrentAddress = Optional(row, "现居住地址");
entity.PostalCode = Optional(row, "邮政编码");
entity.Phone = Optional(row, "联系电话");
entity.Email = Optional(row, "电子邮箱");
entity.Qq = Optional(row, "QQ");
entity.WeChat = Optional(row, "微信");
entity.EmergencyContactName = Optional(row, "紧急联系人");
entity.EmergencyContactRelationship = Optional(row, "与本人关系");
entity.EmergencyContactPhone = Optional(row, "紧急联系电话");
entity.SpecialTags = Optional(row, "特殊标记");
entity.SpecialNeeds = Optional(row, "特殊情况说明");
entity.Biography = Optional(row, "个人简介");
entity.Notes = Optional(row, "备注");
}
return new(created, updated, rows.Count);
+550
View File
@@ -0,0 +1,550 @@
using System.ComponentModel.DataAnnotations;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text.Json;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Route("api/auth/sso")]
public sealed class SsoController(
UserManager<ApplicationUser> userManager,
IAuthSessionService authSessionService,
IDistributedCache cache,
IOptions<SsoOptions> options,
ILogger<SsoController> logger) : ControllerBase
{
private const string BindingIntentProperty = "sso-binding-intent";
private readonly SsoOptions _options = options.Value;
[AllowAnonymous]
[HttpGet("settings")]
public ActionResult<SsoSettingsResponse> Settings() =>
new SsoSettingsResponse(
_options.Enabled,
_options.DisplayName,
EffectiveCallbackUrl());
[AllowAnonymous]
[EnableRateLimiting("public-auth")]
[HttpGet("login")]
public async Task<IActionResult> Login(
[FromQuery] string? returnUrl = null,
[FromQuery] string? bindingIntent = null,
CancellationToken cancellationToken = default)
{
if (!_options.Enabled)
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
var safeReturnUrl = NormalizeReturnUrl(returnUrl);
var properties = new AuthenticationProperties();
if (!string.IsNullOrWhiteSpace(bindingIntent))
{
var targetUserId = await cache.GetStringAsync(
BindingIntentCacheKey(bindingIntent),
cancellationToken);
if (targetUserId is null)
return RedirectToFrontendError("binding_intent_expired", "/account");
properties.Items[BindingIntentProperty] = bindingIntent;
}
var completeUrl = Url.Action(
nameof(Complete),
values: new { returnUrl = safeReturnUrl })!;
properties.RedirectUri = completeUrl;
try
{
await HttpContext.ChallengeAsync(SsoAuthSchemes.Keycloak, properties);
return new EmptyResult();
}
catch (OpenIdConnectProtocolException exception)
{
logger.LogWarning(
exception,
"Keycloak 拒绝了 OIDC 授权请求。当前回调地址为 {CallbackUrl}",
EffectiveCallbackUrl());
return RedirectToFrontendError(
"configuration_error",
string.IsNullOrWhiteSpace(bindingIntent) ? "/login" : "/account");
}
}
[AllowAnonymous]
[ApiExplorerSettings(IgnoreApi = true)]
[HttpGet("complete")]
public async Task<ActionResult> Complete(
[FromQuery] string? returnUrl,
CancellationToken cancellationToken)
{
if (!_options.Enabled)
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
var authentication = await HttpContext.AuthenticateAsync(
SsoAuthSchemes.ExternalCookie);
if (!authentication.Succeeded || authentication.Principal is null)
return RedirectToFrontendError("authentication_failed");
var principal = authentication.Principal;
var subject = principal.FindFirstValue("sub") ??
principal.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrWhiteSpace(subject))
return RedirectToFrontendError("missing_subject");
ApplicationUser? user = null;
var bindingIntent =
authentication.Properties is { } authenticationProperties &&
authenticationProperties.Items.TryGetValue(
BindingIntentProperty,
out var storedBindingIntent)
? storedBindingIntent
: null;
if (!string.IsNullOrWhiteSpace(bindingIntent))
{
var targetUserId = await cache.GetStringAsync(
BindingIntentCacheKey(bindingIntent),
cancellationToken);
user = targetUserId is null
? null
: await userManager.FindByIdAsync(targetUserId);
if (user is null)
return RedirectToFrontendError("binding_intent_expired", "/account");
if (!user.IsEnabled || await userManager.IsLockedOutAsync(user))
return RedirectToFrontendError("account_disabled", "/account");
var linkError = await LinkSsoIdentityAsync(user, subject);
if (linkError is not null)
return RedirectToFrontendError(linkError, "/account");
await cache.RemoveAsync(
BindingIntentCacheKey(bindingIntent),
cancellationToken);
}
user ??= await userManager.FindByLoginAsync(
SsoAuthSchemes.LoginProvider,
subject);
if (user is null && _options.LinkExistingUsersByUserName)
{
var userName = principal.FindFirstValue(_options.UserNameClaim)?.Trim();
if (!string.IsNullOrWhiteSpace(userName))
{
user = await userManager.FindByNameAsync(userName);
if (user is not null)
{
var linkError = await LinkSsoIdentityAsync(user, subject);
if (linkError is not null)
return RedirectToFrontendError(linkError);
}
}
}
if (user is null)
{
var bindingCode = WebEncoders.Base64UrlEncode(
RandomNumberGenerator.GetBytes(32));
var externalUserName =
principal.FindFirstValue(_options.UserNameClaim)?.Trim() ??
principal.FindFirstValue("name")?.Trim() ??
subject;
await cache.SetStringAsync(
BindingCacheKey(bindingCode),
JsonSerializer.Serialize(new SsoBindingTicket(subject, externalUserName)),
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
},
cancellationToken);
await HttpContext.SignOutAsync(SsoAuthSchemes.ExternalCookie);
var bindingPage = BuildFrontendUrl("/sso/bind") +
$"?code={Uri.EscapeDataString(bindingCode)}" +
$"&redirect={Uri.EscapeDataString(NormalizeReturnUrl(returnUrl))}";
return Redirect(bindingPage);
}
if (!user.IsEnabled || await userManager.IsLockedOutAsync(user))
return RedirectToFrontendError("account_disabled");
user.LastLoginAt = DateTime.UtcNow;
var updateResult = await userManager.UpdateAsync(user);
if (!updateResult.Succeeded)
return RedirectToFrontendError("account_update_failed");
var exchangeCode = WebEncoders.Base64UrlEncode(
RandomNumberGenerator.GetBytes(32));
await cache.SetStringAsync(
ExchangeCacheKey(exchangeCode),
user.Id.ToString("D"),
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(2)
},
cancellationToken);
await HttpContext.SignOutAsync(SsoAuthSchemes.ExternalCookie);
var callback = BuildFrontendUrl("/sso/callback") +
$"?code={Uri.EscapeDataString(exchangeCode)}" +
$"&redirect={Uri.EscapeDataString(NormalizeReturnUrl(returnUrl))}";
return Redirect(callback);
}
[AllowAnonymous]
[EnableRateLimiting("public-auth")]
[HttpPost("exchange")]
public async Task<ActionResult<LoginResponse>> Exchange(
SsoExchangeRequest request,
CancellationToken cancellationToken)
{
if (!_options.Enabled)
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
var cacheKey = ExchangeCacheKey(request.Code);
var userId = await cache.GetStringAsync(cacheKey, cancellationToken);
if (userId is null)
return SsoProblem(
"统一身份认证结果已失效,请重新登录。",
StatusCodes.Status401Unauthorized);
await cache.RemoveAsync(cacheKey, cancellationToken);
var user = await userManager.FindByIdAsync(userId);
if (user is null || !user.IsEnabled || await userManager.IsLockedOutAsync(user))
return SsoProblem(
"本地账号不存在、已停用或已锁定。",
StatusCodes.Status401Unauthorized);
var roles = await userManager.GetRolesAsync(user);
var session = await authSessionService.CreateAsync(
user,
roles,
request.IsNativeApp
? AuthenticationClientType.App
: AuthenticationClientType.Web,
cancellationToken);
return AuthController.CreateLoginResponse(session);
}
[AllowAnonymous]
[EnableRateLimiting("public-auth")]
[HttpGet("binding")]
public async Task<ActionResult<SsoBindingInfoResponse>> BindingInfo(
[FromQuery, Required, MinLength(20), MaxLength(200)] string code,
CancellationToken cancellationToken)
{
if (!_options.Enabled)
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
var ticket = await ReadBindingTicketAsync(code, cancellationToken);
if (ticket is null)
return SsoProblem(
"账户绑定请求已失效,请重新使用统一身份认证登录。",
StatusCodes.Status401Unauthorized);
return new SsoBindingInfoResponse(_options.DisplayName, ticket.ExternalUserName);
}
[AllowAnonymous]
[EnableRateLimiting("public-auth")]
[HttpPost("bind")]
public async Task<ActionResult<LoginResponse>> Bind(
SsoBindRequest request,
CancellationToken cancellationToken)
{
if (!_options.Enabled)
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
var ticket = await ReadBindingTicketAsync(request.Code, cancellationToken);
if (ticket is null)
return SsoProblem(
"账户绑定请求已失效,请重新使用统一身份认证登录。",
StatusCodes.Status401Unauthorized);
var user = await userManager.FindByNameAsync(request.UserName.Trim());
if (user is null || !user.IsEnabled || await userManager.IsLockedOutAsync(user))
return InvalidLocalCredentials();
if (!await userManager.CheckPasswordAsync(user, request.Password))
{
await userManager.AccessFailedAsync(user);
return InvalidLocalCredentials();
}
var subjectOwner = await userManager.FindByLoginAsync(
SsoAuthSchemes.LoginProvider,
ticket.Subject);
if (subjectOwner is not null && subjectOwner.Id != user.Id)
return BindingConflict("该统一身份账号已绑定其他教务系统账号。");
var keycloakLogins = (await userManager.GetLoginsAsync(user))
.Where(x => x.LoginProvider == SsoAuthSchemes.LoginProvider)
.ToList();
if (keycloakLogins.Any(x => x.ProviderKey != ticket.Subject))
return BindingConflict("该教务系统账号已绑定其他统一身份账号。");
if (subjectOwner is null)
{
var linkResult = await userManager.AddLoginAsync(
user,
new UserLoginInfo(
SsoAuthSchemes.LoginProvider,
ticket.Subject,
_options.DisplayName));
if (!linkResult.Succeeded)
{
subjectOwner = await userManager.FindByLoginAsync(
SsoAuthSchemes.LoginProvider,
ticket.Subject);
if (subjectOwner?.Id != user.Id)
return BindingConflict("账户绑定失败,请重新发起统一身份认证。");
}
}
await userManager.ResetAccessFailedCountAsync(user);
user.LastLoginAt = DateTime.UtcNow;
var updateResult = await userManager.UpdateAsync(user);
if (!updateResult.Succeeded)
return SsoProblem("本地账号状态更新失败,请稍后重试。", StatusCodes.Status500InternalServerError);
await cache.RemoveAsync(BindingCacheKey(request.Code), cancellationToken);
var roles = await userManager.GetRolesAsync(user);
var session = await authSessionService.CreateAsync(
user,
roles,
request.IsNativeApp
? AuthenticationClientType.App
: AuthenticationClientType.Web,
cancellationToken);
return AuthController.CreateLoginResponse(session);
}
[Authorize]
[HttpGet("account")]
public async Task<ActionResult<SsoAccountResponse>> Account()
{
var user = await CurrentUserAsync();
if (user is null)
return Unauthorized();
var login = (await userManager.GetLoginsAsync(user))
.SingleOrDefault(x => x.LoginProvider == SsoAuthSchemes.LoginProvider);
return new SsoAccountResponse(
_options.Enabled,
_options.DisplayName,
login is not null,
EffectiveCallbackUrl());
}
[Authorize]
[HttpPost("prepare-binding")]
public async Task<ActionResult<SsoBindingStartResponse>> PrepareBinding(
CancellationToken cancellationToken)
{
if (!_options.Enabled)
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
var user = await CurrentUserAsync();
if (user is null || !user.IsEnabled || await userManager.IsLockedOutAsync(user))
return Unauthorized();
if ((await userManager.GetLoginsAsync(user))
.Any(x => x.LoginProvider == SsoAuthSchemes.LoginProvider))
{
return BindingConflict("当前账号已绑定统一身份账号,请先解绑后再更换绑定。");
}
var intentCode = WebEncoders.Base64UrlEncode(
RandomNumberGenerator.GetBytes(32));
await cache.SetStringAsync(
BindingIntentCacheKey(intentCode),
user.Id.ToString("D"),
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
},
cancellationToken);
var loginUrl = Url.Action(
nameof(Login),
values: new
{
returnUrl = "/account",
bindingIntent = intentCode
})!;
return new SsoBindingStartResponse(loginUrl);
}
[Authorize]
[EnableRateLimiting("public-auth")]
[HttpPost("unbind")]
public async Task<IActionResult> Unbind(SsoUnbindRequest request)
{
var user = await CurrentUserAsync();
if (user is null || !user.IsEnabled || await userManager.IsLockedOutAsync(user))
return Unauthorized();
if (!await userManager.HasPasswordAsync(user))
return BindingConflict("当前账号没有本地密码,不能自行解绑,请联系管理员处理。");
if (!await userManager.CheckPasswordAsync(user, request.Password))
{
await userManager.AccessFailedAsync(user);
return InvalidLocalCredentials();
}
var login = (await userManager.GetLoginsAsync(user))
.SingleOrDefault(x => x.LoginProvider == SsoAuthSchemes.LoginProvider);
if (login is null)
return NoContent();
var result = await userManager.RemoveLoginAsync(
user,
login.LoginProvider,
login.ProviderKey);
if (!result.Succeeded)
return SsoProblem("解除统一身份绑定失败,请稍后重试。", StatusCodes.Status500InternalServerError);
await userManager.ResetAccessFailedCountAsync(user);
return NoContent();
}
internal static string NormalizeReturnUrl(string? returnUrl) =>
!string.IsNullOrWhiteSpace(returnUrl) &&
returnUrl.StartsWith('/') &&
!returnUrl.StartsWith("//", StringComparison.Ordinal)
? returnUrl
: "/dashboard";
private string BuildFrontendUrl(string path) =>
string.IsNullOrWhiteSpace(_options.FrontendBaseUrl)
? path
: _options.FrontendBaseUrl.TrimEnd('/') + path;
private RedirectResult RedirectToFrontendError(
string error,
string path = "/login") =>
Redirect(BuildFrontendUrl(path) +
$"?ssoError={Uri.EscapeDataString(error)}");
private static string ExchangeCacheKey(string code) => $"sso:exchange:{code}";
private static string BindingCacheKey(string code) => $"sso:binding:{code}";
private static string BindingIntentCacheKey(string code) =>
$"sso:binding-intent:{code}";
private async Task<ApplicationUser?> CurrentUserAsync()
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
return userId is null ? null : await userManager.FindByIdAsync(userId);
}
private async Task<string?> LinkSsoIdentityAsync(
ApplicationUser user,
string subject)
{
var subjectOwner = await userManager.FindByLoginAsync(
SsoAuthSchemes.LoginProvider,
subject);
if (subjectOwner is not null)
return subjectOwner.Id == user.Id ? null : "identity_already_bound";
if ((await userManager.GetLoginsAsync(user)).Any(x =>
x.LoginProvider == SsoAuthSchemes.LoginProvider &&
x.ProviderKey != subject))
{
return "account_already_bound";
}
var result = await userManager.AddLoginAsync(
user,
new UserLoginInfo(
SsoAuthSchemes.LoginProvider,
subject,
_options.DisplayName));
if (result.Succeeded)
return null;
subjectOwner = await userManager.FindByLoginAsync(
SsoAuthSchemes.LoginProvider,
subject);
return subjectOwner?.Id == user.Id ? null : "account_link_failed";
}
private string EffectiveCallbackUrl()
{
if (!string.IsNullOrWhiteSpace(_options.CallbackUrl))
return _options.CallbackUrl;
return $"{Request.Scheme}://{Request.Host}{Request.PathBase}/signin-keycloak";
}
private async Task<SsoBindingTicket?> ReadBindingTicketAsync(
string code,
CancellationToken cancellationToken)
{
var json = await cache.GetStringAsync(BindingCacheKey(code), cancellationToken);
if (json is null)
return null;
try
{
return JsonSerializer.Deserialize<SsoBindingTicket>(json);
}
catch (JsonException)
{
return null;
}
}
private UnauthorizedObjectResult InvalidLocalCredentials() =>
Unauthorized(new ProblemDetails
{
Title = "账户绑定失败",
Detail = "教务系统账号或密码不正确,或账号已停用。",
Status = StatusCodes.Status401Unauthorized
});
private ObjectResult BindingConflict(string detail) =>
SsoProblem(detail, StatusCodes.Status409Conflict);
private ObjectResult SsoProblem(string detail, int status) =>
StatusCode(status, new ProblemDetails
{
Title = "统一身份认证失败",
Detail = detail,
Status = status
});
}
public sealed record SsoSettingsResponse(
bool Enabled,
string DisplayName,
string CallbackUrl);
public sealed record SsoExchangeRequest(
[Required, MinLength(20), MaxLength(200)] string Code,
bool IsNativeApp = false);
public sealed record SsoBindingInfoResponse(
string ProviderDisplayName,
string ExternalUserName);
public sealed record SsoBindRequest(
[Required, MinLength(20), MaxLength(200)] string Code,
[Required, MaxLength(100)] string UserName,
[Required, MaxLength(100)] string Password,
bool IsNativeApp = false);
internal sealed record SsoBindingTicket(string Subject, string ExternalUserName);
public sealed record SsoAccountResponse(
bool Enabled,
string ProviderDisplayName,
bool IsBound,
string CallbackUrl);
public sealed record SsoBindingStartResponse(string LoginUrl);
public sealed record SsoUnbindRequest(
[Required, MaxLength(100)] string Password);
@@ -0,0 +1,158 @@
using System.ComponentModel.DataAnnotations;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize(Roles = SystemRoles.Student)]
[Route("api/student/profile")]
public sealed class StudentProfileController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope,
IAppCache cache) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<StudentProfileDto>> Get(
CancellationToken cancellationToken)
{
var userId = currentUserDataScope.Current.UserId;
var profile = await db.Students.AsNoTracking()
.Where(x => x.UserId == userId)
.Select(x => new StudentProfileDto(
x.StudentNumber,
x.Name,
x.AdministrativeClass!.Name,
x.AdministrativeClass.Major!.Name,
x.AdministrativeClass.Major.College!.Name,
x.EnrollmentYear,
x.EnrollmentDate,
x.Status,
x.Gender,
x.DateOfBirth,
x.EnglishName,
x.IdCardNumber,
x.Nationality,
x.Ethnicity,
x.PoliticalStatus,
x.NativePlace,
x.HouseholdAddress,
x.CurrentAddress,
x.PostalCode,
x.Phone,
x.Email,
x.Qq,
x.WeChat,
x.EmergencyContactName,
x.EmergencyContactRelationship,
x.EmergencyContactPhone,
x.SpecialTags,
x.SpecialNeeds,
x.Biography))
.SingleOrDefaultAsync(cancellationToken);
return profile is null ? NotFound() : Ok(profile);
}
[HttpPut]
public async Task<IActionResult> Update(
StudentProfileUpdateRequest request,
CancellationToken cancellationToken)
{
var userId = currentUserDataScope.Current.UserId;
var student = await db.Students.SingleOrDefaultAsync(
x => x.UserId == userId,
cancellationToken);
if (student is null) return NotFound();
student.Gender = request.Gender;
student.DateOfBirth = request.DateOfBirth;
student.EnglishName = Normalize(request.EnglishName);
student.IdCardNumber = Normalize(request.IdCardNumber);
student.Nationality = Normalize(request.Nationality);
student.Ethnicity = Normalize(request.Ethnicity);
student.PoliticalStatus = Normalize(request.PoliticalStatus);
student.NativePlace = Normalize(request.NativePlace);
student.HouseholdAddress = Normalize(request.HouseholdAddress);
student.CurrentAddress = Normalize(request.CurrentAddress);
student.PostalCode = Normalize(request.PostalCode);
student.Phone = Normalize(request.Phone);
student.Email = Normalize(request.Email);
student.Qq = Normalize(request.Qq);
student.WeChat = Normalize(request.WeChat);
student.EmergencyContactName = Normalize(request.EmergencyContactName);
student.EmergencyContactRelationship = Normalize(
request.EmergencyContactRelationship);
student.EmergencyContactPhone = Normalize(request.EmergencyContactPhone);
student.SpecialTags = Normalize(request.SpecialTags);
student.SpecialNeeds = Normalize(request.SpecialNeeds);
student.Biography = Normalize(request.Biography);
await db.SaveChangesAsync(cancellationToken);
await cache.RemoveByTagAsync(AppCacheTags.Analytics, cancellationToken);
return NoContent();
}
private static string? Normalize(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
public sealed record StudentProfileDto(
string StudentNumber,
string Name,
string ClassName,
string MajorName,
string CollegeName,
int EnrollmentYear,
DateOnly EnrollmentDate,
StudentStatus Status,
Gender Gender,
DateOnly? DateOfBirth,
string? EnglishName,
string? IdCardNumber,
string? Nationality,
string? Ethnicity,
string? PoliticalStatus,
string? NativePlace,
string? HouseholdAddress,
string? CurrentAddress,
string? PostalCode,
string? Phone,
string? Email,
string? Qq,
string? WeChat,
string? EmergencyContactName,
string? EmergencyContactRelationship,
string? EmergencyContactPhone,
string? SpecialTags,
string? SpecialNeeds,
string? Biography);
public sealed record StudentProfileUpdateRequest(
Gender Gender,
DateOnly? DateOfBirth,
[MaxLength(100)] string? EnglishName,
[MaxLength(30)] string? IdCardNumber,
[MaxLength(50)] string? Nationality,
[MaxLength(50)] string? Ethnicity,
[MaxLength(50)] string? PoliticalStatus,
[MaxLength(100)] string? NativePlace,
[MaxLength(300)] string? HouseholdAddress,
[MaxLength(300)] string? CurrentAddress,
[MaxLength(20)] string? PostalCode,
[MaxLength(30)] string? Phone,
[EmailAddress, MaxLength(100)] string? Email,
[MaxLength(30)] string? Qq,
[MaxLength(60)] string? WeChat,
[MaxLength(50)] string? EmergencyContactName,
[MaxLength(30)] string? EmergencyContactRelationship,
[MaxLength(30)] string? EmergencyContactPhone,
[MaxLength(300)] string? SpecialTags,
[MaxLength(1000)] string? SpecialNeeds,
[MaxLength(1000)] string? Biography);
@@ -0,0 +1,28 @@
using System.Reflection;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Route("api/system")]
public sealed class SystemController : ControllerBase
{
[AllowAnonymous]
[HttpGet("version")]
[ProducesResponseType<SystemVersionResponse>(StatusCodes.Status200OK)]
public SystemVersionResponse GetVersion()
{
var assembly = typeof(SystemController).Assembly;
var informationalVersion = assembly
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
.InformationalVersion;
var version = informationalVersion?.Split('+', 2)[0]
?? assembly.GetName().Version?.ToString(3)
?? "unknown";
return new SystemVersionResponse(version);
}
}
public sealed record SystemVersionResponse(string Version);
@@ -54,6 +54,99 @@ public sealed class GradeItemScore
public decimal? Score { get; set; }
}
/// <summary>
/// Persisted course-result aggregate. One course/term is materialized at each
/// organizational level so the result-analysis page never aggregates raw
/// grade records on request.
/// </summary>
public sealed class CourseGradeStatistic : EntityBase
{
public Guid CourseId { get; set; }
public Guid AcademicTermId { get; set; }
public CourseGradeStatisticScope Scope { get; set; }
public Guid? ScopeEntityId { get; set; }
public int StudentCount { get; set; }
public int PassedCount { get; set; }
public int Below60Count { get; set; }
public int From60To69Count { get; set; }
public int From70To79Count { get; set; }
public int From80To89Count { get; set; }
public int From90To100Count { get; set; }
public decimal HighestScore { get; set; }
public decimal AverageScore { get; set; }
public decimal LowestScore { get; set; }
public decimal PassRate { get; set; }
public DateTime CalculatedAt { get; set; }
}
/// <summary>
/// Materialized analysis for one published teaching class. Course/term
/// organizational benchmarks stay in <see cref="CourseGradeStatistic"/>;
/// this table is the grain used for peer-class and historical comparisons.
/// </summary>
public sealed class TeachingTaskGradeStatistic : EntityBase
{
public Guid GradeSheetId { get; set; }
public GradeSheet? GradeSheet { get; set; }
public Guid TeachingTaskId { get; set; }
public TeachingTask? TeachingTask { get; set; }
public Guid CourseId { get; set; }
public Guid AcademicTermId { get; set; }
public int StudentCount { get; set; }
public int PassedCount { get; set; }
public int ExcellentCount { get; set; }
public decimal HighestScore { get; set; }
public decimal AverageScore { get; set; }
public decimal MedianScore { get; set; }
public decimal LowestScore { get; set; }
public decimal StandardDeviation { get; set; }
public decimal PassRate { get; set; }
public decimal ExcellentRate { get; set; }
public DateTime CalculatedAt { get; set; }
public ICollection<TeachingTaskGradeScoreBand> ScoreBands { get; set; } = [];
}
/// <summary>
/// Flexible score-band rows are kept separately so future band definitions do
/// not require widening the teaching-class summary table.
/// </summary>
public sealed class TeachingTaskGradeScoreBand : EntityBase
{
public Guid TeachingTaskGradeStatisticId { get; set; }
public TeachingTaskGradeStatistic? TeachingTaskGradeStatistic { get; set; }
public required string Label { get; set; }
public decimal LowerBound { get; set; }
public decimal? UpperBound { get; set; }
public int StudentCount { get; set; }
public int SortOrder { get; set; }
}
public enum CourseGradeStatisticScope
{
AdministrativeClass = 1,
Major = 2,
College = 3,
University = 4
}
public sealed class CourseGradeStatisticsRefreshJob : EntityBase
{
public Guid GradeSheetId { get; set; }
public CourseGradeStatisticsRefreshJobStatus Status { get; set; } =
CourseGradeStatisticsRefreshJobStatus.Queued;
public DateTime? StartedAt { get; set; }
public DateTime? CompletedAt { get; set; }
public string? ErrorMessage { get; set; }
}
public enum CourseGradeStatisticsRefreshJobStatus
{
Queued = 1,
Running = 2,
Succeeded = 3,
Failed = 4
}
public enum GradeSheetStatus
{
Draft = 1,
@@ -0,0 +1,44 @@
using Jiaowu.Api.Domain.Common;
namespace Jiaowu.Api.Domain.Academic;
public sealed class OtherExamBatch : EntityBase
{
public string? ExamCode { get; set; }
public required string Name { get; set; }
public string? Organizer { get; set; }
public DateOnly ExamDate { get; set; }
public OtherExamMetricKind MetricKind { get; set; }
public decimal? MaxScore { get; set; }
public string? LevelOptions { get; set; }
public OtherExamBatchStatus Status { get; set; } = OtherExamBatchStatus.Draft;
public int PublicationCount { get; set; }
public DateTime? PublishedAt { get; set; }
public ICollection<OtherExamResult> Results { get; set; } = [];
}
public sealed class OtherExamResult : EntityBase
{
public Guid OtherExamBatchId { get; set; }
public OtherExamBatch? OtherExamBatch { get; set; }
public Guid StudentId { get; set; }
public Student? Student { get; set; }
public int AttemptNumber { get; set; } = 1;
public decimal? Score { get; set; }
public string? Level { get; set; }
public bool? IsPassed { get; set; }
public string? Notes { get; set; }
}
public enum OtherExamMetricKind
{
PassFail = 1,
Level = 2,
Score = 3
}
public enum OtherExamBatchStatus
{
Draft = 1,
Published = 2
}
@@ -30,8 +30,25 @@ public sealed class Student : EntityBase
public DateOnly EnrollmentDate { get; set; }
public StudentStatus Status { get; set; } = StudentStatus.Active;
public DateOnly? DateOfBirth { get; set; }
public string? EnglishName { get; set; }
public string? IdCardNumber { get; set; }
public string? Nationality { get; set; }
public string? Ethnicity { get; set; }
public string? PoliticalStatus { get; set; }
public string? NativePlace { get; set; }
public string? HouseholdAddress { get; set; }
public string? CurrentAddress { get; set; }
public string? PostalCode { get; set; }
public string? Phone { get; set; }
public string? Email { get; set; }
public string? Qq { get; set; }
public string? WeChat { get; set; }
public string? EmergencyContactName { get; set; }
public string? EmergencyContactRelationship { get; set; }
public string? EmergencyContactPhone { get; set; }
public string? SpecialTags { get; set; }
public string? SpecialNeeds { get; set; }
public string? Biography { get; set; }
public string? Notes { get; set; }
public Guid? UserId { get; set; }
}
@@ -0,0 +1,22 @@
namespace Jiaowu.Api.Domain.Identity;
public enum AuthenticationClientType
{
Web = 0,
App = 1
}
public sealed class RefreshSession
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid UserId { get; set; }
public ApplicationUser? User { get; set; }
public required string TokenHash { get; set; }
public AuthenticationClientType ClientType { get; set; }
public required string SecurityStamp { get; set; }
public DateTime ExpiresAt { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime LastRefreshedAt { get; set; } = DateTime.UtcNow;
public DateTime? RevokedAt { get; set; }
public Guid? ReplacedBySessionId { get; set; }
}
@@ -33,7 +33,8 @@ public enum BackgroundJobKind
MakeupExamAuto = 3,
ExamArrangement = 4,
ExamSignInExport = 5,
ExamPublish = 6
ExamPublish = 6,
CourseGradeStatisticsRefresh = 7
}
public enum BackgroundJobOutboxState
@@ -0,0 +1,180 @@
using System.Security.Cryptography;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace Jiaowu.Api.Infrastructure.Auth;
public interface IAuthSessionService
{
Task<AuthSessionResult> CreateAsync(
ApplicationUser user,
IEnumerable<string> roles,
AuthenticationClientType clientType,
CancellationToken cancellationToken = default);
Task<AuthSessionResult?> RefreshAsync(
string refreshToken,
CancellationToken cancellationToken = default);
Task RevokeAsync(
string refreshToken,
CancellationToken cancellationToken = default);
}
public sealed record AuthSessionResult(
string AccessToken,
DateTime AccessTokenExpiresAt,
string RefreshToken,
DateTime SessionExpiresAt,
ApplicationUser User,
IReadOnlyList<string> Roles);
public sealed class AuthSessionService(
AppDbContext db,
UserManager<ApplicationUser> userManager,
ITokenService tokenService,
IOptions<JwtOptions> options) : IAuthSessionService
{
private readonly JwtOptions _options = options.Value;
public async Task<AuthSessionResult> CreateAsync(
ApplicationUser user,
IEnumerable<string> roles,
AuthenticationClientType clientType,
CancellationToken cancellationToken = default)
{
var roleList = roles.ToList();
var now = DateTime.UtcNow;
var rawRefreshToken = CreateRefreshToken();
var session = new RefreshSession
{
UserId = user.Id,
TokenHash = HashToken(rawRefreshToken),
ClientType = clientType,
SecurityStamp = user.SecurityStamp ?? string.Empty,
CreatedAt = now,
LastRefreshedAt = now,
ExpiresAt = now.Add(GetIdleTimeout(clientType))
};
await RemoveExpiredSessionsAsync(user.Id, now, cancellationToken);
db.RefreshSessions.Add(session);
await db.SaveChangesAsync(cancellationToken);
return BuildResult(user, roleList, rawRefreshToken, session.ExpiresAt);
}
public async Task<AuthSessionResult?> RefreshAsync(
string refreshToken,
CancellationToken cancellationToken = default)
{
var tokenHash = HashToken(refreshToken);
var now = DateTime.UtcNow;
var current = await db.RefreshSessions
.Include(x => x.User)
.SingleOrDefaultAsync(x => x.TokenHash == tokenHash, cancellationToken);
var user = current?.User;
if (current is null || user is null || current.RevokedAt.HasValue ||
current.ExpiresAt <= now || !user.IsEnabled ||
await userManager.IsLockedOutAsync(user) ||
!string.Equals(current.SecurityStamp, user.SecurityStamp ?? string.Empty,
StringComparison.Ordinal))
{
return null;
}
var newRawToken = CreateRefreshToken();
var replacement = new RefreshSession
{
UserId = user.Id,
TokenHash = HashToken(newRawToken),
ClientType = current.ClientType,
SecurityStamp = current.SecurityStamp,
CreatedAt = now,
LastRefreshedAt = now,
ExpiresAt = now.Add(GetIdleTimeout(current.ClientType))
};
var rotated = await db.ExecuteInRetriableTransactionAsync(
async transaction =>
{
db.ChangeTracker.Clear();
var updated = await db.RefreshSessions
.Where(x => x.Id == current.Id && x.RevokedAt == null && x.ExpiresAt > now)
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.RevokedAt, now)
.SetProperty(x => x.ReplacedBySessionId, replacement.Id),
cancellationToken);
if (updated != 1)
{
await transaction.RollbackAsync(cancellationToken);
return false;
}
db.RefreshSessions.Add(replacement);
await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return true;
},
cancellationToken);
if (!rotated) return null;
var roles = await userManager.GetRolesAsync(user);
return BuildResult(user, roles.ToList(), newRawToken, replacement.ExpiresAt);
}
public async Task RevokeAsync(
string refreshToken,
CancellationToken cancellationToken = default)
{
var tokenHash = HashToken(refreshToken);
var now = DateTime.UtcNow;
await db.RefreshSessions
.Where(x => x.TokenHash == tokenHash && x.RevokedAt == null)
.ExecuteUpdateAsync(
setters => setters.SetProperty(x => x.RevokedAt, now),
cancellationToken);
}
private AuthSessionResult BuildResult(
ApplicationUser user,
IReadOnlyList<string> roles,
string refreshToken,
DateTime sessionExpiresAt)
{
var accessToken = tokenService.Create(user, roles);
return new AuthSessionResult(
accessToken.Token,
accessToken.ExpiresAt,
refreshToken,
sessionExpiresAt,
user,
roles);
}
private TimeSpan GetIdleTimeout(AuthenticationClientType clientType) =>
TimeSpan.FromMinutes(clientType == AuthenticationClientType.App
? _options.AppIdleMinutes
: _options.WebIdleMinutes);
private async Task RemoveExpiredSessionsAsync(
Guid userId,
DateTime now,
CancellationToken cancellationToken)
{
var retentionCutoff = now.AddDays(-7);
await db.RefreshSessions
.Where(x => x.UserId == userId &&
(x.ExpiresAt < now || x.RevokedAt < retentionCutoff))
.ExecuteDeleteAsync(cancellationToken);
}
private static string CreateRefreshToken() =>
Convert.ToBase64String(RandomNumberGenerator.GetBytes(48));
private static string HashToken(string token) =>
Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(token)));
}
@@ -6,5 +6,7 @@ public sealed class JwtOptions
public string Issuer { get; set; } = "Jiaowu.Api";
public string Audience { get; set; } = "Jiaowu.Web";
public string Key { get; set; } = string.Empty;
public int ExpireMinutes { get; set; } = 480;
public int AccessTokenMinutes { get; set; } = 10;
public int WebIdleMinutes { get; set; } = 30;
public int AppIdleMinutes { get; set; } = 3 * 24 * 60;
}
@@ -0,0 +1,24 @@
namespace Jiaowu.Api.Infrastructure.Auth;
public sealed class SsoOptions
{
public const string SectionName = "Sso";
public bool Enabled { get; set; }
public string DisplayName { get; set; } = "学校统一身份认证";
public string Authority { get; set; } = string.Empty;
public string ClientId { get; set; } = string.Empty;
public string ClientSecret { get; set; } = string.Empty;
public string UserNameClaim { get; set; } = "preferred_username";
public bool RequireHttpsMetadata { get; set; } = true;
public bool LinkExistingUsersByUserName { get; set; } = true;
public string FrontendBaseUrl { get; set; } = string.Empty;
public string CallbackUrl { get; set; } = string.Empty;
}
public static class SsoAuthSchemes
{
public const string Keycloak = "Keycloak";
public const string ExternalCookie = "KeycloakExternal";
public const string LoginProvider = "Keycloak";
}
@@ -9,14 +9,16 @@ namespace Jiaowu.Api.Infrastructure.Auth;
public interface ITokenService
{
string Create(ApplicationUser user, IEnumerable<string> roles);
AccessTokenResult Create(ApplicationUser user, IEnumerable<string> roles);
}
public sealed record AccessTokenResult(string Token, DateTime ExpiresAt);
public sealed class TokenService(IOptions<JwtOptions> options) : ITokenService
{
private readonly JwtOptions _options = options.Value;
public string Create(ApplicationUser user, IEnumerable<string> roles)
public AccessTokenResult Create(ApplicationUser user, IEnumerable<string> roles)
{
var claims = new List<Claim>
{
@@ -37,13 +39,16 @@ public sealed class TokenService(IOptions<JwtOptions> options) : ITokenService
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.Key)),
SecurityAlgorithms.HmacSha256);
var expiresAt = DateTime.UtcNow.AddMinutes(_options.AccessTokenMinutes);
var token = new JwtSecurityToken(
issuer: _options.Issuer,
audience: _options.Audience,
claims: claims,
expires: DateTime.UtcNow.AddMinutes(_options.ExpireMinutes),
expires: expiresAt,
signingCredentials: credentials);
return new JwtSecurityTokenHandler().WriteToken(token);
return new AccessTokenResult(
new JwtSecurityTokenHandler().WriteToken(token),
expiresAt);
}
}
@@ -16,6 +16,7 @@ public sealed class BackgroundJobOptions
public int ExamArrangementConcurrency { get; set; } = 1;
public int ExamSignInExportConcurrency { get; set; } = 1;
public int ExamPublishConcurrency { get; set; } = 1;
public int CourseGradeStatisticsRefreshConcurrency { get; set; } = 1;
public string Exchange { get; set; } = "jiaowu.background-jobs";
public string QueuePrefix { get; set; } = "jiaowu.background-jobs";
public bool UseQuorumQueues { get; set; } = true;
@@ -35,6 +36,8 @@ public sealed class BackgroundJobOptions
BackgroundJobKind.ExamArrangement => ExamArrangementConcurrency,
BackgroundJobKind.ExamSignInExport => ExamSignInExportConcurrency,
BackgroundJobKind.ExamPublish => ExamPublishConcurrency,
BackgroundJobKind.CourseGradeStatisticsRefresh =>
CourseGradeStatisticsRefreshConcurrency,
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null)
};
}
@@ -130,6 +130,15 @@ public sealed class BackgroundJobOutboxPublisher(
message.JobId == x.Id))
.Select(x => x.Id)
.ToListAsync(cancellationToken);
var gradeStatisticsJobs = await db.CourseGradeStatisticsRefreshJobs.AsNoTracking()
.Where(x =>
(x.Status == CourseGradeStatisticsRefreshJobStatus.Queued ||
x.Status == CourseGradeStatisticsRefreshJobStatus.Running) &&
!db.BackgroundJobOutboxMessages.Any(message =>
message.JobKind == BackgroundJobKind.CourseGradeStatisticsRefresh &&
message.JobId == x.Id))
.Select(x => x.Id)
.ToListAsync(cancellationToken);
var missingKeys = automaticJobs
.Select(id => (BackgroundJobKind.AutomaticSchedule, id))
@@ -143,6 +152,8 @@ public sealed class BackgroundJobOutboxPublisher(
(BackgroundJobKind.ExamSignInExport, id)))
.Concat(publishJobs2.Select(id =>
(BackgroundJobKind.ExamPublish, id)))
.Concat(gradeStatisticsJobs.Select(id =>
(BackgroundJobKind.CourseGradeStatisticsRefresh, id)))
.ToList();
foreach (var (kind, jobId) in missingKeys)
{
@@ -2,6 +2,7 @@ using System.Diagnostics;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.System;
using Jiaowu.Api.Infrastructure.Exams;
using Jiaowu.Api.Infrastructure.Grades;
using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Scheduling;
using Microsoft.EntityFrameworkCore;
@@ -100,6 +101,11 @@ public sealed class BackgroundJobRunner(
.GetRequiredService<ExamPublishJobProcessor>()
.ProcessAsync(message.JobId, cancellationToken);
break;
case BackgroundJobKind.CourseGradeStatisticsRefresh:
await scope.ServiceProvider
.GetRequiredService<CourseGradeStatisticsRefreshJobProcessor>()
.ProcessAsync(message.JobId, cancellationToken);
break;
default:
throw new InvalidOperationException(
$"Unsupported background job kind '{message.JobKind}'.");
@@ -308,6 +314,19 @@ public sealed class BackgroundJobRunner(
.SetProperty(x => x.CompletedAt, completedAt),
cancellationToken);
break;
case BackgroundJobKind.CourseGradeStatisticsRefresh:
await db.CourseGradeStatisticsRefreshJobs
.Where(x => x.Id == message.JobId &&
x.Status != CourseGradeStatisticsRefreshJobStatus.Succeeded &&
x.Status != CourseGradeStatisticsRefreshJobStatus.Failed)
.ExecuteUpdateAsync(
setters => setters
.SetProperty(x => x.Status,
CourseGradeStatisticsRefreshJobStatus.Failed)
.SetProperty(x => x.ErrorMessage, error)
.SetProperty(x => x.CompletedAt, completedAt),
cancellationToken);
break;
default:
throw new ArgumentOutOfRangeException(
nameof(message.JobKind),
@@ -319,7 +319,8 @@ internal static class RabbitMqBackgroundJobTopology
BackgroundJobKind.MakeupExamAuto,
BackgroundJobKind.ExamArrangement,
BackgroundJobKind.ExamSignInExport,
BackgroundJobKind.ExamPublish
BackgroundJobKind.ExamPublish,
BackgroundJobKind.CourseGradeStatisticsRefresh
];
public static async Task<IConnection> CreateConnectionAsync(
@@ -418,6 +419,7 @@ internal static class RabbitMqBackgroundJobTopology
BackgroundJobKind.ExamArrangement => "exam.arrangement",
BackgroundJobKind.ExamSignInExport => "exam.sign-in-export",
BackgroundJobKind.ExamPublish => "exam.publish",
BackgroundJobKind.CourseGradeStatisticsRefresh => "grade-statistics.refresh",
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null)
};
@@ -182,12 +182,19 @@ public static class AppCacheKeys
return $"statistics:v2:{Normalize(area)}:scope:{Normalize(dataScope)}:" +
$"college:{effectiveCollegeId?.ToString("N") ?? "all"}:{filterPart}";
}
public static string CourseGradeStatistics(Guid gradeSheetId) =>
$"grade-statistics:sheet:{gradeSheetId:N}";
public static string TeachingTaskGradeAnalytics(Guid gradeSheetId) =>
$"grade-analytics:sheet:{gradeSheetId:N}:v1";
}
public static class AppCacheTags
{
public const string BaseData = "base-data";
public const string Analytics = "analytics";
public const string CourseGradeStatistics = "grade-statistics";
public const string Timetables = "timetables";
public const string TimetableOptions = "timetable:options";
@@ -11,7 +11,8 @@ public static class ExcelWorkbookHelper
string sheetName,
IReadOnlyList<string> headers,
IEnumerable<IReadOnlyList<object?>> rows,
IReadOnlyList<string>? instructions = null)
IReadOnlyList<string>? instructions = null,
Action<IXLWorksheet, int>? configureRow = null)
{
using var workbook = new XLWorkbook();
var sheet = workbook.Worksheets.Add(sheetName);
@@ -33,6 +34,7 @@ public static class ExcelWorkbookHelper
{
SetCellValue(sheet.Cell(rowNumber, column + 1), row[column]);
}
configureRow?.Invoke(sheet, rowNumber);
rowNumber++;
}
@@ -0,0 +1,250 @@
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.Grades;
/// <summary>
/// Rebuilds a course/term's denormalized result statistics. The operation is
/// intentionally idempotent: duplicate RabbitMQ deliveries are safe.
/// </summary>
public sealed class CourseGradeStatisticsRefreshJobProcessor(
AppDbContext db,
IAppCache cache,
ILogger<CourseGradeStatisticsRefreshJobProcessor> logger)
{
public async Task ProcessAsync(Guid jobId, CancellationToken cancellationToken)
{
var job = await db.CourseGradeStatisticsRefreshJobs
.FirstOrDefaultAsync(x => x.Id == jobId, cancellationToken);
if (job is null || job.Status == CourseGradeStatisticsRefreshJobStatus.Succeeded)
return;
job.Status = CourseGradeStatisticsRefreshJobStatus.Running;
job.StartedAt = DateTime.UtcNow;
job.ErrorMessage = null;
await db.SaveChangesAsync(cancellationToken);
var sheetData = await db.GradeSheets.AsNoTracking()
.Where(x => x.Id == job.GradeSheetId)
.Select(x => new { x.Id, x.TeachingTask!.CourseId, x.TeachingTask.AcademicTermId })
.FirstOrDefaultAsync(cancellationToken);
if (sheetData is null)
{
job.Status = CourseGradeStatisticsRefreshJobStatus.Succeeded;
job.CompletedAt = DateTime.UtcNow;
await db.SaveChangesAsync(cancellationToken);
return;
}
var target = new StatisticsTarget(sheetData.CourseId, sheetData.AcademicTermId);
try
{
// Statistics shown to students are based only on formally published
// scores. This prevents an unfinished class from exposing data.
var scores = await db.GradeRecords.AsNoTracking()
.Where(x => x.TotalScore != null &&
x.GradeSheet!.Status == GradeSheetStatus.Published &&
x.GradeSheet.TeachingTask!.CourseId == target.CourseId &&
x.GradeSheet.TeachingTask.AcademicTermId == target.AcademicTermId)
.Select(x => new ScoreRow(
x.GradeSheetId,
x.GradeSheet!.TeachingTaskId,
x.TotalScore!.Value,
x.Student!.AdministrativeClassId,
x.Student.AdministrativeClass!.MajorId,
x.Student.AdministrativeClass.Major!.CollegeId))
.ToListAsync(cancellationToken);
var now = DateTime.UtcNow;
var rebuilt = new List<CourseGradeStatistic>();
AddStatistics(CourseGradeStatisticScope.AdministrativeClass,
scores.GroupBy(x => x.ClassId), rebuilt, target, now);
AddStatistics(CourseGradeStatisticScope.Major,
scores.GroupBy(x => x.MajorId), rebuilt, target, now);
AddStatistics(CourseGradeStatisticScope.College,
scores.GroupBy(x => x.CollegeId), rebuilt, target, now);
AddUniversityStatistic(scores, rebuilt, target, now);
var rebuiltTeachingTasks = scores
.GroupBy(x => new { x.GradeSheetId, x.TeachingTaskId })
.Select(group => CreateTeachingTaskStatistic(
group.Key.GradeSheetId,
group.Key.TeachingTaskId,
group.Select(x => x.Score),
target,
now))
.ToList();
await db.CourseGradeStatistics
.Where(x => x.CourseId == target.CourseId &&
x.AcademicTermId == target.AcademicTermId)
.ExecuteDeleteAsync(cancellationToken);
if (rebuilt.Count > 0)
db.CourseGradeStatistics.AddRange(rebuilt);
var oldTeachingTaskStatisticIds = await db.TeachingTaskGradeStatistics
.Where(x => x.CourseId == target.CourseId &&
x.AcademicTermId == target.AcademicTermId)
.Select(x => x.Id)
.ToListAsync(cancellationToken);
if (oldTeachingTaskStatisticIds.Count > 0)
{
await db.TeachingTaskGradeScoreBands
.Where(x => oldTeachingTaskStatisticIds.Contains(
x.TeachingTaskGradeStatisticId))
.ExecuteDeleteAsync(cancellationToken);
await db.TeachingTaskGradeStatistics
.Where(x => oldTeachingTaskStatisticIds.Contains(x.Id))
.ExecuteDeleteAsync(cancellationToken);
}
if (rebuiltTeachingTasks.Count > 0)
db.TeachingTaskGradeStatistics.AddRange(rebuiltTeachingTasks);
job.Status = CourseGradeStatisticsRefreshJobStatus.Succeeded;
job.CompletedAt = now;
await db.SaveChangesAsync(cancellationToken);
await cache.RemoveByTagAsync(AppCacheTags.CourseGradeStatistics,
cancellationToken);
}
catch (Exception exception)
{
job.Status = CourseGradeStatisticsRefreshJobStatus.Failed;
job.ErrorMessage = exception.GetBaseException().Message[..Math.Min(2000,
exception.GetBaseException().Message.Length)];
await db.SaveChangesAsync(CancellationToken.None);
logger.LogError(exception, "Course grade statistics refresh {JobId} failed.", jobId);
throw;
}
}
private static void AddStatistics(
CourseGradeStatisticScope scope,
IEnumerable<IGrouping<Guid, ScoreRow>> groups,
ICollection<CourseGradeStatistic> target,
StatisticsTarget targetInfo,
DateTime calculatedAt)
{
foreach (var group in groups)
target.Add(Create(scope, group.Key, group.Select(x => x.Score), targetInfo, calculatedAt));
}
private static void AddUniversityStatistic(
IReadOnlyCollection<ScoreRow> scores,
ICollection<CourseGradeStatistic> target,
StatisticsTarget targetInfo,
DateTime calculatedAt)
{
if (scores.Count > 0)
target.Add(Create(CourseGradeStatisticScope.University, null,
scores.Select(x => x.Score), targetInfo, calculatedAt));
}
private static CourseGradeStatistic Create(
CourseGradeStatisticScope scope,
Guid? scopeEntityId,
IEnumerable<decimal> source,
StatisticsTarget targetInfo,
DateTime calculatedAt)
{
var scores = source.ToArray();
var passed = scores.Count(x => x >= 60m);
return new CourseGradeStatistic
{
CourseId = targetInfo.CourseId,
AcademicTermId = targetInfo.AcademicTermId,
Scope = scope,
ScopeEntityId = scopeEntityId,
StudentCount = scores.Length,
PassedCount = passed,
Below60Count = scores.Count(x => x < 60m),
From60To69Count = scores.Count(x => x >= 60m && x < 70m),
From70To79Count = scores.Count(x => x >= 70m && x < 80m),
From80To89Count = scores.Count(x => x >= 80m && x < 90m),
From90To100Count = scores.Count(x => x >= 90m),
HighestScore = scores.Max(),
AverageScore = Math.Round(scores.Average(), 1),
LowestScore = scores.Min(),
PassRate = Math.Round((decimal)passed / scores.Length * 100m, 2),
CalculatedAt = calculatedAt
};
}
private static TeachingTaskGradeStatistic CreateTeachingTaskStatistic(
Guid gradeSheetId,
Guid teachingTaskId,
IEnumerable<decimal> source,
StatisticsTarget targetInfo,
DateTime calculatedAt)
{
var scores = source.OrderBy(x => x).ToArray();
var passed = scores.Count(x => x >= 60m);
var excellent = scores.Count(x => x >= 90m);
var average = scores.Average();
var middle = scores.Length / 2;
var median = scores.Length % 2 == 0
? (scores[middle - 1] + scores[middle]) / 2m
: scores[middle];
var variance = scores.Average(x =>
(double)((x - average) * (x - average)));
var statistic = new TeachingTaskGradeStatistic
{
GradeSheetId = gradeSheetId,
TeachingTaskId = teachingTaskId,
CourseId = targetInfo.CourseId,
AcademicTermId = targetInfo.AcademicTermId,
StudentCount = scores.Length,
PassedCount = passed,
ExcellentCount = excellent,
HighestScore = scores.Max(),
AverageScore = Math.Round(average, 1),
MedianScore = Math.Round(median, 1),
LowestScore = scores.Min(),
StandardDeviation = Math.Round((decimal)Math.Sqrt(variance), 2),
PassRate = Math.Round((decimal)passed / scores.Length * 100m, 2),
ExcellentRate = Math.Round((decimal)excellent / scores.Length * 100m, 2),
CalculatedAt = calculatedAt
};
statistic.ScoreBands =
[
CreateBand(statistic.Id, "059", 0m, 60m,
scores.Count(x => x < 60m), 0),
CreateBand(statistic.Id, "6069", 60m, 70m,
scores.Count(x => x >= 60m && x < 70m), 1),
CreateBand(statistic.Id, "7079", 70m, 80m,
scores.Count(x => x >= 70m && x < 80m), 2),
CreateBand(statistic.Id, "8089", 80m, 90m,
scores.Count(x => x >= 80m && x < 90m), 3),
CreateBand(statistic.Id, "90100", 90m, null,
scores.Count(x => x >= 90m), 4)
];
return statistic;
}
private static TeachingTaskGradeScoreBand CreateBand(
Guid statisticId,
string label,
decimal lowerBound,
decimal? upperBound,
int count,
int sortOrder) => new()
{
TeachingTaskGradeStatisticId = statisticId,
Label = label,
LowerBound = lowerBound,
UpperBound = upperBound,
StudentCount = count,
SortOrder = sortOrder
};
private sealed record ScoreRow(
Guid GradeSheetId,
Guid TeachingTaskId,
decimal Score,
Guid ClassId,
Guid MajorId,
Guid CollegeId);
private sealed record StatisticsTarget(Guid CourseId, Guid AcademicTermId);
}
@@ -66,6 +66,16 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<GradeRecord> GradeRecords => Set<GradeRecord>();
public DbSet<GradeItem> GradeItems => Set<GradeItem>();
public DbSet<GradeItemScore> GradeItemScores => Set<GradeItemScore>();
public DbSet<CourseGradeStatistic> CourseGradeStatistics =>
Set<CourseGradeStatistic>();
public DbSet<TeachingTaskGradeStatistic> TeachingTaskGradeStatistics =>
Set<TeachingTaskGradeStatistic>();
public DbSet<TeachingTaskGradeScoreBand> TeachingTaskGradeScoreBands =>
Set<TeachingTaskGradeScoreBand>();
public DbSet<CourseGradeStatisticsRefreshJob> CourseGradeStatisticsRefreshJobs =>
Set<CourseGradeStatisticsRefreshJob>();
public DbSet<OtherExamBatch> OtherExamBatches => Set<OtherExamBatch>();
public DbSet<OtherExamResult> OtherExamResults => Set<OtherExamResult>();
public DbSet<AttendanceSheet> AttendanceSheets => Set<AttendanceSheet>();
public DbSet<AttendanceRecord> AttendanceRecords => Set<AttendanceRecord>();
public DbSet<AttendanceCheckInAttempt> AttendanceCheckInAttempts =>
@@ -127,6 +137,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
Set<BackgroundJobOutboxMessage>();
public DbSet<AppUpdateRelease> AppUpdateReleases =>
Set<AppUpdateRelease>();
public DbSet<RefreshSession> RefreshSessions => Set<RefreshSession>();
protected override void ConfigureConventions(
ModelConfigurationBuilder configurationBuilder)
@@ -163,6 +174,21 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
entity.Property(x => x.Description).HasMaxLength(100);
});
builder.Entity<RefreshSession>(entity =>
{
entity.Property(x => x.TokenHash).HasMaxLength(64);
entity.Property(x => x.SecurityStamp).HasMaxLength(100);
entity.Property(x => x.ClientType)
.HasConversion<string>()
.HasMaxLength(20);
entity.HasIndex(x => x.TokenHash).IsUnique();
entity.HasIndex(x => new { x.UserId, x.ExpiresAt });
entity.HasOne(x => x.User)
.WithMany()
.HasForeignKey(x => x.UserId)
.OnDelete(DeleteBehavior.Cascade);
});
ConfigureCatalog<Campus>(builder);
ConfigureCatalog<College>(builder);
ConfigureCatalog<Major>(builder);
@@ -241,8 +267,25 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
{
entity.Property(x => x.StudentNumber).HasMaxLength(30);
entity.Property(x => x.Name).HasMaxLength(50);
entity.Property(x => x.EnglishName).HasMaxLength(100);
entity.Property(x => x.IdCardNumber).HasMaxLength(30);
entity.Property(x => x.Nationality).HasMaxLength(50);
entity.Property(x => x.Ethnicity).HasMaxLength(50);
entity.Property(x => x.PoliticalStatus).HasMaxLength(50);
entity.Property(x => x.NativePlace).HasMaxLength(100);
entity.Property(x => x.HouseholdAddress).HasMaxLength(300);
entity.Property(x => x.CurrentAddress).HasMaxLength(300);
entity.Property(x => x.PostalCode).HasMaxLength(20);
entity.Property(x => x.Phone).HasMaxLength(30);
entity.Property(x => x.Email).HasMaxLength(100);
entity.Property(x => x.Qq).HasMaxLength(30);
entity.Property(x => x.WeChat).HasMaxLength(60);
entity.Property(x => x.EmergencyContactName).HasMaxLength(50);
entity.Property(x => x.EmergencyContactRelationship).HasMaxLength(30);
entity.Property(x => x.EmergencyContactPhone).HasMaxLength(30);
entity.Property(x => x.SpecialTags).HasMaxLength(300);
entity.Property(x => x.SpecialNeeds).HasMaxLength(1000);
entity.Property(x => x.Biography).HasMaxLength(1000);
entity.Property(x => x.Notes).HasMaxLength(500);
entity.HasIndex(x => x.StudentNumber).IsUnique();
entity.HasIndex(x => new { x.AdministrativeClassId, x.Status });
@@ -417,7 +460,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
builder.Entity<ScheduleEntry>(entity =>
{
entity.Property(x => x.Kind)
.HasDefaultValue(ScheduleEntryKind.Lecture);
.HasDefaultValue(ScheduleEntryKind.Lecture)
.HasSentinel((ScheduleEntryKind)0);
entity.Property(x => x.Notes).HasMaxLength(500);
entity.HasIndex(x => new
{
@@ -769,7 +813,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
entity.Property(x => x.Name).HasMaxLength(60);
entity.Property(x => x.Weight).HasPrecision(5, 1);
entity.Property(x => x.SourceType)
.HasDefaultValue(GradeItemSourceType.Manual);
.HasDefaultValue(GradeItemSourceType.Manual)
.HasSentinel((GradeItemSourceType)0);
entity.HasIndex(x => new { x.GradeSheetId, x.SortOrder });
entity.HasOne(x => x.GradeSheet)
.WithMany(x => x.Items)
@@ -810,6 +855,72 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<CourseGradeStatistic>(entity =>
{
entity.Property(x => x.HighestScore).HasPrecision(5, 1);
entity.Property(x => x.AverageScore).HasPrecision(5, 1);
entity.Property(x => x.LowestScore).HasPrecision(5, 1);
entity.Property(x => x.PassRate).HasPrecision(5, 2);
entity.HasIndex(x => new
{
x.CourseId, x.AcademicTermId, x.Scope, x.ScopeEntityId
}).IsUnique().HasDatabaseName("UX_CourseGradeStatistics_Scope");
entity.HasIndex(x => new { x.AcademicTermId, x.Scope, x.ScopeEntityId });
entity.HasOne<Course>().WithMany().HasForeignKey(x => x.CourseId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne<AcademicTerm>().WithMany().HasForeignKey(x => x.AcademicTermId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<TeachingTaskGradeStatistic>(entity =>
{
entity.Property(x => x.HighestScore).HasPrecision(5, 1);
entity.Property(x => x.AverageScore).HasPrecision(5, 1);
entity.Property(x => x.MedianScore).HasPrecision(5, 1);
entity.Property(x => x.LowestScore).HasPrecision(5, 1);
entity.Property(x => x.StandardDeviation).HasPrecision(6, 2);
entity.Property(x => x.PassRate).HasPrecision(5, 2);
entity.Property(x => x.ExcellentRate).HasPrecision(5, 2);
entity.HasIndex(x => x.GradeSheetId).IsUnique();
entity.HasIndex(x => x.TeachingTaskId).IsUnique();
entity.HasIndex(x => new { x.CourseId, x.AcademicTermId });
entity.HasOne(x => x.GradeSheet).WithOne()
.HasForeignKey<TeachingTaskGradeStatistic>(x => x.GradeSheetId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.TeachingTask).WithOne()
.HasForeignKey<TeachingTaskGradeStatistic>(x => x.TeachingTaskId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne<Course>().WithMany().HasForeignKey(x => x.CourseId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne<AcademicTerm>().WithMany().HasForeignKey(x => x.AcademicTermId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<TeachingTaskGradeScoreBand>(entity =>
{
entity.Property(x => x.Label).HasMaxLength(30);
entity.Property(x => x.LowerBound).HasPrecision(5, 1);
entity.Property(x => x.UpperBound).HasPrecision(5, 1);
entity.HasIndex(x => new
{
x.TeachingTaskGradeStatisticId,
x.SortOrder
}).IsUnique();
entity.HasOne(x => x.TeachingTaskGradeStatistic)
.WithMany(x => x.ScoreBands)
.HasForeignKey(x => x.TeachingTaskGradeStatisticId)
.OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<CourseGradeStatisticsRefreshJob>(entity =>
{
entity.Property(x => x.ErrorMessage).HasMaxLength(2000);
entity.HasIndex(x => new { x.Status, x.CreatedAt });
entity.HasIndex(x => x.GradeSheetId);
entity.HasOne<GradeSheet>().WithMany().HasForeignKey(x => x.GradeSheetId)
.OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<AttendanceSheet>(entity =>
{
entity.Property(x => x.Name).HasMaxLength(120);
@@ -1153,6 +1264,27 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
entity.HasOne(x => x.GradeRecord).WithMany()
.HasForeignKey(x => x.GradeRecordId).OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<OtherExamBatch>(entity =>
{
entity.Property(x => x.ExamCode).HasMaxLength(60);
entity.Property(x => x.Name).HasMaxLength(150);
entity.Property(x => x.Organizer).HasMaxLength(150);
entity.Property(x => x.LevelOptions).HasMaxLength(500);
entity.Property(x => x.MaxScore).HasPrecision(8, 2);
entity.HasIndex(x => new { x.Status, x.ExamDate });
});
builder.Entity<OtherExamResult>(entity =>
{
entity.Property(x => x.Score).HasPrecision(8, 2);
entity.Property(x => x.Level).HasMaxLength(50);
entity.Property(x => x.Notes).HasMaxLength(500);
entity.HasIndex(x => new { x.OtherExamBatchId, x.StudentId, x.AttemptNumber }).IsUnique();
entity.HasIndex(x => new { x.StudentId, x.OtherExamBatchId });
entity.HasOne(x => x.OtherExamBatch).WithMany(x => x.Results)
.HasForeignKey(x => x.OtherExamBatchId).OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.Student).WithMany()
.HasForeignKey(x => x.StudentId).OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<WarningRule>(entity =>
{
entity.Property(x => x.Name).HasMaxLength(100);
@@ -78,6 +78,18 @@ public sealed class DevelopmentSqliteMigrator(
"20260729_41_app_update_releases";
private const string IntegratedExperimentSchedulingMigration =
"20260802_42_integrated_experiment_scheduling";
private const string RefreshSessionsMigration =
"20260803_43_refresh_sessions";
private const string StudentPersonalProfileMigration =
"20260803_44_student_personal_profile";
private const string OtherExamResultsMigration =
"20260808_45_other_exam_results";
private const string CourseGradeStatisticsMigration =
"20260808_46_course_grade_statistics";
private const string CourseGradeDistributionMigration =
"20260809_47_course_grade_distribution";
private const string TeachingTaskGradeAnalyticsMigration =
"20260809_48_teaching_task_grade_analytics";
public async Task MigrateAsync(CancellationToken cancellationToken = default)
{
@@ -593,6 +605,70 @@ public sealed class DevelopmentSqliteMigrator(
? []
: IntegratedExperimentSchedulingStatements,
cancellationToken);
await ApplyMigrationAsync(
RefreshSessionsMigration,
RefreshSessionsStatements,
cancellationToken);
var studentPersonalProfileExists = await db.Database
.SqlQueryRaw<int>(
"""
SELECT COUNT(*) AS "Value"
FROM pragma_table_info('Students')
WHERE name = 'SpecialTags'
""")
.AnyAsync(value => value > 0, cancellationToken);
await ApplyMigrationAsync(
StudentPersonalProfileMigration,
studentPersonalProfileExists ? [] : StudentPersonalProfileStatements,
cancellationToken);
var otherExamCodeExists = await db.Database
.SqlQueryRaw<int>(
"""
SELECT COUNT(*) AS "Value"
FROM pragma_table_info('OtherExamBatches')
WHERE name = 'ExamCode'
""")
.AnyAsync(value => value > 0, cancellationToken);
var otherExamBatchExists = await db.Database
.SqlQueryRaw<int>(
"""
SELECT COUNT(*) AS "Value"
FROM sqlite_master
WHERE type = 'table' AND name = 'OtherExamBatches'
""")
.AnyAsync(value => value > 0, cancellationToken);
await ApplyMigrationAsync(
OtherExamResultsMigration,
!otherExamBatchExists
? OtherExamResultsStatements.Skip(1)
: otherExamCodeExists
? OtherExamResultsStatements.Skip(1)
: OtherExamResultsStatements,
cancellationToken);
await ApplyMigrationAsync(
CourseGradeStatisticsMigration,
CourseGradeStatisticsStatements,
cancellationToken);
var courseGradeStatisticColumns = (await db.Database
.SqlQueryRaw<string>(
"""
SELECT name AS "Value"
FROM pragma_table_info('CourseGradeStatistics')
""")
.ToListAsync(cancellationToken))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var missingDistributionStatements = CourseGradeDistributionColumns
.Select((column, index) => new { column, index })
.Where(x => !courseGradeStatisticColumns.Contains(x.column))
.Select(x => CourseGradeDistributionStatements[x.index]);
await ApplyMigrationAsync(
CourseGradeDistributionMigration,
missingDistributionStatements,
cancellationToken);
await ApplyMigrationAsync(
TeachingTaskGradeAnalyticsMigration,
TeachingTaskGradeAnalyticsStatements,
cancellationToken);
}
private async Task ApplyMigrationAsync(
@@ -2104,6 +2180,105 @@ public sealed class DevelopmentSqliteMigrator(
"""
];
private static readonly string[] RefreshSessionsStatements =
[
"""
CREATE TABLE IF NOT EXISTS "RefreshSessions" (
"Id" TEXT NOT NULL CONSTRAINT "PK_RefreshSessions" PRIMARY KEY,
"UserId" TEXT NOT NULL,
"TokenHash" TEXT NOT NULL,
"ClientType" TEXT NOT NULL,
"SecurityStamp" TEXT NOT NULL,
"ExpiresAt" TEXT NOT NULL,
"CreatedAt" TEXT NOT NULL,
"LastRefreshedAt" TEXT NOT NULL,
"RevokedAt" TEXT NULL,
"ReplacedBySessionId" TEXT NULL,
CONSTRAINT "FK_RefreshSessions_AspNetUsers_UserId"
FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE
);
""",
"""
CREATE UNIQUE INDEX IF NOT EXISTS "IX_RefreshSessions_TokenHash"
ON "RefreshSessions" ("TokenHash");
""",
"""
CREATE INDEX IF NOT EXISTS "IX_RefreshSessions_UserId_ExpiresAt"
ON "RefreshSessions" ("UserId", "ExpiresAt");
"""
];
private static readonly string[] StudentPersonalProfileStatements =
[
"""ALTER TABLE "Students" ADD COLUMN "Biography" TEXT NULL;""",
"""ALTER TABLE "Students" ADD COLUMN "CurrentAddress" TEXT NULL;""",
"""ALTER TABLE "Students" ADD COLUMN "EmergencyContactName" TEXT NULL;""",
"""ALTER TABLE "Students" ADD COLUMN "EmergencyContactPhone" TEXT NULL;""",
"""ALTER TABLE "Students" ADD COLUMN "EmergencyContactRelationship" TEXT NULL;""",
"""ALTER TABLE "Students" ADD COLUMN "EnglishName" TEXT NULL;""",
"""ALTER TABLE "Students" ADD COLUMN "Ethnicity" TEXT NULL;""",
"""ALTER TABLE "Students" ADD COLUMN "HouseholdAddress" TEXT NULL;""",
"""ALTER TABLE "Students" ADD COLUMN "IdCardNumber" TEXT NULL;""",
"""ALTER TABLE "Students" ADD COLUMN "Nationality" TEXT NULL;""",
"""ALTER TABLE "Students" ADD COLUMN "NativePlace" TEXT NULL;""",
"""ALTER TABLE "Students" ADD COLUMN "PoliticalStatus" TEXT NULL;""",
"""ALTER TABLE "Students" ADD COLUMN "PostalCode" TEXT NULL;""",
"""ALTER TABLE "Students" ADD COLUMN "Qq" TEXT NULL;""",
"""ALTER TABLE "Students" ADD COLUMN "SpecialNeeds" TEXT NULL;""",
"""ALTER TABLE "Students" ADD COLUMN "SpecialTags" TEXT NULL;""",
"""ALTER TABLE "Students" ADD COLUMN "WeChat" TEXT NULL;"""
];
private static readonly string[] OtherExamResultsStatements =
[
"""ALTER TABLE "OtherExamBatches" ADD COLUMN "ExamCode" TEXT NULL;""",
"""CREATE TABLE IF NOT EXISTS "OtherExamBatches" ("Id" TEXT NOT NULL CONSTRAINT "PK_OtherExamBatches" PRIMARY KEY, "ExamCode" TEXT NULL, "Name" TEXT NOT NULL, "Organizer" TEXT NULL, "ExamDate" TEXT NOT NULL, "MetricKind" INTEGER NOT NULL, "MaxScore" TEXT NULL, "LevelOptions" TEXT NULL, "Status" INTEGER NOT NULL, "PublicationCount" INTEGER NOT NULL, "PublishedAt" TEXT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL);""",
"""CREATE INDEX IF NOT EXISTS "IX_OtherExamBatches_Status_ExamDate" ON "OtherExamBatches" ("Status", "ExamDate");""",
"""CREATE TABLE IF NOT EXISTS "OtherExamResults" ("Id" TEXT NOT NULL CONSTRAINT "PK_OtherExamResults" PRIMARY KEY, "OtherExamBatchId" TEXT NOT NULL, "StudentId" TEXT NOT NULL, "AttemptNumber" INTEGER NOT NULL, "Score" TEXT NULL, "Level" TEXT NULL, "IsPassed" INTEGER NULL, "Notes" TEXT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, CONSTRAINT "FK_OtherExamResults_OtherExamBatches" FOREIGN KEY ("OtherExamBatchId") REFERENCES "OtherExamBatches" ("Id") ON DELETE CASCADE, CONSTRAINT "FK_OtherExamResults_Students" FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT);""",
"""DROP INDEX IF EXISTS "IX_OtherExamResults_OtherExamBatchId_StudentId_AttemptNumber";""",
"""CREATE UNIQUE INDEX IF NOT EXISTS "IX_OtherExamResults_OtherExamBatchId_StudentId" ON "OtherExamResults" ("OtherExamBatchId", "StudentId");""",
"""CREATE INDEX IF NOT EXISTS "IX_OtherExamResults_StudentId_OtherExamBatchId" ON "OtherExamResults" ("StudentId", "OtherExamBatchId");"""
];
private static readonly string[] CourseGradeStatisticsStatements =
[
"""CREATE TABLE IF NOT EXISTS "CourseGradeStatistics" ("Id" TEXT NOT NULL CONSTRAINT "PK_CourseGradeStatistics" PRIMARY KEY, "CourseId" TEXT NOT NULL, "AcademicTermId" TEXT NOT NULL, "Scope" INTEGER NOT NULL, "ScopeEntityId" TEXT NULL, "StudentCount" INTEGER NOT NULL, "PassedCount" INTEGER NOT NULL, "HighestScore" TEXT NOT NULL, "AverageScore" TEXT NOT NULL, "LowestScore" TEXT NOT NULL, "PassRate" TEXT NOT NULL, "CalculatedAt" TEXT NOT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, CONSTRAINT "FK_CourseGradeStatistics_Courses_CourseId" FOREIGN KEY ("CourseId") REFERENCES "Courses" ("Id") ON DELETE RESTRICT, CONSTRAINT "FK_CourseGradeStatistics_AcademicTerms_AcademicTermId" FOREIGN KEY ("AcademicTermId") REFERENCES "AcademicTerms" ("Id") ON DELETE RESTRICT);""",
"""CREATE UNIQUE INDEX IF NOT EXISTS "UX_CourseGradeStatistics_Scope" ON "CourseGradeStatistics" ("CourseId", "AcademicTermId", "Scope", "ScopeEntityId");""",
"""CREATE INDEX IF NOT EXISTS "IX_CourseGradeStatistics_AcademicTermId_Scope_ScopeEntityId" ON "CourseGradeStatistics" ("AcademicTermId", "Scope", "ScopeEntityId");""",
"""CREATE TABLE IF NOT EXISTS "CourseGradeStatisticsRefreshJobs" ("Id" TEXT NOT NULL CONSTRAINT "PK_CourseGradeStatisticsRefreshJobs" PRIMARY KEY, "GradeSheetId" TEXT NOT NULL, "Status" INTEGER NOT NULL, "StartedAt" TEXT NULL, "CompletedAt" TEXT NULL, "ErrorMessage" TEXT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, CONSTRAINT "FK_CourseGradeStatisticsRefreshJobs_GradeSheets_GradeSheetId" FOREIGN KEY ("GradeSheetId") REFERENCES "GradeSheets" ("Id") ON DELETE CASCADE);""",
"""CREATE INDEX IF NOT EXISTS "IX_CourseGradeStatisticsRefreshJobs_GradeSheetId" ON "CourseGradeStatisticsRefreshJobs" ("GradeSheetId");""",
"""CREATE INDEX IF NOT EXISTS "IX_CourseGradeStatisticsRefreshJobs_Status_CreatedAt" ON "CourseGradeStatisticsRefreshJobs" ("Status", "CreatedAt");"""
];
private static readonly string[] CourseGradeDistributionStatements =
[
"""ALTER TABLE "CourseGradeStatistics" ADD COLUMN "Below60Count" INTEGER NOT NULL DEFAULT 0;""",
"""ALTER TABLE "CourseGradeStatistics" ADD COLUMN "From60To69Count" INTEGER NOT NULL DEFAULT 0;""",
"""ALTER TABLE "CourseGradeStatistics" ADD COLUMN "From70To79Count" INTEGER NOT NULL DEFAULT 0;""",
"""ALTER TABLE "CourseGradeStatistics" ADD COLUMN "From80To89Count" INTEGER NOT NULL DEFAULT 0;""",
"""ALTER TABLE "CourseGradeStatistics" ADD COLUMN "From90To100Count" INTEGER NOT NULL DEFAULT 0;"""
];
private static readonly string[] CourseGradeDistributionColumns =
[
"Below60Count",
"From60To69Count",
"From70To79Count",
"From80To89Count",
"From90To100Count"
];
private static readonly string[] TeachingTaskGradeAnalyticsStatements =
[
"""CREATE TABLE IF NOT EXISTS "TeachingTaskGradeStatistics" ("Id" TEXT NOT NULL CONSTRAINT "PK_TeachingTaskGradeStatistics" PRIMARY KEY, "GradeSheetId" TEXT NOT NULL, "TeachingTaskId" TEXT NOT NULL, "CourseId" TEXT NOT NULL, "AcademicTermId" TEXT NOT NULL, "StudentCount" INTEGER NOT NULL, "PassedCount" INTEGER NOT NULL, "ExcellentCount" INTEGER NOT NULL, "HighestScore" TEXT NOT NULL, "AverageScore" TEXT NOT NULL, "MedianScore" TEXT NOT NULL, "LowestScore" TEXT NOT NULL, "StandardDeviation" TEXT NOT NULL, "PassRate" TEXT NOT NULL, "ExcellentRate" TEXT NOT NULL, "CalculatedAt" TEXT NOT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, CONSTRAINT "FK_TeachingTaskGradeStatistics_GradeSheets" FOREIGN KEY ("GradeSheetId") REFERENCES "GradeSheets" ("Id") ON DELETE CASCADE, CONSTRAINT "FK_TeachingTaskGradeStatistics_TeachingTasks" FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id") ON DELETE CASCADE, CONSTRAINT "FK_TeachingTaskGradeStatistics_Courses" FOREIGN KEY ("CourseId") REFERENCES "Courses" ("Id") ON DELETE RESTRICT, CONSTRAINT "FK_TeachingTaskGradeStatistics_AcademicTerms" FOREIGN KEY ("AcademicTermId") REFERENCES "AcademicTerms" ("Id") ON DELETE RESTRICT);""",
"""CREATE UNIQUE INDEX IF NOT EXISTS "IX_TeachingTaskGradeStatistics_GradeSheetId" ON "TeachingTaskGradeStatistics" ("GradeSheetId");""",
"""CREATE UNIQUE INDEX IF NOT EXISTS "IX_TeachingTaskGradeStatistics_TeachingTaskId" ON "TeachingTaskGradeStatistics" ("TeachingTaskId");""",
"""CREATE INDEX IF NOT EXISTS "IX_TeachingTaskGradeStatistics_CourseId_AcademicTermId" ON "TeachingTaskGradeStatistics" ("CourseId", "AcademicTermId");""",
"""CREATE INDEX IF NOT EXISTS "IX_TeachingTaskGradeStatistics_AcademicTermId" ON "TeachingTaskGradeStatistics" ("AcademicTermId");""",
"""CREATE TABLE IF NOT EXISTS "TeachingTaskGradeScoreBands" ("Id" TEXT NOT NULL CONSTRAINT "PK_TeachingTaskGradeScoreBands" PRIMARY KEY, "TeachingTaskGradeStatisticId" TEXT NOT NULL, "Label" TEXT NOT NULL, "LowerBound" TEXT NOT NULL, "UpperBound" TEXT NULL, "StudentCount" INTEGER NOT NULL, "SortOrder" INTEGER NOT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, CONSTRAINT "FK_TeachingTaskGradeScoreBands_Statistics" FOREIGN KEY ("TeachingTaskGradeStatisticId") REFERENCES "TeachingTaskGradeStatistics" ("Id") ON DELETE CASCADE);""",
"""CREATE UNIQUE INDEX IF NOT EXISTS "IX_TeachingTaskGradeScoreBands_StatisticId_SortOrder" ON "TeachingTaskGradeScoreBands" ("TeachingTaskGradeStatisticId", "SortOrder");"""
];
private static readonly string[] ApprovalTableStatements =
[
"""CREATE TABLE "CourseExemptions" ("Id" TEXT NOT NULL CONSTRAINT "PK_CourseExemptions" PRIMARY KEY, "StudentId" TEXT NOT NULL, "TeachingTaskId" TEXT NOT NULL, "Reason" TEXT NOT NULL, "Status" INTEGER NOT NULL, "ReviewComment" TEXT NULL, "SubmittedAt" TEXT NOT NULL, "ReviewedAt" TEXT NULL, "ReviewedByUserId" TEXT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, CONSTRAINT "FK_CourseExemptions_Students" FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT, CONSTRAINT "FK_CourseExemptions_TeachingTasks" FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id") ON DELETE RESTRICT);""",
@@ -0,0 +1,60 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class RefreshSessions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "RefreshSessions",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
UserId = table.Column<Guid>(type: "char(36)", nullable: false),
TokenHash = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: false),
ClientType = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false),
SecurityStamp = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: false),
ExpiresAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
LastRefreshedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
RevokedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
ReplacedBySessionId = table.Column<Guid>(type: "char(36)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_RefreshSessions", x => x.Id);
table.ForeignKey(
name: "FK_RefreshSessions_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_RefreshSessions_TokenHash",
table: "RefreshSessions",
column: "TokenHash",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_RefreshSessions_UserId_ExpiresAt",
table: "RefreshSessions",
columns: new[] { "UserId", "ExpiresAt" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "RefreshSessions");
}
}
}
@@ -0,0 +1,205 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class StudentPersonalProfile : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Biography",
table: "Students",
type: "varchar(1000)",
maxLength: 1000,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "CurrentAddress",
table: "Students",
type: "varchar(300)",
maxLength: 300,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "EmergencyContactName",
table: "Students",
type: "varchar(50)",
maxLength: 50,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "EmergencyContactPhone",
table: "Students",
type: "varchar(30)",
maxLength: 30,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "EmergencyContactRelationship",
table: "Students",
type: "varchar(30)",
maxLength: 30,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "EnglishName",
table: "Students",
type: "varchar(100)",
maxLength: 100,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Ethnicity",
table: "Students",
type: "varchar(50)",
maxLength: 50,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "HouseholdAddress",
table: "Students",
type: "varchar(300)",
maxLength: 300,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "IdCardNumber",
table: "Students",
type: "varchar(30)",
maxLength: 30,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Nationality",
table: "Students",
type: "varchar(50)",
maxLength: 50,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "NativePlace",
table: "Students",
type: "varchar(100)",
maxLength: 100,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "PoliticalStatus",
table: "Students",
type: "varchar(50)",
maxLength: 50,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "PostalCode",
table: "Students",
type: "varchar(20)",
maxLength: 20,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Qq",
table: "Students",
type: "varchar(30)",
maxLength: 30,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "SpecialNeeds",
table: "Students",
type: "varchar(1000)",
maxLength: 1000,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "SpecialTags",
table: "Students",
type: "varchar(300)",
maxLength: 300,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "WeChat",
table: "Students",
type: "varchar(60)",
maxLength: 60,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Biography",
table: "Students");
migrationBuilder.DropColumn(
name: "CurrentAddress",
table: "Students");
migrationBuilder.DropColumn(
name: "EmergencyContactName",
table: "Students");
migrationBuilder.DropColumn(
name: "EmergencyContactPhone",
table: "Students");
migrationBuilder.DropColumn(
name: "EmergencyContactRelationship",
table: "Students");
migrationBuilder.DropColumn(
name: "EnglishName",
table: "Students");
migrationBuilder.DropColumn(
name: "Ethnicity",
table: "Students");
migrationBuilder.DropColumn(
name: "HouseholdAddress",
table: "Students");
migrationBuilder.DropColumn(
name: "IdCardNumber",
table: "Students");
migrationBuilder.DropColumn(
name: "Nationality",
table: "Students");
migrationBuilder.DropColumn(
name: "NativePlace",
table: "Students");
migrationBuilder.DropColumn(
name: "PoliticalStatus",
table: "Students");
migrationBuilder.DropColumn(
name: "PostalCode",
table: "Students");
migrationBuilder.DropColumn(
name: "Qq",
table: "Students");
migrationBuilder.DropColumn(
name: "SpecialNeeds",
table: "Students");
migrationBuilder.DropColumn(
name: "SpecialTags",
table: "Students");
migrationBuilder.DropColumn(
name: "WeChat",
table: "Students");
}
}
}
@@ -0,0 +1,97 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class OtherExamResults : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "OtherExamBatches",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
Name = table.Column<string>(type: "varchar(150)", maxLength: 150, nullable: false),
Organizer = table.Column<string>(type: "varchar(150)", maxLength: 150, nullable: true),
ExamDate = table.Column<DateTime>(type: "date", nullable: false),
MetricKind = table.Column<int>(type: "int", nullable: false),
MaxScore = table.Column<decimal>(type: "decimal(8,2)", precision: 8, scale: 2, nullable: true),
LevelOptions = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
Status = table.Column<int>(type: "int", nullable: false),
PublicationCount = table.Column<int>(type: "int", nullable: false),
PublishedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_OtherExamBatches", x => x.Id);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "OtherExamResults",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
OtherExamBatchId = table.Column<Guid>(type: "char(36)", nullable: false),
StudentId = table.Column<Guid>(type: "char(36)", nullable: false),
AttemptNumber = table.Column<int>(type: "int", nullable: false),
Score = table.Column<decimal>(type: "decimal(8,2)", precision: 8, scale: 2, nullable: true),
Level = table.Column<string>(type: "varchar(50)", maxLength: 50, nullable: true),
IsPassed = table.Column<bool>(type: "tinyint(1)", nullable: true),
Notes = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_OtherExamResults", x => x.Id);
table.ForeignKey(
name: "FK_OtherExamResults_OtherExamBatches_OtherExamBatchId",
column: x => x.OtherExamBatchId,
principalTable: "OtherExamBatches",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_OtherExamResults_Students_StudentId",
column: x => x.StudentId,
principalTable: "Students",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_OtherExamBatches_Status_ExamDate",
table: "OtherExamBatches",
columns: new[] { "Status", "ExamDate" });
migrationBuilder.CreateIndex(
name: "IX_OtherExamResults_OtherExamBatchId_StudentId_AttemptNumber",
table: "OtherExamResults",
columns: new[] { "OtherExamBatchId", "StudentId", "AttemptNumber" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_OtherExamResults_StudentId_OtherExamBatchId",
table: "OtherExamResults",
columns: new[] { "StudentId", "OtherExamBatchId" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "OtherExamResults");
migrationBuilder.DropTable(
name: "OtherExamBatches");
}
}
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class OtherExamIdentityAndImport : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ExamCode",
table: "OtherExamBatches",
type: "varchar(60)",
maxLength: 60,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ExamCode",
table: "OtherExamBatches");
}
}
}
@@ -0,0 +1,113 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class CourseGradeStatistics : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "CourseGradeStatistics",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
CourseId = table.Column<Guid>(type: "char(36)", nullable: false),
AcademicTermId = table.Column<Guid>(type: "char(36)", nullable: false),
Scope = table.Column<int>(type: "int", nullable: false),
ScopeEntityId = table.Column<Guid>(type: "char(36)", nullable: true),
StudentCount = table.Column<int>(type: "int", nullable: false),
PassedCount = table.Column<int>(type: "int", nullable: false),
Below60Count = table.Column<int>(type: "int", nullable: false),
From60To69Count = table.Column<int>(type: "int", nullable: false),
From70To79Count = table.Column<int>(type: "int", nullable: false),
From80To89Count = table.Column<int>(type: "int", nullable: false),
From90To100Count = table.Column<int>(type: "int", nullable: false),
HighestScore = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: false),
AverageScore = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: false),
LowestScore = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: false),
PassRate = table.Column<decimal>(type: "decimal(5,2)", precision: 5, scale: 2, nullable: false),
CalculatedAt = 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_CourseGradeStatistics", x => x.Id);
table.ForeignKey(
name: "FK_CourseGradeStatistics_AcademicTerms_AcademicTermId",
column: x => x.AcademicTermId,
principalTable: "AcademicTerms",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_CourseGradeStatistics_Courses_CourseId",
column: x => x.CourseId,
principalTable: "Courses",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "CourseGradeStatisticsRefreshJobs",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
GradeSheetId = table.Column<Guid>(type: "char(36)", nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
StartedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
CompletedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
ErrorMessage = table.Column<string>(type: "varchar(2000)", maxLength: 2000, nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CourseGradeStatisticsRefreshJobs", x => x.Id);
table.ForeignKey(
name: "FK_CourseGradeStatisticsRefreshJobs_GradeSheets_GradeSheetId",
column: x => x.GradeSheetId,
principalTable: "GradeSheets",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_CourseGradeStatistics_AcademicTermId_Scope_ScopeEntityId",
table: "CourseGradeStatistics",
columns: new[] { "AcademicTermId", "Scope", "ScopeEntityId" });
migrationBuilder.CreateIndex(
name: "UX_CourseGradeStatistics_Scope",
table: "CourseGradeStatistics",
columns: new[] { "CourseId", "AcademicTermId", "Scope", "ScopeEntityId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_CourseGradeStatisticsRefreshJobs_GradeSheetId",
table: "CourseGradeStatisticsRefreshJobs",
column: "GradeSheetId");
migrationBuilder.CreateIndex(
name: "IX_CourseGradeStatisticsRefreshJobs_Status_CreatedAt",
table: "CourseGradeStatisticsRefreshJobs",
columns: new[] { "Status", "CreatedAt" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "CourseGradeStatistics");
migrationBuilder.DropTable(
name: "CourseGradeStatisticsRefreshJobs");
}
}
}
@@ -0,0 +1,132 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class TeachingTaskGradeAnalytics : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "TeachingTaskGradeStatistics",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
GradeSheetId = table.Column<Guid>(type: "char(36)", nullable: false),
TeachingTaskId = table.Column<Guid>(type: "char(36)", nullable: false),
CourseId = table.Column<Guid>(type: "char(36)", nullable: false),
AcademicTermId = table.Column<Guid>(type: "char(36)", nullable: false),
StudentCount = table.Column<int>(type: "int", nullable: false),
PassedCount = table.Column<int>(type: "int", nullable: false),
ExcellentCount = table.Column<int>(type: "int", nullable: false),
HighestScore = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: false),
AverageScore = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: false),
MedianScore = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: false),
LowestScore = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: false),
StandardDeviation = table.Column<decimal>(type: "decimal(6,2)", precision: 6, scale: 2, nullable: false),
PassRate = table.Column<decimal>(type: "decimal(5,2)", precision: 5, scale: 2, nullable: false),
ExcellentRate = table.Column<decimal>(type: "decimal(5,2)", precision: 5, scale: 2, nullable: false),
CalculatedAt = 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_TeachingTaskGradeStatistics", x => x.Id);
table.ForeignKey(
name: "FK_TeachingTaskGradeStatistics_AcademicTerms_AcademicTermId",
column: x => x.AcademicTermId,
principalTable: "AcademicTerms",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_TeachingTaskGradeStatistics_Courses_CourseId",
column: x => x.CourseId,
principalTable: "Courses",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_TeachingTaskGradeStatistics_GradeSheets_GradeSheetId",
column: x => x.GradeSheetId,
principalTable: "GradeSheets",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_TeachingTaskGradeStatistics_TeachingTasks_TeachingTaskId",
column: x => x.TeachingTaskId,
principalTable: "TeachingTasks",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "TeachingTaskGradeScoreBands",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
TeachingTaskGradeStatisticId = table.Column<Guid>(type: "char(36)", nullable: false),
Label = table.Column<string>(type: "varchar(30)", maxLength: 30, nullable: false),
LowerBound = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: false),
UpperBound = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: true),
StudentCount = table.Column<int>(type: "int", nullable: false),
SortOrder = table.Column<int>(type: "int", 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_TeachingTaskGradeScoreBands", x => x.Id);
table.ForeignKey(
name: "FK_TeachingTaskGradeScoreBands_TeachingTaskGradeStatistics_Teac~",
column: x => x.TeachingTaskGradeStatisticId,
principalTable: "TeachingTaskGradeStatistics",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_TeachingTaskGradeScoreBands_TeachingTaskGradeStatisticId_Sor~",
table: "TeachingTaskGradeScoreBands",
columns: new[] { "TeachingTaskGradeStatisticId", "SortOrder" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_TeachingTaskGradeStatistics_AcademicTermId",
table: "TeachingTaskGradeStatistics",
column: "AcademicTermId");
migrationBuilder.CreateIndex(
name: "IX_TeachingTaskGradeStatistics_CourseId_AcademicTermId",
table: "TeachingTaskGradeStatistics",
columns: new[] { "CourseId", "AcademicTermId" });
migrationBuilder.CreateIndex(
name: "IX_TeachingTaskGradeStatistics_GradeSheetId",
table: "TeachingTaskGradeStatistics",
column: "GradeSheetId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_TeachingTaskGradeStatistics_TeachingTaskId",
table: "TeachingTaskGradeStatistics",
column: "TeachingTaskId",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "TeachingTaskGradeScoreBands");
migrationBuilder.DropTable(
name: "TeachingTaskGradeStatistics");
}
}
}
@@ -983,6 +983,118 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.ToTable("CourseExemptions");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseGradeStatistic", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<Guid>("AcademicTermId")
.HasColumnType("char(36)");
b.Property<decimal>("AverageScore")
.HasPrecision(5, 1)
.HasColumnType("decimal(5,1)");
b.Property<int>("Below60Count")
.HasColumnType("int");
b.Property<DateTime>("CalculatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("CourseId")
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("From60To69Count")
.HasColumnType("int");
b.Property<int>("From70To79Count")
.HasColumnType("int");
b.Property<int>("From80To89Count")
.HasColumnType("int");
b.Property<int>("From90To100Count")
.HasColumnType("int");
b.Property<decimal>("HighestScore")
.HasPrecision(5, 1)
.HasColumnType("decimal(5,1)");
b.Property<decimal>("LowestScore")
.HasPrecision(5, 1)
.HasColumnType("decimal(5,1)");
b.Property<decimal>("PassRate")
.HasPrecision(5, 2)
.HasColumnType("decimal(5,2)");
b.Property<int>("PassedCount")
.HasColumnType("int");
b.Property<int>("Scope")
.HasColumnType("int");
b.Property<Guid?>("ScopeEntityId")
.HasColumnType("char(36)");
b.Property<int>("StudentCount")
.HasColumnType("int");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("AcademicTermId", "Scope", "ScopeEntityId");
b.HasIndex("CourseId", "AcademicTermId", "Scope", "ScopeEntityId")
.IsUnique()
.HasDatabaseName("UX_CourseGradeStatistics_Scope");
b.ToTable("CourseGradeStatistics");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseGradeStatisticsRefreshJob", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("ErrorMessage")
.HasMaxLength(2000)
.HasColumnType("varchar(2000)");
b.Property<Guid>("GradeSheetId")
.HasColumnType("char(36)");
b.Property<DateTime?>("StartedAt")
.HasColumnType("datetime(6)");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("GradeSheetId");
b.HasIndex("Status", "CreatedAt");
b.ToTable("CourseGradeStatisticsRefreshJobs");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CoursePrerequisite", b =>
{
b.Property<Guid>("Id")
@@ -3278,6 +3390,107 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.ToTable("OfficialDocumentDownloads");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.OtherExamBatch", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("ExamCode")
.HasMaxLength(60)
.HasColumnType("varchar(60)");
b.Property<DateTime>("ExamDate")
.HasColumnType("date");
b.Property<string>("LevelOptions")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<decimal?>("MaxScore")
.HasPrecision(8, 2)
.HasColumnType("decimal(8,2)");
b.Property<int>("MetricKind")
.HasColumnType("int");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("varchar(150)");
b.Property<string>("Organizer")
.HasMaxLength(150)
.HasColumnType("varchar(150)");
b.Property<int>("PublicationCount")
.HasColumnType("int");
b.Property<DateTime?>("PublishedAt")
.HasColumnType("datetime(6)");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("Status", "ExamDate");
b.ToTable("OtherExamBatches");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.OtherExamResult", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<int>("AttemptNumber")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<bool?>("IsPassed")
.HasColumnType("tinyint(1)");
b.Property<string>("Level")
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("Notes")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<Guid>("OtherExamBatchId")
.HasColumnType("char(36)");
b.Property<decimal?>("Score")
.HasPrecision(8, 2)
.HasColumnType("decimal(8,2)");
b.Property<Guid>("StudentId")
.HasColumnType("char(36)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("StudentId", "OtherExamBatchId");
b.HasIndex("OtherExamBatchId", "StudentId", "AttemptNumber")
.IsUnique();
b.ToTable("OtherExamResults");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleEntry", b =>
{
b.Property<Guid>("Id")
@@ -3297,6 +3510,7 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
.HasColumnType("int");
b.Property<int>("Kind")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(1);
@@ -3491,9 +3705,17 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Property<Guid>("AdministrativeClassId")
.HasColumnType("char(36)");
b.Property<string>("Biography")
.HasMaxLength(1000)
.HasColumnType("varchar(1000)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("CurrentAddress")
.HasMaxLength(300)
.HasColumnType("varchar(300)");
b.Property<DateTime?>("DateOfBirth")
.HasColumnType("date");
@@ -3501,20 +3723,56 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<string>("EmergencyContactName")
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("EmergencyContactPhone")
.HasMaxLength(30)
.HasColumnType("varchar(30)");
b.Property<string>("EmergencyContactRelationship")
.HasMaxLength(30)
.HasColumnType("varchar(30)");
b.Property<string>("EnglishName")
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<DateTime>("EnrollmentDate")
.HasColumnType("date");
b.Property<int>("EnrollmentYear")
.HasColumnType("int");
b.Property<string>("Ethnicity")
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<int>("Gender")
.HasColumnType("int");
b.Property<string>("HouseholdAddress")
.HasMaxLength(300)
.HasColumnType("varchar(300)");
b.Property<string>("IdCardNumber")
.HasMaxLength(30)
.HasColumnType("varchar(30)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("Nationality")
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("NativePlace")
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<string>("Notes")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
@@ -3523,6 +3781,26 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
.HasMaxLength(30)
.HasColumnType("varchar(30)");
b.Property<string>("PoliticalStatus")
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("PostalCode")
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<string>("Qq")
.HasMaxLength(30)
.HasColumnType("varchar(30)");
b.Property<string>("SpecialNeeds")
.HasMaxLength(1000)
.HasColumnType("varchar(1000)");
b.Property<string>("SpecialTags")
.HasMaxLength(300)
.HasColumnType("varchar(300)");
b.Property<int>("Status")
.HasColumnType("int");
@@ -3537,6 +3815,10 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Property<Guid?>("UserId")
.HasColumnType("char(36)");
b.Property<string>("WeChat")
.HasMaxLength(60)
.HasColumnType("varchar(60)");
b.HasKey("Id");
b.HasIndex("EnrollmentYear");
@@ -3827,6 +4109,127 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.ToTable("TeachingTaskClasses");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskGradeScoreBand", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Label")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("varchar(30)");
b.Property<decimal>("LowerBound")
.HasPrecision(5, 1)
.HasColumnType("decimal(5,1)");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.Property<int>("StudentCount")
.HasColumnType("int");
b.Property<Guid>("TeachingTaskGradeStatisticId")
.HasColumnType("char(36)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<decimal?>("UpperBound")
.HasPrecision(5, 1)
.HasColumnType("decimal(5,1)");
b.HasKey("Id");
b.HasIndex("TeachingTaskGradeStatisticId", "SortOrder")
.IsUnique();
b.ToTable("TeachingTaskGradeScoreBands");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskGradeStatistic", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<Guid>("AcademicTermId")
.HasColumnType("char(36)");
b.Property<decimal>("AverageScore")
.HasPrecision(5, 1)
.HasColumnType("decimal(5,1)");
b.Property<DateTime>("CalculatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("CourseId")
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("ExcellentCount")
.HasColumnType("int");
b.Property<decimal>("ExcellentRate")
.HasPrecision(5, 2)
.HasColumnType("decimal(5,2)");
b.Property<Guid>("GradeSheetId")
.HasColumnType("char(36)");
b.Property<decimal>("HighestScore")
.HasPrecision(5, 1)
.HasColumnType("decimal(5,1)");
b.Property<decimal>("LowestScore")
.HasPrecision(5, 1)
.HasColumnType("decimal(5,1)");
b.Property<decimal>("MedianScore")
.HasPrecision(5, 1)
.HasColumnType("decimal(5,1)");
b.Property<decimal>("PassRate")
.HasPrecision(5, 2)
.HasColumnType("decimal(5,2)");
b.Property<int>("PassedCount")
.HasColumnType("int");
b.Property<decimal>("StandardDeviation")
.HasPrecision(6, 2)
.HasColumnType("decimal(6,2)");
b.Property<int>("StudentCount")
.HasColumnType("int");
b.Property<Guid>("TeachingTaskId")
.HasColumnType("char(36)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("AcademicTermId");
b.HasIndex("GradeSheetId")
.IsUnique();
b.HasIndex("TeachingTaskId")
.IsUnique();
b.HasIndex("CourseId", "AcademicTermId");
b.ToTable("TeachingTaskGradeStatistics");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskScheduleConstraint", b =>
{
b.Property<Guid>("Id")
@@ -4133,6 +4536,55 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("Jiaowu.Api.Domain.Identity.RefreshSession", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<string>("ClientType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("LastRefreshedAt")
.HasColumnType("datetime(6)");
b.Property<Guid?>("ReplacedBySessionId")
.HasColumnType("char(36)");
b.Property<DateTime?>("RevokedAt")
.HasColumnType("datetime(6)");
b.Property<string>("SecurityStamp")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<Guid>("UserId")
.HasColumnType("char(36)");
b.HasKey("Id");
b.HasIndex("TokenHash")
.IsUnique();
b.HasIndex("UserId", "ExpiresAt");
b.ToTable("RefreshSessions");
});
modelBuilder.Entity("Jiaowu.Api.Domain.System.AppUpdateRelease", b =>
{
b.Property<Guid>("Id")
@@ -4652,6 +5104,30 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("TeachingTask");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseGradeStatistic", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", null)
.WithMany()
.HasForeignKey("AcademicTermId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Academic.Course", null)
.WithMany()
.HasForeignKey("CourseId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseGradeStatisticsRefreshJob", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.GradeSheet", null)
.WithMany()
.HasForeignKey("GradeSheetId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CoursePrerequisite", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.Course", "Course")
@@ -5480,6 +5956,25 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("OfficialDocument");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.OtherExamResult", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.OtherExamBatch", "OtherExamBatch")
.WithMany("Results")
.HasForeignKey("OtherExamBatchId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student")
.WithMany()
.HasForeignKey("StudentId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("OtherExamBatch");
b.Navigation("Student");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleEntry", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom")
@@ -5676,6 +6171,48 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("TeachingTask");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskGradeScoreBand", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTaskGradeStatistic", "TeachingTaskGradeStatistic")
.WithMany("ScoreBands")
.HasForeignKey("TeachingTaskGradeStatisticId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("TeachingTaskGradeStatistic");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskGradeStatistic", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", null)
.WithMany()
.HasForeignKey("AcademicTermId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Academic.Course", null)
.WithMany()
.HasForeignKey("CourseId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Academic.GradeSheet", "GradeSheet")
.WithOne()
.HasForeignKey("Jiaowu.Api.Domain.Academic.TeachingTaskGradeStatistic", "GradeSheetId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask")
.WithOne()
.HasForeignKey("Jiaowu.Api.Domain.Academic.TeachingTaskGradeStatistic", "TeachingTaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("GradeSheet");
b.Navigation("TeachingTask");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskScheduleConstraint", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.Building", "RequiredBuilding")
@@ -5742,6 +6279,17 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("AcademicTerm");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Identity.RefreshSession", b =>
{
b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
{
b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationRole", null)
@@ -5966,6 +6514,11 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("Downloads");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.OtherExamBatch", b =>
{
b.Navigation("Results");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.SchedulePlan", b =>
{
b.Navigation("Entries");
@@ -5978,6 +6531,11 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("Teachers");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskGradeStatistic", b =>
{
b.Navigation("ScoreBands");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskScheduleConstraint", b =>
{
b.Navigation("AllowedClassrooms");
+3 -1
View File
@@ -2,6 +2,7 @@
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Version>2.3.1-beta.1</Version>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<SpaRoot>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)/../../web'))</SpaRoot>
@@ -22,6 +23,7 @@
<ItemGroup>
<PackageReference Include="ClosedXML" Version="0.105.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.10" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Caching.Hybrid" Version="10.1.0" />
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="10.0.10" />
@@ -30,7 +32,7 @@
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.10" />
<PackageReference Include="MySql.EntityFrameworkCore" Version="10.0.7" />
<PackageReference Include="MySql.EntityFrameworkCore" Version="10.0.9" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.17.0" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.17.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.17.0" />
+104 -2
View File
@@ -2,6 +2,7 @@ using System.Text;
using System.Text.Json.Serialization;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.BackgroundJobs;
using Jiaowu.Api.Infrastructure.Grades;
using Jiaowu.Api.Infrastructure.Configuration;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Caching;
@@ -13,7 +14,10 @@ using Jiaowu.Api.Infrastructure.Operations;
using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Scheduling;
using Jiaowu.Api.Infrastructure.Timetables;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
@@ -91,6 +95,29 @@ var performanceReportingOptions = builder.Configuration
var rabbitMqOptions = builder.Configuration
.GetSection(RabbitMqOptions.SectionName)
.Get<RabbitMqOptions>() ?? new RabbitMqOptions();
var ssoOptions = builder.Configuration
.GetSection(SsoOptions.SectionName)
.Get<SsoOptions>() ?? new SsoOptions();
if (ssoOptions.Enabled &&
(string.IsNullOrWhiteSpace(ssoOptions.ClientId) ||
!Uri.TryCreate(ssoOptions.Authority, UriKind.Absolute, out var ssoAuthority) ||
ssoAuthority.Scheme is not ("http" or "https") ||
(ssoOptions.RequireHttpsMetadata && ssoAuthority.Scheme != "https") ||
string.IsNullOrWhiteSpace(ssoOptions.UserNameClaim) ||
(!string.IsNullOrWhiteSpace(ssoOptions.CallbackUrl) &&
(!Uri.TryCreate(ssoOptions.CallbackUrl, UriKind.Absolute, out var callbackUrl) ||
callbackUrl.Scheme is not ("http" or "https") ||
!callbackUrl.AbsolutePath.EndsWith(
"/signin-keycloak",
StringComparison.OrdinalIgnoreCase))) ||
(!string.IsNullOrWhiteSpace(ssoOptions.FrontendBaseUrl) &&
(!Uri.TryCreate(ssoOptions.FrontendBaseUrl, UriKind.Absolute, out var frontendBaseUrl) ||
frontendBaseUrl.Scheme is not ("http" or "https")))))
{
throw new InvalidOperationException(
"启用 Sso 时必须配置有效的 Authority、ClientId、UserNameClaim、CallbackUrl 和 FrontendBaseUrlCallbackUrl 必须以 /signin-keycloak 结尾,生产元数据地址必须使用 HTTPS。");
}
if (string.IsNullOrWhiteSpace(officialDocumentOptions.InstitutionName) ||
string.IsNullOrWhiteSpace(officialDocumentOptions.IssuingOffice) ||
@@ -197,6 +224,7 @@ if (backgroundJobOptions.PollIntervalMilliseconds is < 100 or > 30000 ||
backgroundJobOptions.ExamArrangementConcurrency is < 1 or > 16 ||
backgroundJobOptions.ExamSignInExportConcurrency is < 1 or > 16 ||
backgroundJobOptions.ExamPublishConcurrency is < 1 or > 16 ||
backgroundJobOptions.CourseGradeStatisticsRefreshConcurrency is < 1 or > 16 ||
backgroundJobOptions.ProcessingAttemptLimit is < 1 or > 100 ||
backgroundJobOptions.MaintenanceIntervalSeconds is < 10 or > 3600 ||
backgroundJobOptions.CompletedRetentionDays is < 1 or > 3650 ||
@@ -330,6 +358,10 @@ if (cacheOptions.Enabled && !string.IsNullOrWhiteSpace(redisConnectionString))
builder.Services.AddStackExchangeRedisCache(options =>
options.Configuration = redisConnectionString);
}
else
{
builder.Services.AddDistributedMemoryCache();
}
builder.Services.AddHybridCache(options =>
{
options.MaximumKeyLength = 512;
@@ -360,11 +392,22 @@ if (Encoding.UTF8.GetByteCount(jwtOptions.Key) < 32 ||
throw new InvalidOperationException(
"Jwt:Key 必须配置为至少 32 字节的随机生产密钥,不能使用示例值。");
}
if (jwtOptions.AccessTokenMinutes is < 1 or > 30 ||
jwtOptions.WebIdleMinutes is < 5 or > 1440 ||
jwtOptions.AppIdleMinutes is < 60 or > 43200 ||
jwtOptions.AccessTokenMinutes > jwtOptions.WebIdleMinutes)
{
throw new InvalidOperationException(
"Jwt 访问令牌或 Web/App 空闲有效期配置超出允许范围。");
}
builder.Services.Configure<JwtOptions>(
builder.Configuration.GetSection(JwtOptions.SectionName));
builder.Services.Configure<SsoOptions>(
builder.Configuration.GetSection(SsoOptions.SectionName));
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ITokenService, TokenService>();
builder.Services.AddScoped<IAuthSessionService, AuthSessionService>();
builder.Services.AddScoped<ICurrentUserDataScope, CurrentUserDataScope>();
builder.Services.AddScoped<DatabaseInitializer>();
builder.Services.AddScoped<DemoDataSeeder>();
@@ -384,6 +427,7 @@ builder.Services.AddScoped<MakeupExamAutoJobProcessor>();
builder.Services.AddScoped<ExamArrangementJobProcessor>();
builder.Services.AddScoped<ExamSignInExportJobProcessor>();
builder.Services.AddScoped<ExamPublishJobProcessor>();
builder.Services.AddScoped<CourseGradeStatisticsRefreshJobProcessor>();
builder.Services.AddSingleton<BackgroundJobTelemetry>();
builder.Services.AddScoped<BackgroundJobMonitoringService>();
builder.Services.AddScoped<OperationalHealthService>();
@@ -407,8 +451,12 @@ builder.Services.AddHostedService<BackgroundJobOutboxPublisher>();
builder.Services.AddSingleton<IOfficialDocumentPdfGenerator, OfficialDocumentPdfGenerator>();
builder.Services.AddScoped<OfficialDocumentService>();
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
var authentication = builder.Services
.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
@@ -423,7 +471,51 @@ builder.Services
Encoding.UTF8.GetBytes(jwtOptions.Key)),
ClockSkew = TimeSpan.FromMinutes(1)
};
})
.AddCookie(SsoAuthSchemes.ExternalCookie, options =>
{
options.Cookie.Name = "__Host-jiaowu-sso";
options.Cookie.HttpOnly = true;
options.Cookie.SameSite = SameSiteMode.Lax;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
options.ExpireTimeSpan = TimeSpan.FromMinutes(5);
});
if (ssoOptions.Enabled)
{
authentication.AddOpenIdConnect(SsoAuthSchemes.Keycloak, options =>
{
options.Authority = ssoOptions.Authority.TrimEnd('/');
options.ClientId = ssoOptions.ClientId;
options.ClientSecret = ssoOptions.ClientSecret;
options.SignInScheme = SsoAuthSchemes.ExternalCookie;
options.ResponseType = "code";
options.UsePkce = true;
options.SaveTokens = false;
options.RequireHttpsMetadata = ssoOptions.RequireHttpsMetadata;
options.CallbackPath = "/signin-keycloak";
options.GetClaimsFromUserInfoEndpoint = true;
options.MapInboundClaims = false;
options.ClaimActions.MapUniqueJsonKey(
ssoOptions.UserNameClaim,
ssoOptions.UserNameClaim);
options.TokenValidationParameters.NameClaimType = ssoOptions.UserNameClaim;
options.Events.OnRedirectToIdentityProvider = context =>
{
if (!string.IsNullOrWhiteSpace(ssoOptions.CallbackUrl))
context.ProtocolMessage.RedirectUri = ssoOptions.CallbackUrl;
return Task.CompletedTask;
};
options.Events.OnRemoteFailure = context =>
{
context.HandleResponse();
var loginUrl = string.IsNullOrWhiteSpace(ssoOptions.FrontendBaseUrl)
? "/login"
: ssoOptions.FrontendBaseUrl.TrimEnd('/') + "/login";
context.Response.Redirect(loginUrl + "?ssoError=authentication_failed");
return Task.CompletedTask;
};
});
}
builder.Services.AddAuthorization();
builder.Services.AddRateLimiter(options =>
{
@@ -438,6 +530,16 @@ builder.Services.AddRateLimiter(options =>
QueueLimit = 0,
AutoReplenishment = true
}));
options.AddPolicy("token-refresh", context =>
RateLimitPartition.GetFixedWindowLimiter(
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = 600,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
AutoReplenishment = true
}));
options.AddPolicy("official-verification", context =>
RateLimitPartition.GetFixedWindowLimiter(
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
+7 -1
View File
@@ -10,7 +10,13 @@
},
"Jwt": {
"Key": "jiaowu-development-secret-key-change-before-production",
"ExpireMinutes": 480
"AccessTokenMinutes": 10,
"WebIdleMinutes": 30,
"AppIdleMinutes": 4320
},
"Sso": {
"FrontendBaseUrl": "http://localhost:5173",
"CallbackUrl": "http://localhost:5255/signin-keycloak"
},
"Cors": {
"Origins": [
+16 -1
View File
@@ -56,6 +56,7 @@
"SchedulePublishConcurrency": 1,
"MakeupExamAutoConcurrency": 1,
"ExamArrangementConcurrency": 1,
"CourseGradeStatisticsRefreshConcurrency": 1,
"Exchange": "jiaowu.background-jobs",
"QueuePrefix": "jiaowu.background-jobs",
"UseQuorumQueues": true,
@@ -77,7 +78,21 @@
"Issuer": "Jiaowu.Api",
"Audience": "Jiaowu.Web",
"Key": "",
"ExpireMinutes": 60
"AccessTokenMinutes": 10,
"WebIdleMinutes": 30,
"AppIdleMinutes": 4320
},
"Sso": {
"Enabled": false,
"DisplayName": "学校统一身份认证",
"Authority": "",
"ClientId": "",
"ClientSecret": "",
"UserNameClaim": "preferred_username",
"RequireHttpsMetadata": true,
"LinkExistingUsersByUserName": true,
"FrontendBaseUrl": "",
"CallbackUrl": ""
},
"Cors": {
"Origins": [
+22 -3
View File
@@ -107,7 +107,7 @@ public sealed class AuthControllerTests
var controller = new AuthController(
db,
userManager,
new StubTokenService(),
new StubAuthSessionService(),
NoOpAppCache.Instance);
var request = new StudentActivationRequest(
student.Name,
@@ -143,8 +143,27 @@ public sealed class AuthControllerTests
protected override bool ShouldRetryOn(Exception exception) => false;
}
private sealed class StubTokenService : ITokenService
private sealed class StubAuthSessionService : IAuthSessionService
{
public string Create(ApplicationUser user, IEnumerable<string> roles) => string.Empty;
public Task<AuthSessionResult> CreateAsync(
ApplicationUser user,
IEnumerable<string> roles,
AuthenticationClientType clientType,
CancellationToken cancellationToken = default) =>
Task.FromResult(new AuthSessionResult(
string.Empty,
DateTime.UtcNow.AddMinutes(10),
"test-refresh-token-value-with-sufficient-length",
DateTime.UtcNow.AddMinutes(30),
user,
roles.ToList()));
public Task<AuthSessionResult?> RefreshAsync(
string refreshToken,
CancellationToken cancellationToken = default) => Task.FromResult<AuthSessionResult?>(null);
public Task RevokeAsync(
string refreshToken,
CancellationToken cancellationToken = default) => Task.CompletedTask;
}
}
@@ -0,0 +1,133 @@
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Identity;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace Jiaowu.Api.Tests;
public sealed class AuthSessionServiceTests
{
[Fact]
public async Task Web_session_rotates_refresh_token_and_rejects_reuse()
{
await using var fixture = await SessionFixture.CreateAsync();
var issued = await fixture.Service.CreateAsync(
fixture.User,
[SystemRoles.Student],
AuthenticationClientType.Web);
Assert.InRange(
issued.SessionExpiresAt,
DateTime.UtcNow.AddMinutes(29),
DateTime.UtcNow.AddMinutes(31));
var stored = await fixture.Db.RefreshSessions.SingleAsync();
Assert.NotEqual(issued.RefreshToken, stored.TokenHash);
var refreshed = await fixture.Service.RefreshAsync(issued.RefreshToken);
Assert.NotNull(refreshed);
Assert.NotEqual(issued.RefreshToken, refreshed.RefreshToken);
Assert.Null(await fixture.Service.RefreshAsync(issued.RefreshToken));
Assert.Equal(1, await fixture.Db.RefreshSessions.CountAsync(x => x.RevokedAt == null));
}
[Fact]
public async Task App_session_uses_three_day_sliding_window()
{
await using var fixture = await SessionFixture.CreateAsync();
var issued = await fixture.Service.CreateAsync(
fixture.User,
[SystemRoles.Student],
AuthenticationClientType.App);
Assert.InRange(
issued.SessionExpiresAt,
DateTime.UtcNow.AddDays(3).AddMinutes(-1),
DateTime.UtcNow.AddDays(3).AddMinutes(1));
}
private sealed class SessionFixture : IAsyncDisposable
{
private readonly SqliteConnection _connection;
private readonly ServiceProvider _provider;
private readonly AsyncServiceScope _scope;
private SessionFixture(
SqliteConnection connection,
ServiceProvider provider,
AsyncServiceScope scope,
AppDbContext db,
ApplicationUser user,
IAuthSessionService service)
{
_connection = connection;
_provider = provider;
_scope = scope;
Db = db;
User = user;
Service = service;
}
public AppDbContext Db { get; }
public ApplicationUser User { get; }
public IAuthSessionService Service { get; }
public static async Task<SessionFixture> CreateAsync()
{
var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var services = new ServiceCollection();
services.AddLogging();
services.AddDbContext<AppDbContext>(options => options.UseSqlite(connection));
services.Configure<JwtOptions>(options =>
{
options.Issuer = "tests";
options.Audience = "tests-web";
options.Key = "a-test-signing-key-that-is-at-least-32-bytes-long";
options.AccessTokenMinutes = 10;
options.WebIdleMinutes = 30;
options.AppIdleMinutes = 4320;
});
services
.AddIdentityCore<ApplicationUser>()
.AddRoles<ApplicationRole>()
.AddEntityFrameworkStores<AppDbContext>();
services.AddScoped<ITokenService, TokenService>();
services.AddScoped<IAuthSessionService, AuthSessionService>();
var provider = services.BuildServiceProvider();
var scope = provider.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.EnsureCreatedAsync();
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
var user = new ApplicationUser
{
UserName = "session-user",
DisplayName = "会话测试用户",
IsEnabled = true,
LockoutEnabled = true
};
Assert.True((await userManager.CreateAsync(user, "SessionUser@123")).Succeeded);
return new SessionFixture(
connection,
provider,
scope,
db,
user,
scope.ServiceProvider.GetRequiredService<IAuthSessionService>());
}
public async ValueTask DisposeAsync()
{
await _scope.DisposeAsync();
await _provider.DisposeAsync();
await _connection.DisposeAsync();
}
}
}
@@ -235,6 +235,39 @@ public sealed class MySqlMigrationTests
StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void MySql_experiment_batch_project_query_is_translatable()
{
using var db = new AppDbContext(CreateMySqlOptions());
var projectIds = new[]
{
Guid.Parse("11111111-1111-1111-1111-111111111111"),
Guid.Parse("22222222-2222-2222-2222-222222222222")
};
var accessibleTaskIds = db.TeachingTasks.Select(x => x.Id);
var sql = db.ExperimentProjects
.Where(x => accessibleTaskIds.Contains(x.TeachingTaskId))
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.AcademicTerm)
.WhereIn(projectIds, x => x.Id)
.ToQueryString();
Assert.Contains(
"ExperimentProjects",
sql,
StringComparison.OrdinalIgnoreCase);
Assert.Contains(" IN (", sql, StringComparison.OrdinalIgnoreCase);
Assert.Contains(
projectIds[0].ToString(),
sql,
StringComparison.OrdinalIgnoreCase);
Assert.Contains(
projectIds[1].ToString(),
sql,
StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void MySql_index_names_fit_the_server_identifier_limit()
{
@@ -0,0 +1,245 @@
using System.Text.Json;
using System.Security.Claims;
using Jiaowu.Api.Controllers;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
namespace Jiaowu.Api.Tests;
public sealed class SsoControllerTests
{
[Fact]
public async Task Bind_WithValidLocalCredentials_LinksKeycloakIdentityAndLogsIn()
{
await using var fixture = await BindingFixture.CreateAsync();
var code = new string('a', 32);
await fixture.StoreTicketAsync(code, "keycloak-subject", "external.name");
var result = await fixture.Controller.Bind(
new SsoBindRequest(code, fixture.User.UserName!, "LocalUser@123"),
CancellationToken.None);
var response = Assert.IsType<LoginResponse>(result.Value);
Assert.Equal("test-token", response.Token);
Assert.Equal(fixture.User.Id, response.User.Id);
var linkedUser = await fixture.UserManager.FindByLoginAsync(
SsoAuthSchemes.LoginProvider,
"keycloak-subject");
Assert.Equal(fixture.User.Id, linkedUser?.Id);
Assert.Null(await fixture.Cache.GetStringAsync($"sso:binding:{code}"));
}
[Fact]
public async Task Bind_WithWrongPassword_DoesNotLinkKeycloakIdentity()
{
await using var fixture = await BindingFixture.CreateAsync();
var code = new string('b', 32);
await fixture.StoreTicketAsync(code, "keycloak-subject", "external.name");
var result = await fixture.Controller.Bind(
new SsoBindRequest(code, fixture.User.UserName!, "WrongPassword@123"),
CancellationToken.None);
Assert.NotNull(result.Result);
Assert.Null(await fixture.UserManager.FindByLoginAsync(
SsoAuthSchemes.LoginProvider,
"keycloak-subject"));
var user = await fixture.UserManager.FindByIdAsync(fixture.User.Id.ToString());
Assert.Equal(1, user?.AccessFailedCount);
}
[Fact]
public async Task Account_ReportsCurrentKeycloakBindingAndConfiguredCallback()
{
await using var fixture = await BindingFixture.CreateAsync();
Assert.True((await fixture.UserManager.AddLoginAsync(
fixture.User,
new UserLoginInfo(
SsoAuthSchemes.LoginProvider,
"keycloak-subject",
"学校统一身份认证"))).Succeeded);
fixture.SignInController();
var result = await fixture.Controller.Account();
var response = Assert.IsType<SsoAccountResponse>(result.Value);
Assert.True(response.Enabled);
Assert.True(response.IsBound);
Assert.Equal(
"https://jiaowu.example.edu.cn/signin-keycloak",
response.CallbackUrl);
}
[Fact]
public async Task Unbind_WithCurrentPassword_RemovesOnlyKeycloakLogin()
{
await using var fixture = await BindingFixture.CreateAsync();
Assert.True((await fixture.UserManager.AddLoginAsync(
fixture.User,
new UserLoginInfo(
SsoAuthSchemes.LoginProvider,
"keycloak-subject",
"学校统一身份认证"))).Succeeded);
fixture.SignInController();
var result = await fixture.Controller.Unbind(
new SsoUnbindRequest("LocalUser@123"));
Assert.IsType<NoContentResult>(result);
Assert.Null(await fixture.UserManager.FindByLoginAsync(
SsoAuthSchemes.LoginProvider,
"keycloak-subject"));
Assert.True(await fixture.UserManager.CheckPasswordAsync(
fixture.User,
"LocalUser@123"));
}
[Theory]
[InlineData(null, "/dashboard")]
[InlineData("", "/dashboard")]
[InlineData("https://evil.example/path", "/dashboard")]
[InlineData("//evil.example/path", "/dashboard")]
[InlineData("/grades?term=2026-1", "/grades?term=2026-1")]
public void NormalizeReturnUrl_AllowsOnlyLocalApplicationPaths(
string? value,
string expected)
{
Assert.Equal(expected, SsoController.NormalizeReturnUrl(value));
}
private sealed class BindingFixture : IAsyncDisposable
{
private readonly SqliteConnection _connection;
private readonly ServiceProvider _provider;
private BindingFixture(
SqliteConnection connection,
ServiceProvider provider,
ApplicationUser user,
UserManager<ApplicationUser> userManager,
IDistributedCache cache,
SsoController controller)
{
_connection = connection;
_provider = provider;
User = user;
UserManager = userManager;
Cache = cache;
Controller = controller;
}
public ApplicationUser User { get; }
public UserManager<ApplicationUser> UserManager { get; }
public IDistributedCache Cache { get; }
public SsoController Controller { get; }
public void SignInController()
{
Controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity(
[new Claim(ClaimTypes.NameIdentifier, User.Id.ToString())],
"test"))
}
};
}
public static async Task<BindingFixture> CreateAsync()
{
var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var services = new ServiceCollection();
services.AddLogging();
services.AddDistributedMemoryCache();
services.AddDbContext<AppDbContext>(options => options.UseSqlite(connection));
services
.AddIdentityCore<ApplicationUser>()
.AddRoles<ApplicationRole>()
.AddEntityFrameworkStores<AppDbContext>();
var provider = services.BuildServiceProvider();
var db = provider.GetRequiredService<AppDbContext>();
await db.Database.EnsureCreatedAsync();
var userManager = provider.GetRequiredService<UserManager<ApplicationUser>>();
var user = new ApplicationUser
{
UserName = "local.user",
DisplayName = "本地用户",
IsEnabled = true,
LockoutEnabled = true
};
Assert.True((await userManager.CreateAsync(user, "LocalUser@123")).Succeeded);
var cache = provider.GetRequiredService<IDistributedCache>();
var controller = new SsoController(
userManager,
new StubAuthSessionService(),
cache,
Options.Create(new SsoOptions
{
Enabled = true,
DisplayName = "学校统一身份认证",
CallbackUrl = "https://jiaowu.example.edu.cn/signin-keycloak"
}),
NullLogger<SsoController>.Instance);
return new BindingFixture(
connection,
provider,
user,
userManager,
cache,
controller);
}
public Task StoreTicketAsync(
string code,
string subject,
string externalUserName) =>
Cache.SetStringAsync(
$"sso:binding:{code}",
JsonSerializer.Serialize(
new SsoBindingTicket(subject, externalUserName)));
public async ValueTask DisposeAsync()
{
await _provider.DisposeAsync();
await _connection.DisposeAsync();
}
}
private sealed class StubAuthSessionService : IAuthSessionService
{
public Task<AuthSessionResult> CreateAsync(
ApplicationUser user,
IEnumerable<string> roles,
AuthenticationClientType clientType,
CancellationToken cancellationToken = default) =>
Task.FromResult(new AuthSessionResult(
"test-token",
DateTime.UtcNow.AddMinutes(10),
"test-refresh-token-value-with-sufficient-length",
DateTime.UtcNow.AddMinutes(30),
user,
roles.ToList()));
public Task<AuthSessionResult?> RefreshAsync(
string refreshToken,
CancellationToken cancellationToken = default) => Task.FromResult<AuthSessionResult?>(null);
public Task RevokeAsync(
string refreshToken,
CancellationToken cancellationToken = default) => Task.CompletedTask;
}
}
@@ -0,0 +1,153 @@
using Jiaowu.Api.Controllers;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Tests;
public sealed class StudentProfileControllerTests
{
[Fact]
public async Task Student_can_update_profile_without_changing_identity_or_registration()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options;
await using var db = new AppDbContext(options);
await db.Database.EnsureCreatedAsync();
var user = new ApplicationUser
{
Id = Guid.NewGuid(),
UserName = "2026001",
NormalizedUserName = "2026001",
DisplayName = "原姓名"
};
var college = new College { Code = "CS", Name = "计算机学院" };
var major = new Major
{
Code = "080901",
Name = "计算机科学与技术",
CollegeId = college.Id,
DegreeType = "工学学士"
};
var administrativeClass = new AdministrativeClass
{
Code = "CS2026-01",
Name = "计科 2026-1 班",
MajorId = major.Id,
Grade = 2026
};
var student = new Student
{
StudentNumber = "2026001",
Name = "原姓名",
UserId = user.Id,
AdministrativeClassId = administrativeClass.Id,
EnrollmentYear = 2026,
EnrollmentDate = new DateOnly(2026, 9, 1),
Status = StudentStatus.Active
};
db.AddRange(user, college);
await db.SaveChangesAsync();
db.Add(major);
await db.SaveChangesAsync();
db.Add(administrativeClass);
await db.SaveChangesAsync();
db.Add(student);
await db.SaveChangesAsync();
var controller = new StudentProfileController(
db,
new StudentDataScope(user.Id),
new NoOpCache());
var request = new StudentProfileUpdateRequest(
Gender.Female,
new DateOnly(2008, 3, 12),
" Alice ",
"370000000000000000",
"中国",
"汉族",
"共青团员",
"山东济南",
"户籍地址",
"现住地址",
"250000",
"13800000000",
"student@example.edu.cn",
"123456",
"alice-wechat",
"家长",
"母亲",
"13900000000",
"听力支持, 走读",
"上课时需要靠前座位",
"个人简介");
Assert.IsType<NoContentResult>(await controller.Update(
request,
CancellationToken.None));
db.ChangeTracker.Clear();
var updated = await db.Students.SingleAsync();
Assert.Equal("2026001", updated.StudentNumber);
Assert.Equal("原姓名", updated.Name);
Assert.Equal(administrativeClass.Id, updated.AdministrativeClassId);
Assert.Equal(StudentStatus.Active, updated.Status);
Assert.Equal("Alice", updated.EnglishName);
Assert.Equal("13800000000", updated.Phone);
Assert.Equal("听力支持, 走读", updated.SpecialTags);
Assert.Equal("上课时需要靠前座位", updated.SpecialNeeds);
}
[Fact]
public async Task Missing_linked_student_returns_not_found()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options;
await using var db = new AppDbContext(options);
await db.Database.EnsureCreatedAsync();
var controller = new StudentProfileController(
db,
new StudentDataScope(Guid.NewGuid()),
new NoOpCache());
var result = await controller.Get(CancellationToken.None);
Assert.IsType<NotFoundResult>(result.Result);
}
private sealed class NoOpCache : IAppCache
{
public Task<T> GetOrCreateAsync<T>(
string key,
Func<CancellationToken, Task<T>> factory,
AppCacheProfile profile,
IReadOnlyCollection<string> tags,
CancellationToken cancellationToken) => factory(cancellationToken);
public ValueTask RemoveByTagAsync(
string tag,
CancellationToken cancellationToken = default) => ValueTask.CompletedTask;
}
private sealed class StudentDataScope(Guid userId) : ICurrentUserDataScope
{
public CurrentUserScope Current { get; } = new(
userId,
"测试学生",
null,
DataScope.Self,
new HashSet<string>([SystemRoles.Student]));
}
}
@@ -0,0 +1,20 @@
using System.Reflection;
using Jiaowu.Api.Controllers;
namespace Jiaowu.Api.Tests;
public sealed class SystemControllerTests
{
[Fact]
public void GetVersion_ReturnsConfiguredApplicationVersion()
{
var response = new SystemController().GetVersion();
var configuredVersion = typeof(SystemController).Assembly
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()!
.InformationalVersion
.Split('+', 2)[0];
Assert.Equal(configuredVersion, response.Version);
Assert.Matches(@"^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$", response.Version);
}
}
@@ -0,0 +1,123 @@
using ClosedXML.Excel;
using Jiaowu.Api.Controllers;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Tests;
public sealed class TeachingTaskRosterProfileTests
{
[Fact]
public async Task Assigned_teacher_receives_contact_and_special_fields_in_roster_and_export()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options;
await using var db = new AppDbContext(options);
await db.Database.EnsureCreatedAsync();
var teacherUser = User("T001", "任课教师");
var studentUser = User("S001", "学生甲");
var college = new College { Code = "CS", Name = "计算机学院" };
var major = new Major
{
Code = "080901", Name = "计算机科学与技术",
CollegeId = college.Id, DegreeType = "工学学士"
};
var administrativeClass = new AdministrativeClass
{
Code = "CS2026-01", Name = "计科一班", MajorId = major.Id, Grade = 2026
};
var teacher = new Teacher
{
TeacherNumber = "T001", Name = "任课教师", CollegeId = college.Id,
UserId = teacherUser.Id
};
var student = new Student
{
StudentNumber = "S001", Name = "学生甲", UserId = studentUser.Id,
AdministrativeClassId = administrativeClass.Id,
EnrollmentYear = 2026, EnrollmentDate = new DateOnly(2026, 9, 1),
Phone = "13800000000", Email = "s@example.edu.cn", WeChat = "student-wechat",
EmergencyContactName = "家长", EmergencyContactRelationship = "母亲",
EmergencyContactPhone = "13900000000", SpecialTags = "走读",
SpecialNeeds = "需留意交通延误"
};
var term = new AcademicTerm
{
Code = "2026-1", Name = "2026-2027-1", AcademicYear = "2026-2027",
Season = TermSeason.Autumn, StartDate = new DateOnly(2026, 9, 1),
EndDate = new DateOnly(2027, 1, 20)
};
var course = new Course
{
Code = "CS101", Name = "程序设计", CollegeId = college.Id,
Credits = 4, TotalHours = 64, LectureHours = 48, PracticeHours = 16,
Nature = CourseNature.MajorRequired,
AssessmentMethod = AssessmentMethod.Examination
};
db.AddRange(teacherUser, studentUser, college);
await db.SaveChangesAsync();
db.AddRange(major, teacher, term, course);
await db.SaveChangesAsync();
db.Add(administrativeClass);
await db.SaveChangesAsync();
db.Add(student);
await db.SaveChangesAsync();
var task = new TeachingTask
{
TaskNumber = "2026-CS101-01", Name = "程序设计教学班",
AcademicTermId = term.Id, CourseId = course.Id, Capacity = 40,
Status = TeachingTaskStatus.Published,
Teachers = [new TeachingTaskTeacher { TeacherId = teacher.Id }],
Classes = [new TeachingTaskClass { AdministrativeClassId = administrativeClass.Id }]
};
db.Add(task);
await db.SaveChangesAsync();
var controller = new CourseSelectionsController(
db,
new TeacherDataScope(teacherUser.Id));
var rosterResult = Assert.IsType<OkObjectResult>(
await controller.GetTeachingTaskRoster(task.Id, CancellationToken.None));
var students = Assert.IsAssignableFrom<System.Collections.IEnumerable>(
rosterResult.Value!.GetType().GetProperty("Students")!.GetValue(rosterResult.Value));
var row = Assert.Single(students.Cast<object>());
Assert.Equal("13800000000", Property(row, "Phone"));
Assert.Equal("走读", Property(row, "SpecialTags"));
Assert.Equal("需留意交通延误", Property(row, "SpecialNeeds"));
var export = Assert.IsType<FileContentResult>(
await controller.ExportTeachingTaskRoster(task.Id, CancellationToken.None));
using var stream = new MemoryStream(export.FileContents);
using var workbook = new XLWorkbook(stream);
var sheet = workbook.Worksheet("教学班名单");
Assert.Equal("联系电话", sheet.Cell(1, 5).GetString());
Assert.Equal("特殊标记", sheet.Cell(1, 11).GetString());
Assert.Equal("13800000000", sheet.Cell(2, 5).GetString());
Assert.Equal("走读", sheet.Cell(2, 11).GetString());
}
private static string? Property(object value, string name) =>
value.GetType().GetProperty(name)!.GetValue(value)?.ToString();
private static ApplicationUser User(string userName, string displayName) => new()
{
Id = Guid.NewGuid(), UserName = userName,
NormalizedUserName = userName.ToUpperInvariant(), DisplayName = displayName
};
private sealed class TeacherDataScope(Guid userId) : ICurrentUserDataScope
{
public CurrentUserScope Current { get; } = new(
userId, "任课教师", null, DataScope.Self,
new HashSet<string>([SystemRoles.Teacher]));
}
}
+6 -2
View File
@@ -25,12 +25,16 @@ public sealed class TokenServiceTests
};
var service = new TokenService(options);
var token = new JwtSecurityTokenHandler().ReadJwtToken(
service.Create(user, [SystemRoles.Teacher]));
var result = service.Create(user, [SystemRoles.Teacher]);
var token = new JwtSecurityTokenHandler().ReadJwtToken(result.Token);
Assert.Contains(token.Claims, x =>
x.Type == ClaimTypes.Role && x.Value == SystemRoles.Teacher);
Assert.Contains(token.Claims, x =>
x.Type == ClaimTypes.Name && x.Value == "陈老师");
Assert.InRange(
result.ExpiresAt,
DateTime.UtcNow.AddMinutes(9),
DateTime.UtcNow.AddMinutes(11));
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "web",
"private": true,
"version": "1.0.0",
"version": "2.3.1-beta.1",
"type": "module",
"scripts": {
"dev": "vite",
+2
View File
@@ -1,8 +1,10 @@
<script setup lang="ts">
import SiteFooter from './components/SiteFooter.vue'
import SmartLaunchScreen from './components/SmartLaunchScreen.vue'
</script>
<template>
<SmartLaunchScreen />
<RouterView />
<SiteFooter />
</template>
+39 -6
View File
@@ -1,23 +1,56 @@
import axios from 'axios'
import { goLogin } from '../utils/navigate'
import {
authStorageKeys,
clearAuthSession,
markActivity,
refreshAuthSession,
refreshIfNeeded,
} from '../auth/session'
const http = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL ?? '/api',
timeout: 15000,
})
http.interceptors.request.use((config) => {
const token = localStorage.getItem('jiaowu_token')
http.interceptors.request.use(async (config) => {
const isAuthenticationRequest =
config.url?.endsWith('/auth/login') ||
config.url?.endsWith('/auth/refresh') ||
config.url?.endsWith('/auth/logout') ||
config.url?.endsWith('/auth/sso/exchange') ||
config.url?.endsWith('/auth/sso/bind')
if (!isAuthenticationRequest) {
const activeToken = await refreshIfNeeded(true)
if (activeToken) markActivity()
}
const token = localStorage.getItem(authStorageKeys.token)
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
http.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401 && !error.config?.url?.endsWith('/auth/login')) {
localStorage.removeItem('jiaowu_token')
localStorage.removeItem('jiaowu_user')
async (error) => {
const isAuthenticationRequest =
error.config?.url?.endsWith('/auth/login') ||
error.config?.url?.endsWith('/auth/refresh') ||
error.config?.url?.endsWith('/auth/logout') ||
error.config?.url?.endsWith('/auth/sso/exchange') ||
error.config?.url?.endsWith('/auth/sso/bind')
const retryableConfig = error.config as
(typeof error.config & { _jiaowuRetried?: boolean }) | undefined
if (error.response?.status === 401 && !isAuthenticationRequest &&
!retryableConfig?._jiaowuRetried) {
const token = await refreshAuthSession()
if (token && retryableConfig) {
retryableConfig._jiaowuRetried = true
retryableConfig.headers.Authorization = `Bearer ${token}`
return http.request(retryableConfig)
}
}
if (error.response?.status === 401 && !isAuthenticationRequest) {
clearAuthSession()
goLogin(location.pathname + location.search + location.hash)
}
return Promise.reject(error)
+167
View File
@@ -0,0 +1,167 @@
import axios from 'axios'
import { Capacitor } from '@capacitor/core'
const TOKEN_KEY = 'jiaowu_token'
const REFRESH_TOKEN_KEY = 'jiaowu_refresh_token'
const ACCESS_EXPIRES_KEY = 'jiaowu_access_expires_at'
const SESSION_EXPIRES_KEY = 'jiaowu_session_expires_at'
const USER_KEY = 'jiaowu_user'
const LAST_ACTIVITY_KEY = 'jiaowu_last_activity_at'
const WEB_IDLE_MILLISECONDS = 30 * 60 * 1000
const REFRESH_AHEAD_MILLISECONDS = 60 * 1000
const WEB_SLIDING_TOUCH_MILLISECONDS = 60 * 1000
export interface AuthSessionPayload {
token: string
accessTokenExpiresAt: string
refreshToken: string
sessionExpiresAt: string
user: unknown
}
export const isNativeApp = () => Capacitor.isNativePlatform()
export function saveAuthSession(payload: AuthSessionPayload, recordActivity = true) {
localStorage.setItem(TOKEN_KEY, payload.token)
localStorage.setItem(REFRESH_TOKEN_KEY, payload.refreshToken)
localStorage.setItem(ACCESS_EXPIRES_KEY, payload.accessTokenExpiresAt)
localStorage.setItem(SESSION_EXPIRES_KEY, payload.sessionExpiresAt)
localStorage.setItem(USER_KEY, JSON.stringify(payload.user))
if (recordActivity) markActivity()
window.dispatchEvent(new Event('mingxu-auth-changed'))
}
export function clearAuthSession(notifyExpired = false) {
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(REFRESH_TOKEN_KEY)
localStorage.removeItem(ACCESS_EXPIRES_KEY)
localStorage.removeItem(SESSION_EXPIRES_KEY)
localStorage.removeItem(USER_KEY)
localStorage.removeItem(LAST_ACTIVITY_KEY)
window.dispatchEvent(new Event('mingxu-auth-changed'))
if (notifyExpired) window.dispatchEvent(new Event('mingxu-session-expired'))
}
export function markActivity() {
localStorage.setItem(LAST_ACTIVITY_KEY, String(Date.now()))
}
export function hasExceededWebIdleTimeout(now = Date.now()) {
if (isNativeApp()) return false
const lastActivity = Number(localStorage.getItem(LAST_ACTIVITY_KEY) ?? 0)
return lastActivity > 0 && now - lastActivity >= WEB_IDLE_MILLISECONDS
}
export function hasLocallyExpired(now = Date.now()) {
const sessionExpiresAt = Date.parse(localStorage.getItem(SESSION_EXPIRES_KEY) ?? '')
return hasExceededWebIdleTimeout(now) ||
(Number.isFinite(sessionExpiresAt) && sessionExpiresAt <= now)
}
let refreshPromise: Promise<string | null> | null = null
export function refreshAuthSession(): Promise<string | null> {
if (refreshPromise) return refreshPromise
const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY)
if (!refreshToken || hasLocallyExpired()) {
clearAuthSession(true)
return Promise.resolve(null)
}
refreshPromise = axios.post<AuthSessionPayload>(
`${import.meta.env.VITE_API_BASE_URL ?? '/api'}/auth/refresh`,
{ refreshToken },
{ timeout: 15000 },
).then(({ data }) => {
saveAuthSession(data, false)
return data.token
}).catch((error: unknown) => {
const currentRefreshToken = localStorage.getItem(REFRESH_TOKEN_KEY)
if (currentRefreshToken && currentRefreshToken !== refreshToken) {
return localStorage.getItem(TOKEN_KEY)
}
if (axios.isAxiosError(error) &&
error.response &&
[400, 401, 403].includes(error.response.status)) {
clearAuthSession(true)
return null
}
throw error
}).finally(() => {
refreshPromise = null
})
return refreshPromise
}
export async function refreshIfNeeded(isCurrentRequestActivity = false) {
const token = localStorage.getItem(TOKEN_KEY)
if (!token) return null
if (hasLocallyExpired()) {
clearAuthSession(true)
return null
}
const expiresAt = Date.parse(localStorage.getItem(ACCESS_EXPIRES_KEY) ?? '')
const sessionExpiresAt = Date.parse(localStorage.getItem(SESSION_EXPIRES_KEY) ?? '')
const lastActivity = Number(localStorage.getItem(LAST_ACTIVITY_KEY) ?? 0)
const now = Date.now()
const hasRecentWebActivity = !isNativeApp() &&
(isCurrentRequestActivity || now - lastActivity <= WEB_SLIDING_TOUCH_MILLISECONDS)
const webSessionNeedsSlidingTouch = hasRecentWebActivity &&
Number.isFinite(sessionExpiresAt) &&
sessionExpiresAt - now <= WEB_IDLE_MILLISECONDS - WEB_SLIDING_TOUCH_MILLISECONDS
if (!Number.isFinite(expiresAt) ||
expiresAt - now <= REFRESH_AHEAD_MILLISECONDS ||
webSessionNeedsSlidingTouch) {
return refreshAuthSession()
}
return token
}
export function initializeAuthSession() {
if (!localStorage.getItem(TOKEN_KEY)) return
if (hasLocallyExpired()) {
clearAuthSession(true)
return
}
let lastActivityWrite = 0
const recordActivity = () => {
const now = Date.now()
if (now - lastActivityWrite < 5000) return
lastActivityWrite = now
markActivity()
}
const activityEvents: Array<keyof WindowEventMap> = [
'pointerdown',
'keydown',
'touchstart',
'scroll',
]
activityEvents.forEach(event =>
window.addEventListener(event, recordActivity, { passive: true }))
window.setInterval(() => {
if (!localStorage.getItem(TOKEN_KEY)) return
if (hasLocallyExpired()) {
clearAuthSession(true)
return
}
if (document.visibilityState === 'visible') {
void refreshIfNeeded().catch(() => undefined)
}
}, 30000)
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
void refreshIfNeeded(true).catch(() => undefined)
}
})
window.addEventListener('online', () =>
void refreshIfNeeded(true).catch(() => undefined))
}
export const authStorageKeys = {
token: TOKEN_KEY,
refreshToken: REFRESH_TOKEN_KEY,
user: USER_KEY,
}
+1
View File
@@ -57,6 +57,7 @@ declare module 'vue' {
RichMessageContent: typeof import('./components/RichMessageContent.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SiteFooter: typeof import('./components/SiteFooter.vue')['default']
SmartLaunchScreen: typeof import('./components/SmartLaunchScreen.vue')['default']
}
export interface GlobalDirectives {
+105
View File
@@ -0,0 +1,105 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import http from '../api/http'
interface SystemVersionResponse {
version: string
}
const backendVersion = ref('获取中')
const currentYear = new Date().getFullYear()
const frontendVersion = __APP_VERSION__
onMounted(async () => {
try {
const { data } = await http.get<SystemVersionResponse>('/system/version')
backendVersion.value = data.version ? `v${data.version}` : '未知'
} catch {
backendVersion.value = '未知'
}
})
</script>
<template>
<footer class="site-footer" aria-label="网站版权与版本信息">
<div class="site-footer-inner">
<p>© {{ currentYear }} 明序教务 · 版权所有</p>
<div class="version-list" aria-label="系统版本">
<span>前端版本 <b>v{{ frontendVersion }}</b></span>
<span>后端版本 <b>{{ backendVersion }}</b></span>
</div>
</div>
</footer>
</template>
<style scoped>
.site-footer {
position: relative;
z-index: 1;
border-top: 1px solid rgba(255, 255, 255, .1);
color: #d9e0f2;
background:
linear-gradient(90deg, rgba(255, 255, 255, .025) 1px, transparent 1px),
linear-gradient(112deg, #152550 0%, #1d326c 72%, #176a70 130%);
background-size: 28px 28px, auto;
}
.site-footer-inner {
min-height: 58px;
max-width: 1540px;
margin: 0 auto;
padding: 11px 32px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
}
.site-footer p {
margin: 0;
font-size: 12px;
letter-spacing: .04em;
}
.version-list {
display: flex;
align-items: center;
gap: 8px;
}
.version-list span {
padding: 5px 9px;
border: 1px solid rgba(255, 255, 255, .14);
border-radius: 4px;
color: #aeb9d8;
background: rgba(255, 255, 255, .055);
font-size: 10px;
white-space: nowrap;
}
.version-list b {
margin-left: 5px;
color: #66d5c5;
font: 700 10px/1.2 Consolas, monospace;
}
@media (max-width: 600px) {
.site-footer-inner {
min-height: 72px;
padding: 10px 14px;
align-items: flex-start;
flex-direction: column;
justify-content: center;
gap: 7px;
}
.version-list {
width: 100%;
}
.version-list span {
flex: 1;
text-align: center;
}
}
</style>
+3
View File
@@ -0,0 +1,3 @@
/// <reference types="vite/client" />
declare const __APP_VERSION__: string
+14 -8
View File
@@ -177,6 +177,14 @@ const navigationGroups = computed<NavigationGroup[]>(() => [
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher', 'Student', 'Counselor']),
{ path: '/grades', label: isStudent.value ? '学业成绩' : isTeacher.value ? '成绩录入' : '成绩管理' },
),
...whenVisible(
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Leader', 'Teacher']),
{ path: '/grade-analytics', label: isTeacher.value ? '教学班成绩分析' : '成绩分析中心' },
),
...whenVisible(
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'Student']),
{ path: '/other-exams', label: '其他考试成绩' },
),
...whenVisible(
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'Teacher', 'Student']),
{
@@ -212,6 +220,10 @@ const navigationGroups = computed<NavigationGroup[]>(() => [
key: 'student-status',
label: '学籍管理',
items: [
...whenVisible(
isStudent.value,
{ path: '/my-profile', label: '个人信息' },
),
...whenVisible(
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor', 'Student']),
{ path: '/student-status-changes', label: isStudent.value ? '学籍异动' : '异动审核' },
@@ -263,6 +275,7 @@ const workspaceLabel = computed(() => {
})
const pageTitle = computed(() => {
if (route.path === '/account') return '个人账户'
const matchedItem = navigationGroups.value
.flatMap((group) => group.items)
.find((item) => item.path === route.path)
@@ -318,6 +331,7 @@ onMounted(() => {
<b>{{ auth.user?.displayName ?? '系统管理员' }}</b>
<span>{{ auth.user?.roles?.[0] ?? '教务人员' }}</span>
</div>
<el-button text @click="router.push('/account')">个人账户</el-button>
<el-button text @click="signOut">退出</el-button>
</div>
</div>
@@ -353,10 +367,6 @@ onMounted(() => {
</el-sub-menu>
</template>
</el-menu>
<div class="navigation-status">
<span>CORE SYSTEM</span>
<b>核心业务已就绪</b>
</div>
</nav>
</header>
@@ -404,10 +414,6 @@ onMounted(() => {
</template>
</el-menu>
<div class="phase-note">
<span>第一阶段 · 核心可用版</span>
<p>教学运行学籍与毕业审核已就绪</p>
</div>
</aside>
<div v-if="mobileMenu" class="mobile-mask" @click="mobileMenu = false" />
+6
View File
@@ -6,11 +6,17 @@ import router from './router'
import { initializeAppUpdates } from './services/appUpdates'
import { initializeNativeHome } from './services/nativeHome'
import { setRouter } from './utils/navigate'
import { initializeAuthSession } from './auth/session'
const app = createApp(App)
app.use(createPinia())
app.use(router)
setRouter(router)
window.addEventListener('mingxu-session-expired', () => {
const returnUrl = location.pathname + location.search + location.hash
void router.push({ name: 'login', query: { redirect: returnUrl } })
})
initializeAuthSession()
app.mount('#app')
void initializeAppUpdates()
initializeNativeHome(router)
+45
View File
@@ -13,6 +13,18 @@ const router = createRouter({
component: () => import('../views/LoginView.vue'),
meta: { public: true },
},
{
path: '/sso/callback',
name: 'sso-callback',
component: () => import('../views/SsoCallbackView.vue'),
meta: { public: true },
},
{
path: '/sso/bind',
name: 'sso-bind',
component: () => import('../views/SsoBindView.vue'),
meta: { public: true },
},
{
path: '/timetable',
name: 'public-timetable',
@@ -41,6 +53,17 @@ const router = createRouter({
name: 'dashboard',
component: () => import('../views/DashboardView.vue'),
},
{
path: 'account',
name: 'account',
component: () => import('../views/AccountView.vue'),
},
{
path: 'my-profile',
name: 'my-profile',
component: () => import('../views/StudentProfileView.vue'),
meta: { roles: ['Student'] },
},
{
path: 'base-data',
redirect: '/base-data/organization',
@@ -224,6 +247,28 @@ const router = createRouter({
roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher', 'Student', 'Counselor'],
},
},
{
path: 'grades/:sheetId/statistics',
name: 'course-grade-statistics',
component: () => import('../views/CourseGradeStatisticsView.vue'),
meta: {
roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Leader', 'Counselor', 'Student'],
},
},
{
path: 'grade-analytics',
name: 'grade-analytics',
component: () => import('../views/GradeAnalyticsView.vue'),
meta: {
roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Leader', 'Teacher'],
},
},
{
path: 'other-exams',
name: 'other-exams',
component: () => import('../views/OtherExamsView.vue'),
meta: { roles: ['SuperAdmin', 'AcademicAdmin', 'Student'] },
},
{
path: 'exams',
name: 'exams',
+57 -11
View File
@@ -1,6 +1,12 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import http from '../api/http'
import {
authStorageKeys,
clearAuthSession,
isNativeApp,
saveAuthSession,
} from '../auth/session'
export interface CurrentUser {
id: string
@@ -12,35 +18,75 @@ export interface CurrentUser {
}
export const useAuthStore = defineStore('auth', () => {
const token = ref(localStorage.getItem('jiaowu_token') ?? '')
const saved = localStorage.getItem('jiaowu_user')
const token = ref(localStorage.getItem(authStorageKeys.token) ?? '')
const saved = localStorage.getItem(authStorageKeys.user)
const user = ref<CurrentUser | null>(saved ? JSON.parse(saved) : null)
const isLoggedIn = computed(() => Boolean(token.value))
const isSuperAdmin = computed(() => user.value?.roles.includes('SuperAdmin') ?? false)
async function login(userName: string, password: string) {
const { data } = await http.post('/auth/login', { userName, password })
const { data } = await http.post('/auth/login', {
userName,
password,
isNativeApp: isNativeApp(),
})
token.value = data.token
user.value = data.user
localStorage.setItem('jiaowu_token', data.token)
localStorage.setItem('jiaowu_user', JSON.stringify(data.user))
window.dispatchEvent(new Event('mingxu-auth-changed'))
saveAuthSession(data)
}
async function exchangeSso(code: string) {
const { data } = await http.post('/auth/sso/exchange', {
code,
isNativeApp: isNativeApp(),
})
token.value = data.token
user.value = data.user
saveAuthSession(data)
}
async function bindSso(code: string, userName: string, password: string) {
const { data } = await http.post('/auth/sso/bind', {
code,
userName,
password,
isNativeApp: isNativeApp(),
})
token.value = data.token
user.value = data.user
saveAuthSession(data)
}
async function refresh() {
if (!token.value) return
const { data } = await http.get('/auth/me')
user.value = data
localStorage.setItem('jiaowu_user', JSON.stringify(data))
localStorage.setItem(authStorageKeys.user, JSON.stringify(data))
}
function logout() {
const refreshToken = localStorage.getItem(authStorageKeys.refreshToken)
if (refreshToken) void http.post('/auth/logout', { refreshToken }).catch(() => undefined)
token.value = ''
user.value = null
localStorage.removeItem('jiaowu_token')
localStorage.removeItem('jiaowu_user')
window.dispatchEvent(new Event('mingxu-auth-changed'))
clearAuthSession()
}
return { token, user, isLoggedIn, isSuperAdmin, login, refresh, logout }
window.addEventListener('mingxu-auth-changed', () => {
token.value = localStorage.getItem(authStorageKeys.token) ?? ''
const currentUser = localStorage.getItem(authStorageKeys.user)
user.value = currentUser ? JSON.parse(currentUser) : null
})
return {
token,
user,
isLoggedIn,
isSuperAdmin,
login,
exchangeSso,
bindSso,
refresh,
logout,
}
})
+18 -12
View File
@@ -80,14 +80,7 @@ button { cursor: pointer; }
background: linear-gradient(180deg, #fff, #f5faf9);
font-weight: 650;
}
.horizontal-menu .el-sub-menu__icon-arrow { margin-left: 6px; }
.navigation-status {
flex: 0 0 auto; min-width: 152px; padding-left: 24px;
display: grid; align-content: center; gap: 2px;
border-left: 1px solid var(--line);
}
.navigation-status span { color: var(--teal); font: 700 8px/1.2 Consolas, monospace; letter-spacing: .13em; }
.navigation-status b { color: #677083; font-size: 10px; font-weight: 500; }
.horizontal-menu .el-sub-menu__icon-arrow { display: none; }
.mobile-navigation {
position: fixed; inset: 0 auto 0 0; z-index: 30; width: 286px;
display: flex; flex-direction: column; overflow-y: auto;
@@ -121,10 +114,6 @@ button { cursor: pointer; }
.mobile-nav-menu .el-menu-item.is-active { color: white; background: #2d478d; box-shadow: inset 3px 0 #46c6b5; }
.mobile-nav-menu .el-sub-menu .el-menu { background: rgba(0,0,0,.1); }
.mobile-nav-menu .el-sub-menu .el-menu-item { min-width: 0; padding-left: 40px !important; font-size: 12px; }
.phase-note { margin: auto 20px 24px; padding-top: 17px; border-top: 1px solid rgba(255,255,255,.12); }
.phase-note span { color: #45c6b5; font-size: 11px; font-weight: 700; letter-spacing: .06em; }
.phase-note p { margin: 7px 0 0; color: #9faad0; font-size: 12px; line-height: 1.6; }
.main-area { min-width: 0; }
.menu-toggle {
width: 38px; height: 38px; border: 1px solid rgba(255,255,255,.18);
@@ -1208,6 +1197,23 @@ button { cursor: pointer; }
.public-timetable-link { display: block; margin: 14px 0 18px; color: #176b87; font-size: 13px; font-weight: 650; text-align: center; text-decoration: none; }
.account-activation-link { display: block; margin: -8px 0 18px; color: #315b73; font-size: 13px; font-weight: 650; text-align: center; text-decoration: none; }
.login-submit { width: 100%; margin-top: 6px; height: 46px; }
.sso-login-submit { width: 100%; height: 46px; border-color: #176b87; color: #176b87; font-weight: 650; }
.login-divider { display: flex; align-items: center; gap: 12px; margin: 16px 0; color: #9aa4b2; font-size: 12px; }
.login-divider::before, .login-divider::after { content: ""; flex: 1; height: 1px; background: #e5e9ef; }
.sso-callback-page { min-height: 100vh; display: grid; place-content: center; justify-items: center; padding: 24px; background: #f4f7fa; color: #263445; text-align: center; }
.sso-callback-page h1 { margin: 18px 0 8px; font-size: 22px; }
.sso-callback-page p { margin: 0; color: var(--muted); }
.sso-binding-page { min-height: 100vh; display: grid; place-items: center; padding: 28px; background: radial-gradient(circle at top left, #e3f5f2, transparent 42%), #f4f7fa; }
.sso-binding-card { width: min(470px, 100%); padding: 38px; border: 1px solid #e3e8ee; border-radius: 18px; background: #fff; box-shadow: 0 20px 55px rgba(35, 57, 78, .12); }
.sso-binding-card .brand-mark { margin-bottom: 24px; }
.sso-binding-card h1 { margin: 8px 0 12px; font-family: "STZhongsong", "Songti SC", serif; font-size: 28px; }
.binding-description { margin: 0 0 26px; color: var(--muted); line-height: 1.75; }
.binding-description strong { color: #176b87; }
.sso-binding-card label { display: block; margin-bottom: 18px; }
.sso-binding-card label > span { display: block; margin-bottom: 8px; color: #525b6d; font-size: 12px; font-weight: 650; }
.binding-submit { width: 100%; height: 46px; }
.binding-security-note { margin: 18px 0 10px; color: #7b8794; font-size: 12px; line-height: 1.65; }
.sso-binding-card > a { color: #315b73; font-size: 13px; font-weight: 650; text-decoration: none; }
@media (max-width: 1100px) {
.metric-grid { grid-template-columns: repeat(2, 1fr); }
+203
View File
@@ -0,0 +1,203 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import http, { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const loading = ref(true)
const actionLoading = ref(false)
const sso = reactive({
enabled: false,
providerDisplayName: '学校统一身份认证',
isBound: false,
callbackUrl: '',
})
const ssoErrors: Record<string, string> = {
binding_intent_expired: '绑定请求已失效,请重新发起绑定。',
identity_already_bound: '该统一身份账号已经绑定其他教务系统账号。',
account_already_bound: '当前教务系统账号已经绑定其他统一身份账号。',
account_disabled: '当前教务系统账号已停用或锁定。',
account_link_failed: '统一身份账户绑定失败,请重新尝试。',
configuration_error: '统一身份认证回调地址配置不正确,请联系管理员。',
}
async function loadAccount() {
loading.value = true
try {
const { data } = await http.get('/auth/sso/account')
sso.enabled = Boolean(data.enabled)
sso.providerDisplayName = String(data.providerDisplayName || sso.providerDisplayName)
sso.isBound = Boolean(data.isBound)
sso.callbackUrl = String(data.callbackUrl || '')
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
loading.value = false
}
}
async function startBinding() {
actionLoading.value = true
try {
const { data } = await http.post('/auth/sso/prepare-binding')
window.location.assign(String(data.loginUrl))
} catch (error) {
ElMessage.error(apiErrorMessage(error))
actionLoading.value = false
}
}
async function unbind() {
try {
const result = await ElMessageBox.prompt(
'解绑后将不能再使用当前统一身份账号登录。请输入教务系统密码确认。',
'解除统一身份绑定',
{
inputType: 'password',
inputPlaceholder: '请输入教务系统密码',
inputValidator: (value) => Boolean(value) || '密码不能为空',
confirmButtonText: '确认解绑',
cancelButtonText: '取消',
type: 'warning',
},
)
actionLoading.value = true
await http.post('/auth/sso/unbind', { password: result.value })
ElMessage.success('统一身份账户已解绑。')
await loadAccount()
} catch (error) {
if (error === 'cancel' || error === 'close') return
ElMessage.error(apiErrorMessage(error))
} finally {
actionLoading.value = false
}
}
onMounted(async () => {
const ssoError = String(route.query.ssoError ?? '')
if (ssoError) {
ElMessage.error(ssoErrors[ssoError] ?? '统一身份账户绑定失败,请重新尝试。')
await router.replace('/account')
}
await loadAccount()
})
</script>
<template>
<section class="account-page">
<header class="account-heading">
<div>
<span class="eyebrow">ACCOUNT &amp; SECURITY</span>
<h1>个人账户</h1>
<p>管理您的登录身份与单点登录绑定</p>
</div>
<div class="account-avatar">{{ auth.user?.displayName?.slice(0, 1) ?? '用' }}</div>
</header>
<div class="account-grid">
<article class="account-card identity-card">
<div class="card-title">
<div>
<span>基本信息</span>
<h2>{{ auth.user?.displayName }}</h2>
</div>
<el-tag type="success" effect="light">账号已启用</el-tag>
</div>
<dl>
<div>
<dt>登录账号</dt>
<dd>{{ auth.user?.userName }}</dd>
</div>
<div>
<dt>系统角色</dt>
<dd class="role-list">
<el-tag v-for="role in auth.user?.roles" :key="role" effect="plain">{{ role }}</el-tag>
</dd>
</div>
<div>
<dt>数据范围</dt>
<dd>{{ auth.user?.effectiveDataScope }}</dd>
</div>
</dl>
</article>
<article v-loading="loading" class="account-card sso-card">
<div class="card-title">
<div>
<span>单点登录</span>
<h2>{{ sso.providerDisplayName }}</h2>
</div>
<el-tag v-if="sso.isBound" type="success">已绑定</el-tag>
<el-tag v-else-if="sso.enabled" type="info">未绑定</el-tag>
<el-tag v-else type="warning">未启用</el-tag>
</div>
<p v-if="sso.isBound" class="sso-description">
当前教务系统账号已经关联统一身份认证您可以直接通过 Keycloak 登录
</p>
<p v-else-if="sso.enabled" class="sso-description">
绑定后即使统一身份用户名与教务系统账号不同也可以直接登录当前账号
</p>
<p v-else class="sso-description">管理员尚未启用统一身份认证</p>
<el-button
v-if="sso.enabled && !sso.isBound"
type="primary"
:loading="actionLoading"
@click="startBinding"
>
绑定统一身份账户
</el-button>
<el-button
v-else-if="sso.isBound"
type="danger"
plain
:loading="actionLoading"
@click="unbind"
>
解除绑定
</el-button>
<details v-if="sso.enabled && sso.callbackUrl" class="callback-details">
<summary>管理员配置参考</summary>
<p>Keycloak Valid redirect URI 必须与下列地址完全一致</p>
<code>{{ sso.callbackUrl }}</code>
</details>
</article>
</div>
</section>
</template>
<style scoped>
.account-page { display: grid; gap: 22px; }
.account-heading { display: flex; align-items: center; justify-content: space-between; padding: 26px 30px; border-radius: 18px; background: linear-gradient(125deg, #173a50, #176b87 65%, #2e9b91); color: white; box-shadow: 0 18px 45px rgba(23, 58, 80, .18); }
.account-heading h1 { margin: 7px 0 5px; font-family: "STZhongsong", "Songti SC", serif; font-size: 30px; }
.account-heading p { margin: 0; color: rgba(255,255,255,.72); }
.account-heading .eyebrow { color: #8ee4d8; }
.account-avatar { display: grid; place-items: center; width: 68px; height: 68px; border: 1px solid rgba(255,255,255,.34); border-radius: 22px; background: rgba(255,255,255,.12); font-size: 28px; font-weight: 700; }
.account-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px; }
.account-card { min-width: 0; min-height: 310px; padding: 28px; border: 1px solid #e2e8ee; border-radius: 16px; background: white; box-shadow: 0 12px 34px rgba(35, 57, 78, .07); }
.card-title { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding-bottom: 20px; border-bottom: 1px solid #edf0f3; }
.card-title span { color: #73808d; font-size: 12px; }
.card-title h2 { margin: 6px 0 0; color: #263445; font-size: 21px; }
dl { margin: 6px 0 0; }
dl > div { display: grid; grid-template-columns: 100px 1fr; gap: 18px; padding: 16px 0; border-bottom: 1px solid #f0f2f4; }
dt { color: #7b8794; font-size: 13px; }
dd { margin: 0; color: #263445; font-weight: 650; }
.role-list { display: flex; flex-wrap: wrap; gap: 7px; }
.sso-description { min-height: 54px; margin: 22px 0; color: #65717e; line-height: 1.7; }
.callback-details { margin-top: 24px; color: #65717e; font-size: 12px; }
.callback-details summary { cursor: pointer; color: #315b73; font-weight: 650; }
.callback-details p { margin: 10px 0 7px; }
.callback-details code { display: block; overflow-wrap: anywhere; padding: 10px 12px; border-radius: 8px; background: #f4f7f9; color: #176b87; }
@media (max-width: 1100px) {
.account-grid { grid-template-columns: 1fr; }
.account-heading { padding: 22px; }
.account-avatar { width: 56px; height: 56px; border-radius: 18px; }
.account-card { min-height: 0; padding: 22px; }
}
</style>
+271
View File
@@ -0,0 +1,271 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { ArrowLeft, Refresh } from '@element-plus/icons-vue'
import * as echarts from 'echarts'
import { useRoute, useRouter } from 'vue-router'
import http, { apiErrorMessage } from '../api/http'
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const report = ref<any | null>(null)
const activeScope = ref('class')
const chartElement = ref<HTMLElement>()
const comparisonChartElement = ref<HTMLElement>()
let chart: echarts.ECharts | undefined
let comparisonChart: echarts.ECharts | undefined
const rows = computed(() => [
{ key: 'class', label: '本班', caption: '同班同学', value: report.value?.class },
{ key: 'major', label: '本专业', caption: '专业学生', value: report.value?.major },
{ key: 'college', label: '本学院', caption: '学院学生', value: report.value?.college },
{ key: 'university', label: '本校', caption: '全校学生', value: report.value?.university },
].filter(item => item.value))
const selectedRow = computed(() => rows.value.find(item => item.key === activeScope.value) ?? rows.value[0])
const selectedDistribution = computed(() => selectedRow.value?.value.distribution ?? [])
function score(value: unknown) {
return Number(value).toFixed(1)
}
async function load() {
loading.value = true
try {
report.value = (await http.get(`/grades/sheets/${route.params.sheetId}/statistics`)).data
if (!rows.value.some(item => item.key === activeScope.value)) activeScope.value = rows.value[0]?.key ?? 'class'
await nextTick()
drawChart()
drawComparisonChart()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
loading.value = false
}
}
function drawChart() {
if (!chartElement.value || !selectedRow.value) return
chart?.dispose()
chart = echarts.init(chartElement.value)
const distribution = selectedDistribution.value
const total = selectedRow.value.value.studentCount || 1
const palette = ['#a7b4c6', '#8198b6', '#5f7fa8', '#3d6797', '#173f78']
chart.setOption({
animationDuration: 520,
aria: { enabled: true, decal: { show: true } },
tooltip: {
trigger: 'axis',
formatter: (params: any) => {
const item = distribution[params[0]?.dataIndex ?? 0]
const rate = item ? Number(item.count) / total * 100 : 0
return `<b>${item?.range ?? ''} 分</b><br/>人数 ${item?.count ?? 0} 人<br/>占比 ${rate.toFixed(1)}%`
},
},
grid: { left: 42, right: 22, top: 44, bottom: 38 },
xAxis: {
type: 'category',
name: '分数段',
data: distribution.map((item: any) => item.range),
axisTick: { show: false },
axisLine: { lineStyle: { color: '#cbd4e1' } },
axisLabel: { color: '#475467', fontWeight: 600 },
},
yAxis: {
type: 'value', min: 0, minInterval: 1, name: '人数',
splitLine: { lineStyle: { color: '#edf1f6' } },
},
series: [
{
name: `${selectedRow.value.label}人数`, type: 'bar', barMaxWidth: 62,
data: distribution.map((item: any, index: number) => ({ value: item.count, itemStyle: { color: palette[index] } })),
itemStyle: { borderRadius: [5, 5, 0, 0] },
label: {
show: true, position: 'top', color: '#344054', fontWeight: 700,
formatter: (params: any) => {
const count = Number(params.value)
return `${count}\n${(count / total * 100).toFixed(1)}%`
},
},
},
],
})
}
function drawComparisonChart() {
if (!comparisonChartElement.value || !rows.value.length) return
comparisonChart?.dispose()
comparisonChart = echarts.init(comparisonChartElement.value)
const selectedIndex = rows.value.findIndex(item => item.key === activeScope.value)
comparisonChart.setOption({
animationDuration: 520,
aria: { enabled: true, decal: { show: true } },
tooltip: {
trigger: 'axis',
formatter: (params: any) => {
const item = rows.value[params[0]?.dataIndex ?? 0]
return `<b>${item.label}</b><br/>最高分 ${score(item.value.highestScore)}<br/>平均分 ${score(item.value.averageScore)}<br/>最低分 ${score(item.value.lowestScore)}<br/>合格率 ${score(item.value.passRate)}%`
},
},
legend: { top: 2, data: ['分数区间', '平均分', '合格率'] },
grid: { left: 42, right: 52, top: 56, bottom: 36 },
xAxis: {
type: 'category',
data: rows.value.map(item => item.label),
axisTick: { show: false },
axisLine: { lineStyle: { color: '#cbd4e1' } },
axisLabel: {
color: (value: string) => value === selectedRow.value?.label ? '#173f78' : '#667085',
fontWeight: 600,
},
},
yAxis: [
{ type: 'value', min: 0, max: 100, name: '分数', splitLine: { lineStyle: { color: '#edf1f6' } } },
{ type: 'value', min: 0, max: 100, name: '合格率', axisLabel: { formatter: '{value}%' }, splitLine: { show: false } },
],
series: [
{
name: '区间起点', type: 'bar', stack: 'score-range', silent: true,
itemStyle: { color: 'transparent' }, emphasis: { disabled: true },
data: rows.value.map(item => item.value.lowestScore),
},
{
name: '分数区间', type: 'bar', stack: 'score-range', barWidth: 22,
itemStyle: { borderRadius: 8 },
data: rows.value.map((item, index) => ({
value: item.value.highestScore - item.value.lowestScore,
itemStyle: { color: index === selectedIndex ? '#315a9b' : '#b8c7dc' },
})),
},
{
name: '平均分', type: 'line', smooth: true, symbol: 'circle', symbolSize: 10,
lineStyle: { width: 3, color: '#d58b32' },
itemStyle: { color: '#d58b32', borderColor: '#fff', borderWidth: 2 },
data: rows.value.map(item => item.value.averageScore),
},
{
name: '合格率', type: 'line', yAxisIndex: 1, smooth: true, symbol: 'diamond', symbolSize: 9,
lineStyle: { width: 2, type: 'dashed', color: '#25847a' },
itemStyle: { color: '#25847a' },
data: rows.value.map(item => item.value.passRate),
},
],
})
}
watch(activeScope, async () => {
await nextTick()
drawChart()
drawComparisonChart()
})
onMounted(load)
onBeforeUnmount(() => {
chart?.dispose()
comparisonChart?.dispose()
})
</script>
<template>
<div class="page-stack course-statistics" v-loading="loading">
<section class="page-intro">
<div>
<span class="section-kicker">COURSE INSIGHT</span>
<h2>{{ report?.courseName ?? '课程成绩分析' }}</h2>
<p>{{ report?.courseCode }} · {{ report?.termName }} · 本页数据来自已正式发布成绩</p>
</div>
<div class="actions">
<el-button :icon="ArrowLeft" @click="router.back()">返回</el-button>
<el-button :icon="Refresh" @click="load">刷新</el-button>
</div>
</section>
<el-alert v-if="report?.isRefreshing" type="info" :closable="false"
title="统计数据正在生成,请稍后刷新。" />
<section v-if="rows.length && selectedRow" class="scope-panel">
<nav class="scope-tabs" aria-label="统计范围">
<button
v-for="item in rows"
:key="item.key"
type="button"
:class="{ active: activeScope === item.key }"
@click="activeScope = item.key"
>
<span>{{ item.label }}</span>
<small>{{ item.value.studentCount }} </small>
</button>
</nav>
<article class="scope-summary">
<div class="average-score">
<span>{{ selectedRow.label }}平均分</span>
<strong>{{ score(selectedRow.value.averageScore) }}</strong>
<small>统计对象{{ selectedRow.caption }} · {{ selectedRow.value.studentCount }} </small>
</div>
<dl>
<div><dt>最高分</dt><dd>{{ score(selectedRow.value.highestScore) }}</dd></div>
<div><dt>最低分</dt><dd>{{ score(selectedRow.value.lowestScore) }}</dd></div>
<div><dt>合格人数</dt><dd>{{ selectedRow.value.passedCount }} / {{ selectedRow.value.studentCount }}</dd></div>
<div><dt>合格率</dt><dd>{{ score(selectedRow.value.passRate) }}%</dd></div>
</dl>
</article>
</section>
<el-empty v-else-if="!loading" description="暂无可展示的课程统计" />
<section v-if="rows.length" class="chart-panel">
<div class="chart-heading">
<div><span class="section-kicker">SCORE DISTRIBUTION</span><h3>{{ selectedRow?.label }}分数段分布</h3></div>
<p>按当前标签页单独统计 {{ selectedRow?.value.studentCount }} 柱顶显示人数和占比</p>
</div>
<div ref="chartElement" class="score-chart" />
</section>
<section v-if="rows.length" class="chart-panel comparison-panel">
<div class="chart-heading">
<div><span class="section-kicker">LEVEL COMPARISON</span><h3>各层级整体分析</h3></div>
<p>同时比较四个层级的最低最高分区间平均分和合格率当前标签对应层级高亮</p>
</div>
<div ref="comparisonChartElement" class="score-chart" />
</section>
</div>
</template>
<style scoped>
.actions { display: flex; gap: 10px; }
.scope-panel, .chart-panel { background: var(--el-bg-color); border: 1px solid var(--el-border-color-lighter); box-shadow: 0 10px 30px rgb(21 47 82 / 5%); }
.scope-tabs { display: grid; grid-template-columns: repeat(4, 1fr); border-bottom: 1px solid #dfe5ee; background: #f6f8fb; }
.scope-tabs button { min-height: 58px; padding: 10px 18px; border: 0; border-right: 1px solid #e2e7ef; color: #667085; background: transparent; cursor: pointer; text-align: left; transition: color .2s ease, background .2s ease, box-shadow .2s ease; }
.scope-tabs button:last-child { border-right: 0; }
.scope-tabs button span, .scope-tabs button small { display: block; }
.scope-tabs button span { font-weight: 700; font-size: 14px; }
.scope-tabs button small { margin-top: 3px; color: #98a2b3; font: 11px Consolas, monospace; }
.scope-tabs button.active { color: #173f78; background: #fff; box-shadow: inset 0 3px #315a9b; }
.scope-tabs button:focus-visible { outline: 2px solid #315a9b; outline-offset: -3px; }
.scope-summary { display: grid; grid-template-columns: minmax(220px, .8fr) 1.6fr; gap: 36px; padding: 28px 30px; }
.average-score { padding-right: 30px; border-right: 1px solid #e3e8ef; }
.average-score span, .average-score small { display: block; color: #667085; }
.average-score span { font-size: 12px; font-weight: 700; }
.average-score strong { display: block; margin: 9px 0 7px; color: #173f78; font: 700 48px/1 Consolas, monospace; }
.average-score small { font-size: 11px; }
.scope-summary dl { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin: 0; align-items: stretch; }
.scope-summary dl div { padding: 14px 16px; background: #f7f9fc; border-left: 2px solid #d5deeb; }
.scope-summary dt { color: #667085; font-size: 11px; }
.scope-summary dd { margin: 8px 0 0; color: #243b5a; font: 700 20px/1.1 Consolas, monospace; }
.chart-panel { margin-top: 18px; padding: 22px 26px; }
.chart-heading { display: flex; align-items: end; justify-content: space-between; gap: 20px; }
.chart-heading h3 { margin: 4px 0 0; }
.chart-heading p { max-width: 420px; margin: 0; color: #667085; font-size: 12px; text-align: right; }
.score-chart { height: 350px; margin-top: 10px; }
@media (max-width: 760px) {
.page-intro { align-items: flex-start; }
.actions { flex-wrap: wrap; }
.scope-tabs { grid-template-columns: repeat(2, 1fr); }
.scope-tabs button:nth-child(2) { border-right: 0; }
.scope-tabs button:nth-child(-n + 2) { border-bottom: 1px solid #e2e7ef; }
.scope-summary { grid-template-columns: 1fr; gap: 20px; padding: 22px; }
.average-score { padding: 0 0 20px; border: 0; border-bottom: 1px solid #e3e8ef; }
.scope-summary dl { grid-template-columns: repeat(2, 1fr); }
.chart-heading { align-items: flex-start; flex-direction: column; }
.chart-heading p { text-align: left; }
}
</style>
+462
View File
@@ -0,0 +1,462 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { Refresh, Search } from '@element-plus/icons-vue'
import * as echarts from 'echarts'
import http, { apiErrorMessage } from '../api/http'
import { academicTermLabel, defaultAcademicTermId } from '../utils/academicTerms'
interface TeachingClassItem {
gradeSheetId: string
teachingTaskId: string
taskNumber: string
taskName: string
courseCode: string
courseName: string
termName: string
academicTermId: string
teacherNames: string[]
classNames: string[]
studentCount: number
averageScore: number
passRate: number
excellentRate: number
calculatedAt: string
}
const terms = ref<any[]>([])
const classes = ref<TeachingClassItem[]>([])
const selected = ref<TeachingClassItem>()
const report = ref<any>()
const termId = ref<string>()
const keyword = ref('')
const page = ref(1)
const total = ref(0)
const pageSize = 12
const loading = ref(false)
const reportLoading = ref(false)
const historyMetric = ref<'average' | 'passRate' | 'excellentRate'>('average')
const distributionElement = ref<HTMLElement>()
const peerAverageElement = ref<HTMLElement>()
const peerPassElement = ref<HTMLElement>()
const benchmarkElement = ref<HTMLElement>()
const historyElement = ref<HTMLElement>()
const charts: echarts.ECharts[] = []
const summary = computed(() => report.value?.summary)
const delta = computed(() => report.value?.universityDelta)
const historyMetricMeta = computed(() => ({
average: { label: '平均分', course: '同课程全校平均', instructor: '当前任课教师历年', suffix: ' 分' },
passRate: { label: '合格率', course: '同课程全校合格率', instructor: '当前任课教师历年', suffix: '%' },
excellentRate: { label: '优秀率', course: '同课程全校优秀率', instructor: '当前任课教师历年', suffix: '%' },
}[historyMetric.value]))
function number(value: unknown, digits = 1) {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed.toFixed(digits) : '—'
}
function signed(value: unknown, suffix = '') {
const parsed = Number(value)
if (!Number.isFinite(parsed)) return '—'
return `${parsed > 0 ? '+' : ''}${parsed.toFixed(1)}${suffix}`
}
function disposeCharts() {
charts.splice(0).forEach(chart => chart.dispose())
}
function createChart(element: HTMLElement | undefined) {
if (!element) return undefined
const chart = echarts.init(element)
charts.push(chart)
return chart
}
async function loadClasses(reset = false) {
if (reset) page.value = 1
loading.value = true
try {
const data = (await http.get('/grade-analytics/teaching-classes', {
params: {
academicTermId: termId.value,
keyword: keyword.value.trim() || undefined,
page: page.value,
pageSize,
},
})).data
classes.value = data.items
total.value = data.total
const retained = classes.value.find(item => item.gradeSheetId === selected.value?.gradeSheetId)
selected.value = retained ?? classes.value[0]
if (selected.value) await loadReport(selected.value)
else {
report.value = undefined
disposeCharts()
}
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
loading.value = false
}
}
async function loadReport(item = selected.value) {
if (!item) return
selected.value = item
reportLoading.value = true
try {
report.value = (await http.get(`/grade-analytics/teaching-classes/${item.gradeSheetId}`)).data
await nextTick()
drawCharts()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
reportLoading.value = false
}
}
async function refreshStatistics() {
if (!selected.value) {
await loadClasses()
return
}
reportLoading.value = true
try {
await http.post(`/grade-analytics/teaching-classes/${selected.value.gradeSheetId}/refresh`)
ElMessage.success('已提交统计刷新任务。')
window.setTimeout(() => loadClasses(), 900)
} catch (error) {
ElMessage.error(apiErrorMessage(error))
reportLoading.value = false
}
}
function baseGrid(left = 48, right = 24) {
return { left, right, top: 42, bottom: 44, containLabel: true }
}
function drawCharts() {
disposeCharts()
if (!summary.value) return
const distribution = createChart(distributionElement.value)
distribution?.setOption({
aria: { enabled: true, decal: { show: true } },
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
grid: baseGrid(),
xAxis: { type: 'category', data: summary.value.scoreBands.map((x: any) => x.label), axisTick: { show: false } },
yAxis: { type: 'value', minInterval: 1, name: '人数', splitLine: { lineStyle: { color: '#edf1f6' } } },
series: [{
name: '人数', type: 'bar', barMaxWidth: 54,
data: summary.value.scoreBands.map((x: any, index: number) => ({
value: x.studentCount,
itemStyle: { color: ['#b9c6d8', '#95a9c5', '#6f8db5', '#496f9f', '#204f87'][index] },
})),
label: { show: true, position: 'top', formatter: (p: any) => `${p.value}` },
itemStyle: { borderRadius: [4, 4, 0, 0] },
}],
})
const peers = report.value.peerTeachingClasses ?? []
const peerNames = peers.map((x: any) => `${x.taskNumber} · ${x.teacherNames || '未标注教师'}`)
const peerAverage = createChart(peerAverageElement.value)
peerAverage?.setOption({
aria: { enabled: true, decal: { show: true } },
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' }, valueFormatter: (v: any) => `${number(v)}` },
grid: baseGrid(20, 36),
xAxis: { type: 'value', min: 0, max: 100, name: '平均分', splitLine: { lineStyle: { color: '#edf1f6' } } },
yAxis: { type: 'category', inverse: true, data: peerNames, axisTick: { show: false }, axisLabel: { width: 150, overflow: 'truncate' } },
series: [{
type: 'bar', barMaxWidth: 24,
data: peers.map((x: any) => ({ value: x.averageScore, itemStyle: { color: x.isSelected ? '#204f87' : '#aebdd1' } })),
label: { show: true, position: 'right', formatter: (p: any) => number(p.value) },
itemStyle: { borderRadius: [0, 4, 4, 0] },
}],
})
const peerPass = createChart(peerPassElement.value)
peerPass?.setOption({
aria: { enabled: true, decal: { show: true } },
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' }, valueFormatter: (v: any) => `${number(v)}%` },
grid: baseGrid(20, 42),
xAxis: { type: 'value', min: 0, max: 100, name: '合格率', axisLabel: { formatter: '{value}%' }, splitLine: { lineStyle: { color: '#edf1f6' } } },
yAxis: { type: 'category', inverse: true, data: peerNames, axisTick: { show: false }, axisLabel: { width: 150, overflow: 'truncate' } },
series: [{
type: 'bar', barMaxWidth: 18,
data: peers.map((x: any) => ({ value: x.passRate, itemStyle: { color: x.isSelected ? '#b8732d' : '#dcc3a5' } })),
label: { show: true, position: 'right', formatter: (p: any) => `${number(p.value)}%` },
itemStyle: { borderRadius: [0, 4, 4, 0] },
}],
})
const benchmarks = report.value.scopeBenchmarks ?? []
const benchmark = createChart(benchmarkElement.value)
benchmark?.setOption({
aria: { enabled: true, decal: { show: true } },
tooltip: {
trigger: 'axis',
formatter: (params: any) => {
const item = benchmarks[params[0]?.dataIndex ?? 0]
return `<b>${item.scope} · ${item.name}</b><br/>样本 ${item.studentCount} 人<br/>最高分 ${number(item.highestScore)}<br/>平均分 ${number(item.averageScore)}<br/>最低分 ${number(item.lowestScore)}<br/>合格率 ${number(item.passRate)}%`
},
},
legend: { top: 0, data: ['分数区间', '平均分', '合格率'] },
grid: { left: 30, right: 54, top: 58, bottom: 52, containLabel: true },
xAxis: { type: 'category', data: benchmarks.map((x: any) => `${x.scope}\n${x.name}`), axisTick: { show: false } },
yAxis: [
{ type: 'value', min: 0, max: 100, name: '分数', splitLine: { lineStyle: { color: '#edf1f6' } } },
{ type: 'value', min: 0, max: 100, name: '合格率', axisLabel: { formatter: '{value}%' }, splitLine: { show: false } },
],
series: [
{ name: '区间起点', type: 'bar', stack: 'range', silent: true, itemStyle: { color: 'transparent' }, data: benchmarks.map((x: any) => x.lowestScore) },
{ name: '分数区间', type: 'bar', stack: 'range', barWidth: 20, itemStyle: { color: '#a7b8cf', borderRadius: 6 }, data: benchmarks.map((x: any) => x.highestScore - x.lowestScore) },
{ name: '平均分', type: 'line', symbolSize: 9, lineStyle: { color: '#204f87', width: 3 }, itemStyle: { color: '#204f87' }, data: benchmarks.map((x: any) => x.averageScore) },
{ name: '合格率', type: 'line', yAxisIndex: 1, symbol: 'diamond', symbolSize: 9, lineStyle: { color: '#b8732d', type: 'dashed', width: 2 }, itemStyle: { color: '#b8732d' }, data: benchmarks.map((x: any) => x.passRate) },
],
})
drawHistoryChart()
}
function drawHistoryChart() {
const old = charts.find(chart => chart.getDom() === historyElement.value)
if (old) {
old.dispose()
charts.splice(charts.indexOf(old), 1)
}
const history = report.value?.history ?? []
const chart = createChart(historyElement.value)
if (!chart) return
const field = historyMetric.value === 'average' ? 'AverageScore' : historyMetric.value === 'passRate' ? 'PassRate' : 'ExcellentRate'
const courseField = `course${field}`
const instructorField = field[0].toLowerCase() + field.slice(1)
const seriesType = history.length >= 4 ? 'line' : 'bar'
chart.setOption({
aria: { enabled: true, decal: { show: true } },
tooltip: { trigger: 'axis', valueFormatter: (v: any) => `${number(v)}${historyMetricMeta.value.suffix}` },
legend: { top: 0, data: [historyMetricMeta.value.course, historyMetricMeta.value.instructor] },
grid: { left: 46, right: 28, top: 58, bottom: 54, containLabel: true },
xAxis: { type: 'category', data: history.map((x: any) => x.termName), axisLabel: { rotate: history.length > 5 ? 24 : 0 } },
yAxis: { type: 'value', min: 0, max: 100, name: historyMetricMeta.value.label, axisLabel: { formatter: historyMetric.value === 'average' ? '{value}' : '{value}%' }, splitLine: { lineStyle: { color: '#edf1f6' } } },
series: [
{
name: historyMetricMeta.value.course, type: seriesType, smooth: seriesType === 'line', symbol: 'circle', symbolSize: 8,
barMaxWidth: 34, itemStyle: { color: '#9eafc5' }, lineStyle: { color: '#9eafc5', width: 2 },
data: history.map((x: any) => x[courseField]),
},
{
name: historyMetricMeta.value.instructor, type: seriesType, smooth: seriesType === 'line', symbol: 'diamond', symbolSize: 9,
barMaxWidth: 34, itemStyle: { color: '#204f87' }, lineStyle: { color: '#204f87', width: 3 },
data: history.map((x: any) => x.instructor?.[instructorField] ?? null),
},
],
})
}
function resizeCharts() {
charts.forEach(chart => chart.resize())
}
watch(historyMetric, async () => {
await nextTick()
drawHistoryChart()
})
onMounted(async () => {
try {
terms.value = (await http.get('/base-data/terms')).data
termId.value = defaultAcademicTermId(terms.value)
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
await loadClasses(true)
window.addEventListener('resize', resizeCharts)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', resizeCharts)
disposeCharts()
})
</script>
<template>
<div class="page-stack grade-analytics">
<section class="page-intro analytics-intro">
<div>
<span class="section-kicker">TEACHING CLASS ANALYTICS</span>
<h2>教学班成绩分析</h2>
<p>对比当前教学班同课程教学班学生来源范围与历年成绩统计仅使用已正式发布成绩</p>
</div>
<el-button :icon="Refresh" @click="refreshStatistics">重新计算当前教学班</el-button>
</section>
<section class="filter-bar">
<el-select v-model="termId" clearable placeholder="全部学期" style="width: 240px" @change="loadClasses(true)">
<el-option v-for="term in terms" :key="term.id" :label="academicTermLabel(term)" :value="term.id" />
</el-select>
<el-input v-model="keyword" clearable placeholder="课程、教学班名称或编号" :prefix-icon="Search" @keyup.enter="loadClasses(true)" />
<el-button type="primary" @click="loadClasses(true)">查询</el-button>
</section>
<section class="analytics-shell">
<aside class="class-rail" v-loading="loading">
<header><strong>可查看教学班</strong><small>{{ total }} 个已生成统计</small></header>
<button
v-for="item in classes"
:key="item.gradeSheetId"
type="button"
:class="['class-option', { active: selected?.gradeSheetId === item.gradeSheetId }]"
@click="loadReport(item)"
>
<span class="course-code">{{ item.courseCode }} · {{ item.taskNumber }}</span>
<strong>{{ item.courseName }}</strong>
<small>{{ item.teacherNames.join('、') || '未标注教师' }} · {{ item.studentCount }} </small>
<span class="option-metrics"><b>{{ number(item.averageScore) }}</b> 平均分 · <b>{{ number(item.passRate) }}%</b> 合格</span>
</button>
<el-empty v-if="!loading && !classes.length" :image-size="72" description="当前范围暂无已发布成绩统计" />
<el-pagination
v-if="total > pageSize"
small layout="prev, pager, next" :total="total" :page-size="pageSize" v-model:current-page="page"
@current-change="loadClasses()"
/>
</aside>
<main class="analysis-canvas" v-loading="reportLoading">
<el-empty v-if="!selected" description="请选择一个教学班查看分析" />
<el-alert v-else-if="report?.isRefreshing" type="info" :closable="false" title="该教学班统计正在生成,请稍后刷新。" />
<template v-else-if="summary">
<header class="report-heading">
<div>
<span>{{ report.courseCode }} · {{ report.taskNumber }} · {{ report.termName }}</span>
<h3>{{ report.courseName }}</h3>
<p>{{ report.taskName }} · 数据更新时间 {{ new Date(summary.calculatedAt).toLocaleString() }}</p>
</div>
<div class="sample-stamp"><b>{{ summary.studentCount }}</b><span>有效成绩</span></div>
</header>
<section class="kpi-strip">
<article><span>平均分</span><strong>{{ number(summary.averageScore) }}</strong><small>全体有效成绩</small></article>
<article><span>中位数</span><strong>{{ number(summary.medianScore) }}</strong><small>降低极端值影响</small></article>
<article><span>标准差</span><strong>{{ number(summary.standardDeviation, 2) }}</strong><small>越大表示分数越分散</small></article>
<article><span>最高 / 最低</span><strong>{{ number(summary.highestScore) }} / {{ number(summary.lowestScore) }}</strong><small>成绩跨度 {{ number(summary.highestScore - summary.lowestScore) }}</small></article>
<article><span>合格率</span><strong>{{ number(summary.passRate) }}%</strong><small>{{ summary.passedCount }} / {{ summary.studentCount }} </small></article>
<article><span>优秀率</span><strong>{{ number(summary.excellentRate) }}%</strong><small>{{ summary.excellentCount }} 人达到 90 </small></article>
</section>
<section v-if="delta" class="benchmark-note">
<span>相对本学期全校同课程</span>
<strong :class="{ negative: delta.averageScoreDifference < 0 }">平均分 {{ signed(delta.averageScoreDifference, ' 分') }}</strong>
<strong :class="{ negative: delta.passRateDifference < 0 }">合格率 {{ signed(delta.passRateDifference, '%') }}</strong>
<small>全校基准平均 {{ number(delta.universityAverageScore) }}合格率 {{ number(delta.universityPassRate) }}%</small>
</section>
<section class="chart-grid">
<article class="chart-card">
<header><div><span>DISTRIBUTION</span><h4>本教学班分数段分布</h4></div><p>{{ summary.studentCount }} 按五个固定区间统计</p></header>
<div ref="distributionElement" class="chart" />
</article>
<article class="chart-card">
<header><div><span>PEER AVERAGE</span><h4>同课程教学班平均分</h4></div><p>当前教学班使用深色标识</p></header>
<div ref="peerAverageElement" class="chart" />
</article>
<article class="chart-card">
<header><div><span>PEER PASS RATE</span><h4>同课程教学班合格率</h4></div><p>同一课程同一学期横向比较</p></header>
<div ref="peerPassElement" class="chart" />
</article>
<article class="chart-card chart-card-wide">
<header><div><span>SCOPE BENCHMARK</span><h4>行政班专业学院与全校基准</h4></div><p>对照本教学班学生来源范围显示分数区间平均分和合格率</p></header>
<div ref="benchmarkElement" class="chart chart-large" />
</article>
<article class="chart-card chart-card-wide">
<header class="history-heading">
<div><span>HISTORICAL COMPARISON</span><h4>当前任课教师与全校同课程历年对比</h4></div>
<el-radio-group v-model="historyMetric" size="small">
<el-radio-button value="average">平均分</el-radio-button>
<el-radio-button value="passRate">合格率</el-radio-button>
<el-radio-button value="excellentRate">优秀率</el-radio-button>
</el-radio-group>
</header>
<p class="chart-caption">教师序列按当前教学班任课教师在各学期所带同课程教学班加权汇总全校序列为该课程当期全部已发布成绩</p>
<div ref="historyElement" class="chart chart-large" />
</article>
</section>
</template>
</main>
</section>
</div>
</template>
<style scoped>
.grade-analytics { --ink: #18324f; --blue: #204f87; --line: #dce3ec; }
.analytics-intro { align-items: flex-end; }
.filter-bar { display: grid; grid-template-columns: 240px minmax(260px, 560px) auto; gap: 12px; align-items: center; padding: 16px 18px; background: #f7f9fc; border: 1px solid var(--line); }
.analytics-shell { display: grid; grid-template-columns: 292px minmax(0, 1fr); gap: 18px; align-items: start; }
.class-rail { position: sticky; top: 18px; max-height: calc(100vh - 36px); overflow: auto; background: #f6f8fb; border: 1px solid var(--line); }
.class-rail > header { position: sticky; top: 0; z-index: 2; display: flex; justify-content: space-between; gap: 8px; padding: 15px 16px; background: #eef2f7; border-bottom: 1px solid var(--line); }
.class-rail > header small { color: #667085; }
.class-option { width: 100%; padding: 15px 16px; border: 0; border-bottom: 1px solid #e1e6ed; background: transparent; color: #475467; text-align: left; cursor: pointer; transition: background .18s ease, box-shadow .18s ease; }
.class-option:hover { background: #fff; }
.class-option.active { background: #fff; box-shadow: inset 4px 0 var(--blue); }
.class-option:focus-visible { outline: 2px solid var(--blue); outline-offset: -3px; }
.class-option > * { display: block; }
.class-option strong { margin: 5px 0; color: var(--ink); font-size: 14px; }
.class-option small { overflow: hidden; color: #667085; text-overflow: ellipsis; white-space: nowrap; }
.course-code { color: #7b8797; font: 11px Consolas, monospace; }
.option-metrics { margin-top: 9px; color: #667085; font-size: 11px; }
.option-metrics b { color: var(--blue); font: 700 13px Consolas, monospace; }
.class-rail :deep(.el-pagination) { justify-content: center; padding: 14px 4px; }
.analysis-canvas { min-width: 0; }
.report-heading { display: flex; justify-content: space-between; gap: 20px; padding: 22px 24px; color: #fff; background: var(--ink); }
.report-heading span { color: #b9c9da; font: 11px Consolas, monospace; }
.report-heading h3 { margin: 7px 0 5px; font-size: 24px; }
.report-heading p { margin: 0; color: #cbd5e1; font-size: 12px; }
.sample-stamp { display: flex; flex-direction: column; min-width: 94px; justify-content: center; padding-left: 22px; border-left: 1px solid rgb(255 255 255 / 20%); }
.sample-stamp b { font: 700 34px/1 Consolas, monospace; }
.sample-stamp span { margin-top: 5px; color: #b9c9da; font-size: 11px; }
.kpi-strip { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); background: #fff; border: 1px solid var(--line); border-top: 0; }
.kpi-strip article { min-height: 112px; padding: 17px 20px; border-right: 1px solid var(--line); border-bottom: 1px solid var(--line); }
.kpi-strip article:nth-child(3n) { border-right: 0; }
.kpi-strip span, .kpi-strip small { display: block; color: #667085; }
.kpi-strip span { font-size: 11px; font-weight: 700; }
.kpi-strip strong { display: block; margin: 10px 0 7px; overflow-wrap: anywhere; color: var(--ink); font: 700 24px/1 Consolas, monospace; }
.kpi-strip small { font-size: 10px; }
.benchmark-note { display: flex; align-items: center; flex-wrap: wrap; gap: 12px 20px; padding: 14px 18px; background: #edf3fa; border: 1px solid #ccd9e9; border-top: 0; }
.benchmark-note > span { color: var(--ink); font-weight: 700; }
.benchmark-note strong { color: var(--blue); font: 700 13px Consolas, monospace; }
.benchmark-note strong.negative { color: #9a5b28; }
.benchmark-note small { margin-left: auto; color: #667085; }
.chart-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; margin-top: 18px; }
.chart-card { min-width: 0; padding: 20px 22px; background: #fff; border: 1px solid var(--line); box-shadow: 0 10px 28px rgb(24 50 79 / 5%); }
.chart-card-wide { grid-column: 1 / -1; }
.chart-card header { display: flex; min-width: 0; align-items: end; justify-content: space-between; gap: 18px; }
.chart-card header span { color: #7b8797; font: 10px Consolas, monospace; letter-spacing: .08em; }
.chart-card h4 { margin: 5px 0 0; color: var(--ink); font-size: 16px; }
.chart-card header p, .chart-caption { min-width: 0; margin: 0; color: #667085; font-size: 11px; text-align: right; }
.chart-caption { margin-top: 9px; text-align: left; }
.chart { height: 310px; margin-top: 10px; }
.chart-large { height: 360px; }
@media (max-width: 1180px) {
.analytics-shell { grid-template-columns: 240px minmax(0, 1fr); }
.chart-grid { grid-template-columns: 1fr; }
.chart-card-wide { grid-column: auto; }
}
@media (max-width: 820px) {
.filter-bar { grid-template-columns: 1fr; }
.filter-bar :deep(.el-select) { width: 100% !important; }
.analytics-shell { grid-template-columns: 1fr; }
.class-rail { position: static; max-height: 390px; }
.kpi-strip { grid-template-columns: repeat(2, 1fr); }
.kpi-strip article:nth-child(3n) { border-right: 1px solid var(--line); }
.kpi-strip article:nth-child(2n) { border-right: 0; }
.history-heading, .chart-card header { align-items: flex-start; flex-direction: column; }
.chart-card header p { text-align: left; }
}
@media (max-width: 520px) {
.report-heading { flex-direction: column; }
.sample-stamp { padding: 14px 0 0; border: 0; border-top: 1px solid rgb(255 255 255 / 20%); }
.kpi-strip { grid-template-columns: 1fr; }
.kpi-strip article { border-right: 0 !important; }
.chart-card { padding: 17px 14px; }
}
</style>
+93 -30
View File
@@ -2,6 +2,7 @@
import { computed, onMounted, reactive, ref } from 'vue'
import {
Check,
ArrowRight,
Delete,
DocumentChecked,
Download,
@@ -15,9 +16,11 @@ import {
import http, { apiErrorMessage } from '../api/http'
import { downloadApiFile, importExcel } from '../api/excel'
import { useAuthStore } from '../stores/auth'
import { useRouter } from 'vue-router'
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
const auth = useAuthStore()
const router = useRouter()
const isStudent = computed(() => auth.user?.roles.includes('Student') &&
!auth.user.roles.some((role) =>
['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher'].includes(role)))
@@ -127,6 +130,10 @@ function removeItem(index: number) {
createForm.items.splice(index, 1)
}
function openStatistics(record: any) {
router.push({ name: 'course-grade-statistics', params: { sheetId: record.gradeSheetId } })
}
async function load(resetPage = false) {
loading.value = true
try {
@@ -384,6 +391,10 @@ function scoreClass(score: number | null) {
return ''
}
function isFailingScore(score: number | string | null | undefined) {
return score != null && score !== '' && Number(score) < 60
}
function itemScore(record: any, gradeItemId: string) {
return record.itemScores?.find((itemScore: any) => itemScore.gradeItemId === gradeItemId)
}
@@ -459,7 +470,17 @@ onMounted(async () => {
</section>
<section class="transcript-list" v-loading="loading">
<article v-for="record in transcript.records" :key="record.id">
<article
v-for="record in transcript.records"
:key="record.id"
class="transcript-course-card"
role="button"
tabindex="0"
:aria-label="`查看${record.courseName}成绩分析`"
@click="openStatistics(record)"
@keydown.enter="openStatistics(record)"
@keydown.space.prevent="openStatistics(record)"
>
<div class="transcript-course">
<span>{{ record.termName }}</span>
<h3>{{ record.courseName }}</h3>
@@ -473,11 +494,12 @@ onMounted(async () => {
:style="{ left: `${record.totalScore}%` }"
>{{ record.totalScore }}</span>
</div>
<div class="transcript-result">
<div class="transcript-result" :class="{ 'is-failed': isFailingScore(record.totalScore) }">
<span>{{ examStatusLabels[record.examStatus] }}</span>
<b v-if="record.totalScore != null">{{ record.totalScore }}</b>
<b v-else></b>
<small>绩点 {{ record.gradePoint ?? '—' }}</small>
<span class="card-entry">查看成绩分析 <el-icon><ArrowRight /></el-icon></span>
</div>
</article>
<el-empty v-if="!transcript.records.length" description="当前范围内暂无已发布成绩" />
@@ -602,12 +624,14 @@ onMounted(async () => {
</el-table-column>
<el-table-column :label="`平时 ${detail.regularWeight}%`" width="110">
<template #default="{ row }">
<el-input-number
v-if="detail.canEdit && row.examStatus === 'Normal'"
v-model="row.regularScore"
:min="0" :max="100" :controls="false" size="small"
/>
<span v-else>{{ row.regularScore ?? '' }}</span>
<div class="score-cell" :class="{ 'is-failed': isFailingScore(row.regularScore) }">
<el-input-number
v-if="detail.canEdit && row.examStatus === 'Normal'"
v-model="row.regularScore"
:min="0" :max="100" :controls="false" size="small"
/>
<span v-else>{{ row.regularScore ?? '' }}</span>
</div>
</template>
</el-table-column>
<el-table-column
@@ -617,36 +641,45 @@ onMounted(async () => {
:width="item.sourceType === 'ExperimentSummary' ? 150 : 110"
>
<template #default="{ row }">
<el-input-number
v-if="detail.canEdit && row.examStatus === 'Normal' && item.sourceType !== 'ExperimentSummary'"
:model-value="itemScore(row, item.id)?.score"
@update:model-value="(val: number | undefined) => { const found = itemScore(row, item.id); if (found) found.score = val }"
:min="0" :max="100" :controls="false" size="small"
/>
<span v-else class="item-score-readonly">
{{ itemScore(row, item.id)?.score ?? '—' }}
<small v-if="item.sourceType === 'ExperimentSummary'">已锁定</small>
</span>
<div class="score-cell" :class="{ 'is-failed': isFailingScore(itemScore(row, item.id)?.score) }">
<el-input-number
v-if="detail.canEdit && row.examStatus === 'Normal' && item.sourceType !== 'ExperimentSummary'"
:model-value="itemScore(row, item.id)?.score"
@update:model-value="(val: number | undefined) => { const found = itemScore(row, item.id); if (found) found.score = val }"
:min="0" :max="100" :controls="false" size="small"
/>
<span v-else class="item-score-readonly">
{{ itemScore(row, item.id)?.score ?? '—' }}
<small v-if="item.sourceType === 'ExperimentSummary'">已锁定</small>
</span>
</div>
</template>
</el-table-column>
<el-table-column :label="`期末 ${detail.finalWeight}%`" width="110">
<template #default="{ row }">
<el-input-number
v-if="detail.canEdit && row.examStatus === 'Normal'"
v-model="row.finalScore"
:min="0" :max="100" :controls="false" size="small"
/>
<span v-else>{{ row.finalScore ?? '' }}</span>
<div class="score-cell" :class="{ 'is-failed': isFailingScore(row.finalScore) }">
<el-input-number
v-if="detail.canEdit && row.examStatus === 'Normal'"
v-model="row.finalScore"
:min="0" :max="100" :controls="false" size="small"
/>
<span v-else>{{ row.finalScore ?? '' }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="总评" width="90">
<template #default="{ row }">
<template v-if="detail.canEdit && row.examStatus === 'Normal'">
<b class="total-score preview" :class="scoreClass(calcPreviewTotal(row))">
{{ calcPreviewTotal(row) ?? '—' }}
</b>
</template>
<b v-else class="total-score" :class="scoreClass(row.totalScore)">{{ row.totalScore ?? '—' }}</b>
<div
class="score-cell"
:class="{ 'is-failed': isFailingScore(detail.canEdit && row.examStatus === 'Normal' ? calcPreviewTotal(row) : row.totalScore) }"
>
<template v-if="detail.canEdit && row.examStatus === 'Normal'">
<b class="total-score preview" :class="scoreClass(calcPreviewTotal(row))">
{{ calcPreviewTotal(row) ?? '—' }}
</b>
</template>
<b v-else class="total-score" :class="scoreClass(row.totalScore)">{{ row.totalScore ?? '—' }}</b>
</div>
</template>
</el-table-column>
<el-table-column label="考试状态" width="115">
@@ -795,6 +828,36 @@ onMounted(async () => {
.item-row > span:first-child { flex: 1; font-size: 13px; font-weight: 650; }
.add-item-row { display: flex; align-items: center; gap: 8px; }
.score-cell {
min-height: 30px;
margin: -5px;
padding: 5px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid transparent;
border-radius: 4px;
}
.score-cell.is-failed {
color: #b42318;
background: #fdecec;
border-color: #f4b8b2;
font-weight: 700;
}
.score-cell.is-failed :deep(input) { color: #b42318; font-weight: 700; }
.transcript-result.is-failed {
color: #b42318;
background: #fdecec;
border: 1px solid #f4b8b2;
border-radius: 4px;
padding: 8px;
}
.transcript-result.is-failed b { color: #b42318; }
.transcript-course-card { cursor: pointer; transition: transform .18s ease, border-color .18s ease, box-shadow .18s ease; }
.transcript-course-card:hover { transform: translateY(-2px); border-color: #91a8c8; box-shadow: 0 10px 24px rgb(31 66 112 / 9%); }
.transcript-course-card:focus-visible { outline: 2px solid #315a9b; outline-offset: 3px; }
.card-entry { display: inline-flex !important; align-items: center; justify-content: flex-end; gap: 4px; margin-top: 8px; color: #315a9b !important; font-size: 10px !important; font-weight: 700; }
/* Preview total score */
.total-score.preview {
text-decoration: underline dashed;
+45 -2
View File
@@ -1,13 +1,16 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth'
import http from '../api/http'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const loading = ref(false)
const ssoLoading = ref(false)
const sso = reactive({ enabled: false, displayName: '学校统一身份认证' })
const form = reactive({
userName: String(route.query.userName ?? ''),
password: '',
@@ -24,6 +27,37 @@ async function submit() {
loading.value = false
}
}
function startSso() {
ssoLoading.value = true
const apiBaseUrl = String(import.meta.env.VITE_API_BASE_URL ?? '/api').replace(/\/$/, '')
const redirect = String(route.query.redirect ?? '/dashboard')
window.location.assign(
`${apiBaseUrl}/auth/sso/login?returnUrl=${encodeURIComponent(redirect)}`,
)
}
const ssoErrors: Record<string, string> = {
authentication_failed: '统一身份认证未完成,请重新尝试。',
missing_subject: 'Keycloak 未返回用户唯一标识,请联系管理员检查客户端映射。',
account_disabled: '教务系统账号已停用或锁定,请联系管理员。',
account_link_failed: '统一身份账号绑定失败,请联系管理员。',
account_update_failed: '登录状态更新失败,请稍后重试。',
binding_expired: '账户绑定请求已失效,请重新使用统一身份认证登录。',
configuration_error: '统一身份认证回调地址配置不正确,请联系管理员。',
}
onMounted(async () => {
const ssoError = String(route.query.ssoError ?? '')
if (ssoError) ElMessage.error(ssoErrors[ssoError] ?? '统一身份认证失败,请重新尝试。')
try {
const { data } = await http.get('/auth/sso/settings')
sso.enabled = Boolean(data.enabled)
sso.displayName = String(data.displayName || sso.displayName)
} catch {
sso.enabled = false
}
})
</script>
<template>
@@ -48,7 +82,6 @@ async function submit() {
<span v-if="[3, 8, 9, 14, 18].includes(item)">教学</span>
</div>
</div>
<p class="story-foot">第一阶段 · 基础管理工作台</p>
</section>
<section class="login-panel">
@@ -82,6 +115,16 @@ async function submit() {
>
进入工作台
</el-button>
<div v-if="sso.enabled" class="login-divider"><span></span></div>
<el-button
v-if="sso.enabled"
class="sso-login-submit"
size="large"
:loading="ssoLoading"
@click="startSso"
>
使用{{ sso.displayName }}登录
</el-button>
<router-link class="public-timetable-link" to="/timetable">无需登录查询班级课表 </router-link>
<router-link class="account-activation-link" to="/activate">学生首次登录自助激活账号 </router-link>
</form>
+119
View File
@@ -0,0 +1,119 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Download, Plus, Promotion, Upload } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth'
type ResultRow = { studentNumber: string; studentName?: string; collegeName?: string; className?: string; attemptNumber?: number; score: number | null; level: string; isPassed: boolean | null; notes: string }
const auth = useAuthStore()
const isStudent = computed(() => auth.user?.roles.includes('Student'))
const loading = ref(false)
const batches = ref<any[]>([])
const selected = ref<any>(null)
const results = ref<ResultRow[]>([])
const mine = reactive({ best: [] as any[], history: [] as any[] })
const dialog = ref(false)
const publishDialog = ref(false)
const saving = ref(false)
const form = reactive({ examCode: '', name: '', organizer: '', examDate: new Date().toISOString().slice(0, 10), metricKind: 3, maxScore: 100, levelOptions: '' })
const selectedMetricKind = computed(() => Number(selected.value?.metricKind ?? 0))
const isScoreMetric = computed(() => selectedMetricKind.value === 3)
const isLevelMetric = computed(() => selectedMetricKind.value === 2)
const isPassMetric = computed(() => selectedMetricKind.value === 1)
async function load() {
loading.value = true
try {
if (isStudent.value) Object.assign(mine, (await http.get('/other-exams/mine')).data)
else batches.value = (await http.get('/other-exams/batches')).data
} catch (error) { ElMessage.error(apiErrorMessage(error)) } finally { loading.value = false }
}
async function openBatch(row: any) {
try {
const data = (await http.get(`/other-exams/batches/${row.id}`)).data
const metricKind = Number(data.batch.metricKind ?? 3)
data.batch.metricKind = [1, 2, 3].includes(metricKind) ? metricKind : 3
selected.value = data.batch
results.value = data.results.map((item: any) => ({ ...item, score: item.score ?? null, level: item.level ?? '', notes: item.notes ?? '' }))
} catch (error) { ElMessage.error(apiErrorMessage(error)) }
}
function addResult() { results.value.push({ studentNumber: '', studentName: '', collegeName: '', className: '', score: null, level: '', isPassed: null, notes: '' }) }
async function lookupStudent(row: ResultRow | any) {
row.studentName = ''; row.collegeName = ''; row.className = ''
if (!row.studentNumber.trim()) return
try {
const { data } = await http.get('/other-exams/students/lookup', { params: { studentNumber: row.studentNumber.trim() } })
row.studentNumber = data.studentNumber; row.studentName = data.name; row.collegeName = data.collegeName; row.className = data.className
} catch { ElMessage.warning(`未找到学号 ${row.studentNumber}`) }
}
async function saveResults() {
saving.value = true
try {
await http.put(`/other-exams/batches/${selected.value.id}/results`, { results: results.value.map(row => ({ studentNumber: row.studentNumber, score: isScoreMetric.value ? row.score : null, level: isLevelMetric.value ? row.level : null, isPassed: isPassMetric.value ? row.isPassed : null, notes: row.notes })) })
ElMessage.success('成绩已保存,参加次数由系统自动计算'); await openBatch(selected.value); await load()
} catch (error) { ElMessage.error(apiErrorMessage(error)) } finally { saving.value = false }
}
async function publish() {
publishDialog.value = true
}
async function confirmPublish() {
saving.value = true
try {
await http.post(`/other-exams/batches/${selected.value.id}/publish`)
publishDialog.value = false
ElMessage.success('成绩已发布')
await openBatch(selected.value)
await load()
} catch (error) {
ElMessage.error(`发布失败:${apiErrorMessage(error)}`)
} finally { saving.value = false }
}
async function createBatch() {
try { await http.post('/other-exams/batches', form); dialog.value = false; ElMessage.success('考试批次已建立'); await load() }
catch (error) { ElMessage.error(apiErrorMessage(error)) }
}
async function downloadTemplate() {
try {
const response = await http.get(`/other-exams/batches/${selected.value.id}/template`, { responseType: 'blob' })
const url = URL.createObjectURL(response.data); const link = document.createElement('a'); link.href = url; link.download = `其他考试成绩导入模板-${selected.value.examCode || selected.value.name}.xlsx`; link.click(); URL.revokeObjectURL(url)
} catch (error) { ElMessage.error(apiErrorMessage(error)) }
}
async function importExcel(event: Event) {
const input = event.target as HTMLInputElement; const file = input.files?.[0]; input.value = ''
if (!file) return
const data = new FormData(); data.append('file', file)
try { await http.post(`/other-exams/batches/${selected.value.id}/import`, data, { headers: { 'Content-Type': 'multipart/form-data' } }); ElMessage.success('其他考试成绩导入成功'); await openBatch(selected.value); await load() }
catch (error) { ElMessage.error(apiErrorMessage(error)) }
}
function metricName(kind: number | string) { const value = Number(kind); return value === 1 ? '合格制' : value === 2 ? '等级制' : '分数制' }
function displayResult(row: any) { return row.metricKind === 3 ? `${row.score} / ${row.maxScore}` : row.metricKind === 2 ? row.level : row.isPassed ? '合格' : '不合格' }
onMounted(load)
</script>
<template>
<div class="other-exams-page">
<section class="exam-hero"><div><span class="kicker">ASSESSMENT ARCHIVE</span><h1>其他考试成绩</h1><p>{{ isStudent ? '你的证书、等级考试与校外考试,按考试归档,最优结果一目了然。' : '用考试编码归并同一考试,按场次维护成绩,系统自动记录每位学生的参加次数。' }}</p></div><el-button v-if="!isStudent" type="primary" :icon="Plus" @click="dialog = true">新建考试场次</el-button></section>
<template v-if="isStudent">
<section class="result-board"><div class="board-title"><div><span class="kicker">BEST OUTCOME</span><h2>我的最优结果</h2></div><span class="board-note">同一考试编码下自动比较</span></div><el-table :data="mine.best" v-loading="loading"><el-table-column prop="examName" label="考试" min-width="180"/><el-table-column prop="examCode" label="考试编码" width="130"/><el-table-column prop="examDate" label="考试日期" width="120"/><el-table-column label="最优结果" min-width="150"><template #default="{ row }"><strong class="best-value">{{ displayResult(row) }}</strong></template></el-table-column><el-table-column prop="attemptNumber" label="参加次数" width="100"/></el-table><el-empty v-if="!loading && !mine.best.length" description="暂无已发布的其他考试成绩"/></section>
<section class="result-board history-board"><div class="board-title"><div><span class="kicker">FULL HISTORY</span><h2>历史成绩</h2></div><span class="board-note">每次发布的结果都会保留</span></div><el-table :data="mine.history" v-loading="loading"><el-table-column prop="examName" label="考试" min-width="180"/><el-table-column prop="examCode" label="考试编码" width="130"/><el-table-column prop="examDate" label="考试日期" width="120"/><el-table-column prop="attemptNumber" label="第几次参加" width="110"/><el-table-column label="结果" min-width="140"><template #default="{ row }">{{ displayResult(row) }}</template></el-table-column><el-table-column prop="publishedAt" label="发布时间" width="170"/></el-table></section>
</template>
<template v-else>
<section class="batch-panel"><div class="panel-heading"><div><span class="kicker">EXAM SESSIONS</span><h2>考试场次</h2></div><span>点击场次进入成绩录入</span></div><el-table :data="batches" v-loading="loading" @row-click="openBatch"><el-table-column prop="examCode" label="考试编码" width="140"/><el-table-column prop="name" label="考试名称" min-width="190"/><el-table-column prop="examDate" label="考试日期" width="120"/><el-table-column label="评价方式" width="100"><template #default="{ row }">{{ metricName(row.metricKind) }}</template></el-table-column><el-table-column prop="resultCount" label="已录入" width="90"/><el-table-column label="状态" width="120"><template #default="{ row }"><el-tag :type="row.status === 2 ? 'success' : 'info'">{{ row.status === 2 ? `已发布 ${row.publicationCount} ` : '草稿' }}</el-tag></template></el-table-column></el-table></section>
<section v-if="selected" class="editor-panel"><div class="editor-heading"><div><span class="kicker">{{ selected.examCode }}</span><h2>{{ selected.name }}</h2><p>{{ selected.organizer || '未填写组织方' }} · {{ selected.examDate }} · {{ metricName(selected.metricKind) }}{{ selected.metricKind === 3 ? ` · 满分 ${selected.maxScore}` : '' }}</p></div><div class="editor-actions"><el-button :icon="Download" @click="downloadTemplate">下载导入模板</el-button><label class="upload-button"><Upload />批量导入<input type="file" accept=".xlsx,.xls" @change="importExcel" /></label><el-button :icon="Plus" @click="addResult">新增一行</el-button><el-button type="primary" :loading="saving" @click="saveResults">保存记录</el-button><el-button type="success" :icon="Promotion" @click="publish">发布</el-button></div></div><div class="editor-hint">学号输入完成后离开输入框,系统自动检索姓名、学院和班级;参加次数不需要填写,由系统按考试编码和考试日期自动计算。</div><el-table :data="results" class="score-table"><el-table-column label="学号" min-width="150" fixed><template #default="{ row }"><el-input v-model="row.studentNumber" placeholder="输入学号" @blur="lookupStudent(row)" /></template></el-table-column><el-table-column label="姓名" width="110"><template #default="{ row }"><span :class="{ 'unresolved': row.studentNumber && !row.studentName }">{{ row.studentName || '待检索' }}</span></template></el-table-column><el-table-column label="学院" min-width="150"><template #default="{ row }">{{ row.collegeName || '—' }}</template></el-table-column><el-table-column label="班级" min-width="150"><template #default="{ row }">{{ row.className || '—' }}</template></el-table-column><el-table-column v-if="selected.metricKind === 3" label="成绩" width="150"><template #default="{ row }"><el-input-number v-model="row.score" :min="0" :max="selected.maxScore" :precision="2" controls-position="right" placeholder="请输入分数" /></template></el-table-column><el-table-column v-if="selected.metricKind === 2" label="等级" width="150"><template #default="{ row }"><el-select v-model="row.level" placeholder="选择等级"><el-option v-for="level in (selected.levelOptions || '').split(',').filter(Boolean)" :key="level" :label="level" :value="level" /></el-select></template></el-table-column><el-table-column v-if="selected.metricKind === 1" label="考试结果" width="150"><template #default="{ row }"><el-select v-model="row.isPassed" placeholder="选择结果"><el-option label="合格" :value="true"/><el-option label="不合格" :value="false"/></el-select></template></el-table-column><el-table-column label="参加次数" width="100"><template #default="{ row }"><span class="auto-attempt">{{ row.attemptNumber || '自动' }}</span></template></el-table-column><el-table-column label="备注" min-width="180"><template #default="{ row }"><el-input v-model="row.notes" placeholder="可选" /></template></el-table-column></el-table><el-empty v-if="!results.length" description="还没有成绩记录,点击“新增一行”或使用批量导入" /></section>
</template>
<el-dialog v-model="dialog" title="新建其他考试场次" width="600px"><el-form label-width="100px"><el-form-item label="考试编码" required><el-input v-model="form.examCode" placeholder="如 CET4、IELTS、计算机二级;同一考试始终使用相同编码" /></el-form-item><el-form-item label="考试名称" required><el-input v-model="form.name" placeholder="如 大学英语四级" /></el-form-item><el-form-item label="组织方"><el-input v-model="form.organizer" /></el-form-item><el-form-item label="考试日期"><el-date-picker v-model="form.examDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="评价方式"><el-select v-model="form.metricKind"><el-option label="分数制" :value="3"/><el-option label="等级制" :value="2"/><el-option label="合格/不合格" :value="1"/></el-select></el-form-item><el-form-item v-if="form.metricKind === 3" label="满分"><el-input-number v-model="form.maxScore" :min="1" /></el-form-item><el-form-item v-if="form.metricKind === 2" label="等级顺序"><el-input v-model="form.levelOptions" placeholder="按最优到最差填写,如 A+,A,B,C,D" /></el-form-item></el-form><template #footer><el-button @click="dialog = false">取消</el-button><el-button type="primary" @click="createBatch">建立场次</el-button></template></el-dialog>
<el-dialog v-model="publishDialog" title="发布其他考试成绩" width="460px" :close-on-click-modal="false"><div class="publish-confirm"><strong>确认发布“{{ selected?.name }}”成绩?</strong><p>发布后学生可以查看本批次成绩;如需更正,可以修改后再次发布。</p></div><template #footer><el-button @click="publishDialog = false">取消</el-button><el-button type="success" :loading="saving" @click="confirmPublish">确认发布</el-button></template></el-dialog>
</div>
</template>
<style scoped>
.other-exams-page { --ink:#172b3a; --muted:#718391; --line:#dce6e9; --teal:#087f78; --navy:#193b68; padding-bottom:40px; }
.exam-hero { display:flex; justify-content:space-between; align-items:end; gap:24px; padding:26px 0 24px; border-bottom:1px solid var(--line); margin-bottom:20px; }
.kicker { color:var(--teal); font-size:11px; letter-spacing:.17em; font-weight:800; }.exam-hero h1,.board-title h2,.panel-heading h2,.editor-heading h2 { color:var(--ink); margin:7px 0; letter-spacing:-.025em; }.exam-hero h1 { font-size:32px; }.exam-hero p,.editor-heading p { color:var(--muted); margin:0; line-height:1.7; }.result-board,.batch-panel,.editor-panel { background:#fff; border:1px solid var(--line); border-radius:14px; box-shadow:0 12px 30px rgba(32,61,73,.06); margin-bottom:18px; overflow:hidden; }.board-title,.panel-heading { display:flex; justify-content:space-between; align-items:center; padding:20px 22px 14px; }.board-title h2,.panel-heading h2,.editor-heading h2 { font-size:20px; }.board-note,.panel-heading>span { color:var(--muted); font-size:13px; }.history-board { opacity:.96; }.best-value { color:var(--navy); font-variant-numeric:tabular-nums; }.editor-heading { display:flex; justify-content:space-between; gap:20px; align-items:center; padding:20px 22px 14px; }.editor-actions { display:flex; flex-wrap:wrap; gap:8px; justify-content:flex-end; }.upload-button { display:inline-flex; align-items:center; gap:5px; border:1px solid #dcdfe6; border-radius:4px; padding:8px 14px; color:#606266; cursor:pointer; font-size:14px; }.upload-button:hover { color:var(--navy); border-color:var(--navy); }.upload-button input { display:none; }.editor-hint { margin:0 22px 14px; padding:11px 14px; border-left:3px solid #d4a72c; background:#fff9e8; color:#786223; font-size:13px; }.auto-attempt { display:inline-flex; align-items:center; padding:4px 8px; border-radius:20px; background:#edf6f5; color:var(--teal); font-size:12px; }.unresolved { color:#c27b18; }.score-table :deep(.el-input-number) { width:125px; }.score-table :deep(.el-table__cell) { padding:12px 0; }
@media (max-width:760px) { .exam-hero,.editor-heading,.board-title,.panel-heading { display:block; }.exam-hero .el-button { margin-top:16px; }.editor-actions { justify-content:flex-start; margin-top:16px; }.board-note,.panel-heading>span { display:block; margin-top:5px; }.editor-hint { margin-left:14px; margin-right:14px; } }
.editor-heading > div:first-child { min-width: 0; }
.editor-actions { flex: 0 0 auto; }
.upload-button { flex: 0 0 auto; min-width: 112px; white-space: nowrap; justify-content: center; line-height: 1.4; }
.publish-confirm { color:var(--ink); line-height:1.7; }.publish-confirm p { margin:8px 0 0; color:var(--muted); font-size:13px; }
</style>
+50 -1
View File
@@ -133,8 +133,25 @@ function resetForm(row?: any) {
enrollmentDate: `${currentYear}-09-01`,
status: 'Active',
dateOfBirth: '',
englishName: '',
idCardNumber: '',
nationality: '',
ethnicity: '',
politicalStatus: '',
nativePlace: '',
householdAddress: '',
currentAddress: '',
postalCode: '',
phone: '',
email: '',
qq: '',
weChat: '',
emergencyContactName: '',
emergencyContactRelationship: '',
emergencyContactPhone: '',
specialTags: '',
specialNeeds: '',
biography: '',
notes: '',
}, row ?? {})
}
@@ -516,7 +533,7 @@ watch(active, async () => {
</div>
</section>
<el-dialog v-model="dialogVisible" :title="`${editingId ? '编辑' : '新增'}${pageTitle}`" width="620px">
<el-dialog v-model="dialogVisible" :title="`${editingId ? '编辑' : '新增'}${pageTitle}`" width="min(860px, 94vw)">
<el-form label-position="top" class="entity-form">
<template v-if="active === 'teachers'">
<div class="form-grid">
@@ -580,6 +597,38 @@ watch(active, async () => {
</el-form-item>
</div>
<el-form-item label="出生日期"><el-date-picker v-model="form.dateOfBirth" value-format="YYYY-MM-DD" /></el-form-item>
<el-divider content-position="left">个人资料</el-divider>
<div class="form-grid">
<el-form-item label="英文姓名"><el-input v-model="form.englishName" /></el-form-item>
<el-form-item label="证件号码"><el-input v-model="form.idCardNumber" /></el-form-item>
</div>
<div class="form-grid">
<el-form-item label="国籍"><el-input v-model="form.nationality" /></el-form-item>
<el-form-item label="民族"><el-input v-model="form.ethnicity" /></el-form-item>
</div>
<div class="form-grid">
<el-form-item label="政治面貌"><el-input v-model="form.politicalStatus" /></el-form-item>
<el-form-item label="籍贯"><el-input v-model="form.nativePlace" /></el-form-item>
</div>
<div class="form-grid">
<el-form-item label="户籍地址"><el-input v-model="form.householdAddress" /></el-form-item>
<el-form-item label="现居住地址"><el-input v-model="form.currentAddress" /></el-form-item>
</div>
<div class="form-grid">
<el-form-item label="邮政编码"><el-input v-model="form.postalCode" /></el-form-item>
<el-form-item label="微信"><el-input v-model="form.weChat" /></el-form-item>
</div>
<div class="form-grid">
<el-form-item label="QQ"><el-input v-model="form.qq" /></el-form-item>
<el-form-item label="紧急联系人"><el-input v-model="form.emergencyContactName" /></el-form-item>
</div>
<div class="form-grid">
<el-form-item label="与本人关系"><el-input v-model="form.emergencyContactRelationship" /></el-form-item>
<el-form-item label="紧急联系电话"><el-input v-model="form.emergencyContactPhone" /></el-form-item>
</div>
<el-form-item label="特殊标记"><el-input v-model="form.specialTags" placeholder="多个标记可用逗号分隔" /></el-form-item>
<el-form-item label="特殊情况说明"><el-input v-model="form.specialNeeds" type="textarea" :rows="3" /></el-form-item>
<el-form-item label="个人简介"><el-input v-model="form.biography" type="textarea" :rows="3" /></el-form-item>
</template>
<div class="form-grid">
<el-form-item label="联系电话"><el-input v-model="form.phone" /></el-form-item>
+109
View File
@@ -0,0 +1,109 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import http, { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const loading = ref(false)
const checking = ref(true)
const binding = reactive({
providerDisplayName: '学校统一身份认证',
externalUserName: '',
})
const form = reactive({ userName: '', password: '' })
function safeRedirect(value: unknown) {
const path = String(value ?? '')
return path.startsWith('/') && !path.startsWith('//') ? path : '/dashboard'
}
async function submit() {
if (!form.userName.trim() || !form.password) {
ElMessage.warning('请填写现有教务系统账号和密码。')
return
}
loading.value = true
try {
await auth.bindSso(String(route.query.code ?? ''), form.userName, form.password)
ElMessage.success('账户绑定成功。')
await router.replace(safeRedirect(route.query.redirect))
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
loading.value = false
}
}
onMounted(async () => {
const code = String(route.query.code ?? '')
if (!code) {
await router.replace({ name: 'login', query: { ssoError: 'authentication_failed' } })
return
}
try {
const { data } = await http.get('/auth/sso/binding', { params: { code } })
binding.providerDisplayName = String(data.providerDisplayName || binding.providerDisplayName)
binding.externalUserName = String(data.externalUserName || '')
} catch (error) {
ElMessage.error(apiErrorMessage(error))
await router.replace({ name: 'login', query: { ssoError: 'binding_expired' } })
} finally {
checking.value = false
}
})
</script>
<template>
<main class="sso-binding-page">
<section v-loading="checking" class="sso-binding-card">
<div class="brand-mark" aria-hidden="true">
<span v-for="index in 9" :key="index" />
</div>
<span class="eyebrow">首次使用统一身份认证</span>
<h1>绑定现有教务系统账号</h1>
<p class="binding-description">
已通过{{ binding.providerDisplayName }}验证
<strong v-if="binding.externalUserName">{{ binding.externalUserName }}</strong>
请输入一次现有教务系统账号和密码今后即可直接使用单点登录
</p>
<form @submit.prevent="submit">
<label>
<span>教务系统账号</span>
<el-input v-model="form.userName" size="large" autocomplete="username" />
</label>
<label>
<span>教务系统密码</span>
<el-input
v-model="form.password"
size="large"
type="password"
show-password
autocomplete="current-password"
@keyup.enter="submit"
/>
</label>
<el-button
class="binding-submit"
type="primary"
size="large"
native-type="submit"
:loading="loading"
:disabled="checking"
>
确认绑定并登录
</el-button>
</form>
<p class="binding-security-note">
绑定只建立登录关联不会修改您的角色学院或人员档案
</p>
<router-link to="/login">取消并返回登录页</router-link>
</section>
</main>
</template>
+39
View File
@@ -0,0 +1,39 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
function safeRedirect(value: unknown) {
const path = String(value ?? '')
return path.startsWith('/') && !path.startsWith('//') ? path : '/dashboard'
}
onMounted(async () => {
const code = String(route.query.code ?? '')
if (!code) {
await router.replace({ name: 'login', query: { ssoError: 'authentication_failed' } })
return
}
try {
await auth.exchangeSso(code)
await router.replace(safeRedirect(route.query.redirect))
} catch (error) {
ElMessage.error(apiErrorMessage(error))
await router.replace({ name: 'login', query: { ssoError: 'authentication_failed' } })
}
})
</script>
<template>
<main class="sso-callback-page">
<el-icon class="is-loading" :size="34"><Loading /></el-icon>
<h1>正在完成统一身份认证</h1>
<p>请稍候不要关闭此页面</p>
</main>
</template>
+169
View File
@@ -0,0 +1,169 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { Check, EditPen, Refresh } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http'
const loading = ref(true)
const saving = ref(false)
const editing = ref(false)
const profile = reactive<Record<string, any>>({})
const form = reactive<Record<string, any>>({})
const genderLabels: Record<string, string> = {
Unknown: '未设置', Male: '男', Female: '女',
}
const statusLabels: Record<string, string> = {
Active: '在籍', Suspended: '休学', Graduated: '毕业', Withdrawn: '退学',
}
const editableFields = [
'gender', 'dateOfBirth', 'englishName', 'idCardNumber', 'nationality',
'ethnicity', 'politicalStatus', 'nativePlace', 'householdAddress',
'currentAddress', 'postalCode', 'phone', 'email', 'qq', 'weChat',
'emergencyContactName', 'emergencyContactRelationship',
'emergencyContactPhone', 'specialTags', 'specialNeeds', 'biography',
]
function copyToForm() {
for (const key of editableFields) form[key] = profile[key] ?? ''
}
async function load() {
loading.value = true
try {
Object.assign(profile, (await http.get('/student/profile')).data)
copyToForm()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
loading.value = false
}
}
function startEdit() {
copyToForm()
editing.value = true
}
function cancelEdit() {
copyToForm()
editing.value = false
}
async function save() {
saving.value = true
try {
await http.put('/student/profile', form)
ElMessage.success('个人信息已保存,无需审核。')
editing.value = false
await load()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
saving.value = false
}
}
onMounted(load)
</script>
<template>
<div class="profile-page" v-loading="loading">
<section class="profile-hero">
<div>
<span>STUDENT PROFILE</span>
<h2>我的个人信息</h2>
<p>完善个人资料联系方式与需要任课教师关注的特殊情况</p>
</div>
<div class="profile-actions">
<el-button :icon="Refresh" @click="load">刷新</el-button>
<el-button v-if="!editing" type="primary" :icon="EditPen" @click="startEdit">修改信息</el-button>
<template v-else>
<el-button @click="cancelEdit">取消</el-button>
<el-button type="primary" :icon="Check" :loading="saving" @click="save">直接保存</el-button>
</template>
</div>
</section>
<el-alert
title="联系电话、电子邮箱、微信、紧急联系人以及特殊标记/说明会向你的任课教师展示,并写入教学班名单导出文件。"
type="info"
:closable="false"
show-icon
/>
<section class="identity-strip">
<div><span>学号</span><b>{{ profile.studentNumber }}</b><small>不可自行修改</small></div>
<div><span>姓名</span><b>{{ profile.name }}</b><small>不可自行修改</small></div>
<div><span>学院 / 专业</span><b>{{ profile.collegeName }}</b><small>{{ profile.majorName }}</small></div>
<div><span>行政班 / 学籍</span><b>{{ profile.className }}</b><small>{{ statusLabels[profile.status] }} · {{ profile.enrollmentYear }} </small></div>
</section>
<el-form :model="form" label-position="top" class="profile-form" :disabled="!editing">
<article class="profile-card">
<header><span>01</span><div><h3>身份与背景</h3><p>除学号和姓名外可直接维护常用身份信息</p></div></header>
<div class="form-grid">
<el-form-item label="英文姓名"><el-input v-model="form.englishName" maxlength="100" /></el-form-item>
<el-form-item label="性别"><el-select v-model="form.gender"><el-option v-for="(label, value) in genderLabels" :key="value" :label="label" :value="value" /></el-select></el-form-item>
<el-form-item label="出生日期"><el-date-picker v-model="form.dateOfBirth" value-format="YYYY-MM-DD" /></el-form-item>
<el-form-item label="证件号码"><el-input v-model="form.idCardNumber" maxlength="30" /></el-form-item>
<el-form-item label="国籍"><el-input v-model="form.nationality" maxlength="50" /></el-form-item>
<el-form-item label="民族"><el-input v-model="form.ethnicity" maxlength="50" /></el-form-item>
<el-form-item label="政治面貌"><el-input v-model="form.politicalStatus" maxlength="50" /></el-form-item>
<el-form-item label="籍贯"><el-input v-model="form.nativePlace" maxlength="100" /></el-form-item>
</div>
</article>
<article class="profile-card">
<header><span>02</span><div><h3>联系方式与地址</h3><p>任课教师仅能看到本区的电话邮箱和微信</p></div></header>
<div class="form-grid">
<el-form-item label="联系电话"><el-input v-model="form.phone" maxlength="30" /></el-form-item>
<el-form-item label="电子邮箱"><el-input v-model="form.email" maxlength="100" /></el-form-item>
<el-form-item label="微信"><el-input v-model="form.weChat" maxlength="60" /></el-form-item>
<el-form-item label="QQ"><el-input v-model="form.qq" maxlength="30" /></el-form-item>
<el-form-item label="邮政编码"><el-input v-model="form.postalCode" maxlength="20" /></el-form-item>
<el-form-item label="户籍地址" class="span-2"><el-input v-model="form.householdAddress" maxlength="300" /></el-form-item>
<el-form-item label="现居住地址" class="span-2"><el-input v-model="form.currentAddress" maxlength="300" /></el-form-item>
</div>
</article>
<article class="profile-card attention-card">
<header><span>03</span><div><h3>紧急联系与特殊标记</h3><p>本区内容会向任课教师展示请填写真实且必要的信息</p></div></header>
<div class="form-grid">
<el-form-item label="紧急联系人"><el-input v-model="form.emergencyContactName" maxlength="50" /></el-form-item>
<el-form-item label="与本人关系"><el-input v-model="form.emergencyContactRelationship" maxlength="30" /></el-form-item>
<el-form-item label="紧急联系电话"><el-input v-model="form.emergencyContactPhone" maxlength="30" /></el-form-item>
<el-form-item label="特殊标记"><el-input v-model="form.specialTags" maxlength="300" placeholder="多个标记可用逗号分隔" /></el-form-item>
<el-form-item label="特殊情况说明" class="span-2"><el-input v-model="form.specialNeeds" type="textarea" :rows="4" maxlength="1000" show-word-limit placeholder="例如学习支持、健康与安全方面需教师留意的事项" /></el-form-item>
<el-form-item label="个人简介" class="span-2"><el-input v-model="form.biography" type="textarea" :rows="4" maxlength="1000" show-word-limit /></el-form-item>
</div>
</article>
</el-form>
</div>
</template>
<style scoped>
.profile-page { display: grid; gap: 18px; }
.profile-hero { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 26px 30px; border-radius: 18px; color: white; background: linear-gradient(125deg, #173a50, #176b87 62%, #2e9b91); box-shadow: 0 18px 45px rgba(23, 58, 80, .16); }
.profile-hero span { color: #8ee4d8; font-size: 11px; font-weight: 800; letter-spacing: .12em; }
.profile-hero h2 { margin: 7px 0 5px; font-size: 30px; font-family: "STZhongsong", "Songti SC", serif; }
.profile-hero p { margin: 0; color: rgba(255,255,255,.75); }
.profile-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; }
.identity-strip { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); border: 1px solid var(--line); background: white; }
.identity-strip > div { display: grid; gap: 5px; padding: 18px 20px; border-right: 1px solid var(--line); }
.identity-strip > div:last-child { border-right: 0; }
.identity-strip span, .identity-strip small { color: var(--muted); font-size: 12px; }
.identity-strip b { color: #263445; }
.profile-form { display: grid; gap: 18px; }
.profile-card { padding: 24px; border: 1px solid var(--line); border-radius: 14px; background: white; box-shadow: 0 10px 28px rgba(35,57,78,.05); }
.profile-card > header { display: flex; gap: 14px; margin-bottom: 22px; padding-bottom: 16px; border-bottom: 1px solid #edf0f3; }
.profile-card > header > span { color: var(--teal); font: 800 12px/1 Consolas, monospace; }
.profile-card h3 { margin: 0 0 5px; color: #263445; }
.profile-card p { margin: 0; color: var(--muted); font-size: 12px; }
.attention-card { border-top: 3px solid #d89b36; }
.form-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0 18px; }
.span-2 { grid-column: span 2; }
:deep(.el-form.is-disabled .el-input__wrapper), :deep(.el-form.is-disabled .el-textarea__inner) { background: #f8fafb; color: #364655; }
@media (max-width: 900px) { .identity-strip { grid-template-columns: repeat(2, 1fr); } .form-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 620px) { .profile-hero { align-items: flex-start; flex-direction: column; padding: 22px; } .profile-actions { justify-content: flex-start; } .identity-strip, .form-grid { grid-template-columns: 1fr; } .identity-strip > div { border-right: 0; border-bottom: 1px solid var(--line); } .span-2 { grid-column: auto; } .profile-card { padding: 18px; } }
</style>
+28
View File
@@ -118,6 +118,31 @@ onMounted(async () => {
<el-table-column prop="name" label="姓名" min-width="100" />
<el-table-column prop="className" label="班级" min-width="130" />
<el-table-column prop="majorName" label="专业" min-width="150" />
<el-table-column label="联系方式" min-width="210">
<template #default="{ row }">
<div class="contact-cell">
<span>{{ row.phone || '未填写电话' }}</span>
<small>{{ row.email || row.weChat ? [row.email, row.weChat && `微信 ${row.weChat}`].filter(Boolean).join(' · ') : '未填写邮箱或微信' }}</small>
</div>
</template>
</el-table-column>
<el-table-column label="紧急联系人" min-width="180">
<template #default="{ row }">
<div class="contact-cell">
<span>{{ row.emergencyContactName || '—' }}<template v-if="row.emergencyContactRelationship">{{ row.emergencyContactRelationship }}</template></span>
<small>{{ row.emergencyContactPhone || '未填写联系电话' }}</small>
</div>
</template>
</el-table-column>
<el-table-column label="特殊标记" min-width="210">
<template #default="{ row }">
<div class="special-cell">
<el-tag v-if="row.specialTags" type="warning" effect="light">{{ row.specialTags }}</el-tag>
<span v-else></span>
<small v-if="row.specialNeeds">{{ row.specialNeeds }}</small>
</div>
</template>
</el-table-column>
<el-table-column label="名单来源" width="210">
<template #default="{ row }">{{ row.enrolledAt ? `选课 · ${new Date(row.enrolledAt).toLocaleString('zh-CN')}` : '行政班关联' }}</template>
</el-table-column>
@@ -156,6 +181,9 @@ onMounted(async () => {
.roster-head h3 { margin: 5px 0 4px; font-size: 18px; }
.roster-head p { color: var(--muted); font-size: 12px; margin: 0; }
.roster-head span { color: var(--teal); font-size: 10px; font-weight: 700; }
.contact-cell, .special-cell { display: grid; gap: 4px; line-height: 1.35; }
.contact-cell small, .special-cell small { color: var(--muted); white-space: normal; }
.special-cell .el-tag { width: fit-content; max-width: 100%; white-space: normal; height: auto; padding-block: 3px; }
@media (max-width: 760px) {
.roster-workspace { grid-template-columns: 1fr; }
.roster-task-list { max-height: 220px; border-right: none; border-bottom: 1px solid var(--line); }
+8
View File
@@ -1,15 +1,23 @@
import { defineConfig, loadEnv } from 'vite'
import { readFileSync } from 'node:fs'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
const packageJson = JSON.parse(
readFileSync(new URL('./package.json', import.meta.url), 'utf8'),
) as { version: string }
// https://vite.dev/config/
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
const isCapacitor = env.VITE_ROUTER_MODE === 'hash'
return {
define: {
__APP_VERSION__: JSON.stringify(packageJson.version),
},
plugins: [
vue(),
AutoImport({