This commit is contained in:
2026-07-24 13:11:09 +08:00 Unverified
parent 6c3021ea8d
commit 30d793773c
23 changed files with 3479 additions and 79 deletions
+3 -1
View File
@@ -2,6 +2,8 @@
面向普通高校的教务管理系统。后端使用 ASP.NET Core 10、EF Core 10,前端使用 Vue 3、TypeScript 和 Element Plus。
当前已实现系统登录与角色权限、基础数据、用户管理、教师档案、学生档案、课程库和首页统计。人员及课程列表支持组合筛选、服务端分页和完整增删改查。
## 本地开发:热更新模式
本地开发固定使用 SQLite。首次启动会自动创建 `src/Jiaowu.Api/data/jiaowu-dev.sqlite` 并写入演示组织数据。
@@ -55,7 +57,7 @@ dotnet tool restore
dotnet ef migrations list --project src/Jiaowu.Api --startup-project src/Jiaowu.Api
```
应用启动时会自动执行尚未应用的 MySQL 迁移。SQLite 只用于本地开发,并通过 `EnsureCreated` 建立本地数据库;开发模型变化后,可删除本地 SQLite 文件重新生成,不能把该文件用于生产。
应用启动时会自动执行尚未应用的 MySQL 迁移。SQLite 只用于本地开发:新库通过 `EnsureCreated` 建立,已有开发库通过轻量、版本化的本地升级脚本补齐结构,不需要手动删除数据文件。SQLite 文件不能用于生产。
## 验证
+6
View File
@@ -66,6 +66,9 @@ try {
$headers = @{ Authorization = "Bearer $($login.token)" }
$dashboard = Invoke-RestMethod -Uri 'http://localhost:5255/api/dashboard' -Headers $headers
$campuses = Invoke-RestMethod -Uri 'http://localhost:5255/api/base-data/campuses' -Headers $headers
$teachers = Invoke-RestMethod -Uri 'http://localhost:5255/api/personnel/teachers?page=1&pageSize=10' -Headers $headers
$students = Invoke-RestMethod -Uri 'http://localhost:5255/api/personnel/students?page=1&pageSize=10' -Headers $headers
$courses = Invoke-RestMethod -Uri 'http://localhost:5255/api/courses?page=1&pageSize=10' -Headers $headers
$frontend = Invoke-WebRequest -Uri 'http://localhost:5255/' -TimeoutSec 5
$spaFallback = Invoke-WebRequest -Uri 'http://localhost:5255/base-data' -TimeoutSec 5
$unknownApiParameters = @{
@@ -81,6 +84,9 @@ try {
User = $login.user.displayName
Term = $dashboard.currentTerm.name
Campuses = @($campuses).Count
Teachers = $teachers.total
Students = $students.total
Courses = $courses.total
StaticIndex = $frontend.Content.Contains('明序教务管理系统')
SpaFallback = $spaFallback.StatusCode
ApiNotFound = $unknownApi.StatusCode
+13
View File
@@ -0,0 +1,13 @@
$ErrorActionPreference = 'Stop'
$env:ASPNETCORE_ENVIRONMENT = 'Development'
$env:ASPNETCORE_URLS = 'http://localhost:5256'
$workspaceRoot = Split-Path -Parent $PSScriptRoot
$publishRoot = Join-Path $workspaceRoot '.artifacts/publish'
$process = Start-Process `
-FilePath (Join-Path $publishRoot 'Jiaowu.Api.exe') `
-WorkingDirectory $publishRoot `
-WindowStyle Hidden `
-PassThru
$process.Id
+7
View File
@@ -0,0 +1,7 @@
namespace Jiaowu.Api.Contracts;
public sealed record PagedResult<T>(
IReadOnlyCollection<T> Items,
int Total,
int Page,
int PageSize);
@@ -0,0 +1,215 @@
using System.ComponentModel.DataAnnotations;
using System.Security.Claims;
using Jiaowu.Api.Contracts;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/courses")]
public sealed class CoursesController(AppDbContext db) : ControllerBase
{
private const string WriteRoles =
SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin + "," +
SystemRoles.CollegeAdmin;
[HttpGet]
public async Task<ActionResult<PagedResult<object>>> Get(
int page = 1,
int pageSize = 20,
string? keyword = null,
Guid? collegeId = null,
CourseNature? nature = null,
bool? isEnabled = null,
CancellationToken cancellationToken = default)
{
page = Math.Max(1, page);
pageSize = Math.Clamp(pageSize, 10, 100);
var source = db.Courses.AsNoTracking().AsQueryable();
var scopedCollegeId = GetScopedCollegeId();
if (scopedCollegeId.HasValue)
source = source.Where(x => x.CollegeId == scopedCollegeId.Value);
if (collegeId.HasValue)
source = source.Where(x => x.CollegeId == collegeId.Value);
if (nature.HasValue)
source = source.Where(x => x.Nature == nature.Value);
if (isEnabled.HasValue)
source = source.Where(x => x.IsEnabled == isEnabled.Value);
if (!string.IsNullOrWhiteSpace(keyword))
{
keyword = keyword.Trim();
source = source.Where(x =>
x.Code.Contains(keyword) ||
x.Name.Contains(keyword) ||
(x.EnglishName != null && x.EnglishName.Contains(keyword)));
}
var total = await source.CountAsync(cancellationToken);
var items = await source
.OrderBy(x => x.SortOrder)
.ThenBy(x => x.Code)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(x => new
{
x.Id,
x.Code,
x.Name,
x.EnglishName,
x.CollegeId,
CollegeName = x.College!.Name,
x.Credits,
x.TotalHours,
x.LectureHours,
x.PracticeHours,
x.Nature,
x.AssessmentMethod,
x.Description,
x.IsEnabled,
x.SortOrder,
x.CreatedAt
})
.ToListAsync(cancellationToken);
return Ok(new PagedResult<object>(items, total, page, pageSize));
}
[HttpPost]
[Authorize(Roles = WriteRoles)]
public async Task<ActionResult> Create(
CourseRequest request,
CancellationToken cancellationToken)
{
var validation = await ValidateAsync(request, cancellationToken);
if (validation is not null) return validation;
var entity = new Course
{
Code = request.Code.Trim(),
Name = request.Name.Trim(),
EnglishName = Normalize(request.EnglishName),
CollegeId = request.CollegeId,
Credits = request.Credits,
TotalHours = request.TotalHours,
LectureHours = request.LectureHours,
PracticeHours = request.PracticeHours,
Nature = request.Nature,
AssessmentMethod = request.AssessmentMethod,
Description = Normalize(request.Description),
IsEnabled = request.IsEnabled,
SortOrder = request.SortOrder
};
db.Courses.Add(entity);
return await SaveAsync(entity.Id, true, cancellationToken);
}
[HttpPut("{id:guid}")]
[Authorize(Roles = WriteRoles)]
public async Task<ActionResult> Update(
Guid id,
CourseRequest request,
CancellationToken cancellationToken)
{
var entity = await db.Courses.FindAsync([id], cancellationToken);
if (entity is null) return NotFound();
if (!CanAccessCollege(entity.CollegeId)) return Forbid();
var validation = await ValidateAsync(request, cancellationToken);
if (validation is not null) return validation;
entity.Code = request.Code.Trim();
entity.Name = request.Name.Trim();
entity.EnglishName = Normalize(request.EnglishName);
entity.CollegeId = request.CollegeId;
entity.Credits = request.Credits;
entity.TotalHours = request.TotalHours;
entity.LectureHours = request.LectureHours;
entity.PracticeHours = request.PracticeHours;
entity.Nature = request.Nature;
entity.AssessmentMethod = request.AssessmentMethod;
entity.Description = Normalize(request.Description);
entity.IsEnabled = request.IsEnabled;
entity.SortOrder = request.SortOrder;
return await SaveAsync(entity.Id, false, cancellationToken);
}
[HttpDelete("{id:guid}")]
[Authorize(Roles = WriteRoles)]
public async Task<ActionResult> Delete(Guid id, CancellationToken cancellationToken)
{
var entity = await db.Courses.FindAsync([id], cancellationToken);
if (entity is null) return NotFound();
if (!CanAccessCollege(entity.CollegeId)) return Forbid();
db.Courses.Remove(entity);
return await SaveAsync(id, false, cancellationToken);
}
private async Task<ActionResult?> ValidateAsync(
CourseRequest request,
CancellationToken cancellationToken)
{
if (!CanAccessCollege(request.CollegeId)) return Forbid();
if (!await db.Colleges.AnyAsync(x => x.Id == request.CollegeId, cancellationToken))
return ValidationProblem("所选学院不存在。");
if (request.LectureHours + request.PracticeHours > request.TotalHours)
return ValidationProblem("讲授学时与实践学时之和不能超过总学时。");
return null;
}
private Guid? GetScopedCollegeId()
{
if (!User.IsInRole(SystemRoles.CollegeAdmin))
return null;
return Guid.TryParse(User.FindFirstValue("college_id"), out var collegeId)
? collegeId
: Guid.Empty;
}
private bool CanAccessCollege(Guid collegeId) =>
!GetScopedCollegeId().HasValue || GetScopedCollegeId() == collegeId;
private async Task<ActionResult> SaveAsync(
Guid id,
bool created,
CancellationToken cancellationToken)
{
try
{
await db.SaveChangesAsync(cancellationToken);
return created ? Created(string.Empty, new { id }) : NoContent();
}
catch (DbUpdateException)
{
return Conflict(new ProblemDetails
{
Title = "无法完成操作",
Detail = "课程编码已存在,或课程已被其他教学业务引用。",
Status = StatusCodes.Status409Conflict
});
}
}
private static string? Normalize(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
public sealed record CourseRequest(
[Required, MaxLength(40)] string Code,
[Required, MaxLength(100)] string Name,
[MaxLength(150)] string? EnglishName,
Guid CollegeId,
[Range(typeof(decimal), "0.1", "99")] decimal Credits,
[Range(1, 1000)] int TotalHours,
[Range(0, 1000)] int LectureHours,
[Range(0, 1000)] int PracticeHours,
CourseNature Nature,
AssessmentMethod AssessmentMethod,
[MaxLength(1000)] string? Description,
bool IsEnabled,
int SortOrder);
@@ -29,6 +29,9 @@ public sealed class DashboardController(AppDbContext db) : ControllerBase
Majors = await db.Majors.CountAsync(cancellationToken),
Classes = await db.AdministrativeClasses.CountAsync(cancellationToken),
Classrooms = await db.Classrooms.CountAsync(cancellationToken),
Teachers = await db.Teachers.CountAsync(cancellationToken),
Students = await db.Students.CountAsync(cancellationToken),
Courses = await db.Courses.CountAsync(cancellationToken),
Users = await db.Users.CountAsync(cancellationToken)
}
};
@@ -0,0 +1,410 @@
using System.ComponentModel.DataAnnotations;
using System.Security.Claims;
using Jiaowu.Api.Contracts;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize(Roles = ReadRoles)]
[Route("api/personnel")]
public sealed class PersonnelController(AppDbContext db) : ControllerBase
{
private const string ReadRoles =
SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin + "," +
SystemRoles.CollegeAdmin + "," +
SystemRoles.Counselor;
private const string WriteRoles =
SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin + "," +
SystemRoles.CollegeAdmin;
[HttpGet("teachers")]
public async Task<ActionResult<PagedResult<object>>> GetTeachers(
[FromQuery] PersonnelQuery query,
CancellationToken cancellationToken)
{
var page = NormalizePage(query.Page);
var pageSize = NormalizePageSize(query.PageSize);
var source = db.Teachers.AsNoTracking().AsQueryable();
var scopedCollegeId = GetScopedCollegeId();
if (scopedCollegeId.HasValue)
source = source.Where(x => x.CollegeId == scopedCollegeId.Value);
if (query.CollegeId.HasValue)
source = source.Where(x => x.CollegeId == query.CollegeId);
if (!string.IsNullOrWhiteSpace(query.Keyword))
{
var keyword = query.Keyword.Trim();
source = source.Where(x =>
x.TeacherNumber.Contains(keyword) ||
x.Name.Contains(keyword) ||
(x.Title != null && x.Title.Contains(keyword)));
}
if (query.TeacherStatus.HasValue)
source = source.Where(x => x.Status == query.TeacherStatus);
var total = await source.CountAsync(cancellationToken);
var items = await source
.OrderBy(x => x.TeacherNumber)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(x => new
{
x.Id,
x.TeacherNumber,
x.Name,
x.Gender,
x.CollegeId,
CollegeName = x.College!.Name,
x.Title,
x.Status,
x.HireDate,
x.IsExternal,
x.Phone,
x.Email,
x.Notes,
x.UserId,
x.CreatedAt
})
.ToListAsync(cancellationToken);
return Ok(new PagedResult<object>(items, total, page, pageSize));
}
[HttpPost("teachers")]
[Authorize(Roles = WriteRoles)]
public async Task<ActionResult> CreateTeacher(
TeacherRequest request,
CancellationToken cancellationToken)
{
var validation = await ValidateCollegeAsync(request.CollegeId, cancellationToken);
if (validation is not null) return validation;
var entity = new Teacher
{
TeacherNumber = request.TeacherNumber.Trim(),
Name = request.Name.Trim(),
Gender = request.Gender,
CollegeId = request.CollegeId,
Title = Normalize(request.Title),
Status = request.Status,
HireDate = request.HireDate,
IsExternal = request.IsExternal,
Phone = Normalize(request.Phone),
Email = Normalize(request.Email),
Notes = Normalize(request.Notes)
};
db.Teachers.Add(entity);
return await SaveCreatedAsync(entity.Id, cancellationToken);
}
[HttpPut("teachers/{id:guid}")]
[Authorize(Roles = WriteRoles)]
public async Task<ActionResult> UpdateTeacher(
Guid id,
TeacherRequest request,
CancellationToken cancellationToken)
{
var entity = await db.Teachers.FindAsync([id], cancellationToken);
if (entity is null) return NotFound();
if (!CanAccessCollege(entity.CollegeId)) return Forbid();
var validation = await ValidateCollegeAsync(request.CollegeId, cancellationToken);
if (validation is not null) return validation;
entity.TeacherNumber = request.TeacherNumber.Trim();
entity.Name = request.Name.Trim();
entity.Gender = request.Gender;
entity.CollegeId = request.CollegeId;
entity.Title = Normalize(request.Title);
entity.Status = request.Status;
entity.HireDate = request.HireDate;
entity.IsExternal = request.IsExternal;
entity.Phone = Normalize(request.Phone);
entity.Email = Normalize(request.Email);
entity.Notes = Normalize(request.Notes);
return await SaveNoContentAsync(cancellationToken);
}
[HttpDelete("teachers/{id:guid}")]
[Authorize(Roles = WriteRoles)]
public async Task<IActionResult> DeleteTeacher(Guid id, CancellationToken cancellationToken)
{
var entity = await db.Teachers.FindAsync([id], cancellationToken);
if (entity is null) return NotFound();
if (!CanAccessCollege(entity.CollegeId)) return Forbid();
if (entity.UserId.HasValue)
{
return ConflictProblem("教师已关联登录账号,请先解除账号关联后再删除。");
}
db.Teachers.Remove(entity);
return await SaveNoContentAsync(cancellationToken);
}
[HttpGet("students")]
public async Task<ActionResult<PagedResult<object>>> GetStudents(
[FromQuery] PersonnelQuery query,
CancellationToken cancellationToken)
{
var page = NormalizePage(query.Page);
var pageSize = NormalizePageSize(query.PageSize);
var source = db.Students.AsNoTracking().AsQueryable();
var scopedCollegeId = GetScopedCollegeId();
if (scopedCollegeId.HasValue)
{
source = source.Where(x =>
x.AdministrativeClass!.Major!.CollegeId == scopedCollegeId.Value);
}
if (query.CollegeId.HasValue)
{
source = source.Where(x =>
x.AdministrativeClass!.Major!.CollegeId == query.CollegeId.Value);
}
if (query.MajorId.HasValue)
source = source.Where(x => x.AdministrativeClass!.MajorId == query.MajorId);
if (query.ClassId.HasValue)
source = source.Where(x => x.AdministrativeClassId == query.ClassId);
if (query.EnrollmentYear.HasValue)
source = source.Where(x => x.EnrollmentYear == query.EnrollmentYear);
if (query.StudentStatus.HasValue)
source = source.Where(x => x.Status == query.StudentStatus);
if (!string.IsNullOrWhiteSpace(query.Keyword))
{
var keyword = query.Keyword.Trim();
source = source.Where(x =>
x.StudentNumber.Contains(keyword) || x.Name.Contains(keyword));
}
var total = await source.CountAsync(cancellationToken);
var items = await source
.OrderBy(x => x.StudentNumber)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(x => new
{
x.Id,
x.StudentNumber,
x.Name,
x.Gender,
x.AdministrativeClassId,
ClassName = x.AdministrativeClass!.Name,
MajorId = x.AdministrativeClass.MajorId,
MajorName = x.AdministrativeClass.Major!.Name,
CollegeId = x.AdministrativeClass.Major.CollegeId,
CollegeName = x.AdministrativeClass.Major.College!.Name,
x.EnrollmentYear,
x.EnrollmentDate,
x.Status,
x.DateOfBirth,
x.Phone,
x.Email,
x.Notes,
x.UserId,
x.CreatedAt
})
.ToListAsync(cancellationToken);
return Ok(new PagedResult<object>(items, total, page, pageSize));
}
[HttpPost("students")]
[Authorize(Roles = WriteRoles)]
public async Task<ActionResult> CreateStudent(
StudentRequest request,
CancellationToken cancellationToken)
{
var targetClass = await db.AdministrativeClasses.AsNoTracking()
.Where(x => x.Id == request.AdministrativeClassId)
.Select(x => new { x.Id, CollegeId = x.Major!.CollegeId })
.FirstOrDefaultAsync(cancellationToken);
if (targetClass is null)
return ValidationProblem("所选行政班不存在。");
if (!CanAccessCollege(targetClass.CollegeId))
return Forbid();
var entity = new Student
{
StudentNumber = request.StudentNumber.Trim(),
Name = request.Name.Trim(),
Gender = request.Gender,
AdministrativeClassId = request.AdministrativeClassId,
EnrollmentYear = request.EnrollmentYear,
EnrollmentDate = request.EnrollmentDate,
Status = request.Status,
DateOfBirth = request.DateOfBirth,
Phone = Normalize(request.Phone),
Email = Normalize(request.Email),
Notes = Normalize(request.Notes)
};
db.Students.Add(entity);
return await SaveCreatedAsync(entity.Id, cancellationToken);
}
[HttpPut("students/{id:guid}")]
[Authorize(Roles = WriteRoles)]
public async Task<ActionResult> UpdateStudent(
Guid id,
StudentRequest request,
CancellationToken cancellationToken)
{
var entity = await db.Students
.Include(x => x.AdministrativeClass)
.ThenInclude(x => x!.Major)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (entity is null) return NotFound();
if (!CanAccessCollege(entity.AdministrativeClass!.Major!.CollegeId)) return Forbid();
var targetClass = await db.AdministrativeClasses.AsNoTracking()
.Where(x => x.Id == request.AdministrativeClassId)
.Select(x => new { x.Id, CollegeId = x.Major!.CollegeId })
.FirstOrDefaultAsync(cancellationToken);
if (targetClass is null)
return ValidationProblem("所选行政班不存在。");
if (!CanAccessCollege(targetClass.CollegeId))
return Forbid();
entity.StudentNumber = request.StudentNumber.Trim();
entity.Name = request.Name.Trim();
entity.Gender = request.Gender;
entity.AdministrativeClassId = request.AdministrativeClassId;
entity.EnrollmentYear = request.EnrollmentYear;
entity.EnrollmentDate = request.EnrollmentDate;
entity.Status = request.Status;
entity.DateOfBirth = request.DateOfBirth;
entity.Phone = Normalize(request.Phone);
entity.Email = Normalize(request.Email);
entity.Notes = Normalize(request.Notes);
return await SaveNoContentAsync(cancellationToken);
}
[HttpDelete("students/{id:guid}")]
[Authorize(Roles = WriteRoles)]
public async Task<IActionResult> DeleteStudent(Guid id, CancellationToken cancellationToken)
{
var entity = await db.Students
.Include(x => x.AdministrativeClass)
.ThenInclude(x => x!.Major)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (entity is null) return NotFound();
if (!CanAccessCollege(entity.AdministrativeClass!.Major!.CollegeId)) return Forbid();
if (entity.UserId.HasValue)
{
return ConflictProblem("学生已关联登录账号,请先解除账号关联后再删除。");
}
db.Students.Remove(entity);
return await SaveNoContentAsync(cancellationToken);
}
private Guid? GetScopedCollegeId()
{
if (!User.IsInRole(SystemRoles.CollegeAdmin) &&
!User.IsInRole(SystemRoles.Counselor))
{
return null;
}
return Guid.TryParse(User.FindFirstValue("college_id"), out var collegeId)
? collegeId
: Guid.Empty;
}
private bool CanAccessCollege(Guid collegeId) =>
!GetScopedCollegeId().HasValue || GetScopedCollegeId() == collegeId;
private async Task<ActionResult?> ValidateCollegeAsync(
Guid collegeId,
CancellationToken cancellationToken)
{
if (!CanAccessCollege(collegeId)) return Forbid();
if (!await db.Colleges.AnyAsync(x => x.Id == collegeId, cancellationToken))
return ValidationProblem("所选学院不存在。");
return null;
}
private async Task<ActionResult> SaveCreatedAsync(
Guid id,
CancellationToken cancellationToken)
{
try
{
await db.SaveChangesAsync(cancellationToken);
return Created(string.Empty, new { id });
}
catch (DbUpdateException)
{
return ConflictProblem("编号已存在,或关联数据无效。");
}
}
private async Task<ActionResult> SaveNoContentAsync(CancellationToken cancellationToken)
{
try
{
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
catch (DbUpdateException)
{
return ConflictProblem("编号已存在,或该档案已被其他业务引用。");
}
}
private ActionResult ConflictProblem(string detail) =>
Conflict(new ProblemDetails
{
Title = "无法完成操作",
Detail = detail,
Status = StatusCodes.Status409Conflict
});
private static string? Normalize(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static int NormalizePage(int page) => Math.Max(1, page);
private static int NormalizePageSize(int pageSize) => Math.Clamp(pageSize, 10, 100);
}
public sealed record PersonnelQuery(
int Page = 1,
int PageSize = 20,
string? Keyword = null,
Guid? CollegeId = null,
Guid? MajorId = null,
Guid? ClassId = null,
int? EnrollmentYear = null,
TeacherStatus? TeacherStatus = null,
StudentStatus? StudentStatus = null);
public sealed record TeacherRequest(
[Required, MaxLength(30)] string TeacherNumber,
[Required, MaxLength(50)] string Name,
Gender Gender,
Guid CollegeId,
[MaxLength(30)] string? Title,
TeacherStatus Status,
DateOnly? HireDate,
bool IsExternal,
[MaxLength(30)] string? Phone,
[EmailAddress, MaxLength(100)] string? Email,
[MaxLength(500)] string? Notes);
public sealed record StudentRequest(
[Required, MaxLength(30)] string StudentNumber,
[Required, MaxLength(50)] string Name,
Gender Gender,
Guid AdministrativeClassId,
[Range(2000, 2200)] int EnrollmentYear,
DateOnly EnrollmentDate,
StudentStatus Status,
DateOnly? DateOfBirth,
[MaxLength(30)] string? Phone,
[EmailAddress, MaxLength(100)] string? Email,
[MaxLength(500)] string? Notes);
@@ -0,0 +1,89 @@
using Jiaowu.Api.Domain.Common;
namespace Jiaowu.Api.Domain.Academic;
public sealed class Teacher : EntityBase
{
public required string TeacherNumber { get; set; }
public required string Name { get; set; }
public Gender Gender { get; set; }
public Guid CollegeId { get; set; }
public College? College { get; set; }
public string? Title { get; set; }
public TeacherStatus Status { get; set; } = TeacherStatus.Active;
public DateOnly? HireDate { get; set; }
public bool IsExternal { get; set; }
public string? Phone { get; set; }
public string? Email { get; set; }
public string? Notes { get; set; }
public Guid? UserId { get; set; }
}
public sealed class Student : EntityBase
{
public required string StudentNumber { get; set; }
public required string Name { get; set; }
public Gender Gender { get; set; }
public Guid AdministrativeClassId { get; set; }
public AdministrativeClass? AdministrativeClass { get; set; }
public int EnrollmentYear { get; set; }
public DateOnly EnrollmentDate { get; set; }
public StudentStatus Status { get; set; } = StudentStatus.Active;
public DateOnly? DateOfBirth { get; set; }
public string? Phone { get; set; }
public string? Email { get; set; }
public string? Notes { get; set; }
public Guid? UserId { get; set; }
}
public sealed class Course : CatalogEntity
{
public Guid CollegeId { get; set; }
public College? College { get; set; }
public string? EnglishName { get; set; }
public decimal Credits { get; set; }
public int TotalHours { get; set; }
public int LectureHours { get; set; }
public int PracticeHours { get; set; }
public CourseNature Nature { get; set; }
public AssessmentMethod AssessmentMethod { get; set; }
public string? Description { get; set; }
}
public enum Gender
{
Unknown = 0,
Male = 1,
Female = 2
}
public enum TeacherStatus
{
Active = 1,
OnLeave = 2,
Retired = 3,
Departed = 4
}
public enum StudentStatus
{
Active = 1,
Suspended = 2,
Graduated = 3,
Withdrawn = 4
}
public enum CourseNature
{
GeneralRequired = 1,
MajorRequired = 2,
MajorElective = 3,
GeneralElective = 4,
Practice = 5
}
public enum AssessmentMethod
{
Examination = 1,
Assessment = 2
}
@@ -17,6 +17,9 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<Building> Buildings => Set<Building>();
public DbSet<Classroom> Classrooms => Set<Classroom>();
public DbSet<AcademicTerm> AcademicTerms => Set<AcademicTerm>();
public DbSet<Teacher> Teachers => Set<Teacher>();
public DbSet<Student> Students => Set<Student>();
public DbSet<Course> Courses => Set<Course>();
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
protected override void OnModelCreating(ModelBuilder builder)
@@ -42,6 +45,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
ConfigureCatalog<Building>(builder);
ConfigureCatalog<Classroom>(builder);
ConfigureCatalog<AcademicTerm>(builder);
ConfigureCatalog<Course>(builder);
builder.Entity<College>()
.HasOne(x => x.Campus)
@@ -76,6 +80,58 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
builder.Entity<AcademicTerm>()
.HasIndex(x => x.IsCurrent);
builder.Entity<Teacher>(entity =>
{
entity.Property(x => x.TeacherNumber).HasMaxLength(30);
entity.Property(x => x.Name).HasMaxLength(50);
entity.Property(x => x.Title).HasMaxLength(30);
entity.Property(x => x.Phone).HasMaxLength(30);
entity.Property(x => x.Email).HasMaxLength(100);
entity.Property(x => x.Notes).HasMaxLength(500);
entity.HasIndex(x => x.TeacherNumber).IsUnique();
entity.HasIndex(x => new { x.CollegeId, x.Status });
entity.HasOne(x => x.College)
.WithMany()
.HasForeignKey(x => x.CollegeId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne<ApplicationUser>()
.WithMany()
.HasForeignKey(x => x.UserId)
.OnDelete(DeleteBehavior.SetNull);
});
builder.Entity<Student>(entity =>
{
entity.Property(x => x.StudentNumber).HasMaxLength(30);
entity.Property(x => x.Name).HasMaxLength(50);
entity.Property(x => x.Phone).HasMaxLength(30);
entity.Property(x => x.Email).HasMaxLength(100);
entity.Property(x => x.Notes).HasMaxLength(500);
entity.HasIndex(x => x.StudentNumber).IsUnique();
entity.HasIndex(x => new { x.AdministrativeClassId, x.Status });
entity.HasIndex(x => x.EnrollmentYear);
entity.HasOne(x => x.AdministrativeClass)
.WithMany()
.HasForeignKey(x => x.AdministrativeClassId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne<ApplicationUser>()
.WithMany()
.HasForeignKey(x => x.UserId)
.OnDelete(DeleteBehavior.SetNull);
});
builder.Entity<Course>(entity =>
{
entity.Property(x => x.EnglishName).HasMaxLength(150);
entity.Property(x => x.Credits).HasPrecision(5, 2);
entity.Property(x => x.Description).HasMaxLength(1000);
entity.HasIndex(x => new { x.CollegeId, x.Nature });
entity.HasOne(x => x.College)
.WithMany()
.HasForeignKey(x => x.CollegeId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<AuditLog>(entity =>
{
entity.Property(x => x.Method).HasMaxLength(10);
@@ -9,6 +9,7 @@ public sealed class DatabaseInitializer(
AppDbContext db,
RoleManager<ApplicationRole> roleManager,
UserManager<ApplicationUser> userManager,
DevelopmentSqliteMigrator sqliteMigrator,
IConfiguration configuration,
IHostEnvironment environment,
ILogger<DatabaseInitializer> logger)
@@ -18,6 +19,7 @@ public sealed class DatabaseInitializer(
if (environment.IsDevelopment())
{
await db.Database.EnsureCreatedAsync();
await sqliteMigrator.MigrateAsync();
}
else
{
@@ -108,71 +110,182 @@ public sealed class DatabaseInitializer(
private async Task SeedDevelopmentDataAsync()
{
if (await db.Campuses.AnyAsync())
if (!await db.Campuses.AnyAsync())
{
return;
var campus = new Campus
{
Code = "MAIN",
Name = "主校区",
Address = "大学路 1 号"
};
var college = new College
{
Code = "CS",
Name = "计算机学院",
ShortName = "计算机学院",
CampusId = campus.Id
};
var major = new Major
{
Code = "080901",
Name = "计算机科学与技术",
CollegeId = college.Id,
DegreeType = "工学学士",
SchoolingYears = 4
};
var building = new Building
{
Code = "J1",
Name = "第一教学楼",
CampusId = campus.Id
};
db.AddRange(
campus,
college,
major,
new AdministrativeClass
{
Code = "CS2026-01",
Name = "计科 2026-1 班",
MajorId = major.Id,
Grade = 2026,
CounselorName = "陈老师"
},
building,
new Classroom
{
Code = "J1-201",
Name = "J1-201",
BuildingId = building.Id,
Capacity = 60,
RoomType = "多媒体教室",
Equipment = "投影、扩声、录播"
},
new AcademicTerm
{
Code = "2026-2027-1",
Name = "2026—2027 学年第一学期",
AcademicYear = "2026-2027",
Season = TermSeason.Autumn,
StartDate = new DateOnly(2026, 9, 7),
EndDate = new DateOnly(2027, 1, 17),
IsCurrent = true
});
await db.SaveChangesAsync();
}
var campus = new Campus
{
Code = "MAIN",
Name = "主校区",
Address = "大学路 1 号"
};
var college = new College
{
Code = "CS",
Name = "计算机学院",
ShortName = "计算机学院",
CampusId = campus.Id
};
var major = new Major
{
Code = "080901",
Name = "计算机科学与技术",
CollegeId = college.Id,
DegreeType = "工学学士",
SchoolingYears = 4
};
var building = new Building
{
Code = "J1",
Name = "第一教学楼",
CampusId = campus.Id
};
var computerCollege = await db.Colleges.SingleAsync(x => x.Code == "CS");
var computerClass = await db.AdministrativeClasses
.SingleAsync(x => x.Code == "CS2026-01");
db.AddRange(
campus,
college,
major,
new AdministrativeClass
{
Code = "CS2026-01",
Name = "计科 2026-1 班",
MajorId = major.Id,
Grade = 2026,
CounselorName = "陈老师"
},
building,
new Classroom
{
Code = "J1-201",
Name = "J1-201",
BuildingId = building.Id,
Capacity = 60,
RoomType = "多媒体教室",
Equipment = "投影、扩声、录播"
},
new AcademicTerm
{
Code = "2026-2027-1",
Name = "2026—2027 学年第一学期",
AcademicYear = "2026-2027",
Season = TermSeason.Autumn,
StartDate = new DateOnly(2026, 9, 7),
EndDate = new DateOnly(2027, 1, 17),
IsCurrent = true
});
if (!await db.Teachers.AnyAsync())
{
db.Teachers.AddRange(
new Teacher
{
TeacherNumber = "T2026001",
Name = "陈明远",
Gender = Gender.Male,
CollegeId = computerCollege.Id,
Title = "副教授",
Status = TeacherStatus.Active,
HireDate = new DateOnly(2018, 7, 1),
Email = "chenmy@example.edu.cn"
},
new Teacher
{
TeacherNumber = "T2026002",
Name = "林书雅",
Gender = Gender.Female,
CollegeId = computerCollege.Id,
Title = "讲师",
Status = TeacherStatus.Active,
HireDate = new DateOnly(2022, 9, 1),
Email = "linsy@example.edu.cn"
});
}
if (!await db.Students.AnyAsync())
{
db.Students.AddRange(
new Student
{
StudentNumber = "202601001",
Name = "周启航",
Gender = Gender.Male,
AdministrativeClassId = computerClass.Id,
EnrollmentYear = 2026,
EnrollmentDate = new DateOnly(2026, 9, 7),
Status = StudentStatus.Active
},
new Student
{
StudentNumber = "202601002",
Name = "许知夏",
Gender = Gender.Female,
AdministrativeClassId = computerClass.Id,
EnrollmentYear = 2026,
EnrollmentDate = new DateOnly(2026, 9, 7),
Status = StudentStatus.Active
},
new Student
{
StudentNumber = "202601003",
Name = "方嘉树",
Gender = Gender.Male,
AdministrativeClassId = computerClass.Id,
EnrollmentYear = 2026,
EnrollmentDate = new DateOnly(2026, 9, 7),
Status = StudentStatus.Active
});
}
if (!await db.Courses.AnyAsync())
{
db.Courses.AddRange(
new Course
{
Code = "CS101",
Name = "程序设计基础",
EnglishName = "Fundamentals of Programming",
CollegeId = computerCollege.Id,
Credits = 4,
TotalHours = 64,
LectureHours = 40,
PracticeHours = 24,
Nature = CourseNature.MajorRequired,
AssessmentMethod = AssessmentMethod.Examination,
Description = "面向一年级学生的程序设计入门课程。"
},
new Course
{
Code = "CS201",
Name = "数据结构",
EnglishName = "Data Structures",
CollegeId = computerCollege.Id,
Credits = 3.5m,
TotalHours = 56,
LectureHours = 40,
PracticeHours = 16,
Nature = CourseNature.MajorRequired,
AssessmentMethod = AssessmentMethod.Examination
},
new Course
{
Code = "CS305",
Name = "软件工程实践",
EnglishName = "Software Engineering Practice",
CollegeId = computerCollege.Id,
Credits = 2,
TotalHours = 48,
LectureHours = 8,
PracticeHours = 40,
Nature = CourseNature.Practice,
AssessmentMethod = AssessmentMethod.Assessment
});
}
await db.SaveChangesAsync();
}
@@ -0,0 +1,169 @@
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.Persistence;
public sealed class DevelopmentSqliteMigrator(
AppDbContext db,
ILogger<DevelopmentSqliteMigrator> logger)
{
private const string PeopleAndCoursesMigration = "20260724_01_people_courses";
public async Task MigrateAsync(CancellationToken cancellationToken = default)
{
if (!db.Database.IsSqlite())
{
return;
}
await db.Database.ExecuteSqlRawAsync(
"""
CREATE TABLE IF NOT EXISTS "__DevelopmentSchemaHistory" (
"MigrationId" TEXT NOT NULL CONSTRAINT "PK___DevelopmentSchemaHistory" PRIMARY KEY,
"AppliedAt" TEXT NOT NULL
);
""",
cancellationToken);
var applied = await db.Database
.SqlQuery<string>(
$"""
SELECT "MigrationId" AS "Value"
FROM "__DevelopmentSchemaHistory"
WHERE "MigrationId" = {PeopleAndCoursesMigration}
""")
.AnyAsync(cancellationToken);
if (applied)
{
return;
}
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken);
foreach (var statement in PeopleAndCoursesStatements)
{
await db.Database.ExecuteSqlRawAsync(statement, cancellationToken);
}
await db.Database.ExecuteSqlInterpolatedAsync(
$"""
INSERT INTO "__DevelopmentSchemaHistory" ("MigrationId", "AppliedAt")
VALUES ({PeopleAndCoursesMigration}, {DateTime.UtcNow});
""",
cancellationToken);
await transaction.CommitAsync(cancellationToken);
logger.LogInformation(
"Applied SQLite development schema migration {MigrationId}.",
PeopleAndCoursesMigration);
}
private static readonly string[] PeopleAndCoursesStatements =
[
"""
CREATE TABLE IF NOT EXISTS "Teachers" (
"Id" TEXT NOT NULL CONSTRAINT "PK_Teachers" PRIMARY KEY,
"TeacherNumber" TEXT NOT NULL,
"Name" TEXT NOT NULL,
"Gender" INTEGER NOT NULL,
"CollegeId" TEXT NOT NULL,
"Title" TEXT NULL,
"Status" INTEGER NOT NULL,
"HireDate" TEXT NULL,
"IsExternal" INTEGER NOT NULL,
"Phone" TEXT NULL,
"Email" TEXT NULL,
"Notes" TEXT NULL,
"UserId" TEXT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_Teachers_Colleges_CollegeId"
FOREIGN KEY ("CollegeId") REFERENCES "Colleges" ("Id") ON DELETE RESTRICT,
CONSTRAINT "FK_Teachers_AspNetUsers_UserId"
FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE SET NULL
);
""",
"""
CREATE UNIQUE INDEX IF NOT EXISTS "IX_Teachers_TeacherNumber"
ON "Teachers" ("TeacherNumber");
""",
"""
CREATE INDEX IF NOT EXISTS "IX_Teachers_CollegeId_Status"
ON "Teachers" ("CollegeId", "Status");
""",
"""
CREATE INDEX IF NOT EXISTS "IX_Teachers_UserId"
ON "Teachers" ("UserId");
""",
"""
CREATE TABLE IF NOT EXISTS "Students" (
"Id" TEXT NOT NULL CONSTRAINT "PK_Students" PRIMARY KEY,
"StudentNumber" TEXT NOT NULL,
"Name" TEXT NOT NULL,
"Gender" INTEGER NOT NULL,
"AdministrativeClassId" TEXT NOT NULL,
"EnrollmentYear" INTEGER NOT NULL,
"EnrollmentDate" TEXT NOT NULL,
"Status" INTEGER NOT NULL,
"DateOfBirth" TEXT NULL,
"Phone" TEXT NULL,
"Email" TEXT NULL,
"Notes" TEXT NULL,
"UserId" TEXT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_Students_AdministrativeClasses_AdministrativeClassId"
FOREIGN KEY ("AdministrativeClassId") REFERENCES "AdministrativeClasses" ("Id")
ON DELETE RESTRICT,
CONSTRAINT "FK_Students_AspNetUsers_UserId"
FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE SET NULL
);
""",
"""
CREATE UNIQUE INDEX IF NOT EXISTS "IX_Students_StudentNumber"
ON "Students" ("StudentNumber");
""",
"""
CREATE INDEX IF NOT EXISTS "IX_Students_AdministrativeClassId_Status"
ON "Students" ("AdministrativeClassId", "Status");
""",
"""
CREATE INDEX IF NOT EXISTS "IX_Students_EnrollmentYear"
ON "Students" ("EnrollmentYear");
""",
"""
CREATE INDEX IF NOT EXISTS "IX_Students_UserId"
ON "Students" ("UserId");
""",
"""
CREATE TABLE IF NOT EXISTS "Courses" (
"Id" TEXT NOT NULL CONSTRAINT "PK_Courses" PRIMARY KEY,
"CollegeId" TEXT NOT NULL,
"EnglishName" TEXT NULL,
"Credits" TEXT NOT NULL,
"TotalHours" INTEGER NOT NULL,
"LectureHours" INTEGER NOT NULL,
"PracticeHours" INTEGER NOT NULL,
"Nature" INTEGER NOT NULL,
"AssessmentMethod" INTEGER NOT NULL,
"Description" TEXT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
"Code" TEXT NOT NULL,
"Name" TEXT NOT NULL,
"SortOrder" INTEGER NOT NULL,
"IsEnabled" INTEGER NOT NULL,
CONSTRAINT "FK_Courses_Colleges_CollegeId"
FOREIGN KEY ("CollegeId") REFERENCES "Colleges" ("Id") ON DELETE RESTRICT
);
""",
"""
CREATE UNIQUE INDEX IF NOT EXISTS "IX_Courses_Code" ON "Courses" ("Code");
""",
"""
CREATE INDEX IF NOT EXISTS "IX_Courses_IsEnabled_SortOrder"
ON "Courses" ("IsEnabled", "SortOrder");
""",
"""
CREATE INDEX IF NOT EXISTS "IX_Courses_CollegeId_Nature"
ON "Courses" ("CollegeId", "Nature");
"""
];
}
@@ -0,0 +1,984 @@
// <auto-generated />
using System;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
[DbContext(typeof(AppDbContext))]
[Migration("20260724045307_PeopleAndCourses")]
partial class PeopleAndCourses
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.10")
.HasAnnotation("Relational:MaxIdentifierLength", 64);
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AcademicTerm", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<string>("AcademicYear")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("varchar(40)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<DateOnly>("EndDate")
.HasColumnType("date");
b.Property<bool>("IsCurrent")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsEnabled")
.HasColumnType("tinyint(1)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<int>("Season")
.HasColumnType("int");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.Property<DateOnly>("StartDate")
.HasColumnType("date");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("Code")
.IsUnique();
b.HasIndex("IsCurrent");
b.HasIndex("IsEnabled", "SortOrder");
b.ToTable("AcademicTerms");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AdministrativeClass", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("varchar(40)");
b.Property<string>("CounselorName")
.HasColumnType("longtext");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("Grade")
.HasColumnType("int");
b.Property<bool>("IsEnabled")
.HasColumnType("tinyint(1)");
b.Property<Guid>("MajorId")
.HasColumnType("char(36)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("Code")
.IsUnique();
b.HasIndex("MajorId");
b.HasIndex("IsEnabled", "SortOrder");
b.ToTable("AdministrativeClasses");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Building", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<Guid>("CampusId")
.HasColumnType("char(36)");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("varchar(40)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<bool>("IsEnabled")
.HasColumnType("tinyint(1)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("CampusId");
b.HasIndex("Code")
.IsUnique();
b.HasIndex("IsEnabled", "SortOrder");
b.ToTable("Buildings");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Campus", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<string>("Address")
.HasColumnType("longtext");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("varchar(40)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<bool>("IsEnabled")
.HasColumnType("tinyint(1)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("Code")
.IsUnique();
b.HasIndex("IsEnabled", "SortOrder");
b.ToTable("Campuses");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Classroom", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<Guid>("BuildingId")
.HasColumnType("char(36)");
b.Property<int>("Capacity")
.HasColumnType("int");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("varchar(40)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Equipment")
.HasColumnType("longtext");
b.Property<bool>("IsEnabled")
.HasColumnType("tinyint(1)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<string>("RoomType")
.IsRequired()
.HasColumnType("longtext");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("BuildingId");
b.HasIndex("Code")
.IsUnique();
b.HasIndex("IsEnabled", "SortOrder");
b.ToTable("Classrooms");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.College", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<Guid?>("CampusId")
.HasColumnType("char(36)");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("varchar(40)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<bool>("IsEnabled")
.HasColumnType("tinyint(1)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<string>("ShortName")
.HasColumnType("longtext");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("CampusId");
b.HasIndex("Code")
.IsUnique();
b.HasIndex("IsEnabled", "SortOrder");
b.ToTable("Colleges");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Course", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<int>("AssessmentMethod")
.HasColumnType("int");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("varchar(40)");
b.Property<Guid>("CollegeId")
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("Credits")
.HasPrecision(5, 2)
.HasColumnType("decimal(5,2)");
b.Property<string>("Description")
.HasMaxLength(1000)
.HasColumnType("varchar(1000)");
b.Property<string>("EnglishName")
.HasMaxLength(150)
.HasColumnType("varchar(150)");
b.Property<bool>("IsEnabled")
.HasColumnType("tinyint(1)");
b.Property<int>("LectureHours")
.HasColumnType("int");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<int>("Nature")
.HasColumnType("int");
b.Property<int>("PracticeHours")
.HasColumnType("int");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.Property<int>("TotalHours")
.HasColumnType("int");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("Code")
.IsUnique();
b.HasIndex("CollegeId", "Nature");
b.HasIndex("IsEnabled", "SortOrder");
b.ToTable("Courses");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Major", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("varchar(40)");
b.Property<Guid>("CollegeId")
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("DegreeType")
.IsRequired()
.HasColumnType("longtext");
b.Property<bool>("IsEnabled")
.HasColumnType("tinyint(1)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<int>("SchoolingYears")
.HasColumnType("int");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("Code")
.IsUnique();
b.HasIndex("CollegeId");
b.HasIndex("IsEnabled", "SortOrder");
b.ToTable("Majors");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Student", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<Guid>("AdministrativeClassId")
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<DateOnly?>("DateOfBirth")
.HasColumnType("date");
b.Property<string>("Email")
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<DateOnly>("EnrollmentDate")
.HasColumnType("date");
b.Property<int>("EnrollmentYear")
.HasColumnType("int");
b.Property<int>("Gender")
.HasColumnType("int");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("Notes")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<string>("Phone")
.HasMaxLength(30)
.HasColumnType("varchar(30)");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<string>("StudentNumber")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("varchar(30)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid?>("UserId")
.HasColumnType("char(36)");
b.HasKey("Id");
b.HasIndex("EnrollmentYear");
b.HasIndex("StudentNumber")
.IsUnique();
b.HasIndex("UserId");
b.HasIndex("AdministrativeClassId", "Status");
b.ToTable("Students");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Teacher", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<Guid>("CollegeId")
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Email")
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<int>("Gender")
.HasColumnType("int");
b.Property<DateOnly?>("HireDate")
.HasColumnType("date");
b.Property<bool>("IsExternal")
.HasColumnType("tinyint(1)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("Notes")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<string>("Phone")
.HasMaxLength(30)
.HasColumnType("varchar(30)");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<string>("TeacherNumber")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("varchar(30)");
b.Property<string>("Title")
.HasMaxLength(30)
.HasColumnType("varchar(30)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid?>("UserId")
.HasColumnType("char(36)");
b.HasKey("Id");
b.HasIndex("TeacherNumber")
.IsUnique();
b.HasIndex("UserId");
b.HasIndex("CollegeId", "Status");
b.ToTable("Teachers");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Identity.ApplicationRole", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("longtext");
b.Property<int>("DataScope")
.HasColumnType("int");
b.Property<string>("Description")
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("Jiaowu.Api.Domain.Identity.ApplicationUser", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<int>("AccessFailedCount")
.HasColumnType("int");
b.Property<Guid?>("CollegeId")
.HasColumnType("char(36)");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("longtext");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsEnabled")
.HasColumnType("tinyint(1)");
b.Property<DateTime?>("LastLoginAt")
.HasColumnType("datetime(6)");
b.Property<bool>("LockoutEnabled")
.HasColumnType("tinyint(1)");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("datetime");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<string>("PasswordHash")
.HasColumnType("longtext");
b.Property<string>("PhoneNumber")
.HasColumnType("longtext");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("tinyint(1)");
b.Property<string>("SecurityStamp")
.HasColumnType("longtext");
b.Property<string>("StaffNumber")
.HasMaxLength(30)
.HasColumnType("varchar(30)");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("tinyint(1)");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.HasIndex("StaffNumber");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("Jiaowu.Api.Domain.System.AuditLog", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("IpAddress")
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<string>("Method")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("varchar(300)");
b.Property<int>("StatusCode")
.HasColumnType("int");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid?>("UserId")
.HasColumnType("char(36)");
b.Property<string>("UserName")
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.ToTable("AuditLogs");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<string>("ClaimType")
.HasColumnType("longtext");
b.Property<string>("ClaimValue")
.HasColumnType("longtext");
b.Property<Guid>("RoleId")
.HasColumnType("char(36)");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<string>("ClaimType")
.HasColumnType("longtext");
b.Property<string>("ClaimValue")
.HasColumnType("longtext");
b.Property<Guid>("UserId")
.HasColumnType("char(36)");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("varchar(255)");
b.Property<string>("ProviderKey")
.HasColumnType("varchar(255)");
b.Property<string>("ProviderDisplayName")
.HasColumnType("longtext");
b.Property<Guid>("UserId")
.HasColumnType("char(36)");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("char(36)");
b.Property<Guid>("RoleId")
.HasColumnType("char(36)");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("char(36)");
b.Property<string>("LoginProvider")
.HasColumnType("varchar(255)");
b.Property<string>("Name")
.HasColumnType("varchar(255)");
b.Property<string>("Value")
.HasColumnType("longtext");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AdministrativeClass", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.Major", "Major")
.WithMany()
.HasForeignKey("MajorId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Major");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Building", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.Campus", "Campus")
.WithMany()
.HasForeignKey("CampusId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Campus");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Classroom", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.Building", "Building")
.WithMany()
.HasForeignKey("BuildingId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Building");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.College", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.Campus", "Campus")
.WithMany()
.HasForeignKey("CampusId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("Campus");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Course", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.College", "College")
.WithMany()
.HasForeignKey("CollegeId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("College");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Major", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.College", "College")
.WithMany()
.HasForeignKey("CollegeId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("College");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Student", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.AdministrativeClass", "AdministrativeClass")
.WithMany()
.HasForeignKey("AdministrativeClassId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("AdministrativeClass");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Teacher", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.College", "College")
.WithMany()
.HasForeignKey("CollegeId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("College");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
{
b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,190 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class PeopleAndCourses : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Courses",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
CollegeId = table.Column<Guid>(type: "char(36)", nullable: false),
EnglishName = table.Column<string>(type: "varchar(150)", maxLength: 150, nullable: true),
Credits = table.Column<decimal>(type: "decimal(5,2)", precision: 5, scale: 2, nullable: false),
TotalHours = table.Column<int>(type: "int", nullable: false),
LectureHours = table.Column<int>(type: "int", nullable: false),
PracticeHours = table.Column<int>(type: "int", nullable: false),
Nature = table.Column<int>(type: "int", nullable: false),
AssessmentMethod = table.Column<int>(type: "int", nullable: false),
Description = table.Column<string>(type: "varchar(1000)", maxLength: 1000, nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
Code = table.Column<string>(type: "varchar(40)", maxLength: 40, nullable: false),
Name = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: false),
SortOrder = table.Column<int>(type: "int", nullable: false),
IsEnabled = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Courses", x => x.Id);
table.ForeignKey(
name: "FK_Courses_Colleges_CollegeId",
column: x => x.CollegeId,
principalTable: "Colleges",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "Students",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
StudentNumber = table.Column<string>(type: "varchar(30)", maxLength: 30, nullable: false),
Name = table.Column<string>(type: "varchar(50)", maxLength: 50, nullable: false),
Gender = table.Column<int>(type: "int", nullable: false),
AdministrativeClassId = table.Column<Guid>(type: "char(36)", nullable: false),
EnrollmentYear = table.Column<int>(type: "int", nullable: false),
EnrollmentDate = table.Column<DateOnly>(type: "date", nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
DateOfBirth = table.Column<DateOnly>(type: "date", nullable: true),
Phone = table.Column<string>(type: "varchar(30)", maxLength: 30, nullable: true),
Email = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: true),
Notes = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
UserId = 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_Students", x => x.Id);
table.ForeignKey(
name: "FK_Students_AdministrativeClasses_AdministrativeClassId",
column: x => x.AdministrativeClassId,
principalTable: "AdministrativeClasses",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_Students_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "Teachers",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
TeacherNumber = table.Column<string>(type: "varchar(30)", maxLength: 30, nullable: false),
Name = table.Column<string>(type: "varchar(50)", maxLength: 50, nullable: false),
Gender = table.Column<int>(type: "int", nullable: false),
CollegeId = table.Column<Guid>(type: "char(36)", nullable: false),
Title = table.Column<string>(type: "varchar(30)", maxLength: 30, nullable: true),
Status = table.Column<int>(type: "int", nullable: false),
HireDate = table.Column<DateOnly>(type: "date", nullable: true),
IsExternal = table.Column<bool>(type: "tinyint(1)", nullable: false),
Phone = table.Column<string>(type: "varchar(30)", maxLength: 30, nullable: true),
Email = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: true),
Notes = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
UserId = 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_Teachers", x => x.Id);
table.ForeignKey(
name: "FK_Teachers_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_Teachers_Colleges_CollegeId",
column: x => x.CollegeId,
principalTable: "Colleges",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_Courses_Code",
table: "Courses",
column: "Code",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Courses_CollegeId_Nature",
table: "Courses",
columns: new[] { "CollegeId", "Nature" });
migrationBuilder.CreateIndex(
name: "IX_Courses_IsEnabled_SortOrder",
table: "Courses",
columns: new[] { "IsEnabled", "SortOrder" });
migrationBuilder.CreateIndex(
name: "IX_Students_AdministrativeClassId_Status",
table: "Students",
columns: new[] { "AdministrativeClassId", "Status" });
migrationBuilder.CreateIndex(
name: "IX_Students_EnrollmentYear",
table: "Students",
column: "EnrollmentYear");
migrationBuilder.CreateIndex(
name: "IX_Students_StudentNumber",
table: "Students",
column: "StudentNumber",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Students_UserId",
table: "Students",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_Teachers_CollegeId_Status",
table: "Teachers",
columns: new[] { "CollegeId", "Status" });
migrationBuilder.CreateIndex(
name: "IX_Teachers_TeacherNumber",
table: "Teachers",
column: "TeacherNumber",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Teachers_UserId",
table: "Teachers",
column: "UserId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Courses");
migrationBuilder.DropTable(
name: "Students");
migrationBuilder.DropTable(
name: "Teachers");
}
}
}
@@ -72,7 +72,7 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.HasIndex("IsEnabled", "SortOrder");
b.ToTable("AcademicTerms");
b.ToTable("AcademicTerms", (string)null);
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AdministrativeClass", b =>
@@ -121,7 +121,7 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.HasIndex("IsEnabled", "SortOrder");
b.ToTable("AdministrativeClasses");
b.ToTable("AdministrativeClasses", (string)null);
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Building", b =>
@@ -164,7 +164,7 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.HasIndex("IsEnabled", "SortOrder");
b.ToTable("Buildings");
b.ToTable("Buildings", (string)null);
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Campus", b =>
@@ -205,7 +205,7 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.HasIndex("IsEnabled", "SortOrder");
b.ToTable("Campuses");
b.ToTable("Campuses", (string)null);
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Classroom", b =>
@@ -258,7 +258,7 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.HasIndex("IsEnabled", "SortOrder");
b.ToTable("Classrooms");
b.ToTable("Classrooms", (string)null);
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.College", b =>
@@ -304,7 +304,77 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.HasIndex("IsEnabled", "SortOrder");
b.ToTable("Colleges");
b.ToTable("Colleges", (string)null);
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Course", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<int>("AssessmentMethod")
.HasColumnType("int");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("varchar(40)");
b.Property<Guid>("CollegeId")
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("Credits")
.HasPrecision(5, 2)
.HasColumnType("decimal(5,2)");
b.Property<string>("Description")
.HasMaxLength(1000)
.HasColumnType("varchar(1000)");
b.Property<string>("EnglishName")
.HasMaxLength(150)
.HasColumnType("varchar(150)");
b.Property<bool>("IsEnabled")
.HasColumnType("tinyint(1)");
b.Property<int>("LectureHours")
.HasColumnType("int");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<int>("Nature")
.HasColumnType("int");
b.Property<int>("PracticeHours")
.HasColumnType("int");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.Property<int>("TotalHours")
.HasColumnType("int");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("Code")
.IsUnique();
b.HasIndex("CollegeId", "Nature");
b.HasIndex("IsEnabled", "SortOrder");
b.ToTable("Courses", (string)null);
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Major", b =>
@@ -354,7 +424,144 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.HasIndex("IsEnabled", "SortOrder");
b.ToTable("Majors");
b.ToTable("Majors", (string)null);
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Student", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<Guid>("AdministrativeClassId")
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<DateOnly?>("DateOfBirth")
.HasColumnType("date");
b.Property<string>("Email")
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<DateOnly>("EnrollmentDate")
.HasColumnType("date");
b.Property<int>("EnrollmentYear")
.HasColumnType("int");
b.Property<int>("Gender")
.HasColumnType("int");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("Notes")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<string>("Phone")
.HasMaxLength(30)
.HasColumnType("varchar(30)");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<string>("StudentNumber")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("varchar(30)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid?>("UserId")
.HasColumnType("char(36)");
b.HasKey("Id");
b.HasIndex("EnrollmentYear");
b.HasIndex("StudentNumber")
.IsUnique();
b.HasIndex("UserId");
b.HasIndex("AdministrativeClassId", "Status");
b.ToTable("Students", (string)null);
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Teacher", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<Guid>("CollegeId")
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Email")
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<int>("Gender")
.HasColumnType("int");
b.Property<DateOnly?>("HireDate")
.HasColumnType("date");
b.Property<bool>("IsExternal")
.HasColumnType("tinyint(1)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("Notes")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<string>("Phone")
.HasMaxLength(30)
.HasColumnType("varchar(30)");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<string>("TeacherNumber")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("varchar(30)");
b.Property<string>("Title")
.HasMaxLength(30)
.HasColumnType("varchar(30)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid?>("UserId")
.HasColumnType("char(36)");
b.HasKey("Id");
b.HasIndex("TeacherNumber")
.IsUnique();
b.HasIndex("UserId");
b.HasIndex("CollegeId", "Status");
b.ToTable("Teachers", (string)null);
});
modelBuilder.Entity("Jiaowu.Api.Domain.Identity.ApplicationRole", b =>
@@ -519,7 +726,7 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.HasIndex("CreatedAt");
b.ToTable("AuditLogs");
b.ToTable("AuditLogs", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
@@ -664,6 +871,17 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("Campus");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Course", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.College", "College")
.WithMany()
.HasForeignKey("CollegeId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("College");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Major", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.College", "College")
@@ -675,6 +893,38 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("College");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Student", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.AdministrativeClass", "AdministrativeClass")
.WithMany()
.HasForeignKey("AdministrativeClassId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("AdministrativeClass");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Teacher", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.College", "College")
.WithMany()
.HasForeignKey("CollegeId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("College");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
{
b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationRole", null)
+1
View File
@@ -83,6 +83,7 @@ builder.Services.Configure<JwtOptions>(
builder.Configuration.GetSection(JwtOptions.SectionName));
builder.Services.AddScoped<ITokenService, TokenService>();
builder.Services.AddScoped<DatabaseInitializer>();
builder.Services.AddScoped<DevelopmentSqliteMigrator>();
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
@@ -64,4 +64,76 @@ public sealed class PersistenceTests : IAsyncLifetime
Assert.Equal("东校区", saved.College!.Campus!.Name);
}
[Fact]
public async Task People_and_course_relations_can_be_persisted()
{
var campus = new Campus { Code = "MAIN", Name = "主校区" };
var college = new College
{
Code = "CS",
Name = "计算机学院",
CampusId = campus.Id
};
var major = new Major
{
Code = "080901",
Name = "计算机科学与技术",
CollegeId = college.Id,
DegreeType = "工学学士"
};
var administrativeClass = new AdministrativeClass
{
Code = "CS2026-01",
Name = "计科 2026-1 班",
MajorId = major.Id,
Grade = 2026
};
_db.AddRange(
campus,
college,
major,
administrativeClass,
new Teacher
{
TeacherNumber = "T2026001",
Name = "陈老师",
CollegeId = college.Id,
Status = TeacherStatus.Active
},
new Student
{
StudentNumber = "202601001",
Name = "周同学",
AdministrativeClassId = administrativeClass.Id,
EnrollmentYear = 2026,
EnrollmentDate = new DateOnly(2026, 9, 7),
Status = StudentStatus.Active
},
new Course
{
Code = "CS101",
Name = "程序设计基础",
CollegeId = college.Id,
Credits = 4,
TotalHours = 64,
LectureHours = 40,
PracticeHours = 24,
Nature = CourseNature.MajorRequired,
AssessmentMethod = AssessmentMethod.Examination
});
await _db.SaveChangesAsync();
var teacher = await _db.Teachers.Include(x => x.College).SingleAsync();
var student = await _db.Students
.Include(x => x.AdministrativeClass)
.ThenInclude(x => x!.Major)
.SingleAsync();
var course = await _db.Courses.Include(x => x.College).SingleAsync();
Assert.Equal("计算机学院", teacher.College!.Name);
Assert.Equal("计算机科学与技术", student.AdministrativeClass!.Major!.Name);
Assert.Equal(4m, course.Credits);
Assert.Equal(course.TotalHours, course.LectureHours + course.PracticeHours);
}
}
+1
View File
@@ -24,6 +24,7 @@ declare module 'vue' {
ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElOption: typeof import('element-plus/es')['ElOption']
ElPagination: typeof import('element-plus/es')['ElPagination']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable']
+21 -1
View File
@@ -1,7 +1,14 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { DataAnalysis, OfficeBuilding, Operation, User } from '@element-plus/icons-vue'
import {
Collection,
DataAnalysis,
OfficeBuilding,
Operation,
User,
UserFilled,
} from '@element-plus/icons-vue'
import { useAuthStore } from '../stores/auth'
const route = useRoute()
@@ -14,6 +21,8 @@ const pageTitle = computed(() => {
const titles: Record<string, string> = {
dashboard: '教务总览',
'base-data': '基础数据',
personnel: '人员档案',
courses: '课程库',
users: '用户与权限',
}
return titles[String(route.name)] ?? '教务管理'
@@ -60,6 +69,17 @@ onMounted(() => auth.refresh().catch(() => undefined))
<el-icon><OfficeBuilding /></el-icon>
<template #title>基础数据</template>
</el-menu-item>
<el-menu-item
v-if="auth.user?.roles.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor'].includes(role))"
index="/personnel"
>
<el-icon><UserFilled /></el-icon>
<template #title>人员档案</template>
</el-menu-item>
<el-menu-item index="/courses">
<el-icon><Collection /></el-icon>
<template #title>课程库</template>
</el-menu-item>
<el-menu-item v-if="auth.isSuperAdmin" index="/users">
<el-icon><User /></el-icon>
<template #title>用户与权限</template>
+13
View File
@@ -26,6 +26,19 @@ const router = createRouter({
name: 'base-data',
component: () => import('../views/BaseDataView.vue'),
},
{
path: 'personnel',
name: 'personnel',
component: () => import('../views/PersonnelView.vue'),
meta: {
roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor'],
},
},
{
path: 'courses',
name: 'courses',
component: () => import('../views/CoursesView.vue'),
},
{
path: 'users',
name: 'users',
+41 -2
View File
@@ -126,6 +126,35 @@ button { cursor: pointer; }
.entity-form .el-select, .entity-form .el-date-editor { width: 100%; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.form-grid.compact { align-items: center; }
.form-grid.three { grid-template-columns: repeat(3, 1fr); }
.registry-switch { display: grid; grid-template-columns: 1fr 1fr 180px; min-height: 112px; border: 1px solid var(--line); background: white; }
.registry-switch button { padding: 22px 26px; display: grid; gap: 4px; text-align: left; border: none; border-right: 1px solid var(--line); border-bottom: 3px solid transparent; background: white; }
.registry-switch button:hover { background: #fafbfc; }
.registry-switch button.active { border-bottom-color: var(--teal); background: #f7fbfa; }
.registry-switch button > span { color: #9ba3b1; font: 700 9px/1.2 Consolas, monospace; letter-spacing: .13em; }
.registry-switch button > b { font-family: "STZhongsong", "Songti SC", serif; font-size: 19px; }
.registry-switch button > small { color: var(--muted); font-size: 11px; }
.registry-total { padding: 20px; display: grid; place-content: center; text-align: center; background: var(--indigo-deep); color: white; }
.registry-total span, .registry-total small { color: #aeb8d6; font-size: 10px; }
.registry-total strong { margin: 3px 0; font: 700 30px/1 Consolas, monospace; color: #59d3c1; }
.filter-bar { min-height: 72px; padding: 14px 18px; display: flex; flex-wrap: wrap; align-items: center; gap: 10px; border-bottom: 1px solid var(--line); background: #fbfcfd; }
.filter-bar > .el-input { width: 280px; }
.filter-bar > .el-select { width: 150px; }
.registry-number, .course-code { color: var(--indigo); font: 700 12px/1.2 Consolas, monospace; letter-spacing: .03em; }
.record-name, .course-name { display: grid; gap: 3px; }
.record-name b, .course-name b { color: var(--ink); font-size: 13px; }
.record-name span, .course-name span { color: #9299a7; font-size: 10px; }
.pagination-bar { min-height: 62px; padding: 12px 18px; display: flex; align-items: center; justify-content: space-between; border-top: 1px solid var(--line); }
.pagination-bar > span { color: var(--muted); font-size: 11px; }
.course-ledger { min-height: 92px; display: flex; align-items: stretch; color: white; background: linear-gradient(105deg, #1a2d61, #263f80); overflow: hidden; position: relative; }
.course-ledger::after { content: "CATALOGUE"; position: absolute; right: 22px; top: 8px; color: rgba(255,255,255,.045); font: 700 44px/1 Consolas, monospace; letter-spacing: .08em; }
.ledger-label { width: 220px; padding: 18px 26px; display: flex; align-items: baseline; gap: 7px; border-right: 1px solid rgba(255,255,255,.12); position: relative; z-index: 1; }
.ledger-label span { align-self: flex-start; color: #aeb8d6; font-size: 11px; }
.ledger-label b { margin-left: auto; font: 700 31px/1 Consolas, monospace; color: #58d0bf; }
.ledger-label small { color: #c4cce3; font-size: 10px; }
.ledger-rule { display: flex; align-items: center; gap: 0; position: relative; z-index: 1; }
.ledger-rule span { padding: 8px 28px; border-right: 1px solid rgba(255,255,255,.12); color: #d2d8e9; font-size: 11px; letter-spacing: .08em; }
.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; }
@@ -182,8 +211,18 @@ button { cursor: pointer; }
.foundation-list i { display: none; }
.page-intro { align-items: flex-end; }
.page-intro p { display: none; }
.data-card { overflow: visible; }
.form-grid { grid-template-columns: 1fr; gap: 0; }
.registry-switch { grid-template-columns: 1fr 1fr; }
.registry-switch button { padding: 17px; }
.registry-total { grid-column: 1 / -1; display: flex; align-items: center; gap: 8px; min-height: 54px; }
.registry-total strong { margin-left: auto; font-size: 22px; }
.filter-bar > .el-input { width: 100%; }
.filter-bar > .el-select { flex: 1 1 140px; width: auto; }
.course-ledger { min-height: auto; display: block; }
.ledger-label { width: 100%; }
.ledger-rule { overflow-x: auto; }
.ledger-rule span { padding: 12px 20px; white-space: nowrap; }
.data-card { overflow: hidden; }
.form-grid, .form-grid.three { grid-template-columns: 1fr; gap: 0; }
.el-dialog { width: calc(100vw - 24px) !important; }
.login-page { display: block; min-height: 100vh; background: #f4f6f9; }
.login-story { min-height: 310px; padding: 30px 25px; }
+295
View File
@@ -0,0 +1,295 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Plus, Refresh, Search } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth'
const auth = useAuthStore()
const loading = ref(false)
const rows = ref<any[]>([])
const total = ref(0)
const colleges = ref<any[]>([])
const dialogVisible = ref(false)
const editingId = ref('')
const query = reactive({
page: 1,
pageSize: 20,
keyword: '',
collegeId: undefined as string | undefined,
nature: undefined as string | undefined,
isEnabled: undefined as boolean | undefined,
})
const form = reactive<Record<string, any>>({})
const canManage = computed(() =>
auth.user?.roles.some((role) =>
['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'].includes(role),
) ?? false,
)
const availableColleges = computed(() =>
auth.user?.roles.includes('CollegeAdmin') && auth.user.collegeId
? colleges.value.filter((item) => item.id === auth.user!.collegeId)
: colleges.value,
)
const natureLabels: Record<string, string> = {
GeneralRequired: '通识必修',
MajorRequired: '专业必修',
MajorElective: '专业选修',
GeneralElective: '通识选修',
Practice: '实践课程',
}
const assessmentLabels: Record<string, string> = {
Examination: '考试',
Assessment: '考查',
}
function resetForm(row?: any) {
Object.keys(form).forEach((key) => delete form[key])
Object.assign(form, {
code: '',
name: '',
englishName: '',
collegeId: auth.user?.roles.includes('CollegeAdmin') ? auth.user.collegeId : undefined,
credits: 2,
totalHours: 32,
lectureHours: 24,
practiceHours: 8,
nature: 'MajorRequired',
assessmentMethod: 'Examination',
description: '',
isEnabled: true,
sortOrder: 0,
}, row ?? {})
}
async function load() {
loading.value = true
try {
const { data } = await http.get('/courses', {
params: {
page: query.page,
pageSize: query.pageSize,
keyword: query.keyword || undefined,
collegeId: query.collegeId,
nature: query.nature,
isEnabled: query.isEnabled,
},
})
rows.value = data.items
total.value = data.total
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
loading.value = false
}
}
async function resetFilters() {
Object.assign(query, {
page: 1,
keyword: '',
collegeId: auth.user?.roles.includes('CollegeAdmin') ? auth.user.collegeId : undefined,
nature: undefined,
isEnabled: undefined,
})
await load()
}
function openCreate() {
editingId.value = ''
resetForm()
dialogVisible.value = true
}
function openEdit(row: any) {
editingId.value = row.id
resetForm(row)
dialogVisible.value = true
}
async function save() {
if (!form.code?.trim() || !form.name?.trim() || !form.collegeId) {
ElMessage.warning('请填写课程编码、名称和开课学院。')
return
}
if (form.lectureHours + form.practiceHours > form.totalHours) {
ElMessage.warning('讲授学时与实践学时之和不能超过总学时。')
return
}
try {
if (editingId.value) await http.put(`/courses/${editingId.value}`, form)
else await http.post('/courses', form)
ElMessage.success(editingId.value ? '课程已更新' : '课程已加入课程库')
dialogVisible.value = false
await load()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function remove(row: any) {
try {
await ElMessageBox.confirm(
`确定删除课程“${row.name}”吗?已进入培养方案的课程将无法删除。`,
'删除课程',
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' },
)
await http.delete(`/courses/${row.id}`)
ElMessage.success('课程已删除')
await load()
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
onMounted(async () => {
colleges.value = (await http.get('/base-data/colleges')).data
if (auth.user?.roles.includes('CollegeAdmin') && auth.user.collegeId) {
query.collegeId = auth.user.collegeId
}
await load()
})
</script>
<template>
<div class="page-stack">
<section class="page-intro">
<div>
<span class="section-kicker">COURSE CATALOGUE</span>
<h2>课程库</h2>
<p>维护全校统一课程编码学分学时和考核方式供培养方案与教学任务引用</p>
</div>
<el-button v-if="canManage" type="primary" :icon="Plus" @click="openCreate">新增课程</el-button>
</section>
<section class="course-ledger">
<div class="ledger-label">
<span>课程目录</span>
<b>{{ total }}</b>
<small>门课程</small>
</div>
<div class="ledger-rule">
<span>编码唯一</span>
<span>学时守恒</span>
<span>归属明确</span>
</div>
</section>
<section class="data-card">
<div class="filter-bar">
<el-input
v-model="query.keyword"
:prefix-icon="Search"
clearable
placeholder="搜索课程编码、中英文名称"
@keyup.enter="query.page = 1; load()"
/>
<el-select v-model="query.collegeId" clearable placeholder="全部开课学院">
<el-option v-for="item in availableColleges" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
<el-select v-model="query.nature" clearable placeholder="全部课程性质">
<el-option v-for="(label, value) in natureLabels" :key="value" :label="label" :value="value" />
</el-select>
<el-select v-model="query.isEnabled" clearable placeholder="全部状态">
<el-option label="启用" :value="true" />
<el-option label="停用" :value="false" />
</el-select>
<el-button type="primary" @click="query.page = 1; load()">查询</el-button>
<el-button :icon="Refresh" @click="resetFilters">重置</el-button>
</div>
<el-table v-loading="loading" :data="rows" class="data-table course-table">
<el-table-column label="课程编码" width="130">
<template #default="{ row }"><span class="course-code">{{ row.code }}</span></template>
</el-table-column>
<el-table-column label="课程名称" min-width="210">
<template #default="{ row }">
<div class="course-name">
<b>{{ row.name }}</b>
<span>{{ row.englishName || '—' }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="collegeName" label="开课学院" min-width="145" />
<el-table-column label="性质" width="105">
<template #default="{ row }">{{ natureLabels[row.nature] }}</template>
</el-table-column>
<el-table-column label="学分/学时" width="120">
<template #default="{ row }"><b>{{ row.credits }}</b> 学分 · {{ row.totalHours }} 学时</template>
</el-table-column>
<el-table-column label="学时构成" width="135">
<template #default="{ row }">讲授 {{ row.lectureHours }} / 实践 {{ row.practiceHours }}</template>
</el-table-column>
<el-table-column label="考核" width="80">
<template #default="{ row }">{{ assessmentLabels[row.assessmentMethod] }}</template>
</el-table-column>
<el-table-column label="状态" width="85">
<template #default="{ row }">
<span class="table-status" :class="{ off: !row.isEnabled }">{{ row.isEnabled ? '启用' : '停用' }}</span>
</template>
</el-table-column>
<el-table-column v-if="canManage" label="操作" width="145" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button link type="danger" @click="remove(row)">删除</el-button>
</template>
</el-table-column>
<template #empty><el-empty description="没有符合条件的课程" /></template>
</el-table>
<div class="pagination-bar">
<span> {{ total }} </span>
<el-pagination
v-model:current-page="query.page"
:page-size="query.pageSize"
:total="total"
layout="prev, pager, next"
@current-change="load"
/>
</div>
</section>
<el-dialog v-model="dialogVisible" :title="`${editingId ? '编辑' : '新增'}课程`" width="660px">
<el-form label-position="top" class="entity-form">
<div class="form-grid">
<el-form-item label="课程编码" required><el-input v-model="form.code" placeholder="全校唯一" /></el-form-item>
<el-form-item label="课程名称" required><el-input v-model="form.name" /></el-form-item>
</div>
<el-form-item label="英文名称"><el-input v-model="form.englishName" /></el-form-item>
<div class="form-grid">
<el-form-item label="开课学院" required>
<el-select v-model="form.collegeId">
<el-option v-for="item in availableColleges" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="课程性质">
<el-select v-model="form.nature">
<el-option v-for="(label, value) in natureLabels" :key="value" :label="label" :value="value" />
</el-select>
</el-form-item>
</div>
<div class="form-grid three">
<el-form-item label="学分"><el-input-number v-model="form.credits" :min="0.1" :max="99" :step="0.5" /></el-form-item>
<el-form-item label="总学时"><el-input-number v-model="form.totalHours" :min="1" /></el-form-item>
<el-form-item label="考核方式">
<el-select v-model="form.assessmentMethod">
<el-option v-for="(label, value) in assessmentLabels" :key="value" :label="label" :value="value" />
</el-select>
</el-form-item>
</div>
<div class="form-grid">
<el-form-item label="讲授学时"><el-input-number v-model="form.lectureHours" :min="0" /></el-form-item>
<el-form-item label="实践学时"><el-input-number v-model="form.practiceHours" :min="0" /></el-form-item>
</div>
<el-form-item label="课程简介"><el-input v-model="form.description" type="textarea" :rows="3" /></el-form-item>
<div class="form-grid compact">
<el-form-item label="排序"><el-input-number v-model="form.sortOrder" :min="0" /></el-form-item>
<el-form-item label="状态"><el-switch v-model="form.isEnabled" active-text="启用" inactive-text="停用" /></el-form-item>
</div>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="save">保存课程</el-button>
</template>
</el-dialog>
</div>
</template>
+11 -6
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Calendar, Connection, OfficeBuilding, School } from '@element-plus/icons-vue'
import { Collection, OfficeBuilding, School, UserFilled } from '@element-plus/icons-vue'
import http from '../api/http'
interface DashboardData {
@@ -14,10 +14,10 @@ const loading = ref(true)
const data = ref<DashboardData>({ counts: {} })
const stats = computed(() => [
{ label: '校区', value: data.value.counts.campuses ?? 0, unit: '个', icon: OfficeBuilding },
{ label: '学院', value: data.value.counts.colleges ?? 0, unit: '个', icon: School },
{ label: '专业', value: data.value.counts.majors ?? 0, unit: '', icon: Connection },
{ label: '行政班', value: data.value.counts.classes ?? 0, unit: '', icon: Calendar },
{ label: '教师档案', value: data.value.counts.teachers ?? 0, unit: '', icon: UserFilled },
{ label: '学生档案', value: data.value.counts.students ?? 0, unit: '', icon: OfficeBuilding },
{ label: '课程库', value: data.value.counts.courses ?? 0, unit: '门', icon: Collection },
])
onMounted(async () => {
@@ -81,16 +81,21 @@ onMounted(async () => {
<b>角色权限 + 学院数据范围</b>
<i class="done">已建立</i>
</div>
<div>
<span>人员课程</span>
<b>教师档案 + 学生档案 + 课程库</b>
<i class="done">已建立</i>
</div>
</div>
</article>
<article class="work-card phase-card">
<span class="section-kicker">NEXT MILESTONE</span>
<h3>下一段业务链</h3>
<p>基础数据确认后将进入学生教师课程库和培养方案</p>
<p>人员与课程主数据已就绪下一步进入培养方案课程开设与教学任务</p>
<div class="phase-line">
<span class="active">基础底座</span>
<span>人员档案</span>
<span class="active">人员档案</span>
<span>培养方案</span>
<span>教学运行</span>
</div>
+447
View File
@@ -0,0 +1,447 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Plus, Refresh, Search } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth'
type Registry = 'teachers' | 'students'
interface PageResult {
items: any[]
total: number
page: number
pageSize: number
}
const auth = useAuthStore()
const active = ref<Registry>('teachers')
const loading = ref(false)
const rows = ref<any[]>([])
const total = ref(0)
const dialogVisible = ref(false)
const editingId = ref('')
const colleges = ref<any[]>([])
const majors = ref<any[]>([])
const classes = ref<any[]>([])
const query = reactive({
page: 1,
pageSize: 20,
keyword: '',
collegeId: undefined as string | undefined,
majorId: undefined as string | undefined,
classId: undefined as string | undefined,
enrollmentYear: undefined as number | undefined,
status: undefined as string | undefined,
})
const form = reactive<Record<string, any>>({})
const canManage = computed(() =>
auth.user?.roles.some((role) =>
['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'].includes(role),
) ?? false,
)
const scopedCollegeId = computed(() =>
auth.user?.roles.some((role) => ['CollegeAdmin', 'Counselor'].includes(role))
? auth.user.collegeId
: undefined,
)
const availableColleges = computed(() =>
scopedCollegeId.value
? colleges.value.filter((item) => item.id === scopedCollegeId.value)
: colleges.value,
)
const availableMajors = computed(() =>
scopedCollegeId.value
? majors.value.filter((item) => item.collegeId === scopedCollegeId.value)
: majors.value,
)
const availableClasses = computed(() => {
const majorIds = new Set(availableMajors.value.map((item) => item.id))
return classes.value.filter((item) => majorIds.has(item.majorId))
})
const pageTitle = computed(() => active.value === 'teachers' ? '教师档案' : '学生档案')
const numberLabel = computed(() => active.value === 'teachers' ? '工号' : '学号')
const filteredMajors = computed(() =>
query.collegeId
? availableMajors.value.filter((item) => item.collegeId === query.collegeId)
: availableMajors.value,
)
const filteredClasses = computed(() => {
if (query.majorId) {
return availableClasses.value.filter((item) => item.majorId === query.majorId)
}
if (query.collegeId) {
const majorIds = new Set(
availableMajors.value
.filter((item) => item.collegeId === query.collegeId)
.map((item) => item.id),
)
return availableClasses.value.filter((item) => majorIds.has(item.majorId))
}
return availableClasses.value
})
const formClasses = computed(() => availableClasses.value)
const genderLabels: Record<string, string> = {
Unknown: '未设置', Male: '男', Female: '女',
}
const teacherStatusLabels: Record<string, string> = {
Active: '在职', OnLeave: '离岗', Retired: '退休', Departed: '离职',
}
const studentStatusLabels: Record<string, string> = {
Active: '在籍', Suspended: '休学', Graduated: '毕业', Withdrawn: '退学',
}
function resetForm(row?: any) {
Object.keys(form).forEach((key) => delete form[key])
if (active.value === 'teachers') {
Object.assign(form, {
teacherNumber: '',
name: '',
gender: 'Unknown',
collegeId: scopedCollegeId.value,
title: '',
status: 'Active',
hireDate: '',
isExternal: false,
phone: '',
email: '',
notes: '',
}, row ?? {})
} else {
const currentYear = new Date().getFullYear()
Object.assign(form, {
studentNumber: '',
name: '',
gender: 'Unknown',
administrativeClassId: undefined,
enrollmentYear: currentYear,
enrollmentDate: `${currentYear}-09-01`,
status: 'Active',
dateOfBirth: '',
phone: '',
email: '',
notes: '',
}, row ?? {})
}
}
async function load() {
loading.value = true
try {
const params: Record<string, any> = {
page: query.page,
pageSize: query.pageSize,
keyword: query.keyword || undefined,
collegeId: query.collegeId,
}
if (active.value === 'teachers') {
params.teacherStatus = query.status
} else {
Object.assign(params, {
majorId: query.majorId,
classId: query.classId,
enrollmentYear: query.enrollmentYear,
studentStatus: query.status,
})
}
const { data } = await http.get<PageResult>(`/personnel/${active.value}`, { params })
rows.value = data.items
total.value = data.total
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
loading.value = false
}
}
async function loadReferences() {
const [collegeRes, majorRes, classRes] = await Promise.all([
http.get('/base-data/colleges'),
http.get('/base-data/majors'),
http.get('/base-data/classes'),
])
colleges.value = collegeRes.data
majors.value = majorRes.data
classes.value = classRes.data
if (scopedCollegeId.value) query.collegeId = scopedCollegeId.value
}
function switchRegistry(kind: Registry) {
active.value = kind
Object.assign(query, {
page: 1,
keyword: '',
collegeId: scopedCollegeId.value,
majorId: undefined,
classId: undefined,
enrollmentYear: undefined,
status: undefined,
})
load()
}
function resetFilters() {
Object.assign(query, {
page: 1,
keyword: '',
collegeId: scopedCollegeId.value,
majorId: undefined,
classId: undefined,
enrollmentYear: undefined,
status: undefined,
})
load()
}
function openCreate() {
editingId.value = ''
resetForm()
dialogVisible.value = true
}
function openEdit(row: any) {
editingId.value = row.id
resetForm(row)
dialogVisible.value = true
}
async function save() {
const number = active.value === 'teachers' ? form.teacherNumber : form.studentNumber
if (!number?.trim() || !form.name?.trim()) {
ElMessage.warning(`请填写${numberLabel.value}和姓名。`)
return
}
try {
const path = `/personnel/${active.value}`
if (editingId.value) await http.put(`${path}/${editingId.value}`, form)
else await http.post(path, form)
ElMessage.success(editingId.value ? '档案已更新' : '档案已建立')
dialogVisible.value = false
await load()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function remove(row: any) {
try {
await ElMessageBox.confirm(
`确定删除“${row.name}”的${pageTitle.value}吗?`,
'删除档案',
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' },
)
await http.delete(`/personnel/${active.value}/${row.id}`)
ElMessage.success('档案已删除')
await load()
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
onMounted(async () => {
await Promise.all([loadReferences(), load()])
})
</script>
<template>
<div class="page-stack">
<section class="page-intro registry-intro">
<div>
<span class="section-kicker">ACADEMIC REGISTRY</span>
<h2>人员档案</h2>
<p>以学校统一编号管理教师和学生的归属状态与联系信息</p>
</div>
<el-button v-if="canManage" type="primary" :icon="Plus" @click="openCreate">
新增{{ pageTitle }}
</el-button>
</section>
<section class="registry-switch">
<button
type="button"
:class="{ active: active === 'teachers' }"
@click="switchRegistry('teachers')"
>
<span>TEACHING STAFF</span>
<b>教师档案</b>
<small>工号职称与任职状态</small>
</button>
<button
type="button"
:class="{ active: active === 'students' }"
@click="switchRegistry('students')"
>
<span>STUDENT ROLL</span>
<b>学生档案</b>
<small>学号班级与学籍状态</small>
</button>
<div class="registry-total">
<span>当前结果</span>
<strong>{{ total }}</strong>
<small>条档案</small>
</div>
</section>
<section class="data-card">
<div class="filter-bar">
<el-input
v-model="query.keyword"
:prefix-icon="Search"
clearable
:placeholder="`搜索${numberLabel}、姓名${active === 'teachers' ? '或职称' : ''}`"
@keyup.enter="query.page = 1; load()"
/>
<el-select v-model="query.collegeId" clearable placeholder="全部学院">
<el-option v-for="item in availableColleges" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
<template v-if="active === 'students'">
<el-select v-model="query.majorId" clearable filterable placeholder="全部专业">
<el-option v-for="item in filteredMajors" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
<el-select v-model="query.classId" clearable filterable placeholder="全部班级">
<el-option v-for="item in filteredClasses" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</template>
<el-select v-model="query.status" clearable placeholder="全部状态">
<el-option
v-for="(label, value) in active === 'teachers' ? teacherStatusLabels : studentStatusLabels"
:key="value"
:label="label"
:value="value"
/>
</el-select>
<el-button type="primary" @click="query.page = 1; load()">查询</el-button>
<el-button :icon="Refresh" @click="resetFilters">重置</el-button>
</div>
<el-table v-loading="loading" :data="rows" class="data-table registry-table">
<el-table-column :label="numberLabel" width="135">
<template #default="{ row }"><span class="registry-number">{{ active === 'teachers' ? row.teacherNumber : row.studentNumber }}</span></template>
</el-table-column>
<el-table-column label="姓名" min-width="120">
<template #default="{ row }">
<div class="record-name">
<b>{{ row.name }}</b>
<span>{{ genderLabels[row.gender] }}</span>
</div>
</template>
</el-table-column>
<el-table-column v-if="active === 'teachers'" prop="collegeName" label="所属学院" min-width="150" />
<el-table-column v-if="active === 'teachers'" prop="title" label="职称" width="110">
<template #default="{ row }">{{ row.title || '—' }}</template>
</el-table-column>
<el-table-column v-if="active === 'teachers'" label="教师类别" width="105">
<template #default="{ row }">{{ row.isExternal ? '外聘' : '校内' }}</template>
</el-table-column>
<el-table-column v-if="active === 'students'" prop="className" label="行政班" min-width="155" />
<el-table-column v-if="active === 'students'" prop="majorName" label="专业" min-width="170" />
<el-table-column v-if="active === 'students'" prop="enrollmentYear" label="入学年级" width="105" />
<el-table-column label="状态" width="95">
<template #default="{ row }">
<span class="table-status" :class="{ off: row.status !== 'Active' }">
{{ (active === 'teachers' ? teacherStatusLabels : studentStatusLabels)[row.status] }}
</span>
</template>
</el-table-column>
<el-table-column label="联系方式" min-width="170">
<template #default="{ row }">{{ row.phone || row.email || '—' }}</template>
</el-table-column>
<el-table-column v-if="canManage" label="操作" width="145" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button link type="danger" @click="remove(row)">删除</el-button>
</template>
</el-table-column>
<template #empty><el-empty description="没有符合条件的档案" /></template>
</el-table>
<div class="pagination-bar">
<span> {{ total }} </span>
<el-pagination
v-model:current-page="query.page"
:page-size="query.pageSize"
:total="total"
layout="prev, pager, next"
@current-change="load"
/>
</div>
</section>
<el-dialog v-model="dialogVisible" :title="`${editingId ? '编辑' : '新增'}${pageTitle}`" width="620px">
<el-form label-position="top" class="entity-form">
<template v-if="active === 'teachers'">
<div class="form-grid">
<el-form-item label="工号" required><el-input v-model="form.teacherNumber" /></el-form-item>
<el-form-item label="姓名" required><el-input v-model="form.name" /></el-form-item>
</div>
<div class="form-grid">
<el-form-item label="所属学院" required>
<el-select v-model="form.collegeId">
<el-option v-for="item in availableColleges" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="职称"><el-input v-model="form.title" placeholder="教授、副教授、讲师等" /></el-form-item>
</div>
<div class="form-grid">
<el-form-item label="性别">
<el-select v-model="form.gender">
<el-option v-for="(label, value) in genderLabels" :key="value" :label="label" :value="value" />
</el-select>
</el-form-item>
<el-form-item label="任职状态">
<el-select v-model="form.status">
<el-option v-for="(label, value) in teacherStatusLabels" :key="value" :label="label" :value="value" />
</el-select>
</el-form-item>
</div>
<div class="form-grid">
<el-form-item label="入职日期"><el-date-picker v-model="form.hireDate" value-format="YYYY-MM-DD" /></el-form-item>
<el-form-item label="教师类别"><el-switch v-model="form.isExternal" active-text="外聘" inactive-text="校内" /></el-form-item>
</div>
</template>
<template v-else>
<div class="form-grid">
<el-form-item label="学号" required><el-input v-model="form.studentNumber" /></el-form-item>
<el-form-item label="姓名" required><el-input v-model="form.name" /></el-form-item>
</div>
<el-form-item label="行政班" required>
<el-select v-model="form.administrativeClassId" filterable>
<el-option
v-for="item in formClasses"
:key="item.id"
:label="`${item.collegeName} · ${item.majorName} · ${item.name}`"
:value="item.id"
/>
</el-select>
</el-form-item>
<div class="form-grid">
<el-form-item label="入学年级"><el-input-number v-model="form.enrollmentYear" :min="2000" :max="2200" /></el-form-item>
<el-form-item label="入学日期"><el-date-picker v-model="form.enrollmentDate" value-format="YYYY-MM-DD" /></el-form-item>
</div>
<div class="form-grid">
<el-form-item label="性别">
<el-select v-model="form.gender">
<el-option v-for="(label, value) in genderLabels" :key="value" :label="label" :value="value" />
</el-select>
</el-form-item>
<el-form-item label="学籍状态">
<el-select v-model="form.status">
<el-option v-for="(label, value) in studentStatusLabels" :key="value" :label="label" :value="value" />
</el-select>
</el-form-item>
</div>
<el-form-item label="出生日期"><el-date-picker v-model="form.dateOfBirth" value-format="YYYY-MM-DD" /></el-form-item>
</template>
<div class="form-grid">
<el-form-item label="联系电话"><el-input v-model="form.phone" /></el-form-item>
<el-form-item label="电子邮箱"><el-input v-model="form.email" /></el-form-item>
</div>
<el-form-item label="备注"><el-input v-model="form.notes" type="textarea" :rows="3" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="save">保存档案</el-button>
</template>
</el-dialog>
</div>
</template>