From 8b5103d542453378089ce34dd31e359c296720b1 Mon Sep 17 00:00:00 2001 From: biss Date: Tue, 29 Sep 2026 10:42:28 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=8D=E7=A7=B0Branding=E5=8F=98=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 2 +- .gitea/workflows/publish.yml | 2 +- Dockerfile | 1 + README.md | 15 +++++--- TODO.md | 2 +- .../Controllers/AccountSecurityController.cs | 7 ++-- .../Controllers/GradeAnalyticsController.cs | 6 ++-- src/Jiaowu.Api/Controllers/SsoController.cs | 6 ++-- .../TimetableManagementController.cs | 11 ++++-- .../Controllers/TimetablesController.cs | 9 +++-- .../Branding/BrandingOptions.cs | 21 +++++++++++ .../GradeAnalysisWordReportGenerator.cs | 20 ++++++----- .../OfficialDocumentOptions.cs | 2 +- .../Timetables/PersonalCalendarService.cs | 6 ++-- .../Timetables/TimetablePdfExporter.cs | 31 ++++++++++------ src/Jiaowu.Api/Program.cs | 17 +++++++-- src/Jiaowu.Api/appsettings.json | 9 ++++- .../GradeAnalysisWordReportGeneratorTests.cs | 4 +-- .../GradeAnalyticsCommentsControllerTests.cs | 7 +++- .../PersonalCalendarServiceTests.cs | 4 ++- .../TimetableExcelExporterTests.cs | 2 +- web/branding.config.ts | 36 +++++++++++++++++++ web/capacitor.config.ts | 3 +- web/index.html | 4 +-- .../app/src/main/res/values/strings.xml | 14 ++++---- web/scripts/configure-capacitor.mjs | 20 +++++++++++ web/src/components/SiteFooter.vue | 5 +-- web/src/components/SmartLaunchScreen.vue | 3 +- web/src/config/branding.ts | 10 ++++++ web/src/env.d.ts | 9 +++++ web/src/layouts/AdminLayout.vue | 9 ++--- web/src/services/nativeHome.ts | 3 +- web/src/utils/smartGreeting.ts | 4 ++- web/src/views/AccountActivationView.vue | 3 +- web/src/views/AccountView.vue | 10 +++--- web/src/views/LoginView.vue | 13 +++---- web/src/views/OfficialDocumentVerifyView.vue | 5 +-- web/src/views/SsoBindView.vue | 10 +++--- web/src/views/TimetableView.vue | 3 +- web/src/views/VenueDisplayView.vue | 3 +- web/vite.config.ts | 24 +++++++++++-- 41 files changed, 282 insertions(+), 93 deletions(-) create mode 100644 src/Jiaowu.Api/Infrastructure/Branding/BrandingOptions.cs create mode 100644 web/branding.config.ts create mode 100644 web/src/config/branding.ts diff --git a/.env.example b/.env.example index 721fc35..b8e0718 100644 --- a/.env.example +++ b/.env.example @@ -114,8 +114,8 @@ Cors__Origins__1=capacitor://localhost Cors__Origins__2=https://localhost Cors__Origins__3=http://localhost +# 学校与平台名称统一在 src/Jiaowu.Api/appsettings.json 的 Branding 节配置。 # 二维码使用的公网根地址;反向代理部署时必须填写最终 HTTPS 地址。 -OfficialDocuments__InstitutionName=明序大学 OfficialDocuments__IssuingOffice=教务处 OfficialDocuments__DocumentNumberPrefix=MXU OfficialDocuments__PublicBaseUrl=https://jiaowu.example.edu.cn diff --git a/.gitea/workflows/publish.yml b/.gitea/workflows/publish.yml index 6548d51..33fdad7 100644 --- a/.gitea/workflows/publish.yml +++ b/.gitea/workflows/publish.yml @@ -155,7 +155,7 @@ jobs: files: | artifacts/packages/* body: | - 已发布大学教务管理系统 ${{ gitea.ref_name }}: + 已发布校园综合服务平台 ${{ gitea.ref_name }}: - Windows x64:`.zip` - Linux x64 / ARM64:`.tar.gz` diff --git a/Dockerfile b/Dockerfile index e745b43..d30398b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,6 +3,7 @@ FROM --platform=$BUILDPLATFORM node:24-alpine AS frontend WORKDIR /source COPY versions.props ./ +COPY src/Jiaowu.Api/appsettings.json ./src/Jiaowu.Api/appsettings.json COPY web/package.json web/package-lock.json ./web/ RUN npm --prefix web ci COPY web/ ./web/ diff --git a/README.md b/README.md index decdbf7..7a28e9a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,11 @@ -# 明序教务管理系统 +# 明序校园综合服务平台 -面向普通高校的教务管理系统。后端使用 ASP.NET Core 10、EF Core 10,前端使用 Vue 3、TypeScript 和 Element Plus。 +面向普通高校教学管理、学业服务与校园生活的一体化综合服务平台。后端使用 ASP.NET Core 10、EF Core 10,前端使用 Vue 3、TypeScript 和 Element Plus。 + +学校名称、平台全称、简称、定位语和介绍统一配置在 +`src/Jiaowu.Api/appsettings.json` 的 `Branding` 节。其他学校部署时只需修改这一处并重新 +构建前端;浏览器标题、登录页、PWA、Capacitor 应用、Swagger、课表与成绩分析导出等 +品牌信息会同步更新。程序集名、数据库标识和 App 包名属于兼容性标识,不随展示名称变化。 官方电子凭证支持学生自助申请和教务代签,由服务端生成成绩单与学籍状态证明 PDF,并提供唯一凭证编号、二维码公开验真、下载记录、失效和重签;生产部署应通过 `OfficialDocuments__PublicBaseUrl` 配置二维码使用的最终 HTTPS 根地址。 @@ -69,7 +74,7 @@ npm run cap:open:android ### Web/PWA 应用 -浏览器通过 HTTPS 打开系统后,可使用浏览器的“安装应用”功能将明序教务固定到桌面或 +浏览器通过 HTTPS 打开系统后,可使用浏览器的“安装应用”功能将平台固定到桌面或 开始菜单。PWA 会缓存应用界面与静态资源;接口请求始终走网络,不会离线缓存登录态、 成绩或其他业务数据。发现新版本时由用户确认刷新,避免正在填写表单时被强制中断。 Capacitor Android 构建会自动禁用 PWA Service Worker,仍使用原生能力和独立的 OTA @@ -160,8 +165,8 @@ Kubernetes 或密钥管理系统仍可覆盖文件中的值。 系统支持 Keycloak 的 OpenID Connect 授权码流程。Keycloak 只负责验证身份;账号是否 启用、角色和学院数据范围仍以本系统 Identity 数据为准。首次 SSO 登录会用 `preferred_username`(可通过 `Sso__UserNameClaim` 修改)优先匹配已有登录账号并记录 -外部账号绑定。如果 Keycloak 用户名与教务系统账号不同,认证后会进入账户绑定页,用户 -需要再输入一次现有教务系统账号和密码;验证成功后建立永久绑定并直接登录。绑定不会 +外部账号绑定。如果 Keycloak 用户名与平台账号不同,认证后会进入账户绑定页,用户 +需要再输入一次现有平台账号和密码;验证成功后建立永久绑定并直接登录。绑定不会 自动创建本地账号、修改人员档案或从 Keycloak 导入高权限角色。同一 Keycloak 身份不能 绑定多个本地账号,同一本地账号也不能绑定多个 Keycloak 身份。原账号密码登录和学生 自助激活入口不受影响。 diff --git a/TODO.md b/TODO.md index d61c4be..e6ac7e1 100644 --- a/TODO.md +++ b/TODO.md @@ -1,4 +1,4 @@ -# 教务系统功能清单 +# 校园综合服务平台功能清单 > 说明:`[x]` 表示主业务链路已实现;`[-]` 表示已覆盖部分能力,仍需补齐;`[ ]` 表示尚未纳入当前实现。 diff --git a/src/Jiaowu.Api/Controllers/AccountSecurityController.cs b/src/Jiaowu.Api/Controllers/AccountSecurityController.cs index dd0df32..10d07c8 100644 --- a/src/Jiaowu.Api/Controllers/AccountSecurityController.cs +++ b/src/Jiaowu.Api/Controllers/AccountSecurityController.cs @@ -1,6 +1,7 @@ using System.ComponentModel.DataAnnotations; using System.Security.Claims; using Jiaowu.Api.Domain.Identity; +using Jiaowu.Api.Infrastructure.Branding; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; @@ -10,7 +11,9 @@ namespace Jiaowu.Api.Controllers; [ApiController] [Authorize] [Route("api/auth/security")] -public sealed class AccountSecurityController(UserManager userManager) : ControllerBase +public sealed class AccountSecurityController( + UserManager userManager, + BrandingOptions brandingOptions) : ControllerBase { [HttpGet("totp")] public async Task GetTotpStatus() @@ -30,7 +33,7 @@ public sealed class AccountSecurityController(UserManager userM await userManager.ResetAuthenticatorKeyAsync(user); var key = await userManager.GetAuthenticatorKeyAsync(user); if (string.IsNullOrWhiteSpace(key)) return Problem("无法创建验证器密钥。"); - var issuer = "明序教务"; + var issuer = brandingOptions.ShortName; var account = Uri.EscapeDataString(user.UserName ?? user.Id.ToString("D")); var label = Uri.EscapeDataString($"{issuer}:{user.UserName}"); var uri = $"otpauth://totp/{label}?secret={key}&issuer={Uri.EscapeDataString(issuer)}&digits=6"; diff --git a/src/Jiaowu.Api/Controllers/GradeAnalyticsController.cs b/src/Jiaowu.Api/Controllers/GradeAnalyticsController.cs index 99d9834..b9d3536 100644 --- a/src/Jiaowu.Api/Controllers/GradeAnalyticsController.cs +++ b/src/Jiaowu.Api/Controllers/GradeAnalyticsController.cs @@ -3,6 +3,7 @@ using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Domain.System; using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Caching; +using Jiaowu.Api.Infrastructure.Branding; using Jiaowu.Api.Infrastructure.Grades; using Jiaowu.Api.Infrastructure.Files; using Jiaowu.Api.Infrastructure.OfficialDocuments; @@ -20,6 +21,7 @@ public sealed class GradeAnalyticsController( AppDbContext db, ICurrentUserDataScope currentUserDataScope, OfficialDocumentOptions officialDocumentOptions, + BrandingOptions brandingOptions, IAppCache? cache = null) : ControllerBase { private const string AnalyticsUsers = @@ -345,7 +347,7 @@ public sealed class GradeAnalyticsController( if (report is null) return NotFound(); var content = GradeAnalysisWordReportGenerator.Generate( - report, DateTime.Now, officialDocumentOptions.FontPath); + report, DateTime.Now, brandingOptions.ProductName, officialDocumentOptions.FontPath); var fileName = DownloadFileName.Export("科目总体成绩分析报告", ".docx", report.Summary.CourseCode, academicTermId.HasValue ? "指定学期" : "全部学期"); return File(content, @@ -622,7 +624,7 @@ public sealed class GradeAnalyticsController( }); var content = GradeAnalysisWordReportGenerator.Generate( - report, DateTime.Now, officialDocumentOptions.FontPath); + report, DateTime.Now, brandingOptions.ProductName, officialDocumentOptions.FontPath); var fileName = DownloadFileName.Export("成绩分析报告", ".docx", report.CourseCode, report.TaskNumber); return File( content, diff --git a/src/Jiaowu.Api/Controllers/SsoController.cs b/src/Jiaowu.Api/Controllers/SsoController.cs index 67dd4ea..df2b646 100644 --- a/src/Jiaowu.Api/Controllers/SsoController.cs +++ b/src/Jiaowu.Api/Controllers/SsoController.cs @@ -321,13 +321,13 @@ public sealed class SsoController( SsoAuthSchemes.LoginProvider, ticket.Subject); if (subjectOwner is not null && subjectOwner.Id != user.Id) - return BindingConflict("该统一身份账号已绑定其他教务系统账号。"); + return BindingConflict("该统一身份账号已绑定其他平台账号。"); var keycloakLogins = (await userManager.GetLoginsAsync(user)) .Where(x => x.LoginProvider == SsoAuthSchemes.LoginProvider) .ToList(); if (keycloakLogins.Any(x => x.ProviderKey != ticket.Subject)) - return BindingConflict("该教务系统账号已绑定其他统一身份账号。"); + return BindingConflict("该平台账号已绑定其他统一身份账号。"); if (subjectOwner is null) { @@ -563,7 +563,7 @@ public sealed class SsoController( Unauthorized(new ProblemDetails { Title = "账户绑定失败", - Detail = "教务系统账号或密码不正确,或账号已停用。", + Detail = "平台账号或密码不正确,或账号已停用。", Status = StatusCodes.Status401Unauthorized }); diff --git a/src/Jiaowu.Api/Controllers/TimetableManagementController.cs b/src/Jiaowu.Api/Controllers/TimetableManagementController.cs index a454452..7c0b391 100644 --- a/src/Jiaowu.Api/Controllers/TimetableManagementController.cs +++ b/src/Jiaowu.Api/Controllers/TimetableManagementController.cs @@ -2,6 +2,7 @@ using System.ComponentModel.DataAnnotations; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Auth; +using Jiaowu.Api.Infrastructure.Branding; using Jiaowu.Api.Infrastructure.Excel; using Jiaowu.Api.Infrastructure.Files; using Jiaowu.Api.Infrastructure.Persistence; @@ -20,7 +21,8 @@ public sealed class TimetableManagementController( AppDbContext db, TimetableDataService timetableDataService, ICurrentUserDataScope currentUserDataScope, - OfficialDocumentOptions officialDocumentOptions) : ControllerBase + OfficialDocumentOptions officialDocumentOptions, + BrandingOptions brandingOptions) : ControllerBase { private const string ManagementRoles = SystemRoles.SuperAdmin + "," + @@ -241,7 +243,10 @@ public sealed class TimetableManagementController( } var fileName = DownloadFileName.Export("课表批量导出", ".pdf", timetables[0].Term.Name); - return File(TimetablePdfExporter.Create(timetables, officialDocumentOptions.FontPath), "application/pdf", fileName); + return File( + TimetablePdfExporter.Create(timetables, brandingOptions.ShortName, officialDocumentOptions.FontPath), + "application/pdf", + fileName); } [HttpPost("display-links/export.xlsx")] @@ -299,7 +304,7 @@ public sealed class TimetableManagementController( private ActionResult PdfFile(TimetableData timetable) => File( - TimetablePdfExporter.Create(timetable, officialDocumentOptions.FontPath), + TimetablePdfExporter.Create(timetable, brandingOptions.ShortName, officialDocumentOptions.FontPath), "application/pdf", DownloadFileName.Export("课表", ".pdf", timetable.Subject.Name, timetable.Term.Name)); } diff --git a/src/Jiaowu.Api/Controllers/TimetablesController.cs b/src/Jiaowu.Api/Controllers/TimetablesController.cs index 9793514..0973591 100644 --- a/src/Jiaowu.Api/Controllers/TimetablesController.cs +++ b/src/Jiaowu.Api/Controllers/TimetablesController.cs @@ -3,6 +3,7 @@ using System.Security.Cryptography; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Caching; +using Jiaowu.Api.Infrastructure.Branding; using Jiaowu.Api.Infrastructure.Excel; using Jiaowu.Api.Infrastructure.Files; using Jiaowu.Api.Infrastructure.Persistence; @@ -21,7 +22,8 @@ public sealed class TimetablesController( TimetableDataService timetableDataService, PersonalCalendarService personalCalendarService, IAppCache cache, - OfficialDocumentOptions officialDocumentOptions) : ControllerBase + OfficialDocumentOptions officialDocumentOptions, + BrandingOptions brandingOptions) : ControllerBase { [HttpGet("options")] [AllowAnonymous] @@ -518,7 +520,10 @@ public sealed class TimetablesController( private ActionResult PdfFile(TimetableData result) { var fileName = DownloadFileName.Export("课表", ".pdf", result.Subject.Name, result.Term.Name); - return File(TimetablePdfExporter.Create(result, officialDocumentOptions.FontPath), "application/pdf", fileName); + return File( + TimetablePdfExporter.Create(result, brandingOptions.ShortName, officialDocumentOptions.FontPath), + "application/pdf", + fileName); } private static string SafeFileName(string value) diff --git a/src/Jiaowu.Api/Infrastructure/Branding/BrandingOptions.cs b/src/Jiaowu.Api/Infrastructure/Branding/BrandingOptions.cs new file mode 100644 index 0000000..fa99bd4 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Branding/BrandingOptions.cs @@ -0,0 +1,21 @@ +namespace Jiaowu.Api.Infrastructure.Branding; + +public sealed class BrandingOptions +{ + public const string SectionName = "Branding"; + + public string BrandName { get; set; } = string.Empty; + public string InstitutionName { get; set; } = string.Empty; + public string ProductName { get; set; } = string.Empty; + public string ShortName { get; set; } = string.Empty; + public string Tagline { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + + public bool IsValid() => + !string.IsNullOrWhiteSpace(BrandName) && + !string.IsNullOrWhiteSpace(InstitutionName) && + !string.IsNullOrWhiteSpace(ProductName) && + !string.IsNullOrWhiteSpace(ShortName) && + !string.IsNullOrWhiteSpace(Tagline) && + !string.IsNullOrWhiteSpace(Description); +} diff --git a/src/Jiaowu.Api/Infrastructure/Grades/GradeAnalysisWordReportGenerator.cs b/src/Jiaowu.Api/Infrastructure/Grades/GradeAnalysisWordReportGenerator.cs index dc30bba..e81c357 100644 --- a/src/Jiaowu.Api/Infrastructure/Grades/GradeAnalysisWordReportGenerator.cs +++ b/src/Jiaowu.Api/Infrastructure/Grades/GradeAnalysisWordReportGenerator.cs @@ -23,6 +23,7 @@ public static class GradeAnalysisWordReportGenerator public static byte[] Generate( GradeAnalyticsController.TeachingClassAnalysisReport report, DateTime generatedAt, + string productName, string? chineseFontPath = null) { ArgumentNullException.ThrowIfNull(report.Summary); @@ -31,7 +32,7 @@ public static class GradeAnalysisWordReportGenerator { document.PackageProperties.Title = $"{report.CourseName}成绩分析报告"; document.PackageProperties.Subject = "教学班成绩统计与对比分析"; - document.PackageProperties.Creator = "教务管理系统"; + document.PackageProperties.Creator = productName; document.PackageProperties.Created = generatedAt; var mainPart = document.AddMainDocumentPart(); @@ -40,7 +41,7 @@ public static class GradeAnalysisWordReportGenerator settingsPart.Settings = new Settings(new EvenAndOddHeaders()); settingsPart.Settings.Save(); AddStyles(mainPart); - var headerFooterIds = AddHeaderAndFooter(mainPart); + var headerFooterIds = AddHeaderAndFooter(mainPart, productName); BuildBody(mainPart, report, generatedAt, headerFooterIds, chineseFontPath); mainPart.Document.Save(); } @@ -50,6 +51,7 @@ public static class GradeAnalysisWordReportGenerator public static byte[] Generate( GradeAnalyticsController.CourseOverallAnalysisReport report, DateTime generatedAt, + string productName, string? chineseFontPath = null) { ArgumentNullException.ThrowIfNull(report.Summary); @@ -58,7 +60,7 @@ public static class GradeAnalysisWordReportGenerator { document.PackageProperties.Title = $"{report.Summary.CourseName}科目总体成绩分析报告"; document.PackageProperties.Subject = "课程总体成绩统计与教学质量分析"; - document.PackageProperties.Creator = "教务管理系统"; + document.PackageProperties.Creator = productName; document.PackageProperties.Created = generatedAt; var mainPart = document.AddMainDocumentPart(); @@ -67,7 +69,7 @@ public static class GradeAnalysisWordReportGenerator settingsPart.Settings = new Settings(new EvenAndOddHeaders()); settingsPart.Settings.Save(); AddStyles(mainPart); - var headerFooterIds = AddHeaderAndFooter(mainPart); + var headerFooterIds = AddHeaderAndFooter(mainPart, productName); BuildCourseOverallBody(mainPart, report, generatedAt, headerFooterIds, chineseFontPath); mainPart.Document.Save(); } @@ -484,13 +486,13 @@ public static class GradeAnalysisWordReportGenerator stylesPart.Styles.Save(); } - private static HeaderFooterIds AddHeaderAndFooter(MainDocumentPart mainPart) + private static HeaderFooterIds AddHeaderAndFooter(MainDocumentPart mainPart, string productName) { var defaultHeader = mainPart.AddNewPart(); - defaultHeader.Header = CreateHeader(); + defaultHeader.Header = CreateHeader(productName); defaultHeader.Header.Save(); var evenHeader = mainPart.AddNewPart(); - evenHeader.Header = CreateHeader(); + evenHeader.Header = CreateHeader(productName); evenHeader.Header.Save(); var defaultFooter = mainPart.AddNewPart(); defaultFooter.Footer = CreateFooter(); @@ -505,8 +507,8 @@ public static class GradeAnalysisWordReportGenerator mainPart.GetIdOfPart(evenFooter)); } - private static Header CreateHeader() => - new(Paragraph("成绩分析报告 | 教务管理系统", 18, false, Muted, 0, 0)); + private static Header CreateHeader(string productName) => + new(Paragraph($"成绩分析报告 | {productName}", 18, false, Muted, 0, 0)); private static Footer CreateFooter() { diff --git a/src/Jiaowu.Api/Infrastructure/OfficialDocuments/OfficialDocumentOptions.cs b/src/Jiaowu.Api/Infrastructure/OfficialDocuments/OfficialDocumentOptions.cs index 6d8432c..2856fd9 100644 --- a/src/Jiaowu.Api/Infrastructure/OfficialDocuments/OfficialDocumentOptions.cs +++ b/src/Jiaowu.Api/Infrastructure/OfficialDocuments/OfficialDocumentOptions.cs @@ -4,7 +4,7 @@ public sealed class OfficialDocumentOptions { public const string SectionName = "OfficialDocuments"; - public string InstitutionName { get; set; } = "明序大学"; + public string InstitutionName { get; set; } = string.Empty; public string IssuingOffice { get; set; } = "教务处"; public string DocumentNumberPrefix { get; set; } = "MXU"; public string? PublicBaseUrl { get; set; } diff --git a/src/Jiaowu.Api/Infrastructure/Timetables/PersonalCalendarService.cs b/src/Jiaowu.Api/Infrastructure/Timetables/PersonalCalendarService.cs index 900c5f3..f263808 100644 --- a/src/Jiaowu.Api/Infrastructure/Timetables/PersonalCalendarService.cs +++ b/src/Jiaowu.Api/Infrastructure/Timetables/PersonalCalendarService.cs @@ -4,6 +4,7 @@ using System.Text; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Auth; +using Jiaowu.Api.Infrastructure.Branding; using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.AspNetCore.WebUtilities; using Microsoft.EntityFrameworkCore; @@ -14,7 +15,8 @@ namespace Jiaowu.Api.Infrastructure.Timetables; public sealed class PersonalCalendarService( AppDbContext db, TimetableDataService timetableDataService, - IOptions jwtOptions) + IOptions jwtOptions, + BrandingOptions brandingOptions) { private static readonly TimeSpan ChinaOffset = TimeSpan.FromHours(8); private readonly byte[] _signingKey = SHA256.HashData( @@ -88,7 +90,7 @@ public sealed class PersonalCalendarService( .Select(x => x.Id) .ToListAsync(cancellationToken); - var calendarName = $"明序教务 · {user.DisplayName}个人教学日历"; + var calendarName = $"{brandingOptions.ShortName} · {user.DisplayName}个人教学日历"; var builder = new IcsBuilder(calendarName); foreach (var termId in termIds) { diff --git a/src/Jiaowu.Api/Infrastructure/Timetables/TimetablePdfExporter.cs b/src/Jiaowu.Api/Infrastructure/Timetables/TimetablePdfExporter.cs index 1ce0790..fb6cdde 100644 --- a/src/Jiaowu.Api/Infrastructure/Timetables/TimetablePdfExporter.cs +++ b/src/Jiaowu.Api/Infrastructure/Timetables/TimetablePdfExporter.cs @@ -14,9 +14,13 @@ public static class TimetablePdfExporter private static readonly SKColor Rule = new(206, 220, 226); private static readonly SKColor Pale = new(241, 247, 247); - public static byte[] Create(TimetableData timetable, string? fontPath = null) => Create([timetable], fontPath); + public static byte[] Create(TimetableData timetable, string brandShortName, string? fontPath = null) => + Create([timetable], brandShortName, fontPath); - public static byte[] Create(IReadOnlyCollection timetables, string? fontPath = null) + public static byte[] Create( + IReadOnlyCollection timetables, + string brandShortName, + string? fontPath = null) { if (timetables.Count == 0) throw new ArgumentException("至少需要一张课表。", nameof(timetables)); @@ -29,9 +33,9 @@ public static class TimetablePdfExporter { var periods = Periods(timetable); foreach (var periodPage in periods.Chunk(9)) - DrawPage(document, typeface, timetable, periodPage, ++page); + DrawPage(document, typeface, timetable, periodPage, ++page, brandShortName); foreach (var coursePage in timetable.FlexibleCourses.Chunk(9)) - DrawFlexibleCoursesPage(document, typeface, timetable, coursePage, ++page); + DrawFlexibleCoursesPage(document, typeface, timetable, coursePage, ++page, brandShortName); } document.Close(); return stream.ToArray(); @@ -42,7 +46,8 @@ public static class TimetablePdfExporter SKTypeface typeface, TimetableData timetable, IReadOnlyList periods, - int page) + int page, + string brandShortName) { using var canvas = document.BeginPage(PageWidth, PageHeight); canvas.Clear(SKColors.White); @@ -149,7 +154,7 @@ public static class TimetablePdfExporter } } - DrawFooter(canvas, typeface, page, titleRule); + DrawFooter(canvas, typeface, page, titleRule, brandShortName); document.EndPage(); } @@ -176,7 +181,8 @@ public static class TimetablePdfExporter SKTypeface typeface, TimetableData timetable, IReadOnlyList courses, - int page) + int page, + string brandShortName) { using var canvas = document.BeginPage(PageWidth, PageHeight); canvas.Clear(SKColors.White); @@ -212,7 +218,7 @@ public static class TimetablePdfExporter canvas.DrawLine(Margin, rowY + rowHeight, PageWidth - Margin, rowY + rowHeight, titleRule); } - DrawFooter(canvas, typeface, page, titleRule); + DrawFooter(canvas, typeface, page, titleRule, brandShortName); document.EndPage(); } @@ -297,11 +303,16 @@ public static class TimetablePdfExporter canvas.DrawText(text, x, y, align, font, paint); } - private static void DrawFooter(SKCanvas canvas, SKTypeface typeface, int page, SKPaint titleRule) + private static void DrawFooter( + SKCanvas canvas, + SKTypeface typeface, + int page, + SKPaint titleRule, + string brandShortName) { var footerY = PageHeight - 31; canvas.DrawLine(Margin, footerY - 12, PageWidth - Margin, footerY - 12, titleRule); - DrawText(canvas, typeface, "明序教务 · 课表导出", Margin, footerY, 7.5f, Muted); + DrawText(canvas, typeface, $"{brandShortName} · 课表导出", Margin, footerY, 7.5f, Muted); DrawText(canvas, typeface, $"第 {page} 页", PageWidth - Margin, footerY, 7.5f, Muted, SKTextAlign.Right); } diff --git a/src/Jiaowu.Api/Program.cs b/src/Jiaowu.Api/Program.cs index bc3d6e7..32346ad 100644 --- a/src/Jiaowu.Api/Program.cs +++ b/src/Jiaowu.Api/Program.cs @@ -4,6 +4,7 @@ using System.Text.Json.Serialization; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Domain.System; using Jiaowu.Api.Infrastructure.BackgroundJobs; +using Jiaowu.Api.Infrastructure.Branding; using Jiaowu.Api.Infrastructure.Grades; using Jiaowu.Api.Infrastructure.Configuration; using Jiaowu.Api.Infrastructure.Auth; @@ -87,12 +88,21 @@ if (seedDemoData && builder.Environment.IsDevelopment()) var databaseOptions = builder.Configuration .GetSection(DatabaseOptions.SectionName) .Get() ?? new DatabaseOptions(); +var brandingOptions = builder.Configuration + .GetSection(BrandingOptions.SectionName) + .Get() ?? new BrandingOptions(); +if (!brandingOptions.IsValid()) +{ + throw new InvalidOperationException( + "Branding 品牌配置必须完整填写 BrandName、InstitutionName、ProductName、ShortName、Tagline 和 Description。"); +} var cacheOptions = builder.Configuration .GetSection(AppCacheOptions.SectionName) .Get() ?? new AppCacheOptions(); var officialDocumentOptions = builder.Configuration .GetSection(OfficialDocumentOptions.SectionName) .Get() ?? new OfficialDocumentOptions(); +officialDocumentOptions.InstitutionName = brandingOptions.InstitutionName; var backgroundJobOptions = builder.Configuration .GetSection(BackgroundJobOptions.SectionName) .Get() ?? new BackgroundJobOptions(); @@ -303,6 +313,7 @@ if (string.IsNullOrWhiteSpace(operationsOptions.BackupDirectory) || } builder.Services.AddSingleton(databaseOptions); +builder.Services.AddSingleton(brandingOptions); builder.Services.AddSingleton(cacheOptions); builder.Services.AddSingleton(officialDocumentOptions); builder.Services.AddSingleton(backgroundJobOptions); @@ -331,6 +342,8 @@ builder.Services.AddHttpClient(client => }); builder.Services.Configure( builder.Configuration.GetSection(OfficialDocumentOptions.SectionName)); +builder.Services.PostConfigure(options => + options.InstitutionName = brandingOptions.InstitutionName); builder.Services.AddDbContextPool((services, options) => { options.AddInterceptors( @@ -665,7 +678,7 @@ builder.Services.AddSwaggerGen(options => { options.SwaggerDoc(swaggerDocumentVersion, new OpenApiInfo { - Title = "大学教务管理系统 API", + Title = $"{brandingOptions.ProductName} API", Version = swaggerDocumentVersion }); options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme @@ -714,7 +727,7 @@ app.UseSwaggerUI(options => { options.SwaggerEndpoint( $"/swagger/{swaggerDocumentVersion}/swagger.json", - $"大学教务管理系统 API {swaggerDocumentVersion}"); + $"{brandingOptions.ProductName} API {swaggerDocumentVersion}"); options.DocExpansion(DocExpansion.None); options.DefaultModelsExpandDepth(-1); options.DefaultModelExpandDepth(1); diff --git a/src/Jiaowu.Api/appsettings.json b/src/Jiaowu.Api/appsettings.json index 7cb516d..efc06fd 100644 --- a/src/Jiaowu.Api/appsettings.json +++ b/src/Jiaowu.Api/appsettings.json @@ -1,4 +1,12 @@ { + "Branding": { + "BrandName": "明序", + "InstitutionName": "明序大学", + "ProductName": "明序校园综合服务平台", + "ShortName": "明序校园", + "Tagline": "教学管理 · 学业服务 · 校园生活", + "Description": "面向高校教学管理、学业服务与校园生活的一体化综合服务平台" + }, "Database": { "Provider": "MySql", "ApplyMigrationsOnStartup": false, @@ -121,7 +129,6 @@ ] }, "OfficialDocuments": { - "InstitutionName": "明序大学", "IssuingOffice": "教务处", "DocumentNumberPrefix": "MXU", "PublicBaseUrl": "", diff --git a/tests/Jiaowu.Api.Tests/GradeAnalysisWordReportGeneratorTests.cs b/tests/Jiaowu.Api.Tests/GradeAnalysisWordReportGeneratorTests.cs index 72ef19c..4c53566 100644 --- a/tests/Jiaowu.Api.Tests/GradeAnalysisWordReportGeneratorTests.cs +++ b/tests/Jiaowu.Api.Tests/GradeAnalysisWordReportGeneratorTests.cs @@ -38,7 +38,7 @@ public sealed class GradeAnalysisWordReportGeneratorTests [new(Guid.NewGuid(), "2026-2027 学年第一学期", 100, 76m, 88m, 18m, new(10, 78.5m, 90m, 20m))], new(2.5m, 2m, 76m, 88m)); - var bytes = GradeAnalysisWordReportGenerator.Generate(report, now); + var bytes = GradeAnalysisWordReportGenerator.Generate(report, now, "测试校园综合服务平台"); Assert.True(bytes.Length > 10_000); using var stream = new MemoryStream(bytes); @@ -74,7 +74,7 @@ public sealed class GradeAnalysisWordReportGeneratorTests [new("计算机学院", 18, 80m, 88.89m, 27.78m), new("电子信息学院", 12, 76.3m, 83.33m, 25m)], [new(Guid.NewGuid(), Guid.NewGuid(), "2026-2027 学年第一学期", "TASK-01", "程序设计一班", "张老师", 18, 99m, 80.2m, 82m, 52m, 11.2m, 88.89m, 27.78m)]); - var bytes = GradeAnalysisWordReportGenerator.Generate(report, now); + var bytes = GradeAnalysisWordReportGenerator.Generate(report, now, "测试校园综合服务平台"); Assert.True(bytes.Length > 8_000); using var stream = new MemoryStream(bytes); diff --git a/tests/Jiaowu.Api.Tests/GradeAnalyticsCommentsControllerTests.cs b/tests/Jiaowu.Api.Tests/GradeAnalyticsCommentsControllerTests.cs index be6f906..c1e35dd 100644 --- a/tests/Jiaowu.Api.Tests/GradeAnalyticsCommentsControllerTests.cs +++ b/tests/Jiaowu.Api.Tests/GradeAnalyticsCommentsControllerTests.cs @@ -2,6 +2,7 @@ using Jiaowu.Api.Controllers; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Auth; +using Jiaowu.Api.Infrastructure.Branding; using Jiaowu.Api.Infrastructure.OfficialDocuments; using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.AspNetCore.Mvc; @@ -107,7 +108,11 @@ public sealed class GradeAnalyticsCommentsControllerTests private static GradeAnalyticsController CreateController( AppDbContext db, Guid userId, params string[] roles) => - new(db, new TestDataScope(userId, roles), new OfficialDocumentOptions()); + new( + db, + new TestDataScope(userId, roles), + new OfficialDocumentOptions(), + new BrandingOptions { ProductName = "测试校园综合服务平台" }); private sealed class TestDataScope(Guid userId, params string[] roles) : ICurrentUserDataScope diff --git a/tests/Jiaowu.Api.Tests/PersonalCalendarServiceTests.cs b/tests/Jiaowu.Api.Tests/PersonalCalendarServiceTests.cs index 4ea0996..b5242ba 100644 --- a/tests/Jiaowu.Api.Tests/PersonalCalendarServiceTests.cs +++ b/tests/Jiaowu.Api.Tests/PersonalCalendarServiceTests.cs @@ -2,6 +2,7 @@ using System.Text; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Auth; +using Jiaowu.Api.Infrastructure.Branding; using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Timetables; using Microsoft.Data.Sqlite; @@ -473,5 +474,6 @@ public sealed class PersonalCalendarServiceTests : IAsyncLifetime Options.Create(new JwtOptions { Key = "calendar-test-signing-key-with-at-least-thirty-two-characters" - })); + }), + new BrandingOptions { ShortName = "测试校园" }); } diff --git a/tests/Jiaowu.Api.Tests/TimetableExcelExporterTests.cs b/tests/Jiaowu.Api.Tests/TimetableExcelExporterTests.cs index 5c7f12a..f1ed108 100644 --- a/tests/Jiaowu.Api.Tests/TimetableExcelExporterTests.cs +++ b/tests/Jiaowu.Api.Tests/TimetableExcelExporterTests.cs @@ -172,7 +172,7 @@ public sealed class TimetableExcelExporterTests Assert.All(batchWorkbook.Worksheets, worksheet => Assert.Contains("计算机科学 2601 班", worksheet.Cell("A1").GetString())); - var pdf = TimetablePdfExporter.Create([timetable, timetable]); + var pdf = TimetablePdfExporter.Create([timetable, timetable], "测试校园"); Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(pdf, 0, 4)); Assert.Contains(System.Text.Encoding.ASCII.GetBytes("%%EOF"), pdf); Assert.True(pdf.Length > 2_000, "课表 PDF 必须包含完整课程和非排时课程页面。"); diff --git a/web/branding.config.ts b/web/branding.config.ts new file mode 100644 index 0000000..4446104 --- /dev/null +++ b/web/branding.config.ts @@ -0,0 +1,36 @@ +import { readFileSync } from 'node:fs' + +export interface AppBranding { + brandName: string + institutionName: string + productName: string + shortName: string + tagline: string + description: string +} + +interface AppSettings { + Branding?: Partial, unknown>> +} + +const appsettings = JSON.parse( + readFileSync(new URL('../src/Jiaowu.Api/appsettings.json', import.meta.url), 'utf8'), +) as AppSettings + +function required(field: keyof AppBranding): string { + const pascalCase = `${field.charAt(0).toUpperCase()}${field.slice(1)}` as Capitalize + const value = appsettings.Branding?.[pascalCase] ?? appsettings.Branding?.[field] + if (typeof value !== 'string' || !value.trim()) { + throw new Error(`appsettings.json 中缺少 Branding:${pascalCase}。`) + } + return value.trim() +} + +export const branding: Readonly = Object.freeze({ + brandName: required('brandName'), + institutionName: required('institutionName'), + productName: required('productName'), + shortName: required('shortName'), + tagline: required('tagline'), + description: required('description'), +}) diff --git a/web/capacitor.config.ts b/web/capacitor.config.ts index df484d3..7c02d66 100644 --- a/web/capacitor.config.ts +++ b/web/capacitor.config.ts @@ -1,8 +1,9 @@ import type { CapacitorConfig } from '@capacitor/cli' +import { branding } from './branding.config.ts' const config: CapacitorConfig = { appId: 'edu.mingxu.jiaowu', - appName: '明序教务', + appName: branding.shortName, webDir: 'dist', bundledWebRuntime: false, android: { diff --git a/web/index.html b/web/index.html index fef6682..51a97b4 100644 --- a/web/index.html +++ b/web/index.html @@ -6,8 +6,8 @@ - - 明序教务管理系统 + + __APP_PRODUCT_NAME__
diff --git a/web/native/android/app/src/main/res/values/strings.xml b/web/native/android/app/src/main/res/values/strings.xml index 2a84d9b..64fc467 100644 --- a/web/native/android/app/src/main/res/values/strings.xml +++ b/web/native/android/app/src/main/res/values/strings.xml @@ -1,7 +1,7 @@ - 明序教务 - 明序教务 + {{SHORT_NAME}} + {{SHORT_NAME}} edu.mingxu.jiaowu edu.mingxu.jiaowu 我的课表 @@ -11,9 +11,9 @@ 课堂签到 扫码、定位或发起签到 消息中心 - 查看教务通知和待办 - 明序今日课表 - 明序近期考试 - 明序课堂签到 - 明序教务通知 + 查看平台通知和待办 + {{BRAND_NAME}}今日课表 + {{BRAND_NAME}}近期考试 + {{BRAND_NAME}}课堂签到 + {{BRAND_NAME}}平台通知 diff --git a/web/scripts/configure-capacitor.mjs b/web/scripts/configure-capacitor.mjs index fc56673..325bd5e 100644 --- a/web/scripts/configure-capacitor.mjs +++ b/web/scripts/configure-capacitor.mjs @@ -10,6 +10,21 @@ import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' const webRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const appsettingsPath = resolve(webRoot, '..', 'src', 'Jiaowu.Api', 'appsettings.json') +const branding = JSON.parse(readFileSync(appsettingsPath, 'utf8')).Branding + +if (!branding?.BrandName?.trim() || !branding?.ShortName?.trim()) { + throw new Error('appsettings.json 中缺少 Branding:BrandName 或 Branding:ShortName。') +} + +function escapeXml(value) { + return String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') +} function updateFile(path, update) { if (!existsSync(path)) return false @@ -57,6 +72,11 @@ function configureAndroid() { return content.replace(/minSdkVersion\s*=\s*\d+/, 'minSdkVersion = 26') }) + const stringsPath = resolve(androidRoot, 'app', 'src', 'main', 'res', 'values', 'strings.xml') + updateFile(stringsPath, content => content + .replaceAll('{{BRAND_NAME}}', escapeXml(branding.BrandName.trim())) + .replaceAll('{{SHORT_NAME}}', escapeXml(branding.ShortName.trim()))) + updateFile(manifestPath, content => { const declarations = [ '', diff --git a/web/src/components/SiteFooter.vue b/web/src/components/SiteFooter.vue index 5d516e2..49d0bed 100644 --- a/web/src/components/SiteFooter.vue +++ b/web/src/components/SiteFooter.vue @@ -1,6 +1,7 @@