fix(integrations,grades,clients,web,client,plugin): 课表同步改为按需(修「课表被反复删改」)+ 客户端页合并 + 成绩排名(1.3.1)
线上问题的根因 - ClassIsland 插件默认每 15 秒上报一次,每次都带全量课表,上报完还顺手把课表镜像回教室 (本机 `UploadSchedule` / `MirrorSchedule` 两个开关默认都是开)。于是教室的课表每 15 秒被重写、 服务端也每 15 秒 merge + 清理一次幽灵行 —— 老师看到的就是"课表被反复删改"。 - 现在两个方向都只由 Web 端的一次性待办触发,插件侧那两个开关整体删除; 定时上报只报**运行状态**(当前上什么课),不带课表。 课表同步(#3) - 新增 `IntegrationDevice.requestMirrorSchedule` + `POST /integrations/devices/:id/mirror-schedule` (「下发课表」),与既有的 `request-schedule`(「获取课表」)对称;拉取成功即清标记 - 插件 `ClassPlanWriter.Apply(..., force: true)`:停用同一天同一单双周相位下冲突的旧课表 —— **只停用不删除**,内容原样留在档案里,老师随时能重新启用 - 插件设置页删掉「自动上报课表」「镜像回 ClassIsland」两个开关,改为一句说明 「客户端」页(#4) - 把「ClassIsland 联动」与「客户端设备」合成一页 `ClientHubView.vue`:一个班一张卡片, 两条链路状态放一起,勾选可批量下发课表 / 更新,点卡片进详情做逐台操作 - 「下发提醒」也搬了过来;入口权限用 `classisland:notify`(默认 ADMIN + TEACHER), 页内按钮再各自按 device:manage / remote:control / client:update-push 显示或禁用 - 两个旧页面 `meta.hidden: true` **保留路由**(安装包缓存管理、联动步骤说明仍在), 从详情抽屉里给链接 —— 不删功能 成绩(#5/#6/#7) - 新增 `Exam`(班级 + 考试名 + `countInTotal`)与迁移;`GET/PUT /api/grades/exams`、 `GET /api/grades/ranking`(两条都**不挂权限键**,班级端的有效权限是空集,边界在 service 里) - 排名口径写在服务端:**同一学生同一科目只取最高分**(补录 / 重考会多行,不去重等于"总分多一科")、 并列同名次(1、2、2、4) - 「成绩录入」页加「考试口径」开关(按班级 + 考试名记住);客户端「成绩」页新增总分榜 + 单科榜 - `updateStudentGradeQuery` 改成只有教师 / 管理员能改 —— 原先班级端自己也能改 - 客户端的成绩查询开关改为只读(只显示服务端下发的状态) 其他(#2/#5/#8/#9) - 全校学期开始日期改成 `AppSetting` 的 `term.startDate`,管理员在「学期周次」页直接改、立刻重算周次 (原先这一级只能来自 `.env`) - 大模型助手发紧急通知补上「上课时段 3 秒全屏警告」:判据抽成 `composables/useClassInSession.ts`, 与手动发布页共用 - 客户端去掉「关怀模式」「自动更新」两个开关(教室机器是统一投放的终端) - 修掉「选中设置二级菜单后收起分组、高亮块仍留在原地」:底片只在路由 / 滚动 / 缩放时重测, 而收起分组动不了 `.aside` 的盒子 —— 补上"子项被 overflow:hidden 裁掉"的判据 + 观察分组 `<ul>` 高度 - `src/types/express.d.ts` 里 `req.device` 改成引用 `DeviceContext`:原先手抄了一份结构, 往接口加字段时两边就漂了,而报错会落在 `getDevice()` 的 `return device;` 上,看不出真正原因 验收:`lint` / `typecheck` / `check:icons` / `docs:check` 全绿; 后端端到端 **247/247**、联动链路 **44/44**、插件静态契约 **76/76**、 Web 真实点击 **34/34**(含新增的「客户端」页用例)、客户端冒烟 **90/90**。
This commit is contained in:
1 parent
3236d3ad83
commit
227117ccb3
49 files changed
+2025
-281
No files matched your search
@@ -40,7 +40,9 @@ const MENU_ITEMS = [
|
||||
{ label: '作业发布', path: '/homeworks' },
|
||||
{ label: '通知发布', path: '/notifications' },
|
||||
{ label: '成绩录入', path: '/grades' },
|
||||
{ label: 'ClassIsland 联动', path: '/integrations' },
|
||||
// 2026-10-11 起「ClassIsland 联动」与「客户端设备」合并成这一项;
|
||||
// 门控是 `classisland:notify`(默认 ADMIN + TEACHER),所以教师也看得见。
|
||||
{ label: '客户端', path: '/clients' },
|
||||
];
|
||||
|
||||
/** 教师端必须隐藏的入口(前端隐藏 + 后端 403,双重保障) */
|
||||
@@ -1116,18 +1118,72 @@ async function main() {
|
||||
: `原因=${removedUnified?.reason ?? '-'}`,
|
||||
);
|
||||
|
||||
// 7.85 ClassIsland 联动页:班主任为本班签发设备令牌 → 页面下发提醒表单可用 → 收尾删除设备
|
||||
// (这条链路是"老师在 Web 端发提醒 → 教室里 ClassIsland 全屏弹出"的入口,必须真实点得动)
|
||||
const integrationUi = await win.webContents.executeJavaScript(`(async () => {
|
||||
// 7.84 「客户端」页(合并了原来的「ClassIsland 联动」与「客户端设备」):
|
||||
// 侧栏点得进、班级卡片渲染出来、两个批量按钮在
|
||||
const hubUi = await win.webContents.executeJavaScript(`(async () => {
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const menu = Array.from(document.querySelectorAll('.el-menu-item')).find((node) =>
|
||||
(node.textContent ?? '').trim().startsWith('ClassIsland 联动'),
|
||||
(node.textContent ?? '').trim().startsWith('客户端'),
|
||||
);
|
||||
if (!menu) return { ok: false, reason: '教师端看不到「ClassIsland 联动」入口' };
|
||||
if (!menu) return { ok: false, reason: '教师端看不到「客户端」入口' };
|
||||
menu.click();
|
||||
const deadline = Date.now() + 6000;
|
||||
while (Date.now() < deadline && location.pathname !== '/clients') await sleep(80);
|
||||
if (location.pathname !== '/clients') return { ok: false, reason: '点击后没有跳到 /clients' };
|
||||
await sleep(1200);
|
||||
const title = (document.querySelector('.page-title')?.textContent ?? '').trim();
|
||||
const buttons = Array.from(document.querySelectorAll('button')).map((node) => (node.textContent ?? '').trim());
|
||||
const cards = document.querySelectorAll('.class-card').length;
|
||||
// 详情抽屉:点第一张卡的「详情」,抽屉里应有两条链路的表格
|
||||
const detail = Array.from(document.querySelectorAll('.class-card .card-foot button')).find((node) =>
|
||||
(node.textContent ?? '').trim() === '详情',
|
||||
);
|
||||
if (detail) {
|
||||
detail.click();
|
||||
await sleep(900);
|
||||
}
|
||||
const drawer = document.querySelector('.el-drawer');
|
||||
const drawerText = (drawer?.textContent ?? '').trim();
|
||||
const close = document.querySelector('.el-drawer__close-btn');
|
||||
if (close) close.click();
|
||||
await sleep(400);
|
||||
return {
|
||||
ok: true,
|
||||
title,
|
||||
cards,
|
||||
hasBatchMirror: buttons.includes('批量下发课表'),
|
||||
hasBatchUpdate: buttons.includes('批量下发更新'),
|
||||
hasNotify: buttons.includes('下发提醒'),
|
||||
drawerOpened: Boolean(drawer),
|
||||
drawerHasBothSides: drawerText.includes('ClassIsland 教室端') && drawerText.includes('ClassHelper 客户端'),
|
||||
path: location.pathname,
|
||||
};
|
||||
})()`);
|
||||
record(
|
||||
'「客户端」页(合并 ClassIsland 联动 + 客户端设备):卡片渲染 + 批量下发 + 详情抽屉',
|
||||
Boolean(hubUi?.ok) &&
|
||||
hubUi?.title === '客户端' &&
|
||||
hubUi?.cards > 0 &&
|
||||
hubUi?.hasBatchMirror === true &&
|
||||
hubUi?.drawerOpened === true &&
|
||||
hubUi?.drawerHasBothSides === true,
|
||||
`path=${hubUi?.path ?? '-'} 标题=${hubUi?.title ?? '-'} 卡片=${hubUi?.cards ?? 0} ` +
|
||||
`批量下发课表=${hubUi?.hasBatchMirror ? '有' : '无'} 抽屉=${hubUi?.drawerOpened ? '开' : '未开'} ` +
|
||||
`两端清单都在=${hubUi?.drawerHasBothSides ? '是' : '否'}` +
|
||||
`${hubUi?.reason ? ` 原因=${hubUi.reason}` : ''}`,
|
||||
);
|
||||
|
||||
// 7.85 ClassIsland 联动页:班主任为本班签发设备令牌 → 页面下发提醒表单可用 → 收尾删除设备
|
||||
// (这条链路是"老师在 Web 端发提醒 → 教室里 ClassIsland 全屏弹出"的入口,必须真实点得动)
|
||||
// 这一页 2026-10-11 起**从侧栏摘掉**(常用动作合并进「客户端」),路由还在,
|
||||
// 所以这里用 history 直接过去,后面几条断言仍然盯着这页本身。
|
||||
const integrationUi = await win.webContents.executeJavaScript(`(async () => {
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
history.pushState({}, '', '/integrations');
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
const deadline = Date.now() + 6000;
|
||||
while (Date.now() < deadline && location.pathname !== '/integrations') await sleep(80);
|
||||
if (location.pathname !== '/integrations') return { ok: false, reason: '点击后没有跳到 /integrations' };
|
||||
if (location.pathname !== '/integrations') return { ok: false, reason: '没能跳到 /integrations(旧版联动页)' };
|
||||
await sleep(900);
|
||||
|
||||
const buttons = Array.from(document.querySelectorAll('button')).map((node) => (node.textContent ?? '').trim());
|
||||
|
||||
Reference in new issue
Block a user