Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@

## 未发布

## v0.4.0 — 2026-11-13

- 桌面「呼叫老师」悬浮球不再始终置顶:PPT 放映或最大化窗口会盖住它,回到桌面仍可见;悬浮球加大、改为醒目的橙色和白色粗体大字,确认面板与按钮同步放大。
- 大屏主界面重新对齐:页眉、内容、页脚使用同一页宽;「呼叫老师」卡片补齐样式;整体字号加大;修复绑定页装饰图压住步骤文字、「READY TO CONNECT」上方多出横线的问题。
- 网络稳定性:教师网页实时连接随心跳续期令牌,不再每 15 分钟断开;重连后补齐所有数据。大屏连接 10 秒无响应即重试,网络恢复时立即重连。
- 服务端:登录、注册、改密的密码哈希移出全局锁并限制并发;不存在的账号与错误密码耗时一致。通知列表改用「时间 + ID」分页,同一时刻的记录不再漏掉。撤回通知不再把「失败」「过期」改写为「已关闭」;解绑屏幕时未完成的投递立即标记失败。教师建班校验年级长度。发送过快的提示按实际冷却秒数显示。
- 数据量与性能:学生呼叫保留 90 天、审计日志 180 天,已用或过期的协作邀请自动清理;写入时按行编码、失败时从上次提交还原,每次写操作少一次整库序列化。
- 清理:移除 0.1.3 之前的共享刷新 Cookie 兼容与已废弃的会话字段。**从 0.1.2 及更早版本直接升级的浏览器需重新登录一次。**

## v0.3.5 — 2026-11-13

- 新增学生呼叫:Windows 桌面悬浮入口二次确认后呼叫本班全部关联教师;教师工作台新增「学生呼叫」、未读红色数量、在线实时提醒和手动共享已读,保留处理老师与时间。记录随服务持久化及备份,支持离线后补查;同班 30 秒防重复呼叫。不使用 Web Push,Windows 系统集成仍需实机验收。
Expand Down
2 changes: 1 addition & 1 deletion api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "课堂叮咚 API",
"version": "0.3.5"
"version": "0.4.0"
},
"servers": [
{
Expand Down
6 changes: 5 additions & 1 deletion api/ws/frame.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -611,7 +611,11 @@
},
"d": {
"type": "object",
"properties": {},
"properties": {
"token": {
"type": "string"
}
},
"required": []
}
},
Expand Down
2 changes: 1 addition & 1 deletion deploy/.env.example
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
DOMAIN=classding.example.com
CLASSDING_IMAGE=ghcr.io/alumos/classding-server:v0.3.5
CLASSDING_IMAGE=ghcr.io/alumos/classding-server:v0.4.0
# openssl rand -hex 32
JWT_SECRET=
# 仅首次部署设置;创建成功后清空用户名/密码并重新创建容器
Expand Down
2 changes: 1 addition & 1 deletion deploy/compose.1panel.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# 粘贴到 1Panel 的编排编辑器。首次部署请填写两项密码;升级保留原值和原编排名称。
services:
classding:
image: ghcr.io/alumos/classding-server:0.3.5
image: ghcr.io/alumos/classding-server:0.4.0
platform: linux/amd64
restart: unless-stopped
ports:
Expand Down
2 changes: 1 addition & 1 deletion deploy/compose.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
services:
server:
image: ${CLASSDING_IMAGE:-ghcr.io/alumos/classding-server:v0.3.5}
image: ${CLASSDING_IMAGE:-ghcr.io/alumos/classding-server:v0.4.0}
restart: unless-stopped
environment:
DATA_DIR: /data
Expand Down
11 changes: 8 additions & 3 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

## 主服务(`server/internal/app`)

整个数据库以 `State` 的形式常驻内存,由 `App.mu` 保护;每次成功的写操作后,`repo.Store` 只把变化的行写回 SQLite。
整个数据库以 `State` 的形式常驻内存,由 `App.mu` 保护;每次成功的写操作后,`State.rows()` 逐行编码,`repo.Store` 只把变化的行写回 SQLite。命令失败或保存失败时,内存状态由 `repo.Store.Restore` 从最近一次提交还原,成功路径不再整份拍快照。

| 文件 | 职责 |
| --- | --- |
Expand All @@ -27,10 +27,12 @@
| `routes.go` | 全部路由,一行一个接口,标明访问级别与读写 |
| `app.go` | 启动、旧数据升级、限流、审计、会话撤销 |
| `auth.go`、`security.go`、`validate.go` | 登录与刷新会话、令牌与密码哈希、输入校验 |
| `passwords.go` | Argon2id 在取得 `App.mu` 之前计算(并发上限受控),登录、注册、改密不阻塞其他请求;不存在的账号也做同样耗时的校验 |
| `state.go`、`cursor.go` | 按表逐行编码状态;分页游标(创建时间 + ID,同一时刻的记录不漏不重) |
| `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` | 定时清理过期会话、消息和录音 |
| `maintenance.go` | 定时清理:过期会话;消息与学生呼叫保留 90 天、审计 180 天、录音 7 天;已用或过期的协作邀请 |
| `migration.go` | 0.3.0 之前的大屏迁移核对接口 |

几条约定:
Expand All @@ -49,9 +51,12 @@
| `storage.go` | 以 Windows DPAPI 加密保存前端状态 |
| `displays.go`、`volume.go`、`network.go` | 显示器选择、播报时的系统音量、内网 IPv4 |
| `homework*.go` | 桌面作业卡片(Win32 GDI 绘制) |
| `student_call*.go` | 桌面「呼叫老师」悬浮球:普通窗口层级(不置顶,PPT 放映或最大化窗口会盖住它),点击后才提到前台展开二次确认 |
| `frontend/src/service.ts` | 连接、离线队列、播报、服务器切换与回退的状态机 |

`service.ts` 中所有状态修改都经 `mutate()` 串行执行并立即持久化,失败时整体回滚。每次重连使 `generation` 加一,旧连接上迟到的消息会被丢弃。
`service.ts` 中所有状态修改都经 `mutate()` 串行执行并立即持久化,失败时整体回滚。每次重连使 `generation` 加一,旧连接上迟到的消息会被丢弃。连接 10 秒内未收到欢迎帧即放弃重试;系统报告网络恢复(`online` 事件)时立即重连,不等退避。

教师网页的实时连接每 25 秒心跳时附带当前访问令牌,令牌临近过期先刷新,服务端据此续期,连接不再每 15 分钟断开一次;重连后同时刷新作业、消息、班级、设备和学生呼叫。

## 网页端

Expand Down
4 changes: 2 additions & 2 deletions docs/INTEGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
- AuthResponse: {access_token,user};refresh token 放 HttpOnly Cookie。

## REST
GET config => {speech_enabled:false,registration_enabled:true,version:'0.3.5'}
GET config => {speech_enabled:false,registration_enabled:true,version:'0.4.0'}
POST auth/register {username,password,display_name} => AuthResponse
POST auth/login {username,password} => AuthResponse
POST auth/refresh => AuthResponse;POST auth/logout => {ok:true}
Expand All @@ -33,7 +33,7 @@ GET device/me => {device,class:{id,name},volume,sequence,homeworks,homework_revi
GET device/homeworks => {items:Homework[],homework_revision},设备 Bearer 令牌认证。
GET homeworks?class_id= => {items:Homework[]};POST homeworks {class_id,subject,text,visible,always_on_top} => Homework;PATCH homeworks/:id {subject?,text?,visible?,always_on_top?} => Homework;DELETE homeworks/:id => {ok:true}。
POST messages {class_ids,kind,text,student_name,signature,style,display_sec,repeat,chime,play_mode,audio_id?} => Message;repeat 为朗读或播放遍数:文字 0–3(0 只显示不朗读),原声 1–3
GET messages?class_id=&cursor= => {items:Message[],next_cursor:string|null}
GET messages?class_id=&cursor= => {items:Message[],next_cursor:string|null}(cursor 原样传回上一页的 next_cursor,按创建时间 + ID 倒序,同一时刻不漏不重)
GET messages/:id => Message;POST messages/:id/recall => Message;POST messages/:id/resend => Message
GET phrases => Phrase[];POST phrases {text} => Phrase;PATCH phrases/:id {text} => Phrase;DELETE => {ok:true}
POST audio multipart file => {audio_id,url,transcript?}
Expand Down
2 changes: 1 addition & 1 deletion docs/LOCAL_TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
2. 两位教师在线打开工作台时均应看到「学生呼叫」红色未读数量和提醒;进入页面不消除未读。任一教师手动标记已读后,两端同步消除该条未读,记录保留处理老师和时间。
3. 另一教师关闭页面期间发起呼叫,重新登录后仍可查看未读;重启服务、刷新网页后记录和已读状态保留。未加入班级的账号不得读取或处理该班记录。
4. 30 秒内新呼叫应提示稍后再试;断网不得显示发送成功,重试同一请求不产生重复记录。解绑后悬浮入口不可发送。
5. 在真实 Windows 上检查主窗口隐藏/托盘驻留、悬浮球拖动、多屏工作区与 DPI 缩放、确认/取消、发送中防重复、全屏通知期间隐藏及恢复。浏览器兜底入口和交叉编译不能替代这项实机验收。
5. 在真实 Windows 上检查主窗口隐藏/托盘驻留、悬浮球不置顶(PPT 放映时被覆盖,回到桌面可见)、悬浮球拖动、多屏工作区与 DPI 缩放、确认/取消、发送中防重复、全屏通知期间隐藏及恢复。浏览器兜底入口和交叉编译不能替代这项实机验收。
6. 手机 320/390px、桌面及 150% 字号检查六项导航和未读徽标,不应溢出或遮挡操作。

## 已自动验证与待验证
Expand Down
39 changes: 39 additions & 0 deletions docs/releases/v0.4.0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# 课堂叮咚 v0.4.0

一次以稳定性和体验为主的大版本:桌面呼叫悬浮球不再挡住 PPT,大屏界面重新对齐并放大字号,教师端实时连接更稳,服务端更省资源。

## 大屏客户端

- **悬浮球不再始终置顶**:「呼叫老师」悬浮球处于普通窗口层级。PPT 放映、最大化的窗口会正常盖住它;回到桌面或在普通窗口旁仍可看到和点击。只有点击后展开的确认面板会提到最前。
- **更大更醒目**:悬浮球加大到 112×92(按缩放比例放大),橙色底、深橙描边、白色粗体大字「呼叫 / 老师」并居中;确认面板、标题、正文和按钮都同步加大。
- **界面对齐**:页眉、内容和页脚使用同一页宽,左右边缘对齐;「呼叫老师」卡片补齐样式并与悬浮球同色;正文、设置项、按钮字号整体加大;修复绑定页铃铛装饰压住步骤文字、卡片小标题上方多出横线的问题。
- **断网恢复更快**:连接 10 秒无响应会放弃并重试,不再长时间停在「正在连接」;Windows 报告网络恢复时立即重连。

## 教师与管理端

- 实时连接在心跳中续期登录令牌,不再每 15 分钟断开一次;重连后同时刷新作业、发送记录、班级、设备和学生呼叫。
- 发送记录分页改为「时间 + ID」,同一时刻发出的多条通知不会被漏掉。
- 撤回通知时,已经「失败」或「过期」的投递保留原状态。

## 服务端

- 密码哈希在全局锁外计算并限制并发,登录高峰不再拖慢大屏回执和其他请求;不存在的账号与密码错误耗时一致。
- 学生呼叫保留 90 天、审计日志保留 180 天,已用或过期的协作邀请自动清理,数据库不再无限增长。
- 写入按行编码、失败时从上次提交还原,每次写操作少一次整库序列化。
- 解绑屏幕时,未完成的投递立即标记为失败;教师建班时校验年级长度(最多 40 字);发送过快的提示显示实际冷却秒数。

## 升级

Linux amd64 镜像:`ghcr.io/alumos/classding-server:0.4.0`。保留现有编排和数据卷,仅更新镜像后重建。旧数据库和旧版备份可直接使用;首次运行的定时清理会删除超出保留期的学生呼叫、审计记录和失效邀请。

Windows amd64 安装包:`ClassDingScreen-amd64-installer.exe`。悬浮球与界面调整需要**大屏升级到 v0.4.0**。

## 不兼容变更

- 移除了 0.1.3 之前共用的刷新 Cookie(`classding_refresh`)。只有从 0.1.2 及更早版本直接升级、且浏览器仍持有旧 Cookie 的用户需要重新登录一次。
- 发送记录的分页游标改为不透明字符串;直接调用接口的脚本应原样传回 `next_cursor`,不要自行构造时间戳。

## 已知限制

- 悬浮球层级、字号与界面对齐需在真实 Windows 设备(不同缩放比例、多显示器、PPT 放映)上验收。
- 学生呼叫仍只在教师网页打开时提醒,不使用 Web Push。
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "classding",
"private": true,
"version": "0.3.5",
"version": "0.4.0",
"type": "module",
"packageManager": "pnpm@11.25.0",
"engines": {
Expand Down
2 changes: 1 addition & 1 deletion screen/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ wails build -platform windows/amd64 -nsis -installscope user

## 学生呼叫老师

绑定后 Windows 桌面左侧会显示「呼叫老师」原生悬浮入口;主窗口关闭到托盘后仍可使用,不依赖网页中的浮层。拖动可移到其他显示器;松手停在最近显示器工作区内,避开任务栏,按显示器 DPI 调整大小。悬浮入口平时不抢焦点,只有点击展开二次确认时才激活;取消或 Escape 不发送,默认键盘焦点在取消按钮,Tab 可切换到确认按钮。全屏通知、课堂工具展示期间临时隐藏,结束后恢复,不移动或改变已有作业窗。
绑定后 Windows 桌面左侧会显示橙色「呼叫老师」原生悬浮球(白色粗体大字)。它处在普通窗口层级,不会一直置顶:PPT 放映、最大化窗口会正常盖住它,回到桌面或其他窗口旁边时仍可见;主窗口关闭到托盘后仍可使用,不依赖网页中的浮层。拖动可移到其他显示器;松手停在最近显示器工作区内,避开任务栏,按显示器 DPI 调整大小。悬浮入口平时不抢焦点,只有点击展开二次确认时才激活;取消或 Escape 不发送,默认键盘焦点在取消按钮,Tab 可切换到确认按钮。全屏通知、课堂工具展示期间临时隐藏,结束后恢复,不移动或改变已有作业窗。

确认后由原有 WebView service 复用设备认证和 loopback 代理发送 `POST /api/v1/device/student-calls`,Go 原生层不保存另一份密钥。发送中禁止重复提交;成功须收到有效的服务器记录才显示「呼叫已发送」。本班所有关联老师在打开的教师网页收到提醒,任一老师标记已读后共享为已处理。服务端按同班 30 秒限流,客户端显示 `Retry-After` 等待时间。网络异常或响应丢失会显示结果未确认,用户主动重试沿用同一个幂等编号;不会离线排队或自动补发,退出程序后不保留未确认编号。

Expand Down
2 changes: 1 addition & 1 deletion screen/frontend/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@classding/screen",
"version": "0.3.5",
"version": "0.4.0",
"private": true,
"type": "module",
"scripts": {
Expand Down
21 changes: 15 additions & 6 deletions screen/frontend/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ function App() {
<Link2 size={25} />
</div>
<div className="card-heading">
<span className="overline">READY TO CONNECT</span>
<span className="card-overline">READY TO CONNECT</span>
<h2>绑定到班级</h2>
<p>打开教师端「班级」,获取 6 位绑定码</p>
</div>
Expand Down Expand Up @@ -373,16 +373,25 @@ function App() {
</Card>
<div className="dashboard-side">
<Card className="student-call-card">
<h3>需要老师来教室?</h3>
<p>向本班所有关联老师发送呼叫。Windows 关闭主窗口后仍可使用桌面悬浮入口。</p>
<span className="call-icon">
<BellRing size={26} />
</span>
<div>
<h3>需要老师来教室?</h3>
<p>向本班所有关联老师发送呼叫。关闭主窗口后,桌面上的橙色悬浮球仍可呼叫。</p>
{state.studentCallMessage && (
<p className="call-status" role="status">
{state.studentCallMessage}
</p>
)}
</div>
<Button
variant="secondary"
className="call-button"
onClick={() => setCallOpen(true)}
disabled={state.studentCallSending}
>
<BellRing size={16} /> 呼叫老师
<BellRing size={18} /> 呼叫老师
</Button>
{state.studentCallMessage && <p role="status">{state.studentCallMessage}</p>}
</Card>
<Dialog open={callOpen && state.bound} onOpenChange={setCallOpen}>
<DialogContent>
Expand Down
17 changes: 17 additions & 0 deletions screen/frontend/src/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ const LEDGER_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
const REQUEST_TIMEOUT_MS = 5000;
const HEARTBEAT_MS = 25000;
const SILENCE_MS = 60000;
// A socket that neither opens nor receives its welcome in time is abandoned and retried.
const CONNECT_TIMEOUT_MS = 10000;
const MAX_PENDING = 4096;
const CLIENT_OS = navigator.userAgent.includes('Windows') ? 'windows' : 'browser';
interface StoredState {
Expand Down Expand Up @@ -205,6 +207,13 @@ export class ScreenService {
private bindingEpoch = 0;
private callController?: AbortController;
private callID = '';
private connectTimer?: ReturnType<typeof setTimeout>;
// Reconnect at once when Windows reports the network is back, instead of waiting out the backoff.
private readonly onNetworkBack = () => {
if (this.view.connection === 'online' || !this.state.token || this.disposed) return;
this.attempts = 0;
this.connect();
};
private callOff?: () => void;
private callNativeQueue: Promise<void> = Promise.resolve();
private callNativeKey = '';
Expand Down Expand Up @@ -268,6 +277,7 @@ export class ScreenService {
}
}
async init() {
window.addEventListener?.('online', this.onNetworkBack);
this.callOff = window.runtime?.EventsOnMultiple(
'student-call:send',
(id, binding) => {
Expand Down Expand Up @@ -386,6 +396,7 @@ export class ScreenService {
private connect() {
if (!this.state.token || this.disposed) return;
clearTimeout(this.retry);
clearTimeout(this.connectTimer);
clearInterval(this.heartbeat);
const generation = ++this.generation;
this.validated.clear();
Expand All @@ -401,6 +412,9 @@ export class ScreenService {
this.emit({ connection: 'connecting' });
try {
const ws = (this.socket = new WebSocket(url));
this.connectTimer = setTimeout(() => {
if (generation === this.generation && this.view.connection !== 'online') ws.close();
}, CONNECT_TIMEOUT_MS);
ws.onopen = () => {
if (generation !== this.generation) return;
this.lastFrame = Date.now();
Expand All @@ -426,6 +440,7 @@ export class ScreenService {
};
ws.onclose = () => {
if (generation !== this.generation) return;
clearTimeout(this.connectTimer);
clearInterval(this.heartbeat);
this.emit({ connection: 'offline' });
void this.verifyAfterClose(generation);
Expand Down Expand Up @@ -1062,6 +1077,8 @@ export class ScreenService {
}
dispose() {
this.disposed = true;
window.removeEventListener?.('online', this.onNetworkBack);
clearTimeout(this.connectTimer);
this.callOff?.();
this.callOff = undefined;
this.halt();
Expand Down
Loading
Loading