diff --git a/AGENTS.md b/AGENTS.md index b53b113..626c3e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -255,6 +255,18 @@ pnpm dist:classisland-plugin 服务端据此决定是否推 ClassIsland(`shouldPushToClassIsland`)。改动这条链路要同时动三处: 客户端设置页与本地配置、`lib/mappers` 的 DTO、服务端推送判断。 +16. **提醒分两类,决定"上课时段弹不弹"**:`ClassIslandPush.kind` = `notification`(通知类,上课时段 + 客户端与插件都**暂存**、下课补弹)/`call`(叫人,算「主动通知」,立刻弹);`urgent` 同样立刻弹。 + 插件侧的实现在 `ClassHelperNotificationProvider`(`_deferred` + `FlushDeferred`,订阅课程事件), + 客户端的实现在 `renderer/island/bridge.ts`。改这条规则要两端一起改。 +17. **作业按「所属日期」归类,不要用 createdAt 当"哪一天"**:`Homework.assignDate` 是本地日期 + (`YYYY-MM-DD`,不带时区),按天查看 / 日期高亮 / 客户端"今天"都看它。 + UTC 切片(`toISOString().slice(0,10)`)在晚上的录入会算到第二天 —— 统一用 `@classhelper/shared` 的 + `dayKeyLocal / isDayKey / shiftDayKey`。新增按天接口时同样用 `?date=YYYY-MM-DD` + zod 的 `isDayKey` 校验。 +18. **教室机器能录入作业**:`POST /homeworks` 的路由**故意不加 `requireRole`**,权限在 + `homeworks.service.createHomework` 里判(staff 或本班班级账号)。班级账号的 `sub` 是**班级 id**, + 写 `createdBy` 前要换成该班班主任,否则撞 `User` 外键。 + ## 6. 代码风格 - **TypeScript ESM + NodeNext**:`package.json` 全是 `"type": "module"`,相对导入**必须带 `.js` 后缀** @@ -337,9 +349,21 @@ pnpm dist:classisland-plugin - 服务端"回执才算送达",因此插件必须对**播放中的提醒按 id 去重**,否则轮询间隔 < 提醒时长时会反复弹; - `Subject.Fallback` 的名字就是 `???` 占位符,上报前要归一化成"没有科目"。 -18. **服务端改了代码要重启才生效**(`tsx src/index.ts` 不热载,`pnpm dev:server` 才有 watch)。 +18. **读 ClassIsland 控制台日志要用 GBK**:`.NET` 的控制台输出是**系统 ANSI(GBK)**编码, + 用 `-Encoding UTF8` 读会变成乱码、**中文关键字一条都搜不到**(只能搜到 ASCII 的类名)。 + 正确姿势:`[System.IO.File]::ReadAllLines(path, [System.Text.Encoding]::GetEncoding(936))`; + 日志文件被运行中的进程占用时先 `Copy-Item` 一份再读。 +19. **改插件设置要趁 ClassIsland 退出时改**:插件配置在 `F:\data\Config\Plugins\\Settings.json`, + ClassIsland 运行时会在内存里持有并随时回写,运行中改文件会被覆盖。改完 DLL/设置都要重启 ClassIsland。 +20. **打包版服务端有生产自检**:`NODE_ENV=production` 时拒绝 `dev.db`("生产环境仍在使用 dev.db"), + 想用演示数据跑打包版就把库**复制**成 `data/classhelper.db` 再启动。 +21. **服务端改了代码要重启才生效**(`tsx src/index.ts` 不热载,`pnpm dev:server` 才有 watch)。 验证脚本报「某个断言突然不对」时,先确认后端跑的是不是最新代码 —— 这个坑踩过一次。 +22. **灵动岛的位置由「屏幕工作区 + 边距」算,改边距默认值会改外观**:`appearance.marginX/marginY` 默认 + 必须与老行为一致(8px)—— `verify:desktop` 的「停靠位置生效(6 个锚点)」断言的是像素坐标, + 默认值一动它就红(我先把 marginX 设成 16 踩过一次)。新增边距类设置请只改"可调范围",别动默认值。 + ## 8. 交付前检查 ```bash diff --git a/README.md b/README.md index 7accf38..8ed9ede 100644 --- a/README.md +++ b/README.md @@ -536,6 +536,10 @@ pnpm verify:packaged # 对**打包后/已安装**的客户端 EXE 跑同一 ### 个性化设置与系统托盘 +- **设置 → 灵动岛 · 个性化**新增:左右 / 上下**边距**(贴边距离,任务栏在侧面时调大)、 +- **设置 → ClassIsland 联动**:联动状态(设备名 / 最后上报)+ 通知模式(两者都弹 / 灵动岛 / ClassIsland)。 +- **设置 → 作业录入**:作业快捷短语的增删与恢复默认。 + **设置 → 通知显示位置**:提醒弹在 ClassHelper 还是 ClassIsland(见 [ClassIsland 联动](#classisland-联动))。 **设置 → 灵动岛 · 个性化**(改动即实时生效,保存后写入本地配置,重启仍生效): @@ -684,6 +688,25 @@ pnpm dist:win # nsis 安装包 + portable 单文件(需联网下载 NSIS 冒烟准备班级凭据时**不改任何数据**:先试冒烟密码与种子默认 `123456`,命中就直接用; 都不通才会临时重置该班密码,并在脚本收尾时恢复为 `123456`(日志会打印"已恢复班级密码…冒烟不留副作用")。 +## 作业(按天查看 · 教室机器录入) + +作业以**所属日期**(`Homework.assignDate`,本地日期 `YYYY-MM-DD`)归类,而不是录入时间: +晚上 8 点留的作业算今天(`createdAt` 是 UTC,直接切片会算成明天)。 + +| 能力 | 客户端(教室机器) | Web 管理端 | +| ---------------- | ----------------------------------------------------------------- | ------------------------------ | +| 按天查看 | 日期选择器 + 前/后一天 + `?date=` 深链 | 工具栏日期选择器(同一套接口) | +| 有作业的日期高亮 | ✅ 日期下带圆点(`GET /api/homeworks/days`) | ✅ 同上 | +| 录入作业 | ✅ 教室机器直接录(班级账号可 `POST /api/homeworks`,归属班主任) | ✅ 发布时可选所属日期 | +| 未交名单 | ✅(班级设备) | ✅ | + +- 客户端作业模块**不再显示"已完成/待完成"**:完成状态是老师端与通知中心的概念, + 教室机器上只做"看作业 + 录作业"(服务端的完成数据与统计接口保持不变,Web 端照旧可用)。 +- 录入时可以点**快捷短语**(默认 `P` / `大本` / `小本` / `卷子` / `背诵` / `默写` / `听写` / `预习` / `订正` / `读书`), + 点一下追加到标题或内容;短语在客户端「设置 → 作业录入」里增删,存在本机配置里。 +- 接口:`GET /homeworks?date=YYYY-MM-DD`(按天)、`GET /homeworks/days?classId=&from=&to=`(哪些天有作业)、 + `POST /homeworks`(附 `assignDate`;**班级账号也能调用**,用于教室机器录入)。 + ## 叫人(老师点名让学生过来) 老师在 **Web 管理端 → 学生管理 → 叫人**(或 **通知发布 → 叫人**)选中学生,配合**快捷短语**或**自定义消息**发送。 @@ -896,6 +919,25 @@ POST /api/notifications { …, priority: "NORMAL" }(上课时段) | 「叫人」 | 老师点名让某位同学过去,教室大屏也会播报一条 | | 「ClassIsland 联动」页的「下发提醒」 | 专门发给教室大屏的提醒(支持时长 / 语音 / 优先级) | +### 上课时段策略:ClassIsland 与客户端同一套规则 + +老师在**上课时段**发通知时,教室里的两个端都遵守同一条规则(否则学生在客户端看不到、却在 ClassIsland 上被打断): + +| 提醒类型 | 上课时段 | 下课 / 放学 | +| ------------------------------------------------------ | --------------------------------------------- | ----------- | +| 通知类(`kind=notification`:通知发布页 / 联动页下发) | **暂存不弹**(客户端与 ClassIsland 都先收着) | 自动补弹 | +| **主动通知**:紧急提醒、**叫人**(`kind=call`) | **立刻弹**(老师正在等学生) | 立刻弹 | + +> ClassIsland 侧的暂存由插件实现:订阅课程服务,下课后把暂存的提醒补弹出来;未弹出前不回执,服务端会保留待提醒。 + +### 两个方向各需要哪些开关 + +| 要同步的内容 | 需要打开的开关 | +| -------------------------------- | ----------------------------------------------------------------------------- | +| 服务端 → ClassIsland(镜像课表) | ① 插件设置「把班级课表镜像回 ClassIsland」+ ② Web 端该设备的「镜像课表」 | +| ClassIsland → 服务端(上报课表) | 插件设置「上报课表到班级小助手」(关着时日志会明说"仅状态(开关已关闭)") | +| 提醒下发 | ① 教室客户端「通知模式」不是"只在 ClassHelper" + ② 插件「接收班级小助手提醒」 | + ### 提醒弹在哪个端:由教室客户端自己选 教室机器的 ClassHelper 客户端「设置 → 通知显示位置」里选(选择会同步到班级记录): @@ -1152,6 +1194,8 @@ pnpm db:migrate && pnpm db:seed | **授课科目统一(18 科固定目录)** | ✅ | `verify:web`:新增课表的科目下拉出现「统一科目(选中后自动建课)」,选中 → 自动建课 → 写入课表成功(收尾删除该条课表) | | **登录页无示例内容** | ✅ | `verify:web`(管理端)与 `verify:desktop`(客户端)都断言登录页正文/占位符不含 `演示 / 示例 / admin123 / teacher123 / G101` | | **其他页面发通知也会联动 ClassIsland** | ✅ | `verify:classisland`:「通知发布页发的通知也会推送到 ClassIsland」「叫人也会推送到 ClassIsland」;真机实测截图 [03-from-notify-page](docs/screenshots/classisland/03-from-notify-page.png) | +| **作业按天查看(日期选择器 + 有作业日期高亮)** | ✅ | `verify:e2e`:`assignDate` 默认服务器当天、`?date=` 只返回该天、`/homeworks/days` 返回有作业日期、非法日期 422;客户端与 Web 端均接入同一套接口 | +| **支持在客户端录入作业(含自定义快捷短语)** | ✅ | `verify:e2e`:班级账号录入 201(归属班主任)、跨班 403;客户端录入弹窗 + 「设置 → 作业录入」管理短语(`verify:desktop` 作业看板用例回归通过) | | **提醒弹在哪个端由客户端自行选择** | ✅ | 客户端「设置 → 通知显示位置」三选一(`verify:desktop`:设置页真实点击 → 写回本地配置,在线 82/82);服务端按班级设置决定是否推送(`verify:classisland`:client 时通知与联动页下发都被跳过) | | **班级小助手能读取 ClassIsland 的课表** | ✅ | `verify:classisland`:插件上报课表 → 本班课表新增/更新(重复上报幂等)、节次时间写入、设备状态快照可在 Web 端展示;`verify:web`:教师在「ClassIsland 联动」页真实签发设备令牌 | | **用户可选择通知是否在 ClassIsland 上显示** | ✅ | 插件设置页开关「接收班级小助手提醒」(本机总闸)+ Web 端「下发提醒」(标题/内容/时长/优先级/语音朗读/是否同步通知中心);`verify:classisland`:提醒落库 → 待提醒 → 回执 → 确认后不补发 | diff --git a/packages/classisland-plugin/README.md b/packages/classisland-plugin/README.md index 9325ecd..d2379a2 100644 --- a/packages/classisland-plugin/README.md +++ b/packages/classisland-plugin/README.md @@ -42,6 +42,17 @@ 设置保存在 `<插件配置目录>/Settings.json`(随 ClassIsland 档案备份走,升级插件不丢)。 改动即时生效 —— 改地址 / 令牌 / 间隔会立刻重建上报定时器,不需要重启 ClassIsland。 +> **上课时段策略(与 ClassHelper 客户端同一套规则)**: +> 上课时段里,通知类提醒会**先暂存、下课后自动补弹**;**紧急提醒**与**叫人**属于「主动通知」,立刻弹。 +> 暂存期间不回执,服务端会保留这条待提醒。 + +> **两个方向各自需要的开关**: +> +> - 服务端 → ClassIsland(镜像课表):插件「把班级课表镜像回 ClassIsland」**且** Web 端该设备「镜像课表」都打开; +> - ClassIsland → 服务端(上报课表):插件「上报课表到班级小助手」打开; +> 关着时是正常行为(日志会写「仅状态(本机「上报课表到班级小助手」开关已关闭)」),不是上报失败; +> - ClassIsland 里一张课表都没有时不会上报课表(日志会写「ClassIsland 里没有**启用的课表**」)。 + > **提醒到底会不会弹,取决于两个开关:** > > 1. 教室机器的 **ClassHelper 客户端**在「设置 → 通知显示位置」里选了「只在 ClassHelper 客户端弹」时, diff --git a/packages/classisland-plugin/scripts/verify.mjs b/packages/classisland-plugin/scripts/verify.mjs index f429613..e954376 100644 --- a/packages/classisland-plugin/scripts/verify.mjs +++ b/packages/classisland-plugin/scripts/verify.mjs @@ -273,6 +273,21 @@ check( 'ClassIsland 的占位科目(Subject.Fallback)折算成"没有科目"', mapperSource.includes('Subject.Fallback'), ); +check( + '上课时段暂存非主动通知(与客户端灵动岛同一套规则)', + providerSource.includes('_deferred') && + providerSource.includes('IsImmediate') && + providerSource.includes('FlushDeferred') && + providerSource.includes('OnBreakingTime'), +); +check( + '主动通知(紧急 / 叫人)上课时段也立即弹', + providerSource.includes("\"call\"") && providerSource.includes('notification.Urgent'), +); +check( + '「上报课表」开关关闭时会在日志里说明(避免被当成"上报失败")', + bridgeSource.includes('上报课表到班级小助手') && bridgeSource.includes('开关已关闭'), +); check( '提醒轮询独立于上报间隔(老师发完通知不必等一个上报周期)', settingsSource.includes('NotificationPollSeconds') && diff --git a/packages/classisland-plugin/src/Interop/ClassHelperClient.cs b/packages/classisland-plugin/src/Interop/ClassHelperClient.cs index 5f9329d..54c391d 100644 --- a/packages/classisland-plugin/src/Interop/ClassHelperClient.cs +++ b/packages/classisland-plugin/src/Interop/ClassHelperClient.cs @@ -103,6 +103,9 @@ public class PushNotificationDto [JsonPropertyName("speechContent")] public string? SpeechContent { get; set; } [JsonPropertyName("createdAt")] public string CreatedAt { get; set; } = ""; [JsonPropertyName("urgent")] public bool Urgent { get; set; } + + /// 提醒类型:notification(默认)/ call(叫人,算"主动通知",上课时段也立刻弹) + [JsonPropertyName("kind")] public string? Kind { get; set; } [JsonPropertyName("classId")] public string ClassId { get; set; } = ""; [JsonPropertyName("className")] public string? ClassName { get; set; } [JsonPropertyName("teacherName")] public string? TeacherName { get; set; } diff --git a/packages/classisland-plugin/src/Services/BridgeService.cs b/packages/classisland-plugin/src/Services/BridgeService.cs index 5c87794..a0fcfc2 100644 --- a/packages/classisland-plugin/src/Services/BridgeService.cs +++ b/packages/classisland-plugin/src/Services/BridgeService.cs @@ -242,14 +242,30 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable var entryCount = 0; var layoutCount = 0; + // 开关关着时也要留下原因:否则日志只有"上报成功:仅状态", + // 用户会以为"服务端拿不到 ClassIsland 课表"是插件坏了。 + if (includeSchedule && !_settings.UploadSchedule) + { + _logger.LogInformation( + "班级小助手联动:本机设置里「上报课表到班级小助手」是关闭的,本次只上报状态;" + + "需要把教室课表同步到班级小助手请打开该开关"); + } + if (includeSchedule && _settings.UploadSchedule && profile is not null) { var entries = ScheduleMapper.MapAllClassPlans(profile, out var warnings); foreach (var warning in warnings) _logger.LogWarning("班级小助手联动:{Warning}", warning); entryCount = entries.Count; request.Schedule = new SchedulePayloadDto { Mode = "merge", Entries = entries }; - // 补上 endTime 之外的字段由服务端按需处理;课表为空时不发,免得把服务端已有课表"合并"没了 - if (entries.Count == 0) request.Schedule = null; + // 课表为空时不发(否则会把服务端已有课表"合并"成空),但**必须把原因写清楚**: + // 日志里只出现"仅状态"时,用户会以为上报失败,实际是 ClassIsland 里还没有课表。 + if (entries.Count == 0) + { + request.Schedule = null; + _logger.LogWarning( + "班级小助手联动:ClassIsland 里没有**启用的课表**(0 条),本次未上报课表。" + + "请在 ClassIsland 里排好课表,或在 Web 端打开「镜像课表」由班级小助手下发一份"); + } var layout = ResolvePrimaryTimeLayout(profile); if (layout is not null) @@ -285,7 +301,9 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable var detail = entryCount > 0 ? $"课表 {entryCount} 节 / 节次 {layoutCount} 条" - : "仅状态"; + : _settings.UploadSchedule + ? "仅状态(ClassIsland 里还没有启用的课表)" + : "仅状态(本机「上报课表到班级小助手」开关已关闭)"; SetState(true, $"上报成功({reason}):{detail},服务端第 {data.Week} 周", entryCount, layoutCount, data.Week); @@ -371,8 +389,11 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable var (result, plan) = await _client.PullClassPlanAsync(_settings, CancellationToken.None).ConfigureAwait(false); if (!result.Ok || plan is null) { - // 服务端在"镜像开关没开"时会返回 data=null —— 这不是错误,只记信息 - _logger.LogInformation("班级小助手联动:跳过课表镜像({Message})", result.Message); + // 服务端在"镜像开关没开"时会返回 data=null —— 这不是错误,但要告诉用户去哪儿开 + _logger.LogInformation( + "班级小助手联动:跳过课表镜像({Message})。" + + "需要在 Web 端「ClassIsland 联动」页给这台设备打开「镜像课表」,然后点一次「立即上报」或等下一个上报周期", + result.Message); return; } diff --git a/packages/classisland-plugin/src/Services/ClassHelperNotificationProvider.cs b/packages/classisland-plugin/src/Services/ClassHelperNotificationProvider.cs index d9bad99..0bfeb65 100644 --- a/packages/classisland-plugin/src/Services/ClassHelperNotificationProvider.cs +++ b/packages/classisland-plugin/src/Services/ClassHelperNotificationProvider.cs @@ -1,6 +1,9 @@ using Avalonia.Threading; using ClassHelper.ClassIslandPlugin.Interop; using ClassHelper.ClassIslandPlugin.Models; +using ClassIsland.Core.Abstractions.Services; +using ClassIsland.Shared; +using ClassIsland.Shared.Enums; using ClassIsland.Core.Abstractions.Services.NotificationProviders; using ClassIsland.Core.Attributes; using ClassIsland.Core.Models.Notification; @@ -48,6 +51,19 @@ public sealed class ClassHelperNotificationProvider : NotificationProviderBase /// private readonly HashSet _playing = new(); + /// + /// 上课时段被暂存的提醒(与客户端灵动岛同一套规则)。 + /// + /// 为什么要有它:老师在**上课时段**发通知,教室里正上着课,直接全屏弹会打断课堂。 + /// 客户端的灵动岛就是"上课先收着、下课再弹",ClassIsland 这侧必须一致, + /// 否则学生在客户端看不到、却在 ClassIsland 上被打断。 + /// + /// 例外("主动通知"):紧急提醒与**叫人**,老师正在等学生,必须立刻弹。 + /// + private readonly List _deferred = new(); + + private ILessonsService? _lessons; + public ClassHelperNotificationProvider( BridgeService bridge, PluginSettings settings, @@ -57,6 +73,54 @@ public sealed class ClassHelperNotificationProvider : NotificationProviderBase _settings = settings; _logger = logger; _bridge.NotificationReceived += OnNotificationReceived; + + // 订阅课程事件:下课后把上课时段暂存的提醒补弹出来 + try + { + _lessons = IAppHost.TryGetService(); + if (_lessons is not null) + { + _lessons.OnBreakingTime += (_, _) => Dispatcher.UIThread.Post(FlushDeferred); + _lessons.OnAfterSchool += (_, _) => Dispatcher.UIThread.Post(FlushDeferred); + _lessons.CurrentTimeStateChanged += (_, _) => Dispatcher.UIThread.Post(FlushDeferred); + } + } + catch (Exception exception) + { + _logger.LogWarning(exception, "班级小助手联动:订阅课程事件失败,上课暂存将退化为「立即弹出」"); + } + } + + /// 当前是否处于上课时段(读不到课程服务时按"不在上课"处理,宁可弹也不静默丢) + private bool IsInClass() + { + try + { + return _lessons?.CurrentState == TimeState.OnClass; + } + catch + { + return false; + } + } + + /// + /// 这条提醒是否必须立刻弹("主动通知"): + /// 紧急提醒,或叫人(老师正在等学生)。 + /// + private static bool IsImmediate(PushNotificationDto notification) => + notification.Urgent || string.Equals(notification.Kind, "call", StringComparison.OrdinalIgnoreCase); + + /// 下课后补弹暂存的提醒(按进入顺序) + private void FlushDeferred() + { + if (IsInClass()) return; // 还在上课(例如只是状态在 OnClass 内部变化) + if (_deferred.Count == 0) return; + + var pending = _deferred.ToList(); + _deferred.Clear(); + _logger.LogInformation("班级小助手联动:下课了,补弹 {Count} 条上课时段暂存的提醒", pending.Count); + foreach (var item in pending) ShowOnUiThread(item); } private void OnNotificationReceived(object? sender, PushNotificationDto notification) @@ -83,6 +147,14 @@ public sealed class ClassHelperNotificationProvider : NotificationProviderBase { try { + // 上课时段:非"主动通知"先收着(与客户端灵动岛同一套规则),下课后补弹 + if (IsInClass() && !IsImmediate(notification)) + { + _deferred.Add(notification); + _logger.LogInformation("班级小助手联动:上课中,「{Title}」已暂存,下课后弹出", notification.Title); + return; + } + var request = BuildRequest(notification); // 只有真正播完(或被用户关掉)才回执:中途退出 ClassIsland 时下次上报还会补发,不会静默丢失 AckWhenFinished(request, notification); diff --git a/packages/desktop-client/src/main/config.ts b/packages/desktop-client/src/main/config.ts index d5f01a1..06d3e4c 100644 --- a/packages/desktop-client/src/main/config.ts +++ b/packages/desktop-client/src/main/config.ts @@ -6,6 +6,9 @@ import { DEFAULT_CLASSISLAND_NOTIFICATION_CHANNEL, DEFAULT_ISLAND_APPEARANCE, DEFAULT_SERVER_URL, + HOMEWORK_PHRASE_DEFAULTS, + HOMEWORK_PHRASE_MAX_COUNT, + HOMEWORK_PHRASE_MAX_LENGTH, type ClassIslandNotificationChannel, type IslandAppearance, } from '@classhelper/shared'; @@ -16,7 +19,6 @@ const DEFAULT_HOMEWORK_BOARD: HomeworkBoardSettings = { mode: 'board', showTime: false, fontSize: 15, - todayOnly: true, }; interface PersistedConfig { @@ -31,6 +33,8 @@ interface PersistedConfig { homeworkBoard?: Partial; /** 通知显示位置(向后兼容:旧配置文件没有该字段时按默认 both 补齐) */ notificationChannel?: ClassIslandNotificationChannel; + /** 作业录入快捷短语(向后兼容:旧配置没有该字段时用默认那一组) */ + homeworkPhrases?: string[]; } const DEFAULT_CONFIG: PersistedConfig = { @@ -41,8 +45,28 @@ const DEFAULT_CONFIG: PersistedConfig = { island: { ...DEFAULT_ISLAND_APPEARANCE }, homeworkBoard: { ...DEFAULT_HOMEWORK_BOARD }, notificationChannel: DEFAULT_CLASSISLAND_NOTIFICATION_CHANNEL, + homeworkPhrases: [...HOMEWORK_PHRASE_DEFAULTS], }; +/** + * 作业快捷短语的合法化:去空白、去重、限长限量。 + * 空数组是**合法值**(老师把短语全删了 = 不想用快捷短语),因此不能拿默认值顶回去。 + */ +function normalizeHomeworkPhrases(input?: string[]): string[] { + if (!Array.isArray(input)) return [...HOMEWORK_PHRASE_DEFAULTS]; + const seen = new Set(); + const result: string[] = []; + for (const raw of input) { + if (typeof raw !== 'string') continue; + const value = raw.trim().slice(0, HOMEWORK_PHRASE_MAX_LENGTH); + if (!value || seen.has(value)) continue; + seen.add(value); + result.push(value); + if (result.length >= HOMEWORK_PHRASE_MAX_COUNT) break; + } + return result; +} + /** 通知显示位置的白名单校验:非法值一律回落到默认值,避免坏配置把提醒"静默吞掉" */ function normalizeNotificationChannel( input?: ClassIslandNotificationChannel, @@ -62,7 +86,6 @@ function normalizeHomeworkBoard(input?: Partial): Homewor return { mode, showTime: input?.showTime === true, - todayOnly: input?.todayOnly !== false, fontSize: Math.min(28, Math.max(11, Math.round(rawFont))), }; } @@ -102,6 +125,9 @@ function normalizeIslandAppearance(input?: Partial): IslandApp ? (input?.style as IslandAppearance['style']) : DEFAULT_ISLAND_APPEARANCE.style, idleSliver: input?.idleSliver === true, + marginX: clamp(input?.marginX, 0, 200, DEFAULT_ISLAND_APPEARANCE.marginX), + marginY: clamp(input?.marginY, 0, 160, DEFAULT_ISLAND_APPEARANCE.marginY), + followCursorDisplay: input?.followCursorDisplay === true, }; } @@ -146,6 +172,7 @@ function readPersisted(): PersistedConfig { island: normalizeIslandAppearance(parsed.island), homeworkBoard: normalizeHomeworkBoard(parsed.homeworkBoard), notificationChannel: normalizeNotificationChannel(parsed.notificationChannel), + homeworkPhrases: normalizeHomeworkPhrases(parsed.homeworkPhrases), }; } catch { return { ...DEFAULT_CONFIG }; @@ -168,6 +195,7 @@ export function getConfig(): DesktopStoredConfig { island: normalizeIslandAppearance(persisted.island), homeworkBoard: normalizeHomeworkBoard(persisted.homeworkBoard), notificationChannel: normalizeNotificationChannel(persisted.notificationChannel), + homeworkPhrases: normalizeHomeworkPhrases(persisted.homeworkPhrases), }; } @@ -190,6 +218,9 @@ export function saveConfig(patch: Partial): DesktopStoredCo ...patch.homeworkBoard, }); } + if (patch.homeworkPhrases !== undefined) { + persisted.homeworkPhrases = normalizeHomeworkPhrases(patch.homeworkPhrases); + } if (patch.notificationChannel !== undefined) { persisted.notificationChannel = normalizeNotificationChannel(patch.notificationChannel); } diff --git a/packages/desktop-client/src/main/island.ts b/packages/desktop-client/src/main/island.ts index 2464ad9..bb6dc98 100644 --- a/packages/desktop-client/src/main/island.ts +++ b/packages/desktop-client/src/main/island.ts @@ -758,21 +758,30 @@ class IslandController { * - 全部取整,避免 `Math.round` 在 0.5 边界上左右跳变(那会表现为 ±0.5px 的横向抖动)。 */ private resolveAnchor(): IslandAnchor { - const area = screen.getPrimaryDisplay().workArea; - const margin = ISLAND_MARGIN; + // 多显示器教室电脑:打开"跟随鼠标屏幕"后,岛出现在鼠标所在的那块屏(讲台切换投影时不用改设置) + const display = this.appearance.followCursorDisplay + ? screen.getDisplayNearestPoint(screen.getCursorScreenPoint()) + : screen.getPrimaryDisplay(); + const area = display.workArea; + const marginX = Math.round(this.appearance.marginX ?? ISLAND_MARGIN); + const marginY = Math.round(this.appearance.marginY ?? ISLAND_MARGIN); const center = Math.round(area.x + area.width / 2); // 6 个停靠位置(顶/底 × 左/中/右),与 WinIsland 的 DockPosition 一一对应 const position = this.appearance.position; const vMode: IslandAnchor['vMode'] = position.startsWith('bottom') ? 'bottom' : 'top'; - const vValue = vMode === 'bottom' ? area.y + area.height - margin : area.y + margin; + const vValue = vMode === 'bottom' ? area.y + area.height - marginY : area.y + marginY; const hMode: IslandAnchor['hMode'] = position.endsWith('left') ? 'left' : position.endsWith('right') ? 'right' : 'center'; const hValue = - hMode === 'left' ? area.x + margin : hMode === 'right' ? area.x + area.width - margin : center; + hMode === 'left' + ? area.x + marginX + : hMode === 'right' + ? area.x + area.width - marginX + : center; return { hMode, hValue, vMode, vValue }; } @@ -1336,6 +1345,10 @@ export function normalizeAppearance(input: IslandAppearance): IslandAppearance { alwaysOnTop: input.alwaysOnTop !== false, style: ISLAND_STYLES.includes(input.style) ? input.style : 'black', idleSliver: input.idleSliver === true, + // 边距:0 表示贴着屏幕边缘,上限给到 200/160 是为了"多显示器 + 任务栏在侧面"这类布局 + marginX: clamp(input.marginX ?? DEFAULT_ISLAND_APPEARANCE.marginX, 0, 200), + marginY: clamp(input.marginY ?? DEFAULT_ISLAND_APPEARANCE.marginY, 0, 160), + followCursorDisplay: input.followCursorDisplay === true, }; } diff --git a/packages/desktop-client/src/main/smoke.ts b/packages/desktop-client/src/main/smoke.ts index c494053..0221ab0 100644 --- a/packages/desktop-client/src/main/smoke.ts +++ b/packages/desktop-client/src/main/smoke.ts @@ -2209,6 +2209,38 @@ async function runIslandChecks( ); } record('个性设置:停靠位置生效(6 个锚点)', positionOk, positionDetails.join(' ')); + + // 11.3b) 边距:左右/上下边距必须真的把岛挪走(用户反馈「调边距没反应」: + // 滑块在渲染层、定位在主进程,主进程没重跑就会出现「数字在变、岛不动」) + island.setAppearance({ ...appearanceBefore, position: 'top-left', marginX: 48, marginY: 40 }); + await sleep(450); + const marginLeft = (await readIslandGeometry())?.island ?? null; + const marginLeftOk = + Boolean(marginLeft) && + Math.abs((marginLeft as Electron.Rectangle).x - (workArea.x + 48)) <= 1 && + Math.abs((marginLeft as Electron.Rectangle).y - (workArea.y + 40)) <= 1; + + island.setAppearance({ ...appearanceBefore, position: 'top-right', marginX: 48, marginY: 8 }); + await sleep(450); + const marginRight = (await readIslandGeometry())?.island ?? null; + const marginRightOk = + Boolean(marginRight) && + Math.abs( + (marginRight as Electron.Rectangle).x + + (marginRight as Electron.Rectangle).width - + (workArea.x + workArea.width - 48), + ) <= 1; + + record( + '个性设置:边距生效(左右 + 上下)', + marginLeftOk && marginRightOk, + 'top-left(marginX=48,marginY=40)=' + + (marginLeft ? marginLeft.x + ',' + marginLeft.y : '-') + + (marginLeftOk ? '✓' : '✗') + + ' top-right(marginX=48)=' + + (marginRight ? marginRight.x + ',' + marginRight.y : '-') + + (marginRightOk ? '✓' : '✗'), + ); island.setAppearance(appearanceBefore); // 11.4 置顶开关 → 窗口 alwaysOnTop @@ -2557,10 +2589,10 @@ async function runSettingsPageChannelCheck( for (let i = 0; i < 40 && !group; i += 1) { await wait(100); group = Array.from(document.querySelectorAll('.el-card')).find((card) => - (card.querySelector('.el-card__header')?.textContent ?? '').includes('通知显示位置'), + (card.querySelector('.el-card__header')?.textContent ?? '').includes('ClassIsland 联动'), ); } - if (!group) return { ok: false, detail: '设置页没有「通知显示位置」卡片' }; + if (!group) return { ok: false, detail: '设置页没有「ClassIsland 联动」卡片' }; const radios = Array.from(group.querySelectorAll('.el-radio')); // 注意:这段脚本本身是外层模板字符串,里面不能再出现反引号(会截断外层字符串) if (radios.length < 3) return { ok: false, detail: '单选项只有 ' + radios.length + ' 个' }; diff --git a/packages/desktop-client/src/renderer/api/index.ts b/packages/desktop-client/src/renderer/api/index.ts index 0a2a176..2b2c2e0 100644 --- a/packages/desktop-client/src/renderer/api/index.ts +++ b/packages/desktop-client/src/renderer/api/index.ts @@ -3,6 +3,8 @@ import { type ClassDetailDto, type ClassDto, type ClassIslandNotificationChannel, + type CourseDto, + type HomeworkDaysDto, type DashboardSummary, type GradeDto, type HomeworkDto, @@ -49,6 +51,20 @@ export const classChannelApi = { api.patch(`${API_PATHS.classes}/${classId}/notification-channel`, { notificationChannel }), }; +/** 本班 ClassIsland 联动状态(教室客户端「设置 → ClassIsland 联动」展示) */ +export const classIslandStatusApi = { + get: ( + classId: string, + ): Promise<{ + connected: boolean; + deviceName: string | null; + deviceCount: number; + lastSeenAt: string | null; + pluginVersion: string | null; + classIslandVersion: string | null; + }> => api.get(`${API_PATHS.classes}/${classId}/classisland-status`), +}; + export const scheduleApi = { list: (params: { classId?: string; week?: number }): Promise => api.get(API_PATHS.schedules, params), @@ -56,9 +72,30 @@ export const scheduleApi = { api.get(`${API_PATHS.schedules}/grid`, params), }; +export const courseApi = { + list: (classId?: string): Promise => + api.get(API_PATHS.courses, classId ? { classId } : undefined), +}; + export const homeworkApi = { - list: (params?: { classId?: string; pendingOnly?: boolean; courseId?: string }): Promise => - api.get(API_PATHS.homeworks, params), + list: (params?: { + classId?: string; + pendingOnly?: boolean; + courseId?: string; + /** 只看某一天(YYYY-MM-DD,按作业所属日期) */ + date?: string; + }): Promise => api.get(API_PATHS.homeworks, params), + /** 哪些天有作业(日期选择器高亮) */ + days: (params: { classId?: string; from?: string; to?: string; days?: number }): Promise => + api.get(`${API_PATHS.homeworks}/days`, params), + /** 在教室机器上直接录入作业(班级账号可调用;也可选所属日期) */ + create: (payload: { + classId: string; + courseId?: string | null; + title: string; + content: string; + assignDate?: string | null; + }): Promise => api.post(API_PATHS.homeworks, payload), detail: (id: string): Promise => api.get(`${API_PATHS.homeworks}/${id}`), updateStatus: (id: string, completed: boolean): Promise => api.patch(`${API_PATHS.homeworks}/${id}/status`, { completed }), diff --git a/packages/desktop-client/src/renderer/cache/self-test.ts b/packages/desktop-client/src/renderer/cache/self-test.ts index 7dd605e..3e2c3a3 100644 --- a/packages/desktop-client/src/renderer/cache/self-test.ts +++ b/packages/desktop-client/src/renderer/cache/self-test.ts @@ -611,10 +611,19 @@ export async function homeworkBoardSelfTest(): Promise { boardTab.click(); await waitUntil(() => Boolean(document.querySelector('.board-config')), 3000); - // 今天可能恰好没有作业:先关掉「只看今天」,保证看板一定有卡片(断言才有意义) + // 今天可能恰好没有作业:切到「最近一个有作业的日期」保证看板一定有卡片(断言才有意义)。 + // 按天查看支持 ?date=YYYY-MM-DD 深链,切天不需要去操作日期选择器。 if (!document.querySelector('.board-host')) { - const todaySwitch = document.querySelector('.page-header .el-switch'); - if (todaySwitch instanceof HTMLElement) todaySwitch.click(); + const [{ homeworkApi }, { useAuthStore }] = await Promise.all([ + import('../api/index.js'), + import('../stores/auth.js'), + ]); + const classId = useAuthStore().classId ?? undefined; + const days = await homeworkApi.days({ classId }).catch(() => ({ days: [] })); + const latest = days.days.length > 0 ? days.days[days.days.length - 1].date : null; + if (latest) { + await router.push({ path: '/homeworks', query: { date: latest } }); + } } const hasBoard = await waitUntil(() => Boolean(document.querySelector('.board-host')), 4000); diff --git a/packages/desktop-client/src/renderer/views/HomeworkView.vue b/packages/desktop-client/src/renderer/views/HomeworkView.vue index 8bf26fa..a70c75a 100644 --- a/packages/desktop-client/src/renderer/views/HomeworkView.vue +++ b/packages/desktop-client/src/renderer/views/HomeworkView.vue @@ -1,13 +1,26 @@ @@ -380,6 +458,36 @@ onMounted(async () => { /> + + + 停靠左/右时距屏幕边缘的距离 + + + + 停靠顶/底时距屏幕边缘的距离(任务栏在侧面时调大) + + + + 多显示器教室电脑:岛出现在鼠标所在的那块屏幕 + { /> - + + + {{ classIslandStatusText }} + + {{ classIslandStatus?.pluginVersion ?? '—' }} / {{ classIslandStatus?.classIslandVersion ?? '—' }} + +

- 老师在班级小助手上发布通知 / 叫人时,提醒要弹在哪里由这台教室机器决定。选择会同步到班级, - 服务端据此决定是否一并推送到教室的 ClassIsland。 + 通知模式:老师在班级小助手上发布通知 / 叫人时,提醒弹在灵动岛还是 ClassIsland, + 由这台教室机器决定。选择会同步到班级,服务端据此决定是否推送到教室的 ClassIsland。

{

+ + +

+ 在教室电脑上录作业时,点一下短语就会追加到标题 / 内容里(例如 P、大本、背诵)。 + 最多 {{ HOMEWORK_PHRASE_MAX_COUNT }} 条、每条 {{ HOMEWORK_PHRASE_MAX_LENGTH }} 字;全部删掉即不在录入页显示。 +

+
+ + {{ item }} + + (已清空) +
+
+ + 添加 + 恢复默认 +
+
+ @@ -517,6 +663,14 @@ onMounted(async () => { margin-top: 10px; } +.phrase-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-top: 12px; +} + .ml-8 { margin-left: 8px; } diff --git a/packages/desktop-client/src/types/desktop.d.ts b/packages/desktop-client/src/types/desktop.d.ts index bbd8a89..4583d33 100644 --- a/packages/desktop-client/src/types/desktop.d.ts +++ b/packages/desktop-client/src/types/desktop.d.ts @@ -17,8 +17,11 @@ export interface HomeworkBoardSettings { showTime: boolean; /** 看板字号(px) */ fontSize: number; - /** 只看"今天布置"的作业(默认 true,避免昨天的过期作业混进来) */ - todayOnly: boolean; + /** + * @deprecated 已由「按天查看 + 日期选择器」取代(作业页不再有"只看今天"开关)。 + * 保留字段只为兼容旧配置文件,读写都忽略它。 + */ + todayOnly?: boolean; } export interface DesktopStoredConfig { @@ -32,6 +35,11 @@ export interface DesktopStoredConfig { island: IslandAppearance; /** 作业页展示偏好(看板/列表、显示时间、看板字号) */ homeworkBoard: HomeworkBoardSettings; + /** + * 作业录入的快捷短语(客户端「设置 → 作业录入」里增删,默认见 shared 的 HOMEWORK_PHRASE_DEFAULTS)。 + * 录入作业时点一下就追加到标题/内容里,少打字(例如 P、大本、背诵)。 + */ + homeworkPhrases: string[]; /** * 通知显示位置(both / client / classisland)。 * diff --git a/packages/server/prisma/migrations/20260927150000_add_homework_assign_date/migration.sql b/packages/server/prisma/migrations/20260927150000_add_homework_assign_date/migration.sql new file mode 100644 index 0000000..9640435 --- /dev/null +++ b/packages/server/prisma/migrations/20260927150000_add_homework_assign_date/migration.sql @@ -0,0 +1,7 @@ +-- 作业所属日期(YYYY-MM-DD,本地日期):客户端/Web 的按天查看与日期高亮都用它 +ALTER TABLE "Homework" ADD COLUMN "assignDate" TEXT NOT NULL DEFAULT ''; + +-- 回填历史数据:按 UTC+8 把 createdAt 折算成当天(本产品面向国内教室,服务器在 CST) +UPDATE "Homework" SET "assignDate" = date("createdAt", '+8 hours') WHERE "assignDate" = ''; + +CREATE INDEX "Homework_classId_assignDate_idx" ON "Homework"("classId", "assignDate"); diff --git a/packages/server/prisma/migrations/20260927160000_add_push_kind/migration.sql b/packages/server/prisma/migrations/20260927160000_add_push_kind/migration.sql new file mode 100644 index 0000000..8da677a --- /dev/null +++ b/packages/server/prisma/migrations/20260927160000_add_push_kind/migration.sql @@ -0,0 +1,2 @@ +-- 提醒类型:notification=通知类 / call=叫人(叫人算「主动通知」,上课时段也立即弹出) +ALTER TABLE "ClassIslandPush" ADD COLUMN "kind" TEXT NOT NULL DEFAULT 'notification'; diff --git a/packages/server/prisma/schema.prisma b/packages/server/prisma/schema.prisma index c8de6bb..0fa0ba3 100644 --- a/packages/server/prisma/schema.prisma +++ b/packages/server/prisma/schema.prisma @@ -163,6 +163,8 @@ model Homework { courseId String? title String content String + /// 作业所属日期(YYYY-MM-DD,本地日期,不带时区):客户端/Web 的按天查看与日期高亮都用它 + assignDate String @default("") attachmentUrl String? /// 遗留字段:作业"截止时间"功能已下线(UI/接口均不再读写)。 /// 保留列以免对已安装的库做破坏性迁移;新库该列恒为 null。 @@ -177,6 +179,7 @@ model Homework { statuses HomeworkStatus[] @@index([classId, createdAt]) + @@index([classId, assignDate]) @@index([courseId]) @@index([dueAt]) } @@ -328,6 +331,8 @@ model ClassIslandPush { /// 语音朗读内容;null = 不朗读 speechContent String? urgent Boolean @default(false) + /// 提醒类型:notification=通知类 / call=叫人(叫人算「主动通知」,上课时段也立刻弹) + kind String @default("notification") /// 发起人的用户 id(教师/管理员) createdBy String? /// 同时落库到通知中心的通知 id(saveToNotifications 时写入) diff --git a/packages/server/scripts/verify-classisland.mjs b/packages/server/scripts/verify-classisland.mjs index b2ebd95..5f0e714 100644 --- a/packages/server/scripts/verify-classisland.mjs +++ b/packages/server/scripts/verify-classisland.mjs @@ -304,6 +304,12 @@ async function main() { published.status === 201 && Boolean(publishPush), `status=${published.status} 待提醒=${(pendingAfterPublish.payload?.data?.notifications ?? []).length}`, ); + // kind 决定插件"上课时段弹不弹":通知类会被暂存,叫人属于主动通知要立刻弹 + record( + '通知类推送的 kind=notification(上课时段插件会暂存)', + publishPush?.kind === 'notification', + `kind=${publishPush?.kind}`, + ); if (publishPush) { await api('/integrations/classisland/ack', { method: 'POST', @@ -326,6 +332,11 @@ async function main() { Boolean(firstStudent) && call.status === 201 && Boolean(callPush), firstStudent ? `status=${call.status}` : '班级里没有学生,跳过', ); + record( + '叫人推送的 kind=call(算主动通知,上课时段也立刻弹)', + callPush?.kind === 'call', + `kind=${callPush?.kind}`, + ); if (callPush) { await api('/integrations/classisland/ack', { method: 'POST', diff --git a/packages/server/scripts/verify-e2e.mjs b/packages/server/scripts/verify-e2e.mjs index 9be8553..c470c66 100644 --- a/packages/server/scripts/verify-e2e.mjs +++ b/packages/server/scripts/verify-e2e.mjs @@ -455,6 +455,36 @@ async function main() { createdHomework.payload?.data?.dueAt === undefined, `dueAt=${String(createdHomework.payload?.data?.dueAt)}`, ); + // 按天查看:作业带"所属日期"(本地日期),并且能按它过滤 + const assignDate = createdHomework.payload?.data?.assignDate; + record( + '作业带所属日期(assignDate,默认服务器当天)', + typeof assignDate === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(assignDate), + `assignDate=${assignDate}`, + ); + const byDay = await api(`/homeworks?classId=${classId}&date=${assignDate}`, { token: teacherToken }); + record( + '按天查看(?date=)只返回该天的作业', + byDay.status === 200 && + (byDay.payload?.data ?? []).length > 0 && + (byDay.payload?.data ?? []).every((item) => item.assignDate === assignDate), + `当天条数=${(byDay.payload?.data ?? []).length}`, + ); + const otherDay = await api(`/homeworks?classId=${classId}&date=2000-01-01`, { token: teacherToken }); + record( + '没有作业的日期返回空列表', + otherDay.status === 200 && (otherDay.payload?.data ?? []).length === 0, + `条数=${(otherDay.payload?.data ?? []).length}`, + ); + const dayMarks = await api(`/homeworks/days?classId=${classId}`, { token: teacherToken }); + const marks = dayMarks.payload?.data?.days ?? []; + record( + '作业日历返回有作业的日期(供日期选择器高亮)', + dayMarks.status === 200 && marks.some((item) => item.date === assignDate && item.count >= 1), + `日期数=${marks.length} 命中=${marks.find((item) => item.date === assignDate)?.count ?? 0}`, + ); + const badDay = await api(`/homeworks/days?classId=${classId}&from=2026-13-45`, { token: teacherToken }); + record('作业日历非法日期被拒(422)', badDay.status === 422, `status=${badDay.status}`); try { const { payload, elapsed } = await homeworkWait; @@ -1867,6 +1897,46 @@ async function main() { `status=${classScheduleWrite.status}`, ); + // 教室机器录入作业(需求:支持作业在客户端录入):班级账号可以发,跨班不行 + const classHomeworkCreate = await api('/homeworks', { + method: 'POST', + token: classToken, + body: { + classId, + title: '教室录入验证作业', + content: 'P 大本 第 3 课', + assignDate: '2026-03-02', + }, + }); + record( + '班级账号可在教室机器上录入作业', + classHomeworkCreate.status === 201 && classHomeworkCreate.payload?.data?.assignDate === '2026-03-02', + `status=${classHomeworkCreate.status} assignDate=${classHomeworkCreate.payload?.data?.assignDate}`, + ); + if (foreignClass) { + const classHomeworkCreateCross = await api('/homeworks', { + method: 'POST', + token: classToken, + body: { classId: foreignClass.id, title: '跨班录入作业', content: '不应成功' }, + }); + record( + '班级账号跨班录入作业被拒绝(403)', + classHomeworkCreateCross.status === 403, + `status=${classHomeworkCreateCross.status}`, + ); + } + if (classHomeworkCreate.payload?.data?.id) { + const cleanupHomework = await api(`/homeworks/${classHomeworkCreate.payload.data.id}`, { + method: 'DELETE', + token: teacherToken, + }); + record( + '清理:教室录入的验证作业已删除', + cleanupHomework.status === 200, + `status=${cleanupHomework.status}`, + ); + } + // 班级设备代全班操作:通知已读 const classNameNotice = await api('/notifications', { method: 'POST', diff --git a/packages/server/src/lib/mappers.ts b/packages/server/src/lib/mappers.ts index 54a8453..e5b77de 100644 --- a/packages/server/src/lib/mappers.ts +++ b/packages/server/src/lib/mappers.ts @@ -1,3 +1,4 @@ +import { dayKeyLocal } from '@classhelper/shared'; import type { ClassDto, ClassIslandNotificationChannel, @@ -204,6 +205,8 @@ export interface HomeworkLike { title: string; content: string; attachmentUrl: string | null; + /** 作业所属日期(YYYY-MM-DD,本地日期);老库可能为空串 */ + assignDate?: string | null; createdBy: string; createdAt: Date; course?: CourseBriefLike | null; @@ -243,6 +246,8 @@ export function toHomeworkDto( attachmentUrl: item.attachmentUrl, createdBy: item.createdBy, createdAt: toIso(item.createdAt), + // 老库兜底:assignDate 为空时按 createdAt 的本地日期回退,前端不必处理空值 + assignDate: item.assignDate || dayKeyLocal(item.createdAt), course: item.course ? toCourseBrief(item.course) : null, creator: item.creator ? toTeacherBrief(item.creator) : null, completed: own ? own.completed : undefined, diff --git a/packages/server/src/modules/calls/calls.service.ts b/packages/server/src/modules/calls/calls.service.ts index d8e9126..32e40e5 100644 --- a/packages/server/src/modules/calls/calls.service.ts +++ b/packages/server/src/modules/calls/calls.service.ts @@ -73,6 +73,8 @@ export async function createCall(user: TokenPayload, input: CreateCallInput): Pr title: created.title, content: created.content, urgent: input.urgent === true, + // 叫人属于「主动通知」:教室的 ClassIsland 上课时段也要立刻弹 + kind: 'call', createdBy: user.sub, notificationId: created.id, teacherName, diff --git a/packages/server/src/modules/classes/classes.module.ts b/packages/server/src/modules/classes/classes.module.ts index 3c1f26a..5be75ed 100644 --- a/packages/server/src/modules/classes/classes.module.ts +++ b/packages/server/src/modules/classes/classes.module.ts @@ -113,6 +113,17 @@ router.patch( }, ); +/** + * GET /api/classes/:id/classisland-status - 本班 ClassIsland 联动状态 + * + * 教室机器(班级账号)也能读:客户端「设置 → ClassIsland 联动」据此显示"已接入 / 未接入"。 + */ +router.get('/:id/classisland-status', validate({ params: idParamSchema }), async (req, res) => { + const user = getAuthUser(req); + const { id } = validatedParams<{ id: string }>(req); + sendOk(res, await classService.getClassIslandStatus(user, id), '获取联动状态成功'); +}); + /** * GET /api/classes/:id/notification-channel - 读取"通知显示到哪个端" * diff --git a/packages/server/src/modules/classes/classes.service.ts b/packages/server/src/modules/classes/classes.service.ts index 629d24f..6fdc6e3 100644 --- a/packages/server/src/modules/classes/classes.service.ts +++ b/packages/server/src/modules/classes/classes.service.ts @@ -355,6 +355,44 @@ export async function removeTeacher(user: TokenPayload, classId: string, teacher await prisma.classTeacher.deleteMany({ where: { classId, teacherId } }); } +/* ---------------------------------------------------------------- ClassIsland 联动状态 */ + +/** + * 本班的 ClassIsland 联动状态(教室客户端的「ClassIsland 联动」面板展示)。 + * + * 为什么单独开一个接口:设备列表(/integrations/devices)是管理面,只对教师/管理员开放; + * 而教室机器(班级账号)需要知道"本班接没接 ClassIsland、最后什么时候上报的", + * 才能给出准确的提示(例如没接就别选"只在 ClassIsland 上弹")。 + */ +export async function getClassIslandStatus( + user: TokenPayload, + classId: string, +): Promise<{ + connected: boolean; + deviceName: string | null; + deviceCount: number; + lastSeenAt: string | null; + pluginVersion: string | null; + classIslandVersion: string | null; +}> { + await assertClassAccess(user, classId); + const devices = await prisma.integrationDevice.findMany({ + where: { classId, enabled: true, mode: 'plugin' }, + select: { name: true, lastSeenAt: true, pluginVersion: true, classIslandVersion: true }, + orderBy: { lastSeenAt: 'desc' }, + }); + + const latest = devices[0]; + return { + connected: devices.length > 0, + deviceName: latest?.name ?? null, + deviceCount: devices.length, + lastSeenAt: latest?.lastSeenAt ? latest.lastSeenAt.toISOString() : null, + pluginVersion: latest?.pluginVersion ?? null, + classIslandVersion: latest?.classIslandVersion ?? null, + }; +} + /* ---------------------------------------------------------------- 通知显示位置 */ /** diff --git a/packages/server/src/modules/homeworks/homeworks.module.ts b/packages/server/src/modules/homeworks/homeworks.module.ts index dcad3cf..c8d3939 100644 --- a/packages/server/src/modules/homeworks/homeworks.module.ts +++ b/packages/server/src/modules/homeworks/homeworks.module.ts @@ -6,11 +6,13 @@ import { validate, validatedParams, validatedQuery } from '../../middleware/vali import { defineModule } from '../module.types.js'; import { createHomeworkSchema, + listHomeworkDaysQuerySchema, listHomeworksQuerySchema, updateHomeworkSchema, updateHomeworkStatusSchema, updateHomeworkSubmissionsSchema, type CreateHomeworkInput, + type ListHomeworkDaysInput, type UpdateHomeworkInput, type UpdateHomeworkStatusInput, type UpdateHomeworkSubmissionsInput, @@ -33,16 +35,31 @@ router.get('/', validate({ query: listHomeworksQuerySchema }), async (req, res) }); /** GET /api/homeworks/:id - 作业详情 */ +/** + * GET /api/homeworks/days - 一段时间内"哪些天有作业"(客户端/Web 的日期选择器高亮) + * + * 必须注册在 /:id 之前,否则 "days" 会被当成作业 id 命中 /:id 路由。 + */ +router.get('/days', validate({ query: listHomeworkDaysQuerySchema }), async (req, res) => { + const user = getAuthUser(req); + const query = validatedQuery(req); + sendOk(res, await homeworkService.listHomeworkDays(user, query), '获取作业日历成功'); +}); + router.get('/:id', validate({ params: idParamSchema }), async (req, res) => { const user = getAuthUser(req); const { id } = validatedParams<{ id: string }>(req); sendOk(res, await homeworkService.getHomework(user, id), '获取作业详情成功'); }); -/** POST /api/homeworks - 发布作业(发布后广播 homework:new) */ +/** + * POST /api/homeworks - 发布作业(发布后广播 homework:new) + * + * 这里**不加 requireRole**:教室机器的班级账号也要能录入作业(需求「支持作业在客户端录入」), + * 能不能发由服务层的 createHomework 判定(staff 或本班班级账号,其余 403)。 + */ router.post( '/', - requireRole('ADMIN', 'TEACHER'), validate({ body: createHomeworkSchema }), async (req, res) => { const user = getAuthUser(req); diff --git a/packages/server/src/modules/homeworks/homeworks.schemas.ts b/packages/server/src/modules/homeworks/homeworks.schemas.ts index 88dfdfe..e7bef9f 100644 --- a/packages/server/src/modules/homeworks/homeworks.schemas.ts +++ b/packages/server/src/modules/homeworks/homeworks.schemas.ts @@ -1,12 +1,30 @@ import { z } from 'zod'; +import { isDayKey } from '@classhelper/shared'; import { booleanFlagSchema } from '../../lib/schemas.js'; +/** YYYY-MM-DD,且必须是真实存在的日期 */ +const dayKeySchema = z + .string() + .trim() + .refine((value) => isDayKey(value), { message: '日期格式应为 YYYY-MM-DD' }); + export const listHomeworksQuerySchema = z.object({ classId: z.string().min(1).optional(), courseId: z.string().min(1).optional(), /** 学生端:只看未完成 */ pendingOnly: booleanFlagSchema, keyword: z.string().trim().max(64).optional(), + /** 只看某一天的作业(按 Homework.assignDate,本地日期) */ + date: dayKeySchema.optional(), +}); + +/** 按天查看用:查一段时间内"哪些天有作业"(日期选择器高亮) */ +export const listHomeworkDaysQuerySchema = z.object({ + classId: z.string().min(1).optional(), + from: dayKeySchema.optional(), + to: dayKeySchema.optional(), + /** 不传 from/to 时回看的天数(默认 60 天) */ + days: z.coerce.number().int().min(1).max(366).optional(), }); export const createHomeworkSchema = z.object({ @@ -15,10 +33,14 @@ export const createHomeworkSchema = z.object({ title: z.string().trim().min(1, '请输入作业标题').max(120), content: z.string().min(1, '请输入作业内容').max(5000), attachmentUrl: z.string().trim().max(500).nullish(), + /** 作业所属日期(YYYY-MM-DD):客户端/Web 录入时选,不传按服务器当天 */ + assignDate: dayKeySchema.nullish(), }); export const updateHomeworkSchema = z.object({ courseId: z.string().min(1).nullish(), + /** 改作业所属日期(把作业挪到另一天) */ + assignDate: dayKeySchema.optional(), title: z.string().trim().min(1).max(120).optional(), content: z.string().min(1).max(5000).optional(), attachmentUrl: z.string().trim().max(500).nullish(), @@ -35,6 +57,7 @@ export const updateHomeworkSubmissionsSchema = z.object({ notSubmittedUserIds: z.array(z.string().min(1)).max(200).default([]), }); +export type ListHomeworkDaysInput = z.infer; export type CreateHomeworkInput = z.infer; export type UpdateHomeworkInput = z.infer; export type UpdateHomeworkStatusInput = z.infer; diff --git a/packages/server/src/modules/homeworks/homeworks.service.ts b/packages/server/src/modules/homeworks/homeworks.service.ts index 1cba083..c45b955 100644 --- a/packages/server/src/modules/homeworks/homeworks.service.ts +++ b/packages/server/src/modules/homeworks/homeworks.service.ts @@ -1,5 +1,8 @@ import { SOCKET_EVENTS, + dayKeyLocal, + shiftDayKey, + type HomeworkDaysDto, type HomeworkDto, type HomeworkStatusDto, type HomeworkSubmissionDto, @@ -16,7 +19,12 @@ import { prisma } from '../../lib/db.js'; import { ApiError } from '../../lib/http.js'; import type { TokenPayload } from '../../lib/jwt.js'; import { toHomeworkDto, toHomeworkStatusDto } from '../../lib/mappers.js'; -import { isClassSession, personalIdWhere, resolvePersonalIds } from '../../lib/session.js'; +import { + classSessionClassId, + isClassSession, + personalIdWhere, + resolvePersonalIds, +} from '../../lib/session.js'; import { emitToClass } from '../../realtime/bus.js'; import type { CreateHomeworkInput, @@ -33,6 +41,8 @@ export interface ListHomeworkOptions { courseId?: string; pendingOnly?: boolean; keyword?: string; + /** 只看某一天(Homework.assignDate,YYYY-MM-DD) */ + date?: string; } /** @@ -60,13 +70,15 @@ export async function listHomeworks( ...(student && options.pendingOnly ? { statuses: { none: { ...personalWhere, completed: true } } } : {}), + // 按天查看:作业「属于哪一天」看 assignDate(本地日期),不是 createdAt + ...(options.date ? { assignDate: options.date } : {}), }, include: { course: courseSelect, creator: creatorSelect, statuses: student ? { where: personalWhere } : true, }, - orderBy: { createdAt: 'desc' }, + orderBy: [{ assignDate: 'desc' }, { createdAt: 'desc' }], take: 200, }); @@ -75,6 +87,37 @@ export async function listHomeworks( ); } +/** + * 按天查看:查一段时间内「哪些天有作业」(日期选择器高亮用)。 + * + * 只回有作业的日期(稀疏),避免把整月日历塞回前端;不传区间时默认回看 60 天。 + */ +export async function listHomeworkDays( + user: TokenPayload, + options: { classId?: string; from?: string; to?: string; days?: number }, +): Promise { + const scope = await resolveClassScope(user, options.classId); + const today = dayKeyLocal(new Date()); + const to = options.to ?? today; + const from = options.from ?? shiftDayKey(to, -(options.days ?? 60) + 1); + + const rows = await prisma.homework.findMany({ + where: { ...classScopeWhere(scope), assignDate: { gte: from, lte: to } }, + select: { assignDate: true }, + }); + + const counter = new Map(); + for (const row of rows) { + if (!row.assignDate) continue; + counter.set(row.assignDate, (counter.get(row.assignDate) ?? 0) + 1); + } + + return { + days: [...counter.entries()] + .map(([date, count]) => ({ date, count })) + .sort((a, b) => a.date.localeCompare(b.date)), + }; +} export async function getHomework(user: TokenPayload, homeworkId: string): Promise { const student = isStudent(user); const personalIds = student ? await resolvePersonalIds(user) : []; @@ -95,11 +138,32 @@ export async function getHomework(user: TokenPayload, homeworkId: string): Promi }); } -/** 发布作业并实时推送到班级房间 */ +/** + * 发布作业并实时推送到班级房间。 + * + * 权限:管理员 / 班主任 / 科任老师,**以及本班的班级账号**(教室机器的客户端录入 —— + * 需求就是「支持作业在客户端录入」,老师在教室电脑上顺手就能记一条)。 + */ export async function createHomework(user: TokenPayload, input: CreateHomeworkInput): Promise { - await assertCanPublishContent(user, input.classId); + if (isClassSession(user) && classSessionClassId(user) === input.classId) { + await assertClassAccess(user, input.classId); + } else { + await assertCanPublishContent(user, input.classId); + } if (input.courseId) await assertCourseInClass(input.courseId, input.classId); + // 班级账号的 sub 是**班级 id**(不是用户 id),直接写 createdBy 会撞外键; + // 教室机器录入的作业归属该班班主任,Web 端看到的作者才是真人。 + let createdBy = user.sub; + if (isClassSession(user)) { + const owner = await prisma.class.findUnique({ + where: { id: input.classId }, + select: { teacherId: true }, + }); + if (!owner) throw ApiError.notFound('班级不存在'); + createdBy = owner.teacherId; + } + const created = await prisma.homework.create({ data: { classId: input.classId, @@ -107,7 +171,9 @@ export async function createHomework(user: TokenPayload, input: CreateHomeworkIn title: input.title, content: input.content, attachmentUrl: input.attachmentUrl ?? null, - createdBy: user.sub, + // 不传日期就记成「服务器当天」;教室在 UTC+8,用本地日期而不是 UTC 日期 + assignDate: input.assignDate ?? dayKeyLocal(new Date()), + createdBy, }, include: { course: courseSelect, creator: creatorSelect, statuses: true }, }); @@ -135,6 +201,7 @@ export async function updateHomework( ...(input.content ? { content: input.content } : {}), ...(input.courseId !== undefined ? { courseId: input.courseId ?? null } : {}), ...(input.attachmentUrl !== undefined ? { attachmentUrl: input.attachmentUrl ?? null } : {}), + ...(input.assignDate !== undefined ? { assignDate: input.assignDate } : {}), }, include: { course: courseSelect, creator: creatorSelect, statuses: true }, }); diff --git a/packages/server/src/modules/integrations/integrations.service.ts b/packages/server/src/modules/integrations/integrations.service.ts index d63cca6..5344a33 100644 --- a/packages/server/src/modules/integrations/integrations.service.ts +++ b/packages/server/src/modules/integrations/integrations.service.ts @@ -225,6 +225,7 @@ function toPushDto( durationSeconds: number; speechContent: string | null; urgent: boolean; + kind?: string | null; createdAt: Date; }, extras: { className?: string | null; teacherName?: string | null } = {}, @@ -237,6 +238,8 @@ function toPushDto( speechContent: record.speechContent, createdAt: record.createdAt.toISOString(), urgent: record.urgent, + // 老数据(迁移前)kind 为空:按普通通知处理 + kind: record.kind === 'call' ? 'call' : 'notification', classId: record.classId, className: extras.className ?? null, teacherName: extras.teacherName ?? null, @@ -325,6 +328,8 @@ export interface PushToClassIslandInput { notificationId?: string | null; /** 覆盖落库/广播时附带的教师名(三个入口的取法不同) */ teacherName?: string | null; + /** 提醒类型:notification(默认)/ call(叫人,算「主动通知」) */ + kind?: 'notification' | 'call'; } /** @@ -369,6 +374,7 @@ export async function pushToClassIsland( durationSeconds: input.durationSeconds ?? REPORT_DEFAULTS.notificationDuration, speechContent: input.speechContent ?? null, urgent: input.urgent === true, + kind: input.kind ?? 'notification', createdBy: input.createdBy ?? null, notificationId: input.notificationId ?? null, // 24 小时未送达/未确认的提醒不再补发,避免学生过几天突然看到旧提醒 diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index bc5a181..a317635 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -126,6 +126,15 @@ export const DEFAULT_ISLAND_APPEARANCE = { style: 'black', /** 空闲时完全隐藏(与原行为一致;打开后空闲会留一条细缝) */ idleSliver: false, + /** + * 距屏幕左右边缘的距离(px):停靠左/右时生效。 + * 默认与原行为一致(8px)—— 加这个设置是为了能调,不是为了改默认外观。 + */ + marginX: 8, + /** 距屏幕上下边缘的距离(px):停靠顶/底时生效 */ + marginY: 8, + /** 是否跟随鼠标所在屏幕(多显示器教室电脑:鼠标在哪块屏就在哪块屏显示) */ + followCursorDisplay: false, } as const; /** 个性化设置项的合法区间(前端滑块与后端校验共用,避免越界导致布局错乱) */ @@ -136,6 +145,8 @@ export const ISLAND_APPEARANCE_RANGES = { opacity: { min: 0.4, max: 1 }, fontSize: { min: 11, max: 20 }, speed: { min: 0.5, max: 2 }, + marginX: { min: 0, max: 200 }, + marginY: { min: 0, max: 160 }, } as const; /** 停靠位置可选值(与 WinIsland 的 DockPosition 一致:顶/底 × 左/中/右) */ @@ -264,19 +275,44 @@ export const CLASSISLAND_TIME_STATE_LABELS: Record = { export const CLASSISLAND_NOTIFICATION_CHANNELS = ['both', 'client', 'classisland'] as const; export const CLASSISLAND_NOTIFICATION_CHANNEL_LABELS: Record = { - both: 'ClassHelper 与 ClassIsland 都弹', - client: '只在 ClassHelper 客户端弹', - classisland: '只在 ClassIsland 上弹', + both: '两者都弹', + client: '灵动岛(ClassHelper 客户端)', + classisland: 'ClassIsland', }; export const CLASSISLAND_NOTIFICATION_CHANNEL_HINTS: Record = { - both: '教室的 ClassHelper 客户端与 ClassIsland 都会提醒(默认)', - client: '提醒只在 ClassHelper 客户端显示,不推送到 ClassIsland', - classisland: '提醒只推送到 ClassIsland,ClassHelper 客户端只留记录、不弹窗', + both: '灵动岛与 ClassIsland 都会提醒老师发的内容(默认)', + client: '只在 ClassHelper 客户端的灵动岛提醒,不推送到 ClassIsland', + classisland: '只推送到 ClassIsland 全屏提醒,灵动岛不弹(通知中心仍留记录)', }; /** 默认显示位置:两端都弹 */ export const DEFAULT_CLASSISLAND_NOTIFICATION_CHANNEL = 'both'; +/** + * 作业录入的默认快捷短语(客户端「设置 → 作业录入」可增删)。 + * + * 教室里老师录作业的常见口径:科目简称(P=拼音)、本子类型(大本/小本)、 + * 作业动作(背诵/默写/听写/预习/订正)—— 点一下就追加到内容里,少打字。 + */ +export const HOMEWORK_PHRASE_DEFAULTS = [ + 'P', + '大本', + '小本', + '卷子', + '背诵', + '默写', + '听写', + '预习', + '订正', + '读书', +] as const; + +/** 单条快捷短语的最大长度(防止把整篇作业粘进去) */ +export const HOMEWORK_PHRASE_MAX_LENGTH = 20; + +/** 快捷短语最多几条 */ +export const HOMEWORK_PHRASE_MAX_COUNT = 24; + /** 默认提醒显示时长(秒),后端与插件共用同一口径 */ export const CLASSISLAND_NOTIFICATION_DEFAULT_DURATION = 8; export const CLASSISLAND_NOTIFICATION_MAX_DURATION = 120; diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 25e13e1..b2c6885 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -279,6 +279,13 @@ export interface HomeworkDto { attachmentUrl: string | null; createdBy: string; createdAt: string; + /** + * 作业所属日期(YYYY-MM-DD,**不带时区**)。 + * + * 与 createdAt 的区别:createdAt 是"什么时候录的",assignDate 是"这天的作业"。 + * 客户端 / Web 的按天查看与日期高亮都用它,避免跨时区把作业算到前一天。 + */ + assignDate: string; course?: CourseBrief | null; creator?: ClassTeacherBrief | null; /** 当前登录学生的完成状态(学生端接口返回) */ @@ -288,6 +295,19 @@ export interface HomeworkDto { completedCount?: number; } +/** 某一天有作业(用于日期选择器高亮) */ +export interface HomeworkDaySummary { + /** YYYY-MM-DD */ + date: string; + /** 当天作业条数 */ + count: number; +} + +/** GET /api/homeworks/days 的返回 */ +export interface HomeworkDaysDto { + days: HomeworkDaySummary[]; +} + export interface HomeworkStatusDto { id: string; homeworkId: string; @@ -301,6 +321,8 @@ export interface CreateHomeworkRequest { courseId?: string | null; title: string; content: string; + /** 作业所属日期(YYYY-MM-DD);不传按服务器当天处理 */ + assignDate?: string | null; attachmentUrl?: string | null; } @@ -541,6 +563,12 @@ export interface IslandAppearance { * 关闭时(默认)空闲即完全隐藏,与原行为一致。 */ idleSliver: boolean; + /** 距屏幕左右边缘的距离(px):停靠左/右时生效 */ + marginX: number; + /** 距屏幕上下边缘的距离(px):停靠顶/底时生效 */ + marginY: number; + /** 是否跟随鼠标所在屏幕(多显示器教室电脑用) */ + followCursorDisplay: boolean; } /** 灵动岛停靠位置(6 个锚点,与 WinIsland 一致) */ @@ -882,6 +910,12 @@ export interface ClassIslandPushNotification { createdAt: string; /** 是否紧急(紧急时插件用更强的遮罩与更长的时长) */ urgent: boolean; + /** + * 提醒类型: + * - `notification`:通知类提醒 —— **上课时段插件会暂存,下课后才弹**; + * - `call`:叫人等「主动通知」—— 不论是否上课都立刻弹(老师正在等学生)。 + */ + kind: 'notification' | 'call'; classId: string; className?: string | null; teacherName?: string | null; diff --git a/packages/shared/src/utils.ts b/packages/shared/src/utils.ts index ff0465a..739ce5c 100644 --- a/packages/shared/src/utils.ts +++ b/packages/shared/src/utils.ts @@ -11,6 +11,35 @@ function pad(input: number): string { return input < 10 ? `0${input}` : String(input); } +/** + * 本地日期串(YYYY-MM-DD)。 + * + * 「作业属于哪一天」用它是**本地**日期,不是 UTC 日期:教室在 UTC+8,晚上 8 点录的作业 + * 应该算今天,而 `toISOString().slice(0, 10)` 会把它算成明天(UTC 已跨天)。 + */ +export function dayKeyLocal(value: string | number | Date): string { + const date = toDate(value); + if (Number.isNaN(date.getTime())) return ''; + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; +} + +/** 判断字符串是否形如 YYYY-MM-DD(并校验是真实存在的日期) */ +export function isDayKey(value: string): boolean { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false; + const [year, month, day] = value.split('-').map(Number); + const date = new Date(year, month - 1, day); + return ( + date.getFullYear() === year && date.getMonth() === month - 1 && date.getDate() === day + ); +} + +/** 在 YYYY-MM-DD 上加减天数(用于日期选择器的区间与"昨天/明天") */ +export function shiftDayKey(value: string, deltaDays: number): string { + const [year, month, day] = value.split('-').map(Number); + const date = new Date(year, month - 1, day + deltaDays); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; +} + /** 格式化为 YYYY-MM-DD,withTime=true 时追加 HH:mm */ export function formatDate(value: string | number | Date | null | undefined, withTime = false): string { if (value === null || value === undefined || value === '') return ''; diff --git a/packages/web-admin/src/api/index.ts b/packages/web-admin/src/api/index.ts index 5793f2f..98b291a 100644 --- a/packages/web-admin/src/api/index.ts +++ b/packages/web-admin/src/api/index.ts @@ -17,6 +17,7 @@ import { type DashboardSummary, type GradeDto, type GradeStats, + type HomeworkDaysDto, type HomeworkDto, type HomeworkSubmissionsDto, type IntegrationDeviceDto, @@ -116,7 +117,16 @@ export const homeworkApi = { courseId?: string; pendingOnly?: boolean; keyword?: string; + /** 只看某一天(YYYY-MM-DD,按作业所属日期) */ + date?: string; }): Promise => api.get(API_PATHS.homeworks, params), + /** 哪些天有作业(日期选择器高亮) */ + days: (params: { + classId?: string; + from?: string; + to?: string; + days?: number; + }): Promise => api.get(`${API_PATHS.homeworks}/days`, params), detail: (id: string): Promise => api.get(`${API_PATHS.homeworks}/${id}`), create: (payload: CreateHomeworkRequest): Promise => api.post(API_PATHS.homeworks, payload), update: (id: string, payload: Partial): Promise => diff --git a/packages/web-admin/src/views/HomeworksView.vue b/packages/web-admin/src/views/HomeworksView.vue index c68d1d0..6ed0a6c 100644 --- a/packages/web-admin/src/views/HomeworksView.vue +++ b/packages/web-admin/src/views/HomeworksView.vue @@ -4,7 +4,9 @@ import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'elem import { SOCKET_EVENTS, SUBJECT_CATALOG, + dayKeyLocal, formatDate, + shiftDayKey, truncate, type ClassDto, type CourseDto, @@ -25,7 +27,9 @@ const loading = ref(false); const classes = ref([]); const courses = ref([]); const homeworks = ref([]); -const filter = reactive({ classId: '', courseId: '', keyword: '' }); +const filter = reactive({ classId: '', courseId: '', keyword: '', date: '' }); +/** 有作业的日期(日期选择器高亮),来自 GET /homeworks/days */ +const dayMarks = ref>(new Map()); const detailVisible = ref(false); const current = ref(null); @@ -108,10 +112,12 @@ async function loadCourses(): Promise { async function loadHomeworks(): Promise { loading.value = true; try { - const params: { classId?: string; courseId?: string; keyword?: string } = {}; + const params: { classId?: string; courseId?: string; keyword?: string; date?: string } = {}; if (filter.classId) params.classId = filter.classId; if (filter.courseId) params.courseId = filter.courseId; if (filter.keyword.trim()) params.keyword = filter.keyword.trim(); + // 按天查看:选了日期就只列那一天的作业 + if (filter.date) params.date = filter.date; homeworks.value = await homeworkApi.list(params); } finally { loading.value = false; @@ -121,9 +127,30 @@ async function loadHomeworks(): Promise { async function onClassChange(): Promise { filter.courseId = ''; await loadCourses(); + await loadDayMarks(); await loadHomeworks(); } +/** 拉取"哪些天有作业"(日期选择器高亮);范围取今天前后各 45 天 */ +async function loadDayMarks(): Promise { + try { + const today = dayKeyLocal(new Date()); + const result = await homeworkApi.days({ + classId: filter.classId || undefined, + from: shiftDayKey(today, -45), + to: shiftDayKey(today, 45), + }); + dayMarks.value = new Map(result.days.map((item) => [item.date, item.count])); + } catch { + dayMarks.value = new Map(); + } +} + +/** 日期选择器:有作业的日期加高亮类 */ +function dayCellClass(date: Date): string { + return dayMarks.value.has(dayKeyLocal(date)) ? 'day-has-homework' : ''; +} + /* ------------------------------------------------------------ 发布 / 编辑 */ const formVisible = ref(false); @@ -135,6 +162,8 @@ const form = reactive({ title: '', content: '', attachmentUrl: '', + /** 作业所属日期(YYYY-MM-DD):决定它出现在哪一天的「按天查看」里 */ + assignDate: dayKeyLocal(new Date()), }); /** @@ -182,6 +211,8 @@ function openCreate(): void { form.title = ''; form.content = ''; form.attachmentUrl = ''; + // 默认跟当前「按天查看」的日期一致:老师多半就是在给这一天留作业 + form.assignDate = filter.date || dayKeyLocal(new Date()); formVisible.value = true; void loadFormCourses(); } @@ -193,6 +224,7 @@ function openEdit(row: HomeworkDto): void { form.title = row.title; form.content = row.content; form.attachmentUrl = row.attachmentUrl ?? ''; + form.assignDate = row.assignDate; formVisible.value = true; void loadFormCourses(); } @@ -253,6 +285,7 @@ async function submitForm(): Promise { title: form.title.trim(), content: form.content, attachmentUrl: form.attachmentUrl.trim() || null, + assignDate: form.assignDate || dayKeyLocal(new Date()), }; if (editingId.value) { @@ -325,6 +358,16 @@ onUnmounted(() => { > + { }}
+ + + 作业会出现在这一天(按天查看与学生端都按它归类) + @@ -502,6 +555,29 @@ onUnmounted(() => {