diff --git a/src/Jiaowu.Api/Controllers/AttendanceController.cs b/src/Jiaowu.Api/Controllers/AttendanceController.cs index 573852d..3c25a38 100644 --- a/src/Jiaowu.Api/Controllers/AttendanceController.cs +++ b/src/Jiaowu.Api/Controllers/AttendanceController.cs @@ -8,6 +8,7 @@ using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Excel; +using Jiaowu.Api.Infrastructure.Files; using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Teaching; using Microsoft.AspNetCore.Authorization; @@ -566,7 +567,7 @@ public sealed class AttendanceController( statusLabels.GetValueOrDefault(r.Status, "出勤"), r.Notes }).ToList>()); return File(bytes, ExcelWorkbookHelper.ContentType, - $"考勤表-{sheetData.TaskNumber}-{sheetData.AttendanceDate:yyyyMMdd}.xlsx"); + DownloadFileName.Export("考勤表", ".xlsx", sheetData.TaskNumber, sheetData.AttendanceDate.ToString("yyyyMMdd"))); } [HttpGet("tasks/{teachingTaskId:guid}/statistics")] @@ -596,7 +597,7 @@ public sealed class AttendanceController( return File( bytes, ExcelWorkbookHelper.ContentType, - $"课程考勤统计-{statistics.Course.TaskNumber}.xlsx"); + DownloadFileName.Export("课程考勤统计", ".xlsx", statistics.Course.TaskNumber)); } [HttpPost("sheets/{id:guid}/submit")] diff --git a/src/Jiaowu.Api/Controllers/BaseDataExcelController.cs b/src/Jiaowu.Api/Controllers/BaseDataExcelController.cs index 791f720..7c8bd04 100644 --- a/src/Jiaowu.Api/Controllers/BaseDataExcelController.cs +++ b/src/Jiaowu.Api/Controllers/BaseDataExcelController.cs @@ -3,6 +3,7 @@ using Jiaowu.Api.Domain.Common; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Caching; using Jiaowu.Api.Infrastructure.Excel; +using Jiaowu.Api.Infrastructure.Files; using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -46,7 +47,7 @@ public sealed class BaseDataExcelController(AppDbContext db, IAppCache cache) : "状态填写“启用”或“停用”;是/否字段填写“是”或“否”。", "整批数据会先校验,任一行有误时均不会写入。" ]); - return File(bytes, ExcelWorkbookHelper.ContentType, $"{KindName(kind)}导入模板.xlsx"); + return File(bytes, ExcelWorkbookHelper.ContentType, DownloadFileName.Template(KindName(kind), ".xlsx")); } [HttpGet("{kind}/export")] @@ -56,7 +57,7 @@ public sealed class BaseDataExcelController(AppDbContext db, IAppCache cache) : var rows = await GetExportRowsAsync(kind, cancellationToken); var bytes = ExcelWorkbookHelper.Create(KindName(kind), headers, rows); return File(bytes, ExcelWorkbookHelper.ContentType, - $"{KindName(kind)}-{DateTime.Now:yyyyMMdd-HHmm}.xlsx"); + DownloadFileName.Export(KindName(kind), ".xlsx")); } [HttpPost("{kind}/import")] diff --git a/src/Jiaowu.Api/Controllers/CourseSelectionsController.cs b/src/Jiaowu.Api/Controllers/CourseSelectionsController.cs index df64512..abff537 100644 --- a/src/Jiaowu.Api/Controllers/CourseSelectionsController.cs +++ b/src/Jiaowu.Api/Controllers/CourseSelectionsController.cs @@ -6,6 +6,7 @@ using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.CourseSelection; using Jiaowu.Api.Infrastructure.Excel; +using Jiaowu.Api.Infrastructure.Files; using Jiaowu.Api.Infrastructure.Graduation; using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Teaching; @@ -76,7 +77,7 @@ public sealed class CourseSelectionsController( "专业", "行政班", "年级", "修读状态", "修读类型", "选入时间", "退选时间", "免修审批" ], rows); return File(bytes, ExcelWorkbookHelper.ContentType, - $"修读课程名单-{DateTime.Now:yyyyMMdd-HHmm}.xlsx"); + DownloadFileName.Export("修读课程名单", ".xlsx")); } [HttpGet("enrollments/students")] @@ -137,7 +138,7 @@ public sealed class CourseSelectionsController( "学号", "姓名", "专业", "行政班", "年级", "修读课程", "在修学分" ], rows); return File(bytes, ExcelWorkbookHelper.ContentType, - $"学生修读汇总-{DateTime.Now:yyyyMMdd-HHmm}.xlsx"); + DownloadFileName.Export("学生修读汇总", ".xlsx")); } [HttpGet("rounds")] @@ -1864,7 +1865,7 @@ public sealed class CourseSelectionsController( return File( bytes, ExcelWorkbookHelper.ContentType, - $"教学班名单-{task.TaskNumber}.xlsx"); + DownloadFileName.Export("教学班名单", ".xlsx", task.TaskNumber)); } [HttpGet("offerings/{id:guid}/roster/export.xlsx")] @@ -1920,7 +1921,7 @@ public sealed class CourseSelectionsController( s.EnrolledAt.ToString("yyyy-MM-dd HH:mm") }).ToList>()); return File(bytes, ExcelWorkbookHelper.ContentType, - $"选课名单-{offering.TaskNumber}.xlsx"); + DownloadFileName.Export("选课名单", ".xlsx", offering.TaskNumber)); } private IQueryable ScopedOfferings() diff --git a/src/Jiaowu.Api/Controllers/CoursesExcelController.cs b/src/Jiaowu.Api/Controllers/CoursesExcelController.cs index 4a5ed8f..1048714 100644 --- a/src/Jiaowu.Api/Controllers/CoursesExcelController.cs +++ b/src/Jiaowu.Api/Controllers/CoursesExcelController.cs @@ -4,6 +4,7 @@ using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Caching; using Jiaowu.Api.Infrastructure.Excel; +using Jiaowu.Api.Infrastructure.Files; using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -51,7 +52,7 @@ public sealed class CoursesExcelController( return File( bytes, ExcelWorkbookHelper.ContentType, - "课程库导入模板.xlsx"); + DownloadFileName.Template("课程库", ".xlsx")); } [HttpGet("export")] @@ -98,7 +99,7 @@ public sealed class CoursesExcelController( return File( bytes, ExcelWorkbookHelper.ContentType, - $"课程库-{DateTime.Now:yyyyMMdd-HHmm}.xlsx"); + DownloadFileName.Export("课程库", ".xlsx")); } [HttpPost("import")] diff --git a/src/Jiaowu.Api/Controllers/ExamsController.cs b/src/Jiaowu.Api/Controllers/ExamsController.cs index bd96d7a..e8968c8 100644 --- a/src/Jiaowu.Api/Controllers/ExamsController.cs +++ b/src/Jiaowu.Api/Controllers/ExamsController.cs @@ -6,6 +6,7 @@ using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Caching; using Jiaowu.Api.Infrastructure.Exams; using Jiaowu.Api.Infrastructure.Excel; +using Jiaowu.Api.Infrastructure.Files; using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Teaching; using Microsoft.AspNetCore.Authorization; @@ -1144,7 +1145,7 @@ public sealed class ExamsController( return File( bytes, ExcelWorkbookHelper.ContentType, - $"考场签名单-{FileNamePart(plan.Name)}.xlsx"); + DownloadFileName.Export("考场签名单", ".xlsx", plan.Name)); } // ═══════════════════════════════════════════ @@ -1227,7 +1228,7 @@ public sealed class ExamsController( return File( job.FileBytes, ExcelWorkbookHelper.ContentType, - job.FileName ?? "考场签名单.xlsx"); + job.FileName ?? DownloadFileName.Export("考场签名单", ".xlsx")); } // ═══════════════════════════════════════════ diff --git a/src/Jiaowu.Api/Controllers/ExperimentGradesController.cs b/src/Jiaowu.Api/Controllers/ExperimentGradesController.cs index 55a0d02..4f4f2fd 100644 --- a/src/Jiaowu.Api/Controllers/ExperimentGradesController.cs +++ b/src/Jiaowu.Api/Controllers/ExperimentGradesController.cs @@ -5,6 +5,7 @@ using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Excel; +using Jiaowu.Api.Infrastructure.Files; using Jiaowu.Api.Infrastructure.Grades; using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Teaching; @@ -725,7 +726,7 @@ public sealed class ExperimentGradesController( } }); return File(bytes, ExcelWorkbookHelper.ContentType, - $"实验成绩导入模板-{sheet.ExperimentProject!.Code}.xlsx"); + DownloadFileName.Template("实验成绩", ".xlsx", sheet.ExperimentProject!.Code)); } [HttpPost("sheets/{id:guid}/import")] diff --git a/src/Jiaowu.Api/Controllers/GradeAnalyticsController.cs b/src/Jiaowu.Api/Controllers/GradeAnalyticsController.cs index 7d441bd..a22d5c8 100644 --- a/src/Jiaowu.Api/Controllers/GradeAnalyticsController.cs +++ b/src/Jiaowu.Api/Controllers/GradeAnalyticsController.cs @@ -4,6 +4,7 @@ using Jiaowu.Api.Domain.System; using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Caching; using Jiaowu.Api.Infrastructure.Grades; +using Jiaowu.Api.Infrastructure.Files; using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -227,7 +228,7 @@ public sealed class GradeAnalyticsController( }); var content = GradeAnalysisWordReportGenerator.Generate(report, DateTime.Now); - var fileName = $"{SanitizeFileName(report.CourseCode)}-{SanitizeFileName(report.TaskNumber)}-成绩分析报告.docx"; + var fileName = DownloadFileName.Export("成绩分析报告", ".docx", report.CourseCode, report.TaskNumber); return File( content, "application/vnd.openxmlformats-officedocument.wordprocessingml.document", diff --git a/src/Jiaowu.Api/Controllers/GradesController.cs b/src/Jiaowu.Api/Controllers/GradesController.cs index abdddc5..0db7050 100644 --- a/src/Jiaowu.Api/Controllers/GradesController.cs +++ b/src/Jiaowu.Api/Controllers/GradesController.cs @@ -7,6 +7,7 @@ using Jiaowu.Api.Domain.System; using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Caching; using Jiaowu.Api.Infrastructure.Excel; +using Jiaowu.Api.Infrastructure.Files; using Jiaowu.Api.Infrastructure.Grades; using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Teaching; @@ -815,7 +816,7 @@ public sealed class GradesController( }); var taskName = sheet.TeachingTask!.Name; return File(bytes, ExcelWorkbookHelper.ContentType, - $"成绩导入模板-{taskName}.xlsx"); + DownloadFileName.Template("成绩", ".xlsx", taskName)); } [HttpPost("sheets/{id:guid}/import")] diff --git a/src/Jiaowu.Api/Controllers/OfficialDocumentsController.cs b/src/Jiaowu.Api/Controllers/OfficialDocumentsController.cs index 4e9e26c..a00146e 100644 --- a/src/Jiaowu.Api/Controllers/OfficialDocumentsController.cs +++ b/src/Jiaowu.Api/Controllers/OfficialDocumentsController.cs @@ -4,6 +4,7 @@ using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.OfficialDocuments; +using Jiaowu.Api.Infrastructure.Files; using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -220,7 +221,8 @@ public sealed class OfficialDocumentsController( UserAgent = Request.Headers.UserAgent.ToString() }); await db.SaveChangesAsync(cancellationToken); - return File(document.PdfContent, "application/pdf", $"{document.DocumentNumber}.pdf"); + return File(document.PdfContent, "application/pdf", + DownloadFileName.Document("公文", ".pdf", document.DocumentNumber)); } [HttpGet("{id:guid}/downloads")] diff --git a/src/Jiaowu.Api/Controllers/OtherExamsController.cs b/src/Jiaowu.Api/Controllers/OtherExamsController.cs index 21eac15..a549d2f 100644 --- a/src/Jiaowu.Api/Controllers/OtherExamsController.cs +++ b/src/Jiaowu.Api/Controllers/OtherExamsController.cs @@ -5,6 +5,7 @@ using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Excel; +using Jiaowu.Api.Infrastructure.Files; using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -114,7 +115,8 @@ public sealed class OtherExamsController(AppDbContext db, ICurrentUserDataScope if (batch is null) return NotFound(); var headers = HeadersFor(batch); var bytes = ExcelWorkbookHelper.Create("其他考试成绩导入", headers, [], ["第一行为表头,请勿修改;每行填写一名学生。", "学号用于自动匹配姓名、学院和班级,参加次数由系统自动计算。"]); - return File(bytes, ExcelWorkbookHelper.ContentType, $"其他考试成绩导入模板-{batch.ExamCode ?? batch.Name}.xlsx"); + return File(bytes, ExcelWorkbookHelper.ContentType, + DownloadFileName.Template("其他考试成绩", ".xlsx", batch.ExamCode ?? batch.Name)); } [HttpPost("batches/{id:guid}/import")] diff --git a/src/Jiaowu.Api/Controllers/PersonnelExcelController.cs b/src/Jiaowu.Api/Controllers/PersonnelExcelController.cs index 026522b..ebcbee5 100644 --- a/src/Jiaowu.Api/Controllers/PersonnelExcelController.cs +++ b/src/Jiaowu.Api/Controllers/PersonnelExcelController.cs @@ -4,6 +4,7 @@ using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Caching; using Jiaowu.Api.Infrastructure.Academic; using Jiaowu.Api.Infrastructure.Excel; +using Jiaowu.Api.Infrastructure.Files; using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -74,7 +75,7 @@ public sealed class PersonnelExcelController( "整批数据会先校验,任一行有误时均不会写入。" ]); return File(bytes, ExcelWorkbookHelper.ContentType, - $"{RegistryName(kind)}导入模板.xlsx"); + DownloadFileName.Template(RegistryName(kind), ".xlsx")); } [HttpGet("{kind}/export")] @@ -120,7 +121,7 @@ public sealed class PersonnelExcelController( var bytes = ExcelWorkbookHelper.Create(RegistryName(kind), headers, rows); return File(bytes, ExcelWorkbookHelper.ContentType, - $"{RegistryName(kind)}-{DateTime.Now:yyyyMMdd-HHmm}.xlsx"); + DownloadFileName.Export(RegistryName(kind), ".xlsx")); } [HttpGet("students/current-courses/export")] @@ -174,7 +175,7 @@ public sealed class PersonnelExcelController( StudentCurrentCourseHeaders, rows); return File(bytes, ExcelWorkbookHelper.ContentType, - $"学生正在修读课程-{DateTime.Now:yyyyMMdd-HHmm}.xlsx"); + DownloadFileName.Export("学生正在修读课程", ".xlsx")); } [HttpPost("{kind}/import")] diff --git a/src/Jiaowu.Api/Controllers/StatisticsController.cs b/src/Jiaowu.Api/Controllers/StatisticsController.cs index d63fbd0..55dd2fc 100644 --- a/src/Jiaowu.Api/Controllers/StatisticsController.cs +++ b/src/Jiaowu.Api/Controllers/StatisticsController.cs @@ -4,6 +4,7 @@ using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Caching; using Jiaowu.Api.Infrastructure.Excel; +using Jiaowu.Api.Infrastructure.Files; using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -1047,7 +1048,7 @@ public sealed class StatisticsController( workbook.SaveAs(stream); return File(stream.ToArray(), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - $"{title}.xlsx"); + DownloadFileName.Export(title, ".xlsx")); } private static string MapCourseNature(CourseNature nature) => nature switch diff --git a/src/Jiaowu.Api/Controllers/TimetableManagementController.cs b/src/Jiaowu.Api/Controllers/TimetableManagementController.cs index 5784bf1..a454452 100644 --- a/src/Jiaowu.Api/Controllers/TimetableManagementController.cs +++ b/src/Jiaowu.Api/Controllers/TimetableManagementController.cs @@ -3,6 +3,7 @@ using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Excel; +using Jiaowu.Api.Infrastructure.Files; using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.OfficialDocuments; using Jiaowu.Api.Infrastructure.Timetables; @@ -168,8 +169,7 @@ public sealed class TimetableManagementController( cancellationToken); if (result.Result is null) return result.Error!; var bytes = TimetableExcelExporter.Create(result.Result); - var fileName = - $"{FileName(result.Result.Subject.Name)}-{FileName(result.Result.Term.Name)}-课表.xlsx"; + var fileName = DownloadFileName.Export("课表", ".xlsx", result.Result.Subject.Name, result.Result.Term.Name); return File(bytes, ExcelWorkbookHelper.ContentType, fileName); } @@ -196,7 +196,7 @@ public sealed class TimetableManagementController( } var bytes = TimetableExcelExporter.Create(timetables); - var fileName = $"课表批量导出-{FileName(timetables[0].Term.Name)}.xlsx"; + var fileName = DownloadFileName.Export("课表批量导出", ".xlsx", timetables[0].Term.Name); return File(bytes, ExcelWorkbookHelper.ContentType, fileName); } @@ -240,7 +240,7 @@ public sealed class TimetableManagementController( timetables.Add(result.Result); } - var fileName = $"课表批量导出-{FileName(timetables[0].Term.Name)}.pdf"; + var fileName = DownloadFileName.Export("课表批量导出", ".pdf", timetables[0].Term.Name); return File(TimetablePdfExporter.Create(timetables, officialDocumentOptions.FontPath), "application/pdf", fileName); } @@ -259,7 +259,7 @@ public sealed class TimetableManagementController( .Select(x => new object?[] { "教学楼空余教室", x.Name, "/venue-display/building/" + x.Id }) .ToListAsync(cancellationToken); var bytes = ExcelWorkbookHelper.Create("展牌链接", ["展牌类型", "场地", "相对链接"], classrooms.Concat(buildings).ToList()); - return File(bytes, ExcelWorkbookHelper.ContentType, "场地信息展牌链接.xlsx"); + return File(bytes, ExcelWorkbookHelper.ContentType, DownloadFileName.Export("场地信息展牌链接", ".xlsx")); } private async Task<(TimetableData? Result, ActionResult? Error)> LoadAuthorizedAsync( @@ -301,7 +301,7 @@ public sealed class TimetableManagementController( File( TimetablePdfExporter.Create(timetable, officialDocumentOptions.FontPath), "application/pdf", - $"{FileName(timetable.Subject.Name)}-{FileName(timetable.Term.Name)}-课表.pdf"); + DownloadFileName.Export("课表", ".pdf", timetable.Subject.Name, timetable.Term.Name)); } public sealed record TimetableBatchExportRequest( diff --git a/src/Jiaowu.Api/Controllers/TimetablesController.cs b/src/Jiaowu.Api/Controllers/TimetablesController.cs index bc0e256..9793514 100644 --- a/src/Jiaowu.Api/Controllers/TimetablesController.cs +++ b/src/Jiaowu.Api/Controllers/TimetablesController.cs @@ -4,6 +4,7 @@ using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Caching; using Jiaowu.Api.Infrastructure.Excel; +using Jiaowu.Api.Infrastructure.Files; using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.OfficialDocuments; using Jiaowu.Api.Infrastructure.Timetables; @@ -356,7 +357,7 @@ public sealed class TimetablesController( return File( result.Content, "text/calendar; charset=utf-8", - $"{SafeFileName(user.DisplayName)}-个人教学日历.ics"); + DownloadFileName.Export("个人教学日历", ".ics", user.DisplayName)); } [HttpGet("calendar/{userId:guid}/{accessToken}.ics")] @@ -510,15 +511,13 @@ public sealed class TimetablesController( private ActionResult ExcelFile(TimetableData result) { var bytes = TimetableExcelExporter.Create(result); - var fileName = - $"{SafeFileName(result.Subject.Name)}-{SafeFileName(result.Term.Name)}-课表.xlsx"; + var fileName = DownloadFileName.Export("课表", ".xlsx", result.Subject.Name, result.Term.Name); return File(bytes, ExcelWorkbookHelper.ContentType, fileName); } private ActionResult PdfFile(TimetableData result) { - var fileName = - $"{SafeFileName(result.Subject.Name)}-{SafeFileName(result.Term.Name)}-课表.pdf"; + var fileName = DownloadFileName.Export("课表", ".pdf", result.Subject.Name, result.Term.Name); return File(TimetablePdfExporter.Create(result, officialDocumentOptions.FontPath), "application/pdf", fileName); } diff --git a/src/Jiaowu.Api/Controllers/UsersExcelController.cs b/src/Jiaowu.Api/Controllers/UsersExcelController.cs index c8f401b..7a64404 100644 --- a/src/Jiaowu.Api/Controllers/UsersExcelController.cs +++ b/src/Jiaowu.Api/Controllers/UsersExcelController.cs @@ -2,6 +2,7 @@ using System.Security.Claims; using Jiaowu.Api.Domain.Academic; 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.Identity; @@ -36,7 +37,7 @@ public sealed class UsersExcelController( "教师或学生角色必须填写已存在且未关联其他账号的工号/学号。", "状态填写“启用”或“停用”;整批校验失败时不会写入。" ]); - return File(bytes, ExcelWorkbookHelper.ContentType, "用户与权限导入模板.xlsx"); + return File(bytes, ExcelWorkbookHelper.ContentType, DownloadFileName.Template("用户与权限", ".xlsx")); } [HttpGet("export")] @@ -63,7 +64,7 @@ public sealed class UsersExcelController( } var bytes = ExcelWorkbookHelper.Create("用户与权限", Headers, rows); return File(bytes, ExcelWorkbookHelper.ContentType, - $"用户与权限-{DateTime.Now:yyyyMMdd-HHmm}.xlsx"); + DownloadFileName.Export("用户与权限", ".xlsx")); } [HttpPost("import")] diff --git a/src/Jiaowu.Api/Infrastructure/Exams/ExamSignInExportJobProcessor.cs b/src/Jiaowu.Api/Infrastructure/Exams/ExamSignInExportJobProcessor.cs index 1462b0d..fedbfc9 100644 --- a/src/Jiaowu.Api/Infrastructure/Exams/ExamSignInExportJobProcessor.cs +++ b/src/Jiaowu.Api/Infrastructure/Exams/ExamSignInExportJobProcessor.cs @@ -1,5 +1,6 @@ using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Infrastructure.Persistence; +using Jiaowu.Api.Infrastructure.Files; using Jiaowu.Api.Infrastructure.Teaching; using Microsoft.EntityFrameworkCore; @@ -158,7 +159,7 @@ public sealed class ExamSignInExportJobProcessor( var bytes = ExamSignInWorkbookExporter.Create(data); job.Status = ExamSignInExportJobStatus.Succeeded; - job.FileName = $"考场签名单-{SanitizeFileName(plan.Name)}.xlsx"; + job.FileName = DownloadFileName.Export("考场签名单", ".xlsx", plan.Name); job.FileBytes = bytes; job.FileSize = bytes.Length; job.CurrentStep = "生成完成"; diff --git a/src/Jiaowu.Api/Infrastructure/Files/DownloadFileName.cs b/src/Jiaowu.Api/Infrastructure/Files/DownloadFileName.cs new file mode 100644 index 0000000..ff65cf7 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Files/DownloadFileName.cs @@ -0,0 +1,44 @@ +namespace Jiaowu.Api.Infrastructure.Files; + +/// +/// Creates predictable, Windows-safe names for files the browser downloads. +/// +public static class DownloadFileName +{ + public static string Export(string title, string extension, params string?[] parts) => + Build(title, extension, includeTimestamp: true, parts); + + public static string Template(string title, string extension, params string?[] parts) => + Build(title, extension, includeTimestamp: false, [.. parts, "导入模板"]); + + public static string Document(string title, string extension, params string?[] parts) => + Build(title, extension, includeTimestamp: false, parts); + + private static string Build( + string title, + string extension, + bool includeTimestamp, + IReadOnlyCollection parts) + { + var segments = new List { Clean(title) }; + segments.AddRange(parts.Select(Clean).Where(value => value.Length > 0)); + if (includeTimestamp) + segments.Add(DateTime.Now.ToString("yyyyMMdd-HHmmss")); + + var name = string.Join('-', segments.Where(value => value.Length > 0)); + if (name.Length > 180) name = name[..180].TrimEnd('.', ' '); + return $"{name}{NormalizeExtension(extension)}"; + } + + private static string Clean(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return string.Empty; + var invalid = Path.GetInvalidFileNameChars(); + var cleaned = new string(value.Trim().Select(character => + invalid.Contains(character) ? '-' : character).ToArray()); + return cleaned.Trim('.', ' '); + } + + private static string NormalizeExtension(string extension) => + extension.StartsWith('.') ? extension : $".{extension}"; +} diff --git a/tests/Jiaowu.Api.Tests/DownloadFileNameTests.cs b/tests/Jiaowu.Api.Tests/DownloadFileNameTests.cs new file mode 100644 index 0000000..2c5a495 --- /dev/null +++ b/tests/Jiaowu.Api.Tests/DownloadFileNameTests.cs @@ -0,0 +1,20 @@ +using Jiaowu.Api.Infrastructure.Files; + +namespace Jiaowu.Api.Tests; + +public sealed class DownloadFileNameTests +{ + [Fact] + public void Export_includes_safe_context_and_a_second_precision_timestamp() + { + var name = DownloadFileName.Export("课程/名单", "xlsx", " A:B "); + + Assert.Matches("^课程-名单-A-B-\\d{8}-\\d{6}\\.xlsx$", name); + } + + [Fact] + public void Template_has_a_stable_descriptive_name() + { + Assert.Equal("课程库-导入模板.xlsx", DownloadFileName.Template("课程库", ".xlsx")); + } +} diff --git a/web/src/views/OtherExamsView.vue b/web/src/views/OtherExamsView.vue index d92e2e4..1025af6 100644 --- a/web/src/views/OtherExamsView.vue +++ b/web/src/views/OtherExamsView.vue @@ -3,6 +3,7 @@ import { computed, onMounted, reactive, ref } from 'vue' import { ElMessage } from 'element-plus' import { Download, Plus, Promotion, Refresh, Search, Upload } from '@element-plus/icons-vue' import http, { apiErrorMessage } from '../api/http' +import { downloadApiFile } from '../api/excel' import { useAuthStore } from '../stores/auth' type ResultRow = { studentNumber: string; studentName?: string; collegeName?: string; className?: string; attemptNumber?: number; score: number | null; level: string; isPassed: boolean | null; notes: string } @@ -97,8 +98,7 @@ async function createBatch() { } async function downloadTemplate() { try { - const response = await http.get(`/other-exams/batches/${selected.value.id}/template`, { responseType: 'blob' }) - const url = URL.createObjectURL(response.data); const link = document.createElement('a'); link.href = url; link.download = `其他考试成绩导入模板-${selected.value.examCode || selected.value.name}.xlsx`; link.click(); URL.revokeObjectURL(url) + await downloadApiFile(`/other-exams/batches/${selected.value.id}/template`, '其他考试成绩-导入模板.xlsx') } catch (error) { ElMessage.error(apiErrorMessage(error)) } } async function importExcel(event: Event) {