Files
Academic-Affairs-System/src/Jiaowu.Api/Controllers/BaseDataController.cs
T
biss c91d826169 学年学期页面新增“设为当前”“归档”“撤销归档”,当前学期不能归档、删除或直接取消;已归档学期需先撤销才能重新设为当前。[BaseDataController.cs (line 286)](/E:/jiaowu/src/Jiaowu.Api/Controllers/BaseDataController.cs:286)
切换后,其他学期自动变为历史态;已归档学期进一步弱化。所有主要学期选择器统一默认当前,并显示“当前 / 历史 / 已归档”。[academicTerms.ts (line 1)](/E:/jiaowu/web/src/utils/academicTerms.ts:1)
桌面使用分层表格,390px 手机端改为独立学期卡片,操作不会再挤压表格。[BaseDataView.vue (line 211)](/E:/jiaowu/web/src/views/BaseDataView.vue:211)
归档定义为“历史显示状态”,不是成绩冻结:补考成绩仍可录入并回写,已发布成绩仍可走成绩更正审批。
已加入 SQLite 开发迁移、MySQL 正式迁移和切换/归档/撤销测试。[20260726143000_AcademicTermArchiving.cs (line 1)](/E:/jiaowu/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260726143000_AcademicTermArchiving.cs:1)
2026-07-26 11:14:05 +08:00

652 lines
24 KiB
C#

using System.ComponentModel.DataAnnotations;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Common;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/base-data")]
public sealed class BaseDataController(AppDbContext db) : ControllerBase
{
private const string Administrators =
$"{SystemRoles.SuperAdmin},{SystemRoles.AcademicAdmin}";
[HttpGet("campuses")]
public async Task<ActionResult<IReadOnlyCollection<Campus>>> GetCampuses(
CancellationToken cancellationToken) =>
await db.Campuses.AsNoTracking()
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
.ToListAsync(cancellationToken);
[HttpPost("campuses")]
[Authorize(Roles = Administrators)]
public async Task<ActionResult<Campus>> CreateCampus(
CatalogRequest request,
CancellationToken cancellationToken)
{
var entity = new Campus
{
Code = request.Code.Trim(),
Name = request.Name.Trim(),
Address = request.Description?.Trim(),
SortOrder = request.SortOrder,
IsEnabled = request.IsEnabled
};
return await CreateAsync(entity, "GetCampuses", cancellationToken);
}
[HttpPut("campuses/{id:guid}")]
[Authorize(Roles = Administrators)]
public async Task<ActionResult<Campus>> UpdateCampus(
Guid id,
CatalogRequest request,
CancellationToken cancellationToken)
{
var entity = await db.Campuses.FindAsync([id], cancellationToken);
if (entity is null) return NotFound();
ApplyCatalog(entity, request);
entity.Address = request.Description?.Trim();
await db.SaveChangesAsync(cancellationToken);
return entity;
}
[HttpGet("colleges")]
public async Task<ActionResult<object>> GetColleges(CancellationToken cancellationToken) =>
Ok(await db.Colleges.AsNoTracking()
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
.Select(x => new
{
x.Id, x.Code, x.Name, x.ShortName, x.CampusId,
CampusName = x.Campus != null ? x.Campus.Name : null,
x.IsEnabled, x.SortOrder
})
.ToListAsync(cancellationToken));
[HttpPost("colleges")]
[Authorize(Roles = Administrators)]
public async Task<ActionResult<College>> CreateCollege(
CollegeRequest request,
CancellationToken cancellationToken)
{
if (request.CampusId.HasValue &&
!await db.Campuses.AnyAsync(x => x.Id == request.CampusId, cancellationToken))
{
return ValidationProblem("所选校区不存在。");
}
var entity = new College
{
Code = request.Code.Trim(),
Name = request.Name.Trim(),
ShortName = request.ShortName?.Trim(),
CampusId = request.CampusId,
SortOrder = request.SortOrder,
IsEnabled = request.IsEnabled
};
return await CreateAsync(entity, "GetColleges", cancellationToken);
}
[HttpPut("colleges/{id:guid}")]
[Authorize(Roles = Administrators)]
public async Task<ActionResult<College>> UpdateCollege(
Guid id,
CollegeRequest request,
CancellationToken cancellationToken)
{
var entity = await db.Colleges.FindAsync([id], cancellationToken);
if (entity is null) return NotFound();
ApplyCatalog(entity, request);
entity.ShortName = request.ShortName?.Trim();
entity.CampusId = request.CampusId;
await db.SaveChangesAsync(cancellationToken);
return entity;
}
[HttpGet("majors")]
public async Task<ActionResult<object>> GetMajors(CancellationToken cancellationToken) =>
Ok(await db.Majors.AsNoTracking()
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
.Select(x => new
{
x.Id, x.Code, x.Name, x.CollegeId,
CollegeName = x.College!.Name,
x.DegreeType, x.SchoolingYears, x.IsEnabled, x.SortOrder
})
.ToListAsync(cancellationToken));
[HttpGet("course-categories")]
public async Task<ActionResult<IReadOnlyCollection<CourseCategory>>> GetCourseCategories(
CancellationToken cancellationToken) =>
await db.CourseCategories.AsNoTracking()
.OrderBy(x => x.SortOrder)
.ThenBy(x => x.Code)
.ToListAsync(cancellationToken);
[HttpPost("course-categories")]
[Authorize(Roles = Administrators)]
public async Task<ActionResult<CourseCategory>> CreateCourseCategory(
CatalogRequest request,
CancellationToken cancellationToken)
{
var entity = new CourseCategory
{
Code = request.Code.Trim(),
Name = request.Name.Trim(),
SortOrder = request.SortOrder,
IsEnabled = request.IsEnabled
};
return await CreateAsync(entity, nameof(GetCourseCategories), cancellationToken);
}
[HttpPut("course-categories/{id:guid}")]
[Authorize(Roles = Administrators)]
public async Task<ActionResult<CourseCategory>> UpdateCourseCategory(
Guid id,
CatalogRequest request,
CancellationToken cancellationToken)
{
var entity = await db.CourseCategories.FindAsync([id], cancellationToken);
if (entity is null) return NotFound();
ApplyCatalog(entity, request);
await db.SaveChangesAsync(cancellationToken);
return entity;
}
[HttpPost("majors")]
[Authorize(Roles = Administrators)]
public async Task<ActionResult<Major>> CreateMajor(
MajorRequest request,
CancellationToken cancellationToken)
{
if (!await db.Colleges.AnyAsync(x => x.Id == request.CollegeId, cancellationToken))
return ValidationProblem("所选学院不存在。");
var entity = new Major
{
Code = request.Code.Trim(),
Name = request.Name.Trim(),
CollegeId = request.CollegeId,
DegreeType = request.DegreeType.Trim(),
SchoolingYears = request.SchoolingYears,
SortOrder = request.SortOrder,
IsEnabled = request.IsEnabled
};
return await CreateAsync(entity, "GetMajors", cancellationToken);
}
[HttpPut("majors/{id:guid}")]
[Authorize(Roles = Administrators)]
public async Task<ActionResult<Major>> UpdateMajor(
Guid id,
MajorRequest request,
CancellationToken cancellationToken)
{
var entity = await db.Majors.FindAsync([id], cancellationToken);
if (entity is null) return NotFound();
ApplyCatalog(entity, request);
entity.CollegeId = request.CollegeId;
entity.DegreeType = request.DegreeType.Trim();
entity.SchoolingYears = request.SchoolingYears;
await db.SaveChangesAsync(cancellationToken);
return entity;
}
[HttpGet("classes")]
public async Task<ActionResult<object>> GetClasses(CancellationToken cancellationToken) =>
Ok(await db.AdministrativeClasses.AsNoTracking()
.OrderByDescending(x => x.Grade).ThenBy(x => x.Code)
.Select(x => new
{
x.Id, x.Code, x.Name, x.MajorId,
MajorName = x.Major!.Name,
CollegeName = x.Major.College!.Name,
x.Grade, x.CounselorUserId, x.CounselorName,
x.IsEnabled, x.SortOrder
})
.ToListAsync(cancellationToken));
[HttpGet("counselors")]
[Authorize(Roles = Administrators)]
public async Task<ActionResult<object>> GetCounselors(CancellationToken cancellationToken) =>
Ok(await db.Users.AsNoTracking()
.Where(user =>
user.IsEnabled &&
db.UserRoles.Any(userRole =>
userRole.UserId == user.Id &&
db.Roles.Any(role =>
role.Id == userRole.RoleId &&
role.Name == SystemRoles.Counselor)))
.OrderBy(x => x.DisplayName)
.Select(x => new
{
x.Id,
x.DisplayName,
x.StaffNumber,
x.CollegeId
})
.ToListAsync(cancellationToken));
[HttpPost("classes")]
[Authorize(Roles = Administrators)]
public async Task<ActionResult<AdministrativeClass>> CreateClass(
ClassRequest request,
CancellationToken cancellationToken)
{
if (!await db.Majors.AnyAsync(x => x.Id == request.MajorId, cancellationToken))
return ValidationProblem("所选专业不存在。");
var counselorName = await GetCounselorNameAsync(
request.CounselorUserId,
cancellationToken);
if (request.CounselorUserId.HasValue && counselorName is null)
return ValidationProblem("所选辅导员账号不存在、已停用或没有辅导员角色。");
var entity = new AdministrativeClass
{
Code = request.Code.Trim(),
Name = request.Name.Trim(),
MajorId = request.MajorId,
Grade = request.Grade,
CounselorUserId = request.CounselorUserId,
CounselorName = counselorName,
SortOrder = request.SortOrder,
IsEnabled = request.IsEnabled
};
return await CreateAsync(entity, "GetClasses", cancellationToken);
}
[HttpPut("classes/{id:guid}")]
[Authorize(Roles = Administrators)]
public async Task<ActionResult<AdministrativeClass>> UpdateClass(
Guid id,
ClassRequest request,
CancellationToken cancellationToken)
{
var entity = await db.AdministrativeClasses.FindAsync([id], cancellationToken);
if (entity is null) return NotFound();
var counselorName = await GetCounselorNameAsync(
request.CounselorUserId,
cancellationToken);
if (request.CounselorUserId.HasValue && counselorName is null)
return ValidationProblem("所选辅导员账号不存在、已停用或没有辅导员角色。");
ApplyCatalog(entity, request);
entity.MajorId = request.MajorId;
entity.Grade = request.Grade;
entity.CounselorUserId = request.CounselorUserId;
entity.CounselorName = counselorName;
await db.SaveChangesAsync(cancellationToken);
return entity;
}
[HttpGet("terms")]
public async Task<ActionResult<IReadOnlyCollection<AcademicTerm>>> GetTerms(
CancellationToken cancellationToken) =>
await db.AcademicTerms.AsNoTracking()
.OrderByDescending(x => x.IsCurrent)
.ThenBy(x => x.IsArchived)
.ThenByDescending(x => x.StartDate)
.ToListAsync(cancellationToken);
[HttpPost("terms")]
[Authorize(Roles = Administrators)]
public async Task<ActionResult<AcademicTerm>> CreateTerm(
TermRequest request,
CancellationToken cancellationToken)
{
if (request.EndDate <= request.StartDate)
return ValidationProblem("学期结束日期必须晚于开始日期。");
if (request.IsCurrent && !request.IsEnabled)
return ValidationProblem("当前学期必须保持启用。");
var hasCurrentTerm = await db.AcademicTerms
.AnyAsync(x => x.IsCurrent, cancellationToken);
var shouldBeCurrent = request.IsCurrent || (!hasCurrentTerm && request.IsEnabled);
if (shouldBeCurrent)
{
var currentTerms = await db.AcademicTerms
.Where(x => x.IsCurrent)
.ToListAsync(cancellationToken);
foreach (var currentTerm in currentTerms)
currentTerm.IsCurrent = false;
}
var entity = new AcademicTerm
{
Code = request.Code.Trim(),
Name = request.Name.Trim(),
AcademicYear = request.AcademicYear.Trim(),
Season = request.Season,
StartDate = request.StartDate,
EndDate = request.EndDate,
IsCurrent = shouldBeCurrent,
IsEnabled = request.IsEnabled
};
return await CreateAsync(entity, "GetTerms", cancellationToken);
}
[HttpPut("terms/{id:guid}")]
[Authorize(Roles = Administrators)]
public async Task<ActionResult<AcademicTerm>> UpdateTerm(
Guid id,
TermRequest request,
CancellationToken cancellationToken)
{
var entity = await db.AcademicTerms.FindAsync([id], cancellationToken);
if (entity is null) return NotFound();
if (request.EndDate <= request.StartDate)
return ValidationProblem("学期结束日期必须晚于开始日期。");
if (entity.IsCurrent && !request.IsCurrent)
return ConflictProblem("不能直接取消当前学期,请将另一个学期设为当前。");
if (request.IsCurrent && entity.IsArchived)
return ConflictProblem("已归档学期不能直接设为当前,请先撤销归档。");
if (request.IsCurrent && !request.IsEnabled)
return ValidationProblem("当前学期必须保持启用。");
if (request.IsCurrent)
{
var currentTerms = await db.AcademicTerms
.Where(x => x.Id != id && x.IsCurrent)
.ToListAsync(cancellationToken);
foreach (var currentTerm in currentTerms)
currentTerm.IsCurrent = false;
}
ApplyCatalog(entity, request);
entity.AcademicYear = request.AcademicYear.Trim();
entity.Season = request.Season;
entity.StartDate = request.StartDate;
entity.EndDate = request.EndDate;
entity.IsCurrent = request.IsCurrent;
await db.SaveChangesAsync(cancellationToken);
return entity;
}
[HttpPost("terms/{id:guid}/set-current")]
[Authorize(Roles = Administrators)]
public async Task<ActionResult<AcademicTerm>> SetCurrentTerm(
Guid id,
CancellationToken cancellationToken)
{
var entity = await db.AcademicTerms.FindAsync([id], cancellationToken);
if (entity is null) return NotFound();
if (entity.IsArchived)
return ConflictProblem("已归档学期不能直接设为当前,请先撤销归档。");
if (!entity.IsEnabled)
return ConflictProblem("停用学期不能设为当前,请先启用。");
if (entity.IsCurrent) return entity;
var currentTerms = await db.AcademicTerms
.Where(x => x.Id != id && x.IsCurrent)
.ToListAsync(cancellationToken);
foreach (var currentTerm in currentTerms)
currentTerm.IsCurrent = false;
entity.IsCurrent = true;
await db.SaveChangesAsync(cancellationToken);
return entity;
}
[HttpPost("terms/{id:guid}/archive")]
[Authorize(Roles = Administrators)]
public async Task<ActionResult<AcademicTerm>> ArchiveTerm(
Guid id,
CancellationToken cancellationToken)
{
var entity = await db.AcademicTerms.FindAsync([id], cancellationToken);
if (entity is null) return NotFound();
if (entity.IsCurrent)
return ConflictProblem("当前学期不能归档,请先切换到新的当前学期。");
if (entity.IsArchived) return entity;
entity.IsArchived = true;
entity.ArchivedAt = DateTime.UtcNow;
await db.SaveChangesAsync(cancellationToken);
return entity;
}
[HttpPost("terms/{id:guid}/unarchive")]
[Authorize(Roles = Administrators)]
public async Task<ActionResult<AcademicTerm>> UnarchiveTerm(
Guid id,
CancellationToken cancellationToken)
{
var entity = await db.AcademicTerms.FindAsync([id], cancellationToken);
if (entity is null) return NotFound();
if (!entity.IsArchived) return entity;
entity.IsArchived = false;
entity.ArchivedAt = null;
await db.SaveChangesAsync(cancellationToken);
return entity;
}
[HttpGet("classrooms")]
public async Task<ActionResult<object>> GetClassrooms(CancellationToken cancellationToken) =>
Ok(await db.Classrooms.AsNoTracking()
.OrderBy(x => x.Building!.Campus!.SortOrder)
.ThenBy(x => x.Code)
.Select(x => new
{
x.Id, x.Code, x.Name, x.BuildingId,
CampusId = x.Building!.CampusId,
BuildingName = x.Building!.Name,
CampusName = x.Building.Campus!.Name,
x.Capacity, x.RoomType, x.Equipment, x.IsEnabled, x.SortOrder
})
.ToListAsync(cancellationToken));
[HttpGet("buildings")]
public async Task<ActionResult<object>> GetBuildings(CancellationToken cancellationToken) =>
Ok(await db.Buildings.AsNoTracking()
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
.Select(x => new
{
x.Id, x.Code, x.Name, x.CampusId,
CampusName = x.Campus!.Name, x.IsEnabled, x.SortOrder
})
.ToListAsync(cancellationToken));
[HttpPost("buildings")]
[Authorize(Roles = Administrators)]
public async Task<ActionResult<Building>> CreateBuilding(
BuildingRequest request,
CancellationToken cancellationToken)
{
if (!await db.Campuses.AnyAsync(x => x.Id == request.CampusId, cancellationToken))
return ValidationProblem("所选校区不存在。");
var entity = new Building
{
Code = request.Code.Trim(),
Name = request.Name.Trim(),
CampusId = request.CampusId,
SortOrder = request.SortOrder,
IsEnabled = request.IsEnabled
};
return await CreateAsync(entity, "GetBuildings", cancellationToken);
}
[HttpPost("classrooms")]
[Authorize(Roles = Administrators)]
public async Task<ActionResult<Classroom>> CreateClassroom(
ClassroomRequest request,
CancellationToken cancellationToken)
{
if (!await db.Buildings.AnyAsync(x => x.Id == request.BuildingId, cancellationToken))
return ValidationProblem("所选教学楼不存在。");
var entity = new Classroom
{
Code = request.Code.Trim(),
Name = request.Name.Trim(),
BuildingId = request.BuildingId,
Capacity = request.Capacity,
RoomType = request.RoomType.Trim(),
Equipment = request.Equipment?.Trim(),
SortOrder = request.SortOrder,
IsEnabled = request.IsEnabled
};
return await CreateAsync(entity, "GetClassrooms", cancellationToken);
}
[HttpPut("classrooms/{id:guid}")]
[Authorize(Roles = Administrators)]
public async Task<ActionResult<Classroom>> UpdateClassroom(
Guid id,
ClassroomRequest request,
CancellationToken cancellationToken)
{
var entity = await db.Classrooms.FindAsync([id], cancellationToken);
if (entity is null) return NotFound();
ApplyCatalog(entity, request);
entity.BuildingId = request.BuildingId;
entity.Capacity = request.Capacity;
entity.RoomType = request.RoomType.Trim();
entity.Equipment = request.Equipment?.Trim();
await db.SaveChangesAsync(cancellationToken);
return entity;
}
[HttpDelete("{kind}/{id:guid}")]
[Authorize(Roles = Administrators)]
public async Task<IActionResult> Delete(
string kind,
Guid id,
CancellationToken cancellationToken)
{
object? entity = kind.ToLowerInvariant() switch
{
"campuses" => await db.Campuses.FindAsync([id], cancellationToken),
"colleges" => await db.Colleges.FindAsync([id], cancellationToken),
"majors" => await db.Majors.FindAsync([id], cancellationToken),
"classes" => await db.AdministrativeClasses.FindAsync([id], cancellationToken),
"terms" => await db.AcademicTerms.FindAsync([id], cancellationToken),
"buildings" => await db.Buildings.FindAsync([id], cancellationToken),
"classrooms" => await db.Classrooms.FindAsync([id], cancellationToken),
"course-categories" => await db.CourseCategories.FindAsync([id], cancellationToken),
_ => null
};
if (entity is null) return NotFound();
if (entity is AcademicTerm { IsCurrent: true })
return ConflictProblem("当前学期不能删除,请先切换到其他学期。");
db.Remove(entity);
try
{
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
catch (DbUpdateException)
{
return Conflict(new ProblemDetails
{
Title = "无法删除",
Detail = "该数据已被其他业务引用,请先停用,或移除关联数据后再删除。",
Status = StatusCodes.Status409Conflict
});
}
}
private async Task<ActionResult<TEntity>> CreateAsync<TEntity>(
TEntity entity,
string action,
CancellationToken cancellationToken)
where TEntity : EntityBase
{
db.Add(entity);
try
{
await db.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateException)
{
ModelState.AddModelError("code", "编码已存在或关联数据无效。");
return ValidationProblem(ModelState);
}
return CreatedAtAction(action, new { id = entity.Id }, entity);
}
private async Task<string?> GetCounselorNameAsync(
Guid? userId,
CancellationToken cancellationToken)
{
if (!userId.HasValue) return null;
return await db.Users.AsNoTracking()
.Where(user =>
user.Id == userId &&
user.IsEnabled &&
db.UserRoles.Any(userRole =>
userRole.UserId == user.Id &&
db.Roles.Any(role =>
role.Id == userRole.RoleId &&
role.Name == SystemRoles.Counselor)))
.Select(x => x.DisplayName)
.FirstOrDefaultAsync(cancellationToken);
}
private static void ApplyCatalog(CatalogEntity entity, CatalogRequest request)
{
entity.Code = request.Code.Trim();
entity.Name = request.Name.Trim();
entity.SortOrder = request.SortOrder;
entity.IsEnabled = request.IsEnabled;
}
private ObjectResult ConflictProblem(string detail) =>
Conflict(new ProblemDetails
{
Title = "当前操作无法完成",
Detail = detail,
Status = StatusCodes.Status409Conflict
});
}
public record CatalogRequest(
[Required, MaxLength(40)] string Code,
[Required, MaxLength(100)] string Name,
int SortOrder = 0,
bool IsEnabled = true,
[MaxLength(300)] string? Description = null);
public sealed record CollegeRequest(
string Code, string Name, int SortOrder, bool IsEnabled,
Guid? CampusId,
[MaxLength(50)] string? ShortName)
: CatalogRequest(Code, Name, SortOrder, IsEnabled);
public sealed record MajorRequest(
string Code, string Name, int SortOrder, bool IsEnabled,
Guid CollegeId,
[Required, MaxLength(30)] string DegreeType,
[Range(1, 8)] int SchoolingYears)
: CatalogRequest(Code, Name, SortOrder, IsEnabled);
public sealed record ClassRequest(
string Code, string Name, int SortOrder, bool IsEnabled,
Guid MajorId,
[Range(2000, 2200)] int Grade,
Guid? CounselorUserId)
: CatalogRequest(Code, Name, SortOrder, IsEnabled);
public sealed record TermRequest(
string Code, string Name, bool IsEnabled,
[Required, MaxLength(20)] string AcademicYear,
TermSeason Season,
DateOnly StartDate,
DateOnly EndDate,
bool IsCurrent)
: CatalogRequest(Code, Name, 0, IsEnabled);
public sealed record BuildingRequest(
string Code, string Name, int SortOrder, bool IsEnabled,
Guid CampusId)
: CatalogRequest(Code, Name, SortOrder, IsEnabled);
public sealed record ClassroomRequest(
string Code, string Name, int SortOrder, bool IsEnabled,
Guid BuildingId,
[Range(1, 1000)] int Capacity,
[Required, MaxLength(40)] string RoomType,
[MaxLength(300)] string? Equipment)
: CatalogRequest(Code, Name, SortOrder, IsEnabled);