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
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user