fix(island,homework,schedule,web): 16 项反馈修复(联合通知点不开 / 设置不生效 / 红点位置 / 看板当天且铺满 / 单双周与周数可调 / Web 手机端与批量通知等)
一、灵动岛
- ①「紧急通知后来了新作业,收起再点开不了新作业」根因有二,都已修:
(a) active 仍是紧急那条,点击只会反复打开它 —— 现在点击胶囊优先打开**最新**那条未处理消息(队列里更晚的);
(b) 上课时段 syncWindow 会把普通通知立刻隐藏(表现为点了没反应)—— 新增 explicitShowId,
"用户主动点开"的那条守卫放行(岛完全隐藏时仍严格不打扰)。
冒烟新增「紧急通知后来了新作业:收起再点击打开的是新作业(联合通知回归)」。
- ② 设置实时预览/保存:补「设置页真实链路」断言(window.desktop.islandSetAppearance → IPC → 主进程 →
渲染进程 CSS 变量 + 窗口尺寸),并断言"岛显示中改尺寸立即生效"(实测均通过)。
- ③ 未读红点移到「通知」图标右上角(此前在文字后面),并加 DOM 几何断言(dx>0 且 dy<0,
没有未读时自动造一条未读通知再断言、断言后删除)。
- 冒烟稳定性:新增 island.setBlurCollapseEnabled()(默认开),测试期间关掉"失焦收起",
避免系统抢焦点导致展开态被收回的随机失败;专门验证失焦的用例仍临时打开。
二、作业看板(学生端)
- ⑥ 只看当天:新增「只看今天」开关(默认开),按 createdAt 的本地日期过滤,昨天的过期作业不再混入。
- ⑧ 完整显示:卡片同时渲染标题 + 作业要求全文(列表模式也加「作业要求」列)。
- ⑨ 铺满不滚动:看板外层高度固定 + overflow hidden,按内容/可用高度算缩放系数用 transform: scale 适配
(工具栏显示当前缩放比例),页面本身不出现滚动条。
- ⑦ 全屏看板标题栏用 #header 插槽:标题左对齐、当天日期时间绝对居中(受「显示时间」开关控制,每分钟刷新)。
三、课表与周数
- ⑤ 学期周数可调:新增 Class.termWeeks(默认 20,迁移 + 维护脚本),班主任在班级管理里改;
GET /api/dashboard/term?classId= 返回该班 maxWeek,课表周次下拉与新建课表的默认 weekEnd 都按它来
(不再固定 30 周)。
- ⑩ 课表管理只保留「导入 ClassIsland 课程表」入口(时间表由课程表 JSON 的 TimeLayouts 一并带入),
「导入时间配置」按钮下线;ui-smoke 第 7.7 段改为断言新弹窗(解析 4 行 + 单双周标签 + 旧入口已下线)。
四、以班级为单位(⑬)与 Web 端
- 个人学生账号登录停用:POST /api/auth/login 对 role=STUDENT 返回 403(改用班级码 + 班级密码);
学生行仍作为个人数据记录键由班级会话代全班读写;e2e 全面切换到班级会话并新增该断言。
- ④ 作业科目过少:新增共享 SUBJECT_CATALOG(16 个常见科目)+ 种子课程扩到全科目;
Web 作业发布弹窗可选「其他科目(新建)」,提交前自动建课(同名复用)。
- ⑪ 通知支持多班批量发布(多选 + 逐班发布 + 汇总结果 + 二次确认覆盖所有选中班);
⑫ 单班只显示已读情况、跨班才显示已读率;⑭ 手机端课表可横向滚动看全 7 天;
⑮ 手机端已连接不再显示孤立绿点(仅未连接时显示文字标签);⑯ 手机端「叫人/发布通知」按钮同宽左对齐;
⑬ Web 学生管理去掉「用户名」列(新建自动生成学号式用户名,导入仍支持显式指定)。
验证:verify:e2e 149/149、verify:web 20/20、verify:desktop 75/75(复跑 68/68,打包便携版 75/75 packaged=true)、
typecheck / lint / format 全绿;服务端与 Web 已重新打包并部署到本机安装目录。
This commit is contained in:
1 parent
b8ef5d8dbb
commit
11008928a1
34 files changed
+1421
-340
No files matched your search
@@ -457,6 +457,61 @@ async function dumpIslandDom(_label: string): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 冒烟用:用教师账号发一条普通通知,返回通知 id(失败返回空串)。
|
||||
* 用于"未读红点位置"这类需要真实未读数据的断言。
|
||||
*/
|
||||
async function createSmokeNotification(): Promise<string> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
);
|
||||
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: '渲染进程未注册冒烟钩子' };
|
||||
|
||||
Reference in new issue
Block a user