已完善个人教学日历订阅。

主要能力:
教师、学生可在“我的课表”启用订阅、复制 URL、唤起日历客户端或下载一次性 ICS。
自动覆盖固定课程、调停课后的最新安排、考试/监考、补考,以及灵活课程提醒。
采用独立随机订阅标识和 HMAC 签名;重置或停用后旧地址立即失效。
支持 ETag 条件请求和一小时刷新提示,减少日历客户端重复同步。
订阅地址按实际前端 API 地址生成,兼容独立部署域名。
已增加窄屏弹窗最大宽度和纵向操作布局。
This commit is contained in:
2026-07-26 17:22:58 +08:00 Unverified
parent ec28eb6c16
commit 40b8cb6e3c
15 changed files with 5631 additions and 9 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
面向普通高校的教务管理系统。后端使用 ASP.NET Core 10、EF Core 10,前端使用 Vue 3、TypeScript 和 Element Plus。 面向普通高校的教务管理系统。后端使用 ASP.NET Core 10、EF Core 10,前端使用 Vue 3、TypeScript 和 Element Plus。
当前已实现系统登录与角色权限、基础数据、用户管理、教师档案、学生档案、课程库、培养方案、教学任务、排课课表、学生选课、成绩管理、考试考场、学籍异动、毕业审核、学位授予、毕业离校和首页统计。人员及课程列表支持组合筛选、服务端分页和完整增删改查;培养方案支持课程模块、专业年级版本、复制新版本、发布与旧版本归档,已发布版本可继续维护名称、学分说明和课程结构,适用专业、入学年级及版本号保持锁定;教学任务支持学期课程开设、多教师、合班、容量校验、发布与结课,公共课由校级教务负责、专业必修/专业选修/实践课下放课程所属学院管理,并支持教师按学期申报授课科目、学院审核授课资格、公共课按若干行政班合并教学班,以及在审核通过的教师池中随机均衡分配后批量生成草稿;排课支持学期作息维护、单双周与周次节次、课程可用时间、校区/教学楼/指定教室约束、不占用教室课程、教室容量、教师/行政班/教室冲突校验、自动生成、手工微调和版本化发布;选课支持批次时间窗、投放范围、容量与学分上限、重复课程与课表冲突校验、退课截止时间、满员候补、顺位查询、退课后资格复核与自动递补,以及正式名单和候补队列管理;成绩管理支持分项比例、批量录入、特殊考试状态、自动总评与绩点、教师提交、学院审核、校级发布和学生成绩单;考试管理支持考试计划、场次、考场容量、监考教师、考生名单以及考场/监考/学生时间冲突校验;学籍异动支持休学、复学、退学申请,辅导员、学院、学校三级顺序审核,学生撤回,以及最终审批后自动同步学籍状态;毕业审核按入学年级匹配已发布培养方案,以正式成绩计算总学分、必修通过和未解决不及格课程,支持学院范围查看、人工复核、校级锁定发布和学生结果查询;学位授予以已发布毕业资格为来源,按正式成绩加权平均绩点生成规则结论,支持学院人工复核、校级发布锁定和学生结果查询;毕业离校支持自定义事项与责任部门,按校级、学院、辅导员角色分工办理,强制数据范围校验,学生进度查询,以及必办事项全部完成后的批次锁定。 当前已实现系统登录与角色权限、基础数据、用户管理、教师档案、学生档案、课程库、培养方案、教学任务、排课课表、学生选课、成绩管理、考试考场、学籍异动、毕业审核、学位授予、毕业离校和首页统计。人员及课程列表支持组合筛选、服务端分页和完整增删改查;培养方案支持课程模块、专业年级版本、复制新版本、发布与旧版本归档,已发布版本可继续维护名称、学分说明和课程结构,适用专业、入学年级及版本号保持锁定;教学任务支持学期课程开设、多教师、合班、容量校验、发布与结课,公共课由校级教务负责、专业必修/专业选修/实践课下放课程所属学院管理,并支持教师按学期申报授课科目、学院审核授课资格、公共课按若干行政班合并教学班,以及在审核通过的教师池中随机均衡分配后批量生成草稿;排课支持学期作息维护、单双周与周次节次、课程可用时间、校区/教学楼/指定教室约束、不占用教室课程、教室容量、教师/行政班/教室冲突校验、自动生成、手工微调和版本化发布;教师和学生可启用个人教学日历订阅,将固定课程、考试/监考、补考及灵活课程提醒同步到支持 iCalendar 的客户端,并可重置或停用订阅地址;选课支持批次时间窗、投放范围、容量与学分上限、重复课程与课表冲突校验、退课截止时间、满员候补、顺位查询、退课后资格复核与自动递补,以及正式名单和候补队列管理;成绩管理支持分项比例、批量录入、特殊考试状态、自动总评与绩点、教师提交、学院审核、校级发布和学生成绩单;考试管理支持考试计划、场次、考场容量、监考教师、考生名单以及考场/监考/学生时间冲突校验;学籍异动支持休学、复学、退学申请,辅导员、学院、学校三级顺序审核,学生撤回,以及最终审批后自动同步学籍状态;毕业审核按入学年级匹配已发布培养方案,以正式成绩计算总学分、必修通过和未解决不及格课程,支持学院范围查看、人工复核、校级锁定发布和学生结果查询;学位授予以已发布毕业资格为来源,按正式成绩加权平均绩点生成规则结论,支持学院人工复核、校级发布锁定和学生结果查询;毕业离校支持自定义事项与责任部门,按校级、学院、辅导员角色分工办理,强制数据范围校验,学生进度查询,以及必办事项全部完成后的批次锁定。
课程库支持下载标准模板后批量导入 `.xlsx`,按课程编码新增或更新,并在整批校验失败时不写入任何课程;授课资格既支持教师申报后审核,也支持学院在本院教师范围内直接分配;学生可在“我的培养方案”中查看本人适用的已发布方案,并按已完成、在读、重修中、未通过、待完成和未修读状态核对课程与学分进度。 课程库支持下载标准模板后批量导入 `.xlsx`,按课程编码新增或更新,并在整批校验失败时不写入任何课程;授课资格既支持教师申报后审核,也支持学院在本院教师范围内直接分配;学生可在“我的培养方案”中查看本人适用的已发布方案,并按已完成、在读、重修中、未通过、待完成和未修读状态核对课程与学分进度。
@@ -1,4 +1,5 @@
using System.Security.Claims; using System.Security.Claims;
using System.Security.Cryptography;
using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Caching; using Jiaowu.Api.Infrastructure.Caching;
@@ -16,6 +17,7 @@ namespace Jiaowu.Api.Controllers;
public sealed class TimetablesController( public sealed class TimetablesController(
AppDbContext db, AppDbContext db,
TimetableDataService timetableDataService, TimetableDataService timetableDataService,
PersonalCalendarService personalCalendarService,
IAppCache cache) : ControllerBase IAppCache cache) : ControllerBase
{ {
[HttpGet("options")] [HttpGet("options")]
@@ -201,6 +203,101 @@ public sealed class TimetablesController(
return NotFound(); 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( private async Task<ActionResult> BuildTimetableAsync(
Guid classId, Guid classId,
Guid? academicTermId, Guid? academicTermId,
@@ -327,8 +424,57 @@ public sealed class TimetablesController(
value = value.Replace(character, '-'); value = value.Replace(character, '-');
return value.Trim(); 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( public sealed record TimetableOptionsResponse(
IReadOnlyList<TimetableTermOption> Terms, IReadOnlyList<TimetableTermOption> Terms,
IReadOnlyList<TimetableCollegeOption> Colleges, IReadOnlyList<TimetableCollegeOption> Colleges,
@@ -10,6 +10,8 @@ public sealed class ApplicationUser : IdentityUser<Guid>
public bool IsEnabled { get; set; } = true; public bool IsEnabled { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? LastLoginAt { get; set; } public DateTime? LastLoginAt { get; set; }
public string? CalendarSubscriptionStamp { get; set; }
public DateTime? CalendarSubscriptionCreatedAt { get; set; }
} }
public sealed class ApplicationRole : IdentityRole<Guid> 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.DisplayName).HasMaxLength(50);
entity.Property(x => x.StaffNumber).HasMaxLength(30); entity.Property(x => x.StaffNumber).HasMaxLength(30);
entity.Property(x => x.CalendarSubscriptionStamp).HasMaxLength(64);
entity.HasIndex(x => x.StaffNumber); entity.HasIndex(x => x.StaffNumber);
}); });
@@ -50,6 +50,8 @@ public sealed class DevelopmentSqliteMigrator(
"20260726_27_academic_term_archiving"; "20260726_27_academic_term_archiving";
private const string CourseSelectionWaitlistMigration = private const string CourseSelectionWaitlistMigration =
"20260726_28_course_selection_waitlist"; "20260726_28_course_selection_waitlist";
private const string PersonalCalendarSubscriptionMigration =
"20260726_29_personal_calendar_subscription";
public async Task MigrateAsync(CancellationToken cancellationToken = default) public async Task MigrateAsync(CancellationToken cancellationToken = default)
{ {
@@ -359,6 +361,21 @@ public sealed class DevelopmentSqliteMigrator(
CourseSelectionWaitlistMigration, CourseSelectionWaitlistMigration,
courseSelectionWaitlistExists ? [] : CourseSelectionWaitlistStatements, courseSelectionWaitlistExists ? [] : CourseSelectionWaitlistStatements,
cancellationToken); 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( 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 = private static readonly string[] CourseAdjustmentsStatements =
[ [
""" """
@@ -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");
}
}
}
@@ -3016,6 +3016,13 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Property<int>("AccessFailedCount") b.Property<int>("AccessFailedCount")
.HasColumnType("int"); .HasColumnType("int");
b.Property<DateTime?>("CalendarSubscriptionCreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("CalendarSubscriptionStamp")
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<Guid?>("CollegeId") b.Property<Guid?>("CollegeId")
.HasColumnType("char(36)"); .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.StartWeek,
x.EndWeek, x.EndWeek,
x.WeekPattern, x.WeekPattern,
x.Notes)) x.Notes,
false,
null,
null,
x.UpdatedAt))
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
} }
@@ -290,7 +294,8 @@ public sealed class TimetableDataService(AppDbContext db)
.OrderByDescending(item => item.IsPrimary) .OrderByDescending(item => item.IsPrimary)
.Select(item => item.Teacher!.Name), .Select(item => item.Teacher!.Name),
x.Classes.Select(item => item.AdministrativeClass!.Name), x.Classes.Select(item => item.AdministrativeClass!.Name),
x.Notes)) x.Notes,
x.UpdatedAt))
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
} }
@@ -357,7 +362,8 @@ public sealed class TimetableDataService(AppDbContext db)
x.ExamPlan!.Name, x.ExamPlan!.Name,
InvigilatorNames = x.Invigilators InvigilatorNames = x.Invigilators
.Select(i => i.Teacher!.Name), .Select(i => i.Teacher!.Name),
x.Notes x.Notes,
x.UpdatedAt
}) })
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
@@ -385,7 +391,8 @@ public sealed class TimetableDataService(AppDbContext db)
x.Notes, x.Notes,
true, true,
x.Name, x.Name,
x.ExamDate); x.ExamDate,
x.UpdatedAt);
}).ToList(); }).ToList();
} }
} }
@@ -468,7 +475,8 @@ public sealed record TimetableEntryDto(
string? Notes, string? Notes,
bool IsExam = false, bool IsExam = false,
string? ExamPlanName = null, string? ExamPlanName = null,
DateOnly? ExamDate = null); DateOnly? ExamDate = null,
DateTime UpdatedAt = default);
public sealed record FlexibleCourseDto( public sealed record FlexibleCourseDto(
Guid Id, Guid Id,
@@ -483,4 +491,5 @@ public sealed record FlexibleCourseDto(
int WeeklyHours, int WeeklyHours,
IEnumerable<string> TeacherNames, IEnumerable<string> TeacherNames,
IEnumerable<string> ClassNames, IEnumerable<string> ClassNames,
string? Notes); string? Notes,
DateTime UpdatedAt = default);
+1
View File
@@ -200,6 +200,7 @@ builder.Services.AddScoped<DemoDataSeeder>();
builder.Services.AddScoped<DevelopmentSqliteMigrator>(); builder.Services.AddScoped<DevelopmentSqliteMigrator>();
builder.Services.AddScoped<TimetableDataService>(); builder.Services.AddScoped<TimetableDataService>();
builder.Services.AddScoped<AutomaticScheduleGenerator>(); builder.Services.AddScoped<AutomaticScheduleGenerator>();
builder.Services.AddScoped<PersonalCalendarService>();
builder.Services.AddScoped<AutomaticScheduleJobProcessor>(); builder.Services.AddScoped<AutomaticScheduleJobProcessor>();
builder.Services.AddSingleton<AutomaticScheduleJobQueue>(); builder.Services.AddSingleton<AutomaticScheduleJobQueue>();
builder.Services.AddHostedService<AutomaticScheduleJobWorker>(); 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 public sealed class MySqlMigrationTests
{ {
private const string LatestMigration = private const string LatestMigration =
"20260726180000_CourseSelectionGradeLimits"; "20260726190000_PersonalCalendarSubscription";
[Fact] [Fact]
public void Production_migration_is_discoverable_and_generates_mysql_sql() public void Production_migration_is_discoverable_and_generates_mysql_sql()
@@ -46,6 +46,12 @@ public sealed class MySqlMigrationTests
Assert.Contains( Assert.Contains(
"CREATE UNIQUE INDEX `IX_CourseSelectionRoundGrades_CourseSelectionRoundId_Grade`", "CREATE UNIQUE INDEX `IX_CourseSelectionRoundGrades_CourseSelectionRoundId_Grade`",
script); 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("SET `ExamDate` = DATE(`StartsAt`)", script);
Assert.Contains("DEFAULT 1", script); Assert.Contains("DEFAULT 1", script);
Assert.DoesNotContain("0001-01-01", 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"
}));
}
+246 -1
View File
@@ -1,6 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from 'vue' 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 { useRoute } from 'vue-router'
import http, { apiErrorMessage } from '../api/http' import http, { apiErrorMessage } from '../api/http'
import { downloadApiFile } from '../api/excel' import { downloadApiFile } from '../api/excel'
@@ -23,6 +32,10 @@ const isManager = computed(() =>
) )
const loading = ref(false) const loading = ref(false)
const exportingPdf = 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 terms = ref<any[]>([])
const colleges = ref<any[]>([]) const colleges = ref<any[]>([])
const majors = 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([classId, teacherId, classroomId, planId, resourceType], loadTimetable)
watch(teacherIdParam, (newId) => { if (newId) loadTimetable() }) watch(teacherIdParam, (newId) => { if (newId) loadTimetable() })
@@ -669,6 +808,9 @@ onMounted(async () => {
</el-select> </el-select>
</div> </div>
<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="Download" @click="exportExcel">导出 Excel</el-button>
<el-button :icon="Document" :loading="exportingPdf" @click="exportPdf">导出 PDF</el-button> <el-button :icon="Document" :loading="exportingPdf" @click="exportPdf">导出 PDF</el-button>
</div> </div>
@@ -820,6 +962,88 @@ onMounted(async () => {
/> />
</div> </div>
</section> </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> </main>
</template> </template>
@@ -845,6 +1069,23 @@ onMounted(async () => {
.timetable-toolbar .el-select { width: 120px; } .timetable-toolbar .el-select { width: 120px; }
.timetable-toolbar .week-switcher { display: flex; align-items: center; gap: 6px; padding-left: 2px; } .timetable-toolbar .week-switcher { display: flex; align-items: center; gap: 6px; padding-left: 2px; }
.timetable-toolbar .week-switcher .el-select { width: 245px; } .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; } .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 { 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; } .sheet-meta div { display: grid; gap: 3px; }
@@ -919,6 +1160,10 @@ onMounted(async () => {
.timetable-toolbar > div { flex-wrap: wrap; } .timetable-toolbar > div { flex-wrap: wrap; }
.timetable-toolbar .week-switcher { width: 100%; padding-left: 0; } .timetable-toolbar .week-switcher { width: 100%; padding-left: 0; }
.timetable-toolbar .week-switcher .el-select { min-width: 190px; flex: 1; } .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; } .timetable-sheet { padding: 12px; }
.view-context, .day-view > header { align-items: flex-start; flex-direction: column; gap: 5px; } .view-context, .day-view > header { align-items: flex-start; flex-direction: column; gap: 5px; }
.day-grid { grid-template-columns: 90px minmax(0, 1fr); } .day-grid { grid-template-columns: 90px minmax(0, 1fr); }