This commit is contained in:
2026-07-24 12:42:51 +08:00 Unverified
commit 67905dfa16
56 changed files with 7630 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
**/bin/
**/obj/
**/node_modules/
**/dist/
.vs/
.vscode/
*.user
*.suo
*.sqlite
*.sqlite-shm
*.sqlite-wal
src/Jiaowu.Api/data/
.env
.env.*
!.env.example
+8
View File
@@ -0,0 +1,8 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/Jiaowu.Api/Jiaowu.Api.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/Jiaowu.Api.Tests/Jiaowu.Api.Tests.csproj" />
</Folder>
</Solution>
+52
View File
@@ -0,0 +1,52 @@
# 明序教务管理系统
面向普通高校的教务管理系统。后端使用 ASP.NET Core 10、EF Core 10,前端使用 Vue 3、TypeScript 和 Element Plus。
## 本地开发
本地开发固定使用 SQLite。首次启动会自动创建 `src/Jiaowu.Api/data/jiaowu-dev.sqlite` 并写入演示组织数据。
```powershell
dotnet run --project src/Jiaowu.Api
```
另开一个终端:
```powershell
Set-Location web
npm install
npm run dev
```
访问 `http://localhost:5173`,开发账号为 `admin`,密码为 `Admin@123456`
## MySQL 部署
非 Development 环境只允许使用 MySQL。连接串和 JWT 密钥应通过环境变量或安全配置中心提供:
```powershell
$env:ASPNETCORE_ENVIRONMENT = 'Production'
$env:Database__Provider = 'MySql'
$env:ConnectionStrings__MySql = 'Server=127.0.0.1;Port=3306;Database=jiaowu;User=YOUR_USER;Password=YOUR_PASSWORD;'
$env:Jwt__Key = '至少32字节的随机生产密钥'
dotnet run --project src/Jiaowu.Api
```
生产环境不会创建默认管理员。首次部署前可以临时配置 `SeedAdmin__UserName``SeedAdmin__Password``SeedAdmin__DisplayName`,账号创建后移除这些配置。
生产数据库使用 MySQL 专用 EF Core 迁移。部署前先恢复仓库工具并检查迁移:
```powershell
dotnet tool restore
dotnet ef migrations list --project src/Jiaowu.Api --startup-project src/Jiaowu.Api
```
应用启动时会自动执行尚未应用的 MySQL 迁移。SQLite 只用于本地开发,并通过 `EnsureCreated` 建立本地数据库;开发模型变化后,可删除本地 SQLite 文件重新生成,不能把该文件用于生产。
## 验证
```powershell
dotnet test Jiaowu.slnx
Set-Location web
npm run build
```
+96
View File
@@ -0,0 +1,96 @@
一、系统角色
超级管理员:系统配置、权限、数据字典、日志
教务处:校级教学计划、排课、选课、考试、成绩、学籍管理
院系教务员:本院专业、课程、教学任务、成绩审核
教师:课表、学生名单、考勤、成绩录入、调停课申请
辅导员:学生信息、考勤、学业预警
学生:课表、选课、成绩、考试、学籍申请
校领导:统计分析和决策看板
可选角色:财务人员、宿管人员、用人单位或家长
权限建议采用“角色 + 数据范围”模式,例如院系教务员只能管理本学院数据。
二、核心功能模块
模块 主要功能
基础数据 校区、学院、系部、专业、班级、年级、教室、学期、课程性质、成绩等级等
用户与权限 用户、角色、菜单权限、操作权限、数据权限、登录日志、操作审计
学籍管理 学生档案、照片、班级、学籍状态、转专业、休学、复学、退学、留级、异动审批
课程库 课程编号、学分、学时、课程性质、先修课程、课程简介、开课学院
培养方案 专业培养方案、课程模块、必修/选修要求、学分要求、版本管理
教学任务 学期课程开设、授课教师、教学班、容量、授课周次、合班拆班
智能排课 教师、班级、教室、时间冲突检测,手工调整,课表发布
选课管理 选课轮次、选课限制、容量、退补选、冲突校验、先修条件、候补名单
课表管理 学生课表、教师课表、班级课表、教室课表、周次课表
调停课管理 调课、停课、补课、代课申请,审批及消息通知
考勤管理 点名、迟到、早退、请假、缺勤,批量导入和统计
考试管理 考试安排、考场、监考教师、座位表、缓考、补考、重修考试
成绩管理 成绩项目、权重、录入、导入、提交、审核、发布、修改留痕
补考与重修 补考资格、报名、重修选课、成绩认定、历史记录
教学评价 学生评教、同行评价、督导评价、问卷配置、匿名统计
教师管理 教师档案、职称、所属院系、授课资格、教学工作量
教室资源 教室容量、类型、设备、占用情况、借用申请
毕业审核 学分完成度、必修课程、实践环节、毕业资格、学位资格
学业预警 不及格学分、低绩点、缺勤、延毕风险,预警规则与处理记录
审批中心 学籍异动、调课、缓考、成绩修改、免修、课程替代等统一审批
消息通知 站内消息、公告、待办提醒,可扩展短信、邮件、企业微信
统计报表 学生、课程、成绩、通过率、教师工作量、教室利用率等
数据导入导出 Excel模板、批量校验、错误报告、导入记录、报表导出
系统运维 参数配置、字典管理、任务调度、日志、备份、异常监控
三、学生端主要功能
查看个人信息和学籍状态
查看培养方案及学分完成情况
在线选课、退课和候补
查看课表、考试安排和教室
查看成绩、绩点及成绩单
发起缓考、免修、重修、学籍异动等申请
查看审批进度和消息通知
教学评价
查看学业预警和毕业审核进度
四、教师端主要功能
查看个人课表和教学任务
查看教学班学生名单
考勤和请假确认
平时成绩、考试成绩录入及Excel导入
设置成绩构成与权重
提交成绩并查看审核状态
调课、停课、补课、代课申请
查看评教结果和教学工作量
导出点名册、成绩单等材料
五、关键业务规则
系统设计时应重点保证:
排课不能造成教师、班级或教室冲突
选课需要校验时间冲突、容量、先修课程和选课资格
已发布成绩不能直接修改,必须申请、审批并保留修改记录
培养方案需要版本化,不能因新方案影响旧年级学生
学籍异动必须保留完整历史
重要发布操作应支持撤回限制、二次确认和审计
Excel导入应先预检,确认无误后再正式写入
权限不仅控制菜单,还要控制学院、专业、班级等数据范围
六、推荐建设阶段
第一阶段:核心可用版
组织、用户、权限和基础数据
学生、教师、课程、班级、教室管理
培养方案
教学任务
排课与课表
学生选课
成绩录入、审核和查询
基础学籍管理
通知、审批、Excel导入导出
操作日志
第二阶段:完整教务版
考试与考场安排
补考、缓考、重修
考勤与请假
调停课
教学评价
学业预警
教师工作量
毕业与学位审核
综合统计报表
第三阶段:智慧教务版
智能排课优化
移动端或微信端
电子成绩单与电子证明
数据驾驶舱
校园统一身份认证
对接财务、一卡通、宿舍、图书馆和教育厅平台
+13
View File
@@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "10.0.10",
"commands": [
"dotnet-ef"
],
"rollForward": false
}
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"sdk": {
"version": "10.0.302",
"rollForward": "latestPatch"
}
}
+67
View File
@@ -0,0 +1,67 @@
$ErrorActionPreference = 'Stop'
$workspaceRoot = Split-Path -Parent $PSScriptRoot
$apiProjectDirectory = Join-Path $workspaceRoot 'src/Jiaowu.Api'
$apiExecutable = Join-Path $workspaceRoot 'src/Jiaowu.Api/bin/Debug/net10.0/Jiaowu.Api.exe'
$stdoutPath = Join-Path $env:TEMP 'jiaowu-api-smoke.out.log'
$stderrPath = Join-Path $env:TEMP 'jiaowu-api-smoke.err.log'
$env:ASPNETCORE_ENVIRONMENT = 'Development'
$env:ASPNETCORE_URLS = 'http://localhost:5255'
$startParameters = @{
FilePath = $apiExecutable
WorkingDirectory = $apiProjectDirectory
WindowStyle = 'Hidden'
RedirectStandardOutput = $stdoutPath
RedirectStandardError = $stderrPath
PassThru = $true
}
$process = Start-Process @startParameters
try {
$healthy = $false
for ($attempt = 0; $attempt -lt 30; $attempt++) {
Start-Sleep -Milliseconds 500
try {
$health = Invoke-RestMethod -Uri 'http://localhost:5255/health' -TimeoutSec 2
$healthy = $true
break
}
catch {
# The API may still be starting.
}
}
if (-not $healthy) {
Get-Content -LiteralPath $stdoutPath -ErrorAction SilentlyContinue
Get-Content -LiteralPath $stderrPath -ErrorAction SilentlyContinue
throw 'API did not become healthy.'
}
$loginBody = @{
userName = 'admin'
password = 'Admin@123456'
} | ConvertTo-Json
$loginParameters = @{
Method = 'Post'
Uri = 'http://localhost:5255/api/auth/login'
ContentType = 'application/json'
Body = $loginBody
}
$login = Invoke-RestMethod @loginParameters
$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
[pscustomobject]@{
Health = $health.status
Database = $health.database
User = $login.user.displayName
Term = $dashboard.currentTerm.name
Campuses = @($campuses).Count
} | Format-List
}
finally {
if (-not $process.HasExited) {
Stop-Process -Id $process.Id -Force
}
}
@@ -0,0 +1,90 @@
using System.ComponentModel.DataAnnotations;
using System.Security.Claims;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Route("api/auth")]
public sealed class AuthController(
UserManager<ApplicationUser> userManager,
ITokenService tokenService) : ControllerBase
{
[AllowAnonymous]
[HttpPost("login")]
public async Task<ActionResult<LoginResponse>> Login(LoginRequest request)
{
var user = await userManager.FindByNameAsync(request.UserName);
if (user is null || !user.IsEnabled)
{
return Unauthorized(new ProblemDetails
{
Title = "登录失败",
Detail = "账号或密码不正确,或账号已停用。",
Status = StatusCodes.Status401Unauthorized
});
}
if (await userManager.IsLockedOutAsync(user) ||
!await userManager.CheckPasswordAsync(user, request.Password))
{
await userManager.AccessFailedAsync(user);
return Unauthorized(new ProblemDetails
{
Title = "登录失败",
Detail = "账号或密码不正确,或账号已停用。",
Status = StatusCodes.Status401Unauthorized
});
}
await userManager.ResetAccessFailedCountAsync(user);
user.LastLoginAt = DateTime.UtcNow;
await userManager.UpdateAsync(user);
var roles = await userManager.GetRolesAsync(user);
return new LoginResponse(
tokenService.Create(user, roles),
new CurrentUserResponse(
user.Id,
user.UserName!,
user.DisplayName,
roles,
user.CollegeId));
}
[Authorize]
[HttpGet("me")]
public async Task<ActionResult<CurrentUserResponse>> Me()
{
var id = User.FindFirstValue(ClaimTypes.NameIdentifier);
var user = id is null ? null : await userManager.FindByIdAsync(id);
if (user is null || !user.IsEnabled)
{
return Unauthorized();
}
return new CurrentUserResponse(
user.Id,
user.UserName!,
user.DisplayName,
await userManager.GetRolesAsync(user),
user.CollegeId);
}
}
public sealed record LoginRequest(
[Required, MaxLength(100)] string UserName,
[Required, MaxLength(100)] string Password);
public sealed record LoginResponse(string Token, CurrentUserResponse User);
public sealed record CurrentUserResponse(
Guid Id,
string UserName,
string DisplayName,
IEnumerable<string> Roles,
Guid? CollegeId);
@@ -0,0 +1,468 @@
using System.ComponentModel.DataAnnotations;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Common;
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/base-data")]
public sealed class BaseDataController(AppDbContext db) : ControllerBase
{
private const string Administrators =
$"{SystemRoles.SuperAdmin},{SystemRoles.AcademicAdmin}";
[HttpGet("campuses")]
public async Task<ActionResult<IReadOnlyCollection<Campus>>> GetCampuses(
CancellationToken cancellationToken) =>
await db.Campuses.AsNoTracking()
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
.ToListAsync(cancellationToken);
[HttpPost("campuses")]
[Authorize(Roles = Administrators)]
public async Task<ActionResult<Campus>> CreateCampus(
CatalogRequest request,
CancellationToken cancellationToken)
{
var entity = new Campus
{
Code = request.Code.Trim(),
Name = request.Name.Trim(),
Address = request.Description?.Trim(),
SortOrder = request.SortOrder,
IsEnabled = request.IsEnabled
};
return await CreateAsync(entity, "GetCampuses", cancellationToken);
}
[HttpPut("campuses/{id:guid}")]
[Authorize(Roles = Administrators)]
public async Task<ActionResult<Campus>> UpdateCampus(
Guid id,
CatalogRequest request,
CancellationToken cancellationToken)
{
var entity = await db.Campuses.FindAsync([id], cancellationToken);
if (entity is null) return NotFound();
ApplyCatalog(entity, request);
entity.Address = request.Description?.Trim();
await db.SaveChangesAsync(cancellationToken);
return entity;
}
[HttpGet("colleges")]
public async Task<ActionResult<object>> GetColleges(CancellationToken cancellationToken) =>
Ok(await db.Colleges.AsNoTracking()
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
.Select(x => new
{
x.Id, x.Code, x.Name, x.ShortName, x.CampusId,
CampusName = x.Campus != null ? x.Campus.Name : null,
x.IsEnabled, x.SortOrder
})
.ToListAsync(cancellationToken));
[HttpPost("colleges")]
[Authorize(Roles = Administrators)]
public async Task<ActionResult<College>> CreateCollege(
CollegeRequest request,
CancellationToken cancellationToken)
{
if (request.CampusId.HasValue &&
!await db.Campuses.AnyAsync(x => x.Id == request.CampusId, cancellationToken))
{
return ValidationProblem("所选校区不存在。");
}
var entity = new College
{
Code = request.Code.Trim(),
Name = request.Name.Trim(),
ShortName = request.ShortName?.Trim(),
CampusId = request.CampusId,
SortOrder = request.SortOrder,
IsEnabled = request.IsEnabled
};
return await CreateAsync(entity, "GetColleges", cancellationToken);
}
[HttpPut("colleges/{id:guid}")]
[Authorize(Roles = Administrators)]
public async Task<ActionResult<College>> UpdateCollege(
Guid id,
CollegeRequest request,
CancellationToken cancellationToken)
{
var entity = await db.Colleges.FindAsync([id], cancellationToken);
if (entity is null) return NotFound();
ApplyCatalog(entity, request);
entity.ShortName = request.ShortName?.Trim();
entity.CampusId = request.CampusId;
await db.SaveChangesAsync(cancellationToken);
return entity;
}
[HttpGet("majors")]
public async Task<ActionResult<object>> GetMajors(CancellationToken cancellationToken) =>
Ok(await db.Majors.AsNoTracking()
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
.Select(x => new
{
x.Id, x.Code, x.Name, x.CollegeId,
CollegeName = x.College!.Name,
x.DegreeType, x.SchoolingYears, x.IsEnabled, x.SortOrder
})
.ToListAsync(cancellationToken));
[HttpPost("majors")]
[Authorize(Roles = Administrators)]
public async Task<ActionResult<Major>> CreateMajor(
MajorRequest request,
CancellationToken cancellationToken)
{
if (!await db.Colleges.AnyAsync(x => x.Id == request.CollegeId, cancellationToken))
return ValidationProblem("所选学院不存在。");
var entity = new Major
{
Code = request.Code.Trim(),
Name = request.Name.Trim(),
CollegeId = request.CollegeId,
DegreeType = request.DegreeType.Trim(),
SchoolingYears = request.SchoolingYears,
SortOrder = request.SortOrder,
IsEnabled = request.IsEnabled
};
return await CreateAsync(entity, "GetMajors", cancellationToken);
}
[HttpPut("majors/{id:guid}")]
[Authorize(Roles = Administrators)]
public async Task<ActionResult<Major>> UpdateMajor(
Guid id,
MajorRequest request,
CancellationToken cancellationToken)
{
var entity = await db.Majors.FindAsync([id], cancellationToken);
if (entity is null) return NotFound();
ApplyCatalog(entity, request);
entity.CollegeId = request.CollegeId;
entity.DegreeType = request.DegreeType.Trim();
entity.SchoolingYears = request.SchoolingYears;
await db.SaveChangesAsync(cancellationToken);
return entity;
}
[HttpGet("classes")]
public async Task<ActionResult<object>> GetClasses(CancellationToken cancellationToken) =>
Ok(await db.AdministrativeClasses.AsNoTracking()
.OrderByDescending(x => x.Grade).ThenBy(x => x.Code)
.Select(x => new
{
x.Id, x.Code, x.Name, x.MajorId,
MajorName = x.Major!.Name,
CollegeName = x.Major.College!.Name,
x.Grade, x.CounselorName, x.IsEnabled, x.SortOrder
})
.ToListAsync(cancellationToken));
[HttpPost("classes")]
[Authorize(Roles = Administrators)]
public async Task<ActionResult<AdministrativeClass>> CreateClass(
ClassRequest request,
CancellationToken cancellationToken)
{
if (!await db.Majors.AnyAsync(x => x.Id == request.MajorId, cancellationToken))
return ValidationProblem("所选专业不存在。");
var entity = new AdministrativeClass
{
Code = request.Code.Trim(),
Name = request.Name.Trim(),
MajorId = request.MajorId,
Grade = request.Grade,
CounselorName = request.CounselorName?.Trim(),
SortOrder = request.SortOrder,
IsEnabled = request.IsEnabled
};
return await CreateAsync(entity, "GetClasses", cancellationToken);
}
[HttpPut("classes/{id:guid}")]
[Authorize(Roles = Administrators)]
public async Task<ActionResult<AdministrativeClass>> UpdateClass(
Guid id,
ClassRequest request,
CancellationToken cancellationToken)
{
var entity = await db.AdministrativeClasses.FindAsync([id], cancellationToken);
if (entity is null) return NotFound();
ApplyCatalog(entity, request);
entity.MajorId = request.MajorId;
entity.Grade = request.Grade;
entity.CounselorName = request.CounselorName?.Trim();
await db.SaveChangesAsync(cancellationToken);
return entity;
}
[HttpGet("terms")]
public async Task<ActionResult<IReadOnlyCollection<AcademicTerm>>> GetTerms(
CancellationToken cancellationToken) =>
await db.AcademicTerms.AsNoTracking()
.OrderByDescending(x => x.StartDate)
.ToListAsync(cancellationToken);
[HttpPost("terms")]
[Authorize(Roles = Administrators)]
public async Task<ActionResult<AcademicTerm>> CreateTerm(
TermRequest request,
CancellationToken cancellationToken)
{
if (request.EndDate <= request.StartDate)
return ValidationProblem("学期结束日期必须晚于开始日期。");
if (request.IsCurrent)
await db.AcademicTerms.ExecuteUpdateAsync(
setters => setters.SetProperty(x => x.IsCurrent, false),
cancellationToken);
var entity = new AcademicTerm
{
Code = request.Code.Trim(),
Name = request.Name.Trim(),
AcademicYear = request.AcademicYear.Trim(),
Season = request.Season,
StartDate = request.StartDate,
EndDate = request.EndDate,
IsCurrent = request.IsCurrent,
IsEnabled = request.IsEnabled
};
return await CreateAsync(entity, "GetTerms", cancellationToken);
}
[HttpPut("terms/{id:guid}")]
[Authorize(Roles = Administrators)]
public async Task<ActionResult<AcademicTerm>> UpdateTerm(
Guid id,
TermRequest request,
CancellationToken cancellationToken)
{
var entity = await db.AcademicTerms.FindAsync([id], cancellationToken);
if (entity is null) return NotFound();
if (request.IsCurrent)
await db.AcademicTerms.Where(x => x.Id != id).ExecuteUpdateAsync(
setters => setters.SetProperty(x => x.IsCurrent, false),
cancellationToken);
ApplyCatalog(entity, request);
entity.AcademicYear = request.AcademicYear.Trim();
entity.Season = request.Season;
entity.StartDate = request.StartDate;
entity.EndDate = request.EndDate;
entity.IsCurrent = request.IsCurrent;
await db.SaveChangesAsync(cancellationToken);
return entity;
}
[HttpGet("classrooms")]
public async Task<ActionResult<object>> GetClassrooms(CancellationToken cancellationToken) =>
Ok(await db.Classrooms.AsNoTracking()
.OrderBy(x => x.Building!.Campus!.SortOrder)
.ThenBy(x => x.Code)
.Select(x => new
{
x.Id, x.Code, x.Name, x.BuildingId,
BuildingName = x.Building!.Name,
CampusName = x.Building.Campus!.Name,
x.Capacity, x.RoomType, x.Equipment, x.IsEnabled, x.SortOrder
})
.ToListAsync(cancellationToken));
[HttpGet("buildings")]
public async Task<ActionResult<object>> GetBuildings(CancellationToken cancellationToken) =>
Ok(await db.Buildings.AsNoTracking()
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
.Select(x => new
{
x.Id, x.Code, x.Name, x.CampusId,
CampusName = x.Campus!.Name, x.IsEnabled, x.SortOrder
})
.ToListAsync(cancellationToken));
[HttpPost("buildings")]
[Authorize(Roles = Administrators)]
public async Task<ActionResult<Building>> CreateBuilding(
BuildingRequest request,
CancellationToken cancellationToken)
{
if (!await db.Campuses.AnyAsync(x => x.Id == request.CampusId, cancellationToken))
return ValidationProblem("所选校区不存在。");
var entity = new Building
{
Code = request.Code.Trim(),
Name = request.Name.Trim(),
CampusId = request.CampusId,
SortOrder = request.SortOrder,
IsEnabled = request.IsEnabled
};
return await CreateAsync(entity, "GetBuildings", cancellationToken);
}
[HttpPost("classrooms")]
[Authorize(Roles = Administrators)]
public async Task<ActionResult<Classroom>> CreateClassroom(
ClassroomRequest request,
CancellationToken cancellationToken)
{
if (!await db.Buildings.AnyAsync(x => x.Id == request.BuildingId, cancellationToken))
return ValidationProblem("所选教学楼不存在。");
var entity = new Classroom
{
Code = request.Code.Trim(),
Name = request.Name.Trim(),
BuildingId = request.BuildingId,
Capacity = request.Capacity,
RoomType = request.RoomType.Trim(),
Equipment = request.Equipment?.Trim(),
SortOrder = request.SortOrder,
IsEnabled = request.IsEnabled
};
return await CreateAsync(entity, "GetClassrooms", cancellationToken);
}
[HttpPut("classrooms/{id:guid}")]
[Authorize(Roles = Administrators)]
public async Task<ActionResult<Classroom>> UpdateClassroom(
Guid id,
ClassroomRequest request,
CancellationToken cancellationToken)
{
var entity = await db.Classrooms.FindAsync([id], cancellationToken);
if (entity is null) return NotFound();
ApplyCatalog(entity, request);
entity.BuildingId = request.BuildingId;
entity.Capacity = request.Capacity;
entity.RoomType = request.RoomType.Trim();
entity.Equipment = request.Equipment?.Trim();
await db.SaveChangesAsync(cancellationToken);
return entity;
}
[HttpDelete("{kind}/{id:guid}")]
[Authorize(Roles = Administrators)]
public async Task<IActionResult> Delete(
string kind,
Guid id,
CancellationToken cancellationToken)
{
object? entity = kind.ToLowerInvariant() switch
{
"campuses" => await db.Campuses.FindAsync([id], cancellationToken),
"colleges" => await db.Colleges.FindAsync([id], cancellationToken),
"majors" => await db.Majors.FindAsync([id], cancellationToken),
"classes" => await db.AdministrativeClasses.FindAsync([id], cancellationToken),
"terms" => await db.AcademicTerms.FindAsync([id], cancellationToken),
"buildings" => await db.Buildings.FindAsync([id], cancellationToken),
"classrooms" => await db.Classrooms.FindAsync([id], cancellationToken),
_ => null
};
if (entity is null) return NotFound();
db.Remove(entity);
try
{
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
catch (DbUpdateException)
{
return Conflict(new ProblemDetails
{
Title = "无法删除",
Detail = "该数据已被其他业务引用,请先停用,或移除关联数据后再删除。",
Status = StatusCodes.Status409Conflict
});
}
}
private async Task<ActionResult<TEntity>> CreateAsync<TEntity>(
TEntity entity,
string action,
CancellationToken cancellationToken)
where TEntity : EntityBase
{
db.Add(entity);
try
{
await db.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateException)
{
ModelState.AddModelError("code", "编码已存在或关联数据无效。");
return ValidationProblem(ModelState);
}
return CreatedAtAction(action, new { id = entity.Id }, entity);
}
private static void ApplyCatalog(CatalogEntity entity, CatalogRequest request)
{
entity.Code = request.Code.Trim();
entity.Name = request.Name.Trim();
entity.SortOrder = request.SortOrder;
entity.IsEnabled = request.IsEnabled;
}
}
public record CatalogRequest(
[Required, MaxLength(40)] string Code,
[Required, MaxLength(100)] string Name,
int SortOrder = 0,
bool IsEnabled = true,
[MaxLength(300)] string? Description = null);
public sealed record CollegeRequest(
string Code, string Name, int SortOrder, bool IsEnabled,
Guid? CampusId,
[MaxLength(50)] string? ShortName)
: CatalogRequest(Code, Name, SortOrder, IsEnabled);
public sealed record MajorRequest(
string Code, string Name, int SortOrder, bool IsEnabled,
Guid CollegeId,
[Required, MaxLength(30)] string DegreeType,
[Range(1, 8)] int SchoolingYears)
: CatalogRequest(Code, Name, SortOrder, IsEnabled);
public sealed record ClassRequest(
string Code, string Name, int SortOrder, bool IsEnabled,
Guid MajorId,
[Range(2000, 2200)] int Grade,
[MaxLength(50)] string? CounselorName)
: CatalogRequest(Code, Name, SortOrder, IsEnabled);
public sealed record TermRequest(
string Code, string Name, bool IsEnabled,
[Required, MaxLength(20)] string AcademicYear,
TermSeason Season,
DateOnly StartDate,
DateOnly EndDate,
bool IsCurrent)
: CatalogRequest(Code, Name, 0, IsEnabled);
public sealed record BuildingRequest(
string Code, string Name, int SortOrder, bool IsEnabled,
Guid CampusId)
: CatalogRequest(Code, Name, SortOrder, IsEnabled);
public sealed record ClassroomRequest(
string Code, string Name, int SortOrder, bool IsEnabled,
Guid BuildingId,
[Range(1, 1000)] int Capacity,
[Required, MaxLength(40)] string RoomType,
[MaxLength(300)] string? Equipment)
: CatalogRequest(Code, Name, SortOrder, IsEnabled);
@@ -0,0 +1,36 @@
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/dashboard")]
public sealed class DashboardController(AppDbContext db) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<object>> Get(CancellationToken cancellationToken)
{
var currentTerm = await db.AcademicTerms
.AsNoTracking()
.Where(x => x.IsCurrent)
.Select(x => new { x.Id, x.Name, x.StartDate, x.EndDate })
.FirstOrDefaultAsync(cancellationToken);
return new
{
CurrentTerm = currentTerm,
Counts = new
{
Campuses = await db.Campuses.CountAsync(cancellationToken),
Colleges = await db.Colleges.CountAsync(cancellationToken),
Majors = await db.Majors.CountAsync(cancellationToken),
Classes = await db.AdministrativeClasses.CountAsync(cancellationToken),
Classrooms = await db.Classrooms.CountAsync(cancellationToken),
Users = await db.Users.CountAsync(cancellationToken)
}
};
}
}
@@ -0,0 +1,143 @@
using System.ComponentModel.DataAnnotations;
using System.Security.Claims;
using Jiaowu.Api.Domain.Identity;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize(Roles = SystemRoles.SuperAdmin)]
[Route("api/users")]
public sealed class UsersController(
UserManager<ApplicationUser> userManager,
RoleManager<ApplicationRole> roleManager) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<object>> GetUsers(CancellationToken cancellationToken)
{
var users = await userManager.Users.AsNoTracking()
.OrderBy(x => x.UserName)
.Select(x => new
{
x.Id, x.UserName, x.DisplayName, x.StaffNumber,
x.CollegeId, x.IsEnabled, x.LastLoginAt, x.CreatedAt
})
.ToListAsync(cancellationToken);
var result = new List<object>();
foreach (var user in users)
{
var identityUser = await userManager.FindByIdAsync(user.Id.ToString());
result.Add(new
{
user.Id, user.UserName, user.DisplayName, user.StaffNumber,
user.CollegeId, user.IsEnabled, user.LastLoginAt, user.CreatedAt,
Roles = identityUser is null
? []
: await userManager.GetRolesAsync(identityUser)
});
}
return Ok(result);
}
[HttpGet("roles")]
public async Task<ActionResult<object>> GetRoles(CancellationToken cancellationToken) =>
Ok(await roleManager.Roles.AsNoTracking()
.OrderBy(x => x.Name)
.Select(x => new { x.Name, x.Description, x.DataScope })
.ToListAsync(cancellationToken));
[HttpPost]
public async Task<ActionResult> Create(CreateUserRequest request)
{
var invalidRoles = request.Roles
.Except(SystemRoles.All, StringComparer.OrdinalIgnoreCase)
.ToArray();
if (invalidRoles.Length > 0)
return ValidationProblem($"无效角色:{string.Join("", invalidRoles)}");
var user = new ApplicationUser
{
UserName = request.UserName.Trim(),
DisplayName = request.DisplayName.Trim(),
StaffNumber = request.StaffNumber?.Trim(),
CollegeId = request.CollegeId,
LockoutEnabled = true,
IsEnabled = true
};
var result = await userManager.CreateAsync(user, request.Password);
if (!result.Succeeded)
return IdentityValidationProblem(result);
result = await userManager.AddToRolesAsync(user, request.Roles);
if (!result.Succeeded)
return IdentityValidationProblem(result);
return CreatedAtAction(nameof(GetUsers), new { id = user.Id }, new { user.Id });
}
[HttpPut("{id:guid}/status")]
public async Task<IActionResult> SetStatus(Guid id, SetUserStatusRequest request)
{
var user = await userManager.FindByIdAsync(id.ToString());
if (user is null) return NotFound();
if (User.FindFirstValue(ClaimTypes.NameIdentifier) == id.ToString() &&
!request.IsEnabled)
return ValidationProblem("不能停用当前登录账号。");
user.IsEnabled = request.IsEnabled;
var result = await userManager.UpdateAsync(user);
return result.Succeeded ? NoContent() : IdentityValidationProblem(result);
}
[HttpPut("{id:guid}/roles")]
public async Task<IActionResult> SetRoles(Guid id, SetRolesRequest request)
{
var user = await userManager.FindByIdAsync(id.ToString());
if (user is null) return NotFound();
var invalidRoles = request.Roles
.Except(SystemRoles.All, StringComparer.OrdinalIgnoreCase)
.ToArray();
if (invalidRoles.Length > 0)
return ValidationProblem($"无效角色:{string.Join("", invalidRoles)}");
var existing = await userManager.GetRolesAsync(user);
if (User.FindFirstValue(ClaimTypes.NameIdentifier) == id.ToString() &&
existing.Contains(SystemRoles.SuperAdmin) &&
!request.Roles.Contains(SystemRoles.SuperAdmin))
{
return ValidationProblem("不能移除当前账号的超级管理员角色。");
}
var removeResult = await userManager.RemoveFromRolesAsync(
user,
existing.Except(request.Roles));
if (!removeResult.Succeeded) return IdentityValidationProblem(removeResult);
var addResult = await userManager.AddToRolesAsync(
user,
request.Roles.Except(existing));
return addResult.Succeeded ? NoContent() : IdentityValidationProblem(addResult);
}
private ActionResult IdentityValidationProblem(IdentityResult result)
{
foreach (var error in result.Errors)
ModelState.AddModelError(error.Code, error.Description);
return ValidationProblem(ModelState);
}
}
public sealed record CreateUserRequest(
[Required, MaxLength(50)] string UserName,
[Required, MaxLength(50)] string DisplayName,
[Required, MinLength(8), MaxLength(100)] string Password,
[MaxLength(30)] string? StaffNumber,
Guid? CollegeId,
[MinLength(1)] string[] Roles);
public sealed record SetUserStatusRequest(bool IsEnabled);
public sealed record SetRolesRequest([MinLength(1)] string[] Roles);
@@ -0,0 +1,62 @@
using Jiaowu.Api.Domain.Common;
namespace Jiaowu.Api.Domain.Academic;
public sealed class Campus : CatalogEntity
{
public string? Address { get; set; }
}
public sealed class College : CatalogEntity
{
public Guid? CampusId { get; set; }
public Campus? Campus { get; set; }
public string? ShortName { get; set; }
}
public sealed class Major : CatalogEntity
{
public Guid CollegeId { get; set; }
public College? College { get; set; }
public required string DegreeType { get; set; }
public int SchoolingYears { get; set; } = 4;
}
public sealed class AdministrativeClass : CatalogEntity
{
public Guid MajorId { get; set; }
public Major? Major { get; set; }
public int Grade { get; set; }
public string? CounselorName { get; set; }
}
public sealed class Building : CatalogEntity
{
public Guid CampusId { get; set; }
public Campus? Campus { get; set; }
}
public sealed class Classroom : CatalogEntity
{
public Guid BuildingId { get; set; }
public Building? Building { get; set; }
public int Capacity { get; set; }
public string RoomType { get; set; } = "普通教室";
public string? Equipment { get; set; }
}
public sealed class AcademicTerm : CatalogEntity
{
public required string AcademicYear { get; set; }
public TermSeason Season { get; set; }
public DateOnly StartDate { get; set; }
public DateOnly EndDate { get; set; }
public bool IsCurrent { get; set; }
}
public enum TermSeason
{
Autumn = 1,
Spring = 2,
Summer = 3
}
@@ -0,0 +1,16 @@
namespace Jiaowu.Api.Domain.Common;
public abstract class EntityBase
{
public Guid Id { get; set; } = Guid.NewGuid();
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
public abstract class CatalogEntity : EntityBase
{
public required string Code { get; set; }
public required string Name { get; set; }
public int SortOrder { get; set; }
public bool IsEnabled { get; set; } = true;
}
@@ -0,0 +1,49 @@
using Microsoft.AspNetCore.Identity;
namespace Jiaowu.Api.Domain.Identity;
public sealed class ApplicationUser : IdentityUser<Guid>
{
public required string DisplayName { get; set; }
public string? StaffNumber { get; set; }
public Guid? CollegeId { get; set; }
public bool IsEnabled { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? LastLoginAt { get; set; }
}
public sealed class ApplicationRole : IdentityRole<Guid>
{
public string? Description { get; set; }
public DataScope DataScope { get; set; } = DataScope.Self;
}
public enum DataScope
{
Self = 0,
Class = 1,
College = 2,
All = 3
}
public static class SystemRoles
{
public const string SuperAdmin = "SuperAdmin";
public const string AcademicAdmin = "AcademicAdmin";
public const string CollegeAdmin = "CollegeAdmin";
public const string Teacher = "Teacher";
public const string Counselor = "Counselor";
public const string Student = "Student";
public const string Leader = "Leader";
public static readonly string[] All =
[
SuperAdmin,
AcademicAdmin,
CollegeAdmin,
Teacher,
Counselor,
Student,
Leader
];
}
+13
View File
@@ -0,0 +1,13 @@
using Jiaowu.Api.Domain.Common;
namespace Jiaowu.Api.Domain.System;
public sealed class AuditLog : EntityBase
{
public Guid? UserId { get; set; }
public string? UserName { get; set; }
public required string Method { get; set; }
public required string Path { get; set; }
public int StatusCode { get; set; }
public string? IpAddress { get; set; }
}
@@ -0,0 +1,10 @@
namespace Jiaowu.Api.Infrastructure.Auth;
public sealed class JwtOptions
{
public const string SectionName = "Jwt";
public string Issuer { get; set; } = "Jiaowu.Api";
public string Audience { get; set; } = "Jiaowu.Web";
public string Key { get; set; } = string.Empty;
public int ExpireMinutes { get; set; } = 480;
}
@@ -0,0 +1,48 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Jiaowu.Api.Domain.Identity;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
namespace Jiaowu.Api.Infrastructure.Auth;
public interface ITokenService
{
string Create(ApplicationUser user, IEnumerable<string> roles);
}
public sealed class TokenService(IOptions<JwtOptions> options) : ITokenService
{
private readonly JwtOptions _options = options.Value;
public string Create(ApplicationUser user, IEnumerable<string> roles)
{
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
new(JwtRegisteredClaimNames.UniqueName, user.UserName ?? string.Empty),
new(ClaimTypes.NameIdentifier, user.Id.ToString()),
new(ClaimTypes.Name, user.DisplayName)
};
claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role)));
if (user.CollegeId is { } collegeId)
{
claims.Add(new Claim("college_id", collegeId.ToString()));
}
var credentials = new SigningCredentials(
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.Key)),
SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: _options.Issuer,
audience: _options.Audience,
claims: claims,
expires: DateTime.UtcNow.AddMinutes(_options.ExpireMinutes),
signingCredentials: credentials);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}
@@ -0,0 +1,38 @@
using System.Security.Claims;
using Jiaowu.Api.Domain.System;
using Jiaowu.Api.Infrastructure.Persistence;
namespace Jiaowu.Api.Infrastructure.Middleware;
public sealed class AuditMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(HttpContext context, AppDbContext db)
{
await next(context);
if (HttpMethods.IsGet(context.Request.Method) ||
context.Request.Path.StartsWithSegments("/swagger"))
{
return;
}
// A failed business write can leave invalid tracked entities in this request scope.
// Audit persistence must not retry those entities and replace the original response.
db.ChangeTracker.Clear();
db.AuditLogs.Add(new AuditLog
{
UserId = Guid.TryParse(
context.User.FindFirstValue(ClaimTypes.NameIdentifier),
out var userId)
? userId
: null,
UserName = context.User.Identity?.Name,
Method = context.Request.Method,
Path = context.Request.Path,
StatusCode = context.Response.StatusCode,
IpAddress = context.Connection.RemoteIpAddress?.ToString()
});
await db.SaveChangesAsync(context.RequestAborted);
}
}
@@ -0,0 +1,111 @@
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Common;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Domain.System;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.Persistence;
public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
: IdentityDbContext<ApplicationUser, ApplicationRole, Guid>(options)
{
public DbSet<Campus> Campuses => Set<Campus>();
public DbSet<College> Colleges => Set<College>();
public DbSet<Major> Majors => Set<Major>();
public DbSet<AdministrativeClass> AdministrativeClasses => Set<AdministrativeClass>();
public DbSet<Building> Buildings => Set<Building>();
public DbSet<Classroom> Classrooms => Set<Classroom>();
public DbSet<AcademicTerm> AcademicTerms => Set<AcademicTerm>();
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
builder.Entity<ApplicationUser>(entity =>
{
entity.Property(x => x.DisplayName).HasMaxLength(50);
entity.Property(x => x.StaffNumber).HasMaxLength(30);
entity.HasIndex(x => x.StaffNumber);
});
builder.Entity<ApplicationRole>(entity =>
{
entity.Property(x => x.Description).HasMaxLength(100);
});
ConfigureCatalog<Campus>(builder);
ConfigureCatalog<College>(builder);
ConfigureCatalog<Major>(builder);
ConfigureCatalog<AdministrativeClass>(builder);
ConfigureCatalog<Building>(builder);
ConfigureCatalog<Classroom>(builder);
ConfigureCatalog<AcademicTerm>(builder);
builder.Entity<College>()
.HasOne(x => x.Campus)
.WithMany()
.HasForeignKey(x => x.CampusId)
.OnDelete(DeleteBehavior.Restrict);
builder.Entity<Major>()
.HasOne(x => x.College)
.WithMany()
.HasForeignKey(x => x.CollegeId)
.OnDelete(DeleteBehavior.Restrict);
builder.Entity<AdministrativeClass>()
.HasOne(x => x.Major)
.WithMany()
.HasForeignKey(x => x.MajorId)
.OnDelete(DeleteBehavior.Restrict);
builder.Entity<Building>()
.HasOne(x => x.Campus)
.WithMany()
.HasForeignKey(x => x.CampusId)
.OnDelete(DeleteBehavior.Restrict);
builder.Entity<Classroom>()
.HasOne(x => x.Building)
.WithMany()
.HasForeignKey(x => x.BuildingId)
.OnDelete(DeleteBehavior.Restrict);
builder.Entity<AcademicTerm>()
.HasIndex(x => x.IsCurrent);
builder.Entity<AuditLog>(entity =>
{
entity.Property(x => x.Method).HasMaxLength(10);
entity.Property(x => x.Path).HasMaxLength(300);
entity.Property(x => x.UserName).HasMaxLength(100);
entity.Property(x => x.IpAddress).HasMaxLength(64);
entity.HasIndex(x => x.CreatedAt);
});
}
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
foreach (var entry in ChangeTracker.Entries<EntityBase>()
.Where(x => x.State == EntityState.Modified))
{
entry.Entity.UpdatedAt = DateTime.UtcNow;
}
return base.SaveChangesAsync(cancellationToken);
}
private static void ConfigureCatalog<TEntity>(ModelBuilder builder)
where TEntity : CatalogEntity
{
builder.Entity<TEntity>(entity =>
{
entity.Property(x => x.Code).HasMaxLength(40);
entity.Property(x => x.Name).HasMaxLength(100);
entity.HasIndex(x => x.Code).IsUnique();
entity.HasIndex(x => new { x.IsEnabled, x.SortOrder });
});
}
}
@@ -0,0 +1,190 @@
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.Persistence;
public sealed class DatabaseInitializer(
AppDbContext db,
RoleManager<ApplicationRole> roleManager,
UserManager<ApplicationUser> userManager,
IConfiguration configuration,
IHostEnvironment environment,
ILogger<DatabaseInitializer> logger)
{
public async Task InitializeAsync()
{
if (environment.IsDevelopment())
{
await db.Database.EnsureCreatedAsync();
}
else
{
await db.Database.MigrateAsync();
}
await SeedRolesAsync();
await SeedAdministratorAsync();
if (environment.IsDevelopment())
{
await SeedDevelopmentDataAsync();
}
}
private async Task SeedRolesAsync()
{
var roleDefinitions = new Dictionary<string, (string Description, DataScope Scope)>
{
[SystemRoles.SuperAdmin] = ("系统配置与全部数据管理", DataScope.All),
[SystemRoles.AcademicAdmin] = ("校级教务管理", DataScope.All),
[SystemRoles.CollegeAdmin] = ("院系教务管理", DataScope.College),
[SystemRoles.Teacher] = ("教师教学工作台", DataScope.Self),
[SystemRoles.Counselor] = ("辅导员与班级管理", DataScope.Class),
[SystemRoles.Student] = ("学生自助服务", DataScope.Self),
[SystemRoles.Leader] = ("校级统计查看", DataScope.All)
};
foreach (var (name, definition) in roleDefinitions)
{
if (await roleManager.RoleExistsAsync(name))
{
continue;
}
var result = await roleManager.CreateAsync(new ApplicationRole
{
Name = name,
Description = definition.Description,
DataScope = definition.Scope
});
EnsureSucceeded(result, $"创建角色 {name}");
}
}
private async Task SeedAdministratorAsync()
{
var userName = configuration["SeedAdmin:UserName"];
var password = configuration["SeedAdmin:Password"];
if (string.IsNullOrWhiteSpace(userName) || string.IsNullOrWhiteSpace(password))
{
if (!environment.IsDevelopment())
{
logger.LogWarning("未配置 SeedAdmin,生产环境不会创建默认管理员。");
}
return;
}
var user = await userManager.FindByNameAsync(userName);
if (user is null)
{
user = new ApplicationUser
{
UserName = userName,
DisplayName = configuration["SeedAdmin:DisplayName"] ?? "系统管理员",
LockoutEnabled = true,
IsEnabled = true
};
EnsureSucceeded(await userManager.CreateAsync(user, password), "创建初始管理员");
}
if (!user.LockoutEnabled)
{
user.LockoutEnabled = true;
EnsureSucceeded(await userManager.UpdateAsync(user), "启用管理员登录保护");
}
if (!await userManager.IsInRoleAsync(user, SystemRoles.SuperAdmin))
{
EnsureSucceeded(
await userManager.AddToRoleAsync(user, SystemRoles.SuperAdmin),
"授予超级管理员角色");
}
}
private async Task SeedDevelopmentDataAsync()
{
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();
}
private static void EnsureSucceeded(IdentityResult result, string action)
{
if (result.Succeeded)
{
return;
}
throw new InvalidOperationException(
$"{action}失败:{string.Join("", result.Errors.Select(x => x.Description))}");
}
}
@@ -0,0 +1,7 @@
namespace Jiaowu.Api.Infrastructure.Persistence;
public sealed class DatabaseOptions
{
public const string SectionName = "Database";
public string Provider { get; set; } = "MySql";
}
@@ -0,0 +1,734 @@
// <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("20260724042846_InitialMySql")]
partial class InitialMySql
{
/// <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.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.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.Major", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.College", "College")
.WithMany()
.HasForeignKey("CollegeId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
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,577 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using MySql.EntityFrameworkCore.Metadata;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class InitialMySql : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterDatabase()
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "AcademicTerms",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
AcademicYear = table.Column<string>(type: "longtext", nullable: false),
Season = table.Column<int>(type: "int", nullable: false),
StartDate = table.Column<DateOnly>(type: "date", nullable: false),
EndDate = table.Column<DateOnly>(type: "date", nullable: false),
IsCurrent = table.Column<bool>(type: "tinyint(1)", nullable: false),
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_AcademicTerms", x => x.Id);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "AspNetRoles",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
Description = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: true),
DataScope = table.Column<int>(type: "int", nullable: false),
Name = table.Column<string>(type: "varchar(256)", maxLength: 256, nullable: true),
NormalizedName = table.Column<string>(type: "varchar(256)", maxLength: 256, nullable: true),
ConcurrencyStamp = table.Column<string>(type: "longtext", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetRoles", x => x.Id);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "AspNetUsers",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
DisplayName = table.Column<string>(type: "varchar(50)", maxLength: 50, nullable: false),
StaffNumber = table.Column<string>(type: "varchar(30)", maxLength: 30, nullable: true),
CollegeId = table.Column<Guid>(type: "char(36)", nullable: true),
IsEnabled = table.Column<bool>(type: "tinyint(1)", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
LastLoginAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
UserName = table.Column<string>(type: "varchar(256)", maxLength: 256, nullable: true),
NormalizedUserName = table.Column<string>(type: "varchar(256)", maxLength: 256, nullable: true),
Email = table.Column<string>(type: "varchar(256)", maxLength: 256, nullable: true),
NormalizedEmail = table.Column<string>(type: "varchar(256)", maxLength: 256, nullable: true),
EmailConfirmed = table.Column<bool>(type: "tinyint(1)", nullable: false),
PasswordHash = table.Column<string>(type: "longtext", nullable: true),
SecurityStamp = table.Column<string>(type: "longtext", nullable: true),
ConcurrencyStamp = table.Column<string>(type: "longtext", nullable: true),
PhoneNumber = table.Column<string>(type: "longtext", nullable: true),
PhoneNumberConfirmed = table.Column<bool>(type: "tinyint(1)", nullable: false),
TwoFactorEnabled = table.Column<bool>(type: "tinyint(1)", nullable: false),
LockoutEnd = table.Column<DateTimeOffset>(type: "datetime", nullable: true),
LockoutEnabled = table.Column<bool>(type: "tinyint(1)", nullable: false),
AccessFailedCount = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUsers", x => x.Id);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "AuditLogs",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
UserId = table.Column<Guid>(type: "char(36)", nullable: true),
UserName = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: true),
Method = table.Column<string>(type: "varchar(10)", maxLength: 10, nullable: false),
Path = table.Column<string>(type: "varchar(300)", maxLength: 300, nullable: false),
StatusCode = table.Column<int>(type: "int", nullable: false),
IpAddress = table.Column<string>(type: "varchar(64)", maxLength: 64, 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_AuditLogs", x => x.Id);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "Campuses",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
Address = table.Column<string>(type: "longtext", 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_Campuses", x => x.Id);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "AspNetRoleClaims",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("MySQL:ValueGenerationStrategy", MySQLValueGenerationStrategy.IdentityColumn),
RoleId = table.Column<Guid>(type: "char(36)", nullable: false),
ClaimType = table.Column<string>(type: "longtext", nullable: true),
ClaimValue = table.Column<string>(type: "longtext", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id);
table.ForeignKey(
name: "FK_AspNetRoleClaims_AspNetRoles_RoleId",
column: x => x.RoleId,
principalTable: "AspNetRoles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "AspNetUserClaims",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("MySQL:ValueGenerationStrategy", MySQLValueGenerationStrategy.IdentityColumn),
UserId = table.Column<Guid>(type: "char(36)", nullable: false),
ClaimType = table.Column<string>(type: "longtext", nullable: true),
ClaimValue = table.Column<string>(type: "longtext", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserClaims", x => x.Id);
table.ForeignKey(
name: "FK_AspNetUserClaims_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "AspNetUserLogins",
columns: table => new
{
LoginProvider = table.Column<string>(type: "varchar(255)", nullable: false),
ProviderKey = table.Column<string>(type: "varchar(255)", nullable: false),
ProviderDisplayName = table.Column<string>(type: "longtext", nullable: true),
UserId = table.Column<Guid>(type: "char(36)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey });
table.ForeignKey(
name: "FK_AspNetUserLogins_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "AspNetUserRoles",
columns: table => new
{
UserId = table.Column<Guid>(type: "char(36)", nullable: false),
RoleId = table.Column<Guid>(type: "char(36)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId });
table.ForeignKey(
name: "FK_AspNetUserRoles_AspNetRoles_RoleId",
column: x => x.RoleId,
principalTable: "AspNetRoles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_AspNetUserRoles_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "AspNetUserTokens",
columns: table => new
{
UserId = table.Column<Guid>(type: "char(36)", nullable: false),
LoginProvider = table.Column<string>(type: "varchar(255)", nullable: false),
Name = table.Column<string>(type: "varchar(255)", nullable: false),
Value = table.Column<string>(type: "longtext", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name });
table.ForeignKey(
name: "FK_AspNetUserTokens_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "Buildings",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
CampusId = table.Column<Guid>(type: "char(36)", nullable: false),
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_Buildings", x => x.Id);
table.ForeignKey(
name: "FK_Buildings_Campuses_CampusId",
column: x => x.CampusId,
principalTable: "Campuses",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "Colleges",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
CampusId = table.Column<Guid>(type: "char(36)", nullable: true),
ShortName = table.Column<string>(type: "longtext", 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_Colleges", x => x.Id);
table.ForeignKey(
name: "FK_Colleges_Campuses_CampusId",
column: x => x.CampusId,
principalTable: "Campuses",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "Classrooms",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
BuildingId = table.Column<Guid>(type: "char(36)", nullable: false),
Capacity = table.Column<int>(type: "int", nullable: false),
RoomType = table.Column<string>(type: "longtext", nullable: false),
Equipment = table.Column<string>(type: "longtext", 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_Classrooms", x => x.Id);
table.ForeignKey(
name: "FK_Classrooms_Buildings_BuildingId",
column: x => x.BuildingId,
principalTable: "Buildings",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "Majors",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
CollegeId = table.Column<Guid>(type: "char(36)", nullable: false),
DegreeType = table.Column<string>(type: "longtext", nullable: false),
SchoolingYears = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
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_Majors", x => x.Id);
table.ForeignKey(
name: "FK_Majors_Colleges_CollegeId",
column: x => x.CollegeId,
principalTable: "Colleges",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "AdministrativeClasses",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
MajorId = table.Column<Guid>(type: "char(36)", nullable: false),
Grade = table.Column<int>(type: "int", nullable: false),
CounselorName = table.Column<string>(type: "longtext", 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_AdministrativeClasses", x => x.Id);
table.ForeignKey(
name: "FK_AdministrativeClasses_Majors_MajorId",
column: x => x.MajorId,
principalTable: "Majors",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_AcademicTerms_Code",
table: "AcademicTerms",
column: "Code",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_AcademicTerms_IsCurrent",
table: "AcademicTerms",
column: "IsCurrent");
migrationBuilder.CreateIndex(
name: "IX_AcademicTerms_IsEnabled_SortOrder",
table: "AcademicTerms",
columns: new[] { "IsEnabled", "SortOrder" });
migrationBuilder.CreateIndex(
name: "IX_AdministrativeClasses_Code",
table: "AdministrativeClasses",
column: "Code",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_AdministrativeClasses_IsEnabled_SortOrder",
table: "AdministrativeClasses",
columns: new[] { "IsEnabled", "SortOrder" });
migrationBuilder.CreateIndex(
name: "IX_AdministrativeClasses_MajorId",
table: "AdministrativeClasses",
column: "MajorId");
migrationBuilder.CreateIndex(
name: "IX_AspNetRoleClaims_RoleId",
table: "AspNetRoleClaims",
column: "RoleId");
migrationBuilder.CreateIndex(
name: "RoleNameIndex",
table: "AspNetRoles",
column: "NormalizedName",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_AspNetUserClaims_UserId",
table: "AspNetUserClaims",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_AspNetUserLogins_UserId",
table: "AspNetUserLogins",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_AspNetUserRoles_RoleId",
table: "AspNetUserRoles",
column: "RoleId");
migrationBuilder.CreateIndex(
name: "EmailIndex",
table: "AspNetUsers",
column: "NormalizedEmail");
migrationBuilder.CreateIndex(
name: "IX_AspNetUsers_StaffNumber",
table: "AspNetUsers",
column: "StaffNumber");
migrationBuilder.CreateIndex(
name: "UserNameIndex",
table: "AspNetUsers",
column: "NormalizedUserName",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_AuditLogs_CreatedAt",
table: "AuditLogs",
column: "CreatedAt");
migrationBuilder.CreateIndex(
name: "IX_Buildings_CampusId",
table: "Buildings",
column: "CampusId");
migrationBuilder.CreateIndex(
name: "IX_Buildings_Code",
table: "Buildings",
column: "Code",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Buildings_IsEnabled_SortOrder",
table: "Buildings",
columns: new[] { "IsEnabled", "SortOrder" });
migrationBuilder.CreateIndex(
name: "IX_Campuses_Code",
table: "Campuses",
column: "Code",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Campuses_IsEnabled_SortOrder",
table: "Campuses",
columns: new[] { "IsEnabled", "SortOrder" });
migrationBuilder.CreateIndex(
name: "IX_Classrooms_BuildingId",
table: "Classrooms",
column: "BuildingId");
migrationBuilder.CreateIndex(
name: "IX_Classrooms_Code",
table: "Classrooms",
column: "Code",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Classrooms_IsEnabled_SortOrder",
table: "Classrooms",
columns: new[] { "IsEnabled", "SortOrder" });
migrationBuilder.CreateIndex(
name: "IX_Colleges_CampusId",
table: "Colleges",
column: "CampusId");
migrationBuilder.CreateIndex(
name: "IX_Colleges_Code",
table: "Colleges",
column: "Code",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Colleges_IsEnabled_SortOrder",
table: "Colleges",
columns: new[] { "IsEnabled", "SortOrder" });
migrationBuilder.CreateIndex(
name: "IX_Majors_Code",
table: "Majors",
column: "Code",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Majors_CollegeId",
table: "Majors",
column: "CollegeId");
migrationBuilder.CreateIndex(
name: "IX_Majors_IsEnabled_SortOrder",
table: "Majors",
columns: new[] { "IsEnabled", "SortOrder" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AcademicTerms");
migrationBuilder.DropTable(
name: "AdministrativeClasses");
migrationBuilder.DropTable(
name: "AspNetRoleClaims");
migrationBuilder.DropTable(
name: "AspNetUserClaims");
migrationBuilder.DropTable(
name: "AspNetUserLogins");
migrationBuilder.DropTable(
name: "AspNetUserRoles");
migrationBuilder.DropTable(
name: "AspNetUserTokens");
migrationBuilder.DropTable(
name: "AuditLogs");
migrationBuilder.DropTable(
name: "Classrooms");
migrationBuilder.DropTable(
name: "Majors");
migrationBuilder.DropTable(
name: "AspNetRoles");
migrationBuilder.DropTable(
name: "AspNetUsers");
migrationBuilder.DropTable(
name: "Buildings");
migrationBuilder.DropTable(
name: "Colleges");
migrationBuilder.DropTable(
name: "Campuses");
}
}
}
@@ -0,0 +1,731 @@
// <auto-generated />
using System;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
[DbContext(typeof(AppDbContext))]
partial class AppDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(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.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.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.Major", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.College", "College")
.WithMany()
.HasForeignKey("CollegeId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
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
}
}
}
+22
View File
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.10" />
<PackageReference Include="MySql.EntityFrameworkCore" Version="10.0.7" />
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.4" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
</ItemGroup>
</Project>
+14
View File
@@ -0,0 +1,14 @@
@Host = http://localhost:5255
GET {{Host}}/health
Accept: application/json
###
POST {{Host}}/api/auth/login
Content-Type: application/json
{
"userName": "admin",
"password": "Admin@123456"
}
+206
View File
@@ -0,0 +1,206 @@
using System.Text;
using System.Text.Json.Serialization;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Middleware;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi.Models;
var builder = WebApplication.CreateBuilder(args);
var databaseOptions = builder.Configuration
.GetSection(DatabaseOptions.SectionName)
.Get<DatabaseOptions>() ?? new DatabaseOptions();
if (databaseOptions.Provider.Equals("SQLite", StringComparison.OrdinalIgnoreCase) &&
!builder.Environment.IsDevelopment())
{
throw new InvalidOperationException("SQLite 仅允许在 Development 环境使用。生产环境请配置 MySql。");
}
builder.Services.AddDbContext<AppDbContext>(options =>
{
if (databaseOptions.Provider.Equals("SQLite", StringComparison.OrdinalIgnoreCase))
{
var sqliteConnectionString = builder.Configuration.GetConnectionString("SQLite")
?? throw new InvalidOperationException("缺少 ConnectionStrings:SQLite。");
var sqliteBuilder = new SqliteConnectionStringBuilder(sqliteConnectionString);
if (!Path.IsPathRooted(sqliteBuilder.DataSource))
{
sqliteBuilder.DataSource = Path.GetFullPath(
sqliteBuilder.DataSource,
builder.Environment.ContentRootPath);
}
Directory.CreateDirectory(
Path.GetDirectoryName(sqliteBuilder.DataSource)
?? builder.Environment.ContentRootPath);
options.UseSqlite(sqliteBuilder.ConnectionString);
return;
}
if (!databaseOptions.Provider.Equals("MySql", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException(
$"不支持数据库 Provider '{databaseOptions.Provider}',可选值为 SQLite、MySql。");
}
var connectionString = builder.Configuration.GetConnectionString("MySql");
if (string.IsNullOrWhiteSpace(connectionString))
{
throw new InvalidOperationException(
"缺少 MySQL 连接串。请通过 ConnectionStrings__MySql 环境变量配置。");
}
options.UseMySQL(connectionString);
});
builder.Services
.AddIdentityCore<ApplicationUser>(options =>
{
options.Password.RequiredLength = 8;
options.Password.RequireDigit = true;
options.Password.RequireLowercase = true;
options.Password.RequireUppercase = true;
options.Password.RequireNonAlphanumeric = true;
options.User.RequireUniqueEmail = false;
options.Lockout.MaxFailedAccessAttempts = 5;
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15);
})
.AddRoles<ApplicationRole>()
.AddEntityFrameworkStores<AppDbContext>();
var jwtOptions = builder.Configuration.GetSection(JwtOptions.SectionName).Get<JwtOptions>()
?? throw new InvalidOperationException("缺少 Jwt 配置。");
if (Encoding.UTF8.GetByteCount(jwtOptions.Key) < 32)
{
throw new InvalidOperationException("Jwt:Key 至少需要 32 字节。");
}
builder.Services.Configure<JwtOptions>(
builder.Configuration.GetSection(JwtOptions.SectionName));
builder.Services.AddScoped<ITokenService, TokenService>();
builder.Services.AddScoped<DatabaseInitializer>();
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwtOptions.Issuer,
ValidAudience = jwtOptions.Audience,
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(jwtOptions.Key)),
ClockSkew = TimeSpan.FromMinutes(1)
};
});
builder.Services.AddAuthorization();
builder.Services.AddCors(options =>
{
options.AddPolicy("Web", policy =>
{
var origins = builder.Configuration.GetSection("Cors:Origins").Get<string[]>()
?? ["http://localhost:5173"];
policy.WithOrigins(origins)
.AllowAnyHeader()
.AllowAnyMethod();
});
});
builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler(options =>
{
options.ExceptionHandler = async context =>
{
var exception = context.Features
.Get<Microsoft.AspNetCore.Diagnostics.IExceptionHandlerFeature>()?.Error;
var isConstraintConflict = exception is DbUpdateException;
var statusCode = isConstraintConflict
? StatusCodes.Status409Conflict
: StatusCodes.Status500InternalServerError;
context.Response.StatusCode = statusCode;
await Results.Problem(
title: isConstraintConflict ? "数据约束冲突" : "服务器处理请求时发生错误",
detail: isConstraintConflict
? "编码可能已存在,或该数据正在被其他业务引用。"
: builder.Environment.IsDevelopment()
? exception?.Message
: "请稍后重试,并联系系统管理员查看日志。",
statusCode: statusCode)
.ExecuteAsync(context);
};
});
builder.Services.AddControllers()
.AddJsonOptions(options =>
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()));
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new OpenApiInfo
{
Title = "大学教务管理系统 API",
Version = "v1"
});
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
Name = "Authorization",
Type = SecuritySchemeType.Http,
Scheme = "bearer",
BearerFormat = "JWT",
In = ParameterLocation.Header
});
options.AddSecurityRequirement(new OpenApiSecurityRequirement
{
[
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = "Bearer"
}
}
] = []
});
});
var app = builder.Build();
app.UseExceptionHandler();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseCors("Web");
app.UseAuthentication();
app.UseAuthorization();
app.UseMiddleware<AuditMiddleware>();
app.MapControllers();
app.MapGet("/health", () => Results.Ok(new
{
Status = "healthy",
Database = databaseOptions.Provider,
Environment = app.Environment.EnvironmentName,
Time = DateTimeOffset.UtcNow
})).AllowAnonymous();
using (var scope = app.Services.CreateScope())
{
await scope.ServiceProvider.GetRequiredService<DatabaseInitializer>()
.InitializeAsync();
}
app.Run();
public partial class Program;
@@ -0,0 +1,30 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:55471",
"sslPort": 0
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5255",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"launchUrl": "swagger",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,22 @@
{
"Database": {
"Provider": "SQLite"
},
"ConnectionStrings": {
"SQLite": "Data Source=data/jiaowu-dev.sqlite"
},
"Jwt": {
"Key": "jiaowu-development-secret-key-change-before-production"
},
"SeedAdmin": {
"UserName": "admin",
"Password": "Admin@123456",
"DisplayName": "系统管理员"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
+27
View File
@@ -0,0 +1,27 @@
{
"Database": {
"Provider": "MySql"
},
"ConnectionStrings": {
"MySql": ""
},
"Jwt": {
"Issuer": "Jiaowu.Api",
"Audience": "Jiaowu.Web",
"Key": "REPLACE_IN_PRODUCTION_WITH_A_LONG_RANDOM_SECRET",
"ExpireMinutes": 480
},
"Cors": {
"Origins": [
"http://localhost:5173"
]
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore.Database.Command": "Warning"
}
},
"AllowedHosts": "*"
}
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Jiaowu.Api\Jiaowu.Api.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,67 @@
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Tests;
public sealed class PersistenceTests : IAsyncLifetime
{
private readonly SqliteConnection _connection = new("Data Source=:memory:");
private AppDbContext _db = null!;
public async Task InitializeAsync()
{
await _connection.OpenAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(_connection)
.Options;
_db = new AppDbContext(options);
await _db.Database.EnsureCreatedAsync();
}
public async Task DisposeAsync()
{
await _db.DisposeAsync();
await _connection.DisposeAsync();
}
[Fact]
public async Task Catalog_codes_are_unique()
{
_db.Campuses.Add(new Campus { Code = "MAIN", Name = "主校区" });
await _db.SaveChangesAsync();
_db.Campuses.Add(new Campus { Code = "MAIN", Name = "另一个校区" });
await Assert.ThrowsAsync<DbUpdateException>(() => _db.SaveChangesAsync());
}
[Fact]
public async Task Organization_relations_can_be_persisted()
{
var campus = new Campus { Code = "EAST", Name = "东校区" };
var college = new College
{
Code = "ART",
Name = "艺术学院",
CampusId = campus.Id
};
var major = new Major
{
Code = "1305",
Name = "设计学",
CollegeId = college.Id,
DegreeType = "艺术学学士"
};
_db.AddRange(campus, college, major);
await _db.SaveChangesAsync();
var saved = await _db.Majors
.Include(x => x.College)
.ThenInclude(x => x!.Campus)
.SingleAsync();
Assert.Equal("东校区", saved.College!.Campus!.Name);
}
}
@@ -0,0 +1,36 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Microsoft.Extensions.Options;
namespace Jiaowu.Api.Tests;
public sealed class TokenServiceTests
{
[Fact]
public void Token_contains_identity_and_roles()
{
var options = Options.Create(new JwtOptions
{
Issuer = "tests",
Audience = "tests-web",
Key = "a-test-signing-key-that-is-at-least-32-bytes-long"
});
var user = new ApplicationUser
{
Id = Guid.NewGuid(),
UserName = "teacher01",
DisplayName = "陈老师"
};
var service = new TokenService(options);
var token = new JwtSecurityTokenHandler().ReadJwtToken(
service.Create(user, [SystemRoles.Teacher]));
Assert.Contains(token.Claims, x =>
x.Type == ClaimTypes.Role && x.Value == SystemRoles.Teacher);
Assert.Contains(token.Claims, x =>
x.Type == ClaimTypes.Name && x.Value == "陈老师");
}
}
+1
View File
@@ -0,0 +1 @@
VITE_API_BASE_URL=/api
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#152550" />
<meta name="description" content="明序大学教务管理系统" />
<title>明序教务管理系统</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+2354
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.18.1",
"element-plus": "^2.14.3",
"pinia": "^4.0.2",
"vue": "^3.5.39",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@types/node": "^24.13.2",
"@vitejs/plugin-vue": "^6.0.7",
"@vue/tsconfig": "^0.9.1",
"typescript": "~6.0.2",
"unplugin-auto-import": "^21.0.0",
"unplugin-vue-components": "^32.1.0",
"vite": "^8.1.1",
"vue-tsc": "^3.3.5"
}
}
+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
<rect width="48" height="48" rx="8" fill="#152550"/>
<g fill="none" stroke="#a7b5e4" stroke-width="2">
<path d="M9 9h8v8H9zM20 9h8v8h-8zM31 9h8v8h-8z"/>
<path d="M9 20h8v8H9zM31 20h8v8h-8z"/>
<path d="M9 31h8v8H9zM20 31h8v8h-8zM31 31h8v8h-8z"/>
</g>
<path fill="#3ec1ae" d="M20 20h8v8h-8z"/>
</svg>

After

Width:  |  Height:  |  Size: 380 B

+3
View File
@@ -0,0 +1,3 @@
<template>
<RouterView />
</template>
+35
View File
@@ -0,0 +1,35 @@
import axios from 'axios'
const http = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL ?? '/api',
timeout: 15000,
})
http.interceptors.request.use((config) => {
const token = localStorage.getItem('jiaowu_token')
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
http.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401 && !error.config?.url?.endsWith('/auth/login')) {
localStorage.removeItem('jiaowu_token')
localStorage.removeItem('jiaowu_user')
window.location.assign(`/login?redirect=${encodeURIComponent(location.pathname)}`)
}
return Promise.reject(error)
},
)
export function apiErrorMessage(error: unknown) {
if (!axios.isAxiosError(error)) return '操作失败,请稍后重试。'
const data = error.response?.data
if (data?.errors) {
return Object.values(data.errors).flat().join('')
}
return data?.detail ?? data?.title ?? '操作失败,请检查网络连接。'
}
export default http
+11
View File
@@ -0,0 +1,11 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
const ElMessage: typeof import('element-plus/es').ElMessage
const ElMessageBox: typeof import('element-plus/es').ElMessageBox
}
+38
View File
@@ -0,0 +1,38 @@
/* eslint-disable */
// @ts-nocheck
// biome-ignore lint: disable
// oxlint-disable
// ------
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
ElButton: typeof import('element-plus/es')['ElButton']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElEmpty: typeof import('element-plus/es')['ElEmpty']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElIcon: typeof import('element-plus/es')['ElIcon']
ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElOption: typeof import('element-plus/es')['ElOption']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTag: typeof import('element-plus/es')['ElTag']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}
export interface GlobalDirectives {
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
}
}
+103
View File
@@ -0,0 +1,103 @@
<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 { useAuthStore } from '../stores/auth'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const collapsed = ref(false)
const mobileMenu = ref(false)
const pageTitle = computed(() => {
const titles: Record<string, string> = {
dashboard: '教务总览',
'base-data': '基础数据',
users: '用户与权限',
}
return titles[String(route.name)] ?? '教务管理'
})
function signOut() {
auth.logout()
router.push('/login')
}
onMounted(() => auth.refresh().catch(() => undefined))
</script>
<template>
<div class="shell" :class="{ 'is-collapsed': collapsed }">
<aside class="sidebar" :class="{ 'is-mobile-open': mobileMenu }">
<div class="brand">
<div class="brand-mark" aria-hidden="true">
<span v-for="index in 9" :key="index" />
</div>
<div v-if="!collapsed">
<strong>明序教务</strong>
<small>ACADEMIC OFFICE</small>
</div>
</div>
<div v-if="!collapsed" class="term-stamp">
<span>当前工作区</span>
<b>校级教务管理</b>
</div>
<el-menu
router
:default-active="route.path"
:collapse="collapsed"
class="nav-menu"
@select="mobileMenu = false"
>
<el-menu-item index="/dashboard">
<el-icon><DataAnalysis /></el-icon>
<template #title>教务总览</template>
</el-menu-item>
<el-menu-item index="/base-data">
<el-icon><OfficeBuilding /></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>
</el-menu-item>
</el-menu>
<div v-if="!collapsed" class="phase-note">
<span>第一阶段 · 基础底座</span>
<p>组织权限和教学资源正在运行</p>
</div>
</aside>
<div v-if="mobileMenu" class="mobile-mask" @click="mobileMenu = false" />
<main class="main-area">
<header class="topbar">
<button class="menu-toggle desktop-only" type="button" @click="collapsed = !collapsed">
<el-icon><Operation /></el-icon>
</button>
<button class="menu-toggle mobile-only" type="button" @click="mobileMenu = true">
<el-icon><Operation /></el-icon>
</button>
<div class="page-heading">
<span>教务工作台</span>
<h1>{{ pageTitle }}</h1>
</div>
<div class="user-block">
<div class="avatar">{{ auth.user?.displayName?.slice(0, 1) ?? '管' }}</div>
<div class="user-copy">
<b>{{ auth.user?.displayName ?? '系统管理员' }}</b>
<span>{{ auth.user?.roles?.[0] ?? '教务人员' }}</span>
</div>
<el-button text @click="signOut">退出</el-button>
</div>
</header>
<section class="content">
<RouterView />
</section>
</main>
</div>
</template>
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import './style.css'
import App from './App.vue'
import router from './router'
createApp(App)
.use(createPinia())
.use(router)
.mount('#app')
+53
View File
@@ -0,0 +1,53 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import AdminLayout from '../layouts/AdminLayout.vue'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/login',
name: 'login',
component: () => import('../views/LoginView.vue'),
meta: { public: true },
},
{
path: '/',
component: AdminLayout,
children: [
{ path: '', redirect: '/dashboard' },
{
path: 'dashboard',
name: 'dashboard',
component: () => import('../views/DashboardView.vue'),
},
{
path: 'base-data',
name: 'base-data',
component: () => import('../views/BaseDataView.vue'),
},
{
path: 'users',
name: 'users',
component: () => import('../views/UsersView.vue'),
meta: { roles: ['SuperAdmin'] },
},
],
},
{ path: '/:pathMatch(.*)*', redirect: '/dashboard' },
],
})
router.beforeEach((to) => {
const auth = useAuthStore()
if (!to.meta.public && !auth.isLoggedIn) {
return { name: 'login', query: { redirect: to.fullPath } }
}
if (to.name === 'login' && auth.isLoggedIn) return { name: 'dashboard' }
const roles = to.meta.roles as string[] | undefined
if (roles && !roles.some((role) => auth.user?.roles.includes(role))) {
return { name: 'dashboard' }
}
})
export default router
+43
View File
@@ -0,0 +1,43 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import http from '../api/http'
export interface CurrentUser {
id: string
userName: string
displayName: string
roles: string[]
collegeId?: string
}
export const useAuthStore = defineStore('auth', () => {
const token = ref(localStorage.getItem('jiaowu_token') ?? '')
const saved = localStorage.getItem('jiaowu_user')
const user = ref<CurrentUser | null>(saved ? JSON.parse(saved) : null)
const isLoggedIn = computed(() => Boolean(token.value))
const isSuperAdmin = computed(() => user.value?.roles.includes('SuperAdmin') ?? false)
async function login(userName: string, password: string) {
const { data } = await http.post('/auth/login', { userName, password })
token.value = data.token
user.value = data.user
localStorage.setItem('jiaowu_token', data.token)
localStorage.setItem('jiaowu_user', JSON.stringify(data.user))
}
async function refresh() {
if (!token.value) return
const { data } = await http.get('/auth/me')
user.value = data
localStorage.setItem('jiaowu_user', JSON.stringify(data))
}
function logout() {
token.value = ''
user.value = null
localStorage.removeItem('jiaowu_token')
localStorage.removeItem('jiaowu_user')
}
return { token, user, isLoggedIn, isSuperAdmin, login, refresh, logout }
})
+198
View File
@@ -0,0 +1,198 @@
:root {
font-family: "Microsoft YaHei", "PingFang SC", "Noto Sans CJK SC", sans-serif;
color: #182033;
background: #f3f5f8;
font-synthesis: none;
text-rendering: optimizeLegibility;
--ink: #182033;
--muted: #6d7689;
--indigo: #233876;
--indigo-deep: #152550;
--teal: #087f73;
--amber: #c78724;
--paper: #ffffff;
--line: #dfe4eb;
--soft: #f3f5f8;
--el-color-primary: #233876;
--el-color-success: #087f73;
--el-border-radius-base: 7px;
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; min-height: 100vh; }
button, input, textarea, select { font: inherit; }
button { cursor: pointer; }
#app { min-height: 100vh; }
.shell { min-height: 100vh; display: grid; grid-template-columns: 256px 1fr; transition: grid-template-columns .2s ease; }
.shell.is-collapsed { grid-template-columns: 72px 1fr; }
.sidebar {
position: fixed; inset: 0 auto 0 0; width: 256px; z-index: 20; overflow: hidden;
display: flex; flex-direction: column; color: #eef2ff;
background:
linear-gradient(rgba(255,255,255,.025) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,.025) 1px, transparent 1px),
var(--indigo-deep);
background-size: 26px 26px;
transition: width .2s ease, transform .25s ease;
}
.is-collapsed .sidebar { width: 72px; }
.brand { height: 86px; padding: 0 24px; display: flex; align-items: center; gap: 13px; border-bottom: 1px solid rgba(255,255,255,.1); white-space: nowrap; }
.is-collapsed .brand { padding: 0 17px; }
.brand strong { display: block; font-family: "STZhongsong", "Songti SC", serif; font-size: 21px; letter-spacing: .12em; color: white; }
.brand small { display: block; margin-top: 3px; font: 9px/1.2 Consolas, monospace; letter-spacing: .16em; color: #9faad1; }
.brand-mark { flex: 0 0 auto; width: 31px; height: 31px; display: grid; grid-template-columns: repeat(3,1fr); gap: 3px; }
.brand-mark span { border: 1px solid #9eabd8; }
.brand-mark span:nth-child(2), .brand-mark span:nth-child(5), .brand-mark span:nth-child(8) { background: #3ec1ae; border-color: #3ec1ae; }
.brand-mark.large { width: 42px; height: 42px; gap: 4px; }
.term-stamp { margin: 24px 20px 11px; padding: 13px 15px; border-left: 2px solid #d89b42; background: rgba(255,255,255,.06); }
.term-stamp span { display: block; font-size: 11px; color: #aab3d4; }
.term-stamp b { display: block; margin-top: 5px; font-size: 13px; font-weight: 500; color: white; }
.nav-menu.el-menu { border: none; background: transparent; padding: 8px 10px; }
.nav-menu .el-menu-item { height: 48px; margin: 4px 0; border-radius: 7px; color: #bbc4e2; }
.nav-menu .el-menu-item:hover { color: white; background: rgba(255,255,255,.07); }
.nav-menu .el-menu-item.is-active { color: white; background: #2d478d; box-shadow: inset 3px 0 #46c6b5; }
.nav-menu .el-icon { font-size: 18px; }
.phase-note { margin: auto 20px 24px; padding-top: 17px; border-top: 1px solid rgba(255,255,255,.12); }
.phase-note span { color: #45c6b5; font-size: 11px; font-weight: 700; letter-spacing: .06em; }
.phase-note p { margin: 7px 0 0; color: #9faad0; font-size: 12px; line-height: 1.6; }
.main-area { grid-column: 2; min-width: 0; }
.topbar { height: 86px; padding: 0 32px; display: flex; align-items: center; gap: 20px; background: rgba(255,255,255,.96); border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 10; backdrop-filter: blur(10px); }
.menu-toggle { width: 38px; height: 38px; border: 1px solid var(--line); border-radius: 7px; color: var(--indigo); background: white; }
.page-heading span { display: block; color: #9aa1af; font-size: 10px; letter-spacing: .14em; text-transform: uppercase; }
.page-heading h1 { margin: 3px 0 0; font-size: 20px; font-weight: 650; letter-spacing: .04em; }
.user-block { margin-left: auto; display: flex; align-items: center; gap: 11px; }
.avatar { width: 38px; height: 38px; display: grid; place-items: center; border-radius: 50%; color: white; background: var(--teal); font-weight: 700; }
.user-copy b, .user-copy span { display: block; }
.user-copy b { font-size: 13px; }
.user-copy span { margin-top: 3px; color: var(--muted); font-size: 11px; }
.content { padding: 28px 32px 48px; max-width: 1540px; margin: 0 auto; }
.mobile-only { display: none; }
.section-kicker { color: var(--teal); font: 700 10px/1.2 Consolas, monospace; letter-spacing: .16em; }
.term-hero { min-height: 178px; padding: 32px 36px; display: flex; align-items: end; color: white; background: linear-gradient(118deg, #233876, #192d67 65%, #116d70); position: relative; overflow: hidden; }
.term-hero::after { content: ""; position: absolute; right: -40px; top: -110px; width: 360px; height: 360px; border: 54px solid rgba(255,255,255,.055); border-radius: 50%; }
.term-hero h2 { margin: 12px 0 7px; font-family: "STZhongsong", "Songti SC", serif; font-size: clamp(25px, 3vw, 38px); letter-spacing: .05em; }
.term-hero p { margin: 0; color: #c8d1ed; font-size: 13px; }
.term-hero button { position: relative; z-index: 1; margin-left: auto; width: 226px; padding: 15px 17px; display: flex; justify-content: space-between; border: 1px solid rgba(255,255,255,.35); color: white; background: rgba(255,255,255,.08); }
.term-hero button:hover { background: rgba(255,255,255,.15); }
.metric-grid { margin-top: 18px; display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; }
.metric-card { min-height: 108px; padding: 22px; display: flex; align-items: center; gap: 17px; border: 1px solid var(--line); background: white; }
.metric-card .el-icon { width: 41px; height: 41px; border-radius: 50%; color: var(--indigo); background: #edf0f8; font-size: 19px; }
.metric-card span { display: block; color: var(--muted); font-size: 12px; }
.metric-card strong { display: block; margin-top: 5px; font: 700 27px/1 Consolas, monospace; color: var(--ink); }
.metric-card small { margin-left: 4px; color: var(--muted); font: 400 11px/1 sans-serif; }
.dashboard-grid { margin-top: 18px; display: grid; grid-template-columns: 1.35fr 1fr; gap: 18px; }
.work-card { min-height: 270px; padding: 28px; border: 1px solid var(--line); background: white; }
.card-heading { display: flex; justify-content: space-between; align-items: flex-start; }
.work-card h3 { margin: 8px 0 0; font-size: 18px; }
.status-chip { padding: 5px 9px; border-radius: 20px; color: var(--teal); background: #e8f5f2; font-size: 11px; }
.foundation-list { margin-top: 24px; }
.foundation-list > div { display: grid; grid-template-columns: 82px 1fr auto; gap: 14px; align-items: center; padding: 14px 0; border-top: 1px solid #eaedf1; }
.foundation-list span { color: var(--muted); font-size: 12px; }
.foundation-list b { font-size: 13px; font-weight: 550; }
.foundation-list i { font-size: 11px; font-style: normal; color: var(--teal); }
.phase-card { background: #fbfaf6; border-color: #e9e2d2; }
.phase-card p { max-width: 420px; margin: 16px 0 31px; color: var(--muted); font-size: 13px; line-height: 1.8; }
.phase-line { display: grid; grid-template-columns: repeat(4, 1fr); border-top: 2px solid #ded8c9; }
.phase-line span { position: relative; padding-top: 14px; color: #9b9588; font-size: 11px; }
.phase-line span::before { content: ""; position: absolute; top: -5px; left: 0; width: 8px; height: 8px; border-radius: 50%; background: #c7c0b0; }
.phase-line span.active { color: var(--amber); font-weight: 700; }
.phase-line span.active::before { background: var(--amber); box-shadow: 0 0 0 4px #f5ead6; }
.page-stack { display: grid; gap: 18px; }
.page-intro { min-height: 106px; padding: 8px 4px; display: flex; align-items: center; justify-content: space-between; gap: 20px; }
.page-intro h2 { margin: 7px 0 7px; font-family: "STZhongsong", "Songti SC", serif; font-size: 27px; }
.page-intro p { margin: 0; color: var(--muted); font-size: 13px; }
.data-card { border: 1px solid var(--line); background: white; overflow: hidden; }
.data-tabs { display: grid; grid-template-columns: repeat(7, minmax(105px, 1fr)); border-bottom: 1px solid var(--line); overflow-x: auto; }
.data-tabs button { min-width: 110px; padding: 16px 15px 14px; text-align: left; border: none; border-right: 1px solid #eaedf1; border-bottom: 3px solid transparent; color: var(--ink); background: #fafbfc; }
.data-tabs button:hover { background: white; }
.data-tabs button.active { border-bottom-color: var(--teal); background: white; }
.data-tabs b, .data-tabs span { display: block; }
.data-tabs b { font-size: 13px; }
.data-tabs span { margin-top: 4px; color: #9299a7; font-size: 10px; white-space: nowrap; }
.table-toolbar { min-height: 69px; padding: 14px 18px; display: flex; align-items: center; gap: 10px; border-bottom: 1px solid var(--line); }
.table-toolbar .el-input { width: min(340px, 60vw); }
.table-toolbar > span { margin-left: auto; color: var(--muted); font-size: 11px; }
.data-table { min-height: 360px; }
.el-table th.el-table__cell { color: #596274; background: #fafbfc; font-size: 12px; font-weight: 650; }
.el-table .cell { font-size: 12px; }
.table-status { display: inline-flex; align-items: center; gap: 6px; font-size: 11px; color: var(--teal); }
.table-status::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.table-status.off { color: #a0a6b1; }
.role-tag { margin: 2px 4px 2px 0; }
.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; }
.login-page { min-height: 100vh; display: grid; grid-template-columns: minmax(440px, 1.2fr) minmax(420px, .8fr); background: white; }
.login-story { min-height: 100vh; padding: 54px clamp(45px, 6vw, 90px); display: flex; flex-direction: column; color: white; background: linear-gradient(142deg, #13224d, #243a77 62%, #176b71); overflow: hidden; position: relative; }
.login-story::before { content: ""; position: absolute; inset: 0; opacity: .28; background-image: linear-gradient(rgba(255,255,255,.06) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.06) 1px, transparent 1px); background-size: 46px 46px; }
.story-brand, .story-copy, .schedule-signature, .story-foot { position: relative; z-index: 1; }
.story-brand { display: flex; align-items: center; gap: 16px; }
.story-brand strong { display: block; font-family: "STZhongsong", "Songti SC", serif; font-size: 25px; letter-spacing: .15em; }
.story-brand small { display: block; margin-top: 5px; color: #b7c1e2; font: 9px Consolas, monospace; letter-spacing: .17em; }
.story-copy { margin: auto 0 35px; }
.eyebrow { color: #5bd2c1; font-size: 11px; font-weight: 700; letter-spacing: .18em; }
.story-copy h1 { margin: 17px 0 20px; font-family: "STZhongsong", "Songti SC", serif; font-size: clamp(34px, 4vw, 55px); line-height: 1.34; font-weight: 500; letter-spacing: .03em; }
.story-copy p { max-width: 600px; margin: 0; color: #c4cce5; font-size: 14px; line-height: 1.8; }
.schedule-signature { width: min(600px, 90%); display: grid; grid-template-columns: repeat(5, 1fr); border-top: 1px solid rgba(255,255,255,.18); border-left: 1px solid rgba(255,255,255,.18); }
.schedule-signature div { aspect-ratio: 2.05; border-right: 1px solid rgba(255,255,255,.18); border-bottom: 1px solid rgba(255,255,255,.18); }
.schedule-signature div.active { display: grid; place-items: center; color: white; background: rgba(50,193,173,.52); font-size: 10px; }
.story-foot { margin: 20px 0 0; color: #9da9cc; font-size: 11px; letter-spacing: .08em; }
.login-panel { display: grid; place-items: center; padding: 45px; }
.login-form { width: min(390px, 100%); }
.form-intro { margin-bottom: 36px; }
.form-intro span { color: var(--teal); font-size: 12px; font-weight: 700; }
.form-intro h2 { margin: 8px 0 8px; font-family: "STZhongsong", "Songti SC", serif; font-size: 29px; }
.form-intro p { margin: 0; color: var(--muted); font-size: 13px; }
.login-form label { display: block; margin-bottom: 20px; }
.login-form label > span { display: block; margin-bottom: 8px; color: #525b6d; font-size: 12px; font-weight: 650; }
.login-submit { width: 100%; margin-top: 6px; height: 46px; }
.dev-hint { margin-top: 24px; padding: 13px 15px; display: flex; justify-content: space-between; color: #767e8d; background: #f5f7fa; font-size: 11px; }
@media (max-width: 1100px) {
.metric-grid { grid-template-columns: repeat(2, 1fr); }
.dashboard-grid { grid-template-columns: 1fr; }
.login-page { grid-template-columns: 1fr 430px; }
.login-story { padding-inline: 42px; }
}
@media (max-width: 980px) {
.shell, .shell.is-collapsed { display: block; }
.sidebar, .is-collapsed .sidebar { width: 256px; transform: translateX(-100%); }
.sidebar.is-mobile-open { transform: translateX(0); }
.main-area { width: 100%; }
.mobile-mask { position: fixed; inset: 0; z-index: 15; background: rgba(13,24,48,.45); }
.desktop-only { display: none; }
.mobile-only { display: inline-grid; place-items: center; }
.topbar { height: 72px; padding: 0 16px; }
.user-copy { display: none; }
.content { padding: 18px 14px 35px; }
.term-hero { min-height: 220px; padding: 25px; flex-direction: column; align-items: flex-start; justify-content: flex-end; }
.term-hero button { margin: 22px 0 0; width: 100%; }
.metric-grid { grid-template-columns: 1fr 1fr; gap: 10px; }
.metric-card { padding: 15px; min-height: 90px; }
.metric-card .el-icon { display: none; }
.dashboard-grid { gap: 10px; }
.work-card { padding: 21px; }
.foundation-list > div { grid-template-columns: 72px 1fr; }
.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; }
.el-dialog { width: calc(100vw - 24px) !important; }
.login-page { display: block; min-height: 100vh; background: #f4f6f9; }
.login-story { min-height: 310px; padding: 30px 25px; }
.story-copy { margin: auto 0 0; }
.story-copy h1 { font-size: 30px; margin-bottom: 0; }
.story-copy p, .schedule-signature, .story-foot { display: none; }
.login-panel { padding: 32px 22px; background: white; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; transition: none !important; }
}
+264
View File
@@ -0,0 +1,264 @@
<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 Kind = 'campuses' | 'colleges' | 'majors' | 'classes' | 'terms' | 'buildings' | 'classrooms'
interface Row { id: string; code: string; name: string; isEnabled: boolean; [key: string]: unknown }
const tabs: { key: Kind; label: string; hint: string }[] = [
{ key: 'campuses', label: '校区', hint: '学校的物理校区' },
{ key: 'colleges', label: '学院', hint: '教学组织单位' },
{ key: 'majors', label: '专业', hint: '专业与授予学位' },
{ key: 'classes', label: '行政班', hint: '按年级组织的班级' },
{ key: 'terms', label: '学期', hint: '教学运行时间轴' },
{ key: 'buildings', label: '教学楼', hint: '校区内教学建筑' },
{ key: 'classrooms', label: '教室', hint: '可排课教学空间' },
]
const auth = useAuthStore()
const active = ref<Kind>('campuses')
const rows = ref<Row[]>([])
const loading = ref(false)
const dialogVisible = ref(false)
const editingId = ref('')
const keyword = ref('')
const references = reactive<Record<string, Row[]>>({
campuses: [], colleges: [], majors: [], buildings: [],
})
const form = reactive<Record<string, any>>({})
const title = computed(() => tabs.find((x) => x.key === active.value)?.label ?? '')
const canManage = computed(() =>
auth.user?.roles.some((role) => ['SuperAdmin', 'AcademicAdmin'].includes(role)) ?? false,
)
const filteredRows = computed(() => {
const q = keyword.value.trim().toLowerCase()
if (!q) return rows.value
return rows.value.filter((row) =>
[row.code, row.name, row.collegeName, row.campusName, row.majorName, row.buildingName]
.filter(Boolean).some((value) => String(value).toLowerCase().includes(q)),
)
})
function resetForm(row?: Row) {
Object.keys(form).forEach((key) => delete form[key])
Object.assign(form, {
code: '', name: '', sortOrder: 0, isEnabled: true,
campusId: undefined, collegeId: undefined, majorId: undefined, buildingId: undefined,
shortName: '', degreeType: '学士', schoolingYears: 4,
grade: new Date().getFullYear(), counselorName: '',
academicYear: `${new Date().getFullYear()}-${new Date().getFullYear() + 1}`,
season: 'Autumn', startDate: '', endDate: '', isCurrent: false,
capacity: 60, roomType: '普通教室', equipment: '',
}, row ?? {})
}
async function load() {
loading.value = true
try {
rows.value = (await http.get(`/base-data/${active.value}`)).data
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
loading.value = false
}
}
async function loadReferences() {
const kinds = ['campuses', 'colleges', 'majors', 'buildings'] as const
await Promise.all(kinds.map(async (kind) => {
references[kind] = (await http.get(`/base-data/${kind}`)).data
}))
}
async function changeTab() {
keyword.value = ''
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()) {
ElMessage.warning('请填写编码和名称。')
return
}
try {
if (editingId.value) {
await http.put(`/base-data/${active.value}/${editingId.value}`, form)
} else {
await http.post(`/base-data/${active.value}`, form)
}
ElMessage.success(editingId.value ? '已保存修改' : `已新增${title.value}`)
dialogVisible.value = false
await Promise.all([load(), loadReferences()])
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function remove(row: any) {
try {
await ElMessageBox.confirm(
`确定删除“${row.name}”吗?已被业务引用的数据将无法删除。`,
'删除确认',
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' },
)
await http.delete(`/base-data/${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([load(), loadReferences()])
})
</script>
<template>
<div class="page-stack">
<section class="page-intro">
<div>
<span class="section-kicker">MASTER DATA</span>
<h2>基础数据</h2>
<p>先建立稳定的组织与教学资源编码后续业务均从这里引用</p>
</div>
<el-button v-if="canManage" type="primary" :icon="Plus" @click="openCreate">新增{{ title }}</el-button>
</section>
<section class="data-card">
<nav class="data-tabs" aria-label="基础数据分类">
<button
v-for="tab in tabs"
:key="tab.key"
type="button"
:class="{ active: active === tab.key }"
@click="active = tab.key; changeTab()"
>
<b>{{ tab.label }}</b>
<span>{{ tab.hint }}</span>
</button>
</nav>
<div class="table-toolbar">
<el-input v-model="keyword" :prefix-icon="Search" clearable placeholder="搜索编码、名称或所属单位" />
<el-button :icon="Refresh" @click="load">刷新</el-button>
<span> {{ filteredRows.length }} </span>
</div>
<el-table v-loading="loading" :data="filteredRows" class="data-table">
<el-table-column prop="code" label="编码" min-width="130" />
<el-table-column prop="name" label="名称" min-width="180" />
<el-table-column v-if="active === 'colleges'" prop="campusName" label="所属校区" min-width="140" />
<el-table-column v-if="active === 'majors'" prop="collegeName" label="所属学院" min-width="150" />
<el-table-column v-if="active === 'majors'" prop="degreeType" label="学位类型" min-width="120" />
<el-table-column v-if="active === 'classes'" prop="majorName" label="所属专业" min-width="170" />
<el-table-column v-if="active === 'classes'" prop="grade" label="年级" width="90" />
<el-table-column v-if="active === 'terms'" prop="academicYear" label="学年" width="120" />
<el-table-column v-if="active === 'terms'" label="当前学期" width="100">
<template #default="{ row }"><el-tag v-if="row.isCurrent" type="success">当前</el-tag><span v-else></span></template>
</el-table-column>
<el-table-column v-if="active === 'buildings'" prop="campusName" label="所属校区" min-width="140" />
<el-table-column v-if="active === 'classrooms'" prop="buildingName" label="教学楼" min-width="130" />
<el-table-column v-if="active === 'classrooms'" prop="capacity" label="容量" width="90" />
<el-table-column label="状态" width="90">
<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="150" 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>
</section>
<el-dialog v-model="dialogVisible" :title="`${editingId ? '编辑' : '新增'}${title}`" width="560px">
<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 v-if="active === 'campuses'" label="地址">
<el-input v-model="form.description" />
</el-form-item>
<el-form-item v-if="active === 'colleges' || active === 'buildings'" label="所属校区">
<el-select v-model="form.campusId" clearable>
<el-option v-for="item in references.campuses" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item v-if="active === 'colleges'" label="学院简称"><el-input v-model="form.shortName" /></el-form-item>
<el-form-item v-if="active === 'majors'" label="所属学院" required>
<el-select v-model="form.collegeId">
<el-option v-for="item in references.colleges" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
<div v-if="active === 'majors'" class="form-grid">
<el-form-item label="学位类型"><el-input v-model="form.degreeType" /></el-form-item>
<el-form-item label="学制"><el-input-number v-model="form.schoolingYears" :min="1" :max="8" /></el-form-item>
</div>
<el-form-item v-if="active === 'classes'" label="所属专业" required>
<el-select v-model="form.majorId" filterable>
<el-option v-for="item in references.majors" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
<div v-if="active === 'classes'" class="form-grid">
<el-form-item label="年级"><el-input-number v-model="form.grade" :min="2000" :max="2200" /></el-form-item>
<el-form-item label="辅导员"><el-input v-model="form.counselorName" /></el-form-item>
</div>
<template v-if="active === 'terms'">
<div class="form-grid">
<el-form-item label="学年"><el-input v-model="form.academicYear" /></el-form-item>
<el-form-item label="学期季">
<el-select v-model="form.season">
<el-option label="秋季学期" value="Autumn" />
<el-option label="春季学期" value="Spring" />
<el-option label="夏季学期" value="Summer" />
</el-select>
</el-form-item>
</div>
<div class="form-grid">
<el-form-item label="开始日期"><el-date-picker v-model="form.startDate" value-format="YYYY-MM-DD" /></el-form-item>
<el-form-item label="结束日期"><el-date-picker v-model="form.endDate" value-format="YYYY-MM-DD" /></el-form-item>
</div>
<el-form-item><el-checkbox v-model="form.isCurrent">设为当前学期</el-checkbox></el-form-item>
</template>
<el-form-item v-if="active === 'classrooms'" label="所属教学楼" required>
<el-select v-model="form.buildingId">
<el-option v-for="item in references.buildings" :key="item.id" :label="`${item.campusName} · ${item.name}`" :value="item.id" />
</el-select>
</el-form-item>
<div v-if="active === 'classrooms'" class="form-grid">
<el-form-item label="容量"><el-input-number v-model="form.capacity" :min="1" :max="1000" /></el-form-item>
<el-form-item label="教室类型"><el-input v-model="form.roomType" /></el-form-item>
</div>
<el-form-item v-if="active === 'classrooms'" label="设备"><el-input v-model="form.equipment" /></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>
+100
View File
@@ -0,0 +1,100 @@
<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 http from '../api/http'
interface DashboardData {
currentTerm?: { name: string; startDate: string; endDate: string }
counts: Record<string, number>
}
const router = useRouter()
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 },
])
onMounted(async () => {
try {
data.value = (await http.get('/dashboard')).data
} finally {
loading.value = false
}
})
</script>
<template>
<div v-loading="loading" class="dashboard">
<section class="term-hero">
<div>
<span class="section-kicker">CURRENT TERM</span>
<h2>{{ data.currentTerm?.name ?? '尚未设置当前学期' }}</h2>
<p v-if="data.currentTerm">
{{ data.currentTerm.startDate }} {{ data.currentTerm.endDate }}
</p>
<p v-else>请先在基础数据中建立学期档案</p>
</div>
<button type="button" @click="router.push('/base-data')">
<span>维护学期与组织数据</span>
<b></b>
</button>
</section>
<section class="metric-grid">
<article v-for="stat in stats" :key="stat.label" class="metric-card">
<el-icon><component :is="stat.icon" /></el-icon>
<div>
<span>{{ stat.label }}</span>
<strong>{{ stat.value }}<small>{{ stat.unit }}</small></strong>
</div>
</article>
</section>
<section class="dashboard-grid">
<article class="work-card foundation-card">
<div class="card-heading">
<div>
<span class="section-kicker">FOUNDATION</span>
<h3>基础数据完整度</h3>
</div>
<span class="status-chip">运行中</span>
</div>
<div class="foundation-list">
<div>
<span>组织体系</span>
<b>校区 学院 专业 行政班</b>
<i class="done">已建立</i>
</div>
<div>
<span>教学空间</span>
<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>
<div class="phase-line">
<span class="active">基础底座</span>
<span>人员档案</span>
<span>培养方案</span>
<span>教学运行</span>
</div>
</article>
</section>
</div>
</template>
+89
View File
@@ -0,0 +1,89 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const loading = ref(false)
const form = reactive({ userName: 'admin', password: 'Admin@123456' })
async function submit() {
loading.value = true
try {
await auth.login(form.userName, form.password)
await router.replace(String(route.query.redirect ?? '/dashboard'))
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
loading.value = false
}
}
</script>
<template>
<main class="login-page">
<section class="login-story">
<div class="story-brand">
<div class="brand-mark large" aria-hidden="true">
<span v-for="index in 9" :key="index" />
</div>
<div>
<strong>明序教务</strong>
<small>MINGXU ACADEMIC SYSTEM</small>
</div>
</div>
<div class="story-copy">
<span class="eyebrow">大学教务管理平台</span>
<h1>让每一项教学安排<br />都有清晰的来处与去向</h1>
<p>从组织档案开始逐步连接培养方案教学任务排课选课与成绩</p>
</div>
<div class="schedule-signature" aria-hidden="true">
<div v-for="item in 20" :key="item" :class="{ active: [3, 8, 9, 14, 18].includes(item) }">
<span v-if="[3, 8, 9, 14, 18].includes(item)">教学</span>
</div>
</div>
<p class="story-foot">第一阶段 · 基础管理工作台</p>
</section>
<section class="login-panel">
<form class="login-form" @submit.prevent="submit">
<div class="form-intro">
<span>欢迎回来</span>
<h2>登录教务工作台</h2>
<p>使用学校分配的管理账号继续</p>
</div>
<label>
<span>账号</span>
<el-input v-model="form.userName" size="large" autocomplete="username" />
</label>
<label>
<span>密码</span>
<el-input
v-model="form.password"
size="large"
type="password"
show-password
autocomplete="current-password"
@keyup.enter="submit"
/>
</label>
<el-button
class="login-submit"
type="primary"
size="large"
native-type="submit"
:loading="loading"
>
进入工作台
</el-button>
<div class="dev-hint">
<b>本地开发账号</b>
<span>admin / Admin@123456</span>
</div>
</form>
</section>
</main>
</template>
+142
View File
@@ -0,0 +1,142 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { Plus, Search } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http'
interface UserRow {
id: string; userName: string; displayName: string; staffNumber?: string
roles: string[]; isEnabled: boolean; lastLoginAt?: string
}
interface Role { name: string; description: string }
const users = ref<UserRow[]>([])
const roles = ref<Role[]>([])
const colleges = ref<any[]>([])
const loading = ref(false)
const dialogVisible = ref(false)
const keyword = ref('')
const form = reactive({
userName: '', displayName: '', password: '', staffNumber: '',
collegeId: undefined as string | undefined, roles: [] as string[],
})
async function load() {
loading.value = true
try {
const [userRes, roleRes, collegeRes] = await Promise.all([
http.get('/users'), http.get('/users/roles'), http.get('/base-data/colleges'),
])
users.value = userRes.data
roles.value = roleRes.data
colleges.value = collegeRes.data
} finally {
loading.value = false
}
}
function openCreate() {
Object.assign(form, {
userName: '', displayName: '', password: '', staffNumber: '',
collegeId: undefined, roles: [],
})
dialogVisible.value = true
}
async function create() {
try {
await http.post('/users', form)
ElMessage.success('账号已创建')
dialogVisible.value = false
await load()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function setStatus(row: any) {
try {
await http.put(`/users/${row.id}/status`, { isEnabled: !row.isEnabled })
ElMessage.success(row.isEnabled ? '账号已停用' : '账号已启用')
await load()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
onMounted(load)
</script>
<template>
<div class="page-stack">
<section class="page-intro">
<div>
<span class="section-kicker">IDENTITY & ACCESS</span>
<h2>用户与权限</h2>
<p>账号决定谁能进入系统角色与学院范围决定能看到哪些数据</p>
</div>
<el-button type="primary" :icon="Plus" @click="openCreate">创建账号</el-button>
</section>
<section class="data-card">
<div class="table-toolbar">
<el-input v-model="keyword" :prefix-icon="Search" clearable placeholder="搜索账号、姓名或工号" />
<span> {{ users.length }} 个账号</span>
</div>
<el-table
v-loading="loading"
:data="users.filter((x) => !keyword || `${x.userName}${x.displayName}${x.staffNumber}`.includes(keyword))"
>
<el-table-column prop="userName" label="账号" min-width="130" />
<el-table-column prop="displayName" label="姓名" min-width="120" />
<el-table-column prop="staffNumber" label="工号/学号" min-width="130">
<template #default="{ row }">{{ row.staffNumber || '—' }}</template>
</el-table-column>
<el-table-column label="角色" min-width="220">
<template #default="{ row }">
<el-tag v-for="role in row.roles" :key="role" class="role-tag" effect="plain">{{ role }}</el-tag>
</template>
</el-table-column>
<el-table-column label="最后登录" min-width="170">
<template #default="{ row }">{{ row.lastLoginAt ? new Date(row.lastLoginAt).toLocaleString() : '尚未登录' }}</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }"><span class="table-status" :class="{ off: !row.isEnabled }">{{ row.isEnabled ? '启用' : '停用' }}</span></template>
</el-table-column>
<el-table-column label="操作" width="100">
<template #default="{ row }">
<el-button link :type="row.isEnabled ? 'danger' : 'primary'" @click="setStatus(row)">
{{ row.isEnabled ? '停用' : '启用' }}
</el-button>
</template>
</el-table-column>
</el-table>
</section>
<el-dialog v-model="dialogVisible" title="创建账号" width="540px">
<el-form label-position="top" class="entity-form">
<div class="form-grid">
<el-form-item label="登录账号" required><el-input v-model="form.userName" /></el-form-item>
<el-form-item label="姓名" required><el-input v-model="form.displayName" /></el-form-item>
</div>
<div class="form-grid">
<el-form-item label="初始密码" required><el-input v-model="form.password" type="password" show-password /></el-form-item>
<el-form-item label="工号/学号"><el-input v-model="form.staffNumber" /></el-form-item>
</div>
<el-form-item label="所属学院">
<el-select v-model="form.collegeId" clearable>
<el-option v-for="item in colleges" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="角色" required>
<el-select v-model="form.roles" multiple>
<el-option v-for="role in roles" :key="role.name" :label="`${role.name} · ${role.description}`" :value="role.name" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="create">创建账号</el-button>
</template>
</el-dialog>
</div>
</template>
+15
View File
@@ -0,0 +1,15 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+33
View File
@@ -0,0 +1,33 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
AutoImport({
resolvers: [ElementPlusResolver()],
dts: 'src/auto-imports.d.ts',
}),
Components({
resolvers: [ElementPlusResolver({ importStyle: 'css', directives: true })],
dts: 'src/components.d.ts',
}),
],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:5255',
changeOrigin: true,
},
'/health': {
target: 'http://localhost:5255',
changeOrigin: true,
},
},
},
})