1
This commit is contained in:
+15
@@ -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
|
||||
@@ -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>
|
||||
@@ -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
|
||||
```
|
||||
@@ -0,0 +1,96 @@
|
||||
一、系统角色
|
||||
超级管理员:系统配置、权限、数据字典、日志
|
||||
教务处:校级教学计划、排课、选课、考试、成绩、学籍管理
|
||||
院系教务员:本院专业、课程、教学任务、成绩审核
|
||||
教师:课表、学生名单、考勤、成绩录入、调停课申请
|
||||
辅导员:学生信息、考勤、学业预警
|
||||
学生:课表、选课、成绩、考试、学籍申请
|
||||
校领导:统计分析和决策看板
|
||||
可选角色:财务人员、宿管人员、用人单位或家长
|
||||
权限建议采用“角色 + 数据范围”模式,例如院系教务员只能管理本学院数据。
|
||||
二、核心功能模块
|
||||
模块 主要功能
|
||||
基础数据 校区、学院、系部、专业、班级、年级、教室、学期、课程性质、成绩等级等
|
||||
用户与权限 用户、角色、菜单权限、操作权限、数据权限、登录日志、操作审计
|
||||
学籍管理 学生档案、照片、班级、学籍状态、转专业、休学、复学、退学、留级、异动审批
|
||||
课程库 课程编号、学分、学时、课程性质、先修课程、课程简介、开课学院
|
||||
培养方案 专业培养方案、课程模块、必修/选修要求、学分要求、版本管理
|
||||
教学任务 学期课程开设、授课教师、教学班、容量、授课周次、合班拆班
|
||||
智能排课 教师、班级、教室、时间冲突检测,手工调整,课表发布
|
||||
选课管理 选课轮次、选课限制、容量、退补选、冲突校验、先修条件、候补名单
|
||||
课表管理 学生课表、教师课表、班级课表、教室课表、周次课表
|
||||
调停课管理 调课、停课、补课、代课申请,审批及消息通知
|
||||
考勤管理 点名、迟到、早退、请假、缺勤,批量导入和统计
|
||||
考试管理 考试安排、考场、监考教师、座位表、缓考、补考、重修考试
|
||||
成绩管理 成绩项目、权重、录入、导入、提交、审核、发布、修改留痕
|
||||
补考与重修 补考资格、报名、重修选课、成绩认定、历史记录
|
||||
教学评价 学生评教、同行评价、督导评价、问卷配置、匿名统计
|
||||
教师管理 教师档案、职称、所属院系、授课资格、教学工作量
|
||||
教室资源 教室容量、类型、设备、占用情况、借用申请
|
||||
毕业审核 学分完成度、必修课程、实践环节、毕业资格、学位资格
|
||||
学业预警 不及格学分、低绩点、缺勤、延毕风险,预警规则与处理记录
|
||||
审批中心 学籍异动、调课、缓考、成绩修改、免修、课程替代等统一审批
|
||||
消息通知 站内消息、公告、待办提醒,可扩展短信、邮件、企业微信
|
||||
统计报表 学生、课程、成绩、通过率、教师工作量、教室利用率等
|
||||
数据导入导出 Excel模板、批量校验、错误报告、导入记录、报表导出
|
||||
系统运维 参数配置、字典管理、任务调度、日志、备份、异常监控
|
||||
|
||||
三、学生端主要功能
|
||||
查看个人信息和学籍状态
|
||||
查看培养方案及学分完成情况
|
||||
在线选课、退课和候补
|
||||
查看课表、考试安排和教室
|
||||
查看成绩、绩点及成绩单
|
||||
发起缓考、免修、重修、学籍异动等申请
|
||||
查看审批进度和消息通知
|
||||
教学评价
|
||||
查看学业预警和毕业审核进度
|
||||
四、教师端主要功能
|
||||
查看个人课表和教学任务
|
||||
查看教学班学生名单
|
||||
考勤和请假确认
|
||||
平时成绩、考试成绩录入及Excel导入
|
||||
设置成绩构成与权重
|
||||
提交成绩并查看审核状态
|
||||
调课、停课、补课、代课申请
|
||||
查看评教结果和教学工作量
|
||||
导出点名册、成绩单等材料
|
||||
五、关键业务规则
|
||||
系统设计时应重点保证:
|
||||
排课不能造成教师、班级或教室冲突
|
||||
选课需要校验时间冲突、容量、先修课程和选课资格
|
||||
已发布成绩不能直接修改,必须申请、审批并保留修改记录
|
||||
培养方案需要版本化,不能因新方案影响旧年级学生
|
||||
学籍异动必须保留完整历史
|
||||
重要发布操作应支持撤回限制、二次确认和审计
|
||||
Excel导入应先预检,确认无误后再正式写入
|
||||
权限不仅控制菜单,还要控制学院、专业、班级等数据范围
|
||||
六、推荐建设阶段
|
||||
第一阶段:核心可用版
|
||||
组织、用户、权限和基础数据
|
||||
学生、教师、课程、班级、教室管理
|
||||
培养方案
|
||||
教学任务
|
||||
排课与课表
|
||||
学生选课
|
||||
成绩录入、审核和查询
|
||||
基础学籍管理
|
||||
通知、审批、Excel导入导出
|
||||
操作日志
|
||||
第二阶段:完整教务版
|
||||
考试与考场安排
|
||||
补考、缓考、重修
|
||||
考勤与请假
|
||||
调停课
|
||||
教学评价
|
||||
学业预警
|
||||
教师工作量
|
||||
毕业与学位审核
|
||||
综合统计报表
|
||||
第三阶段:智慧教务版
|
||||
智能排课优化
|
||||
移动端或微信端
|
||||
电子成绩单与电子证明
|
||||
数据驾驶舱
|
||||
校园统一身份认证
|
||||
对接财务、一卡通、宿舍、图书馆和教育厅平台
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": 1,
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"dotnet-ef": {
|
||||
"version": "10.0.10",
|
||||
"commands": [
|
||||
"dotnet-ef"
|
||||
],
|
||||
"rollForward": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "10.0.302",
|
||||
"rollForward": "latestPatch"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
];
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
Generated
+734
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+577
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+731
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 == "陈老师");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
VITE_API_BASE_URL=/api
|
||||
@@ -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?
|
||||
@@ -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>
|
||||
Generated
+2354
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<RouterView />
|
||||
</template>
|
||||
@@ -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
|
||||
Vendored
+11
@@ -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
|
||||
}
|
||||
Vendored
+38
@@ -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']
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user