升级
This commit is contained in:
@@ -6,6 +6,8 @@
|
|||||||
|
|
||||||
权限采用后端强制校验的角色与数据范围模型。多角色账号按 `All > College > Class > Self` 取最高数据范围:校级角色可访问全校数据,院系管理员限定本学院,辅导员通过稳定的账号 ID 绑定所带行政班,教师和学生限定本人及当前教学关系;前端菜单和路由限制仅作为交互辅助,不替代 API 授权。
|
权限采用后端强制校验的角色与数据范围模型。多角色账号按 `All > College > Class > Self` 取最高数据范围:校级角色可访问全校数据,院系管理员限定本学院,辅导员通过稳定的账号 ID 绑定所带行政班,教师和学生限定本人及当前教学关系;前端菜单和路由限制仅作为交互辅助,不替代 API 授权。
|
||||||
|
|
||||||
|
学生和教师档案是登录账号的人员主数据:新增或 Excel 导入档案时,系统以学号/工号自动创建同名登录账号并分配学生/教师角色,后续修改姓名、编号、学院或在籍/在职状态时同步账号。`AspNetUsers` 仅作为 ASP.NET Core Identity 的内部安全存储,负责密码哈希、登录锁定、角色和令牌,不需要再手工重复建立学生、教师用户。
|
||||||
|
|
||||||
## 本地开发:热更新模式
|
## 本地开发:热更新模式
|
||||||
|
|
||||||
本地开发固定使用 SQLite。首次启动会自动创建 `src/Jiaowu.Api/data/jiaowu-dev.sqlite` 并写入演示组织数据。
|
本地开发固定使用 SQLite。首次启动会自动创建 `src/Jiaowu.Api/data/jiaowu-dev.sqlite` 并写入演示组织数据。
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ namespace Jiaowu.Api.Controllers;
|
|||||||
[Route("api/personnel")]
|
[Route("api/personnel")]
|
||||||
public sealed class PersonnelController(
|
public sealed class PersonnelController(
|
||||||
AppDbContext db,
|
AppDbContext db,
|
||||||
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
ICurrentUserDataScope currentUserDataScope,
|
||||||
|
PersonnelAccountService personnelAccountService) : ControllerBase
|
||||||
{
|
{
|
||||||
private const string ReadRoles =
|
private const string ReadRoles =
|
||||||
SystemRoles.SuperAdmin + "," +
|
SystemRoles.SuperAdmin + "," +
|
||||||
@@ -103,7 +104,14 @@ public sealed class PersonnelController(
|
|||||||
Notes = Normalize(request.Notes)
|
Notes = Normalize(request.Notes)
|
||||||
};
|
};
|
||||||
db.Teachers.Add(entity);
|
db.Teachers.Add(entity);
|
||||||
return await SaveCreatedAsync(entity.Id, cancellationToken);
|
return await CreateWithAccountAsync(
|
||||||
|
entity.Id,
|
||||||
|
request.InitialPassword,
|
||||||
|
() => personnelAccountService.EnsureTeacherAccountAsync(
|
||||||
|
entity,
|
||||||
|
request.InitialPassword,
|
||||||
|
cancellationToken),
|
||||||
|
cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("teachers/{id:guid}")]
|
[HttpPut("teachers/{id:guid}")]
|
||||||
@@ -130,6 +138,18 @@ public sealed class PersonnelController(
|
|||||||
entity.Phone = Normalize(request.Phone);
|
entity.Phone = Normalize(request.Phone);
|
||||||
entity.Email = Normalize(request.Email);
|
entity.Email = Normalize(request.Email);
|
||||||
entity.Notes = Normalize(request.Notes);
|
entity.Notes = Normalize(request.Notes);
|
||||||
|
if (!entity.UserId.HasValue &&
|
||||||
|
(string.IsNullOrWhiteSpace(request.InitialPassword) ||
|
||||||
|
request.InitialPassword.Length < 8))
|
||||||
|
return ValidationProblem("该教师档案尚未开通账号,请填写至少 8 位初始密码。");
|
||||||
|
if (entity.UserId.HasValue || !string.IsNullOrWhiteSpace(request.InitialPassword))
|
||||||
|
{
|
||||||
|
var account = await personnelAccountService.EnsureTeacherAccountAsync(
|
||||||
|
entity,
|
||||||
|
request.InitialPassword,
|
||||||
|
cancellationToken);
|
||||||
|
if (!account.Success) return AccountProblem(account.Error!);
|
||||||
|
}
|
||||||
return await SaveNoContentAsync(cancellationToken);
|
return await SaveNoContentAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,7 +259,14 @@ public sealed class PersonnelController(
|
|||||||
Notes = Normalize(request.Notes)
|
Notes = Normalize(request.Notes)
|
||||||
};
|
};
|
||||||
db.Students.Add(entity);
|
db.Students.Add(entity);
|
||||||
return await SaveCreatedAsync(entity.Id, cancellationToken);
|
return await CreateWithAccountAsync(
|
||||||
|
entity.Id,
|
||||||
|
request.InitialPassword,
|
||||||
|
() => personnelAccountService.EnsureStudentAccountAsync(
|
||||||
|
entity,
|
||||||
|
request.InitialPassword,
|
||||||
|
cancellationToken),
|
||||||
|
cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("students/{id:guid}")]
|
[HttpPut("students/{id:guid}")]
|
||||||
@@ -276,6 +303,18 @@ public sealed class PersonnelController(
|
|||||||
entity.Phone = Normalize(request.Phone);
|
entity.Phone = Normalize(request.Phone);
|
||||||
entity.Email = Normalize(request.Email);
|
entity.Email = Normalize(request.Email);
|
||||||
entity.Notes = Normalize(request.Notes);
|
entity.Notes = Normalize(request.Notes);
|
||||||
|
if (!entity.UserId.HasValue &&
|
||||||
|
(string.IsNullOrWhiteSpace(request.InitialPassword) ||
|
||||||
|
request.InitialPassword.Length < 8))
|
||||||
|
return ValidationProblem("该学生档案尚未开通账号,请填写至少 8 位初始密码。");
|
||||||
|
if (entity.UserId.HasValue || !string.IsNullOrWhiteSpace(request.InitialPassword))
|
||||||
|
{
|
||||||
|
var account = await personnelAccountService.EnsureStudentAccountAsync(
|
||||||
|
entity,
|
||||||
|
request.InitialPassword,
|
||||||
|
cancellationToken);
|
||||||
|
if (!account.Success) return AccountProblem(account.Error!);
|
||||||
|
}
|
||||||
return await SaveNoContentAsync(cancellationToken);
|
return await SaveNoContentAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,6 +411,39 @@ public sealed class PersonnelController(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<ActionResult> CreateWithAccountAsync(
|
||||||
|
Guid id,
|
||||||
|
string? initialPassword,
|
||||||
|
Func<Task<PersonnelAccountResult>> createAccount,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(initialPassword) || initialPassword.Length < 8)
|
||||||
|
return ValidationProblem("新增人员时必须填写至少 8 位初始密码。");
|
||||||
|
|
||||||
|
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var account = await createAccount();
|
||||||
|
if (!account.Success)
|
||||||
|
{
|
||||||
|
await transaction.RollbackAsync(cancellationToken);
|
||||||
|
return AccountProblem(account.Error!);
|
||||||
|
}
|
||||||
|
await transaction.CommitAsync(cancellationToken);
|
||||||
|
return Created(string.Empty, new
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
account.UserId,
|
||||||
|
account.UserName
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (DbUpdateException)
|
||||||
|
{
|
||||||
|
await transaction.RollbackAsync(cancellationToken);
|
||||||
|
return ConflictProblem("编号已存在,或关联数据无效。");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<ActionResult> SaveNoContentAsync(CancellationToken cancellationToken)
|
private async Task<ActionResult> SaveNoContentAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -393,6 +465,14 @@ public sealed class PersonnelController(
|
|||||||
Status = StatusCodes.Status409Conflict
|
Status = StatusCodes.Status409Conflict
|
||||||
});
|
});
|
||||||
|
|
||||||
|
private ActionResult AccountProblem(string detail) =>
|
||||||
|
Conflict(new ProblemDetails
|
||||||
|
{
|
||||||
|
Title = "登录账号创建失败",
|
||||||
|
Detail = detail,
|
||||||
|
Status = StatusCodes.Status409Conflict
|
||||||
|
});
|
||||||
|
|
||||||
private static string? Normalize(string? value) =>
|
private static string? Normalize(string? value) =>
|
||||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
|
||||||
@@ -422,7 +502,8 @@ public sealed record TeacherRequest(
|
|||||||
bool IsExternal,
|
bool IsExternal,
|
||||||
[MaxLength(30)] string? Phone,
|
[MaxLength(30)] string? Phone,
|
||||||
[EmailAddress, MaxLength(100)] string? Email,
|
[EmailAddress, MaxLength(100)] string? Email,
|
||||||
[MaxLength(500)] string? Notes);
|
[MaxLength(500)] string? Notes,
|
||||||
|
[MinLength(8), MaxLength(100)] string? InitialPassword = null);
|
||||||
|
|
||||||
public sealed record StudentRequest(
|
public sealed record StudentRequest(
|
||||||
[Required, MaxLength(30)] string StudentNumber,
|
[Required, MaxLength(30)] string StudentNumber,
|
||||||
@@ -435,4 +516,5 @@ public sealed record StudentRequest(
|
|||||||
DateOnly? DateOfBirth,
|
DateOnly? DateOfBirth,
|
||||||
[MaxLength(30)] string? Phone,
|
[MaxLength(30)] string? Phone,
|
||||||
[EmailAddress, MaxLength(100)] string? Email,
|
[EmailAddress, MaxLength(100)] string? Email,
|
||||||
[MaxLength(500)] string? Notes);
|
[MaxLength(500)] string? Notes,
|
||||||
|
[MinLength(8), MaxLength(100)] string? InitialPassword = null);
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ namespace Jiaowu.Api.Controllers;
|
|||||||
[Route("api/personnel")]
|
[Route("api/personnel")]
|
||||||
public sealed class PersonnelExcelController(
|
public sealed class PersonnelExcelController(
|
||||||
AppDbContext db,
|
AppDbContext db,
|
||||||
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
ICurrentUserDataScope currentUserDataScope,
|
||||||
|
PersonnelAccountService personnelAccountService) : ControllerBase
|
||||||
{
|
{
|
||||||
private const string ReadRoles =
|
private const string ReadRoles =
|
||||||
SystemRoles.SuperAdmin + "," +
|
SystemRoles.SuperAdmin + "," +
|
||||||
@@ -32,13 +33,13 @@ public sealed class PersonnelExcelController(
|
|||||||
private static readonly string[] TeacherHeaders =
|
private static readonly string[] TeacherHeaders =
|
||||||
[
|
[
|
||||||
"工号", "姓名", "性别", "学院编码", "职称", "任职状态",
|
"工号", "姓名", "性别", "学院编码", "职称", "任职状态",
|
||||||
"入职日期", "教师类别", "联系电话", "电子邮箱", "备注"
|
"入职日期", "教师类别", "联系电话", "电子邮箱", "备注", "初始密码"
|
||||||
];
|
];
|
||||||
|
|
||||||
private static readonly string[] StudentHeaders =
|
private static readonly string[] StudentHeaders =
|
||||||
[
|
[
|
||||||
"学号", "姓名", "性别", "行政班编码", "入学年级", "入学日期",
|
"学号", "姓名", "性别", "行政班编码", "入学年级", "入学日期",
|
||||||
"学籍状态", "出生日期", "联系电话", "电子邮箱", "备注"
|
"学籍状态", "出生日期", "联系电话", "电子邮箱", "备注", "初始密码"
|
||||||
];
|
];
|
||||||
|
|
||||||
[HttpGet("{kind}/template")]
|
[HttpGet("{kind}/template")]
|
||||||
@@ -56,6 +57,7 @@ public sealed class PersonnelExcelController(
|
|||||||
? "工号是唯一标识;学院编码必须已存在。"
|
? "工号是唯一标识;学院编码必须已存在。"
|
||||||
: "学号是唯一标识;行政班编码必须已存在。",
|
: "学号是唯一标识;行政班编码必须已存在。",
|
||||||
"编号已存在时更新档案,不存在时新增档案。",
|
"编号已存在时更新档案,不存在时新增档案。",
|
||||||
|
"新增人员或补建账号时必须填写至少 8 位初始密码;已有关联账号时可留空。",
|
||||||
"日期填写为 yyyy-MM-dd;不适用的可选字段可以留空。",
|
"日期填写为 yyyy-MM-dd;不适用的可选字段可以留空。",
|
||||||
"整批数据会先校验,任一行有误时均不会写入。"
|
"整批数据会先校验,任一行有误时均不会写入。"
|
||||||
]);
|
]);
|
||||||
@@ -81,7 +83,7 @@ public sealed class PersonnelExcelController(
|
|||||||
.Select(x => Row(
|
.Select(x => Row(
|
||||||
x.TeacherNumber, x.Name, GenderName(x.Gender), x.College!.Code,
|
x.TeacherNumber, x.Name, GenderName(x.Gender), x.College!.Code,
|
||||||
x.Title, TeacherStatusName(x.Status), x.HireDate,
|
x.Title, TeacherStatusName(x.Status), x.HireDate,
|
||||||
x.IsExternal ? "外聘" : "校内", x.Phone, x.Email, x.Notes))
|
x.IsExternal ? "外聘" : "校内", x.Phone, x.Email, x.Notes, null))
|
||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -94,7 +96,7 @@ public sealed class PersonnelExcelController(
|
|||||||
.Select(x => Row(
|
.Select(x => Row(
|
||||||
x.StudentNumber, x.Name, GenderName(x.Gender),
|
x.StudentNumber, x.Name, GenderName(x.Gender),
|
||||||
x.AdministrativeClass!.Code, x.EnrollmentYear, x.EnrollmentDate,
|
x.AdministrativeClass!.Code, x.EnrollmentYear, x.EnrollmentDate,
|
||||||
StudentStatusName(x.Status), x.DateOfBirth, x.Phone, x.Email, x.Notes))
|
StudentStatusName(x.Status), x.DateOfBirth, x.Phone, x.Email, x.Notes, null))
|
||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,6 +214,12 @@ public sealed class PersonnelExcelController(
|
|||||||
entity.Phone = Optional(row, "联系电话");
|
entity.Phone = Optional(row, "联系电话");
|
||||||
entity.Email = Optional(row, "电子邮箱");
|
entity.Email = Optional(row, "电子邮箱");
|
||||||
entity.Notes = Optional(row, "备注");
|
entity.Notes = Optional(row, "备注");
|
||||||
|
var account = await personnelAccountService.EnsureTeacherAccountAsync(
|
||||||
|
entity,
|
||||||
|
Optional(row, "初始密码"),
|
||||||
|
cancellationToken);
|
||||||
|
if (!account.Success)
|
||||||
|
errors.Add($"第 {row.RowNumber} 行:{account.Error}");
|
||||||
}
|
}
|
||||||
return new(created, updated, rows.Count);
|
return new(created, updated, rows.Count);
|
||||||
}
|
}
|
||||||
@@ -284,6 +292,12 @@ public sealed class PersonnelExcelController(
|
|||||||
entity.Phone = Optional(row, "联系电话");
|
entity.Phone = Optional(row, "联系电话");
|
||||||
entity.Email = Optional(row, "电子邮箱");
|
entity.Email = Optional(row, "电子邮箱");
|
||||||
entity.Notes = Optional(row, "备注");
|
entity.Notes = Optional(row, "备注");
|
||||||
|
var account = await personnelAccountService.EnsureStudentAccountAsync(
|
||||||
|
entity,
|
||||||
|
Optional(row, "初始密码"),
|
||||||
|
cancellationToken);
|
||||||
|
if (!account.Success)
|
||||||
|
errors.Add($"第 {row.RowNumber} 行:{account.Error}");
|
||||||
}
|
}
|
||||||
return new(created, updated, rows.Count);
|
return new(created, updated, rows.Count);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
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]
|
||||||
|
[Route("api/timetables")]
|
||||||
|
public sealed class TimetablesController(AppDbContext db) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet("options")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
public async Task<ActionResult> GetOptions(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var terms = await db.AcademicTerms.AsNoTracking()
|
||||||
|
.Where(x => x.IsEnabled)
|
||||||
|
.OrderByDescending(x => x.StartDate)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.Id,
|
||||||
|
x.Name,
|
||||||
|
x.AcademicYear,
|
||||||
|
x.Season,
|
||||||
|
x.StartDate,
|
||||||
|
x.EndDate,
|
||||||
|
x.IsCurrent,
|
||||||
|
HasPublishedTimetable = db.SchedulePlans.Any(plan =>
|
||||||
|
plan.AcademicTermId == x.Id &&
|
||||||
|
plan.Status == SchedulePlanStatus.Published)
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var defaultTermId = terms.FirstOrDefault(x => x.IsCurrent)?.Id
|
||||||
|
?? terms.FirstOrDefault(x => x.HasPublishedTimetable)?.Id;
|
||||||
|
var classes = await db.AdministrativeClasses.AsNoTracking()
|
||||||
|
.Where(x => x.IsEnabled && x.Major!.IsEnabled && x.Major.College!.IsEnabled)
|
||||||
|
.OrderByDescending(x => x.Grade)
|
||||||
|
.ThenBy(x => x.Code)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.Id,
|
||||||
|
x.Code,
|
||||||
|
x.Name,
|
||||||
|
x.Grade,
|
||||||
|
MajorName = x.Major!.Name,
|
||||||
|
CollegeName = x.Major.College!.Name,
|
||||||
|
HasPublishedTimetable = defaultTermId.HasValue &&
|
||||||
|
db.ScheduleEntries.Any(entry =>
|
||||||
|
entry.SchedulePlan!.AcademicTermId == defaultTermId.Value &&
|
||||||
|
entry.SchedulePlan.Status == SchedulePlanStatus.Published &&
|
||||||
|
entry.TeachingTask!.Classes.Any(item =>
|
||||||
|
item.AdministrativeClassId == x.Id))
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
return Ok(new { Terms = terms, Classes = classes });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("classes/{classId:guid}")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
public Task<ActionResult> GetClassTimetable(
|
||||||
|
Guid classId,
|
||||||
|
Guid? academicTermId,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
BuildTimetableAsync(classId, academicTermId, null, cancellationToken);
|
||||||
|
|
||||||
|
[HttpGet("mine")]
|
||||||
|
[Authorize(Roles = SystemRoles.Student)]
|
||||||
|
public async Task<ActionResult> GetMyTimetable(
|
||||||
|
Guid? academicTermId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!Guid.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out var userId))
|
||||||
|
return Unauthorized();
|
||||||
|
var student = await db.Students.AsNoTracking()
|
||||||
|
.Where(x => x.UserId == userId)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.Id,
|
||||||
|
x.AdministrativeClassId,
|
||||||
|
x.StudentNumber,
|
||||||
|
x.Name
|
||||||
|
})
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (student is null)
|
||||||
|
return Conflict(new ProblemDetails
|
||||||
|
{
|
||||||
|
Title = "学生档案未关联",
|
||||||
|
Detail = "当前登录账号没有关联学生档案,请联系教务管理员。",
|
||||||
|
Status = StatusCodes.Status409Conflict
|
||||||
|
});
|
||||||
|
|
||||||
|
return await BuildTimetableAsync(
|
||||||
|
student.AdministrativeClassId,
|
||||||
|
academicTermId,
|
||||||
|
student.Id,
|
||||||
|
cancellationToken,
|
||||||
|
new { student.StudentNumber, student.Name });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ActionResult> BuildTimetableAsync(
|
||||||
|
Guid classId,
|
||||||
|
Guid? academicTermId,
|
||||||
|
Guid? studentId,
|
||||||
|
CancellationToken cancellationToken,
|
||||||
|
object? student = null)
|
||||||
|
{
|
||||||
|
var administrativeClass = await db.AdministrativeClasses.AsNoTracking()
|
||||||
|
.Where(x => x.Id == classId && x.IsEnabled)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.Id,
|
||||||
|
x.Code,
|
||||||
|
x.Name,
|
||||||
|
x.Grade,
|
||||||
|
MajorName = x.Major!.Name,
|
||||||
|
CollegeName = x.Major.College!.Name
|
||||||
|
})
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (administrativeClass is null) return NotFound();
|
||||||
|
|
||||||
|
var termQuery = db.AcademicTerms.AsNoTracking().Where(x => x.IsEnabled);
|
||||||
|
if (academicTermId.HasValue)
|
||||||
|
termQuery = termQuery.Where(x => x.Id == academicTermId);
|
||||||
|
else
|
||||||
|
termQuery = termQuery.OrderByDescending(x => x.IsCurrent)
|
||||||
|
.ThenByDescending(x => x.StartDate);
|
||||||
|
var term = await termQuery
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.Id,
|
||||||
|
x.Name,
|
||||||
|
x.AcademicYear,
|
||||||
|
x.Season,
|
||||||
|
x.StartDate,
|
||||||
|
x.EndDate,
|
||||||
|
x.IsCurrent
|
||||||
|
})
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (term is null) return NotFound();
|
||||||
|
|
||||||
|
var plan = await db.SchedulePlans.AsNoTracking()
|
||||||
|
.Where(x =>
|
||||||
|
x.AcademicTermId == term.Id &&
|
||||||
|
x.Status == SchedulePlanStatus.Published)
|
||||||
|
.Select(x => new { x.Id, x.Name, x.Version, x.PublishedAt })
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
var slots = await db.ScheduleTimeSlots.AsNoTracking()
|
||||||
|
.Where(x => x.AcademicTermId == term.Id && x.IsEnabled)
|
||||||
|
.OrderBy(x => x.PeriodNumber)
|
||||||
|
.Select(x => new { x.PeriodNumber, x.Name, x.StartsAt, x.EndsAt })
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (plan is null)
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
Term = term,
|
||||||
|
Class = administrativeClass,
|
||||||
|
Student = student,
|
||||||
|
Plan = (object?)null,
|
||||||
|
Slots = slots,
|
||||||
|
Entries = Array.Empty<object>()
|
||||||
|
});
|
||||||
|
|
||||||
|
var entries = db.ScheduleEntries.AsNoTracking()
|
||||||
|
.Where(x =>
|
||||||
|
x.SchedulePlanId == plan.Id &&
|
||||||
|
x.TeachingTask!.Classes.Any(item =>
|
||||||
|
item.AdministrativeClassId == classId));
|
||||||
|
if (studentId.HasValue)
|
||||||
|
{
|
||||||
|
var selectedTaskIds = db.CourseEnrollments.AsNoTracking()
|
||||||
|
.Where(x =>
|
||||||
|
x.StudentId == studentId.Value &&
|
||||||
|
x.Status == CourseEnrollmentStatus.Enrolled &&
|
||||||
|
x.CourseSelectionOffering!.CourseSelectionRound!.AcademicTermId == term.Id)
|
||||||
|
.Select(x => x.CourseSelectionOffering!.TeachingTaskId);
|
||||||
|
entries = db.ScheduleEntries.AsNoTracking()
|
||||||
|
.Where(x =>
|
||||||
|
x.SchedulePlanId == plan.Id &&
|
||||||
|
(x.TeachingTask!.Classes.Any(item =>
|
||||||
|
item.AdministrativeClassId == classId) ||
|
||||||
|
selectedTaskIds.Contains(x.TeachingTaskId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await entries
|
||||||
|
.OrderBy(x => x.DayOfWeek)
|
||||||
|
.ThenBy(x => x.StartPeriod)
|
||||||
|
.ThenBy(x => x.TeachingTask!.Course!.Code)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.Id,
|
||||||
|
x.TeachingTaskId,
|
||||||
|
TaskNumber = x.TeachingTask!.TaskNumber,
|
||||||
|
TaskName = x.TeachingTask.Name,
|
||||||
|
CourseCode = x.TeachingTask.Course!.Code,
|
||||||
|
CourseName = x.TeachingTask.Course.Name,
|
||||||
|
TeacherNames = x.TeachingTask.Teachers
|
||||||
|
.OrderByDescending(item => item.IsPrimary)
|
||||||
|
.Select(item => item.Teacher!.Name),
|
||||||
|
ClassNames = x.TeachingTask.Classes
|
||||||
|
.Select(item => item.AdministrativeClass!.Name),
|
||||||
|
ClassroomName = x.Classroom == null ? "不占用教室" : x.Classroom.Name,
|
||||||
|
BuildingName = x.Classroom == null ? null : x.Classroom.Building!.Name,
|
||||||
|
CampusName = x.Classroom == null
|
||||||
|
? null
|
||||||
|
: x.Classroom.Building!.Campus!.Name,
|
||||||
|
x.DayOfWeek,
|
||||||
|
x.StartPeriod,
|
||||||
|
x.PeriodCount,
|
||||||
|
x.StartWeek,
|
||||||
|
x.EndWeek,
|
||||||
|
x.WeekPattern,
|
||||||
|
x.Notes
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
Term = term,
|
||||||
|
Class = administrativeClass,
|
||||||
|
Student = student,
|
||||||
|
Plan = plan,
|
||||||
|
Slots = slots,
|
||||||
|
Entries = result
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Domain.Identity;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Auth;
|
||||||
|
|
||||||
|
public sealed class PersonnelAccountService(
|
||||||
|
AppDbContext db,
|
||||||
|
UserManager<ApplicationUser> userManager)
|
||||||
|
{
|
||||||
|
public Task<PersonnelAccountResult> EnsureTeacherAccountAsync(
|
||||||
|
Teacher teacher,
|
||||||
|
string? initialPassword,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
EnsureAccountAsync(
|
||||||
|
teacher.Id,
|
||||||
|
teacher.UserId,
|
||||||
|
teacher.TeacherNumber,
|
||||||
|
teacher.Name,
|
||||||
|
teacher.CollegeId,
|
||||||
|
teacher.Status == TeacherStatus.Active,
|
||||||
|
SystemRoles.Teacher,
|
||||||
|
initialPassword,
|
||||||
|
userId => teacher.UserId = userId,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
public async Task<PersonnelAccountResult> EnsureStudentAccountAsync(
|
||||||
|
Student student,
|
||||||
|
string? initialPassword,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var collegeId = await db.AdministrativeClasses.AsNoTracking()
|
||||||
|
.Where(x => x.Id == student.AdministrativeClassId)
|
||||||
|
.Select(x => (Guid?)x.Major!.CollegeId)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (!collegeId.HasValue)
|
||||||
|
return PersonnelAccountResult.Failed("学生所在行政班或学院不存在。");
|
||||||
|
|
||||||
|
return await EnsureAccountAsync(
|
||||||
|
student.Id,
|
||||||
|
student.UserId,
|
||||||
|
student.StudentNumber,
|
||||||
|
student.Name,
|
||||||
|
collegeId.Value,
|
||||||
|
student.Status == StudentStatus.Active,
|
||||||
|
SystemRoles.Student,
|
||||||
|
initialPassword,
|
||||||
|
userId => student.UserId = userId,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<PersonnelAccountResult> EnsureAccountAsync(
|
||||||
|
Guid profileId,
|
||||||
|
Guid? linkedUserId,
|
||||||
|
string number,
|
||||||
|
string displayName,
|
||||||
|
Guid collegeId,
|
||||||
|
bool isEnabled,
|
||||||
|
string role,
|
||||||
|
string? initialPassword,
|
||||||
|
Action<Guid> linkProfile,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var normalizedNumber = number.Trim();
|
||||||
|
ApplicationUser? user = null;
|
||||||
|
if (linkedUserId.HasValue)
|
||||||
|
user = await userManager.FindByIdAsync(linkedUserId.Value.ToString());
|
||||||
|
user ??= await userManager.FindByNameAsync(normalizedNumber);
|
||||||
|
|
||||||
|
if (user is null)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(initialPassword))
|
||||||
|
return PersonnelAccountResult.Failed("请填写至少 8 位初始密码,以便同时创建登录账号。");
|
||||||
|
|
||||||
|
user = new ApplicationUser
|
||||||
|
{
|
||||||
|
UserName = normalizedNumber,
|
||||||
|
DisplayName = displayName.Trim(),
|
||||||
|
StaffNumber = normalizedNumber,
|
||||||
|
CollegeId = collegeId,
|
||||||
|
IsEnabled = isEnabled,
|
||||||
|
LockoutEnabled = true
|
||||||
|
};
|
||||||
|
var createResult = await userManager.CreateAsync(user, initialPassword);
|
||||||
|
if (!createResult.Succeeded) return Failed(createResult);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var occupied = role == SystemRoles.Teacher
|
||||||
|
? await db.Teachers.AsNoTracking().AnyAsync(
|
||||||
|
x =>
|
||||||
|
x.Id != profileId &&
|
||||||
|
x.UserId == user.Id &&
|
||||||
|
x.TeacherNumber != normalizedNumber,
|
||||||
|
cancellationToken)
|
||||||
|
: await db.Students.AsNoTracking().AnyAsync(
|
||||||
|
x =>
|
||||||
|
x.Id != profileId &&
|
||||||
|
x.UserId == user.Id &&
|
||||||
|
x.StudentNumber != normalizedNumber,
|
||||||
|
cancellationToken);
|
||||||
|
if (occupied)
|
||||||
|
return PersonnelAccountResult.Failed($"登录账号“{normalizedNumber}”已关联其他人员档案。");
|
||||||
|
|
||||||
|
user.UserName = normalizedNumber;
|
||||||
|
user.DisplayName = displayName.Trim();
|
||||||
|
user.StaffNumber = normalizedNumber;
|
||||||
|
user.CollegeId = collegeId;
|
||||||
|
user.IsEnabled = isEnabled;
|
||||||
|
var updateResult = await userManager.UpdateAsync(user);
|
||||||
|
if (!updateResult.Succeeded) return Failed(updateResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await userManager.IsInRoleAsync(user, role))
|
||||||
|
{
|
||||||
|
var roleResult = await userManager.AddToRoleAsync(user, role);
|
||||||
|
if (!roleResult.Succeeded) return Failed(roleResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
linkProfile(user.Id);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
return PersonnelAccountResult.Succeeded(user.Id, normalizedNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PersonnelAccountResult Failed(IdentityResult result) =>
|
||||||
|
PersonnelAccountResult.Failed(string.Join(
|
||||||
|
";",
|
||||||
|
result.Errors.Select(x => x.Description)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record PersonnelAccountResult(
|
||||||
|
bool Success,
|
||||||
|
Guid? UserId,
|
||||||
|
string? UserName,
|
||||||
|
string? Error)
|
||||||
|
{
|
||||||
|
public static PersonnelAccountResult Succeeded(Guid userId, string userName) =>
|
||||||
|
new(true, userId, userName, null);
|
||||||
|
|
||||||
|
public static PersonnelAccountResult Failed(string error) =>
|
||||||
|
new(false, null, null, error);
|
||||||
|
}
|
||||||
@@ -85,6 +85,7 @@ builder.Services.Configure<JwtOptions>(
|
|||||||
builder.Services.AddHttpContextAccessor();
|
builder.Services.AddHttpContextAccessor();
|
||||||
builder.Services.AddScoped<ITokenService, TokenService>();
|
builder.Services.AddScoped<ITokenService, TokenService>();
|
||||||
builder.Services.AddScoped<ICurrentUserDataScope, CurrentUserDataScope>();
|
builder.Services.AddScoped<ICurrentUserDataScope, CurrentUserDataScope>();
|
||||||
|
builder.Services.AddScoped<PersonnelAccountService>();
|
||||||
builder.Services.AddScoped<DatabaseInitializer>();
|
builder.Services.AddScoped<DatabaseInitializer>();
|
||||||
builder.Services.AddScoped<DevelopmentSqliteMigrator>();
|
builder.Services.AddScoped<DevelopmentSqliteMigrator>();
|
||||||
builder.Services.AddScoped<DevelopmentDemoDataSeeder>();
|
builder.Services.AddScoped<DevelopmentDemoDataSeeder>();
|
||||||
|
|||||||
@@ -98,6 +98,8 @@ const navigationGroups = computed<NavigationGroup[]>(() => [
|
|||||||
hasAnyRole(['SuperAdmin', 'AcademicAdmin']),
|
hasAnyRole(['SuperAdmin', 'AcademicAdmin']),
|
||||||
{ path: '/schedules', label: '排课与课表' },
|
{ path: '/schedules', label: '排课与课表' },
|
||||||
),
|
),
|
||||||
|
...whenVisible(isStudent.value, { path: '/my-timetable', label: '我的课表' }),
|
||||||
|
...whenVisible(!isStudent.value, { path: '/class-timetable', label: '班级课表查询' }),
|
||||||
...whenVisible(
|
...whenVisible(
|
||||||
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Student']),
|
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Student']),
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ const router = createRouter({
|
|||||||
component: () => import('../views/LoginView.vue'),
|
component: () => import('../views/LoginView.vue'),
|
||||||
meta: { public: true },
|
meta: { public: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/timetable',
|
||||||
|
name: 'public-timetable',
|
||||||
|
component: () => import('../views/TimetableView.vue'),
|
||||||
|
meta: { public: true },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
component: AdminLayout,
|
component: AdminLayout,
|
||||||
@@ -114,6 +120,17 @@ const router = createRouter({
|
|||||||
component: () => import('../views/SchedulesView.vue'),
|
component: () => import('../views/SchedulesView.vue'),
|
||||||
meta: { roles: ['SuperAdmin', 'AcademicAdmin'] },
|
meta: { roles: ['SuperAdmin', 'AcademicAdmin'] },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'class-timetable',
|
||||||
|
name: 'class-timetable',
|
||||||
|
component: () => import('../views/TimetableView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'my-timetable',
|
||||||
|
name: 'my-timetable',
|
||||||
|
component: () => import('../views/TimetableView.vue'),
|
||||||
|
meta: { roles: ['Student'], mine: true },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'course-selections',
|
path: 'course-selections',
|
||||||
name: 'course-selections',
|
name: 'course-selections',
|
||||||
|
|||||||
@@ -938,6 +938,7 @@ button { cursor: pointer; }
|
|||||||
.form-intro p { margin: 0; color: var(--muted); font-size: 13px; }
|
.form-intro p { margin: 0; color: var(--muted); font-size: 13px; }
|
||||||
.login-form label { display: block; margin-bottom: 20px; }
|
.login-form label { display: block; margin-bottom: 20px; }
|
||||||
.login-form label > span { display: block; margin-bottom: 8px; color: #525b6d; font-size: 12px; font-weight: 650; }
|
.login-form label > span { display: block; margin-bottom: 8px; color: #525b6d; font-size: 12px; font-weight: 650; }
|
||||||
|
.public-timetable-link { display: block; margin: 14px 0 18px; color: #176b87; font-size: 13px; font-weight: 650; text-align: center; text-decoration: none; }
|
||||||
.login-submit { width: 100%; margin-top: 6px; height: 46px; }
|
.login-submit { width: 100%; margin-top: 6px; height: 46px; }
|
||||||
.dev-hint { margin-top: 24px; padding: 13px 15px; display: flex; justify-content: space-between; color: #767e8d; background: #f5f7fa; font-size: 11px; }
|
.dev-hint { margin-top: 24px; padding: 13px 15px; display: flex; justify-content: space-between; color: #767e8d; background: #f5f7fa; font-size: 11px; }
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ async function submit() {
|
|||||||
<div class="form-intro">
|
<div class="form-intro">
|
||||||
<span>欢迎回来</span>
|
<span>欢迎回来</span>
|
||||||
<h2>登录教务工作台</h2>
|
<h2>登录教务工作台</h2>
|
||||||
<p>使用学校分配的管理账号继续。</p>
|
<p>学生使用学号、教师使用工号登录;管理人员使用学校分配的账号。</p>
|
||||||
</div>
|
</div>
|
||||||
<label>
|
<label>
|
||||||
<span>账号</span>
|
<span>账号</span>
|
||||||
@@ -79,6 +79,7 @@ async function submit() {
|
|||||||
>
|
>
|
||||||
进入工作台
|
进入工作台
|
||||||
</el-button>
|
</el-button>
|
||||||
|
<router-link class="public-timetable-link" to="/timetable">无需登录,查询班级课表 →</router-link>
|
||||||
<div class="dev-hint">
|
<div class="dev-hint">
|
||||||
<b>本地开发账号</b>
|
<b>本地开发账号</b>
|
||||||
<span>admin / Admin@123456</span>
|
<span>admin / Admin@123456</span>
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ function resetForm(row?: any) {
|
|||||||
phone: '',
|
phone: '',
|
||||||
email: '',
|
email: '',
|
||||||
notes: '',
|
notes: '',
|
||||||
|
initialPassword: '',
|
||||||
}, row ?? {})
|
}, row ?? {})
|
||||||
} else {
|
} else {
|
||||||
const currentYear = new Date().getFullYear()
|
const currentYear = new Date().getFullYear()
|
||||||
@@ -128,6 +129,7 @@ function resetForm(row?: any) {
|
|||||||
phone: '',
|
phone: '',
|
||||||
email: '',
|
email: '',
|
||||||
notes: '',
|
notes: '',
|
||||||
|
initialPassword: '',
|
||||||
}, row ?? {})
|
}, row ?? {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -266,11 +268,22 @@ async function save() {
|
|||||||
ElMessage.warning(`请填写${numberLabel.value}和姓名。`)
|
ElMessage.warning(`请填写${numberLabel.value}和姓名。`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if ((!editingId.value || !form.userId) && String(form.initialPassword ?? '').length < 8) {
|
||||||
|
ElMessage.warning('请设置至少 8 位初始密码,系统会同时创建同号登录账号。')
|
||||||
|
return
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const path = `/personnel/${active.value}`
|
const path = `/personnel/${active.value}`
|
||||||
if (editingId.value) await http.put(`${path}/${editingId.value}`, form)
|
const payload = { ...form }
|
||||||
else await http.post(path, form)
|
if (editingId.value && form.userId) {
|
||||||
ElMessage.success(editingId.value ? '档案已更新' : '档案已建立')
|
delete payload.initialPassword
|
||||||
|
}
|
||||||
|
if (editingId.value) {
|
||||||
|
await http.put(`${path}/${editingId.value}`, payload)
|
||||||
|
} else {
|
||||||
|
await http.post(path, payload)
|
||||||
|
}
|
||||||
|
ElMessage.success(editingId.value ? '档案与登录账号已同步' : '档案和同号登录账号已建立')
|
||||||
dialogVisible.value = false
|
dialogVisible.value = false
|
||||||
await load()
|
await load()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -407,6 +420,13 @@ watch(active, async () => {
|
|||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column label="登录账号" width="105">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="table-status" :class="{ off: !row.userId }">
|
||||||
|
{{ row.userId ? '已开通' : '未开通' }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="联系方式" min-width="170">
|
<el-table-column label="联系方式" min-width="170">
|
||||||
<template #default="{ row }">{{ row.phone || row.email || '—' }}</template>
|
<template #default="{ row }">{{ row.phone || row.email || '—' }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -501,6 +521,23 @@ watch(active, async () => {
|
|||||||
<el-form-item label="电子邮箱"><el-input v-model="form.email" /></el-form-item>
|
<el-form-item label="电子邮箱"><el-input v-model="form.email" /></el-form-item>
|
||||||
</div>
|
</div>
|
||||||
<el-form-item label="备注"><el-input v-model="form.notes" type="textarea" :rows="3" /></el-form-item>
|
<el-form-item label="备注"><el-input v-model="form.notes" type="textarea" :rows="3" /></el-form-item>
|
||||||
|
<el-alert
|
||||||
|
v-if="!editingId || !form.userId"
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
:title="editingId
|
||||||
|
? '该历史档案尚未开通账号,本次保存会以工号或学号补建登录账号。'
|
||||||
|
: '保存档案后,系统会以工号或学号作为登录账号,并自动分配对应角色。'"
|
||||||
|
/>
|
||||||
|
<el-form-item v-if="!editingId || !form.userId" label="初始密码" required>
|
||||||
|
<el-input
|
||||||
|
v-model="form.initialPassword"
|
||||||
|
type="password"
|
||||||
|
show-password
|
||||||
|
autocomplete="new-password"
|
||||||
|
placeholder="至少 8 位,仅用于创建密码哈希"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="dialogVisible = false">取消</el-button>
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import http, { apiErrorMessage } from '../api/http'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const isMine = computed(() => route.meta.mine === true)
|
||||||
|
const isPublic = computed(() => route.meta.public === true)
|
||||||
|
const loading = ref(false)
|
||||||
|
const terms = ref<any[]>([])
|
||||||
|
const classes = ref<any[]>([])
|
||||||
|
const termId = ref('')
|
||||||
|
const classId = ref('')
|
||||||
|
const timetable = ref<any | null>(null)
|
||||||
|
const weekdays = ['', '周一', '周二', '周三', '周四', '周五', '周六', '周日']
|
||||||
|
const patternLabels: Record<string, string> = { All: '每周', Odd: '单周', Even: '双周' }
|
||||||
|
|
||||||
|
const maxPeriods = computed(() => {
|
||||||
|
const slotMaximum = Math.max(0, ...((timetable.value?.slots ?? []).map((x: any) => x.periodNumber)))
|
||||||
|
const entryMaximum = Math.max(
|
||||||
|
0,
|
||||||
|
...((timetable.value?.entries ?? []).map((x: any) => x.startPeriod + x.periodCount - 1)),
|
||||||
|
)
|
||||||
|
return Math.max(8, slotMaximum, entryMaximum)
|
||||||
|
})
|
||||||
|
|
||||||
|
const slotMap = computed<Map<number, any>>(() =>
|
||||||
|
new Map<number, any>(
|
||||||
|
(timetable.value?.slots ?? []).map((item: any) => [item.periodNumber, item]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
function formatTime(value: string) {
|
||||||
|
return value?.slice(0, 5) ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function entryStyle(entry: any) {
|
||||||
|
return {
|
||||||
|
gridColumn: String(entry.dayOfWeek + 1),
|
||||||
|
gridRow: `${entry.startPeriod + 1} / span ${entry.periodCount}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function weeks(entry: any) {
|
||||||
|
const pattern = patternLabels[entry.weekPattern]
|
||||||
|
return `${entry.startWeek}—${entry.endWeek}周${pattern === '每周' ? '' : ` · ${pattern}`}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function location(entry: any) {
|
||||||
|
return [entry.campusName, entry.buildingName, entry.classroomName].filter(Boolean).join(' · ')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOptions() {
|
||||||
|
const { data } = await http.get('/timetables/options')
|
||||||
|
terms.value = data.terms
|
||||||
|
classes.value = data.classes
|
||||||
|
termId.value = data.terms.find((item: any) => item.isCurrent)?.id
|
||||||
|
?? data.terms.find((item: any) => item.hasPublishedTimetable)?.id
|
||||||
|
?? data.terms[0]?.id
|
||||||
|
?? ''
|
||||||
|
classId.value = data.classes.find((item: any) => item.hasPublishedTimetable)?.id
|
||||||
|
?? data.classes[0]?.id
|
||||||
|
?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTimetable() {
|
||||||
|
if (!termId.value || (!isMine.value && !classId.value)) return
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const url = isMine.value ? '/timetables/mine' : `/timetables/classes/${classId.value}`
|
||||||
|
timetable.value = (await http.get(url, {
|
||||||
|
params: { academicTermId: termId.value },
|
||||||
|
})).data
|
||||||
|
} catch (error) {
|
||||||
|
timetable.value = null
|
||||||
|
ElMessage.error(apiErrorMessage(error))
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch([termId, classId], loadTimetable)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
await loadOptions()
|
||||||
|
await loadTimetable()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(apiErrorMessage(error))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main :class="['timetable-page', { 'public-timetable': isPublic }]">
|
||||||
|
<header v-if="isPublic" class="public-header">
|
||||||
|
<router-link class="public-brand" to="/timetable">明序教务 · 课表查询</router-link>
|
||||||
|
<router-link class="login-link" to="/login">登录工作台</router-link>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="timetable-heading">
|
||||||
|
<div>
|
||||||
|
<span class="section-kicker">{{ isMine ? 'MY TIMETABLE' : 'CLASS TIMETABLE' }}</span>
|
||||||
|
<h2>{{ isMine ? '我的课表' : '班级课表查询' }}</h2>
|
||||||
|
<p v-if="isMine">行政班课程与本人已选课程统一展示,仅采用教务处已发布课表。</p>
|
||||||
|
<p v-else>无需登录即可查询各行政班已正式发布的课程安排。</p>
|
||||||
|
</div>
|
||||||
|
<div class="timetable-filters">
|
||||||
|
<el-select v-model="termId" filterable placeholder="选择学期">
|
||||||
|
<el-option
|
||||||
|
v-for="term in terms"
|
||||||
|
:key="term.id"
|
||||||
|
:label="`${term.name}${term.hasPublishedTimetable ? '' : '(未发布)'}`"
|
||||||
|
:value="term.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<el-select
|
||||||
|
v-if="!isMine"
|
||||||
|
v-model="classId"
|
||||||
|
filterable
|
||||||
|
placeholder="选择行政班"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="item in classes"
|
||||||
|
:key="item.id"
|
||||||
|
:label="`${item.collegeName} · ${item.majorName} · ${item.name}`"
|
||||||
|
:value="item.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-loading="loading" class="timetable-sheet">
|
||||||
|
<div v-if="timetable" class="sheet-meta">
|
||||||
|
<div>
|
||||||
|
<strong>{{ timetable.class.name }}</strong>
|
||||||
|
<span>{{ timetable.class.collegeName }} · {{ timetable.class.majorName }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="timetable.student">
|
||||||
|
<strong>{{ timetable.student.name }}</strong>
|
||||||
|
<span>{{ timetable.student.studentNumber }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>{{ timetable.term.name }}</strong>
|
||||||
|
<span v-if="timetable.plan">发布于 {{ new Date(timetable.plan.publishedAt).toLocaleString('zh-CN') }}</span>
|
||||||
|
<span v-else>本学期课表尚未发布</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="timetable?.plan && timetable.entries.length" class="timetable-scroll">
|
||||||
|
<div
|
||||||
|
class="week-grid"
|
||||||
|
:style="{ gridTemplateRows: `48px repeat(${maxPeriods}, 110px)` }"
|
||||||
|
>
|
||||||
|
<div class="grid-corner">节次</div>
|
||||||
|
<div v-for="day in 7" :key="`head-${day}`" class="day-head">{{ weekdays[day] }}</div>
|
||||||
|
<template v-for="period in maxPeriods" :key="`period-${period}`">
|
||||||
|
<div class="period-head" :style="{ gridRow: String(period + 1) }">
|
||||||
|
<strong>第 {{ period }} 节</strong>
|
||||||
|
<span v-if="slotMap.get(period)">
|
||||||
|
{{ formatTime(slotMap.get(period).startsAt) }}—{{ formatTime(slotMap.get(period).endsAt) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="day in 7"
|
||||||
|
:key="`cell-${period}-${day}`"
|
||||||
|
class="grid-cell"
|
||||||
|
:style="{ gridColumn: String(day + 1), gridRow: String(period + 1) }"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<article
|
||||||
|
v-for="entry in timetable.entries"
|
||||||
|
:key="entry.id"
|
||||||
|
class="course-block"
|
||||||
|
:style="entryStyle(entry)"
|
||||||
|
>
|
||||||
|
<strong>{{ entry.courseName }}</strong>
|
||||||
|
<span>{{ entry.teacherNames.join('、') || '教师待定' }}</span>
|
||||||
|
<span>{{ location(entry) }}</span>
|
||||||
|
<small>{{ weeks(entry) }} · 第 {{ entry.startPeriod }}—{{ entry.startPeriod + entry.periodCount - 1 }} 节</small>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-empty
|
||||||
|
v-else-if="timetable && !loading"
|
||||||
|
:description="timetable.plan ? '该课表暂时没有课程安排' : '所选学期尚未发布课表'"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.timetable-page { display: grid; gap: 20px; }
|
||||||
|
.public-timetable { min-height: 100vh; padding: 0 32px 40px; background: #f4f7fa; }
|
||||||
|
.public-header { height: 68px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid #dce4eb; }
|
||||||
|
.public-brand { color: #17324d; font-weight: 750; text-decoration: none; letter-spacing: .02em; }
|
||||||
|
.login-link { color: #176b87; text-decoration: none; font-weight: 650; }
|
||||||
|
.timetable-heading { display: flex; align-items: end; justify-content: space-between; gap: 24px; padding: 24px 28px; background: #fff; border: 1px solid #dce4eb; }
|
||||||
|
.timetable-heading h2 { margin: 5px 0 8px; color: #17324d; font-size: 27px; }
|
||||||
|
.timetable-heading p { margin: 0; color: #647587; }
|
||||||
|
.timetable-filters { display: flex; gap: 12px; }
|
||||||
|
.timetable-filters .el-select { width: 270px; }
|
||||||
|
.timetable-sheet { min-height: 360px; padding: 22px; background: #fff; border: 1px solid #dce4eb; }
|
||||||
|
.sheet-meta { display: flex; justify-content: space-between; gap: 24px; margin-bottom: 18px; padding-bottom: 16px; border-bottom: 1px solid #e6ebf0; }
|
||||||
|
.sheet-meta div { display: grid; gap: 3px; }
|
||||||
|
.sheet-meta strong { color: #17324d; }
|
||||||
|
.sheet-meta span { color: #718191; font-size: 13px; }
|
||||||
|
.timetable-scroll { overflow: auto; }
|
||||||
|
.week-grid { min-width: 1120px; display: grid; grid-template-columns: 92px repeat(7, minmax(140px, 1fr)); position: relative; }
|
||||||
|
.grid-corner, .day-head, .period-head { z-index: 2; background: #f3f6f8; border: 1px solid #dce4eb; color: #3f5366; }
|
||||||
|
.grid-corner, .day-head { display: grid; place-items: center; font-weight: 700; }
|
||||||
|
.grid-corner { grid-column: 1; grid-row: 1; }
|
||||||
|
.day-head { grid-row: 1; }
|
||||||
|
.day-head:nth-of-type(2) { grid-column: 2; }
|
||||||
|
.day-head:nth-of-type(3) { grid-column: 3; }
|
||||||
|
.day-head:nth-of-type(4) { grid-column: 4; }
|
||||||
|
.day-head:nth-of-type(5) { grid-column: 5; }
|
||||||
|
.day-head:nth-of-type(6) { grid-column: 6; }
|
||||||
|
.day-head:nth-of-type(7) { grid-column: 7; }
|
||||||
|
.day-head:nth-of-type(8) { grid-column: 8; }
|
||||||
|
.period-head { grid-column: 1; display: grid; place-content: center; gap: 4px; text-align: center; }
|
||||||
|
.period-head span { color: #7c8b98; font-size: 11px; }
|
||||||
|
.grid-cell { border: 1px solid #e4e9ee; background: #fff; }
|
||||||
|
.course-block { z-index: 3; margin: 4px; padding: 9px 10px; overflow: hidden; display: flex; flex-direction: column; gap: 4px; border-left: 4px solid #176b87; background: #e9f3f5; color: #24475a; box-shadow: 0 2px 5px rgba(23, 50, 77, .08); }
|
||||||
|
.course-block strong { color: #123a4b; font-size: 14px; }
|
||||||
|
.course-block span { font-size: 12px; }
|
||||||
|
.course-block small { margin-top: auto; color: #5f7885; font-size: 11px; }
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.public-timetable { padding: 0 14px 24px; }
|
||||||
|
.timetable-heading, .sheet-meta { align-items: stretch; flex-direction: column; }
|
||||||
|
.timetable-filters { flex-direction: column; }
|
||||||
|
.timetable-filters .el-select { width: 100%; }
|
||||||
|
.timetable-sheet { padding: 12px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user