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`)。改动这条链路要同时动三处:
|
服务端据此决定是否推 ClassIsland(`shouldPushToClassIsland`)。改动这条链路要同时动三处:
|
||||||
客户端设置页与本地配置、`lib/mappers` 的 DTO、服务端推送判断。
|
客户端设置页与本地配置、`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. 代码风格
|
## 6. 代码风格
|
||||||
|
|
||||||
- **TypeScript ESM + NodeNext**:`package.json` 全是 `"type": "module"`,相对导入**必须带 `.js` 后缀**
|
- **TypeScript ESM + NodeNext**:`package.json` 全是 `"type": "module"`,相对导入**必须带 `.js` 后缀**
|
||||||
@@ -337,9 +349,21 @@ pnpm dist:classisland-plugin
|
|||||||
- 服务端"回执才算送达",因此插件必须对**播放中的提醒按 id 去重**,否则轮询间隔 < 提醒时长时会反复弹;
|
- 服务端"回执才算送达",因此插件必须对**播放中的提醒按 id 去重**,否则轮询间隔 < 提醒时长时会反复弹;
|
||||||
- `Subject.Fallback` 的名字就是 `???` 占位符,上报前要归一化成"没有科目"。
|
- `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. 交付前检查
|
## 8. 交付前检查
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -536,6 +536,10 @@ pnpm verify:packaged # 对**打包后/已安装**的客户端 EXE 跑同一
|
|||||||
|
|
||||||
### 个性化设置与系统托盘
|
### 个性化设置与系统托盘
|
||||||
|
|
||||||
|
- **设置 → 灵动岛 · 个性化**新增:左右 / 上下**边距**(贴边距离,任务栏在侧面时调大)、
|
||||||
|
- **设置 → ClassIsland 联动**:联动状态(设备名 / 最后上报)+ 通知模式(两者都弹 / 灵动岛 / ClassIsland)。
|
||||||
|
- **设置 → 作业录入**:作业快捷短语的增删与恢复默认。
|
||||||
|
|
||||||
**设置 → 通知显示位置**:提醒弹在 ClassHelper 还是 ClassIsland(见 [ClassIsland 联动](#classisland-联动))。
|
**设置 → 通知显示位置**:提醒弹在 ClassHelper 还是 ClassIsland(见 [ClassIsland 联动](#classisland-联动))。
|
||||||
|
|
||||||
**设置 → 灵动岛 · 个性化**(改动即实时生效,保存后写入本地配置,重启仍生效):
|
**设置 → 灵动岛 · 个性化**(改动即实时生效,保存后写入本地配置,重启仍生效):
|
||||||
@@ -684,6 +688,25 @@ pnpm dist:win # nsis 安装包 + portable 单文件(需联网下载 NSIS
|
|||||||
冒烟准备班级凭据时**不改任何数据**:先试冒烟密码与种子默认 `123456`,命中就直接用;
|
冒烟准备班级凭据时**不改任何数据**:先试冒烟密码与种子默认 `123456`,命中就直接用;
|
||||||
都不通才会临时重置该班密码,并在脚本收尾时恢复为 `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 管理端 → 学生管理 → 叫人**(或 **通知发布 → 叫人**)选中学生,配合**快捷短语**或**自定义消息**发送。
|
老师在 **Web 管理端 → 学生管理 → 叫人**(或 **通知发布 → 叫人**)选中学生,配合**快捷短语**或**自定义消息**发送。
|
||||||
@@ -896,6 +919,25 @@ POST /api/notifications { …, priority: "NORMAL" }(上课时段)
|
|||||||
| 「叫人」 | 老师点名让某位同学过去,教室大屏也会播报一条 |
|
| 「叫人」 | 老师点名让某位同学过去,教室大屏也会播报一条 |
|
||||||
| 「ClassIsland 联动」页的「下发提醒」 | 专门发给教室大屏的提醒(支持时长 / 语音 / 优先级) |
|
| 「ClassIsland 联动」页的「下发提醒」 | 专门发给教室大屏的提醒(支持时长 / 语音 / 优先级) |
|
||||||
|
|
||||||
|
### 上课时段策略:ClassIsland 与客户端同一套规则
|
||||||
|
|
||||||
|
老师在**上课时段**发通知时,教室里的两个端都遵守同一条规则(否则学生在客户端看不到、却在 ClassIsland 上被打断):
|
||||||
|
|
||||||
|
| 提醒类型 | 上课时段 | 下课 / 放学 |
|
||||||
|
| ------------------------------------------------------ | --------------------------------------------- | ----------- |
|
||||||
|
| 通知类(`kind=notification`:通知发布页 / 联动页下发) | **暂存不弹**(客户端与 ClassIsland 都先收着) | 自动补弹 |
|
||||||
|
| **主动通知**:紧急提醒、**叫人**(`kind=call`) | **立刻弹**(老师正在等学生) | 立刻弹 |
|
||||||
|
|
||||||
|
> ClassIsland 侧的暂存由插件实现:订阅课程服务,下课后把暂存的提醒补弹出来;未弹出前不回执,服务端会保留待提醒。
|
||||||
|
|
||||||
|
### 两个方向各需要哪些开关
|
||||||
|
|
||||||
|
| 要同步的内容 | 需要打开的开关 |
|
||||||
|
| -------------------------------- | ----------------------------------------------------------------------------- |
|
||||||
|
| 服务端 → ClassIsland(镜像课表) | ① 插件设置「把班级课表镜像回 ClassIsland」+ ② Web 端该设备的「镜像课表」 |
|
||||||
|
| ClassIsland → 服务端(上报课表) | 插件设置「上报课表到班级小助手」(关着时日志会明说"仅状态(开关已关闭)") |
|
||||||
|
| 提醒下发 | ① 教室客户端「通知模式」不是"只在 ClassHelper" + ② 插件「接收班级小助手提醒」 |
|
||||||
|
|
||||||
### 提醒弹在哪个端:由教室客户端自己选
|
### 提醒弹在哪个端:由教室客户端自己选
|
||||||
|
|
||||||
教室机器的 ClassHelper 客户端「设置 → 通知显示位置」里选(选择会同步到班级记录):
|
教室机器的 ClassHelper 客户端「设置 → 通知显示位置」里选(选择会同步到班级记录):
|
||||||
@@ -1152,6 +1194,8 @@ pnpm db:migrate && pnpm db:seed
|
|||||||
| **授课科目统一(18 科固定目录)** | ✅ | `verify:web`:新增课表的科目下拉出现「统一科目(选中后自动建课)」,选中 → 自动建课 → 写入课表成功(收尾删除该条课表) |
|
| **授课科目统一(18 科固定目录)** | ✅ | `verify:web`:新增课表的科目下拉出现「统一科目(选中后自动建课)」,选中 → 自动建课 → 写入课表成功(收尾删除该条课表) |
|
||||||
| **登录页无示例内容** | ✅ | `verify:web`(管理端)与 `verify:desktop`(客户端)都断言登录页正文/占位符不含 `演示 / 示例 / admin123 / teacher123 / G101` |
|
| **登录页无示例内容** | ✅ | `verify:web`(管理端)与 `verify:desktop`(客户端)都断言登录页正文/占位符不含 `演示 / 示例 / admin123 / teacher123 / G101` |
|
||||||
| **其他页面发通知也会联动 ClassIsland** | ✅ | `verify:classisland`:「通知发布页发的通知也会推送到 ClassIsland」「叫人也会推送到 ClassIsland」;真机实测截图 [03-from-notify-page](docs/screenshots/classisland/03-from-notify-page.png) |
|
| **其他页面发通知也会联动 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 时通知与联动页下发都被跳过) |
|
| **提醒弹在哪个端由客户端自行选择** | ✅ | 客户端「设置 → 通知显示位置」三选一(`verify:desktop`:设置页真实点击 → 写回本地配置,在线 82/82);服务端按班级设置决定是否推送(`verify:classisland`:client 时通知与联动页下发都被跳过) |
|
||||||
| **班级小助手能读取 ClassIsland 的课表** | ✅ | `verify:classisland`:插件上报课表 → 本班课表新增/更新(重复上报幂等)、节次时间写入、设备状态快照可在 Web 端展示;`verify:web`:教师在「ClassIsland 联动」页真实签发设备令牌 |
|
| **班级小助手能读取 ClassIsland 的课表** | ✅ | `verify:classisland`:插件上报课表 → 本班课表新增/更新(重复上报幂等)、节次时间写入、设备状态快照可在 Web 端展示;`verify:web`:教师在「ClassIsland 联动」页真实签发设备令牌 |
|
||||||
| **用户可选择通知是否在 ClassIsland 上显示** | ✅ | 插件设置页开关「接收班级小助手提醒」(本机总闸)+ Web 端「下发提醒」(标题/内容/时长/优先级/语音朗读/是否同步通知中心);`verify:classisland`:提醒落库 → 待提醒 → 回执 → 确认后不补发 |
|
| **用户可选择通知是否在 ClassIsland 上显示** | ✅ | 插件设置页开关「接收班级小助手提醒」(本机总闸)+ Web 端「下发提醒」(标题/内容/时长/优先级/语音朗读/是否同步通知中心);`verify:classisland`:提醒落库 → 待提醒 → 回执 → 确认后不补发 |
|
||||||
|
|||||||
@@ -42,6 +42,17 @@
|
|||||||
设置保存在 `<插件配置目录>/Settings.json`(随 ClassIsland 档案备份走,升级插件不丢)。
|
设置保存在 `<插件配置目录>/Settings.json`(随 ClassIsland 档案备份走,升级插件不丢)。
|
||||||
改动即时生效 —— 改地址 / 令牌 / 间隔会立刻重建上报定时器,不需要重启 ClassIsland。
|
改动即时生效 —— 改地址 / 令牌 / 间隔会立刻重建上报定时器,不需要重启 ClassIsland。
|
||||||
|
|
||||||
|
> **上课时段策略(与 ClassHelper 客户端同一套规则)**:
|
||||||
|
> 上课时段里,通知类提醒会**先暂存、下课后自动补弹**;**紧急提醒**与**叫人**属于「主动通知」,立刻弹。
|
||||||
|
> 暂存期间不回执,服务端会保留这条待提醒。
|
||||||
|
|
||||||
|
> **两个方向各自需要的开关**:
|
||||||
|
>
|
||||||
|
> - 服务端 → ClassIsland(镜像课表):插件「把班级课表镜像回 ClassIsland」**且** Web 端该设备「镜像课表」都打开;
|
||||||
|
> - ClassIsland → 服务端(上报课表):插件「上报课表到班级小助手」打开;
|
||||||
|
> 关着时是正常行为(日志会写「仅状态(本机「上报课表到班级小助手」开关已关闭)」),不是上报失败;
|
||||||
|
> - ClassIsland 里一张课表都没有时不会上报课表(日志会写「ClassIsland 里没有**启用的课表**」)。
|
||||||
|
|
||||||
> **提醒到底会不会弹,取决于两个开关:**
|
> **提醒到底会不会弹,取决于两个开关:**
|
||||||
>
|
>
|
||||||
> 1. 教室机器的 **ClassHelper 客户端**在「设置 → 通知显示位置」里选了「只在 ClassHelper 客户端弹」时,
|
> 1. 教室机器的 **ClassHelper 客户端**在「设置 → 通知显示位置」里选了「只在 ClassHelper 客户端弹」时,
|
||||||
|
|||||||
@@ -273,6 +273,21 @@ check(
|
|||||||
'ClassIsland 的占位科目(Subject.Fallback)折算成"没有科目"',
|
'ClassIsland 的占位科目(Subject.Fallback)折算成"没有科目"',
|
||||||
mapperSource.includes('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(
|
check(
|
||||||
'提醒轮询独立于上报间隔(老师发完通知不必等一个上报周期)',
|
'提醒轮询独立于上报间隔(老师发完通知不必等一个上报周期)',
|
||||||
settingsSource.includes('NotificationPollSeconds') &&
|
settingsSource.includes('NotificationPollSeconds') &&
|
||||||
|
|||||||
@@ -103,6 +103,9 @@ public class PushNotificationDto
|
|||||||
[JsonPropertyName("speechContent")] public string? SpeechContent { get; set; }
|
[JsonPropertyName("speechContent")] public string? SpeechContent { get; set; }
|
||||||
[JsonPropertyName("createdAt")] public string CreatedAt { get; set; } = "";
|
[JsonPropertyName("createdAt")] public string CreatedAt { get; set; } = "";
|
||||||
[JsonPropertyName("urgent")] public bool Urgent { 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("classId")] public string ClassId { get; set; } = "";
|
||||||
[JsonPropertyName("className")] public string? ClassName { get; set; }
|
[JsonPropertyName("className")] public string? ClassName { get; set; }
|
||||||
[JsonPropertyName("teacherName")] public string? TeacherName { get; set; }
|
[JsonPropertyName("teacherName")] public string? TeacherName { get; set; }
|
||||||
|
|||||||
@@ -242,14 +242,30 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
|||||||
|
|
||||||
var entryCount = 0;
|
var entryCount = 0;
|
||||||
var layoutCount = 0;
|
var layoutCount = 0;
|
||||||
|
// 开关关着时也要留下原因:否则日志只有"上报成功:仅状态",
|
||||||
|
// 用户会以为"服务端拿不到 ClassIsland 课表"是插件坏了。
|
||||||
|
if (includeSchedule && !_settings.UploadSchedule)
|
||||||
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"班级小助手联动:本机设置里「上报课表到班级小助手」是关闭的,本次只上报状态;" +
|
||||||
|
"需要把教室课表同步到班级小助手请打开该开关");
|
||||||
|
}
|
||||||
|
|
||||||
if (includeSchedule && _settings.UploadSchedule && profile is not null)
|
if (includeSchedule && _settings.UploadSchedule && profile is not null)
|
||||||
{
|
{
|
||||||
var entries = ScheduleMapper.MapAllClassPlans(profile, out var warnings);
|
var entries = ScheduleMapper.MapAllClassPlans(profile, out var warnings);
|
||||||
foreach (var warning in warnings) _logger.LogWarning("班级小助手联动:{Warning}", warning);
|
foreach (var warning in warnings) _logger.LogWarning("班级小助手联动:{Warning}", warning);
|
||||||
entryCount = entries.Count;
|
entryCount = entries.Count;
|
||||||
request.Schedule = new SchedulePayloadDto { Mode = "merge", Entries = entries };
|
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);
|
var layout = ResolvePrimaryTimeLayout(profile);
|
||||||
if (layout is not null)
|
if (layout is not null)
|
||||||
@@ -285,7 +301,9 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
|||||||
|
|
||||||
var detail = entryCount > 0
|
var detail = entryCount > 0
|
||||||
? $"课表 {entryCount} 节 / 节次 {layoutCount} 条"
|
? $"课表 {entryCount} 节 / 节次 {layoutCount} 条"
|
||||||
: "仅状态";
|
: _settings.UploadSchedule
|
||||||
|
? "仅状态(ClassIsland 里还没有启用的课表)"
|
||||||
|
: "仅状态(本机「上报课表到班级小助手」开关已关闭)";
|
||||||
SetState(true, $"上报成功({reason}):{detail},服务端第 {data.Week} 周",
|
SetState(true, $"上报成功({reason}):{detail},服务端第 {data.Week} 周",
|
||||||
entryCount, layoutCount, 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);
|
var (result, plan) = await _client.PullClassPlanAsync(_settings, CancellationToken.None).ConfigureAwait(false);
|
||||||
if (!result.Ok || plan is null)
|
if (!result.Ok || plan is null)
|
||||||
{
|
{
|
||||||
// 服务端在"镜像开关没开"时会返回 data=null —— 这不是错误,只记信息
|
// 服务端在"镜像开关没开"时会返回 data=null —— 这不是错误,但要告诉用户去哪儿开
|
||||||
_logger.LogInformation("班级小助手联动:跳过课表镜像({Message})", result.Message);
|
_logger.LogInformation(
|
||||||
|
"班级小助手联动:跳过课表镜像({Message})。" +
|
||||||
|
"需要在 Web 端「ClassIsland 联动」页给这台设备打开「镜像课表」,然后点一次「立即上报」或等下一个上报周期",
|
||||||
|
result.Message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
using ClassHelper.ClassIslandPlugin.Interop;
|
using ClassHelper.ClassIslandPlugin.Interop;
|
||||||
using ClassHelper.ClassIslandPlugin.Models;
|
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.Abstractions.Services.NotificationProviders;
|
||||||
using ClassIsland.Core.Attributes;
|
using ClassIsland.Core.Attributes;
|
||||||
using ClassIsland.Core.Models.Notification;
|
using ClassIsland.Core.Models.Notification;
|
||||||
@@ -48,6 +51,19 @@ public sealed class ClassHelperNotificationProvider : NotificationProviderBase
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private readonly HashSet<string> _playing = new();
|
private readonly HashSet<string> _playing = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 上课时段被暂存的提醒(与客户端灵动岛同一套规则)。
|
||||||
|
///
|
||||||
|
/// 为什么要有它:老师在**上课时段**发通知,教室里正上着课,直接全屏弹会打断课堂。
|
||||||
|
/// 客户端的灵动岛就是"上课先收着、下课再弹",ClassIsland 这侧必须一致,
|
||||||
|
/// 否则学生在客户端看不到、却在 ClassIsland 上被打断。
|
||||||
|
///
|
||||||
|
/// 例外("主动通知"):紧急提醒与**叫人**,老师正在等学生,必须立刻弹。
|
||||||
|
/// </summary>
|
||||||
|
private readonly List<PushNotificationDto> _deferred = new();
|
||||||
|
|
||||||
|
private ILessonsService? _lessons;
|
||||||
|
|
||||||
public ClassHelperNotificationProvider(
|
public ClassHelperNotificationProvider(
|
||||||
BridgeService bridge,
|
BridgeService bridge,
|
||||||
PluginSettings settings,
|
PluginSettings settings,
|
||||||
@@ -57,6 +73,54 @@ public sealed class ClassHelperNotificationProvider : NotificationProviderBase
|
|||||||
_settings = settings;
|
_settings = settings;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_bridge.NotificationReceived += OnNotificationReceived;
|
_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)
|
private void OnNotificationReceived(object? sender, PushNotificationDto notification)
|
||||||
@@ -83,6 +147,14 @@ public sealed class ClassHelperNotificationProvider : NotificationProviderBase
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
// 上课时段:非"主动通知"先收着(与客户端灵动岛同一套规则),下课后补弹
|
||||||
|
if (IsInClass() && !IsImmediate(notification))
|
||||||
|
{
|
||||||
|
_deferred.Add(notification);
|
||||||
|
_logger.LogInformation("班级小助手联动:上课中,「{Title}」已暂存,下课后弹出", notification.Title);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var request = BuildRequest(notification);
|
var request = BuildRequest(notification);
|
||||||
// 只有真正播完(或被用户关掉)才回执:中途退出 ClassIsland 时下次上报还会补发,不会静默丢失
|
// 只有真正播完(或被用户关掉)才回执:中途退出 ClassIsland 时下次上报还会补发,不会静默丢失
|
||||||
AckWhenFinished(request, notification);
|
AckWhenFinished(request, notification);
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import {
|
|||||||
DEFAULT_CLASSISLAND_NOTIFICATION_CHANNEL,
|
DEFAULT_CLASSISLAND_NOTIFICATION_CHANNEL,
|
||||||
DEFAULT_ISLAND_APPEARANCE,
|
DEFAULT_ISLAND_APPEARANCE,
|
||||||
DEFAULT_SERVER_URL,
|
DEFAULT_SERVER_URL,
|
||||||
|
HOMEWORK_PHRASE_DEFAULTS,
|
||||||
|
HOMEWORK_PHRASE_MAX_COUNT,
|
||||||
|
HOMEWORK_PHRASE_MAX_LENGTH,
|
||||||
type ClassIslandNotificationChannel,
|
type ClassIslandNotificationChannel,
|
||||||
type IslandAppearance,
|
type IslandAppearance,
|
||||||
} from '@classhelper/shared';
|
} from '@classhelper/shared';
|
||||||
@@ -16,7 +19,6 @@ const DEFAULT_HOMEWORK_BOARD: HomeworkBoardSettings = {
|
|||||||
mode: 'board',
|
mode: 'board',
|
||||||
showTime: false,
|
showTime: false,
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
todayOnly: true,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
interface PersistedConfig {
|
interface PersistedConfig {
|
||||||
@@ -31,6 +33,8 @@ interface PersistedConfig {
|
|||||||
homeworkBoard?: Partial<HomeworkBoardSettings>;
|
homeworkBoard?: Partial<HomeworkBoardSettings>;
|
||||||
/** 通知显示位置(向后兼容:旧配置文件没有该字段时按默认 both 补齐) */
|
/** 通知显示位置(向后兼容:旧配置文件没有该字段时按默认 both 补齐) */
|
||||||
notificationChannel?: ClassIslandNotificationChannel;
|
notificationChannel?: ClassIslandNotificationChannel;
|
||||||
|
/** 作业录入快捷短语(向后兼容:旧配置没有该字段时用默认那一组) */
|
||||||
|
homeworkPhrases?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_CONFIG: PersistedConfig = {
|
const DEFAULT_CONFIG: PersistedConfig = {
|
||||||
@@ -41,8 +45,28 @@ const DEFAULT_CONFIG: PersistedConfig = {
|
|||||||
island: { ...DEFAULT_ISLAND_APPEARANCE },
|
island: { ...DEFAULT_ISLAND_APPEARANCE },
|
||||||
homeworkBoard: { ...DEFAULT_HOMEWORK_BOARD },
|
homeworkBoard: { ...DEFAULT_HOMEWORK_BOARD },
|
||||||
notificationChannel: DEFAULT_CLASSISLAND_NOTIFICATION_CHANNEL,
|
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(
|
function normalizeNotificationChannel(
|
||||||
input?: ClassIslandNotificationChannel,
|
input?: ClassIslandNotificationChannel,
|
||||||
@@ -62,7 +86,6 @@ function normalizeHomeworkBoard(input?: Partial<HomeworkBoardSettings>): Homewor
|
|||||||
return {
|
return {
|
||||||
mode,
|
mode,
|
||||||
showTime: input?.showTime === true,
|
showTime: input?.showTime === true,
|
||||||
todayOnly: input?.todayOnly !== false,
|
|
||||||
fontSize: Math.min(28, Math.max(11, Math.round(rawFont))),
|
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'])
|
? (input?.style as IslandAppearance['style'])
|
||||||
: DEFAULT_ISLAND_APPEARANCE.style,
|
: DEFAULT_ISLAND_APPEARANCE.style,
|
||||||
idleSliver: input?.idleSliver === true,
|
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),
|
island: normalizeIslandAppearance(parsed.island),
|
||||||
homeworkBoard: normalizeHomeworkBoard(parsed.homeworkBoard),
|
homeworkBoard: normalizeHomeworkBoard(parsed.homeworkBoard),
|
||||||
notificationChannel: normalizeNotificationChannel(parsed.notificationChannel),
|
notificationChannel: normalizeNotificationChannel(parsed.notificationChannel),
|
||||||
|
homeworkPhrases: normalizeHomeworkPhrases(parsed.homeworkPhrases),
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return { ...DEFAULT_CONFIG };
|
return { ...DEFAULT_CONFIG };
|
||||||
@@ -168,6 +195,7 @@ export function getConfig(): DesktopStoredConfig {
|
|||||||
island: normalizeIslandAppearance(persisted.island),
|
island: normalizeIslandAppearance(persisted.island),
|
||||||
homeworkBoard: normalizeHomeworkBoard(persisted.homeworkBoard),
|
homeworkBoard: normalizeHomeworkBoard(persisted.homeworkBoard),
|
||||||
notificationChannel: normalizeNotificationChannel(persisted.notificationChannel),
|
notificationChannel: normalizeNotificationChannel(persisted.notificationChannel),
|
||||||
|
homeworkPhrases: normalizeHomeworkPhrases(persisted.homeworkPhrases),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,6 +218,9 @@ export function saveConfig(patch: Partial<DesktopStoredConfig>): DesktopStoredCo
|
|||||||
...patch.homeworkBoard,
|
...patch.homeworkBoard,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (patch.homeworkPhrases !== undefined) {
|
||||||
|
persisted.homeworkPhrases = normalizeHomeworkPhrases(patch.homeworkPhrases);
|
||||||
|
}
|
||||||
if (patch.notificationChannel !== undefined) {
|
if (patch.notificationChannel !== undefined) {
|
||||||
persisted.notificationChannel = normalizeNotificationChannel(patch.notificationChannel);
|
persisted.notificationChannel = normalizeNotificationChannel(patch.notificationChannel);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -758,21 +758,30 @@ class IslandController {
|
|||||||
* - 全部取整,避免 `Math.round` 在 0.5 边界上左右跳变(那会表现为 ±0.5px 的横向抖动)。
|
* - 全部取整,避免 `Math.round` 在 0.5 边界上左右跳变(那会表现为 ±0.5px 的横向抖动)。
|
||||||
*/
|
*/
|
||||||
private resolveAnchor(): IslandAnchor {
|
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);
|
const center = Math.round(area.x + area.width / 2);
|
||||||
|
|
||||||
// 6 个停靠位置(顶/底 × 左/中/右),与 WinIsland 的 DockPosition 一一对应
|
// 6 个停靠位置(顶/底 × 左/中/右),与 WinIsland 的 DockPosition 一一对应
|
||||||
const position = this.appearance.position;
|
const position = this.appearance.position;
|
||||||
const vMode: IslandAnchor['vMode'] = position.startsWith('bottom') ? 'bottom' : 'top';
|
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')
|
const hMode: IslandAnchor['hMode'] = position.endsWith('left')
|
||||||
? 'left'
|
? 'left'
|
||||||
: position.endsWith('right')
|
: position.endsWith('right')
|
||||||
? 'right'
|
? 'right'
|
||||||
: 'center';
|
: 'center';
|
||||||
const hValue =
|
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 };
|
return { hMode, hValue, vMode, vValue };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1336,6 +1345,10 @@ export function normalizeAppearance(input: IslandAppearance): IslandAppearance {
|
|||||||
alwaysOnTop: input.alwaysOnTop !== false,
|
alwaysOnTop: input.alwaysOnTop !== false,
|
||||||
style: ISLAND_STYLES.includes(input.style) ? input.style : 'black',
|
style: ISLAND_STYLES.includes(input.style) ? input.style : 'black',
|
||||||
idleSliver: input.idleSliver === true,
|
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(' '));
|
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);
|
island.setAppearance(appearanceBefore);
|
||||||
|
|
||||||
// 11.4 置顶开关 → 窗口 alwaysOnTop
|
// 11.4 置顶开关 → 窗口 alwaysOnTop
|
||||||
@@ -2557,10 +2589,10 @@ async function runSettingsPageChannelCheck(
|
|||||||
for (let i = 0; i < 40 && !group; i += 1) {
|
for (let i = 0; i < 40 && !group; i += 1) {
|
||||||
await wait(100);
|
await wait(100);
|
||||||
group = Array.from(document.querySelectorAll('.el-card')).find((card) =>
|
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'));
|
const radios = Array.from(group.querySelectorAll('.el-radio'));
|
||||||
// 注意:这段脚本本身是外层模板字符串,里面不能再出现反引号(会截断外层字符串)
|
// 注意:这段脚本本身是外层模板字符串,里面不能再出现反引号(会截断外层字符串)
|
||||||
if (radios.length < 3) return { ok: false, detail: '单选项只有 ' + radios.length + ' 个' };
|
if (radios.length < 3) return { ok: false, detail: '单选项只有 ' + radios.length + ' 个' };
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import {
|
|||||||
type ClassDetailDto,
|
type ClassDetailDto,
|
||||||
type ClassDto,
|
type ClassDto,
|
||||||
type ClassIslandNotificationChannel,
|
type ClassIslandNotificationChannel,
|
||||||
|
type CourseDto,
|
||||||
|
type HomeworkDaysDto,
|
||||||
type DashboardSummary,
|
type DashboardSummary,
|
||||||
type GradeDto,
|
type GradeDto,
|
||||||
type HomeworkDto,
|
type HomeworkDto,
|
||||||
@@ -49,6 +51,20 @@ export const classChannelApi = {
|
|||||||
api.patch(`${API_PATHS.classes}/${classId}/notification-channel`, { notificationChannel }),
|
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 = {
|
export const scheduleApi = {
|
||||||
list: (params: { classId?: string; week?: number }): Promise<ScheduleDto[]> =>
|
list: (params: { classId?: string; week?: number }): Promise<ScheduleDto[]> =>
|
||||||
api.get(API_PATHS.schedules, params),
|
api.get(API_PATHS.schedules, params),
|
||||||
@@ -56,9 +72,30 @@ export const scheduleApi = {
|
|||||||
api.get(`${API_PATHS.schedules}/grid`, params),
|
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 = {
|
export const homeworkApi = {
|
||||||
list: (params?: { classId?: string; pendingOnly?: boolean; courseId?: string }): Promise<HomeworkDto[]> =>
|
list: (params?: {
|
||||||
api.get(API_PATHS.homeworks, 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}`),
|
detail: (id: string): Promise<HomeworkDto> => api.get(`${API_PATHS.homeworks}/${id}`),
|
||||||
updateStatus: (id: string, completed: boolean): Promise<HomeworkStatusDto> =>
|
updateStatus: (id: string, completed: boolean): Promise<HomeworkStatusDto> =>
|
||||||
api.patch(`${API_PATHS.homeworks}/${id}/status`, { completed }),
|
api.patch(`${API_PATHS.homeworks}/${id}/status`, { completed }),
|
||||||
|
|||||||
+12
-3
@@ -611,10 +611,19 @@ export async function homeworkBoardSelfTest(): Promise<SmokeCheckResult> {
|
|||||||
boardTab.click();
|
boardTab.click();
|
||||||
await waitUntil(() => Boolean(document.querySelector('.board-config')), 3000);
|
await waitUntil(() => Boolean(document.querySelector('.board-config')), 3000);
|
||||||
|
|
||||||
// 今天可能恰好没有作业:先关掉「只看今天」,保证看板一定有卡片(断言才有意义)
|
// 今天可能恰好没有作业:切到「最近一个有作业的日期」保证看板一定有卡片(断言才有意义)。
|
||||||
|
// 按天查看支持 ?date=YYYY-MM-DD 深链,切天不需要去操作日期选择器。
|
||||||
if (!document.querySelector('.board-host')) {
|
if (!document.querySelector('.board-host')) {
|
||||||
const todaySwitch = document.querySelector('.page-header .el-switch');
|
const [{ homeworkApi }, { useAuthStore }] = await Promise.all([
|
||||||
if (todaySwitch instanceof HTMLElement) todaySwitch.click();
|
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);
|
const hasBoard = await waitUntil(() => Boolean(document.querySelector('.board-host')), 4000);
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,26 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
|
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { ElMessage } from 'element-plus';
|
import { ElMessage } from 'element-plus';
|
||||||
import { SOCKET_EVENTS, formatDate, type HomeworkDto, type HomeworkSubmissionDto } from '@classhelper/shared';
|
import {
|
||||||
import { homeworkApi } from '../api/index.js';
|
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 { fetchWithCache } from '../cache/index.js';
|
||||||
import { useAppStore } from '../stores/app.js';
|
import { useAppStore } from '../stores/app.js';
|
||||||
import { useAuthStore } from '../stores/auth.js';
|
import { useAuthStore } from '../stores/auth.js';
|
||||||
import { useRealtimeStore } from '../stores/realtime.js';
|
import { useRealtimeStore } from '../stores/realtime.js';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
const appStore = useAppStore();
|
const appStore = useAppStore();
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const realtime = useRealtimeStore();
|
const realtime = useRealtimeStore();
|
||||||
@@ -17,45 +30,65 @@ const DEFAULT_BOARD = {
|
|||||||
mode: 'board' as 'board' | 'list',
|
mode: 'board' as 'board' | 'list',
|
||||||
showTime: false,
|
showTime: false,
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
todayOnly: true,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const homeworks = ref<HomeworkDto[]>([]);
|
const homeworks = ref<HomeworkDto[]>([]);
|
||||||
const filter = ref<'all' | 'pending' | 'done'>('all');
|
|
||||||
const fromCache = ref(false);
|
const fromCache = ref(false);
|
||||||
const updatedAt = ref<number | null>(null);
|
const updatedAt = ref<number | null>(null);
|
||||||
const detailVisible = ref(false);
|
const detailVisible = ref(false);
|
||||||
const current = ref<HomeworkDto | null>(null);
|
const current = ref<HomeworkDto | null>(null);
|
||||||
const submitting = ref(false);
|
|
||||||
|
|
||||||
const viewMode = ref<'board' | 'list'>(DEFAULT_BOARD.mode);
|
const viewMode = ref<'board' | 'list'>(DEFAULT_BOARD.mode);
|
||||||
const boardShowTime = ref(DEFAULT_BOARD.showTime);
|
const boardShowTime = ref(DEFAULT_BOARD.showTime);
|
||||||
const boardFontSize = ref(DEFAULT_BOARD.fontSize);
|
const boardFontSize = ref(DEFAULT_BOARD.fontSize);
|
||||||
const todayOnly = ref(DEFAULT_BOARD.todayOnly);
|
|
||||||
const savingBoard = ref(false);
|
const savingBoard = ref(false);
|
||||||
|
|
||||||
/** 全屏放大的科目(null = 未打开) */
|
/** 全屏放大的科目(null = 未打开) */
|
||||||
const fullscreenCourse = ref<string | null>(null);
|
const fullscreenCourse = ref<string | null>(null);
|
||||||
const fullscreenBoard = ref(false);
|
const fullscreenBoard = ref(false);
|
||||||
|
|
||||||
/* ------------------------------------------------------------ 当天的判定 */
|
/* ------------------------------------------------------------ 按天查看 */
|
||||||
|
|
||||||
/** 本地"今天"的日期串(YYYY-MM-DD) */
|
/**
|
||||||
function localDayKey(value: string | Date): string {
|
* 当前查看的日期(YYYY-MM-DD,本地):默认今天,可用日期选择器或前后一天切换。
|
||||||
const date = value instanceof Date ? value : new Date(value);
|
* 支持 `?date=YYYY-MM-DD` 深链(冒烟测试与"直接打开某天的作业"都靠它)。
|
||||||
if (Number.isNaN(date.getTime())) return '';
|
*/
|
||||||
const year = date.getFullYear();
|
const selectedDate = ref(
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
typeof route.query.date === 'string' && isDayKey(route.query.date)
|
||||||
const day = String(date.getDate()).padStart(2, '0');
|
? route.query.date
|
||||||
return `${year}-${month}-${day}`;
|
: 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());
|
const now = ref(new Date());
|
||||||
let clockTimer: ReturnType<typeof setInterval> | null = null;
|
let clockTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
const todayKey = computed(() => localDayKey(now.value));
|
|
||||||
const todayText = computed(
|
const todayText = computed(
|
||||||
() =>
|
() =>
|
||||||
`${now.value.getFullYear()} 年 ${now.value.getMonth() + 1} 月 ${now.value.getDate()} 日 ` +
|
`${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')}`,
|
`${String(now.value.getHours()).padStart(2, '0')}:${String(now.value.getMinutes()).padStart(2, '0')}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
/** 按"今天"与完成状态过滤 */
|
/**
|
||||||
const filtered = computed(() => {
|
* 列表内容 = 服务端按天返回的结果(`?date=`),因此这里不再做二次过滤。
|
||||||
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;
|
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 {
|
interface BoardColumn {
|
||||||
course: string;
|
course: string;
|
||||||
items: HomeworkDto[];
|
items: HomeworkDto[];
|
||||||
pending: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const boardColumns = computed<BoardColumn[]>(() => {
|
const boardColumns = computed<BoardColumn[]>(() => {
|
||||||
@@ -116,7 +146,6 @@ const boardColumns = computed<BoardColumn[]>(() => {
|
|||||||
const columns: BoardColumn[] = [...groups.entries()].map(([course, items]) => ({
|
const columns: BoardColumn[] = [...groups.entries()].map(([course, items]) => ({
|
||||||
course,
|
course,
|
||||||
items: [...items].sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1)),
|
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 rank = (name: string): number => {
|
||||||
const index = COURSE_ORDER.findIndex((item) => name.includes(item));
|
const index = COURSE_ORDER.findIndex((item) => name.includes(item));
|
||||||
@@ -241,16 +270,19 @@ async function loadHomework(): Promise<void> {
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const classId = auth.classId ?? undefined;
|
const classId = auth.classId ?? undefined;
|
||||||
|
const day = selectedDate.value;
|
||||||
|
// 缓存按"班级 + 日期"分键:翻到别的天不会串数据,离线也能看回看过的那些天
|
||||||
const result = await fetchWithCache<HomeworkDto[]>(
|
const result = await fetchWithCache<HomeworkDto[]>(
|
||||||
'homeworks',
|
'homeworks',
|
||||||
'self',
|
`${classId ?? 'self'}@${day}`,
|
||||||
() => homeworkApi.list(classId ? { classId } : undefined),
|
() => homeworkApi.list(classId ? { classId, date: day } : { date: day }),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
homeworks.value = result.data;
|
homeworks.value = result.data;
|
||||||
fromCache.value = result.fromCache;
|
fromCache.value = result.fromCache;
|
||||||
updatedAt.value = result.updatedAt;
|
updatedAt.value = result.updatedAt;
|
||||||
if (!result.fromCache) appStore.markSynced();
|
if (!result.fromCache) appStore.markSynced();
|
||||||
|
void loadDayMarks(classId);
|
||||||
if (current.value) {
|
if (current.value) {
|
||||||
current.value = homeworks.value.find((item) => item.id === current.value?.id) ?? 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> {
|
async function loadBoardSettings(): Promise<void> {
|
||||||
const config = await window.desktop?.getConfig?.();
|
const config = await window.desktop?.getConfig?.();
|
||||||
@@ -267,7 +321,6 @@ async function loadBoardSettings(): Promise<void> {
|
|||||||
if (!saved) return;
|
if (!saved) return;
|
||||||
if (saved.mode === 'board' || saved.mode === 'list') viewMode.value = saved.mode;
|
if (saved.mode === 'board' || saved.mode === 'list') viewMode.value = saved.mode;
|
||||||
if (typeof saved.showTime === 'boolean') boardShowTime.value = saved.showTime;
|
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)) {
|
if (typeof saved.fontSize === 'number' && Number.isFinite(saved.fontSize)) {
|
||||||
boardFontSize.value = Math.min(28, Math.max(11, Math.round(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,
|
mode: viewMode.value,
|
||||||
showTime: boardShowTime.value,
|
showTime: boardShowTime.value,
|
||||||
fontSize: boardFontSize.value,
|
fontSize: boardFontSize.value,
|
||||||
todayOnly: todayOnly.value,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
fitBoards();
|
fitBoards();
|
||||||
@@ -308,31 +360,72 @@ function openBoardFullscreen(): void {
|
|||||||
fitBoards();
|
fitBoards();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleComplete(item: HomeworkDto | null): Promise<void> {
|
/* ------------------------------------------------------------ 录入作业(教室机器) */
|
||||||
if (!item) return;
|
|
||||||
if (appStore.offline) {
|
const createVisible = ref(false);
|
||||||
ElMessage.warning('离线状态下无法提交,请恢复网络后重试');
|
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;
|
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 {
|
try {
|
||||||
const next = item.completed !== true;
|
await homeworkApi.create({
|
||||||
await homeworkApi.updateStatus(item.id, next);
|
classId,
|
||||||
item.completed = next;
|
courseId: createForm.value.courseId || null,
|
||||||
if (item.homeworkStatus) item.homeworkStatus.completed = next;
|
title,
|
||||||
else
|
content,
|
||||||
item.homeworkStatus = {
|
assignDate: selectedDate.value,
|
||||||
id: `local-${item.id}`,
|
});
|
||||||
homeworkId: item.id,
|
ElMessage.success(`已录入到 ${selectedDate.value}`);
|
||||||
userId: auth.user?.id ?? '',
|
createVisible.value = false;
|
||||||
completed: next,
|
await loadHomework();
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
ElMessage.success(next ? '已标记为完成' : '已取消完成标记');
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(error instanceof Error ? error.message : '提交失败');
|
ElMessage.error(error instanceof Error ? error.message : '录入失败');
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false;
|
createSaving.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -415,6 +508,8 @@ let resizeObserver: ResizeObserver | null = null;
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await loadBoardSettings();
|
await loadBoardSettings();
|
||||||
|
await loadPhrases();
|
||||||
|
await loadCourses();
|
||||||
await loadHomework();
|
await loadHomework();
|
||||||
clockTimer = setInterval(() => {
|
clockTimer = setInterval(() => {
|
||||||
now.value = new Date();
|
now.value = new Date();
|
||||||
@@ -448,26 +543,30 @@ onUnmounted(() => {
|
|||||||
<div>
|
<div>
|
||||||
<h2 class="page-title">我的作业</h2>
|
<h2 class="page-title">我的作业</h2>
|
||||||
<p class="page-subtitle">
|
<p class="page-subtitle">
|
||||||
今天 {{ todayCount }} 份 · 待完成 {{ pendingCount }} · 已完成 {{ doneCount }}
|
{{ selectedDayText }} · {{ filtered.length }} 份
|
||||||
<el-tag v-if="fromCache" size="small" type="warning" effect="plain">离线缓存</el-tag>
|
<el-tag v-if="fromCache" size="small" type="warning" effect="plain">离线缓存</el-tag>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<el-switch
|
<el-button-group>
|
||||||
v-model="todayOnly"
|
<el-button :icon="'ArrowLeft'" @click="shiftDate(-1)">前一天</el-button>
|
||||||
active-text="只看今天"
|
<el-button :disabled="isToday" @click="selectDate(dayKeyLocal(now))">今天</el-button>
|
||||||
inactive-text="全部日期"
|
<el-button @click="shiftDate(1)">后一天<el-icon class="ml-4"><ArrowRight /></el-icon></el-button>
|
||||||
@change="saveBoardSettings"
|
</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-group v-model="viewMode" @change="saveBoardSettings">
|
||||||
<el-radio-button value="board">看板</el-radio-button>
|
<el-radio-button value="board">看板</el-radio-button>
|
||||||
<el-radio-button value="list">列表</el-radio-button>
|
<el-radio-button value="list">列表</el-radio-button>
|
||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
|
<el-button type="primary" :icon="'Plus'" @click="openCreate">录入作业</el-button>
|
||||||
<el-button :icon="'Refresh'" @click="loadHomework">刷新</el-button>
|
<el-button :icon="'Refresh'" @click="loadHomework">刷新</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -499,7 +598,7 @@ onUnmounted(() => {
|
|||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<el-card v-loading="loading" shadow="never" class="homework-body">
|
<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">
|
<div v-else-if="viewMode === 'board'" ref="boardHostRef" class="board-host">
|
||||||
@@ -508,19 +607,17 @@ onUnmounted(() => {
|
|||||||
v-for="column in boardColumns"
|
v-for="column in boardColumns"
|
||||||
:key="column.course"
|
:key="column.course"
|
||||||
class="board-card"
|
class="board-card"
|
||||||
:class="{ 'has-pending': column.pending > 0 }"
|
|
||||||
@click="openCourseFullscreen(column)"
|
@click="openCourseFullscreen(column)"
|
||||||
>
|
>
|
||||||
<header class="board-card-head">
|
<header class="board-card-head">
|
||||||
<span class="board-course">{{ column.course }}</span>
|
<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>
|
</header>
|
||||||
<ol class="board-list">
|
<ol class="board-list">
|
||||||
<li
|
<li
|
||||||
v-for="(item, index) in column.items"
|
v-for="(item, index) in column.items"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
class="board-item"
|
class="board-item"
|
||||||
:class="{ done: item.completed }"
|
|
||||||
@click.stop="openDetail(item)"
|
@click.stop="openDetail(item)"
|
||||||
>
|
>
|
||||||
<span class="board-index">{{ index + 1 }}.</span>
|
<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 v-if="item.content.trim()" class="board-content">{{ item.content }}</span>
|
||||||
</span>
|
</span>
|
||||||
<span v-if="boardShowTime" class="board-time">{{ formatDate(item.createdAt, true) }}</span>
|
<span v-if="boardShowTime" class="board-time">{{ formatDate(item.createdAt, true) }}</span>
|
||||||
<span v-if="item.completed" class="board-done-tag">已完成</span>
|
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
<footer class="board-card-foot">
|
<footer class="board-card-foot">
|
||||||
@@ -543,35 +639,23 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<!-- 列表模式(默认表格视图) -->
|
<!-- 列表模式(默认表格视图) -->
|
||||||
<el-table v-else :data="filtered" @row-click="openDetail">
|
<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="title" label="作业标题" min-width="200" show-overflow-tooltip />
|
||||||
<el-table-column prop="content" label="作业要求" min-width="240" show-overflow-tooltip />
|
<el-table-column prop="content" label="作业要求" min-width="240" show-overflow-tooltip />
|
||||||
<el-table-column label="课程" width="110">
|
<el-table-column label="课程" width="110">
|
||||||
<template #default="{ row }">{{ row.course?.name ?? '-' }}</template>
|
<template #default="{ row }">{{ row.course?.name ?? '-' }}</template>
|
||||||
</el-table-column>
|
</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>
|
<template #default="{ row }">{{ formatDate(row.createdAt, true) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="150" fixed="right">
|
<el-table-column label="操作" width="130" fixed="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button v-if="auth.isClassSession" link type="primary" @click.stop="openSubmissions(row)">
|
<el-button v-if="auth.isClassSession" link type="primary" @click.stop="openSubmissions(row)">
|
||||||
未交名单
|
未交名单
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button
|
<el-button v-else link type="primary" @click.stop="openDetail(row)">查看</el-button>
|
||||||
v-else
|
|
||||||
link
|
|
||||||
:type="row.completed ? 'warning' : 'primary'"
|
|
||||||
:loading="submitting"
|
|
||||||
@click.stop="toggleComplete(row)"
|
|
||||||
>
|
|
||||||
{{ row.completed ? '取消完成' : '标记完成' }}
|
|
||||||
</el-button>
|
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@@ -602,7 +686,6 @@ onUnmounted(() => {
|
|||||||
v-for="(item, index) in fullscreenItems"
|
v-for="(item, index) in fullscreenItems"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
class="board-item"
|
class="board-item"
|
||||||
:class="{ done: item.completed }"
|
|
||||||
@click="openDetail(item)"
|
@click="openDetail(item)"
|
||||||
>
|
>
|
||||||
<span class="board-index">{{ index + 1 }}.</span>
|
<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 v-if="item.content.trim()" class="board-content">{{ item.content }}</span>
|
||||||
</span>
|
</span>
|
||||||
<span v-if="boardShowTime" class="board-time">{{ formatDate(item.createdAt, true) }}</span>
|
<span v-if="boardShowTime" class="board-time">{{ formatDate(item.createdAt, true) }}</span>
|
||||||
<span v-if="item.completed" class="board-done-tag">已完成</span>
|
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
<el-empty v-if="fullscreenItems.length === 0" description="该科目今天没有作业" />
|
<el-empty v-if="fullscreenItems.length === 0" :description="`该科目 ${selectedDayText}没有作业`" />
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<!-- 全屏:整块看板放大 -->
|
<!-- 全屏:整块看板放大 -->
|
||||||
@@ -639,14 +721,13 @@ onUnmounted(() => {
|
|||||||
<section v-for="column in boardColumns" :key="column.course" class="board-card">
|
<section v-for="column in boardColumns" :key="column.course" class="board-card">
|
||||||
<header class="board-card-head">
|
<header class="board-card-head">
|
||||||
<span class="board-course">{{ column.course }}</span>
|
<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>
|
</header>
|
||||||
<ol class="board-list">
|
<ol class="board-list">
|
||||||
<li
|
<li
|
||||||
v-for="(item, index) in column.items"
|
v-for="(item, index) in column.items"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
class="board-item"
|
class="board-item"
|
||||||
:class="{ done: item.completed }"
|
|
||||||
@click="openDetail(item)"
|
@click="openDetail(item)"
|
||||||
>
|
>
|
||||||
<span class="board-index">{{ index + 1 }}.</span>
|
<span class="board-index">{{ index + 1 }}.</span>
|
||||||
@@ -662,6 +743,65 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</el-dialog>
|
</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">
|
<el-dialog v-model="submissionsVisible" title="未交名单" width="520px">
|
||||||
<p class="text-muted" style="margin-top: 0">
|
<p class="text-muted" style="margin-top: 0">
|
||||||
@@ -693,14 +833,10 @@ onUnmounted(() => {
|
|||||||
<el-descriptions :column="2" border size="small">
|
<el-descriptions :column="2" border size="small">
|
||||||
<el-descriptions-item label="课程">{{ current.course?.name ?? '-' }}</el-descriptions-item>
|
<el-descriptions-item label="课程">{{ current.course?.name ?? '-' }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="发布人">{{ current.creator?.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) }}
|
{{ formatDate(current.createdAt, true) }}
|
||||||
</el-descriptions-item>
|
</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>
|
</el-descriptions>
|
||||||
|
|
||||||
<div class="mt-16">
|
<div class="mt-16">
|
||||||
@@ -714,24 +850,10 @@ onUnmounted(() => {
|
|||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-16">
|
<div v-if="auth.isClassSession" class="mt-16">
|
||||||
<el-button
|
<el-button type="primary" :disabled="appStore.offline" @click="openSubmissions(current)">
|
||||||
v-if="auth.isClassSession"
|
|
||||||
type="primary"
|
|
||||||
:disabled="appStore.offline"
|
|
||||||
@click="openSubmissions(current)"
|
|
||||||
>
|
|
||||||
维护未交名单
|
维护未交名单
|
||||||
</el-button>
|
</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>
|
<span v-if="appStore.offline" class="text-muted" style="margin-left: 10px">离线状态下不可提交</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -740,6 +862,35 @@ onUnmounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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 {
|
.text-danger {
|
||||||
color: #f56c6c;
|
color: #f56c6c;
|
||||||
}
|
}
|
||||||
@@ -932,7 +1083,7 @@ onUnmounted(() => {
|
|||||||
color: #8e8e93;
|
color: #8e8e93;
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-done-tag {
|
.board-done-tag-unused {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
font-size: calc(var(--board-font, 15px) * 0.66);
|
font-size: calc(var(--board-font, 15px) * 0.66);
|
||||||
color: #32d74b;
|
color: #32d74b;
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import {
|
|||||||
CLASSISLAND_NOTIFICATION_CHANNEL_HINTS,
|
CLASSISLAND_NOTIFICATION_CHANNEL_HINTS,
|
||||||
CLASSISLAND_NOTIFICATION_CHANNEL_LABELS,
|
CLASSISLAND_NOTIFICATION_CHANNEL_LABELS,
|
||||||
CLASSISLAND_NOTIFICATION_CHANNELS,
|
CLASSISLAND_NOTIFICATION_CHANNELS,
|
||||||
|
HOMEWORK_PHRASE_DEFAULTS,
|
||||||
|
HOMEWORK_PHRASE_MAX_COUNT,
|
||||||
|
HOMEWORK_PHRASE_MAX_LENGTH,
|
||||||
DEFAULT_ISLAND_APPEARANCE,
|
DEFAULT_ISLAND_APPEARANCE,
|
||||||
ISLAND_APPEARANCE_RANGES as RANGES,
|
ISLAND_APPEARANCE_RANGES as RANGES,
|
||||||
ISLAND_POSITION_LABELS,
|
ISLAND_POSITION_LABELS,
|
||||||
@@ -19,7 +22,7 @@ import {
|
|||||||
type IslandStyle,
|
type IslandStyle,
|
||||||
} from '@classhelper/shared';
|
} from '@classhelper/shared';
|
||||||
import type { DesktopAppInfo } from '../../types/desktop.js';
|
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 { pingHealth, setApiBaseUrl } from '../api/http.js';
|
||||||
import { normalizeServerUrl } from '../config.js';
|
import { normalizeServerUrl } from '../config.js';
|
||||||
import { useAppStore } from '../stores/app.js';
|
import { useAppStore } from '../stores/app.js';
|
||||||
@@ -137,6 +140,22 @@ const channelSaving = ref(false);
|
|||||||
* 注意**不能**顺手把「两端都弹」也禁掉:那样教室一旦选过"只在客户端",没设备时就再也切不回来了。
|
* 注意**不能**顺手把「两端都弹」也禁掉:那样教室一旦选过"只在客户端",没设备时就再也切不回来了。
|
||||||
*/
|
*/
|
||||||
const classIslandConnected = 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(() =>
|
const channelOptions = computed(() =>
|
||||||
CLASSISLAND_NOTIFICATION_CHANNELS.map((value) => ({
|
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 });
|
const island = ref<IslandAppearance>({ ...DEFAULT_ISLAND_APPEARANCE });
|
||||||
@@ -235,11 +311,13 @@ onMounted(async () => {
|
|||||||
await appStore.refreshCacheStats();
|
await appStore.refreshCacheStats();
|
||||||
await loadIslandAppearance();
|
await loadIslandAppearance();
|
||||||
await loadClassChannel();
|
await loadClassChannel();
|
||||||
// 本班有没有 ClassIsland 设备:没接就禁用"弹在 ClassIsland"这一项,避免选了却没效果
|
await loadPhrases();
|
||||||
const classList = await classApi.list().catch(() => []);
|
// 本班 ClassIsland 联动状态:没接设备就别让选「只在 ClassIsland 上弹」
|
||||||
classIslandConnected.value = classList.some(
|
if (auth.classId) {
|
||||||
(item) => item.id === auth.classId && item.classIslandConnected === true,
|
const status = await classIslandStatusApi.get(auth.classId).catch(() => null);
|
||||||
);
|
classIslandStatus.value = status;
|
||||||
|
classIslandConnected.value = status?.connected === true;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -380,6 +458,36 @@ onMounted(async () => {
|
|||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</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-form-item label="动画">
|
||||||
<el-switch
|
<el-switch
|
||||||
:model-value="island.animations"
|
:model-value="island.animations"
|
||||||
@@ -422,10 +530,16 @@ onMounted(async () => {
|
|||||||
/>
|
/>
|
||||||
</el-card>
|
</el-card>
|
||||||
<el-card shadow="never" class="mt-12">
|
<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">
|
<p class="text-muted">
|
||||||
老师在班级小助手上发布通知 / 叫人时,提醒要弹在哪里由这台教室机器决定。选择会同步到班级,
|
<strong>通知模式</strong>:老师在班级小助手上发布通知 / 叫人时,提醒弹在灵动岛还是 ClassIsland,
|
||||||
服务端据此决定是否一并推送到教室的 ClassIsland。
|
由这台教室机器决定。选择会同步到班级,服务端据此决定是否推送到教室的 ClassIsland。
|
||||||
</p>
|
</p>
|
||||||
<el-radio-group
|
<el-radio-group
|
||||||
:model-value="appStore.notificationChannel"
|
:model-value="appStore.notificationChannel"
|
||||||
@@ -445,6 +559,38 @@ onMounted(async () => {
|
|||||||
</p>
|
</p>
|
||||||
</el-card>
|
</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">
|
<el-card shadow="never" class="mt-12">
|
||||||
<template #header><span>账号信息</span></template>
|
<template #header><span>账号信息</span></template>
|
||||||
<el-descriptions :column="1" border size="small">
|
<el-descriptions :column="1" border size="small">
|
||||||
@@ -517,6 +663,14 @@ onMounted(async () => {
|
|||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.phrase-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.ml-8 {
|
.ml-8 {
|
||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-2
@@ -17,8 +17,11 @@ export interface HomeworkBoardSettings {
|
|||||||
showTime: boolean;
|
showTime: boolean;
|
||||||
/** 看板字号(px) */
|
/** 看板字号(px) */
|
||||||
fontSize: number;
|
fontSize: number;
|
||||||
/** 只看"今天布置"的作业(默认 true,避免昨天的过期作业混进来) */
|
/**
|
||||||
todayOnly: boolean;
|
* @deprecated 已由「按天查看 + 日期选择器」取代(作业页不再有"只看今天"开关)。
|
||||||
|
* 保留字段只为兼容旧配置文件,读写都忽略它。
|
||||||
|
*/
|
||||||
|
todayOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DesktopStoredConfig {
|
export interface DesktopStoredConfig {
|
||||||
@@ -32,6 +35,11 @@ export interface DesktopStoredConfig {
|
|||||||
island: IslandAppearance;
|
island: IslandAppearance;
|
||||||
/** 作业页展示偏好(看板/列表、显示时间、看板字号) */
|
/** 作业页展示偏好(看板/列表、显示时间、看板字号) */
|
||||||
homeworkBoard: HomeworkBoardSettings;
|
homeworkBoard: HomeworkBoardSettings;
|
||||||
|
/**
|
||||||
|
* 作业录入的快捷短语(客户端「设置 → 作业录入」里增删,默认见 shared 的 HOMEWORK_PHRASE_DEFAULTS)。
|
||||||
|
* 录入作业时点一下就追加到标题/内容里,少打字(例如 P、大本、背诵)。
|
||||||
|
*/
|
||||||
|
homeworkPhrases: string[];
|
||||||
/**
|
/**
|
||||||
* 通知显示位置(both / client / classisland)。
|
* 通知显示位置(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?
|
courseId String?
|
||||||
title String
|
title String
|
||||||
content String
|
content String
|
||||||
|
/// 作业所属日期(YYYY-MM-DD,本地日期,不带时区):客户端/Web 的按天查看与日期高亮都用它
|
||||||
|
assignDate String @default("")
|
||||||
attachmentUrl String?
|
attachmentUrl String?
|
||||||
/// 遗留字段:作业"截止时间"功能已下线(UI/接口均不再读写)。
|
/// 遗留字段:作业"截止时间"功能已下线(UI/接口均不再读写)。
|
||||||
/// 保留列以免对已安装的库做破坏性迁移;新库该列恒为 null。
|
/// 保留列以免对已安装的库做破坏性迁移;新库该列恒为 null。
|
||||||
@@ -177,6 +179,7 @@ model Homework {
|
|||||||
statuses HomeworkStatus[]
|
statuses HomeworkStatus[]
|
||||||
|
|
||||||
@@index([classId, createdAt])
|
@@index([classId, createdAt])
|
||||||
|
@@index([classId, assignDate])
|
||||||
@@index([courseId])
|
@@index([courseId])
|
||||||
@@index([dueAt])
|
@@index([dueAt])
|
||||||
}
|
}
|
||||||
@@ -328,6 +331,8 @@ model ClassIslandPush {
|
|||||||
/// 语音朗读内容;null = 不朗读
|
/// 语音朗读内容;null = 不朗读
|
||||||
speechContent String?
|
speechContent String?
|
||||||
urgent Boolean @default(false)
|
urgent Boolean @default(false)
|
||||||
|
/// 提醒类型:notification=通知类 / call=叫人(叫人算「主动通知」,上课时段也立刻弹)
|
||||||
|
kind String @default("notification")
|
||||||
/// 发起人的用户 id(教师/管理员)
|
/// 发起人的用户 id(教师/管理员)
|
||||||
createdBy String?
|
createdBy String?
|
||||||
/// 同时落库到通知中心的通知 id(saveToNotifications 时写入)
|
/// 同时落库到通知中心的通知 id(saveToNotifications 时写入)
|
||||||
|
|||||||
@@ -304,6 +304,12 @@ async function main() {
|
|||||||
published.status === 201 && Boolean(publishPush),
|
published.status === 201 && Boolean(publishPush),
|
||||||
`status=${published.status} 待提醒=${(pendingAfterPublish.payload?.data?.notifications ?? []).length}`,
|
`status=${published.status} 待提醒=${(pendingAfterPublish.payload?.data?.notifications ?? []).length}`,
|
||||||
);
|
);
|
||||||
|
// kind 决定插件"上课时段弹不弹":通知类会被暂存,叫人属于主动通知要立刻弹
|
||||||
|
record(
|
||||||
|
'通知类推送的 kind=notification(上课时段插件会暂存)',
|
||||||
|
publishPush?.kind === 'notification',
|
||||||
|
`kind=${publishPush?.kind}`,
|
||||||
|
);
|
||||||
if (publishPush) {
|
if (publishPush) {
|
||||||
await api('/integrations/classisland/ack', {
|
await api('/integrations/classisland/ack', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -326,6 +332,11 @@ async function main() {
|
|||||||
Boolean(firstStudent) && call.status === 201 && Boolean(callPush),
|
Boolean(firstStudent) && call.status === 201 && Boolean(callPush),
|
||||||
firstStudent ? `status=${call.status}` : '班级里没有学生,跳过',
|
firstStudent ? `status=${call.status}` : '班级里没有学生,跳过',
|
||||||
);
|
);
|
||||||
|
record(
|
||||||
|
'叫人推送的 kind=call(算主动通知,上课时段也立刻弹)',
|
||||||
|
callPush?.kind === 'call',
|
||||||
|
`kind=${callPush?.kind}`,
|
||||||
|
);
|
||||||
if (callPush) {
|
if (callPush) {
|
||||||
await api('/integrations/classisland/ack', {
|
await api('/integrations/classisland/ack', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -455,6 +455,36 @@ async function main() {
|
|||||||
createdHomework.payload?.data?.dueAt === undefined,
|
createdHomework.payload?.data?.dueAt === undefined,
|
||||||
`dueAt=${String(createdHomework.payload?.data?.dueAt)}`,
|
`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 {
|
try {
|
||||||
const { payload, elapsed } = await homeworkWait;
|
const { payload, elapsed } = await homeworkWait;
|
||||||
@@ -1867,6 +1897,46 @@ async function main() {
|
|||||||
`status=${classScheduleWrite.status}`,
|
`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', {
|
const classNameNotice = await api('/notifications', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { dayKeyLocal } from '@classhelper/shared';
|
||||||
import type {
|
import type {
|
||||||
ClassDto,
|
ClassDto,
|
||||||
ClassIslandNotificationChannel,
|
ClassIslandNotificationChannel,
|
||||||
@@ -204,6 +205,8 @@ export interface HomeworkLike {
|
|||||||
title: string;
|
title: string;
|
||||||
content: string;
|
content: string;
|
||||||
attachmentUrl: string | null;
|
attachmentUrl: string | null;
|
||||||
|
/** 作业所属日期(YYYY-MM-DD,本地日期);老库可能为空串 */
|
||||||
|
assignDate?: string | null;
|
||||||
createdBy: string;
|
createdBy: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
course?: CourseBriefLike | null;
|
course?: CourseBriefLike | null;
|
||||||
@@ -243,6 +246,8 @@ export function toHomeworkDto(
|
|||||||
attachmentUrl: item.attachmentUrl,
|
attachmentUrl: item.attachmentUrl,
|
||||||
createdBy: item.createdBy,
|
createdBy: item.createdBy,
|
||||||
createdAt: toIso(item.createdAt),
|
createdAt: toIso(item.createdAt),
|
||||||
|
// 老库兜底:assignDate 为空时按 createdAt 的本地日期回退,前端不必处理空值
|
||||||
|
assignDate: item.assignDate || dayKeyLocal(item.createdAt),
|
||||||
course: item.course ? toCourseBrief(item.course) : null,
|
course: item.course ? toCourseBrief(item.course) : null,
|
||||||
creator: item.creator ? toTeacherBrief(item.creator) : null,
|
creator: item.creator ? toTeacherBrief(item.creator) : null,
|
||||||
completed: own ? own.completed : undefined,
|
completed: own ? own.completed : undefined,
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ export async function createCall(user: TokenPayload, input: CreateCallInput): Pr
|
|||||||
title: created.title,
|
title: created.title,
|
||||||
content: created.content,
|
content: created.content,
|
||||||
urgent: input.urgent === true,
|
urgent: input.urgent === true,
|
||||||
|
// 叫人属于「主动通知」:教室的 ClassIsland 上课时段也要立刻弹
|
||||||
|
kind: 'call',
|
||||||
createdBy: user.sub,
|
createdBy: user.sub,
|
||||||
notificationId: created.id,
|
notificationId: created.id,
|
||||||
teacherName,
|
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 - 读取"通知显示到哪个端"
|
* 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 } });
|
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 { defineModule } from '../module.types.js';
|
||||||
import {
|
import {
|
||||||
createHomeworkSchema,
|
createHomeworkSchema,
|
||||||
|
listHomeworkDaysQuerySchema,
|
||||||
listHomeworksQuerySchema,
|
listHomeworksQuerySchema,
|
||||||
updateHomeworkSchema,
|
updateHomeworkSchema,
|
||||||
updateHomeworkStatusSchema,
|
updateHomeworkStatusSchema,
|
||||||
updateHomeworkSubmissionsSchema,
|
updateHomeworkSubmissionsSchema,
|
||||||
type CreateHomeworkInput,
|
type CreateHomeworkInput,
|
||||||
|
type ListHomeworkDaysInput,
|
||||||
type UpdateHomeworkInput,
|
type UpdateHomeworkInput,
|
||||||
type UpdateHomeworkStatusInput,
|
type UpdateHomeworkStatusInput,
|
||||||
type UpdateHomeworkSubmissionsInput,
|
type UpdateHomeworkSubmissionsInput,
|
||||||
@@ -33,16 +35,31 @@ router.get('/', validate({ query: listHomeworksQuerySchema }), async (req, res)
|
|||||||
});
|
});
|
||||||
|
|
||||||
/** GET /api/homeworks/:id - 作业详情 */
|
/** 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) => {
|
router.get('/:id', validate({ params: idParamSchema }), async (req, res) => {
|
||||||
const user = getAuthUser(req);
|
const user = getAuthUser(req);
|
||||||
const { id } = validatedParams<{ id: string }>(req);
|
const { id } = validatedParams<{ id: string }>(req);
|
||||||
sendOk(res, await homeworkService.getHomework(user, id), '获取作业详情成功');
|
sendOk(res, await homeworkService.getHomework(user, id), '获取作业详情成功');
|
||||||
});
|
});
|
||||||
|
|
||||||
/** POST /api/homeworks - 发布作业(发布后广播 homework:new) */
|
/**
|
||||||
|
* POST /api/homeworks - 发布作业(发布后广播 homework:new)
|
||||||
|
*
|
||||||
|
* 这里**不加 requireRole**:教室机器的班级账号也要能录入作业(需求「支持作业在客户端录入」),
|
||||||
|
* 能不能发由服务层的 createHomework 判定(staff 或本班班级账号,其余 403)。
|
||||||
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
'/',
|
'/',
|
||||||
requireRole('ADMIN', 'TEACHER'),
|
|
||||||
validate({ body: createHomeworkSchema }),
|
validate({ body: createHomeworkSchema }),
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
const user = getAuthUser(req);
|
const user = getAuthUser(req);
|
||||||
|
|||||||
@@ -1,12 +1,30 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { isDayKey } from '@classhelper/shared';
|
||||||
import { booleanFlagSchema } from '../../lib/schemas.js';
|
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({
|
export const listHomeworksQuerySchema = z.object({
|
||||||
classId: z.string().min(1).optional(),
|
classId: z.string().min(1).optional(),
|
||||||
courseId: z.string().min(1).optional(),
|
courseId: z.string().min(1).optional(),
|
||||||
/** 学生端:只看未完成 */
|
/** 学生端:只看未完成 */
|
||||||
pendingOnly: booleanFlagSchema,
|
pendingOnly: booleanFlagSchema,
|
||||||
keyword: z.string().trim().max(64).optional(),
|
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({
|
export const createHomeworkSchema = z.object({
|
||||||
@@ -15,10 +33,14 @@ export const createHomeworkSchema = z.object({
|
|||||||
title: z.string().trim().min(1, '请输入作业标题').max(120),
|
title: z.string().trim().min(1, '请输入作业标题').max(120),
|
||||||
content: z.string().min(1, '请输入作业内容').max(5000),
|
content: z.string().min(1, '请输入作业内容').max(5000),
|
||||||
attachmentUrl: z.string().trim().max(500).nullish(),
|
attachmentUrl: z.string().trim().max(500).nullish(),
|
||||||
|
/** 作业所属日期(YYYY-MM-DD):客户端/Web 录入时选,不传按服务器当天 */
|
||||||
|
assignDate: dayKeySchema.nullish(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const updateHomeworkSchema = z.object({
|
export const updateHomeworkSchema = z.object({
|
||||||
courseId: z.string().min(1).nullish(),
|
courseId: z.string().min(1).nullish(),
|
||||||
|
/** 改作业所属日期(把作业挪到另一天) */
|
||||||
|
assignDate: dayKeySchema.optional(),
|
||||||
title: z.string().trim().min(1).max(120).optional(),
|
title: z.string().trim().min(1).max(120).optional(),
|
||||||
content: z.string().min(1).max(5000).optional(),
|
content: z.string().min(1).max(5000).optional(),
|
||||||
attachmentUrl: z.string().trim().max(500).nullish(),
|
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([]),
|
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 CreateHomeworkInput = z.infer<typeof createHomeworkSchema>;
|
||||||
export type UpdateHomeworkInput = z.infer<typeof updateHomeworkSchema>;
|
export type UpdateHomeworkInput = z.infer<typeof updateHomeworkSchema>;
|
||||||
export type UpdateHomeworkStatusInput = z.infer<typeof updateHomeworkStatusSchema>;
|
export type UpdateHomeworkStatusInput = z.infer<typeof updateHomeworkStatusSchema>;
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
SOCKET_EVENTS,
|
SOCKET_EVENTS,
|
||||||
|
dayKeyLocal,
|
||||||
|
shiftDayKey,
|
||||||
|
type HomeworkDaysDto,
|
||||||
type HomeworkDto,
|
type HomeworkDto,
|
||||||
type HomeworkStatusDto,
|
type HomeworkStatusDto,
|
||||||
type HomeworkSubmissionDto,
|
type HomeworkSubmissionDto,
|
||||||
@@ -16,7 +19,12 @@ import { prisma } from '../../lib/db.js';
|
|||||||
import { ApiError } from '../../lib/http.js';
|
import { ApiError } from '../../lib/http.js';
|
||||||
import type { TokenPayload } from '../../lib/jwt.js';
|
import type { TokenPayload } from '../../lib/jwt.js';
|
||||||
import { toHomeworkDto, toHomeworkStatusDto } from '../../lib/mappers.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 { emitToClass } from '../../realtime/bus.js';
|
||||||
import type {
|
import type {
|
||||||
CreateHomeworkInput,
|
CreateHomeworkInput,
|
||||||
@@ -33,6 +41,8 @@ export interface ListHomeworkOptions {
|
|||||||
courseId?: string;
|
courseId?: string;
|
||||||
pendingOnly?: boolean;
|
pendingOnly?: boolean;
|
||||||
keyword?: string;
|
keyword?: string;
|
||||||
|
/** 只看某一天(Homework.assignDate,YYYY-MM-DD) */
|
||||||
|
date?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -60,13 +70,15 @@ export async function listHomeworks(
|
|||||||
...(student && options.pendingOnly
|
...(student && options.pendingOnly
|
||||||
? { statuses: { none: { ...personalWhere, completed: true } } }
|
? { statuses: { none: { ...personalWhere, completed: true } } }
|
||||||
: {}),
|
: {}),
|
||||||
|
// 按天查看:作业「属于哪一天」看 assignDate(本地日期),不是 createdAt
|
||||||
|
...(options.date ? { assignDate: options.date } : {}),
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
course: courseSelect,
|
course: courseSelect,
|
||||||
creator: creatorSelect,
|
creator: creatorSelect,
|
||||||
statuses: student ? { where: personalWhere } : true,
|
statuses: student ? { where: personalWhere } : true,
|
||||||
},
|
},
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: [{ assignDate: 'desc' }, { createdAt: 'desc' }],
|
||||||
take: 200,
|
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> {
|
export async function getHomework(user: TokenPayload, homeworkId: string): Promise<HomeworkDto> {
|
||||||
const student = isStudent(user);
|
const student = isStudent(user);
|
||||||
const personalIds = student ? await resolvePersonalIds(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> {
|
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);
|
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({
|
const created = await prisma.homework.create({
|
||||||
data: {
|
data: {
|
||||||
classId: input.classId,
|
classId: input.classId,
|
||||||
@@ -107,7 +171,9 @@ export async function createHomework(user: TokenPayload, input: CreateHomeworkIn
|
|||||||
title: input.title,
|
title: input.title,
|
||||||
content: input.content,
|
content: input.content,
|
||||||
attachmentUrl: input.attachmentUrl ?? null,
|
attachmentUrl: input.attachmentUrl ?? null,
|
||||||
createdBy: user.sub,
|
// 不传日期就记成「服务器当天」;教室在 UTC+8,用本地日期而不是 UTC 日期
|
||||||
|
assignDate: input.assignDate ?? dayKeyLocal(new Date()),
|
||||||
|
createdBy,
|
||||||
},
|
},
|
||||||
include: { course: courseSelect, creator: creatorSelect, statuses: true },
|
include: { course: courseSelect, creator: creatorSelect, statuses: true },
|
||||||
});
|
});
|
||||||
@@ -135,6 +201,7 @@ export async function updateHomework(
|
|||||||
...(input.content ? { content: input.content } : {}),
|
...(input.content ? { content: input.content } : {}),
|
||||||
...(input.courseId !== undefined ? { courseId: input.courseId ?? null } : {}),
|
...(input.courseId !== undefined ? { courseId: input.courseId ?? null } : {}),
|
||||||
...(input.attachmentUrl !== undefined ? { attachmentUrl: input.attachmentUrl ?? null } : {}),
|
...(input.attachmentUrl !== undefined ? { attachmentUrl: input.attachmentUrl ?? null } : {}),
|
||||||
|
...(input.assignDate !== undefined ? { assignDate: input.assignDate } : {}),
|
||||||
},
|
},
|
||||||
include: { course: courseSelect, creator: creatorSelect, statuses: true },
|
include: { course: courseSelect, creator: creatorSelect, statuses: true },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -225,6 +225,7 @@ function toPushDto(
|
|||||||
durationSeconds: number;
|
durationSeconds: number;
|
||||||
speechContent: string | null;
|
speechContent: string | null;
|
||||||
urgent: boolean;
|
urgent: boolean;
|
||||||
|
kind?: string | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
},
|
},
|
||||||
extras: { className?: string | null; teacherName?: string | null } = {},
|
extras: { className?: string | null; teacherName?: string | null } = {},
|
||||||
@@ -237,6 +238,8 @@ function toPushDto(
|
|||||||
speechContent: record.speechContent,
|
speechContent: record.speechContent,
|
||||||
createdAt: record.createdAt.toISOString(),
|
createdAt: record.createdAt.toISOString(),
|
||||||
urgent: record.urgent,
|
urgent: record.urgent,
|
||||||
|
// 老数据(迁移前)kind 为空:按普通通知处理
|
||||||
|
kind: record.kind === 'call' ? 'call' : 'notification',
|
||||||
classId: record.classId,
|
classId: record.classId,
|
||||||
className: extras.className ?? null,
|
className: extras.className ?? null,
|
||||||
teacherName: extras.teacherName ?? null,
|
teacherName: extras.teacherName ?? null,
|
||||||
@@ -325,6 +328,8 @@ export interface PushToClassIslandInput {
|
|||||||
notificationId?: string | null;
|
notificationId?: string | null;
|
||||||
/** 覆盖落库/广播时附带的教师名(三个入口的取法不同) */
|
/** 覆盖落库/广播时附带的教师名(三个入口的取法不同) */
|
||||||
teacherName?: string | null;
|
teacherName?: string | null;
|
||||||
|
/** 提醒类型:notification(默认)/ call(叫人,算「主动通知」) */
|
||||||
|
kind?: 'notification' | 'call';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -369,6 +374,7 @@ export async function pushToClassIsland(
|
|||||||
durationSeconds: input.durationSeconds ?? REPORT_DEFAULTS.notificationDuration,
|
durationSeconds: input.durationSeconds ?? REPORT_DEFAULTS.notificationDuration,
|
||||||
speechContent: input.speechContent ?? null,
|
speechContent: input.speechContent ?? null,
|
||||||
urgent: input.urgent === true,
|
urgent: input.urgent === true,
|
||||||
|
kind: input.kind ?? 'notification',
|
||||||
createdBy: input.createdBy ?? null,
|
createdBy: input.createdBy ?? null,
|
||||||
notificationId: input.notificationId ?? null,
|
notificationId: input.notificationId ?? null,
|
||||||
// 24 小时未送达/未确认的提醒不再补发,避免学生过几天突然看到旧提醒
|
// 24 小时未送达/未确认的提醒不再补发,避免学生过几天突然看到旧提醒
|
||||||
|
|||||||
@@ -126,6 +126,15 @@ export const DEFAULT_ISLAND_APPEARANCE = {
|
|||||||
style: 'black',
|
style: 'black',
|
||||||
/** 空闲时完全隐藏(与原行为一致;打开后空闲会留一条细缝) */
|
/** 空闲时完全隐藏(与原行为一致;打开后空闲会留一条细缝) */
|
||||||
idleSliver: false,
|
idleSliver: false,
|
||||||
|
/**
|
||||||
|
* 距屏幕左右边缘的距离(px):停靠左/右时生效。
|
||||||
|
* 默认与原行为一致(8px)—— 加这个设置是为了能调,不是为了改默认外观。
|
||||||
|
*/
|
||||||
|
marginX: 8,
|
||||||
|
/** 距屏幕上下边缘的距离(px):停靠顶/底时生效 */
|
||||||
|
marginY: 8,
|
||||||
|
/** 是否跟随鼠标所在屏幕(多显示器教室电脑:鼠标在哪块屏就在哪块屏显示) */
|
||||||
|
followCursorDisplay: false,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/** 个性化设置项的合法区间(前端滑块与后端校验共用,避免越界导致布局错乱) */
|
/** 个性化设置项的合法区间(前端滑块与后端校验共用,避免越界导致布局错乱) */
|
||||||
@@ -136,6 +145,8 @@ export const ISLAND_APPEARANCE_RANGES = {
|
|||||||
opacity: { min: 0.4, max: 1 },
|
opacity: { min: 0.4, max: 1 },
|
||||||
fontSize: { min: 11, max: 20 },
|
fontSize: { min: 11, max: 20 },
|
||||||
speed: { min: 0.5, max: 2 },
|
speed: { min: 0.5, max: 2 },
|
||||||
|
marginX: { min: 0, max: 200 },
|
||||||
|
marginY: { min: 0, max: 160 },
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/** 停靠位置可选值(与 WinIsland 的 DockPosition 一致:顶/底 × 左/中/右) */
|
/** 停靠位置可选值(与 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_CHANNELS = ['both', 'client', 'classisland'] as const;
|
||||||
|
|
||||||
export const CLASSISLAND_NOTIFICATION_CHANNEL_LABELS: Record<string, string> = {
|
export const CLASSISLAND_NOTIFICATION_CHANNEL_LABELS: Record<string, string> = {
|
||||||
both: 'ClassHelper 与 ClassIsland 都弹',
|
both: '两者都弹',
|
||||||
client: '只在 ClassHelper 客户端弹',
|
client: '灵动岛(ClassHelper 客户端)',
|
||||||
classisland: '只在 ClassIsland 上弹',
|
classisland: 'ClassIsland',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CLASSISLAND_NOTIFICATION_CHANNEL_HINTS: Record<string, string> = {
|
export const CLASSISLAND_NOTIFICATION_CHANNEL_HINTS: Record<string, string> = {
|
||||||
both: '教室的 ClassHelper 客户端与 ClassIsland 都会提醒(默认)',
|
both: '灵动岛与 ClassIsland 都会提醒老师发的内容(默认)',
|
||||||
client: '提醒只在 ClassHelper 客户端显示,不推送到 ClassIsland',
|
client: '只在 ClassHelper 客户端的灵动岛提醒,不推送到 ClassIsland',
|
||||||
classisland: '提醒只推送到 ClassIsland,ClassHelper 客户端只留记录、不弹窗',
|
classisland: '只推送到 ClassIsland 全屏提醒,灵动岛不弹(通知中心仍留记录)',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 默认显示位置:两端都弹 */
|
/** 默认显示位置:两端都弹 */
|
||||||
export const DEFAULT_CLASSISLAND_NOTIFICATION_CHANNEL = 'both';
|
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_DEFAULT_DURATION = 8;
|
||||||
export const CLASSISLAND_NOTIFICATION_MAX_DURATION = 120;
|
export const CLASSISLAND_NOTIFICATION_MAX_DURATION = 120;
|
||||||
|
|||||||
@@ -279,6 +279,13 @@ export interface HomeworkDto {
|
|||||||
attachmentUrl: string | null;
|
attachmentUrl: string | null;
|
||||||
createdBy: string;
|
createdBy: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
/**
|
||||||
|
* 作业所属日期(YYYY-MM-DD,**不带时区**)。
|
||||||
|
*
|
||||||
|
* 与 createdAt 的区别:createdAt 是"什么时候录的",assignDate 是"这天的作业"。
|
||||||
|
* 客户端 / Web 的按天查看与日期高亮都用它,避免跨时区把作业算到前一天。
|
||||||
|
*/
|
||||||
|
assignDate: string;
|
||||||
course?: CourseBrief | null;
|
course?: CourseBrief | null;
|
||||||
creator?: ClassTeacherBrief | null;
|
creator?: ClassTeacherBrief | null;
|
||||||
/** 当前登录学生的完成状态(学生端接口返回) */
|
/** 当前登录学生的完成状态(学生端接口返回) */
|
||||||
@@ -288,6 +295,19 @@ export interface HomeworkDto {
|
|||||||
completedCount?: number;
|
completedCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 某一天有作业(用于日期选择器高亮) */
|
||||||
|
export interface HomeworkDaySummary {
|
||||||
|
/** YYYY-MM-DD */
|
||||||
|
date: string;
|
||||||
|
/** 当天作业条数 */
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/homeworks/days 的返回 */
|
||||||
|
export interface HomeworkDaysDto {
|
||||||
|
days: HomeworkDaySummary[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface HomeworkStatusDto {
|
export interface HomeworkStatusDto {
|
||||||
id: string;
|
id: string;
|
||||||
homeworkId: string;
|
homeworkId: string;
|
||||||
@@ -301,6 +321,8 @@ export interface CreateHomeworkRequest {
|
|||||||
courseId?: string | null;
|
courseId?: string | null;
|
||||||
title: string;
|
title: string;
|
||||||
content: string;
|
content: string;
|
||||||
|
/** 作业所属日期(YYYY-MM-DD);不传按服务器当天处理 */
|
||||||
|
assignDate?: string | null;
|
||||||
attachmentUrl?: string | null;
|
attachmentUrl?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -541,6 +563,12 @@ export interface IslandAppearance {
|
|||||||
* 关闭时(默认)空闲即完全隐藏,与原行为一致。
|
* 关闭时(默认)空闲即完全隐藏,与原行为一致。
|
||||||
*/
|
*/
|
||||||
idleSliver: boolean;
|
idleSliver: boolean;
|
||||||
|
/** 距屏幕左右边缘的距离(px):停靠左/右时生效 */
|
||||||
|
marginX: number;
|
||||||
|
/** 距屏幕上下边缘的距离(px):停靠顶/底时生效 */
|
||||||
|
marginY: number;
|
||||||
|
/** 是否跟随鼠标所在屏幕(多显示器教室电脑用) */
|
||||||
|
followCursorDisplay: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 灵动岛停靠位置(6 个锚点,与 WinIsland 一致) */
|
/** 灵动岛停靠位置(6 个锚点,与 WinIsland 一致) */
|
||||||
@@ -882,6 +910,12 @@ export interface ClassIslandPushNotification {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
/** 是否紧急(紧急时插件用更强的遮罩与更长的时长) */
|
/** 是否紧急(紧急时插件用更强的遮罩与更长的时长) */
|
||||||
urgent: boolean;
|
urgent: boolean;
|
||||||
|
/**
|
||||||
|
* 提醒类型:
|
||||||
|
* - `notification`:通知类提醒 —— **上课时段插件会暂存,下课后才弹**;
|
||||||
|
* - `call`:叫人等「主动通知」—— 不论是否上课都立刻弹(老师正在等学生)。
|
||||||
|
*/
|
||||||
|
kind: 'notification' | 'call';
|
||||||
classId: string;
|
classId: string;
|
||||||
className?: string | null;
|
className?: string | null;
|
||||||
teacherName?: string | null;
|
teacherName?: string | null;
|
||||||
|
|||||||
@@ -11,6 +11,35 @@ function pad(input: number): string {
|
|||||||
return input < 10 ? `0${input}` : String(input);
|
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 */
|
/** 格式化为 YYYY-MM-DD,withTime=true 时追加 HH:mm */
|
||||||
export function formatDate(value: string | number | Date | null | undefined, withTime = false): string {
|
export function formatDate(value: string | number | Date | null | undefined, withTime = false): string {
|
||||||
if (value === null || value === undefined || value === '') return '';
|
if (value === null || value === undefined || value === '') return '';
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
type DashboardSummary,
|
type DashboardSummary,
|
||||||
type GradeDto,
|
type GradeDto,
|
||||||
type GradeStats,
|
type GradeStats,
|
||||||
|
type HomeworkDaysDto,
|
||||||
type HomeworkDto,
|
type HomeworkDto,
|
||||||
type HomeworkSubmissionsDto,
|
type HomeworkSubmissionsDto,
|
||||||
type IntegrationDeviceDto,
|
type IntegrationDeviceDto,
|
||||||
@@ -116,7 +117,16 @@ export const homeworkApi = {
|
|||||||
courseId?: string;
|
courseId?: string;
|
||||||
pendingOnly?: boolean;
|
pendingOnly?: boolean;
|
||||||
keyword?: string;
|
keyword?: string;
|
||||||
|
/** 只看某一天(YYYY-MM-DD,按作业所属日期) */
|
||||||
|
date?: string;
|
||||||
}): Promise<HomeworkDto[]> => api.get(API_PATHS.homeworks, params),
|
}): 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}`),
|
detail: (id: string): Promise<HomeworkDto> => api.get(`${API_PATHS.homeworks}/${id}`),
|
||||||
create: (payload: CreateHomeworkRequest): Promise<HomeworkDto> => api.post(API_PATHS.homeworks, payload),
|
create: (payload: CreateHomeworkRequest): Promise<HomeworkDto> => api.post(API_PATHS.homeworks, payload),
|
||||||
update: (id: string, payload: Partial<CreateHomeworkRequest>): Promise<HomeworkDto> =>
|
update: (id: string, payload: Partial<CreateHomeworkRequest>): Promise<HomeworkDto> =>
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'elem
|
|||||||
import {
|
import {
|
||||||
SOCKET_EVENTS,
|
SOCKET_EVENTS,
|
||||||
SUBJECT_CATALOG,
|
SUBJECT_CATALOG,
|
||||||
|
dayKeyLocal,
|
||||||
formatDate,
|
formatDate,
|
||||||
|
shiftDayKey,
|
||||||
truncate,
|
truncate,
|
||||||
type ClassDto,
|
type ClassDto,
|
||||||
type CourseDto,
|
type CourseDto,
|
||||||
@@ -25,7 +27,9 @@ const loading = ref(false);
|
|||||||
const classes = ref<ClassDto[]>([]);
|
const classes = ref<ClassDto[]>([]);
|
||||||
const courses = ref<CourseDto[]>([]);
|
const courses = ref<CourseDto[]>([]);
|
||||||
const homeworks = ref<HomeworkDto[]>([]);
|
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 detailVisible = ref(false);
|
||||||
const current = ref<HomeworkDto | null>(null);
|
const current = ref<HomeworkDto | null>(null);
|
||||||
|
|
||||||
@@ -108,10 +112,12 @@ async function loadCourses(): Promise<void> {
|
|||||||
async function loadHomeworks(): Promise<void> {
|
async function loadHomeworks(): Promise<void> {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
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.classId) params.classId = filter.classId;
|
||||||
if (filter.courseId) params.courseId = filter.courseId;
|
if (filter.courseId) params.courseId = filter.courseId;
|
||||||
if (filter.keyword.trim()) params.keyword = filter.keyword.trim();
|
if (filter.keyword.trim()) params.keyword = filter.keyword.trim();
|
||||||
|
// 按天查看:选了日期就只列那一天的作业
|
||||||
|
if (filter.date) params.date = filter.date;
|
||||||
homeworks.value = await homeworkApi.list(params);
|
homeworks.value = await homeworkApi.list(params);
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
@@ -121,9 +127,30 @@ async function loadHomeworks(): Promise<void> {
|
|||||||
async function onClassChange(): Promise<void> {
|
async function onClassChange(): Promise<void> {
|
||||||
filter.courseId = '';
|
filter.courseId = '';
|
||||||
await loadCourses();
|
await loadCourses();
|
||||||
|
await loadDayMarks();
|
||||||
await loadHomeworks();
|
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);
|
const formVisible = ref(false);
|
||||||
@@ -135,6 +162,8 @@ const form = reactive({
|
|||||||
title: '',
|
title: '',
|
||||||
content: '',
|
content: '',
|
||||||
attachmentUrl: '',
|
attachmentUrl: '',
|
||||||
|
/** 作业所属日期(YYYY-MM-DD):决定它出现在哪一天的「按天查看」里 */
|
||||||
|
assignDate: dayKeyLocal(new Date()),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -182,6 +211,8 @@ function openCreate(): void {
|
|||||||
form.title = '';
|
form.title = '';
|
||||||
form.content = '';
|
form.content = '';
|
||||||
form.attachmentUrl = '';
|
form.attachmentUrl = '';
|
||||||
|
// 默认跟当前「按天查看」的日期一致:老师多半就是在给这一天留作业
|
||||||
|
form.assignDate = filter.date || dayKeyLocal(new Date());
|
||||||
formVisible.value = true;
|
formVisible.value = true;
|
||||||
void loadFormCourses();
|
void loadFormCourses();
|
||||||
}
|
}
|
||||||
@@ -193,6 +224,7 @@ function openEdit(row: HomeworkDto): void {
|
|||||||
form.title = row.title;
|
form.title = row.title;
|
||||||
form.content = row.content;
|
form.content = row.content;
|
||||||
form.attachmentUrl = row.attachmentUrl ?? '';
|
form.attachmentUrl = row.attachmentUrl ?? '';
|
||||||
|
form.assignDate = row.assignDate;
|
||||||
formVisible.value = true;
|
formVisible.value = true;
|
||||||
void loadFormCourses();
|
void loadFormCourses();
|
||||||
}
|
}
|
||||||
@@ -253,6 +285,7 @@ async function submitForm(): Promise<void> {
|
|||||||
title: form.title.trim(),
|
title: form.title.trim(),
|
||||||
content: form.content,
|
content: form.content,
|
||||||
attachmentUrl: form.attachmentUrl.trim() || null,
|
attachmentUrl: form.attachmentUrl.trim() || null,
|
||||||
|
assignDate: form.assignDate || dayKeyLocal(new Date()),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (editingId.value) {
|
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-option v-for="item in courses" :key="item.id" :label="item.name" :value="item.id" />
|
||||||
</el-select>
|
</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
|
<el-input
|
||||||
v-model="filter.keyword"
|
v-model="filter.keyword"
|
||||||
placeholder="搜索标题/内容"
|
placeholder="搜索标题/内容"
|
||||||
@@ -400,6 +443,16 @@ onUnmounted(() => {
|
|||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</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-form-item label="标题" prop="title">
|
||||||
<el-input v-model="form.title" maxlength="120" show-word-limit />
|
<el-input v-model="form.title" maxlength="120" show-word-limit />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -502,6 +555,29 @@ onUnmounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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 {
|
.text-danger {
|
||||||
color: #f56c6c;
|
color: #f56c6c;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user