From 67905dfa16e43d0e834a062399ad8fdefe5014ae Mon Sep 17 00:00:00 2001 From: biss Date: Fri, 24 Jul 2026 12:42:51 +0800 Subject: [PATCH] 1 --- .gitignore | 15 + Jiaowu.slnx | 8 + README.md | 52 + TODO.md | 96 + dotnet-tools.json | 13 + global.json | 6 + scripts/smoke-test.ps1 | 67 + src/Jiaowu.Api/Controllers/AuthController.cs | 90 + .../Controllers/BaseDataController.cs | 468 ++++ .../Controllers/DashboardController.cs | 36 + src/Jiaowu.Api/Controllers/UsersController.cs | 143 + .../Domain/Academic/OrganizationEntities.cs | 62 + src/Jiaowu.Api/Domain/Common/EntityBase.cs | 16 + .../Domain/Identity/ApplicationUser.cs | 49 + src/Jiaowu.Api/Domain/System/AuditLog.cs | 13 + .../Infrastructure/Auth/JwtOptions.cs | 10 + .../Infrastructure/Auth/TokenService.cs | 48 + .../Middleware/AuditMiddleware.cs | 38 + .../Persistence/AppDbContext.cs | 111 + .../Persistence/DatabaseInitializer.cs | 190 ++ .../Persistence/DatabaseOptions.cs | 7 + .../20260724042846_InitialMySql.Designer.cs | 734 +++++ .../MySql/20260724042846_InitialMySql.cs | 577 ++++ .../MySql/AppDbContextModelSnapshot.cs | 731 +++++ src/Jiaowu.Api/Jiaowu.Api.csproj | 22 + src/Jiaowu.Api/Jiaowu.Api.http | 14 + src/Jiaowu.Api/Program.cs | 206 ++ src/Jiaowu.Api/Properties/launchSettings.json | 30 + src/Jiaowu.Api/appsettings.Development.json | 22 + src/Jiaowu.Api/appsettings.json | 27 + .../Jiaowu.Api.Tests/Jiaowu.Api.Tests.csproj | 27 + tests/Jiaowu.Api.Tests/PersistenceTests.cs | 67 + tests/Jiaowu.Api.Tests/TokenServiceTests.cs | 36 + web/.env.example | 1 + web/.gitignore | 24 + web/index.html | 15 + web/package-lock.json | 2354 +++++++++++++++++ web/package.json | 29 + web/public/favicon.svg | 9 + web/src/App.vue | 3 + web/src/api/http.ts | 35 + web/src/auto-imports.d.ts | 11 + web/src/components.d.ts | 38 + web/src/layouts/AdminLayout.vue | 103 + web/src/main.ts | 10 + web/src/router/index.ts | 53 + web/src/stores/auth.ts | 43 + web/src/style.css | 198 ++ web/src/views/BaseDataView.vue | 264 ++ web/src/views/DashboardView.vue | 100 + web/src/views/LoginView.vue | 89 + web/src/views/UsersView.vue | 142 + web/tsconfig.app.json | 15 + web/tsconfig.json | 7 + web/tsconfig.node.json | 23 + web/vite.config.ts | 33 + 56 files changed, 7630 insertions(+) create mode 100644 .gitignore create mode 100644 Jiaowu.slnx create mode 100644 README.md create mode 100644 TODO.md create mode 100644 dotnet-tools.json create mode 100644 global.json create mode 100644 scripts/smoke-test.ps1 create mode 100644 src/Jiaowu.Api/Controllers/AuthController.cs create mode 100644 src/Jiaowu.Api/Controllers/BaseDataController.cs create mode 100644 src/Jiaowu.Api/Controllers/DashboardController.cs create mode 100644 src/Jiaowu.Api/Controllers/UsersController.cs create mode 100644 src/Jiaowu.Api/Domain/Academic/OrganizationEntities.cs create mode 100644 src/Jiaowu.Api/Domain/Common/EntityBase.cs create mode 100644 src/Jiaowu.Api/Domain/Identity/ApplicationUser.cs create mode 100644 src/Jiaowu.Api/Domain/System/AuditLog.cs create mode 100644 src/Jiaowu.Api/Infrastructure/Auth/JwtOptions.cs create mode 100644 src/Jiaowu.Api/Infrastructure/Auth/TokenService.cs create mode 100644 src/Jiaowu.Api/Infrastructure/Middleware/AuditMiddleware.cs create mode 100644 src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs create mode 100644 src/Jiaowu.Api/Infrastructure/Persistence/DatabaseInitializer.cs create mode 100644 src/Jiaowu.Api/Infrastructure/Persistence/DatabaseOptions.cs create mode 100644 src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724042846_InitialMySql.Designer.cs create mode 100644 src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724042846_InitialMySql.cs create mode 100644 src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs create mode 100644 src/Jiaowu.Api/Jiaowu.Api.csproj create mode 100644 src/Jiaowu.Api/Jiaowu.Api.http create mode 100644 src/Jiaowu.Api/Program.cs create mode 100644 src/Jiaowu.Api/Properties/launchSettings.json create mode 100644 src/Jiaowu.Api/appsettings.Development.json create mode 100644 src/Jiaowu.Api/appsettings.json create mode 100644 tests/Jiaowu.Api.Tests/Jiaowu.Api.Tests.csproj create mode 100644 tests/Jiaowu.Api.Tests/PersistenceTests.cs create mode 100644 tests/Jiaowu.Api.Tests/TokenServiceTests.cs create mode 100644 web/.env.example create mode 100644 web/.gitignore create mode 100644 web/index.html create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/public/favicon.svg create mode 100644 web/src/App.vue create mode 100644 web/src/api/http.ts create mode 100644 web/src/auto-imports.d.ts create mode 100644 web/src/components.d.ts create mode 100644 web/src/layouts/AdminLayout.vue create mode 100644 web/src/main.ts create mode 100644 web/src/router/index.ts create mode 100644 web/src/stores/auth.ts create mode 100644 web/src/style.css create mode 100644 web/src/views/BaseDataView.vue create mode 100644 web/src/views/DashboardView.vue create mode 100644 web/src/views/LoginView.vue create mode 100644 web/src/views/UsersView.vue create mode 100644 web/tsconfig.app.json create mode 100644 web/tsconfig.json create mode 100644 web/tsconfig.node.json create mode 100644 web/vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5062f5c --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Jiaowu.slnx b/Jiaowu.slnx new file mode 100644 index 0000000..d3a6bdb --- /dev/null +++ b/Jiaowu.slnx @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..0207ce1 --- /dev/null +++ b/README.md @@ -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 +``` diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..2324671 --- /dev/null +++ b/TODO.md @@ -0,0 +1,96 @@ +一、系统角色 +超级管理员:系统配置、权限、数据字典、日志 +教务处:校级教学计划、排课、选课、考试、成绩、学籍管理 +院系教务员:本院专业、课程、教学任务、成绩审核 +教师:课表、学生名单、考勤、成绩录入、调停课申请 +辅导员:学生信息、考勤、学业预警 +学生:课表、选课、成绩、考试、学籍申请 +校领导:统计分析和决策看板 +可选角色:财务人员、宿管人员、用人单位或家长 +权限建议采用“角色 + 数据范围”模式,例如院系教务员只能管理本学院数据。 +二、核心功能模块 +模块 主要功能 +基础数据 校区、学院、系部、专业、班级、年级、教室、学期、课程性质、成绩等级等 +用户与权限 用户、角色、菜单权限、操作权限、数据权限、登录日志、操作审计 +学籍管理 学生档案、照片、班级、学籍状态、转专业、休学、复学、退学、留级、异动审批 +课程库 课程编号、学分、学时、课程性质、先修课程、课程简介、开课学院 +培养方案 专业培养方案、课程模块、必修/选修要求、学分要求、版本管理 +教学任务 学期课程开设、授课教师、教学班、容量、授课周次、合班拆班 +智能排课 教师、班级、教室、时间冲突检测,手工调整,课表发布 +选课管理 选课轮次、选课限制、容量、退补选、冲突校验、先修条件、候补名单 +课表管理 学生课表、教师课表、班级课表、教室课表、周次课表 +调停课管理 调课、停课、补课、代课申请,审批及消息通知 +考勤管理 点名、迟到、早退、请假、缺勤,批量导入和统计 +考试管理 考试安排、考场、监考教师、座位表、缓考、补考、重修考试 +成绩管理 成绩项目、权重、录入、导入、提交、审核、发布、修改留痕 +补考与重修 补考资格、报名、重修选课、成绩认定、历史记录 +教学评价 学生评教、同行评价、督导评价、问卷配置、匿名统计 +教师管理 教师档案、职称、所属院系、授课资格、教学工作量 +教室资源 教室容量、类型、设备、占用情况、借用申请 +毕业审核 学分完成度、必修课程、实践环节、毕业资格、学位资格 +学业预警 不及格学分、低绩点、缺勤、延毕风险,预警规则与处理记录 +审批中心 学籍异动、调课、缓考、成绩修改、免修、课程替代等统一审批 +消息通知 站内消息、公告、待办提醒,可扩展短信、邮件、企业微信 +统计报表 学生、课程、成绩、通过率、教师工作量、教室利用率等 +数据导入导出 Excel模板、批量校验、错误报告、导入记录、报表导出 +系统运维 参数配置、字典管理、任务调度、日志、备份、异常监控 + +三、学生端主要功能 +查看个人信息和学籍状态 +查看培养方案及学分完成情况 +在线选课、退课和候补 +查看课表、考试安排和教室 +查看成绩、绩点及成绩单 +发起缓考、免修、重修、学籍异动等申请 +查看审批进度和消息通知 +教学评价 +查看学业预警和毕业审核进度 +四、教师端主要功能 +查看个人课表和教学任务 +查看教学班学生名单 +考勤和请假确认 +平时成绩、考试成绩录入及Excel导入 +设置成绩构成与权重 +提交成绩并查看审核状态 +调课、停课、补课、代课申请 +查看评教结果和教学工作量 +导出点名册、成绩单等材料 +五、关键业务规则 +系统设计时应重点保证: +排课不能造成教师、班级或教室冲突 +选课需要校验时间冲突、容量、先修课程和选课资格 +已发布成绩不能直接修改,必须申请、审批并保留修改记录 +培养方案需要版本化,不能因新方案影响旧年级学生 +学籍异动必须保留完整历史 +重要发布操作应支持撤回限制、二次确认和审计 +Excel导入应先预检,确认无误后再正式写入 +权限不仅控制菜单,还要控制学院、专业、班级等数据范围 +六、推荐建设阶段 +第一阶段:核心可用版 +组织、用户、权限和基础数据 +学生、教师、课程、班级、教室管理 +培养方案 +教学任务 +排课与课表 +学生选课 +成绩录入、审核和查询 +基础学籍管理 +通知、审批、Excel导入导出 +操作日志 +第二阶段:完整教务版 +考试与考场安排 +补考、缓考、重修 +考勤与请假 +调停课 +教学评价 +学业预警 +教师工作量 +毕业与学位审核 +综合统计报表 +第三阶段:智慧教务版 +智能排课优化 +移动端或微信端 +电子成绩单与电子证明 +数据驾驶舱 +校园统一身份认证 +对接财务、一卡通、宿舍、图书馆和教育厅平台 \ No newline at end of file diff --git a/dotnet-tools.json b/dotnet-tools.json new file mode 100644 index 0000000..cce89d8 --- /dev/null +++ b/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "10.0.10", + "commands": [ + "dotnet-ef" + ], + "rollForward": false + } + } +} \ No newline at end of file diff --git a/global.json b/global.json new file mode 100644 index 0000000..5f5c8e8 --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "10.0.302", + "rollForward": "latestPatch" + } +} diff --git a/scripts/smoke-test.ps1 b/scripts/smoke-test.ps1 new file mode 100644 index 0000000..84e75ff --- /dev/null +++ b/scripts/smoke-test.ps1 @@ -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 + } +} diff --git a/src/Jiaowu.Api/Controllers/AuthController.cs b/src/Jiaowu.Api/Controllers/AuthController.cs new file mode 100644 index 0000000..cd81f77 --- /dev/null +++ b/src/Jiaowu.Api/Controllers/AuthController.cs @@ -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 userManager, + ITokenService tokenService) : ControllerBase +{ + [AllowAnonymous] + [HttpPost("login")] + public async Task> 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> 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 Roles, + Guid? CollegeId); diff --git a/src/Jiaowu.Api/Controllers/BaseDataController.cs b/src/Jiaowu.Api/Controllers/BaseDataController.cs new file mode 100644 index 0000000..9280a8d --- /dev/null +++ b/src/Jiaowu.Api/Controllers/BaseDataController.cs @@ -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>> 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> 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> 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> 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> 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> 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> 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> 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> 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> 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> 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> 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>> GetTerms( + CancellationToken cancellationToken) => + await db.AcademicTerms.AsNoTracking() + .OrderByDescending(x => x.StartDate) + .ToListAsync(cancellationToken); + + [HttpPost("terms")] + [Authorize(Roles = Administrators)] + public async Task> 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> 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> 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> 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> 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> 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> 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 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> CreateAsync( + 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); diff --git a/src/Jiaowu.Api/Controllers/DashboardController.cs b/src/Jiaowu.Api/Controllers/DashboardController.cs new file mode 100644 index 0000000..fce902a --- /dev/null +++ b/src/Jiaowu.Api/Controllers/DashboardController.cs @@ -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> 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) + } + }; + } +} diff --git a/src/Jiaowu.Api/Controllers/UsersController.cs b/src/Jiaowu.Api/Controllers/UsersController.cs new file mode 100644 index 0000000..49e3191 --- /dev/null +++ b/src/Jiaowu.Api/Controllers/UsersController.cs @@ -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 userManager, + RoleManager roleManager) : ControllerBase +{ + [HttpGet] + public async Task> 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(); + 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> 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 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 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 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); diff --git a/src/Jiaowu.Api/Domain/Academic/OrganizationEntities.cs b/src/Jiaowu.Api/Domain/Academic/OrganizationEntities.cs new file mode 100644 index 0000000..11ac351 --- /dev/null +++ b/src/Jiaowu.Api/Domain/Academic/OrganizationEntities.cs @@ -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 +} diff --git a/src/Jiaowu.Api/Domain/Common/EntityBase.cs b/src/Jiaowu.Api/Domain/Common/EntityBase.cs new file mode 100644 index 0000000..9ceb7f1 --- /dev/null +++ b/src/Jiaowu.Api/Domain/Common/EntityBase.cs @@ -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; +} diff --git a/src/Jiaowu.Api/Domain/Identity/ApplicationUser.cs b/src/Jiaowu.Api/Domain/Identity/ApplicationUser.cs new file mode 100644 index 0000000..9470d28 --- /dev/null +++ b/src/Jiaowu.Api/Domain/Identity/ApplicationUser.cs @@ -0,0 +1,49 @@ +using Microsoft.AspNetCore.Identity; + +namespace Jiaowu.Api.Domain.Identity; + +public sealed class ApplicationUser : IdentityUser +{ + 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 +{ + 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 + ]; +} diff --git a/src/Jiaowu.Api/Domain/System/AuditLog.cs b/src/Jiaowu.Api/Domain/System/AuditLog.cs new file mode 100644 index 0000000..af0409a --- /dev/null +++ b/src/Jiaowu.Api/Domain/System/AuditLog.cs @@ -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; } +} diff --git a/src/Jiaowu.Api/Infrastructure/Auth/JwtOptions.cs b/src/Jiaowu.Api/Infrastructure/Auth/JwtOptions.cs new file mode 100644 index 0000000..4ba63d0 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Auth/JwtOptions.cs @@ -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; +} diff --git a/src/Jiaowu.Api/Infrastructure/Auth/TokenService.cs b/src/Jiaowu.Api/Infrastructure/Auth/TokenService.cs new file mode 100644 index 0000000..1b78f54 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Auth/TokenService.cs @@ -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 roles); +} + +public sealed class TokenService(IOptions options) : ITokenService +{ + private readonly JwtOptions _options = options.Value; + + public string Create(ApplicationUser user, IEnumerable roles) + { + var claims = new List + { + 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); + } +} diff --git a/src/Jiaowu.Api/Infrastructure/Middleware/AuditMiddleware.cs b/src/Jiaowu.Api/Infrastructure/Middleware/AuditMiddleware.cs new file mode 100644 index 0000000..d568c5a --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Middleware/AuditMiddleware.cs @@ -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); + } +} diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs b/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs new file mode 100644 index 0000000..9c0229e --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs @@ -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 options) + : IdentityDbContext(options) +{ + public DbSet Campuses => Set(); + public DbSet Colleges => Set(); + public DbSet Majors => Set(); + public DbSet AdministrativeClasses => Set(); + public DbSet Buildings => Set(); + public DbSet Classrooms => Set(); + public DbSet AcademicTerms => Set(); + public DbSet AuditLogs => Set(); + + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); + + builder.Entity(entity => + { + entity.Property(x => x.DisplayName).HasMaxLength(50); + entity.Property(x => x.StaffNumber).HasMaxLength(30); + entity.HasIndex(x => x.StaffNumber); + }); + + builder.Entity(entity => + { + entity.Property(x => x.Description).HasMaxLength(100); + }); + + ConfigureCatalog(builder); + ConfigureCatalog(builder); + ConfigureCatalog(builder); + ConfigureCatalog(builder); + ConfigureCatalog(builder); + ConfigureCatalog(builder); + ConfigureCatalog(builder); + + builder.Entity() + .HasOne(x => x.Campus) + .WithMany() + .HasForeignKey(x => x.CampusId) + .OnDelete(DeleteBehavior.Restrict); + + builder.Entity() + .HasOne(x => x.College) + .WithMany() + .HasForeignKey(x => x.CollegeId) + .OnDelete(DeleteBehavior.Restrict); + + builder.Entity() + .HasOne(x => x.Major) + .WithMany() + .HasForeignKey(x => x.MajorId) + .OnDelete(DeleteBehavior.Restrict); + + builder.Entity() + .HasOne(x => x.Campus) + .WithMany() + .HasForeignKey(x => x.CampusId) + .OnDelete(DeleteBehavior.Restrict); + + builder.Entity() + .HasOne(x => x.Building) + .WithMany() + .HasForeignKey(x => x.BuildingId) + .OnDelete(DeleteBehavior.Restrict); + + builder.Entity() + .HasIndex(x => x.IsCurrent); + + builder.Entity(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 SaveChangesAsync(CancellationToken cancellationToken = default) + { + foreach (var entry in ChangeTracker.Entries() + .Where(x => x.State == EntityState.Modified)) + { + entry.Entity.UpdatedAt = DateTime.UtcNow; + } + + return base.SaveChangesAsync(cancellationToken); + } + + private static void ConfigureCatalog(ModelBuilder builder) + where TEntity : CatalogEntity + { + builder.Entity(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 }); + }); + } +} diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/DatabaseInitializer.cs b/src/Jiaowu.Api/Infrastructure/Persistence/DatabaseInitializer.cs new file mode 100644 index 0000000..bc78a81 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Persistence/DatabaseInitializer.cs @@ -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 roleManager, + UserManager userManager, + IConfiguration configuration, + IHostEnvironment environment, + ILogger 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 + { + [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))}"); + } +} diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/DatabaseOptions.cs b/src/Jiaowu.Api/Infrastructure/Persistence/DatabaseOptions.cs new file mode 100644 index 0000000..7eb7c8d --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Persistence/DatabaseOptions.cs @@ -0,0 +1,7 @@ +namespace Jiaowu.Api.Infrastructure.Persistence; + +public sealed class DatabaseOptions +{ + public const string SectionName = "Database"; + public string Provider { get; set; } = "MySql"; +} diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724042846_InitialMySql.Designer.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724042846_InitialMySql.Designer.cs new file mode 100644 index 0000000..efca7a1 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724042846_InitialMySql.Designer.cs @@ -0,0 +1,734 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicYear") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("IsCurrent") + .HasColumnType("tinyint(1)"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Season") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("StartDate") + .HasColumnType("date"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CounselorName") + .HasColumnType("longtext"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Grade") + .HasColumnType("int"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("MajorId") + .HasColumnType("char(36)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CampusId") + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Address") + .HasColumnType("longtext"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("BuildingId") + .HasColumnType("char(36)"); + + b.Property("Capacity") + .HasColumnType("int"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Equipment") + .HasColumnType("longtext"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("RoomType") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CampusId") + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("ShortName") + .HasColumnType("longtext"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CollegeId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DegreeType") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SchoolingYears") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("longtext"); + + b.Property("DataScope") + .HasColumnType("int"); + + b.Property("Description") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("CollegeId") + .HasColumnType("char(36)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("longtext"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("tinyint(1)"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("LastLoginAt") + .HasColumnType("datetime(6)"); + + b.Property("LockoutEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("LockoutEnd") + .HasColumnType("datetime"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("longtext"); + + b.Property("PhoneNumber") + .HasColumnType("longtext"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("tinyint(1)"); + + b.Property("SecurityStamp") + .HasColumnType("longtext"); + + b.Property("StaffNumber") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IpAddress") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("Method") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("StatusCode") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("UserName") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.ToTable("AuditLogs"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("ClaimType") + .HasColumnType("longtext"); + + b.Property("ClaimValue") + .HasColumnType("longtext"); + + b.Property("RoleId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("ClaimType") + .HasColumnType("longtext"); + + b.Property("ClaimValue") + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("varchar(255)"); + + b.Property("ProviderKey") + .HasColumnType("varchar(255)"); + + b.Property("ProviderDisplayName") + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("RoleId") + .HasColumnType("char(36)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("LoginProvider") + .HasColumnType("varchar(255)"); + + b.Property("Name") + .HasColumnType("varchar(255)"); + + b.Property("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", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", 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", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724042846_InitialMySql.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724042846_InitialMySql.cs new file mode 100644 index 0000000..c263c3d --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724042846_InitialMySql.cs @@ -0,0 +1,577 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using MySql.EntityFrameworkCore.Metadata; + +#nullable disable + +namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql +{ + /// + public partial class InitialMySql : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterDatabase() + .Annotation("MySQL:Charset", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "AcademicTerms", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false), + AcademicYear = table.Column(type: "longtext", nullable: false), + Season = table.Column(type: "int", nullable: false), + StartDate = table.Column(type: "date", nullable: false), + EndDate = table.Column(type: "date", nullable: false), + IsCurrent = table.Column(type: "tinyint(1)", nullable: false), + CreatedAt = table.Column(type: "datetime(6)", nullable: false), + UpdatedAt = table.Column(type: "datetime(6)", nullable: false), + Code = table.Column(type: "varchar(40)", maxLength: 40, nullable: false), + Name = table.Column(type: "varchar(100)", maxLength: 100, nullable: false), + SortOrder = table.Column(type: "int", nullable: false), + IsEnabled = table.Column(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(type: "char(36)", nullable: false), + Description = table.Column(type: "varchar(100)", maxLength: 100, nullable: true), + DataScope = table.Column(type: "int", nullable: false), + Name = table.Column(type: "varchar(256)", maxLength: 256, nullable: true), + NormalizedName = table.Column(type: "varchar(256)", maxLength: 256, nullable: true), + ConcurrencyStamp = table.Column(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(type: "char(36)", nullable: false), + DisplayName = table.Column(type: "varchar(50)", maxLength: 50, nullable: false), + StaffNumber = table.Column(type: "varchar(30)", maxLength: 30, nullable: true), + CollegeId = table.Column(type: "char(36)", nullable: true), + IsEnabled = table.Column(type: "tinyint(1)", nullable: false), + CreatedAt = table.Column(type: "datetime(6)", nullable: false), + LastLoginAt = table.Column(type: "datetime(6)", nullable: true), + UserName = table.Column(type: "varchar(256)", maxLength: 256, nullable: true), + NormalizedUserName = table.Column(type: "varchar(256)", maxLength: 256, nullable: true), + Email = table.Column(type: "varchar(256)", maxLength: 256, nullable: true), + NormalizedEmail = table.Column(type: "varchar(256)", maxLength: 256, nullable: true), + EmailConfirmed = table.Column(type: "tinyint(1)", nullable: false), + PasswordHash = table.Column(type: "longtext", nullable: true), + SecurityStamp = table.Column(type: "longtext", nullable: true), + ConcurrencyStamp = table.Column(type: "longtext", nullable: true), + PhoneNumber = table.Column(type: "longtext", nullable: true), + PhoneNumberConfirmed = table.Column(type: "tinyint(1)", nullable: false), + TwoFactorEnabled = table.Column(type: "tinyint(1)", nullable: false), + LockoutEnd = table.Column(type: "datetime", nullable: true), + LockoutEnabled = table.Column(type: "tinyint(1)", nullable: false), + AccessFailedCount = table.Column(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(type: "char(36)", nullable: false), + UserId = table.Column(type: "char(36)", nullable: true), + UserName = table.Column(type: "varchar(100)", maxLength: 100, nullable: true), + Method = table.Column(type: "varchar(10)", maxLength: 10, nullable: false), + Path = table.Column(type: "varchar(300)", maxLength: 300, nullable: false), + StatusCode = table.Column(type: "int", nullable: false), + IpAddress = table.Column(type: "varchar(64)", maxLength: 64, nullable: true), + CreatedAt = table.Column(type: "datetime(6)", nullable: false), + UpdatedAt = table.Column(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(type: "char(36)", nullable: false), + Address = table.Column(type: "longtext", nullable: true), + CreatedAt = table.Column(type: "datetime(6)", nullable: false), + UpdatedAt = table.Column(type: "datetime(6)", nullable: false), + Code = table.Column(type: "varchar(40)", maxLength: 40, nullable: false), + Name = table.Column(type: "varchar(100)", maxLength: 100, nullable: false), + SortOrder = table.Column(type: "int", nullable: false), + IsEnabled = table.Column(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(type: "int", nullable: false) + .Annotation("MySQL:ValueGenerationStrategy", MySQLValueGenerationStrategy.IdentityColumn), + RoleId = table.Column(type: "char(36)", nullable: false), + ClaimType = table.Column(type: "longtext", nullable: true), + ClaimValue = table.Column(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(type: "int", nullable: false) + .Annotation("MySQL:ValueGenerationStrategy", MySQLValueGenerationStrategy.IdentityColumn), + UserId = table.Column(type: "char(36)", nullable: false), + ClaimType = table.Column(type: "longtext", nullable: true), + ClaimValue = table.Column(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(type: "varchar(255)", nullable: false), + ProviderKey = table.Column(type: "varchar(255)", nullable: false), + ProviderDisplayName = table.Column(type: "longtext", nullable: true), + UserId = table.Column(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(type: "char(36)", nullable: false), + RoleId = table.Column(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(type: "char(36)", nullable: false), + LoginProvider = table.Column(type: "varchar(255)", nullable: false), + Name = table.Column(type: "varchar(255)", nullable: false), + Value = table.Column(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(type: "char(36)", nullable: false), + CampusId = table.Column(type: "char(36)", nullable: false), + CreatedAt = table.Column(type: "datetime(6)", nullable: false), + UpdatedAt = table.Column(type: "datetime(6)", nullable: false), + Code = table.Column(type: "varchar(40)", maxLength: 40, nullable: false), + Name = table.Column(type: "varchar(100)", maxLength: 100, nullable: false), + SortOrder = table.Column(type: "int", nullable: false), + IsEnabled = table.Column(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(type: "char(36)", nullable: false), + CampusId = table.Column(type: "char(36)", nullable: true), + ShortName = table.Column(type: "longtext", nullable: true), + CreatedAt = table.Column(type: "datetime(6)", nullable: false), + UpdatedAt = table.Column(type: "datetime(6)", nullable: false), + Code = table.Column(type: "varchar(40)", maxLength: 40, nullable: false), + Name = table.Column(type: "varchar(100)", maxLength: 100, nullable: false), + SortOrder = table.Column(type: "int", nullable: false), + IsEnabled = table.Column(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(type: "char(36)", nullable: false), + BuildingId = table.Column(type: "char(36)", nullable: false), + Capacity = table.Column(type: "int", nullable: false), + RoomType = table.Column(type: "longtext", nullable: false), + Equipment = table.Column(type: "longtext", nullable: true), + CreatedAt = table.Column(type: "datetime(6)", nullable: false), + UpdatedAt = table.Column(type: "datetime(6)", nullable: false), + Code = table.Column(type: "varchar(40)", maxLength: 40, nullable: false), + Name = table.Column(type: "varchar(100)", maxLength: 100, nullable: false), + SortOrder = table.Column(type: "int", nullable: false), + IsEnabled = table.Column(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(type: "char(36)", nullable: false), + CollegeId = table.Column(type: "char(36)", nullable: false), + DegreeType = table.Column(type: "longtext", nullable: false), + SchoolingYears = table.Column(type: "int", nullable: false), + CreatedAt = table.Column(type: "datetime(6)", nullable: false), + UpdatedAt = table.Column(type: "datetime(6)", nullable: false), + Code = table.Column(type: "varchar(40)", maxLength: 40, nullable: false), + Name = table.Column(type: "varchar(100)", maxLength: 100, nullable: false), + SortOrder = table.Column(type: "int", nullable: false), + IsEnabled = table.Column(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(type: "char(36)", nullable: false), + MajorId = table.Column(type: "char(36)", nullable: false), + Grade = table.Column(type: "int", nullable: false), + CounselorName = table.Column(type: "longtext", nullable: true), + CreatedAt = table.Column(type: "datetime(6)", nullable: false), + UpdatedAt = table.Column(type: "datetime(6)", nullable: false), + Code = table.Column(type: "varchar(40)", maxLength: 40, nullable: false), + Name = table.Column(type: "varchar(100)", maxLength: 100, nullable: false), + SortOrder = table.Column(type: "int", nullable: false), + IsEnabled = table.Column(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" }); + } + + /// + 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"); + } + } +} diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs new file mode 100644 index 0000000..d314e2d --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs @@ -0,0 +1,731 @@ +// +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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicYear") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("IsCurrent") + .HasColumnType("tinyint(1)"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Season") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("StartDate") + .HasColumnType("date"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CounselorName") + .HasColumnType("longtext"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Grade") + .HasColumnType("int"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("MajorId") + .HasColumnType("char(36)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CampusId") + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Address") + .HasColumnType("longtext"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("BuildingId") + .HasColumnType("char(36)"); + + b.Property("Capacity") + .HasColumnType("int"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Equipment") + .HasColumnType("longtext"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("RoomType") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CampusId") + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("ShortName") + .HasColumnType("longtext"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CollegeId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DegreeType") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SchoolingYears") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("longtext"); + + b.Property("DataScope") + .HasColumnType("int"); + + b.Property("Description") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("CollegeId") + .HasColumnType("char(36)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("longtext"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("tinyint(1)"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("LastLoginAt") + .HasColumnType("datetime(6)"); + + b.Property("LockoutEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("LockoutEnd") + .HasColumnType("datetime"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("longtext"); + + b.Property("PhoneNumber") + .HasColumnType("longtext"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("tinyint(1)"); + + b.Property("SecurityStamp") + .HasColumnType("longtext"); + + b.Property("StaffNumber") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IpAddress") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("Method") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("StatusCode") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("UserName") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.ToTable("AuditLogs"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("ClaimType") + .HasColumnType("longtext"); + + b.Property("ClaimValue") + .HasColumnType("longtext"); + + b.Property("RoleId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("ClaimType") + .HasColumnType("longtext"); + + b.Property("ClaimValue") + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("varchar(255)"); + + b.Property("ProviderKey") + .HasColumnType("varchar(255)"); + + b.Property("ProviderDisplayName") + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("RoleId") + .HasColumnType("char(36)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("LoginProvider") + .HasColumnType("varchar(255)"); + + b.Property("Name") + .HasColumnType("varchar(255)"); + + b.Property("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", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", 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", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Jiaowu.Api/Jiaowu.Api.csproj b/src/Jiaowu.Api/Jiaowu.Api.csproj new file mode 100644 index 0000000..53a7910 --- /dev/null +++ b/src/Jiaowu.Api/Jiaowu.Api.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + enable + enable + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/src/Jiaowu.Api/Jiaowu.Api.http b/src/Jiaowu.Api/Jiaowu.Api.http new file mode 100644 index 0000000..5b1d627 --- /dev/null +++ b/src/Jiaowu.Api/Jiaowu.Api.http @@ -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" +} diff --git a/src/Jiaowu.Api/Program.cs b/src/Jiaowu.Api/Program.cs new file mode 100644 index 0000000..518e4cc --- /dev/null +++ b/src/Jiaowu.Api/Program.cs @@ -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() ?? new DatabaseOptions(); + +if (databaseOptions.Provider.Equals("SQLite", StringComparison.OrdinalIgnoreCase) && + !builder.Environment.IsDevelopment()) +{ + throw new InvalidOperationException("SQLite 仅允许在 Development 环境使用。生产环境请配置 MySql。"); +} + +builder.Services.AddDbContext(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(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() + .AddEntityFrameworkStores(); + +var jwtOptions = builder.Configuration.GetSection(JwtOptions.SectionName).Get() + ?? throw new InvalidOperationException("缺少 Jwt 配置。"); +if (Encoding.UTF8.GetByteCount(jwtOptions.Key) < 32) +{ + throw new InvalidOperationException("Jwt:Key 至少需要 32 字节。"); +} + +builder.Services.Configure( + builder.Configuration.GetSection(JwtOptions.SectionName)); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +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() + ?? ["http://localhost:5173"]; + policy.WithOrigins(origins) + .AllowAnyHeader() + .AllowAnyMethod(); + }); +}); + +builder.Services.AddProblemDetails(); +builder.Services.AddExceptionHandler(options => +{ + options.ExceptionHandler = async context => + { + var exception = context.Features + .Get()?.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(); +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() + .InitializeAsync(); +} + +app.Run(); + +public partial class Program; diff --git a/src/Jiaowu.Api/Properties/launchSettings.json b/src/Jiaowu.Api/Properties/launchSettings.json new file mode 100644 index 0000000..38fc8c3 --- /dev/null +++ b/src/Jiaowu.Api/Properties/launchSettings.json @@ -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" + } + } + } +} diff --git a/src/Jiaowu.Api/appsettings.Development.json b/src/Jiaowu.Api/appsettings.Development.json new file mode 100644 index 0000000..c28fb4c --- /dev/null +++ b/src/Jiaowu.Api/appsettings.Development.json @@ -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" + } + } +} diff --git a/src/Jiaowu.Api/appsettings.json b/src/Jiaowu.Api/appsettings.json new file mode 100644 index 0000000..f915a5d --- /dev/null +++ b/src/Jiaowu.Api/appsettings.json @@ -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": "*" +} diff --git a/tests/Jiaowu.Api.Tests/Jiaowu.Api.Tests.csproj b/tests/Jiaowu.Api.Tests/Jiaowu.Api.Tests.csproj new file mode 100644 index 0000000..36a6ab7 --- /dev/null +++ b/tests/Jiaowu.Api.Tests/Jiaowu.Api.Tests.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + diff --git a/tests/Jiaowu.Api.Tests/PersistenceTests.cs b/tests/Jiaowu.Api.Tests/PersistenceTests.cs new file mode 100644 index 0000000..4def75d --- /dev/null +++ b/tests/Jiaowu.Api.Tests/PersistenceTests.cs @@ -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() + .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(() => _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); + } +} diff --git a/tests/Jiaowu.Api.Tests/TokenServiceTests.cs b/tests/Jiaowu.Api.Tests/TokenServiceTests.cs new file mode 100644 index 0000000..e67117a --- /dev/null +++ b/tests/Jiaowu.Api.Tests/TokenServiceTests.cs @@ -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 == "陈老师"); + } +} diff --git a/web/.env.example b/web/.env.example new file mode 100644 index 0000000..14ea4ad --- /dev/null +++ b/web/.env.example @@ -0,0 +1 @@ +VITE_API_BASE_URL=/api diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/web/.gitignore @@ -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? diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..072ac7f --- /dev/null +++ b/web/index.html @@ -0,0 +1,15 @@ + + + + + + + + + 明序教务管理系统 + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..336226b --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,2354 @@ +{ + "name": "web", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "web", + "version": "0.0.0", + "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" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", + "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@element-plus/icons-vue": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz", + "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@popperjs/core": { + "name": "@sxzz/popperjs-es", + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz", + "integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.8", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.8.tgz", + "integrity": "sha512-0ZjgOg7oO6farnNGup7yvoM/YXZV84OZxHAwtflItNa/6zzQyVb5LNxyea3FEKEX2XlagIKzrlH7wwxkKgtiew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", + "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.28" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz", + "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz", + "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.40.tgz", + "integrity": "sha512-39E8IgOhTbVDnoJFMKc2DvYnypcZwUqgUhQkccva/0m6FUwtIKSGV7n1hpVmYcFaoRAwf9pBcwnKlCEsN63ZEQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/shared": "3.5.40", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.40.tgz", + "integrity": "sha512-pwkx4vqlqOspFstrcmzwkKLePVMD3PT65imRzLhanU2V1Fj4K13g6OXjanOyzw3aTAuRk84BOmY8f3rEHqPaVA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.40.tgz", + "integrity": "sha512-gIf497P4kpuALcvs5n3AEg1Vdn0pSY4XbjASIfHNYF1/MP3T2Mf2STERTubysBxCRxzJGJYtF/O7vwJrxFB3Vw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/compiler-core": "3.5.40", + "@vue/compiler-dom": "3.5.40", + "@vue/compiler-ssr": "3.5.40", + "@vue/shared": "3.5.40", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.19", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.40.tgz", + "integrity": "sha512-rrE5xiXG663+vHCHa3J9p2z5OcBRjXmoqenprJxAFQxg5pSshzeBiCE6pu46axapRJ2Adk0YDA2BRZVjiHXnhg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/devtools-api": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.1.5.tgz", + "integrity": "sha512-YJipMVAKe5wT5CWf5kTYCaNV7NMNjFVxJkIkJaJ4W/nCxEBzlZzrOsYKeCymdCrFZmBS/+wTWFoUs3Jf/Q6XSQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@vue/devtools-kit": "^8.1.5" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.1.5.tgz", + "integrity": "sha512-FcSAxsi4eWuXLCB7Rv9lj0aIVHHPNVQ2BazGf4RJTc2JCqb4BQg0hk87ZFhminCfl+mD5OUI0rX2cgyu4kJOGA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@vue/devtools-shared": "^8.1.5", + "birpc": "^2.6.1", + "hookable": "^5.5.3", + "perfect-debounce": "^2.0.0" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.1.5.tgz", + "integrity": "sha512-mhT4zcPFhF+Xk1O4BfhhrbXzpmfqY03fS6xGpcllbQG7lDjhQf8pQHcTIhqQIYx1hfwtHmk/6jM96ele0UxPqQ==", + "license": "MIT", + "peer": true + }, + "node_modules/@vue/language-core": { + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.3.8.tgz", + "integrity": "sha512-ieGT8jJdhhy0mGzStZhsg/qPw5bQZJg5yF+3+XU6saf4sM7yo9ZXy3h+nCwrm2+b4qS/SypkNdR2jAF3uei9tA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "@vue/compiler-dom": "^3.5.0", + "@vue/shared": "^3.5.0", + "alien-signals": "^3.2.1", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1", + "picomatch": "^4.0.4" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.40.tgz", + "integrity": "sha512-B7ot9UlUZOi1zbq61/LvE88ZLTV8IlajTdiZTAEiDQgrnIMIZoPr9kGw0Zw46ObW62O9+H/Be3kMbfb7kYPQZA==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.40.tgz", + "integrity": "sha512-KAZLweuZ6uUJPK1PMSQPgBU5gCjgrrfjUhSglmU9NhH+Zjepa8cnwSydPWDWHDwOgY4g3VcZ+PljbiHlURNCbw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.40.tgz", + "integrity": "sha512-ZfrX8ssZQds900L9pr8AuK05ddnMsR4MPMZr8cPN9GoqoPWcXLhjvvbIA2SMv+7a97sJ1vv9pj/zxK0Cq/eEFQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.40", + "@vue/runtime-core": "3.5.40", + "@vue/shared": "3.5.40", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.40.tgz", + "integrity": "sha512-XNJym9WpevhTVt1HuwOrCRJ5Q+9z4BjTMrDtjTrvx74SmUll8spNTw6whWJa9mEkO4PKn5TihI/bm/8ds2QVJw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.40", + "@vue/runtime-dom": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.40.tgz", + "integrity": "sha512-WxnBtruIqOoV3rA4jeKDWzrYI5h7Cp4+pjwDi8kWGHz+IslhiN+wguLVVhtv2l8VoU02rzDCVfDjgCl1lNpZVg==", + "license": "MIT" + }, + "node_modules/@vue/tsconfig": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@vue/tsconfig/-/tsconfig-0.9.1.tgz", + "integrity": "sha512-buvjm+9NzLCJL29KY1j1991YYJ5e6275OiK+G4jtmfIb+z4POywbdm0wXusT9adVWqe0xqg70TbI7+mRx4uU9w==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "typescript": ">= 5.8", + "vue": "^3.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/@vueuse/core": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.3.0.tgz", + "integrity": "sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "14.3.0", + "@vueuse/shared": "14.3.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@vueuse/metadata": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.3.0.tgz", + "integrity": "sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.3.0.tgz", + "integrity": "sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/alien-signals": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.2.1.tgz", + "integrity": "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.21", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/element-plus": { + "version": "2.14.3", + "resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.14.3.tgz", + "integrity": "sha512-pJcvxcpZjYruNzuJhAeVwnbYjfNgzBKnWHwSVEhwzM2/kcLI3brzmtIBxtPqd4hQWJfD1PRnjoc1WipLw2eBGg==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^4.2.0", + "@element-plus/icons-vue": "^2.3.2", + "@floating-ui/dom": "^1.7.6", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.8", + "@types/lodash": "^4.17.24", + "@types/lodash-es": "^4.17.12", + "@vueuse/core": "14.3.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.20", + "lodash": "^4.18.1", + "lodash-es": "^4.18.1", + "lodash-unified": "^1.0.3", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0", + "vue-component-type-helpers": "^3.3.5" + }, + "peerDependencies": { + "vue": "^3.3.7" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/exsolve": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.0.tgz", + "integrity": "sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "license": "MIT", + "peer": true + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/local-pkg": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.2.1.tgz", + "integrity": "sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "mlly": "^1.7.4", + "pkg-types": "^2.3.0", + "quansync": "^0.2.11" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, + "node_modules/lodash-unified": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/lodash-unified/-/lodash-unified-1.0.3.tgz", + "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==", + "license": "MIT", + "peerDependencies": { + "@types/lodash-es": "*", + "lodash": "*", + "lodash-es": "*" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/mlly/node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/mlly/node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/normalize-wheel-es": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", + "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==", + "license": "BSD-3-Clause" + }, + "node_modules/nostics": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/nostics/-/nostics-1.2.0.tgz", + "integrity": "sha512-FGqEfhQjrvo1lL8KFifdTQiNwwQHJxC1jtYE1Rc54qF/jxONUNL+kC9gS1krX8Q65PgrQ5fCqH/I4NhWBvdSqg==", + "license": "MIT" + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", + "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", + "license": "MIT", + "peer": true + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-4.0.2.tgz", + "integrity": "sha512-yKVVA7bSj5oRZFp/Ab9wLlmyb5gPUYEiIm4ryiWTe/xe7PtkRdMVOp1X1ggvq0c6Uj7Q0Du1HnV2mtAwM0Ks1g==", + "license": "MIT", + "dependencies": { + "nostics": "^1.1.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "@vue/devtools-api": "^8.1.5", + "typescript": ">=5.6.0", + "vue": "^3.5.11" + }, + "peerDependenciesMeta": { + "@vue/devtools-api": { + "optional": false + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/pkg-types": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", + "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.2.4", + "exsolve": "^1.0.8", + "pathe": "^2.0.3" + } + }, + "node_modules/postcss": { + "version": "8.5.22", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz", + "integrity": "sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/quansync": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", + "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/antfu" + }, + { + "type": "individual", + "url": "https://github.com/sponsors/sxzz" + } + ], + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/scule": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz", + "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", + "dev": true, + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/unimport": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/unimport/-/unimport-5.7.0.tgz", + "integrity": "sha512-njnL6sp8lEA8QQbZrt+52p/g4X0rw3bnGGmUcJnt1jeG8+iiqO779aGz0PirCtydAIVcuTBRlJ52F0u46z309Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "escape-string-regexp": "^5.0.0", + "estree-walker": "^3.0.3", + "local-pkg": "^1.1.2", + "magic-string": "^0.30.21", + "mlly": "^1.8.0", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "pkg-types": "^2.3.0", + "scule": "^1.3.0", + "strip-literal": "^3.1.0", + "tinyglobby": "^0.2.15", + "unplugin": "^2.3.11", + "unplugin-utils": "^0.3.1" + }, + "engines": { + "node": ">=18.12.0" + } + }, + "node_modules/unimport/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/unplugin": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz", + "integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "acorn": "^8.15.0", + "picomatch": "^4.0.3", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": ">=18.12.0" + } + }, + "node_modules/unplugin-auto-import": { + "version": "21.0.0", + "resolved": "https://registry.npmjs.org/unplugin-auto-import/-/unplugin-auto-import-21.0.0.tgz", + "integrity": "sha512-vWuC8SwqJmxZFYwPojhOhOXDb5xFhNNcEVb9K/RFkyk/3VnfaOjzitWN7v+8DEKpMjSsY2AEGXNgt6I0yQrhRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "local-pkg": "^1.1.2", + "magic-string": "^0.30.21", + "picomatch": "^4.0.3", + "unimport": "^5.6.0", + "unplugin": "^2.3.11", + "unplugin-utils": "^0.3.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@nuxt/kit": "^4.0.0", + "@vueuse/core": "*" + }, + "peerDependenciesMeta": { + "@nuxt/kit": { + "optional": true + }, + "@vueuse/core": { + "optional": true + } + } + }, + "node_modules/unplugin-utils": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/unplugin-utils/-/unplugin-utils-0.3.2.tgz", + "integrity": "sha512-xVToRh2CTmLk2HnEG7ac4rl1MJTT3RFkpS8B++/SnB0kXvuaavD+n3m/vrzyWQOdJNSZQACnbz01pnppbwV5BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/unplugin-vue-components": { + "version": "32.1.0", + "resolved": "https://registry.npmjs.org/unplugin-vue-components/-/unplugin-vue-components-32.1.0.tgz", + "integrity": "sha512-YiUkSxuRjab18XFOrX5VsIxXzccrfmHVGsGeJgSgklb829DQmCy9E4vvDUE4tuvZZdxyFJZX0Oc4TPnnxiiMyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^5.0.0", + "local-pkg": "^1.2.0", + "magic-string": "^0.30.21", + "mlly": "^1.8.2", + "obug": "^2.1.1", + "picomatch": "^4.0.4", + "tinyglobby": "^0.2.16", + "unplugin": "^3.0.0", + "unplugin-utils": "^0.3.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@nuxt/kit": "^3.2.2 || ^4.0.0", + "vue": "^3.0.0" + }, + "peerDependenciesMeta": { + "@nuxt/kit": { + "optional": true + } + } + }, + "node_modules/unplugin-vue-components/node_modules/unplugin": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.3.0.tgz", + "integrity": "sha512-qa66K+crbfyE6JK10GjvbJeRrOsuC/JpbnHctfyp/i4oBTxWOzJfRZyDiOk1PtErMFRu8JhsU/wPvOdBNWe5Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "picomatch": "^4.0.4", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@farmfe/core": "*", + "@rspack/core": "*", + "bun-types-no-globals": "*", + "esbuild": "*", + "rolldown": "*", + "rollup": "*", + "unloader": "*", + "vite": "*", + "webpack": "*" + }, + "peerDependenciesMeta": { + "@farmfe/core": { + "optional": true + }, + "@rspack/core": { + "optional": true + }, + "bun-types-no-globals": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "rolldown": { + "optional": true + }, + "rollup": { + "optional": true + }, + "unloader": { + "optional": true + }, + "vite": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/vite": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.40.tgz", + "integrity": "sha512-+8PJ4SJXdn/cHGImF4CKdxlWHIN5Dkt7DoufRREM6h6uVCx2m7QxgcEQmmzyOK8A9mcafg7sFbJFYsdFVubTig==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.40", + "@vue/compiler-sfc": "3.5.40", + "@vue/runtime-dom": "3.5.40", + "@vue/server-renderer": "3.5.40", + "@vue/shared": "3.5.40" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.3.8.tgz", + "integrity": "sha512-troqCMmQodQDqUqn63NQaFi+CDSclSe7sc8VEBFqf5GFLqmGR2Ph3P2WEC7qwpRVyEWsTi/aAr4vyOe/B1hU3g==", + "license": "MIT" + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-router/node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/vue-tsc": { + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.3.8.tgz", + "integrity": "sha512-xXmYlVQpcwJDWyGlqbHrGVOl1h3UOsASymRibrHc+iy9j/UNnOrOn4u+fntHz4D6Cs74RtapeqVV6CzJeg+UlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.28", + "@vue/language-core": "3.3.8" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..95f0f7b --- /dev/null +++ b/web/package.json @@ -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" + } +} diff --git a/web/public/favicon.svg b/web/public/favicon.svg new file mode 100644 index 0000000..d4bce90 --- /dev/null +++ b/web/public/favicon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/web/src/App.vue b/web/src/App.vue new file mode 100644 index 0000000..7c2aa3f --- /dev/null +++ b/web/src/App.vue @@ -0,0 +1,3 @@ + diff --git a/web/src/api/http.ts b/web/src/api/http.ts new file mode 100644 index 0000000..de601ed --- /dev/null +++ b/web/src/api/http.ts @@ -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 diff --git a/web/src/auto-imports.d.ts b/web/src/auto-imports.d.ts new file mode 100644 index 0000000..8c1957b --- /dev/null +++ b/web/src/auto-imports.d.ts @@ -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 +} diff --git a/web/src/components.d.ts b/web/src/components.d.ts new file mode 100644 index 0000000..2f82aed --- /dev/null +++ b/web/src/components.d.ts @@ -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'] + } +} diff --git a/web/src/layouts/AdminLayout.vue b/web/src/layouts/AdminLayout.vue new file mode 100644 index 0000000..f87b159 --- /dev/null +++ b/web/src/layouts/AdminLayout.vue @@ -0,0 +1,103 @@ + + + diff --git a/web/src/main.ts b/web/src/main.ts new file mode 100644 index 0000000..0a64ce0 --- /dev/null +++ b/web/src/main.ts @@ -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') diff --git a/web/src/router/index.ts b/web/src/router/index.ts new file mode 100644 index 0000000..07fc073 --- /dev/null +++ b/web/src/router/index.ts @@ -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 diff --git a/web/src/stores/auth.ts b/web/src/stores/auth.ts new file mode 100644 index 0000000..c5f1aa9 --- /dev/null +++ b/web/src/stores/auth.ts @@ -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(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 } +}) diff --git a/web/src/style.css b/web/src/style.css new file mode 100644 index 0000000..43694e5 --- /dev/null +++ b/web/src/style.css @@ -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; } +} diff --git a/web/src/views/BaseDataView.vue b/web/src/views/BaseDataView.vue new file mode 100644 index 0000000..efe8637 --- /dev/null +++ b/web/src/views/BaseDataView.vue @@ -0,0 +1,264 @@ + + + diff --git a/web/src/views/DashboardView.vue b/web/src/views/DashboardView.vue new file mode 100644 index 0000000..cddc5ac --- /dev/null +++ b/web/src/views/DashboardView.vue @@ -0,0 +1,100 @@ + + + diff --git a/web/src/views/LoginView.vue b/web/src/views/LoginView.vue new file mode 100644 index 0000000..f4fb1ac --- /dev/null +++ b/web/src/views/LoginView.vue @@ -0,0 +1,89 @@ + + + diff --git a/web/src/views/UsersView.vue b/web/src/views/UsersView.vue new file mode 100644 index 0000000..d5abe03 --- /dev/null +++ b/web/src/views/UsersView.vue @@ -0,0 +1,142 @@ + + + diff --git a/web/tsconfig.app.json b/web/tsconfig.app.json new file mode 100644 index 0000000..d72aa75 --- /dev/null +++ b/web/tsconfig.app.json @@ -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"] +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json new file mode 100644 index 0000000..8455dcb --- /dev/null +++ b/web/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"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..a7221b0 --- /dev/null +++ b/web/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, + }, + }, + }, +})