学籍异动:休学、复学、退学申请及辅导员→学院→教务处分级审批。
毕业审核:批次计算、缺失课程检查、人工复核、结果发布。 学位授予:毕业资格与 GPA 计算、人工调整、发布授予结果。 毕业离校:离校事项配置、责任角色分工、逐项办理及批次关闭。
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
面向普通高校的教务管理系统。后端使用 ASP.NET Core 10、EF Core 10,前端使用 Vue 3、TypeScript 和 Element Plus。
|
面向普通高校的教务管理系统。后端使用 ASP.NET Core 10、EF Core 10,前端使用 Vue 3、TypeScript 和 Element Plus。
|
||||||
|
|
||||||
当前已实现系统登录与角色权限、基础数据、用户管理、教师档案、学生档案、课程库、培养方案、教学任务、排课课表、学生选课、成绩管理、考试考场和首页统计。人员及课程列表支持组合筛选、服务端分页和完整增删改查;培养方案支持课程模块、专业年级版本、复制新版本、发布锁定和旧版本归档;教学任务支持学期课程开设、多教师、合班、容量校验、发布与结课;排课支持单双周、周次节次、教室容量、教师/行政班/教室冲突校验和版本化发布;选课支持批次时间窗、投放范围、容量与学分上限、重复课程与课表冲突校验、退课截止时间和实时教学班名单;成绩管理支持分项比例、批量录入、特殊考试状态、自动总评与绩点、教师提交、学院审核、校级发布和学生成绩单;考试管理支持考试计划、场次、考场容量、监考教师、考生名单以及考场/监考/学生时间冲突校验。
|
当前已实现系统登录与角色权限、基础数据、用户管理、教师档案、学生档案、课程库、培养方案、教学任务、排课课表、学生选课、成绩管理、考试考场、学籍异动、毕业审核、学位授予、毕业离校和首页统计。人员及课程列表支持组合筛选、服务端分页和完整增删改查;培养方案支持课程模块、专业年级版本、复制新版本、发布锁定和旧版本归档;教学任务支持学期课程开设、多教师、合班、容量校验、发布与结课;排课支持单双周、周次节次、教室容量、教师/行政班/教室冲突校验和版本化发布;选课支持批次时间窗、投放范围、容量与学分上限、重复课程与课表冲突校验、退课截止时间和实时教学班名单;成绩管理支持分项比例、批量录入、特殊考试状态、自动总评与绩点、教师提交、学院审核、校级发布和学生成绩单;考试管理支持考试计划、场次、考场容量、监考教师、考生名单以及考场/监考/学生时间冲突校验;学籍异动支持休学、复学、退学申请,辅导员、学院、学校三级顺序审核,学生撤回,以及最终审批后自动同步学籍状态;毕业审核按入学年级匹配已发布培养方案,以正式成绩计算总学分、必修通过和未解决不及格课程,支持学院范围查看、人工复核、校级锁定发布和学生结果查询;学位授予以已发布毕业资格为来源,按正式成绩加权平均绩点生成规则结论,支持学院人工复核、校级发布锁定和学生结果查询;毕业离校支持自定义事项与责任部门,按校级、学院、辅导员角色分工办理,强制数据范围校验,学生进度查询,以及必办事项全部完成后的批次锁定。
|
||||||
|
|
||||||
权限采用后端强制校验的角色与数据范围模型。多角色账号按 `All > College > Class > Self` 取最高数据范围:校级角色可访问全校数据,院系管理员限定本学院,辅导员通过稳定的账号 ID 绑定所带行政班,教师和学生限定本人及当前教学关系;前端菜单和路由限制仅作为交互辅助,不替代 API 授权。
|
权限采用后端强制校验的角色与数据范围模型。多角色账号按 `All > College > Class > Self` 取最高数据范围:校级角色可访问全校数据,院系管理员限定本学院,辅导员通过稳定的账号 ID 绑定所带行政班,教师和学生限定本人及当前教学关系;前端菜单和路由限制仅作为交互辅助,不替代 API 授权。
|
||||||
|
|
||||||
|
|||||||
@@ -328,6 +328,416 @@ try {
|
|||||||
throw 'Personal exam schedule is not visible to student or invigilator.'
|
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
|
$frontend = Invoke-WebRequest -Uri 'http://localhost:5255/' -TimeoutSec 5
|
||||||
$spaFallback = Invoke-WebRequest -Uri 'http://localhost:5255/base-data' -TimeoutSec 5
|
$spaFallback = Invoke-WebRequest -Uri 'http://localhost:5255/base-data' -TimeoutSec 5
|
||||||
$unknownApiParameters = @{
|
$unknownApiParameters = @{
|
||||||
@@ -361,6 +771,14 @@ try {
|
|||||||
TranscriptRecords = @($studentTranscript.records).Count
|
TranscriptRecords = @($studentTranscript.records).Count
|
||||||
ExamSessions = @($examDetail.sessions).Count
|
ExamSessions = @($examDetail.sessions).Count
|
||||||
ExamRoster = @($examRoster.students).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
|
AccessUpdate = $true
|
||||||
ScopeChecks = $scopeChecks -join ', '
|
ScopeChecks = $scopeChecks -join ', '
|
||||||
StaticIndex = $frontend.Content.Contains('明序教务管理系统')
|
StaticIndex = $frontend.Content.Contains('明序教务管理系统')
|
||||||
|
|||||||
@@ -51,6 +51,31 @@ public sealed class DashboardController(AppDbContext db) : ControllerBase
|
|||||||
GradeRecords = await db.GradeRecords.CountAsync(cancellationToken),
|
GradeRecords = await db.GradeRecords.CountAsync(cancellationToken),
|
||||||
ExamPlans = await db.ExamPlans.CountAsync(cancellationToken),
|
ExamPlans = await db.ExamPlans.CountAsync(cancellationToken),
|
||||||
ExamSessions = await db.ExamSessions.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)
|
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));
|
}).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]
|
[HttpPost]
|
||||||
[Authorize(Roles = SystemRoles.Student)]
|
[Authorize(Roles = SystemRoles.Student)]
|
||||||
public async Task<ActionResult> Create(
|
public async Task<ActionResult> Create(
|
||||||
@@ -76,14 +107,19 @@ public sealed class StudentStatusChangesController(
|
|||||||
.FirstOrDefaultAsync(x => x.Id == id, token);
|
.FirstOrDefaultAsync(x => x.Id == id, token);
|
||||||
if (change is null) return NotFound();
|
if (change is null) return NotFound();
|
||||||
var scope = currentUserDataScope.Current;
|
var scope = currentUserDataScope.Current;
|
||||||
|
if (!CanReviewCurrentStage(scope, change.State))
|
||||||
|
return ConflictProblem("当前角色或审核阶段不允许执行该操作。");
|
||||||
|
|
||||||
if (!request.Approved)
|
if (!request.Approved)
|
||||||
{
|
{
|
||||||
if (change.State is StudentStatusChangeState.Approved or
|
if (string.IsNullOrWhiteSpace(request.Comment))
|
||||||
StudentStatusChangeState.Rejected or StudentStatusChangeState.Cancelled)
|
return BadRequest(new ProblemDetails
|
||||||
return ConflictProblem("该申请已经结束。");
|
{
|
||||||
|
Title = "审核意见不完整",
|
||||||
|
Detail = "驳回申请时必须填写审核意见。",
|
||||||
|
Status = StatusCodes.Status400BadRequest
|
||||||
|
});
|
||||||
change.State = StudentStatusChangeState.Rejected;
|
change.State = StudentStatusChangeState.Rejected;
|
||||||
change.ReviewComment = request.Comment?.Trim();
|
|
||||||
change.ReviewedAt = DateTime.UtcNow;
|
|
||||||
}
|
}
|
||||||
else if (scope.IsInRole(SystemRoles.Counselor) &&
|
else if (scope.IsInRole(SystemRoles.Counselor) &&
|
||||||
change.State == StudentStatusChangeState.Submitted)
|
change.State == StudentStatusChangeState.Submitted)
|
||||||
@@ -100,12 +136,30 @@ public sealed class StudentStatusChangesController(
|
|||||||
change.ApprovedAt = DateTime.UtcNow;
|
change.ApprovedAt = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
else return ConflictProblem("当前角色或审核阶段不允许执行该操作。");
|
else return ConflictProblem("当前角色或审核阶段不允许执行该操作。");
|
||||||
|
|
||||||
change.ReviewComment = request.Comment?.Trim();
|
change.ReviewComment = request.Comment?.Trim();
|
||||||
change.ReviewedAt = DateTime.UtcNow;
|
change.ReviewedAt = DateTime.UtcNow;
|
||||||
await db.SaveChangesAsync(token);
|
await db.SaveChangesAsync(token);
|
||||||
return NoContent();
|
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()
|
private IQueryable<StudentStatusChange> ScopedChanges()
|
||||||
{
|
{
|
||||||
var scope = currentUserDataScope.Current;
|
var scope = currentUserDataScope.Current;
|
||||||
@@ -123,6 +177,21 @@ public sealed class StudentStatusChangesController(
|
|||||||
return source.Where(_ => false);
|
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
|
private ActionResult ConflictProblem(string detail) => Conflict(new ProblemDetails
|
||||||
{
|
{
|
||||||
Title = "无法完成学籍异动操作", Detail = detail,
|
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>();
|
Set<ExamSessionInvigilator>();
|
||||||
public DbSet<StudentStatusChange> StudentStatusChanges =>
|
public DbSet<StudentStatusChange> StudentStatusChanges =>
|
||||||
Set<StudentStatusChange>();
|
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>();
|
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder builder)
|
protected override void OnModelCreating(ModelBuilder builder)
|
||||||
@@ -396,6 +408,82 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
entity.HasOne(x => x.Student).WithMany()
|
entity.HasOne(x => x.Student).WithMany()
|
||||||
.HasForeignKey(x => x.StudentId).OnDelete(DeleteBehavior.Restrict);
|
.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 =>
|
builder.Entity<AuditLog>(entity =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
private const string GradesMigration = "20260724_07_grades";
|
private const string GradesMigration = "20260724_07_grades";
|
||||||
private const string ExamsMigration = "20260724_08_exams";
|
private const string ExamsMigration = "20260724_08_exams";
|
||||||
private const string StudentStatusChangesMigration = "20260724_09_student_status_changes";
|
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)
|
public async Task MigrateAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
@@ -75,6 +78,18 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
StudentStatusChangesMigration,
|
StudentStatusChangesMigration,
|
||||||
StudentStatusChangesStatements,
|
StudentStatusChangesStatements,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
await ApplyMigrationAsync(
|
||||||
|
GraduationAuditsMigration,
|
||||||
|
GraduationAuditsStatements,
|
||||||
|
cancellationToken);
|
||||||
|
await ApplyMigrationAsync(
|
||||||
|
DegreeAwardsMigration,
|
||||||
|
DegreeAwardsStatements,
|
||||||
|
cancellationToken);
|
||||||
|
await ApplyMigrationAsync(
|
||||||
|
GraduationClearanceMigration,
|
||||||
|
GraduationClearanceStatements,
|
||||||
|
cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ApplyMigrationAsync(
|
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");"""
|
"""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");"""
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
+2183
File diff suppressed because it is too large
Load Diff
+124
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+2324
File diff suppressed because it is too large
Load Diff
+115
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+2491
File diff suppressed because it is too large
Load Diff
+128
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+460
@@ -634,6 +634,115 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.ToTable("CurriculumPlans");
|
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 =>
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamPlan", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -836,6 +945,254 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.ToTable("GradeSheets");
|
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 =>
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Major", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -1684,6 +2041,33 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.Navigation("Major");
|
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 =>
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamPlan", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm")
|
b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm")
|
||||||
@@ -1771,6 +2155,62 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.Navigation("TeachingTask");
|
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 =>
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Major", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Jiaowu.Api.Domain.Academic.College", "College")
|
b.HasOne("Jiaowu.Api.Domain.Academic.College", "College")
|
||||||
@@ -1996,6 +2436,11 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.Navigation("Modules");
|
b.Navigation("Modules");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.DegreeAwardBatch", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Results");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamPlan", b =>
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamPlan", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Sessions");
|
b.Navigation("Sessions");
|
||||||
@@ -2011,6 +2456,21 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.Navigation("Records");
|
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 =>
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.SchedulePlan", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Entries");
|
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([]));
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+3
@@ -27,6 +27,9 @@ declare module 'vue' {
|
|||||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||||
ElOption: typeof import('element-plus/es')['ElOption']
|
ElOption: typeof import('element-plus/es')['ElOption']
|
||||||
ElPagination: typeof import('element-plus/es')['ElPagination']
|
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']
|
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||||
ElTable: typeof import('element-plus/es')['ElTable']
|
ElTable: typeof import('element-plus/es')['ElTable']
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
CircleCheck,
|
CircleCheck,
|
||||||
DocumentChecked,
|
DocumentChecked,
|
||||||
AlarmClock,
|
AlarmClock,
|
||||||
|
RefreshRight,
|
||||||
|
Medal,
|
||||||
User,
|
User,
|
||||||
UserFilled,
|
UserFilled,
|
||||||
} from '@element-plus/icons-vue'
|
} from '@element-plus/icons-vue'
|
||||||
@@ -45,6 +47,10 @@ const pageTitle = computed(() => {
|
|||||||
'course-selections': '选课与教学班',
|
'course-selections': '选课与教学班',
|
||||||
grades: '成绩与学业档案',
|
grades: '成绩与学业档案',
|
||||||
exams: '考试与考场',
|
exams: '考试与考场',
|
||||||
|
'student-status-changes': '学籍异动',
|
||||||
|
'graduation-audits': '毕业资格审核',
|
||||||
|
'degree-awards': '学位授予审核',
|
||||||
|
'graduation-clearance': '毕业离校办理',
|
||||||
users: '用户与权限',
|
users: '用户与权限',
|
||||||
}
|
}
|
||||||
return titles[String(route.name)] ?? '教务管理'
|
return titles[String(route.name)] ?? '教务管理'
|
||||||
@@ -147,6 +153,34 @@ onMounted(() => auth.refresh().catch(() => undefined))
|
|||||||
<el-icon><AlarmClock /></el-icon>
|
<el-icon><AlarmClock /></el-icon>
|
||||||
<template #title>{{ auth.user?.roles.includes('Student') ? '我的考试' : auth.user?.roles.includes('Teacher') ? '我的监考' : '考试管理' }}</template>
|
<template #title>{{ auth.user?.roles.includes('Student') ? '我的考试' : auth.user?.roles.includes('Teacher') ? '我的监考' : '考试管理' }}</template>
|
||||||
</el-menu-item>
|
</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-menu-item v-if="auth.isSuperAdmin" index="/users">
|
||||||
<el-icon><User /></el-icon>
|
<el-icon><User /></el-icon>
|
||||||
<template #title>用户与权限</template>
|
<template #title>用户与权限</template>
|
||||||
@@ -155,7 +189,7 @@ onMounted(() => auth.refresh().catch(() => undefined))
|
|||||||
|
|
||||||
<div v-if="!collapsed" class="phase-note">
|
<div v-if="!collapsed" class="phase-note">
|
||||||
<span>第一阶段 · 核心可用版</span>
|
<span>第一阶段 · 核心可用版</span>
|
||||||
<p>教学运行、选课与成绩管理已就绪</p>
|
<p>教学运行、学籍与毕业审核已就绪</p>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,38 @@ const router = createRouter({
|
|||||||
component: () => import('../views/ExamsView.vue'),
|
component: () => import('../views/ExamsView.vue'),
|
||||||
meta: { roles: ['SuperAdmin', 'AcademicAdmin', 'Teacher', 'Student'] },
|
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',
|
path: 'users',
|
||||||
name: 'users',
|
name: 'users',
|
||||||
|
|||||||
@@ -546,6 +546,221 @@ button { cursor: pointer; }
|
|||||||
.exam-ticket-grid p { margin:0; color:var(--muted); font-size:10px; }
|
.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; }
|
.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-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 { 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; }
|
.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-staff { padding:10px 0 0; border-left:none; border-top:1px solid var(--line); }
|
||||||
.exam-row-actions { grid-column:auto; padding:0; }
|
.exam-row-actions { grid-column:auto; padding:0; }
|
||||||
.exam-ticket-grid { grid-template-columns:1fr; }
|
.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; }
|
.el-drawer { width: 100% !important; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -122,13 +122,33 @@ onMounted(async () => {
|
|||||||
<b>{{ data.counts.examPlans ?? 0 }} 个计划 · {{ data.counts.examSessions ?? 0 }} 个场次</b>
|
<b>{{ data.counts.examPlans ?? 0 }} 个计划 · {{ data.counts.examSessions ?? 0 }} 个场次</b>
|
||||||
<i class="done">已建立</i>
|
<i class="done">已建立</i>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article class="work-card phase-card">
|
<article class="work-card phase-card">
|
||||||
<span class="section-kicker">NEXT MILESTONE</span>
|
<span class="section-kicker">NEXT MILESTONE</span>
|
||||||
<h3>下一段业务链</h3>
|
<h3>下一段业务链</h3>
|
||||||
<p>考试计划、考场、监考与个人日程已就绪,下一步进入学籍异动与毕业审核。</p>
|
<p>毕业离校事项配置、责任角色分工、学生进度与批次关闭已就绪,核心教务业务链已形成闭环。</p>
|
||||||
<div class="phase-line">
|
<div class="phase-line">
|
||||||
<span class="active">基础底座</span>
|
<span class="active">基础底座</span>
|
||||||
<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>
|
||||||
|
<span class="active">毕业审核</span>
|
||||||
|
<span class="active">学位授予</span>
|
||||||
|
<span class="active">毕业离校</span>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user