diff --git a/src/Jiaowu.Api/Controllers/TuitionController.cs b/src/Jiaowu.Api/Controllers/TuitionController.cs new file mode 100644 index 0000000..99641f0 --- /dev/null +++ b/src/Jiaowu.Api/Controllers/TuitionController.cs @@ -0,0 +1,135 @@ +using Jiaowu.Api.Contracts; +using Jiaowu.Api.Domain.Academic; +using System.Security.Claims; +using Jiaowu.Api.Domain.Identity; +using Jiaowu.Api.Infrastructure.Excel; +using Jiaowu.Api.Infrastructure.Files; +using Jiaowu.Api.Infrastructure.Persistence; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Jiaowu.Api.Controllers; + +[ApiController, Authorize, Route("api/tuition")] +public sealed class TuitionController(AppDbContext db) : ControllerBase +{ + private const string Managers = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin; + + [HttpGet("terms"), Authorize(Roles = Managers)] + public async Task Terms(CancellationToken ct) => Ok(await db.AcademicTerms.AsNoTracking().OrderByDescending(x => x.StartDate).Select(x => new { x.Id, x.Name, x.IsCurrent, Setting = db.TuitionTermSettings.Where(s => s.AcademicTermId == x.Id).Select(s => new { s.CreditUnitPrice, s.Status, s.CreditsLockedAt }).FirstOrDefault() }).ToListAsync(ct)); + + [HttpPut("terms/{termId:guid}/credit-price"), Authorize(Roles = Managers)] + public async Task SetCreditPrice(Guid termId, TuitionPriceRequest request, CancellationToken ct) + { + if (request.CreditUnitPrice < 0) return BadRequest(new { message = "每学分金额不能为负数。" }); + if (!await db.AcademicTerms.AnyAsync(x => x.Id == termId, ct)) return NotFound(); + var setting = await db.TuitionTermSettings.SingleOrDefaultAsync(x => x.AcademicTermId == termId, ct); + if (setting is null) { setting = new TuitionTermSetting { AcademicTermId = termId, CreditUnitPrice = request.CreditUnitPrice }; db.TuitionTermSettings.Add(setting); } + else if (setting.Status != TuitionTermStatus.Draft) return Conflict(new { message = "学分费已锁定,不能修改单价。" }); + else setting.CreditUnitPrice = request.CreditUnitPrice; + await db.SaveChangesAsync(ct); return NoContent(); + } + + [HttpGet("terms/{termId:guid}/import-template.xlsx"), Authorize(Roles = Managers)] + public IActionResult Template(Guid termId) => File(ExcelWorkbookHelper.Create("学费名单导入", ["学号", "基础学费", "住宿费"], [], ["按学号匹配学生;仅覆盖未缴的基础学费和住宿费。", "已缴项目的差额不会覆盖,将自动记为期末多退少补调整项。"]), ExcelWorkbookHelper.ContentType, DownloadFileName.Template("学费名单", ".xlsx")); + + [HttpPost("terms/{termId:guid}/import"), Authorize(Roles = Managers)] + public async Task Import(Guid termId, IFormFile file, CancellationToken ct) + { + IReadOnlyList rows; try { rows = await ExcelWorkbookHelper.ReadAsync(file, ["学号", "基础学费", "住宿费"], ct); } catch (InvalidDataException ex) { return BadRequest(new { message = ex.Message }); } + if (!await db.AcademicTerms.AnyAsync(x => x.Id == termId, ct)) return NotFound(); + if (rows.Count == 0) return BadRequest(new { message = "Excel 中没有可导入的数据行,请从第 2 行开始填写。" }); + var errors = new List(); + var inputs = new List<(int RowNumber, string Number, decimal Base, decimal Accommodation)>(); + foreach (var row in rows) + { + var number = row["学号"]; + if (string.IsNullOrWhiteSpace(number)) { errors.Add($"第 {row.RowNumber} 行:学号不能为空。"); continue; } + if (!decimal.TryParse(row["基础学费"], out var baseTuition) || baseTuition < 0) + errors.Add($"第 {row.RowNumber} 行:基础学费必须是大于等于 0 的数字。"); + if (!decimal.TryParse(row["住宿费"], out var accommodation) || accommodation < 0) + errors.Add($"第 {row.RowNumber} 行:住宿费必须是大于等于 0 的数字。"); + if (baseTuition >= 0 && accommodation >= 0) inputs.Add((row.RowNumber, number, baseTuition, accommodation)); + } + if (errors.Count > 0) return BadRequest(new { message = string.Join(";", errors.Take(20)) }); + if (inputs.GroupBy(x => x.Number).Any(x => x.Count() > 1)) return BadRequest(new { message = "Excel 中存在重复学号,请每名学生只保留一行。" }); + var students = await db.Students.Where(x => inputs.Select(i => i.Number).Contains(x.StudentNumber)).ToDictionaryAsync(x => x.StudentNumber, ct); + var missingNumbers = inputs.Where(x => !students.ContainsKey(x.Number)).Select(x => $"第 {x.RowNumber} 行({x.Number})").Take(20).ToArray(); + if (missingNumbers.Length > 0) return BadRequest(new { message = $"以下学号未匹配学生:{string.Join("、", missingNumbers)}。" }); + foreach (var input in inputs) + { + var bill = await db.TuitionBills.SingleOrDefaultAsync(x => x.AcademicTermId == termId && x.StudentId == students[input.Number].Id, ct); + if (bill is null) { bill = new TuitionBill { AcademicTermId = termId, StudentId = students[input.Number].Id, BaseTuition = input.Base, AccommodationFee = input.Accommodation }; db.TuitionBills.Add(bill); continue; } + if (bill.BasePaid == 0) bill.BaseTuition = input.Base; else bill.ReconciliationAdjustment += input.Base - bill.BaseTuition; + if (bill.AccommodationPaid == 0) bill.AccommodationFee = input.Accommodation; else bill.ReconciliationAdjustment += input.Accommodation - bill.AccommodationFee; + } + await db.SaveChangesAsync(ct); return Ok(new { imported = inputs.Count }); + } + + [HttpPost("terms/{termId:guid}/lock-credits"), Authorize(Roles = Managers)] + public async Task LockCredits(Guid termId, CancellationToken ct) + { + var setting = await db.TuitionTermSettings.SingleOrDefaultAsync(x => x.AcademicTermId == termId, ct); + if (setting is null) return BadRequest(new { message = "请先设定每学分金额。" }); + if (setting.Status != TuitionTermStatus.Draft) return Conflict(new { message = "学分费已经锁定。" }); + if (await db.CourseSelectionRounds.AnyAsync(x => x.AcademicTermId == termId && x.Status != CourseSelectionRoundStatus.Closed, ct)) return Conflict(new { message = "本学期仍有未结束的选课轮次,不能锁定学分费。" }); + var credits = await db.CourseEnrollments.Where(x => x.CourseSelectionOffering!.CourseSelectionRound!.AcademicTermId == termId && x.Status == CourseEnrollmentStatus.Enrolled).GroupBy(x => x.StudentId).Select(x => new { StudentId = x.Key, Credits = x.Sum(e => e.CourseSelectionOffering!.TeachingTask!.Course!.Credits) }).ToListAsync(ct); + foreach (var credit in credits) { var bill = await db.TuitionBills.SingleOrDefaultAsync(x => x.AcademicTermId == termId && x.StudentId == credit.StudentId, ct); if (bill is null) { bill = new TuitionBill { AcademicTermId = termId, StudentId = credit.StudentId }; db.TuitionBills.Add(bill); } bill.CreditCount = credit.Credits; bill.CreditFee = credit.Credits * setting.CreditUnitPrice; } + setting.Status = TuitionTermStatus.CreditsLocked; setting.CreditsLockedAt = DateTime.UtcNow; + await db.SaveChangesAsync(ct); return Ok(new { locked = credits.Count }); + } + + [HttpPost("terms/{termId:guid}/open-payment"), Authorize(Roles = Managers)] + public async Task OpenPayment(Guid termId, CancellationToken ct) { var setting = await db.TuitionTermSettings.SingleOrDefaultAsync(x => x.AcademicTermId == termId, ct); if (setting is null || setting.Status != TuitionTermStatus.CreditsLocked) return Conflict(new { message = "请先锁定学分费。" }); setting.Status = TuitionTermStatus.OpenForPayment; await db.SaveChangesAsync(ct); return NoContent(); } + + [HttpGet("bills"), Authorize(Roles = Managers)] + public async Task Bills(Guid termId, [FromQuery] string? keyword, [FromQuery] int page = 1, [FromQuery] int pageSize = 20, CancellationToken ct = default) + { + var source = db.TuitionBills.AsNoTracking().Where(x => x.AcademicTermId == termId); + if (!string.IsNullOrWhiteSpace(keyword)) + { + var search = keyword.Trim(); + source = source.Where(x => x.Student!.StudentNumber.Contains(search) || x.Student.Name.Contains(search)); + } + page = Math.Max(1, page); pageSize = Math.Clamp(pageSize, 1, 100); + var total = await source.CountAsync(ct); + var items = await source.OrderBy(x => x.Student!.StudentNumber).Skip((page - 1) * pageSize).Take(pageSize) + .Select(x => new { x.Id, x.StudentId, x.Student!.StudentNumber, StudentName = x.Student.Name, x.BaseTuition, x.AccommodationFee, x.CreditCount, x.CreditFee, x.ReconciliationAdjustment, x.BasePaid, x.AccommodationPaid, x.CreditPaid, x.AdjustmentPaid }) + .ToListAsync(ct); + return Ok(new PagedResult(items.Cast().ToList(), total, page, pageSize)); + } + + [HttpPost("bills/{billId:guid}/reconcile"), Authorize(Roles = Managers)] + public async Task Reconcile(Guid billId, ReconcileRequest request, CancellationToken ct) { var bill = await db.TuitionBills.FindAsync([billId], ct); if (bill is null) return NotFound(); bill.ReconciliationAdjustment += request.Amount; await db.SaveChangesAsync(ct); return NoContent(); } + + [HttpGet("mine"), Authorize(Roles = SystemRoles.Student)] + public async Task Mine(CancellationToken ct) + { + if (!Guid.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out var userId)) return Forbid(); + return Ok(await db.TuitionBills.AsNoTracking().Where(x => x.Student!.UserId == userId).OrderByDescending(x => x.AcademicTerm!.StartDate).Select(x => new { x.Id, TermName = x.AcademicTerm!.Name, x.BaseTuition, x.AccommodationFee, x.CreditCount, x.CreditFee, x.ReconciliationAdjustment, x.BasePaid, x.AccommodationPaid, x.CreditPaid, x.AdjustmentPaid, IsPayable = db.TuitionTermSettings.Any(s => s.AcademicTermId == x.AcademicTermId && s.Status == TuitionTermStatus.OpenForPayment) }).ToListAsync(ct)); + } + + [HttpPost("bills/{billId:guid}/items/{item}/pay-balance"), Authorize(Roles = SystemRoles.Student)] + public async Task PayBalance(Guid billId, TuitionFeeItem item, CancellationToken ct) + { + if (!Guid.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out var userId)) return Forbid(); + var bill = await db.TuitionBills.Include(x => x.Student).SingleOrDefaultAsync(x => x.Id == billId && x.Student!.UserId == userId, ct); + if (bill is null) return NotFound(); + if (!await db.TuitionTermSettings.AnyAsync(x => x.AcademicTermId == bill.AcademicTermId && x.Status == TuitionTermStatus.OpenForPayment, ct)) return Conflict(new { message = "当前学期尚未开放缴费。" }); + var due = Due(bill, item); if (due <= 0) return Conflict(new { message = "该项目没有待缴金额。" }); + var wallet = await db.CampusWalletAccounts.SingleOrDefaultAsync(x => x.StudentId == bill.StudentId, ct); if (wallet is null || wallet.Balance < due) return Conflict(new { message = "校园余额不足。" }); + wallet.Balance -= due; AddPaid(bill, item, due); db.TuitionPayments.Add(new TuitionPayment { TuitionBillId = bill.Id, Item = item, Amount = due, Method = TuitionPaymentMethod.Balance, ConfirmedByUserId = userId, ConfirmedAt = DateTime.UtcNow }); await db.SaveChangesAsync(ct); return NoContent(); + } + + [HttpPost("bills/{billId:guid}/items/{item}/confirm-offline"), Authorize(Roles = Managers)] + public async Task ConfirmOffline(Guid billId, TuitionFeeItem item, OfflineTuitionPaymentRequest request, CancellationToken ct) + { var bill = await db.TuitionBills.FindAsync([billId], ct); if (bill is null) return NotFound(); var due = Due(bill, item); if (due <= 0) return Conflict(new { message = "该项目没有待缴金额。" }); AddPaid(bill, item, due); db.TuitionPayments.Add(new TuitionPayment { TuitionBillId = bill.Id, Item = item, Amount = due, Method = TuitionPaymentMethod.Offline, ReferenceNumber = request.ReferenceNumber?.Trim(), ConfirmedByUserId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!), ConfirmedAt = DateTime.UtcNow }); await db.SaveChangesAsync(ct); return NoContent(); } + + private static decimal Due(TuitionBill b, TuitionFeeItem item) => item switch { TuitionFeeItem.BaseTuition => b.BaseTuition - b.BasePaid, TuitionFeeItem.AccommodationFee => b.AccommodationFee - b.AccommodationPaid, TuitionFeeItem.CreditFee => b.CreditFee - b.CreditPaid, TuitionFeeItem.ReconciliationAdjustment => b.ReconciliationAdjustment - b.AdjustmentPaid, _ => 0 }; + private static void AddPaid(TuitionBill b, TuitionFeeItem item, decimal amount) { if (item == TuitionFeeItem.BaseTuition) b.BasePaid += amount; else if (item == TuitionFeeItem.AccommodationFee) b.AccommodationPaid += amount; else if (item == TuitionFeeItem.CreditFee) b.CreditPaid += amount; else b.AdjustmentPaid += amount; } +} + +public sealed record TuitionPriceRequest(decimal CreditUnitPrice); +public sealed record ReconcileRequest(decimal Amount, string? Reason); +public sealed record OfflineTuitionPaymentRequest(string? ReferenceNumber); diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260822090000_CampusRegistrationPayments.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260822090000_CampusRegistrationPayments.cs new file mode 100644 index 0000000..6662b7a --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260822090000_CampusRegistrationPayments.cs @@ -0,0 +1,74 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql; + +public partial class CampusRegistrationPayments : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + // MySQL DDL is not transactional. IF NOT EXISTS makes a retry safe when + // an earlier attempt created this first table but failed later in the migration. + migrationBuilder.Sql(""" + CREATE TABLE IF NOT EXISTS `CampusRegistrationProjects` ( + `Id` char(36) NOT NULL, `Code` varchar(40) NOT NULL, `Name` varchar(120) NOT NULL, + `Description` varchar(2000) NULL, `CollegeId` char(36) NULL, + `RegistrationStartsAt` datetime(6) NOT NULL, `RegistrationEndsAt` datetime(6) NOT NULL, + `Capacity` int NOT NULL, `FeeAmount` decimal(10,2) NOT NULL, `RequiresReview` tinyint(1) NOT NULL, + `Status` int NOT NULL, `ContactName` varchar(50) NULL, `ContactPhone` varchar(30) NULL, + `Notes` varchar(500) NULL, `CreatedByUserId` char(36) NOT NULL, + `CreatedAt` datetime(6) NOT NULL, `UpdatedAt` datetime(6) NOT NULL, + PRIMARY KEY (`Id`), + CONSTRAINT `FK_CRP_College` FOREIGN KEY (`CollegeId`) REFERENCES `Colleges` (`Id`) ON DELETE SET NULL, + CONSTRAINT `FK_CRP_Creator` FOREIGN KEY (`CreatedByUserId`) REFERENCES `AspNetUsers` (`Id`) ON DELETE RESTRICT + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + """); + migrationBuilder.Sql(""" + CREATE TABLE IF NOT EXISTS `CampusRegistrationApplications` ( + `Id` char(36) NOT NULL, `ProjectId` char(36) NOT NULL, `StudentId` char(36) NOT NULL, `Status` int NOT NULL, + `ApplicantNote` varchar(1000) NULL, `ReviewedByUserId` char(36) NULL, `ReviewedAt` datetime(6) NULL, + `ReviewComment` varchar(500) NULL, `ConfirmedAt` datetime(6) NULL, `CancelledAt` datetime(6) NULL, + `CreatedAt` datetime(6) NOT NULL, `UpdatedAt` datetime(6) NOT NULL, PRIMARY KEY (`Id`), + CONSTRAINT `FK_CRA_Project` FOREIGN KEY (`ProjectId`) REFERENCES `CampusRegistrationProjects` (`Id`) ON DELETE CASCADE, + CONSTRAINT `FK_CRA_Student` FOREIGN KEY (`StudentId`) REFERENCES `Students` (`Id`) ON DELETE RESTRICT, + CONSTRAINT `FK_CRA_Reviewer` FOREIGN KEY (`ReviewedByUserId`) REFERENCES `AspNetUsers` (`Id`) ON DELETE SET NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + CREATE TABLE IF NOT EXISTS `CampusPaymentOrders` ( + `Id` char(36) NOT NULL, `ApplicationId` char(36) NOT NULL, `OrderNumber` varchar(40) NOT NULL, + `Amount` decimal(10,2) NOT NULL, `PaymentMethod` int NOT NULL, `Status` int NOT NULL, + `ConfirmedByUserId` char(36) NULL, `ConfirmedAt` datetime(6) NULL, `ReferenceNumber` varchar(100) NULL, + `Note` varchar(500) NULL, `CreatedAt` datetime(6) NOT NULL, `UpdatedAt` datetime(6) NOT NULL, PRIMARY KEY (`Id`), + CONSTRAINT `FK_CPO_Application` FOREIGN KEY (`ApplicationId`) REFERENCES `CampusRegistrationApplications` (`Id`) ON DELETE CASCADE, + CONSTRAINT `FK_CPO_Confirmer` FOREIGN KEY (`ConfirmedByUserId`) REFERENCES `AspNetUsers` (`Id`) ON DELETE SET NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + """); + + CreateIndexIfMissing(migrationBuilder, "CampusRegistrationProjects", "IX_CampusRegistrationProjects_Code", "`Code`", unique: true); + CreateIndexIfMissing(migrationBuilder, "CampusRegistrationProjects", "IX_CRP_College_Status_Ends", "`CollegeId`, `Status`, `RegistrationEndsAt`"); + CreateIndexIfMissing(migrationBuilder, "CampusRegistrationProjects", "IX_CampusRegistrationProjects_CreatedByUserId", "`CreatedByUserId`"); + CreateIndexIfMissing(migrationBuilder, "CampusRegistrationApplications", "IX_CampusRegistrationApplications_ProjectId_StudentId", "`ProjectId`, `StudentId`", unique: true); + CreateIndexIfMissing(migrationBuilder, "CampusRegistrationApplications", "IX_CampusRegistrationApplications_ProjectId_Status_CreatedAt", "`ProjectId`, `Status`, `CreatedAt`"); + CreateIndexIfMissing(migrationBuilder, "CampusRegistrationApplications", "IX_CampusRegistrationApplications_StudentId", "`StudentId`"); + CreateIndexIfMissing(migrationBuilder, "CampusRegistrationApplications", "IX_CampusRegistrationApplications_ReviewedByUserId", "`ReviewedByUserId`"); + CreateIndexIfMissing(migrationBuilder, "CampusPaymentOrders", "IX_CampusPaymentOrders_ApplicationId", "`ApplicationId`", unique: true); + CreateIndexIfMissing(migrationBuilder, "CampusPaymentOrders", "IX_CampusPaymentOrders_OrderNumber", "`OrderNumber`", unique: true); + CreateIndexIfMissing(migrationBuilder, "CampusPaymentOrders", "IX_CampusPaymentOrders_Status_CreatedAt", "`Status`, `CreatedAt`"); + CreateIndexIfMissing(migrationBuilder, "CampusPaymentOrders", "IX_CampusPaymentOrders_ConfirmedByUserId", "`ConfirmedByUserId`"); + } + + private static void CreateIndexIfMissing(MigrationBuilder migrationBuilder, string table, string name, string columns, bool unique = false) + { + var statement = $"CREATE {(unique ? "UNIQUE " : string.Empty)}INDEX `{name}` ON `{table}` ({columns})"; + migrationBuilder.Sql($""" + SET @index_exists = (SELECT COUNT(*) FROM `INFORMATION_SCHEMA`.`STATISTICS` + WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = '{table}' AND `INDEX_NAME` = '{name}'); + SET @create_index_sql = IF(@index_exists = 0, '{statement}', 'SELECT 1'); + PREPARE create_index_stmt FROM @create_index_sql; + EXECUTE create_index_stmt; + DEALLOCATE PREPARE create_index_stmt; + """); + } + protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.DropTable(name: "CampusPaymentOrders"); migrationBuilder.DropTable(name: "CampusRegistrationApplications"); migrationBuilder.DropTable(name: "CampusRegistrationProjects"); } +} diff --git a/tests/Jiaowu.Api.Tests/MySqlMigrationTests.cs b/tests/Jiaowu.Api.Tests/MySqlMigrationTests.cs index 63e038a..04bb668 100644 --- a/tests/Jiaowu.Api.Tests/MySqlMigrationTests.cs +++ b/tests/Jiaowu.Api.Tests/MySqlMigrationTests.cs @@ -314,6 +314,36 @@ public sealed class MySqlMigrationTests } [Fact] +<<<<<<< Updated upstream +======= + public void Campus_registration_migration_uses_mysql_safe_foreign_key_names() + { + using var db = new AppDbContext(CreateMySqlOptions()); + + var script = db.GetService().GenerateScript( + fromMigration: "20260822085356_GradeAnalyticsCommentInteractions", + toMigration: "20260822100000_CampusWalletBalances"); + + Assert.Contains("CREATE TABLE IF NOT EXISTS `CampusRegistrationProjects`", script); + Assert.Contains("CREATE TABLE IF NOT EXISTS `CampusRegistrationApplications`", script); + Assert.Contains("CREATE TABLE IF NOT EXISTS `CampusPaymentOrders`", script); + Assert.Contains("@create_index_sql", script); + Assert.Contains("IX_CRP_College_Status_Ends", script); + Assert.DoesNotContain( + "IX_CampusRegistrationProjects_CollegeId_Status_RegistrationEndsAt", + script); + Assert.Contains("CONSTRAINT `FK_CRA_Project`", script); + Assert.DoesNotContain( + "FK_CampusRegistrationApplications_CampusRegistrationProjects_ProjectId", + script); + Assert.Contains("CONSTRAINT `FK_CWT_Account`", script); + Assert.DoesNotContain( + "FK_CampusWalletTransactions_CampusWalletAccounts_WalletAccountId", + script); + } + + [Fact] +>>>>>>> Stashed changes public void Application_mysql_connections_allow_migration_user_variables() { var normalized = MySqlConnectionStringPolicy.ForApplication( diff --git a/web/src/api/http.ts b/web/src/api/http.ts index acefec3..90cff73 100644 --- a/web/src/api/http.ts +++ b/web/src/api/http.ts @@ -68,6 +68,8 @@ export function apiErrorMessage(error: unknown) { if (!axios.isAxiosError(error)) return '操作失败,请稍后重试。' const status = error.response?.status const data = error.response?.data + const message = typeof data?.message === 'string' ? data.message.trim() : '' + if (message) return message if (data?.errors && typeof data.errors === 'object') { const validationMessage = Object.values(data.errors) .flat() diff --git a/web/src/services/pwa.ts b/web/src/services/pwa.ts index 7c03cf1..b1af7e9 100644 --- a/web/src/services/pwa.ts +++ b/web/src/services/pwa.ts @@ -4,7 +4,9 @@ import { ElMessageBox } from 'element-plus' let updatePromptVisible = false function serviceWorkerUrl() { - return new URL('sw.js', import.meta.env.BASE_URL).toString() + // Vite's BASE_URL is normally a path such as "/" rather than an absolute + // URL. The URL constructor requires an absolute base in browsers. + return new URL(`${import.meta.env.BASE_URL}sw.js`, window.location.origin).toString() } async function promptToRefresh(registration: ServiceWorkerRegistration) { diff --git a/web/src/views/CampusWalletManagementView.vue b/web/src/views/CampusWalletManagementView.vue new file mode 100644 index 0000000..98f7503 --- /dev/null +++ b/web/src/views/CampusWalletManagementView.vue @@ -0,0 +1,97 @@ + + + + + diff --git a/web/src/views/TuitionView.vue b/web/src/views/TuitionView.vue new file mode 100644 index 0000000..82c82b6 --- /dev/null +++ b/web/src/views/TuitionView.vue @@ -0,0 +1,159 @@ + + + + +