学籍异动:休学、复学、退学申请及辅导员→学院→教务处分级审批。

毕业审核:批次计算、缺失课程检查、人工复核、结果发布。
学位授予:毕业资格与 GPA 计算、人工调整、发布授予结果。
毕业离校:离校事项配置、责任角色分工、逐项办理及批次关闭。
This commit is contained in:
2026-07-24 17:30:05 +08:00 Unverified
parent 7493ab4a60
commit b0eaa20da6
34 changed files with 11137 additions and 8 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
面向普通高校的教务管理系统。后端使用 ASP.NET Core 10、EF Core 10,前端使用 Vue 3、TypeScript 和 Element Plus。
当前已实现系统登录与角色权限、基础数据、用户管理、教师档案、学生档案、课程库、培养方案、教学任务、排课课表、学生选课、成绩管理、考试考场和首页统计。人员及课程列表支持组合筛选、服务端分页和完整增删改查;培养方案支持课程模块、专业年级版本、复制新版本、发布锁定和旧版本归档;教学任务支持学期课程开设、多教师、合班、容量校验、发布与结课;排课支持单双周、周次节次、教室容量、教师/行政班/教室冲突校验和版本化发布;选课支持批次时间窗、投放范围、容量与学分上限、重复课程与课表冲突校验、退课截止时间和实时教学班名单;成绩管理支持分项比例、批量录入、特殊考试状态、自动总评与绩点、教师提交、学院审核、校级发布和学生成绩单;考试管理支持考试计划、场次、考场容量、监考教师、考生名单以及考场/监考/学生时间冲突校验。
当前已实现系统登录与角色权限、基础数据、用户管理、教师档案、学生档案、课程库、培养方案、教学任务、排课课表、学生选课、成绩管理、考试考场、学籍异动、毕业审核、学位授予、毕业离校和首页统计。人员及课程列表支持组合筛选、服务端分页和完整增删改查;培养方案支持课程模块、专业年级版本、复制新版本、发布锁定和旧版本归档;教学任务支持学期课程开设、多教师、合班、容量校验、发布与结课;排课支持单双周、周次节次、教室容量、教师/行政班/教室冲突校验和版本化发布;选课支持批次时间窗、投放范围、容量与学分上限、重复课程与课表冲突校验、退课截止时间和实时教学班名单;成绩管理支持分项比例、批量录入、特殊考试状态、自动总评与绩点、教师提交、学院审核、校级发布和学生成绩单;考试管理支持考试计划、场次、考场容量、监考教师、考生名单以及考场/监考/学生时间冲突校验;学籍异动支持休学、复学、退学申请,辅导员、学院、学校三级顺序审核,学生撤回,以及最终审批后自动同步学籍状态;毕业审核按入学年级匹配已发布培养方案,以正式成绩计算总学分、必修通过和未解决不及格课程,支持学院范围查看、人工复核、校级锁定发布和学生结果查询;学位授予以已发布毕业资格为来源,按正式成绩加权平均绩点生成规则结论,支持学院人工复核、校级发布锁定和学生结果查询;毕业离校支持自定义事项与责任部门,按校级、学院、辅导员角色分工办理,强制数据范围校验,学生进度查询,以及必办事项全部完成后的批次锁定
权限采用后端强制校验的角色与数据范围模型。多角色账号按 `All > College > Class > Self` 取最高数据范围:校级角色可访问全校数据,院系管理员限定本学院,辅导员通过稳定的账号 ID 绑定所带行政班,教师和学生限定本人及当前教学关系;前端菜单和路由限制仅作为交互辅助,不替代 API 授权。
+418
View File
@@ -328,6 +328,416 @@ try {
throw 'Personal exam schedule is not visible to student or invigilator.'
}
$statusOptions = Invoke-RestMethod `
-Uri 'http://localhost:5255/api/student-status-changes/options' `
-Headers $studentHeaders
$statusChanges = Invoke-RestMethod `
-Uri 'http://localhost:5255/api/student-status-changes' `
-Headers $studentHeaders
$openStatusChange = @($statusChanges) |
Where-Object { $_.state -in @('Submitted', 'CounselorApproved', 'CollegeApproved') } |
Select-Object -First 1
if ($null -eq $openStatusChange) {
$statusChangeType = if ($statusOptions.status -eq 'Active') {
'Suspension'
}
elseif ($statusOptions.status -eq 'Suspended') {
'Resumption'
}
else {
throw "Smoke-test student status '$($statusOptions.status)' cannot start a reversible change."
}
$statusChangeBody = @{
type = $statusChangeType
reason = '端到端验证学籍异动三级审核流程。'
} | ConvertTo-Json
$createdStatusChange = Invoke-RestMethod `
-Method Post `
-Uri 'http://localhost:5255/api/student-status-changes' `
-Headers $studentHeaders `
-ContentType 'application/json' `
-Body $statusChangeBody
$statusChanges = Invoke-RestMethod `
-Uri 'http://localhost:5255/api/student-status-changes' `
-Headers $studentHeaders
$openStatusChange = @($statusChanges) |
Where-Object { $_.id -eq $createdStatusChange.id } |
Select-Object -First 1
}
$expectedStudentStatus = $openStatusChange.targetStatus
$approvalBody = @{ approved = $true; comment = '端到端审核通过。' } | ConvertTo-Json
if ($openStatusChange.state -eq 'Submitted') {
$prematureCollegeReview = Invoke-WebRequest `
-Method Post `
-Uri "http://localhost:5255/api/student-status-changes/$($openStatusChange.id)/review" `
-Headers $collegeHeaders `
-ContentType 'application/json' `
-Body $approvalBody `
-SkipHttpErrorCheck
if ($prematureCollegeReview.StatusCode -ne 409) {
throw 'College reviewer was able to bypass the counselor stage.'
}
$counselorLoginBody = @{
userName = 'counselor'
password = 'Counselor@123456'
} | ConvertTo-Json
$counselorLogin = Invoke-RestMethod `
-Method Post `
-Uri 'http://localhost:5255/api/auth/login' `
-ContentType 'application/json' `
-Body $counselorLoginBody
$counselorHeaders = @{ Authorization = "Bearer $($counselorLogin.token)" }
Invoke-RestMethod `
-Method Post `
-Uri "http://localhost:5255/api/student-status-changes/$($openStatusChange.id)/review" `
-Headers $counselorHeaders `
-ContentType 'application/json' `
-Body $approvalBody |
Out-Null
$openStatusChange.state = 'CounselorApproved'
}
if ($openStatusChange.state -eq 'CounselorApproved') {
Invoke-RestMethod `
-Method Post `
-Uri "http://localhost:5255/api/student-status-changes/$($openStatusChange.id)/review" `
-Headers $collegeHeaders `
-ContentType 'application/json' `
-Body $approvalBody |
Out-Null
$openStatusChange.state = 'CollegeApproved'
}
if ($openStatusChange.state -eq 'CollegeApproved') {
Invoke-RestMethod `
-Method Post `
-Uri "http://localhost:5255/api/student-status-changes/$($openStatusChange.id)/review" `
-Headers $headers `
-ContentType 'application/json' `
-Body $approvalBody |
Out-Null
}
$completedStatusChanges = Invoke-RestMethod `
-Uri 'http://localhost:5255/api/student-status-changes' `
-Headers $studentHeaders
$completedStatusChange = @($completedStatusChanges) |
Where-Object { $_.id -eq $openStatusChange.id } |
Select-Object -First 1
$updatedStatusOptions = Invoke-RestMethod `
-Uri 'http://localhost:5255/api/student-status-changes/options' `
-Headers $studentHeaders
if ($completedStatusChange.state -ne 'Approved' -or
$updatedStatusOptions.status -ne $expectedStudentStatus) {
throw 'Student status change was not finalized or did not update the student record.'
}
$graduationBatches = Invoke-RestMethod `
-Uri 'http://localhost:5255/api/graduation-audits/batches' `
-Headers $headers
$graduationBatch = @($graduationBatches) |
Where-Object { $_.name -eq '2030届端到端毕业资格审核' } |
Select-Object -First 1
if ($null -eq $graduationBatch) {
$graduationBatchBody = @{
name = '2030届端到端毕业资格审核'
graduationYear = 2030
enrollmentYear = 2026
notes = '用于验证培养方案、成绩快照、人工复核与结果发布。'
} | ConvertTo-Json
$createdGraduationBatch = Invoke-RestMethod `
-Method Post `
-Uri 'http://localhost:5255/api/graduation-audits/batches' `
-Headers $headers `
-ContentType 'application/json' `
-Body $graduationBatchBody
Invoke-RestMethod `
-Method Post `
-Uri "http://localhost:5255/api/graduation-audits/batches/$($createdGraduationBatch.id)/calculate" `
-Headers $headers |
Out-Null
$graduationBatch = Invoke-RestMethod `
-Uri "http://localhost:5255/api/graduation-audits/batches/$($createdGraduationBatch.id)" `
-Headers $headers
$studentGraduationResult = @($graduationBatch.results) |
Where-Object { $_.studentNumber -eq '202601001' } |
Select-Object -First 1
if ($null -eq $studentGraduationResult) {
throw 'Calculated graduation batch does not contain the smoke-test student.'
}
$overrideDecisionBody = @{
conclusion = 'Eligible'
comment = '端到端验证人工复核调整。'
} | ConvertTo-Json
Invoke-RestMethod `
-Method Put `
-Uri "http://localhost:5255/api/graduation-audits/results/$($studentGraduationResult.id)" `
-Headers $headers `
-ContentType 'application/json' `
-Body $overrideDecisionBody |
Out-Null
$restoreDecisionBody = @{
conclusion = 'Ineligible'
comment = '恢复规则结论后发布测试结果。'
} | ConvertTo-Json
Invoke-RestMethod `
-Method Put `
-Uri "http://localhost:5255/api/graduation-audits/results/$($studentGraduationResult.id)" `
-Headers $headers `
-ContentType 'application/json' `
-Body $restoreDecisionBody |
Out-Null
Invoke-RestMethod `
-Method Post `
-Uri "http://localhost:5255/api/graduation-audits/batches/$($graduationBatch.id)/publish" `
-Headers $headers |
Out-Null
}
else {
$graduationBatch = Invoke-RestMethod `
-Uri "http://localhost:5255/api/graduation-audits/batches/$($graduationBatch.id)" `
-Headers $headers
}
$graduationBatch = Invoke-RestMethod `
-Uri "http://localhost:5255/api/graduation-audits/batches/$($graduationBatch.id)" `
-Headers $headers
$studentGraduationResult = Invoke-RestMethod `
-Uri 'http://localhost:5255/api/graduation-audits/my-result' `
-Headers $studentHeaders
if ($graduationBatch.status -ne 'Published' -or
$studentGraduationResult.conclusion -ne 'Ineligible') {
throw 'Published graduation result is not visible to the student.'
}
$graduationBatches = Invoke-RestMethod `
-Uri 'http://localhost:5255/api/graduation-audits/batches' `
-Headers $headers
$degreeSourceBatch = @($graduationBatches) |
Where-Object { $_.name -eq '2030届学位授予资格演示' } |
Select-Object -First 1
if ($null -eq $degreeSourceBatch) {
$degreeSourceBody = @{
name = '2030届学位授予资格演示'
graduationYear = 2030
enrollmentYear = 2026
notes = '为未关联登录账号的演示学生生成学位授予前置资格。'
} | ConvertTo-Json
$createdDegreeSource = Invoke-RestMethod `
-Method Post `
-Uri 'http://localhost:5255/api/graduation-audits/batches' `
-Headers $headers `
-ContentType 'application/json' `
-Body $degreeSourceBody
Invoke-RestMethod `
-Method Post `
-Uri "http://localhost:5255/api/graduation-audits/batches/$($createdDegreeSource.id)/calculate" `
-Headers $headers |
Out-Null
$degreeSourceDetail = Invoke-RestMethod `
-Uri "http://localhost:5255/api/graduation-audits/batches/$($createdDegreeSource.id)" `
-Headers $headers
$degreeSourceResult = @($degreeSourceDetail.results) |
Where-Object { $_.studentNumber -eq '202601002' } |
Select-Object -First 1
$degreeSourceDecision = @{
conclusion = 'Eligible'
comment = '演示学生经校级专项复核符合毕业条件。'
} | ConvertTo-Json
Invoke-RestMethod `
-Method Put `
-Uri "http://localhost:5255/api/graduation-audits/results/$($degreeSourceResult.id)" `
-Headers $headers `
-ContentType 'application/json' `
-Body $degreeSourceDecision |
Out-Null
Invoke-RestMethod `
-Method Post `
-Uri "http://localhost:5255/api/graduation-audits/batches/$($createdDegreeSource.id)/publish" `
-Headers $headers |
Out-Null
}
$degreeBatches = Invoke-RestMethod `
-Uri 'http://localhost:5255/api/degree-awards/batches' `
-Headers $headers
$degreeBatch = @($degreeBatches) |
Where-Object { $_.name -eq '2030届端到端学位授予审核' } |
Select-Object -First 1
if ($null -eq $degreeBatch) {
$degreeBatchBody = @{
name = '2030届端到端学位授予审核'
graduationYear = 2030
degreeName = '工学学士'
minimumGradePoint = 0
notes = '验证毕业资格来源、绩点快照、人工复核与发布锁定。'
} | ConvertTo-Json
$createdDegreeBatch = Invoke-RestMethod `
-Method Post `
-Uri 'http://localhost:5255/api/degree-awards/batches' `
-Headers $headers `
-ContentType 'application/json' `
-Body $degreeBatchBody
Invoke-RestMethod `
-Method Post `
-Uri "http://localhost:5255/api/degree-awards/batches/$($createdDegreeBatch.id)/calculate" `
-Headers $headers |
Out-Null
$degreeBatch = Invoke-RestMethod `
-Uri "http://localhost:5255/api/degree-awards/batches/$($createdDegreeBatch.id)" `
-Headers $headers
$degreeResult = @($degreeBatch.results) | Select-Object -First 1
if ($null -eq $degreeResult -or $degreeResult.conclusion -ne 'Granted') {
throw 'Degree-award calculation did not include the eligible graduated student.'
}
$degreeRejectBody = @{
conclusion = 'NotGranted'
comment = '端到端验证学位结论人工调整。'
} | ConvertTo-Json
Invoke-RestMethod `
-Method Put `
-Uri "http://localhost:5255/api/degree-awards/results/$($degreeResult.id)" `
-Headers $collegeHeaders `
-ContentType 'application/json' `
-Body $degreeRejectBody |
Out-Null
$degreeRestoreBody = @{
conclusion = 'Granted'
comment = '恢复规则结论并完成授予发布。'
} | ConvertTo-Json
Invoke-RestMethod `
-Method Put `
-Uri "http://localhost:5255/api/degree-awards/results/$($degreeResult.id)" `
-Headers $headers `
-ContentType 'application/json' `
-Body $degreeRestoreBody |
Out-Null
Invoke-RestMethod `
-Method Post `
-Uri "http://localhost:5255/api/degree-awards/batches/$($degreeBatch.id)/publish" `
-Headers $headers |
Out-Null
}
$degreeBatch = Invoke-RestMethod `
-Uri "http://localhost:5255/api/degree-awards/batches/$($degreeBatch.id)" `
-Headers $headers
$collegeDegreeBatch = Invoke-RestMethod `
-Uri "http://localhost:5255/api/degree-awards/batches/$($degreeBatch.id)" `
-Headers $collegeHeaders
if ($degreeBatch.status -ne 'Published' -or
@($degreeBatch.results).Count -lt 1 -or
@($collegeDegreeBatch.results).Count -lt 1) {
throw 'Published degree-award results are not visible in the authorized college scope.'
}
$clearanceBatches = Invoke-RestMethod `
-Uri 'http://localhost:5255/api/graduation-clearance/batches' `
-Headers $headers
$clearanceBatch = @($clearanceBatches) |
Where-Object { $_.name -eq '2030届端到端毕业离校手续' } |
Select-Object -First 1
if ($null -eq $clearanceBatch) {
$clearanceBatchBody = @{
name = '2030届端到端毕业离校手续'
graduationYear = 2030
notes = '验证校级、学院、辅导员分工办理与批次关闭。'
items = @(
@{
code = 'FINANCE'
name = '财务费用结清'
responsibleUnit = '财务处'
responsibleRole = 'AcademicAdmin'
isRequired = $true
},
@{
code = 'COLLEGE'
name = '学院材料归档'
responsibleUnit = '计算机学院'
responsibleRole = 'CollegeAdmin'
isRequired = $true
},
@{
code = 'DORM'
name = '宿舍退宿确认'
responsibleUnit = '学生工作办公室'
responsibleRole = 'Counselor'
isRequired = $true
}
)
} | ConvertTo-Json -Depth 5
$createdClearanceBatch = Invoke-RestMethod `
-Method Post `
-Uri 'http://localhost:5255/api/graduation-clearance/batches' `
-Headers $headers `
-ContentType 'application/json' `
-Body $clearanceBatchBody
Invoke-RestMethod `
-Method Post `
-Uri "http://localhost:5255/api/graduation-clearance/batches/$($createdClearanceBatch.id)/generate" `
-Headers $headers |
Out-Null
$clearanceBatch = Invoke-RestMethod `
-Uri "http://localhost:5255/api/graduation-clearance/batches/$($createdClearanceBatch.id)" `
-Headers $headers
if (@($clearanceBatch.records).Count -ne 3) {
throw 'Graduation-clearance records were not generated for each configured item.'
}
$academicClearanceRecord = @($clearanceBatch.records) |
Where-Object { $_.responsibleRole -eq 'AcademicAdmin' } |
Select-Object -First 1
$wrongRoleResponse = Invoke-WebRequest `
-Method Put `
-Uri "http://localhost:5255/api/graduation-clearance/records/$($academicClearanceRecord.id)" `
-Headers $collegeHeaders `
-ContentType 'application/json' `
-Body (@{ status = 'Completed'; notes = '越权测试。' } | ConvertTo-Json) `
-SkipHttpErrorCheck
if ($wrongRoleResponse.StatusCode -ne 403) {
throw 'College role was able to complete an academic-level clearance item.'
}
$counselorLoginBody = @{
userName = 'counselor'
password = 'Counselor@123456'
} | ConvertTo-Json
$counselorLogin = Invoke-RestMethod `
-Method Post `
-Uri 'http://localhost:5255/api/auth/login' `
-ContentType 'application/json' `
-Body $counselorLoginBody
$counselorHeaders = @{ Authorization = "Bearer $($counselorLogin.token)" }
foreach ($record in @($clearanceBatch.records)) {
$recordHeaders = switch ($record.responsibleRole) {
'CollegeAdmin' { $collegeHeaders }
'Counselor' { $counselorHeaders }
default { $headers }
}
$recordBody = @{
status = 'Completed'
notes = "$($record.responsibleUnit)端到端办理完成。"
} | ConvertTo-Json
Invoke-RestMethod `
-Method Put `
-Uri "http://localhost:5255/api/graduation-clearance/records/$($record.id)" `
-Headers $recordHeaders `
-ContentType 'application/json' `
-Body $recordBody |
Out-Null
}
Invoke-RestMethod `
-Method Post `
-Uri "http://localhost:5255/api/graduation-clearance/batches/$($clearanceBatch.id)/close" `
-Headers $headers |
Out-Null
}
$clearanceBatch = Invoke-RestMethod `
-Uri "http://localhost:5255/api/graduation-clearance/batches/$($clearanceBatch.id)" `
-Headers $headers
$collegeClearanceBatch = Invoke-RestMethod `
-Uri "http://localhost:5255/api/graduation-clearance/batches/$($clearanceBatch.id)" `
-Headers $collegeHeaders
if ($clearanceBatch.status -ne 'Closed' -or
@($clearanceBatch.records | Where-Object { $_.status -eq 'Pending' }).Count -ne 0 -or
@($collegeClearanceBatch.records).Count -lt 1) {
throw 'Graduation-clearance batch was not closed or is missing from college scope.'
}
$frontend = Invoke-WebRequest -Uri 'http://localhost:5255/' -TimeoutSec 5
$spaFallback = Invoke-WebRequest -Uri 'http://localhost:5255/base-data' -TimeoutSec 5
$unknownApiParameters = @{
@@ -361,6 +771,14 @@ try {
TranscriptRecords = @($studentTranscript.records).Count
ExamSessions = @($examDetail.sessions).Count
ExamRoster = @($examRoster.students).Count
StatusChanges = @($completedStatusChanges).Count
StudentStatus = $updatedStatusOptions.status
GraduationResults = @($graduationBatch.results).Count
GraduationConclusion = $studentGraduationResult.conclusion
DegreeResults = @($degreeBatch.results).Count
DegreeConclusion = $degreeBatch.results[0].conclusion
ClearanceRecords = @($clearanceBatch.records).Count
ClearanceStatus = $clearanceBatch.status
AccessUpdate = $true
ScopeChecks = $scopeChecks -join ', '
StaticIndex = $frontend.Content.Contains('明序教务管理系统')
@@ -51,6 +51,31 @@ public sealed class DashboardController(AppDbContext db) : ControllerBase
GradeRecords = await db.GradeRecords.CountAsync(cancellationToken),
ExamPlans = await db.ExamPlans.CountAsync(cancellationToken),
ExamSessions = await db.ExamSessions.CountAsync(cancellationToken),
StudentStatusChanges = await db.StudentStatusChanges
.CountAsync(cancellationToken),
PendingStudentStatusChanges = await db.StudentStatusChanges.CountAsync(
x => x.State == StudentStatusChangeState.Submitted ||
x.State == StudentStatusChangeState.CounselorApproved ||
x.State == StudentStatusChangeState.CollegeApproved,
cancellationToken),
GraduationAuditBatches = await db.GraduationAuditBatches
.CountAsync(cancellationToken),
PublishedGraduationAuditBatches = await db.GraduationAuditBatches
.CountAsync(
x => x.Status == GraduationAuditBatchStatus.Published,
cancellationToken),
DegreeAwardBatches = await db.DegreeAwardBatches
.CountAsync(cancellationToken),
PublishedDegreeAwardBatches = await db.DegreeAwardBatches
.CountAsync(
x => x.Status == DegreeAwardBatchStatus.Published,
cancellationToken),
GraduationClearanceBatches = await db.GraduationClearanceBatches
.CountAsync(cancellationToken),
OpenGraduationClearanceBatches = await db.GraduationClearanceBatches
.CountAsync(
x => x.Status == GraduationClearanceBatchStatus.Open,
cancellationToken),
Users = await db.Users.CountAsync(cancellationToken)
}
};
@@ -0,0 +1,278 @@
using System.ComponentModel.DataAnnotations;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Graduation;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/degree-awards")]
public sealed class DegreeAwardsController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope) : ControllerBase
{
private const string Managers =
SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;
private const string Reviewers = Managers + "," + SystemRoles.CollegeAdmin;
[HttpGet("batches")]
[Authorize(Roles = Reviewers)]
public async Task<ActionResult> GetBatches(CancellationToken token)
{
var collegeId = RestrictedCollegeId();
return Ok(await db.DegreeAwardBatches.AsNoTracking()
.OrderByDescending(x => x.GraduationYear)
.ThenByDescending(x => x.CreatedAt)
.Select(x => new
{
x.Id, x.Name, x.GraduationYear, x.DegreeName,
x.MinimumGradePoint, x.Status, x.Notes,
x.CalculatedAt, x.PublishedAt,
ResultCount = x.Results.Count(result =>
!collegeId.HasValue ||
result.Student!.AdministrativeClass!.Major!.CollegeId == collegeId),
GrantedCount = x.Results.Count(result =>
(!collegeId.HasValue ||
result.Student!.AdministrativeClass!.Major!.CollegeId == collegeId) &&
result.Conclusion == DegreeAwardConclusion.Granted),
NotGrantedCount = x.Results.Count(result =>
(!collegeId.HasValue ||
result.Student!.AdministrativeClass!.Major!.CollegeId == collegeId) &&
result.Conclusion == DegreeAwardConclusion.NotGranted),
OverrideCount = x.Results.Count(result =>
(!collegeId.HasValue ||
result.Student!.AdministrativeClass!.Major!.CollegeId == collegeId) &&
result.IsOverridden)
}).ToListAsync(token));
}
[HttpGet("batches/{id:guid}")]
[Authorize(Roles = Reviewers)]
public async Task<ActionResult> GetBatch(Guid id, CancellationToken token)
{
var batch = await db.DegreeAwardBatches.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == id, token);
if (batch is null) return NotFound();
var collegeId = RestrictedCollegeId();
var source = db.DegreeAwardResults.AsNoTracking()
.Where(x => x.DegreeAwardBatchId == id);
if (collegeId.HasValue)
source = source.Where(x =>
x.Student!.AdministrativeClass!.Major!.CollegeId == collegeId);
return Ok(new
{
batch.Id, batch.Name, batch.GraduationYear, batch.DegreeName,
batch.MinimumGradePoint, batch.Status, batch.Notes,
batch.CalculatedAt, batch.PublishedAt,
Results = await source.OrderBy(x => x.Student!.StudentNumber)
.Select(x => new
{
x.Id, x.StudentId, x.Student!.StudentNumber, x.Student.Name,
ClassName = x.Student.AdministrativeClass!.Name,
MajorName = x.Student.AdministrativeClass.Major!.Name,
CollegeName = x.Student.AdministrativeClass.Major.College!.Name,
x.AverageGradePoint, x.CalculatedConclusion, x.Conclusion,
x.ExceptionReason, x.IsOverridden,
x.ReviewComment, x.ReviewedAt
}).ToListAsync(token)
});
}
[HttpPost("batches")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> Create(
DegreeAwardBatchRequest request,
CancellationToken token)
{
var batch = new DegreeAwardBatch
{
Name = request.Name.Trim(),
GraduationYear = request.GraduationYear,
DegreeName = request.DegreeName.Trim(),
MinimumGradePoint = request.MinimumGradePoint,
Notes = request.Notes?.Trim()
};
db.DegreeAwardBatches.Add(batch);
await db.SaveChangesAsync(token);
return Created(string.Empty, new { batch.Id });
}
[HttpPost("batches/{id:guid}/calculate")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> Calculate(Guid id, CancellationToken token)
{
var batch = await db.DegreeAwardBatches
.FirstOrDefaultAsync(x => x.Id == id, token);
if (batch is null) return NotFound();
if (batch.Status != DegreeAwardBatchStatus.Draft)
return ConflictProblem("已发布批次不能重新计算。");
var auditCandidates = await db.GraduationAuditResults.AsNoTracking()
.Include(x => x.GraduationAuditBatch)
.Include(x => x.Student)
.ThenInclude(x => x!.AdministrativeClass)
.ThenInclude(x => x!.Major)
.Where(x =>
x.GraduationAuditBatch!.GraduationYear == batch.GraduationYear &&
x.GraduationAuditBatch.Status == GraduationAuditBatchStatus.Published &&
x.Conclusion == GraduationAuditConclusion.Eligible)
.OrderByDescending(x => x.GraduationAuditBatch!.PublishedAt)
.ToListAsync(token);
var audits = auditCandidates
.GroupBy(x => x.StudentId)
.Select(x => x.First())
.ToList();
var studentIds = audits.Select(x => x.StudentId).ToArray();
var gradePoints = await db.GradeRecords.AsNoTracking()
.Where(x => studentIds.Contains(x.StudentId) &&
x.GradeSheet!.Status == GradeSheetStatus.Published &&
x.GradePoint.HasValue)
.Select(x => new
{
x.StudentId,
GradePoint = x.GradePoint!.Value,
x.GradeSheet!.TeachingTask!.Course!.Credits
}).ToListAsync(token);
var oldResults = await db.DegreeAwardResults
.Where(x => x.DegreeAwardBatchId == id).ToListAsync(token);
db.DegreeAwardResults.RemoveRange(oldResults);
await db.SaveChangesAsync(token);
foreach (var audit in audits)
{
var grades = gradePoints.Where(x => x.StudentId == audit.StudentId).ToList();
var credits = grades.Sum(x => x.Credits);
var average = credits == 0
? 0
: Math.Round(
grades.Sum(x => x.GradePoint * x.Credits) / credits,
2,
MidpointRounding.AwayFromZero);
var conclusion = DegreeAwardRules.Evaluate(
true, audit.Student!.Status, average, batch.MinimumGradePoint);
var reason = audit.Student.Status != StudentStatus.Graduated
? "学籍状态尚未转为毕业"
: average < batch.MinimumGradePoint
? $"平均绩点 {average:0.00},低于批次要求 {batch.MinimumGradePoint:0.00}"
: string.Empty;
db.DegreeAwardResults.Add(new DegreeAwardResult
{
DegreeAwardBatchId = batch.Id,
StudentId = audit.StudentId,
GraduationAuditResultId = audit.Id,
AverageGradePoint = average,
CalculatedConclusion = conclusion,
Conclusion = conclusion,
ExceptionReason = reason
});
}
batch.CalculatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(token);
return Ok(new { ResultCount = audits.Count });
}
[HttpPut("results/{id:guid}")]
[Authorize(Roles = Reviewers)]
public async Task<ActionResult> Review(
Guid id,
DegreeAwardDecisionRequest request,
CancellationToken token)
{
var result = await db.DegreeAwardResults
.Include(x => x.DegreeAwardBatch)
.Include(x => x.Student)
.ThenInclude(x => x!.AdministrativeClass)
.ThenInclude(x => x!.Major)
.FirstOrDefaultAsync(x => x.Id == id, token);
if (result is null) return NotFound();
if (result.DegreeAwardBatch!.Status != DegreeAwardBatchStatus.Draft)
return ConflictProblem("已发布的授予结果不能修改。");
var collegeId = RestrictedCollegeId();
if (collegeId.HasValue &&
result.Student!.AdministrativeClass!.Major!.CollegeId != collegeId)
return Forbid();
result.Conclusion = request.Conclusion;
result.IsOverridden = request.Conclusion != result.CalculatedConclusion;
result.ReviewComment = request.Comment.Trim();
result.ReviewedAt = DateTime.UtcNow;
await db.SaveChangesAsync(token);
return NoContent();
}
[HttpPost("batches/{id:guid}/publish")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> Publish(Guid id, CancellationToken token)
{
var batch = await db.DegreeAwardBatches.Include(x => x.Results)
.FirstOrDefaultAsync(x => x.Id == id, token);
if (batch is null) return NotFound();
if (batch.Status != DegreeAwardBatchStatus.Draft)
return ConflictProblem("该批次已经发布。");
if (batch.Results.Count == 0)
return ConflictProblem("没有可发布的学位授予审核结果。");
batch.Status = DegreeAwardBatchStatus.Published;
batch.PublishedAt = DateTime.UtcNow;
await db.SaveChangesAsync(token);
return NoContent();
}
[HttpGet("my-result")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> GetMyResult(CancellationToken token)
{
var userId = currentUserDataScope.Current.UserId;
var result = await db.DegreeAwardResults.AsNoTracking()
.Where(x => x.Student!.UserId == userId &&
x.DegreeAwardBatch!.Status == DegreeAwardBatchStatus.Published)
.OrderByDescending(x => x.DegreeAwardBatch!.GraduationYear)
.ThenByDescending(x => x.DegreeAwardBatch!.PublishedAt)
.Select(x => new
{
x.Id,
BatchName = x.DegreeAwardBatch!.Name,
x.DegreeAwardBatch.GraduationYear,
x.DegreeAwardBatch.DegreeName,
x.DegreeAwardBatch.MinimumGradePoint,
x.Student!.StudentNumber, x.Student.Name,
MajorName = x.Student.AdministrativeClass!.Major!.Name,
x.AverageGradePoint, x.Conclusion, x.ExceptionReason,
x.IsOverridden, x.ReviewComment,
x.DegreeAwardBatch.PublishedAt
}).FirstOrDefaultAsync(token);
return Ok(result);
}
private Guid? RestrictedCollegeId()
{
var scope = currentUserDataScope.Current;
return scope.IsInRole(SystemRoles.CollegeAdmin)
? scope.CollegeId ?? Guid.Empty
: null;
}
private ActionResult ConflictProblem(string detail) => Conflict(new ProblemDetails
{
Title = "无法完成学位授予操作",
Detail = detail,
Status = StatusCodes.Status409Conflict
});
}
public sealed record DegreeAwardBatchRequest(
[Required, MinLength(3), MaxLength(120)] string Name,
[Range(2000, 2200)] int GraduationYear,
[Required, MinLength(2), MaxLength(80)] string DegreeName,
[Range(typeof(decimal), "0", "5")] decimal MinimumGradePoint,
[MaxLength(500)] string? Notes);
public sealed record DegreeAwardDecisionRequest(
DegreeAwardConclusion Conclusion,
[Required, MinLength(5), MaxLength(500)] string Comment);
@@ -0,0 +1,332 @@
using System.ComponentModel.DataAnnotations;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Graduation;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/graduation-audits")]
public sealed class GraduationAuditsController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope) : ControllerBase
{
private const string Managers =
SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;
private const string Reviewers =
Managers + "," + SystemRoles.CollegeAdmin;
[HttpGet("batches")]
[Authorize(Roles = Reviewers)]
public async Task<ActionResult> GetBatches(CancellationToken token)
{
var collegeId = RestrictedCollegeId();
return Ok(await db.GraduationAuditBatches.AsNoTracking()
.OrderByDescending(x => x.GraduationYear)
.ThenByDescending(x => x.CreatedAt)
.Select(x => new
{
x.Id, x.Name, x.GraduationYear, x.EnrollmentYear, x.Status,
x.Notes, x.CalculatedAt, x.PublishedAt, x.CreatedAt,
ResultCount = x.Results.Count(result =>
!collegeId.HasValue ||
result.Student!.AdministrativeClass!.Major!.CollegeId == collegeId),
EligibleCount = x.Results.Count(result =>
(!collegeId.HasValue ||
result.Student!.AdministrativeClass!.Major!.CollegeId == collegeId) &&
result.Conclusion == GraduationAuditConclusion.Eligible),
IneligibleCount = x.Results.Count(result =>
(!collegeId.HasValue ||
result.Student!.AdministrativeClass!.Major!.CollegeId == collegeId) &&
result.Conclusion == GraduationAuditConclusion.Ineligible),
OverrideCount = x.Results.Count(result =>
(!collegeId.HasValue ||
result.Student!.AdministrativeClass!.Major!.CollegeId == collegeId) &&
result.IsOverridden)
}).ToListAsync(token));
}
[HttpGet("batches/{id:guid}")]
[Authorize(Roles = Reviewers)]
public async Task<ActionResult> GetBatch(Guid id, CancellationToken token)
{
var batch = await db.GraduationAuditBatches.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == id, token);
if (batch is null) return NotFound();
var collegeId = RestrictedCollegeId();
var results = db.GraduationAuditResults.AsNoTracking()
.Where(x => x.GraduationAuditBatchId == id);
if (collegeId.HasValue)
results = results.Where(x =>
x.Student!.AdministrativeClass!.Major!.CollegeId == collegeId);
return Ok(new
{
batch.Id, batch.Name, batch.GraduationYear, batch.EnrollmentYear,
batch.Status, batch.Notes, batch.CalculatedAt, batch.PublishedAt,
Results = await results
.OrderBy(x => x.Student!.StudentNumber)
.Select(x => new
{
x.Id, x.StudentId, x.Student!.StudentNumber, x.Student.Name,
ClassName = x.Student.AdministrativeClass!.Name,
MajorName = x.Student.AdministrativeClass.Major!.Name,
CollegeName = x.Student.AdministrativeClass.Major.College!.Name,
x.StudentStatusSnapshot,
PlanName = x.CurriculumPlan != null ? x.CurriculumPlan.Name : null,
x.RequiredCredits, x.EarnedCredits,
x.RequiredCourseCount, x.PassedRequiredCourseCount,
x.FailedCourseCount, x.MissingCourseNames,
x.CalculatedConclusion, x.Conclusion, x.IsOverridden,
x.ReviewComment, x.ReviewedAt
}).ToListAsync(token)
});
}
[HttpPost("batches")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> CreateBatch(
GraduationAuditBatchRequest request,
CancellationToken token)
{
if (request.EnrollmentYear > request.GraduationYear)
return ValidationProblem("入学年级不能晚于毕业年份。");
var batch = new GraduationAuditBatch
{
Name = request.Name.Trim(),
GraduationYear = request.GraduationYear,
EnrollmentYear = request.EnrollmentYear,
Notes = request.Notes?.Trim()
};
db.GraduationAuditBatches.Add(batch);
await db.SaveChangesAsync(token);
return Created(string.Empty, new { batch.Id });
}
[HttpPost("batches/{id:guid}/calculate")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> Calculate(Guid id, CancellationToken token)
{
var batch = await db.GraduationAuditBatches
.FirstOrDefaultAsync(x => x.Id == id, token);
if (batch is null) return NotFound();
if (batch.Status != GraduationAuditBatchStatus.Draft)
return ConflictProblem("已发布批次不能重新计算。");
var students = await db.Students.AsNoTracking()
.Include(x => x.AdministrativeClass)
.ThenInclude(x => x!.Major)
.Where(x => x.EnrollmentYear == batch.EnrollmentYear &&
(x.Status == StudentStatus.Active ||
x.Status == StudentStatus.Suspended))
.OrderBy(x => x.StudentNumber)
.ToListAsync(token);
var plans = await db.CurriculumPlans.AsNoTracking()
.Include(x => x.Modules)
.ThenInclude(x => x.Courses)
.ThenInclude(x => x.Course)
.Where(x => x.EffectiveGrade == batch.EnrollmentYear &&
x.Status == CurriculumPlanStatus.Published)
.ToListAsync(token);
var studentIds = students.Select(x => x.Id).ToArray();
var grades = await db.GradeRecords.AsNoTracking()
.Where(x => studentIds.Contains(x.StudentId) &&
x.GradeSheet!.Status == GradeSheetStatus.Published)
.Select(x => new GradeSnapshot(
x.StudentId,
x.GradeSheet!.TeachingTask!.CourseId,
x.GradeSheet.TeachingTask.Course!.Name,
x.GradeSheet.TeachingTask.Course.Credits,
x.TotalScore,
x.ExamStatus))
.ToListAsync(token);
var oldResults = await db.GraduationAuditResults
.Where(x => x.GraduationAuditBatchId == id).ToListAsync(token);
db.GraduationAuditResults.RemoveRange(oldResults);
await db.SaveChangesAsync(token);
foreach (var student in students)
{
var plan = plans.FirstOrDefault(x =>
x.MajorId == student.AdministrativeClass!.MajorId);
var requiredCourses = plan?.Modules
.SelectMany(x => x.Courses)
.Where(x => x.Type == CurriculumCourseType.Required)
.ToList() ?? [];
var studentGrades = grades.Where(x => x.StudentId == student.Id).ToList();
var passedCourseIds = studentGrades
.Where(IsPassed)
.Select(x => x.CourseId)
.Distinct()
.ToHashSet();
var earnedCredits = studentGrades
.Where(IsPassed)
.GroupBy(x => x.CourseId)
.Sum(x => x.Max(item => item.Credits));
var missingCourses = requiredCourses
.Where(x => !passedCourseIds.Contains(x.CourseId))
.Select(x => x.Course!.Name)
.Distinct()
.ToArray();
var missingCourseNames = plan is null
? "未匹配已发布的培养方案"
: string.Join("、", missingCourses);
if (missingCourseNames.Length > 2000)
missingCourseNames = missingCourseNames[..2000];
var failedCourseCount = studentGrades
.GroupBy(x => x.CourseId)
.Count(x => !x.Any(IsPassed));
var conclusion = GraduationAuditRules.Evaluate(
plan is not null,
student.Status,
plan?.TotalCredits ?? 0,
earnedCredits,
requiredCourses.Count,
requiredCourses.Count - missingCourses.Length,
failedCourseCount);
db.GraduationAuditResults.Add(new GraduationAuditResult
{
GraduationAuditBatchId = batch.Id,
StudentId = student.Id,
CurriculumPlanId = plan?.Id,
StudentStatusSnapshot = student.Status,
RequiredCredits = plan?.TotalCredits ?? 0,
EarnedCredits = earnedCredits,
RequiredCourseCount = requiredCourses.Count,
PassedRequiredCourseCount = requiredCourses.Count - missingCourses.Length,
FailedCourseCount = failedCourseCount,
MissingCourseNames = missingCourseNames,
CalculatedConclusion = conclusion,
Conclusion = conclusion
});
}
batch.CalculatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(token);
return Ok(new { ResultCount = students.Count });
}
[HttpPut("results/{id:guid}")]
[Authorize(Roles = Reviewers)]
public async Task<ActionResult> ReviewResult(
Guid id,
GraduationAuditDecisionRequest request,
CancellationToken token)
{
var result = await db.GraduationAuditResults
.Include(x => x.GraduationAuditBatch)
.Include(x => x.Student)
.ThenInclude(x => x!.AdministrativeClass)
.ThenInclude(x => x!.Major)
.FirstOrDefaultAsync(x => x.Id == id, token);
if (result is null) return NotFound();
if (result.GraduationAuditBatch!.Status != GraduationAuditBatchStatus.Draft)
return ConflictProblem("已发布的审核结果不能修改。");
var collegeId = RestrictedCollegeId();
if (collegeId.HasValue &&
result.Student!.AdministrativeClass!.Major!.CollegeId != collegeId)
return Forbid();
result.Conclusion = request.Conclusion;
result.IsOverridden = request.Conclusion != result.CalculatedConclusion;
result.ReviewComment = request.Comment.Trim();
result.ReviewedAt = DateTime.UtcNow;
await db.SaveChangesAsync(token);
return NoContent();
}
[HttpPost("batches/{id:guid}/publish")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> Publish(Guid id, CancellationToken token)
{
var batch = await db.GraduationAuditBatches
.Include(x => x.Results)
.ThenInclude(x => x.Student)
.FirstOrDefaultAsync(x => x.Id == id, token);
if (batch is null) return NotFound();
if (batch.Status != GraduationAuditBatchStatus.Draft)
return ConflictProblem("该批次已经发布。");
if (batch.Results.Count == 0)
return ConflictProblem("请先计算毕业资格,再发布结果。");
batch.Status = GraduationAuditBatchStatus.Published;
batch.PublishedAt = DateTime.UtcNow;
foreach (var result in batch.Results.Where(x =>
x.Conclusion == GraduationAuditConclusion.Eligible &&
x.Student!.Status == StudentStatus.Active))
result.Student!.Status = StudentStatus.Graduated;
await db.SaveChangesAsync(token);
return NoContent();
}
[HttpGet("my-result")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> GetMyResult(CancellationToken token)
{
var userId = currentUserDataScope.Current.UserId;
var result = await db.GraduationAuditResults.AsNoTracking()
.Where(x => x.Student!.UserId == userId &&
x.GraduationAuditBatch!.Status ==
GraduationAuditBatchStatus.Published)
.OrderByDescending(x => x.GraduationAuditBatch!.GraduationYear)
.ThenByDescending(x => x.GraduationAuditBatch!.PublishedAt)
.Select(x => new
{
x.Id,
BatchName = x.GraduationAuditBatch!.Name,
x.GraduationAuditBatch.GraduationYear,
x.Student!.StudentNumber, x.Student.Name,
MajorName = x.Student.AdministrativeClass!.Major!.Name,
PlanName = x.CurriculumPlan != null ? x.CurriculumPlan.Name : null,
x.RequiredCredits, x.EarnedCredits,
x.RequiredCourseCount, x.PassedRequiredCourseCount,
x.FailedCourseCount, x.MissingCourseNames,
x.Conclusion, x.IsOverridden, x.ReviewComment,
x.GraduationAuditBatch.PublishedAt
}).FirstOrDefaultAsync(token);
return Ok(result);
}
private Guid? RestrictedCollegeId()
{
var scope = currentUserDataScope.Current;
return scope.IsInRole(SystemRoles.CollegeAdmin)
? scope.CollegeId ?? Guid.Empty
: null;
}
private static bool IsPassed(GradeSnapshot grade) =>
grade.ExamStatus == GradeExamStatus.Exempt ||
grade.TotalScore is decimal score && score >= 60;
private sealed record GradeSnapshot(
Guid StudentId,
Guid CourseId,
string CourseName,
decimal Credits,
decimal? TotalScore,
GradeExamStatus ExamStatus);
private ActionResult ConflictProblem(string detail) => Conflict(new ProblemDetails
{
Title = "无法完成毕业审核操作",
Detail = detail,
Status = StatusCodes.Status409Conflict
});
}
public sealed record GraduationAuditBatchRequest(
[Required, MinLength(3), MaxLength(120)] string Name,
[Range(2000, 2200)] int GraduationYear,
[Range(2000, 2200)] int EnrollmentYear,
[MaxLength(500)] string? Notes);
public sealed record GraduationAuditDecisionRequest(
GraduationAuditConclusion Conclusion,
[Required, MinLength(5), MaxLength(500)] string Comment);
@@ -0,0 +1,318 @@
using System.ComponentModel.DataAnnotations;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Graduation;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/graduation-clearance")]
public sealed class GraduationClearanceController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope) : ControllerBase
{
private const string Managers =
SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;
private const string Workers =
Managers + "," + SystemRoles.CollegeAdmin + "," + SystemRoles.Counselor;
[HttpGet("batches")]
[Authorize(Roles = Workers)]
public async Task<ActionResult> GetBatches(CancellationToken token)
{
var batches = await db.GraduationClearanceBatches.AsNoTracking()
.OrderByDescending(x => x.GraduationYear)
.ThenByDescending(x => x.CreatedAt)
.Select(x => new
{
x.Id, x.Name, x.GraduationYear, x.Status, x.Notes,
x.ClosedAt, x.CreatedAt, ItemCount = x.Items.Count
}).ToListAsync(token);
var records = await ScopedRecords().AsNoTracking()
.Select(x => new
{
BatchId = x.GraduationClearanceItem!.GraduationClearanceBatchId,
x.StudentId, x.Status
}).ToListAsync(token);
return Ok(batches.Select(batch =>
{
var scoped = records.Where(x => x.BatchId == batch.Id).ToList();
return new
{
batch.Id, batch.Name, batch.GraduationYear, batch.Status,
batch.Notes, batch.ClosedAt, batch.CreatedAt, batch.ItemCount,
StudentCount = scoped.Select(x => x.StudentId).Distinct().Count(),
RecordCount = scoped.Count,
CompletedCount = scoped.Count(x =>
x.Status != GraduationClearanceRecordStatus.Pending)
};
}));
}
[HttpGet("batches/{id:guid}")]
[Authorize(Roles = Workers)]
public async Task<ActionResult> GetBatch(Guid id, CancellationToken token)
{
var batch = await db.GraduationClearanceBatches.AsNoTracking()
.Where(x => x.Id == id)
.Select(x => new
{
x.Id, x.Name, x.GraduationYear, x.Status,
x.Notes, x.ClosedAt, x.CreatedAt,
Items = x.Items.OrderBy(item => item.SortOrder).Select(item => new
{
item.Id, item.Code, item.Name, item.ResponsibleUnit,
item.ResponsibleRole, item.IsRequired, item.SortOrder
})
}).FirstOrDefaultAsync(token);
if (batch is null) return NotFound();
var records = await ScopedRecords().AsNoTracking()
.Where(x => x.GraduationClearanceItem!.GraduationClearanceBatchId == id)
.OrderBy(x => x.Student!.StudentNumber)
.ThenBy(x => x.GraduationClearanceItem!.SortOrder)
.Select(x => new
{
x.Id, x.StudentId, x.Student!.StudentNumber, x.Student.Name,
ClassName = x.Student.AdministrativeClass!.Name,
MajorName = x.Student.AdministrativeClass.Major!.Name,
CollegeName = x.Student.AdministrativeClass.Major.College!.Name,
ItemId = x.GraduationClearanceItemId,
ItemName = x.GraduationClearanceItem!.Name,
x.GraduationClearanceItem.ResponsibleUnit,
x.GraduationClearanceItem.ResponsibleRole,
x.GraduationClearanceItem.IsRequired,
x.Status, x.Notes, x.CompletedAt
}).ToListAsync(token);
return Ok(new
{
batch.Id, batch.Name, batch.GraduationYear, batch.Status,
batch.Notes, batch.ClosedAt, batch.CreatedAt, batch.Items,
Records = records
});
}
[HttpPost("batches")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> Create(
GraduationClearanceBatchRequest request,
CancellationToken token)
{
var allowedRoles = new[]
{
SystemRoles.AcademicAdmin,
SystemRoles.CollegeAdmin,
SystemRoles.Counselor
};
if (request.Items.Count == 0)
return ValidationProblem("至少配置一个离校事项。");
if (request.Items.Select(x => x.Code.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase).Count() != request.Items.Count)
return ValidationProblem("离校事项编码不能重复。");
if (request.Items.Any(x => !allowedRoles.Contains(
x.ResponsibleRole, StringComparer.OrdinalIgnoreCase)))
return ValidationProblem("离校事项责任角色无效。");
var batch = new GraduationClearanceBatch
{
Name = request.Name.Trim(),
GraduationYear = request.GraduationYear,
Notes = request.Notes?.Trim(),
Items = request.Items.Select((item, index) =>
new GraduationClearanceItem
{
Code = item.Code.Trim(),
Name = item.Name.Trim(),
ResponsibleUnit = item.ResponsibleUnit.Trim(),
ResponsibleRole = item.ResponsibleRole,
IsRequired = item.IsRequired,
SortOrder = index + 1
}).ToList()
};
db.GraduationClearanceBatches.Add(batch);
await db.SaveChangesAsync(token);
return Created(string.Empty, new { batch.Id });
}
[HttpPost("batches/{id:guid}/generate")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> Generate(Guid id, CancellationToken token)
{
var batch = await db.GraduationClearanceBatches
.Include(x => x.Items)
.FirstOrDefaultAsync(x => x.Id == id, token);
if (batch is null) return NotFound();
if (batch.Status != GraduationClearanceBatchStatus.Open)
return ConflictProblem("已关闭批次不能重新生成办理记录。");
var eligibleAudits = await db.GraduationAuditResults.AsNoTracking()
.Where(x =>
x.GraduationAuditBatch!.GraduationYear == batch.GraduationYear &&
x.GraduationAuditBatch.Status == GraduationAuditBatchStatus.Published &&
x.Conclusion == GraduationAuditConclusion.Eligible &&
x.Student!.Status == StudentStatus.Graduated)
.OrderByDescending(x => x.GraduationAuditBatch!.PublishedAt)
.Select(x => new { x.StudentId })
.ToListAsync(token);
var studentIds = eligibleAudits.Select(x => x.StudentId).Distinct().ToArray();
var existing = await db.GraduationClearanceRecords.AsNoTracking()
.Where(x => x.GraduationClearanceItem!.GraduationClearanceBatchId == id)
.Select(x => new { x.GraduationClearanceItemId, x.StudentId })
.ToListAsync(token);
var existingKeys = existing
.Select(x => (x.GraduationClearanceItemId, x.StudentId))
.ToHashSet();
var created = 0;
foreach (var item in batch.Items)
foreach (var studentId in studentIds)
{
if (existingKeys.Contains((item.Id, studentId))) continue;
db.GraduationClearanceRecords.Add(new GraduationClearanceRecord
{
GraduationClearanceItemId = item.Id,
StudentId = studentId
});
created++;
}
await db.SaveChangesAsync(token);
return Ok(new { StudentCount = studentIds.Length, CreatedRecordCount = created });
}
[HttpPut("records/{id:guid}")]
[Authorize(Roles = Workers)]
public async Task<ActionResult> UpdateRecord(
Guid id,
GraduationClearanceRecordRequest request,
CancellationToken token)
{
var record = await db.GraduationClearanceRecords
.Include(x => x.GraduationClearanceItem)
.ThenInclude(x => x!.GraduationClearanceBatch)
.Include(x => x.Student)
.ThenInclude(x => x!.AdministrativeClass)
.ThenInclude(x => x!.Major)
.FirstOrDefaultAsync(x => x.Id == id, token);
if (record is null) return NotFound();
if (record.GraduationClearanceItem!.GraduationClearanceBatch!.Status !=
GraduationClearanceBatchStatus.Open)
return ConflictProblem("批次已关闭,不能修改办理记录。");
if (!CanManage(record)) return Forbid();
record.Status = request.Status;
record.Notes = request.Notes?.Trim();
record.CompletedAt = request.Status == GraduationClearanceRecordStatus.Pending
? null
: DateTime.UtcNow;
record.CompletedByUserId =
request.Status == GraduationClearanceRecordStatus.Pending
? null
: currentUserDataScope.Current.UserId;
await db.SaveChangesAsync(token);
return NoContent();
}
[HttpPost("batches/{id:guid}/close")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> Close(Guid id, CancellationToken token)
{
var batch = await db.GraduationClearanceBatches
.Include(x => x.Items)
.ThenInclude(x => x.Records)
.FirstOrDefaultAsync(x => x.Id == id, token);
if (batch is null) return NotFound();
if (batch.Status != GraduationClearanceBatchStatus.Open)
return ConflictProblem("该批次已经关闭。");
var records = batch.Items.SelectMany(item => item.Records.Select(record =>
(item.IsRequired, record.Status)));
if (!GraduationClearanceRules.CanClose(records))
return ConflictProblem("仍有必办离校事项尚未完成。");
batch.Status = GraduationClearanceBatchStatus.Closed;
batch.ClosedAt = DateTime.UtcNow;
await db.SaveChangesAsync(token);
return NoContent();
}
[HttpGet("my-clearance")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> GetMyClearance(CancellationToken token)
{
var userId = currentUserDataScope.Current.UserId;
var batch = await db.GraduationClearanceBatches.AsNoTracking()
.Where(x => x.Items.Any(item =>
item.Records.Any(record => record.Student!.UserId == userId)))
.OrderByDescending(x => x.GraduationYear)
.ThenByDescending(x => x.CreatedAt)
.Select(x => new
{
x.Id, x.Name, x.GraduationYear, x.Status, x.Notes, x.ClosedAt,
Items = x.Items.OrderBy(item => item.SortOrder)
.SelectMany(item => item.Records
.Where(record => record.Student!.UserId == userId)
.Select(record => new
{
record.Id, ItemName = item.Name, item.ResponsibleUnit,
item.IsRequired, record.Status,
record.Notes, record.CompletedAt
}))
}).FirstOrDefaultAsync(token);
return Ok(batch);
}
private IQueryable<GraduationClearanceRecord> ScopedRecords()
{
var scope = currentUserDataScope.Current;
var source = db.GraduationClearanceRecords.AsQueryable();
if (scope.IsInRole(SystemRoles.Counselor))
return source.Where(x =>
x.Student!.AdministrativeClass!.CounselorUserId == scope.UserId);
if (scope.IsInRole(SystemRoles.CollegeAdmin))
return source.Where(x =>
x.Student!.AdministrativeClass!.Major!.CollegeId == scope.CollegeId);
return source;
}
private bool CanManage(GraduationClearanceRecord record)
{
var scope = currentUserDataScope.Current;
if (scope.IsInRole(SystemRoles.SuperAdmin)) return true;
var role = record.GraduationClearanceItem!.ResponsibleRole;
if (role == SystemRoles.AcademicAdmin &&
scope.IsInRole(SystemRoles.AcademicAdmin)) return true;
if (role == SystemRoles.CollegeAdmin &&
scope.IsInRole(SystemRoles.CollegeAdmin))
return record.Student!.AdministrativeClass!.Major!.CollegeId ==
scope.CollegeId;
return role == SystemRoles.Counselor &&
scope.IsInRole(SystemRoles.Counselor) &&
record.Student!.AdministrativeClass!.CounselorUserId == scope.UserId;
}
private ActionResult ConflictProblem(string detail) => Conflict(new ProblemDetails
{
Title = "无法完成毕业离校操作",
Detail = detail,
Status = StatusCodes.Status409Conflict
});
}
public sealed record GraduationClearanceBatchRequest(
[Required, MinLength(3), MaxLength(120)] string Name,
[Range(2000, 2200)] int GraduationYear,
[MaxLength(500)] string? Notes,
IReadOnlyList<GraduationClearanceItemRequest> Items);
public sealed record GraduationClearanceItemRequest(
[Required, MinLength(2), MaxLength(30)] string Code,
[Required, MinLength(2), MaxLength(100)] string Name,
[Required, MinLength(2), MaxLength(100)] string ResponsibleUnit,
[Required, MaxLength(30)] string ResponsibleRole,
bool IsRequired);
public sealed record GraduationClearanceRecordRequest(
GraduationClearanceRecordStatus Status,
[MaxLength(500)] string? Notes);
@@ -30,6 +30,37 @@ public sealed class StudentStatusChangesController(
}).ToListAsync(token));
}
[HttpGet("options")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> GetOptions(CancellationToken token)
{
var userId = currentUserDataScope.Current.UserId;
var student = await db.Students.AsNoTracking()
.FirstOrDefaultAsync(x => x.UserId == userId, token);
if (student is null) return ConflictProblem("当前账号未关联学生档案。");
var types = student.Status switch
{
StudentStatus.Active => new[]
{
StudentStatusChangeType.Suspension,
StudentStatusChangeType.Withdrawal
},
StudentStatus.Suspended => new[]
{
StudentStatusChangeType.Resumption,
StudentStatusChangeType.Withdrawal
},
_ => []
};
var hasPending = await db.StudentStatusChanges.AnyAsync(x =>
x.StudentId == student.Id &&
(x.State == StudentStatusChangeState.Submitted ||
x.State == StudentStatusChangeState.CounselorApproved ||
x.State == StudentStatusChangeState.CollegeApproved), token);
return Ok(new { student.Status, Types = types, HasPending = hasPending });
}
[HttpPost]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> Create(
@@ -76,14 +107,19 @@ public sealed class StudentStatusChangesController(
.FirstOrDefaultAsync(x => x.Id == id, token);
if (change is null) return NotFound();
var scope = currentUserDataScope.Current;
if (!CanReviewCurrentStage(scope, change.State))
return ConflictProblem("当前角色或审核阶段不允许执行该操作。");
if (!request.Approved)
{
if (change.State is StudentStatusChangeState.Approved or
StudentStatusChangeState.Rejected or StudentStatusChangeState.Cancelled)
return ConflictProblem("该申请已经结束。");
if (string.IsNullOrWhiteSpace(request.Comment))
return BadRequest(new ProblemDetails
{
Title = "审核意见不完整",
Detail = "驳回申请时必须填写审核意见。",
Status = StatusCodes.Status400BadRequest
});
change.State = StudentStatusChangeState.Rejected;
change.ReviewComment = request.Comment?.Trim();
change.ReviewedAt = DateTime.UtcNow;
}
else if (scope.IsInRole(SystemRoles.Counselor) &&
change.State == StudentStatusChangeState.Submitted)
@@ -100,12 +136,30 @@ public sealed class StudentStatusChangesController(
change.ApprovedAt = DateTime.UtcNow;
}
else return ConflictProblem("当前角色或审核阶段不允许执行该操作。");
change.ReviewComment = request.Comment?.Trim();
change.ReviewedAt = DateTime.UtcNow;
await db.SaveChangesAsync(token);
return NoContent();
}
[HttpPost("{id:guid}/cancel")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> Cancel(Guid id, CancellationToken token)
{
var userId = currentUserDataScope.Current.UserId;
var change = await db.StudentStatusChanges.FirstOrDefaultAsync(
x => x.Id == id && x.Student!.UserId == userId, token);
if (change is null) return NotFound();
if (change.State != StudentStatusChangeState.Submitted)
return ConflictProblem("只有尚未进入审核的申请可以撤回。");
change.State = StudentStatusChangeState.Cancelled;
change.ReviewedAt = DateTime.UtcNow;
await db.SaveChangesAsync(token);
return NoContent();
}
private IQueryable<StudentStatusChange> ScopedChanges()
{
var scope = currentUserDataScope.Current;
@@ -123,6 +177,21 @@ public sealed class StudentStatusChangesController(
return source.Where(_ => false);
}
private static bool CanReviewCurrentStage(
CurrentUserScope scope,
StudentStatusChangeState state) =>
state switch
{
StudentStatusChangeState.Submitted =>
scope.IsInRole(SystemRoles.Counselor),
StudentStatusChangeState.CounselorApproved =>
scope.IsInRole(SystemRoles.CollegeAdmin),
StudentStatusChangeState.CollegeApproved =>
scope.IsInRole(SystemRoles.AcademicAdmin) ||
scope.IsInRole(SystemRoles.SuperAdmin),
_ => false
};
private ActionResult ConflictProblem(string detail) => Conflict(new ProblemDetails
{
Title = "无法完成学籍异动操作", Detail = detail,
@@ -0,0 +1,45 @@
using Jiaowu.Api.Domain.Common;
namespace Jiaowu.Api.Domain.Academic;
public sealed class DegreeAwardBatch : EntityBase
{
public required string Name { get; set; }
public int GraduationYear { get; set; }
public required string DegreeName { get; set; }
public decimal MinimumGradePoint { get; set; } = 2.0m;
public DegreeAwardBatchStatus Status { get; set; } = DegreeAwardBatchStatus.Draft;
public string? Notes { get; set; }
public DateTime? CalculatedAt { get; set; }
public DateTime? PublishedAt { get; set; }
public ICollection<DegreeAwardResult> Results { get; set; } = [];
}
public sealed class DegreeAwardResult : EntityBase
{
public Guid DegreeAwardBatchId { get; set; }
public DegreeAwardBatch? DegreeAwardBatch { get; set; }
public Guid StudentId { get; set; }
public Student? Student { get; set; }
public Guid GraduationAuditResultId { get; set; }
public GraduationAuditResult? GraduationAuditResult { get; set; }
public decimal AverageGradePoint { get; set; }
public DegreeAwardConclusion CalculatedConclusion { get; set; }
public DegreeAwardConclusion Conclusion { get; set; }
public required string ExceptionReason { get; set; }
public bool IsOverridden { get; set; }
public string? ReviewComment { get; set; }
public DateTime? ReviewedAt { get; set; }
}
public enum DegreeAwardBatchStatus
{
Draft = 1,
Published = 2
}
public enum DegreeAwardConclusion
{
NotGranted = 1,
Granted = 2
}
@@ -0,0 +1,50 @@
using Jiaowu.Api.Domain.Common;
namespace Jiaowu.Api.Domain.Academic;
public sealed class GraduationAuditBatch : EntityBase
{
public required string Name { get; set; }
public int GraduationYear { get; set; }
public int EnrollmentYear { get; set; }
public GraduationAuditBatchStatus Status { get; set; } =
GraduationAuditBatchStatus.Draft;
public string? Notes { get; set; }
public DateTime? CalculatedAt { get; set; }
public DateTime? PublishedAt { get; set; }
public ICollection<GraduationAuditResult> Results { get; set; } = [];
}
public sealed class GraduationAuditResult : EntityBase
{
public Guid GraduationAuditBatchId { get; set; }
public GraduationAuditBatch? GraduationAuditBatch { get; set; }
public Guid StudentId { get; set; }
public Student? Student { get; set; }
public Guid? CurriculumPlanId { get; set; }
public CurriculumPlan? CurriculumPlan { get; set; }
public StudentStatus StudentStatusSnapshot { get; set; }
public decimal RequiredCredits { get; set; }
public decimal EarnedCredits { get; set; }
public int RequiredCourseCount { get; set; }
public int PassedRequiredCourseCount { get; set; }
public int FailedCourseCount { get; set; }
public required string MissingCourseNames { get; set; }
public GraduationAuditConclusion CalculatedConclusion { get; set; }
public GraduationAuditConclusion Conclusion { get; set; }
public bool IsOverridden { get; set; }
public string? ReviewComment { get; set; }
public DateTime? ReviewedAt { get; set; }
}
public enum GraduationAuditBatchStatus
{
Draft = 1,
Published = 2
}
public enum GraduationAuditConclusion
{
Ineligible = 1,
Eligible = 2
}
@@ -0,0 +1,53 @@
using Jiaowu.Api.Domain.Common;
namespace Jiaowu.Api.Domain.Academic;
public sealed class GraduationClearanceBatch : EntityBase
{
public required string Name { get; set; }
public int GraduationYear { get; set; }
public GraduationClearanceBatchStatus Status { get; set; } =
GraduationClearanceBatchStatus.Open;
public string? Notes { get; set; }
public DateTime? ClosedAt { get; set; }
public ICollection<GraduationClearanceItem> Items { get; set; } = [];
}
public sealed class GraduationClearanceItem : EntityBase
{
public Guid GraduationClearanceBatchId { get; set; }
public GraduationClearanceBatch? GraduationClearanceBatch { get; set; }
public required string Code { get; set; }
public required string Name { get; set; }
public required string ResponsibleUnit { get; set; }
public required string ResponsibleRole { get; set; }
public bool IsRequired { get; set; } = true;
public int SortOrder { get; set; }
public ICollection<GraduationClearanceRecord> Records { get; set; } = [];
}
public sealed class GraduationClearanceRecord : EntityBase
{
public Guid GraduationClearanceItemId { get; set; }
public GraduationClearanceItem? GraduationClearanceItem { get; set; }
public Guid StudentId { get; set; }
public Student? Student { get; set; }
public GraduationClearanceRecordStatus Status { get; set; } =
GraduationClearanceRecordStatus.Pending;
public string? Notes { get; set; }
public DateTime? CompletedAt { get; set; }
public Guid? CompletedByUserId { get; set; }
}
public enum GraduationClearanceBatchStatus
{
Open = 1,
Closed = 2
}
public enum GraduationClearanceRecordStatus
{
Pending = 1,
Completed = 2,
Waived = 3
}
@@ -0,0 +1,17 @@
using Jiaowu.Api.Domain.Academic;
namespace Jiaowu.Api.Infrastructure.Graduation;
public static class DegreeAwardRules
{
public static DegreeAwardConclusion Evaluate(
bool graduationEligible,
StudentStatus studentStatus,
decimal averageGradePoint,
decimal minimumGradePoint) =>
graduationEligible &&
studentStatus == StudentStatus.Graduated &&
averageGradePoint >= minimumGradePoint
? DegreeAwardConclusion.Granted
: DegreeAwardConclusion.NotGranted;
}
@@ -0,0 +1,24 @@
using Jiaowu.Api.Domain.Academic;
namespace Jiaowu.Api.Infrastructure.Graduation;
public static class GraduationAuditRules
{
public static GraduationAuditConclusion Evaluate(
bool hasPublishedPlan,
StudentStatus studentStatus,
decimal requiredCredits,
decimal earnedCredits,
int requiredCourseCount,
int passedRequiredCourseCount,
int failedCourseCount)
{
if (!hasPublishedPlan || studentStatus != StudentStatus.Active)
return GraduationAuditConclusion.Ineligible;
if (earnedCredits < requiredCredits ||
passedRequiredCourseCount < requiredCourseCount ||
failedCourseCount > 0)
return GraduationAuditConclusion.Ineligible;
return GraduationAuditConclusion.Eligible;
}
}
@@ -0,0 +1,16 @@
using Jiaowu.Api.Domain.Academic;
namespace Jiaowu.Api.Infrastructure.Graduation;
public static class GraduationClearanceRules
{
public static bool CanClose(
IEnumerable<(bool IsRequired, GraduationClearanceRecordStatus Status)> records)
{
var snapshot = records.ToArray();
return snapshot.Length > 0 && snapshot
.Where(x => x.IsRequired)
.All(x => x.Status is GraduationClearanceRecordStatus.Completed or
GraduationClearanceRecordStatus.Waived);
}
}
@@ -41,6 +41,18 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
Set<ExamSessionInvigilator>();
public DbSet<StudentStatusChange> StudentStatusChanges =>
Set<StudentStatusChange>();
public DbSet<GraduationAuditBatch> GraduationAuditBatches =>
Set<GraduationAuditBatch>();
public DbSet<GraduationAuditResult> GraduationAuditResults =>
Set<GraduationAuditResult>();
public DbSet<DegreeAwardBatch> DegreeAwardBatches => Set<DegreeAwardBatch>();
public DbSet<DegreeAwardResult> DegreeAwardResults => Set<DegreeAwardResult>();
public DbSet<GraduationClearanceBatch> GraduationClearanceBatches =>
Set<GraduationClearanceBatch>();
public DbSet<GraduationClearanceItem> GraduationClearanceItems =>
Set<GraduationClearanceItem>();
public DbSet<GraduationClearanceRecord> GraduationClearanceRecords =>
Set<GraduationClearanceRecord>();
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
protected override void OnModelCreating(ModelBuilder builder)
@@ -396,6 +408,82 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
entity.HasOne(x => x.Student).WithMany()
.HasForeignKey(x => x.StudentId).OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<GraduationAuditBatch>(entity =>
{
entity.Property(x => x.Name).HasMaxLength(120);
entity.Property(x => x.Notes).HasMaxLength(500);
entity.HasIndex(x => new { x.GraduationYear, x.EnrollmentYear });
entity.HasIndex(x => x.Status);
});
builder.Entity<GraduationAuditResult>(entity =>
{
entity.Property(x => x.RequiredCredits).HasPrecision(6, 2);
entity.Property(x => x.EarnedCredits).HasPrecision(6, 2);
entity.Property(x => x.MissingCourseNames).HasMaxLength(2000);
entity.Property(x => x.ReviewComment).HasMaxLength(500);
entity.HasIndex(x => new { x.GraduationAuditBatchId, x.StudentId })
.IsUnique();
entity.HasIndex(x => new { x.Conclusion, x.IsOverridden });
entity.HasOne(x => x.GraduationAuditBatch).WithMany(x => x.Results)
.HasForeignKey(x => x.GraduationAuditBatchId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.Student).WithMany()
.HasForeignKey(x => x.StudentId).OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.CurriculumPlan).WithMany()
.HasForeignKey(x => x.CurriculumPlanId).OnDelete(DeleteBehavior.SetNull);
});
builder.Entity<DegreeAwardBatch>(entity =>
{
entity.Property(x => x.Name).HasMaxLength(120);
entity.Property(x => x.DegreeName).HasMaxLength(80);
entity.Property(x => x.MinimumGradePoint).HasPrecision(3, 2);
entity.Property(x => x.Notes).HasMaxLength(500);
entity.HasIndex(x => new { x.GraduationYear, x.Status });
});
builder.Entity<DegreeAwardResult>(entity =>
{
entity.Property(x => x.AverageGradePoint).HasPrecision(4, 2);
entity.Property(x => x.ExceptionReason).HasMaxLength(500);
entity.Property(x => x.ReviewComment).HasMaxLength(500);
entity.HasIndex(x => new { x.DegreeAwardBatchId, x.StudentId }).IsUnique();
entity.HasIndex(x => new { x.Conclusion, x.IsOverridden });
entity.HasOne(x => x.DegreeAwardBatch).WithMany(x => x.Results)
.HasForeignKey(x => x.DegreeAwardBatchId).OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.Student).WithMany()
.HasForeignKey(x => x.StudentId).OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.GraduationAuditResult).WithMany()
.HasForeignKey(x => x.GraduationAuditResultId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<GraduationClearanceBatch>(entity =>
{
entity.Property(x => x.Name).HasMaxLength(120);
entity.Property(x => x.Notes).HasMaxLength(500);
entity.HasIndex(x => new { x.GraduationYear, x.Status });
});
builder.Entity<GraduationClearanceItem>(entity =>
{
entity.Property(x => x.Code).HasMaxLength(30);
entity.Property(x => x.Name).HasMaxLength(100);
entity.Property(x => x.ResponsibleUnit).HasMaxLength(100);
entity.Property(x => x.ResponsibleRole).HasMaxLength(30);
entity.HasIndex(x => new { x.GraduationClearanceBatchId, x.Code }).IsUnique();
entity.HasOne(x => x.GraduationClearanceBatch).WithMany(x => x.Items)
.HasForeignKey(x => x.GraduationClearanceBatchId)
.OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<GraduationClearanceRecord>(entity =>
{
entity.Property(x => x.Notes).HasMaxLength(500);
entity.HasIndex(x => new { x.GraduationClearanceItemId, x.StudentId })
.IsUnique();
entity.HasIndex(x => new { x.StudentId, x.Status });
entity.HasOne(x => x.GraduationClearanceItem).WithMany(x => x.Records)
.HasForeignKey(x => x.GraduationClearanceItemId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.Student).WithMany()
.HasForeignKey(x => x.StudentId).OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<AuditLog>(entity =>
{
@@ -15,6 +15,9 @@ public sealed class DevelopmentSqliteMigrator(
private const string GradesMigration = "20260724_07_grades";
private const string ExamsMigration = "20260724_08_exams";
private const string StudentStatusChangesMigration = "20260724_09_student_status_changes";
private const string GraduationAuditsMigration = "20260724_10_graduation_audits";
private const string DegreeAwardsMigration = "20260724_11_degree_awards";
private const string GraduationClearanceMigration = "20260724_12_graduation_clearance";
public async Task MigrateAsync(CancellationToken cancellationToken = default)
{
@@ -75,6 +78,18 @@ public sealed class DevelopmentSqliteMigrator(
StudentStatusChangesMigration,
StudentStatusChangesStatements,
cancellationToken);
await ApplyMigrationAsync(
GraduationAuditsMigration,
GraduationAuditsStatements,
cancellationToken);
await ApplyMigrationAsync(
DegreeAwardsMigration,
DegreeAwardsStatements,
cancellationToken);
await ApplyMigrationAsync(
GraduationClearanceMigration,
GraduationClearanceStatements,
cancellationToken);
}
private async Task ApplyMigrationAsync(
@@ -637,4 +652,119 @@ public sealed class DevelopmentSqliteMigrator(
""",
"""CREATE INDEX IF NOT EXISTS "IX_StudentStatusChanges_StudentId_State" ON "StudentStatusChanges" ("StudentId", "State");"""
];
private static readonly string[] GraduationAuditsStatements =
[
"""
CREATE TABLE IF NOT EXISTS "GraduationAuditBatches" (
"Id" TEXT NOT NULL CONSTRAINT "PK_GraduationAuditBatches" PRIMARY KEY,
"Name" TEXT NOT NULL, "GraduationYear" INTEGER NOT NULL,
"EnrollmentYear" INTEGER NOT NULL, "Status" INTEGER NOT NULL,
"Notes" TEXT NULL, "CalculatedAt" TEXT NULL, "PublishedAt" TEXT NULL,
"CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL
);
""",
"""CREATE INDEX IF NOT EXISTS "IX_GraduationAuditBatches_GraduationYear_EnrollmentYear" ON "GraduationAuditBatches" ("GraduationYear", "EnrollmentYear");""",
"""CREATE INDEX IF NOT EXISTS "IX_GraduationAuditBatches_Status" ON "GraduationAuditBatches" ("Status");""",
"""
CREATE TABLE IF NOT EXISTS "GraduationAuditResults" (
"Id" TEXT NOT NULL CONSTRAINT "PK_GraduationAuditResults" PRIMARY KEY,
"GraduationAuditBatchId" TEXT NOT NULL, "StudentId" TEXT NOT NULL,
"CurriculumPlanId" TEXT NULL, "StudentStatusSnapshot" INTEGER NOT NULL,
"RequiredCredits" TEXT NOT NULL, "EarnedCredits" TEXT NOT NULL,
"RequiredCourseCount" INTEGER NOT NULL,
"PassedRequiredCourseCount" INTEGER NOT NULL,
"FailedCourseCount" INTEGER NOT NULL,
"MissingCourseNames" TEXT NOT NULL,
"CalculatedConclusion" INTEGER NOT NULL, "Conclusion" INTEGER NOT NULL,
"IsOverridden" INTEGER NOT NULL, "ReviewComment" TEXT NULL,
"ReviewedAt" TEXT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_GraduationAuditResults_GraduationAuditBatches_GraduationAuditBatchId"
FOREIGN KEY ("GraduationAuditBatchId") REFERENCES "GraduationAuditBatches" ("Id") ON DELETE CASCADE,
CONSTRAINT "FK_GraduationAuditResults_Students_StudentId"
FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT,
CONSTRAINT "FK_GraduationAuditResults_CurriculumPlans_CurriculumPlanId"
FOREIGN KEY ("CurriculumPlanId") REFERENCES "CurriculumPlans" ("Id") ON DELETE SET NULL
);
""",
"""CREATE UNIQUE INDEX IF NOT EXISTS "IX_GraduationAuditResults_GraduationAuditBatchId_StudentId" ON "GraduationAuditResults" ("GraduationAuditBatchId", "StudentId");""",
"""CREATE INDEX IF NOT EXISTS "IX_GraduationAuditResults_StudentId" ON "GraduationAuditResults" ("StudentId");""",
"""CREATE INDEX IF NOT EXISTS "IX_GraduationAuditResults_CurriculumPlanId" ON "GraduationAuditResults" ("CurriculumPlanId");""",
"""CREATE INDEX IF NOT EXISTS "IX_GraduationAuditResults_Conclusion_IsOverridden" ON "GraduationAuditResults" ("Conclusion", "IsOverridden");"""
];
private static readonly string[] DegreeAwardsStatements =
[
"""
CREATE TABLE IF NOT EXISTS "DegreeAwardBatches" (
"Id" TEXT NOT NULL CONSTRAINT "PK_DegreeAwardBatches" PRIMARY KEY,
"Name" TEXT NOT NULL, "GraduationYear" INTEGER NOT NULL,
"DegreeName" TEXT NOT NULL, "MinimumGradePoint" TEXT NOT NULL,
"Status" INTEGER NOT NULL, "Notes" TEXT NULL,
"CalculatedAt" TEXT NULL, "PublishedAt" TEXT NULL,
"CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL
);
""",
"""CREATE INDEX IF NOT EXISTS "IX_DegreeAwardBatches_GraduationYear_Status" ON "DegreeAwardBatches" ("GraduationYear", "Status");""",
"""
CREATE TABLE IF NOT EXISTS "DegreeAwardResults" (
"Id" TEXT NOT NULL CONSTRAINT "PK_DegreeAwardResults" PRIMARY KEY,
"DegreeAwardBatchId" TEXT NOT NULL, "StudentId" TEXT NOT NULL,
"GraduationAuditResultId" TEXT NOT NULL, "AverageGradePoint" TEXT NOT NULL,
"CalculatedConclusion" INTEGER NOT NULL, "Conclusion" INTEGER NOT NULL,
"ExceptionReason" TEXT NOT NULL, "IsOverridden" INTEGER NOT NULL,
"ReviewComment" TEXT NULL, "ReviewedAt" TEXT NULL,
"CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_DegreeAwardResults_DegreeAwardBatches_DegreeAwardBatchId"
FOREIGN KEY ("DegreeAwardBatchId") REFERENCES "DegreeAwardBatches" ("Id") ON DELETE CASCADE,
CONSTRAINT "FK_DegreeAwardResults_Students_StudentId"
FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT,
CONSTRAINT "FK_DegreeAwardResults_GraduationAuditResults_GraduationAuditResultId"
FOREIGN KEY ("GraduationAuditResultId") REFERENCES "GraduationAuditResults" ("Id") ON DELETE RESTRICT
);
""",
"""CREATE UNIQUE INDEX IF NOT EXISTS "IX_DegreeAwardResults_DegreeAwardBatchId_StudentId" ON "DegreeAwardResults" ("DegreeAwardBatchId", "StudentId");""",
"""CREATE INDEX IF NOT EXISTS "IX_DegreeAwardResults_StudentId" ON "DegreeAwardResults" ("StudentId");""",
"""CREATE INDEX IF NOT EXISTS "IX_DegreeAwardResults_GraduationAuditResultId" ON "DegreeAwardResults" ("GraduationAuditResultId");""",
"""CREATE INDEX IF NOT EXISTS "IX_DegreeAwardResults_Conclusion_IsOverridden" ON "DegreeAwardResults" ("Conclusion", "IsOverridden");"""
];
private static readonly string[] GraduationClearanceStatements =
[
"""
CREATE TABLE IF NOT EXISTS "GraduationClearanceBatches" (
"Id" TEXT NOT NULL CONSTRAINT "PK_GraduationClearanceBatches" PRIMARY KEY,
"Name" TEXT NOT NULL, "GraduationYear" INTEGER NOT NULL,
"Status" INTEGER NOT NULL, "Notes" TEXT NULL, "ClosedAt" TEXT NULL,
"CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL
);
""",
"""CREATE INDEX IF NOT EXISTS "IX_GraduationClearanceBatches_GraduationYear_Status" ON "GraduationClearanceBatches" ("GraduationYear", "Status");""",
"""
CREATE TABLE IF NOT EXISTS "GraduationClearanceItems" (
"Id" TEXT NOT NULL CONSTRAINT "PK_GraduationClearanceItems" PRIMARY KEY,
"GraduationClearanceBatchId" TEXT NOT NULL, "Code" TEXT NOT NULL,
"Name" TEXT NOT NULL, "ResponsibleUnit" TEXT NOT NULL,
"ResponsibleRole" TEXT NOT NULL, "IsRequired" INTEGER NOT NULL,
"SortOrder" INTEGER NOT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_GraduationClearanceItems_GraduationClearanceBatches_GraduationClearanceBatchId"
FOREIGN KEY ("GraduationClearanceBatchId") REFERENCES "GraduationClearanceBatches" ("Id") ON DELETE CASCADE
);
""",
"""CREATE UNIQUE INDEX IF NOT EXISTS "IX_GraduationClearanceItems_GraduationClearanceBatchId_Code" ON "GraduationClearanceItems" ("GraduationClearanceBatchId", "Code");""",
"""
CREATE TABLE IF NOT EXISTS "GraduationClearanceRecords" (
"Id" TEXT NOT NULL CONSTRAINT "PK_GraduationClearanceRecords" PRIMARY KEY,
"GraduationClearanceItemId" TEXT NOT NULL, "StudentId" TEXT NOT NULL,
"Status" INTEGER NOT NULL, "Notes" TEXT NULL, "CompletedAt" TEXT NULL,
"CompletedByUserId" TEXT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_GraduationClearanceRecords_GraduationClearanceItems_GraduationClearanceItemId"
FOREIGN KEY ("GraduationClearanceItemId") REFERENCES "GraduationClearanceItems" ("Id") ON DELETE CASCADE,
CONSTRAINT "FK_GraduationClearanceRecords_Students_StudentId"
FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT
);
""",
"""CREATE UNIQUE INDEX IF NOT EXISTS "IX_GraduationClearanceRecords_GraduationClearanceItemId_StudentId" ON "GraduationClearanceRecords" ("GraduationClearanceItemId", "StudentId");""",
"""CREATE INDEX IF NOT EXISTS "IX_GraduationClearanceRecords_StudentId_Status" ON "GraduationClearanceRecords" ("StudentId", "Status");"""
];
}
@@ -0,0 +1,124 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class GraduationAudits : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "GraduationAuditBatches",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
Name = table.Column<string>(type: "varchar(120)", maxLength: 120, nullable: false),
GraduationYear = table.Column<int>(type: "int", nullable: false),
EnrollmentYear = table.Column<int>(type: "int", nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
Notes = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
CalculatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
PublishedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_GraduationAuditBatches", x => x.Id);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "GraduationAuditResults",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
GraduationAuditBatchId = table.Column<Guid>(type: "char(36)", nullable: false),
StudentId = table.Column<Guid>(type: "char(36)", nullable: false),
CurriculumPlanId = table.Column<Guid>(type: "char(36)", nullable: true),
StudentStatusSnapshot = table.Column<int>(type: "int", nullable: false),
RequiredCredits = table.Column<decimal>(type: "decimal(6,2)", precision: 6, scale: 2, nullable: false),
EarnedCredits = table.Column<decimal>(type: "decimal(6,2)", precision: 6, scale: 2, nullable: false),
RequiredCourseCount = table.Column<int>(type: "int", nullable: false),
PassedRequiredCourseCount = table.Column<int>(type: "int", nullable: false),
FailedCourseCount = table.Column<int>(type: "int", nullable: false),
MissingCourseNames = table.Column<string>(type: "varchar(2000)", maxLength: 2000, nullable: false),
CalculatedConclusion = table.Column<int>(type: "int", nullable: false),
Conclusion = table.Column<int>(type: "int", nullable: false),
IsOverridden = table.Column<bool>(type: "tinyint(1)", nullable: false),
ReviewComment = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
ReviewedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_GraduationAuditResults", x => x.Id);
table.ForeignKey(
name: "FK_GraduationAuditResults_CurriculumPlans_CurriculumPlanId",
column: x => x.CurriculumPlanId,
principalTable: "CurriculumPlans",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_GraduationAuditResults_GraduationAuditBatches_GraduationAudi~",
column: x => x.GraduationAuditBatchId,
principalTable: "GraduationAuditBatches",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_GraduationAuditResults_Students_StudentId",
column: x => x.StudentId,
principalTable: "Students",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_GraduationAuditBatches_GraduationYear_EnrollmentYear",
table: "GraduationAuditBatches",
columns: new[] { "GraduationYear", "EnrollmentYear" });
migrationBuilder.CreateIndex(
name: "IX_GraduationAuditBatches_Status",
table: "GraduationAuditBatches",
column: "Status");
migrationBuilder.CreateIndex(
name: "IX_GraduationAuditResults_Conclusion_IsOverridden",
table: "GraduationAuditResults",
columns: new[] { "Conclusion", "IsOverridden" });
migrationBuilder.CreateIndex(
name: "IX_GraduationAuditResults_CurriculumPlanId",
table: "GraduationAuditResults",
column: "CurriculumPlanId");
migrationBuilder.CreateIndex(
name: "IX_GraduationAuditResults_GraduationAuditBatchId_StudentId",
table: "GraduationAuditResults",
columns: new[] { "GraduationAuditBatchId", "StudentId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_GraduationAuditResults_StudentId",
table: "GraduationAuditResults",
column: "StudentId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "GraduationAuditResults");
migrationBuilder.DropTable(
name: "GraduationAuditBatches");
}
}
}
@@ -0,0 +1,115 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class DegreeAwards : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "DegreeAwardBatches",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
Name = table.Column<string>(type: "varchar(120)", maxLength: 120, nullable: false),
GraduationYear = table.Column<int>(type: "int", nullable: false),
DegreeName = table.Column<string>(type: "varchar(80)", maxLength: 80, nullable: false),
MinimumGradePoint = table.Column<decimal>(type: "decimal(3,2)", precision: 3, scale: 2, nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
Notes = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
CalculatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
PublishedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_DegreeAwardBatches", x => x.Id);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "DegreeAwardResults",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
DegreeAwardBatchId = table.Column<Guid>(type: "char(36)", nullable: false),
StudentId = table.Column<Guid>(type: "char(36)", nullable: false),
GraduationAuditResultId = table.Column<Guid>(type: "char(36)", nullable: false),
AverageGradePoint = table.Column<decimal>(type: "decimal(4,2)", precision: 4, scale: 2, nullable: false),
CalculatedConclusion = table.Column<int>(type: "int", nullable: false),
Conclusion = table.Column<int>(type: "int", nullable: false),
ExceptionReason = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: false),
IsOverridden = table.Column<bool>(type: "tinyint(1)", nullable: false),
ReviewComment = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
ReviewedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_DegreeAwardResults", x => x.Id);
table.ForeignKey(
name: "FK_DegreeAwardResults_DegreeAwardBatches_DegreeAwardBatchId",
column: x => x.DegreeAwardBatchId,
principalTable: "DegreeAwardBatches",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_DegreeAwardResults_GraduationAuditResults_GraduationAuditRes~",
column: x => x.GraduationAuditResultId,
principalTable: "GraduationAuditResults",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_DegreeAwardResults_Students_StudentId",
column: x => x.StudentId,
principalTable: "Students",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_DegreeAwardBatches_GraduationYear_Status",
table: "DegreeAwardBatches",
columns: new[] { "GraduationYear", "Status" });
migrationBuilder.CreateIndex(
name: "IX_DegreeAwardResults_Conclusion_IsOverridden",
table: "DegreeAwardResults",
columns: new[] { "Conclusion", "IsOverridden" });
migrationBuilder.CreateIndex(
name: "IX_DegreeAwardResults_DegreeAwardBatchId_StudentId",
table: "DegreeAwardResults",
columns: new[] { "DegreeAwardBatchId", "StudentId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_DegreeAwardResults_GraduationAuditResultId",
table: "DegreeAwardResults",
column: "GraduationAuditResultId");
migrationBuilder.CreateIndex(
name: "IX_DegreeAwardResults_StudentId",
table: "DegreeAwardResults",
column: "StudentId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "DegreeAwardResults");
migrationBuilder.DropTable(
name: "DegreeAwardBatches");
}
}
}
@@ -0,0 +1,128 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class GraduationClearance : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "GraduationClearanceBatches",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
Name = table.Column<string>(type: "varchar(120)", maxLength: 120, nullable: false),
GraduationYear = table.Column<int>(type: "int", nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
Notes = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
ClosedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_GraduationClearanceBatches", x => x.Id);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "GraduationClearanceItems",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
GraduationClearanceBatchId = table.Column<Guid>(type: "char(36)", nullable: false),
Code = table.Column<string>(type: "varchar(30)", maxLength: 30, nullable: false),
Name = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: false),
ResponsibleUnit = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: false),
ResponsibleRole = table.Column<string>(type: "varchar(30)", maxLength: 30, nullable: false),
IsRequired = table.Column<bool>(type: "tinyint(1)", nullable: false),
SortOrder = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_GraduationClearanceItems", x => x.Id);
table.ForeignKey(
name: "FK_GraduationClearanceItems_GraduationClearanceBatches_Graduati~",
column: x => x.GraduationClearanceBatchId,
principalTable: "GraduationClearanceBatches",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "GraduationClearanceRecords",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
GraduationClearanceItemId = table.Column<Guid>(type: "char(36)", nullable: false),
StudentId = table.Column<Guid>(type: "char(36)", nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
Notes = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
CompletedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
CompletedByUserId = table.Column<Guid>(type: "char(36)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_GraduationClearanceRecords", x => x.Id);
table.ForeignKey(
name: "FK_GraduationClearanceRecords_GraduationClearanceItems_Graduati~",
column: x => x.GraduationClearanceItemId,
principalTable: "GraduationClearanceItems",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_GraduationClearanceRecords_Students_StudentId",
column: x => x.StudentId,
principalTable: "Students",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_GraduationClearanceBatches_GraduationYear_Status",
table: "GraduationClearanceBatches",
columns: new[] { "GraduationYear", "Status" });
migrationBuilder.CreateIndex(
name: "IX_GraduationClearanceItems_GraduationClearanceBatchId_Code",
table: "GraduationClearanceItems",
columns: new[] { "GraduationClearanceBatchId", "Code" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_GraduationClearanceRecords_GraduationClearanceItemId_Student~",
table: "GraduationClearanceRecords",
columns: new[] { "GraduationClearanceItemId", "StudentId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_GraduationClearanceRecords_StudentId_Status",
table: "GraduationClearanceRecords",
columns: new[] { "StudentId", "Status" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "GraduationClearanceRecords");
migrationBuilder.DropTable(
name: "GraduationClearanceItems");
migrationBuilder.DropTable(
name: "GraduationClearanceBatches");
}
}
}
@@ -634,6 +634,115 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.ToTable("CurriculumPlans");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.DegreeAwardBatch", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime?>("CalculatedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("DegreeName")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("varchar(80)");
b.Property<int>("GraduationYear")
.HasColumnType("int");
b.Property<decimal>("MinimumGradePoint")
.HasPrecision(3, 2)
.HasColumnType("decimal(3,2)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("varchar(120)");
b.Property<string>("Notes")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<DateTime?>("PublishedAt")
.HasColumnType("datetime(6)");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("GraduationYear", "Status");
b.ToTable("DegreeAwardBatches");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.DegreeAwardResult", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<decimal>("AverageGradePoint")
.HasPrecision(4, 2)
.HasColumnType("decimal(4,2)");
b.Property<int>("CalculatedConclusion")
.HasColumnType("int");
b.Property<int>("Conclusion")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("DegreeAwardBatchId")
.HasColumnType("char(36)");
b.Property<string>("ExceptionReason")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<Guid>("GraduationAuditResultId")
.HasColumnType("char(36)");
b.Property<bool>("IsOverridden")
.HasColumnType("tinyint(1)");
b.Property<string>("ReviewComment")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<DateTime?>("ReviewedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("StudentId")
.HasColumnType("char(36)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("GraduationAuditResultId");
b.HasIndex("StudentId");
b.HasIndex("Conclusion", "IsOverridden");
b.HasIndex("DegreeAwardBatchId", "StudentId")
.IsUnique();
b.ToTable("DegreeAwardResults");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamPlan", b =>
{
b.Property<Guid>("Id")
@@ -836,6 +945,254 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.ToTable("GradeSheets");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationAuditBatch", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime?>("CalculatedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("EnrollmentYear")
.HasColumnType("int");
b.Property<int>("GraduationYear")
.HasColumnType("int");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("varchar(120)");
b.Property<string>("Notes")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<DateTime?>("PublishedAt")
.HasColumnType("datetime(6)");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("Status");
b.HasIndex("GraduationYear", "EnrollmentYear");
b.ToTable("GraduationAuditBatches");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationAuditResult", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<int>("CalculatedConclusion")
.HasColumnType("int");
b.Property<int>("Conclusion")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid?>("CurriculumPlanId")
.HasColumnType("char(36)");
b.Property<decimal>("EarnedCredits")
.HasPrecision(6, 2)
.HasColumnType("decimal(6,2)");
b.Property<int>("FailedCourseCount")
.HasColumnType("int");
b.Property<Guid>("GraduationAuditBatchId")
.HasColumnType("char(36)");
b.Property<bool>("IsOverridden")
.HasColumnType("tinyint(1)");
b.Property<string>("MissingCourseNames")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("varchar(2000)");
b.Property<int>("PassedRequiredCourseCount")
.HasColumnType("int");
b.Property<int>("RequiredCourseCount")
.HasColumnType("int");
b.Property<decimal>("RequiredCredits")
.HasPrecision(6, 2)
.HasColumnType("decimal(6,2)");
b.Property<string>("ReviewComment")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<DateTime?>("ReviewedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("StudentId")
.HasColumnType("char(36)");
b.Property<int>("StudentStatusSnapshot")
.HasColumnType("int");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("CurriculumPlanId");
b.HasIndex("StudentId");
b.HasIndex("Conclusion", "IsOverridden");
b.HasIndex("GraduationAuditBatchId", "StudentId")
.IsUnique();
b.ToTable("GraduationAuditResults");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationClearanceBatch", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime?>("ClosedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("GraduationYear")
.HasColumnType("int");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("varchar(120)");
b.Property<string>("Notes")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("GraduationYear", "Status");
b.ToTable("GraduationClearanceBatches");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationClearanceItem", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("varchar(30)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("GraduationClearanceBatchId")
.HasColumnType("char(36)");
b.Property<bool>("IsRequired")
.HasColumnType("tinyint(1)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<string>("ResponsibleRole")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("varchar(30)");
b.Property<string>("ResponsibleUnit")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("GraduationClearanceBatchId", "Code")
.IsUnique();
b.ToTable("GraduationClearanceItems");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationClearanceRecord", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("datetime(6)");
b.Property<Guid?>("CompletedByUserId")
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("GraduationClearanceItemId")
.HasColumnType("char(36)");
b.Property<string>("Notes")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<Guid>("StudentId")
.HasColumnType("char(36)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("GraduationClearanceItemId", "StudentId")
.IsUnique();
b.HasIndex("StudentId", "Status");
b.ToTable("GraduationClearanceRecords");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Major", b =>
{
b.Property<Guid>("Id")
@@ -1684,6 +2041,33 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("Major");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.DegreeAwardResult", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.DegreeAwardBatch", "DegreeAwardBatch")
.WithMany("Results")
.HasForeignKey("DegreeAwardBatchId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Academic.GraduationAuditResult", "GraduationAuditResult")
.WithMany()
.HasForeignKey("GraduationAuditResultId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student")
.WithMany()
.HasForeignKey("StudentId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("DegreeAwardBatch");
b.Navigation("GraduationAuditResult");
b.Navigation("Student");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamPlan", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm")
@@ -1771,6 +2155,62 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("TeachingTask");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationAuditResult", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.CurriculumPlan", "CurriculumPlan")
.WithMany()
.HasForeignKey("CurriculumPlanId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Jiaowu.Api.Domain.Academic.GraduationAuditBatch", "GraduationAuditBatch")
.WithMany("Results")
.HasForeignKey("GraduationAuditBatchId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student")
.WithMany()
.HasForeignKey("StudentId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("CurriculumPlan");
b.Navigation("GraduationAuditBatch");
b.Navigation("Student");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationClearanceItem", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.GraduationClearanceBatch", "GraduationClearanceBatch")
.WithMany("Items")
.HasForeignKey("GraduationClearanceBatchId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("GraduationClearanceBatch");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationClearanceRecord", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.GraduationClearanceItem", "GraduationClearanceItem")
.WithMany("Records")
.HasForeignKey("GraduationClearanceItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student")
.WithMany()
.HasForeignKey("StudentId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("GraduationClearanceItem");
b.Navigation("Student");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Major", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.College", "College")
@@ -1996,6 +2436,11 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("Modules");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.DegreeAwardBatch", b =>
{
b.Navigation("Results");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamPlan", b =>
{
b.Navigation("Sessions");
@@ -2011,6 +2456,21 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("Records");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationAuditBatch", b =>
{
b.Navigation("Results");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationClearanceBatch", b =>
{
b.Navigation("Items");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationClearanceItem", b =>
{
b.Navigation("Records");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.SchedulePlan", b =>
{
b.Navigation("Entries");
@@ -0,0 +1,32 @@
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Graduation;
namespace Jiaowu.Api.Tests;
public sealed class DegreeAwardRulesTests
{
[Fact]
public void Graduated_student_meeting_gpa_requirement_is_granted()
{
var conclusion = DegreeAwardRules.Evaluate(
true, StudentStatus.Graduated, 3.12m, 2.0m);
Assert.Equal(DegreeAwardConclusion.Granted, conclusion);
}
[Theory]
[InlineData(false, StudentStatus.Graduated, 3.12, 2.0)]
[InlineData(true, StudentStatus.Active, 3.12, 2.0)]
[InlineData(true, StudentStatus.Graduated, 1.99, 2.0)]
public void Missing_prerequisite_prevents_degree_award(
bool graduationEligible,
StudentStatus status,
decimal averageGradePoint,
decimal minimumGradePoint)
{
var conclusion = DegreeAwardRules.Evaluate(
graduationEligible, status, averageGradePoint, minimumGradePoint);
Assert.Equal(DegreeAwardConclusion.NotGranted, conclusion);
}
}
@@ -0,0 +1,38 @@
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Graduation;
namespace Jiaowu.Api.Tests;
public sealed class GraduationAuditRulesTests
{
[Fact]
public void Active_student_with_full_credits_and_courses_is_eligible()
{
var conclusion = GraduationAuditRules.Evaluate(
true, StudentStatus.Active, 160, 162, 42, 42, 0);
Assert.Equal(GraduationAuditConclusion.Eligible, conclusion);
}
[Theory]
[InlineData(false, StudentStatus.Active, 160, 162, 42, 42, 0)]
[InlineData(true, StudentStatus.Suspended, 160, 162, 42, 42, 0)]
[InlineData(true, StudentStatus.Active, 160, 159, 42, 42, 0)]
[InlineData(true, StudentStatus.Active, 160, 162, 42, 41, 0)]
[InlineData(true, StudentStatus.Active, 160, 162, 42, 42, 1)]
public void Any_unresolved_requirement_makes_student_ineligible(
bool hasPlan,
StudentStatus status,
decimal requiredCredits,
decimal earnedCredits,
int requiredCourses,
int passedRequiredCourses,
int failedCourses)
{
var conclusion = GraduationAuditRules.Evaluate(
hasPlan, status, requiredCredits, earnedCredits,
requiredCourses, passedRequiredCourses, failedCourses);
Assert.Equal(GraduationAuditConclusion.Ineligible, conclusion);
}
}
@@ -0,0 +1,38 @@
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Graduation;
namespace Jiaowu.Api.Tests;
public sealed class GraduationClearanceRulesTests
{
[Fact]
public void Required_items_may_be_completed_or_waived()
{
var records = new[]
{
(true, GraduationClearanceRecordStatus.Completed),
(true, GraduationClearanceRecordStatus.Waived),
(false, GraduationClearanceRecordStatus.Pending)
};
Assert.True(GraduationClearanceRules.CanClose(records));
}
[Fact]
public void Pending_required_item_prevents_batch_close()
{
var records = new[]
{
(true, GraduationClearanceRecordStatus.Completed),
(true, GraduationClearanceRecordStatus.Pending)
};
Assert.False(GraduationClearanceRules.CanClose(records));
}
[Fact]
public void Empty_batch_cannot_be_closed()
{
Assert.False(GraduationClearanceRules.CanClose([]));
}
}
+3
View File
@@ -27,6 +27,9 @@ declare module 'vue' {
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElOption: typeof import('element-plus/es')['ElOption']
ElPagination: typeof import('element-plus/es')['ElPagination']
ElProgress: typeof import('element-plus/es')['ElProgress']
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable']
+35 -1
View File
@@ -12,6 +12,8 @@ import {
CircleCheck,
DocumentChecked,
AlarmClock,
RefreshRight,
Medal,
User,
UserFilled,
} from '@element-plus/icons-vue'
@@ -45,6 +47,10 @@ const pageTitle = computed(() => {
'course-selections': '选课与教学班',
grades: '成绩与学业档案',
exams: '考试与考场',
'student-status-changes': '学籍异动',
'graduation-audits': '毕业资格审核',
'degree-awards': '学位授予审核',
'graduation-clearance': '毕业离校办理',
users: '用户与权限',
}
return titles[String(route.name)] ?? '教务管理'
@@ -147,6 +153,34 @@ onMounted(() => auth.refresh().catch(() => undefined))
<el-icon><AlarmClock /></el-icon>
<template #title>{{ auth.user?.roles.includes('Student') ? '我的考试' : auth.user?.roles.includes('Teacher') ? '我的监考' : '考试管理' }}</template>
</el-menu-item>
<el-menu-item
v-if="auth.user?.roles.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor', 'Student'].includes(role))"
index="/student-status-changes"
>
<el-icon><RefreshRight /></el-icon>
<template #title>{{ auth.user?.roles.includes('Student') ? '学籍异动' : '异动审核' }}</template>
</el-menu-item>
<el-menu-item
v-if="auth.user?.roles.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Student'].includes(role))"
index="/graduation-audits"
>
<el-icon><Medal /></el-icon>
<template #title>{{ auth.user?.roles.includes('Student') ? '毕业资格' : '毕业审核' }}</template>
</el-menu-item>
<el-menu-item
v-if="auth.user?.roles.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Student'].includes(role))"
index="/degree-awards"
>
<el-icon><Medal /></el-icon>
<template #title>{{ auth.user?.roles.includes('Student') ? '学位结果' : '学位授予' }}</template>
</el-menu-item>
<el-menu-item
v-if="auth.user?.roles.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor', 'Student'].includes(role))"
index="/graduation-clearance"
>
<el-icon><CircleCheck /></el-icon>
<template #title>{{ auth.user?.roles.includes('Student') ? '毕业离校' : '离校办理' }}</template>
</el-menu-item>
<el-menu-item v-if="auth.isSuperAdmin" index="/users">
<el-icon><User /></el-icon>
<template #title>用户与权限</template>
@@ -155,7 +189,7 @@ onMounted(() => auth.refresh().catch(() => undefined))
<div v-if="!collapsed" class="phase-note">
<span>第一阶段 · 核心可用版</span>
<p>教学运行选课与成绩管理已就绪</p>
<p>教学运行学籍与毕业审核已就绪</p>
</div>
</aside>
+32
View File
@@ -80,6 +80,38 @@ const router = createRouter({
component: () => import('../views/ExamsView.vue'),
meta: { roles: ['SuperAdmin', 'AcademicAdmin', 'Teacher', 'Student'] },
},
{
path: 'student-status-changes',
name: 'student-status-changes',
component: () => import('../views/StudentStatusChangesView.vue'),
meta: {
roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor', 'Student'],
},
},
{
path: 'graduation-audits',
name: 'graduation-audits',
component: () => import('../views/GraduationAuditsView.vue'),
meta: {
roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Student'],
},
},
{
path: 'degree-awards',
name: 'degree-awards',
component: () => import('../views/DegreeAwardsView.vue'),
meta: {
roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Student'],
},
},
{
path: 'graduation-clearance',
name: 'graduation-clearance',
component: () => import('../views/GraduationClearanceView.vue'),
meta: {
roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor', 'Student'],
},
},
{
path: 'users',
name: 'users',
+257
View File
@@ -546,6 +546,221 @@ button { cursor: pointer; }
.exam-ticket-grid p { margin:0; color:var(--muted); font-size:10px; }
.exam-ticket-grid footer { grid-column:1/-1; padding:12px 17px; display:flex; align-items:center; gap:7px; border-top:1px dashed var(--line); color:#586275; font-size:10px; }
.status-identity {
min-height: 130px; padding: 24px 28px; display: flex; align-items: center;
justify-content: space-between; gap: 28px; color: white;
background:
linear-gradient(90deg, rgba(255,255,255,.04) 1px, transparent 1px),
linear-gradient(135deg, #172858, #263e7c 65%, #176d70);
background-size: 35px 35px, auto;
}
.status-identity span { color: #70ded0; font: 700 9px Consolas, monospace; letter-spacing: .14em; }
.status-identity b { display: block; margin: 8px 0 6px; font: 600 27px "STZhongsong","Songti SC",serif; }
.status-identity p { margin: 0; color: #c4cee6; font-size: 10px; }
.status-available { min-width: 290px; padding: 17px 20px; border: 1px solid rgba(255,255,255,.15); }
.status-available > span { display: block; margin-bottom: 11px; }
.status-available strong { display: inline-block; margin-right: 7px; padding: 7px 12px; color: white; border: 1px solid rgba(112,222,208,.55); font-size: 11px; }
.status-available em { color: #c4cee6; font-size: 10px; font-style: normal; }
.status-review-summary { display: grid; grid-template-columns: repeat(3, 170px) 1fr; color: white; background: #182955; }
.status-review-summary > div { min-height: 104px; padding: 20px 24px; border-right: 1px solid rgba(255,255,255,.12); }
.status-review-summary span { display: block; color: #9eacd0; font-size: 9px; }
.status-review-summary b { display: inline-block; margin-top: 12px; color: #65d7c8; font: 700 28px/1 Consolas, monospace; }
.status-review-summary small { margin-left: 5px; color: #aeb9d6; font-size: 9px; }
.status-review-summary p { margin: 0; padding: 24px 28px; align-self: center; color: #bdc7e0; font-size: 10px; line-height: 1.7; }
.status-folio-list { display: grid; gap: 13px; min-height: 180px; }
.status-folio-list > article { border: 1px solid var(--line); background: white; box-shadow: 0 5px 18px rgba(30,44,77,.035); }
.status-folio-list > article.actionable { border-left: 3px solid var(--teal); }
.status-folio-list article > header { min-height: 100px; display: grid; grid-template-columns: 150px 1fr auto; align-items: center; gap: 24px; padding: 18px 22px; border-bottom: 1px solid var(--line); }
.folio-number { align-self: stretch; display: grid; place-content: center; padding-right: 22px; border-right: 1px dashed var(--line); }
.folio-number span { color: var(--teal); font: 700 8px Consolas,monospace; letter-spacing: .12em; }
.folio-number b { margin-top: 8px; color: var(--indigo); font: 700 16px Consolas,monospace; }
.folio-person > span { color: var(--teal); font-size: 9px; }
.folio-person h3 { margin: 7px 0 5px; font: 600 18px "STZhongsong","Songti SC",serif; }
.folio-person p { margin: 0; color: var(--muted); font-size: 9px; }
.folio-reason { padding: 17px 22px; display: grid; grid-template-columns: 125px 1fr; gap: 25px; border-bottom: 1px solid var(--line); }
.folio-reason span { color: var(--muted); font-size: 9px; }
.folio-reason p { margin: 0; color: #40495a; font-size: 11px; line-height: 1.7; }
.approval-track { padding: 20px 22px; display: grid; grid-template-columns: repeat(3, 1fr); border-bottom: 1px solid var(--line); }
.approval-track > div { min-height: 55px; display: grid; grid-template-columns: 36px 1fr; align-content: center; position: relative; }
.approval-track > div:not(:last-child)::after { content:""; position:absolute; left:30px; right:8px; top:17px; height:1px; background:#d9dee7; }
.approval-track i { width: 34px; height: 34px; display: grid; place-items: center; z-index:1; color:#8d95a4; border:1px solid #cfd5df; border-radius:50%; background:white; font:normal 700 10px Consolas,monospace; }
.approval-track b, .approval-track small { grid-column: 2; padding-left: 8px; }
.approval-track b { margin-top: -31px; font-size: 11px; }
.approval-track small { margin-top: 4px; color: var(--muted); font-size: 8px; }
.approval-track .done i { color:white; border-color:var(--teal); background:var(--teal); }
.approval-track .done::after { background:var(--teal) !important; }
.approval-track .active i { color:white; border-color:var(--indigo); background:var(--indigo); box-shadow:0 0 0 4px #e9edf7; }
.approval-track .stopped i { color:white; border-color:#b44c4c; background:#b44c4c; }
.status-folio-list article > footer { min-height: 65px; padding: 12px 22px; display:flex; align-items:center; justify-content:space-between; gap:18px; background:#fafbfc; }
.review-comment span, .review-comment b { display:block; }
.review-comment span { color:var(--muted); font-size:8px; }
.review-comment b { margin-top:5px; color:#515a6c; font-size:10px; }
.folio-actions { display:flex; gap:8px; }
.folio-actions .el-button + .el-button { margin-left:0; }
.review-target { margin-bottom: 18px; padding: 16px 18px; border-left: 3px solid var(--teal); background:#f5f7fa; }
.review-target span { color:var(--teal); font:700 9px Consolas,monospace; }
.review-target b { display:block; margin:6px 0 4px; font-size:14px; }
.review-target p { margin:0; color:var(--muted); font-size:9px; }
.graduation-batch-strip { padding:10px; display:flex; gap:9px; overflow-x:auto; border:1px solid var(--line); background:white; }
.graduation-batch-strip button { flex:0 0 290px; padding:14px; display:grid; grid-template-columns:1fr auto; gap:6px; text-align:left; border:1px solid var(--line); background:#fafbfc; }
.graduation-batch-strip button.active { color:white; border-color:var(--indigo); background:var(--indigo); }
.graduation-batch-strip b { grid-column:1/-1; font:600 15px "STZhongsong","Songti SC",serif; }
.graduation-batch-strip span,.graduation-batch-strip small,.graduation-batch-strip i { color:#8b93a2; font-size:9px; font-style:normal; }
.graduation-batch-strip button.active span,.graduation-batch-strip button.active small { color:#c5cee5; }
.graduation-workbench { border:1px solid var(--line); background:white; }
.graduation-workbench > header { padding:21px 24px; display:flex; align-items:center; justify-content:space-between; gap:20px; border-bottom:1px solid var(--line); }
.graduation-workbench header span { color:var(--teal); font:700 9px Consolas,monospace; letter-spacing:.13em; }
.graduation-workbench header h3 { margin:7px 0 5px; font:600 21px "STZhongsong","Songti SC",serif; }
.graduation-workbench header p { margin:0; color:var(--muted); font-size:9px; }
.graduation-summary { display:grid; grid-template-columns:repeat(4,1fr); color:white; background:linear-gradient(110deg,#172958,#273e7c 70%,#176c70); }
.graduation-summary > div { min-height:82px; padding:17px 21px; border-right:1px solid rgba(255,255,255,.12); }
.graduation-summary span { display:block; color:#aeb8d7; font-size:8px; }
.graduation-summary b { display:inline-block; margin-top:10px; color:#66d9ca; font:700 24px/1 Consolas,monospace; }
.graduation-summary small { margin-left:5px; color:#bfc8df; font-size:8px; }
.graduation-filter { padding:13px 18px; display:flex; align-items:center; gap:9px; border-bottom:1px solid var(--line); background:#fafbfc; }
.graduation-filter .el-input { width:300px; }
.graduation-filter .el-select { width:155px; }
.graduation-filter > span { margin-left:auto; color:var(--muted); font-size:9px; }
.graduation-result-list { padding:0 20px 18px; }
.graduation-result-list article { min-height:105px; display:grid; grid-template-columns:minmax(200px,1.2fr) 170px 185px 130px 70px; align-items:center; gap:20px; border-bottom:1px solid var(--line); }
.graduation-student span { color:var(--teal); font-size:8px; }
.graduation-student h4 { margin:6px 0 4px; font-size:14px; }
.graduation-student p { margin:0; color:var(--muted); font-size:8px; }
.credit-progress span,.course-clearance span { color:var(--muted); font-size:8px; }
.credit-progress b,.course-clearance b { display:block; margin:7px 0; color:var(--indigo); font:700 15px Consolas,monospace; }
.course-clearance { padding-left:17px; border-left:1px solid var(--line); }
.course-clearance small { display:block; max-width:180px; color:#8e5960; font-size:8px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.graduation-conclusion { padding:12px; display:grid; grid-template-columns:24px 1fr; gap:2px 7px; color:#9a555b; border:1px solid #ead7d9; background:#fffafa; }
.graduation-conclusion.eligible { color:#21796d; border-color:#cbe4df; background:#f6fbfa; }
.graduation-conclusion .el-icon { grid-row:1/3; align-self:center; font-size:20px; }
.graduation-conclusion span { font-size:9px; font-weight:700; }
.graduation-conclusion small { font-size:7px; opacity:.7; }
.graduation-certificate { overflow:hidden; border:1px solid var(--line); background:white; box-shadow:0 12px 35px rgba(24,41,85,.07); }
.graduation-certificate > header { min-height:82px; padding:21px 28px; display:flex; align-items:center; justify-content:space-between; color:white; background:linear-gradient(110deg,#172958,#263e7c 70%,#176c70); }
.graduation-certificate > header span { color:#73dfd1; font:700 10px Consolas,monospace; letter-spacing:.18em; }
.graduation-certificate > header i { font:normal 700 28px "STZhongsong","Songti SC",serif; }
.certificate-person { display:grid; grid-template-columns:repeat(3,1fr); border-bottom:1px solid var(--line); }
.certificate-person > div { padding:20px 28px; border-right:1px solid var(--line); }
.certificate-person span,.certificate-person b { display:block; }
.certificate-person span { color:var(--muted); font-size:8px; }
.certificate-person b { margin-top:7px; font-size:14px; }
.certificate-conclusion { min-height:145px; padding:28px; display:flex; align-items:center; justify-content:center; gap:20px; color:#a14e55; background:#fffafa; border-bottom:1px solid var(--line); }
.certificate-conclusion.eligible { color:#19796b; background:#f5fbf9; }
.certificate-conclusion .el-icon { font-size:48px; }
.certificate-conclusion span { font-size:9px; letter-spacing:.08em; }
.certificate-conclusion h3 { margin:7px 0 5px; font:600 27px "STZhongsong","Songti SC",serif; }
.certificate-conclusion p { margin:0; color:var(--muted); font-size:9px; }
.certificate-metrics { display:grid; grid-template-columns:repeat(3,1fr); }
.certificate-metrics > div { min-height:115px; padding:23px 28px; border-right:1px solid var(--line); }
.certificate-metrics span { display:block; color:var(--muted); font-size:8px; }
.certificate-metrics b { display:block; margin:10px 0; color:var(--indigo); font:700 22px Consolas,monospace; }
.certificate-metrics p { margin:0; color:var(--muted); font-size:8px; }
.graduation-certificate > footer { padding:20px 28px; display:grid; gap:13px; background:#fafbfc; }
.graduation-certificate > footer span,.graduation-certificate > footer b { display:block; }
.graduation-certificate > footer span { color:var(--muted); font-size:8px; }
.graduation-certificate > footer b { margin-top:5px; font-size:10px; }
.degree-batch-strip { padding:10px; display:flex; gap:9px; overflow-x:auto; border:1px solid var(--line); background:white; }
.degree-batch-strip button { flex:0 0 290px; padding:14px; display:grid; grid-template-columns:1fr auto; gap:6px; text-align:left; border:1px solid var(--line); background:#fafbfc; }
.degree-batch-strip button.active { color:white; border-color:#8b692d; background:linear-gradient(120deg,#263968,#8b692d); }
.degree-batch-strip b { grid-column:1/-1; font:600 15px "STZhongsong","Songti SC",serif; }
.degree-batch-strip span,.degree-batch-strip small,.degree-batch-strip i { color:#8b93a2; font-size:9px; font-style:normal; }
.degree-batch-strip button.active span,.degree-batch-strip button.active small { color:#e3d8bd; }
.degree-workbench { border:1px solid var(--line); background:white; }
.degree-workbench > header { padding:21px 24px; display:flex; align-items:center; justify-content:space-between; gap:20px; border-bottom:1px solid var(--line); }
.degree-workbench header span { color:#a27b36; font:700 9px Consolas,monospace; letter-spacing:.13em; }
.degree-workbench header h3 { margin:7px 0 5px; font:600 21px "STZhongsong","Songti SC",serif; }
.degree-workbench header p { margin:0; color:var(--muted); font-size:9px; }
.degree-summary { display:grid; grid-template-columns:repeat(4,1fr); color:white; background:linear-gradient(110deg,#182957,#2d4178 67%,#8b692d); }
.degree-summary > div { min-height:82px; padding:17px 21px; border-right:1px solid rgba(255,255,255,.12); }
.degree-summary span { display:block; color:#c3c9dc; font-size:8px; }
.degree-summary b { display:block; margin-top:10px; color:#e5c87f; font:700 24px Consolas,monospace; }
.degree-result-list { padding:0 20px 18px; }
.degree-result-list article { min-height:105px; display:grid; grid-template-columns:minmax(190px,1.2fr) 150px minmax(180px,1fr) 125px 70px; align-items:center; gap:20px; border-bottom:1px solid var(--line); }
.degree-result-list article > div:first-child span { color:#a27b36; font-size:8px; }
.degree-result-list h4 { margin:6px 0 4px; font-size:14px; }
.degree-result-list p { margin:0; color:var(--muted); font-size:8px; }
.degree-result-list article > div:nth-child(2) span,.degree-rule-note span { color:var(--muted); font-size:8px; }
.degree-result-list article > div:nth-child(2) b { display:block; margin:7px 0; color:var(--indigo); font:700 20px Consolas,monospace; }
.degree-result-list article > div:nth-child(2) small { color:var(--muted); font-size:8px; }
.degree-rule-note { padding-left:16px; border-left:1px solid var(--line); }
.degree-rule-note b { display:block; margin-top:7px; color:#5f6879; font-size:9px; line-height:1.5; }
.degree-result-chip { padding:11px; display:grid; grid-template-columns:22px 1fr; color:#9a555b; border:1px solid #ead7d9; background:#fffafa; }
.degree-result-chip.granted { color:#7b622d; border-color:#dfd1aa; background:#fdfbf5; }
.degree-result-chip .el-icon { grid-row:1/3; align-self:center; font-size:17px; }
.degree-result-chip b { font-size:9px; }
.degree-result-chip small { font-size:7px; opacity:.7; }
.degree-certificate { min-height:380px; display:grid; grid-template-columns:220px 1fr; border:1px solid var(--line); background:white; box-shadow:0 12px 35px rgba(24,41,85,.07); }
.degree-seal { display:grid; place-content:center; gap:16px; text-align:center; color:#c7a75d; background:linear-gradient(150deg,#172858,#2b3f76 70%,#80622b); }
.degree-seal .el-icon { margin:auto; font-size:70px; }
.degree-seal span { font:700 9px Consolas,monospace; letter-spacing:.17em; }
.degree-copy { padding:45px 48px; }
.degree-copy > span { color:#a27b36; font:700 9px Consolas,monospace; letter-spacing:.12em; }
.degree-copy h3 { margin:15px 0 10px; color:#9c555b; font:600 34px "STZhongsong","Songti SC",serif; }
.degree-certificate.granted .degree-copy h3 { color:#80652c; }
.degree-copy > p { margin:0; color:#596274; font-size:12px; }
.degree-copy dl { margin:32px 0; display:grid; grid-template-columns:repeat(3,1fr); border:1px solid var(--line); }
.degree-copy dl div { padding:17px 20px; border-right:1px solid var(--line); }
.degree-copy dt { color:var(--muted); font-size:8px; }
.degree-copy dd { margin:7px 0 0; color:var(--indigo); font:700 16px Consolas,monospace; }
.degree-copy footer { padding-top:18px; border-top:1px solid var(--line); }
.degree-copy footer span,.degree-copy footer b { display:block; }
.degree-copy footer span { color:var(--muted); font-size:8px; }
.degree-copy footer b { margin-top:7px; font-size:10px; }
.clearance-batch-strip { padding:10px; display:flex; gap:9px; overflow-x:auto; border:1px solid var(--line); background:white; }
.clearance-batch-strip button { flex:0 0 300px; padding:14px; display:grid; grid-template-columns:1fr auto; gap:6px; text-align:left; border:1px solid var(--line); background:#fafbfc; }
.clearance-batch-strip button.active { color:white; border-color:var(--indigo); background:linear-gradient(120deg,#182958,#234477 66%,#19736f); }
.clearance-batch-strip b { grid-column:1/-1; font:600 15px "STZhongsong","Songti SC",serif; }
.clearance-batch-strip span,.clearance-batch-strip small,.clearance-batch-strip i { color:#8b93a2; font-size:9px; font-style:normal; }
.clearance-batch-strip button.active span,.clearance-batch-strip button.active small { color:#c4d6dd; }
.clearance-workbench { border:1px solid var(--line); background:white; }
.clearance-workbench > header { padding:21px 24px; display:flex; align-items:center; justify-content:space-between; gap:20px; border-bottom:1px solid var(--line); }
.clearance-workbench header span { color:var(--teal); font:700 9px Consolas,monospace; letter-spacing:.13em; }
.clearance-workbench header h3 { margin:7px 0 5px; font:600 21px "STZhongsong","Songti SC",serif; }
.clearance-workbench header p { margin:0; color:var(--muted); font-size:9px; }
.clearance-item-legend { display:flex; overflow-x:auto; color:white; background:linear-gradient(105deg,#182958,#29417c 70%,#19736f); }
.clearance-item-legend > div { flex:1 0 180px; min-height:80px; padding:16px 20px; border-right:1px solid rgba(255,255,255,.12); }
.clearance-item-legend span,.clearance-item-legend b,.clearance-item-legend small { display:block; }
.clearance-item-legend span { color:#64d7c8; font:700 8px Consolas,monospace; }
.clearance-item-legend b { margin:7px 0 5px; font-size:10px; }
.clearance-item-legend small { color:#bfc9e1; font-size:8px; }
.clearance-ledgers { padding:18px; display:grid; gap:14px; background:#f5f7fa; }
.clearance-ledgers > article { border:1px solid var(--line); background:white; }
.clearance-ledgers article > header { padding:16px 20px; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--line); }
.clearance-ledgers header span { color:var(--teal); font-size:8px; }
.clearance-ledgers header h4 { margin:5px 0 3px; font-size:14px; }
.clearance-ledgers header p { margin:0; color:var(--muted); font-size:8px; }
.clearance-ledgers header > b { color:var(--indigo); font:700 22px Consolas,monospace; }
.clearance-record-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(190px,1fr)); }
.clearance-record-grid > div { min-height:125px; padding:18px; display:grid; grid-template-columns:24px 1fr; align-content:start; border-right:1px solid var(--line); border-bottom:1px solid var(--line); }
.clearance-record-grid .el-icon { grid-row:1/4; color:#bbc1cc; font-size:18px; }
.clearance-record-grid > div.done .el-icon { color:var(--teal); }
.clearance-record-grid span { color:var(--muted); font-size:8px; }
.clearance-record-grid b { margin:6px 0 4px; font-size:10px; }
.clearance-record-grid small { color:#a06a6e; font-size:8px; }
.clearance-record-grid > div.done small { color:#318276; }
.clearance-record-grid > div > div { grid-column:1/-1; margin-top:13px; }
.clearance-record-grid .el-button + .el-button { margin-left:5px; }
.clearance-pass { border:1px solid var(--line); background:white; }
.clearance-pass > header { min-height:125px; padding:25px 28px; display:flex; align-items:center; justify-content:space-between; color:white; background:linear-gradient(115deg,#182958,#2a407c 68%,#19736f); }
.clearance-pass header span { color:#67d9ca; font:700 9px Consolas,monospace; letter-spacing:.14em; }
.clearance-pass header h3 { margin:9px 0 6px; font:600 24px "STZhongsong","Songti SC",serif; }
.clearance-pass header p { margin:0; color:#c1cce3; font-size:9px; }
.clearance-pass header > b { font:700 30px Consolas,monospace; }
.clearance-student-list { padding:12px 26px 25px; }
.clearance-student-list article { min-height:90px; display:grid; grid-template-columns:45px 1fr auto; align-items:center; gap:16px; position:relative; border-bottom:1px solid var(--line); }
.clearance-student-list article:not(:last-child)::after { content:""; position:absolute; left:17px; top:62px; bottom:-29px; width:1px; background:#d8dde6; }
.clearance-student-list i { width:35px; height:35px; display:grid; place-items:center; z-index:1; color:#8c94a3; border:1px solid #cfd5df; border-radius:50%; background:white; font:normal 700 10px Consolas,monospace; }
.clearance-student-list article.done i { color:white; border-color:var(--teal); background:var(--teal); }
.clearance-student-list article > div span { color:var(--teal); font-size:8px; }
.clearance-student-list h4 { margin:5px 0 3px; font-size:12px; }
.clearance-student-list p { margin:0; color:var(--muted); font-size:8px; }
.clearance-form-head { margin:10px 0; display:flex; align-items:center; justify-content:space-between; }
.clearance-form-list { display:grid; gap:8px; max-height:340px; overflow-y:auto; }
.clearance-form-list > div { display:grid; grid-template-columns:110px 1fr 1fr 130px 80px 45px; gap:8px; align-items:center; }
.login-page { min-height: 100vh; display: grid; grid-template-columns: minmax(440px, 1.2fr) minmax(420px, .8fr); background: white; }
.login-story { min-height: 100vh; padding: 54px clamp(45px, 6vw, 90px); display: flex; flex-direction: column; color: white; background: linear-gradient(142deg, #13224d, #243a77 62%, #176b71); overflow: hidden; position: relative; }
.login-story::before { content: ""; position: absolute; inset: 0; opacity: .28; background-image: linear-gradient(rgba(255,255,255,.06) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.06) 1px, transparent 1px); background-size: 46px 46px; }
@@ -698,6 +913,48 @@ button { cursor: pointer; }
.exam-staff { padding:10px 0 0; border-left:none; border-top:1px solid var(--line); }
.exam-row-actions { grid-column:auto; padding:0; }
.exam-ticket-grid { grid-template-columns:1fr; }
.status-identity { align-items:flex-start; flex-direction:column; }
.status-available { width:100%; min-width:0; }
.status-review-summary { grid-template-columns:repeat(3,1fr); }
.status-review-summary > div { min-height:86px; padding:16px; }
.status-review-summary p { grid-column:1/-1; padding:16px; border-top:1px solid rgba(255,255,255,.12); }
.status-folio-list article > header { grid-template-columns:1fr auto; gap:14px; }
.folio-number { grid-column:1/-1; display:block; padding:0 0 12px; border-right:none; border-bottom:1px dashed var(--line); }
.folio-number b { margin-left:10px; }
.folio-reason { grid-template-columns:1fr; gap:7px; }
.approval-track { grid-template-columns:1fr; gap:13px; }
.approval-track > div:not(:last-child)::after { left:17px; right:auto; top:31px; bottom:-14px; width:1px; height:auto; }
.status-folio-list article > footer { align-items:stretch; flex-direction:column; }
.folio-actions, .folio-actions .el-button { width:100%; }
.graduation-workbench > header { align-items:flex-start; flex-direction:column; }
.graduation-summary { grid-template-columns:1fr 1fr; }
.graduation-summary > div:nth-child(2) { border-right:none; }
.graduation-filter { flex-wrap:wrap; }
.graduation-filter .el-input { width:100%; }
.graduation-filter .el-select { flex:1; width:auto; }
.graduation-result-list article { padding:16px 0; grid-template-columns:1fr 1fr; gap:13px; }
.graduation-student { grid-column:1/-1; }
.graduation-result-list article > .el-button { justify-self:end; }
.certificate-person,.certificate-metrics { grid-template-columns:1fr; }
.certificate-person > div,.certificate-metrics > div { border-right:none; border-bottom:1px solid var(--line); }
.certificate-conclusion { justify-content:flex-start; padding:24px 20px; }
.degree-workbench > header { align-items:flex-start; flex-direction:column; }
.degree-summary { grid-template-columns:1fr 1fr; }
.degree-result-list article { padding:16px 0; grid-template-columns:1fr 1fr; gap:13px; }
.degree-result-list article > div:first-child { grid-column:1/-1; }
.degree-rule-note { padding-left:0; border-left:none; }
.degree-result-list article > .el-button { justify-self:end; }
.degree-certificate { grid-template-columns:1fr; }
.degree-seal { min-height:150px; }
.degree-seal .el-icon { font-size:48px; }
.degree-copy { padding:28px 22px; }
.degree-copy dl { grid-template-columns:1fr; }
.degree-copy dl div { border-right:none; border-bottom:1px solid var(--line); }
.clearance-workbench > header { align-items:flex-start; flex-direction:column; }
.clearance-ledgers { padding:10px; }
.clearance-record-grid { grid-template-columns:1fr; }
.clearance-pass > header { align-items:flex-start; gap:18px; }
.clearance-form-list > div { grid-template-columns:1fr 1fr; padding:10px; border:1px solid var(--line); }
.el-drawer { width: 100% !important; }
}
+25 -1
View File
@@ -122,13 +122,33 @@ onMounted(async () => {
<b>{{ data.counts.examPlans ?? 0 }} 个计划 · {{ data.counts.examSessions ?? 0 }} 个场次</b>
<i class="done">已建立</i>
</div>
<div>
<span>学籍异动</span>
<b>{{ data.counts.studentStatusChanges ?? 0 }} 项申请 · {{ data.counts.pendingStudentStatusChanges ?? 0 }} 项审核中</b>
<i class="done">已建立</i>
</div>
<div>
<span>毕业审核</span>
<b>{{ data.counts.graduationAuditBatches ?? 0 }} 个批次 · {{ data.counts.publishedGraduationAuditBatches ?? 0 }} 个已发布</b>
<i class="done">已建立</i>
</div>
<div>
<span>学位授予</span>
<b>{{ data.counts.degreeAwardBatches ?? 0 }} 个批次 · {{ data.counts.publishedDegreeAwardBatches ?? 0 }} 个已发布</b>
<i class="done">已建立</i>
</div>
<div>
<span>毕业离校</span>
<b>{{ data.counts.graduationClearanceBatches ?? 0 }} 个批次 · {{ data.counts.openGraduationClearanceBatches ?? 0 }} 个办理中</b>
<i class="done">已建立</i>
</div>
</div>
</article>
<article class="work-card phase-card">
<span class="section-kicker">NEXT MILESTONE</span>
<h3>下一段业务链</h3>
<p>考试计划考场监考与个人日程已就绪下一步进入学籍异动与毕业审核</p>
<p>毕业离校事项配置责任角色分工学生进度与批次关闭已就绪核心教务业务链已形成闭环</p>
<div class="phase-line">
<span class="active">基础底座</span>
<span class="active">人员档案</span>
@@ -137,6 +157,10 @@ onMounted(async () => {
<span class="active">学生选课</span>
<span class="active">成绩管理</span>
<span class="active">考试考场</span>
<span class="active">学籍异动</span>
<span class="active">毕业审核</span>
<span class="active">学位授予</span>
<span class="active">毕业离校</span>
</div>
</article>
</section>
+201
View File
@@ -0,0 +1,201 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Check, Close, Medal, Plus, Refresh, Stamp } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth'
const auth = useAuthStore()
const isStudent = computed(() => auth.user?.roles.includes('Student') ?? false)
const isPublisher = computed(() =>
auth.user?.roles.some((role) => ['SuperAdmin', 'AcademicAdmin'].includes(role)) ?? false)
const loading = ref(false)
const batches = ref<any[]>([])
const selected = ref<any | null>(null)
const myResult = ref<any | null>(null)
const createDialog = ref(false)
const reviewDialog = ref(false)
const reviewTarget = ref<any | null>(null)
const keyword = ref('')
const conclusionFilter = ref('')
const batchForm = reactive({
name: '2030届学士学位授予审核',
graduationYear: 2030,
degreeName: '工学学士',
minimumGradePoint: 2,
notes: '',
})
const reviewForm = reactive({ conclusion: 'NotGranted', comment: '' })
const conclusionLabels: Record<string, string> = {
Granted: '建议授予',
NotGranted: '暂不授予',
}
const filteredResults = computed(() => {
const q = keyword.value.trim().toLowerCase()
return (selected.value?.results ?? []).filter((x: any) =>
(!conclusionFilter.value || x.conclusion === conclusionFilter.value) &&
(!q || `${x.studentNumber}${x.name}${x.className}${x.majorName}`.toLowerCase().includes(q)))
})
function dateText(value?: string) {
if (!value) return '—'
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', hour12: false,
}).format(new Date(value))
}
async function load() {
loading.value = true
try {
if (isStudent.value) {
myResult.value = (await http.get('/degree-awards/my-result')).data
return
}
batches.value = (await http.get('/degree-awards/batches')).data
const batch = batches.value.find((x) => x.id === selected.value?.id) ?? batches.value[0]
if (batch) await selectBatch(batch.id)
else selected.value = null
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
loading.value = false
}
}
async function selectBatch(id: string) {
selected.value = (await http.get(`/degree-awards/batches/${id}`)).data
}
async function createBatch() {
try {
const response = await http.post('/degree-awards/batches', batchForm)
createDialog.value = false
await http.post(`/degree-awards/batches/${response.data.id}/calculate`)
ElMessage.success('授予批次已建立,并完成首次规则计算。')
await load()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function calculate() {
try {
await ElMessageBox.confirm('重新计算会覆盖当前人工复核结论。', '重新计算学位资格', {
type: 'warning', confirmButtonText: '重新计算',
})
await http.post(`/degree-awards/batches/${selected.value.id}/calculate`)
await load()
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
function openReview(row: any) {
reviewTarget.value = row
reviewForm.conclusion = row.conclusion
reviewForm.comment = row.reviewComment ?? ''
reviewDialog.value = true
}
async function saveReview() {
if (reviewForm.comment.trim().length < 5) {
ElMessage.warning('复核意见至少填写 5 个字。')
return
}
try {
await http.put(`/degree-awards/results/${reviewTarget.value.id}`, reviewForm)
reviewDialog.value = false
ElMessage.success('学位复核结论已保存。')
await selectBatch(selected.value.id)
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function publish() {
try {
await ElMessageBox.confirm('发布后授予结论将锁定并开放学生查询。', '发布学位授予结果', {
type: 'warning', confirmButtonText: '确认发布',
})
await http.post(`/degree-awards/batches/${selected.value.id}/publish`)
await load()
ElMessage.success('学位授予结果已发布。')
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
onMounted(load)
</script>
<template>
<div class="page-stack degree-page">
<section class="page-intro">
<div>
<span class="section-kicker">DEGREE CONFERRAL</span>
<h2>{{ isStudent ? '我的学位授予结果' : '学位授予审核' }}</h2>
<p>{{ isStudent ? '查看学校正式发布的学位授予结论。' : '以已发布毕业资格为前置条件,结合正式成绩加权平均绩点生成授予建议。' }}</p>
</div>
<el-button v-if="isPublisher && !isStudent" type="primary" :icon="Plus" @click="createDialog = true">新建授予批次</el-button>
<el-button v-else :icon="Refresh" @click="load">刷新</el-button>
</section>
<template v-if="isStudent">
<section v-if="myResult" class="degree-certificate" :class="{ granted: myResult.conclusion === 'Granted' }" v-loading="loading">
<div class="degree-seal"><el-icon><Medal /></el-icon><span>DEGREE CONFERRAL</span></div>
<div class="degree-copy">
<span>{{ myResult.graduationYear }} · {{ myResult.batchName }}</span>
<h3>{{ conclusionLabels[myResult.conclusion] }}</h3>
<p>{{ myResult.name }}{{ myResult.studentNumber }}{{ myResult.majorName }}</p>
<dl><div><dt>学位名称</dt><dd>{{ myResult.degreeName }}</dd></div><div><dt>平均绩点</dt><dd>{{ myResult.averageGradePoint }}</dd></div><div><dt>最低要求</dt><dd>{{ myResult.minimumGradePoint }}</dd></div></dl>
<footer><span>发布于 {{ dateText(myResult.publishedAt) }}</span><b>{{ myResult.reviewComment || myResult.exceptionReason || '符合批次授予规则' }}</b></footer>
</div>
</section>
<el-empty v-else v-loading="loading" description="学校尚未发布你的学位授予结果" />
</template>
<template v-else>
<section class="degree-batch-strip">
<button v-for="batch in batches" :key="batch.id" :class="{ active: selected?.id === batch.id }" @click="selectBatch(batch.id)">
<span>{{ batch.graduationYear }} · {{ batch.degreeName }}</span><b>{{ batch.name }}</b>
<small>{{ batch.resultCount }} · {{ batch.grantedCount }} 人建议授予</small><i>{{ batch.status === 'Published' ? '已发布' : '审核中' }}</i>
</button>
</section>
<section v-if="selected" class="degree-workbench" v-loading="loading">
<header>
<div><span>CONFERRAL REGISTER</span><h3>{{ selected.name }}</h3><p>{{ selected.degreeName }} · 最低平均绩点 {{ selected.minimumGradePoint }} · 计算于 {{ dateText(selected.calculatedAt) }}</p></div>
<div v-if="selected.status === 'Draft' && isPublisher"><el-button :icon="Refresh" @click="calculate">重新计算</el-button><el-button type="primary" :icon="Stamp" @click="publish">发布结果</el-button></div>
<el-tag v-else type="success" effect="plain">授予结果已锁定</el-tag>
</header>
<div class="degree-summary">
<div><span>参审人数</span><b>{{ selected.results.length }}</b></div>
<div><span>建议授予</span><b>{{ selected.results.filter((x: any) => x.conclusion === 'Granted').length }}</b></div>
<div><span>暂不授予</span><b>{{ selected.results.filter((x: any) => x.conclusion === 'NotGranted').length }}</b></div>
<div><span>人工调整</span><b>{{ selected.results.filter((x: any) => x.isOverridden).length }}</b></div>
</div>
<div class="graduation-filter">
<el-input v-model="keyword" clearable placeholder="搜索学号、姓名、专业或班级" />
<el-select v-model="conclusionFilter" clearable placeholder="全部结论"><el-option label="建议授予" value="Granted" /><el-option label="暂不授予" value="NotGranted" /></el-select>
<span>显示 {{ filteredResults.length }} </span>
</div>
<div class="degree-result-list">
<article v-for="row in filteredResults" :key="row.id">
<div><span>{{ row.studentNumber }} · {{ row.className }}</span><h4>{{ row.name }}</h4><p>{{ row.majorName }}</p></div>
<div><span>加权平均绩点</span><b>{{ row.averageGradePoint }}</b><small>批次要求 {{ selected.minimumGradePoint }}</small></div>
<div class="degree-rule-note"><span>规则说明</span><b>{{ row.exceptionReason || '毕业资格与绩点均符合' }}</b></div>
<div class="degree-result-chip" :class="{ granted: row.conclusion === 'Granted' }"><el-icon><Check v-if="row.conclusion === 'Granted'" /><Close v-else /></el-icon><b>{{ conclusionLabels[row.conclusion] }}</b><small>{{ row.isOverridden ? '人工复核' : '规则计算' }}</small></div>
<el-button v-if="selected.status === 'Draft'" link type="primary" @click="openReview(row)">人工复核</el-button>
</article>
<el-empty v-if="!filteredResults.length" description="没有符合筛选条件的授予记录" />
</div>
</section>
<el-empty v-else v-loading="loading" description="尚未建立学位授予批次" />
</template>
<el-dialog v-model="createDialog" title="新建学位授予批次" width="640px">
<el-form label-position="top">
<el-form-item label="批次名称"><el-input v-model="batchForm.name" /></el-form-item>
<div class="form-grid three"><el-form-item label="毕业年份"><el-input-number v-model="batchForm.graduationYear" :min="2000" :max="2200" /></el-form-item><el-form-item label="学位名称"><el-input v-model="batchForm.degreeName" /></el-form-item><el-form-item label="最低平均绩点"><el-input-number v-model="batchForm.minimumGradePoint" :min="0" :max="5" :step="0.1" /></el-form-item></div>
<el-form-item label="说明"><el-input v-model="batchForm.notes" type="textarea" :rows="3" /></el-form-item>
</el-form>
<template #footer><el-button @click="createDialog=false">取消</el-button><el-button type="primary" @click="createBatch">建立并计算</el-button></template>
</el-dialog>
<el-dialog v-model="reviewDialog" title="人工复核学位授予结论" width="620px">
<div v-if="reviewTarget" class="review-target"><span>{{ reviewTarget.studentNumber }}</span><b>{{ reviewTarget.name }} · 平均绩点 {{ reviewTarget.averageGradePoint }}</b><p>规则结论{{ conclusionLabels[reviewTarget.calculatedConclusion] }}</p></div>
<el-form label-position="top"><el-form-item label="复核结论"><el-radio-group v-model="reviewForm.conclusion"><el-radio-button value="Granted">建议授予</el-radio-button><el-radio-button value="NotGranted">暂不授予</el-radio-button></el-radio-group></el-form-item><el-form-item label="复核意见"><el-input v-model="reviewForm.comment" type="textarea" :rows="4" maxlength="500" show-word-limit /></el-form-item></el-form>
<template #footer><el-button @click="reviewDialog=false">取消</el-button><el-button type="primary" @click="saveReview">保存复核结论</el-button></template>
</el-dialog>
</div>
</template>
+248
View File
@@ -0,0 +1,248 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Check, DocumentChecked, Plus, Refresh, Stamp } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth'
const auth = useAuthStore()
const isStudent = computed(() => auth.user?.roles.includes('Student') ?? false)
const isPublisher = computed(() =>
auth.user?.roles.some((role) => ['SuperAdmin', 'AcademicAdmin'].includes(role)) ?? false)
const batches = ref<any[]>([])
const selected = ref<any | null>(null)
const myResult = ref<any | null>(null)
const loading = ref(false)
const createDialog = ref(false)
const decisionDialog = ref(false)
const decisionTarget = ref<any | null>(null)
const keyword = ref('')
const conclusionFilter = ref('')
const batchForm = reactive({
name: '2030届本科生毕业资格审核',
graduationYear: 2030,
enrollmentYear: 2026,
notes: '',
})
const decisionForm = reactive({ conclusion: 'Ineligible', comment: '' })
const statusLabels: Record<string, string> = { Draft: '审核中', Published: '已发布' }
const conclusionLabels: Record<string, string> = { Eligible: '符合毕业条件', Ineligible: '暂不符合' }
const filteredResults = computed(() => {
const q = keyword.value.trim().toLowerCase()
return (selected.value?.results ?? []).filter((x: any) =>
(!conclusionFilter.value || x.conclusion === conclusionFilter.value) &&
(!q || `${x.studentNumber}${x.name}${x.className}${x.majorName}`.toLowerCase().includes(q)))
})
function percent(earned: number, required: number) {
if (!required) return 0
return Math.min(100, Math.round((earned / required) * 100))
}
function dateText(value?: string) {
if (!value) return '—'
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', hour12: false,
}).format(new Date(value))
}
async function load() {
loading.value = true
try {
if (isStudent.value) {
myResult.value = (await http.get('/graduation-audits/my-result')).data
return
}
batches.value = (await http.get('/graduation-audits/batches')).data
const batch = batches.value.find((x) => x.id === selected.value?.id) ?? batches.value[0]
if (batch) await selectBatch(batch.id)
else selected.value = null
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
loading.value = false
}
}
async function selectBatch(id: string) {
selected.value = (await http.get(`/graduation-audits/batches/${id}`)).data
}
async function createBatch() {
if (!batchForm.name.trim()) return
try {
const response = await http.post('/graduation-audits/batches', batchForm)
createDialog.value = false
await http.post(`/graduation-audits/batches/${response.data.id}/calculate`)
ElMessage.success('审核批次已建立,并完成首次资格计算。')
await load()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function calculate() {
try {
await ElMessageBox.confirm('重新计算会覆盖当前人工调整结果,是否继续?', '重新计算资格', {
type: 'warning', confirmButtonText: '重新计算',
})
await http.post(`/graduation-audits/batches/${selected.value.id}/calculate`)
ElMessage.success('已按最新培养方案与发布成绩重新计算。')
await load()
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
function openDecision(row: any) {
decisionTarget.value = row
decisionForm.conclusion = row.conclusion
decisionForm.comment = row.reviewComment ?? ''
decisionDialog.value = true
}
async function saveDecision() {
if (decisionForm.comment.trim().length < 5) {
ElMessage.warning('人工复核意见至少填写 5 个字。')
return
}
try {
await http.put(`/graduation-audits/results/${decisionTarget.value.id}`, decisionForm)
decisionDialog.value = false
ElMessage.success('人工复核结论已保存。')
await selectBatch(selected.value.id)
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function publish() {
try {
await ElMessageBox.confirm(
'发布后结果不可修改;符合条件的在籍学生将同步为“毕业”状态。',
'发布毕业审核结果',
{ type: 'warning', confirmButtonText: '确认发布' },
)
await http.post(`/graduation-audits/batches/${selected.value.id}/publish`)
ElMessage.success('毕业审核结果已正式发布。')
await load()
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
onMounted(load)
</script>
<template>
<div class="page-stack graduation-page">
<section class="page-intro">
<div>
<span class="section-kicker">DEGREE CLEARANCE</span>
<h2>{{ isStudent ? '我的毕业资格' : '毕业资格审核' }}</h2>
<p>{{ isStudent ? '查看学校正式发布的毕业资格审核结论和未完成项目。' : '以培养方案和已发布成绩为依据批量计算,支持人工复核并形成不可变发布结果。' }}</p>
</div>
<el-button v-if="isPublisher && !isStudent" type="primary" :icon="Plus" @click="createDialog = true">新建审核批次</el-button>
<el-button v-else :icon="Refresh" @click="load">刷新</el-button>
</section>
<template v-if="isStudent">
<section v-if="myResult" class="graduation-certificate" v-loading="loading">
<header>
<span>OFFICIAL DEGREE CLEARANCE</span>
<i>{{ myResult.graduationYear }}</i>
</header>
<div class="certificate-person">
<div><span>学生姓名</span><b>{{ myResult.name }}</b></div>
<div><span>学号</span><b>{{ myResult.studentNumber }}</b></div>
<div><span>专业</span><b>{{ myResult.majorName }}</b></div>
</div>
<div class="certificate-conclusion" :class="{ eligible: myResult.conclusion === 'Eligible' }">
<el-icon><DocumentChecked /></el-icon>
<div><span>毕业资格审核结论</span><h3>{{ conclusionLabels[myResult.conclusion] }}</h3><p>{{ myResult.batchName }} · 发布于 {{ dateText(myResult.publishedAt) }}</p></div>
</div>
<div class="certificate-metrics">
<div><span>学分完成</span><b>{{ myResult.earnedCredits }} / {{ myResult.requiredCredits }}</b><el-progress :percentage="percent(myResult.earnedCredits, myResult.requiredCredits)" :show-text="false" /></div>
<div><span>必修课程</span><b>{{ myResult.passedRequiredCourseCount }} / {{ myResult.requiredCourseCount }}</b><p>已通过 / 应通过</p></div>
<div><span>未解决不及格</span><b>{{ myResult.failedCourseCount }}</b><p>门课程</p></div>
</div>
<footer>
<div><span>未完成课程</span><b>{{ myResult.missingCourseNames || '无' }}</b></div>
<div v-if="myResult.reviewComment"><span>复核意见</span><b>{{ myResult.reviewComment }}</b></div>
</footer>
</section>
<el-empty v-else v-loading="loading" description="学校尚未发布你的毕业资格审核结果" />
</template>
<template v-else>
<section class="graduation-batch-strip">
<button v-for="batch in batches" :key="batch.id" :class="{ active: selected?.id === batch.id }" @click="selectBatch(batch.id)">
<span>{{ batch.graduationYear }} · {{ batch.enrollmentYear }}</span>
<b>{{ batch.name }}</b>
<small>{{ batch.resultCount }} · {{ batch.eligibleCount }} 人符合</small>
<i>{{ statusLabels[batch.status] }}</i>
</button>
</section>
<section v-if="selected" class="graduation-workbench" v-loading="loading">
<header>
<div><span>AUDIT REGISTER</span><h3>{{ selected.name }}</h3><p>计算时间 {{ dateText(selected.calculatedAt) }} · 规则基于已发布培养方案与成绩</p></div>
<div v-if="selected.status === 'Draft' && isPublisher">
<el-button :icon="Refresh" @click="calculate">重新计算</el-button>
<el-button type="primary" :icon="Stamp" @click="publish">发布结果</el-button>
</div>
<el-tag v-else type="success" effect="plain">结果已锁定</el-tag>
</header>
<div class="graduation-summary">
<div><span>参审学生</span><b>{{ selected.results.length }}</b><small></small></div>
<div><span>符合条件</span><b>{{ selected.results.filter((x: any) => x.conclusion === 'Eligible').length }}</b><small></small></div>
<div><span>暂不符合</span><b>{{ selected.results.filter((x: any) => x.conclusion === 'Ineligible').length }}</b><small></small></div>
<div><span>人工调整</span><b>{{ selected.results.filter((x: any) => x.isOverridden).length }}</b><small></small></div>
</div>
<div class="graduation-filter">
<el-input v-model="keyword" clearable placeholder="搜索学号、姓名、专业或班级" />
<el-select v-model="conclusionFilter" clearable placeholder="全部结论">
<el-option label="符合毕业条件" value="Eligible" />
<el-option label="暂不符合" value="Ineligible" />
</el-select>
<span>显示 {{ filteredResults.length }} </span>
</div>
<div class="graduation-result-list">
<article v-for="row in filteredResults" :key="row.id">
<div class="graduation-student"><span>{{ row.studentNumber }} · {{ row.className }}</span><h4>{{ row.name }}</h4><p>{{ row.majorName }} · {{ row.planName || '未匹配培养方案' }}</p></div>
<div class="credit-progress"><span>学分完成度</span><b>{{ row.earnedCredits }} / {{ row.requiredCredits }}</b><el-progress :percentage="percent(row.earnedCredits, row.requiredCredits)" :show-text="false" /></div>
<div class="course-clearance"><span>必修通过</span><b>{{ row.passedRequiredCourseCount }} / {{ row.requiredCourseCount }}</b><small v-if="row.missingCourseNames">{{ row.missingCourseNames }}</small><small v-else>必修项目已完成</small></div>
<div class="graduation-conclusion" :class="{ eligible: row.conclusion === 'Eligible' }"><el-icon><Check /></el-icon><span>{{ conclusionLabels[row.conclusion] }}</span><small v-if="row.isOverridden">人工复核</small><small v-else>规则计算</small></div>
<el-button v-if="selected.status === 'Draft'" link type="primary" @click="openDecision(row)">人工复核</el-button>
</article>
<el-empty v-if="!filteredResults.length" description="没有符合筛选条件的审核结果" />
</div>
</section>
<el-empty v-else v-loading="loading" description="尚未建立毕业资格审核批次" />
</template>
<el-dialog v-model="createDialog" title="新建毕业资格审核批次" width="620px">
<el-form label-position="top">
<el-form-item label="批次名称"><el-input v-model="batchForm.name" /></el-form-item>
<div class="form-grid">
<el-form-item label="毕业年份"><el-input-number v-model="batchForm.graduationYear" :min="2000" :max="2200" /></el-form-item>
<el-form-item label="目标入学年级"><el-input-number v-model="batchForm.enrollmentYear" :min="2000" :max="2200" /></el-form-item>
</div>
<el-form-item label="说明"><el-input v-model="batchForm.notes" type="textarea" :rows="3" /></el-form-item>
</el-form>
<template #footer><el-button @click="createDialog = false">取消</el-button><el-button type="primary" @click="createBatch">建立并计算</el-button></template>
</el-dialog>
<el-dialog v-model="decisionDialog" title="人工复核毕业资格" width="620px">
<div v-if="decisionTarget" class="review-target">
<span>{{ decisionTarget.studentNumber }}</span><b>{{ decisionTarget.name }} · {{ decisionTarget.majorName }}</b>
<p>规则结论{{ conclusionLabels[decisionTarget.calculatedConclusion] }} · 学分 {{ decisionTarget.earnedCredits }} / {{ decisionTarget.requiredCredits }}</p>
</div>
<el-form label-position="top">
<el-form-item label="复核结论">
<el-radio-group v-model="decisionForm.conclusion">
<el-radio-button value="Eligible">符合毕业条件</el-radio-button>
<el-radio-button value="Ineligible">暂不符合</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="复核意见"><el-input v-model="decisionForm.comment" type="textarea" :rows="4" maxlength="500" show-word-limit /></el-form-item>
</el-form>
<template #footer><el-button @click="decisionDialog = false">取消</el-button><el-button type="primary" @click="saveDecision">保存复核结论</el-button></template>
</el-dialog>
</div>
</template>
+252
View File
@@ -0,0 +1,252 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Check, CircleCheck, Plus, Refresh, Stamp } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth'
const auth = useAuthStore()
const isStudent = computed(() => auth.user?.roles.includes('Student') ?? false)
const isManager = computed(() =>
auth.user?.roles.some((role) => ['SuperAdmin', 'AcademicAdmin'].includes(role)) ?? false)
const batches = ref<any[]>([])
const selected = ref<any | null>(null)
const myClearance = ref<any | null>(null)
const loading = ref(false)
const createDialog = ref(false)
const recordDialog = ref(false)
const recordTarget = ref<any | null>(null)
const recordForm = reactive({ status: 'Completed', notes: '' })
const batchForm = reactive({
name: '2030届毕业生离校手续',
graduationYear: 2030,
notes: '',
items: [
{ code: 'LIBRARY', name: '图书资料归还', responsibleUnit: '图书馆', responsibleRole: 'AcademicAdmin', isRequired: true },
{ code: 'FINANCE', name: '财务费用结清', responsibleUnit: '财务处', responsibleRole: 'AcademicAdmin', isRequired: true },
{ code: 'DORM', name: '宿舍退宿确认', responsibleUnit: '学生工作办公室', responsibleRole: 'Counselor', isRequired: true },
{ code: 'COLLEGE', name: '学院材料归档', responsibleUnit: '所在学院', responsibleRole: 'CollegeAdmin', isRequired: true },
{ code: 'CERTIFICATE', name: '毕业证书领取', responsibleUnit: '教务处', responsibleRole: 'AcademicAdmin', isRequired: false },
],
})
const statusLabels: Record<string, string> = {
Pending: '待办理', Completed: '已完成', Waived: '已豁免',
}
const roleLabels: Record<string, string> = {
AcademicAdmin: '校级办理', CollegeAdmin: '学院办理', Counselor: '辅导员办理',
}
const studentLedgers = computed(() => {
const map = new Map<string, any>()
for (const record of selected.value?.records ?? []) {
if (!map.has(record.studentId)) {
map.set(record.studentId, {
studentId: record.studentId,
studentNumber: record.studentNumber,
name: record.name,
className: record.className,
majorName: record.majorName,
records: [],
})
}
map.get(record.studentId).records.push(record)
}
return [...map.values()]
})
function dateText(value?: string) {
if (!value) return '—'
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', hour12: false,
}).format(new Date(value))
}
function completedCount(records: any[]) {
return records.filter((x) => x.status !== 'Pending').length
}
function canManage(record: any) {
const roles = auth.user?.roles ?? []
return roles.includes('SuperAdmin') || roles.includes(record.responsibleRole)
}
function tagType(status: string) {
if (status === 'Completed') return 'success'
if (status === 'Waived') return 'info'
return 'warning'
}
async function load() {
loading.value = true
try {
if (isStudent.value) {
myClearance.value = (await http.get('/graduation-clearance/my-clearance')).data
return
}
batches.value = (await http.get('/graduation-clearance/batches')).data
const batch = batches.value.find((x) => x.id === selected.value?.id) ?? batches.value[0]
if (batch) await selectBatch(batch.id)
else selected.value = null
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
loading.value = false
}
}
async function selectBatch(id: string) {
selected.value = (await http.get(`/graduation-clearance/batches/${id}`)).data
}
function addItem() {
batchForm.items.push({
code: '', name: '', responsibleUnit: '',
responsibleRole: 'AcademicAdmin', isRequired: true,
})
}
function removeItem(index: number) {
batchForm.items.splice(index, 1)
}
async function createBatch() {
if (!batchForm.items.length || batchForm.items.some((x) =>
!x.code.trim() || !x.name.trim() || !x.responsibleUnit.trim())) {
ElMessage.warning('请完整填写每一项离校事项。')
return
}
try {
const response = await http.post('/graduation-clearance/batches', batchForm)
createDialog.value = false
await http.post(`/graduation-clearance/batches/${response.data.id}/generate`)
ElMessage.success('离校批次已建立,并生成毕业生办理清单。')
await load()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function generate() {
try {
await http.post(`/graduation-clearance/batches/${selected.value.id}/generate`)
ElMessage.success('已补齐最新毕业生的离校办理记录。')
await load()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
function openRecord(record: any, status: string) {
recordTarget.value = record
recordForm.status = status
recordForm.notes = record.notes ?? ''
recordDialog.value = true
}
async function saveRecord() {
try {
await http.put(`/graduation-clearance/records/${recordTarget.value.id}`, recordForm)
recordDialog.value = false
ElMessage.success('离校事项办理状态已更新。')
await selectBatch(selected.value.id)
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function closeBatch() {
try {
await ElMessageBox.confirm('关闭后所有办理记录将锁定,请确认必办事项均已办结。', '关闭离校批次', {
type: 'warning', confirmButtonText: '确认关闭',
})
await http.post(`/graduation-clearance/batches/${selected.value.id}/close`)
ElMessage.success('离校批次已关闭。')
await load()
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
onMounted(load)
</script>
<template>
<div class="page-stack clearance-page">
<section class="page-intro">
<div>
<span class="section-kicker">GRADUATION CLEARANCE</span>
<h2>{{ isStudent ? '我的毕业离校' : '毕业离校办理' }}</h2>
<p>{{ isStudent ? '查看各责任部门的离校手续办理进度。' : '按毕业生生成跨部门事项清单,责任角色分工办理,必办事项全部完成后统一关闭。' }}</p>
</div>
<el-button v-if="isManager && !isStudent" type="primary" :icon="Plus" @click="createDialog = true">新建离校批次</el-button>
<el-button v-else :icon="Refresh" @click="load">刷新</el-button>
</section>
<template v-if="isStudent">
<section v-if="myClearance" class="clearance-pass" v-loading="loading">
<header>
<div><span>LEAVING CAMPUS CHECKLIST</span><h3>{{ myClearance.name }}</h3><p>{{ myClearance.graduationYear }} · {{ myClearance.status === 'Closed' ? '离校手续已完成' : '离校手续办理中' }}</p></div>
<b>{{ completedCount(myClearance.items) }} / {{ myClearance.items.length }}</b>
</header>
<div class="clearance-student-list">
<article v-for="(item, index) in myClearance.items" :key="item.id" :class="{ done: item.status !== 'Pending' }">
<i><el-icon v-if="item.status !== 'Pending'"><Check /></el-icon><span v-else>{{ Number(index) + 1 }}</span></i>
<div><span>{{ item.responsibleUnit }}</span><h4>{{ item.itemName }}</h4><p>{{ item.notes || (item.isRequired ? '必办事项' : '非必办事项') }}</p></div>
<el-tag :type="tagType(item.status)" effect="plain">{{ statusLabels[item.status] }}</el-tag>
</article>
</div>
</section>
<el-empty v-else v-loading="loading" description="暂未生成你的毕业离校办理清单" />
</template>
<template v-else>
<section class="clearance-batch-strip">
<button v-for="batch in batches" :key="batch.id" :class="{ active: selected?.id === batch.id }" @click="selectBatch(batch.id)">
<span>{{ batch.graduationYear }} · {{ batch.itemCount }} 项手续</span><b>{{ batch.name }}</b>
<small>{{ batch.studentCount }} 名毕业生 · {{ batch.completedCount }}/{{ batch.recordCount }} 项已办</small><i>{{ batch.status === 'Closed' ? '已关闭' : '办理中' }}</i>
</button>
</section>
<section v-if="selected" class="clearance-workbench" v-loading="loading">
<header>
<div><span>CLEARANCE LEDGER</span><h3>{{ selected.name }}</h3><p>{{ selected.items.length }} 项离校手续 · {{ studentLedgers.length }} 名毕业生</p></div>
<div v-if="selected.status === 'Open' && isManager"><el-button :icon="Refresh" @click="generate">补齐名单</el-button><el-button type="primary" :icon="Stamp" @click="closeBatch">关闭批次</el-button></div>
<el-tag v-else type="success" effect="plain">批次已锁定</el-tag>
</header>
<div class="clearance-item-legend">
<div v-for="item in selected.items" :key="item.id"><span>{{ item.code }}</span><b>{{ item.name }}</b><small>{{ item.responsibleUnit }} · {{ roleLabels[item.responsibleRole] }}</small></div>
</div>
<div class="clearance-ledgers">
<article v-for="student in studentLedgers" :key="student.studentId">
<header><div><span>{{ student.studentNumber }} · {{ student.className }}</span><h4>{{ student.name }}</h4><p>{{ student.majorName }}</p></div><b>{{ completedCount(student.records) }}/{{ student.records.length }}</b></header>
<div class="clearance-record-grid">
<div v-for="record in student.records" :key="record.id" :class="{ done: record.status !== 'Pending' }">
<el-icon><CircleCheck /></el-icon>
<span>{{ record.responsibleUnit }}</span><b>{{ record.itemName }}</b>
<small>{{ statusLabels[record.status] }}<template v-if="record.completedAt"> · {{ dateText(record.completedAt) }}</template></small>
<div v-if="selected.status === 'Open' && canManage(record)">
<el-button link type="primary" @click="openRecord(record, 'Completed')">办结</el-button>
<el-button link @click="openRecord(record, 'Waived')">豁免</el-button>
<el-button v-if="record.status !== 'Pending'" link type="danger" @click="openRecord(record, 'Pending')">重置</el-button>
</div>
</div>
</div>
</article>
<el-empty v-if="!studentLedgers.length" description="尚未生成毕业生离校办理记录" />
</div>
</section>
<el-empty v-else v-loading="loading" description="尚未建立毕业离校批次" />
</template>
<el-dialog v-model="createDialog" title="新建毕业离校批次" width="900px">
<el-form label-position="top">
<div class="form-grid"><el-form-item label="批次名称"><el-input v-model="batchForm.name" /></el-form-item><el-form-item label="毕业年份"><el-input-number v-model="batchForm.graduationYear" :min="2000" :max="2200" /></el-form-item></div>
<el-form-item label="说明"><el-input v-model="batchForm.notes" /></el-form-item>
<div class="clearance-form-head"><b>离校事项配置</b><el-button :icon="Plus" @click="addItem">增加事项</el-button></div>
<div class="clearance-form-list">
<div v-for="(item, index) in batchForm.items" :key="index">
<el-input v-model="item.code" placeholder="事项编码" />
<el-input v-model="item.name" placeholder="事项名称" />
<el-input v-model="item.responsibleUnit" placeholder="责任部门" />
<el-select v-model="item.responsibleRole"><el-option label="校级教务" value="AcademicAdmin" /><el-option label="学院教务" value="CollegeAdmin" /><el-option label="辅导员" value="Counselor" /></el-select>
<el-switch v-model="item.isRequired" active-text="必办" />
<el-button link type="danger" @click="removeItem(index)">移除</el-button>
</div>
</div>
</el-form>
<template #footer><el-button @click="createDialog=false">取消</el-button><el-button type="primary" @click="createBatch">建立并生成清单</el-button></template>
</el-dialog>
<el-dialog v-model="recordDialog" title="更新离校事项" width="580px">
<div v-if="recordTarget" class="review-target"><span>{{ recordTarget.studentNumber }}</span><b>{{ recordTarget.name }} · {{ recordTarget.itemName }}</b><p>{{ recordTarget.responsibleUnit }}</p></div>
<el-form label-position="top"><el-form-item label="办理状态"><el-radio-group v-model="recordForm.status"><el-radio-button value="Completed">已完成</el-radio-button><el-radio-button value="Waived">已豁免</el-radio-button><el-radio-button value="Pending">重置待办</el-radio-button></el-radio-group></el-form-item><el-form-item label="办理备注"><el-input v-model="recordForm.notes" type="textarea" :rows="4" maxlength="500" show-word-limit /></el-form-item></el-form>
<template #footer><el-button @click="recordDialog=false">取消</el-button><el-button type="primary" @click="saveRecord">保存办理状态</el-button></template>
</el-dialog>
</div>
</template>
+282
View File
@@ -0,0 +1,282 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Check, Close, Plus, RefreshRight, Stamp } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth'
type ChangeState =
| 'Submitted'
| 'CounselorApproved'
| 'CollegeApproved'
| 'Approved'
| 'Rejected'
| 'Cancelled'
const auth = useAuthStore()
const isStudent = computed(() => auth.user?.roles.includes('Student') ?? false)
const changes = ref<any[]>([])
const options = ref<any | null>(null)
const loading = ref(false)
const applyDialog = ref(false)
const reviewDialog = ref(false)
const selected = ref<any | null>(null)
const reviewApproved = ref(true)
const applyForm = reactive({ type: '', reason: '' })
const reviewForm = reactive({ comment: '' })
const typeLabels: Record<string, string> = {
Suspension: '休学',
Resumption: '复学',
Withdrawal: '退学',
}
const statusLabels: Record<string, string> = {
Active: '在籍',
Suspended: '休学',
Withdrawn: '退学',
Graduated: '毕业',
}
const stateLabels: Record<ChangeState, string> = {
Submitted: '待辅导员审核',
CounselorApproved: '待学院审核',
CollegeApproved: '待校级审核',
Approved: '已批准',
Rejected: '已驳回',
Cancelled: '已撤回',
}
const steps = [
{ label: '辅导员审核', state: 'Submitted' },
{ label: '学院审核', state: 'CounselorApproved' },
{ label: '校级审批', state: 'CollegeApproved' },
]
const currentQueue = computed(() => changes.value.filter(canReview))
const finishedCount = computed(() =>
changes.value.filter((x) => ['Approved', 'Rejected', 'Cancelled'].includes(x.state)).length)
function dateText(value?: string) {
if (!value) return '—'
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', hour12: false,
}).format(new Date(value))
}
function stageIndex(state: ChangeState) {
if (state === 'Submitted') return 0
if (state === 'CounselorApproved') return 1
if (state === 'CollegeApproved') return 2
if (state === 'Approved') return 3
return -1
}
function stepClass(change: any, index: number) {
const current = stageIndex(change.state)
return {
done: change.state === 'Approved' || current > index,
active: current === index,
stopped: ['Rejected', 'Cancelled'].includes(change.state) && index === Math.max(current, 0),
}
}
function canReview(change: any) {
const roles = auth.user?.roles ?? []
return (
(change.state === 'Submitted' && roles.includes('Counselor')) ||
(change.state === 'CounselorApproved' && roles.includes('CollegeAdmin')) ||
(change.state === 'CollegeApproved' &&
roles.some((role) => ['AcademicAdmin', 'SuperAdmin'].includes(role)))
)
}
function stateTagType(state: ChangeState) {
if (state === 'Approved') return 'success'
if (state === 'Rejected') return 'danger'
if (state === 'Cancelled') return 'info'
return 'warning'
}
async function load() {
loading.value = true
try {
const requests = [http.get('/student-status-changes')]
if (isStudent.value) requests.push(http.get('/student-status-changes/options'))
const [changeResponse, optionResponse] = await Promise.all(requests)
changes.value = changeResponse.data
if (optionResponse) options.value = optionResponse.data
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
loading.value = false
}
}
function openApply() {
applyForm.type = options.value?.types?.[0] ?? ''
applyForm.reason = ''
applyDialog.value = true
}
async function submitApply() {
if (!applyForm.type || applyForm.reason.trim().length < 10) {
ElMessage.warning('请选择异动类型,并填写至少 10 个字的申请说明。')
return
}
try {
await http.post('/student-status-changes', applyForm)
applyDialog.value = false
ElMessage.success('申请已提交,等待辅导员审核。')
await load()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function cancel(change: any) {
try {
await ElMessageBox.confirm('撤回后本次申请将结束,需要时可重新提交。', '撤回申请', {
type: 'warning', confirmButtonText: '确认撤回',
})
await http.post(`/student-status-changes/${change.id}/cancel`)
ElMessage.success('申请已撤回。')
await load()
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
function openReview(change: any, approved: boolean) {
selected.value = change
reviewApproved.value = approved
reviewForm.comment = ''
reviewDialog.value = true
}
async function submitReview() {
if (!reviewApproved.value && !reviewForm.comment.trim()) {
ElMessage.warning('驳回申请时必须填写审核意见。')
return
}
try {
await http.post(`/student-status-changes/${selected.value.id}/review`, {
approved: reviewApproved.value,
comment: reviewForm.comment,
})
reviewDialog.value = false
ElMessage.success(reviewApproved.value ? '审核已通过,申请进入下一环节。' : '申请已驳回。')
await load()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
onMounted(load)
</script>
<template>
<div class="page-stack status-change-page">
<section class="page-intro">
<div>
<span class="section-kicker">STUDENT RECORD TRANSFER</span>
<h2>{{ isStudent ? '学籍异动申请' : '学籍异动审核' }}</h2>
<p>{{ isStudent ? '在线办理休学、复学或退学申请,清晰跟踪每一级审核进度。' : '按辅导员、学院、学校三级权限逐级审核,最终结果同步学生学籍。' }}</p>
</div>
<el-button
v-if="isStudent"
type="primary"
:icon="Plus"
:disabled="options?.hasPending || !options?.types?.length"
@click="openApply"
>发起申请</el-button>
<el-button v-else :icon="RefreshRight" @click="load">刷新队列</el-button>
</section>
<section v-if="isStudent && options" class="status-identity">
<div>
<span>CURRENT STUDENT STATUS</span>
<b>{{ statusLabels[options.status] }}</b>
<p>{{ options.hasPending ? '当前有申请正在流转,请等待处理。' : '当前可以提交新的学籍异动申请。' }}</p>
</div>
<div class="status-available">
<span>可申请业务</span>
<strong v-for="type in options.types" :key="type">{{ typeLabels[type] }}</strong>
<em v-if="!options.types.length">当前状态无可申请业务</em>
</div>
</section>
<section v-if="!isStudent" class="status-review-summary">
<div><span>当前待我审核</span><b>{{ currentQueue.length }}</b><small></small></div>
<div><span>辖区申请总数</span><b>{{ changes.length }}</b><small></small></div>
<div><span>已结束</span><b>{{ finishedCount }}</b><small></small></div>
<p>系统仅开放当前审核层级的操作所有越级请求都会由服务端拒绝</p>
</section>
<section class="status-folio-list" v-loading="loading">
<article v-for="change in changes" :key="change.id" :class="{ actionable: canReview(change) }">
<header>
<div class="folio-number">
<span>APPLICATION FOLIO</span>
<b>{{ change.studentNumber }}</b>
</div>
<div class="folio-person">
<span>{{ change.collegeName }} · {{ change.className }}</span>
<h3>{{ change.name }} · {{ typeLabels[change.type] }}申请</h3>
<p>{{ statusLabels[change.originalStatus] }} {{ statusLabels[change.targetStatus] }} · 提交于 {{ dateText(change.submittedAt) }}</p>
</div>
<el-tag :type="stateTagType(change.state)" effect="plain">{{ stateLabels[change.state as ChangeState] }}</el-tag>
</header>
<div class="folio-reason">
<span>申请说明</span>
<p>{{ change.reason }}</p>
</div>
<div class="approval-track">
<div v-for="(step, index) in steps" :key="step.state" :class="stepClass(change, index)">
<i><el-icon v-if="stepClass(change, index).done"><Check /></el-icon><span v-else>{{ index + 1 }}</span></i>
<b>{{ step.label }}</b>
<small>{{ stepClass(change, index).done ? '已通过' : stepClass(change, index).active ? '处理中' : '待流转' }}</small>
</div>
</div>
<footer>
<div v-if="change.reviewComment" class="review-comment">
<span>最近审核意见</span><b>{{ change.reviewComment }}</b>
</div>
<div v-else class="review-comment"><span>流程编号</span><b>{{ change.id.slice(0, 8).toUpperCase() }}</b></div>
<div class="folio-actions">
<el-button v-if="isStudent && change.state === 'Submitted'" type="danger" plain @click="cancel(change)">撤回申请</el-button>
<template v-if="canReview(change)">
<el-button :icon="Close" type="danger" plain @click="openReview(change, false)">驳回</el-button>
<el-button :icon="Stamp" type="primary" @click="openReview(change, true)">审核通过</el-button>
</template>
</div>
</footer>
</article>
<el-empty v-if="!changes.length && !loading" :description="isStudent ? '尚未提交学籍异动申请' : '当前辖区暂无学籍异动申请'" />
</section>
<el-dialog v-model="applyDialog" title="发起学籍异动申请" width="620px">
<el-form label-position="top">
<el-form-item label="申请类型">
<el-radio-group v-model="applyForm.type">
<el-radio-button v-for="type in options?.types" :key="type" :value="type">{{ typeLabels[type] }}</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="申请说明">
<el-input v-model="applyForm.reason" type="textarea" :rows="6" maxlength="1000" show-word-limit placeholder="请说明申请原因、预计时间以及需要学校了解的情况(至少 10 个字)" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="applyDialog = false">取消</el-button>
<el-button type="primary" @click="submitApply">提交申请</el-button>
</template>
</el-dialog>
<el-dialog v-model="reviewDialog" :title="reviewApproved ? '审核通过' : '驳回申请'" width="600px">
<div v-if="selected" class="review-target">
<span>{{ selected.studentNumber }}</span>
<b>{{ selected.name }} · {{ typeLabels[selected.type] }}申请</b>
<p>{{ selected.collegeName }} · {{ selected.className }}</p>
</div>
<el-form label-position="top">
<el-form-item :label="reviewApproved ? '审核意见(选填)' : '驳回原因(必填)'">
<el-input v-model="reviewForm.comment" type="textarea" :rows="4" maxlength="500" show-word-limit />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="reviewDialog = false">取消</el-button>
<el-button :type="reviewApproved ? 'primary' : 'danger'" @click="submitReview">{{ reviewApproved ? '确认通过' : '确认驳回' }}</el-button>
</template>
</el-dialog>
</div>
</template>