fix(homework,island,verify): 全屏作业看板自适应 + 已安装副本覆盖升级 + 新增打包版冒烟

① 全屏作业看板自适应(用户反馈:全屏模式没有自适应,卡片挤在左上角)
- 全屏看板按「可用宽 × 可用高」枚举 1..N 列,取**单卡面积最大且宽高比最接近 1.15** 的方案,
  注入 `--board-cols`(不再靠 CSS auto-fit 猜);列数变了再量一次缩放系数,
  `transform-origin: top center` 保证整体居中、不溢出屏幕
- 两个全屏弹窗各自持有 ref(原来共用同名 ref,后挂载的会覆盖前一个,量到的是另一个弹窗);
  列表型「单个科目全屏」补上 `transform: scale()`(该规则原先只作用于 `.board`,列表全屏从未缩放)
- 弹窗 `@opened` 后轮询等待布局就绪(最多 20×60ms)再计算;窗口 resize 时自动重算;
  另加一条全局 `.board-fullscreen .el-dialog__body` 兜底,保证内容区铺满整屏
- 实测:6 个科目 → 3 列、排版宽 1155px(视口 1187px)、可视底 678px(视口高 765px)

② 「灵动岛设置没办法生效」的真正原因:用户机器上的副本一直没被更新
- 学生端实际运行的是安装目录 `D:\class\@classhelperdesktop-client` 里的 `resources/app.asar`,
  而 `pnpm dist:win` 只更新 `release/`;只发新版 release 不动安装目录,用户看到的还是旧界面
- 已就地覆盖升级该目录,并用同一套冒烟实测**那个副本**:`packaged: true`、76/76 全绿
- README 补充「把修复交付到已安装的客户端」步骤(robocopy 覆盖 + verify:packaged 实测)

③ 新增/加固自动化验证
- `verify:desktop` 新增「作业看板全屏自适应(铺满屏幕、内容不溢出)」:进作业页 → 切看板 →
  打开全屏 → 断言排版宽铺满视口、可视底不超出屏幕;再把卡片补到 6 张触发 resize,
  要求列数自动变多(1 → 3)且仍不溢出
- 点击/命中这类异步链路由"固定 sleep 后读一次"改为**轮询等待**(曾偶发误判为"点不开"),
  失败时把渲染进程当前类名、窗口可见性、岛体矩形写进结论,便于区分真 bug 与竞态
- 新增 `pnpm verify:packaged`:对**打包后/已安装**的 EXE 跑同一套冒烟(含二次启动校验文件锁),
  避免"开发产物全绿但交付给学生的 EXE 是旧的"这类问题
- 实测:`verify:desktop` 76/76(离线复跑 68/68);`verify:packaged` 对
  `release/win-unpacked` 与 `D:\class\@classhelperdesktop-client` 均 76/76、`packaged: true`;
  `verify:e2e` 149/149(安装实例);`typecheck` / `lint` / `format:check` 全绿
This commit is contained in:
LoveChengke committed 2026-09-13 09:26:52 +08:00
1 parent 11008928a1
commit 0ff2dc228b
7 files changed
+466 -67

No files matched your search

+50 -10
View File
@@ -1220,6 +1220,11 @@ async function runIslandChecks(
island.setHitTestCursor(pillPoint);
await sleep(260);
const interactiveWhilePill = island.getInteractive();
// 点击前记录渲染进程当前形态:若渲染进程停在 expanded(状态推送滞后),
// 合成点击会被解释成"点空白处 → 收起",表现为"点了没反应",这里留证据便于定位。
const classBeforeClick = await islandWindow?.webContents
.executeJavaScript(`document.querySelector('.island-card')?.className ?? '-'`)
.catch(() => '-');
await islandWindow?.webContents
.executeJavaScript(
`(() => {
@@ -1228,16 +1233,24 @@ async function runIslandChecks(
})()`,
)
.catch(() => undefined);
await sleep(750);
const reopened = island.getState();
// 轮询等待主进程状态落到 expanded(不同环境 IPC/动画耗时不同,固定 sleep 会误判)
let reopened = island.getState();
for (let attempt = 0; attempt < 12 && reopened.mode !== 'expanded'; attempt += 1) {
await sleep(120);
reopened = island.getState();
}
const classAfterClick = await islandWindow?.webContents
.executeJavaScript(`document.querySelector('.island-card')?.className ?? '-'`)
.catch(() => '-');
record(
'收起态点击可再次展开(胶囊命中恢复 + 点击后展开)',
forcedThrough === false &&
interactiveWhilePill === true &&
reopened.mode === 'expanded' &&
reopened.active?.id === 'smoke-collapse-reopen',
`置穿透后=${forcedThrough} 光标在胶囊上时=${interactiveWhilePill} 点击后 mode=${reopened.mode} ` +
`active=${reopened.active?.id ?? '-'}`,
`置穿透后=${forcedThrough} 光标在胶囊上时=${interactiveWhilePill} ` +
`点击前渲染类名=${classBeforeClick} 点击后 mode=${reopened.mode} ` +
`active=${reopened.active?.id ?? '-'} 点击后渲染类名=${classAfterClick}`,
);
const sliverForInteractive = getSliverSize();
@@ -1979,10 +1992,16 @@ async function runIslandChecks(
)
.catch(() => undefined);
await sleep(180);
// 兜底轮询读的是主进程光标坐标:注入到岛体中心,等价于"指针停在岛内"
// 兜底轮询读的是主进程光标坐标:注入到岛体中心,等价于"指针停在岛内"。
// 命中判定链路是异步的(渲染进程上报矩形 → 主进程 120ms 轮询校正),
// 因此这里**轮询等待**而不是固定 sleep 后读一次,避免偶发误判。
island.setHitTestCursor(islandCardScreenPoint(0.5, 0.5));
await sleep(260);
const interactiveInside = island.getInteractive();
let interactiveInside = island.getInteractive();
for (let index = 0; index < 10 && interactiveInside !== true; index += 1) {
await sleep(120);
island.setHitTestCursor(islandCardScreenPoint(0.5, 0.5));
interactiveInside = island.getInteractive();
}
await islandWindow?.webContents
.executeJavaScript(
`(() => {
@@ -1993,13 +2012,20 @@ async function runIslandChecks(
.catch(() => undefined);
// 光标的权威判定在主进程(兜底轮询):这里把光标注入到屏幕左上角,等价于"指针离开岛体"
island.setHitTestCursor({ x: 1, y: 1 });
await sleep(260);
const interactiveOutside = island.getInteractive();
let interactiveOutside = island.getInteractive();
for (let index = 0; index < 10 && interactiveOutside !== false; index += 1) {
await sleep(120);
island.setHitTestCursor({ x: 1, y: 1 });
interactiveOutside = island.getInteractive();
}
const hitCheckGeometry = island.getHitRect();
island.setHitTestCursor(null);
record(
'岛外鼠标穿透(固定大窗口不吞桌面点击)',
interactiveInside === true && interactiveOutside === false,
`指针在岛内 interactive=${interactiveInside},指针在岛外 interactive=${interactiveOutside}(要求 true / false)`,
`指针在岛内 interactive=${interactiveInside},指针在岛外 interactive=${interactiveOutside}(要求 true / false)` +
` 窗口可见=${islandWindow?.isVisible() ?? false} 状态=${island.getState().mode}` +
` 岛体矩形=${hitCheckGeometry ? `${Math.round(hitCheckGeometry.x)},${Math.round(hitCheckGeometry.y)} ${Math.round(hitCheckGeometry.width)}x${Math.round(hitCheckGeometry.height)}` : 'null'}`,
);
island.setAppearance({ ...appearanceBefore, style: 'black' });
await sleep(200);
@@ -2464,6 +2490,20 @@ export async function runSmokeTest(win: BrowserWindow): Promise<void> {
String(timeline?.detail ?? ''),
);
// 作业看板全屏自适应(用户反馈:全屏看板没有铺满屏幕):
// 进作业页 → 切看板 → 打开全屏 → 量布局宽度/可视底部,要求铺满且不需要滚动
const boardFullscreen = await win.webContents.executeJavaScript(
`(async () => {
if (!window.__classhelperSmoke__?.homeworkBoardSelfTest) return { ok: false, detail: '缺少看板自适应自检钩子' };
return await window.__classhelperSmoke__.homeworkBoardSelfTest();
})()`,
);
record(
'作业看板全屏自适应(铺满屏幕、内容不溢出)',
Boolean(boardFullscreen?.ok),
String(boardFullscreen?.detail ?? ''),
);
// 侧边栏点击导航(回归测试:曾因把 index 当路由名导致点击无反应)
const navigation = await win.webContents.executeJavaScript(
`(async () => {
+108
View File
@@ -475,6 +475,113 @@ export async function scheduleTimelineSelfTest(): Promise<SmokeCheckResult> {
}
}
}
/**
* 冒烟自检 8(需后端在线):作业看板**全屏自适应**。
*
* 用户反馈"全屏模式没有自适应":点开全屏后卡片挤在左上角、内容显示不全。
* 这里走真实界面链路:进入作业页 → 切到「看板」→ 点「全屏看板」→
* 断言 ①布局宽度铺满视口(列数由算法注入 `--board-cols`)②缩放后可视底部不超出屏幕(无需滚动)。
*/
export async function homeworkBoardSelfTest(): Promise<SmokeCheckResult> {
const { router } = await import('../router/index.js');
try {
await router.push('/homeworks');
if (!(await waitUntil(() => Boolean(document.querySelector('.homework-page')), 6000))) {
return { ok: false, detail: '作业页未挂载' };
}
// 必须是「看板」模式(列表模式没有全屏看板入口)
const boardTab = Array.from(document.querySelectorAll('.el-radio-button')).find((node) =>
(node.textContent ?? '').includes('看板'),
);
if (!(boardTab instanceof HTMLElement)) return { ok: false, detail: '找不到「看板」切换按钮' };
boardTab.click();
await waitUntil(() => Boolean(document.querySelector('.board-config')), 3000);
// 今天可能恰好没有作业:先关掉「只看今天」,保证看板一定有卡片(断言才有意义)
if (!document.querySelector('.board-host')) {
const todaySwitch = document.querySelector('.page-header .el-switch');
if (todaySwitch instanceof HTMLElement) todaySwitch.click();
}
const hasBoard = await waitUntil(() => Boolean(document.querySelector('.board-host')), 4000);
const fullscreenButton = Array.from(document.querySelectorAll('button')).find((node) =>
(node.textContent ?? '').includes('全屏看板'),
);
if (!(fullscreenButton instanceof HTMLElement)) return { ok: false, detail: '找不到「全屏看板」按钮' };
fullscreenButton.click();
const dialogReady = await waitUntil(
() => Boolean(document.querySelector('.board-fullscreen .board-lg')),
6000,
);
if (!dialogReady) return { ok: false, detail: '全屏看板弹窗未出现' };
// 等自适应轮询跑完(最多 20 次 × 60ms)
await new Promise((resolve) => setTimeout(resolve, 1600));
const board = document.querySelector('.board-fullscreen .board-lg');
const host = document.querySelector('.board-fullscreen .board-host-lg');
if (!(board instanceof HTMLElement) || !(host instanceof HTMLElement)) {
return { ok: false, detail: '全屏看板容器缺失' };
}
const rect = board.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const layoutWidth = board.offsetWidth;
const columns = Number(board.style.getPropertyValue('--board-cols') || 0);
const cards = board.querySelectorAll('.board-card').length;
const scale = Number((getComputedStyle(board).transform.match(/matrix\(([-\d.]+)/) ?? [])[1] ?? 1);
const fillsWidth = layoutWidth >= viewportWidth * 0.9;
const fitsScreen = rect.bottom <= viewportHeight + 2 && rect.width <= viewportWidth + 2;
// 第二阶段:模拟"科目很多"(真实班级看板常有 5~8 个科目)。
// 克隆卡片到 6 张 → 触发 resize 让自适应算法重算 → 断言列数自动变多且仍然铺满、不溢出。
let manyCards = '';
let manyOk = false;
const sample = board.querySelector('.board-card');
if (sample instanceof HTMLElement) {
while (board.querySelectorAll('.board-card').length < 6) {
const clone = sample.cloneNode(true) as HTMLElement;
clone.setAttribute('data-smoke-filler', '1');
board.appendChild(clone);
}
window.dispatchEvent(new Event('resize'));
await new Promise((resolve) => setTimeout(resolve, 1500));
const cardsMany = board.querySelectorAll('.board-card').length;
const columnsMany = Number(board.style.getPropertyValue('--board-cols') || 0);
const layoutWidthMany = board.offsetWidth;
const rectMany = board.getBoundingClientRect();
manyOk =
cardsMany === 6 &&
columnsMany >= 2 &&
columnsMany <= cardsMany &&
layoutWidthMany >= viewportWidth * 0.9 &&
rectMany.bottom <= viewportHeight + 2;
manyCards =
`多科目:卡片=${cardsMany} 列数=${columnsMany} 排版宽=${layoutWidthMany} ` +
`可视底=${Math.round(rectMany.bottom)} 通过=${manyOk}`;
}
const closeButton = document.querySelector('.board-fullscreen .el-dialog__headerbtn');
if (closeButton instanceof HTMLElement) closeButton.click();
return {
ok: hasBoard && cards > 0 && columns >= 1 && columns <= cards && fillsWidth && fitsScreen && manyOk,
detail:
`卡片=${cards} 列数=${columns} 缩放=${Number.isFinite(scale) ? scale.toFixed(3) : '-'} ` +
`排版宽=${layoutWidth}(视口宽=${viewportWidth})可视底=${Math.round(rect.bottom)}(视口高=${viewportHeight}) ` +
`铺满宽=${fillsWidth} 不超出屏幕=${fitsScreen};${manyCards}`,
};
} catch (error) {
return {
ok: false,
detail: `看板自适应自检异常:${error instanceof Error ? error.message : String(error)}`,
};
}
}
/** 冒烟收尾:断开实时通道并退出登录,保证下次冒烟从登录页开始 */
export async function sessionCleanup(): Promise<SmokeCheckResult> {
const [{ useAuthStore }, { useRealtimeStore }] = await Promise.all([
@@ -501,6 +608,7 @@ export function registerSmokeHooks(): void {
islandRealtimeCleanup,
islandReadState,
scheduleTimelineSelfTest,
homeworkBoardSelfTest,
sessionCleanup,
};
}
@@ -148,24 +148,93 @@ const boardScale = ref(1);
const fullscreenHostRef = ref<HTMLElement | null>(null);
const fullscreenInnerRef = ref<HTMLElement | null>(null);
const fullscreenScale = ref(1);
/** 全屏单个科目视图(列表型):与整块看板分开持有 ref,避免两个全屏弹窗互相覆盖 */
const courseHostRef = ref<HTMLElement | null>(null);
const courseInnerRef = ref<HTMLElement | null>(null);
const fullscreenCourseScale = ref(1);
/** 全屏看板的列数(由"铺满屏幕"算法动态算出;见 computeColumns) */
const fullscreenColumns = ref(0);
function computeScale(host: HTMLElement | null, inner: HTMLElement | null): number {
function computeScale(host: HTMLElement | null, inner: HTMLElement | null, minScale = 0.45): number {
if (!host || !inner) return 1;
const available = host.clientHeight;
// 先按 1 倍量一次内容高度(transform 不影响 offsetHeight)
const content = inner.offsetHeight;
if (available <= 0 || content <= 0) return 1;
const scale = Math.min(1, available / content);
return Math.max(0.45, Math.round(scale * 1000) / 1000);
return Math.max(minScale, Math.round(scale * 1000) / 1000);
}
/**
* 全屏看板列数:在"可用宽 × 可用高"里枚举 1..count 列,
* 选出"单卡面积最大"的方案(面积越大越铺满屏幕),并用宽高比惩罚避免出现
* 又扁又长的卡片。这样无论 2 个科目还是 12 个科目,全屏都是一屏铺满。
*/
function computeColumns(count: number, host: HTMLElement | null): number {
if (count <= 1) return 1;
if (!host) return count;
const width = host.clientWidth || 0;
const height = host.clientHeight || 0;
if (width <= 0 || height <= 0) return Math.min(3, count);
const gap = Math.max(8, boardFontSize.value * 1.53);
const wantRatio = 1.15;
let bestCols = 1;
let bestScore = -Infinity;
for (let cols = 1; cols <= count; cols += 1) {
const rows = Math.ceil(count / cols);
const cardWidth = (width - gap * (cols - 1)) / cols;
const cardHeight = (height - gap * (rows - 1)) / rows;
if (cardWidth <= 0 || cardHeight <= 0) continue;
const ratio = cardWidth / cardHeight;
const ratioPenalty = Math.max(ratio / wantRatio, wantRatio / ratio);
const score = (cardWidth * cardHeight) / ratioPenalty;
if (score > bestScore) {
bestScore = score;
bestCols = cols;
}
}
return bestCols;
}
function fitBoards(): void {
void nextTick(() => {
boardScale.value = computeScale(boardHostRef.value, boardInnerRef.value);
fullscreenScale.value = computeScale(fullscreenHostRef.value, fullscreenInnerRef.value);
fullscreenCourseScale.value = computeScale(courseHostRef.value, courseInnerRef.value);
});
}
/**
* 全屏看板适配:弹窗是异步挂载的(有过渡),刚打开时容器尺寸可能是 0,
* 因此这里轮询等待布局就绪,再算列数与缩放,保证"一屏铺满、不用滚动"。
* 列数改变会重排,所以排完再量一次缩放系数。
*/
async function fitFullscreen(kind: 'board' | 'course' = 'board'): Promise<void> {
for (let attempt = 0; attempt < 20; attempt += 1) {
const host = kind === 'board' ? fullscreenHostRef.value : courseHostRef.value;
const inner = kind === 'board' ? fullscreenInnerRef.value : courseInnerRef.value;
if (host && inner && host.clientHeight > 60 && host.clientWidth > 60) {
if (kind === 'board') {
const count = inner.children.length || boardColumns.value.length || 1;
fullscreenColumns.value = computeColumns(count, host);
await nextTick();
fullscreenScale.value = computeScale(host, fullscreenInnerRef.value, 0.3);
} else {
fullscreenCourseScale.value = computeScale(host, inner, 0.3);
}
return;
}
await new Promise((resolve) => setTimeout(resolve, 60));
}
}
/** 视口/窗口尺寸变化:全屏看板重新自适应 */
function onResize(): void {
fitBoards();
if (fullscreenBoard.value) void fitFullscreen('board');
if (fullscreenCourse.value) void fitFullscreen('course');
}
/* ------------------------------------------------------------ 数据加载 */
async function loadHomework(): Promise<void> {
@@ -355,6 +424,7 @@ onMounted(async () => {
resizeObserver = new ResizeObserver(() => fitBoards());
if (boardHostRef.value) resizeObserver.observe(boardHostRef.value);
}
window.addEventListener('resize', onResize);
fitBoards();
realtime.on(SOCKET_EVENTS.homeworkNew, onHomeworkEvent);
realtime.on(SOCKET_EVENTS.homeworkUpdated, onHomeworkEvent);
@@ -364,6 +434,7 @@ onMounted(async () => {
onUnmounted(() => {
if (clockTimer) clearInterval(clockTimer);
resizeObserver?.disconnect();
window.removeEventListener('resize', onResize);
realtime.off(SOCKET_EVENTS.homeworkNew, onHomeworkEvent);
realtime.off(SOCKET_EVENTS.homeworkUpdated, onHomeworkEvent);
appStore.offServerRecovered(onRecovered);
@@ -512,6 +583,7 @@ onUnmounted(() => {
fullscreen
:title="`${fullscreenCourse ?? ''} · 作业`"
class="board-fullscreen"
@opened="fitFullscreen('course')"
>
<template #header="{ titleId, titleClass }">
<div class="board-dialog-head">
@@ -520,11 +592,11 @@ onUnmounted(() => {
<span v-if="boardShowTime" class="board-dialog-clock">{{ todayText }}</span>
</div>
</template>
<div ref="fullscreenHostRef" class="board-host board-host-lg">
<div ref="courseHostRef" class="board-host board-host-lg">
<ol
ref="fullscreenInnerRef"
ref="courseInnerRef"
class="board-list board-list-lg"
:style="{ ...fullscreenStyle, '--board-scale': fullscreenScale }"
:style="{ ...fullscreenStyle, '--board-scale': fullscreenCourseScale }"
>
<li
v-for="(item, index) in fullscreenItems"
@@ -547,7 +619,7 @@ onUnmounted(() => {
</el-dialog>
<!-- 全屏:整块看板放大 -->
<el-dialog v-model="fullscreenBoard" fullscreen class="board-fullscreen">
<el-dialog v-model="fullscreenBoard" fullscreen class="board-fullscreen" @opened="fitFullscreen('board')">
<template #header="{ titleId, titleClass }">
<div class="board-dialog-head">
<span :id="titleId" :class="titleClass">今日作业看板</span>
@@ -558,7 +630,11 @@ onUnmounted(() => {
<div
ref="fullscreenInnerRef"
class="board board-lg"
:style="{ ...fullscreenStyle, '--board-scale': fullscreenScale }"
:style="{
...fullscreenStyle,
'--board-scale': fullscreenScale,
'--board-cols': String(fullscreenColumns || 1),
}"
>
<section v-for="column in boardColumns" :key="column.course" class="board-card">
<header class="board-card-head">
@@ -738,9 +814,12 @@ onUnmounted(() => {
transform-origin: top center;
}
/* 全屏看板:列数由脚本按"铺满屏幕"算好后注入(--board-cols),不再靠 auto-fit 猜;
行高保持内容自然高度,交给 transform: scale() 适配,避免卡片被压扁截断内容。 */
.board-lg {
grid-template-columns: repeat(auto-fit, minmax(calc(var(--board-font, 15px) * 16), 1fr));
grid-template-columns: repeat(var(--board-cols, 2), minmax(0, 1fr));
gap: calc(var(--board-font, 15px) * 0.9);
align-content: start;
}
.board-card {
@@ -875,6 +954,12 @@ onUnmounted(() => {
color: #0a84ff;
}
.board-list-lg {
width: 100%;
transform: scale(var(--board-scale, 1));
transform-origin: top center;
}
.board-list-lg .board-item {
padding: 6px 8px;
}
@@ -904,6 +989,14 @@ onUnmounted(() => {
overflow: hidden;
}
/* 兜底:`class` 由 el-dialog 透传时可能不携带 scoped 属性,这里用全局选择器保证
全屏弹窗内容区一定铺满整屏(否则看板会缩在左上角)。 */
:global(.board-fullscreen .el-dialog__body) {
padding-top: 6px;
height: calc(100vh - 84px);
overflow: hidden;
}
.submission-toolbar {
display: flex;
align-items: center;
+2
View File
@@ -133,6 +133,8 @@ declare global {
}>;
/** ClassIsland 风格「今天」时间轴自检(造课 → 读 DOM → 清理) */
scheduleTimelineSelfTest(): Promise<{ ok: boolean; detail: string }>;
/** 作业看板全屏自适应自检(切看板 → 打开全屏 → 量尺寸) */
homeworkBoardSelfTest(): Promise<{ ok: boolean; detail: string }>;
sessionCleanup(): Promise<{ ok: boolean; detail: string }>;
};
}