From 0267d82939745d1b6dbb082524857b3ebfdc8f2d Mon Sep 17 00:00:00 2001
From: Alumos <1281610535@qq.com>
Date: Fri, 13 Nov 2026 13:11:01 +0800
Subject: [PATCH] =?UTF-8?q?feat(student-calls):=20=E5=AD=A6=E7=94=9F?=
=?UTF-8?q?=E6=A1=8C=E9=9D=A2=E5=91=BC=E5=8F=AB=E8=80=81=E5=B8=88=E4=B8=8E?=
=?UTF-8?q?=E6=95=99=E5=B8=88=E5=85=B1=E4=BA=AB=E5=B7=B2=E8=AF=BB?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 大屏端:绑定后 Windows 桌面显示「呼叫老师」原生悬浮入口,二次确认后以设备身份呼叫本班全部关联老师;取消/Escape 不发送,支持多显示器拖动、DPI 缩放与任务栏避让,全屏展示期间隐藏,托盘后仍可用。
- 教师端:侧栏「发通知」下方新增「学生呼叫」入口与未读红色数量,网页在线时通过 WS 提醒,断线重连与定时刷新补齐,明确标记后共享已读并保留处理人与时间,不自动已读。
- 服务端:新增 POST /device/student-calls、GET /student-calls、POST /student-calls/{id}/read;request_id 幂等、同班 30 秒限流、按班级成员鉴权,记录持久化到 SQLite 并纳入备份,旧备份缺少该集合仍可恢复。
- 契约与文档:重新生成 OpenAPI/WS 类型,补充 INTEGRATION、ARCHITECTURE、LOCAL_TESTING 及各端 README。
- 版本升至 0.3.5,新增发布说明。
---
.prettierignore | 1 +
CHANGELOG.md | 4 +
README.md | 1 +
api/openapi.yaml | 299 ++++++++++++--
api/ws/frame.schema.json | 44 ++
deploy/.env.example | 2 +-
deploy/compose.1panel.yaml | 2 +-
deploy/compose.yaml | 2 +-
docs/ARCHITECTURE.md | 2 +-
docs/INTEGRATION.md | 10 +-
docs/LOCAL_TESTING.md | 14 +-
docs/releases/v0.3.5.md | 24 ++
package.json | 4 +-
screen/README.md | 10 +
screen/app.go | 8 +-
screen/frontend/package.json | 2 +-
screen/frontend/src/main.tsx | 48 +++
screen/frontend/src/native.ts | 5 +-
screen/frontend/src/service.ts | 120 +++++-
screen/frontend/src/ui.css | 59 +++
.../tests/native-student-call.test.ts | 159 ++++++++
screen/frontend/tests/service.test.ts | 132 ++++++
screen/student_call.go | 181 +++++++++
screen/student_call_other.go | 11 +
screen/student_call_test.go | 131 ++++++
screen/student_call_windows.go | 380 +++++++++++++++++
screen/wails.json | 2 +-
scripts/api-generator/package.json | 2 +-
scripts/generate-contract.mjs | 25 ++
server/README.md | 6 +
server/internal/app/app.go | 3 +
server/internal/app/backup.go | 44 +-
server/internal/app/classes.go | 5 +
server/internal/app/hub.go | 2 +
server/internal/app/models.go | 80 ++--
server/internal/app/realtime.go | 1 +
server/internal/app/routes.go | 3 +
server/internal/app/student_calls.go | 203 ++++++++++
.../internal/app/student_calls_backup_test.go | 136 +++++++
.../app/student_calls_realtime_test.go | 83 ++++
server/internal/app/student_calls_test.go | 382 ++++++++++++++++++
server/internal/repo/store.go | 4 +-
tests/e2e/homework.spec.ts | 11 +-
tests/e2e/student-calls-integration.spec.ts | 162 ++++++++
tests/e2e/student-calls.spec.ts | 218 ++++++++++
web/apps/admin/package.json | 2 +-
web/apps/teacher/README.md | 1 +
web/apps/teacher/package.json | 2 +-
web/apps/teacher/src/App.tsx | 62 ++-
web/apps/teacher/src/auth.tsx | 3 +
web/apps/teacher/src/hooks.ts | 46 ++-
web/apps/teacher/src/models.ts | 3 +
web/apps/teacher/src/pages/StudentCalls.tsx | 151 +++++++
web/apps/teacher/src/studentCalls.test.ts | 62 +++
web/apps/teacher/src/studentCalls.ts | 53 +++
web/packages/api/package.json | 2 +-
web/packages/api/src/types.ts | 307 ++++++++++----
web/packages/api/src/ws-types.ts | 12 +
web/packages/ui/package.json | 2 +-
59 files changed, 3553 insertions(+), 182 deletions(-)
create mode 100644 docs/releases/v0.3.5.md
create mode 100644 screen/frontend/tests/native-student-call.test.ts
create mode 100644 screen/student_call.go
create mode 100644 screen/student_call_other.go
create mode 100644 screen/student_call_test.go
create mode 100644 screen/student_call_windows.go
create mode 100644 server/internal/app/student_calls.go
create mode 100644 server/internal/app/student_calls_backup_test.go
create mode 100644 server/internal/app/student_calls_realtime_test.go
create mode 100644 server/internal/app/student_calls_test.go
create mode 100644 tests/e2e/student-calls-integration.spec.ts
create mode 100644 tests/e2e/student-calls.spec.ts
create mode 100644 web/apps/teacher/src/pages/StudentCalls.tsx
create mode 100644 web/apps/teacher/src/studentCalls.test.ts
create mode 100644 web/apps/teacher/src/studentCalls.ts
diff --git a/.prettierignore b/.prettierignore
index d3d676b..81ea5ca 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -7,6 +7,7 @@ web/packages/api/src/ws-types.ts
**/dist/
server/static/
node_modules/
+.agent/
.pnpm-store/
tools/
.claude/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4c6d38c..daf26e2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,10 @@
## 未发布
+## v0.3.5 — 2026-11-13
+
+- 新增学生呼叫:Windows 桌面悬浮入口二次确认后呼叫本班全部关联教师;教师工作台新增「学生呼叫」、未读红色数量、在线实时提醒和手动共享已读,保留处理老师与时间。记录随服务持久化及备份,支持离线后补查;同班 30 秒防重复呼叫。不使用 Web Push,Windows 系统集成仍需实机验收。
+
- 修复管理后台手机端成功/错误提示遮挡顶部导航的问题:窄屏提示改为底部显示,桌面保留右上角,并支持手动关闭。新增提示显示期间的窄屏导航回归测试,覆盖 320/390px 与 100%/150% 字号。
## v0.3.4 — 2026-11-04
diff --git a/README.md b/README.md
index 9f7abb9..37b91e9 100644
--- a/README.md
+++ b/README.md
@@ -37,6 +37,7 @@ pnpm dev:screen # 大屏浏览器预览,默认 http://localhost:5175
- 服务端提供 REST 与 WebSocket、设备通知队列、回执和本地录音存储;大屏已有浏览器预览与 Windows 客户端代码,Windows 安装、系统音量、置顶和开机启动等仍需在目标设备上测试。
- 当前没有服务端语音识别或 TTS 服务:录音按原声发送,文字由大屏尝试使用系统语音播报。系统配置提供全站 ZIP 手动导出和恢复,并支持下发设备地址迁移计划;自动更新与定时备份尚未实现。大屏其余限制见 [大屏说明](screen/README.md)。
- 教师支持布置各科作业,Windows 客户端用独立原生桌面悬浮窗显示,可在后台和电脑上调整显示及置顶;设备实时接收完整作业快照,并在重连时补齐。
+- 学生可在 Windows 原生桌面「呼叫老师」入口二次确认,通知当前班级所有关联老师;教师端「学生呼叫」显示持久记录、未读红色数量和网页在线提醒,显式标记后共享已读并保留处理人及时间。不会在进入页面时自动已读,不使用 Web Push;同班 30 秒防重复呼叫。悬浮入口系统行为仍需 Windows 实机验收。
- 学校创建与教师 Excel 导入、后台客户端版本管理、账号注销尚未实现;学校角色隔离已有基础代码与测试,完整学校采购流程尚未开放。
- SQLite 数据在服务启动时加载到内存并由单个进程写入,**同一数据目录只能运行一个主服务实例**。尚未完成规范化仓储和大规模性能验证。优先使用管理后台的全站备份,或停服后备份完整数据目录及录音。
diff --git a/api/openapi.yaml b/api/openapi.yaml
index b02e875..7cedb24 100644
--- a/api/openapi.yaml
+++ b/api/openapi.yaml
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "课堂叮咚 API",
- "version": "0.3.4"
+ "version": "0.3.5"
},
"servers": [
{
@@ -1757,7 +1757,7 @@
}
},
"post": {
- "operationId": "post__messages_37",
+ "operationId": "post__messages_40",
"summary": "POST /messages",
"security": [
{
@@ -1798,9 +1798,167 @@
}
}
},
+ "/device/student-calls": {
+ "post": {
+ "operationId": "post__device_student_calls_37",
+ "summary": "POST /device/student-calls",
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "request_id": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 128,
+ "pattern": "^[A-Za-z0-9_.:-]+$"
+ }
+ },
+ "required": [
+ "request_id"
+ ]
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "成功",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/StudentCall"
+ }
+ }
+ }
+ },
+ "default": {
+ "description": "业务错误",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/student-calls": {
+ "get": {
+ "operationId": "get__student_calls_38",
+ "summary": "GET /student-calls",
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "成功",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/StudentCallList"
+ }
+ }
+ }
+ },
+ "default": {
+ "description": "业务错误",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ }
+ },
+ "parameters": [
+ {
+ "name": "class_id",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "cursor",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "limit",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 100
+ }
+ }
+ ]
+ }
+ },
+ "/student-calls/{id}/read": {
+ "post": {
+ "operationId": "post__student_calls__id__read_39",
+ "summary": "POST /student-calls/{id}/read",
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "成功",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/StudentCall"
+ }
+ }
+ }
+ },
+ "default": {
+ "description": "业务错误",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"/messages/{id}": {
"get": {
- "operationId": "get__messages__id__38",
+ "operationId": "get__messages__id__41",
"summary": "GET /messages/{id}",
"security": [
{
@@ -1841,7 +1999,7 @@
}
},
"delete": {
- "operationId": "delete__messages__id__39",
+ "operationId": "delete__messages__id__42",
"summary": "DELETE /messages/{id}",
"security": [
{
@@ -1884,7 +2042,7 @@
},
"/messages/{id}/recall": {
"post": {
- "operationId": "post__messages__id__recall_40",
+ "operationId": "post__messages__id__recall_43",
"summary": "POST /messages/{id}/recall",
"security": [
{
@@ -1927,7 +2085,7 @@
},
"/messages/{id}/resend": {
"post": {
- "operationId": "post__messages__id__resend_41",
+ "operationId": "post__messages__id__resend_44",
"summary": "POST /messages/{id}/resend",
"security": [
{
@@ -1970,7 +2128,7 @@
},
"/phrases": {
"get": {
- "operationId": "get__phrases_42",
+ "operationId": "get__phrases_45",
"summary": "GET /phrases",
"security": [
{
@@ -2004,7 +2162,7 @@
}
},
"post": {
- "operationId": "post__phrases_43",
+ "operationId": "post__phrases_46",
"summary": "POST /phrases",
"security": [
{
@@ -2055,7 +2213,7 @@
},
"/phrases/{id}": {
"patch": {
- "operationId": "patch__phrases__id__44",
+ "operationId": "patch__phrases__id__47",
"summary": "PATCH /phrases/{id}",
"security": [
{
@@ -2114,7 +2272,7 @@
}
},
"delete": {
- "operationId": "delete__phrases__id__45",
+ "operationId": "delete__phrases__id__48",
"summary": "DELETE /phrases/{id}",
"security": [
{
@@ -2157,7 +2315,7 @@
},
"/classes/{id}/tools/countdown": {
"post": {
- "operationId": "post__classes__id__tools_countdown_46",
+ "operationId": "post__classes__id__tools_countdown_49",
"summary": "POST /classes/{id}/tools/countdown",
"security": [
{
@@ -2218,7 +2376,7 @@
},
"/classes/{id}/tools/rollcall": {
"post": {
- "operationId": "post__classes__id__tools_rollcall_47",
+ "operationId": "post__classes__id__tools_rollcall_50",
"summary": "POST /classes/{id}/tools/rollcall",
"security": [
{
@@ -2282,7 +2440,7 @@
},
"/classes/{id}/tools/stop": {
"post": {
- "operationId": "post__classes__id__tools_stop_48",
+ "operationId": "post__classes__id__tools_stop_51",
"summary": "POST /classes/{id}/tools/stop",
"security": [
{
@@ -2325,7 +2483,7 @@
},
"/audio": {
"post": {
- "operationId": "post__audio_49",
+ "operationId": "post__audio_52",
"summary": "POST /audio",
"security": [
{
@@ -2377,7 +2535,7 @@
},
"/admin/overview": {
"get": {
- "operationId": "get__admin_overview_50",
+ "operationId": "get__admin_overview_53",
"summary": "GET /admin/overview",
"security": [
{
@@ -2410,7 +2568,7 @@
},
"/admin/activation-codes": {
"get": {
- "operationId": "get__admin_activation_codes_51",
+ "operationId": "get__admin_activation_codes_54",
"summary": "GET /admin/activation-codes",
"security": [
{
@@ -2478,7 +2636,7 @@
}
},
"post": {
- "operationId": "post__admin_activation_codes_52",
+ "operationId": "post__admin_activation_codes_55",
"summary": "POST /admin/activation-codes",
"security": [
{
@@ -2552,7 +2710,7 @@
},
"/admin/activation-codes/{code}": {
"patch": {
- "operationId": "patch__admin_activation_codes__code__53",
+ "operationId": "patch__admin_activation_codes__code__56",
"summary": "PATCH /admin/activation-codes/{code}",
"security": [
{
@@ -2623,7 +2781,7 @@
},
"/admin/activation-codes/delete": {
"post": {
- "operationId": "post__admin_activation_codes_delete_54",
+ "operationId": "post__admin_activation_codes_delete_57",
"summary": "POST /admin/activation-codes/delete",
"security": [
{
@@ -2687,7 +2845,7 @@
},
"/admin/schools": {
"get": {
- "operationId": "get__admin_schools_55",
+ "operationId": "get__admin_schools_58",
"summary": "GET /admin/schools",
"security": [
{
@@ -2721,7 +2879,7 @@
}
},
"post": {
- "operationId": "post__admin_schools_56",
+ "operationId": "post__admin_schools_59",
"summary": "POST /admin/schools",
"security": [
{
@@ -2772,7 +2930,7 @@
},
"/admin/schools/{id}": {
"patch": {
- "operationId": "patch__admin_schools__id__57",
+ "operationId": "patch__admin_schools__id__60",
"summary": "PATCH /admin/schools/{id}",
"security": [
{
@@ -2831,7 +2989,7 @@
}
},
"delete": {
- "operationId": "delete__admin_schools__id__58",
+ "operationId": "delete__admin_schools__id__61",
"summary": "DELETE /admin/schools/{id}",
"security": [
{
@@ -2874,7 +3032,7 @@
},
"/admin/users": {
"get": {
- "operationId": "get__admin_users_59",
+ "operationId": "get__admin_users_62",
"summary": "GET /admin/users",
"security": [
{
@@ -2908,7 +3066,7 @@
}
},
"post": {
- "operationId": "post__admin_users_60",
+ "operationId": "post__admin_users_63",
"summary": "POST /admin/users",
"security": [
{
@@ -2970,7 +3128,7 @@
},
"/admin/users/{id}": {
"patch": {
- "operationId": "patch__admin_users__id__61",
+ "operationId": "patch__admin_users__id__64",
"summary": "PATCH /admin/users/{id}",
"security": [
{
@@ -3039,7 +3197,7 @@
}
},
"delete": {
- "operationId": "delete__admin_users__id__62",
+ "operationId": "delete__admin_users__id__65",
"summary": "DELETE /admin/users/{id}",
"security": [
{
@@ -3082,7 +3240,7 @@
},
"/admin/users/{id}/unlock-login": {
"post": {
- "operationId": "post__admin_users__id__unlock_login_63",
+ "operationId": "post__admin_users__id__unlock_login_66",
"summary": "POST /admin/users/{id}/unlock-login",
"security": [
{
@@ -3125,7 +3283,7 @@
},
"/admin/classes": {
"get": {
- "operationId": "get__admin_classes_64",
+ "operationId": "get__admin_classes_67",
"summary": "GET /admin/classes",
"security": [
{
@@ -3159,7 +3317,7 @@
}
},
"post": {
- "operationId": "post__admin_classes_65",
+ "operationId": "post__admin_classes_68",
"summary": "POST /admin/classes",
"security": [
{
@@ -3225,7 +3383,7 @@
},
"/admin/classes/{id}": {
"patch": {
- "operationId": "patch__admin_classes__id__66",
+ "operationId": "patch__admin_classes__id__69",
"summary": "PATCH /admin/classes/{id}",
"security": [
{
@@ -3294,7 +3452,7 @@
}
},
"delete": {
- "operationId": "delete__admin_classes__id__67",
+ "operationId": "delete__admin_classes__id__70",
"summary": "DELETE /admin/classes/{id}",
"security": [
{
@@ -3337,7 +3495,7 @@
},
"/admin/devices": {
"get": {
- "operationId": "get__admin_devices_68",
+ "operationId": "get__admin_devices_71",
"summary": "GET /admin/devices",
"security": [
{
@@ -3373,7 +3531,7 @@
},
"/admin/messages": {
"get": {
- "operationId": "get__admin_messages_69",
+ "operationId": "get__admin_messages_72",
"summary": "GET /admin/messages",
"security": [
{
@@ -3440,7 +3598,7 @@
},
"/admin/messages/{id}": {
"delete": {
- "operationId": "delete__admin_messages__id__70",
+ "operationId": "delete__admin_messages__id__73",
"summary": "DELETE /admin/messages/{id}",
"security": [
{
@@ -3483,7 +3641,7 @@
},
"/admin/audit-logs": {
"get": {
- "operationId": "get__admin_audit_logs_71",
+ "operationId": "get__admin_audit_logs_74",
"summary": "GET /admin/audit-logs",
"security": [
{
@@ -3816,6 +3974,75 @@
"sort"
]
},
+ "StudentCall": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "class_id": {
+ "type": "string"
+ },
+ "class_name": {
+ "type": "string"
+ },
+ "device_id": {
+ "type": "string"
+ },
+ "device_name": {
+ "type": "string"
+ },
+ "created_at": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "read_at": {
+ "type": "string"
+ },
+ "read_by": {
+ "type": "string"
+ },
+ "read_by_name": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "id",
+ "class_id",
+ "class_name",
+ "device_id",
+ "device_name",
+ "created_at",
+ "read_at",
+ "read_by",
+ "read_by_name"
+ ]
+ },
+ "StudentCallList": {
+ "type": "object",
+ "properties": {
+ "items": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/StudentCall"
+ }
+ },
+ "unread_count": {
+ "type": "integer"
+ },
+ "next_cursor": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ },
+ "required": [
+ "items",
+ "unread_count",
+ "next_cursor"
+ ]
+ },
"Homework": {
"type": "object",
"properties": {
diff --git a/api/ws/frame.schema.json b/api/ws/frame.schema.json
index 4f3f5ea..f8279f4 100644
--- a/api/ws/frame.schema.json
+++ b/api/ws/frame.schema.json
@@ -740,6 +740,50 @@
"t",
"d"
]
+ },
+ {
+ "type": "object",
+ "properties": {
+ "t": {
+ "const": "student_call"
+ },
+ "id": {
+ "type": "string"
+ },
+ "seq": {
+ "type": "integer"
+ },
+ "ts": {
+ "type": "integer"
+ },
+ "d": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "class_id": {
+ "type": "string"
+ },
+ "kind": {
+ "type": "string",
+ "enum": [
+ "created",
+ "read"
+ ]
+ }
+ },
+ "required": [
+ "id",
+ "class_id",
+ "kind"
+ ]
+ }
+ },
+ "required": [
+ "t",
+ "d"
+ ]
}
]
}
diff --git a/deploy/.env.example b/deploy/.env.example
index c112b0f..5511347 100644
--- a/deploy/.env.example
+++ b/deploy/.env.example
@@ -1,5 +1,5 @@
DOMAIN=classding.example.com
-CLASSDING_IMAGE=ghcr.io/alumos/classding-server:v0.3.4
+CLASSDING_IMAGE=ghcr.io/alumos/classding-server:v0.3.5
# openssl rand -hex 32
JWT_SECRET=
# 仅首次部署设置;创建成功后清空用户名/密码并重新创建容器
diff --git a/deploy/compose.1panel.yaml b/deploy/compose.1panel.yaml
index 5c1115b..669e3b0 100644
--- a/deploy/compose.1panel.yaml
+++ b/deploy/compose.1panel.yaml
@@ -1,7 +1,7 @@
# 粘贴到 1Panel 的编排编辑器。首次部署请填写两项密码;升级保留原值和原编排名称。
services:
classding:
- image: ghcr.io/alumos/classding-server:0.3.4
+ image: ghcr.io/alumos/classding-server:0.3.5
platform: linux/amd64
restart: unless-stopped
ports:
diff --git a/deploy/compose.yaml b/deploy/compose.yaml
index dc08eda..1269a89 100644
--- a/deploy/compose.yaml
+++ b/deploy/compose.yaml
@@ -1,6 +1,6 @@
services:
server:
- image: ${CLASSDING_IMAGE:-ghcr.io/alumos/classding-server:v0.3.4}
+ image: ${CLASSDING_IMAGE:-ghcr.io/alumos/classding-server:v0.3.5}
restart: unless-stopped
environment:
DATA_DIR: /data
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index e7443d2..385b817 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -27,7 +27,7 @@
| `routes.go` | 全部路由,一行一个接口,标明访问级别与读写 |
| `app.go` | 启动、旧数据升级、限流、审计、会话撤销 |
| `auth.go`、`security.go`、`validate.go` | 登录与刷新会话、令牌与密码哈希、输入校验 |
-| `classes.go`、`devices.go`、`messages.go`、`homework.go`、`audio.go`、`tools.go` | 各业务领域 |
+| `classes.go`、`devices.go`、`messages.go`、`homework.go`、`student_calls.go`、`audio.go`、`tools.go` | 各业务领域 |
| `admin.go`、`settings.go`、`backup.go` | 管理后台、站点设置、备份与恢复 |
| `realtime.go`、`hub.go` | WebSocket 协议与连接池 |
| `maintenance.go` | 定时清理过期会话、消息和录音 |
diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md
index a94226c..df28ae4 100644
--- a/docs/INTEGRATION.md
+++ b/docs/INTEGRATION.md
@@ -14,7 +14,7 @@
- AuthResponse: {access_token,user};refresh token 放 HttpOnly Cookie。
## REST
-GET config => {speech_enabled:false,registration_enabled:true,version:'0.3.4'}
+GET config => {speech_enabled:false,registration_enabled:true,version:'0.3.5'}
POST auth/register {username,password,display_name} => AuthResponse
POST auth/login {username,password} => AuthResponse
POST auth/refresh => AuthResponse;POST auth/logout => {ok:true}
@@ -58,6 +58,14 @@ GET admin/devices => Device[];GET admin/messages => {items:Message[],next_curs
作业列表和快照均包含隐藏作业,设备只展示 `visible=true` 的项目。每班最多 12 条,科目 1–24 字、内容 1–300 字;作者和班主任可以修改或删除,其他协同教师仅可查看他人的作业。班级成员均可发布自己的作业。更改在持久化成功后推送给该班设备及教师,教师可据此刷新列表。设备断线后由 welcome 补全快照,并可定期调用 device/homeworks 兜底;客户端应忽略修订号低于已采用快照的响应。
+## 学生呼叫
+
+- `POST /device/student-calls {request_id}` 使用设备 Bearer 认证,按设备当前绑定班级创建呼叫;不接受客户端指定班级、教师或学生身份。`request_id` 为 1–128 个 ASCII 字母、数字或 `_.:-`;同设备同班级重试同一 ID 返回原记录且不重复推送,冲突返回 409。同班新呼叫间隔 30 秒,限流返回 429 与 `Retry-After`。
+- `GET /student-calls?class_id=&limit=&cursor=` 只返回当前教师已加入班级的记录,管理员角色不绕过成员检查。`limit` 默认 100、范围 1–100;响应 `{items:StudentCall[],unread_count,next_cursor:string|null}`。按创建时间和 ID 倒序稳定翻页,未读数量不受分页影响;指定班级时数量仅统计该班。
+- `StudentCall` 包含 `id,class_id,class_name,device_id,device_name,created_at,read_at,read_by,read_by_name`;最后三个字段未读时为空字符串。班级与设备名称为记录快照,无学生账号或自由文本。
+- `POST /student-calls/:id/read` 明确标记共享已读;首位处理老师与时间保持不变。仅浏览或打开页面不会标记已读。记录及共享状态写入 SQLite 并包含在备份中,旧备份缺少该集合仍可恢复。
+- 教师 WS 帧 `student_call d:{id,class_id,kind:created|read}` 在持久化提交后发给当前班级成员;网页在线时提示并刷新红色数量,断线重连/定时查询补齐。不是 Web Push,不承诺锁屏或关闭浏览器后提醒。
+
## 前端共享包
- `@classding/ui`:shadcn/ui 组件(`components/ui/*`)、项目自有组件(`components/classding`)、站点设置、`lib/delivery`(投递状态的统一文案与配色)、`notice`(教室通知画面,大屏与教师端预览共用,样式为 `notice.css`)、`version`(读取根目录 `package.json` 的版本号)。
- 大屏客户端的按钮、输入框等轻量控件位于 `screen/frontend/src/ui.tsx` 与 `ui.css`,不进入共享包。
diff --git a/docs/LOCAL_TESTING.md b/docs/LOCAL_TESTING.md
index 2da080c..4101db5 100644
--- a/docs/LOCAL_TESTING.md
+++ b/docs/LOCAL_TESTING.md
@@ -24,12 +24,22 @@
教师开发账号 `teacher`,密码 `ClassDing2026!`。在「我的班级」获取绑定码,在大屏页留空服务地址并输入绑定码。随后发送一条通知,查看大屏及发送记录。浏览器声音可能需要先点击测试播报;录音还需要浏览器和系统允许麦克风访问。
+## 学生呼叫验收(未发布)
+
+1. 给同一班级加入两位教师,并将 Windows 大屏绑定该班;确认桌面悬浮球出现。点击后取消,教师端不应产生记录;再次点击并确认发送。
+2. 两位教师在线打开工作台时均应看到「学生呼叫」红色未读数量和提醒;进入页面不消除未读。任一教师手动标记已读后,两端同步消除该条未读,记录保留处理老师和时间。
+3. 另一教师关闭页面期间发起呼叫,重新登录后仍可查看未读;重启服务、刷新网页后记录和已读状态保留。未加入班级的账号不得读取或处理该班记录。
+4. 30 秒内新呼叫应提示稍后再试;断网不得显示发送成功,重试同一请求不产生重复记录。解绑后悬浮入口不可发送。
+5. 在真实 Windows 上检查主窗口隐藏/托盘驻留、悬浮球拖动、多屏工作区与 DPI 缩放、确认/取消、发送中防重复、全屏通知期间隐藏及恢复。浏览器兜底入口和交叉编译不能替代这项实机验收。
+6. 手机 320/390px、桌面及 150% 字号检查六项导航和未读徽标,不应溢出或遮挡操作。
+
## 已自动验证与待验证
-- 浏览器验收覆盖电脑、平板和手机宽度的后台登录、图表、激活码生成及搜索、教师导航;电脑和手机另有真实绑定、200 字通知、回执和录音上传,新增 HTTP 会话保持、账号和班级管理、管理员解除登录限制、复制降级和并发刷新,共 26 项流程。
+- 浏览器验收覆盖电脑、平板和手机宽度的后台登录、图表、激活码生成及搜索、教师导航;电脑和手机另有真实绑定、200 字通知、回执和录音上传,新增 HTTP 会话保持、账号和班级管理、管理员解除登录限制、复制降级和并发刷新等流程。
- 录音流程使用浏览器合成音源和真实 MediaRecorder/上传接口。实际麦克风、微信内置浏览器、Safari 和 Windows WebView2 仍需手动验证。
- 大屏消息单元测试覆盖重连、重复消息、撤回、队列持久化、过期消息与无效封装。
-- Go 测试覆盖权限、租户隔离、会话刷新与账号班级管理、可信代理、双班投递与重连、保存失败回滚和静态网页路由。
+- 学生呼叫浏览器回归覆盖双教师真实 HTTP/WS 共享已读、刷新保留、幂等与限流,并覆盖分页、重连、临时断网、旧服务兼容、账号切换与窄屏布局;页面打开不会自动标记已读。
+- Go 测试覆盖权限、租户隔离、会话刷新与账号班级管理、可信代理、双班投递与重连、保存失败回滚和静态网页路由;学生呼叫另覆盖持久化重启、并发首次处理、跨班拒绝、失败无广播、旧备份兼容及失效 WebSocket 会话过滤。
- Windows 可执行文件可以交叉编译;托盘、开机启动、音量恢复、PPT 置顶、4K 最后一排可读性和 NSIS 安装须在目标机器验证。
- Docker 与 Windows 发布工作流在版本标签推送后运行,实际结果以 GitHub Actions 和 Release 附件为准。服务端和 Windows 客户端均以 amd64 为发布目标,无需 QEMU。
diff --git a/docs/releases/v0.3.5.md b/docs/releases/v0.3.5.md
new file mode 100644
index 0000000..38d485c
--- /dev/null
+++ b/docs/releases/v0.3.5.md
@@ -0,0 +1,24 @@
+# 课堂叮咚 v0.3.5
+
+学生可以在教室大屏的 Windows 桌面上点「呼叫老师」,二次确认后通知本班全部关联老师;老师在工作台「学生呼叫」中查看并共享处理。
+
+## 新功能
+
+- **桌面呼叫入口**:绑定后 Windows 桌面会显示「呼叫老师」原生悬浮入口,不占用任务栏。平时不抢焦点,点击后展开二次确认;取消或按 Escape 不发送,默认焦点在取消按钮。拖动可移到其他显示器,松手停在最近显示器工作区内并避开任务栏,按显示器缩放比例调整大小。
+- **呼叫本班全部老师**:确认后由设备身份直接发送,不需要账号,也不会指定班级、老师或学生姓名。本班所有关联老师都会收到这条呼叫记录。
+- **教师端「学生呼叫」**:教师工作台侧栏在「发通知」下方新增入口,未处理时显示红色数量。打开页面时提供网页在线提醒;断线重连或定时刷新后补齐,记录随服务持久化并包含在全站备份中。
+- **共享已读**:任一位老师明确点开标记后,该条记录对其他老师同步为已处理,并保留处理老师与时间。仅浏览列表或进入页面不会自动标记已读。
+- **防重复呼叫**:同一班级 30 秒内只能发起一次;连续点击时客户端显示剩余等待时间,服务端同时拦截。
+
+## 升级
+
+Linux amd64 镜像:`ghcr.io/alumos/classding-server:0.3.5`。保留现有编排和数据卷,仅更新镜像后重建。旧数据目录和旧版备份可以直接使用,缺少学生呼叫记录时会按空记录处理。
+
+Windows amd64 安装包:`ClassDingScreen-amd64-installer.exe`。**大屏需升级到 v0.3.5** 才会出现桌面「呼叫老师」入口;教师端的学生呼叫页面也需要本版服务端。
+
+## 已知限制
+
+- 只在教师网页打开时提醒,不使用 Web Push,不承诺锁屏、关闭浏览器或手机后台时的提醒。
+- 呼叫内容是固定提示,不含自由文本、录音或学生账号,也不提供回拨。
+- 桌面悬浮入口的系统行为(多显示器、缩放比例、全屏覆盖、托盘、开机启动)仍需在真实 Windows 设备上验收。
+- 同班 30 秒限流按班级计算,不做跨班级或跨设备合并。
diff --git a/package.json b/package.json
index 3863ad6..0d0f050 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "classding",
"private": true,
- "version": "0.3.4",
+ "version": "0.3.5",
"type": "module",
"packageManager": "pnpm@11.25.0",
"engines": {
@@ -17,7 +17,7 @@
"check:toolchain": "node scripts/check-toolchain.mjs",
"format": "prettier --write .",
"format:check": "prettier --check .",
- "test": "vitest run screen/frontend/tests web/apps/admin/src/*.test.ts",
+ "test": "vitest run screen/frontend/tests web/apps/admin/src/*.test.ts web/apps/teacher/src/*.test.ts",
"test:e2e": "playwright test",
"api:generate": "node scripts/generate-contract.mjs && pnpm --filter @classding/api-generator generate",
"prepare:static": "node scripts/prepare-static.mjs"
diff --git a/screen/README.md b/screen/README.md
index ba39eec..60bba12 100644
--- a/screen/README.md
+++ b/screen/README.md
@@ -50,6 +50,16 @@ wails build -platform windows/amd64 -nsis -installscope user
作业包含在全站备份中;站点迁移还会核验作业修订号,拒绝明显落后的备份。换站时仍须按发布说明暂停旧站写入并恢复最终备份。
+## 学生呼叫老师
+
+绑定后 Windows 桌面左侧会显示「呼叫老师」原生悬浮入口;主窗口关闭到托盘后仍可使用,不依赖网页中的浮层。拖动可移到其他显示器;松手停在最近显示器工作区内,避开任务栏,按显示器 DPI 调整大小。悬浮入口平时不抢焦点,只有点击展开二次确认时才激活;取消或 Escape 不发送,默认键盘焦点在取消按钮,Tab 可切换到确认按钮。全屏通知、课堂工具展示期间临时隐藏,结束后恢复,不移动或改变已有作业窗。
+
+确认后由原有 WebView service 复用设备认证和 loopback 代理发送 `POST /api/v1/device/student-calls`,Go 原生层不保存另一份密钥。发送中禁止重复提交;成功须收到有效的服务器记录才显示「呼叫已发送」。本班所有关联老师在打开的教师网页收到提醒,任一老师标记已读后共享为已处理。服务端按同班 30 秒限流,客户端显示 `Retry-After` 等待时间。网络异常或响应丢失会显示结果未确认,用户主动重试沿用同一个幂等编号;不会离线排队或自动补发,退出程序后不保留未确认编号。
+
+离线时保留入口供查看明确错误,确认按钮不可发送;解绑清理入口和未完成请求,再绑定不会接收旧绑定结果。请求超时和 WebView 无响应分别有服务请求/原生看门狗超时提示;客户端待命页另有相同二次确认的备用入口,浏览器预览仅提供备用入口,不会创建原生桌面窗口。
+
+待 Windows 实机验收:主窗口关闭/静默托盘状态呼叫、鼠标和触屏点击/拖动边界、取消不发、键盘焦点与 Escape/Tab/Enter、断网及 401/403/429 错误、重试不重复、解绑中止、混合 DPI/负坐标多屏/任务栏工作区变化、全屏通知/课堂工具与作业窗恢复、退出清理 GDI 和窗口资源。交叉编译、Go 测试及浏览器测试不能替代上述实机检查。
+
## 当前未完成
渲染进程守护与日志轮转、自动下载更新、字体子集和语音包检测尚未实现。托盘、静默启动、多显示器及 Wails 程序的 Windows/NSIS 实机测试尚未完成;不得以浏览器预览或交叉编译成功代替实机验收。
diff --git a/screen/app.go b/screen/app.go
index 82b584a..ffa2f83 100644
--- a/screen/app.go
+++ b/screen/app.go
@@ -42,7 +42,8 @@ type App struct {
audioMu sync.Mutex
original *volumeSnapshot
- homework homeworkState
+ homework homeworkState
+ studentCall studentCallState
}
func NewApp() (*App, error) {
@@ -92,6 +93,9 @@ func (a *App) startup(ctx context.Context) {
}
}
if a.initErr == nil {
+ if err := a.startStudentCall(); err != nil {
+ log.Printf("学生呼叫悬浮入口初始化失败: %v", err)
+ }
if err := a.RestoreAudio(); err != nil {
log.Printf("恢复上次音量失败: %v", err)
}
@@ -159,6 +163,7 @@ func (a *App) trayUnavailable() {
}
func (a *App) shutdown(_ context.Context) {
+ a.closeStudentCall()
a.closeHomework()
a.windowMu.Lock()
tray := a.tray
@@ -192,6 +197,7 @@ func (a *App) SetPresentationActive(active bool) error {
}
<-a.initDone
a.suspendHomework(active)
+ a.suspendStudentCall(active)
if !active {
runtime.WindowUnfullscreen(a.ctx)
runtime.WindowSetAlwaysOnTop(a.ctx, false)
diff --git a/screen/frontend/package.json b/screen/frontend/package.json
index ca17bff..9d0af0f 100644
--- a/screen/frontend/package.json
+++ b/screen/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "@classding/screen",
- "version": "0.3.4",
+ "version": "0.3.5",
"private": true,
"type": "module",
"scripts": {
diff --git a/screen/frontend/src/main.tsx b/screen/frontend/src/main.tsx
index b2fdc05..50ebe59 100644
--- a/screen/frontend/src/main.tsx
+++ b/screen/frontend/src/main.tsx
@@ -1,6 +1,14 @@
import React, { useEffect, useRef, useState, useSyncExternalStore } from 'react';
import { createRoot } from 'react-dom/client';
import { NoticeScreen } from '@classding/ui/notice';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@classding/ui/components/ui/dialog';
import { Badge, Brand, Button, Card, Input, Switch } from './ui';
import {
ArrowRight,
@@ -34,6 +42,7 @@ void service.init();
function App() {
const state = useSyncExternalStore(service.subscribe, service.getSnapshot);
const [code, setCode] = useState('');
+ const [callOpen, setCallOpen] = useState(false);
const [deviceName, setDeviceName] = useState('');
const [server, setServer] = useState('');
const [advanced, setAdvanced] = useState(false);
@@ -363,6 +372,45 @@ function App() {
+
+ 需要老师来教室?
+ 向本班所有关联老师发送呼叫。Windows 关闭主窗口后仍可使用桌面悬浮入口。
+
+ {state.studentCallMessage && {state.studentCallMessage}
}
+
+
diff --git a/screen/frontend/src/native.ts b/screen/frontend/src/native.ts
index 13c3b1b..7570d73 100644
--- a/screen/frontend/src/native.ts
+++ b/screen/frontend/src/native.ts
@@ -13,6 +13,8 @@ export interface HomeworkSettings {
preferences: Record
;
}
export interface NativeApp {
+ UpdateStudentCallStatus(bound: boolean, online: boolean, className: string, binding: number): Promise;
+ CompleteStudentCall(id: string, success: boolean, message: string): Promise;
UpdateHomeworks(value: string): Promise;
GetHomeworkSettings(): Promise;
SetHomeworkPreference(id: string, action: string): Promise;
@@ -42,7 +44,8 @@ declare global {
};
}
}
-export const native = (): NativeApp | undefined => window.go?.main?.App;
+export const native = (): NativeApp | undefined =>
+ typeof window === 'undefined' ? undefined : window.go?.main?.App;
const KEY = 'classding.screen.state.v1';
export async function loadState(): Promise {
return native() ? await native()!.LoadState() : localStorage.getItem(KEY) || '';
diff --git a/screen/frontend/src/service.ts b/screen/frontend/src/service.ts
index ba045bc..173d1fe 100644
--- a/screen/frontend/src/service.ts
+++ b/screen/frontend/src/service.ts
@@ -1,6 +1,6 @@
import type { components, paths } from '@classding/api/types';
import type { AckStatus, Notice, WSFrame } from '@classding/api/ws';
-import { configureServer, getLocalAddresses, loadState, normalizeServer, saveState } from './native';
+import { configureServer, getLocalAddresses, loadState, normalizeServer, saveState, native } from './native';
import { readHomeworks, type Homework } from './homework';
import { playChime, playNotice } from './audio';
import { APP_VERSION } from '@classding/ui/version';
@@ -53,6 +53,8 @@ export type Tool =
| { name: 'rollcall'; names: string[]; selected: string; ends: number }
| null;
export interface Snapshot {
+ studentCallSending: boolean;
+ studentCallMessage: string;
homeworks: Homework[];
ready: boolean;
busy: boolean;
@@ -163,6 +165,8 @@ const remoteConfig = (value: unknown): RemoteConfig | undefined => {
export class ScreenService {
private state = fresh();
private view: Snapshot = {
+ studentCallSending: false,
+ studentCallMessage: '',
homeworks: [],
ready: false,
busy: false,
@@ -198,6 +202,12 @@ export class ScreenService {
private suppressStarts = false;
private generation = 0;
private disposed = false;
+ private bindingEpoch = 0;
+ private callController?: AbortController;
+ private callID = '';
+ private callOff?: () => void;
+ private callNativeQueue: Promise = Promise.resolve();
+ private callNativeKey = '';
subscribe = (fn: () => void) => {
this.listeners.add(fn);
return () => {
@@ -219,6 +229,7 @@ export class ScreenService {
pending: Math.max(0, this.state.queue.length - (current ? 1 : 0)),
};
this.listeners.forEach((fn) => fn());
+ this.syncStudentCall();
}
private mutate(change: () => void): Promise {
const operation = this.ops.then(async () => {
@@ -257,6 +268,19 @@ export class ScreenService {
}
}
async init() {
+ this.callOff = window.runtime?.EventsOnMultiple(
+ 'student-call:send',
+ (id, binding) => {
+ if (typeof id !== 'string' || binding !== this.bindingEpoch) return;
+ void this.sendStudentCall(id)
+ .then(
+ () => native()?.CompleteStudentCall(id, true, '呼叫已发送,本班老师打开网页即可收到提醒'),
+ (error) => native()?.CompleteStudentCall(id, false, errorText(error)),
+ )
+ .catch(() => {});
+ },
+ -1,
+ );
try {
const raw = await loadState();
if (raw) {
@@ -331,6 +355,7 @@ export class ScreenService {
)
throw new Error('服务器返回的绑定信息不完整');
serverAccepted = true;
+ this.resetStudentCall();
await this.mutate(() => {
this.state = {
...fresh(),
@@ -899,6 +924,96 @@ export class ScreenService {
}
return `${this.base}${url.startsWith('/') ? '' : '/'}${url}`;
}
+ private syncStudentCall() {
+ if (this.disposed || !native()) return;
+ const { bound, connection, className } = this.view;
+ const online = connection === 'online';
+ const epoch = this.bindingEpoch;
+ const key = JSON.stringify([bound, online, className, epoch]);
+ if (key === this.callNativeKey) return;
+ this.callNativeKey = key;
+ this.callNativeQueue = this.callNativeQueue
+ .catch(() => {})
+ .then(async () => {
+ if (!this.disposed) await native()?.UpdateStudentCallStatus(bound, online, className, epoch);
+ })
+ .catch((error) => {
+ this.emit({ error: `学生呼叫入口更新失败:${errorText(error)}` });
+ });
+ }
+ private resetStudentCall() {
+ this.bindingEpoch++;
+ this.callController?.abort();
+ this.callController = undefined;
+ this.callID = '';
+ this.emit({ studentCallSending: false, studentCallMessage: '' });
+ }
+ // Retry an uncertain request with its original id, never queue calls offline.
+ async sendStudentCall(requestID?: string): Promise {
+ if (this.disposed || !this.state.token) throw new Error('设备未绑定,请重新绑定后呼叫');
+ if (this.view.studentCallSending) throw new Error('呼叫正在发送,请勿重复点击');
+ if (this.view.connection !== 'online') throw new Error('连接中断,呼叫未发送。请恢复网络后重试');
+ const id = this.callID || requestID || crypto.randomUUID();
+ if (!/^[A-Za-z0-9_.:-]{1,128}$/.test(id)) throw new Error('呼叫编号无效');
+ this.callID = id;
+ const epoch = this.bindingEpoch;
+ const controller = new AbortController();
+ this.callController = controller;
+ this.emit({ studentCallSending: true, studentCallMessage: '正在发送,请稍候…' });
+ const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
+ try {
+ const response = await this.request('/api/v1/device/student-calls', {
+ method: 'POST',
+ headers: { Authorization: `Bearer ${this.state.token}`, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ request_id: id }),
+ signal: controller.signal,
+ });
+ const data = (await response.json().catch(() => ({}))) as Record;
+ if (epoch !== this.bindingEpoch || this.disposed) throw new Error('设备绑定已变化,呼叫结果不再适用');
+ if (!response.ok) {
+ if (response.status === 401) {
+ await this.unbind();
+ throw new Error('设备已失效或被解绑,请重新绑定');
+ }
+ if (response.status === 403) throw new Error('设备授权不可用,请联系学校管理员');
+ if (response.status === 429) {
+ this.callID = '';
+ const seconds = response.headers?.get('Retry-After');
+ throw new Error(
+ seconds && /^\d+$/.test(seconds)
+ ? '本班刚刚发起过呼叫,请在 ' + seconds + ' 秒后重试'
+ : '本班刚刚发起过呼叫,请稍后重试',
+ );
+ }
+ if (response.status === 409) this.callID = '';
+ throw new Error(
+ typeof data.message === 'string' ? data.message : '发送失败(' + response.status + '),请重试',
+ );
+ }
+ if (
+ typeof data.id !== 'string' ||
+ !data.id ||
+ data.class_id !== this.state.classId ||
+ data.device_id !== this.state.deviceId ||
+ typeof data.created_at !== 'string' ||
+ !Number.isFinite(Date.parse(data.created_at))
+ )
+ throw new Error('服务器未确认有效呼叫,结果未知,请重试(不会重复创建同一呼叫)');
+ this.callID = '';
+ this.emit({ studentCallMessage: '呼叫已发送,本班老师打开网页即可收到提醒' });
+ } catch (error) {
+ const message =
+ abortError(error) || error instanceof TypeError
+ ? '网络异常,发送结果未确认,请重试(不会重复创建同一呼叫)'
+ : errorText(error);
+ if (epoch === this.bindingEpoch && !this.disposed) this.emit({ studentCallMessage: message });
+ throw new Error(message);
+ } finally {
+ clearTimeout(timeout);
+ if (this.callController === controller) this.callController = undefined;
+ if (epoch === this.bindingEpoch && !this.disposed) this.emit({ studentCallSending: false });
+ }
+ }
async dismiss() {
const id = this.view.current?.message_id;
if (!id) return;
@@ -914,6 +1029,7 @@ export class ScreenService {
}
// Stops playback, timers and the socket; the next connect starts fresh.
private halt() {
+ this.resetStudentCall();
this.generation++;
this.active?.abort();
clearTimeout(this.retry);
@@ -946,6 +1062,8 @@ export class ScreenService {
}
dispose() {
this.disposed = true;
+ this.callOff?.();
+ this.callOff = undefined;
this.halt();
}
}
diff --git a/screen/frontend/src/ui.css b/screen/frontend/src/ui.css
index cb0e820..519777a 100644
--- a/screen/frontend/src/ui.css
+++ b/screen/frontend/src/ui.css
@@ -56,6 +56,65 @@ button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
+
+/* Base UI dialog primitives are shared with apps that compile Tailwind utilities.
+ The screen bundle intentionally keeps its own small modal layout so the native
+ fallback confirmation remains usable when the main window is still visible. */
+[data-slot='dialog-overlay'] {
+ position: fixed;
+ inset: 0;
+ z-index: 50;
+ isolation: isolate;
+ background: rgb(38 55 46 / 18%);
+}
+
+[data-slot='dialog-content'] {
+ position: fixed;
+ top: 50%;
+ left: 50%;
+ z-index: 51;
+ display: grid;
+ width: calc(100% - 2rem);
+ max-width: 24rem;
+ gap: 1rem;
+ padding: 1rem;
+ transform: translate(-50%, -50%);
+ border: 1px solid var(--cd-border);
+ border-radius: 0.75rem;
+ background: var(--cd-panel);
+ color: var(--cd-text);
+ box-shadow: 0 18px 48px rgb(38 55 46 / 20%);
+ outline: none;
+}
+
+[data-slot='dialog-content'] [data-slot='dialog-header'] {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+[data-slot='dialog-content'] [data-slot='dialog-footer'] {
+ display: flex;
+ justify-content: flex-end;
+ gap: 0.5rem;
+ margin: 0 -1rem -1rem;
+ padding: 0.75rem 1rem;
+ border-top: 1px solid var(--cd-border);
+ border-radius: 0 0 0.75rem 0.75rem;
+ background: var(--cd-soft);
+}
+
+[data-slot='dialog-content'] > [data-slot='dialog-close'] {
+ position: absolute;
+ top: 0.5rem;
+ right: 0.5rem;
+}
+
+@media (min-width: 640px) {
+ [data-slot='dialog-content'] {
+ max-width: 24rem;
+ }
+}
a {
color: inherit;
text-decoration: none;
diff --git a/screen/frontend/tests/native-student-call.test.ts b/screen/frontend/tests/native-student-call.test.ts
new file mode 100644
index 0000000..9047641
--- /dev/null
+++ b/screen/frontend/tests/native-student-call.test.ts
@@ -0,0 +1,159 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+const mocks = vi.hoisted(() => ({
+ disk: JSON.stringify({
+ version: 1,
+ token: 'student-call-bridge-token',
+ deviceId: 'd1',
+ classId: 'c1',
+ deviceName: '教室',
+ className: '三年二班',
+ server: 'http://localhost:18080',
+ volume: 70,
+ lastSeq: 0,
+ queue: [],
+ ledger: {},
+ }),
+ configure: vi.fn(async (server: string) => server),
+}));
+
+vi.mock('../src/native', async (importOriginal) => ({
+ ...(await importOriginal()),
+ loadState: async () => mocks.disk,
+ saveState: async (value: string) => {
+ mocks.disk = value;
+ },
+ configureServer: mocks.configure,
+}));
+
+import { ScreenService } from '../src/service';
+
+class Socket {
+ static OPEN = 1;
+ static instances: Socket[] = [];
+ readyState = 1;
+ onopen?: () => void;
+ onmessage?: (event: { data: string }) => void;
+ onclose?: () => void;
+ onerror?: () => void;
+ constructor(public url: string) {
+ Socket.instances.push(this);
+ }
+ send(_text: string) {}
+ close() {
+ this.onclose?.();
+ }
+ frame(value: object) {
+ this.onmessage?.({ data: JSON.stringify(value) });
+ }
+}
+
+const welcome = { t: 'welcome', d: { class: { id: 'c1', name: '三年二班' }, volume: 70 } };
+let services: ScreenService[] = [];
+let eventHandler: ((...args: unknown[]) => void) | undefined;
+let eventOff: ReturnType;
+let app: {
+ UpdateStudentCallStatus: ReturnType;
+ CompleteStudentCall: ReturnType;
+};
+
+beforeEach(() => {
+ mocks.disk = JSON.stringify({
+ version: 1,
+ token: 'student-call-bridge-token',
+ deviceId: 'd1',
+ classId: 'c1',
+ deviceName: '教室',
+ className: '三年二班',
+ server: 'http://localhost:18080',
+ volume: 70,
+ lastSeq: 0,
+ queue: [],
+ ledger: {},
+ });
+ mocks.configure.mockClear();
+ Socket.instances = [];
+ services = [];
+ eventHandler = undefined;
+ eventOff = vi.fn();
+ app = {
+ UpdateStudentCallStatus: vi.fn(async () => {}),
+ CompleteStudentCall: vi.fn(async () => {}),
+ };
+ vi.stubGlobal('window', {
+ location: { origin: 'http://localhost:5175', href: 'http://localhost:5175/' },
+ go: { main: { App: app } },
+ runtime: {
+ EventsOnMultiple: vi.fn((_name: string, callback: (...args: unknown[]) => void) => {
+ eventHandler = callback;
+ return eventOff;
+ }),
+ },
+ });
+ vi.stubGlobal('WebSocket', Socket);
+});
+
+afterEach(() => {
+ services.forEach((service) => service.dispose());
+ vi.unstubAllGlobals();
+});
+
+async function online() {
+ const service = new ScreenService();
+ services.push(service);
+ await service.init();
+ Socket.instances.at(-1)!.frame(welcome);
+ await vi.waitFor(() => expect(service.getSnapshot().connection).toBe('online'));
+ await vi.waitFor(() => expect(app.UpdateStudentCallStatus).toHaveBeenCalled());
+ return service;
+}
+
+function response(data: unknown, status = 200) {
+ return { ok: status < 400, status, json: async () => data, headers: new Headers() } as Response;
+}
+
+describe('学生呼叫原生桥接', () => {
+ it('注册事件、拒绝旧绑定 epoch,并回传发送结果', async () => {
+ const service = await online();
+ expect(eventHandler).toBeDefined();
+ expect(eventOff).not.toHaveBeenCalled();
+
+ const request = vi.fn().mockResolvedValue({
+ ...response({ id: 'call1', class_id: 'c1', device_id: 'd1', created_at: '2026-11-13T03:00:00Z' }),
+ });
+ vi.stubGlobal('fetch', request);
+
+ eventHandler!('stale-id', 99);
+ await Promise.resolve();
+ expect(request).not.toHaveBeenCalled();
+ expect(app.CompleteStudentCall).not.toHaveBeenCalled();
+
+ eventHandler!('native-call-id', 0);
+ await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(1));
+ await vi.waitFor(() =>
+ expect(app.CompleteStudentCall).toHaveBeenCalledWith(
+ 'native-call-id',
+ true,
+ '呼叫已发送,本班老师打开网页即可收到提醒',
+ ),
+ );
+ expect(JSON.parse(request.mock.calls[0][1].body).request_id).toBe('native-call-id');
+ });
+
+ it('解绑使旧epoch事件失效,dispose移除监听', async () => {
+ const service = await online();
+ const request = vi.fn();
+ vi.stubGlobal('fetch', request);
+ const callback = eventHandler!;
+ await service.unbind();
+ callback('old-binding', 0);
+ await Promise.resolve();
+ expect(request).not.toHaveBeenCalled();
+ expect(eventOff).not.toHaveBeenCalled();
+ service.dispose();
+ expect(eventOff).toHaveBeenCalledTimes(1);
+ callback('after-dispose', 0);
+ await Promise.resolve();
+ expect(request).not.toHaveBeenCalled();
+ });
+});
diff --git a/screen/frontend/tests/service.test.ts b/screen/frontend/tests/service.test.ts
index 8b28ef2..69e6037 100644
--- a/screen/frontend/tests/service.test.ts
+++ b/screen/frontend/tests/service.test.ts
@@ -97,6 +97,138 @@ afterEach(() => {
vi.unstubAllGlobals();
});
+describe('学生呼叫', () => {
+ const result = { id: 'call1', class_id: 'c1', device_id: 'd1', created_at: '2026-11-13T03:00:00Z' };
+ async function online() {
+ mocks.disk = JSON.stringify({
+ version: 1,
+ token: 'student-call-test-token',
+ deviceId: 'd1',
+ classId: 'c1',
+ deviceName: '教室',
+ className: '三年二班',
+ volume: 70,
+ lastSeq: 0,
+ queue: [],
+ ledger: {},
+ });
+ const service = makeService();
+ await service.init();
+ socket().frame(welcome);
+ await vi.waitFor(() => expect(service.getSnapshot().connection).toBe('online'));
+ return service;
+ }
+ function response(data: unknown, status = 200, retry?: string) {
+ return {
+ ok: status < 400,
+ status,
+ json: async () => data,
+ headers: new Headers(retry ? { 'Retry-After': retry } : {}),
+ } as Response;
+ }
+ it('未绑定和离线不发送请求', async () => {
+ const request = vi.fn();
+ vi.stubGlobal('fetch', request);
+ const service = makeService();
+ await service.init();
+ await expect(service.sendStudentCall()).rejects.toThrow('未绑定');
+ const bound = await online();
+ socket().close();
+ await expect(bound.sendStudentCall()).rejects.toThrow('连接中断');
+ expect(request.mock.calls.filter(([path]) => String(path).includes('student-calls'))).toHaveLength(0);
+ });
+ it('复用设备认证并阻止发送中重复,成功必须收到有效记录', async () => {
+ const service = await online();
+ let resolve!: (value: Response) => void;
+ const request = vi.fn(
+ (_path: string, _init?: RequestInit) =>
+ new Promise((done) => {
+ resolve = done;
+ }),
+ );
+ vi.stubGlobal('fetch', request);
+ const sending = service.sendStudentCall('native-id');
+ await expect(service.sendStudentCall()).rejects.toThrow('正在发送');
+ expect(request).toHaveBeenCalledWith(
+ '/api/v1/device/student-calls',
+ expect.objectContaining({
+ method: 'POST',
+ headers: { Authorization: 'Bearer student-call-test-token', 'Content-Type': 'application/json' },
+ body: JSON.stringify({ request_id: 'native-id' }),
+ }),
+ );
+ resolve(response(result));
+ await sending;
+ expect(service.getSnapshot()).toMatchObject({
+ studentCallSending: false,
+ studentCallMessage: expect.stringContaining('已发送'),
+ });
+ });
+ it('不确定失败保留幂等编号,跨备用入口重试不创建新请求', async () => {
+ const service = await online();
+ const request = vi
+ .fn()
+ .mockRejectedValueOnce(new TypeError('offline'))
+ .mockResolvedValue(response(result));
+ vi.stubGlobal('fetch', request);
+ await expect(service.sendStudentCall('original-id')).rejects.toThrow('未确认');
+ await service.sendStudentCall('another-native-id');
+ expect(request.mock.calls.map(([, init]) => JSON.parse(init.body).request_id)).toEqual([
+ 'original-id',
+ 'original-id',
+ ]);
+ });
+ it('限流显示等待秒数,不假成功;无效成功响应可重试', async () => {
+ const service = await online();
+ const request = vi
+ .fn()
+ .mockResolvedValueOnce(response({}, 429, '27'))
+ .mockResolvedValueOnce(response({}))
+ .mockResolvedValue(response(result));
+ vi.stubGlobal('fetch', request);
+ await expect(service.sendStudentCall('rate-id')).rejects.toThrow('27 秒');
+ await expect(service.sendStudentCall('new-id')).rejects.toThrow('未确认有效呼叫');
+ await service.sendStudentCall('ignore-id');
+ expect(request.mock.calls.map(([, init]) => JSON.parse(init.body).request_id)).toEqual([
+ 'rate-id',
+ 'new-id',
+ 'new-id',
+ ]);
+ });
+ it('解绑中止请求并拒绝旧成功写回', async () => {
+ const service = await online();
+ let resolve!: (value: Response) => void;
+ let signal: AbortSignal | undefined;
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn((_path: string, init: RequestInit) => {
+ signal = init.signal!;
+ return new Promise((done) => {
+ resolve = done;
+ });
+ }),
+ );
+ const pending = service.sendStudentCall('old-binding');
+ await service.unbind();
+ expect(signal?.aborted).toBe(true);
+ resolve(response(result));
+ await expect(pending).rejects.toThrow('绑定已变化');
+ expect(service.getSnapshot()).toMatchObject({
+ bound: false,
+ studentCallSending: false,
+ studentCallMessage: '',
+ });
+ });
+ it('401 清除失效绑定,403 明确显示授权不可用', async () => {
+ const service = await online();
+ const request = vi.fn().mockResolvedValueOnce(response({}, 403)).mockResolvedValue(response({}, 401));
+ vi.stubGlobal('fetch', request);
+ await expect(service.sendStudentCall('license-id')).rejects.toThrow('授权不可用');
+ await expect(service.sendStudentCall()).rejects.toThrow('已失效');
+ expect(service.getSnapshot().bound).toBe(false);
+ });
+});
+
describe('大屏消息可靠性', () => {
it('忽略格式无效的消息封装,并继续接收合法通知', async () => {
mocks.disk = JSON.stringify({
diff --git a/screen/student_call.go b/screen/student_call.go
new file mode 100644
index 0000000..113469d
--- /dev/null
+++ b/screen/student_call.go
@@ -0,0 +1,181 @@
+package main
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "errors"
+ "sync"
+ "time"
+
+ "github.com/wailsapp/wails/v2/pkg/runtime"
+)
+
+// No credentials cross this bridge. The existing WebView service performs the
+// authenticated request through its configured loopback proxy, even in the tray.
+type studentCallView struct {
+ Bound, Online, Suspended, Sending bool
+ ClassName, Message string
+ Binding, Revision int
+}
+type studentCallWindow interface {
+ update(studentCallView) error
+ close()
+}
+type studentCallState struct {
+ mu sync.Mutex
+ window studentCallWindow
+ initErr error
+ view studentCallView
+ requestID string
+ binding int
+ timer *time.Timer
+ closed bool
+ emit func(string, int)
+}
+
+func (a *App) startStudentCall() error {
+ a.studentCall.mu.Lock()
+ defer a.studentCall.mu.Unlock()
+ a.studentCall.emit = func(id string, binding int) {
+ if a.ctx != nil {
+ runtime.EventsEmit(a.ctx, "student-call:send", id, binding)
+ }
+ }
+ window, err := newStudentCallWindow(a.beginStudentCall)
+ if err != nil {
+ a.studentCall.initErr = err
+ return err
+ }
+ a.studentCall.window = window
+ return window.update(a.studentCall.view)
+}
+
+// UpdateStudentCallStatus receives non-secret binding/connectivity metadata.
+func (a *App) UpdateStudentCallStatus(bound, online bool, className string, binding int) error {
+ if err := a.ready(); err != nil {
+ return err
+ }
+ s := &a.studentCall
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.closed {
+ return errors.New("学生呼叫已关闭")
+ }
+ if s.view.Bound != bound || s.view.ClassName != className || s.binding != binding {
+ if s.timer != nil {
+ s.timer.Stop()
+ s.timer = nil
+ }
+ s.requestID = ""
+ s.view.Sending = false
+ s.view.Message = ""
+ }
+ s.binding, s.view.Binding = binding, binding
+ s.view.Bound, s.view.Online, s.view.ClassName = bound, online, className
+ return s.renderLocked()
+}
+func (s *studentCallState) renderLocked() error {
+ s.view.Revision++
+ if s.window != nil {
+ return s.window.update(s.view)
+ }
+ return s.initErr
+}
+func (a *App) beginStudentCall(binding int) {
+ s := &a.studentCall
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.closed || s.view.Sending || s.view.Suspended || binding != s.binding {
+ return
+ }
+ if !s.view.Bound {
+ s.view.Message = "设备未绑定,请打开课堂叮咚重新绑定"
+ _ = s.renderLocked()
+ return
+ }
+ if !s.view.Online {
+ s.view.Message = "连接中断,呼叫未发送。请恢复网络后重试"
+ _ = s.renderLocked()
+ return
+ }
+ if s.requestID == "" {
+ var id [16]byte
+ if _, err := rand.Read(id[:]); err != nil {
+ s.view.Message = "无法生成呼叫编号,请重试"
+ _ = s.renderLocked()
+ return
+ }
+ s.requestID = hex.EncodeToString(id[:])
+ }
+ id := s.requestID
+ s.view.Sending, s.view.Message = true, "正在发送,请稍候…"
+ _ = s.renderLocked()
+ // This also covers a stalled/crashed WebView. JS retains the canonical request id.
+ s.timer = time.AfterFunc(12*time.Second, func() {
+ a.finishStudentCall(id, false, "发送结果未确认,请重试(不会重复创建同一呼叫)")
+ })
+ if s.emit != nil {
+ s.emit(id, binding)
+ }
+}
+func (a *App) CompleteStudentCall(id string, success bool, message string) {
+ a.finishStudentCall(id, success, message)
+}
+func (a *App) finishStudentCall(id string, success bool, message string) {
+ s := &a.studentCall
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.closed || id == "" || id != s.requestID {
+ return
+ }
+ if s.timer != nil {
+ s.timer.Stop()
+ s.timer = nil
+ }
+ s.view.Sending = false
+ s.view.Message = message
+ // The JS service owns the canonical idempotency key across uncertain retries.
+ // This id only correlates this native attempt, so stale callbacks cannot finish a retry.
+ s.requestID = ""
+ _ = s.renderLocked()
+}
+func (a *App) suspendStudentCall(active bool) {
+ s := &a.studentCall
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.view.Suspended = active
+ _ = s.renderLocked()
+}
+func (a *App) closeStudentCall() {
+ s := &a.studentCall
+ s.mu.Lock()
+ s.closed = true
+ s.emit = nil
+ if s.timer != nil {
+ s.timer.Stop()
+ s.timer = nil
+ }
+ window := s.window
+ s.window = nil
+ s.mu.Unlock()
+ // Never wait for the UI thread while holding the callback/state mutex.
+ if window != nil {
+ window.close()
+ }
+}
+
+// A suspend/unbind can win the state mutex before a native confirmation.
+// It must clear the native optimistic spinner even when begin exits without emitting.
+func studentCallAcknowledged(previous, next studentCallView, sentRevision int) bool {
+ return next.Suspended || !next.Bound || next.Binding != previous.Binding ||
+ (next.Revision > sentRevision && (next.Sending || next.Message != ""))
+}
+
+// Geometry is in physical pixels, including negative monitor origins.
+func studentCallClamp(x, y, width, height, left, top, right, bottom int) (int, int) {
+ return max(left, min(x, right-width)), max(top, min(y, bottom-height))
+}
+func studentCallDragged(dx, dy, dpi int) bool {
+ threshold := max(4, 6*dpi/96)
+ return dx*dx+dy*dy >= threshold*threshold
+}
diff --git a/screen/student_call_other.go b/screen/student_call_other.go
new file mode 100644
index 0000000..73f7ff9
--- /dev/null
+++ b/screen/student_call_other.go
@@ -0,0 +1,11 @@
+//go:build !windows
+
+package main
+
+type noopStudentCallWindow struct{}
+
+func newStudentCallWindow(_ func(int)) (studentCallWindow, error) {
+ return noopStudentCallWindow{}, nil
+}
+func (noopStudentCallWindow) update(studentCallView) error { return nil }
+func (noopStudentCallWindow) close() {}
diff --git a/screen/student_call_test.go b/screen/student_call_test.go
new file mode 100644
index 0000000..0604c12
--- /dev/null
+++ b/screen/student_call_test.go
@@ -0,0 +1,131 @@
+package main
+
+import (
+ "strings"
+ "sync"
+ "testing"
+)
+
+type callTestWindow struct {
+ mu sync.Mutex
+ latest studentCallView
+ closed bool
+}
+
+func (w *callTestWindow) update(v studentCallView) error {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ w.latest = v
+ return nil
+}
+func (w *callTestWindow) close() { w.mu.Lock(); defer w.mu.Unlock(); w.closed = true }
+func callTestApp(t *testing.T) (*App, *[]string) {
+ t.Helper()
+ ready := make(chan struct{})
+ close(ready)
+ ids := []string{}
+ a := &App{initDone: ready}
+ a.studentCall.window = &callTestWindow{}
+ a.studentCall.emit = func(id string, _ int) { ids = append(ids, id) }
+ t.Cleanup(a.closeStudentCall)
+ return a, &ids
+}
+func TestStudentCallGatesAndCompletion(t *testing.T) {
+ a, ids := callTestApp(t)
+ a.beginStudentCall(0)
+ if len(*ids) != 0 || !strings.Contains(a.studentCall.view.Message, "未绑定") {
+ t.Fatal("unbound call accepted")
+ }
+ _ = a.UpdateStudentCallStatus(true, false, "同名班", 1)
+ a.beginStudentCall(1)
+ if len(*ids) != 0 || !strings.Contains(a.studentCall.view.Message, "连接中断") {
+ t.Fatal("offline call accepted")
+ }
+ _ = a.UpdateStudentCallStatus(true, true, "同名班", 1)
+ a.beginStudentCall(0)
+ if len(*ids) != 0 {
+ t.Fatal("stale confirmation accepted")
+ }
+ a.beginStudentCall(1)
+ a.beginStudentCall(1)
+ if len(*ids) != 1 || len((*ids)[0]) != 32 {
+ t.Fatal("missing duplicate guard or invalid id")
+ }
+ id := (*ids)[0]
+ a.CompleteStudentCall("stale", true, "wrong")
+ if !a.studentCall.view.Sending {
+ t.Fatal("stale callback applied")
+ }
+ a.CompleteStudentCall(id, false, "失败可见")
+ if a.studentCall.view.Sending || a.studentCall.view.Message != "失败可见" {
+ t.Fatal("failure not rendered")
+ }
+ a.beginStudentCall(1)
+ if len(*ids) != 2 || (*ids)[1] == id {
+ t.Fatal("native attempt correlation id not renewed")
+ }
+ a.CompleteStudentCall(id, true, "late")
+ if !a.studentCall.view.Sending {
+ t.Fatal("previous attempt completed new retry")
+ }
+ a.CompleteStudentCall((*ids)[1], true, "成功可见")
+ if a.studentCall.view.Sending || a.studentCall.view.Message != "成功可见" {
+ t.Fatal("success not rendered")
+ }
+}
+func TestStudentCallPendingSnapshot(t *testing.T) {
+ previous := studentCallView{Bound: true, Binding: 1, Revision: 3}
+ for _, next := range []studentCallView{
+ {Bound: true, Binding: 1, Revision: 4, Suspended: true},
+ {Bound: false, Binding: 1, Revision: 4},
+ {Bound: true, Binding: 2, Revision: 4},
+ {Bound: true, Binding: 1, Revision: 4, Sending: true},
+ {Bound: true, Binding: 1, Revision: 4, Message: "done"},
+ } {
+ if !studentCallAcknowledged(previous, next, 3) {
+ t.Fatalf("native pending spinner not cleared by %#v", next)
+ }
+ }
+ if studentCallAcknowledged(previous, previous, 3) {
+ t.Fatal("stale pre-send snapshot cleared native pending guard")
+ }
+}
+
+func TestStudentCallRebindSuspendClose(t *testing.T) {
+ a, ids := callTestApp(t)
+ _ = a.UpdateStudentCallStatus(true, true, "同名班", 1)
+ a.suspendStudentCall(true)
+ a.beginStudentCall(1)
+ if len(*ids) != 0 {
+ t.Fatal("presentation accepted call")
+ }
+ a.suspendStudentCall(false)
+ a.beginStudentCall(1)
+ id := (*ids)[0]
+ _ = a.UpdateStudentCallStatus(true, true, "同名班", 2)
+ a.CompleteStudentCall(id, true, "old")
+ if a.studentCall.view.Sending || a.studentCall.view.Message != "" {
+ t.Fatal("same-name rebind reused result")
+ }
+ a.closeStudentCall()
+ a.beginStudentCall(2)
+ if len(*ids) != 1 {
+ t.Fatal("closed manager emitted")
+ }
+ if a.UpdateStudentCallStatus(true, true, "x", 3) == nil {
+ t.Fatal("closed manager accepted state")
+ }
+}
+func TestStudentCallPhysicalGeometry(t *testing.T) {
+ x, y := studentCallClamp(-2200, 1200, 100, 80, -1920, -200, 0, 1000)
+ if x != -1920 || y != 920 {
+ t.Fatalf("negative monitor clamp: %d,%d", x, y)
+ }
+ x, y = studentCallClamp(2100, -100, 120, 80, 1920, 0, 3840, 1080)
+ if x != 2100 || y != 0 {
+ t.Fatalf("second workarea clamp: %d,%d", x, y)
+ }
+ if studentCallDragged(3, 2, 96) || !studentCallDragged(6, 0, 96) || studentCallDragged(8, 0, 192) || !studentCallDragged(12, 0, 192) {
+ t.Fatal("DPI drag threshold")
+ }
+}
diff --git a/screen/student_call_windows.go b/screen/student_call_windows.go
new file mode 100644
index 0000000..eaf19a9
--- /dev/null
+++ b/screen/student_call_windows.go
@@ -0,0 +1,380 @@
+//go:build windows
+
+package main
+
+import (
+ "errors"
+ "fmt"
+ "runtime"
+ "sync"
+ "syscall"
+ "unsafe"
+)
+
+const scUpdate = wmApp + 61
+
+var scWindows sync.Map
+var scProc = syscall.NewCallback(studentCallWndProc)
+
+type nativeStudentCallWindow struct {
+ mu sync.Mutex
+ pending studentCallView
+ closed bool
+ hwnd uintptr
+ done chan struct{}
+ onConfirm func(int)
+ // All fields below are owned by the native UI thread.
+ view studentCallView
+ expanded, down, dragged, positioned bool
+ origin, cursor trayPoint
+ x, y, width, height, dpi int
+ confirm, cancel uintptr
+ awaiting bool
+ sentRevision int
+ buttonFont uintptr
+ fontDPI int
+}
+
+func newStudentCallWindow(onConfirm func(int)) (studentCallWindow, error) {
+ m := &nativeStudentCallWindow{done: make(chan struct{}), onConfirm: onConfirm, dpi: 96}
+ ready := make(chan error, 1)
+ go m.run(ready)
+ if err := <-ready; err != nil {
+ return nil, err
+ }
+ return m, nil
+}
+func (m *nativeStudentCallWindow) run(ready chan error) {
+ runtime.LockOSThread()
+ defer runtime.UnlockOSThread()
+ defer close(m.done)
+ name := syscall.StringToUTF16Ptr("ClassDingStudentCall")
+ title := syscall.StringToUTF16Ptr("呼叫老师 · 课堂叮咚")
+ instance, _, _ := getModuleHandle.Call(0)
+ arrow, _, _ := hwLoadCursor.Call(0, 32512)
+ class := trayWindowClass{size: uint32(unsafe.Sizeof(trayWindowClass{})), windowProc: scProc, instance: instance, cursor: arrow, className: uintptr(unsafe.Pointer(name))}
+ if atom, _, err := registerClassEx.Call(uintptr(unsafe.Pointer(&class))); atom == 0 {
+ ready <- fmt.Errorf("无法注册呼叫窗口:%w", err)
+ return
+ }
+ defer unregisterClass.Call(uintptr(unsafe.Pointer(name)), instance)
+ hwnd, _, err := createWindowEx.Call(0x80|0x08000000, uintptr(unsafe.Pointer(name)), uintptr(unsafe.Pointer(title)), 0x80000000, 0, 0, 80, 64, 0, 0, instance, 0) // TOOLWINDOW | NOACTIVATE / POPUP
+ if hwnd == 0 {
+ ready <- fmt.Errorf("无法创建呼叫窗口:%w", err)
+ return
+ }
+ m.hwnd = hwnd
+ scWindows.Store(hwnd, m)
+ defer scWindows.Delete(hwnd)
+ button := syscall.StringToUTF16Ptr("BUTTON")
+ for i, label := range []string{"取消", "确认呼叫"} {
+ text := syscall.StringToUTF16Ptr(label)
+ child, _, _ := createWindowEx.Call(0, uintptr(unsafe.Pointer(button)), uintptr(unsafe.Pointer(text)), 0x40000000|0x10000, 0, 0, 0, 0, hwnd, uintptr(i+1), instance, 0) // CHILD | TABSTOP
+ if child == 0 {
+ destroyWindow.Call(hwnd)
+ ready <- errors.New("无法创建呼叫确认按钮")
+ return
+ }
+ if i == 0 {
+ m.cancel = child
+ } else {
+ m.confirm = child
+ }
+ }
+ defer destroyWindow.Call(hwnd)
+ defer func() {
+ if m.buttonFont != 0 {
+ hwDeleteObject.Call(m.buttonFont)
+ }
+ }()
+ hwSetTimer.Call(hwnd, 1, 2000, 0)
+ ready <- nil
+ var msg trayMessageData
+ for {
+ result, _, _ := getMessage.Call(uintptr(unsafe.Pointer(&msg)), 0, 0, 0)
+ if int32(result) <= 0 {
+ break
+ }
+ if msg.message == 0x0100 && m.expanded { // keyboard applies to native child buttons too
+ switch msg.wParam {
+ case 27:
+ m.collapse()
+ continue
+ case 13:
+ focus, _, _ := user32.NewProc("GetFocus").Call()
+ if focus == m.confirm {
+ m.send()
+ } else {
+ m.collapse()
+ }
+ continue
+ case 9:
+ focus, _, _ := user32.NewProc("GetFocus").Call()
+ next := m.cancel
+ if focus == m.cancel && !m.view.Sending {
+ next = m.confirm
+ }
+ user32.NewProc("SetFocus").Call(next)
+ continue
+ }
+ }
+ translateMessage.Call(uintptr(unsafe.Pointer(&msg)))
+ dispatchMessage.Call(uintptr(unsafe.Pointer(&msg)))
+ }
+}
+func (m *nativeStudentCallWindow) update(view studentCallView) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ if m.closed {
+ return errors.New("呼叫窗口已关闭")
+ }
+ m.pending = view
+ if ok, _, err := postMessage.Call(m.hwnd, scUpdate, 0, 0); ok == 0 {
+ return err
+ }
+ return nil
+}
+func (m *nativeStudentCallWindow) close() {
+ m.mu.Lock()
+ if m.closed {
+ m.mu.Unlock()
+ return
+ }
+ m.closed = true
+ postMessage.Call(m.hwnd, wmClose, 0, 0)
+ m.mu.Unlock()
+ <-m.done
+}
+func (m *nativeStudentCallWindow) px(v int) int { return v * m.dpi / 96 }
+func (m *nativeStudentCallWindow) place() {
+ style := uintptr(0x80 | 0x08000000)
+ if m.expanded {
+ style = 0x80
+ }
+ user32.NewProc("SetWindowLongPtrW").Call(m.hwnd, ^uintptr(19), style)
+ if dpiProc := user32.NewProc("GetDpiForWindow"); dpiProc.Find() == nil {
+ if dpi, _, _ := dpiProc.Call(m.hwnd); dpi != 0 {
+ m.dpi = int(dpi)
+ }
+ }
+ m.width, m.height = m.px(84), m.px(68)
+ if m.expanded {
+ m.width, m.height = m.px(420), m.px(240)
+ }
+ monitor, _, _ := user32.NewProc("MonitorFromWindow").Call(m.hwnd, 2)
+ info := monitorInfo{size: uint32(unsafe.Sizeof(monitorInfo{}))}
+ if ok, _, _ := getMonitorInfo.Call(monitor, uintptr(unsafe.Pointer(&info))); ok == 0 {
+ return
+ }
+ w := info.work
+ if !m.positioned {
+ m.x, m.y = int(w.left)+m.px(20), int(w.top)+int(w.bottom-w.top)/2
+ m.positioned = true
+ }
+ m.width = min(m.width, int(w.right-w.left))
+ m.height = min(m.height, int(w.bottom-w.top))
+ m.x, m.y = studentCallClamp(m.x, m.y, m.width, m.height, int(w.left), int(w.top), int(w.right), int(w.bottom))
+ setWindowPos.Call(m.hwnd, ^uintptr(0), uintptr(m.x), uintptr(m.y), uintptr(m.width), uintptr(m.height), 0x0010) // TOPMOST / NOACTIVATE
+ radius := m.px(24)
+ if m.expanded {
+ radius = m.px(12)
+ }
+ region, _, _ := hwRoundRegion.Call(0, 0, uintptr(m.width+1), uintptr(m.height+1), uintptr(radius*2), uintptr(radius*2))
+ if region != 0 {
+ if ok, _, _ := hwSetRegion.Call(m.hwnd, region, 1); ok == 0 {
+ hwDeleteObject.Call(region)
+ }
+ }
+ if m.fontDPI != m.dpi {
+ if font := hwFont(m.px(18), 500); font != 0 {
+ previous := m.buttonFont
+ m.buttonFont, m.fontDPI = font, m.dpi
+ for _, button := range []uintptr{m.cancel, m.confirm} {
+ user32.NewProc("SendMessageW").Call(button, 0x0030, font, 1) // WM_SETFONT
+ }
+ if previous != 0 {
+ hwDeleteObject.Call(previous)
+ }
+ }
+ }
+ for i, button := range []uintptr{m.cancel, m.confirm} {
+ buttonWidth := max(1, (m.width-m.px(48))/2)
+ setWindowPos.Call(button, 0, uintptr(m.px(20)+i*(buttonWidth+m.px(8))), uintptr(m.height-m.px(64)), uintptr(buttonWidth), uintptr(m.px(44)), 0x0004|0x0010)
+ show := uintptr(0)
+ if m.expanded {
+ show = 4
+ }
+ hwShowWindow.Call(button, show)
+ }
+ user32.NewProc("EnableWindow").Call(m.confirm, boolUint(!m.view.Sending && m.view.Bound && m.view.Online))
+ if m.view.Suspended || !m.view.Bound {
+ hwShowWindow.Call(m.hwnd, 0)
+ } else {
+ hwShowWindow.Call(m.hwnd, 4)
+ }
+ hwInvalidate.Call(m.hwnd, 0, 1)
+}
+func boolUint(b bool) uintptr {
+ if b {
+ return 1
+ }
+ return 0
+}
+func (m *nativeStudentCallWindow) expand() {
+ m.expanded = true
+ // Only this explicit user action activates the confirmation. Idle ball never steals focus.
+ user32.NewProc("SetWindowLongPtrW").Call(m.hwnd, ^uintptr(19), 0x80)
+ m.place()
+ setForegroundWindow.Call(m.hwnd)
+ user32.NewProc("SetFocus").Call(m.cancel) // Cancel is the safe default.
+}
+func (m *nativeStudentCallWindow) collapse() {
+ m.expanded = false
+ user32.NewProc("SetWindowLongPtrW").Call(m.hwnd, ^uintptr(19), 0x80|0x08000000)
+ m.place()
+}
+func (m *nativeStudentCallWindow) send() {
+ if !m.expanded || m.view.Sending || !m.view.Bound || !m.view.Online || m.view.Suspended {
+ return
+ }
+ m.awaiting, m.sentRevision = true, m.view.Revision
+ m.view.Sending = true
+ m.view.Message = "正在发送,请稍候…"
+ m.place()
+ // This only queues a runtime event; it does not perform HTTP or wait on WebView.
+ // Calling before returning keeps pending native snapshots ordered with send state.
+ m.onConfirm(m.view.Binding)
+}
+func studentCallWndProc(hwnd, message, wParam, lParam uintptr) uintptr {
+ if value, ok := scWindows.Load(hwnd); ok {
+ m := value.(*nativeStudentCallWindow)
+ switch message {
+ case scUpdate:
+ m.mu.Lock()
+ view := m.pending
+ m.mu.Unlock()
+ if m.view.Bound != view.Bound || m.view.Binding != view.Binding || view.Suspended {
+ m.expanded = false
+ }
+ if studentCallAcknowledged(m.view, view, m.sentRevision) {
+ m.awaiting = false
+ }
+ if m.awaiting {
+ view.Sending = true
+ }
+ m.view = view
+ m.place()
+ return 0
+ case 0x0113, 0x007e, 0x001a:
+ if !m.down {
+ m.place()
+ }
+ return 0 // timer, display/work-area changes
+ case 0x02e0: // Keep physical drag position; resize and clamp for the new DPI.
+ // No dereference of the callback uintptr (also safe under checkptr/vet).
+ m.dpi = int(wParam & 0xffff)
+ m.place()
+ return 0
+ case 0x0021:
+ if !m.expanded {
+ return 3
+ } // MA_NOACTIVATE
+ case 0x0014:
+ return 1
+ case 0x000f:
+ m.paint()
+ return 0
+ case 0x0201:
+ if !m.expanded {
+ m.down, m.dragged = true, false
+ getCursorPos.Call(uintptr(unsafe.Pointer(&m.cursor)))
+ m.origin = trayPoint{int32(m.x), int32(m.y)}
+ user32.NewProc("SetCapture").Call(hwnd)
+ }
+ return 0
+ case 0x0200:
+ if m.down {
+ var point trayPoint
+ getCursorPos.Call(uintptr(unsafe.Pointer(&point)))
+ dx, dy := int(point.x-m.cursor.x), int(point.y-m.cursor.y)
+ m.dragged = m.dragged || studentCallDragged(dx, dy, m.dpi)
+ if m.dragged {
+ m.x, m.y = int(m.origin.x)+dx, int(m.origin.y)+dy
+ setWindowPos.Call(hwnd, 0, uintptr(m.x), uintptr(m.y), 0, 0, 0x0001|0x0004|0x0010)
+ }
+ }
+ return 0
+ case wmLeftButtonUp:
+ if m.down {
+ m.down = false
+ user32.NewProc("ReleaseCapture").Call()
+ if m.dragged {
+ m.place()
+ } else {
+ m.expand()
+ }
+ }
+ return 0
+ case 0x0215:
+ m.down = false
+ return 0 // capture cancelled: never turn into a click
+ case 0x0111:
+ if wParam&0xffff == 1 {
+ m.collapse()
+ }
+ if wParam&0xffff == 2 {
+ m.send()
+ }
+ return 0
+ case wmClose:
+ m.mu.Lock()
+ closed := m.closed
+ m.mu.Unlock()
+ if closed {
+ destroyWindow.Call(hwnd)
+ } else {
+ m.collapse()
+ }
+ return 0
+ case wmDestroy:
+ postQuitMessage.Call(0)
+ return 0
+ }
+ }
+ result, _, _ := defWindowProc.Call(hwnd, message, wParam, lParam)
+ return result
+}
+func (m *nativeStudentCallWindow) paint() {
+ var paint hwPaint
+ dc, _, _ := hwBeginPaint.Call(m.hwnd, uintptr(unsafe.Pointer(&paint)))
+ if dc == 0 {
+ return
+ }
+ defer hwEndPaint.Call(m.hwnd, uintptr(unsafe.Pointer(&paint)))
+ full := displayRect{0, 0, int32(m.width), int32(m.height)}
+ fill := [3]uint8{243, 249, 245}
+ ink := [3]uint8{25, 94, 67}
+ brush, _, _ := hwCreateBrush.Call(hwRGB(fill))
+ hwFillRect.Call(dc, uintptr(unsafe.Pointer(&full)), brush)
+ hwDeleteObject.Call(brush)
+ hwSetBkMode.Call(dc, 1)
+ hwSetTextColor.Call(dc, hwRGB(ink))
+ font := hwFont(m.px(18), 600)
+ old, _, _ := hwSelectObject.Call(dc, font)
+ defer func() { hwSelectObject.Call(dc, old); hwDeleteObject.Call(font) }()
+ if !m.expanded {
+ hwText(dc, "呼叫\n老师", &full, 0x0001|0x0010)
+ return
+ }
+ title := displayRect{int32(m.px(20)), int32(m.px(16)), int32(m.width - m.px(20)), int32(m.px(48))}
+ hwText(dc, "呼叫老师", &title, 0x0020)
+ body := displayRect{title.left, int32(m.px(58)), title.right, int32(m.height - m.px(72))}
+ text := m.view.ClassName + ":确定呼叫本班所有关联老师吗?\n取消不会发送。"
+ if !m.view.Online {
+ text = "连接中断,呼叫未发送。请恢复网络后重试。"
+ }
+ if m.view.Message != "" {
+ text = m.view.Message
+ }
+ hwText(dc, text, &body, 0x0010)
+}
diff --git a/screen/wails.json b/screen/wails.json
index 31dc5ab..64ff2b0 100644
--- a/screen/wails.json
+++ b/screen/wails.json
@@ -11,7 +11,7 @@
"info": {
"companyName": "ClassDing",
"productName": "课堂叮咚大屏端",
- "productVersion": "0.3.4",
+ "productVersion": "0.3.5",
"copyright": "ClassDing"
}
}
diff --git a/scripts/api-generator/package.json b/scripts/api-generator/package.json
index 51bf508..9bd6a24 100644
--- a/scripts/api-generator/package.json
+++ b/scripts/api-generator/package.json
@@ -1,6 +1,6 @@
{
"name": "@classding/api-generator",
- "version": "0.3.4",
+ "version": "0.3.5",
"private": true,
"type": "module",
"scripts": {
diff --git a/scripts/generate-contract.mjs b/scripts/generate-contract.mjs
index b5b7706..f0222c3 100644
--- a/scripts/generate-contract.mjs
+++ b/scripts/generate-contract.mjs
@@ -86,6 +86,18 @@ const schemas = {
joined_at: timestamp,
}),
Phrase: obj({ id: str, text: str, sort: num }),
+ StudentCall: obj({
+ id: str,
+ class_id: str,
+ class_name: str,
+ device_id: str,
+ device_name: str,
+ created_at: timestamp,
+ read_at: str,
+ read_by: str,
+ read_by_name: str,
+ }),
+ StudentCallList: obj({ items: arr(ref('StudentCall')), unread_count: num, next_cursor: nullable }),
Homework: homework,
HomeworkList: obj({ items: arr(ref('Homework')) }),
DeviceHomeworkList: obj({ items: arr(ref('Homework')), homework_revision: homeworkRevision }),
@@ -352,6 +364,18 @@ add(
);
add('/homeworks/{id}', 'delete', ref('OK'));
add('/messages', 'get', ref('MessageList'));
+add(
+ '/device/student-calls',
+ 'post',
+ ref('StudentCall'),
+ obj({ request_id: { type: 'string', minLength: 1, maxLength: 128, pattern: '^[A-Za-z0-9_.:-]+$' } }),
+);
+add('/student-calls', 'get', ref('StudentCallList'));
+paths['/student-calls'].get.parameters = [
+ ...['class_id', 'cursor'].map((name) => ({ name, in: 'query', required: false, schema: str })),
+ { name: 'limit', in: 'query', required: false, schema: { type: 'integer', minimum: 1, maximum: 100 } },
+];
+add('/student-calls/{id}/read', 'post', ref('StudentCall'));
add('/messages', 'post', ref('Message'), ref('MessageInput'));
add('/messages/{id}', 'get', ref('Message'));
add('/messages/{id}', 'delete', ref('OK'));
@@ -521,6 +545,7 @@ const frames = {
pong: obj({ server_time: num }),
receipt: obj({ message_id: str, class_id: str, device_id: str, status: str, at: timestamp }),
presence: obj({ class_id: str, device_id: str, online: bool }),
+ student_call: obj({ id: str, class_id: str, kind: { type: 'string', enum: ['created', 'read'] } }),
};
writeFileSync(
'api/ws/frame.schema.json',
diff --git a/server/README.md b/server/README.md
index a817a29..fda5691 100644
--- a/server/README.md
+++ b/server/README.md
@@ -40,6 +40,12 @@ go build ./cmd/classding
服务支持 HTTP 刷新会话;管理端和教师端使用独立 HttpOnly Cookie。HTTPS 部署可强制设置 `COOKIE_SECURE=true`。旧 `ADMIN_TOTP_SECRET` 环境变量将被忽略,可从部署配置移除。使用容器部署时,需确保上述所需环境变量确实传入容器;尤其在反向代理后按客户端限流时,必须正确配置可信代理。切勿将公网或不受控网段当作可信代理,否则客户端可能伪造来源地址。
+## 学生呼叫(未发布)
+
+已绑定且授权有效的设备可向当前班级发起固定内容呼叫,教师仅能查看、处理自己加入班级的记录。呼叫和首次处理老师、时间保存在 SQLite,并随全站备份恢复;旧备份不含呼叫集合时按空集合兼容。当前成员在线时由教师 WebSocket 提醒,离线后通过列表补查,不使用 Web Push。
+
+同班新呼叫间隔 30 秒,设备请求以 `request_id` 幂等重试;仅显式标记已读才改变全班共享状态。接口、分页及错误语义见 [`docs/INTEGRATION.md`](../docs/INTEGRATION.md#学生呼叫未发布),验收见 [`docs/LOCAL_TESTING.md`](../docs/LOCAL_TESTING.md#学生呼叫验收未发布)。
+
## 数据与当前限制
当前 SQLite 使用 WAL,持久化按实体保存 JSON 并建索引,启动时载入内存,写入由主服务串行处理;它不是开发方案中规划的规范化 sqlc 仓储。**同一数据目录只能由一个主服务进程使用**,不支持多实例共享数据库。
diff --git a/server/internal/app/app.go b/server/internal/app/app.go
index 4203b45..b8154d6 100644
--- a/server/internal/app/app.go
+++ b/server/internal/app/app.go
@@ -102,6 +102,9 @@ func New(c Config) (*App, error) {
// upgradeState fills in data that databases written by older releases lack.
func upgradeState(s *State) {
+ if s.StudentCalls == nil {
+ s.StudentCalls = map[string]studentCallRecord{}
+ }
initializeSettings(s)
if s.Schools == nil {
s.Schools = map[string]School{}
diff --git a/server/internal/app/backup.go b/server/internal/app/backup.go
index a93f357..9edb38e 100644
--- a/server/internal/app/backup.go
+++ b/server/internal/app/backup.go
@@ -184,7 +184,7 @@ func (a *App) backup(w http.ResponseWriter, r *http.Request) {
// Validate every relationship that is required for live entities. Historical
// messages and audio can legitimately refer to since-deleted users/classes/devices.
func validateBackupState(s State) error {
- if s.Users == nil || s.Classes == nil || s.Members == nil || s.Devices == nil || s.Messages == nil || s.Homeworks == nil || s.Codes == nil || s.Invites == nil || s.Sessions == nil || s.Phrases == nil || s.Audio == nil || s.Audit == nil || s.Settings == nil {
+ if s.Users == nil || s.Classes == nil || s.Members == nil || s.Devices == nil || s.Messages == nil || s.Homeworks == nil || s.StudentCalls == nil || s.Codes == nil || s.Invites == nil || s.Sessions == nil || s.Phrases == nil || s.Audio == nil || s.Audit == nil || s.Settings == nil {
return errors.New("missing state collection")
}
if _, ok := s.Settings["site_name"]; !ok {
@@ -251,6 +251,27 @@ func validateBackupState(s State) error {
return errors.New("too many homeworks")
}
}
+ requestIDs := map[string]bool{}
+ for key, item := range s.StudentCalls {
+ _, classExists := s.Classes[item.ClassID]
+ created, createdOK := parseTime(item.CreatedAt)
+ if key == "" || item.ID != key || !classExists || strings.TrimSpace(item.ClassName) == "" || item.DeviceID == "" || strings.TrimSpace(item.DeviceName) == "" || !createdOK || !studentCallRequestID.MatchString(item.RequestID) || requestIDs[item.RequestID] {
+ return errors.New("invalid student call")
+ }
+ requestIDs[item.RequestID] = true
+ if item.ReadAt == "" {
+ if item.ReadBy != "" || item.ReadByName != "" {
+ return errors.New("invalid unread student call")
+ }
+ } else {
+ read, readOK := parseTime(item.ReadAt)
+ if !readOK || read.Before(created) || item.ReadBy == "" || strings.TrimSpace(item.ReadByName) == "" {
+ return errors.New("invalid read student call")
+ }
+ }
+ // Device and processing-teacher identities are historical snapshots: the
+ // device can be unbound/rebound elsewhere and the teacher can be deleted.
+ }
tokens := map[string]bool{}
for key, d := range s.Devices {
_, exists := s.Classes[d.ClassID]
@@ -423,6 +444,27 @@ func prepareRestore(zipPath, stage string) (State, error) {
}
}
}
+ // Older version-1 archives predate student calls; only absence is compatible.
+ // A present collection and its records must not use null/missing fields to
+ // silently erase replay keys or turn a processed request back into unread.
+ if state.StudentCalls == nil {
+ if _, exists := collections["student_calls"]; exists {
+ return empty, errors.New("invalid student call collection")
+ }
+ state.StudentCalls = map[string]studentCallRecord{}
+ } else {
+ var items map[string]map[string]json.RawMessage
+ if json.Unmarshal(collections["student_calls"], &items) != nil {
+ return empty, errors.New("invalid student call collection")
+ }
+ for _, fields := range items {
+ for _, key := range []string{"id", "class_id", "class_name", "device_id", "device_name", "created_at", "read_at", "read_by", "read_by_name", "request_id"} {
+ if value, exists := fields[key]; !exists || bytes.Equal(bytes.TrimSpace(value), []byte("null")) {
+ return empty, errors.New("missing student call field")
+ }
+ }
+ }
+ }
if err := validateBackupState(state); err != nil {
return empty, err
}
diff --git a/server/internal/app/classes.go b/server/internal/app/classes.go
index 1684cea..295b3a5 100644
--- a/server/internal/app/classes.go
+++ b/server/internal/app/classes.go
@@ -195,6 +195,11 @@ func (a *App) deleteClass(_ http.ResponseWriter, r *http.Request, u *User) (any,
// removeClass deletes the class with everything that belongs to it and
// releases its license for reuse.
func (a *App) removeClass(c Class, u *User) {
+ for key, item := range a.state.StudentCalls {
+ if item.ClassID == c.ID {
+ delete(a.state.StudentCalls, key)
+ }
+ }
for key, item := range a.state.Homeworks {
if item.ClassID == c.ID {
delete(a.state.Homeworks, key)
diff --git a/server/internal/app/hub.go b/server/internal/app/hub.go
index 96a46ea..4e74b00 100644
--- a/server/internal/app/hub.go
+++ b/server/internal/app/hub.go
@@ -19,6 +19,8 @@ type Peer struct {
ID string // Device ID, for screens.
ClassID string // For screens.
UserID string // For teachers.
+ // Immutable once registered; student-call broadcasts recheck the live session.
+ teacherSession claims
}
func newPeer(parent context.Context, conn *websocket.Conn) *Peer {
diff --git a/server/internal/app/models.go b/server/internal/app/models.go
index ae7ff7c..09ebd76 100644
--- a/server/internal/app/models.go
+++ b/server/internal/app/models.go
@@ -3,7 +3,7 @@ package app
import "time"
// Version is the release reported by the server and offered to classroom clients.
-const Version = "0.3.4"
+const Version = "0.3.5"
// Role decides which workspace an account may sign in to and what it governs.
type Role string
@@ -293,41 +293,63 @@ type Audit struct {
At string `json:"at"`
}
+// StudentCall is a shared classroom request. Names are immutable event snapshots.
+// Read fields deliberately have no omitempty: unread values are empty strings.
+type StudentCall struct {
+ ID string `json:"id"`
+ ClassID string `json:"class_id"`
+ ClassName string `json:"class_name"`
+ DeviceID string `json:"device_id"`
+ DeviceName string `json:"device_name"`
+ CreatedAt string `json:"created_at"`
+ ReadAt string `json:"read_at"`
+ ReadBy string `json:"read_by"`
+ ReadByName string `json:"read_by_name"`
+}
+
+// studentCallRecord keeps replay keys in storage/backups, never in API responses.
+type studentCallRecord struct {
+ StudentCall
+ RequestID string `json:"request_id"`
+}
+
// State is the whole database. It lives in memory under App.mu and is written
// through to SQLite after every successful change.
type State struct {
- Users map[string]User `json:"users"`
- Classes map[string]Class `json:"classes"`
- Members map[string]Member `json:"class_members"`
- Devices map[string]Device `json:"devices"`
- Messages map[string]Message `json:"messages"`
- Homeworks map[string]Homework `json:"homeworks"`
- Codes map[string]Code `json:"activation_codes"`
- Invites map[string]Invite `json:"collab_invites"`
- Sessions map[string]Session `json:"sessions"`
- Phrases map[string]Phrase `json:"phrases"`
- Audio map[string]Audio `json:"audio"`
- Audit map[string]Audit `json:"audit_logs"`
- Settings map[string]string `json:"settings"`
- Schools map[string]School `json:"schools"`
+ StudentCalls map[string]studentCallRecord `json:"student_calls"`
+ Users map[string]User `json:"users"`
+ Classes map[string]Class `json:"classes"`
+ Members map[string]Member `json:"class_members"`
+ Devices map[string]Device `json:"devices"`
+ Messages map[string]Message `json:"messages"`
+ Homeworks map[string]Homework `json:"homeworks"`
+ Codes map[string]Code `json:"activation_codes"`
+ Invites map[string]Invite `json:"collab_invites"`
+ Sessions map[string]Session `json:"sessions"`
+ Phrases map[string]Phrase `json:"phrases"`
+ Audio map[string]Audio `json:"audio"`
+ Audit map[string]Audit `json:"audit_logs"`
+ Settings map[string]string `json:"settings"`
+ Schools map[string]School `json:"schools"`
}
func newState() State {
return State{
- Users: map[string]User{},
- Classes: map[string]Class{},
- Members: map[string]Member{},
- Devices: map[string]Device{},
- Messages: map[string]Message{},
- Homeworks: map[string]Homework{},
- Codes: map[string]Code{},
- Invites: map[string]Invite{},
- Sessions: map[string]Session{},
- Phrases: map[string]Phrase{},
- Audio: map[string]Audio{},
- Audit: map[string]Audit{},
- Settings: map[string]string{},
- Schools: map[string]School{},
+ StudentCalls: map[string]studentCallRecord{},
+ Users: map[string]User{},
+ Classes: map[string]Class{},
+ Members: map[string]Member{},
+ Devices: map[string]Device{},
+ Messages: map[string]Message{},
+ Homeworks: map[string]Homework{},
+ Codes: map[string]Code{},
+ Invites: map[string]Invite{},
+ Sessions: map[string]Session{},
+ Phrases: map[string]Phrase{},
+ Audio: map[string]Audio{},
+ Audit: map[string]Audit{},
+ Settings: map[string]string{},
+ Schools: map[string]School{},
}
}
diff --git a/server/internal/app/realtime.go b/server/internal/app/realtime.go
index fdfea13..13b1038 100644
--- a/server/internal/app/realtime.go
+++ b/server/internal/app/realtime.go
@@ -366,6 +366,7 @@ func (a *App) connectTeacher(p *Peer, token string) (claims, bool) {
return c, false
}
p.UserID = u.ID
+ p.teacherSession = c
a.hub.addTeacher(p)
p.send(Frame{T: "welcome", D: map[string]any{"server_time": time.Now().Unix()}})
return c, true
diff --git a/server/internal/app/routes.go b/server/internal/app/routes.go
index f489cde..21a934c 100644
--- a/server/internal/app/routes.go
+++ b/server/internal/app/routes.go
@@ -54,6 +54,9 @@ func (a *App) routes() {
r.Post("/device/bind", c(public, a.bindDevice))
r.Get("/device/me", q(public, a.deviceMe))
r.Get("/device/homeworks", q(public, a.deviceHomeworks))
+ r.Post("/device/student-calls", c(public, a.createStudentCall))
+ r.Get("/student-calls", q(signedIn, a.listStudentCalls))
+ r.Post("/student-calls/{id}/read", c(signedIn, a.readStudentCall))
r.Post("/device/migration-check", q(public, a.deviceMigrationCheck))
r.Patch("/devices/{id}", c(signedIn, a.renameDevice))
r.Delete("/devices/{id}", c(signedIn, a.removeDevice))
diff --git a/server/internal/app/student_calls.go b/server/internal/app/student_calls.go
new file mode 100644
index 0000000..2c85e11
--- /dev/null
+++ b/server/internal/app/student_calls.go
@@ -0,0 +1,203 @@
+package app
+
+import (
+ "bytes"
+ "encoding/base64"
+ "encoding/json"
+ "io"
+ "net/http"
+ "regexp"
+ "slices"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/go-chi/chi/v5"
+)
+
+const studentCallCooldown = 30 * time.Second
+
+var studentCallRequestID = regexp.MustCompile(`^[A-Za-z0-9_.:-]{1,128}$`)
+
+func (a *App) createStudentCall(w http.ResponseWriter, r *http.Request, _ *User) (any, error) {
+ d, c, err := a.boundDevice(r)
+ if err != nil {
+ return nil, err
+ }
+ if !a.licensed(c) {
+ return nil, licenseInactive()
+ }
+ var in struct {
+ RequestID string `json:"request_id"`
+ }
+ if err := decodeStrict(r, &in); err != nil {
+ return nil, err
+ }
+ if !studentCallRequestID.MatchString(in.RequestID) {
+ return nil, bad("request_id 需为 1–128 位字母、数字或 _ . : -。")
+ }
+ // Keys are globally reserved, not scoped to a device or a class. Authenticate
+ // and validate the current binding/license before returning even a replay.
+ for _, item := range a.state.StudentCalls {
+ if item.RequestID == in.RequestID {
+ if item.DeviceID != d.ID || item.ClassID != c.ID {
+ return nil, fail(409, "REQUEST_ID_CONFLICT", "request_id 已被使用,请使用新的请求标识。")
+ }
+ return item.StudentCall, nil
+ }
+ }
+ at := time.Now()
+ for _, item := range a.state.StudentCalls {
+ created, valid := parseTime(item.CreatedAt)
+ if item.ClassID == c.ID && valid && at.Sub(created) < studentCallCooldown {
+ remaining := studentCallCooldown - at.Sub(created)
+ seconds := (remaining + time.Second - 1) / time.Second
+ w.Header().Set("Retry-After", strconv.FormatInt(int64(seconds), 10))
+ return nil, tooMany("该班刚刚发起过呼叫,请稍后再试。")
+ }
+ }
+ item := studentCallRecord{StudentCall: StudentCall{ID: id("sc"), ClassID: c.ID, ClassName: c.Name, DeviceID: d.ID, DeviceName: d.Name, CreatedAt: stamp(at)}, RequestID: in.RequestID}
+ a.state.StudentCalls[item.ID] = item
+ a.studentCallChanged(item, "created")
+ return item.StudentCall, nil
+}
+
+type studentCallCursor struct {
+ CreatedAt string `json:"created_at"`
+ ID string `json:"id"`
+}
+
+type studentCallList struct {
+ Items []StudentCall `json:"items"`
+ UnreadCount int `json:"unread_count"`
+ NextCursor *string `json:"next_cursor"`
+}
+
+// Compare parsed times rather than RFC3339Nano strings: a whole second and a
+// fractional second do not sort lexically at the boundary.
+func compareStudentCalls(x, y StudentCall) int {
+ left, _ := parseTime(x.CreatedAt)
+ right, _ := parseTime(y.CreatedAt)
+ if order := right.Compare(left); order != 0 {
+ return order
+ }
+ return strings.Compare(y.ID, x.ID)
+}
+
+func (a *App) listStudentCalls(_ http.ResponseWriter, r *http.Request, u *User) (any, error) {
+ query := r.URL.Query()
+ classID := query.Get("class_id")
+ if classID != "" {
+ if _, err := a.getClass(u, classID, false); err != nil {
+ return nil, err
+ }
+ }
+ limit := 100
+ if raw, exists := query["limit"]; exists {
+ var err error
+ if len(raw) != 1 {
+ return nil, bad("limit 需为 1–100。")
+ }
+ limit, err = strconv.Atoi(raw[0])
+ if err != nil || limit < 1 || limit > 100 {
+ return nil, bad("limit 需为 1–100。")
+ }
+ }
+ var cursor *StudentCall
+ if raw := query.Get("cursor"); raw != "" {
+ if len(raw) > 1024 {
+ return nil, bad("分页标识无效。")
+ }
+ data, err := base64.RawURLEncoding.DecodeString(raw)
+ if err != nil {
+ return nil, bad("分页标识无效。")
+ }
+ var key studentCallCursor
+ decoder := json.NewDecoder(bytes.NewReader(data))
+ decoder.DisallowUnknownFields()
+ if decoder.Decode(&key) != nil || key.ID == "" || len(key.ID) > 128 {
+ return nil, bad("分页标识无效。")
+ }
+ var extra any
+ if decoder.Decode(&extra) != io.EOF {
+ return nil, bad("分页标识无效。")
+ }
+ if _, valid := parseTime(key.CreatedAt); !valid {
+ return nil, bad("分页标识无效。")
+ }
+ cursor = &StudentCall{ID: key.ID, CreatedAt: key.CreatedAt}
+ }
+ result := studentCallList{Items: []StudentCall{}}
+ for _, item := range a.state.StudentCalls {
+ c, exists := a.state.Classes[item.ClassID]
+ if !exists || classID != "" && classID != item.ClassID || !a.canClass(u, c) {
+ continue
+ }
+ if item.ReadAt == "" {
+ result.UnreadCount++
+ }
+ if cursor == nil || compareStudentCalls(item.StudentCall, *cursor) > 0 {
+ result.Items = append(result.Items, item.StudentCall)
+ }
+ }
+ slices.SortFunc(result.Items, compareStudentCalls)
+ if len(result.Items) > limit {
+ result.Items = result.Items[:limit]
+ last := result.Items[len(result.Items)-1]
+ key, _ := json.Marshal(studentCallCursor{CreatedAt: last.CreatedAt, ID: last.ID})
+ encoded := base64.RawURLEncoding.EncodeToString(key)
+ result.NextCursor = &encoded
+ }
+ return result, nil
+}
+
+func (a *App) readStudentCall(_ http.ResponseWriter, r *http.Request, u *User) (any, error) {
+ item, exists := a.state.StudentCalls[chi.URLParam(r, "id")]
+ if !exists {
+ return nil, missing()
+ }
+ if _, err := a.getClass(u, item.ClassID, false); err != nil {
+ return nil, err
+ }
+ // A read is a shared acknowledgement. The first processor is authoritative;
+ // retries and other teachers must not overwrite them or broadcast again.
+ if item.ReadAt == "" {
+ item.ReadAt, item.ReadBy, item.ReadByName = now(), u.ID, u.DisplayName
+ a.state.StudentCalls[item.ID] = item
+ a.audit(u, "student_call.read", item.ID)
+ a.studentCallChanged(item, "read")
+ }
+ return item.StudentCall, nil
+}
+
+// broadcastStudentCall runs with App.mu held. A connected socket is not proof
+// of authorization: its token may expire or be retired before the next ping.
+func (a *App) broadcastStudentCall(classID string, frame Frame) {
+ var peers []*Peer
+ a.hub.mu.Lock()
+ for _, uid := range a.teachers(classID) {
+ u := a.state.Users[uid]
+ for p := range a.hub.teachers[uid] {
+ session := p.teacherSession
+ if session.Subject == uid && session.Expires > time.Now().Unix() && session.Version == u.TokenVersion && session.Epoch == a.authEpoch() {
+ peers = append(peers, p)
+ }
+ }
+ }
+ a.hub.mu.Unlock()
+ sendAll(peers, frame)
+}
+
+func (a *App) studentCallChanged(item studentCallRecord, kind string) {
+ a.deferJob(func() {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ // Recheck live membership after commit, not at enqueue time: a teacher may
+ // have left or been disabled, or the class may have been deleted meanwhile.
+ current, exists := a.state.StudentCalls[item.ID]
+ if !exists || current.ClassID != item.ClassID {
+ return
+ }
+ a.broadcastStudentCall(item.ClassID, Frame{T: "student_call", D: map[string]any{"id": item.ID, "class_id": item.ClassID, "kind": kind}})
+ })
+}
diff --git a/server/internal/app/student_calls_backup_test.go b/server/internal/app/student_calls_backup_test.go
new file mode 100644
index 0000000..1840220
--- /dev/null
+++ b/server/internal/app/student_calls_backup_test.go
@@ -0,0 +1,136 @@
+package app
+
+import (
+ "context"
+ "encoding/json"
+ "testing"
+)
+
+func mutateStudentBackup(t *testing.T, archive []byte, mutate func(map[string]any)) []byte {
+ t.Helper()
+ return alterBackup(t, archive, func(files map[string][]byte) {
+ var manifest map[string]any
+ if err := json.Unmarshal(files["manifest.json"], &manifest); err != nil {
+ t.Fatal(err)
+ }
+ mutate(manifest["state"].(map[string]any))
+ files["manifest.json"], _ = json.Marshal(manifest)
+ })
+}
+
+func TestStudentCallsBackupCompatibilityRoundTripAndValidation(t *testing.T) {
+ src := prepareBackupFixture(t)
+ item := postStudentCall(t, src, "token-a", "backup-key")
+ expectStatus(t, src.request(t, "POST", studentCallsPath+"/"+item.ID+"/read", src.token("collab"), nil, nil), 200)
+ archive := getBackup(t, src)
+ dst := newSecurityFixture(t)
+ expectStatus(t, restoreBackup(t, dst, dst.token("super"), archive), 200)
+ if got := dst.app.state.StudentCalls[item.ID]; got.RequestID != "backup-key" || got.ReadBy != "collab_a" {
+ t.Fatalf("restore lost call: %+v", got)
+ }
+ var persisted State
+ if err := dst.app.store.Load(context.Background(), &persisted); err != nil || persisted.StudentCalls[item.ID] != dst.app.state.StudentCalls[item.ID] {
+ t.Fatal("restore not persisted", err)
+ }
+ if replay := postStudentCall(t, dst, "token-a", "backup-key"); replay.ID != item.ID || replay.ReadBy != "collab_a" {
+ t.Fatal("restore lost replay or read state")
+ }
+ expectStatus(t, dst.request(t, "POST", deviceCallsPath, "token-a", map[string]string{"request_id": "too-soon"}, nil), 429)
+ legacy := mutateStudentBackup(t, archive, func(s map[string]any) { delete(s, "student_calls") })
+ old := newSecurityFixture(t)
+ expectStatus(t, restoreBackup(t, old, old.token("super"), legacy), 200)
+ if old.app.state.StudentCalls == nil || len(old.app.state.StudentCalls) != 0 {
+ t.Fatal("legacy backup not upgraded")
+ }
+ postStudentCall(t, old, "token-a", "new-after-legacy")
+
+ for _, tc := range []struct {
+ name string
+ mutate func(map[string]any)
+ }{
+ {"null collection", func(s map[string]any) { s["student_calls"] = nil }},
+ {"missing class", func(i map[string]any) { i["class_id"] = "missing" }},
+ {"key mismatch", func(i map[string]any) { i["id"] = "mismatch" }},
+ {"empty device", func(i map[string]any) { i["device_id"] = "" }},
+ {"empty device name", func(i map[string]any) { i["device_name"] = " " }},
+ {"empty class name", func(i map[string]any) { i["class_name"] = " " }},
+ {"invalid created time", func(i map[string]any) { i["created_at"] = "invalid" }},
+ {"read before creation", func(i map[string]any) { i["read_at"] = "2000-01-01T00:00:00Z" }},
+ {"invalid read time", func(i map[string]any) { i["read_at"] = "invalid" }},
+ {"no processor", func(i map[string]any) { i["read_by"] = "" }},
+ {"no processor name", func(i map[string]any) { i["read_by_name"] = " " }},
+ {"partial unread", func(i map[string]any) { i["read_at"] = "" }},
+ {"bad request key", func(i map[string]any) { i["request_id"] = " whitespace " }},
+ {"missing read field", func(i map[string]any) { delete(i, "read_at") }},
+ {"missing request key", func(i map[string]any) { delete(i, "request_id") }},
+ {"null read field", func(i map[string]any) { i["read_by_name"] = nil }},
+ {"duplicate request key", func(s map[string]any) {
+ calls := s["student_calls"].(map[string]any)
+ copy := map[string]any{}
+ for k, v := range calls[item.ID].(map[string]any) {
+ copy[k] = v
+ }
+ copy["id"] = "duplicate"
+ calls["duplicate"] = copy
+ }},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ corrupt := mutateStudentBackup(t, archive, func(s map[string]any) {
+ if tc.name == "null collection" || tc.name == "duplicate request key" {
+ tc.mutate(s)
+ } else {
+ tc.mutate(s["student_calls"].(map[string]any)[item.ID].(map[string]any))
+ }
+ })
+ f := newSecurityFixture(t)
+ expectStatus(t, restoreBackup(t, f, f.token("super"), corrupt), 400)
+ if len(f.app.state.StudentCalls) != 0 {
+ t.Fatal("invalid backup mutated live calls")
+ }
+ })
+ }
+}
+
+func TestStudentCallsHistoricalDevicesAndProcessorsSurviveBackup(t *testing.T) {
+ src := prepareBackupFixture(t)
+ item := postStudentCall(t, src, "token-a", "history")
+ expectStatus(t, src.request(t, "POST", studentCallsPath+"/"+item.ID+"/read", src.token("collab"), nil, nil), 200)
+ // Real administrative deletion must preserve the first processor snapshot.
+ expectStatus(t, src.request(t, "DELETE", "/api/v1/admin/users/collab_a", src.token("school"), nil, nil), 200)
+ expectStatus(t, src.request(t, "DELETE", "/api/v1/devices/device_a", src.token("owner"), nil, nil), 200)
+ archive := getBackup(t, src)
+ for _, rebound := range []bool{false, true} {
+ data := archive
+ if rebound {
+ // A restored historical ID may now describe a replacement binding in a
+ // different class. It must not invalidate the original call snapshot.
+ data = mutateStudentBackup(t, archive, func(s map[string]any) {
+ s["devices"].(map[string]any)["device_a"] = map[string]any{"id": "device_a", "class_id": "class_b", "name": "replacement", "token_hash": hash("replacement-token")}
+ })
+ // Legacy notification validation also checks existing device/class pairs;
+ // remove those unrelated historical messages for this focused scenario.
+ data = mutateStudentBackup(t, data, func(s map[string]any) { s["messages"] = map[string]any{} })
+ }
+ dst := newSecurityFixture(t)
+ expectStatus(t, restoreBackup(t, dst, dst.token("super"), data), 200)
+ got := dst.app.state.StudentCalls[item.ID]
+ if got.ReadBy != "collab_a" || got.ReadByName != "乙老师" || got.DeviceName != "甲设备" || got.ClassID != "class_a" {
+ t.Fatalf("history changed: %+v", got)
+ }
+ }
+}
+
+func TestStudentCallsClassDeletionRollsBack(t *testing.T) {
+ f := newSecurityFixture(t)
+ item := postStudentCall(t, f, "token-a", "delete-rollback")
+ restore := closeStoreForRollback(t, f.app)
+ expectStatus(t, f.request(t, "DELETE", "/api/v1/classes/class_a", f.token("owner"), nil, nil), 500)
+ if f.app.state.StudentCalls[item.ID].ID != item.ID {
+ t.Fatal("failed class deletion lost calls")
+ }
+ restore()
+ expectStatus(t, f.request(t, "DELETE", "/api/v1/classes/class_a", f.token("owner"), nil, nil), 200)
+ if len(f.app.state.StudentCalls) != 0 {
+ t.Fatal("class deletion did not remove calls")
+ }
+}
diff --git a/server/internal/app/student_calls_realtime_test.go b/server/internal/app/student_calls_realtime_test.go
new file mode 100644
index 0000000..b839e60
--- /dev/null
+++ b/server/internal/app/student_calls_realtime_test.go
@@ -0,0 +1,83 @@
+package app
+
+import (
+ "context"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/coder/websocket"
+ "github.com/coder/websocket/wsjson"
+)
+
+func TestStudentCallsRetiredPeerSessions(t *testing.T) {
+ for _, mode := range []string{"expired", "version", "epoch", "subject"} {
+ t.Run(mode, func(t *testing.T) {
+ f := newSecurityFixture(t)
+ good := callPeer(t, f.app, "owner_a")
+ bad := callPeer(t, f.app, "owner_a")
+ switch mode {
+ case "expired":
+ bad.teacherSession.Expires = time.Now().Unix()
+ case "version":
+ bad.teacherSession.Version++
+ case "epoch":
+ bad.teacherSession.Epoch = "retired"
+ case "subject":
+ bad.teacherSession.Subject = "owner_b"
+ }
+ item := postStudentCall(t, f, "token-a", "retired")
+ takeCallFrame(t, good, item, "created")
+ expectStatus(t, f.request(t, "POST", studentCallsPath+"/"+item.ID+"/read", f.token("collab"), nil, nil), 200)
+ takeCallFrame(t, good, item, "read")
+ if len(bad.queue) != 0 {
+ t.Fatal("retired session received a call event")
+ }
+ })
+ }
+}
+
+func connectCallTeacher(t *testing.T, base, token string) *websocket.Conn {
+ t.Helper()
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+ conn, _, err := websocket.Dial(ctx, "ws"+strings.TrimPrefix(base, "http")+"/ws/teacher", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { conn.CloseNow() })
+ if err := wsjson.Write(ctx, conn, map[string]any{"t": "hello", "d": map[string]string{"token": token}}); err != nil {
+ t.Fatal(err)
+ }
+ rtRead(t, conn, "welcome")
+ return conn
+}
+
+func TestStudentCallsRealWebsocketAndReconnect(t *testing.T) {
+ f := newSecurityFixture(t)
+ server := httptest.NewServer(f.app.Handler())
+ t.Cleanup(server.Close)
+ owner := connectCallTeacher(t, server.URL, f.token("owner"))
+ collab := connectCallTeacher(t, server.URL, f.token("collab"))
+ item := postStudentCall(t, f, "token-a", "online")
+ for _, conn := range []*websocket.Conn{owner, collab} {
+ frame := rtRead(t, conn, "student_call")
+ data := frame.D.(map[string]any)
+ if data["id"] != item.ID || data["class_id"] != "class_a" || data["kind"] != "created" || len(data) != 3 {
+ t.Fatalf("bad websocket payload: %+v", frame)
+ }
+ }
+ collab.CloseNow()
+ expectStatus(t, f.request(t, "POST", studentCallsPath+"/"+item.ID+"/read", f.token("owner"), nil, nil), 200)
+ frame := rtRead(t, owner, "student_call")
+ if frame.D.(map[string]any)["kind"] != "read" {
+ t.Fatal("missing read event")
+ }
+ connectCallTeacher(t, server.URL, f.token("collab"))
+ var got studentCallList
+ rtRequest(t, server.URL, "GET", "/student-calls", f.token("collab"), nil, 200, &got)
+ if got.UnreadCount != 0 || len(got.Items) != 1 || got.Items[0].ReadBy != "owner_a" {
+ t.Fatalf("offline/reconnected shared read lost: %+v", got)
+ }
+}
diff --git a/server/internal/app/student_calls_test.go b/server/internal/app/student_calls_test.go
new file mode 100644
index 0000000..3d1ad8d
--- /dev/null
+++ b/server/internal/app/student_calls_test.go
@@ -0,0 +1,382 @@
+package app
+
+import (
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "net/http/httptest"
+ "reflect"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+)
+
+const studentCallsPath = "/api/v1/student-calls"
+const deviceCallsPath = "/api/v1/device/student-calls"
+
+func postStudentCall(t *testing.T, f *securityFixture, token, key string) StudentCall {
+ t.Helper()
+ w := f.request(t, "POST", deviceCallsPath, token, map[string]string{"request_id": key}, nil)
+ expectStatus(t, w, 200)
+ var item StudentCall
+ if err := json.Unmarshal(w.Body.Bytes(), &item); err != nil {
+ t.Fatal(err)
+ }
+ if strings.Contains(w.Body.String(), "request_id") {
+ t.Fatal("internal replay key leaked")
+ }
+ for _, key := range []string{"read_at", "read_by", "read_by_name"} {
+ if !strings.Contains(w.Body.String(), `"`+key+`":""`) && item.ReadAt == "" {
+ t.Fatalf("missing empty field %s: %s", key, w.Body)
+ }
+ }
+ return item
+}
+
+func listStudentCall(t *testing.T, f *securityFixture, who, suffix string) studentCallList {
+ t.Helper()
+ w := f.request(t, "GET", studentCallsPath+suffix, f.token(who), nil, nil)
+ expectStatus(t, w, 200)
+ var list studentCallList
+ if err := json.Unmarshal(w.Body.Bytes(), &list); err != nil {
+ t.Fatal(err)
+ }
+ return list
+}
+
+func callPeer(t *testing.T, a *App, uid string) *Peer {
+ t.Helper()
+ ctx, cancel := context.WithCancel(context.Background())
+ u := a.state.Users[uid]
+ session, _ := a.verify(a.access(u))
+ p := &Peer{ctx: ctx, cancel: cancel, UserID: uid, queue: make(chan Frame, 256), teacherSession: session}
+ a.hub.addTeacher(p)
+ t.Cleanup(func() { cancel(); a.hub.removeTeacherPeer(p) })
+ return p
+}
+
+func takeCallFrame(t *testing.T, p *Peer, item StudentCall, kind string) {
+ t.Helper()
+ select {
+ case frame := <-p.queue:
+ if frame.T != "student_call" || !reflect.DeepEqual(frame.D, map[string]any{"id": item.ID, "class_id": item.ClassID, "kind": kind}) {
+ t.Fatalf("wrong frame: %+v", frame)
+ }
+ default:
+ t.Fatal("missing call frame")
+ }
+}
+
+func TestStudentCallsPermissionsReplayAndSharedRead(t *testing.T) {
+ f := newSecurityFixture(t)
+ owner := callPeer(t, f.app, "owner_a")
+ collab := callPeer(t, f.app, "collab_a")
+ outsiders := []*Peer{callPeer(t, f.app, "school_a"), callPeer(t, f.app, "super"), callPeer(t, f.app, "owner_b"), callPeer(t, f.app, "outsider")}
+ item := postStudentCall(t, f, "token-a", "request-1")
+ if item.ClassID != "class_a" || item.DeviceID != "device_a" || item.ClassName != "甲班" || item.DeviceName != "甲设备" || item.ReadAt != "" {
+ t.Fatalf("wrong snapshot: %+v", item)
+ }
+ for _, p := range []*Peer{owner, collab} {
+ takeCallFrame(t, p, item, "created")
+ }
+ if replay := postStudentCall(t, f, "token-a", "request-1"); replay != item {
+ t.Fatal("replay changed record")
+ }
+ for _, who := range []string{"school", "super", "other", "outsider"} {
+ if list := listStudentCall(t, f, who, ""); len(list.Items) != 0 || list.UnreadCount != 0 {
+ t.Fatalf("%s leaked calls", who)
+ }
+ expectStatus(t, f.request(t, "GET", studentCallsPath+"?class_id=class_a", f.token(who), nil, nil), 403)
+ expectStatus(t, f.request(t, "POST", studentCallsPath+"/"+item.ID+"/read", f.token(who), nil, nil), 403)
+ }
+ expectStatus(t, f.request(t, "GET", studentCallsPath, "", nil, nil), 401)
+ expectStatus(t, f.request(t, "GET", studentCallsPath, "token-a", nil, nil), 401)
+ expectStatus(t, f.request(t, "POST", deviceCallsPath, f.token("owner"), map[string]string{"request_id": "teacher"}, nil), 401)
+ expectStatus(t, f.request(t, "POST", studentCallsPath+"/"+item.ID+"/read", "token-a", nil, nil), 401)
+ expectStatus(t, f.request(t, "POST", deviceCallsPath, "token-b", map[string]string{"request_id": "request-1"}, nil), 409)
+ w := f.request(t, "POST", deviceCallsPath, "token-a", map[string]string{"request_id": "request-2"}, nil)
+ expectStatus(t, w, 429)
+ if w.Header().Get("Retry-After") == "" {
+ t.Fatal("cooldown lacks Retry-After")
+ }
+ for _, who := range []string{"owner", "collab"} {
+ if got := listStudentCall(t, f, who, ""); got.UnreadCount != 1 || len(got.Items) != 1 || got.Items[0] != item {
+ t.Fatal("list should not auto-read")
+ }
+ }
+ w = f.request(t, "POST", studentCallsPath+"/"+item.ID+"/read", f.token("collab"), nil, nil)
+ expectStatus(t, w, 200)
+ var read StudentCall
+ if err := json.Unmarshal(w.Body.Bytes(), &read); err != nil {
+ t.Fatal(err)
+ }
+ if read.ReadAt == "" || read.ReadBy != "collab_a" || read.ReadByName != "乙老师" {
+ t.Fatalf("wrong processing snapshot: %+v", read)
+ }
+ for _, p := range []*Peer{owner, collab} {
+ takeCallFrame(t, p, item, "read")
+ }
+ w = f.request(t, "POST", studentCallsPath+"/"+item.ID+"/read", f.token("owner"), nil, nil)
+ expectStatus(t, w, 200)
+ var again StudentCall
+ _ = json.Unmarshal(w.Body.Bytes(), &again)
+ if again != read || postStudentCall(t, f, "token-a", "request-1") != read {
+ t.Fatal("idempotent read/replay changed first processor")
+ }
+ if got := listStudentCall(t, f, "owner", ""); got.UnreadCount != 0 || len(got.Items) != 1 {
+ t.Fatal("shared read lost record or unread did not clear")
+ }
+ for _, p := range append(outsiders, owner, collab) {
+ if len(p.queue) != 0 {
+ t.Fatal("unauthorized or duplicate event")
+ }
+ }
+}
+
+func TestStudentCallsStrictInputBindingAndLicenses(t *testing.T) {
+ f := newSecurityFixture(t)
+ for _, body := range []any{nil, map[string]any{}, map[string]any{"request_id": nil}, map[string]any{"request_id": 3}, map[string]any{"request_id": ""}, map[string]any{"request_id": " white "}, map[string]any{"request_id": strings.Repeat("a", 129)}, map[string]any{"request_id": "中文"}, map[string]any{"request_id": "ok", "class_id": "class_b"}, map[string]any{"request_id": "ok", "device_id": "device_b"}} {
+ expectStatus(t, f.request(t, "POST", deviceCallsPath, "token-a", body, nil), 400)
+ }
+ for _, raw := range []string{`null`, `{"request_id":"ok"}{}`, `["ok"]`} {
+ req := httptest.NewRequest("POST", deviceCallsPath, strings.NewReader(raw))
+ req.Header.Set("Authorization", "Bearer token-a")
+ w := httptest.NewRecorder()
+ f.app.Handler().ServeHTTP(w, req)
+ expectStatus(t, w, 400)
+ }
+ item := postStudentCall(t, f, "token-a", "aA0_.:-")
+ d := f.app.state.Devices["device_a"]
+ d.ClassID = "class_b"
+ f.app.state.Devices[d.ID] = d
+ expectStatus(t, f.request(t, "POST", deviceCallsPath, "token-a", map[string]string{"request_id": "aA0_.:-"}, nil), 409)
+ moved := postStudentCall(t, f, "token-a", "moved")
+ if moved.ClassID != "class_b" || item.ClassID != "class_a" {
+ t.Fatal("binding not authoritative")
+ }
+ d.ClassID = "class_a"
+ f.app.state.Devices[d.ID] = d
+ for _, mode := range []string{"expired", "revoked", "released", "missing", "wrong_class"} {
+ original := f.app.state.Codes["CODE-A"]
+ code := original
+ switch mode {
+ case "expired":
+ expired := stamp(time.Now().Add(-time.Hour))
+ code.ExpiresAt = &expired
+ case "revoked":
+ code.Status = LicenseRevoked
+ case "released":
+ code.Status = LicenseReleased
+ case "wrong_class":
+ other := "class_b"
+ code.ClassID = &other
+ }
+ f.app.state.Codes["CODE-A"] = code
+ if mode == "missing" {
+ delete(f.app.state.Codes, "CODE-A")
+ }
+ expectStatus(t, f.request(t, "POST", deviceCallsPath, "token-a", map[string]string{"request_id": "aA0_.:-"}, nil), 403)
+ f.app.state.Codes["CODE-A"] = original
+ }
+ delete(f.app.state.Devices, "device_a")
+ expectStatus(t, f.request(t, "POST", deviceCallsPath, "token-a", map[string]string{"request_id": "aA0_.:-"}, nil), 401)
+}
+
+func TestStudentCallsClassWideCooldownAndRollback(t *testing.T) {
+ f := newSecurityFixture(t)
+ p := callPeer(t, f.app, "owner_a")
+ restore := closeStoreForRollback(t, f.app)
+ expectStatus(t, f.request(t, "POST", deviceCallsPath, "token-a", map[string]string{"request_id": "retry"}, nil), 500)
+ if len(f.app.state.StudentCalls) != 0 || len(p.queue) != 0 {
+ t.Fatal("failed create leaked state/event")
+ }
+ restore()
+ item := postStudentCall(t, f, "token-a", "retry")
+ takeCallFrame(t, p, item, "created")
+ // A second device shares the class cooldown, not its own separate timer.
+ f.app.state.Devices["device_a2"] = Device{ID: "device_a2", ClassID: "class_a", Name: "second", TokenHash: hash("token-a2")}
+ expectStatus(t, f.request(t, "POST", deviceCallsPath, "token-a2", map[string]string{"request_id": "retry"}, nil), 409)
+ expectStatus(t, f.request(t, "POST", deviceCallsPath, "token-a2", map[string]string{"request_id": "second"}, nil), 429)
+ postStudentCall(t, f, "token-b", "other-class")
+ restore = closeStoreForRollback(t, f.app)
+ expectStatus(t, f.request(t, "POST", studentCallsPath+"/"+item.ID+"/read", f.token("owner"), nil, nil), 500)
+ if f.app.state.StudentCalls[item.ID].ReadAt != "" || len(p.queue) != 0 {
+ t.Fatal("failed read consumed state/event")
+ }
+ restore()
+ expectStatus(t, f.request(t, "POST", studentCallsPath+"/"+item.ID+"/read", f.token("collab"), nil, nil), 200)
+ takeCallFrame(t, p, item, "read")
+ old := f.app.state.StudentCalls[item.ID]
+ old.CreatedAt = stamp(time.Now().Add(-31 * time.Second))
+ f.app.state.StudentCalls[item.ID] = old
+ postStudentCall(t, f, "token-a2", "second")
+}
+
+func TestStudentCallsConcurrentReadAndCreate(t *testing.T) {
+ f := newSecurityFixture(t)
+ var wg sync.WaitGroup
+ ids := make(chan string, 12)
+ for i := 0; i < 12; i++ {
+ wg.Go(func() {
+ w := f.request(t, "POST", deviceCallsPath, "token-a", map[string]string{"request_id": "concurrent"}, nil)
+ expectStatus(t, w, 200)
+ var item StudentCall
+ _ = json.Unmarshal(w.Body.Bytes(), &item)
+ ids <- item.ID
+ })
+ }
+ wg.Wait()
+ close(ids)
+ var id string
+ for got := range ids {
+ if id != "" && got != id {
+ t.Fatal("duplicate request created different records")
+ }
+ id = got
+ }
+ if len(f.app.state.StudentCalls) != 1 {
+ t.Fatal("duplicate records")
+ }
+ reads := make(chan StudentCall, 12)
+ for i := 0; i < 12; i++ {
+ who := "owner"
+ if i%2 == 0 {
+ who = "collab"
+ }
+ wg.Go(func() {
+ w := f.request(t, "POST", studentCallsPath+"/"+id+"/read", f.token(who), nil, nil)
+ expectStatus(t, w, 200)
+ var item StudentCall
+ _ = json.Unmarshal(w.Body.Bytes(), &item)
+ reads <- item
+ })
+ }
+ wg.Wait()
+ close(reads)
+ first := f.app.state.StudentCalls[id].StudentCall
+ for got := range reads {
+ if got != first {
+ t.Fatal("concurrent read overwrote first processor")
+ }
+ }
+ if first.ReadAt == "" || first.ReadBy == "" {
+ t.Fatal("not processed")
+ }
+}
+
+func TestStudentCallsBroadcastRechecksCurrentAccess(t *testing.T) {
+ f := newSecurityFixture(t)
+ item := postStudentCall(t, f, "token-a", "pending-event")
+ owner := callPeer(t, f.app, "owner_a")
+ collab := callPeer(t, f.app, "collab_a")
+ outsider := callPeer(t, f.app, "outsider")
+ f.app.studentCallChanged(f.app.state.StudentCalls[item.ID], "read")
+ jobs := f.app.jobs
+ f.app.jobs = nil
+ delete(f.app.state.Members, memberKey("class_a", "collab_a"))
+ u := f.app.state.Users["owner_a"]
+ u.Status = AccountDisabled
+ f.app.state.Users[u.ID] = u
+ for _, job := range jobs {
+ job()
+ }
+ for _, p := range []*Peer{owner, collab, outsider} {
+ if len(p.queue) != 0 {
+ t.Fatal("lost/absent permission leaked broadcast")
+ }
+ }
+ expectStatus(t, f.request(t, "GET", studentCallsPath, f.token("owner"), nil, nil), 401)
+ expectStatus(t, f.request(t, "POST", studentCallsPath+"/"+item.ID+"/read", f.token("collab"), nil, nil), 403)
+ f.app.studentCallChanged(f.app.state.StudentCalls[item.ID], "created")
+ jobs = f.app.jobs
+ f.app.jobs = nil
+ delete(f.app.state.StudentCalls, item.ID)
+ for _, job := range jobs {
+ job()
+ }
+ if len(collab.queue) != 0 {
+ t.Fatal("deleted call event leaked")
+ }
+}
+
+func TestStudentCallsPaginationCountsAndStableTimes(t *testing.T) {
+ f := newSecurityFixture(t)
+ at := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
+ for i := 0; i < 105; i++ {
+ id := fmt.Sprintf("call_%03d", i)
+ f.app.state.StudentCalls[id] = studentCallRecord{StudentCall: StudentCall{ID: id, ClassID: "class_a", ClassName: "甲班", DeviceID: "device_a", DeviceName: "甲设备", CreatedAt: stamp(at.Add(time.Duration(i/2) * time.Second))}, RequestID: id}
+ }
+ first := listStudentCall(t, f, "owner", "")
+ if len(first.Items) != 100 || first.UnreadCount != 105 || first.NextCursor == nil {
+ t.Fatal("default window must not truncate unread count")
+ }
+ second := listStudentCall(t, f, "owner", "?cursor="+*first.NextCursor)
+ if len(second.Items) != 5 || second.UnreadCount != 105 || second.NextCursor != nil {
+ t.Fatal("wrong next page")
+ }
+ all := append(first.Items, second.Items...)
+ seen := map[string]bool{}
+ for _, item := range all {
+ if seen[item.ID] {
+ t.Fatal("cursor duplicated tied timestamp")
+ }
+ seen[item.ID] = true
+ }
+ for _, suffix := range []string{"?limit=0", "?limit=101", "?limit=oops", "?limit=", "?limit=1&limit=2", "?cursor=bad", "?cursor=" + base64.RawURLEncoding.EncodeToString([]byte(`{"created_at":"invalid","id":"x"}`))} {
+ expectStatus(t, f.request(t, "GET", studentCallsPath+suffix, f.token("owner"), nil, nil), 400)
+ }
+ fractional := StudentCall{ID: "frac", CreatedAt: "2026-01-01T00:00:00.1Z"}
+ whole := StudentCall{ID: "whole", CreatedAt: "2026-01-01T00:00:00Z"}
+ if compareStudentCalls(fractional, whole) >= 0 {
+ t.Fatal("fractional timestamp sorted before whole second")
+ }
+ if got := listStudentCall(t, f, "school", ""); got.UnreadCount != 0 {
+ t.Fatal("admin got school count")
+ }
+}
+
+func TestStudentCallsPersistRestartAndClassDeletion(t *testing.T) {
+ seed := newSecurityFixture(t)
+ config := Config{DataDir: t.TempDir(), JWTSecret: strings.Repeat("r", 32)}
+ a, err := New(config)
+ if err != nil {
+ t.Fatal(err)
+ }
+ data, _ := json.Marshal(seed.app.state)
+ if err = json.Unmarshal(data, &a.state); err != nil {
+ t.Fatal(err)
+ }
+ f := &securityFixture{app: a, users: seed.users}
+ item := postStudentCall(t, f, "token-a", "restart")
+ if err = a.Close(); err != nil {
+ t.Fatal(err)
+ }
+ a, err = New(config)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = a.Close() })
+ f.app = a
+ if replay := postStudentCall(t, f, "token-a", "restart"); replay != item {
+ t.Fatal("restart lost request dedupe")
+ }
+ expectStatus(t, f.request(t, "POST", deviceCallsPath, "token-a", map[string]string{"request_id": "restart-new"}, nil), 429)
+ if got := listStudentCall(t, f, "owner", ""); got.UnreadCount != 1 {
+ t.Fatal("offline records lost")
+ }
+ expectStatus(t, f.request(t, "POST", studentCallsPath+"/"+item.ID+"/read", f.token("collab"), nil, nil), 200)
+ var persisted State
+ if err = a.store.Load(context.Background(), &persisted); err != nil || persisted.StudentCalls[item.ID].ReadBy != "collab_a" {
+ t.Fatal("shared read not durable", err)
+ }
+ expectStatus(t, f.request(t, "DELETE", "/api/v1/classes/class_a", f.token("owner"), nil, nil), 200)
+ if len(a.state.StudentCalls) != 0 {
+ t.Fatal("class deletion left call records")
+ }
+ persisted = State{}
+ if err = a.store.Load(context.Background(), &persisted); err != nil || len(persisted.StudentCalls) != 0 {
+ t.Fatal("class deletion not durable", err)
+ }
+}
diff --git a/server/internal/repo/store.go b/server/internal/repo/store.go
index 6dd35c4..bcdb00e 100644
--- a/server/internal/repo/store.go
+++ b/server/internal/repo/store.go
@@ -11,7 +11,7 @@ import (
"path/filepath"
)
-var tables = []string{"users", "classes", "class_members", "devices", "messages", "homeworks", "activation_codes", "collab_invites", "sessions", "phrases", "audio", "audit_logs", "settings", "schools"}
+var tables = []string{"users", "classes", "class_members", "devices", "messages", "homeworks", "student_calls", "activation_codes", "collab_invites", "sessions", "phrases", "audio", "audit_logs", "settings", "schools"}
type Store struct {
db *sql.DB
@@ -44,7 +44,7 @@ func Open(path string) (*Store, error) {
}
s.saved[table] = map[string]string{}
}
- for _, q := range []string{`CREATE UNIQUE INDEX IF NOT EXISTS users_username ON users(json_extract(body,'$.username'))`, `CREATE INDEX IF NOT EXISTS classes_org ON classes(json_extract(body,'$.org_id'))`, `CREATE INDEX IF NOT EXISTS devices_class ON devices(json_extract(body,'$.class_id'))`, `CREATE INDEX IF NOT EXISTS messages_created ON messages(json_extract(body,'$.created_at'))`, `CREATE INDEX IF NOT EXISTS homeworks_class ON homeworks(json_extract(body,'$.class_id'))`, `CREATE INDEX IF NOT EXISTS members_class ON class_members(json_extract(body,'$.class_id'))`} {
+ for _, q := range []string{`CREATE UNIQUE INDEX IF NOT EXISTS users_username ON users(json_extract(body,'$.username'))`, `CREATE INDEX IF NOT EXISTS classes_org ON classes(json_extract(body,'$.org_id'))`, `CREATE INDEX IF NOT EXISTS devices_class ON devices(json_extract(body,'$.class_id'))`, `CREATE INDEX IF NOT EXISTS messages_created ON messages(json_extract(body,'$.created_at'))`, `CREATE UNIQUE INDEX IF NOT EXISTS student_calls_request ON student_calls(json_extract(body,'$.request_id'))`, `CREATE INDEX IF NOT EXISTS student_calls_class ON student_calls(json_extract(body,'$.class_id'))`, `CREATE INDEX IF NOT EXISTS homeworks_class ON homeworks(json_extract(body,'$.class_id'))`, `CREATE INDEX IF NOT EXISTS members_class ON class_members(json_extract(body,'$.class_id'))`} {
if _, err = db.Exec(q); err != nil {
db.Close()
return nil, err
diff --git a/tests/e2e/homework.spec.ts b/tests/e2e/homework.spec.ts
index 0895bf1..41f1c47 100644
--- a/tests/e2e/homework.spec.ts
+++ b/tests/e2e/homework.spec.ts
@@ -90,10 +90,13 @@ test('教师发布、修改、切换显示设置并删除作业,布局适应
return { top: rect.top, left: rect.left, right: rect.right };
}),
);
- expect(positions).toHaveLength(5);
- expect(new Set(positions.map((position) => position.top)).size).toBe(1);
- for (let index = 1; index < positions.length; index++)
- expect(positions[index].left).toBeGreaterThanOrEqual(positions[index - 1].right - 1);
+ expect(positions).toHaveLength(6);
+ expect(new Set(positions.map((position) => position.top)).size).toBe(2);
+ for (const row of [positions.slice(0, 3), positions.slice(3)]) {
+ expect(new Set(row.map((position) => position.top)).size).toBe(1);
+ for (let index = 1; index < row.length; index++)
+ expect(row[index].left).toBeGreaterThanOrEqual(row[index - 1].right - 1);
+ }
}
await expect(card).toBeVisible();
await page.screenshot({ path: info.outputPath(`homework-${width}-${scheme}.png`), fullPage: true });
diff --git a/tests/e2e/student-calls-integration.spec.ts b/tests/e2e/student-calls-integration.spec.ts
new file mode 100644
index 0000000..7e0631e
--- /dev/null
+++ b/tests/e2e/student-calls-integration.spec.ts
@@ -0,0 +1,162 @@
+import { expect, test, type APIRequestContext, type Page } from '@playwright/test';
+
+const apiURL = 'http://127.0.0.1:18080';
+const teacherURL = 'http://127.0.0.1:15173';
+const password = 'StudentCallFixture2026';
+const auth = (token: string) => ({ Authorization: 'Bearer ' + token });
+
+async function signIn(api: APIRequestContext, username: string, secret = password, scope = 'teacher') {
+ const response = await api.post('/api/v1/auth/login', {
+ headers: { 'X-ClassDing-Auth-Scope': scope },
+ data: { username, password: secret },
+ });
+ expect(response.status()).toBe(200);
+ return (await response.json()).access_token as string;
+}
+async function login(page: Page, username: string) {
+ await page.goto(teacherURL + '/login');
+ await page.getByLabel('用户名', { exact: true }).fill(username);
+ await page.getByLabel('密码', { exact: true }).fill(password);
+ await page.getByRole('button', { name: '进入工作台' }).click();
+ await expect(page.getByText('实时连接已开启')).toBeVisible();
+}
+
+test('真实设备呼叫向两位老师实时送达,打开不自动已读且共享处理持久保存', async ({
+ page,
+ browser,
+ playwright,
+}, info) => {
+ const api = await playwright.request.newContext({ baseURL: apiURL });
+ const otherContext = await browser.newContext({ viewport: info.project.use.viewport });
+ const other = await otherContext.newPage();
+ const screen = await otherContext.newPage();
+ const suffix = Date.now().toString(36) + (info.project.name === 'mobile' ? 'm' : 'd');
+ const ownerName = 'call.owner.' + suffix;
+ const colleagueName = 'call.peer.' + suffix;
+ let classId = '';
+ const userIds: string[] = [];
+ let adminToken = '';
+ try {
+ adminToken = await signIn(api, 'admin', 'AdminDing2026!', 'admin');
+ for (const [username, display_name] of [
+ [ownerName, '呼叫班主任'],
+ [colleagueName, '呼叫任课老师'],
+ ]) {
+ const response = await api.post('/api/v1/admin/users', {
+ headers: auth(adminToken),
+ data: { username, password, display_name },
+ });
+ expect(response.status()).toBe(200);
+ userIds.push((await response.json()).id);
+ }
+ const createdClass = await api.post('/api/v1/admin/classes', {
+ headers: auth(adminToken),
+ data: { name: '呼叫验收班' + suffix, owner_id: userIds[0], max_teachers: 2, max_devices: 1 },
+ });
+ expect(createdClass.status()).toBe(200);
+ classId = (await createdClass.json()).id;
+ const ownerToken = await signIn(api, ownerName);
+ const colleagueToken = await signIn(api, colleagueName);
+ const invite = await api.post('/api/v1/classes/' + classId + '/invites', { headers: auth(ownerToken) });
+ expect(invite.status()).toBe(200);
+ const joined = await api.post('/api/v1/classes/join', {
+ headers: auth(colleagueToken),
+ data: { code: (await invite.json()).code },
+ });
+ expect(joined.status()).toBe(200);
+ const bindCode = await api.post('/api/v1/classes/' + classId + '/bind-code', {
+ headers: auth(ownerToken),
+ });
+ expect(bindCode.status()).toBe(200);
+ await screen.goto('http://127.0.0.1:15175');
+ await screen.getByLabel('6 位班级绑定码').fill((await bindCode.json()).code);
+ await screen.getByRole('button', { name: '设备与服务器设置', exact: true }).click();
+ await screen.getByLabel('设备名称', { exact: true }).fill('呼叫验收大屏');
+ await screen.getByRole('button', { name: '连接班级', exact: true }).click();
+ await expect(screen.getByText('连接正常', { exact: true })).toBeVisible();
+ await login(page, ownerName);
+ await login(other, colleagueName);
+ let callRequests = 0;
+ screen.on('request', (request) => {
+ if (request.url().endsWith('/api/v1/device/student-calls') && request.method() === 'POST')
+ callRequests++;
+ });
+ await screen.getByRole('button', { name: '呼叫老师', exact: true }).click();
+ const confirmation = screen.getByRole('dialog');
+ await expect(confirmation.getByText('确认呼叫老师?', { exact: true })).toBeVisible();
+ await screen.screenshot({ path: info.outputPath('student-call-confirmation.png'), fullPage: true });
+ await confirmation.getByRole('button', { name: '取消', exact: true }).click();
+ await expect(confirmation).toBeHidden();
+ expect(callRequests).toBe(0);
+ const afterCancel = await api.get('/api/v1/student-calls', { headers: auth(ownerToken) });
+ expect(await afterCancel.json()).toMatchObject({ items: [], unread_count: 0 });
+ await screen.getByRole('button', { name: '呼叫老师', exact: true }).click();
+ const sent = screen.waitForResponse(
+ (response) =>
+ response.url().endsWith('/api/v1/device/student-calls') && response.request().method() === 'POST',
+ );
+ await confirmation.getByRole('button', { name: '确认呼叫', exact: true }).click();
+ const response = await sent;
+ expect(response.status()).toBe(200);
+ expect(callRequests).toBe(1);
+ await expect(confirmation.getByRole('status')).toContainText('呼叫已发送');
+ const requestId = response.request().postDataJSON().request_id as string;
+ const deviceAuthorization = response.request().headers()['authorization'];
+ const send = () =>
+ api.post('/api/v1/device/student-calls', {
+ headers: { Authorization: deviceAuthorization },
+ data: { request_id: requestId },
+ });
+ const call = await response.json();
+ expect((await (await send()).json()).id).toBe(call.id);
+ const tooSoon = await api.post('/api/v1/device/student-calls', {
+ headers: { Authorization: deviceAuthorization },
+ data: { request_id: requestId + '-new' },
+ });
+ expect(tooSoon.status()).toBe(429);
+ expect(Number(tooSoon.headers()['retry-after'])).toBeGreaterThan(0);
+ for (const teacher of [page, other]) {
+ const entry = teacher
+ .getByRole('link', { name: '学生呼叫,1 条未读', exact: true })
+ .filter({ visible: true });
+ await expect(entry).toBeVisible({ timeout: 8000 });
+ await entry.click();
+ await expect(teacher.getByRole('heading', { name: '学生呼叫', exact: true })).toBeVisible();
+ await expect(teacher.locator('[data-call-id="' + call.id + '"]')).toContainText('呼叫验收大屏');
+ await expect(teacher.getByRole('button', { name: '标记已读' })).toBeVisible();
+ }
+ // Viewing and reloading must never mutate shared unread state.
+ await page.reload();
+ await expect(page.getByRole('button', { name: '标记已读' })).toBeVisible();
+ await page.screenshot({ path: info.outputPath('student-call-unread.png'), fullPage: true });
+ const before = await api.get('/api/v1/student-calls', { headers: auth(ownerToken) });
+ expect((await before.json()).unread_count).toBe(1);
+ await other.getByRole('button', { name: '标记已读' }).click();
+ for (const teacher of [page, other]) {
+ const card = teacher.locator('[data-call-id="' + call.id + '"]');
+ await expect(card).toContainText('呼叫任课老师 已处理', { timeout: 8000 });
+ await expect(card.getByRole('button', { name: '标记已读' })).toHaveCount(0);
+ await expect(teacher.getByText('0 条未读', { exact: true })).toBeVisible();
+ }
+ await page.reload();
+ await expect(page.locator('[data-call-id="' + call.id + '"]')).toContainText('呼叫任课老师 已处理');
+ const saved = await api.get('/api/v1/student-calls', { headers: auth(colleagueToken) });
+ expect(await saved.json()).toMatchObject({
+ unread_count: 0,
+ items: [{ id: call.id, read_by: userIds[1] }],
+ });
+ } finally {
+ await otherContext.close();
+ if (adminToken && classId) {
+ expect(
+ (await api.delete('/api/v1/admin/classes/' + classId, { headers: auth(adminToken) })).status(),
+ ).toBe(200);
+ }
+ for (const id of userIds) {
+ expect((await api.delete('/api/v1/admin/users/' + id, { headers: auth(adminToken) })).status()).toBe(
+ 200,
+ );
+ }
+ await api.dispose();
+ }
+});
diff --git a/tests/e2e/student-calls.spec.ts b/tests/e2e/student-calls.spec.ts
new file mode 100644
index 0000000..d35383d
--- /dev/null
+++ b/tests/e2e/student-calls.spec.ts
@@ -0,0 +1,218 @@
+import { expect, test, type Page, type WebSocketRoute } from '@playwright/test';
+import type { StudentCall } from '../../web/apps/teacher/src/models';
+
+const teacherURL = 'http://127.0.0.1:15173';
+const call = (id = 'call-1'): StudentCall => ({
+ id,
+ class_id: 'class-1',
+ class_name: '一年级(1)班',
+ device_id: 'device-1',
+ device_name: '教室大屏',
+ created_at: '2026-11-13T01:00:00Z',
+ read_at: '',
+ read_by: '',
+ read_by_name: '',
+});
+
+async function mockCalls(page: Page, initial = [call()]) {
+ const state = {
+ items: initial,
+ reads: 0,
+ gets: 0,
+ legacy: false,
+ account: 'teacher-a',
+ loggedIn: true,
+ cursor: false,
+ };
+ const sockets = new Set();
+ await page.routeWebSocket('**/ws/teacher', (socket) => {
+ sockets.add(socket);
+ socket.onMessage((message) => {
+ const frame = JSON.parse(String(message));
+ if (frame.t === 'hello') socket.send(JSON.stringify({ t: 'welcome', d: {} }));
+ if (frame.t === 'ping') socket.send(JSON.stringify({ t: 'pong', d: {} }));
+ });
+ socket.onClose(() => sockets.delete(socket));
+ });
+ await page.route('**/api/v1/**', async (route) => {
+ const path = new URL(route.request().url()).pathname.replace('/api/v1', '');
+ const user = { id: state.account, username: state.account, display_name: '测试老师', ui_scale: 1 };
+ if (path === '/auth/logout') {
+ state.loggedIn = false;
+ return route.fulfill({ json: { ok: true } });
+ }
+ if (path === '/auth/login') {
+ state.loggedIn = true;
+ return route.fulfill({ json: { access_token: state.account, user } });
+ }
+ if (path === '/auth/refresh' || path === '/me')
+ return route.fulfill(
+ state.loggedIn
+ ? { json: path === '/me' ? user : { access_token: state.account, user } }
+ : { status: 401, json: { code: 'UNAUTHORIZED', message: '请登录' } },
+ );
+ if (path === '/student-calls') {
+ state.gets++;
+ if (state.legacy)
+ return route.fulfill({ status: 404, json: { code: 'NOT_FOUND', message: 'unknown endpoint' } });
+ const later = new URL(route.request().url()).searchParams.has('cursor');
+ return route.fulfill({
+ json: {
+ items: state.cursor ? state.items.slice(later ? 1 : 0, later ? undefined : 1) : state.items,
+ unread_count: state.items.filter((item) => !item.read_at).length,
+ next_cursor: state.cursor && !later ? 'older-page' : null,
+ },
+ });
+ }
+ if (path.endsWith('/read')) {
+ state.reads++;
+ const id = path.split('/')[2];
+ state.items = state.items.map((item) =>
+ item.id !== id || item.read_at
+ ? item
+ : {
+ ...item,
+ read_at: '2026-11-13T01:02:00Z',
+ read_by: 'teacher-b',
+ read_by_name: '李老师',
+ },
+ );
+ return route.fulfill({ json: state.items.find((item) => item.id === id) });
+ }
+ if (path === '/public/site') return route.fulfill({ json: {} });
+ return route.fulfill({ json: [] });
+ });
+ const emit = (kind: 'created' | 'read', id = 'call-1') => {
+ for (const socket of sockets)
+ socket.send(JSON.stringify({ t: 'student_call', d: { id, class_id: 'class-1', kind } }));
+ };
+ return { state, sockets, emit };
+}
+
+function navigation(page: Page) {
+ return page.locator((page.viewportSize()?.width ?? 1440) < 768 ? '.bottom-nav' : '.side-nav');
+}
+
+test('学生呼叫打开不自动已读,显式处理共享结果并刷新恢复,后页角标同步', async ({ page }) => {
+ const { state } = await mockCalls(page, [call(), call('call-2')]);
+ state.cursor = true;
+ await page.goto(`${teacherURL}/student-calls`);
+ const nav = navigation(page).locator('a[href="/student-calls"]');
+ await expect(nav).toHaveAttribute('aria-label', '学生呼叫,2 条未读');
+ await expect(page.locator('.student-call-card')).toHaveCount(1);
+ await expect(page.getByText('打开页面不会自动标记已读。', { exact: false })).toBeVisible();
+ expect(state.reads).toBe(0);
+ await page.getByRole('button', { name: '加载更早的呼叫' }).click();
+ const older = page.locator('[data-call-id="call-2"]');
+ await expect(older).toBeVisible();
+ expect(state.reads).toBe(0);
+ await older.getByRole('button', { name: '标记已读' }).click();
+ await expect(older).toContainText('李老师 已处理');
+ await expect(nav).toHaveAttribute('aria-label', '学生呼叫,1 条未读');
+ expect(state.reads).toBe(1);
+ await page.reload();
+ await page.getByRole('button', { name: '加载更早的呼叫' }).click();
+ await expect(older).toContainText('已处理');
+ await expect(older.getByRole('button', { name: '标记已读' })).toHaveCount(0);
+ expect(state.reads).toBe(1);
+});
+
+test('学生呼叫 WS 实时更新只提醒新增,处理后共享消角标,断线重连补查', async ({ page }) => {
+ const { state, sockets, emit } = await mockCalls(page, []);
+ await page.goto(`${teacherURL}/student-calls`);
+ await expect(page.getByText('实时连接已开启')).toBeVisible();
+ state.items = [call()];
+ emit('created');
+ await expect(page.locator('.student-call-card')).toHaveCount(1);
+ await expect(page.locator('[data-sonner-toast]')).toContainText('一年级(1)班的学生正在呼叫老师');
+ emit('created');
+ await expect(page.locator('[data-sonner-toast]')).toHaveCount(1);
+ state.items = [
+ { ...call(), read_at: '2026-11-13T01:02:00Z', read_by: 'teacher-b', read_by_name: '李老师' },
+ ];
+ emit('read');
+ await expect(page.locator('.student-call-card')).toContainText('李老师 已处理');
+ await expect(navigation(page).locator('a[href="/student-calls"]')).toHaveAttribute(
+ 'aria-label',
+ '学生呼叫',
+ );
+ expect(state.reads).toBe(0);
+ for (const socket of sockets) socket.close();
+ state.items.unshift(call('missed-call'));
+ await expect(page.locator('[data-call-id="missed-call"]')).toBeVisible();
+});
+
+test('学生呼叫旧服务404停止自动重试且不刷toast,手动刷新可恢复', async ({ page }) => {
+ const { state } = await mockCalls(page);
+ state.legacy = true;
+ await page.clock.install();
+ await page.goto(`${teacherURL}/student-calls`);
+ await expect(page.getByText('当前服务暂未开放学生呼叫')).toBeVisible();
+ const requests = state.gets;
+ await page.clock.fastForward(120000);
+ expect(state.gets).toBe(requests);
+ await expect(page.locator('[data-sonner-toast]')).toHaveCount(0);
+ state.legacy = false;
+ await page.getByRole('button', { name: '刷新', exact: true }).click();
+ await expect(page.locator('.student-call-card')).toHaveCount(1);
+});
+
+test('学生呼叫断开实时通道仍轮询补查', async ({ page }) => {
+ const { state, sockets } = await mockCalls(page, []);
+ await page.clock.install();
+ await page.goto(`${teacherURL}/student-calls`);
+ await expect(page.getByText('还没有学生呼叫')).toBeVisible();
+ for (const socket of sockets) socket.close();
+ state.items = [call('polled-call')];
+ await page.clock.fastForward(31000);
+ await expect(page.locator('[data-call-id="polled-call"]')).toBeVisible();
+});
+
+test('学生呼叫手机六项导航两行三列,标准与大字号不横向拥挤', async ({ page }) => {
+ await page.setViewportSize({ width: 320, height: 760 });
+ await mockCalls(page);
+ await page.goto(`${teacherURL}/student-calls`);
+ await expect(page.locator('.student-call-card')).toHaveCount(1);
+ for (const size of [16, 24]) {
+ await page.evaluate((size) => {
+ document.documentElement.style.fontSize = `${size}px`;
+ }, size);
+ const links = page.locator('.bottom-nav a');
+ await expect(links).toHaveCount(6);
+ const boxes = await links.evaluateAll((elements) =>
+ elements.map((item) => {
+ const rect = item.getBoundingClientRect();
+ return { x: rect.x, y: rect.y, width: rect.width, height: rect.height, right: rect.right };
+ }),
+ );
+ expect(boxes[0].y).toBe(boxes[2].y);
+ expect(boxes[3].y).toBe(boxes[5].y);
+ expect(boxes[3].y).toBeGreaterThan(boxes[0].y);
+ expect(boxes.every((box) => box.height >= 44 && box.width >= 80 && box.right <= 321)).toBe(true);
+ await expect(page.locator('.bottom-nav a[href="/student-calls"]')).toContainText('学生呼叫');
+ await page.getByRole('button', { name: '标记已读' }).scrollIntoViewIfNeeded();
+ await expect(page.getByRole('button', { name: '标记已读' })).toBeInViewport();
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
+ }
+});
+
+test('学生呼叫退出换账号不泄漏列表角标和提醒', async ({ page }) => {
+ const { state, emit } = await mockCalls(page);
+ await page.goto(`${teacherURL}/student-calls`);
+ await expect(page.locator('.student-call-card')).toHaveCount(1);
+ emit('created');
+ await expect(page.locator('[data-sonner-toast]')).toHaveCount(1);
+ await navigation(page).locator('a[href="/me"]').click();
+ await page.getByRole('button', { name: '退出当前账号' }).click();
+ await expect(page.getByRole('button', { name: '进入工作台' })).toBeVisible();
+ await expect(page.locator('[data-sonner-toast]')).toHaveCount(0);
+ state.account = 'teacher-c';
+ state.items = [];
+ await page.getByLabel('用户名', { exact: true }).fill('teacher-c');
+ await page.getByLabel('密码', { exact: true }).fill('test-password');
+ await page.getByRole('button', { name: '进入工作台' }).click();
+ await navigation(page).locator('a[href="/student-calls"]').click();
+ await expect(page.getByText('还没有学生呼叫')).toBeVisible();
+ await expect(page.locator('.student-call-unread')).toHaveCount(0);
+ await expect(page.locator('.student-call-card')).toHaveCount(0);
+});
diff --git a/web/apps/admin/package.json b/web/apps/admin/package.json
index d7431fe..9d4fc87 100644
--- a/web/apps/admin/package.json
+++ b/web/apps/admin/package.json
@@ -1,6 +1,6 @@
{
"name": "@classding/admin",
- "version": "0.3.4",
+ "version": "0.3.5",
"private": true,
"type": "module",
"scripts": {
diff --git a/web/apps/teacher/README.md b/web/apps/teacher/README.md
index 4b66b38..efb274b 100644
--- a/web/apps/teacher/README.md
+++ b/web/apps/teacher/README.md
@@ -25,6 +25,7 @@ pnpm dev
- 可向有权限的班级发送文字或上传原声,显示预览、发送记录及设备回执。离线消息只在有效期内等待补发。
- 录音依赖浏览器麦克风权限,最长 30 秒;当前发送原声,不提供服务端语音转文字。文本语音由大屏使用可用的系统语音播报。
+- 「学生呼叫」位于「发通知」之后,显示所有关联班级的呼叫记录和红色未读数量;打开页面不会自动已读。任一关联老师点击「标记已读」后共享为已处理,保留首位处理人和时间;记录在刷新、重新登录和服务重启后保留。网页在线时实时提醒,并在重连或定时查询时补齐,不提供 Web Push 或关闭浏览器后的系统推送。
- 页面有桌面和移动布局,但录音权限、微信内置浏览器及目标手机的显示和交互仍需实机验证。
检查与构建:
diff --git a/web/apps/teacher/package.json b/web/apps/teacher/package.json
index 2379144..37df895 100644
--- a/web/apps/teacher/package.json
+++ b/web/apps/teacher/package.json
@@ -1,6 +1,6 @@
{
"name": "@classding/teacher",
- "version": "0.3.4",
+ "version": "0.3.5",
"private": true,
"type": "module",
"scripts": {
diff --git a/web/apps/teacher/src/App.tsx b/web/apps/teacher/src/App.tsx
index 0cf5941..cbcd6ab 100644
--- a/web/apps/teacher/src/App.tsx
+++ b/web/apps/teacher/src/App.tsx
@@ -1,6 +1,16 @@
import { useState } from 'react';
import { Navigate, NavLink, Outlet, Route, Routes, useLocation } from 'react-router';
-import { BookOpenCheck, CircleHelp, History, LayoutGrid, LogOut, Radio, Send, UserRound } from 'lucide-react';
+import {
+ BellRing,
+ BookOpenCheck,
+ CircleHelp,
+ History,
+ LayoutGrid,
+ LogOut,
+ Radio,
+ Send,
+ UserRound,
+} from 'lucide-react';
import { Avatar, AvatarFallback } from '@classding/ui/components/ui/avatar';
import { Badge } from '@classding/ui/components/ui/badge';
import {
@@ -58,10 +68,13 @@ import HistoryPage from './pages/History';
import MePage from './pages/Me';
import LoginPage from './pages/Login';
import HomeworkPage from './pages/Homework';
+import StudentCallsPage from './pages/StudentCalls';
+import { useStudentCalls } from './studentCalls';
import { APP_VERSION } from '@classding/ui/version';
const links = [
{ to: '/send', icon: Send, label: '发通知', short: '发通知' },
+ { to: '/student-calls', icon: BellRing, label: '学生呼叫', short: '学生呼叫' },
{ to: '/classes', icon: LayoutGrid, label: '我的班级', short: '班级' },
{ to: '/homework', icon: BookOpenCheck, label: '布置作业', short: '作业' },
{ to: '/history', icon: History, label: '发送记录', short: '记录' },
@@ -106,6 +119,17 @@ function HelpDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open
function Layout() {
const { user, logout, updateUser } = useAuth();
const online = useTeacherSocket();
+ const calls = useStudentCalls(user?.id ?? '');
+ const unread = calls.data?.pages[0]?.unread_count ?? 0;
+ const callLabel = unread ? `学生呼叫,${unread} 条未读` : '学生呼叫';
+ const callBadge = unread > 0 && (
+
+ {unread > 99 ? '99+' : unread}
+
+ );
function changeScale(scale: number) {
if (!user) return;
const previous = user;
@@ -142,10 +166,13 @@ function Layout() {
}
+ render={}
>
{label}
+ {to === '/student-calls' && (
+ {callBadge}
+ )}
))}
@@ -186,32 +213,36 @@ function Layout() {
-
+
-
+
工作台
-
+
{active.label}
-
-
+
+
- {online ? '实时连接已开启' : '正在连接实时通道'}
+ {online ? '实时连接已开启' : '正在连接实时通道'}
@@ -227,21 +258,25 @@ function Layout() {
);
- return user ? : ;
+ return user ? : ;
}
export default function App() {
@@ -274,6 +309,7 @@ export default function App() {
}>
} />
} />
+ } />
} />
} />
} />
diff --git a/web/apps/teacher/src/auth.tsx b/web/apps/teacher/src/auth.tsx
index 2c4e85a..330820c 100644
--- a/web/apps/teacher/src/auth.tsx
+++ b/web/apps/teacher/src/auth.tsx
@@ -30,6 +30,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
revision.current++;
setUser(null);
cache.clear();
+ toast.dismiss();
toast.error('登录状态已变化,请重新登录');
}),
[cache],
@@ -56,6 +57,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setAccessToken(response.access_token, response.user.id);
setUser(response.user);
cache.clear();
+ toast.dismiss();
setReady(true);
}
async function logout() {
@@ -65,6 +67,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setUser(null);
setReady(true);
cache.clear();
+ toast.dismiss();
try {
await request;
} catch {
diff --git a/web/apps/teacher/src/hooks.ts b/web/apps/teacher/src/hooks.ts
index 83c6e54..22630de 100644
--- a/web/apps/teacher/src/hooks.ts
+++ b/web/apps/teacher/src/hooks.ts
@@ -1,7 +1,11 @@
import { useEffect, useRef, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { api, getAccessToken } from '@classding/api';
-import type { Class, Homework, MessagePage, Phrase } from './models';
+import type { Class, Homework, MessagePage, Phrase, StudentCallPage } from './models';
+import type { InfiniteData } from '@tanstack/react-query';
+import { toast } from 'sonner';
+import { useAuth } from './auth';
+import { readStudentCallEvent, studentCallsKey, studentCallsUnavailable } from './studentCalls';
export const useClasses = () => useQuery({ queryKey: ['classes'], queryFn: () => api('/classes') });
export const useHomeworks = (classId: string) =>
useQuery({
@@ -19,9 +23,14 @@ export const useMessages = (classId = '') =>
});
export function useTeacherSocket() {
const cache = useQueryClient();
+ const { user } = useAuth();
+ const userId = user?.id ?? '';
const [online, setOnline] = useState(false);
useEffect(() => {
+ if (!userId) return;
+ setOnline(false);
let stopped = false;
+ const announced = new Set();
let socket: WebSocket | undefined;
let timeout: ReturnType;
let ping: ReturnType;
@@ -29,14 +38,17 @@ export function useTeacherSocket() {
let lastFrame = Date.now();
function connect() {
if (stopped) return;
- socket = new WebSocket(
+ const current = new WebSocket(
`${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/ws/teacher`,
);
- socket.onopen = () => {
+ socket = current;
+ current.onopen = () => {
+ if (stopped || socket !== current) return;
lastFrame = Date.now();
- socket?.send(JSON.stringify({ t: 'hello', d: { token: getAccessToken() } }));
+ current.send(JSON.stringify({ t: 'hello', d: { token: getAccessToken() } }));
};
- socket.onmessage = (event) => {
+ current.onmessage = (event) => {
+ if (stopped || socket !== current) return;
lastFrame = Date.now();
let frame;
try {
@@ -48,6 +60,8 @@ export function useTeacherSocket() {
attempts = 0;
setOnline(true);
void cache.invalidateQueries({ queryKey: ['homeworks'] });
+ if (!studentCallsUnavailable(cache.getQueryState(studentCallsKey(userId))?.error))
+ void cache.invalidateQueries({ queryKey: studentCallsKey(userId) });
clearInterval(ping);
ping = setInterval(() => {
if (Date.now() - lastFrame > 60000) {
@@ -57,6 +71,21 @@ export function useTeacherSocket() {
if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ t: 'ping', d: {} }));
}, 25000);
}
+ if (frame.t === 'student_call') {
+ const call = readStudentCallEvent(frame.d);
+ if (!call) return;
+ void cache.invalidateQueries({ queryKey: studentCallsKey(userId) }).then(() => {
+ if (stopped || socket !== current || call.kind !== 'created' || announced.has(call.id)) return;
+ announced.add(call.id);
+ if (announced.size > 512) announced.delete(announced.values().next().value!);
+ const data = cache.getQueryData>(studentCallsKey(userId));
+ const item = data?.pages.flatMap((page) => page.items).find((item) => item.id === call.id);
+ toast.info(item ? `${item.class_name}的学生正在呼叫老师` : '收到新的学生呼叫', {
+ id: `student-call-${userId}-${call.id}`,
+ description: '请在“学生呼叫”中查看,处理后标记已读。',
+ });
+ });
+ }
if (frame.t === 'receipt') void cache.invalidateQueries({ queryKey: ['messages'] });
if (frame.t === 'homework') void cache.invalidateQueries({ queryKey: ['homeworks'] });
if (frame.t === 'presence') {
@@ -64,7 +93,8 @@ export function useTeacherSocket() {
void cache.invalidateQueries({ queryKey: ['devices'] });
}
};
- socket.onclose = () => {
+ current.onclose = () => {
+ if (stopped || socket !== current) return;
setOnline(false);
clearInterval(ping);
if (!stopped) {
@@ -79,7 +109,7 @@ export function useTeacherSocket() {
});
}
};
- socket.onerror = () => socket?.close();
+ current.onerror = () => current.close();
}
connect();
return () => {
@@ -88,7 +118,7 @@ export function useTeacherSocket() {
clearInterval(ping);
socket?.close();
};
- }, [cache]);
+ }, [cache, userId]);
return online;
}
export function useNow(interval = 1000) {
diff --git a/web/apps/teacher/src/models.ts b/web/apps/teacher/src/models.ts
index e97c41a..71fa6ab 100644
--- a/web/apps/teacher/src/models.ts
+++ b/web/apps/teacher/src/models.ts
@@ -11,3 +11,6 @@ export type Phrase = Schemas['Phrase'];
export type Homework = Schemas['Homework'];
export type AuthResponse = Schemas['AuthResponse'];
export type BindCode = Schemas['BindCode'];
+
+export type StudentCall = Schemas['StudentCall'];
+export type StudentCallPage = Schemas['StudentCallList'];
diff --git a/web/apps/teacher/src/pages/StudentCalls.tsx b/web/apps/teacher/src/pages/StudentCalls.tsx
new file mode 100644
index 0000000..11fa016
--- /dev/null
+++ b/web/apps/teacher/src/pages/StudentCalls.tsx
@@ -0,0 +1,151 @@
+import { useEffect, useRef } from 'react';
+import { useMutation, useQueryClient, type InfiniteData } from '@tanstack/react-query';
+import { api } from '@classding/api';
+import { BellRing, Check, RefreshCw } from 'lucide-react';
+import { toast } from 'sonner';
+import { Alert, AlertDescription, AlertTitle } from '@classding/ui/components/ui/alert';
+import { Badge } from '@classding/ui/components/ui/badge';
+import { Button } from '@classding/ui/components/ui/button';
+import { Card, CardContent } from '@classding/ui/components/ui/card';
+import { Spinner } from '@classding/ui/components/ui/spinner';
+import { useAuth } from '../auth';
+import { EmptyState, ErrorState, Loading, PageHeader, formatTime } from '../components';
+import type { StudentCall, StudentCallPage } from '../models';
+import {
+ applyStudentCallRead,
+ studentCallsKey,
+ studentCallsUnavailable,
+ useStudentCalls,
+} from '../studentCalls';
+
+export default function StudentCallsPage() {
+ const { user } = useAuth();
+ const userId = user?.id ?? '';
+ const currentUser = useRef(userId);
+ currentUser.current = userId;
+ const mounted = useRef(true);
+ useEffect(() => {
+ mounted.current = true;
+ return () => {
+ mounted.current = false;
+ };
+ }, []);
+ const cache = useQueryClient();
+ const query = useStudentCalls(userId);
+ const pages = query.data?.pages ?? [];
+ const items = [...new Map(pages.flatMap((page) => page.items).map((item) => [item.id, item])).values()];
+ const unread = pages[0]?.unread_count ?? 0;
+ const markRead = useMutation({
+ mutationFn: ({ id }: { id: string; account: string }) =>
+ api(`/student-calls/${encodeURIComponent(id)}/read`, { method: 'POST' }),
+ onSuccess: (call, { account }) => {
+ if (!mounted.current || currentUser.current !== account) return;
+ cache.setQueryData>(studentCallsKey(account), (data) =>
+ applyStudentCallRead(data, call),
+ );
+ void cache.invalidateQueries({ queryKey: studentCallsKey(account) });
+ toast.success('已标记已读,所有关联老师共享此处理状态');
+ },
+ onError: (error, { account }) => {
+ if (mounted.current && currentUser.current === account) toast.error(error.message);
+ },
+ });
+ const refresh = () => {
+ if (!query.isFetching) void query.refetch();
+ };
+ return (
+
+
+
+
+ {unread} 条未读
+ 标记已读即表示已处理
+
+
+
+ {query.isError && query.data && !studentCallsUnavailable(query.error) && (
+
+
+ 暂时无法同步最新呼叫
+
+ 以下保留上次获取的记录和未读数量,连接恢复后会自动重试。也可点击刷新。
+
+
+ )}
+ {studentCallsUnavailable(query.error) ? (
+
+
+ 当前服务暂未开放学生呼叫
+ 请等待服务升级后再试,其他功能不受影响。
+
+ ) : query.isPending ? (
+
+ ) : query.isError && !query.data ? (
+
+ ) : items.length === 0 ? (
+
}
+ title="还没有学生呼叫"
+ description="学生在教室端确认呼叫后,记录会保存在这里。网页在线时会实时提醒。"
+ />
+ ) : (
+
+ {items.map((item) => (
+ -
+
+
+
+
+
{item.class_name}
+
+ {item.read_at ? '已处理' : '未读'}
+
+
+
+ {item.device_name || '教室设备'} · 呼叫于 {formatTime(item.created_at)}
+
+ {item.read_at ? (
+
+ {item.read_by_name || '关联老师'} 已处理 · {formatTime(item.read_at)}
+
+ ) : (
+
任一老师处理后,所有老师同步为已读。
+ )}
+
+ {!item.read_at && (
+
+ )}
+
+
+
+ ))}
+
+ )}
+ {query.hasNextPage && !query.isError && (
+
+ )}
+
+ 共享已读不会删除记录。仅在网页打开且联网时实时提醒,不发送系统推送。
+
+
+ );
+}
diff --git a/web/apps/teacher/src/studentCalls.test.ts b/web/apps/teacher/src/studentCalls.test.ts
new file mode 100644
index 0000000..c7fd566
--- /dev/null
+++ b/web/apps/teacher/src/studentCalls.test.ts
@@ -0,0 +1,62 @@
+import { describe, expect, it } from 'vitest';
+import { applyStudentCallRead, readStudentCallEvent, studentCallsKey } from './studentCalls';
+import type { StudentCall, StudentCallPage } from './models';
+
+const call: StudentCall = {
+ id: 'call-1',
+ class_id: 'class-1',
+ class_name: '一年级',
+ device_id: 'device-1',
+ device_name: '教室',
+ created_at: '2026-01-01T01:00:00Z',
+ read_at: '',
+ read_by: '',
+ read_by_name: '',
+};
+const handled: StudentCall = {
+ ...call,
+ read_at: '2026-01-01T01:01:00Z',
+ read_by: 'teacher-2',
+ read_by_name: '李老师',
+};
+const page = (items: StudentCall[], count: number): StudentCallPage => ({
+ items,
+ unread_count: count,
+ next_cursor: null,
+});
+
+describe('student calls shared state', () => {
+ it('scopes cache keys by account', () => {
+ expect(studentCallsKey('a')).not.toEqual(studentCallsKey('b'));
+ });
+ it('parses only known websocket frames', () => {
+ expect(readStudentCallEvent({ id: '1', class_id: '2', kind: 'created' })?.kind).toBe('created');
+ expect(readStudentCallEvent({ id: '1', class_id: '2', kind: 'read' })?.kind).toBe('read');
+ for (const value of [
+ null,
+ {},
+ 'read',
+ { id: 1, class_id: '2', kind: 'read' },
+ { id: '', class_id: '2', kind: 'created' },
+ { id: '1', class_id: '2', kind: 'delete' },
+ ])
+ expect(readStudentCallEvent(value)).toBeNull();
+ });
+ it('updates the first page global badge when reading a later page', () => {
+ const original = { pages: [page([], 3), page([call], 3)], pageParams: ['', 'cursor'] };
+ const updated = applyStudentCallRead(original, handled)!;
+ expect(updated.pages.map((item) => item.unread_count)).toEqual([2, 2]);
+ expect(updated.pages[1].items[0]).toEqual(handled);
+ expect(original.pages[1].items[0].read_at).toBe('');
+ expect(applyStudentCallRead(updated, handled)).toEqual(updated);
+ });
+ it('never underflows or guesses unknown call counts', () => {
+ expect(
+ applyStudentCallRead({ pages: [page([call], 0)], pageParams: [''] }, handled)?.pages[0].unread_count,
+ ).toBe(0);
+ expect(
+ applyStudentCallRead({ pages: [page([], 3)], pageParams: [''] }, handled)?.pages[0].unread_count,
+ ).toBe(3);
+ expect(applyStudentCallRead(undefined, handled)).toBeUndefined();
+ });
+});
diff --git a/web/apps/teacher/src/studentCalls.ts b/web/apps/teacher/src/studentCalls.ts
new file mode 100644
index 0000000..f34d434
--- /dev/null
+++ b/web/apps/teacher/src/studentCalls.ts
@@ -0,0 +1,53 @@
+import { useInfiniteQuery, type InfiniteData } from '@tanstack/react-query';
+import { ApiError, api } from '@classding/api';
+import type { StudentCall, StudentCallPage } from './models';
+
+export const studentCallsKey = (userId: string) => ['student-calls', userId] as const;
+export const studentCallsUnavailable = (error: unknown) => error instanceof ApiError && error.status === 404;
+
+export function useStudentCalls(userId: string) {
+ return useInfiniteQuery({
+ queryKey: studentCallsKey(userId),
+ initialPageParam: '',
+ queryFn: ({ signal, pageParam }) =>
+ api(`/student-calls${pageParam ? `?cursor=${encodeURIComponent(pageParam)}` : ''}`, {
+ signal,
+ }),
+ getNextPageParam: (page) => page.next_cursor ?? undefined,
+ enabled: !!userId,
+ retry: (count, error) => !studentCallsUnavailable(error) && count < 1,
+ refetchInterval: (query) => (studentCallsUnavailable(query.state.error) ? false : 30000),
+ refetchIntervalInBackground: true,
+ refetchOnMount: (query) => !studentCallsUnavailable(query.state.error),
+ refetchOnWindowFocus: (query) => !studentCallsUnavailable(query.state.error),
+ refetchOnReconnect: (query) => !studentCallsUnavailable(query.state.error),
+ });
+}
+
+// Keep the badge correct immediately after a confirmed shared read, then reconcile with the server.
+export function applyStudentCallRead(data: InfiniteData | undefined, call: StudentCall) {
+ if (!data) return data;
+ const previous = data.pages.flatMap((page) => page.items).find((item) => item.id === call.id);
+ const decrement = previous && !previous.read_at && call.read_at ? 1 : 0;
+ return {
+ ...data,
+ pages: data.pages.map((page) => ({
+ ...page,
+ items: page.items.map((item) => (item.id === call.id ? call : item)),
+ unread_count: Math.max(0, page.unread_count - decrement),
+ })),
+ };
+}
+
+export type StudentCallEvent = { id: string; class_id: string; kind: 'created' | 'read' };
+export function readStudentCallEvent(data: unknown): StudentCallEvent | null {
+ if (!data || typeof data !== 'object') return null;
+ const value = data as Partial;
+ return typeof value.id === 'string' &&
+ value.id !== '' &&
+ typeof value.class_id === 'string' &&
+ value.class_id !== '' &&
+ (value.kind === 'created' || value.kind === 'read')
+ ? (value as StudentCallEvent)
+ : null;
+}
diff --git a/web/packages/api/package.json b/web/packages/api/package.json
index b77d1f0..8b5c758 100644
--- a/web/packages/api/package.json
+++ b/web/packages/api/package.json
@@ -1,6 +1,6 @@
{
"name": "@classding/api",
- "version": "0.3.4",
+ "version": "0.3.5",
"private": true,
"type": "module",
"exports": {
diff --git a/web/packages/api/src/types.ts b/web/packages/api/src/types.ts
index 60141d6..1667597 100644
--- a/web/packages/api/src/types.ts
+++ b/web/packages/api/src/types.ts
@@ -515,7 +515,58 @@ export interface paths {
get: operations["get__messages_36"];
put?: never;
/** POST /messages */
- post: operations["post__messages_37"];
+ post: operations["post__messages_40"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/device/student-calls": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** POST /device/student-calls */
+ post: operations["post__device_student_calls_37"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/student-calls": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** GET /student-calls */
+ get: operations["get__student_calls_38"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/student-calls/{id}/read": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** POST /student-calls/{id}/read */
+ post: operations["post__student_calls__id__read_39"];
delete?: never;
options?: never;
head?: never;
@@ -530,11 +581,11 @@ export interface paths {
cookie?: never;
};
/** GET /messages/{id} */
- get: operations["get__messages__id__38"];
+ get: operations["get__messages__id__41"];
put?: never;
post?: never;
/** DELETE /messages/{id} */
- delete: operations["delete__messages__id__39"];
+ delete: operations["delete__messages__id__42"];
options?: never;
head?: never;
patch?: never;
@@ -550,7 +601,7 @@ export interface paths {
get?: never;
put?: never;
/** POST /messages/{id}/recall */
- post: operations["post__messages__id__recall_40"];
+ post: operations["post__messages__id__recall_43"];
delete?: never;
options?: never;
head?: never;
@@ -567,7 +618,7 @@ export interface paths {
get?: never;
put?: never;
/** POST /messages/{id}/resend */
- post: operations["post__messages__id__resend_41"];
+ post: operations["post__messages__id__resend_44"];
delete?: never;
options?: never;
head?: never;
@@ -582,10 +633,10 @@ export interface paths {
cookie?: never;
};
/** GET /phrases */
- get: operations["get__phrases_42"];
+ get: operations["get__phrases_45"];
put?: never;
/** POST /phrases */
- post: operations["post__phrases_43"];
+ post: operations["post__phrases_46"];
delete?: never;
options?: never;
head?: never;
@@ -603,11 +654,11 @@ export interface paths {
put?: never;
post?: never;
/** DELETE /phrases/{id} */
- delete: operations["delete__phrases__id__45"];
+ delete: operations["delete__phrases__id__48"];
options?: never;
head?: never;
/** PATCH /phrases/{id} */
- patch: operations["patch__phrases__id__44"];
+ patch: operations["patch__phrases__id__47"];
trace?: never;
};
"/classes/{id}/tools/countdown": {
@@ -620,7 +671,7 @@ export interface paths {
get?: never;
put?: never;
/** POST /classes/{id}/tools/countdown */
- post: operations["post__classes__id__tools_countdown_46"];
+ post: operations["post__classes__id__tools_countdown_49"];
delete?: never;
options?: never;
head?: never;
@@ -637,7 +688,7 @@ export interface paths {
get?: never;
put?: never;
/** POST /classes/{id}/tools/rollcall */
- post: operations["post__classes__id__tools_rollcall_47"];
+ post: operations["post__classes__id__tools_rollcall_50"];
delete?: never;
options?: never;
head?: never;
@@ -654,7 +705,7 @@ export interface paths {
get?: never;
put?: never;
/** POST /classes/{id}/tools/stop */
- post: operations["post__classes__id__tools_stop_48"];
+ post: operations["post__classes__id__tools_stop_51"];
delete?: never;
options?: never;
head?: never;
@@ -671,7 +722,7 @@ export interface paths {
get?: never;
put?: never;
/** POST /audio */
- post: operations["post__audio_49"];
+ post: operations["post__audio_52"];
delete?: never;
options?: never;
head?: never;
@@ -686,7 +737,7 @@ export interface paths {
cookie?: never;
};
/** GET /admin/overview */
- get: operations["get__admin_overview_50"];
+ get: operations["get__admin_overview_53"];
put?: never;
post?: never;
delete?: never;
@@ -703,10 +754,10 @@ export interface paths {
cookie?: never;
};
/** GET /admin/activation-codes */
- get: operations["get__admin_activation_codes_51"];
+ get: operations["get__admin_activation_codes_54"];
put?: never;
/** POST /admin/activation-codes */
- post: operations["post__admin_activation_codes_52"];
+ post: operations["post__admin_activation_codes_55"];
delete?: never;
options?: never;
head?: never;
@@ -727,7 +778,7 @@ export interface paths {
options?: never;
head?: never;
/** PATCH /admin/activation-codes/{code} */
- patch: operations["patch__admin_activation_codes__code__53"];
+ patch: operations["patch__admin_activation_codes__code__56"];
trace?: never;
};
"/admin/activation-codes/delete": {
@@ -740,7 +791,7 @@ export interface paths {
get?: never;
put?: never;
/** POST /admin/activation-codes/delete */
- post: operations["post__admin_activation_codes_delete_54"];
+ post: operations["post__admin_activation_codes_delete_57"];
delete?: never;
options?: never;
head?: never;
@@ -755,10 +806,10 @@ export interface paths {
cookie?: never;
};
/** GET /admin/schools */
- get: operations["get__admin_schools_55"];
+ get: operations["get__admin_schools_58"];
put?: never;
/** POST /admin/schools */
- post: operations["post__admin_schools_56"];
+ post: operations["post__admin_schools_59"];
delete?: never;
options?: never;
head?: never;
@@ -776,11 +827,11 @@ export interface paths {
put?: never;
post?: never;
/** DELETE /admin/schools/{id} */
- delete: operations["delete__admin_schools__id__58"];
+ delete: operations["delete__admin_schools__id__61"];
options?: never;
head?: never;
/** PATCH /admin/schools/{id} */
- patch: operations["patch__admin_schools__id__57"];
+ patch: operations["patch__admin_schools__id__60"];
trace?: never;
};
"/admin/users": {
@@ -791,10 +842,10 @@ export interface paths {
cookie?: never;
};
/** GET /admin/users */
- get: operations["get__admin_users_59"];
+ get: operations["get__admin_users_62"];
put?: never;
/** POST /admin/users */
- post: operations["post__admin_users_60"];
+ post: operations["post__admin_users_63"];
delete?: never;
options?: never;
head?: never;
@@ -812,11 +863,11 @@ export interface paths {
put?: never;
post?: never;
/** DELETE /admin/users/{id} */
- delete: operations["delete__admin_users__id__62"];
+ delete: operations["delete__admin_users__id__65"];
options?: never;
head?: never;
/** PATCH /admin/users/{id} */
- patch: operations["patch__admin_users__id__61"];
+ patch: operations["patch__admin_users__id__64"];
trace?: never;
};
"/admin/users/{id}/unlock-login": {
@@ -829,7 +880,7 @@ export interface paths {
get?: never;
put?: never;
/** POST /admin/users/{id}/unlock-login */
- post: operations["post__admin_users__id__unlock_login_63"];
+ post: operations["post__admin_users__id__unlock_login_66"];
delete?: never;
options?: never;
head?: never;
@@ -844,10 +895,10 @@ export interface paths {
cookie?: never;
};
/** GET /admin/classes */
- get: operations["get__admin_classes_64"];
+ get: operations["get__admin_classes_67"];
put?: never;
/** POST /admin/classes */
- post: operations["post__admin_classes_65"];
+ post: operations["post__admin_classes_68"];
delete?: never;
options?: never;
head?: never;
@@ -865,11 +916,11 @@ export interface paths {
put?: never;
post?: never;
/** DELETE /admin/classes/{id} */
- delete: operations["delete__admin_classes__id__67"];
+ delete: operations["delete__admin_classes__id__70"];
options?: never;
head?: never;
/** PATCH /admin/classes/{id} */
- patch: operations["patch__admin_classes__id__66"];
+ patch: operations["patch__admin_classes__id__69"];
trace?: never;
};
"/admin/devices": {
@@ -880,7 +931,7 @@ export interface paths {
cookie?: never;
};
/** GET /admin/devices */
- get: operations["get__admin_devices_68"];
+ get: operations["get__admin_devices_71"];
put?: never;
post?: never;
delete?: never;
@@ -897,7 +948,7 @@ export interface paths {
cookie?: never;
};
/** GET /admin/messages */
- get: operations["get__admin_messages_69"];
+ get: operations["get__admin_messages_72"];
put?: never;
post?: never;
delete?: never;
@@ -917,7 +968,7 @@ export interface paths {
put?: never;
post?: never;
/** DELETE /admin/messages/{id} */
- delete: operations["delete__admin_messages__id__70"];
+ delete: operations["delete__admin_messages__id__73"];
options?: never;
head?: never;
patch?: never;
@@ -931,7 +982,7 @@ export interface paths {
cookie?: never;
};
/** GET /admin/audit-logs */
- get: operations["get__admin_audit_logs_71"];
+ get: operations["get__admin_audit_logs_74"];
put?: never;
post?: never;
delete?: never;
@@ -1019,6 +1070,23 @@ export interface components {
text: string;
sort: number;
};
+ StudentCall: {
+ id: string;
+ class_id: string;
+ class_name: string;
+ device_id: string;
+ device_name: string;
+ /** Format: date-time */
+ created_at: string;
+ read_at: string;
+ read_by: string;
+ read_by_name: string;
+ };
+ StudentCallList: {
+ items: components["schemas"]["StudentCall"][];
+ unread_count: number;
+ next_cursor: string | null;
+ };
Homework: {
id: string;
class_id: string;
@@ -2458,7 +2526,7 @@ export interface operations {
};
};
};
- post__messages_37: {
+ post__messages_40: {
parameters: {
query?: never;
header?: never;
@@ -2491,7 +2559,106 @@ export interface operations {
};
};
};
- get__messages__id__38: {
+ post__device_student_calls_37: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": {
+ request_id: string;
+ };
+ };
+ };
+ responses: {
+ /** @description 成功 */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["StudentCall"];
+ };
+ };
+ /** @description 业务错误 */
+ default: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ };
+ };
+ get__student_calls_38: {
+ parameters: {
+ query?: {
+ class_id?: string;
+ cursor?: string;
+ limit?: number;
+ };
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description 成功 */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["StudentCallList"];
+ };
+ };
+ /** @description 业务错误 */
+ default: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ };
+ };
+ post__student_calls__id__read_39: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description 成功 */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["StudentCall"];
+ };
+ };
+ /** @description 业务错误 */
+ default: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ };
+ };
+ get__messages__id__41: {
parameters: {
query?: never;
header?: never;
@@ -2522,7 +2689,7 @@ export interface operations {
};
};
};
- delete__messages__id__39: {
+ delete__messages__id__42: {
parameters: {
query?: never;
header?: never;
@@ -2553,7 +2720,7 @@ export interface operations {
};
};
};
- post__messages__id__recall_40: {
+ post__messages__id__recall_43: {
parameters: {
query?: never;
header?: never;
@@ -2584,7 +2751,7 @@ export interface operations {
};
};
};
- post__messages__id__resend_41: {
+ post__messages__id__resend_44: {
parameters: {
query?: never;
header?: never;
@@ -2615,7 +2782,7 @@ export interface operations {
};
};
};
- get__phrases_42: {
+ get__phrases_45: {
parameters: {
query?: never;
header?: never;
@@ -2644,7 +2811,7 @@ export interface operations {
};
};
};
- post__phrases_43: {
+ post__phrases_46: {
parameters: {
query?: never;
header?: never;
@@ -2679,7 +2846,7 @@ export interface operations {
};
};
};
- delete__phrases__id__45: {
+ delete__phrases__id__48: {
parameters: {
query?: never;
header?: never;
@@ -2710,7 +2877,7 @@ export interface operations {
};
};
};
- patch__phrases__id__44: {
+ patch__phrases__id__47: {
parameters: {
query?: never;
header?: never;
@@ -2747,7 +2914,7 @@ export interface operations {
};
};
};
- post__classes__id__tools_countdown_46: {
+ post__classes__id__tools_countdown_49: {
parameters: {
query?: never;
header?: never;
@@ -2784,7 +2951,7 @@ export interface operations {
};
};
};
- post__classes__id__tools_rollcall_47: {
+ post__classes__id__tools_rollcall_50: {
parameters: {
query?: never;
header?: never;
@@ -2821,7 +2988,7 @@ export interface operations {
};
};
};
- post__classes__id__tools_stop_48: {
+ post__classes__id__tools_stop_51: {
parameters: {
query?: never;
header?: never;
@@ -2852,7 +3019,7 @@ export interface operations {
};
};
};
- post__audio_49: {
+ post__audio_52: {
parameters: {
query?: never;
header?: never;
@@ -2888,7 +3055,7 @@ export interface operations {
};
};
};
- get__admin_overview_50: {
+ get__admin_overview_53: {
parameters: {
query?: never;
header?: never;
@@ -2917,7 +3084,7 @@ export interface operations {
};
};
};
- get__admin_activation_codes_51: {
+ get__admin_activation_codes_54: {
parameters: {
query?: {
class_id?: string;
@@ -2951,7 +3118,7 @@ export interface operations {
};
};
};
- post__admin_activation_codes_52: {
+ post__admin_activation_codes_55: {
parameters: {
query?: never;
header?: never;
@@ -2991,7 +3158,7 @@ export interface operations {
};
};
};
- patch__admin_activation_codes__code__53: {
+ patch__admin_activation_codes__code__56: {
parameters: {
query?: never;
header?: never;
@@ -3031,7 +3198,7 @@ export interface operations {
};
};
};
- post__admin_activation_codes_delete_54: {
+ post__admin_activation_codes_delete_57: {
parameters: {
query?: never;
header?: never;
@@ -3068,7 +3235,7 @@ export interface operations {
};
};
};
- get__admin_schools_55: {
+ get__admin_schools_58: {
parameters: {
query?: never;
header?: never;
@@ -3097,7 +3264,7 @@ export interface operations {
};
};
};
- post__admin_schools_56: {
+ post__admin_schools_59: {
parameters: {
query?: never;
header?: never;
@@ -3132,7 +3299,7 @@ export interface operations {
};
};
};
- delete__admin_schools__id__58: {
+ delete__admin_schools__id__61: {
parameters: {
query?: never;
header?: never;
@@ -3163,7 +3330,7 @@ export interface operations {
};
};
};
- patch__admin_schools__id__57: {
+ patch__admin_schools__id__60: {
parameters: {
query?: never;
header?: never;
@@ -3200,7 +3367,7 @@ export interface operations {
};
};
};
- get__admin_users_59: {
+ get__admin_users_62: {
parameters: {
query?: never;
header?: never;
@@ -3229,7 +3396,7 @@ export interface operations {
};
};
};
- post__admin_users_60: {
+ post__admin_users_63: {
parameters: {
query?: never;
header?: never;
@@ -3267,7 +3434,7 @@ export interface operations {
};
};
};
- delete__admin_users__id__62: {
+ delete__admin_users__id__65: {
parameters: {
query?: never;
header?: never;
@@ -3298,7 +3465,7 @@ export interface operations {
};
};
};
- patch__admin_users__id__61: {
+ patch__admin_users__id__64: {
parameters: {
query?: never;
header?: never;
@@ -3339,7 +3506,7 @@ export interface operations {
};
};
};
- post__admin_users__id__unlock_login_63: {
+ post__admin_users__id__unlock_login_66: {
parameters: {
query?: never;
header?: never;
@@ -3370,7 +3537,7 @@ export interface operations {
};
};
};
- get__admin_classes_64: {
+ get__admin_classes_67: {
parameters: {
query?: never;
header?: never;
@@ -3399,7 +3566,7 @@ export interface operations {
};
};
};
- post__admin_classes_65: {
+ post__admin_classes_68: {
parameters: {
query?: never;
header?: never;
@@ -3438,7 +3605,7 @@ export interface operations {
};
};
};
- delete__admin_classes__id__67: {
+ delete__admin_classes__id__70: {
parameters: {
query?: never;
header?: never;
@@ -3469,7 +3636,7 @@ export interface operations {
};
};
};
- patch__admin_classes__id__66: {
+ patch__admin_classes__id__69: {
parameters: {
query?: never;
header?: never;
@@ -3510,7 +3677,7 @@ export interface operations {
};
};
};
- get__admin_devices_68: {
+ get__admin_devices_71: {
parameters: {
query?: never;
header?: never;
@@ -3539,7 +3706,7 @@ export interface operations {
};
};
};
- get__admin_messages_69: {
+ get__admin_messages_72: {
parameters: {
query?: {
class_id?: string;
@@ -3573,7 +3740,7 @@ export interface operations {
};
};
};
- delete__admin_messages__id__70: {
+ delete__admin_messages__id__73: {
parameters: {
query?: never;
header?: never;
@@ -3604,7 +3771,7 @@ export interface operations {
};
};
};
- get__admin_audit_logs_71: {
+ get__admin_audit_logs_74: {
parameters: {
query?: never;
header?: never;
diff --git a/web/packages/api/src/ws-types.ts b/web/packages/api/src/ws-types.ts
index 84de85b..0d2a68c 100644
--- a/web/packages/api/src/ws-types.ts
+++ b/web/packages/api/src/ws-types.ts
@@ -199,6 +199,18 @@ export interface components {
device_id: string;
online: boolean;
};
+ } | {
+ /** @constant */
+ t: "student_call";
+ id?: string;
+ seq?: number;
+ ts?: number;
+ d: {
+ id: string;
+ class_id: string;
+ /** @enum {string} */
+ kind: "created" | "read";
+ };
};
};
responses: never;
diff --git a/web/packages/ui/package.json b/web/packages/ui/package.json
index e1b0128..14a6be2 100644
--- a/web/packages/ui/package.json
+++ b/web/packages/ui/package.json
@@ -1,6 +1,6 @@
{
"name": "@classding/ui",
- "version": "0.3.4",
+ "version": "0.3.5",
"private": true,
"type": "module",
"exports": {