已完善个人教学日历订阅。
主要能力: 教师、学生可在“我的课表”启用订阅、复制 URL、唤起日历客户端或下载一次性 ICS。 自动覆盖固定课程、调停课后的最新安排、考试/监考、补考,以及灵活课程提醒。 采用独立随机订阅标识和 HMAC 签名;重置或停用后旧地址立即失效。 支持 ETag 条件请求和一小时刷新提示,减少日历客户端重复同步。 订阅地址按实际前端 API 地址生成,兼容独立部署域名。 已增加窄屏弹窗最大宽度和纵向操作布局。
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
面向普通高校的教务管理系统。后端使用 ASP.NET Core 10、EF Core 10,前端使用 Vue 3、TypeScript 和 Element Plus。
|
||||
|
||||
当前已实现系统登录与角色权限、基础数据、用户管理、教师档案、学生档案、课程库、培养方案、教学任务、排课课表、学生选课、成绩管理、考试考场、学籍异动、毕业审核、学位授予、毕业离校和首页统计。人员及课程列表支持组合筛选、服务端分页和完整增删改查;培养方案支持课程模块、专业年级版本、复制新版本、发布与旧版本归档,已发布版本可继续维护名称、学分说明和课程结构,适用专业、入学年级及版本号保持锁定;教学任务支持学期课程开设、多教师、合班、容量校验、发布与结课,公共课由校级教务负责、专业必修/专业选修/实践课下放课程所属学院管理,并支持教师按学期申报授课科目、学院审核授课资格、公共课按若干行政班合并教学班,以及在审核通过的教师池中随机均衡分配后批量生成草稿;排课支持学期作息维护、单双周与周次节次、课程可用时间、校区/教学楼/指定教室约束、不占用教室课程、教室容量、教师/行政班/教室冲突校验、自动生成、手工微调和版本化发布;选课支持批次时间窗、投放范围、容量与学分上限、重复课程与课表冲突校验、退课截止时间、满员候补、顺位查询、退课后资格复核与自动递补,以及正式名单和候补队列管理;成绩管理支持分项比例、批量录入、特殊考试状态、自动总评与绩点、教师提交、学院审核、校级发布和学生成绩单;考试管理支持考试计划、场次、考场容量、监考教师、考生名单以及考场/监考/学生时间冲突校验;学籍异动支持休学、复学、退学申请,辅导员、学院、学校三级顺序审核,学生撤回,以及最终审批后自动同步学籍状态;毕业审核按入学年级匹配已发布培养方案,以正式成绩计算总学分、必修通过和未解决不及格课程,支持学院范围查看、人工复核、校级锁定发布和学生结果查询;学位授予以已发布毕业资格为来源,按正式成绩加权平均绩点生成规则结论,支持学院人工复核、校级发布锁定和学生结果查询;毕业离校支持自定义事项与责任部门,按校级、学院、辅导员角色分工办理,强制数据范围校验,学生进度查询,以及必办事项全部完成后的批次锁定。
|
||||
当前已实现系统登录与角色权限、基础数据、用户管理、教师档案、学生档案、课程库、培养方案、教学任务、排课课表、学生选课、成绩管理、考试考场、学籍异动、毕业审核、学位授予、毕业离校和首页统计。人员及课程列表支持组合筛选、服务端分页和完整增删改查;培养方案支持课程模块、专业年级版本、复制新版本、发布与旧版本归档,已发布版本可继续维护名称、学分说明和课程结构,适用专业、入学年级及版本号保持锁定;教学任务支持学期课程开设、多教师、合班、容量校验、发布与结课,公共课由校级教务负责、专业必修/专业选修/实践课下放课程所属学院管理,并支持教师按学期申报授课科目、学院审核授课资格、公共课按若干行政班合并教学班,以及在审核通过的教师池中随机均衡分配后批量生成草稿;排课支持学期作息维护、单双周与周次节次、课程可用时间、校区/教学楼/指定教室约束、不占用教室课程、教室容量、教师/行政班/教室冲突校验、自动生成、手工微调和版本化发布;教师和学生可启用个人教学日历订阅,将固定课程、考试/监考、补考及灵活课程提醒同步到支持 iCalendar 的客户端,并可重置或停用订阅地址;选课支持批次时间窗、投放范围、容量与学分上限、重复课程与课表冲突校验、退课截止时间、满员候补、顺位查询、退课后资格复核与自动递补,以及正式名单和候补队列管理;成绩管理支持分项比例、批量录入、特殊考试状态、自动总评与绩点、教师提交、学院审核、校级发布和学生成绩单;考试管理支持考试计划、场次、考场容量、监考教师、考生名单以及考场/监考/学生时间冲突校验;学籍异动支持休学、复学、退学申请,辅导员、学院、学校三级顺序审核,学生撤回,以及最终审批后自动同步学籍状态;毕业审核按入学年级匹配已发布培养方案,以正式成绩计算总学分、必修通过和未解决不及格课程,支持学院范围查看、人工复核、校级锁定发布和学生结果查询;学位授予以已发布毕业资格为来源,按正式成绩加权平均绩点生成规则结论,支持学院人工复核、校级发布锁定和学生结果查询;毕业离校支持自定义事项与责任部门,按校级、学院、辅导员角色分工办理,强制数据范围校验,学生进度查询,以及必办事项全部完成后的批次锁定。
|
||||
|
||||
课程库支持下载标准模板后批量导入 `.xlsx`,按课程编码新增或更新,并在整批校验失败时不写入任何课程;授课资格既支持教师申报后审核,也支持学院在本院教师范围内直接分配;学生可在“我的培养方案”中查看本人适用的已发布方案,并按已完成、在读、重修中、未通过、待完成和未修读状态核对课程与学分进度。
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
@@ -16,6 +17,7 @@ namespace Jiaowu.Api.Controllers;
|
||||
public sealed class TimetablesController(
|
||||
AppDbContext db,
|
||||
TimetableDataService timetableDataService,
|
||||
PersonalCalendarService personalCalendarService,
|
||||
IAppCache cache) : ControllerBase
|
||||
{
|
||||
[HttpGet("options")]
|
||||
@@ -201,6 +203,101 @@ public sealed class TimetablesController(
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
[HttpGet("mine/calendar-subscription")]
|
||||
[Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)]
|
||||
public async Task<ActionResult> GetMyCalendarSubscription(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await CurrentUserAsync(cancellationToken);
|
||||
if (user is null) return Unauthorized();
|
||||
return Ok(BuildCalendarSubscriptionResponse(user));
|
||||
}
|
||||
|
||||
[HttpPost("mine/calendar-subscription")]
|
||||
[Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)]
|
||||
public async Task<ActionResult> EnableMyCalendarSubscription(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await CurrentUserAsync(cancellationToken);
|
||||
if (user is null) return Unauthorized();
|
||||
if (string.IsNullOrWhiteSpace(user.CalendarSubscriptionStamp))
|
||||
{
|
||||
user.CalendarSubscriptionStamp = PersonalCalendarService.CreateStamp();
|
||||
user.CalendarSubscriptionCreatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
return Ok(BuildCalendarSubscriptionResponse(user));
|
||||
}
|
||||
|
||||
[HttpPost("mine/calendar-subscription/rotate")]
|
||||
[Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)]
|
||||
public async Task<ActionResult> RotateMyCalendarSubscription(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await CurrentUserAsync(cancellationToken);
|
||||
if (user is null) return Unauthorized();
|
||||
user.CalendarSubscriptionStamp = PersonalCalendarService.CreateStamp();
|
||||
user.CalendarSubscriptionCreatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return Ok(BuildCalendarSubscriptionResponse(user));
|
||||
}
|
||||
|
||||
[HttpDelete("mine/calendar-subscription")]
|
||||
[Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)]
|
||||
public async Task<ActionResult> DisableMyCalendarSubscription(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await CurrentUserAsync(cancellationToken);
|
||||
if (user is null) return Unauthorized();
|
||||
user.CalendarSubscriptionStamp = null;
|
||||
user.CalendarSubscriptionCreatedAt = null;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpGet("mine/calendar.ics")]
|
||||
[Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)]
|
||||
public async Task<ActionResult> DownloadMyCalendar(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await CurrentUserAsync(cancellationToken);
|
||||
if (user is null) return Unauthorized();
|
||||
var result = await personalCalendarService.BuildAsync(user, cancellationToken);
|
||||
if (result is null) return CalendarProfileNotFound();
|
||||
return File(
|
||||
result.Content,
|
||||
"text/calendar; charset=utf-8",
|
||||
$"{SafeFileName(user.DisplayName)}-个人教学日历.ics");
|
||||
}
|
||||
|
||||
[HttpGet("calendar/{userId:guid}/{accessToken}.ics")]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult> GetCalendarFeed(
|
||||
Guid userId,
|
||||
string accessToken,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await db.Users.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
|
||||
if (user is null ||
|
||||
!personalCalendarService.IsAccessTokenValid(user, accessToken))
|
||||
return NotFound();
|
||||
|
||||
var result = await personalCalendarService.BuildAsync(user, cancellationToken);
|
||||
if (result is null) return NotFound();
|
||||
var etag = $"\"{Convert.ToHexString(SHA256.HashData(result.Content))}\"";
|
||||
if (Request.Headers.IfNoneMatch.ToString()
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Contains(etag, StringComparer.Ordinal))
|
||||
{
|
||||
return StatusCode(StatusCodes.Status304NotModified);
|
||||
}
|
||||
Response.Headers.CacheControl = "private, max-age=300";
|
||||
Response.Headers.ETag = etag;
|
||||
Response.Headers.Append("X-Content-Type-Options", "nosniff");
|
||||
return File(result.Content, "text/calendar; charset=utf-8");
|
||||
}
|
||||
|
||||
private async Task<ActionResult> BuildTimetableAsync(
|
||||
Guid classId,
|
||||
Guid? academicTermId,
|
||||
@@ -327,8 +424,57 @@ public sealed class TimetablesController(
|
||||
value = value.Replace(character, '-');
|
||||
return value.Trim();
|
||||
}
|
||||
|
||||
private async Task<ApplicationUser?> CurrentUserAsync(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!Guid.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out var userId))
|
||||
return null;
|
||||
return await db.Users.FirstOrDefaultAsync(
|
||||
x => x.Id == userId && x.IsEnabled,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private PersonalCalendarSubscriptionResponse BuildCalendarSubscriptionResponse(
|
||||
ApplicationUser user)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(user.CalendarSubscriptionStamp))
|
||||
return new(false, null, null, null, null);
|
||||
|
||||
var accessToken = personalCalendarService.CreateAccessToken(user);
|
||||
var feedPath =
|
||||
$"timetables/calendar/{user.Id:N}/{accessToken}.ics";
|
||||
var feedUrl =
|
||||
$"{Request.Scheme}://{Request.Host}{Request.PathBase}" +
|
||||
$"/api/{feedPath}";
|
||||
var webcalUrl = feedUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase)
|
||||
? $"webcal://{feedUrl[8..]}"
|
||||
: feedUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
|
||||
? $"webcal://{feedUrl[7..]}"
|
||||
: feedUrl;
|
||||
return new(
|
||||
true,
|
||||
feedUrl,
|
||||
feedPath,
|
||||
webcalUrl,
|
||||
user.CalendarSubscriptionCreatedAt);
|
||||
}
|
||||
|
||||
private ActionResult CalendarProfileNotFound() => Conflict(new ProblemDetails
|
||||
{
|
||||
Title = "档案未关联",
|
||||
Detail = "当前登录账号没有关联有效的教师或学生档案,请联系教务管理员。",
|
||||
Status = StatusCodes.Status409Conflict
|
||||
});
|
||||
}
|
||||
|
||||
public sealed record PersonalCalendarSubscriptionResponse(
|
||||
bool IsEnabled,
|
||||
string? FeedUrl,
|
||||
string? FeedPath,
|
||||
string? WebcalUrl,
|
||||
DateTime? CreatedAt);
|
||||
|
||||
public sealed record TimetableOptionsResponse(
|
||||
IReadOnlyList<TimetableTermOption> Terms,
|
||||
IReadOnlyList<TimetableCollegeOption> Colleges,
|
||||
|
||||
@@ -10,6 +10,8 @@ public sealed class ApplicationUser : IdentityUser<Guid>
|
||||
public bool IsEnabled { get; set; } = true;
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime? LastLoginAt { get; set; }
|
||||
public string? CalendarSubscriptionStamp { get; set; }
|
||||
public DateTime? CalendarSubscriptionCreatedAt { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ApplicationRole : IdentityRole<Guid>
|
||||
|
||||
@@ -119,6 +119,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
{
|
||||
entity.Property(x => x.DisplayName).HasMaxLength(50);
|
||||
entity.Property(x => x.StaffNumber).HasMaxLength(30);
|
||||
entity.Property(x => x.CalendarSubscriptionStamp).HasMaxLength(64);
|
||||
entity.HasIndex(x => x.StaffNumber);
|
||||
});
|
||||
|
||||
|
||||
@@ -50,6 +50,8 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
"20260726_27_academic_term_archiving";
|
||||
private const string CourseSelectionWaitlistMigration =
|
||||
"20260726_28_course_selection_waitlist";
|
||||
private const string PersonalCalendarSubscriptionMigration =
|
||||
"20260726_29_personal_calendar_subscription";
|
||||
|
||||
public async Task MigrateAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -359,6 +361,21 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
CourseSelectionWaitlistMigration,
|
||||
courseSelectionWaitlistExists ? [] : CourseSelectionWaitlistStatements,
|
||||
cancellationToken);
|
||||
|
||||
var personalCalendarSubscriptionExists = await db.Database
|
||||
.SqlQueryRaw<int>(
|
||||
"""
|
||||
SELECT COUNT(*) AS "Value"
|
||||
FROM pragma_table_info('AspNetUsers')
|
||||
WHERE name = 'CalendarSubscriptionStamp'
|
||||
""")
|
||||
.AnyAsync(value => value > 0, cancellationToken);
|
||||
await ApplyMigrationAsync(
|
||||
PersonalCalendarSubscriptionMigration,
|
||||
personalCalendarSubscriptionExists
|
||||
? []
|
||||
: PersonalCalendarSubscriptionStatements,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task ApplyMigrationAsync(
|
||||
@@ -1616,6 +1633,18 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
"""
|
||||
];
|
||||
|
||||
private static readonly string[] PersonalCalendarSubscriptionStatements =
|
||||
[
|
||||
"""
|
||||
ALTER TABLE "AspNetUsers"
|
||||
ADD COLUMN "CalendarSubscriptionStamp" TEXT NULL;
|
||||
""",
|
||||
"""
|
||||
ALTER TABLE "AspNetUsers"
|
||||
ADD COLUMN "CalendarSubscriptionCreatedAt" TEXT NULL;
|
||||
"""
|
||||
];
|
||||
|
||||
private static readonly string[] CourseAdjustmentsStatements =
|
||||
[
|
||||
"""
|
||||
|
||||
+4398
File diff suppressed because it is too large
Load Diff
+40
@@ -0,0 +1,40 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class PersonalCalendarSubscription : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "CalendarSubscriptionCreatedAt",
|
||||
table: "AspNetUsers",
|
||||
type: "datetime(6)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "CalendarSubscriptionStamp",
|
||||
table: "AspNetUsers",
|
||||
type: "varchar(64)",
|
||||
maxLength: 64,
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CalendarSubscriptionCreatedAt",
|
||||
table: "AspNetUsers");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CalendarSubscriptionStamp",
|
||||
table: "AspNetUsers");
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
@@ -3016,6 +3016,13 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.Property<int>("AccessFailedCount")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("CalendarSubscriptionCreatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<string>("CalendarSubscriptionStamp")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("varchar(64)");
|
||||
|
||||
b.Property<Guid?>("CollegeId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Timetables;
|
||||
|
||||
public sealed class PersonalCalendarService(
|
||||
AppDbContext db,
|
||||
TimetableDataService timetableDataService,
|
||||
IOptions<JwtOptions> jwtOptions)
|
||||
{
|
||||
private static readonly TimeSpan ChinaOffset = TimeSpan.FromHours(8);
|
||||
private readonly byte[] _signingKey = SHA256.HashData(
|
||||
Encoding.UTF8.GetBytes($"{jwtOptions.Value.Key}|personal-calendar"));
|
||||
|
||||
public static string CreateStamp() =>
|
||||
Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
|
||||
|
||||
public string CreateAccessToken(ApplicationUser user)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(user.CalendarSubscriptionStamp))
|
||||
throw new InvalidOperationException("个人日历订阅尚未启用。");
|
||||
|
||||
var payload = Encoding.UTF8.GetBytes(
|
||||
$"{user.Id:N}:{user.CalendarSubscriptionStamp}");
|
||||
using var hmac = new HMACSHA256(_signingKey);
|
||||
return WebEncoders.Base64UrlEncode(hmac.ComputeHash(payload));
|
||||
}
|
||||
|
||||
public bool IsAccessTokenValid(ApplicationUser user, string accessToken)
|
||||
{
|
||||
if (!user.IsEnabled || string.IsNullOrWhiteSpace(user.CalendarSubscriptionStamp))
|
||||
return false;
|
||||
|
||||
byte[] provided;
|
||||
try
|
||||
{
|
||||
provided = WebEncoders.Base64UrlDecode(accessToken);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var expected = WebEncoders.Base64UrlDecode(CreateAccessToken(user));
|
||||
return provided.Length == expected.Length &&
|
||||
CryptographicOperations.FixedTimeEquals(provided, expected);
|
||||
}
|
||||
|
||||
public async Task<PersonalCalendarResult?> BuildAsync(
|
||||
ApplicationUser user,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var student = await db.Students.AsNoTracking()
|
||||
.Where(x => x.UserId == user.Id && x.Status == StudentStatus.Active)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.AdministrativeClassId,
|
||||
x.StudentNumber,
|
||||
x.Name
|
||||
})
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
var teacher = student is null
|
||||
? await db.Teachers.AsNoTracking()
|
||||
.Where(x => x.UserId == user.Id && x.Status == TeacherStatus.Active)
|
||||
.Select(x => new { x.Id, x.Name })
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
: null;
|
||||
if (student is null && teacher is null) return null;
|
||||
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow.Add(ChinaOffset));
|
||||
var earliestDate = today.AddMonths(-2);
|
||||
var latestDate = today.AddYears(1);
|
||||
var termIds = await db.AcademicTerms.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.IsEnabled &&
|
||||
x.EndDate >= earliestDate &&
|
||||
x.StartDate <= latestDate)
|
||||
.OrderBy(x => x.StartDate)
|
||||
.Select(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var calendarName = $"明序教务 · {user.DisplayName}个人教学日历";
|
||||
var builder = new IcsBuilder(calendarName);
|
||||
foreach (var termId in termIds)
|
||||
{
|
||||
var timetable = student is not null
|
||||
? await timetableDataService.BuildAsync(
|
||||
TimetableResourceType.Class,
|
||||
student.AdministrativeClassId,
|
||||
termId,
|
||||
null,
|
||||
false,
|
||||
student.Id,
|
||||
new TimetableStudentDto(student.StudentNumber, student.Name),
|
||||
cancellationToken)
|
||||
: await timetableDataService.BuildAsync(
|
||||
TimetableResourceType.Teacher,
|
||||
teacher!.Id,
|
||||
termId,
|
||||
null,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
cancellationToken);
|
||||
if (timetable is null) continue;
|
||||
|
||||
AddScheduledCourses(builder, timetable);
|
||||
AddExamEntries(builder, timetable, teacher is not null);
|
||||
AddFlexibleCourseReminders(builder, timetable);
|
||||
}
|
||||
|
||||
var makeupSessions = await LoadMakeupSessionsAsync(
|
||||
student?.Id,
|
||||
teacher?.Id,
|
||||
earliestDate,
|
||||
latestDate,
|
||||
cancellationToken);
|
||||
foreach (var session in makeupSessions)
|
||||
{
|
||||
builder.AddTimedEvent(
|
||||
$"makeup-{session.Id:N}@jiaowu",
|
||||
teacher is null
|
||||
? $"[补考] {session.CourseName}"
|
||||
: $"[补考监考] {session.CourseName}",
|
||||
ToUtc(session.ExamDate, TimeOnly.FromDateTime(session.StartsAt)),
|
||||
ToUtc(session.ExamDate, TimeOnly.FromDateTime(session.EndsAt)),
|
||||
JoinLocation(
|
||||
session.CampusName,
|
||||
session.BuildingName,
|
||||
session.ClassroomName),
|
||||
JoinDescription(
|
||||
session.PlanName,
|
||||
session.TaskName,
|
||||
session.Notes),
|
||||
"补考",
|
||||
session.UpdatedAt);
|
||||
}
|
||||
|
||||
return new PersonalCalendarResult(
|
||||
builder.Build(),
|
||||
calendarName,
|
||||
builder.EventCount);
|
||||
}
|
||||
|
||||
private static void AddScheduledCourses(IcsBuilder builder, TimetableData timetable)
|
||||
{
|
||||
var slots = timetable.Slots.ToDictionary(x => x.PeriodNumber);
|
||||
var termMonday = StartOfWeek(timetable.Term.StartDate);
|
||||
foreach (var entry in timetable.Entries)
|
||||
{
|
||||
if (!slots.TryGetValue(entry.StartPeriod, out var startSlot) ||
|
||||
!slots.TryGetValue(
|
||||
entry.StartPeriod + entry.PeriodCount - 1,
|
||||
out var endSlot))
|
||||
continue;
|
||||
|
||||
for (var week = entry.StartWeek; week <= entry.EndWeek; week++)
|
||||
{
|
||||
if (!OccursInWeek(entry.WeekPattern, week)) continue;
|
||||
var date = termMonday.AddDays((week - 1) * 7 + entry.DayOfWeek - 1);
|
||||
if (date < timetable.Term.StartDate || date > timetable.Term.EndDate)
|
||||
continue;
|
||||
|
||||
builder.AddTimedEvent(
|
||||
$"course-{entry.Id:N}-{date:yyyyMMdd}@jiaowu",
|
||||
entry.CourseName,
|
||||
ToUtc(date, startSlot.StartsAt),
|
||||
ToUtc(date, endSlot.EndsAt),
|
||||
JoinLocation(
|
||||
entry.CampusName,
|
||||
entry.BuildingName,
|
||||
entry.ClassroomName),
|
||||
JoinDescription(
|
||||
entry.TaskName,
|
||||
$"教师:{string.Join('、', entry.TeacherNames)}",
|
||||
$"教学班:{string.Join('、', entry.ClassNames)}",
|
||||
entry.Notes),
|
||||
"课程",
|
||||
entry.UpdatedAt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddExamEntries(
|
||||
IcsBuilder builder,
|
||||
TimetableData timetable,
|
||||
bool isTeacher)
|
||||
{
|
||||
var slots = timetable.Slots.ToDictionary(x => x.PeriodNumber);
|
||||
foreach (var entry in timetable.ExamEntries)
|
||||
{
|
||||
if (entry.ExamDate is not DateOnly examDate ||
|
||||
!slots.TryGetValue(entry.StartPeriod, out var startSlot) ||
|
||||
!slots.TryGetValue(
|
||||
entry.StartPeriod + entry.PeriodCount - 1,
|
||||
out var endSlot))
|
||||
continue;
|
||||
|
||||
builder.AddTimedEvent(
|
||||
$"exam-{entry.Id:N}@jiaowu",
|
||||
isTeacher
|
||||
? $"[监考] {entry.CourseName}"
|
||||
: $"[考试] {entry.CourseName}",
|
||||
ToUtc(examDate, startSlot.StartsAt),
|
||||
ToUtc(examDate, endSlot.EndsAt),
|
||||
JoinLocation(
|
||||
entry.CampusName,
|
||||
entry.BuildingName,
|
||||
entry.ClassroomName),
|
||||
JoinDescription(
|
||||
entry.ExamPlanName,
|
||||
entry.TaskName,
|
||||
string.Join('、', entry.TeacherNames),
|
||||
entry.Notes),
|
||||
"考试",
|
||||
entry.UpdatedAt);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddFlexibleCourseReminders(
|
||||
IcsBuilder builder,
|
||||
TimetableData timetable)
|
||||
{
|
||||
foreach (var course in timetable.FlexibleCourses)
|
||||
{
|
||||
builder.AddAllDayEvent(
|
||||
$"flexible-{course.Id:N}-{timetable.Term.Id:N}@jiaowu",
|
||||
$"[灵活安排] {course.CourseName}",
|
||||
timetable.Term.StartDate,
|
||||
JoinDescription(
|
||||
$"{course.StartWeek}—{course.EndWeek} 周",
|
||||
$"每周 {course.WeeklyHours} 学时",
|
||||
$"教师:{string.Join('、', course.TeacherNames)}",
|
||||
course.Notes),
|
||||
"灵活课程",
|
||||
course.UpdatedAt);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<List<PersonalMakeupSession>> LoadMakeupSessionsAsync(
|
||||
Guid? studentId,
|
||||
Guid? teacherId,
|
||||
DateOnly earliestDate,
|
||||
DateOnly latestDate,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!studentId.HasValue && !teacherId.HasValue) return [];
|
||||
var source = db.MakeupExamSessions.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.MakeupExamPlan!.Status == MakeupExamPlanStatus.Published &&
|
||||
x.ExamDate >= earliestDate &&
|
||||
x.ExamDate <= latestDate);
|
||||
source = studentId.HasValue
|
||||
? source.Where(x => x.Enrollments.Any(e => e.StudentId == studentId.Value))
|
||||
: source.Where(x => x.Invigilators.Any(i => i.TeacherId == teacherId!.Value));
|
||||
|
||||
return await source
|
||||
.OrderBy(x => x.ExamDate)
|
||||
.ThenBy(x => x.StartsAt)
|
||||
.Select(x => new PersonalMakeupSession(
|
||||
x.Id,
|
||||
x.MakeupExamPlan!.Name,
|
||||
x.TeachingTask!.Name,
|
||||
x.TeachingTask.Course!.Name,
|
||||
x.ExamDate,
|
||||
x.StartsAt,
|
||||
x.EndsAt,
|
||||
x.Classroom == null ? null : x.Classroom.Name,
|
||||
x.Classroom == null ? null : x.Classroom.Building!.Name,
|
||||
x.Classroom == null ? null : x.Classroom.Building!.Campus!.Name,
|
||||
x.Notes,
|
||||
x.UpdatedAt))
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static DateOnly StartOfWeek(DateOnly date)
|
||||
{
|
||||
var day = date.DayOfWeek == DayOfWeek.Sunday ? 7 : (int)date.DayOfWeek;
|
||||
return date.AddDays(1 - day);
|
||||
}
|
||||
|
||||
private static bool OccursInWeek(WeekPattern pattern, int week) => pattern switch
|
||||
{
|
||||
WeekPattern.Odd => week % 2 == 1,
|
||||
WeekPattern.Even => week % 2 == 0,
|
||||
_ => true
|
||||
};
|
||||
|
||||
private static DateTime ToUtc(DateOnly date, TimeOnly time)
|
||||
{
|
||||
var local = date.ToDateTime(time, DateTimeKind.Unspecified);
|
||||
return new DateTimeOffset(local, ChinaOffset).UtcDateTime;
|
||||
}
|
||||
|
||||
private static string JoinLocation(params string?[] parts) =>
|
||||
string.Join(" · ", parts.Where(x => !string.IsNullOrWhiteSpace(x)));
|
||||
|
||||
private static string JoinDescription(params string?[] parts) =>
|
||||
string.Join("\n", parts.Where(x => !string.IsNullOrWhiteSpace(x)));
|
||||
|
||||
private sealed record PersonalMakeupSession(
|
||||
Guid Id,
|
||||
string PlanName,
|
||||
string TaskName,
|
||||
string CourseName,
|
||||
DateOnly ExamDate,
|
||||
DateTime StartsAt,
|
||||
DateTime EndsAt,
|
||||
string? ClassroomName,
|
||||
string? BuildingName,
|
||||
string? CampusName,
|
||||
string? Notes,
|
||||
DateTime UpdatedAt);
|
||||
}
|
||||
|
||||
public sealed record PersonalCalendarResult(
|
||||
byte[] Content,
|
||||
string Name,
|
||||
int EventCount);
|
||||
|
||||
internal sealed class IcsBuilder
|
||||
{
|
||||
private static readonly UTF8Encoding Utf8 = new(false);
|
||||
private readonly StringBuilder _content = new();
|
||||
private bool _built;
|
||||
|
||||
public IcsBuilder(string name)
|
||||
{
|
||||
AddLine("BEGIN:VCALENDAR");
|
||||
AddLine("VERSION:2.0");
|
||||
AddLine("PRODID:-//Mingxu Jiaowu//Personal Teaching Calendar//ZH-CN");
|
||||
AddLine("CALSCALE:GREGORIAN");
|
||||
AddLine("METHOD:PUBLISH");
|
||||
AddLine($"X-WR-CALNAME:{Escape(name)}");
|
||||
AddLine("X-WR-TIMEZONE:Asia/Shanghai");
|
||||
AddLine("REFRESH-INTERVAL;VALUE=DURATION:PT1H");
|
||||
AddLine("X-PUBLISHED-TTL:PT1H");
|
||||
}
|
||||
|
||||
public int EventCount { get; private set; }
|
||||
|
||||
public void AddTimedEvent(
|
||||
string uid,
|
||||
string summary,
|
||||
DateTime startsAtUtc,
|
||||
DateTime endsAtUtc,
|
||||
string location,
|
||||
string description,
|
||||
string category,
|
||||
DateTime updatedAt)
|
||||
{
|
||||
AddLine("BEGIN:VEVENT");
|
||||
AddLine($"UID:{uid}");
|
||||
AddLine($"DTSTAMP:{FormatUtc(NormalizeUpdatedAt(updatedAt))}");
|
||||
AddLine($"LAST-MODIFIED:{FormatUtc(NormalizeUpdatedAt(updatedAt))}");
|
||||
AddLine($"DTSTART:{FormatUtc(startsAtUtc)}");
|
||||
AddLine($"DTEND:{FormatUtc(endsAtUtc)}");
|
||||
AddLine($"SUMMARY:{Escape(summary)}");
|
||||
if (!string.IsNullOrWhiteSpace(location))
|
||||
AddLine($"LOCATION:{Escape(location)}");
|
||||
if (!string.IsNullOrWhiteSpace(description))
|
||||
AddLine($"DESCRIPTION:{Escape(description)}");
|
||||
AddLine($"CATEGORIES:{Escape(category)}");
|
||||
AddLine("STATUS:CONFIRMED");
|
||||
AddLine("END:VEVENT");
|
||||
EventCount++;
|
||||
}
|
||||
|
||||
public void AddAllDayEvent(
|
||||
string uid,
|
||||
string summary,
|
||||
DateOnly date,
|
||||
string description,
|
||||
string category,
|
||||
DateTime updatedAt)
|
||||
{
|
||||
AddLine("BEGIN:VEVENT");
|
||||
AddLine($"UID:{uid}");
|
||||
AddLine($"DTSTAMP:{FormatUtc(NormalizeUpdatedAt(updatedAt))}");
|
||||
AddLine($"LAST-MODIFIED:{FormatUtc(NormalizeUpdatedAt(updatedAt))}");
|
||||
AddLine($"DTSTART;VALUE=DATE:{date:yyyyMMdd}");
|
||||
AddLine($"DTEND;VALUE=DATE:{date.AddDays(1):yyyyMMdd}");
|
||||
AddLine($"SUMMARY:{Escape(summary)}");
|
||||
if (!string.IsNullOrWhiteSpace(description))
|
||||
AddLine($"DESCRIPTION:{Escape(description)}");
|
||||
AddLine($"CATEGORIES:{Escape(category)}");
|
||||
AddLine("STATUS:CONFIRMED");
|
||||
AddLine("TRANSP:TRANSPARENT");
|
||||
AddLine("END:VEVENT");
|
||||
EventCount++;
|
||||
}
|
||||
|
||||
public byte[] Build()
|
||||
{
|
||||
if (!_built)
|
||||
{
|
||||
AddLine("END:VCALENDAR");
|
||||
_built = true;
|
||||
}
|
||||
return Utf8.GetBytes(_content.ToString());
|
||||
}
|
||||
|
||||
private void AddLine(string line)
|
||||
{
|
||||
var current = new StringBuilder();
|
||||
var byteCount = 0;
|
||||
foreach (var rune in line.EnumerateRunes())
|
||||
{
|
||||
var value = rune.ToString();
|
||||
var runeBytes = Utf8.GetByteCount(value);
|
||||
if (byteCount > 0 && byteCount + runeBytes > 74)
|
||||
{
|
||||
_content.Append(current).Append("\r\n ");
|
||||
current.Clear();
|
||||
byteCount = 1;
|
||||
}
|
||||
current.Append(value);
|
||||
byteCount += runeBytes;
|
||||
}
|
||||
_content.Append(current).Append("\r\n");
|
||||
}
|
||||
|
||||
private static string Escape(string value) => value
|
||||
.Replace("\\", "\\\\", StringComparison.Ordinal)
|
||||
.Replace("\r\n", "\\n", StringComparison.Ordinal)
|
||||
.Replace("\n", "\\n", StringComparison.Ordinal)
|
||||
.Replace(";", "\\;", StringComparison.Ordinal)
|
||||
.Replace(",", "\\,", StringComparison.Ordinal);
|
||||
|
||||
private static string FormatUtc(DateTime value) =>
|
||||
value.ToUniversalTime().ToString(
|
||||
"yyyyMMdd'T'HHmmss'Z'",
|
||||
CultureInfo.InvariantCulture);
|
||||
|
||||
private static DateTime NormalizeUpdatedAt(DateTime value) =>
|
||||
value == default
|
||||
? new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc)
|
||||
: value.Kind == DateTimeKind.Unspecified
|
||||
? DateTime.SpecifyKind(value, DateTimeKind.Utc)
|
||||
: value.ToUniversalTime();
|
||||
}
|
||||
@@ -99,7 +99,11 @@ public sealed class TimetableDataService(AppDbContext db)
|
||||
x.StartWeek,
|
||||
x.EndWeek,
|
||||
x.WeekPattern,
|
||||
x.Notes))
|
||||
x.Notes,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
x.UpdatedAt))
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
@@ -290,7 +294,8 @@ public sealed class TimetableDataService(AppDbContext db)
|
||||
.OrderByDescending(item => item.IsPrimary)
|
||||
.Select(item => item.Teacher!.Name),
|
||||
x.Classes.Select(item => item.AdministrativeClass!.Name),
|
||||
x.Notes))
|
||||
x.Notes,
|
||||
x.UpdatedAt))
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
@@ -357,7 +362,8 @@ public sealed class TimetableDataService(AppDbContext db)
|
||||
x.ExamPlan!.Name,
|
||||
InvigilatorNames = x.Invigilators
|
||||
.Select(i => i.Teacher!.Name),
|
||||
x.Notes
|
||||
x.Notes,
|
||||
x.UpdatedAt
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
@@ -385,7 +391,8 @@ public sealed class TimetableDataService(AppDbContext db)
|
||||
x.Notes,
|
||||
true,
|
||||
x.Name,
|
||||
x.ExamDate);
|
||||
x.ExamDate,
|
||||
x.UpdatedAt);
|
||||
}).ToList();
|
||||
}
|
||||
}
|
||||
@@ -468,7 +475,8 @@ public sealed record TimetableEntryDto(
|
||||
string? Notes,
|
||||
bool IsExam = false,
|
||||
string? ExamPlanName = null,
|
||||
DateOnly? ExamDate = null);
|
||||
DateOnly? ExamDate = null,
|
||||
DateTime UpdatedAt = default);
|
||||
|
||||
public sealed record FlexibleCourseDto(
|
||||
Guid Id,
|
||||
@@ -483,4 +491,5 @@ public sealed record FlexibleCourseDto(
|
||||
int WeeklyHours,
|
||||
IEnumerable<string> TeacherNames,
|
||||
IEnumerable<string> ClassNames,
|
||||
string? Notes);
|
||||
string? Notes,
|
||||
DateTime UpdatedAt = default);
|
||||
|
||||
@@ -200,6 +200,7 @@ builder.Services.AddScoped<DemoDataSeeder>();
|
||||
builder.Services.AddScoped<DevelopmentSqliteMigrator>();
|
||||
builder.Services.AddScoped<TimetableDataService>();
|
||||
builder.Services.AddScoped<AutomaticScheduleGenerator>();
|
||||
builder.Services.AddScoped<PersonalCalendarService>();
|
||||
builder.Services.AddScoped<AutomaticScheduleJobProcessor>();
|
||||
builder.Services.AddSingleton<AutomaticScheduleJobQueue>();
|
||||
builder.Services.AddHostedService<AutomaticScheduleJobWorker>();
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("Jiaowu.Api.Tests")]
|
||||
@@ -9,7 +9,7 @@ namespace Jiaowu.Api.Tests;
|
||||
public sealed class MySqlMigrationTests
|
||||
{
|
||||
private const string LatestMigration =
|
||||
"20260726180000_CourseSelectionGradeLimits";
|
||||
"20260726190000_PersonalCalendarSubscription";
|
||||
|
||||
[Fact]
|
||||
public void Production_migration_is_discoverable_and_generates_mysql_sql()
|
||||
@@ -46,6 +46,12 @@ public sealed class MySqlMigrationTests
|
||||
Assert.Contains(
|
||||
"CREATE UNIQUE INDEX `IX_CourseSelectionRoundGrades_CourseSelectionRoundId_Grade`",
|
||||
script);
|
||||
Assert.Contains(
|
||||
"ADD `CalendarSubscriptionStamp` varchar(64) NULL",
|
||||
script);
|
||||
Assert.Contains(
|
||||
"ADD `CalendarSubscriptionCreatedAt` datetime(6) NULL",
|
||||
script);
|
||||
Assert.Contains("SET `ExamDate` = DATE(`StartsAt`)", script);
|
||||
Assert.Contains("DEFAULT 1", script);
|
||||
Assert.DoesNotContain("0001-01-01", script);
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
using System.Text;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Jiaowu.Api.Infrastructure.Timetables;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Jiaowu.Api.Tests;
|
||||
|
||||
public sealed class PersonalCalendarServiceTests : IAsyncLifetime
|
||||
{
|
||||
private readonly SqliteConnection _connection = new("Data Source=:memory:");
|
||||
private AppDbContext _db = null!;
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await _connection.OpenAsync();
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseSqlite(_connection)
|
||||
.Options;
|
||||
_db = new AppDbContext(options);
|
||||
await _db.Database.EnsureCreatedAsync();
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
await _db.DisposeAsync();
|
||||
await _connection.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Build_creates_live_teacher_feed_with_courses_and_exams()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var mondayOffset = today.DayOfWeek == DayOfWeek.Sunday
|
||||
? 6
|
||||
: (int)today.DayOfWeek - 1;
|
||||
var termStart = today.AddDays(-mondayOffset);
|
||||
var campus = new Campus { Code = "MAIN", Name = "主校区" };
|
||||
var college = new College
|
||||
{
|
||||
Code = "CS",
|
||||
Name = "计算机学院",
|
||||
CampusId = campus.Id
|
||||
};
|
||||
var user = new ApplicationUser
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserName = "calendar-teacher",
|
||||
NormalizedUserName = "CALENDAR-TEACHER",
|
||||
DisplayName = "陈老师",
|
||||
CalendarSubscriptionStamp = PersonalCalendarService.CreateStamp(),
|
||||
CalendarSubscriptionCreatedAt = DateTime.UtcNow
|
||||
};
|
||||
var teacher = new Teacher
|
||||
{
|
||||
TeacherNumber = "T-CALENDAR",
|
||||
Name = "陈老师",
|
||||
CollegeId = college.Id,
|
||||
UserId = user.Id,
|
||||
Status = TeacherStatus.Active
|
||||
};
|
||||
var course = new Course
|
||||
{
|
||||
Code = "CS101-CALENDAR",
|
||||
Name = "程序设计基础",
|
||||
CollegeId = college.Id,
|
||||
Credits = 4,
|
||||
TotalHours = 64,
|
||||
LectureHours = 48,
|
||||
PracticeHours = 16,
|
||||
Nature = CourseNature.MajorRequired,
|
||||
AssessmentMethod = AssessmentMethod.Examination
|
||||
};
|
||||
var term = new AcademicTerm
|
||||
{
|
||||
Code = "CALENDAR-TERM",
|
||||
Name = "个人日历测试学期",
|
||||
AcademicYear = "2026-2027",
|
||||
Season = TermSeason.Autumn,
|
||||
StartDate = termStart,
|
||||
EndDate = termStart.AddDays(40),
|
||||
IsCurrent = true
|
||||
};
|
||||
var building = new Building
|
||||
{
|
||||
Code = "J-CALENDAR",
|
||||
Name = "第一教学楼",
|
||||
CampusId = campus.Id
|
||||
};
|
||||
var classroom = new Classroom
|
||||
{
|
||||
Code = "J-CALENDAR-101",
|
||||
Name = "101",
|
||||
BuildingId = building.Id,
|
||||
Capacity = 60
|
||||
};
|
||||
_db.AddRange(
|
||||
campus,
|
||||
college,
|
||||
user,
|
||||
teacher,
|
||||
course,
|
||||
term,
|
||||
building,
|
||||
classroom);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var scheduledTask = new TeachingTask
|
||||
{
|
||||
TaskNumber = "CALENDAR-TASK-01",
|
||||
Name = "程序设计基础教学班",
|
||||
AcademicTermId = term.Id,
|
||||
CourseId = course.Id,
|
||||
Capacity = 60,
|
||||
Status = TeachingTaskStatus.Published,
|
||||
Teachers =
|
||||
[
|
||||
new TeachingTaskTeacher { TeacherId = teacher.Id, IsPrimary = true }
|
||||
]
|
||||
};
|
||||
var flexibleTask = new TeachingTask
|
||||
{
|
||||
TaskNumber = "CALENDAR-TASK-02",
|
||||
Name = "程序设计实践",
|
||||
AcademicTermId = term.Id,
|
||||
CourseId = course.Id,
|
||||
Capacity = 60,
|
||||
Status = TeachingTaskStatus.Published,
|
||||
SchedulingMode = TeachingTaskSchedulingMode.Flexible,
|
||||
StartWeek = 1,
|
||||
EndWeek = 4,
|
||||
WeeklyHours = 2,
|
||||
Teachers =
|
||||
[
|
||||
new TeachingTaskTeacher { TeacherId = teacher.Id, IsPrimary = true }
|
||||
]
|
||||
};
|
||||
var plan = new SchedulePlan
|
||||
{
|
||||
AcademicTermId = term.Id,
|
||||
Name = "正式课表",
|
||||
Version = "V1",
|
||||
Status = SchedulePlanStatus.Published,
|
||||
PublishedAt = DateTime.UtcNow
|
||||
};
|
||||
_db.AddRange(
|
||||
scheduledTask,
|
||||
flexibleTask,
|
||||
plan,
|
||||
new ScheduleTimeSlot
|
||||
{
|
||||
AcademicTermId = term.Id,
|
||||
PeriodNumber = 1,
|
||||
Name = "第一节",
|
||||
StartsAt = new TimeOnly(8, 0),
|
||||
EndsAt = new TimeOnly(8, 45)
|
||||
},
|
||||
new ScheduleTimeSlot
|
||||
{
|
||||
AcademicTermId = term.Id,
|
||||
PeriodNumber = 2,
|
||||
Name = "第二节",
|
||||
StartsAt = new TimeOnly(8, 55),
|
||||
EndsAt = new TimeOnly(9, 40)
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
_db.ScheduleEntries.Add(new ScheduleEntry
|
||||
{
|
||||
SchedulePlanId = plan.Id,
|
||||
TeachingTaskId = scheduledTask.Id,
|
||||
ClassroomId = classroom.Id,
|
||||
DayOfWeek = 1,
|
||||
StartPeriod = 1,
|
||||
PeriodCount = 2,
|
||||
StartWeek = 1,
|
||||
EndWeek = 2,
|
||||
WeekPattern = WeekPattern.All
|
||||
});
|
||||
var examDate = termStart.AddDays(5);
|
||||
var examPlan = new ExamPlan
|
||||
{
|
||||
AcademicTermId = term.Id,
|
||||
Name = "期末考试",
|
||||
Status = ExamPlanStatus.Published,
|
||||
PublishedAt = DateTime.UtcNow
|
||||
};
|
||||
var exam = new ExamSession
|
||||
{
|
||||
ExamPlan = examPlan,
|
||||
TeachingTaskId = scheduledTask.Id,
|
||||
ClassroomId = classroom.Id,
|
||||
ExamDate = examDate,
|
||||
StartPeriod = 1,
|
||||
PeriodCount = 2,
|
||||
StartsAt = examDate.ToDateTime(new TimeOnly(8, 0)),
|
||||
EndsAt = examDate.ToDateTime(new TimeOnly(9, 40)),
|
||||
Invigilators =
|
||||
[
|
||||
new ExamSessionInvigilator { TeacherId = teacher.Id }
|
||||
]
|
||||
};
|
||||
var makeupPlan = new MakeupExamPlan
|
||||
{
|
||||
AcademicTermId = term.Id,
|
||||
Name = "补考安排",
|
||||
Status = MakeupExamPlanStatus.Published,
|
||||
PublishedAt = DateTime.UtcNow
|
||||
};
|
||||
var makeupDate = termStart.AddDays(12);
|
||||
var makeup = new MakeupExamSession
|
||||
{
|
||||
MakeupExamPlan = makeupPlan,
|
||||
TeachingTaskId = scheduledTask.Id,
|
||||
ClassroomId = classroom.Id,
|
||||
ExamDate = makeupDate,
|
||||
StartPeriod = 1,
|
||||
PeriodCount = 2,
|
||||
StartsAt = makeupDate.ToDateTime(new TimeOnly(8, 0)),
|
||||
EndsAt = makeupDate.ToDateTime(new TimeOnly(9, 40)),
|
||||
Invigilators =
|
||||
[
|
||||
new MakeupExamSessionInvigilator { TeacherId = teacher.Id }
|
||||
]
|
||||
};
|
||||
_db.AddRange(exam, makeup);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var service = CreateService();
|
||||
var result = await service.BuildAsync(user, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(5, result.EventCount);
|
||||
var ics = Encoding.UTF8.GetString(result.Content);
|
||||
Assert.Contains("REFRESH-INTERVAL;VALUE=DURATION:PT1H", ics);
|
||||
Assert.Contains("SUMMARY:程序设计基础", ics);
|
||||
Assert.Contains("SUMMARY:[监考] 程序设计基础", ics);
|
||||
Assert.Contains("SUMMARY:[补考监考] 程序设计基础", ics);
|
||||
Assert.Contains("SUMMARY:[灵活安排] 程序设计基础", ics);
|
||||
Assert.Contains(
|
||||
$"DTSTART:{termStart:yyyyMMdd}T000000Z",
|
||||
ics);
|
||||
Assert.All(
|
||||
ics.Split("\r\n", StringSplitOptions.RemoveEmptyEntries),
|
||||
line => Assert.True(
|
||||
Encoding.UTF8.GetByteCount(line) <= 75,
|
||||
$"ICS line exceeds 75 octets: {line}"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Access_token_is_bound_to_user_stamp_and_can_be_rotated()
|
||||
{
|
||||
var user = new ApplicationUser
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserName = "calendar-user",
|
||||
DisplayName = "订阅用户",
|
||||
CalendarSubscriptionStamp = PersonalCalendarService.CreateStamp()
|
||||
};
|
||||
var service = CreateService();
|
||||
var originalToken = service.CreateAccessToken(user);
|
||||
|
||||
Assert.True(service.IsAccessTokenValid(user, originalToken));
|
||||
Assert.False(service.IsAccessTokenValid(user, originalToken + "x"));
|
||||
|
||||
user.CalendarSubscriptionStamp = PersonalCalendarService.CreateStamp();
|
||||
Assert.False(service.IsAccessTokenValid(user, originalToken));
|
||||
Assert.True(service.IsAccessTokenValid(user, service.CreateAccessToken(user)));
|
||||
|
||||
user.CalendarSubscriptionStamp = null;
|
||||
Assert.False(service.IsAccessTokenValid(user, originalToken));
|
||||
}
|
||||
|
||||
private PersonalCalendarService CreateService() => new(
|
||||
_db,
|
||||
new TimetableDataService(_db),
|
||||
Options.Create(new JwtOptions
|
||||
{
|
||||
Key = "calendar-test-signing-key-with-at-least-thirty-two-characters"
|
||||
}));
|
||||
}
|
||||
@@ -1,6 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { ArrowLeft, ArrowRight, Document, Download } from '@element-plus/icons-vue'
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Calendar,
|
||||
CopyDocument,
|
||||
Document,
|
||||
Download,
|
||||
RefreshRight,
|
||||
SwitchButton,
|
||||
} from '@element-plus/icons-vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import http, { apiErrorMessage } from '../api/http'
|
||||
import { downloadApiFile } from '../api/excel'
|
||||
@@ -23,6 +32,10 @@ const isManager = computed(() =>
|
||||
)
|
||||
const loading = ref(false)
|
||||
const exportingPdf = ref(false)
|
||||
const calendarDialogVisible = ref(false)
|
||||
const calendarLoading = ref(false)
|
||||
const calendarActionLoading = ref(false)
|
||||
const calendarSubscription = ref<any | null>(null)
|
||||
const terms = ref<any[]>([])
|
||||
const colleges = ref<any[]>([])
|
||||
const majors = ref<any[]>([])
|
||||
@@ -494,6 +507,132 @@ async function exportPdf() {
|
||||
}
|
||||
}
|
||||
|
||||
async function openCalendarSubscription() {
|
||||
calendarDialogVisible.value = true
|
||||
calendarLoading.value = true
|
||||
try {
|
||||
calendarSubscription.value = normalizeCalendarSubscription((
|
||||
await http.get('/timetables/mine/calendar-subscription')
|
||||
).data)
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
} finally {
|
||||
calendarLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function enableCalendarSubscription() {
|
||||
calendarActionLoading.value = true
|
||||
try {
|
||||
calendarSubscription.value = normalizeCalendarSubscription((
|
||||
await http.post('/timetables/mine/calendar-subscription')
|
||||
).data)
|
||||
ElMessage.success('个人教学日历订阅已启用')
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
} finally {
|
||||
calendarActionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function rotateCalendarSubscription() {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'重置后,已添加到日历客户端的旧地址会立即失效,需要使用新地址重新订阅。',
|
||||
'重置订阅地址',
|
||||
{ type: 'warning', confirmButtonText: '确认重置', cancelButtonText: '取消' },
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
calendarActionLoading.value = true
|
||||
try {
|
||||
calendarSubscription.value = normalizeCalendarSubscription((
|
||||
await http.post('/timetables/mine/calendar-subscription/rotate')
|
||||
).data)
|
||||
ElMessage.success('订阅地址已重置')
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
} finally {
|
||||
calendarActionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function disableCalendarSubscription() {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'停用后,所有日历客户端都无法再通过当前地址获取教学安排。',
|
||||
'停用日历订阅',
|
||||
{ type: 'warning', confirmButtonText: '确认停用', cancelButtonText: '取消' },
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
calendarActionLoading.value = true
|
||||
try {
|
||||
await http.delete('/timetables/mine/calendar-subscription')
|
||||
calendarSubscription.value = { isEnabled: false }
|
||||
ElMessage.success('个人教学日历订阅已停用')
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
} finally {
|
||||
calendarActionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyCalendarUrl() {
|
||||
const value = calendarSubscription.value?.feedUrl
|
||||
if (!value) return
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(value)
|
||||
} else {
|
||||
const input = document.createElement('textarea')
|
||||
input.value = value
|
||||
input.style.position = 'fixed'
|
||||
input.style.opacity = '0'
|
||||
document.body.appendChild(input)
|
||||
input.select()
|
||||
document.execCommand('copy')
|
||||
input.remove()
|
||||
}
|
||||
ElMessage.success('订阅地址已复制')
|
||||
} catch {
|
||||
ElMessage.error('复制失败,请手动选择订阅地址。')
|
||||
}
|
||||
}
|
||||
|
||||
function openCalendarClient() {
|
||||
const value = calendarSubscription.value?.webcalUrl
|
||||
if (value) window.location.href = value
|
||||
}
|
||||
|
||||
function normalizeCalendarSubscription(value: any) {
|
||||
if (!value?.feedPath) return value
|
||||
const configuredBase = String(http.defaults.baseURL ?? '/api')
|
||||
const apiBase = new URL(
|
||||
configuredBase.endsWith('/') ? configuredBase : `${configuredBase}/`,
|
||||
window.location.origin,
|
||||
)
|
||||
const feedUrl = new URL(value.feedPath, apiBase).toString()
|
||||
return {
|
||||
...value,
|
||||
feedUrl,
|
||||
webcalUrl: feedUrl.replace(/^https?:\/\//i, 'webcal://'),
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadCalendarSnapshot() {
|
||||
try {
|
||||
await downloadApiFile(
|
||||
'/timetables/mine/calendar.ics',
|
||||
'个人教学日历.ics',
|
||||
)
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
watch([classId, teacherId, classroomId, planId, resourceType], loadTimetable)
|
||||
watch(teacherIdParam, (newId) => { if (newId) loadTimetable() })
|
||||
|
||||
@@ -669,6 +808,9 @@ onMounted(async () => {
|
||||
</el-select>
|
||||
</div>
|
||||
<div>
|
||||
<el-button v-if="isMine" :icon="Calendar" @click="openCalendarSubscription">
|
||||
订阅日历
|
||||
</el-button>
|
||||
<el-button :icon="Download" @click="exportExcel">导出 Excel</el-button>
|
||||
<el-button :icon="Document" :loading="exportingPdf" @click="exportPdf">导出 PDF</el-button>
|
||||
</div>
|
||||
@@ -820,6 +962,88 @@ onMounted(async () => {
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-dialog
|
||||
v-model="calendarDialogVisible"
|
||||
title="个人教学日历订阅"
|
||||
width="640px"
|
||||
class="calendar-subscription-dialog"
|
||||
>
|
||||
<div v-loading="calendarLoading" class="calendar-subscription">
|
||||
<template v-if="calendarSubscription?.isEnabled">
|
||||
<div class="calendar-status active">
|
||||
<span>LIVE FEED</span>
|
||||
<div>
|
||||
<strong>订阅已启用</strong>
|
||||
<small>课表和考试安排变更后,日历客户端会在下次同步时自动更新。</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="calendar-url-field">
|
||||
<label>订阅地址</label>
|
||||
<el-input :model-value="calendarSubscription.feedUrl" readonly>
|
||||
<template #append>
|
||||
<el-button :icon="CopyDocument" aria-label="复制订阅地址" @click="copyCalendarUrl" />
|
||||
</template>
|
||||
</el-input>
|
||||
<small>此地址等同于访问密钥,请勿分享到公开群聊或网页。</small>
|
||||
</div>
|
||||
|
||||
<div class="calendar-actions">
|
||||
<el-button type="primary" :icon="Calendar" @click="openCalendarClient">
|
||||
在日历客户端中订阅
|
||||
</el-button>
|
||||
<el-button :icon="Download" @click="downloadCalendarSnapshot">
|
||||
下载一次性 ICS
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="calendar-security-actions">
|
||||
<el-button
|
||||
:icon="RefreshRight"
|
||||
:loading="calendarActionLoading"
|
||||
@click="rotateCalendarSubscription"
|
||||
>重置地址</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
plain
|
||||
:icon="SwitchButton"
|
||||
:loading="calendarActionLoading"
|
||||
@click="disableCalendarSubscription"
|
||||
>停用订阅</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="calendarSubscription">
|
||||
<div class="calendar-status">
|
||||
<span>PRIVATE CALENDAR</span>
|
||||
<div>
|
||||
<strong>将教学安排同步到常用日历</strong>
|
||||
<small>启用后会生成仅属于你的长期订阅地址,可随时重置或停用。</small>
|
||||
</div>
|
||||
</div>
|
||||
<el-button
|
||||
type="primary"
|
||||
:icon="Calendar"
|
||||
:loading="calendarActionLoading"
|
||||
@click="enableCalendarSubscription"
|
||||
>启用个人日历订阅</el-button>
|
||||
<el-button :icon="Download" @click="downloadCalendarSnapshot">
|
||||
仅下载一次性 ICS
|
||||
</el-button>
|
||||
</template>
|
||||
|
||||
<div class="calendar-coverage">
|
||||
<strong>自动同步范围</strong>
|
||||
<ul>
|
||||
<li>已发布课表中的固定课程和调停课后的最新安排</li>
|
||||
<li>学生考试、补考;教师授课、监考和补考监考</li>
|
||||
<li>非排时课程会在学期首日生成全天提醒</li>
|
||||
</ul>
|
||||
<p>苹果日历可直接点击订阅;Google 日历、Outlook 可复制地址后选择“通过 URL 添加”。服务需具备外网可访问的 HTTPS 地址才能跨设备同步。</p>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -845,6 +1069,23 @@ onMounted(async () => {
|
||||
.timetable-toolbar .el-select { width: 120px; }
|
||||
.timetable-toolbar .week-switcher { display: flex; align-items: center; gap: 6px; padding-left: 2px; }
|
||||
.timetable-toolbar .week-switcher .el-select { width: 245px; }
|
||||
.calendar-subscription { min-height: 180px; display: grid; gap: 18px; }
|
||||
:global(.calendar-subscription-dialog) { max-width: calc(100vw - 24px); }
|
||||
.calendar-status { padding: 17px 18px; display: grid; grid-template-columns: 105px 1fr; gap: 18px; border: 1px solid #dce4eb; border-left: 4px solid #7890a4; background: #f8fafb; }
|
||||
.calendar-status.active { border-left-color: #27806e; background: #f3faf7; }
|
||||
.calendar-status > span { color: #637d92; font: 700 10px/1.4 Consolas, monospace; letter-spacing: .08em; }
|
||||
.calendar-status.active > span { color: #277866; }
|
||||
.calendar-status > div { display: grid; gap: 5px; }
|
||||
.calendar-status strong { color: #17324d; }
|
||||
.calendar-status small { color: #637587; line-height: 1.6; }
|
||||
.calendar-url-field { display: grid; gap: 8px; }
|
||||
.calendar-url-field label, .calendar-coverage > strong { color: #314c62; font-size: 13px; font-weight: 700; }
|
||||
.calendar-url-field small { color: #a36922; }
|
||||
.calendar-actions, .calendar-security-actions { display: flex; flex-wrap: wrap; gap: 10px; }
|
||||
.calendar-security-actions { padding-top: 16px; border-top: 1px solid #e2e8ed; }
|
||||
.calendar-coverage { padding: 16px 18px; border: 1px solid #dce4eb; background: #f8fafb; }
|
||||
.calendar-coverage ul { margin: 10px 0; padding-left: 20px; color: #526b7d; line-height: 1.8; font-size: 13px; }
|
||||
.calendar-coverage p { margin: 0; color: #788895; font-size: 12px; line-height: 1.7; }
|
||||
.timetable-export-area { min-width: 0; padding: 2px; background: #fff; }
|
||||
.sheet-meta { display: flex; justify-content: space-between; gap: 24px; margin-bottom: 18px; padding-bottom: 16px; border-bottom: 1px solid #e6ebf0; }
|
||||
.sheet-meta div { display: grid; gap: 3px; }
|
||||
@@ -919,6 +1160,10 @@ onMounted(async () => {
|
||||
.timetable-toolbar > div { flex-wrap: wrap; }
|
||||
.timetable-toolbar .week-switcher { width: 100%; padding-left: 0; }
|
||||
.timetable-toolbar .week-switcher .el-select { min-width: 190px; flex: 1; }
|
||||
:global(.calendar-subscription-dialog) { margin-top: 4vh; }
|
||||
.calendar-status { grid-template-columns: 1fr; gap: 7px; }
|
||||
.calendar-actions, .calendar-security-actions { display: grid; grid-template-columns: 1fr; }
|
||||
.calendar-actions .el-button, .calendar-security-actions .el-button { width: 100%; margin-left: 0; }
|
||||
.timetable-sheet { padding: 12px; }
|
||||
.view-context, .day-view > header { align-items: flex-start; flex-direction: column; gap: 5px; }
|
||||
.day-grid { grid-template-columns: 90px minmax(0, 1fr); }
|
||||
|
||||
Reference in New Issue
Block a user