feat(shared,server,web,client,plugin)!: 角色与权限重构 + 学期周次 + 课表方向反转(1.2.0)

学生、任课关系、周次口径三处长期靠约定维持的事情,改为由数据模型保证。

学生不是账号
- 拆出独立 Student 表,没有 passwordHash / role / 任何登录字段
- 「学生不能登录」由表结构保证,不再靠「登录前判 role 再 403」的代码分支
- 三张个人数据表外键 userId → studentId;删掉 Enrollment 与 ClassTeacher
- 学生一律称「学号」、教师一律称「工号」,「学生账号」这个概念消失

任课关系唯一来源 = Course(班级 + 科目 + 教师)
- 班主任 = Class.teacherId,科任 = Course.teacherId,同一人两处指向同一账号、不重复建号
- 作业与成绩按科目再收一道:挂科目只由该科任课老师改,没挂科目归班主任 / 管理员
- 新增「任课教师分配」与「班级教师批量导入」(带「使用工号匹配」开关)

成绩与名单
- 成绩等级:百分制 / 等级制 A·B·C·D / 自定义,支持单个与批量改等级
- 按学号查成绩明细(教师端 + 班级端 + 设备令牌),受按班开关控制
- 调班 / 转出(单个与批量)+ 可查历史;学号不变、历史作业成绩留原归属
- 毕业归档页(按年度记录毕业班级 / 毕业生 / 转出学生 / 该届通知与作业)
- 学年升级(未毕业不归档、数据沿用);班级称呼改为「2026级1班」

ClassHelper 班级端在线状态
- Web 端显示 班级 / 设备名 / 设备号 / 在线离线 / 最后在线 / 最后心跳
- lastHeartbeatAt 驱动 60 秒在线窗口,与只表示「带状态上报」的 lastSeenAt 分开

学期周次
- 新增 TermWeek(classId 空串 = 全校默认),管理员可用日期范围选择器逐周配置
- 一键按开学日期生成整学期;联网拉取调休建议(只给建议、不改数据,无外网失败属正常)
- 改周次后随课表下发给教室的 ClassHelper 班级端

课表方向反转
- 主方向改为「服务端 → 教室」自动下发;教室不再自动回传
- 取教室课表改为手动动作(点击前弹警告),插件按待办标记在下一次心跳补报

改名
- 班级小助手 → ClassHelper:界面、窗口标题、托盘、灵动岛、EXE、安装包、
  安装目录、systemd 描述、插件清单与设置页、官网、文档站、README

BREAKING CHANGE: 产品名与 EXE 名变更(班级小助手.exe → ClassHelper.exe),
安装目录随之改变,不支持覆盖安装,需先卸载旧版。
服务端首次启动会自动应用 6 个数据库迁移(学生行按 id 原样搬迁,历史数据不丢),
升级前请先备份。
This commit is contained in:
LoveChengke committed 2026-10-06 19:41:18 +08:00
1 parent 17f55864bf
commit 9d5ac7adf9
172 files changed
+8761 -1896

No files matched your search

@@ -80,6 +80,14 @@ public class ReportSettingsDto
{
[JsonPropertyName("mirrorScheduleToClassIsland")] public bool MirrorScheduleToClassIsland { get; set; }
[JsonPropertyName("enabled")] public bool Enabled { get; set; }
/// <summary>
/// 服务端请求**立刻上报一次课表**(老师在 Web 端点了「从教室机器获取课表」)。
///
/// 它与本机的「上报课表到 ClassHelper」开关无关:那个开关管的是"要不要**自动**回传",
/// 现在是默认关闭的;而这是一次显式的人工请求,看到就要照做。
/// </summary>
[JsonPropertyName("scheduleRequested")] public bool ScheduleRequested { get; set; }
}
public class ReportResultDto
@@ -157,10 +165,32 @@ public class ClassPlanMirrorDto
[JsonPropertyName("termStartDate")] public string TermStartDate { get; set; } = "";
}
/// <summary>服务端逐周配的学期周次区间(管理员在「学期周次」里维护)。</summary>
public class TermWeekRangeDto
{
[JsonPropertyName("weekNumber")] public int WeekNumber { get; set; }
/// <summary>YYYY-MM-DD</summary>
[JsonPropertyName("startDate")] public string StartDate { get; set; } = "";
/// <summary>YYYY-MM-DD</summary>
[JsonPropertyName("endDate")] public string EndDate { get; set; } = "";
}
public class PullResultDto
{
[JsonPropertyName("classPlan")] public ClassPlanMirrorDto? ClassPlan { get; set; }
[JsonPropertyName("week")] public int Week { get; set; }
/// <summary>
/// 学期逐周区间;空 = 服务端只按学期开始日期线性推算。
///
/// ClassIsland 自身只认"教学周序号 + 单双周",没有"按日期区间定义某一周"的模型
/// (本机装的 2.1.0.1 没有该字段),所以这份区间在插件侧只用于:
/// 1) 校正"现在是第几周"(调休会让线性推算整体错位);
/// 2) 在设置页里显示教室这台机器当前处在哪一段日期区间,方便老师核对。
/// 课表本身的周次口径仍由 <see cref="ClassPlanMirrorDto.TermStartDate"/> 与单双周表达。
/// </summary>
[JsonPropertyName("weekRanges")] public List<TermWeekRangeDto> WeekRanges { get; set; } = new();
[JsonPropertyName("serverTime")] public string ServerTime { get; set; } = "";
}
@@ -174,7 +204,7 @@ internal class ApiEnvelope<T>
[JsonPropertyName("code")] public string? Code { get; set; }
}
/// <summary>调用班级小助手后端的结果(不抛异常,便于设置页展示原因)。</summary>
/// <summary>调用 ClassHelper后端的结果(不抛异常,便于设置页展示原因)。</summary>
public class ApiCallResult
{
public bool Ok { get; init; }
@@ -187,7 +217,7 @@ public class ApiCallResult
}
/// <summary>
/// 班级小助手后端客户端。
/// ClassHelper后端客户端。
///
/// 只用 <see cref="HttpClient"/> + System.Text.Json:不引入额外依赖包,
/// 避免插件加载时因为"插件程序集隔离"缺少依赖而无法启动
@@ -214,7 +244,7 @@ public sealed class ClassHelperClient : IDisposable
};
/// <summary>
/// 与班级小助手通信的 HttpClient。
/// 与 ClassHelper通信的 HttpClient。
///
/// **必须显式处理代理**:教室机器(以及开发机)常常配了系统代理,
/// 而 Windows 的代理设置默认连 `127.0.0.1` 也会走代理 —— 于是"填写令牌后立刻 HTTP 404",
@@ -335,7 +365,7 @@ public sealed class ClassHelperClient : IDisposable
// 老师截图给我们就能一眼定位。
message = envelope is null
? $"HTTP {(int)response.StatusCode}({method.Method} {request.RequestUri})——" +
"响应不是班级小助手的标准格式,请检查服务器地址是否正确、" +
"响应不是 ClassHelper的标准格式,请检查服务器地址是否正确、" +
"或该系统代理是否拦截了内网请求"
: $"HTTP {(int)response.StatusCode}({method.Method} {request.RequestUri})";
}
@@ -350,7 +380,7 @@ public sealed class ClassHelperClient : IDisposable
// 设置页显示"连接异常"却没有原因,与上面专门为代理错误页写的诊断自相矛盾。
LastError =
$"HTTP {(int)response.StatusCode}({method.Method} {request.RequestUri})——" +
"响应不是班级小助手的标准格式,请检查服务器地址是否正确,或该系统代理是否拦截了内网请求";
"响应不是 ClassHelper的标准格式,请检查服务器地址是否正确,或该系统代理是否拦截了内网请求";
return new ApiEnvelope<T> { Success = false, Message = LastError };
}
@@ -365,7 +395,7 @@ public sealed class ClassHelperClient : IDisposable
}
catch (HttpRequestException exception)
{
LastError = $"无法连接班级小助手服务:{exception.Message}";
LastError = $"无法连接 ClassHelper服务:{exception.Message}";
return null;
}
catch (Exception exception)
@@ -17,8 +17,11 @@ public class PluginSettings : ObservableObject
private string _deviceKey = "";
private bool _autoReport = true;
private int _reportIntervalSeconds = 60;
private bool _uploadSchedule = true;
private bool _mirrorSchedule = false;
// 同步方向(2026-10-06 起):课表以 ClassHelper为准,**服务端 → 教室自动下发**;
// 「把教室课表传上去」改为 Web 端的一次性人工动作(点之前会弹警告)。
// 自动回传会悄悄盖掉老师刚手排的课,所以默认关闭。
private bool _uploadSchedule = false;
private bool _mirrorSchedule = true;
private bool _receiveNotifications = true;
private int _notificationDurationSeconds = 8;
private int _notificationPollSeconds = 10;
@@ -26,7 +29,7 @@ public class PluginSettings : ObservableObject
private bool _showDebugInfo = false;
/// <summary>
/// 班级小助手后端地址,例如 <c>http://127.0.0.1:4000</c>。
/// ClassHelper后端地址,例如 <c>http://127.0.0.1:4000</c>。
/// </summary>
public string ServerUrl
{
@@ -35,7 +38,7 @@ public class PluginSettings : ObservableObject
}
/// <summary>
/// 设备令牌(形如 <c>chci_...</c>):在班级小助手 Web 端「ClassIsland 联动」页面生成。
/// 设备令牌(形如 <c>chci_...</c>):在 ClassHelper Web 端「ClassIsland 联动」页面生成。
/// </summary>
public string DeviceToken
{
@@ -74,8 +77,13 @@ public class PluginSettings : ObservableObject
}
/// <summary>
/// 是否把 ClassIsland 的课表同步到班级小助手。
/// 关闭后仍会上报"当前上什么课"的状态,只是不再写课表。
/// 是否把 ClassIsland 的课表**自动**同步到 ClassHelper。
///
/// **默认关闭**:课表以 ClassHelper为准,教室这边改完课表不会自己传上去
/// (否则老师刚在 ClassHelper上排好的课会被教室的旧课表悄悄覆盖)。
/// 要取教室的课表,请在 Web 端点「从教室机器获取课表」——那是一次显式的人工请求,
/// 与本开关无关(见 <c>ReportSettingsDto.ScheduleRequested</c>)。
/// 关闭后仍会上报"当前上什么课"的状态。
/// </summary>
public bool UploadSchedule
{
@@ -84,8 +92,10 @@ public class PluginSettings : ObservableObject
}
/// <summary>
/// 是否把班级小助手上排好的课表镜像回 ClassIsland
/// (新建一份名为「班级小助手-&lt;班级名&gt;」的档案课表,不覆盖老师原有的课表)。
/// 是否把 ClassHelper上排好的课表镜像回 ClassIsland
/// (新建一份名为「ClassHelper-&lt;班级名&gt;」的档案课表,不覆盖老师原有的课表)。
///
/// **默认开启**:这是现在课表同步的主方向。
/// </summary>
public bool MirrorSchedule
{
@@ -94,7 +104,7 @@ public class PluginSettings : ObservableObject
}
/// <summary>
/// 是否接收老师在班级小助手上发布的提醒,并在 ClassIsland 上弹出。
/// 是否接收老师在 ClassHelper上发布的提醒,并在 ClassIsland 上弹出。
/// </summary>
public bool ReceiveNotifications
{
+7 -7
View File
@@ -54,7 +54,7 @@ public class Plugin : PluginBase
var application = AppBase.Current;
if (application is null)
{
Logger?.LogError("班级小助手联动:拿不到 ClassIsland 应用实例,联动未启动");
Logger?.LogError("ClassHelper 联动:拿不到 ClassIsland 应用实例,联动未启动");
return;
}
@@ -72,14 +72,14 @@ public class Plugin : PluginBase
var lessons = IAppHost.TryGetService<ILessonsService>();
if (lessons is null)
{
Logger?.LogError("班级小助手联动:拿不到课程服务,联动已停用");
Logger?.LogError("ClassHelper 联动:拿不到课程服务,联动已停用");
return;
}
var bridge = IAppHost.TryGetService<BridgeService>();
if (bridge is null)
{
Logger?.LogError("班级小助手联动:拿不到联动服务,联动已停用");
Logger?.LogError("ClassHelper 联动:拿不到联动服务,联动已停用");
return;
}
@@ -89,12 +89,12 @@ public class Plugin : PluginBase
// 应用刚起来时还没有第一个上报点,先主动拉一次未弹出的提醒(离线期间老师发的通知):
// 不这么做的话,最长要等一个上报间隔才会弹出来
_ = bridge.PullPendingAsync();
Logger?.LogInformation("班级小助手联动:已接入课程服务(服务器 {Server})", Settings.ServerUrl);
Logger?.LogInformation("ClassHelper 联动:已接入课程服务(服务器 {Server})", Settings.ServerUrl);
}
catch (Exception exception)
{
// 联动失败绝不能把 ClassIsland 本体拖下水
Logger?.LogError(exception, "班级小助手联动:初始化联动服务失败");
Logger?.LogError(exception, "ClassHelper 联动:初始化联动服务失败");
}
}
@@ -120,7 +120,7 @@ public class Plugin : PluginBase
{
// 配置损坏不该拦住插件加载:用默认值继续,原文件保留供排查
IAppHost.TryGetService<ILogger<Plugin>>()?.LogWarning(exception,
"班级小助手联动:读取设置失败,已回退到默认设置({Path})", path);
"ClassHelper 联动:读取设置失败,已回退到默认设置({Path})", path);
}
var needGenerateKey = string.IsNullOrWhiteSpace(settings.DeviceKey);
@@ -165,7 +165,7 @@ public class Plugin : PluginBase
catch (Exception exception)
{
IAppHost.TryGetService<ILogger<Plugin>>()?.LogWarning(exception,
"班级小助手联动:保存设置失败({Path})", path);
"ClassHelper 联动:保存设置失败({Path})", path);
}
}
}
@@ -15,10 +15,10 @@ namespace ClassHelper.ClassIslandPlugin.Services;
/// <summary>
/// 联动主循环。三件事:
/// 1. <b>上报</b>:应用启动后、课程事件(上课/下课/放学/状态变化)发生时、以及每 N 秒,
/// 把"当前上什么课"与(可选的)全量课表推给班级小助手;
/// 把"当前上什么课"与(可选的)全量课表推给 ClassHelper;
/// 2. <b>接收</b>:每次上报的返回值里带一条"尚未确认的下发提醒",
/// 交给 <see cref="ClassHelperNotificationProvider"/> 在 ClassIsland 上弹出;
/// 3. <b>镜像</b>:开启镜像开关时,把班级小助手上排好的课表写回 ClassIsland 档案。
/// 3. <b>镜像</b>:开启镜像开关时,把 ClassHelper上排好的课表写回 ClassIsland 档案。
///
/// 为什么用"上报即拉取"而不是长连接:
/// 插件不需要额外的 Socket/WebSocket 依赖(避免程序集隔离带来的加载问题),
@@ -174,7 +174,7 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
}
Raise(nameof(IsAttached));
_logger.LogInformation("班级小助手联动:已订阅课程事件");
_logger.LogInformation("ClassHelper 联动:已订阅课程事件");
RequestReport("应用启动");
}
@@ -216,7 +216,7 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
/// 执行一次上报并返回是否真的发出去了。
/// <paramref name="includeSchedule"/> 为 true 时带上全量课表与节次时间。
/// </summary>
public async Task<bool> ReportAsync(string reason, bool includeSchedule = true)
public async Task<bool> ReportAsync(string reason, bool includeSchedule = true, bool forceSchedule = false)
{
if (_disposed) return false;
@@ -253,17 +253,21 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
var layoutCount = 0;
// 开关关着时也要留下原因:否则日志只有"上报成功:仅状态",
// 用户会以为"服务端拿不到 ClassIsland 课表"是插件坏了。
if (includeSchedule && !_settings.UploadSchedule)
// forceSchedule:服务端显式点了「从教室机器获取课表」——这是一次人工请求,
// 不受本机「自动上报课表」开关影响(见下方 ReportSettingsDto.ScheduleRequested)
var uploadSchedule = forceSchedule || _settings.UploadSchedule;
if (includeSchedule && !uploadSchedule)
{
_logger.LogInformation(
"班级小助手联动:本机设置里「上报课表到班级小助手」是关闭的,本次只上报状态;" +
"需要把教室课表同步到班级小助手请打开该开关");
"ClassHelper 联动:本机设置里「上报课表到 ClassHelper」是关闭的,本次只上报状态;" +
"需要把教室课表同步到 ClassHelper请打开该开关,或在 Web 端点「从教室机器获取课表」");
}
if (includeSchedule && _settings.UploadSchedule && profile is not null)
if (includeSchedule && uploadSchedule && profile is not null)
{
var entries = ScheduleMapper.MapAllClassPlans(profile, out var warnings);
foreach (var warning in warnings) _logger.LogWarning("班级小助手联动:{Warning}", warning);
foreach (var warning in warnings) _logger.LogWarning("ClassHelper 联动:{Warning}", warning);
entryCount = entries.Count;
request.Schedule = new SchedulePayloadDto { Mode = "merge", Entries = entries };
// 课表为空时不发(否则会把服务端已有课表"合并"成空),但**必须把原因写清楚**:
@@ -272,8 +276,8 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
{
request.Schedule = null;
_logger.LogWarning(
"班级小助手联动:ClassIsland 里没有**启用的课表**(0 条),本次未上报课表。" +
"请在 ClassIsland 里排好课表,或在 Web 端打开「镜像课表」由班级小助手下发一份");
"ClassHelper 联动:ClassIsland 里没有**启用的课表**(0 条),本次未上报课表。" +
"请在 ClassIsland 里排好课表,或在 Web 端打开「镜像课表」由 ClassHelper下发一份");
}
var layout = ResolvePrimaryTimeLayout(profile);
@@ -296,7 +300,7 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
.ConfigureAwait(false);
if (!result.Ok || data is null)
{
_logger.LogWarning("班级小助手联动上报失败({Reason}):{Message}", reason, result.Message);
_logger.LogWarning("ClassHelper 联动上报失败({Reason}):{Message}", reason, result.Message);
SetState(false, result.Message, entryCount, layoutCount, null);
return false;
}
@@ -306,15 +310,15 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
if (data.ScheduleApplied)
{
_logger.LogInformation(
"班级小助手联动:课表已同步(新增 {Created} / 更新 {Updated})",
"ClassHelper 联动:课表已同步(新增 {Created} / 更新 {Updated})",
data.ScheduleCreated, data.ScheduleUpdated);
}
var detail = entryCount > 0
? $"课表 {entryCount} 节 / 节次 {layoutCount} 条"
: _settings.UploadSchedule
: uploadSchedule
? "仅状态(ClassIsland 里还没有启用的课表)"
: "仅状态(本机「上报课表到班级小助手」开关已关闭)";
: "仅状态(本机「上报课表到 ClassHelper」开关已关闭)";
SetState(true, $"上报成功({reason}):{detail},服务端第 {data.Week} 周",
entryCount, layoutCount, data.Week);
@@ -325,6 +329,14 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
NotificationReceived?.Invoke(this, pending);
}
// 服务端请求"立刻上报一次课表"(老师在 Web 端点了「从教室机器获取课表」)。
// 放在镜像之前:先把教室的现状推上去,再做服务端→教室的镜像,顺序反了会把刚拿到的课表盖掉。
if (data.Settings?.ScheduleRequested == true && !uploadSchedule)
{
_logger.LogInformation("ClassHelper 联动:服务端请求立即上报课表,正在补发一次…");
_ = ReportAsync("服务端请求课表", includeSchedule: true, forceSchedule: true);
}
if (_settings.MirrorSchedule)
{
await MirrorScheduleAsync().ConfigureAwait(false);
@@ -338,7 +350,7 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
// (事件回调与定时器都是),没有这个 catch,中途抛出的异常(例如跨线程改档案时的
// "Collection was modified")会被 unobserved task 静默吞掉:日志里一条都没有,
// 而 LastReportMessage 还停在上一次的成功结果,用户以为一切正常。
_logger.LogError(exception, "班级小助手联动:上报异常({Reason})", reason);
_logger.LogError(exception, "ClassHelper 联动:上报异常({Reason})", reason);
SetState(false, $"上报异常:{exception.Message}", 0, 0, null);
return false;
}
@@ -369,13 +381,13 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
var (result, items) = await _client.PendingAsync(_settings, CancellationToken.None).ConfigureAwait(false);
if (!result.Ok)
{
_logger.LogInformation("班级小助手联动:拉取待提醒失败({Message})", result.Message);
_logger.LogInformation("ClassHelper 联动:拉取待提醒失败({Message})", result.Message);
return;
}
foreach (var item in items)
{
_logger.LogInformation("班级小助手联动:取到待弹出提醒「{Title}」(id={Id})", item.Title, item.Id);
_logger.LogInformation("ClassHelper 联动:取到待弹出提醒「{Title}」(id={Id})", item.Title, item.Id);
NotificationReceived?.Invoke(this, item);
}
}
@@ -392,16 +404,16 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
if (result.Ok)
{
NotificationCount += 1;
_logger.LogInformation("班级小助手联动:已确认提醒 {Id}", id);
_logger.LogInformation("ClassHelper 联动:已确认提醒 {Id}", id);
}
else
{
// 回执失败无妨:服务端会按 24 小时有效期补发,最坏是重复弹一次
_logger.LogWarning("班级小助手联动:确认提醒失败 {Message}", result.Message);
_logger.LogWarning("ClassHelper 联动:确认提醒失败 {Message}", result.Message);
}
}
/// <summary>把班级小助手上排好的课表写回 ClassIsland(新建带前缀的档案课表,不动老师原有的课表)。</summary>
/// <summary>把 ClassHelper上排好的课表写回 ClassIsland(新建带前缀的档案课表,不动老师原有的课表)。</summary>
public async Task MirrorScheduleAsync()
{
var profile = ResolveProfile();
@@ -412,7 +424,7 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
{
// 服务端在"镜像开关没开"时会返回 data=null —— 这不是错误,但要告诉用户去哪儿开
_logger.LogInformation(
"班级小助手联动:跳过课表镜像({Message})。" +
"ClassHelper 联动:跳过课表镜像({Message})。" +
"需要在 Web 端「ClassIsland 联动」页给这台设备打开「镜像课表」,然后点一次「立即上报」或等下一个上报周期",
result.Message);
return;
@@ -423,7 +435,7 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
var signature = MirrorSignature(plan);
if (signature == _lastMirrorSignature)
{
_logger.LogDebug("班级小助手联动:课表内容与上次一致,跳过镜像");
_logger.LogDebug("ClassHelper 联动:课表内容与上次一致,跳过镜像");
return;
}
@@ -451,22 +463,22 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
if (!outcome.Applied)
{
_logger.LogInformation("班级小助手联动:课表镜像未生效({Reason})",
_logger.LogInformation("ClassHelper 联动:课表镜像未生效({Reason})",
outcome.Warnings.FirstOrDefault() ?? "服务端课表为空");
return;
}
_lastMirrorSignature = signature;
_logger.LogInformation("班级小助手联动:已把班级课表写入 ClassIsland({Plans})",
_logger.LogInformation("ClassHelper 联动:已把班级课表写入 ClassIsland({Plans})",
string.Join("、", outcome.PlanNames.Select(name => $"「{name}」")));
foreach (var warning in outcome.Warnings)
{
_logger.LogWarning("班级小助手联动镜像提示:{Warning}", warning);
_logger.LogWarning("ClassHelper 联动镜像提示:{Warning}", warning);
}
}
catch (Exception exception)
{
_logger.LogError(exception, "班级小助手联动:课表镜像失败");
_logger.LogError(exception, "ClassHelper 联动:课表镜像失败");
}
}
@@ -495,7 +507,7 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
/* ---------------------------------------------------------------- 内部实现 */
/// <summary>插件版本(与 manifest.yml / csproj 保持一致)。</summary>
private const string PluginVersion = "1.1.2.0";
private const string PluginVersion = "1.2.0.0";
/// <summary>ClassIsland 版本读不到时不该让上报失败(开发版可能为 null)。</summary>
private static string SafeAppVersion()
@@ -524,13 +536,13 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
catch (Exception exception)
{
// 课程服务在"应用启动中/课表未加载"时可能抛异常,这里降级为只有心跳的上报
_logger.LogWarning(exception, "班级小助手联动:读取课程状态失败,本次只上报心跳");
_logger.LogWarning(exception, "ClassHelper 联动:读取课程状态失败,本次只上报心跳");
return new StateDto { ClientTime = DateTimeOffset.Now.ToString("o") };
}
}
/// <summary>
/// 选一份"最有代表性"的时间表上报给班级小助手:**上课点最多的那一份**。
/// 选一份"最有代表性"的时间表上报给 ClassHelper:**上课点最多的那一份**。
///
/// 为什么不按"当前课表"选:镜像会给每个(星期 + 单双周)建一份时间表,
/// 于是"今天"那一份可能只有一两节课 —— 按当前课表选就会把只有一节的时间表报上去,
@@ -578,7 +590,7 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
}
catch (Exception exception)
{
_logger.LogWarning(exception, "班级小助手联动:读取档案失败");
_logger.LogWarning(exception, "ClassHelper 联动:读取档案失败");
return null;
}
}
@@ -12,14 +12,14 @@ using Microsoft.Extensions.Logging;
namespace ClassHelper.ClassIslandPlugin.Services;
/// <summary>
/// 提醒提供方:把老师在班级小助手上发布的提醒显示到 ClassIsland 上。
/// 提醒提供方:把老师在 ClassHelper上发布的提醒显示到 ClassIsland 上。
///
/// 与 <see cref="BridgeService"/> 的分工:
/// - <see cref="BridgeService"/> 负责"拉"(上报时顺路把待提醒带回来)与"回执"(ack);
/// - 本类只负责"显示"——提醒必须由提醒提供方发出,ClassIsland 才会走主界面的
/// 全屏特效 / 语音 / 音效那一整套流程。
///
/// 为什么不给这个提供方再配一套"提醒设置":本插件的开关全在「班级小助手联动」设置页里,
/// 为什么不给这个提供方再配一套"提醒设置":本插件的开关全在「ClassHelper 联动」设置页里,
/// 再在「提醒」设置页放第二份开关只会让老师困惑(两处都能关,到底以哪个为准)。
/// 因此这里用无设置的 <see cref="NotificationProviderBase"/>,读的是同一份 <see cref="PluginSettings"/>。
/// </summary>
@@ -27,9 +27,9 @@ namespace ClassHelper.ClassIslandPlugin.Services;
// 不是 "fluent(...)" 表达式):E8BD 是喇叭字形。
[NotificationProviderInfo(
ProviderGuidValue,
"班级小助手提醒",
"ClassHelper提醒",
"\uE8BD",
"显示老师从班级小助手下发的提醒。")]
"显示老师从 ClassHelper下发的提醒。")]
public sealed class ClassHelperNotificationProvider : NotificationProviderBase
{
/// <summary>
@@ -100,11 +100,11 @@ public sealed class ClassHelperNotificationProvider : NotificationProviderBase
_lessons.OnAfterSchool += (_, _) => Dispatcher.UIThread.Post(FlushDeferred);
_lessons.CurrentTimeStateChanged += (_, _) => Dispatcher.UIThread.Post(FlushDeferred);
_lessonsHooked = true;
_logger.LogInformation("班级小助手联动:提醒提供方已订阅课程事件(上课时段暂存、下课补弹)");
_logger.LogInformation("ClassHelper 联动:提醒提供方已订阅课程事件(上课时段暂存、下课补弹)");
}
catch (Exception exception)
{
_logger.LogWarning(exception, "班级小助手联动:订阅课程事件失败,将在下次提醒时重试");
_logger.LogWarning(exception, "ClassHelper 联动:订阅课程事件失败,将在下次提醒时重试");
}
}
@@ -137,7 +137,7 @@ public sealed class ClassHelperNotificationProvider : NotificationProviderBase
var pending = _deferred.ToList();
_deferred.Clear();
_logger.LogInformation("班级小助手联动:下课了,补弹 {Count} 条上课时段暂存的提醒", pending.Count);
_logger.LogInformation("ClassHelper 联动:下课了,补弹 {Count} 条上课时段暂存的提醒", pending.Count);
foreach (var item in pending) ShowOnUiThread(item);
}
@@ -151,7 +151,7 @@ public sealed class ClassHelperNotificationProvider : NotificationProviderBase
{
if (!_playing.Add(notification.Id))
{
_logger.LogDebug("班级小助手联动:提醒 {Id} 正在播放,跳过重复投递", notification.Id);
_logger.LogDebug("ClassHelper 联动:提醒 {Id} 正在播放,跳过重复投递", notification.Id);
return;
}
}
@@ -169,7 +169,7 @@ public sealed class ClassHelperNotificationProvider : NotificationProviderBase
if (IsInClass() && !IsImmediate(notification))
{
_deferred.Add(notification);
_logger.LogInformation("班级小助手联动:上课中,「{Title}」已暂存,下课后弹出", notification.Title);
_logger.LogInformation("ClassHelper 联动:上课中,「{Title}」已暂存,下课后弹出", notification.Title);
return;
}
@@ -177,7 +177,7 @@ public sealed class ClassHelperNotificationProvider : NotificationProviderBase
// 只有真正播完(或被用户关掉)才回执:中途退出 ClassIsland 时下次上报还会补发,不会静默丢失
AckWhenFinished(request, notification);
ShowNotification(request);
_logger.LogInformation("班级小助手联动:已弹出提醒「{Title}」({Seconds} 秒)",
_logger.LogInformation("ClassHelper 联动:已弹出提醒「{Title}」({Seconds} 秒)",
notification.Title, notification.DurationSeconds);
}
catch (Exception exception)
@@ -185,7 +185,7 @@ public sealed class ClassHelperNotificationProvider : NotificationProviderBase
// 显示失败不能让 ClassIsland 崩掉:记日志,且**不回执**,服务端下次轮询还会重发。
// 同时要把 id 从"正在播放"里放出来,否则本次失败后再也不会重试。
lock (_playing) _playing.Remove(notification.Id);
_logger.LogError(exception, "班级小助手联动:显示提醒「{Title}」失败", notification.Title);
_logger.LogError(exception, "ClassHelper 联动:显示提醒「{Title}」失败", notification.Title);
}
}
@@ -204,7 +204,7 @@ public sealed class ClassHelperNotificationProvider : NotificationProviderBase
var duration = TimeSpan.FromSeconds(seconds);
var maskDuration = TimeSpan.FromSeconds(Math.Min(notification.Urgent ? 5 : 3, seconds));
var title = string.IsNullOrWhiteSpace(notification.Title) ? "班级小助手" : notification.Title;
var title = string.IsNullOrWhiteSpace(notification.Title) ? "ClassHelper" : notification.Title;
var content = string.IsNullOrWhiteSpace(notification.Content) ? title : notification.Content;
// 语音:老师勾了朗读 + 本机允许朗读,两者都满足才念(本机开关是给学生机的总闸)
@@ -240,7 +240,7 @@ public sealed class ClassHelperNotificationProvider : NotificationProviderBase
return new NotificationRequest { MaskContent = mask, OverlayContent = overlay };
}
/// <summary>提醒播完 / 被关掉后回执给班级小助手,服务端不再补发。</summary>
/// <summary>提醒播完 / 被关掉后回执给 ClassHelper,服务端不再补发。</summary>
private void AckWhenFinished(NotificationRequest request, PushNotificationDto notification)
{
void Handler(object? sender, EventArgs args)
@@ -5,10 +5,10 @@ using ClassIsland.Shared.Models.Profile;
namespace ClassHelper.ClassIslandPlugin.Services;
/// <summary>
/// 把班级小助手拉回来的课表写进 ClassIsland 档案。
/// 把 ClassHelper拉回来的课表写进 ClassIsland 档案。
///
/// **核心约束**:ClassIsland 的一份 <see cref="ClassPlan"/> 只带**一个** <see cref="TimeRule"/>
/// (即一个星期几 + 一个单双周相位),而班级小助手的课表同时覆盖 7 天与单双周。
/// (即一个星期几 + 一个单双周相位),而 ClassHelper的课表同时覆盖 7 天与单双周。
/// 因此这里把服务端的条目**按(星期几 + 单双周)分组**,每一组生成一份独立的档案课表。
///
/// **时间点的构造方式**:ClassIsland 的 <c>ClassPlan.Classes[i]</c> 必须与
@@ -25,10 +25,10 @@ namespace ClassHelper.ClassIslandPlugin.Services;
public static class ClassPlanWriter
{
/// <summary>识别前缀:只有名字带此前缀的课表才由本插件创建与维护。</summary>
public const string PlanNamePrefix = "班级小助手-";
public const string PlanNamePrefix = "ClassHelper-";
/// <summary>时间表名字后缀。</summary>
public const string TimeLayoutNameSuffix = "(班级小助手)";
public const string TimeLayoutNameSuffix = "(ClassHelper)";
/// <summary>一次镜像的结果(写入的课表名与冲突提示)。</summary>
public sealed class ApplyResult
@@ -146,7 +146,7 @@ public static class ClassPlanWriter
{
result.Warnings.Add(
$"星期 {group.WeekDay} 已有启用的课表({string.Join("、", conflicts)})," +
"两者重叠时以班级小助手同步的课表为准");
"两者重叠时以 ClassHelper同步的课表为准");
}
}
@@ -183,7 +183,7 @@ public static class ClassPlanWriter
/// 按分组内的条目重建时间表:**每节课之间补一个课间**(最后一节之后不补)。
///
/// 课间 = 「上一节下课」到「下一节上课」之间的那段空隙,直接由两条课目的时间算出来,
/// 不依赖服务端有没有给课间点 —— 这样无论班级小助手那边是否维护节次时间表,
/// 不依赖服务端有没有给课间点 —— 这样无论 ClassHelper那边是否维护节次时间表,
/// ClassIsland 档案里的课表都会有一致的课间(需求:每节课的间隔都是课间,放学后除外)。
/// </summary>
private static (Guid Id, TimeLayout Layout) BuildTimeLayout(
@@ -226,7 +226,7 @@ public static class ClassPlanWriter
}
/// <summary>
/// 课表名:单组时用「班级小助手-&lt;班级名&gt;」;多组时补上星期与单双周以免互相覆盖。
/// 课表名:单组时用「ClassHelper-&lt;班级名&gt;」;多组时补上星期与单双周以免互相覆盖。
/// </summary>
private static string BuildPlanName(
string profileName,
@@ -235,7 +235,7 @@ public static class ClassPlanWriter
int weekCountDivTotal,
int groupCount)
{
var baseName = string.IsNullOrWhiteSpace(profileName) ? "班级小助手课表" : profileName.Trim();
var baseName = string.IsNullOrWhiteSpace(profileName) ? "ClassHelper课表" : profileName.Trim();
if (!baseName.StartsWith(PlanNamePrefix, StringComparison.Ordinal))
{
baseName = PlanNamePrefix + baseName;
@@ -10,7 +10,7 @@ namespace ClassHelper.ClassIslandPlugin.Services;
/// <summary>
/// 课表 / 状态 → 上报载荷 的换算层。
///
/// 这里集中全部"ClassIsland 口径 ↔ 班级小助手口径"的转换,保证只有一处需要跟着
/// 这里集中全部"ClassIsland 口径 ↔ ClassHelper口径"的转换,保证只有一处需要跟着
/// ClassIsland 的模型变化而修改:
/// - 星期:ClassIsland <c>WeekDay</c> 是 0=周日、1=周一…6=周六;本系统是 1=周一…7=周日;
/// - 单双周:ClassIsland <c>WeekCountDiv</c>/<c>WeekCountDivTotal</c>;本系统 ALL/ODD/EVEN;
@@ -91,7 +91,7 @@ public static class ScheduleMapper
var parity = ToWeekParity(plan.TimeRule.WeekCountDiv, plan.TimeRule.WeekCountDivTotal);
if (plan.TimeRule.WeekCountDivTotal > 2)
{
warning = "存在 3 周以上的轮换课表,班级小助手只支持单双周,已按「每周」处理";
warning = "存在 3 周以上的轮换课表,ClassHelper只支持单双周,已按「每周」处理";
}
var classes = plan.Classes;
@@ -102,7 +102,7 @@ public static class ScheduleMapper
// 索引必须与 Classes 对齐:这里按下标取时间点,**不能**先用 Where 过滤再取
var point = classPoints[index];
if (info is null) continue;
// 老师停用的一节:本系统没有"停用"概念,不上报(否则学生端会看到一节实际上不上的课)
// 老师停用的一节:本系统没有"停用"概念,不上报(否则 ClassHelper 班级端会看到一节实际上不上的课)
if (!info.IsEnabled) continue;
// 空课(未安排科目)不上报:服务端的科目是必填的,空课由课表里的缺席表达
@@ -15,10 +15,10 @@
<ScrollViewer>
<StackPanel Classes="settings-container animated-intro" Spacing="0">
<!-- 连接 -->
<TextBlock Text="连接班级小助手"
<TextBlock Text="连接 ClassHelper"
Theme="{StaticResource SubtitleTextBlockStyle}" />
<TextBlock Margin="0 6 0 12" TextWrapping="Wrap"
Text="填入班级小助手后端的地址与设备令牌。设备令牌在班级小助手 Web 端「ClassIsland 联动」页面里为本班新建设备后生成,只显示一次。" />
Text="填入 ClassHelper后端的地址与设备令牌。设备令牌在 ClassHelper Web 端「ClassIsland 联动」页面里为本班新建设备后生成,只显示一次。" />
<TextBox Watermark="http://127.0.0.1:4000"
Text="{Binding Settings.ServerUrl, Mode=TwoWay}" />
@@ -53,10 +53,10 @@
<TextBlock Margin="0 24 0 0" Text="同步内容"
Theme="{StaticResource SubtitleTextBlockStyle}" />
<TextBlock Margin="0 6 0 12" TextWrapping="Wrap"
Text="课表以「合并」方式写入班级小助手:同一星期、同一时间的课目会被更新,不会产生重复行。" />
Text="课表以「合并」方式写入 ClassHelper:同一星期、同一时间的课目会被更新,不会产生重复行。" />
<fa:SettingsExpander Header="上报课表到班级小助手"
Description="把 ClassIsland 里启用的课表同步给班级小助手,Web 端与桌面客户端即可看到课程表。">
<fa:SettingsExpander Header="自动上报课表到 ClassHelper"
Description="把 ClassIsland 里启用的课表**自动**同步给 ClassHelper。默认关闭:课表以 ClassHelper 为准,自动回传会在老师手排课之后被教室的旧课表悄悄覆盖。要取教室的课表,请在 Web 端点「从教室机器获取课表」——那是一次显式的人工请求,与本开关无关。">
<fa:SettingsExpander.IconSource>
<ci:FluentIconSource Glyph="&#xE8A5;" />
</fa:SettingsExpander.IconSource>
@@ -66,7 +66,7 @@
</fa:SettingsExpander>
<fa:SettingsExpander Header="把班级课表镜像回 ClassIsland"
Description="反方向同步:把班级小助手上排好的课表写回 ClassIsland,会新建一份以「班级小助手-」开头的档案课表,不会修改老师原有的课表。">
Description="反方向同步:把 ClassHelper上排好的课表写回 ClassIsland,会新建一份以「ClassHelper-」开头的档案课表,不会修改老师原有的课表。">
<fa:SettingsExpander.IconSource>
<ci:FluentIconSource Glyph="&#xE8F1;" />
</fa:SettingsExpander.IconSource>
@@ -101,10 +101,10 @@
<TextBlock Margin="0 24 0 0" Text="提醒"
Theme="{StaticResource SubtitleTextBlockStyle}" />
<TextBlock Margin="0 6 0 12" TextWrapping="Wrap"
Text="老师在班级小助手上发布的提醒会以 ClassIsland 的全屏提醒呈现;未弹出的提醒在应用重开后会自动补发,确认弹出后不再重复。" />
Text="老师在 ClassHelper上发布的提醒会以 ClassIsland 的全屏提醒呈现;未弹出的提醒在应用重开后会自动补发,确认弹出后不再重复。" />
<fa:SettingsExpander Header="接收班级小助手提醒"
Description="关闭后本机会忽略老师在班级小助手上发布的提醒(提醒仍会留在班级通知中心)。">
<fa:SettingsExpander Header="接收 ClassHelper提醒"
Description="关闭后本机会忽略老师在 ClassHelper上发布的提醒(提醒仍会留在班级通知中心)。">
<fa:SettingsExpander.IconSource>
<ci:FluentIconSource Glyph="&#xE8BD;" />
</fa:SettingsExpander.IconSource>
@@ -165,7 +165,7 @@
</StackPanel>
<TextBlock Margin="0 24 0 0" Opacity="0.6" TextWrapping="Wrap"
Text="提示:班级小助手 Web 端 →「ClassIsland 联动」可以新建/重置设备令牌、开关同步项,并实时看到本机当前上的课。" />
Text="提示:ClassHelper Web 端 →「ClassIsland 联动」可以新建/重置设备令牌、开关同步项,并实时看到本机当前上的课。" />
</StackPanel>
</ScrollViewer>
</controls:SettingsPageBase>
@@ -11,7 +11,7 @@ using CommunityToolkit.Mvvm.Input;
namespace ClassHelper.ClassIslandPlugin.Views;
/// <summary>
/// 「班级小助手联动」设置页。
/// 「ClassHelper 联动」设置页。
///
/// 页面上的绑定源就是本页自己(<c>DataContext = this</c>):<see cref="Settings"/> 暴露全部配置项,
/// <see cref="Bridge"/> 暴露运行状态。这样 XAML 里能直接写 <c>{Binding Settings.Xxx}</c>,
@@ -20,7 +20,7 @@ namespace ClassHelper.ClassIslandPlugin.Views;
// 图标用 Fluent 字体的码位(与 ClassIsland 内置设置页写法一致):未选中/选中同一枚插头图标
[SettingsPageInfo(
"classhelper.classisland.bridge.settings",
"班级小助手联动",
"ClassHelper 联动",
"\uE71B",
"\uE71B",
SettingsPageCategory.External)]