多角色账号按 All > College > Class > Self 自动取最高权限。
学院管理员限制在所属学院。 辅导员通过稳定账号 ID 绑定行政班,避免重名串班。 教师只能访问本人档案、授课课程和所授课学生。 学生只能访问本人档案及所在班级课程。 教师/学生角色会自动校验并绑定工号或学号档案。 超级管理员可在用户页面调整角色、学院、工号/学号,并预览生效后的数据范围。
This commit is contained in:
@@ -4,6 +4,8 @@
|
|||||||
|
|
||||||
当前已实现系统登录与角色权限、基础数据、用户管理、教师档案、学生档案、课程库、培养方案、教学任务、排课课表和首页统计。人员及课程列表支持组合筛选、服务端分页和完整增删改查;培养方案支持课程模块、专业年级版本、复制新版本、发布锁定和旧版本归档;教学任务支持学期课程开设、多教师、合班、容量校验、发布与结课;排课支持单双周、周次节次、教室容量、教师/行政班/教室冲突校验和版本化发布。
|
当前已实现系统登录与角色权限、基础数据、用户管理、教师档案、学生档案、课程库、培养方案、教学任务、排课课表和首页统计。人员及课程列表支持组合筛选、服务端分页和完整增删改查;培养方案支持课程模块、专业年级版本、复制新版本、发布锁定和旧版本归档;教学任务支持学期课程开设、多教师、合班、容量校验、发布与结课;排课支持单双周、周次节次、教室容量、教师/行政班/教室冲突校验和版本化发布。
|
||||||
|
|
||||||
|
权限采用后端强制校验的角色与数据范围模型。多角色账号按 `All > College > Class > Self` 取最高数据范围:校级角色可访问全校数据,院系管理员限定本学院,辅导员通过稳定的账号 ID 绑定所带行政班,教师和学生限定本人及当前教学关系;前端菜单和路由限制仅作为交互辅助,不替代 API 授权。
|
||||||
|
|
||||||
## 本地开发:热更新模式
|
## 本地开发:热更新模式
|
||||||
|
|
||||||
本地开发固定使用 SQLite。首次启动会自动创建 `src/Jiaowu.Api/data/jiaowu-dev.sqlite` 并写入演示组织数据。
|
本地开发固定使用 SQLite。首次启动会自动创建 `src/Jiaowu.Api/data/jiaowu-dev.sqlite` 并写入演示组织数据。
|
||||||
@@ -20,7 +22,17 @@ npm install
|
|||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
访问 `http://localhost:5173`,开发账号为 `admin`,密码为 `Admin@123456`。
|
访问 `http://localhost:5173`。本地开发会自动创建以下分级权限账号:
|
||||||
|
|
||||||
|
| 数据范围 | 账号 | 密码 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 超级管理员 | `admin` | `Admin@123456` |
|
||||||
|
| 校级教务 | `academic` | `Academic@123456` |
|
||||||
|
| 学院教务 | `college` | `College@123456` |
|
||||||
|
| 所带班级 | `counselor` | `Counselor@123456` |
|
||||||
|
| 教师本人 | `teacher` | `Teacher@123456` |
|
||||||
|
| 学生本人 | `student` | `Student@123456` |
|
||||||
|
| 领导查看 | `leader` | `Leader@123456` |
|
||||||
|
|
||||||
## 本地开发:单服务模式
|
## 本地开发:单服务模式
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,15 @@ try {
|
|||||||
$headers = @{ Authorization = "Bearer $($login.token)" }
|
$headers = @{ Authorization = "Bearer $($login.token)" }
|
||||||
$dashboard = Invoke-RestMethod -Uri 'http://localhost:5255/api/dashboard' -Headers $headers
|
$dashboard = Invoke-RestMethod -Uri 'http://localhost:5255/api/dashboard' -Headers $headers
|
||||||
$campuses = Invoke-RestMethod -Uri 'http://localhost:5255/api/base-data/campuses' -Headers $headers
|
$campuses = Invoke-RestMethod -Uri 'http://localhost:5255/api/base-data/campuses' -Headers $headers
|
||||||
|
$classes = Invoke-RestMethod -Uri 'http://localhost:5255/api/base-data/classes' -Headers $headers
|
||||||
|
$counselors = Invoke-RestMethod -Uri 'http://localhost:5255/api/base-data/counselors' -Headers $headers
|
||||||
|
$assignedClassCount = @($classes | Where-Object {
|
||||||
|
$null -ne $_.counselorUserId -and
|
||||||
|
@($counselors).id -contains $_.counselorUserId
|
||||||
|
}).Count
|
||||||
|
if ($assignedClassCount -lt 1) {
|
||||||
|
throw 'No administrative class is linked to a counselor account.'
|
||||||
|
}
|
||||||
$teachers = Invoke-RestMethod -Uri 'http://localhost:5255/api/personnel/teachers?page=1&pageSize=10' -Headers $headers
|
$teachers = Invoke-RestMethod -Uri 'http://localhost:5255/api/personnel/teachers?page=1&pageSize=10' -Headers $headers
|
||||||
$students = Invoke-RestMethod -Uri 'http://localhost:5255/api/personnel/students?page=1&pageSize=10' -Headers $headers
|
$students = Invoke-RestMethod -Uri 'http://localhost:5255/api/personnel/students?page=1&pageSize=10' -Headers $headers
|
||||||
$courses = Invoke-RestMethod -Uri 'http://localhost:5255/api/courses?page=1&pageSize=10' -Headers $headers
|
$courses = Invoke-RestMethod -Uri 'http://localhost:5255/api/courses?page=1&pageSize=10' -Headers $headers
|
||||||
@@ -82,6 +91,77 @@ try {
|
|||||||
-Uri "http://localhost:5255/api/schedules/plans/$($schedulePlans[0].id)" `
|
-Uri "http://localhost:5255/api/schedules/plans/$($schedulePlans[0].id)" `
|
||||||
-Headers $headers
|
-Headers $headers
|
||||||
}
|
}
|
||||||
|
$managedUsers = Invoke-RestMethod -Uri 'http://localhost:5255/api/users' -Headers $headers
|
||||||
|
$teacherAccount = @($managedUsers) |
|
||||||
|
Where-Object { $_.userName -eq 'teacher' } |
|
||||||
|
Select-Object -First 1
|
||||||
|
if ($null -eq $teacherAccount) {
|
||||||
|
throw 'Development teacher account was not seeded.'
|
||||||
|
}
|
||||||
|
$teacherAccessBody = @{
|
||||||
|
staffNumber = $teacherAccount.staffNumber
|
||||||
|
collegeId = $teacherAccount.collegeId
|
||||||
|
roles = @('Teacher')
|
||||||
|
} | ConvertTo-Json
|
||||||
|
Invoke-RestMethod `
|
||||||
|
-Method Put `
|
||||||
|
-Uri "http://localhost:5255/api/users/$($teacherAccount.id)/roles" `
|
||||||
|
-Headers $headers `
|
||||||
|
-ContentType 'application/json' `
|
||||||
|
-Body $teacherAccessBody
|
||||||
|
|
||||||
|
$scopeScenarios = @(
|
||||||
|
@{
|
||||||
|
UserName = 'college'; Password = 'College@123456'; Scope = 'College'
|
||||||
|
Teachers = 2; Students = 3; Courses = 3
|
||||||
|
},
|
||||||
|
@{
|
||||||
|
UserName = 'counselor'; Password = 'Counselor@123456'; Scope = 'Class'
|
||||||
|
Teachers = 1; Students = 3; Courses = 1
|
||||||
|
},
|
||||||
|
@{
|
||||||
|
UserName = 'teacher'; Password = 'Teacher@123456'; Scope = 'Self'
|
||||||
|
Teachers = 1; Students = 3; Courses = 1
|
||||||
|
},
|
||||||
|
@{
|
||||||
|
UserName = 'student'; Password = 'Student@123456'; Scope = 'Self'
|
||||||
|
Teachers = 0; Students = 1; Courses = 1
|
||||||
|
}
|
||||||
|
)
|
||||||
|
$scopeChecks = foreach ($scenario in $scopeScenarios) {
|
||||||
|
$scenarioLoginBody = @{
|
||||||
|
userName = $scenario.UserName
|
||||||
|
password = $scenario.Password
|
||||||
|
} | ConvertTo-Json
|
||||||
|
$scenarioLogin = Invoke-RestMethod `
|
||||||
|
-Method Post `
|
||||||
|
-Uri 'http://localhost:5255/api/auth/login' `
|
||||||
|
-ContentType 'application/json' `
|
||||||
|
-Body $scenarioLoginBody
|
||||||
|
$scenarioHeaders = @{ Authorization = "Bearer $($scenarioLogin.token)" }
|
||||||
|
$scenarioTeachers = Invoke-RestMethod `
|
||||||
|
-Uri 'http://localhost:5255/api/personnel/teachers?page=1&pageSize=10' `
|
||||||
|
-Headers $scenarioHeaders
|
||||||
|
$scenarioStudents = Invoke-RestMethod `
|
||||||
|
-Uri 'http://localhost:5255/api/personnel/students?page=1&pageSize=10' `
|
||||||
|
-Headers $scenarioHeaders
|
||||||
|
$scenarioCourses = Invoke-RestMethod `
|
||||||
|
-Uri 'http://localhost:5255/api/courses?page=1&pageSize=10' `
|
||||||
|
-Headers $scenarioHeaders
|
||||||
|
if ($scenarioLogin.user.effectiveDataScope -ne $scenario.Scope -or
|
||||||
|
$scenarioTeachers.total -ne $scenario.Teachers -or
|
||||||
|
$scenarioStudents.total -ne $scenario.Students -or
|
||||||
|
$scenarioCourses.total -ne $scenario.Courses) {
|
||||||
|
throw ("Data-scope check failed for {0}: scope={1}, teachers={2}, students={3}, courses={4}." -f
|
||||||
|
$scenario.UserName,
|
||||||
|
$scenarioLogin.user.effectiveDataScope,
|
||||||
|
$scenarioTeachers.total,
|
||||||
|
$scenarioStudents.total,
|
||||||
|
$scenarioCourses.total)
|
||||||
|
}
|
||||||
|
"$($scenario.UserName):$($scenario.Scope)"
|
||||||
|
}
|
||||||
|
|
||||||
$frontend = Invoke-WebRequest -Uri 'http://localhost:5255/' -TimeoutSec 5
|
$frontend = Invoke-WebRequest -Uri 'http://localhost:5255/' -TimeoutSec 5
|
||||||
$spaFallback = Invoke-WebRequest -Uri 'http://localhost:5255/base-data' -TimeoutSec 5
|
$spaFallback = Invoke-WebRequest -Uri 'http://localhost:5255/base-data' -TimeoutSec 5
|
||||||
$unknownApiParameters = @{
|
$unknownApiParameters = @{
|
||||||
@@ -97,6 +177,7 @@ try {
|
|||||||
User = $login.user.displayName
|
User = $login.user.displayName
|
||||||
Term = $dashboard.currentTerm.name
|
Term = $dashboard.currentTerm.name
|
||||||
Campuses = @($campuses).Count
|
Campuses = @($campuses).Count
|
||||||
|
CounselorAssignments = $assignedClassCount
|
||||||
Teachers = $teachers.total
|
Teachers = $teachers.total
|
||||||
Students = $students.total
|
Students = $students.total
|
||||||
Courses = $courses.total
|
Courses = $courses.total
|
||||||
@@ -105,6 +186,8 @@ try {
|
|||||||
TeachingTasks = $teachingTasks.total
|
TeachingTasks = $teachingTasks.total
|
||||||
Schedules = @($schedulePlans).Count
|
Schedules = @($schedulePlans).Count
|
||||||
ScheduleEntries = if ($null -ne $scheduleDetail) { @($scheduleDetail.entries).Count } else { 0 }
|
ScheduleEntries = if ($null -ne $scheduleDetail) { @($scheduleDetail.entries).Count } else { 0 }
|
||||||
|
AccessUpdate = $true
|
||||||
|
ScopeChecks = $scopeChecks -join ', '
|
||||||
StaticIndex = $frontend.Content.Contains('明序教务管理系统')
|
StaticIndex = $frontend.Content.Contains('明序教务管理系统')
|
||||||
SpaFallback = $spaFallback.StatusCode
|
SpaFallback = $spaFallback.StatusCode
|
||||||
ApiNotFound = $unknownApi.StatusCode
|
ApiNotFound = $unknownApi.StatusCode
|
||||||
|
|||||||
@@ -53,7 +53,8 @@ public sealed class AuthController(
|
|||||||
user.UserName!,
|
user.UserName!,
|
||||||
user.DisplayName,
|
user.DisplayName,
|
||||||
roles,
|
roles,
|
||||||
user.CollegeId));
|
user.CollegeId,
|
||||||
|
EffectiveDataScopeResolver.Resolve(roles).ToString()));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Authorize]
|
[Authorize]
|
||||||
@@ -67,12 +68,14 @@ public sealed class AuthController(
|
|||||||
return Unauthorized();
|
return Unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var roles = await userManager.GetRolesAsync(user);
|
||||||
return new CurrentUserResponse(
|
return new CurrentUserResponse(
|
||||||
user.Id,
|
user.Id,
|
||||||
user.UserName!,
|
user.UserName!,
|
||||||
user.DisplayName,
|
user.DisplayName,
|
||||||
await userManager.GetRolesAsync(user),
|
roles,
|
||||||
user.CollegeId);
|
user.CollegeId,
|
||||||
|
EffectiveDataScopeResolver.Resolve(roles).ToString());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,4 +90,5 @@ public sealed record CurrentUserResponse(
|
|||||||
string UserName,
|
string UserName,
|
||||||
string DisplayName,
|
string DisplayName,
|
||||||
IEnumerable<string> Roles,
|
IEnumerable<string> Roles,
|
||||||
Guid? CollegeId);
|
Guid? CollegeId,
|
||||||
|
string EffectiveDataScope);
|
||||||
|
|||||||
@@ -168,7 +168,29 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
|||||||
x.Id, x.Code, x.Name, x.MajorId,
|
x.Id, x.Code, x.Name, x.MajorId,
|
||||||
MajorName = x.Major!.Name,
|
MajorName = x.Major!.Name,
|
||||||
CollegeName = x.Major.College!.Name,
|
CollegeName = x.Major.College!.Name,
|
||||||
x.Grade, x.CounselorName, x.IsEnabled, x.SortOrder
|
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));
|
.ToListAsync(cancellationToken));
|
||||||
|
|
||||||
@@ -180,6 +202,11 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
|||||||
{
|
{
|
||||||
if (!await db.Majors.AnyAsync(x => x.Id == request.MajorId, cancellationToken))
|
if (!await db.Majors.AnyAsync(x => x.Id == request.MajorId, cancellationToken))
|
||||||
return ValidationProblem("所选专业不存在。");
|
return ValidationProblem("所选专业不存在。");
|
||||||
|
var counselorName = await GetCounselorNameAsync(
|
||||||
|
request.CounselorUserId,
|
||||||
|
cancellationToken);
|
||||||
|
if (request.CounselorUserId.HasValue && counselorName is null)
|
||||||
|
return ValidationProblem("所选辅导员账号不存在、已停用或没有辅导员角色。");
|
||||||
|
|
||||||
var entity = new AdministrativeClass
|
var entity = new AdministrativeClass
|
||||||
{
|
{
|
||||||
@@ -187,7 +214,8 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
|||||||
Name = request.Name.Trim(),
|
Name = request.Name.Trim(),
|
||||||
MajorId = request.MajorId,
|
MajorId = request.MajorId,
|
||||||
Grade = request.Grade,
|
Grade = request.Grade,
|
||||||
CounselorName = request.CounselorName?.Trim(),
|
CounselorUserId = request.CounselorUserId,
|
||||||
|
CounselorName = counselorName,
|
||||||
SortOrder = request.SortOrder,
|
SortOrder = request.SortOrder,
|
||||||
IsEnabled = request.IsEnabled
|
IsEnabled = request.IsEnabled
|
||||||
};
|
};
|
||||||
@@ -203,10 +231,16 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
|||||||
{
|
{
|
||||||
var entity = await db.AdministrativeClasses.FindAsync([id], cancellationToken);
|
var entity = await db.AdministrativeClasses.FindAsync([id], cancellationToken);
|
||||||
if (entity is null) return NotFound();
|
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);
|
ApplyCatalog(entity, request);
|
||||||
entity.MajorId = request.MajorId;
|
entity.MajorId = request.MajorId;
|
||||||
entity.Grade = request.Grade;
|
entity.Grade = request.Grade;
|
||||||
entity.CounselorName = request.CounselorName?.Trim();
|
entity.CounselorUserId = request.CounselorUserId;
|
||||||
|
entity.CounselorName = counselorName;
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
return entity;
|
return entity;
|
||||||
}
|
}
|
||||||
@@ -409,6 +443,24 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
|||||||
return CreatedAtAction(action, new { id = entity.Id }, entity);
|
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)
|
private static void ApplyCatalog(CatalogEntity entity, CatalogRequest request)
|
||||||
{
|
{
|
||||||
entity.Code = request.Code.Trim();
|
entity.Code = request.Code.Trim();
|
||||||
@@ -442,7 +494,7 @@ public sealed record ClassRequest(
|
|||||||
string Code, string Name, int SortOrder, bool IsEnabled,
|
string Code, string Name, int SortOrder, bool IsEnabled,
|
||||||
Guid MajorId,
|
Guid MajorId,
|
||||||
[Range(2000, 2200)] int Grade,
|
[Range(2000, 2200)] int Grade,
|
||||||
[MaxLength(50)] string? CounselorName)
|
Guid? CounselorUserId)
|
||||||
: CatalogRequest(Code, Name, SortOrder, IsEnabled);
|
: CatalogRequest(Code, Name, SortOrder, IsEnabled);
|
||||||
|
|
||||||
public sealed record TermRequest(
|
public sealed record TermRequest(
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.Security.Claims;
|
|
||||||
using Jiaowu.Api.Contracts;
|
using Jiaowu.Api.Contracts;
|
||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -13,7 +13,9 @@ namespace Jiaowu.Api.Controllers;
|
|||||||
[ApiController]
|
[ApiController]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
[Route("api/courses")]
|
[Route("api/courses")]
|
||||||
public sealed class CoursesController(AppDbContext db) : ControllerBase
|
public sealed class CoursesController(
|
||||||
|
AppDbContext db,
|
||||||
|
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
||||||
{
|
{
|
||||||
private const string WriteRoles =
|
private const string WriteRoles =
|
||||||
SystemRoles.SuperAdmin + "," +
|
SystemRoles.SuperAdmin + "," +
|
||||||
@@ -32,10 +34,7 @@ public sealed class CoursesController(AppDbContext db) : ControllerBase
|
|||||||
{
|
{
|
||||||
page = Math.Max(1, page);
|
page = Math.Max(1, page);
|
||||||
pageSize = Math.Clamp(pageSize, 10, 100);
|
pageSize = Math.Clamp(pageSize, 10, 100);
|
||||||
var source = db.Courses.AsNoTracking().AsQueryable();
|
var source = ScopedCourses().AsNoTracking();
|
||||||
var scopedCollegeId = GetScopedCollegeId();
|
|
||||||
if (scopedCollegeId.HasValue)
|
|
||||||
source = source.Where(x => x.CollegeId == scopedCollegeId.Value);
|
|
||||||
if (collegeId.HasValue)
|
if (collegeId.HasValue)
|
||||||
source = source.Where(x => x.CollegeId == collegeId.Value);
|
source = source.Where(x => x.CollegeId == collegeId.Value);
|
||||||
if (nature.HasValue)
|
if (nature.HasValue)
|
||||||
@@ -162,17 +161,37 @@ public sealed class CoursesController(AppDbContext db) : ControllerBase
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Guid? GetScopedCollegeId()
|
private IQueryable<Course> ScopedCourses()
|
||||||
{
|
{
|
||||||
if (!User.IsInRole(SystemRoles.CollegeAdmin))
|
var scope = currentUserDataScope.Current;
|
||||||
return null;
|
var source = db.Courses.AsQueryable();
|
||||||
return Guid.TryParse(User.FindFirstValue("college_id"), out var collegeId)
|
if (scope.Scope == DataScope.All) return source;
|
||||||
? collegeId
|
if (scope.Scope == DataScope.College)
|
||||||
: Guid.Empty;
|
return source.Where(x => x.CollegeId == scope.RestrictedCollegeId);
|
||||||
|
if (scope.Scope == DataScope.Class)
|
||||||
|
{
|
||||||
|
return source.Where(course => db.TeachingTasks.Any(task =>
|
||||||
|
task.CourseId == course.Id &&
|
||||||
|
task.Classes.Any(item =>
|
||||||
|
item.AdministrativeClass!.CounselorUserId == scope.UserId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
var userId = scope.UserId;
|
||||||
|
var isTeacher = scope.IsInRole(SystemRoles.Teacher);
|
||||||
|
var isStudent = scope.IsInRole(SystemRoles.Student);
|
||||||
|
return source.Where(course =>
|
||||||
|
isTeacher && db.TeachingTasks.Any(task =>
|
||||||
|
task.CourseId == course.Id &&
|
||||||
|
task.Teachers.Any(item => item.Teacher!.UserId == userId)) ||
|
||||||
|
isStudent && db.TeachingTasks.Any(task =>
|
||||||
|
task.CourseId == course.Id &&
|
||||||
|
task.Classes.Any(item =>
|
||||||
|
item.AdministrativeClass!.Students.Any(student =>
|
||||||
|
student.UserId == userId))));
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool CanAccessCollege(Guid collegeId) =>
|
private bool CanAccessCollege(Guid collegeId) =>
|
||||||
!GetScopedCollegeId().HasValue || GetScopedCollegeId() == collegeId;
|
currentUserDataScope.Current.CanAccessCollege(collegeId);
|
||||||
|
|
||||||
private async Task<ActionResult> SaveAsync(
|
private async Task<ActionResult> SaveAsync(
|
||||||
Guid id,
|
Guid id,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.Security.Claims;
|
|
||||||
using Jiaowu.Api.Contracts;
|
using Jiaowu.Api.Contracts;
|
||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -13,7 +13,9 @@ namespace Jiaowu.Api.Controllers;
|
|||||||
[ApiController]
|
[ApiController]
|
||||||
[Authorize(Roles = ReadRoles)]
|
[Authorize(Roles = ReadRoles)]
|
||||||
[Route("api/curriculum-plans")]
|
[Route("api/curriculum-plans")]
|
||||||
public sealed class CurriculumPlansController(AppDbContext db) : ControllerBase
|
public sealed class CurriculumPlansController(
|
||||||
|
AppDbContext db,
|
||||||
|
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
||||||
{
|
{
|
||||||
private const string ReadRoles =
|
private const string ReadRoles =
|
||||||
SystemRoles.SuperAdmin + "," +
|
SystemRoles.SuperAdmin + "," +
|
||||||
@@ -447,12 +449,7 @@ public sealed class CurriculumPlansController(AppDbContext db) : ControllerBase
|
|||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
private Guid? ScopedCollegeId()
|
private Guid? ScopedCollegeId()
|
||||||
{
|
=> currentUserDataScope.Current.RestrictedCollegeId;
|
||||||
if (!User.IsInRole(SystemRoles.CollegeAdmin)) return null;
|
|
||||||
return Guid.TryParse(User.FindFirstValue("college_id"), out var collegeId)
|
|
||||||
? collegeId
|
|
||||||
: Guid.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<ActionResult> SaveCreatedAsync(Guid id, CancellationToken cancellationToken)
|
private async Task<ActionResult> SaveCreatedAsync(Guid id, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.Security.Claims;
|
|
||||||
using Jiaowu.Api.Contracts;
|
using Jiaowu.Api.Contracts;
|
||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -13,13 +13,17 @@ namespace Jiaowu.Api.Controllers;
|
|||||||
[ApiController]
|
[ApiController]
|
||||||
[Authorize(Roles = ReadRoles)]
|
[Authorize(Roles = ReadRoles)]
|
||||||
[Route("api/personnel")]
|
[Route("api/personnel")]
|
||||||
public sealed class PersonnelController(AppDbContext db) : ControllerBase
|
public sealed class PersonnelController(
|
||||||
|
AppDbContext db,
|
||||||
|
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
||||||
{
|
{
|
||||||
private const string ReadRoles =
|
private const string ReadRoles =
|
||||||
SystemRoles.SuperAdmin + "," +
|
SystemRoles.SuperAdmin + "," +
|
||||||
SystemRoles.AcademicAdmin + "," +
|
SystemRoles.AcademicAdmin + "," +
|
||||||
SystemRoles.CollegeAdmin + "," +
|
SystemRoles.CollegeAdmin + "," +
|
||||||
SystemRoles.Counselor;
|
SystemRoles.Counselor + "," +
|
||||||
|
SystemRoles.Teacher + "," +
|
||||||
|
SystemRoles.Student;
|
||||||
|
|
||||||
private const string WriteRoles =
|
private const string WriteRoles =
|
||||||
SystemRoles.SuperAdmin + "," +
|
SystemRoles.SuperAdmin + "," +
|
||||||
@@ -33,10 +37,7 @@ public sealed class PersonnelController(AppDbContext db) : ControllerBase
|
|||||||
{
|
{
|
||||||
var page = NormalizePage(query.Page);
|
var page = NormalizePage(query.Page);
|
||||||
var pageSize = NormalizePageSize(query.PageSize);
|
var pageSize = NormalizePageSize(query.PageSize);
|
||||||
var source = db.Teachers.AsNoTracking().AsQueryable();
|
var source = ApplyTeacherScope(db.Teachers.AsNoTracking());
|
||||||
var scopedCollegeId = GetScopedCollegeId();
|
|
||||||
if (scopedCollegeId.HasValue)
|
|
||||||
source = source.Where(x => x.CollegeId == scopedCollegeId.Value);
|
|
||||||
if (query.CollegeId.HasValue)
|
if (query.CollegeId.HasValue)
|
||||||
source = source.Where(x => x.CollegeId == query.CollegeId);
|
source = source.Where(x => x.CollegeId == query.CollegeId);
|
||||||
if (!string.IsNullOrWhiteSpace(query.Keyword))
|
if (!string.IsNullOrWhiteSpace(query.Keyword))
|
||||||
@@ -155,13 +156,7 @@ public sealed class PersonnelController(AppDbContext db) : ControllerBase
|
|||||||
{
|
{
|
||||||
var page = NormalizePage(query.Page);
|
var page = NormalizePage(query.Page);
|
||||||
var pageSize = NormalizePageSize(query.PageSize);
|
var pageSize = NormalizePageSize(query.PageSize);
|
||||||
var source = db.Students.AsNoTracking().AsQueryable();
|
var source = ApplyStudentScope(db.Students.AsNoTracking());
|
||||||
var scopedCollegeId = GetScopedCollegeId();
|
|
||||||
if (scopedCollegeId.HasValue)
|
|
||||||
{
|
|
||||||
source = source.Where(x =>
|
|
||||||
x.AdministrativeClass!.Major!.CollegeId == scopedCollegeId.Value);
|
|
||||||
}
|
|
||||||
if (query.CollegeId.HasValue)
|
if (query.CollegeId.HasValue)
|
||||||
{
|
{
|
||||||
source = source.Where(x =>
|
source = source.Where(x =>
|
||||||
@@ -303,21 +298,54 @@ public sealed class PersonnelController(AppDbContext db) : ControllerBase
|
|||||||
return await SaveNoContentAsync(cancellationToken);
|
return await SaveNoContentAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Guid? GetScopedCollegeId()
|
private IQueryable<Teacher> ApplyTeacherScope(IQueryable<Teacher> source)
|
||||||
{
|
{
|
||||||
if (!User.IsInRole(SystemRoles.CollegeAdmin) &&
|
var scope = currentUserDataScope.Current;
|
||||||
!User.IsInRole(SystemRoles.Counselor))
|
if (scope.Scope == DataScope.All) return source;
|
||||||
|
if (scope.Scope == DataScope.College)
|
||||||
|
return source.Where(x => x.CollegeId == scope.RestrictedCollegeId);
|
||||||
|
if (scope.Scope == DataScope.Class)
|
||||||
{
|
{
|
||||||
return null;
|
return source.Where(teacher => db.TeachingTasks.Any(task =>
|
||||||
|
task.Teachers.Any(item => item.TeacherId == teacher.Id) &&
|
||||||
|
task.Classes.Any(item =>
|
||||||
|
item.AdministrativeClass!.CounselorUserId == scope.UserId)));
|
||||||
}
|
}
|
||||||
|
|
||||||
return Guid.TryParse(User.FindFirstValue("college_id"), out var collegeId)
|
var userId = scope.UserId;
|
||||||
? collegeId
|
return scope.IsInRole(SystemRoles.Teacher)
|
||||||
: Guid.Empty;
|
? source.Where(x => x.UserId == userId)
|
||||||
|
: source.Where(_ => false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private IQueryable<Student> ApplyStudentScope(IQueryable<Student> source)
|
||||||
|
{
|
||||||
|
var scope = currentUserDataScope.Current;
|
||||||
|
if (scope.Scope == DataScope.All) return source;
|
||||||
|
if (scope.Scope == DataScope.College)
|
||||||
|
{
|
||||||
|
return source.Where(x =>
|
||||||
|
x.AdministrativeClass!.Major!.CollegeId == scope.RestrictedCollegeId);
|
||||||
|
}
|
||||||
|
if (scope.Scope == DataScope.Class)
|
||||||
|
{
|
||||||
|
return source.Where(x =>
|
||||||
|
x.AdministrativeClass!.CounselorUserId == scope.UserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
var userId = scope.UserId;
|
||||||
|
var isTeacher = scope.IsInRole(SystemRoles.Teacher);
|
||||||
|
var isStudent = scope.IsInRole(SystemRoles.Student);
|
||||||
|
return source.Where(student =>
|
||||||
|
isStudent && student.UserId == userId ||
|
||||||
|
isTeacher && db.TeachingTasks.Any(task =>
|
||||||
|
task.Teachers.Any(item => item.Teacher!.UserId == userId) &&
|
||||||
|
task.Classes.Any(item =>
|
||||||
|
item.AdministrativeClassId == student.AdministrativeClassId)));
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool CanAccessCollege(Guid collegeId) =>
|
private bool CanAccessCollege(Guid collegeId) =>
|
||||||
!GetScopedCollegeId().HasValue || GetScopedCollegeId() == collegeId;
|
currentUserDataScope.Current.CanAccessCollege(collegeId);
|
||||||
|
|
||||||
private async Task<ActionResult?> ValidateCollegeAsync(
|
private async Task<ActionResult?> ValidateCollegeAsync(
|
||||||
Guid collegeId,
|
Guid collegeId,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.Security.Claims;
|
|
||||||
using Jiaowu.Api.Contracts;
|
using Jiaowu.Api.Contracts;
|
||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -13,7 +13,9 @@ namespace Jiaowu.Api.Controllers;
|
|||||||
[ApiController]
|
[ApiController]
|
||||||
[Authorize(Roles = ManagementRoles)]
|
[Authorize(Roles = ManagementRoles)]
|
||||||
[Route("api/teaching-tasks")]
|
[Route("api/teaching-tasks")]
|
||||||
public sealed class TeachingTasksController(AppDbContext db) : ControllerBase
|
public sealed class TeachingTasksController(
|
||||||
|
AppDbContext db,
|
||||||
|
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
||||||
{
|
{
|
||||||
private const string ManagementRoles =
|
private const string ManagementRoles =
|
||||||
SystemRoles.SuperAdmin + "," +
|
SystemRoles.SuperAdmin + "," +
|
||||||
@@ -314,12 +316,7 @@ public sealed class TeachingTasksController(AppDbContext db) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
private Guid? ScopedCollegeId()
|
private Guid? ScopedCollegeId()
|
||||||
{
|
=> currentUserDataScope.Current.RestrictedCollegeId;
|
||||||
if (!User.IsInRole(SystemRoles.CollegeAdmin)) return null;
|
|
||||||
return Guid.TryParse(User.FindFirstValue("college_id"), out var collegeId)
|
|
||||||
? collegeId
|
|
||||||
: Guid.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<ActionResult> SaveAsync(
|
private async Task<ActionResult> SaveAsync(
|
||||||
Guid id,
|
Guid id,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -12,6 +14,7 @@ namespace Jiaowu.Api.Controllers;
|
|||||||
[Authorize(Roles = SystemRoles.SuperAdmin)]
|
[Authorize(Roles = SystemRoles.SuperAdmin)]
|
||||||
[Route("api/users")]
|
[Route("api/users")]
|
||||||
public sealed class UsersController(
|
public sealed class UsersController(
|
||||||
|
AppDbContext db,
|
||||||
UserManager<ApplicationUser> userManager,
|
UserManager<ApplicationUser> userManager,
|
||||||
RoleManager<ApplicationRole> roleManager) : ControllerBase
|
RoleManager<ApplicationRole> roleManager) : ControllerBase
|
||||||
{
|
{
|
||||||
@@ -53,29 +56,35 @@ public sealed class UsersController(
|
|||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<ActionResult> Create(CreateUserRequest request)
|
public async Task<ActionResult> Create(CreateUserRequest request)
|
||||||
{
|
{
|
||||||
var invalidRoles = request.Roles
|
var roles = request.Roles.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
|
||||||
.Except(SystemRoles.All, StringComparer.OrdinalIgnoreCase)
|
var invalidRoles = ValidateRoles(roles);
|
||||||
.ToArray();
|
if (invalidRoles is not null) return invalidRoles;
|
||||||
if (invalidRoles.Length > 0)
|
var staffNumber = Normalize(request.StaffNumber);
|
||||||
return ValidationProblem($"无效角色:{string.Join("、", invalidRoles)}");
|
var profiles = await ResolveProfilesAsync(staffNumber, roles);
|
||||||
|
if (profiles.Error is not null) return profiles.Error;
|
||||||
|
|
||||||
var user = new ApplicationUser
|
var user = new ApplicationUser
|
||||||
{
|
{
|
||||||
UserName = request.UserName.Trim(),
|
UserName = request.UserName.Trim(),
|
||||||
DisplayName = request.DisplayName.Trim(),
|
DisplayName = request.DisplayName.Trim(),
|
||||||
StaffNumber = request.StaffNumber?.Trim(),
|
StaffNumber = staffNumber,
|
||||||
CollegeId = request.CollegeId,
|
CollegeId = request.CollegeId,
|
||||||
LockoutEnabled = true,
|
LockoutEnabled = true,
|
||||||
IsEnabled = true
|
IsEnabled = true
|
||||||
};
|
};
|
||||||
|
await using var transaction = await db.Database.BeginTransactionAsync();
|
||||||
var result = await userManager.CreateAsync(user, request.Password);
|
var result = await userManager.CreateAsync(user, request.Password);
|
||||||
if (!result.Succeeded)
|
if (!result.Succeeded)
|
||||||
return IdentityValidationProblem(result);
|
return IdentityValidationProblem(result);
|
||||||
|
|
||||||
result = await userManager.AddToRolesAsync(user, request.Roles);
|
result = await userManager.AddToRolesAsync(user, roles);
|
||||||
if (!result.Succeeded)
|
if (!result.Succeeded)
|
||||||
return IdentityValidationProblem(result);
|
return IdentityValidationProblem(result);
|
||||||
|
|
||||||
|
if (profiles.Teacher is not null) profiles.Teacher.UserId = user.Id;
|
||||||
|
if (profiles.Student is not null) profiles.Student.UserId = user.Id;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
await transaction.CommitAsync();
|
||||||
return CreatedAtAction(nameof(GetUsers), new { id = user.Id }, new { user.Id });
|
return CreatedAtAction(nameof(GetUsers), new { id = user.Id }, new { user.Id });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,28 +108,97 @@ public sealed class UsersController(
|
|||||||
var user = await userManager.FindByIdAsync(id.ToString());
|
var user = await userManager.FindByIdAsync(id.ToString());
|
||||||
if (user is null) return NotFound();
|
if (user is null) return NotFound();
|
||||||
|
|
||||||
var invalidRoles = request.Roles
|
var roles = request.Roles.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
|
||||||
.Except(SystemRoles.All, StringComparer.OrdinalIgnoreCase)
|
var invalidRoles = ValidateRoles(roles);
|
||||||
.ToArray();
|
if (invalidRoles is not null) return invalidRoles;
|
||||||
if (invalidRoles.Length > 0)
|
var staffNumber = Normalize(request.StaffNumber);
|
||||||
return ValidationProblem($"无效角色:{string.Join("、", invalidRoles)}");
|
var profiles = await ResolveProfilesAsync(staffNumber, roles, user.Id);
|
||||||
|
if (profiles.Error is not null) return profiles.Error;
|
||||||
|
|
||||||
var existing = await userManager.GetRolesAsync(user);
|
var existing = await userManager.GetRolesAsync(user);
|
||||||
if (User.FindFirstValue(ClaimTypes.NameIdentifier) == id.ToString() &&
|
if (User.FindFirstValue(ClaimTypes.NameIdentifier) == id.ToString() &&
|
||||||
existing.Contains(SystemRoles.SuperAdmin) &&
|
existing.Contains(SystemRoles.SuperAdmin) &&
|
||||||
!request.Roles.Contains(SystemRoles.SuperAdmin))
|
!roles.Contains(SystemRoles.SuperAdmin, StringComparer.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
return ValidationProblem("不能移除当前账号的超级管理员角色。");
|
return ValidationProblem("不能移除当前账号的超级管理员角色。");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await using var transaction = await db.Database.BeginTransactionAsync();
|
||||||
|
user.StaffNumber = staffNumber;
|
||||||
|
user.CollegeId = request.CollegeId;
|
||||||
|
var updateResult = await userManager.UpdateAsync(user);
|
||||||
|
if (!updateResult.Succeeded) return IdentityValidationProblem(updateResult);
|
||||||
var removeResult = await userManager.RemoveFromRolesAsync(
|
var removeResult = await userManager.RemoveFromRolesAsync(
|
||||||
user,
|
user,
|
||||||
existing.Except(request.Roles));
|
existing.Except(roles, StringComparer.OrdinalIgnoreCase));
|
||||||
if (!removeResult.Succeeded) return IdentityValidationProblem(removeResult);
|
if (!removeResult.Succeeded) return IdentityValidationProblem(removeResult);
|
||||||
var addResult = await userManager.AddToRolesAsync(
|
var addResult = await userManager.AddToRolesAsync(
|
||||||
user,
|
user,
|
||||||
request.Roles.Except(existing));
|
roles.Except(existing, StringComparer.OrdinalIgnoreCase));
|
||||||
return addResult.Succeeded ? NoContent() : IdentityValidationProblem(addResult);
|
if (!addResult.Succeeded) return IdentityValidationProblem(addResult);
|
||||||
|
|
||||||
|
var linkedTeachers = await db.Teachers.Where(x => x.UserId == id).ToListAsync();
|
||||||
|
var linkedStudents = await db.Students.Where(x => x.UserId == id).ToListAsync();
|
||||||
|
if (!roles.Contains(SystemRoles.Teacher, StringComparer.OrdinalIgnoreCase))
|
||||||
|
foreach (var teacher in linkedTeachers) teacher.UserId = null;
|
||||||
|
if (!roles.Contains(SystemRoles.Student, StringComparer.OrdinalIgnoreCase))
|
||||||
|
foreach (var student in linkedStudents) student.UserId = null;
|
||||||
|
if (profiles.Teacher is not null) profiles.Teacher.UserId = id;
|
||||||
|
if (profiles.Student is not null) profiles.Student.UserId = id;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
await transaction.CommitAsync();
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private ActionResult? ValidateRoles(IReadOnlyCollection<string> roles)
|
||||||
|
{
|
||||||
|
if (roles.Count == 0) return ValidationProblem("至少需要分配一个角色。");
|
||||||
|
var invalidRoles = roles
|
||||||
|
.Except(SystemRoles.All, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToArray();
|
||||||
|
return invalidRoles.Length == 0
|
||||||
|
? null
|
||||||
|
: ValidationProblem($"无效角色:{string.Join("、", invalidRoles)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(Teacher? Teacher, Student? Student, ActionResult? Error)>
|
||||||
|
ResolveProfilesAsync(
|
||||||
|
string? staffNumber,
|
||||||
|
IReadOnlyCollection<string> roles,
|
||||||
|
Guid? currentUserId = null)
|
||||||
|
{
|
||||||
|
var needsTeacher = roles.Contains(
|
||||||
|
SystemRoles.Teacher,
|
||||||
|
StringComparer.OrdinalIgnoreCase);
|
||||||
|
var needsStudent = roles.Contains(
|
||||||
|
SystemRoles.Student,
|
||||||
|
StringComparer.OrdinalIgnoreCase);
|
||||||
|
if (!needsTeacher && !needsStudent) return (null, null, null);
|
||||||
|
if (string.IsNullOrWhiteSpace(staffNumber))
|
||||||
|
return (null, null, ValidationProblem("教师或学生角色必须填写对应的工号/学号。"));
|
||||||
|
|
||||||
|
Teacher? teacher = null;
|
||||||
|
Student? student = null;
|
||||||
|
if (needsTeacher)
|
||||||
|
{
|
||||||
|
teacher = await db.Teachers.SingleOrDefaultAsync(
|
||||||
|
x => x.TeacherNumber == staffNumber);
|
||||||
|
if (teacher is null)
|
||||||
|
return (null, null, ValidationProblem("未找到与该工号对应的教师档案。"));
|
||||||
|
if (teacher.UserId.HasValue && teacher.UserId != currentUserId)
|
||||||
|
return (null, null, ConflictProblem("该教师档案已经关联其他账号。"));
|
||||||
|
}
|
||||||
|
if (needsStudent)
|
||||||
|
{
|
||||||
|
student = await db.Students.SingleOrDefaultAsync(
|
||||||
|
x => x.StudentNumber == staffNumber);
|
||||||
|
if (student is null)
|
||||||
|
return (null, null, ValidationProblem("未找到与该学号对应的学生档案。"));
|
||||||
|
if (student.UserId.HasValue && student.UserId != currentUserId)
|
||||||
|
return (null, null, ConflictProblem("该学生档案已经关联其他账号。"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (teacher, student, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private ActionResult IdentityValidationProblem(IdentityResult result)
|
private ActionResult IdentityValidationProblem(IdentityResult result)
|
||||||
@@ -129,6 +207,17 @@ public sealed class UsersController(
|
|||||||
ModelState.AddModelError(error.Code, error.Description);
|
ModelState.AddModelError(error.Code, error.Description);
|
||||||
return ValidationProblem(ModelState);
|
return ValidationProblem(ModelState);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ActionResult ConflictProblem(string detail) =>
|
||||||
|
Conflict(new ProblemDetails
|
||||||
|
{
|
||||||
|
Title = "账号关联冲突",
|
||||||
|
Detail = detail,
|
||||||
|
Status = StatusCodes.Status409Conflict
|
||||||
|
});
|
||||||
|
|
||||||
|
private static string? Normalize(string? value) =>
|
||||||
|
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record CreateUserRequest(
|
public sealed record CreateUserRequest(
|
||||||
@@ -140,4 +229,7 @@ public sealed record CreateUserRequest(
|
|||||||
[MinLength(1)] string[] Roles);
|
[MinLength(1)] string[] Roles);
|
||||||
|
|
||||||
public sealed record SetUserStatusRequest(bool IsEnabled);
|
public sealed record SetUserStatusRequest(bool IsEnabled);
|
||||||
public sealed record SetRolesRequest([MinLength(1)] string[] Roles);
|
public sealed record SetRolesRequest(
|
||||||
|
[MaxLength(30)] string? StaffNumber,
|
||||||
|
Guid? CollegeId,
|
||||||
|
[MinLength(1)] string[] Roles);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Jiaowu.Api.Domain.Common;
|
using Jiaowu.Api.Domain.Common;
|
||||||
|
using Jiaowu.Api.Domain.Identity;
|
||||||
|
|
||||||
namespace Jiaowu.Api.Domain.Academic;
|
namespace Jiaowu.Api.Domain.Academic;
|
||||||
|
|
||||||
@@ -28,6 +29,8 @@ public sealed class AdministrativeClass : CatalogEntity
|
|||||||
public Major? Major { get; set; }
|
public Major? Major { get; set; }
|
||||||
public int Grade { get; set; }
|
public int Grade { get; set; }
|
||||||
public string? CounselorName { get; set; }
|
public string? CounselorName { get; set; }
|
||||||
|
public Guid? CounselorUserId { get; set; }
|
||||||
|
public ApplicationUser? CounselorUser { get; set; }
|
||||||
public ICollection<Student> Students { get; set; } = [];
|
public ICollection<Student> Students { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Jiaowu.Api.Domain.Identity;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Auth;
|
||||||
|
|
||||||
|
public interface ICurrentUserDataScope
|
||||||
|
{
|
||||||
|
CurrentUserScope Current { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record CurrentUserScope(
|
||||||
|
Guid UserId,
|
||||||
|
string? DisplayName,
|
||||||
|
Guid? CollegeId,
|
||||||
|
DataScope Scope,
|
||||||
|
IReadOnlySet<string> Roles)
|
||||||
|
{
|
||||||
|
public bool IsInRole(string role) => Roles.Contains(role);
|
||||||
|
|
||||||
|
public bool CanAccessCollege(Guid collegeId) =>
|
||||||
|
Scope == DataScope.All ||
|
||||||
|
Scope == DataScope.College && CollegeId == collegeId;
|
||||||
|
|
||||||
|
public Guid? RestrictedCollegeId => Scope switch
|
||||||
|
{
|
||||||
|
DataScope.All => null,
|
||||||
|
DataScope.College => CollegeId ?? Guid.Empty,
|
||||||
|
_ => Guid.Empty
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class CurrentUserDataScope(IHttpContextAccessor httpContextAccessor)
|
||||||
|
: ICurrentUserDataScope
|
||||||
|
{
|
||||||
|
public CurrentUserScope Current
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var principal = httpContextAccessor.HttpContext?.User;
|
||||||
|
var roles = new HashSet<string>(
|
||||||
|
principal?.FindAll(ClaimTypes.Role).Select(x => x.Value) ?? [],
|
||||||
|
StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
return new CurrentUserScope(
|
||||||
|
ParseGuid(principal?.FindFirstValue(ClaimTypes.NameIdentifier)),
|
||||||
|
principal?.FindFirstValue("display_name") ??
|
||||||
|
principal?.FindFirstValue(ClaimTypes.Name),
|
||||||
|
ParseNullableGuid(principal?.FindFirstValue("college_id")),
|
||||||
|
EffectiveDataScopeResolver.Resolve(roles),
|
||||||
|
roles);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Guid ParseGuid(string? value) =>
|
||||||
|
Guid.TryParse(value, out var id) ? id : Guid.Empty;
|
||||||
|
|
||||||
|
private static Guid? ParseNullableGuid(string? value) =>
|
||||||
|
Guid.TryParse(value, out var id) ? id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class EffectiveDataScopeResolver
|
||||||
|
{
|
||||||
|
private static readonly IReadOnlyDictionary<string, DataScope> RoleScopes =
|
||||||
|
new Dictionary<string, DataScope>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
[SystemRoles.SuperAdmin] = DataScope.All,
|
||||||
|
[SystemRoles.AcademicAdmin] = DataScope.All,
|
||||||
|
[SystemRoles.Leader] = DataScope.All,
|
||||||
|
[SystemRoles.CollegeAdmin] = DataScope.College,
|
||||||
|
[SystemRoles.Counselor] = DataScope.Class,
|
||||||
|
[SystemRoles.Teacher] = DataScope.Self,
|
||||||
|
[SystemRoles.Student] = DataScope.Self
|
||||||
|
};
|
||||||
|
|
||||||
|
public static DataScope Resolve(IEnumerable<string> roles)
|
||||||
|
{
|
||||||
|
var effectiveScope = DataScope.Self;
|
||||||
|
foreach (var role in roles)
|
||||||
|
{
|
||||||
|
if (RoleScopes.TryGetValue(role, out var roleScope) &&
|
||||||
|
roleScope > effectiveScope)
|
||||||
|
{
|
||||||
|
effectiveScope = roleScope;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return effectiveScope;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,7 +23,8 @@ public sealed class TokenService(IOptions<JwtOptions> options) : ITokenService
|
|||||||
new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
|
new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
|
||||||
new(JwtRegisteredClaimNames.UniqueName, user.UserName ?? string.Empty),
|
new(JwtRegisteredClaimNames.UniqueName, user.UserName ?? string.Empty),
|
||||||
new(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
new(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
||||||
new(ClaimTypes.Name, user.DisplayName)
|
new(ClaimTypes.Name, user.DisplayName),
|
||||||
|
new("display_name", user.DisplayName)
|
||||||
};
|
};
|
||||||
|
|
||||||
claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role)));
|
claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role)));
|
||||||
|
|||||||
@@ -72,6 +72,14 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey(x => x.MajorId)
|
.HasForeignKey(x => x.MajorId)
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
builder.Entity<AdministrativeClass>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasIndex(x => x.CounselorUserId);
|
||||||
|
entity.HasOne(x => x.CounselorUser)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(x => x.CounselorUserId)
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
});
|
||||||
|
|
||||||
builder.Entity<Building>()
|
builder.Entity<Building>()
|
||||||
.HasOne(x => x.Campus)
|
.HasOne(x => x.Campus)
|
||||||
|
|||||||
@@ -419,6 +419,85 @@ public sealed class DatabaseInitializer(
|
|||||||
});
|
});
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await SeedDevelopmentUsersAsync(computerCollege.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SeedDevelopmentUsersAsync(Guid collegeId)
|
||||||
|
{
|
||||||
|
var definitions = new[]
|
||||||
|
{
|
||||||
|
new DevelopmentUser(
|
||||||
|
"academic", "校级教务员", "Academic@123456",
|
||||||
|
null, null, SystemRoles.AcademicAdmin),
|
||||||
|
new DevelopmentUser(
|
||||||
|
"college", "计算机学院教务员", "College@123456",
|
||||||
|
"A2026001", collegeId, SystemRoles.CollegeAdmin),
|
||||||
|
new DevelopmentUser(
|
||||||
|
"counselor", "陈老师", "Counselor@123456",
|
||||||
|
"C2026001", collegeId, SystemRoles.Counselor),
|
||||||
|
new DevelopmentUser(
|
||||||
|
"teacher", "陈明远", "Teacher@123456",
|
||||||
|
"T2026001", collegeId, SystemRoles.Teacher),
|
||||||
|
new DevelopmentUser(
|
||||||
|
"student", "周启航", "Student@123456",
|
||||||
|
"202601001", collegeId, SystemRoles.Student),
|
||||||
|
new DevelopmentUser(
|
||||||
|
"leader", "教学分管领导", "Leader@123456",
|
||||||
|
null, null, SystemRoles.Leader)
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var definition in definitions)
|
||||||
|
{
|
||||||
|
var user = await userManager.FindByNameAsync(definition.UserName);
|
||||||
|
if (user is null)
|
||||||
|
{
|
||||||
|
user = new ApplicationUser
|
||||||
|
{
|
||||||
|
UserName = definition.UserName,
|
||||||
|
DisplayName = definition.DisplayName,
|
||||||
|
StaffNumber = definition.StaffNumber,
|
||||||
|
CollegeId = definition.CollegeId,
|
||||||
|
LockoutEnabled = true,
|
||||||
|
IsEnabled = true
|
||||||
|
};
|
||||||
|
EnsureSucceeded(
|
||||||
|
await userManager.CreateAsync(user, definition.Password),
|
||||||
|
$"创建开发账号 {definition.UserName}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await userManager.IsInRoleAsync(user, definition.Role))
|
||||||
|
{
|
||||||
|
EnsureSucceeded(
|
||||||
|
await userManager.AddToRoleAsync(user, definition.Role),
|
||||||
|
$"授予开发账号 {definition.UserName} 角色");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (definition.Role == SystemRoles.Teacher)
|
||||||
|
{
|
||||||
|
var teacher = await db.Teachers.SingleAsync(
|
||||||
|
x => x.TeacherNumber == definition.StaffNumber);
|
||||||
|
if (!teacher.UserId.HasValue) teacher.UserId = user.Id;
|
||||||
|
}
|
||||||
|
if (definition.Role == SystemRoles.Student)
|
||||||
|
{
|
||||||
|
var student = await db.Students.SingleAsync(
|
||||||
|
x => x.StudentNumber == definition.StaffNumber);
|
||||||
|
if (!student.UserId.HasValue) student.UserId = user.Id;
|
||||||
|
}
|
||||||
|
if (definition.Role == SystemRoles.Counselor)
|
||||||
|
{
|
||||||
|
var classes = await db.AdministrativeClasses
|
||||||
|
.Where(x =>
|
||||||
|
x.CounselorUserId == null &&
|
||||||
|
x.CounselorName == definition.DisplayName)
|
||||||
|
.ToListAsync();
|
||||||
|
foreach (var administrativeClass in classes)
|
||||||
|
administrativeClass.CounselorUserId = user.Id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void EnsureSucceeded(IdentityResult result, string action)
|
private static void EnsureSucceeded(IdentityResult result, string action)
|
||||||
@@ -431,4 +510,12 @@ public sealed class DatabaseInitializer(
|
|||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
$"{action}失败:{string.Join(";", result.Errors.Select(x => x.Description))}");
|
$"{action}失败:{string.Join(";", result.Errors.Select(x => x.Description))}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed record DevelopmentUser(
|
||||||
|
string UserName,
|
||||||
|
string DisplayName,
|
||||||
|
string Password,
|
||||||
|
string? StaffNumber,
|
||||||
|
Guid? CollegeId,
|
||||||
|
string Role);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
private const string CurriculumPlansMigration = "20260724_02_curriculum_plans";
|
private const string CurriculumPlansMigration = "20260724_02_curriculum_plans";
|
||||||
private const string TeachingTasksMigration = "20260724_03_teaching_tasks";
|
private const string TeachingTasksMigration = "20260724_03_teaching_tasks";
|
||||||
private const string SchedulesMigration = "20260724_04_schedules";
|
private const string SchedulesMigration = "20260724_04_schedules";
|
||||||
|
private const string ClassCounselorMigration = "20260724_05_class_counselor";
|
||||||
|
|
||||||
public async Task MigrateAsync(CancellationToken cancellationToken = default)
|
public async Task MigrateAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
@@ -43,6 +44,20 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
SchedulesMigration,
|
SchedulesMigration,
|
||||||
SchedulesStatements,
|
SchedulesStatements,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
var counselorColumnExists = await db.Database
|
||||||
|
.SqlQueryRaw<int>(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) AS "Value"
|
||||||
|
FROM pragma_table_info('AdministrativeClasses')
|
||||||
|
WHERE name = 'CounselorUserId'
|
||||||
|
""")
|
||||||
|
.AnyAsync(value => value > 0, cancellationToken);
|
||||||
|
await ApplyMigrationAsync(
|
||||||
|
ClassCounselorMigration,
|
||||||
|
counselorColumnExists
|
||||||
|
? ClassCounselorStatements.Skip(1)
|
||||||
|
: ClassCounselorStatements,
|
||||||
|
cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ApplyMigrationAsync(
|
private async Task ApplyMigrationAsync(
|
||||||
@@ -399,4 +414,16 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
ON "ScheduleEntries" ("ClassroomId");
|
ON "ScheduleEntries" ("ClassroomId");
|
||||||
"""
|
"""
|
||||||
];
|
];
|
||||||
|
|
||||||
|
private static readonly string[] ClassCounselorStatements =
|
||||||
|
[
|
||||||
|
"""
|
||||||
|
ALTER TABLE "AdministrativeClasses"
|
||||||
|
ADD COLUMN "CounselorUserId" TEXT NULL;
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS "IX_AdministrativeClasses_CounselorUserId"
|
||||||
|
ON "AdministrativeClasses" ("CounselorUserId");
|
||||||
|
"""
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
+1483
File diff suppressed because it is too large
Load Diff
+50
@@ -0,0 +1,50 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class ClassCounselorAssignment : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "CounselorUserId",
|
||||||
|
table: "AdministrativeClasses",
|
||||||
|
type: "char(36)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AdministrativeClasses_CounselorUserId",
|
||||||
|
table: "AdministrativeClasses",
|
||||||
|
column: "CounselorUserId");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_AdministrativeClasses_AspNetUsers_CounselorUserId",
|
||||||
|
table: "AdministrativeClasses",
|
||||||
|
column: "CounselorUserId",
|
||||||
|
principalTable: "AspNetUsers",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_AdministrativeClasses_AspNetUsers_CounselorUserId",
|
||||||
|
table: "AdministrativeClasses");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_AdministrativeClasses_CounselorUserId",
|
||||||
|
table: "AdministrativeClasses");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "CounselorUserId",
|
||||||
|
table: "AdministrativeClasses");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
@@ -89,6 +89,9 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.Property<string>("CounselorName")
|
b.Property<string>("CounselorName")
|
||||||
.HasColumnType("longtext");
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
|
b.Property<Guid?>("CounselorUserId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
b.Property<DateTime>("CreatedAt")
|
||||||
.HasColumnType("datetime(6)");
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
@@ -117,6 +120,8 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.HasIndex("Code")
|
b.HasIndex("Code")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("CounselorUserId");
|
||||||
|
|
||||||
b.HasIndex("MajorId");
|
b.HasIndex("MajorId");
|
||||||
|
|
||||||
b.HasIndex("IsEnabled", "SortOrder");
|
b.HasIndex("IsEnabled", "SortOrder");
|
||||||
@@ -1154,12 +1159,19 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
|
|
||||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AdministrativeClass", b =>
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AdministrativeClass", b =>
|
||||||
{
|
{
|
||||||
|
b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", "CounselorUser")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CounselorUserId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
b.HasOne("Jiaowu.Api.Domain.Academic.Major", "Major")
|
b.HasOne("Jiaowu.Api.Domain.Academic.Major", "Major")
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey("MajorId")
|
.HasForeignKey("MajorId")
|
||||||
.OnDelete(DeleteBehavior.Restrict)
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("CounselorUser");
|
||||||
|
|
||||||
b.Navigation("Major");
|
b.Navigation("Major");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -81,7 +81,9 @@ if (Encoding.UTF8.GetByteCount(jwtOptions.Key) < 32)
|
|||||||
|
|
||||||
builder.Services.Configure<JwtOptions>(
|
builder.Services.Configure<JwtOptions>(
|
||||||
builder.Configuration.GetSection(JwtOptions.SectionName));
|
builder.Configuration.GetSection(JwtOptions.SectionName));
|
||||||
|
builder.Services.AddHttpContextAccessor();
|
||||||
builder.Services.AddScoped<ITokenService, TokenService>();
|
builder.Services.AddScoped<ITokenService, TokenService>();
|
||||||
|
builder.Services.AddScoped<ICurrentUserDataScope, CurrentUserDataScope>();
|
||||||
builder.Services.AddScoped<DatabaseInitializer>();
|
builder.Services.AddScoped<DatabaseInitializer>();
|
||||||
builder.Services.AddScoped<DevelopmentSqliteMigrator>();
|
builder.Services.AddScoped<DevelopmentSqliteMigrator>();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Jiaowu.Api.Domain.Identity;
|
||||||
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Tests;
|
||||||
|
|
||||||
|
public sealed class DataScopeTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData(SystemRoles.Student, null, DataScope.Self)]
|
||||||
|
[InlineData(SystemRoles.Counselor, SystemRoles.Teacher, DataScope.Class)]
|
||||||
|
[InlineData(SystemRoles.CollegeAdmin, SystemRoles.Counselor, DataScope.College)]
|
||||||
|
[InlineData(SystemRoles.SuperAdmin, SystemRoles.CollegeAdmin, DataScope.All)]
|
||||||
|
[InlineData(SystemRoles.AcademicAdmin, SystemRoles.Student, DataScope.All)]
|
||||||
|
public void Resolver_UsesHighestScope(
|
||||||
|
string firstRole,
|
||||||
|
string? secondRole,
|
||||||
|
DataScope expected)
|
||||||
|
{
|
||||||
|
var roles = secondRole is null
|
||||||
|
? [firstRole]
|
||||||
|
: new[] { firstRole, secondRole };
|
||||||
|
|
||||||
|
Assert.Equal(expected, EffectiveDataScopeResolver.Resolve(roles));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CurrentScope_ReadsIdentityAndCollegeClaims()
|
||||||
|
{
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
var collegeId = Guid.NewGuid();
|
||||||
|
var claims = new[]
|
||||||
|
{
|
||||||
|
new Claim(ClaimTypes.NameIdentifier, userId.ToString()),
|
||||||
|
new Claim(ClaimTypes.Name, "院系管理员"),
|
||||||
|
new Claim(ClaimTypes.Role, SystemRoles.CollegeAdmin),
|
||||||
|
new Claim("college_id", collegeId.ToString())
|
||||||
|
};
|
||||||
|
var accessor = new HttpContextAccessor
|
||||||
|
{
|
||||||
|
HttpContext = new DefaultHttpContext
|
||||||
|
{
|
||||||
|
User = new ClaimsPrincipal(new ClaimsIdentity(claims, "test"))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var current = new CurrentUserDataScope(accessor).Current;
|
||||||
|
|
||||||
|
Assert.Equal(userId, current.UserId);
|
||||||
|
Assert.Equal(collegeId, current.CollegeId);
|
||||||
|
Assert.Equal(DataScope.College, current.Scope);
|
||||||
|
Assert.True(current.CanAccessCollege(collegeId));
|
||||||
|
Assert.False(current.CanAccessCollege(Guid.NewGuid()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Domain.Identity;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
using Microsoft.Data.Sqlite;
|
using Microsoft.Data.Sqlite;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -82,12 +83,21 @@ public sealed class PersistenceTests : IAsyncLifetime
|
|||||||
CollegeId = college.Id,
|
CollegeId = college.Id,
|
||||||
DegreeType = "工学学士"
|
DegreeType = "工学学士"
|
||||||
};
|
};
|
||||||
|
var counselor = new ApplicationUser
|
||||||
|
{
|
||||||
|
UserName = "counselor",
|
||||||
|
NormalizedUserName = "COUNSELOR",
|
||||||
|
DisplayName = "陈老师"
|
||||||
|
};
|
||||||
var administrativeClass = new AdministrativeClass
|
var administrativeClass = new AdministrativeClass
|
||||||
{
|
{
|
||||||
Code = "CS2026-01",
|
Code = "CS2026-01",
|
||||||
Name = "计科 2026-1 班",
|
Name = "计科 2026-1 班",
|
||||||
MajorId = major.Id,
|
MajorId = major.Id,
|
||||||
Grade = 2026
|
Grade = 2026,
|
||||||
|
CounselorName = counselor.DisplayName,
|
||||||
|
CounselorUserId = counselor.Id,
|
||||||
|
CounselorUser = counselor
|
||||||
};
|
};
|
||||||
var course = new Course
|
var course = new Course
|
||||||
{
|
{
|
||||||
@@ -135,6 +145,7 @@ public sealed class PersistenceTests : IAsyncLifetime
|
|||||||
campus,
|
campus,
|
||||||
college,
|
college,
|
||||||
major,
|
major,
|
||||||
|
counselor,
|
||||||
administrativeClass,
|
administrativeClass,
|
||||||
teacher,
|
teacher,
|
||||||
new Student
|
new Student
|
||||||
@@ -223,6 +234,9 @@ public sealed class PersistenceTests : IAsyncLifetime
|
|||||||
.Include(x => x.AdministrativeClass)
|
.Include(x => x.AdministrativeClass)
|
||||||
.ThenInclude(x => x!.Major)
|
.ThenInclude(x => x!.Major)
|
||||||
.SingleAsync();
|
.SingleAsync();
|
||||||
|
var savedClass = await _db.AdministrativeClasses
|
||||||
|
.Include(x => x.CounselorUser)
|
||||||
|
.SingleAsync();
|
||||||
var savedCourse = await _db.Courses.Include(x => x.College).SingleAsync();
|
var savedCourse = await _db.Courses.Include(x => x.College).SingleAsync();
|
||||||
var curriculumPlan = await _db.CurriculumPlans
|
var curriculumPlan = await _db.CurriculumPlans
|
||||||
.Include(x => x.Modules)
|
.Include(x => x.Modules)
|
||||||
@@ -239,6 +253,7 @@ public sealed class PersistenceTests : IAsyncLifetime
|
|||||||
|
|
||||||
Assert.Equal("计算机学院", savedTeacher.College!.Name);
|
Assert.Equal("计算机学院", savedTeacher.College!.Name);
|
||||||
Assert.Equal("计算机科学与技术", student.AdministrativeClass!.Major!.Name);
|
Assert.Equal("计算机科学与技术", student.AdministrativeClass!.Major!.Name);
|
||||||
|
Assert.Equal("陈老师", savedClass.CounselorUser!.DisplayName);
|
||||||
Assert.Equal(4m, savedCourse.Credits);
|
Assert.Equal(4m, savedCourse.Credits);
|
||||||
Assert.Equal(savedCourse.TotalHours, savedCourse.LectureHours + savedCourse.PracticeHours);
|
Assert.Equal(savedCourse.TotalHours, savedCourse.LectureHours + savedCourse.PracticeHours);
|
||||||
Assert.Single(curriculumPlan.Modules);
|
Assert.Single(curriculumPlan.Modules);
|
||||||
|
|||||||
Vendored
+1
@@ -11,6 +11,7 @@ export {}
|
|||||||
/* prettier-ignore */
|
/* prettier-ignore */
|
||||||
declare module 'vue' {
|
declare module 'vue' {
|
||||||
export interface GlobalComponents {
|
export interface GlobalComponents {
|
||||||
|
ElAlert: typeof import('element-plus/es')['ElAlert']
|
||||||
ElButton: typeof import('element-plus/es')['ElButton']
|
ElButton: typeof import('element-plus/es')['ElButton']
|
||||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||||
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export interface CurrentUser {
|
|||||||
displayName: string
|
displayName: string
|
||||||
roles: string[]
|
roles: string[]
|
||||||
collegeId?: string
|
collegeId?: string
|
||||||
|
effectiveDataScope: 'Self' | 'Class' | 'College' | 'All'
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', () => {
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
|
|||||||
@@ -124,6 +124,13 @@ button { cursor: pointer; }
|
|||||||
.table-status::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
.table-status::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
||||||
.table-status.off { color: #a0a6b1; }
|
.table-status.off { color: #a0a6b1; }
|
||||||
.role-tag { margin: 2px 4px 2px 0; }
|
.role-tag { margin: 2px 4px 2px 0; }
|
||||||
|
.role-editor-summary {
|
||||||
|
display: flex; align-items: baseline; justify-content: space-between; gap: 16px;
|
||||||
|
margin: -4px 0 20px; padding: 14px 16px;
|
||||||
|
border-left: 3px solid var(--teal); background: #f5f7fb;
|
||||||
|
}
|
||||||
|
.role-editor-summary strong { color: var(--ink); font-size: 17px; }
|
||||||
|
.role-editor-summary span { color: var(--muted); font-size: 13px; }
|
||||||
.entity-form .el-select, .entity-form .el-date-editor { width: 100%; }
|
.entity-form .el-select, .entity-form .el-date-editor { width: 100%; }
|
||||||
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||||
.form-grid.compact { align-items: center; }
|
.form-grid.compact { align-items: center; }
|
||||||
@@ -332,6 +339,7 @@ button { cursor: pointer; }
|
|||||||
.schedule-search { flex-wrap: wrap; }
|
.schedule-search { flex-wrap: wrap; }
|
||||||
.schedule-search .el-input { width: 100%; }
|
.schedule-search .el-input { width: 100%; }
|
||||||
.schedule-search > span { width: 100%; margin-left: 0; }
|
.schedule-search > span { width: 100%; margin-left: 0; }
|
||||||
|
.role-editor-summary { align-items: flex-start; flex-direction: column; gap: 4px; }
|
||||||
.form-grid, .form-grid.three { grid-template-columns: 1fr; gap: 0; }
|
.form-grid, .form-grid.three { grid-template-columns: 1fr; gap: 0; }
|
||||||
.el-dialog { width: calc(100vw - 24px) !important; }
|
.el-dialog { width: calc(100vw - 24px) !important; }
|
||||||
.login-page { display: block; min-height: 100vh; background: #f4f6f9; }
|
.login-page { display: block; min-height: 100vh; background: #f4f6f9; }
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ const loading = ref(false)
|
|||||||
const dialogVisible = ref(false)
|
const dialogVisible = ref(false)
|
||||||
const editingId = ref('')
|
const editingId = ref('')
|
||||||
const keyword = ref('')
|
const keyword = ref('')
|
||||||
const references = reactive<Record<string, Row[]>>({
|
const references = reactive<Record<string, any[]>>({
|
||||||
campuses: [], colleges: [], majors: [], buildings: [],
|
campuses: [], colleges: [], majors: [], buildings: [], counselors: [],
|
||||||
})
|
})
|
||||||
const form = reactive<Record<string, any>>({})
|
const form = reactive<Record<string, any>>({})
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ function resetForm(row?: Row) {
|
|||||||
code: '', name: '', sortOrder: 0, isEnabled: true,
|
code: '', name: '', sortOrder: 0, isEnabled: true,
|
||||||
campusId: undefined, collegeId: undefined, majorId: undefined, buildingId: undefined,
|
campusId: undefined, collegeId: undefined, majorId: undefined, buildingId: undefined,
|
||||||
shortName: '', degreeType: '学士', schoolingYears: 4,
|
shortName: '', degreeType: '学士', schoolingYears: 4,
|
||||||
grade: new Date().getFullYear(), counselorName: '',
|
grade: new Date().getFullYear(), counselorUserId: undefined,
|
||||||
academicYear: `${new Date().getFullYear()}-${new Date().getFullYear() + 1}`,
|
academicYear: `${new Date().getFullYear()}-${new Date().getFullYear() + 1}`,
|
||||||
season: 'Autumn', startDate: '', endDate: '', isCurrent: false,
|
season: 'Autumn', startDate: '', endDate: '', isCurrent: false,
|
||||||
capacity: 60, roomType: '普通教室', equipment: '',
|
capacity: 60, roomType: '普通教室', equipment: '',
|
||||||
@@ -71,6 +71,9 @@ async function loadReferences() {
|
|||||||
await Promise.all(kinds.map(async (kind) => {
|
await Promise.all(kinds.map(async (kind) => {
|
||||||
references[kind] = (await http.get(`/base-data/${kind}`)).data
|
references[kind] = (await http.get(`/base-data/${kind}`)).data
|
||||||
}))
|
}))
|
||||||
|
if (canManage.value) {
|
||||||
|
references.counselors = (await http.get('/base-data/counselors')).data
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function changeTab() {
|
async function changeTab() {
|
||||||
@@ -168,6 +171,9 @@ onMounted(async () => {
|
|||||||
<el-table-column v-if="active === 'majors'" prop="degreeType" label="学位类型" min-width="120" />
|
<el-table-column v-if="active === 'majors'" prop="degreeType" label="学位类型" min-width="120" />
|
||||||
<el-table-column v-if="active === 'classes'" prop="majorName" label="所属专业" min-width="170" />
|
<el-table-column v-if="active === 'classes'" prop="majorName" label="所属专业" min-width="170" />
|
||||||
<el-table-column v-if="active === 'classes'" prop="grade" label="年级" width="90" />
|
<el-table-column v-if="active === 'classes'" prop="grade" label="年级" width="90" />
|
||||||
|
<el-table-column v-if="active === 'classes'" prop="counselorName" label="辅导员" min-width="120">
|
||||||
|
<template #default="{ row }">{{ row.counselorName || '未分配' }}</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column v-if="active === 'terms'" prop="academicYear" label="学年" width="120" />
|
<el-table-column v-if="active === 'terms'" prop="academicYear" label="学年" width="120" />
|
||||||
<el-table-column v-if="active === 'terms'" label="当前学期" width="100">
|
<el-table-column v-if="active === 'terms'" label="当前学期" width="100">
|
||||||
<template #default="{ row }"><el-tag v-if="row.isCurrent" type="success">当前</el-tag><span v-else>—</span></template>
|
<template #default="{ row }"><el-tag v-if="row.isCurrent" type="success">当前</el-tag><span v-else>—</span></template>
|
||||||
@@ -221,7 +227,16 @@ onMounted(async () => {
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
<div v-if="active === 'classes'" class="form-grid">
|
<div v-if="active === 'classes'" class="form-grid">
|
||||||
<el-form-item label="年级"><el-input-number v-model="form.grade" :min="2000" :max="2200" /></el-form-item>
|
<el-form-item label="年级"><el-input-number v-model="form.grade" :min="2000" :max="2200" /></el-form-item>
|
||||||
<el-form-item label="辅导员"><el-input v-model="form.counselorName" /></el-form-item>
|
<el-form-item label="辅导员账号">
|
||||||
|
<el-select v-model="form.counselorUserId" clearable filterable>
|
||||||
|
<el-option
|
||||||
|
v-for="item in references.counselors"
|
||||||
|
:key="item.id"
|
||||||
|
:label="`${item.displayName}${item.staffNumber ? ` · ${item.staffNumber}` : ''}`"
|
||||||
|
:value="item.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
</div>
|
</div>
|
||||||
<template v-if="active === 'terms'">
|
<template v-if="active === 'terms'">
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
|
|||||||
+108
-5
@@ -1,24 +1,53 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { Plus, Search } from '@element-plus/icons-vue'
|
import { EditPen, Plus, Search } from '@element-plus/icons-vue'
|
||||||
import http, { apiErrorMessage } from '../api/http'
|
import http, { apiErrorMessage } from '../api/http'
|
||||||
|
|
||||||
interface UserRow {
|
interface UserRow {
|
||||||
id: string; userName: string; displayName: string; staffNumber?: string
|
id: string; userName: string; displayName: string; staffNumber?: string
|
||||||
roles: string[]; isEnabled: boolean; lastLoginAt?: string
|
collegeId?: string; roles: string[]; isEnabled: boolean; lastLoginAt?: string
|
||||||
}
|
}
|
||||||
interface Role { name: string; description: string }
|
type DataScope = 'Self' | 'Class' | 'College' | 'All'
|
||||||
|
interface Role { name: string; description: string; dataScope: DataScope }
|
||||||
|
|
||||||
const users = ref<UserRow[]>([])
|
const users = ref<UserRow[]>([])
|
||||||
const roles = ref<Role[]>([])
|
const roles = ref<Role[]>([])
|
||||||
const colleges = ref<any[]>([])
|
const colleges = ref<any[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const dialogVisible = ref(false)
|
const dialogVisible = ref(false)
|
||||||
|
const roleDialogVisible = ref(false)
|
||||||
|
const editingUser = ref<UserRow | null>(null)
|
||||||
|
const editingRoles = ref<string[]>([])
|
||||||
|
const editingStaffNumber = ref('')
|
||||||
|
const editingCollegeId = ref<string>()
|
||||||
const keyword = ref('')
|
const keyword = ref('')
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
userName: '', displayName: '', password: '', staffNumber: '',
|
userName: '', displayName: '', password: '', staffNumber: '',
|
||||||
collegeId: undefined as string | undefined, roles: [] as string[],
|
collegeId: undefined as string | undefined, roles: [] as string[],
|
||||||
})
|
})
|
||||||
|
const scopePriority: Record<DataScope, number> = {
|
||||||
|
Self: 0,
|
||||||
|
Class: 1,
|
||||||
|
College: 2,
|
||||||
|
All: 3,
|
||||||
|
}
|
||||||
|
const scopeNames: Record<DataScope, string> = {
|
||||||
|
Self: '本人数据',
|
||||||
|
Class: '所带班级',
|
||||||
|
College: '所属学院',
|
||||||
|
All: '全校数据',
|
||||||
|
}
|
||||||
|
const editingScope = computed(() => {
|
||||||
|
const values = roles.value
|
||||||
|
.filter((role) => editingRoles.value.includes(role.name))
|
||||||
|
.map((role) => role.dataScope)
|
||||||
|
const effective = values.reduce<DataScope>(
|
||||||
|
(current, value) =>
|
||||||
|
scopePriority[value] > scopePriority[current] ? value : current,
|
||||||
|
'Self',
|
||||||
|
)
|
||||||
|
return scopeNames[effective]
|
||||||
|
})
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -63,6 +92,30 @@ async function setStatus(row: any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openRoleEditor(row: any) {
|
||||||
|
editingUser.value = row
|
||||||
|
editingRoles.value = [...row.roles]
|
||||||
|
editingStaffNumber.value = row.staffNumber ?? ''
|
||||||
|
editingCollegeId.value = row.collegeId
|
||||||
|
roleDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRoles() {
|
||||||
|
if (!editingUser.value) return
|
||||||
|
try {
|
||||||
|
await http.put(`/users/${editingUser.value.id}/roles`, {
|
||||||
|
staffNumber: editingStaffNumber.value,
|
||||||
|
collegeId: editingCollegeId.value,
|
||||||
|
roles: editingRoles.value,
|
||||||
|
})
|
||||||
|
ElMessage.success('角色与数据范围已更新')
|
||||||
|
roleDialogVisible.value = false
|
||||||
|
await load()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(apiErrorMessage(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(load)
|
onMounted(load)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -102,8 +155,11 @@ onMounted(load)
|
|||||||
<el-table-column label="状态" width="90">
|
<el-table-column label="状态" width="90">
|
||||||
<template #default="{ row }"><span class="table-status" :class="{ off: !row.isEnabled }">{{ row.isEnabled ? '启用' : '停用' }}</span></template>
|
<template #default="{ row }"><span class="table-status" :class="{ off: !row.isEnabled }">{{ row.isEnabled ? '启用' : '停用' }}</span></template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="100">
|
<el-table-column label="操作" width="175">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
|
<el-button link type="primary" :icon="EditPen" @click="openRoleEditor(row)">
|
||||||
|
调整角色
|
||||||
|
</el-button>
|
||||||
<el-button link :type="row.isEnabled ? 'danger' : 'primary'" @click="setStatus(row)">
|
<el-button link :type="row.isEnabled ? 'danger' : 'primary'" @click="setStatus(row)">
|
||||||
{{ row.isEnabled ? '停用' : '启用' }}
|
{{ row.isEnabled ? '停用' : '启用' }}
|
||||||
</el-button>
|
</el-button>
|
||||||
@@ -138,5 +194,52 @@ onMounted(load)
|
|||||||
<el-button type="primary" @click="create">创建账号</el-button>
|
<el-button type="primary" @click="create">创建账号</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="roleDialogVisible" title="调整角色与数据范围" width="540px">
|
||||||
|
<div v-if="editingUser" class="role-editor-summary">
|
||||||
|
<strong>{{ editingUser.displayName }}</strong>
|
||||||
|
<span>{{ editingUser.userName }} · {{ editingUser.staffNumber || '未关联工号/学号' }}</span>
|
||||||
|
</div>
|
||||||
|
<el-form label-position="top" class="entity-form">
|
||||||
|
<div class="form-grid">
|
||||||
|
<el-form-item label="工号/学号">
|
||||||
|
<el-input v-model="editingStaffNumber" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="所属学院">
|
||||||
|
<el-select v-model="editingCollegeId" clearable>
|
||||||
|
<el-option
|
||||||
|
v-for="item in colleges"
|
||||||
|
:key="item.id"
|
||||||
|
:label="item.name"
|
||||||
|
:value="item.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
<el-form-item label="角色" required>
|
||||||
|
<el-select v-model="editingRoles" multiple>
|
||||||
|
<el-option
|
||||||
|
v-for="role in roles"
|
||||||
|
:key="role.name"
|
||||||
|
:label="`${role.name} · ${role.description}`"
|
||||||
|
:value="role.name"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-alert
|
||||||
|
:closable="false"
|
||||||
|
type="info"
|
||||||
|
show-icon
|
||||||
|
:title="`保存后有效范围:${editingScope}`"
|
||||||
|
description="多角色账号自动采用最高数据范围。教师或学生角色要求账号的工号/学号能匹配对应档案。"
|
||||||
|
/>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="roleDialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :disabled="!editingRoles.length" @click="saveRoles">
|
||||||
|
保存角色
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user