已完善个人教学日历订阅。
主要能力: 教师、学生可在“我的课表”启用订阅、复制 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。
|
面向普通高校的教务管理系统。后端使用 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 =
|
||||||
[
|
[
|
||||||
"""
|
"""
|
||||||
|
|||||||
+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")
|
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);
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -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); }
|
||||||
|
|||||||
Reference in New Issue
Block a user