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:
1 parent
d0830f6830
commit
7536abfc95
33 files changed
+1236
-162
No files matched your search
@@ -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
|
||||
|
||||
@@ -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`:提醒落库 → 待提醒 → 回执 → 确认后不补发 |
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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)。
|
||||
*
|
||||
|
||||
+7
@@ -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';
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 小时未送达/未确认的提醒不再补发,避免学生过几天突然看到旧提醒
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 '';
|
||||
|
||||
@@ -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> =>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user