feat(homeworks,island,classisland,desktop,web,server): 作业按天查看与教室机器录入 + 提醒上课时段暂存规则 + 灵动岛边距/跟随鼠标屏幕

- 作业:新增 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(需先构建产物)。
This commit is contained in:
LoveChengke committed 2026-09-27 16:34:01 +08:00
1 parent d0830f6830
commit 7536abfc95
33 files changed
+1236 -162

No files matched your search

+25 -1
View File
@@ -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\<id>\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
+44
View File
@@ -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`:提醒落库 → 待提醒 → 回执 → 确认后不补发 |
+11
View File
@@ -42,6 +42,17 @@
设置保存在 `<插件配置目录>/Settings.json`(随 ClassIsland 档案备份走,升级插件不丢)。
改动即时生效 —— 改地址 / 令牌 / 间隔会立刻重建上报定时器,不需要重启 ClassIsland。
> **上课时段策略(与 ClassHelper 客户端同一套规则)**:
> 上课时段里,通知类提醒会**先暂存、下课后自动补弹**;**紧急提醒**与**叫人**属于「主动通知」,立刻弹。
> 暂存期间不回执,服务端会保留这条待提醒。
> **两个方向各自需要的开关**:
>
> - 服务端 → ClassIsland(镜像课表):插件「把班级课表镜像回 ClassIsland」**且** Web 端该设备「镜像课表」都打开;
> - ClassIsland → 服务端(上报课表):插件「上报课表到班级小助手」打开;
> 关着时是正常行为(日志会写「仅状态(本机「上报课表到班级小助手」开关已关闭)」),不是上报失败;
> - ClassIsland 里一张课表都没有时不会上报课表(日志会写「ClassIsland 里没有**启用的课表**」)。
> **提醒到底会不会弹,取决于两个开关:**
>
> 1. 教室机器的 **ClassHelper 客户端**在「设置 → 通知显示位置」里选了「只在 ClassHelper 客户端弹」时,
@@ -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') &&
@@ -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; }
/// <summary>提醒类型:notification(默认)/ call(叫人,算"主动通知",上课时段也立刻弹)</summary>
[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; }
@@ -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;
}
@@ -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
/// </summary>
private readonly HashSet<string> _playing = new();
/// <summary>
/// 上课时段被暂存的提醒(与客户端灵动岛同一套规则)。
///
/// 为什么要有它:老师在**上课时段**发通知,教室里正上着课,直接全屏弹会打断课堂。
/// 客户端的灵动岛就是"上课先收着、下课再弹",ClassIsland 这侧必须一致,
/// 否则学生在客户端看不到、却在 ClassIsland 上被打断。
///
/// 例外("主动通知"):紧急提醒与**叫人**,老师正在等学生,必须立刻弹。
/// </summary>
private readonly List<PushNotificationDto> _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<ILessonsService>();
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, "班级小助手联动:订阅课程事件失败,上课暂存将退化为「立即弹出」");
}
}
/// <summary>当前是否处于上课时段(读不到课程服务时按"不在上课"处理,宁可弹也不静默丢)</summary>
private bool IsInClass()
{
try
{
return _lessons?.CurrentState == TimeState.OnClass;
}
catch
{
return false;
}
}
/// <summary>
/// 这条提醒是否必须立刻弹("主动通知"):
/// 紧急提醒,或叫人(老师正在等学生)。
/// </summary>
private static bool IsImmediate(PushNotificationDto notification) =>
notification.Urgent || string.Equals(notification.Kind, "call", StringComparison.OrdinalIgnoreCase);
/// <summary>下课后补弹暂存的提醒(按进入顺序)</summary>
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);
+33 -2
View File
@@ -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<HomeworkBoardSettings>;
/** 通知显示位置(向后兼容:旧配置文件没有该字段时按默认 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<string>();
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<HomeworkBoardSettings>): 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<IslandAppearance>): 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<DesktopStoredConfig>): DesktopStoredCo
...patch.homeworkBoard,
});
}
if (patch.homeworkPhrases !== undefined) {
persisted.homeworkPhrases = normalizeHomeworkPhrases(patch.homeworkPhrases);
}
if (patch.notificationChannel !== undefined) {
persisted.notificationChannel = normalizeNotificationChannel(patch.notificationChannel);
}
+17 -4
View File
@@ -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,
};
}
+34 -2
View File
@@ -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 + ' 个' };
@@ -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<ScheduleDto[]> =>
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<CourseDto[]> =>
api.get(API_PATHS.courses, classId ? { classId } : undefined),
};
export const homeworkApi = {
list: (params?: { classId?: string; pendingOnly?: boolean; courseId?: string }): Promise<HomeworkDto[]> =>
api.get(API_PATHS.homeworks, params),
list: (params?: {
classId?: string;
pendingOnly?: boolean;
courseId?: string;
/** 只看某一天(YYYY-MM-DD,按作业所属日期) */
date?: string;
}): Promise<HomeworkDto[]> => api.get(API_PATHS.homeworks, params),
/** 哪些天有作业(日期选择器高亮) */
days: (params: { classId?: string; from?: string; to?: string; days?: number }): Promise<HomeworkDaysDto> =>
api.get(`${API_PATHS.homeworks}/days`, params),
/** 在教室机器上直接录入作业(班级账号可调用;也可选所属日期) */
create: (payload: {
classId: string;
courseId?: string | null;
title: string;
content: string;
assignDate?: string | null;
}): Promise<HomeworkDto> => api.post(API_PATHS.homeworks, payload),
detail: (id: string): Promise<HomeworkDto> => api.get(`${API_PATHS.homeworks}/${id}`),
updateStatus: (id: string, completed: boolean): Promise<HomeworkStatusDto> =>
api.patch(`${API_PATHS.homeworks}/${id}/status`, { completed }),
+12 -3
View File
@@ -611,10 +611,19 @@ export async function homeworkBoardSelfTest(): Promise<SmokeCheckResult> {
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);
@@ -1,13 +1,26 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ElMessage } from 'element-plus';
import { SOCKET_EVENTS, formatDate, type HomeworkDto, type HomeworkSubmissionDto } from '@classhelper/shared';
import { homeworkApi } from '../api/index.js';
import {
HOMEWORK_PHRASE_DEFAULTS,
SOCKET_EVENTS,
dayKeyLocal,
formatDate,
isDayKey,
shiftDayKey,
type CourseDto,
type HomeworkDto,
type HomeworkSubmissionDto,
} from '@classhelper/shared';
import { courseApi, homeworkApi } from '../api/index.js';
import { fetchWithCache } from '../cache/index.js';
import { useAppStore } from '../stores/app.js';
import { useAuthStore } from '../stores/auth.js';
import { useRealtimeStore } from '../stores/realtime.js';
const route = useRoute();
const router = useRouter();
const appStore = useAppStore();
const auth = useAuthStore();
const realtime = useRealtimeStore();
@@ -17,45 +30,65 @@ const DEFAULT_BOARD = {
mode: 'board' as 'board' | 'list',
showTime: false,
fontSize: 15,
todayOnly: true,
};
const loading = ref(false);
const homeworks = ref<HomeworkDto[]>([]);
const filter = ref<'all' | 'pending' | 'done'>('all');
const fromCache = ref(false);
const updatedAt = ref<number | null>(null);
const detailVisible = ref(false);
const current = ref<HomeworkDto | null>(null);
const submitting = ref(false);
const viewMode = ref<'board' | 'list'>(DEFAULT_BOARD.mode);
const boardShowTime = ref(DEFAULT_BOARD.showTime);
const boardFontSize = ref(DEFAULT_BOARD.fontSize);
const todayOnly = ref(DEFAULT_BOARD.todayOnly);
const savingBoard = ref(false);
/** 全屏放大的科目(null = 未打开) */
const fullscreenCourse = ref<string | null>(null);
const fullscreenBoard = ref(false);
/* ------------------------------------------------------------ 当天的判定 */
/* ------------------------------------------------------------ 按天查看 */
/** 本地"今天"的日期串(YYYY-MM-DD) */
function localDayKey(value: string | Date): string {
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return '';
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
/**
* 当前查看的日期(YYYY-MM-DD,本地):默认今天,可用日期选择器或前后一天切换。
* 支持 `?date=YYYY-MM-DD` 深链(冒烟测试与"直接打开某天的作业"都靠它)。
*/
const selectedDate = ref(
typeof route.query.date === 'string' && isDayKey(route.query.date)
? route.query.date
: dayKeyLocal(new Date()),
);
/** 有作业的日期(日期选择器高亮):来自 GET /homeworks/days */
const dayMarks = ref<Map<string, number>>(new Map());
/** 录入作业的科目下拉 */
const courses = ref<CourseDto[]>([]);
/** 选中的是否就是今天(标题与按钮文案用) */
const isToday = computed(() => selectedDate.value === dayKeyLocal(now.value));
/** 日期选择器:给有作业的日期加高亮类 */
function dayCellClass(date: Date): string {
return dayMarks.value.has(dayKeyLocal(date)) ? 'day-has-homework' : '';
}
async function selectDate(value: string | null): Promise<void> {
if (!value || !isDayKey(value)) return;
selectedDate.value = value;
// 同步到地址栏:刷新/分享/冒烟测试都能落在同一天
await router.replace({ path: '/homeworks', query: { date: value } });
await loadHomework();
}
/** 前后一天(教室电脑上不用打开日历也能翻) */
async function shiftDate(delta: number): Promise<void> {
await selectDate(shiftDayKey(selectedDate.value, delta));
}
/** 每分钟刷新一次"现在",用于标题栏时钟与"今天"的判定 */
const now = ref(new Date());
let clockTimer: ReturnType<typeof setInterval> | null = null;
const todayKey = computed(() => localDayKey(now.value));
const todayText = computed(
() =>
`${now.value.getFullYear()} 年 ${now.value.getMonth() + 1} 月 ${now.value.getDate()} 日 ` +
@@ -63,21 +96,19 @@ const todayText = computed(
`${String(now.value.getHours()).padStart(2, '0')}:${String(now.value.getMinutes()).padStart(2, '0')}`,
);
/** 按"今天"与完成状态过滤 */
const filtered = computed(() => {
let list = homeworks.value;
if (todayOnly.value) list = list.filter((item) => localDayKey(item.createdAt) === todayKey.value);
if (filter.value === 'pending') return list.filter((item) => item.completed !== true);
if (filter.value === 'done') return list.filter((item) => item.completed === true);
return list;
/**
* 列表内容 = 服务端按天返回的结果(`?date=`),因此这里不再做二次过滤。
*
* 注意:"已完成"是老师端/通知中心的概念,客户端作业模块只做**展示 + 录入**,
* 因此这里没有任何完成状态相关的筛选与统计。
*/
const filtered = computed(() => homeworks.value);
const selectedDayText = computed(() => {
if (isToday.value) return '今天';
const [year, month, day] = selectedDate.value.split('-');
return `${Number(year)} 年 ${Number(month)} 月 ${Number(day)} 日`;
});
const todayCount = computed(
() => homeworks.value.filter((item) => localDayKey(item.createdAt) === todayKey.value).length,
);
const pendingCount = computed(() => filtered.value.filter((item) => item.completed !== true).length);
const doneCount = computed(() => filtered.value.filter((item) => item.completed === true).length);
/**
* 看板分组:按科目聚合成卡片(未关联课程的归到「其他」),
* 科目内部按发布时间倒序,卡片顺序按学科常见顺序排列。
@@ -102,7 +133,6 @@ const COURSE_ORDER = [
interface BoardColumn {
course: string;
items: HomeworkDto[];
pending: number;
}
const boardColumns = computed<BoardColumn[]>(() => {
@@ -116,7 +146,6 @@ const boardColumns = computed<BoardColumn[]>(() => {
const columns: BoardColumn[] = [...groups.entries()].map(([course, items]) => ({
course,
items: [...items].sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1)),
pending: items.filter((item) => item.completed !== true).length,
}));
const rank = (name: string): number => {
const index = COURSE_ORDER.findIndex((item) => name.includes(item));
@@ -241,16 +270,19 @@ async function loadHomework(): Promise<void> {
loading.value = true;
try {
const classId = auth.classId ?? undefined;
const day = selectedDate.value;
// 缓存按"班级 + 日期"分键:翻到别的天不会串数据,离线也能看回看过的那些天
const result = await fetchWithCache<HomeworkDto[]>(
'homeworks',
'self',
() => homeworkApi.list(classId ? { classId } : undefined),
`${classId ?? 'self'}@${day}`,
() => homeworkApi.list(classId ? { classId, date: day } : { date: day }),
[],
);
homeworks.value = result.data;
fromCache.value = result.fromCache;
updatedAt.value = result.updatedAt;
if (!result.fromCache) appStore.markSynced();
void loadDayMarks(classId);
if (current.value) {
current.value = homeworks.value.find((item) => item.id === current.value?.id) ?? current.value;
}
@@ -260,6 +292,28 @@ async function loadHomework(): Promise<void> {
}
}
/**
* 拉取"哪些天有作业"(日期选择器高亮)。
*
* 范围取当前查看日期前后各 45 天:教室电脑上翻月份也能看到有作业的日子。
*/
async function loadDayMarks(classId: string | undefined): Promise<void> {
try {
const from = shiftDayKey(selectedDate.value, -45);
const to = shiftDayKey(selectedDate.value, 45);
const result = await homeworkApi.days({ classId, from, to });
dayMarks.value = new Map(result.days.map((item) => [item.date, item.count]));
} catch {
// 离线/失败时保留上一次的高亮,不打断展示
}
}
/** 科目下拉(录入作业时可选) */
async function loadCourses(): Promise<void> {
if (!auth.classId) return;
courses.value = await courseApi.list(auth.classId).catch(() => []);
}
/** 读取看板偏好(主进程配置为单一事实来源) */
async function loadBoardSettings(): Promise<void> {
const config = await window.desktop?.getConfig?.();
@@ -267,7 +321,6 @@ async function loadBoardSettings(): Promise<void> {
if (!saved) return;
if (saved.mode === 'board' || saved.mode === 'list') viewMode.value = saved.mode;
if (typeof saved.showTime === 'boolean') boardShowTime.value = saved.showTime;
if (typeof saved.todayOnly === 'boolean') todayOnly.value = saved.todayOnly;
if (typeof saved.fontSize === 'number' && Number.isFinite(saved.fontSize)) {
boardFontSize.value = Math.min(28, Math.max(11, Math.round(saved.fontSize)));
}
@@ -282,7 +335,6 @@ async function saveBoardSettings(): Promise<void> {
mode: viewMode.value,
showTime: boardShowTime.value,
fontSize: boardFontSize.value,
todayOnly: todayOnly.value,
},
});
fitBoards();
@@ -308,31 +360,72 @@ function openBoardFullscreen(): void {
fitBoards();
}
async function toggleComplete(item: HomeworkDto | null): Promise<void> {
if (!item) return;
if (appStore.offline) {
ElMessage.warning('离线状态下无法提交,请恢复网络后重试');
/* ------------------------------------------------------------ 录入作业(教室机器) */
const createVisible = ref(false);
const createSaving = ref(false);
/** 快捷短语:来自客户端本地配置(设置 → 作业录入可增删) */
const phrases = ref<string[]>([...HOMEWORK_PHRASE_DEFAULTS]);
/** 短语插入到哪个输入框:跟着最后一次聚焦的字段走 */
const lastFocused = ref<'title' | 'content'>('content');
const createForm = ref({ courseId: '', title: '', content: '' });
async function loadPhrases(): Promise<void> {
const config = await window.desktop?.getConfig?.();
if (config?.homeworkPhrases) phrases.value = [...config.homeworkPhrases];
}
function openCreate(): void {
createForm.value = { courseId: '', title: '', content: '' };
lastFocused.value = 'content';
createVisible.value = true;
}
/** 点快捷短语:把词追加到当前字段末尾(空格分隔,避免连成一坨) */
function insertPhrase(phrase: string): void {
const field = lastFocused.value;
const current = createForm.value[field];
const next = current.trim() ? `${current.trimEnd()} ${phrase}` : phrase;
createForm.value = { ...createForm.value, [field]: next };
}
async function submitCreate(): Promise<void> {
const classId = auth.classId;
if (!classId) {
ElMessage.error('当前会话没有班级信息,无法录入作业');
return;
}
submitting.value = true;
if (appStore.offline) {
ElMessage.warning('离线状态下无法录入作业,请恢复网络后重试');
return;
}
const title = createForm.value.title.trim();
const content = createForm.value.content.trim();
if (!title) {
ElMessage.warning('请填写作业标题');
return;
}
if (!content) {
ElMessage.warning('请填写作业要求');
return;
}
createSaving.value = true;
try {
const next = item.completed !== true;
await homeworkApi.updateStatus(item.id, next);
item.completed = next;
if (item.homeworkStatus) item.homeworkStatus.completed = next;
else
item.homeworkStatus = {
id: `local-${item.id}`,
homeworkId: item.id,
userId: auth.user?.id ?? '',
completed: next,
updatedAt: new Date().toISOString(),
};
ElMessage.success(next ? '已标记为完成' : '已取消完成标记');
await homeworkApi.create({
classId,
courseId: createForm.value.courseId || null,
title,
content,
assignDate: selectedDate.value,
});
ElMessage.success(`已录入到 ${selectedDate.value}`);
createVisible.value = false;
await loadHomework();
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '提交失败');
ElMessage.error(error instanceof Error ? error.message : '录入失败');
} finally {
submitting.value = false;
createSaving.value = false;
}
}
@@ -415,6 +508,8 @@ let resizeObserver: ResizeObserver | null = null;
onMounted(async () => {
await loadBoardSettings();
await loadPhrases();
await loadCourses();
await loadHomework();
clockTimer = setInterval(() => {
now.value = new Date();
@@ -448,26 +543,30 @@ onUnmounted(() => {
<div>
<h2 class="page-title">我的作业</h2>
<p class="page-subtitle">
今天 {{ todayCount }} 份 · 待完成 {{ pendingCount }} · 已完成 {{ doneCount }}
{{ selectedDayText }} · {{ filtered.length }} 份
<el-tag v-if="fromCache" size="small" type="warning" effect="plain">离线缓存</el-tag>
</p>
</div>
<div class="toolbar">
<el-switch
v-model="todayOnly"
active-text="只看今天"
inactive-text="全部日期"
@change="saveBoardSettings"
<el-button-group>
<el-button :icon="'ArrowLeft'" @click="shiftDate(-1)">前一天</el-button>
<el-button :disabled="isToday" @click="selectDate(dayKeyLocal(now))">今天</el-button>
<el-button @click="shiftDate(1)">后一天<el-icon class="ml-4"><ArrowRight /></el-icon></el-button>
</el-button-group>
<el-date-picker
:model-value="selectedDate"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择日期"
class="day-picker"
:cell-class-name="dayCellClass"
@update:model-value="(value: string | null) => selectDate(value)"
/>
<el-radio-group v-model="filter">
<el-radio-button value="all">全部</el-radio-button>
<el-radio-button value="pending">未完成</el-radio-button>
<el-radio-button value="done">已完成</el-radio-button>
</el-radio-group>
<el-radio-group v-model="viewMode" @change="saveBoardSettings">
<el-radio-button value="board">看板</el-radio-button>
<el-radio-button value="list">列表</el-radio-button>
</el-radio-group>
<el-button type="primary" :icon="'Plus'" @click="openCreate">录入作业</el-button>
<el-button :icon="'Refresh'" @click="loadHomework">刷新</el-button>
</div>
</div>
@@ -499,7 +598,7 @@ onUnmounted(() => {
</el-card>
<el-card v-loading="loading" shadow="never" class="homework-body">
<el-empty v-if="filtered.length === 0" description="今天没有作业" />
<el-empty v-if="filtered.length === 0" :description="`${selectedDayText}没有作业`" />
<!-- 看板模式:按科目分卡片,点击卡片放大全屏、点击条目看详情 -->
<div v-else-if="viewMode === 'board'" ref="boardHostRef" class="board-host">
@@ -508,19 +607,17 @@ onUnmounted(() => {
v-for="column in boardColumns"
:key="column.course"
class="board-card"
:class="{ 'has-pending': column.pending > 0 }"
@click="openCourseFullscreen(column)"
>
<header class="board-card-head">
<span class="board-course">{{ column.course }}</span>
<span v-if="column.pending > 0" class="board-badge">{{ column.pending }}</span>
<span class="board-badge">{{ column.items.length }}</span>
</header>
<ol class="board-list">
<li
v-for="(item, index) in column.items"
:key="item.id"
class="board-item"
:class="{ done: item.completed }"
@click.stop="openDetail(item)"
>
<span class="board-index">{{ index + 1 }}.</span>
@@ -530,7 +627,6 @@ onUnmounted(() => {
<span v-if="item.content.trim()" class="board-content">{{ item.content }}</span>
</span>
<span v-if="boardShowTime" class="board-time">{{ formatDate(item.createdAt, true) }}</span>
<span v-if="item.completed" class="board-done-tag">已完成</span>
</li>
</ol>
<footer class="board-card-foot">
@@ -543,35 +639,23 @@ onUnmounted(() => {
<!-- 列表模式(默认表格视图) -->
<el-table v-else :data="filtered" @row-click="openDetail">
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="row.completed ? 'success' : 'info'" size="small" effect="light">
{{ row.completed ? '已完成' : '待完成' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="title" label="作业标题" min-width="200" show-overflow-tooltip />
<el-table-column prop="content" label="作业要求" min-width="240" show-overflow-tooltip />
<el-table-column label="课程" width="110">
<template #default="{ row }">{{ row.course?.name ?? '-' }}</template>
</el-table-column>
<el-table-column label="发布时间" width="170">
<el-table-column label="所属日期" width="120">
<template #default="{ row }">{{ row.assignDate }}</template>
</el-table-column>
<el-table-column label="录入时间" width="170">
<template #default="{ row }">{{ formatDate(row.createdAt, true) }}</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<el-table-column label="操作" width="130" fixed="right">
<template #default="{ row }">
<el-button v-if="auth.isClassSession" link type="primary" @click.stop="openSubmissions(row)">
未交名单
</el-button>
<el-button
v-else
link
:type="row.completed ? 'warning' : 'primary'"
:loading="submitting"
@click.stop="toggleComplete(row)"
>
{{ row.completed ? '取消完成' : '标记完成' }}
</el-button>
<el-button v-else link type="primary" @click.stop="openDetail(row)">查看</el-button>
</template>
</el-table-column>
</el-table>
@@ -602,7 +686,6 @@ onUnmounted(() => {
v-for="(item, index) in fullscreenItems"
:key="item.id"
class="board-item"
:class="{ done: item.completed }"
@click="openDetail(item)"
>
<span class="board-index">{{ index + 1 }}.</span>
@@ -611,11 +694,10 @@ onUnmounted(() => {
<span v-if="item.content.trim()" class="board-content">{{ item.content }}</span>
</span>
<span v-if="boardShowTime" class="board-time">{{ formatDate(item.createdAt, true) }}</span>
<span v-if="item.completed" class="board-done-tag">已完成</span>
</li>
</ol>
</div>
<el-empty v-if="fullscreenItems.length === 0" description="该科目今天没有作业" />
<el-empty v-if="fullscreenItems.length === 0" :description="`该科目 ${selectedDayText}没有作业`" />
</el-dialog>
<!-- 全屏:整块看板放大 -->
@@ -639,14 +721,13 @@ onUnmounted(() => {
<section v-for="column in boardColumns" :key="column.course" class="board-card">
<header class="board-card-head">
<span class="board-course">{{ column.course }}</span>
<span v-if="column.pending > 0" class="board-badge">{{ column.pending }}</span>
<span class="board-badge">{{ column.items.length }}</span>
</header>
<ol class="board-list">
<li
v-for="(item, index) in column.items"
:key="item.id"
class="board-item"
:class="{ done: item.completed }"
@click="openDetail(item)"
>
<span class="board-index">{{ index + 1 }}.</span>
@@ -662,6 +743,65 @@ onUnmounted(() => {
</div>
</el-dialog>
<!-- 录入作业(教室机器):快捷短语点一下就追加到标题/内容 -->
<el-dialog v-model="createVisible" title="录入作业" width="620px">
<el-form label-width="90px">
<el-form-item label="所属日期">
<el-date-picker
:model-value="selectedDate"
type="date"
value-format="YYYY-MM-DD"
:cell-class-name="dayCellClass"
@update:model-value="(value: string | null) => selectDate(value)"
/>
<span class="text-muted ml-8">默认就是当前正在查看的这一天</span>
</el-form-item>
<el-form-item label="科目">
<el-select v-model="createForm.courseId" clearable placeholder="可不选" style="width: 220px">
<el-option v-for="item in courses" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="标题">
<el-input
v-model="createForm.title"
maxlength="120"
show-word-limit
placeholder="例如:第 3 课生字"
@focus="lastFocused = 'title'"
/>
</el-form-item>
<el-form-item label="作业要求">
<el-input
v-model="createForm.content"
type="textarea"
:rows="3"
maxlength="5000"
placeholder="例如:P 12 大本,背诵第 3 段"
@focus="lastFocused = 'content'"
/>
</el-form-item>
<el-form-item v-if="phrases.length > 0" label="快捷短语">
<div class="phrase-row">
<el-tag
v-for="item in phrases"
:key="item"
class="phrase-chip"
type="info"
effect="plain"
@click="insertPhrase(item)"
>
{{ item }}
</el-tag>
<span class="text-muted">点一下追加到「{{ lastFocused === 'title' ? '标题' : '作业要求' }}」</span>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="createVisible = false">取消</el-button>
<el-button type="primary" :loading="createSaving" @click="submitCreate">保存作业</el-button>
</template>
</el-dialog>
<!-- 未交名单:勾选谁没交(其余学生一律视为已交) -->
<el-dialog v-model="submissionsVisible" title="未交名单" width="520px">
<p class="text-muted" style="margin-top: 0">
@@ -693,14 +833,10 @@ onUnmounted(() => {
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="课程">{{ current.course?.name ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="发布人">{{ current.creator?.name ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="发布时间">
<el-descriptions-item label="所属日期">{{ current.assignDate }}</el-descriptions-item>
<el-descriptions-item label="录入时间">
{{ formatDate(current.createdAt, true) }}
</el-descriptions-item>
<el-descriptions-item label="我的状态" :span="2">
<el-tag :type="current.completed ? 'success' : 'info'" size="small">
{{ current.completed ? '已完成' : '未完成' }}
</el-tag>
</el-descriptions-item>
</el-descriptions>
<div class="mt-16">
@@ -714,24 +850,10 @@ onUnmounted(() => {
</el-button>
</div>
<div class="mt-16">
<el-button
v-if="auth.isClassSession"
type="primary"
:disabled="appStore.offline"
@click="openSubmissions(current)"
>
<div v-if="auth.isClassSession" class="mt-16">
<el-button type="primary" :disabled="appStore.offline" @click="openSubmissions(current)">
维护未交名单
</el-button>
<el-button
v-else
type="primary"
:loading="submitting"
:disabled="appStore.offline"
@click="toggleComplete(current)"
>
{{ current.completed ? '取消完成标记' : '标记为已完成' }}
</el-button>
<span v-if="appStore.offline" class="text-muted" style="margin-left: 10px">离线状态下不可提交</span>
</div>
</template>
@@ -740,6 +862,35 @@ onUnmounted(() => {
</template>
<style scoped>
/* 日期选择器:有作业的日子加一个圆点,翻月份时一眼看出哪天有作业 */
:deep(.day-has-homework .el-date-table-cell__text)::after {
content: '';
position: absolute;
left: 50%;
bottom: 2px;
width: 4px;
height: 4px;
margin-left: -2px;
border-radius: 50%;
background: currentColor;
}
.day-picker {
width: 168px;
}
.phrase-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.phrase-chip {
cursor: pointer;
user-select: none;
}
.text-danger {
color: #f56c6c;
}
@@ -932,7 +1083,7 @@ onUnmounted(() => {
color: #8e8e93;
}
.board-done-tag {
.board-done-tag-unused {
flex: 0 0 auto;
font-size: calc(var(--board-font, 15px) * 0.66);
color: #32d74b;
@@ -6,6 +6,9 @@ import {
CLASSISLAND_NOTIFICATION_CHANNEL_HINTS,
CLASSISLAND_NOTIFICATION_CHANNEL_LABELS,
CLASSISLAND_NOTIFICATION_CHANNELS,
HOMEWORK_PHRASE_DEFAULTS,
HOMEWORK_PHRASE_MAX_COUNT,
HOMEWORK_PHRASE_MAX_LENGTH,
DEFAULT_ISLAND_APPEARANCE,
ISLAND_APPEARANCE_RANGES as RANGES,
ISLAND_POSITION_LABELS,
@@ -19,7 +22,7 @@ import {
type IslandStyle,
} from '@classhelper/shared';
import type { DesktopAppInfo } from '../../types/desktop.js';
import { classApi } from '../api/index.js';
import { classIslandStatusApi } from '../api/index.js';
import { pingHealth, setApiBaseUrl } from '../api/http.js';
import { normalizeServerUrl } from '../config.js';
import { useAppStore } from '../stores/app.js';
@@ -137,6 +140,22 @@ const channelSaving = ref(false);
* 注意**不能**顺手把「两端都弹」也禁掉:那样教室一旦选过"只在客户端",没设备时就再也切不回来了。
*/
const classIslandConnected = ref(false);
/** 本班 ClassIsland 设备的展示信息(设备名 / 最后上报),来自 /classes/:id/classisland-status */
const classIslandStatus = ref<{
connected: boolean;
deviceName: string | null;
deviceCount: number;
lastSeenAt: string | null;
pluginVersion: string | null;
classIslandVersion: string | null;
} | null>(null);
const classIslandStatusText = computed(() => {
const status = classIslandStatus.value;
if (!status || !status.connected) return '未接入';
const seen = status.lastSeenAt ? `最后上报 ${formatDate(status.lastSeenAt, true)}` : '尚未上报过';
return `已接入 · ${status.deviceName ?? 'ClassIsland 设备'} · ${seen}`;
});
const channelOptions = computed(() =>
CLASSISLAND_NOTIFICATION_CHANNELS.map((value) => ({
@@ -163,6 +182,63 @@ async function applyChannel(value: ClassIslandNotificationChannel): Promise<void
}
}
/* ------------------------------------------------------------ 作业录入快捷短语 */
/**
* 作业快捷短语:录作业时点一下就把词追加到标题/内容里(P、大本、背诵…)。
*
* 存在客户端本地配置(这台教室机器自己的习惯),不占用服务端字段;
* 空数组是合法值 —— 老师把短语全删了就是"不想用快捷短语"。
*/
const phrases = ref<string[]>([...HOMEWORK_PHRASE_DEFAULTS]);
const phraseInput = ref('');
const savingPhrases = ref(false);
async function loadPhrases(): Promise<void> {
const config = await window.desktop?.getConfig?.();
if (config?.homeworkPhrases) phrases.value = [...config.homeworkPhrases];
}
async function savePhrases(): Promise<void> {
if (!window.desktop?.saveConfig) return;
savingPhrases.value = true;
try {
const saved = await window.desktop.saveConfig({ homeworkPhrases: [...phrases.value] });
phrases.value = [...saved.homeworkPhrases];
} finally {
savingPhrases.value = false;
}
}
async function addPhrase(): Promise<void> {
const value = phraseInput.value.trim().slice(0, HOMEWORK_PHRASE_MAX_LENGTH);
if (!value) return;
if (phrases.value.includes(value)) {
ElMessage.warning('这条短语已经有了');
return;
}
if (phrases.value.length >= HOMEWORK_PHRASE_MAX_COUNT) {
ElMessage.warning(`最多 ${HOMEWORK_PHRASE_MAX_COUNT} 条快捷短语`);
return;
}
phrases.value = [...phrases.value, value];
phraseInput.value = '';
await savePhrases();
ElMessage.success('已添加');
}
async function removePhrase(value: string): Promise<void> {
phrases.value = phrases.value.filter((item) => item !== value);
await savePhrases();
}
async function resetPhrases(): Promise<void> {
await ElMessageBox.confirm('恢复为默认快捷短语?', '恢复默认', { type: 'warning' });
phrases.value = [...HOMEWORK_PHRASE_DEFAULTS];
await savePhrases();
ElMessage.success('已恢复默认');
}
/* ------------------------------------------------------------ 灵动岛个性化 */
const island = ref<IslandAppearance>({ ...DEFAULT_ISLAND_APPEARANCE });
@@ -235,11 +311,13 @@ onMounted(async () => {
await appStore.refreshCacheStats();
await loadIslandAppearance();
await loadClassChannel();
// 本班有没有 ClassIsland 设备:没接就禁用"弹在 ClassIsland"这一项,避免选了却没效果
const classList = await classApi.list().catch(() => []);
classIslandConnected.value = classList.some(
(item) => item.id === auth.classId && item.classIslandConnected === true,
);
await loadPhrases();
// 本班 ClassIsland 联动状态:没接设备就别让选「只在 ClassIsland 上弹」
if (auth.classId) {
const status = await classIslandStatusApi.get(auth.classId).catch(() => null);
classIslandStatus.value = status;
classIslandConnected.value = status?.connected === true;
}
});
</script>
@@ -380,6 +458,36 @@ onMounted(async () => {
/>
</el-select>
</el-form-item>
<el-form-item :label="`左右边距 ${island.marginX}px`">
<el-slider
:model-value="island.marginX"
:min="RANGES.marginX.min"
:max="RANGES.marginX.max"
:step="2"
@input="(value: number) => applyIslandAppearance({ marginX: value })"
/>
<span class="text-muted ml-8">停靠左/右时距屏幕边缘的距离</span>
</el-form-item>
<el-form-item :label="`上下边距 ${island.marginY}px`">
<el-slider
:model-value="island.marginY"
:min="RANGES.marginY.min"
:max="RANGES.marginY.max"
:step="2"
@input="(value: number) => applyIslandAppearance({ marginY: value })"
/>
<span class="text-muted ml-8">停靠顶/底时距屏幕边缘的距离(任务栏在侧面时调大)</span>
</el-form-item>
<el-form-item label="跟随鼠标屏幕">
<el-switch
:model-value="island.followCursorDisplay"
@change="
(value: boolean | string | number) =>
applyIslandAppearance({ followCursorDisplay: Boolean(value) })
"
/>
<span class="text-muted ml-8">多显示器教室电脑:岛出现在鼠标所在的那块屏幕</span>
</el-form-item>
<el-form-item label="动画">
<el-switch
:model-value="island.animations"
@@ -422,10 +530,16 @@ onMounted(async () => {
/>
</el-card>
<el-card shadow="never" class="mt-12">
<template #header><span>通知显示位置</span></template>
<template #header><span>ClassIsland 联动</span></template>
<el-descriptions :column="1" border size="small" class="mb-12">
<el-descriptions-item label="联动状态">{{ classIslandStatusText }}</el-descriptions-item>
<el-descriptions-item label="插件 / ClassIsland">
{{ classIslandStatus?.pluginVersion ?? '—' }} / {{ classIslandStatus?.classIslandVersion ?? '—' }}
</el-descriptions-item>
</el-descriptions>
<p class="text-muted">
老师在班级小助手上发布通知 / 叫人时,提醒要弹在哪里由这台教室机器决定。选择会同步到班级,
服务端据此决定是否一并推送到教室的 ClassIsland。
<strong>通知模式</strong>:老师在班级小助手上发布通知 / 叫人时,提醒弹在灵动岛还是 ClassIsland,
由这台教室机器决定。选择会同步到班级,服务端据此决定是否推送到教室的 ClassIsland。
</p>
<el-radio-group
:model-value="appStore.notificationChannel"
@@ -445,6 +559,38 @@ onMounted(async () => {
</p>
</el-card>
<el-card shadow="never" class="mt-12">
<template #header><span>作业录入</span></template>
<p class="text-muted">
在教室电脑上录作业时,点一下短语就会追加到标题 / 内容里(例如 P、大本、背诵)。
最多 {{ HOMEWORK_PHRASE_MAX_COUNT }} 条、每条 {{ HOMEWORK_PHRASE_MAX_LENGTH }} 字;全部删掉即不在录入页显示。
</p>
<div class="phrase-row">
<el-tag
v-for="item in phrases"
:key="item"
closable
type="info"
effect="plain"
@close="removePhrase(item)"
>
{{ item }}
</el-tag>
<span v-if="phrases.length === 0" class="text-muted">(已清空)</span>
</div>
<div class="toolbar mt-12">
<el-input
v-model="phraseInput"
placeholder="新增短语,如 大本"
style="width: 200px"
:maxlength="HOMEWORK_PHRASE_MAX_LENGTH"
@keyup.enter="addPhrase"
/>
<el-button type="primary" :loading="savingPhrases" @click="addPhrase">添加</el-button>
<el-button @click="resetPhrases">恢复默认</el-button>
</div>
</el-card>
<el-card shadow="never" class="mt-12">
<template #header><span>账号信息</span></template>
<el-descriptions :column="1" border size="small">
@@ -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;
}
+10 -2
View File
@@ -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)。
*
@@ -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");
@@ -0,0 +1,2 @@
-- 提醒类型:notification=通知类 / call=叫人(叫人算「主动通知」,上课时段也立即弹出)
ALTER TABLE "ClassIslandPush" ADD COLUMN "kind" TEXT NOT NULL DEFAULT 'notification';
+5
View File
@@ -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 时写入)
@@ -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',
+70
View File
@@ -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',
+5
View File
@@ -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,
@@ -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,
@@ -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 - 读取"通知显示到哪个端"
*
@@ -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,
};
}
/* ---------------------------------------------------------------- 通知显示位置 */
/**
@@ -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<ListHomeworkDaysInput>(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);
@@ -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<typeof listHomeworkDaysQuerySchema>;
export type CreateHomeworkInput = z.infer<typeof createHomeworkSchema>;
export type UpdateHomeworkInput = z.infer<typeof updateHomeworkSchema>;
export type UpdateHomeworkStatusInput = z.infer<typeof updateHomeworkStatusSchema>;
@@ -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<HomeworkDaysDto> {
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<string, number>();
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<HomeworkDto> {
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<HomeworkDto> {
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 },
});
@@ -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 小时未送达/未确认的提醒不再补发,避免学生过几天突然看到旧提醒
+42 -6
View File
@@ -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<string, string> = {
export const CLASSISLAND_NOTIFICATION_CHANNELS = ['both', 'client', 'classisland'] as const;
export const CLASSISLAND_NOTIFICATION_CHANNEL_LABELS: Record<string, string> = {
both: 'ClassHelper 与 ClassIsland 都弹',
client: '只在 ClassHelper 客户端弹',
classisland: '只在 ClassIsland 上弹',
both: '两者都弹',
client: '灵动岛(ClassHelper 客户端)',
classisland: 'ClassIsland',
};
export const CLASSISLAND_NOTIFICATION_CHANNEL_HINTS: Record<string, string> = {
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;
+34
View File
@@ -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;
+29
View File
@@ -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 '';
+10
View File
@@ -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<HomeworkDto[]> => api.get(API_PATHS.homeworks, params),
/** 哪些天有作业(日期选择器高亮) */
days: (params: {
classId?: string;
from?: string;
to?: string;
days?: number;
}): Promise<HomeworkDaysDto> => api.get(`${API_PATHS.homeworks}/days`, params),
detail: (id: string): Promise<HomeworkDto> => api.get(`${API_PATHS.homeworks}/${id}`),
create: (payload: CreateHomeworkRequest): Promise<HomeworkDto> => api.post(API_PATHS.homeworks, payload),
update: (id: string, payload: Partial<CreateHomeworkRequest>): Promise<HomeworkDto> =>
+78 -2
View File
@@ -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<ClassDto[]>([]);
const courses = ref<CourseDto[]>([]);
const homeworks = ref<HomeworkDto[]>([]);
const filter = reactive({ classId: '', courseId: '', keyword: '' });
const filter = reactive({ classId: '', courseId: '', keyword: '', date: '' });
/** 有作业的日期(日期选择器高亮),来自 GET /homeworks/days */
const dayMarks = ref<Map<string, number>>(new Map());
const detailVisible = ref(false);
const current = ref<HomeworkDto | null>(null);
@@ -108,10 +112,12 @@ async function loadCourses(): Promise<void> {
async function loadHomeworks(): Promise<void> {
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<void> {
async function onClassChange(): Promise<void> {
filter.courseId = '';
await loadCourses();
await loadDayMarks();
await loadHomeworks();
}
/** 拉取"哪些天有作业"(日期选择器高亮);范围取今天前后各 45 天 */
async function loadDayMarks(): Promise<void> {
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<void> {
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(() => {
>
<el-option v-for="item in courses" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
<el-date-picker
v-model="filter.date"
type="date"
value-format="YYYY-MM-DD"
placeholder="按天查看"
clearable
class="day-picker"
:cell-class-name="dayCellClass"
@change="loadHomeworks"
/>
<el-input
v-model="filter.keyword"
placeholder="搜索标题/内容"
@@ -400,6 +443,16 @@ onUnmounted(() => {
}}
</div>
</el-form-item>
<el-form-item label="所属日期">
<el-date-picker
v-model="form.assignDate"
type="date"
value-format="YYYY-MM-DD"
:cell-class-name="dayCellClass"
style="width: 200px"
/>
<span class="form-hint">作业会出现在这一天(按天查看与学生端都按它归类)</span>
</el-form-item>
<el-form-item label="标题" prop="title">
<el-input v-model="form.title" maxlength="120" show-word-limit />
</el-form-item>
@@ -502,6 +555,29 @@ onUnmounted(() => {
</template>
<style scoped>
/* 日期选择器:有作业的日子加一个圆点,翻月份时一眼看出哪天有作业 */
:deep(.day-has-homework .el-date-table-cell__text)::after {
content: '';
position: absolute;
left: 50%;
bottom: 2px;
width: 4px;
height: 4px;
margin-left: -2px;
border-radius: 50%;
background: currentColor;
}
.day-picker {
width: 168px;
}
.form-hint {
margin-left: 10px;
opacity: 0.65;
font-size: 12px;
}
.text-danger {
color: #f56c6c;
}