From 27c3944c28a360e5c39a85cfee15e36f4f4577bd Mon Sep 17 00:00:00 2001 From: biss Date: Fri, 24 Jul 2026 22:18:32 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8D=87=E7=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 + .../Controllers/PersonnelController.cs | 92 ++++++- .../Controllers/PersonnelExcelController.cs | 24 +- .../Controllers/TimetablesController.cs | 231 +++++++++++++++++ .../Auth/PersonnelAccountService.cs | 144 +++++++++++ src/Jiaowu.Api/Program.cs | 1 + web/src/layouts/AdminLayout.vue | 2 + web/src/router/index.ts | 17 ++ web/src/style.css | 1 + web/src/views/LoginView.vue | 3 +- web/src/views/PersonnelView.vue | 43 +++- web/src/views/TimetableView.vue | 235 ++++++++++++++++++ 12 files changed, 781 insertions(+), 14 deletions(-) create mode 100644 src/Jiaowu.Api/Controllers/TimetablesController.cs create mode 100644 src/Jiaowu.Api/Infrastructure/Auth/PersonnelAccountService.cs create mode 100644 web/src/views/TimetableView.vue diff --git a/README.md b/README.md index 1ff016b..764cae9 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ 权限采用后端强制校验的角色与数据范围模型。多角色账号按 `All > College > Class > Self` 取最高数据范围:校级角色可访问全校数据,院系管理员限定本学院,辅导员通过稳定的账号 ID 绑定所带行政班,教师和学生限定本人及当前教学关系;前端菜单和路由限制仅作为交互辅助,不替代 API 授权。 +学生和教师档案是登录账号的人员主数据:新增或 Excel 导入档案时,系统以学号/工号自动创建同名登录账号并分配学生/教师角色,后续修改姓名、编号、学院或在籍/在职状态时同步账号。`AspNetUsers` 仅作为 ASP.NET Core Identity 的内部安全存储,负责密码哈希、登录锁定、角色和令牌,不需要再手工重复建立学生、教师用户。 + ## 本地开发:热更新模式 本地开发固定使用 SQLite。首次启动会自动创建 `src/Jiaowu.Api/data/jiaowu-dev.sqlite` 并写入演示组织数据。 diff --git a/src/Jiaowu.Api/Controllers/PersonnelController.cs b/src/Jiaowu.Api/Controllers/PersonnelController.cs index e47db5a..f2c5227 100644 --- a/src/Jiaowu.Api/Controllers/PersonnelController.cs +++ b/src/Jiaowu.Api/Controllers/PersonnelController.cs @@ -15,7 +15,8 @@ namespace Jiaowu.Api.Controllers; [Route("api/personnel")] public sealed class PersonnelController( AppDbContext db, - ICurrentUserDataScope currentUserDataScope) : ControllerBase + ICurrentUserDataScope currentUserDataScope, + PersonnelAccountService personnelAccountService) : ControllerBase { private const string ReadRoles = SystemRoles.SuperAdmin + "," + @@ -103,7 +104,14 @@ public sealed class PersonnelController( Notes = Normalize(request.Notes) }; 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}")] @@ -130,6 +138,18 @@ public sealed class PersonnelController( entity.Phone = Normalize(request.Phone); entity.Email = Normalize(request.Email); 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); } @@ -239,7 +259,14 @@ public sealed class PersonnelController( Notes = Normalize(request.Notes) }; 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}")] @@ -276,6 +303,18 @@ public sealed class PersonnelController( entity.Phone = Normalize(request.Phone); entity.Email = Normalize(request.Email); 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); } @@ -372,6 +411,39 @@ public sealed class PersonnelController( } } + private async Task CreateWithAccountAsync( + Guid id, + string? initialPassword, + Func> 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 SaveNoContentAsync(CancellationToken cancellationToken) { try @@ -393,6 +465,14 @@ public sealed class PersonnelController( Status = StatusCodes.Status409Conflict }); + private ActionResult AccountProblem(string detail) => + Conflict(new ProblemDetails + { + Title = "登录账号创建失败", + Detail = detail, + Status = StatusCodes.Status409Conflict + }); + private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); @@ -422,7 +502,8 @@ public sealed record TeacherRequest( bool IsExternal, [MaxLength(30)] string? Phone, [EmailAddress, MaxLength(100)] string? Email, - [MaxLength(500)] string? Notes); + [MaxLength(500)] string? Notes, + [MinLength(8), MaxLength(100)] string? InitialPassword = null); public sealed record StudentRequest( [Required, MaxLength(30)] string StudentNumber, @@ -435,4 +516,5 @@ public sealed record StudentRequest( DateOnly? DateOfBirth, [MaxLength(30)] string? Phone, [EmailAddress, MaxLength(100)] string? Email, - [MaxLength(500)] string? Notes); + [MaxLength(500)] string? Notes, + [MinLength(8), MaxLength(100)] string? InitialPassword = null); diff --git a/src/Jiaowu.Api/Controllers/PersonnelExcelController.cs b/src/Jiaowu.Api/Controllers/PersonnelExcelController.cs index 69f8aff..30777be 100644 --- a/src/Jiaowu.Api/Controllers/PersonnelExcelController.cs +++ b/src/Jiaowu.Api/Controllers/PersonnelExcelController.cs @@ -14,7 +14,8 @@ namespace Jiaowu.Api.Controllers; [Route("api/personnel")] public sealed class PersonnelExcelController( AppDbContext db, - ICurrentUserDataScope currentUserDataScope) : ControllerBase + ICurrentUserDataScope currentUserDataScope, + PersonnelAccountService personnelAccountService) : ControllerBase { private const string ReadRoles = SystemRoles.SuperAdmin + "," + @@ -32,13 +33,13 @@ public sealed class PersonnelExcelController( private static readonly string[] TeacherHeaders = [ "工号", "姓名", "性别", "学院编码", "职称", "任职状态", - "入职日期", "教师类别", "联系电话", "电子邮箱", "备注" + "入职日期", "教师类别", "联系电话", "电子邮箱", "备注", "初始密码" ]; private static readonly string[] StudentHeaders = [ "学号", "姓名", "性别", "行政班编码", "入学年级", "入学日期", - "学籍状态", "出生日期", "联系电话", "电子邮箱", "备注" + "学籍状态", "出生日期", "联系电话", "电子邮箱", "备注", "初始密码" ]; [HttpGet("{kind}/template")] @@ -56,6 +57,7 @@ public sealed class PersonnelExcelController( ? "工号是唯一标识;学院编码必须已存在。" : "学号是唯一标识;行政班编码必须已存在。", "编号已存在时更新档案,不存在时新增档案。", + "新增人员或补建账号时必须填写至少 8 位初始密码;已有关联账号时可留空。", "日期填写为 yyyy-MM-dd;不适用的可选字段可以留空。", "整批数据会先校验,任一行有误时均不会写入。" ]); @@ -81,7 +83,7 @@ public sealed class PersonnelExcelController( .Select(x => Row( x.TeacherNumber, x.Name, GenderName(x.Gender), x.College!.Code, 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(); } else @@ -94,7 +96,7 @@ 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.Phone, x.Email, x.Notes, null)) .ToList(); } @@ -212,6 +214,12 @@ public sealed class PersonnelExcelController( entity.Phone = Optional(row, "联系电话"); entity.Email = 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); } @@ -284,6 +292,12 @@ public sealed class PersonnelExcelController( entity.Phone = Optional(row, "联系电话"); entity.Email = 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); } diff --git a/src/Jiaowu.Api/Controllers/TimetablesController.cs b/src/Jiaowu.Api/Controllers/TimetablesController.cs new file mode 100644 index 0000000..a308312 --- /dev/null +++ b/src/Jiaowu.Api/Controllers/TimetablesController.cs @@ -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 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 GetClassTimetable( + Guid classId, + Guid? academicTermId, + CancellationToken cancellationToken) => + BuildTimetableAsync(classId, academicTermId, null, cancellationToken); + + [HttpGet("mine")] + [Authorize(Roles = SystemRoles.Student)] + public async Task 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 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() + }); + + 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 + }); + } +} diff --git a/src/Jiaowu.Api/Infrastructure/Auth/PersonnelAccountService.cs b/src/Jiaowu.Api/Infrastructure/Auth/PersonnelAccountService.cs new file mode 100644 index 0000000..569b61f --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Auth/PersonnelAccountService.cs @@ -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 userManager) +{ + public Task 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 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 EnsureAccountAsync( + Guid profileId, + Guid? linkedUserId, + string number, + string displayName, + Guid collegeId, + bool isEnabled, + string role, + string? initialPassword, + Action 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); +} diff --git a/src/Jiaowu.Api/Program.cs b/src/Jiaowu.Api/Program.cs index 79dfa75..9f34f57 100644 --- a/src/Jiaowu.Api/Program.cs +++ b/src/Jiaowu.Api/Program.cs @@ -85,6 +85,7 @@ builder.Services.Configure( builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/web/src/layouts/AdminLayout.vue b/web/src/layouts/AdminLayout.vue index b23cd86..2a1bd20 100644 --- a/web/src/layouts/AdminLayout.vue +++ b/web/src/layouts/AdminLayout.vue @@ -98,6 +98,8 @@ const navigationGroups = computed(() => [ hasAnyRole(['SuperAdmin', 'AcademicAdmin']), { path: '/schedules', label: '排课与课表' }, ), + ...whenVisible(isStudent.value, { path: '/my-timetable', label: '我的课表' }), + ...whenVisible(!isStudent.value, { path: '/class-timetable', label: '班级课表查询' }), ...whenVisible( hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Student']), { diff --git a/web/src/router/index.ts b/web/src/router/index.ts index 1e7b756..7e9f0a6 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -11,6 +11,12 @@ const router = createRouter({ component: () => import('../views/LoginView.vue'), meta: { public: true }, }, + { + path: '/timetable', + name: 'public-timetable', + component: () => import('../views/TimetableView.vue'), + meta: { public: true }, + }, { path: '/', component: AdminLayout, @@ -114,6 +120,17 @@ const router = createRouter({ component: () => import('../views/SchedulesView.vue'), 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', name: 'course-selections', diff --git a/web/src/style.css b/web/src/style.css index f3212f9..2bcf364 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -938,6 +938,7 @@ button { cursor: pointer; } .form-intro p { margin: 0; color: var(--muted); font-size: 13px; } .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; } +.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; } .dev-hint { margin-top: 24px; padding: 13px 15px; display: flex; justify-content: space-between; color: #767e8d; background: #f5f7fa; font-size: 11px; } diff --git a/web/src/views/LoginView.vue b/web/src/views/LoginView.vue index f4fb1ac..58ce995 100644 --- a/web/src/views/LoginView.vue +++ b/web/src/views/LoginView.vue @@ -53,7 +53,7 @@ async function submit() {
欢迎回来

登录教务工作台

-

使用学校分配的管理账号继续。

+

学生使用学号、教师使用工号登录;管理人员使用学校分配的账号。