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:
LoveChengke committed 2026-09-12 21:01:31 +08:00
1 parent 0690546307
commit b69f708aa2
23 files changed
+534 -222

No files matched your search

@@ -186,12 +186,15 @@ function syncTargets(snap = false): void {
springs.radius.snap(radius);
morph.value = { width: size.width, height: size.height, radius };
progress.value = mode.value === 'expanded' ? 1 : 0;
refreshInteractive();
return;
}
springs.width.setTarget(size.width);
springs.height.setTarget(size.height);
springs.radius.setTarget(radius);
startFrameLoop();
// 目标几何变了:立刻按新尺寸重算一次命中(形变过程中每帧也会重算)
refreshInteractive();
}
function startFrameLoop(): void {
@@ -218,6 +221,8 @@ function startFrameLoop(): void {
span < 1 ? (mode.value === 'expanded' ? 1 : 0) : Math.abs(morph.value.height - pill) / span;
if (moving || !springs.width.settled || !springs.height.settled || !springs.radius.settled) {
// 形变过程中岛的可点区域在变:每帧按缓存指针位置重算命中(值不变时不发 IPC)
refreshInteractive();
rafHandle = requestAnimationFrame(frame);
return;
}
@@ -228,6 +233,7 @@ function startFrameLoop(): void {
springs.radius.snap(targetRadius(size));
morph.value = { width: size.width, height: size.height, radius: springs.radius.value };
progress.value = mode.value === 'expanded' ? 1 : 0;
refreshInteractive();
rafHandle = null;
};
rafHandle = requestAnimationFrame(frame);
@@ -293,7 +299,11 @@ const islandClass = computed(() => ({
* 指针在岛体上(或可交互控件上)时才让窗口接收鼠标,否则穿透,保证不吞桌面点击。
*/
let lastInteractive: boolean | null = null;
/** 最近一次已知的指针位置:岛收起/变大后用它重算命中,避免"鼠标没动就点不到" */
let lastPointer: { x: number; y: number } | null = null;
function updateInteractive(clientX: number, clientY: number): void {
lastPointer = { x: clientX, y: clientY };
const node = document.querySelector('.island-card') as HTMLElement | null;
let inside = false;
if (node) {
@@ -310,6 +320,29 @@ function updateInteractive(clientX: number, clientY: number): void {
bridge?.setInteractive?.(inside);
}
/**
* 状态 / 外观变化后主动重算一次命中测试。
*
* 为什么必须主动算:窗口默认 `setIgnoreMouseEvents(true, {forward:true})`,
* 只有在收到 mousemove 时才会打开命中。岛"收起 / 展开 / 换形态"时几何变了,
* 但指针可能一动没动(没有新的 mousemove)—— 此时窗口会一直保持"穿透",
* 表现就是**收起后怎么点都打不开**(用户反馈)。所以每次状态变化都用缓存的指针位置重算一次。
*/
function refreshInteractive(): void {
const fullyHidden = state.value?.mode === 'hidden' && !appearance.value.idleSliver;
if (fullyHidden) {
if (lastInteractive !== false) {
lastInteractive = false;
bridge?.setInteractive?.(false);
}
return;
}
if (!lastPointer) return;
const pointer = lastPointer;
lastInteractive = null; // 强制按新几何再算一次
updateInteractive(pointer.x, pointer.y);
}
function applyAppearance(next: IslandAppearance | null | undefined): void {
if (!next) return;
appearance.value = next;
+33 -11
View File
@@ -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,
};
+114 -8
View File
@@ -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) => {
@@ -1,6 +1,5 @@
import {
SOCKET_EVENTS,
formatDate,
resolveClassStatus,
type ClassPeriod,
type HomeworkDto,
@@ -91,9 +90,8 @@ export function pushNotificationToIsland(notification: NotificationDto): void {
});
}
/** 新作业上岛(homework:new):胶囊提示"新作业",展开看截止时间 */
/** 新作业上岛(homework:new):胶囊提示"新作业",点击展开看作业要求 */
export function pushHomeworkToIsland(homework: HomeworkDto): void {
const due = homework.dueAt ? formatDate(homework.dueAt, true) : '未设置截止时间';
window.desktop?.islandPush({
notification: {
id: `homework-${homework.id}`,
@@ -104,7 +102,6 @@ export function pushHomeworkToIsland(homework: HomeworkDto): void {
courseName: homework.course?.name ?? null,
teacherName: homework.creator?.name ?? null,
kind: 'homework',
subtitle: `截止时间:${due}`,
},
context: getIslandClassContext(),
});
@@ -79,7 +79,7 @@ export const useRealtimeStore = defineStore('realtime', () => {
instance.on(SOCKET_EVENTS.homeworkNew, (payload: HomeworkDto) => {
mark(SOCKET_EVENTS.homeworkNew);
ElMessage.success(`新作业:${payload.title}`);
// 作业发布也上岛:胶囊提示"新作业",点击展开看截止时间与要求
// 作业发布也上岛:胶囊提示"新作业",点击展开看作业要求
pushHomeworkToIsland(payload);
dispatch(SOCKET_EVENTS.homeworkNew, payload);
});
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { ElMessage } from 'element-plus';
import { SOCKET_EVENTS, formatDate, formatDeadline, isOverdue, type HomeworkDto } from '@classhelper/shared';
import { SOCKET_EVENTS, formatDate, type HomeworkDto } from '@classhelper/shared';
import { homeworkApi } from '../api/index.js';
import { fetchWithCache } from '../cache/index.js';
import { useAppStore } from '../stores/app.js';
@@ -147,12 +147,8 @@ onUnmounted(() => {
<el-table v-else :data="filtered" @row-click="openDetail">
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag
:type="row.completed ? 'success' : isOverdue(row.dueAt) ? 'danger' : 'info'"
size="small"
effect="light"
>
{{ row.completed ? '已完成' : isOverdue(row.dueAt) ? '已逾期' : '待完成' }}
<el-tag :type="row.completed ? 'success' : 'info'" size="small" effect="light">
{{ row.completed ? '已完成' : '待完成' }}
</el-tag>
</template>
</el-table-column>
@@ -160,16 +156,6 @@ onUnmounted(() => {
<el-table-column label="课程" width="110">
<template #default="{ row }">{{ row.course?.name ?? '-' }}</template>
</el-table-column>
<el-table-column label="截止时间" width="180">
<template #default="{ row }">{{ formatDate(row.dueAt, true) || '不限' }}</template>
</el-table-column>
<el-table-column label="剩余" width="120">
<template #default="{ row }">
<span :class="{ 'text-danger': isOverdue(row.dueAt) && !row.completed }">
{{ row.completed ? '—' : formatDeadline(row.dueAt) }}
</span>
</template>
</el-table-column>
<el-table-column label="操作" width="130" fixed="right">
<template #default="{ row }">
<el-button
@@ -198,9 +184,6 @@ onUnmounted(() => {
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="课程">{{ current.course?.name ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="发布人">{{ current.creator?.name ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="截止时间">
{{ formatDate(current.dueAt, true) || '不限' }}
</el-descriptions-item>
<el-descriptions-item label="发布时间">
{{ formatDate(current.createdAt, true) }}
</el-descriptions-item>