From 7536abfc95cd317f272e316067eeece3d63f92e6 Mon Sep 17 00:00:00 2001
From: LoveChengke
Date: Sun, 27 Sep 2026 16:34:01 +0800
Subject: [PATCH] =?UTF-8?q?feat(homeworks,island,classisland,desktop,web,s?=
=?UTF-8?q?erver):=20=E4=BD=9C=E4=B8=9A=E6=8C=89=E5=A4=A9=E6=9F=A5?=
=?UTF-8?q?=E7=9C=8B=E4=B8=8E=E6=95=99=E5=AE=A4=E6=9C=BA=E5=99=A8=E5=BD=95?=
=?UTF-8?q?=E5=85=A5=20+=20=E6=8F=90=E9=86=92=E4=B8=8A=E8=AF=BE=E6=97=B6?=
=?UTF-8?q?=E6=AE=B5=E6=9A=82=E5=AD=98=E8=A7=84=E5=88=99=20+=20=E7=81=B5?=
=?UTF-8?q?=E5=8A=A8=E5=B2=9B=E8=BE=B9=E8=B7=9D/=E8=B7=9F=E9=9A=8F?=
=?UTF-8?q?=E9=BC=A0=E6=A0=87=E5=B1=8F=E5=B9=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 作业:新增 Homework.assignDate(本地日期 YYYY-MM-DD;UTC 切片在晚上会把今天的作业算成明天),列表按天过滤与排序,新增 GET /homeworks?date= 与 GET /homeworks/days(日期高亮)供客户端与 Web 共用;客户端作业页改为"看作业 + 录作业"(不再显示完成状态),支持快捷短语(存本机配置,「设置 → 作业录入」可增删/恢复默认)
- 教室机器录入:POST /homeworks 去掉 requireRole,权限下沉到 homeworks.service(staff 或本班班级账号);班级账号的 sub 是班级 id,写 createdBy 前换成该班班主任,避免撞 User 外键
- 提醒类型:ClassIslandPush.kind(notification / call)。通知类上课时段暂存、下课补弹,叫人/紧急立即弹;客户端灵动岛与 ClassIsland 插件共用同一套规则
- 灵动岛:新增 marginX / marginY(默认仍 8px,不动老外观)与「跟随鼠标屏幕」;文案统一成"灵动岛 / ClassIsland"
- 设置页:新增「ClassIsland 联动」(联动状态 + 通知模式)与「作业录入」分区
- 校验:verify:e2e 增补 assignDate 默认当天 / ?date= 过滤 / days / 非法日期 422 / 班级账号录入 201 与跨班 403;verify:classisland 与插件静态契约增补上课时段暂存断言(57/57)
- 文档:README 与 AGENTS.md 同步(作业按天、上课时段策略、两个方向的开关表、新增踩坑)
门禁:pnpm lint 0 error、pnpm typecheck 四包通过、verify:classisland-plugin 57/57 通过。
未跑:verify:e2e / verify:classisland(需后端启动)、verify:web / verify:desktop(需先构建产物)。
---
AGENTS.md | 26 +-
README.md | 44 ++
packages/classisland-plugin/README.md | 11 +
.../classisland-plugin/scripts/verify.mjs | 15 +
.../src/Interop/ClassHelperClient.cs | 3 +
.../src/Services/BridgeService.cs | 31 +-
.../ClassHelperNotificationProvider.cs | 72 ++++
packages/desktop-client/src/main/config.ts | 35 +-
packages/desktop-client/src/main/island.ts | 21 +-
packages/desktop-client/src/main/smoke.ts | 36 +-
.../desktop-client/src/renderer/api/index.ts | 41 +-
.../src/renderer/cache/self-test.ts | 15 +-
.../src/renderer/views/HomeworkView.vue | 385 ++++++++++++------
.../src/renderer/views/SettingsView.vue | 172 +++++++-
.../desktop-client/src/types/desktop.d.ts | 12 +-
.../migration.sql | 7 +
.../migration.sql | 2 +
packages/server/prisma/schema.prisma | 5 +
.../server/scripts/verify-classisland.mjs | 11 +
packages/server/scripts/verify-e2e.mjs | 70 ++++
packages/server/src/lib/mappers.ts | 5 +
.../server/src/modules/calls/calls.service.ts | 2 +
.../src/modules/classes/classes.module.ts | 11 +
.../src/modules/classes/classes.service.ts | 38 ++
.../src/modules/homeworks/homeworks.module.ts | 21 +-
.../modules/homeworks/homeworks.schemas.ts | 23 ++
.../modules/homeworks/homeworks.service.ts | 77 +++-
.../integrations/integrations.service.ts | 6 +
packages/shared/src/constants.ts | 48 ++-
packages/shared/src/types.ts | 34 ++
packages/shared/src/utils.ts | 29 ++
packages/web-admin/src/api/index.ts | 10 +
.../web-admin/src/views/HomeworksView.vue | 80 +++-
33 files changed, 1236 insertions(+), 162 deletions(-)
create mode 100644 packages/server/prisma/migrations/20260927150000_add_homework_assign_date/migration.sql
create mode 100644 packages/server/prisma/migrations/20260927160000_add_push_kind/migration.sql
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 () => {
/>
+
+ applyIslandAppearance({ marginX: value })"
+ />
+ 停靠左/右时距屏幕边缘的距离
+
+
+ applyIslandAppearance({ marginY: value })"
+ />
+ 停靠顶/底时距屏幕边缘的距离(任务栏在侧面时调大)
+
+
+
+ applyIslandAppearance({ followCursorDisplay: Boolean(value) })
+ "
+ />
+ 多显示器教室电脑:岛出现在鼠标所在的那块屏幕
+
{
/>
- 通知显示位置
+ ClassIsland 联动
+
+ {{ 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