feat(web,desktop,server): 灵动岛收起后可再次打开 · 下线作业截止时间 · Web 端圆角设计
灵动岛"收起后部分情况无法再次打开"(用户反馈): - 收起不再等于消失:只要有未处理通知(当前展示的或队列里的),超时后保持胶囊常驻, 只有"知道了 / 标为已读"、队列清空或进入上课时段才彻底隐藏。 - 收起态点击兜底:active 为空但队列还有未看通知时,点击胶囊会把它取出并展开; 上课时段仍严格不显示(该兜底放在上课守卫之后,不影响"上课不打扰")。 - 命中测试自愈:渲染进程记住最近指针位置,状态/外观变化与形变每一帧都按新几何重算命中; 岛可见时主进程也会重新下发可交互,避免"窗口还处于穿透 → 点了没反应"。 - 新增 3 条回归断言:收起后胶囊常驻、收起态点击可再次展开(命中恢复)、空闲细缝也可点开。 作业"截止时间"功能下线(用户要求): - 接口/类型:HomeworkDto 与创建请求去掉 dueAt,homeworks schema/service 不再读写; 仪表盘去掉"即将截止的作业"卡片与待截止统计口径(改为"未完成/待完成")。 - Web:作业发布表单移除截止时间选择、列表与详情移除截止时间列; 学生端作业列表/详情移除截止时间与"已逾期"状态(只保留完成状态)。 - 灵动岛:作业卡不再显示"截止时间:…"副标题,改为只展示作业要求。 - DB 中的 Homework.dueAt 列**保留**(已在 schema 注明为遗留字段),避免对已安装库做破坏性迁移; 种子数据不再写入该列。 Web 管理端圆角设计(用户要求): - 新增设计令牌 --ch-radius-xl/lg/md/sm/pill 与两级柔和投影,并让 Element Plus 的 --el-border-radius-* 对齐同一套令牌;卡片、弹窗、抽屉、按钮、输入、标签、分页、 侧栏菜单项、表格表头统一改造,卡片去硬边框改投影 + 悬停微浮起,顶栏/侧栏做圆角收口。 - 未改组件源码,全部为 CSS 变量 + 类选择器覆盖;手机端媒体查询保持生效(截图已更新)。 验证:verify:desktop 62/62(含 3 条收起态回归)与离线复跑 56/56、打包便携版 62/62(packaged=true)、 verify:e2e 136/136(新增"作业接口不再返回 dueAt")、verify:web 20/20、typecheck/lint/format 全绿; 服务端与 Web 已重新打包并部署到本机安装目录。
This commit is contained in:
1 parent
0690546307
commit
b69f708aa2
23 files changed
+534
-222
No files matched your search
@@ -371,10 +371,22 @@ class IslandController {
|
||||
handleAction(payload: IslandActionPayload): void {
|
||||
switch (payload.action) {
|
||||
case 'expand': {
|
||||
const active = this.state.active;
|
||||
// 上课时段:普通通知(含普通叫人)一律不显示;但紧急通知 / 紧急叫人本来就是必须
|
||||
// 立刻看到的,收起(回缩为胶囊)之后必须能再次点开——否则学生会误以为消息消失了。
|
||||
if (this.state.inClass && !(active && this.isImmediate(active))) break;
|
||||
const showing = this.state.active;
|
||||
if (this.state.inClass && !(showing && this.isImmediate(showing))) break;
|
||||
// 收起态(胶囊 / 空闲细缝)再次点击必须能打开:
|
||||
// active 为空但队列里还有未看通知时,先把它取出来当"当前通知"再展开,
|
||||
// 否则会出现"岛明明在屏幕上,点了没反应"(部分情况下的收起态无法再次打开)。
|
||||
if (!this.state.active) {
|
||||
const pending = this.state.queued[this.state.queued.length - 1];
|
||||
if (pending) {
|
||||
this.state.queued = this.state.queued.filter((item) => item.id !== pending.id);
|
||||
this.state.active = pending;
|
||||
logger.info(`灵动岛:收起态点击,重新打开队列中的通知 ${pending.id}`);
|
||||
}
|
||||
}
|
||||
const active = this.state.active;
|
||||
if (active) {
|
||||
this.setState({ mode: 'expanded' });
|
||||
this.scheduleCollapse(
|
||||
@@ -515,14 +527,19 @@ class IslandController {
|
||||
if (this.state.mode === 'expanded') this.setState({ mode: 'pill' });
|
||||
return;
|
||||
}
|
||||
if (this.state.mode === 'expanded') {
|
||||
const hasMore = this.state.queued.length > 1;
|
||||
if (hasMore) {
|
||||
this.setState({ mode: 'pill' });
|
||||
this.scheduleCollapse(TIMEOUTS.afterClassPill);
|
||||
} else {
|
||||
this.hide();
|
||||
}
|
||||
// 关键行为:只要还有"没处理完"的通知(当前展示的或队列里的),收起后**保持胶囊**,
|
||||
// 不再直接消失 —— 否则用户看到岛收起了却点不到("收起状态无法再次打开")。
|
||||
// 彻底隐藏只发生在:用户点了"知道了 / 标为已读"、队列清空、或进入上课时段。
|
||||
const pending = this.state.active !== null || this.state.queued.length > 0;
|
||||
if (this.state.mode === 'expanded' && this.state.queued.length > 1) {
|
||||
// 还有多条未看:先回缩为"还有 N 条"胶囊
|
||||
this.setState({ mode: 'pill' });
|
||||
if (pending) return;
|
||||
this.hide();
|
||||
return;
|
||||
}
|
||||
if (pending) {
|
||||
if (this.state.mode !== 'pill') this.setState({ mode: 'pill' });
|
||||
return;
|
||||
}
|
||||
this.hide();
|
||||
@@ -959,4 +976,9 @@ export function registerIslandIpc(): void {
|
||||
});
|
||||
}
|
||||
|
||||
export { SIZES as ISLAND_SIZES, URGENT_SIZE as ISLAND_URGENT_SIZE, CALL_SIZE as ISLAND_CALL_SIZE };
|
||||
export {
|
||||
SIZES as ISLAND_SIZES,
|
||||
URGENT_SIZE as ISLAND_URGENT_SIZE,
|
||||
CALL_SIZE as ISLAND_CALL_SIZE,
|
||||
TIMEOUTS as ISLAND_TIMEOUTS,
|
||||
};
|
||||
@@ -5,7 +5,14 @@ import type { BrowserWindow, NativeImage } from 'electron';
|
||||
import type { IslandAppearance, IslandNotification } from '@classhelper/shared';
|
||||
import { getConfig, saveConfig } from './config.js';
|
||||
import { getDiagnostics } from './ipc.js';
|
||||
import { getSliverSize, island, ISLAND_CALL_SIZE, ISLAND_SIZES, ISLAND_URGENT_SIZE } from './island.js';
|
||||
import {
|
||||
getSliverSize,
|
||||
island,
|
||||
ISLAND_CALL_SIZE,
|
||||
ISLAND_SIZES,
|
||||
ISLAND_TIMEOUTS,
|
||||
ISLAND_URGENT_SIZE,
|
||||
} from './island.js';
|
||||
import { destroyTray, getTrayState, isTrayReady } from './tray.js';
|
||||
|
||||
interface SmokeResult {
|
||||
@@ -839,14 +846,14 @@ async function runIslandChecks(
|
||||
`展开时 focusable=${focusable} 失焦后 mode=${afterBlur.mode} focusable=${islandWindow?.isFocusable() ?? '-'}`,
|
||||
);
|
||||
|
||||
// 4.6) 新作业上岛(kind=homework):胶囊 + 展开显示截止时间
|
||||
// 4.6) 新作业上岛(kind=homework):胶囊 + 展开显示作业要求
|
||||
//(截止时间功能已下线:这里同时守住"作业卡上不再出现截止时间")
|
||||
island.handleAction({ action: 'dismiss' });
|
||||
await sleep(400);
|
||||
island.pushNotification(
|
||||
{
|
||||
...makeNotification('smoke-homework', 'NORMAL', '第 3 章课后练习'),
|
||||
kind: 'homework',
|
||||
subtitle: '截止时间:2026-09-15 22:00',
|
||||
teacherName: '张老师',
|
||||
},
|
||||
{ inClass: false },
|
||||
@@ -868,13 +875,13 @@ async function runIslandChecks(
|
||||
);
|
||||
island.handleAction({ action: 'expand' });
|
||||
await sleep(700);
|
||||
const homeworkHint = await islandWindow?.webContents.executeJavaScript(
|
||||
`document.querySelector('.island-card.expanded .call-hint')?.textContent?.trim() ?? ''`,
|
||||
const homeworkCardText = await islandWindow?.webContents.executeJavaScript(
|
||||
`document.querySelector('.island-card.expanded')?.innerText?.replace(/\\s+/g, ' ').trim() ?? ''`,
|
||||
);
|
||||
record(
|
||||
'作业详情显示截止时间',
|
||||
(homeworkHint ?? '').includes('截止时间'),
|
||||
`附加说明="${homeworkHint ?? '-'}"`,
|
||||
'作业详情只显示作业要求(截止时间已下线)',
|
||||
(homeworkCardText ?? '').includes('第 3 章课后练习') && !(homeworkCardText ?? '').includes('截止时间'),
|
||||
`作业卡文案="${homeworkCardText ?? '-'}"`,
|
||||
);
|
||||
await captureIsland('island-7-homework', ISLAND_SIZES.expanded);
|
||||
|
||||
@@ -957,6 +964,105 @@ async function runIslandChecks(
|
||||
island.setClassState({ inClass: false, currentPeriodEnd: null, week: 1 });
|
||||
await sleep(400);
|
||||
|
||||
// 4.9) 收起态回归(用户反馈"收起后部分情况无法再次打开"):
|
||||
// a) 胶囊不再因超时消失(有未处理通知时常驻);
|
||||
// b) 收起后胶囊仍然可命中(窗口保持可交互)→ 点击可再次展开;
|
||||
// c) 空闲细缝态同样是"可见即可点"(细缝 6x22 也能命中)。
|
||||
// 注:此处不使用后面的 drainIsland(它在本函数更靠下的位置定义),就地排空队列。
|
||||
const drainForCollapseCheck = async (): Promise<void> => {
|
||||
for (let index = 0; index < 20; index += 1) {
|
||||
const current = island.getState();
|
||||
if (!current.active && current.queued.length === 0) return;
|
||||
island.handleAction({ action: 'dismiss' });
|
||||
await sleep(120);
|
||||
}
|
||||
};
|
||||
/** 合成"指针移到卡片上":命中测试由渲染进程按指针位置决定(真实场景由系统转发 mousemove) */
|
||||
const hoverCard = async (): Promise<boolean> =>
|
||||
Boolean(
|
||||
await islandWindow?.webContents
|
||||
.executeJavaScript(
|
||||
`(() => {
|
||||
const card = document.querySelector('.island-card');
|
||||
if (!card) return false;
|
||||
const r = card.getBoundingClientRect();
|
||||
window.dispatchEvent(
|
||||
new MouseEvent('mousemove', {
|
||||
clientX: r.left + r.width / 2,
|
||||
clientY: r.top + r.height / 2,
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
})()`,
|
||||
)
|
||||
.catch(() => false),
|
||||
);
|
||||
await drainForCollapseCheck();
|
||||
await sleep(300);
|
||||
island.pushNotification(makeNotification('smoke-collapse-reopen', 'NORMAL', '收起态回归校验'), {
|
||||
inClass: false,
|
||||
});
|
||||
await sleep(500);
|
||||
const collapseBefore = island.getState();
|
||||
await hoverCard();
|
||||
await sleep(200);
|
||||
await sleep(ISLAND_TIMEOUTS.pill + 700);
|
||||
const lingered = island.getState();
|
||||
const lingeredVisible = islandWindow?.isVisible() ?? false;
|
||||
record(
|
||||
'收起后胶囊常驻(超时不再消失,随时可再次点开)',
|
||||
collapseBefore.mode === 'pill' && lingered.mode === 'pill' && lingeredVisible,
|
||||
`收起前=${collapseBefore.mode} 超时后=${lingered.mode} 可见=${lingeredVisible}`,
|
||||
);
|
||||
// 命中:把窗口显式置为"穿透"(等价于收起瞬间窗口被重置),再做一次状态变化 ——
|
||||
// 主进程会按当前形态重新下发命中,渲染进程也会按缓存指针重算,两者都必须把命中恢复。
|
||||
island.setInteractive(false);
|
||||
await sleep(60);
|
||||
const forcedThrough = island.getInteractive();
|
||||
island.handleAction({ action: 'collapse' });
|
||||
await sleep(300);
|
||||
const interactiveWhilePill = island.getInteractive();
|
||||
await islandWindow?.webContents
|
||||
.executeJavaScript(
|
||||
`(() => {
|
||||
document.querySelector('.island-card')?.click();
|
||||
return true;
|
||||
})()`,
|
||||
)
|
||||
.catch(() => undefined);
|
||||
await sleep(750);
|
||||
const reopened = island.getState();
|
||||
record(
|
||||
'收起态点击可再次展开(胶囊命中恢复 + 点击后展开)',
|
||||
forcedThrough === false &&
|
||||
interactiveWhilePill === true &&
|
||||
reopened.mode === 'expanded' &&
|
||||
reopened.active?.id === 'smoke-collapse-reopen',
|
||||
`置穿透后=${forcedThrough} 状态变化后=${interactiveWhilePill} 点击后 mode=${reopened.mode} ` +
|
||||
`active=${reopened.active?.id ?? '-'}`,
|
||||
);
|
||||
|
||||
const sliverForInteractive = getSliverSize();
|
||||
island.setAppearance({ ...island.getAppearance(), idleSliver: true });
|
||||
await sleep(200);
|
||||
await drainForCollapseCheck();
|
||||
await sleep(500);
|
||||
const sliverState = island.getState();
|
||||
await hoverCard();
|
||||
await sleep(250);
|
||||
const sliverInteractive = island.getInteractive();
|
||||
const sliverVisible = islandWindow?.isVisible() ?? false;
|
||||
record(
|
||||
'空闲细缝态保持可交互(细缝很窄,指针停在岛上仍能点开)',
|
||||
sliverState.mode === 'hidden' && sliverVisible && sliverInteractive === true,
|
||||
`mode=${sliverState.mode} 可见=${sliverVisible} interactive=${sliverInteractive} ` +
|
||||
`细缝=${sliverForInteractive.width}x${sliverForInteractive.height}`,
|
||||
);
|
||||
island.setAppearance({ ...island.getAppearance(), idleSliver: false });
|
||||
await drainForCollapseCheck();
|
||||
await sleep(300);
|
||||
|
||||
// 5) 截图留档的像素级断言:每张图必须有实际绘制内容、颜色丰富,且宽高比与
|
||||
// 状态机配置的窗口尺寸一致(DPI 无关),紧急形态还必须出现红色描边/内部光晕像素。
|
||||
const shotDetails = islandShots.map((shot) => {
|
||||
|
||||
Reference in new issue
Block a user