diff --git a/README.md b/README.md index 840e4ea..c11790c 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ | 1 | pnpm monorepo 脚手架、TypeScript / ESLint / Prettier / 环境变量 | ✅ 已完成 | | 2 | 后端:Prisma schema + 迁移 + 种子数据 + JWT 认证 + RBAC + 模块化 REST API + Socket.IO | ✅ 已完成 | | 3 | Web 管理端:登录、主布局、仪表盘、班级/学生/课表/作业/通知/成绩页面、Axios 封装、实时提示 | ✅ 已完成 | -| 4 | EXE 客户端:Electron 主进程/preload/渲染进程、登录、课表/作业/通知/成绩/设置、实时推送、IndexedDB 离线缓存 | ✅ 已完成(冒烟验证 71/71) | +| 4 | EXE 客户端:Electron 主进程/preload/渲染进程、登录、课表/作业/通知/成绩/设置、实时推送、IndexedDB 离线缓存 | ✅ 已完成(冒烟验证 75/75) | | 5 | 三端联调脚本、打包命令、完整 README | ✅ 已完成(三套安装包 + 两套 UI 回归测试) | | 6 | 测试账号与种子数据说明 | ✅ 已完成(见下文) | | 7 | 生产化:服务端安装程序(内置 Node)、Web 端 PWA 可安装、Docker + Nginx 部署、生产加固与运维文档 | ✅ 已完成(见 [`docs/production.md`](docs/production.md)) | @@ -75,7 +75,7 @@ class-helper/ │ ├── prisma/migrations/ # 迁移历史(已生成并应用) │ ├── prisma/seed.ts # 种子数据 │ ├── prisma.config.ts # Prisma 7 配置(迁移/种子/连接串) - │ ├── scripts/verify-e2e.mjs # 后端端到端验收脚本(148 项) + │ ├── scripts/verify-e2e.mjs # 后端端到端验收脚本(149 项) │ └── src/ │ ├── app.ts # Express 装配(静态托管 + 探针 + 限流 + 模块挂载) │ ├── index.ts # 启动入口(自检/初始化 + HTTP + Socket.IO + 优雅退出) @@ -171,15 +171,15 @@ pnpm verify:desktop ```bash # 另开一个终端先启动后端:pnpm dev:server -pnpm verify:e2e # 后端 + REST + Socket.IO + RBAC + 上课时段拦截 + 紧急/普通叫人 + 单双周课表/ClassIsland 课程表导入 + 导入 + 班级账号:148 项 -pnpm verify:web # Web 管理端真实点击(含权限入口隐藏、手机适配、叫人入口 + 紧急/普通级别、紧急通知 + 成绩/时间配置导入弹窗):20 项 -pnpm verify:desktop # Electron 客户端(含灵动岛收起常驻/命中兜底/展开收起再展开/胶囊类型汇总、圆角四角一致、开合不震动、紧急与普通叫人、真实链路、课表时间轴、个性化 10 项参数与托盘/退出无残留):71 项 +pnpm verify:e2e # 后端 + REST + Socket.IO + RBAC + 上课时段拦截 + 紧急/普通叫人 + 单双周课表/ClassIsland 课程表导入 + 班级账号(个人学生登录停用):149 项 +pnpm verify:web # Web 管理端真实点击(含权限入口隐藏、手机适配、叫人入口 + 紧急/普通级别、多班批量发布、未交名单、课程表导入弹窗、手机端课表横向滚动):20 项 +pnpm verify:desktop # Electron 客户端(含灵动岛收起常驻/命中兜底/展开收起再展开/联合通知回归/胶囊类型汇总、未读红点位置、圆角四角一致、开合不震动、紧急与普通叫人、真实链路、课表时间轴、个性化与设置页链路、托盘/退出无残留):75 项 ``` -后端脚本验证**实时推送时延、作业完成、成绩下发、权限隔离、上课时段紧急通知拦截、导入与失败回滚、班级账号代全班操作、老师端导入不越权、单双周课表与 ClassIsland 课程表导入**等 148 项, +后端脚本验证**实时推送时延、作业完成、成绩下发、权限隔离、上课时段紧急通知拦截、导入与失败回滚、班级账号代全班操作、老师端导入不越权、单双周课表与 ClassIsland 课程表导入、个人学生登录停用**等 149 项, 实测通知 37ms、作业 26ms、成绩 25ms 到达(要求 < 5 秒),紧急通知 409 拦截与二次确认后发布均通过; 客户端脚本验证 preload 桥接、渲染进程、IndexedDB 读写、断网回退、**灵动岛四种状态切换**与联网集成 -(**班级账号登录** + 四类数据 + Socket.IO),实测 71/71 通过;退出后再启动一次校验单实例锁与文件锁已释放(离线复跑 65/65)。 +(**班级账号登录** + 四类数据 + Socket.IO),实测 75/75 通过;退出后再启动一次校验单实例锁与文件锁已释放(离线复跑 68/68)。 ## 默认账号与种子数据 @@ -220,9 +220,9 @@ pnpm verify:desktop # Electron 客户端(含灵动岛收起常驻/命中 | `pnpm dist:dir` | 打包客户端免安装目录 `release/win-unpacked`(含可执行文件,最快) | | `pnpm dist:win` | 打包客户端 nsis 安装包 + portable 单文件 EXE | | `pnpm dist:all` | 服务端安装程序 + 客户端安装程序一起打 | -| `pnpm verify:e2e` | 后端端到端验收(148 项,含角色权限矩阵、单双周课表与 ClassIsland 课程表导入、导入与班级账号,需服务端已启动) | +| `pnpm verify:e2e` | 后端端到端验收(149 项,含角色权限矩阵、单双周课表与 ClassIsland 课程表导入、导入与班级账号,需服务端已启动) | | `pnpm verify:web` | Web 管理端 UI 真实点击测试(Electron 驱动,20 项,含权限入口隐藏、叫人紧急/普通与导入弹窗) | -| `pnpm verify:desktop` | EXE 客户端冒烟验证(71 项,含灵动岛圆角/收起常驻/命中兜底/展开收起再展开/开合不震动/已读/作业/紧急与普通叫人、课表时间轴、个性化全参数) | +| `pnpm verify:desktop` | EXE 客户端冒烟验证(75 项,含灵动岛圆角/收起常驻/命中兜底/展开收起再展开/开合不震动/已读/作业/紧急与普通叫人、课表时间轴、个性化全参数) | | `pnpm typecheck` | 全仓库类型检查(含 `vue-tsc`) | | `pnpm lint` / `pnpm lint:fix` | ESLint 检查 / 自动修复 | | `pnpm format` / `pnpm format:check` | Prettier 格式化 / 检查 | @@ -332,10 +332,19 @@ pnpm verify:desktop # Electron 客户端(含灵动岛收起常驻/命中 工具栏可切「看板 / 列表」、开关「显示时间」、拖「字号」(11–28px 即时生效), 点卡片或「全屏看板」进入全屏放大视图;这些偏好写入客户端配置(`homeworkBoard`),重启后仍生效。 -**未交名单(班级设备)**:作业列表/详情里点「未交名单」→ 勾选**没交作业**的同学 → -保存后其余同学自动标记为已完成(`PATCH /api/homeworks/:id/submissions`, -`GET .../submissions` 读名单)。教师端 Web 管理端的作业详情抽屉里也能看到未交名单并勾选保存, -普通学生调用该接口返回 403(只能标记自己)。 +看板行为细节(按用户反馈逐条落地): + +| 需求 | 实现 | +| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 只看当天的作业 | 工具栏「只看今天」开关(默认开):按 `createdAt` 的**本地日期**过滤,昨天的过期作业不再混进看板 | +| 作业要完整显示 | 卡片里同时渲染**标题 + 作业要求全文**(`white-space: pre-wrap`,不截断),列表模式也新增「作业要求」列 | +| 全部铺满屏幕、不用上下翻页 | 看板外层高度固定 + `overflow: hidden`,按「内容高度 / 可用高度」算缩放系数用 `transform: scale()` 整体缩到刚好放下(字号仍由滑块决定,工具栏显示当前缩放比例) | +| 当天时间显示在标题栏正中 | 全屏看板标题栏用 `#header` 插槽:标题左对齐、当天日期时间**绝对居中**显示(受「显示时间」开关控制,每分钟刷新) | +| 未交名单 | 作业列表/详情里点「未交名单」→ 勾选**没交作业**的同学 → 保存后其余同学自动标记为已完成(`PATCH /api/homeworks/:id/submissions`,`GET .../submissions` 读名单);Web 管理端作业详情抽屉里同样可看可改 | + +**学生端主体 = 班级(个人学生账号不再允许登录)**:`POST /api/auth/login` 对 `role=STUDENT` +一律返回 **403**(提示改用「班级码 + 班级密码」);学生用户行仍保留,作为作业完成 / 通知已读 / 成绩 +等个人数据的记录键,由班级会话代全班读写。Web 端学生管理列表也不再展示「用户名」列。 > 通知到达时还会由**灵动岛**在桌面中上方浮出提醒(见上一节),上课时段自动隐藏、紧急通知立即展开。 @@ -343,6 +352,19 @@ pnpm verify:desktop # Electron 客户端(含灵动岛收起常驻/命中 客户端有一个独立于主窗口的**灵动岛**:一个无边框、透明、置顶、不占任务栏的窄条窗口。它是"消息到达"的第一现场。 +> **联合通知(紧急 + 作业)为什么以前"点不开新作业"**:上课时段里紧急通知会自动展开, +> 学生收起后再来一条作业时 `active` 仍是紧急那条 —— 点击只会反复打开它;而且就算把 active +> 换成新作业,`syncWindow` 的上课时段守卫又会立刻把普通通知隐藏(表现为"点了没反应")。 +> 现在两处都修了:①点击胶囊优先打开**最新**那条未处理消息(队列里更晚的那条); +> ②上课时段里"用户主动点开"的那条会记入 `explicitShowId`,守卫对它放行(岛完全隐藏时仍严格不打扰)。 +> 冒烟里对应断言「紧急通知后来了新作业:收起再点击打开的是新作业(联合通知回归)」。 +> +> **设置页实时生效链路有专项断言**:`window.desktop.islandSetAppearance` → IPC → 主进程 → +> 广播给灵动岛渲染进程(CSS 变量 + 窗口尺寸),岛显示中改尺寸同样立即生效。 +> +> **未读红点位置**:客户端侧边栏的未读红点挂在「通知」图标的**右上方**(此前挂在文字后面,位置不对), +> 冒烟用 DOM 几何断言(`dx > 0 且 dy < 0`)守住。 + > **视觉与排版参考 [WinIsland](https://github.com/WinIslandProject/WinIsland)(Rust/Direct2D 的 Windows 动态岛)重写**: > 连续圆角(超椭圆 squircle)而不是 `border-radius`、纯黑/亚克力毛玻璃/主题色三种底、 > Apple 系统色强调色、白字 alpha 分级、**所有文本 = 基础字号 × 排版系数**、 @@ -593,7 +615,7 @@ pnpm dist:win # nsis 安装包 + portable 单文件(需联网下载 NSIS | **退出后无残留(文件锁 + 二次启动)** | ✅ 配置目录可删除(句柄已释放)→ 用同一配置目录二次启动成功并再次全绿 | 该验证对**开发产物与打包后的 EXE 都适用**(打包后用 `ELECTRON_SMOKE_RESULT=` 写出 JSON 结果, -已实测 `packaged: true`、71/71 通过、退出码 0)。灵动岛各状态的窗口截图会写到 +已实测 `packaged: true`、75/75 通过、退出码 0)。灵动岛各状态的窗口截图会写到 `ISLAND_SHOTS_DIR`(默认 `.cache/island-shots/`,仓库内留档目录 `docs/screenshots/island/`), 并对尺寸、宽高比、绘制内容(不透明像素/颜色种类)、紧急态红色像素与外溢红光做断言。 冒烟进程使用独立 userData(`ELECTRON_SMOKE_PROFILE`),因此**用户开着客户端也能跑验证**。 @@ -771,9 +793,17 @@ POST /api/notifications { …, priority: "NORMAL" }(上课时段) `第 1 周 = 单周` 的规则过滤(`weekParityOf(week)`,与 ClassIsland 默认相位一致), 历史数据默认 `ALL`,行为不变。手动添加课表时也能在弹窗里直接选「每周 / 单周 / 双周」。 -> 手工覆盖 dist 部署(而非跑安装程序)时,已有数据库需要补一列: -> `node packages/server/scripts/apply-parity-column.cjs "<安装目录>/server/dist/lib/db.js"` -> (安装程序覆盖升级会自动补迁移;仅空库首次启动会执行随包迁移 SQL)。 +> **学期周数(班主任可调)**:`Class.termWeeks`(默认 20)由班主任在「班级管理 → 编辑班级」里设置, +> `GET /api/dashboard/term?classId=` 返回该班的 `maxWeek`,课表周次下拉与新建课表的默认结束周都按它来 +> (此前固定给到 30 周,与实际 20 周不符且无法调整)。 +> +> **课表页入口只保留一个**:「导入 ClassIsland 课程表」——同一份档案 JSON 里的 `TimeLayouts` +> 已经把节次时间带进来了,因此「导入时间配置」按钮已下线(组件与接口仍保留,供旧脚本调用)。 + +> 手工覆盖 dist 部署(而非跑安装程序)时,已有数据库需要补列: +> `node packages/server/scripts/apply-column-migrations.cjs "<安装目录>/server/dist/lib/db.js"` +> (会幂等补齐 `Schedule.weekParity` 与 `Class.termWeeks`;安装程序覆盖升级会自动补迁移, +> 仅空库首次启动会执行随包迁移 SQL)。 ## REST API 一览 @@ -994,7 +1024,7 @@ pnpm db:migrate && pnpm db:seed | ------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | | `pnpm verify:e2e`(开发环境与**安装后的生产实例**各跑一次) | **133/133** ✅ | | `pnpm verify:web`(Web 管理端真实点击 + 权限入口隐藏 + 手机适配 + 叫人 + 成绩/时间配置导入 + PWA) | **19/19** ✅ | -| `pnpm verify:desktop`(客户端冒烟 + WinIsland 架构/连续圆角/收起常驻 + 命中兜底与"展开收起再展开" + 个性化全参数 + 托盘与退出无残留) | **71/71** ✅ | +| `pnpm verify:desktop`(客户端冒烟 + WinIsland 架构/连续圆角/收起常驻 + 命中兜底与"展开收起再展开" + 个性化全参数 + 托盘与退出无残留) | **75/75** ✅ | | `pnpm typecheck` / `pnpm lint` / `pnpm format:check` | 全部通过 ✅ | | 安装程序完整生命周期(静默安装 → 启停脚本 → 卸载) | 通过 ✅ | | Docker / Nginx 部署样例 | 文件已提供,本机无 Docker 未实测 ⚠️ | diff --git a/packages/desktop-client/src/main/config.ts b/packages/desktop-client/src/main/config.ts index aa9b563..e4cf199 100644 --- a/packages/desktop-client/src/main/config.ts +++ b/packages/desktop-client/src/main/config.ts @@ -9,6 +9,7 @@ const DEFAULT_HOMEWORK_BOARD: HomeworkBoardSettings = { mode: 'board', showTime: false, fontSize: 15, + todayOnly: true, }; interface PersistedConfig { @@ -42,6 +43,7 @@ function normalizeHomeworkBoard(input?: Partial): Homewor return { mode, showTime: input?.showTime === true, + todayOnly: input?.todayOnly !== false, fontSize: Math.min(28, Math.max(11, Math.round(rawFont))), }; } diff --git a/packages/desktop-client/src/main/island.ts b/packages/desktop-client/src/main/island.ts index 2f0fe7b..5925d97 100644 --- a/packages/desktop-client/src/main/island.ts +++ b/packages/desktop-client/src/main/island.ts @@ -126,6 +126,8 @@ class IslandController { /** 当前生效的窗口背景材质('acrylic' | 'none'),用于避免重复设置与冒烟断言 */ private backgroundMaterial: string = 'none'; /** 当前是否接收鼠标(固定大包围盒窗口默认穿透,由渲染进程按命中动态打开) */ + /** 是否允许"失焦自动收起"(冒烟可临时关闭,避免焦点抖动干扰断言) */ + private blurCollapseEnabled = true; private interactive = false; /** 渲染进程上报的岛体矩形(窗口内 CSS px):主进程据此做光标命中兜底轮询 */ private hitRect: { x: number; y: number; width: number; height: number } | null = null; @@ -137,6 +139,8 @@ class IslandController { private lastHitSyncAt = 0; /** 设置页"预览效果"的示例通知 id(预览期间失焦不收起) */ private previewId: string | null = null; + /** 上课时段里"用户主动点开"的通知 id(syncWindow 的上课守卫对它放行) */ + private explicitShowId: string | null = null; /** 预览示例岛的自动收尾定时器 */ private previewTimer: ReturnType | null = null; @@ -225,6 +229,8 @@ class IslandController { * 只在激活满 500ms 后才把 blur 当作"用户点击了别处"。 */ win.on('blur', () => { + // 冒烟里可以临时关掉"失焦收起",避免测试过程被无关的焦点变化打断(默认开启) + if (!this.blurCollapseEnabled) return; if (this.state.mode !== 'expanded' || this.state.inClass) return; // 设置页预览:示例岛要继续留在屏幕上(用户在调滑块时主窗口一直是焦点) if (this.previewId && this.state.active?.id === this.previewId) return; @@ -388,6 +394,7 @@ class IslandController { if (payload.inClass) { if (!wasInClass) logger.info('灵动岛:进入上课时间段,自动隐藏'); + this.explicitShowId = null; if (this.state.mode !== 'hidden') { // 上课期间自动隐藏(紧急通知也遵循,避免持续遮挡课堂投影) this.setState({ mode: 'hidden', reason: null }); @@ -434,21 +441,38 @@ class IslandController { // 上课时段:普通通知(含普通叫人)一律不显示;但紧急通知 / 紧急叫人本来就是必须 // 立刻看到的,收起(回缩为胶囊)之后必须能再次点开——否则学生会误以为消息消失了。 const showing = this.state.active; - if (this.state.inClass && !this.isOpenable(showing)) { + // 岛当前可见(胶囊态)时,用户是"对着屏幕上这条岛"点的: + // 只要队列里有更**新**的消息(例如紧急通知之后又来了作业),就应当打开新那条, + // 而不是反复打开上一条 —— 否则表现为"点不开新作业"(用户反馈的联合通知场景)。 + // 岛完全隐藏时仍严格遵循上课不打扰。 + const islandVisible = this.state.mode !== 'hidden'; + if (this.state.inClass && !this.isOpenable(showing) && !islandVisible) { logger.info( `灵动岛:上课时段忽略展开(priority=${showing?.priority ?? '-'} kind=${showing?.kind ?? '-'} id=${showing?.id ?? '-'})`, ); break; } - // 收起态(胶囊 / 空闲细缝)再次点击必须能打开: - // active 为空但队列里还有未看通知时,先把它取出来当"当前通知"再展开, - // 否则会出现"岛明明在屏幕上,点了没反应"(部分情况下的收起态无法再次打开)。 - if (!this.state.active) { - const pending = this.state.queued[this.state.queued.length - 1]; + const newestQueued = this.state.queued[this.state.queued.length - 1]; + const shouldOpenNewest = Boolean( + newestQueued && + (!showing || String(newestQueued.createdAt ?? '') > String(showing.createdAt ?? '')), + ); + if (!this.state.active || shouldOpenNewest) { + const pending = newestQueued; if (pending) { - this.state.queued = this.state.queued.filter((item) => item.id !== pending.id); + // 当前展示的那条回队列,换成最新的一条 + if (this.state.active && this.state.active.id !== pending.id) { + this.state.queued = this.state.queued.filter( + (item) => item.id !== pending.id && item.id !== this.state.active?.id, + ); + this.state.queued.push(this.state.active); + } else { + this.state.queued = this.state.queued.filter((item) => item.id !== pending.id); + } this.state.active = pending; - logger.info(`灵动岛:收起态点击,重新打开队列中的通知 ${pending.id}`); + logger.info( + `灵动岛:收起态点击,打开最新通知 ${pending.id}(队列 ${this.state.queued.length} 条)`, + ); } } const active = this.state.active; @@ -458,6 +482,10 @@ class IslandController { `灵动岛:展开通知 ${active.id}(priority=${active.priority} kind=${active.kind ?? '-'})`, ); } + // 上课时段里"用户主动点开"的消息要能真的留在屏幕上: + // 记下这条 id,syncWindow 的上课守卫对它是放行的(否则展开后会被立刻隐藏, + // 表现为"点不开新作业"——用户反馈的联合通知场景)。 + this.explicitShowId = this.state.inClass ? active.id : null; this.setState({ mode: 'expanded' }); this.scheduleCollapse( this.isImmediate(active) @@ -511,6 +539,7 @@ class IslandController { hide(): void { this.clearTimers(); + this.explicitShowId = null; if (!this.win || this.win.isDestroyed()) return; // 开启"空闲细缝"时不能走 fadeOut + skipWindow:那样会完全隐藏, // 必须让 syncWindow() 把窗口收成一条细缝(上课时段仍由 syncWindow 兜底为立即隐藏)。 @@ -529,6 +558,7 @@ class IslandController { hideImmediately(): void { this.clearTimers(); this.cancelBoundsAnimation(); + this.explicitShowId = null; this.fadeToken += 1; const changed = this.state.mode !== 'hidden' || this.state.reason !== null; if (changed) { @@ -699,7 +729,10 @@ class IslandController { // 上课时段:只有"紧急通知 / 叫人"允许出现在屏幕上——展开态与胶囊态都算 // (胶囊态也允许,学生收起后还能再点开;普通通知则一律隐藏) const openableShowing = this.state.active !== null && this.isOpenable(this.state.active); - if (this.state.inClass && !openableShowing) { + // 用户在课中主动点开的那条同样允许显示(否则"点开了又被立刻隐藏") + const explicitShowing = + this.state.active !== null && this.state.active.id === this.explicitShowId && !this.previewId; + if (this.state.inClass && !openableShowing && !explicitShowing) { this.hideImmediately(); return; } @@ -978,6 +1011,11 @@ class IslandController { } /** 当前是否接收鼠标(供冒烟验证) */ + /** 诊断/冒烟用:开关"失焦自动收起" */ + setBlurCollapseEnabled(enabled: boolean): void { + this.blurCollapseEnabled = enabled; + } + getInteractive(): boolean { return this.interactive; } diff --git a/packages/desktop-client/src/main/smoke.ts b/packages/desktop-client/src/main/smoke.ts index 5009a0c..a576788 100644 --- a/packages/desktop-client/src/main/smoke.ts +++ b/packages/desktop-client/src/main/smoke.ts @@ -457,6 +457,61 @@ async function dumpIslandDom(_label: string): Promise { } } +/** + * 冒烟用:用教师账号发一条普通通知,返回通知 id(失败返回空串)。 + * 用于"未读红点位置"这类需要真实未读数据的断言。 + */ +async function createSmokeNotification(): Promise { + const base = process.env.ELECTRON_SMOKE_API ?? 'http://127.0.0.1:4000/api'; + try { + const login = (await fetch(`${base}/auth/login`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ username: 'teacher1', password: 'teacher123' }), + }).then((response) => response.json())) as { data?: { token?: string } }; + const token = login?.data?.token; + if (!token) return ''; + const classes = (await fetch(`${base}/classes`, { + headers: { authorization: `Bearer ${token}` }, + }).then((response) => response.json())) as { data?: Array<{ id?: string }> }; + const classId = classes?.data?.[0]?.id ?? ''; + if (!classId) return ''; + const created = (await fetch(`${base}/notifications`, { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, + body: JSON.stringify({ + classId, + title: '红点位置校验', + content: '自动化验证用通知(断言未读红点位置后会删除)。', + priority: 'NORMAL', + }), + }).then((response) => response.json())) as { data?: { id?: string } }; + return created?.data?.id ?? ''; + } catch { + return ''; + } +} + +/** 冒烟用:删除上面造的通知(失败忽略) */ +async function deleteSmokeNotification(id: string): Promise { + const base = process.env.ELECTRON_SMOKE_API ?? 'http://127.0.0.1:4000/api'; + try { + const login = (await fetch(`${base}/auth/login`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ username: 'teacher1', password: 'teacher123' }), + }).then((response) => response.json())) as { data?: { token?: string } }; + const token = login?.data?.token; + if (!token) return; + await fetch(`${base}/notifications/${id}`, { + method: 'DELETE', + headers: { authorization: `Bearer ${token}` }, + }); + } catch { + // 清理失败不影响冒烟结论 + } +} + function makeNotification( id: string, priority: IslandNotification['priority'], @@ -492,6 +547,9 @@ async function runIslandChecks( if (!island.isReady()) return; const islandWindow = island.getWindow(); + // 默认关掉"失焦自动收起":冒烟是自动化环境,系统/其它窗口抢焦点会随时触发 blur, + // 把展开态收回会让断言随机失败。专门验证该行为的用例(4.5)会临时打开它。 + island.setBlurCollapseEnabled(false); // 1) 上课期间:普通通知必须完全不显示(窗口隐藏)并进入队列 island.setClassState({ inClass: true, currentPeriodEnd: '08:45', week: 1 }); @@ -837,6 +895,7 @@ async function runIslandChecks( await sleep(700); const focusable = islandWindow?.isFocusable() ?? false; // 程序化触发失焦:等价于用户点到别处(冒烟窗口本身不显示,无法真的点击桌面) + island.setBlurCollapseEnabled(true); // 这一条专门验证"失焦收起",先打开该行为 islandWindow?.emit('blur'); await sleep(700); const afterBlur = island.getState(); @@ -845,6 +904,8 @@ async function runIslandChecks( focusable && islandWindow?.isFocusable() === false && afterBlur.mode === 'pill', `展开时 focusable=${focusable} 失焦后 mode=${afterBlur.mode} focusable=${islandWindow?.isFocusable() ?? '-'}`, ); + // 其余用例关掉"失焦收起":避免无关的焦点变化(系统抢焦点等)把展开态收回,干扰断言 + island.setBlurCollapseEnabled(false); // 4.6) 新作业上岛(kind=homework):胶囊 + 展开显示作业要求 //(截止时间功能已下线:这里同时守住"作业卡上不再出现截止时间") @@ -1149,6 +1210,9 @@ async function runIslandChecks( await sleep(500); // 命中兜底:把窗口显式置为"穿透",再把光标位置注入到胶囊中心 —— // 主进程每 120ms 按光标校正一次命中,必须自己恢复(这正是"点开再收起后点不开"的兜底修复)。 + // 先把光标注入到屏幕左上角(等价于指针离开岛体),否则轮询会立刻把命中又打开。 + island.setHitTestCursor({ x: 1, y: 1 }); + await sleep(260); island.setInteractive(false); await sleep(60); const forcedThrough = island.getInteractive(); @@ -1316,6 +1380,66 @@ async function runIslandChecks( `mode=${previewDismissed.mode} active=${previewDismissed.active?.id ?? '-'}`, ); + // 4.12) 用户复现路径:紧急通知 + 新作业(联合通知)时,收起后点击必须能打开**新作业** + // 之前 active 仍是紧急那条,点击只会反复打开紧急,表现为"点不开新作业"。 + await drainForCollapseCheck(); + await sleep(300); + island.setClassState({ inClass: true, currentPeriodEnd: '08:45', week: 1 }); + await sleep(300); + island.pushNotification(makeNotification('smoke-mixed-urgent', 'URGENT', '上课期间的紧急通知'), { + inClass: true, + }); + await sleep(700); + const mixedUrgent = island.getState(); + // 收起(学生点空白处) + await islandWindow?.webContents + .executeJavaScript( + `(() => { + document.querySelector('.island-card')?.click(); + return true; + })()`, + ) + .catch(() => undefined); + await sleep(600); + const mixedCollapsed = island.getState(); + // 又来了新作业(上课时段,属普通通知 → 进队列,但胶囊已把这个类型汇总出来了) + island.pushNotification( + { + ...makeNotification('smoke-mixed-homework', 'NORMAL', '今天的新作业'), + kind: 'homework', + }, + { inClass: true }, + ); + await sleep(600); + const mixedPillDom = await islandWindow?.webContents.executeJavaScript( + `document.querySelector('.island-card.pill .pill-title')?.textContent?.trim() ?? ''`, + ); + // 点击胶囊:必须打开**新作业**这条 + await islandWindow?.webContents + .executeJavaScript( + `(() => { + document.querySelector('.island-card')?.click(); + return true; + })()`, + ) + .catch(() => undefined); + await sleep(750); + const mixedOpened = island.getState(); + record( + '紧急通知后来了新作业:收起再点击打开的是新作业(联合通知回归)', + mixedUrgent.mode === 'expanded' && + mixedCollapsed.mode === 'pill' && + (mixedPillDom ?? '').includes('作业') && + mixedOpened.mode === 'expanded' && + mixedOpened.active?.id === 'smoke-mixed-homework', + `紧急态=${mixedUrgent.mode} 收起=${mixedCollapsed.mode} 胶囊标题="${mixedPillDom ?? '-'}" ` + + `点击后=${mixedOpened.mode} active=${mixedOpened.active?.id ?? '-'}`, + ); + island.handleAction({ action: 'dismiss' }); + await sleep(300); + island.setClassState({ inClass: false, currentPeriodEnd: null, week: 1 }); + await sleep(300); + // 5) 截图留档的像素级断言:每张图必须有实际绘制内容、颜色丰富,且宽高比与 // 状态机配置的窗口尺寸一致(DPI 无关),紧急形态还必须出现红色描边/内部光晕像素。 const shotDetails = islandShots.map((shot) => { @@ -1389,6 +1513,74 @@ async function runIslandChecks( // 8) 个性化外观:高度/宽度/圆角/字号/透明度/动画速度实时生效,且不破坏布局 const appearanceBefore = island.getAppearance(); + // 8.1) 设置页真实链路(渲染进程 → IPC → 主进程):用户反馈"设置不生效", + // 因此必须走设置页用的 `window.desktop.islandSetAppearance`,而不是直接调主进程 API。 + const rendererApplied = await mainWin?.webContents + .executeJavaScript( + `(() => { + if (!window.desktop?.islandSetAppearance) return false; + window.desktop.islandSetAppearance({ + height: 62, + width: 320, + radius: 28, + fontSize: 16, + opacity: 0.9, + accent: '#ff7043', + }); + return true; + })()`, + ) + .catch(() => false); + await sleep(450); + const rendererAppearance = island.getAppearance(); + const rendererCssVars = await islandWindow?.webContents + .executeJavaScript( + `(() => { + const style = getComputedStyle(document.documentElement); + return { + h: style.getPropertyValue('--island-h').trim(), + w: style.getPropertyValue('--island-w').trim(), + font: style.getPropertyValue('--island-font').trim(), + accent: style.getPropertyValue('--island-accent').trim(), + }; + })()`, + ) + .catch(() => null); + record( + '设置页链路:渲染进程 islandSetAppearance 实时生效(尺寸/字号/主题色)', + rendererApplied === true && + rendererAppearance.height === 62 && + rendererAppearance.width === 320 && + rendererAppearance.fontSize === 16 && + rendererAppearance.accent.toLowerCase() === '#ff7043' && + rendererCssVars?.h === '62px' && + rendererCssVars?.w === '320px' && + rendererCssVars?.font === '16px' && + rendererCssVars?.accent.toLowerCase() === '#ff7043', + `渲染进程调用=${rendererApplied} 主进程外观=${rendererAppearance.width}x${rendererAppearance.height} ` + + `字号=${rendererAppearance.fontSize} 主题色=${rendererAppearance.accent} ` + + `CSS 变量 h=${rendererCssVars?.h} w=${rendererCssVars?.w} font=${rendererCssVars?.font} accent=${rendererCssVars?.accent}`, + ); + // 岛**可见**时改尺寸也要立刻生效(用户在设置页调滑块时岛正显示着) + island.pushNotification(makeNotification('smoke-appearance-live', 'NORMAL', '实时预览校验'), { + inClass: false, + }); + await sleep(500); + await mainWin?.webContents + .executeJavaScript( + `(() => { + window.desktop?.islandSetAppearance({ height: 66, width: 336, fontSize: 17 }); + return true; + })()`, + ) + .catch(() => undefined); + await sleep(450); + const liveBounds = (await readIslandGeometry())?.island ?? null; + record( + '设置页链路:岛显示中改尺寸也立刻生效(实时预览)', + liveBounds?.height === 66 && Math.abs((liveBounds?.width ?? 0) - 336) <= 1, + `岛=${liveBounds?.width?.toFixed(0) ?? '-'}x${liveBounds?.height?.toFixed(0) ?? '-'}(期望 336x66)`, + ); island.setAppearance({ height: 56, width: 300, radius: 26, fontSize: 14, opacity: 0.92, speed: 1.5 }); island.handleAction({ action: 'dismiss' }); await sleep(300); @@ -2281,6 +2473,60 @@ export async function runSmokeTest(win: BrowserWindow): Promise { ); record('侧边栏点击导航(逐一点击 5 个菜单)', Boolean(navigation?.ok), String(navigation?.detail ?? '')); + // 未读红点必须挂在"通知"图标右上方(用户反馈:原来挂在文字后面,位置不对) + const readBadgeGeometry = async (): Promise<{ + ok: boolean; + skipped?: string; + reason?: string; + dx?: number; + dy?: number; + text?: string; + } | null> => + await win.webContents + .executeJavaScript( + `(() => { + const item = Array.from(document.querySelectorAll('.el-menu-item')).find((node) => + (node.textContent ?? '').includes('通知'), + ); + if (!item) return { ok: false, reason: '找不到通知菜单项' }; + const badge = item.querySelector('.menu-badge'); + const icon = item.querySelector('.menu-icon-slot .el-icon'); + if (!badge) return { ok: true, skipped: '当前没有未读消息' }; + if (!icon) return { ok: false, reason: '通知图标没有 .menu-icon-slot 定位父级' }; + const b = badge.getBoundingClientRect(); + const i = icon.getBoundingClientRect(); + return { + ok: true, + dx: Math.round(b.left + b.width / 2 - (i.left + i.width / 2)), + dy: Math.round(b.top + b.height / 2 - (i.top + i.height / 2)), + text: (badge.textContent ?? '').trim(), + }; + })()`, + ) + .catch(() => null); + + let badgeCheck = await readBadgeGeometry(); + let injectedId = ''; + if (badgeCheck?.skipped) { + // 没有未读消息时红点不会渲染:造一条未读通知(教师账号),等实时推送刷新后再断言位置 + const created = await createSmokeNotification(); + if (created) { + injectedId = created; + await sleep(1400); + badgeCheck = await readBadgeGeometry(); + } + } + record( + '未读红点位置正确(挂在"通知"图标右上方)', + badgeCheck?.ok === true && + (badgeCheck?.skipped !== undefined || ((badgeCheck?.dx ?? 0) > 0 && (badgeCheck?.dy ?? 0) < 0)), + badgeCheck?.skipped + ? `${badgeCheck.skipped}(未能构造未读消息,已跳过位置断言)` + : `红点=${badgeCheck?.text} 相对图标中心 dx=${badgeCheck?.dx} dy=${badgeCheck?.dy}(要求 dx>0 且 dy<0)` + + `${badgeCheck?.reason ? ` 原因=${badgeCheck.reason}` : ''}`, + ); + if (injectedId) await deleteSmokeNotification(injectedId); + const cleanup = await win.webContents.executeJavaScript( `(async () => { if (!window.__classhelperSmoke__) return { ok: false, detail: '渲染进程未注册冒烟钩子' }; diff --git a/packages/desktop-client/src/renderer/api/index.ts b/packages/desktop-client/src/renderer/api/index.ts index ea67857..41f8855 100644 --- a/packages/desktop-client/src/renderer/api/index.ts +++ b/packages/desktop-client/src/renderer/api/index.ts @@ -67,5 +67,6 @@ export const gradeApi = { export const dashboardApi = { summary: (): Promise => api.get(API_PATHS.dashboard), - term: (): Promise<{ currentWeek: number; maxWeek: number }> => api.get('/dashboard/term'), + term: (classId?: string): Promise<{ currentWeek: number; maxWeek: number }> => + api.get('/dashboard/term', classId ? { classId } : undefined), }; diff --git a/packages/desktop-client/src/renderer/layouts/ClientLayout.vue b/packages/desktop-client/src/renderer/layouts/ClientLayout.vue index 2761b95..208b95f 100644 --- a/packages/desktop-client/src/renderer/layouts/ClientLayout.vue +++ b/packages/desktop-client/src/renderer/layouts/ClientLayout.vue @@ -106,13 +106,17 @@ onUnmounted(() => { - + + + + + {{ item.title }} - + - -
-
-
- {{ column.course }} - {{ column.pending }} -
-
    -
  1. - {{ index + 1 }}. - {{ item.title }} - {{ formatDate(item.createdAt, true) }} -
  2. -
-
+ + +
+
+
+
+ {{ column.course }} + {{ column.pending }} +
+
    +
  1. + {{ index + 1 }}. + + {{ item.title }} + {{ item.content }} + + {{ formatDate(item.createdAt, true) }} +
  2. +
+
+
@@ -468,7 +602,7 @@ onUnmounted(() => { - {{ student.name }}({{ student.username }}) + {{ student.name }}
@@ -543,6 +677,27 @@ onUnmounted(() => { margin: 0; } +/* 作业页整体不滚动:看板靠自适应缩放铺满,避免"要上下翻页才能看全" */ +.homework-page { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + box-sizing: border-box; +} + +.homework-body { + flex: 1 1 auto; + min-height: 0; +} + +.homework-body :deep(.el-card__body) { + height: 100%; + display: flex; + flex-direction: column; + min-height: 0; +} + /* ---------------------------------------------------------------- 看板 */ .board-config { @@ -560,15 +715,32 @@ onUnmounted(() => { font-weight: 600; } +/* 承载区:高度固定,里面靠 scale 适配,绝不出现滚动条 */ +.board-host { + flex: 1 1 auto; + min-height: 240px; + overflow: hidden; + display: flex; + align-items: flex-start; + justify-content: center; +} + +.board-host-lg { + height: calc(100vh - 150px); +} + .board { display: grid; - grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); - gap: 14px; + grid-template-columns: repeat(auto-fit, minmax(calc(var(--board-font, 15px) * 15), 1fr)); + gap: calc(var(--board-font, 15px) * 0.8); + width: 100%; + transform: scale(var(--board-scale, 1)); + transform-origin: top center; } .board-lg { - grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); - gap: 20px; + grid-template-columns: repeat(auto-fit, minmax(calc(var(--board-font, 15px) * 16), 1fr)); + gap: calc(var(--board-font, 15px) * 0.9); } .board-card { @@ -577,8 +749,7 @@ onUnmounted(() => { background: #1c1c1e; color: #f5f5f7; border-radius: 18px; - padding: calc(var(--board-font, 15px) * 0.9) calc(var(--board-font, 15px) * 1.1); - min-height: 120px; + padding: calc(var(--board-font, 15px) * 0.8) calc(var(--board-font, 15px) * 0.95); cursor: zoom-in; transition: transform 0.16s ease, @@ -596,7 +767,7 @@ onUnmounted(() => { align-items: center; justify-content: space-between; gap: 8px; - margin-bottom: calc(var(--board-font, 15px) * 0.5); + margin-bottom: calc(var(--board-font, 15px) * 0.45); } .board-course { @@ -625,13 +796,13 @@ onUnmounted(() => { list-style: none; display: flex; flex-direction: column; - gap: calc(var(--board-font, 15px) * 0.35); + gap: calc(var(--board-font, 15px) * 0.4); flex: 1; } .board-item { display: flex; - align-items: baseline; + align-items: flex-start; gap: 8px; font-size: var(--board-font, 15px); line-height: 1.5; @@ -649,12 +820,29 @@ onUnmounted(() => { flex: 0 0 auto; } -.board-title { +.board-text { flex: 1 1 auto; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.board-title { + font-weight: 600; word-break: break-word; } -.board-item.done .board-title { +/* 作业要求:完整展示(不截断),字号略小以保持层级 */ +.board-content { + font-size: calc(var(--board-font, 15px) * 0.82); + color: #c7c7cc; + white-space: pre-wrap; + word-break: break-word; +} + +.board-item.done .board-title, +.board-item.done .board-content { text-decoration: line-through; color: #8e8e93; } @@ -675,7 +863,7 @@ onUnmounted(() => { display: flex; align-items: center; justify-content: space-between; - margin-top: calc(var(--board-font, 15px) * 0.5); + margin-top: calc(var(--board-font, 15px) * 0.45); font-size: calc(var(--board-font, 15px) * 0.7); } @@ -691,6 +879,31 @@ onUnmounted(() => { padding: 6px 8px; } +/* 全屏弹窗标题栏:标题 + 当天时间(时间绝对居中对齐) */ +.board-dialog-head { + position: relative; + display: flex; + align-items: center; + justify-content: flex-start; + padding-right: 48px; +} + +.board-dialog-clock { + position: absolute; + left: 50%; + transform: translateX(-50%); + font-size: 14px; + font-weight: 500; + color: #6b7280; + white-space: nowrap; +} + +.board-fullscreen :deep(.el-dialog__body) { + padding-top: 6px; + height: calc(100vh - 84px); + overflow: hidden; +} + .submission-toolbar { display: flex; align-items: center; diff --git a/packages/desktop-client/src/renderer/views/ScheduleView.vue b/packages/desktop-client/src/renderer/views/ScheduleView.vue index 53a5c54..7249607 100644 --- a/packages/desktop-client/src/renderer/views/ScheduleView.vue +++ b/packages/desktop-client/src/renderer/views/ScheduleView.vue @@ -171,7 +171,8 @@ function remainText(minutes: number): string { } async function loadTerm(): Promise { - const result = await fetchWithCache('classes', 'term', () => dashboardApi.term(), { + // 带 classId:周次上限取本班"教学周数"(班主任可调,默认 20 周) + const result = await fetchWithCache('classes', 'term', () => dashboardApi.term(auth.classId ?? undefined), { currentWeek: 1, maxWeek: 20, }); diff --git a/packages/desktop-client/src/types/desktop.d.ts b/packages/desktop-client/src/types/desktop.d.ts index 87d75d0..774cb04 100644 --- a/packages/desktop-client/src/types/desktop.d.ts +++ b/packages/desktop-client/src/types/desktop.d.ts @@ -8,10 +8,12 @@ import type { IslandAppearance, IslandNotification, IslandState } from '@classhe export interface HomeworkBoardSettings { /** 展示模式:board = 按科目卡片看板,list = 默认表格 */ mode: 'board' | 'list'; - /** 看板条目是否显示时间 */ + /** 看板条目是否显示时间(同时也控制"今日作业看板"标题栏中间的当天时间) */ showTime: boolean; /** 看板字号(px) */ fontSize: number; + /** 只看"今天布置"的作业(默认 true,避免昨天的过期作业混进来) */ + todayOnly: boolean; } export interface DesktopStoredConfig { diff --git a/packages/server/prisma/migrations/20260913000000_add_class_term_weeks/migration.sql b/packages/server/prisma/migrations/20260913000000_add_class_term_weeks/migration.sql new file mode 100644 index 0000000..41bcf9b --- /dev/null +++ b/packages/server/prisma/migrations/20260913000000_add_class_term_weeks/migration.sql @@ -0,0 +1,2 @@ +-- 班级教学周数(班主任可调):默认 20 周,旧数据行为不变(原来固定按 20 周展示,见 seed/导入默认值) +ALTER TABLE "Class" ADD COLUMN "termWeeks" INTEGER NOT NULL DEFAULT 20; \ No newline at end of file diff --git a/packages/server/prisma/schema.prisma b/packages/server/prisma/schema.prisma index 0c9e65e..9cdf906 100644 --- a/packages/server/prisma/schema.prisma +++ b/packages/server/prisma/schema.prisma @@ -58,6 +58,8 @@ model Class { code String @unique /// 班级密码(bcrypt 哈希);null = 尚未设置,此时禁止班级账号登录 passwordHash String? + /// 本学期教学周数(班主任可调,默认 20):课表周次选择与 weekEnd 默认值都用它 + termWeeks Int @default(20) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/packages/server/prisma/seed.ts b/packages/server/prisma/seed.ts index 7bde588..ef95592 100644 --- a/packages/server/prisma/seed.ts +++ b/packages/server/prisma/seed.ts @@ -10,6 +10,7 @@ * teacher2 / teacher123 - 李老师(高一(2)班班主任,高二(3)班协作教师) * student01..student15 / student123 */ +import { SUBJECT_CATALOG } from '@classhelper/shared'; import { disconnectPrisma, prisma } from '../src/lib/db.js'; import { logger } from '../src/lib/logger.js'; import { hashPassword } from '../src/lib/password.js'; @@ -33,7 +34,8 @@ const STUDENT_NAMES = [ '胡一鸣', ]; -const COURSE_NAMES = ['语文', '数学', '英语', '物理', '化学']; +// 课程目录:与 shared 的 SUBJECT_CATALOG 保持一致(演示数据覆盖全部常见科目) +const COURSE_NAMES = [...SUBJECT_CATALOG]; /** 每天的上课节次模板(dayOfWeek 1-5) */ const PERIOD_TEMPLATE = [ diff --git a/packages/server/scripts/apply-column-migrations.cjs b/packages/server/scripts/apply-column-migrations.cjs new file mode 100644 index 0000000..185c849 --- /dev/null +++ b/packages/server/scripts/apply-column-migrations.cjs @@ -0,0 +1,82 @@ +/** + * 维护脚本:给"已有数据库"补齐结构性新增列(不依赖安装程序升级)。 + * + * 为什么需要它:安装版服务端只在**库为空**时执行随包迁移 SQL(首次启动建表); + * 用安装程序覆盖升级时,升级逻辑会自动补齐新迁移。若像本地联调那样**手工覆盖 dist** + * 部署,就需要显式跑一次本脚本。 + * + * 用法(用安装目录自带的 node 即可,无需 SDK):传入**已编译的 `lib/db.js`**, + * 直接复用服务端自己配置好的 Prisma 客户端(Prisma 7 需要 driver adapter,不能裸 new): + * + * node apply-column-migrations.cjs "<安装目录>/server/dist/lib/db.js" + * + * 环境变量(与服务端一致): + * DATABASE_PROVIDER=sqlite + * DATABASE_URL="file:<绝对路径>/classhelper.db" + * + * 幂等:已存在的列会跳过。 + */ +const { pathToFileURL } = require('node:url'); + +const dbEntry = process.argv[2]; +if (!dbEntry) { + console.error('用法:node apply-column-migrations.cjs <已编译的 lib/db.js 绝对路径>'); + process.exit(2); +} + +/** 需要保证存在的列(新增迁移时在这里追加一行) */ +const COLUMNS = [ + { + table: 'Schedule', + column: 'weekParity', + sql: `ALTER TABLE "Schedule" ADD COLUMN "weekParity" TEXT NOT NULL DEFAULT 'ALL'`, + note: '课表单双周(ALL/ODD/EVEN)', + }, + { + table: 'Class', + column: 'termWeeks', + sql: `ALTER TABLE "Class" ADD COLUMN "termWeeks" INTEGER NOT NULL DEFAULT 20`, + note: '班级教学周数(班主任可调,默认 20)', + }, +]; + +(async () => { + let prisma; + try { + const module = await import(pathToFileURL(dbEntry).href); + prisma = module.prisma ?? module.default?.prisma; + } catch (error) { + console.error( + '[migrate] 无法加载服务端的 Prisma 客户端:', + error && error.message ? error.message : error, + ); + process.exitCode = 1; + return; + } + if (!prisma) { + console.error('[migrate] lib/db.js 里没有导出 prisma'); + process.exitCode = 1; + return; + } + + try { + for (const item of COLUMNS) { + const columns = await prisma.$queryRawUnsafe(`PRAGMA table_info("${item.table}")`); + if (columns.some((column) => column.name === item.column)) { + console.log(`[migrate] ${item.table}.${item.column} 已存在(跳过)`); + continue; + } + await prisma.$executeRawUnsafe(item.sql); + console.log(`[migrate] 已新增 ${item.table}.${item.column} —— ${item.note}`); + } + for (const table of [...new Set(COLUMNS.map((item) => item.table))]) { + const after = await prisma.$queryRawUnsafe(`PRAGMA table_info("${table}")`); + console.log(`[migrate] ${table} 列:${after.map((column) => column.name).join(', ')}`); + } + } catch (error) { + console.error('[migrate] 失败:', error && error.message ? error.message : error); + process.exitCode = 1; + } finally { + await prisma.$disconnect?.(); + } +})(); diff --git a/packages/server/scripts/apply-parity-column.cjs b/packages/server/scripts/apply-parity-column.cjs deleted file mode 100644 index e4f0fca..0000000 --- a/packages/server/scripts/apply-parity-column.cjs +++ /dev/null @@ -1,62 +0,0 @@ -/** - * 维护脚本:给"已有数据库"补上 Schedule.weekParity 列(单双周课表)。 - * - * 为什么需要它:安装版服务端只在**库为空**时执行随包迁移 SQL(首次启动建表); - * 用安装程序覆盖升级时,升级逻辑会自动补齐新迁移。若像本地联调那样**手工覆盖 dist** - * 部署,就需要显式跑一次本脚本。 - * - * 用法(用安装目录自带的 node 即可,无需 SDK):传入**已编译的 `lib/db.js`**, - * 直接复用服务端自己配置好的 Prisma 客户端(Prisma 7 需要 driver adapter,不能裸 new): - * - * node apply-parity-column.cjs "<安装目录>/server/dist/lib/db.js" - * - * 环境变量(与服务端一致): - * DATABASE_PROVIDER=sqlite - * DATABASE_URL="file:<绝对路径>/classhelper.db" - */ -const { pathToFileURL } = require('node:url'); - -const dbEntry = process.argv[2]; -if (!dbEntry) { - console.error('用法:node apply-parity-column.cjs <已编译的 lib/db.js 绝对路径>'); - process.exit(2); -} - -(async () => { - let prisma; - try { - const module = await import(pathToFileURL(dbEntry).href); - prisma = module.prisma ?? module.default?.prisma; - } catch (error) { - console.error( - '[parity] 无法加载服务端的 Prisma 客户端:', - error && error.message ? error.message : error, - ); - process.exitCode = 1; - return; - } - if (!prisma) { - console.error('[parity] lib/db.js 里没有导出 prisma'); - process.exitCode = 1; - return; - } - - try { - const columns = await prisma.$queryRawUnsafe('PRAGMA table_info("Schedule")'); - if (columns.some((column) => column.name === 'weekParity')) { - console.log('[parity] Schedule.weekParity 已存在,无需处理'); - } else { - await prisma.$executeRawUnsafe( - `ALTER TABLE "Schedule" ADD COLUMN "weekParity" TEXT NOT NULL DEFAULT 'ALL'`, - ); - console.log('[parity] 已新增 Schedule.weekParity(默认 ALL,旧数据行为不变)'); - } - const after = await prisma.$queryRawUnsafe('PRAGMA table_info("Schedule")'); - console.log(`[parity] 当前列:${after.map((column) => column.name).join(', ')}`); - } catch (error) { - console.error('[parity] 失败:', error && error.message ? error.message : error); - process.exitCode = 1; - } finally { - await prisma.$disconnect?.(); - } -})(); diff --git a/packages/server/scripts/verify-e2e.mjs b/packages/server/scripts/verify-e2e.mjs index 01da4cf..5d8cc5a 100644 --- a/packages/server/scripts/verify-e2e.mjs +++ b/packages/server/scripts/verify-e2e.mjs @@ -85,13 +85,46 @@ async function main() { record('管理员登录 admin', Boolean(adminToken), `status=${adminLogin.status}`); if (!adminToken) process.exit(1); - const studentLogin = await api('/auth/login', { + // 学生端主体 = 班级:用「班级码 + 班级密码」登录(个人学生账号已停用登录入口) + const classesForLogin = await api('/classes', { token: teacherToken }); + const loginClass = classesForLogin.payload?.data?.[0]; + if (!loginClass?.code) { + record( + '学生端班级登录(班级码 + 班级密码 → classSession)', + false, + '没有可用班级码,请先执行 pnpm db:seed', + ); + process.exit(1); + } + let studentLogin = await api('/auth/class-login', { method: 'POST', - body: { username: 'student01', password: 'student123' }, + body: { code: loginClass.code, password: '123456' }, }); + if (studentLogin.status !== 200) { + // 演示库密码被改过时,由管理员重置为已知值(仅验证环境) + await api(`/classes/${loginClass.id}/class-account`, { + method: 'PATCH', + token: adminToken, + body: { code: loginClass.code, password: '123456' }, + }); + studentLogin = await api('/auth/class-login', { + method: 'POST', + body: { code: loginClass.code, password: '123456' }, + }); + } const studentToken = studentLogin.payload?.data?.token; const studentUser = studentLogin.payload?.data?.user; - record('学生登录 student01', Boolean(studentToken), `classId=${studentUser?.classId}`); + record( + '学生端班级登录(班级码 + 班级密码 → classSession)', + Boolean(studentToken) && studentUser?.classSession === true, + `code=${loginClass.code} status=${studentLogin.status} classId=${studentUser?.classId ?? studentUser?.id}`, + ); + record( + '个人学生账号登录已停用(403)', + (await api('/auth/login', { method: 'POST', body: { username: 'student01', password: 'student123' } })) + .status === 403, + 'student01 → 403', + ); const wrongPassword = await api('/auth/login', { method: 'POST', @@ -128,7 +161,8 @@ async function main() { const studentList = await api(`/students?classId=${classId}`, { token: adminToken }); const classmates = studentList.payload?.data ?? []; - const targetStudent = classmates.find((item) => item.id === studentUser?.id) ?? classmates[0]; + // 班级会话下没有"当前学生",目标学生固定取班里第一位(用于成绩/叫人等需要具体学生 id 的用例) + const targetStudent = classmates[0]; record('教师获取学生名单', classmates.length > 0, `共 ${classmates.length} 人`); const courses = await api(`/courses?classId=${classId}`, { token: teacherToken }); @@ -468,8 +502,8 @@ async function main() { body: { notSubmittedUserIds: [] }, }); record( - '普通学生不能维护未交名单(403)', - studentSubmissions.status === 403, + '班级设备可以维护未交名单(非教师/非班级会话会被拒)', + studentSubmissions.status === 200, `status=${studentSubmissions.status}`, ); @@ -497,7 +531,7 @@ async function main() { }); record('教师录入成绩', createdGrade.status === 201, `status=${createdGrade.status}`); - if (targetStudent?.id === studentUser?.id) { + if (targetStudent) { try { const { payload, elapsed } = await gradeWait; record( @@ -808,7 +842,7 @@ async function main() { token: teacherToken, body: { classId, - studentId: studentUser.id, + studentId: targetStudent?.id, quickPhrase: '请到办公室找我', }, }); @@ -829,7 +863,7 @@ async function main() { token: teacherToken, body: { classId, - studentId: studentUser.id, + studentId: targetStudent?.id, quickPhrase: '请立刻到办公室', urgent: true, }, @@ -845,7 +879,7 @@ async function main() { token: teacherToken, body: { classId, - studentId: studentUser.id, + studentId: targetStudent?.id, quickPhrase: '请到讲台找我', message: '带上昨天的数学作业本', }, @@ -860,7 +894,7 @@ async function main() { const callDuringClass = await api('/calls', { method: 'POST', token: teacherToken, - body: { classId, studentId: studentUser.id, quickPhrase: '请马上来一趟' }, + body: { classId, studentId: targetStudent?.id, quickPhrase: '请马上来一趟' }, }); record( '上课时段允许叫人(普通叫人同样不受紧急通知 409 限制)', @@ -871,7 +905,7 @@ async function main() { const studentCalls = await api('/calls', { method: 'POST', token: studentToken, - body: { classId, studentId: studentUser.id, quickPhrase: '学生不能叫人' }, + body: { classId, studentId: targetStudent?.id, quickPhrase: '学生不能叫人' }, }); record('学生调用叫人接口被拒绝(403)', studentCalls.status === 403, `status=${studentCalls.status}`); @@ -1006,7 +1040,13 @@ async function main() { const subjectGrades = await api('/grades', { method: 'POST', token: teacher2Token, - body: { classId: classId, userId: studentUser?.id, examName: '越权成绩', score: 90, totalScore: 100 }, + body: { + classId: classId, + userId: targetStudent?.id, + examName: '越权成绩', + score: 90, + totalScore: 100, + }, }); record('科任老师录入成绩被拒绝(403)', subjectGrades.status === 403, `status=${subjectGrades.status}`); @@ -1016,7 +1056,7 @@ async function main() { token: teacherToken, body: { classId: classId, - userId: studentUser?.id, + userId: targetStudent?.id, examName: '班主任本班成绩', score: 90, totalScore: 100, @@ -1037,7 +1077,7 @@ async function main() { token: teacherToken, body: { classId: foreignClass.id, - userId: studentUser?.id, + userId: targetStudent?.id, examName: '班主任跨班成绩', score: 90, totalScore: 100, @@ -1055,7 +1095,7 @@ async function main() { token: adminToken, body: { classId: classId, - userId: studentUser?.id, + userId: targetStudent?.id, examName: '管理员录入成绩', score: 88, totalScore: 100, @@ -1132,7 +1172,7 @@ async function main() { const studentVisible = studentClasses.payload?.data ?? []; record( '学生班级列表仅含自己所在班级', - studentVisible.length === 1 && studentVisible[0]?.id === studentUser?.classId, + studentVisible.length === 1 && studentVisible[0]?.id === classId, `共 ${studentVisible.length} 个`, ); @@ -1140,7 +1180,7 @@ async function main() { const homeworkClassIds = new Set((studentHomeworkAll.payload?.data ?? []).map((item) => item.classId)); record( '学生作业列表已按班级收敛', - homeworkClassIds.size <= 1 && (homeworkClassIds.size === 0 || homeworkClassIds.has(studentUser?.classId)), + homeworkClassIds.size <= 1 && (homeworkClassIds.size === 0 || homeworkClassIds.has(classId)), `涉及 ${homeworkClassIds.size} 个班级`, ); @@ -1750,7 +1790,7 @@ async function main() { const classGradesWrite = await api('/grades', { method: 'POST', token: classToken, - body: { classId, userId: studentUser?.id, examName: '班级账号越权成绩', score: 90, totalScore: 100 }, + body: { classId, userId: targetStudent?.id, examName: '班级账号越权成绩', score: 90, totalScore: 100 }, }); record( '班级账号录入成绩被拒绝(403)', @@ -1843,7 +1883,7 @@ async function main() { const classNameGrade = await api('/grades', { method: 'POST', token: adminToken, - body: { classId, userId: studentUser?.id, examName: '班级总览回归', score: 77, totalScore: 100 }, + body: { classId, userId: targetStudent?.id, examName: '班级总览回归', score: 77, totalScore: 100 }, }); const classMyGrades = await api('/grades/my', { token: classToken }); const gradeStudentIds = new Set((classMyGrades.payload?.data ?? []).map((item) => item.userId)); diff --git a/packages/server/src/lib/mappers.ts b/packages/server/src/lib/mappers.ts index 029dd4d..9687ab4 100644 --- a/packages/server/src/lib/mappers.ts +++ b/packages/server/src/lib/mappers.ts @@ -93,6 +93,8 @@ export interface ClassLike { name: string; grade: string; teacherId: string; + /** 本学期教学周数(班主任可调,默认 20) */ + termWeeks?: number; createdAt: Date; teacher?: TeacherBriefLike | null; _count?: { @@ -110,6 +112,7 @@ export function toClassDto(item: ClassLike): ClassDto { name: item.name, grade: item.grade, teacherId: item.teacherId, + termWeeks: item.termWeeks ?? 20, createdAt: toIso(item.createdAt), teacher: item.teacher ? toTeacherBrief(item.teacher) : null, studentCount: item._count?.students ?? undefined, diff --git a/packages/server/src/modules/auth/auth.service.ts b/packages/server/src/modules/auth/auth.service.ts index a19c1d5..b5765db 100644 --- a/packages/server/src/modules/auth/auth.service.ts +++ b/packages/server/src/modules/auth/auth.service.ts @@ -8,7 +8,13 @@ import { hashPassword, verifyPassword } from '../../lib/password.js'; import { isClassSession } from '../../lib/session.js'; import type { ChangePasswordInput, ClassLoginInput, LoginInput } from './auth.schemas.js'; -/** 登录:校验用户名密码并签发 JWT(教师/管理员/个人学生账号) */ +/** + * 登录:校验用户名密码并签发 JWT。 + * + * 学生端主体是**班级**(班级码 + 班级密码):个人学生账号(student01…)**不再允许登录**, + * 它们的用户名只是"作业完成 / 通知已读 / 成绩"等个人数据的记录键(由班级会话代全班读写)。 + * 教师与管理员仍用用户名登录。 + */ export async function login(input: LoginInput): Promise { const user = await prisma.user.findUnique({ where: { username: input.username } }); if (!user) throw ApiError.unauthorized('用户名或密码错误'); @@ -16,6 +22,11 @@ export async function login(input: LoginInput): Promise { const passwordMatched = await verifyPassword(input.password, user.passwordHash); if (!passwordMatched) throw ApiError.unauthorized('用户名或密码错误'); + // 个人学生账号已停用登录入口(需求:以班级为单位,删掉学生用自己用户名登录) + if (user.role === 'STUDENT') { + throw ApiError.forbidden('学生请使用「班级码 + 班级密码」登录(个人学生账号已停用)'); + } + const payload: TokenPayload = { sub: user.id, username: user.username, diff --git a/packages/server/src/modules/classes/classes.schemas.ts b/packages/server/src/modules/classes/classes.schemas.ts index 4c5f367..99ee3ee 100644 --- a/packages/server/src/modules/classes/classes.schemas.ts +++ b/packages/server/src/modules/classes/classes.schemas.ts @@ -17,6 +17,8 @@ export const createClassSchema = z.object({ export const updateClassSchema = z.object({ name: z.string().trim().min(1).max(64).optional(), grade: z.string().trim().min(1).max(32).optional(), + /** 本学期教学周数(1~40,班主任可调) */ + termWeeks: z.coerce.number().int().min(1).max(40).optional(), }); /** 设置 / 重置班级账号(班级码 + 班级密码):仅管理员 */ diff --git a/packages/server/src/modules/classes/classes.service.ts b/packages/server/src/modules/classes/classes.service.ts index 835b43d..cc1b2c9 100644 --- a/packages/server/src/modules/classes/classes.service.ts +++ b/packages/server/src/modules/classes/classes.service.ts @@ -174,7 +174,12 @@ export async function updateClass( const updated = await prisma.class.update({ where: { id: classId }, - data: { ...(input.name ? { name: input.name } : {}), ...(input.grade ? { grade: input.grade } : {}) }, + data: { + ...(input.name ? { name: input.name } : {}), + ...(input.grade ? { grade: input.grade } : {}), + // 教学周数(班主任可调):课表周次选择与默认 weekEnd 都用它 + ...(input.termWeeks !== undefined ? { termWeeks: input.termWeeks } : {}), + }, include: { teacher: { select: { id: true, name: true, username: true } }, _count: { select: countSelect }, diff --git a/packages/server/src/modules/dashboard/dashboard.module.ts b/packages/server/src/modules/dashboard/dashboard.module.ts index fffbd35..e9f1502 100644 --- a/packages/server/src/modules/dashboard/dashboard.module.ts +++ b/packages/server/src/modules/dashboard/dashboard.module.ts @@ -1,6 +1,6 @@ import { Router } from 'express'; import { sendOk } from '../../lib/http.js'; -import { resolveCurrentWeek, MAX_TERM_WEEK } from '../../lib/term.js'; +import { resolveCurrentWeek } from '../../lib/term.js'; import { authenticate, getAuthUser } from '../../middleware/auth.js'; import { defineModule } from '../module.types.js'; import * as dashboardService from './dashboard.service.js'; @@ -14,9 +14,17 @@ router.get('/summary', async (req, res) => { sendOk(res, await dashboardService.getDashboardSummary(user), '获取仪表盘数据成功'); }); -/** GET /api/dashboard/term - 学期信息(当前教学周) */ -router.get('/term', (_req, res) => { - sendOk(res, { currentWeek: resolveCurrentWeek(), maxWeek: MAX_TERM_WEEK }, '获取学期信息成功'); +/** + * GET /api/dashboard/term?classId= - 学期信息(当前教学周 + 本班教学周数) + * + * `maxWeek` 取**班级的教学周数**(`Class.termWeeks`,班主任可在班级设置里调,默认 20), + * 这样周次选择器不会再出现"实际 20 周却给到 30 周"的情况;不带 classId 时退回全局默认值。 + */ +router.get('/term', async (req, res) => { + const user = getAuthUser(req); + const classId = typeof req.query.classId === 'string' && req.query.classId ? req.query.classId : undefined; + const maxWeek = await dashboardService.resolveTermWeeks(user, classId); + sendOk(res, { currentWeek: resolveCurrentWeek(), maxWeek }, '获取学期信息成功'); }); export const dashboardModule = defineModule({ diff --git a/packages/server/src/modules/dashboard/dashboard.service.ts b/packages/server/src/modules/dashboard/dashboard.service.ts index 347511f..1f80f93 100644 --- a/packages/server/src/modules/dashboard/dashboard.service.ts +++ b/packages/server/src/modules/dashboard/dashboard.service.ts @@ -7,6 +7,7 @@ import { requireStudentClassId, } from '../../lib/access.js'; import { prisma } from '../../lib/db.js'; +import { MAX_TERM_WEEK } from '../../lib/term.js'; import type { TokenPayload } from '../../lib/jwt.js'; import { toHomeworkDto, toNotificationDto } from '../../lib/mappers.js'; import { personalIdWhere, resolvePersonalIds } from '../../lib/session.js'; @@ -18,6 +19,21 @@ const creatorSelect = { select: { id: true, name: true, username: true } } as co * 仪表盘汇总。 * 教师/管理员看到的是"自己管理的班级"的汇总;学生看到自己班级的汇总。 */ +/** + * 本班教学周数(班主任可调,默认 20)。 + * - 学生 / 班级会话:固定为自己所在班级; + * - 教师 / 管理员:优先用显式 classId(越权查不到时回退默认值); + * - 都不传:全局默认(MAX_TERM_WEEK 兜底)。 + */ +export async function resolveTermWeeks(user: TokenPayload, classId?: string): Promise { + const targetClassId = classId ?? (isStudent(user) ? requireStudentClassId(user) : undefined); + if (!targetClassId) return MAX_TERM_WEEK; + const record = await prisma.class.findUnique({ + where: { id: targetClassId }, + select: { termWeeks: true }, + }); + return record?.termWeeks && record.termWeeks > 0 ? record.termWeeks : MAX_TERM_WEEK; +} export async function getDashboardSummary(user: TokenPayload): Promise { const student = isStudent(user); // 学生强制限定在自己班级,避免越权统计 diff --git a/packages/server/src/modules/schedules/schedules.service.ts b/packages/server/src/modules/schedules/schedules.service.ts index 230b01d..cebadcd 100644 --- a/packages/server/src/modules/schedules/schedules.service.ts +++ b/packages/server/src/modules/schedules/schedules.service.ts @@ -105,6 +105,12 @@ export async function getClassStatus( } export async function createSchedule(user: TokenPayload, input: CreateScheduleInput): Promise { + // 教学周数由班级设置决定(班主任可调,默认 20 周) + const classRecord = await prisma.class.findUnique({ + where: { id: input.classId }, + select: { termWeeks: true }, + }); + const termWeeks = classRecord?.termWeeks ?? 20; await assertCanManageSchedule(user, input.classId); await assertCourseInClass(input.courseId, input.classId); @@ -117,7 +123,7 @@ export async function createSchedule(user: TokenPayload, input: CreateScheduleIn endTime: input.endTime, location: input.location ?? null, weekStart: input.weekStart ?? 1, - weekEnd: input.weekEnd ?? 20, + weekEnd: input.weekEnd ?? termWeeks, // 单双周:默认 ALL(每周),与旧数据行为一致 weekParity: input.weekParity ?? 'ALL', }, diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 5d94567..8f9d353 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -66,6 +66,29 @@ export const WEEKDAY_LABELS: Record = { export const WEEKDAYS: readonly number[] = [1, 2, 3, 4, 5, 6, 7]; +/** + * 常用科目目录:作业/课表里"新建课程"时可直接选(也用于种子数据与导入时的科目建议)。 + * 顺序按中学常见排课习惯,前 9 个是文化课,后面是技术与体艺类。 + */ +export const SUBJECT_CATALOG: readonly string[] = [ + '语文', + '数学', + '英语', + '物理', + '化学', + '生物', + '政治', + '历史', + '地理', + '信息技术', + '通用技术', + '体育', + '音乐', + '美术', + '心理健康', + '劳动', +] as const; + /** * 单双周标签与取值(课表用)。 * 语义与 ClassIsland 课表一致:WeekCountDivTotal=1 → 每周;=2 时 WeekCountDiv=1 → 单周、=2 → 双周。 diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 6ab4157..9c5b8c2 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -104,6 +104,8 @@ export interface ClassDto { code?: string; /** 是否已设置班级密码(哈希永不外泄) */ hasPassword?: boolean; + /** 本学期教学周数(班主任可调,默认 20):课表周次选择与默认 weekEnd 都用它 */ + termWeeks: number; } export interface ClassTeacherBrief { @@ -128,6 +130,8 @@ export interface CreateClassRequest { export interface UpdateClassRequest { name?: string; grade?: string; + /** 本学期教学周数(1~40,班主任可调) */ + termWeeks?: number; } /** 管理员设置/重置班级账号(班级码 + 班级密码) */ diff --git a/packages/web-admin/src/api/index.ts b/packages/web-admin/src/api/index.ts index 3bca885..768346b 100644 --- a/packages/web-admin/src/api/index.ts +++ b/packages/web-admin/src/api/index.ts @@ -192,7 +192,12 @@ export const studentApi = { export const dashboardApi = { summary: (): Promise => api.get(API_PATHS.dashboard), - term: (): Promise<{ currentWeek: number; maxWeek: number }> => api.get('/dashboard/term'), + /** + * 学期信息:`maxWeek` 是该班的教学周数(`Class.termWeeks`,班主任可调,默认 20), + * 不带 classId 时后端回退全局默认值。 + */ + term: (classId?: string): Promise<{ currentWeek: number; maxWeek: number }> => + api.get('/dashboard/term', classId ? { classId } : undefined), }; /* ------------------------------------------------------------------ 协作教师 */ diff --git a/packages/web-admin/src/components/ClassPlanImportDialog.vue b/packages/web-admin/src/components/ClassPlanImportDialog.vue index d98cb51..f65b416 100644 --- a/packages/web-admin/src/components/ClassPlanImportDialog.vue +++ b/packages/web-admin/src/components/ClassPlanImportDialog.vue @@ -281,8 +281,13 @@ function parityLabel(item: ClassPlanEntryDto): string { - - + + diff --git a/packages/web-admin/src/layouts/AdminLayout.vue b/packages/web-admin/src/layouts/AdminLayout.vue index 8d4977f..7bd60f6 100644 --- a/packages/web-admin/src/layouts/AdminLayout.vue +++ b/packages/web-admin/src/layouts/AdminLayout.vue @@ -87,6 +87,13 @@ const connectionType = computed(() => realtime.connected ? 'success' : realtime.connecting ? 'warning' : 'danger', ); +/** + * 小屏顶栏的实时通道提示: + * 连接正常时 **什么都不显示**(旧版只显示一个绿色圆点,看起来像坏掉的元素); + * 只有"连接中 / 已断开"这种异常态才显示带文字的紧凑标签。 + */ +const mobileConnectionText = computed(() => (realtime.connecting ? '重连中…' : '已断开')); + async function handleLogout(): Promise { await ElMessageBox.confirm('确认退出当前账号?', '退出登录', { type: 'warning' }); await auth.logout(); @@ -167,15 +174,15 @@ async function submitPassword(): Promise { {{ connectionText }} + - + {{ mobileConnectionText }} 已接收 {{ realtime.eventCount }} 条实时事件 @@ -389,6 +396,12 @@ async function submitPassword(): Promise { .header-title { font-size: 15px; } + + /* 异常态才出现的实时通道标签:紧凑、不挤占标题 */ + .header-conn-mobile { + flex: 0 0 auto; + font-size: 11px; + } } diff --git a/packages/web-admin/src/styles/index.css b/packages/web-admin/src/styles/index.css index f25abfc..ab1c928 100644 --- a/packages/web-admin/src/styles/index.css +++ b/packages/web-admin/src/styles/index.css @@ -416,6 +416,15 @@ body { justify-content: center; } + /* + * 手机端按钮换行后各自独占整行:必须清掉 Element Plus 的 `.el-button + .el-button` + * 12px 左外边距,否则第二个按钮的"内容盒 + 12px"超出容器被 flex 压缩, + * 出现「宽度不一致、右边不齐」(通知页的「叫人 / 发布通知」就是这样)。 + */ + .toolbar > .el-button + .el-button { + margin-left: 0; + } + /* 统计卡片:小屏两列 */ .stat-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); diff --git a/packages/web-admin/src/views/ClassesView.vue b/packages/web-admin/src/views/ClassesView.vue index f21c196..292e6ea 100644 --- a/packages/web-admin/src/views/ClassesView.vue +++ b/packages/web-admin/src/views/ClassesView.vue @@ -44,7 +44,7 @@ async function loadClasses(): Promise { const formVisible = ref(false); const formRef = ref(); const editingId = ref(null); -const form = reactive({ name: '', grade: '', code: '' }); +const form = reactive({ name: '', grade: '', code: '', termWeeks: 20 }); const rules: FormRules = { name: [{ required: true, message: '请输入班级名称', trigger: 'blur' }], grade: [{ required: true, message: '请输入年级', trigger: 'blur' }], @@ -55,6 +55,7 @@ function openCreate(): void { form.name = ''; form.grade = ''; form.code = ''; + form.termWeeks = 20; formVisible.value = true; } @@ -63,6 +64,7 @@ function openEdit(row: ClassDto): void { form.name = row.name; form.grade = row.grade; form.code = ''; + form.termWeeks = row.termWeeks ?? 20; formVisible.value = true; } @@ -71,7 +73,11 @@ async function submitForm(): Promise { if (!valid) return; if (editingId.value) { - await classApi.update(editingId.value, { name: form.name, grade: form.grade }); + await classApi.update(editingId.value, { + name: form.name, + grade: form.grade, + termWeeks: form.termWeeks, + }); ElMessage.success('班级已更新'); } else { await classApi.create({ @@ -395,6 +401,12 @@ onUnmounted(() => { + + + + 本学期一共多少教学周(默认 20);课表周次选择与默认结束周都按它来 + +