feat(homeworks,island,classisland,desktop,web,server): 作业按天查看与教室机器录入 + 提醒上课时段暂存规则 + 灵动岛边距/跟随鼠标屏幕

- 作业:新增 Homework.assignDate(本地日期 YYYY-MM-DD;UTC 切片在晚上会把今天的作业算成明天),列表按天过滤与排序,新增 GET /homeworks?date= 与 GET /homeworks/days(日期高亮)供客户端与 Web 共用;客户端作业页改为"看作业 + 录作业"(不再显示完成状态),支持快捷短语(存本机配置,「设置 → 作业录入」可增删/恢复默认)
- 教室机器录入:POST /homeworks 去掉 requireRole,权限下沉到 homeworks.service(staff 或本班班级账号);班级账号的 sub 是班级 id,写 createdBy 前换成该班班主任,避免撞 User 外键
- 提醒类型:ClassIslandPush.kind(notification / call)。通知类上课时段暂存、下课补弹,叫人/紧急立即弹;客户端灵动岛与 ClassIsland 插件共用同一套规则
- 灵动岛:新增 marginX / marginY(默认仍 8px,不动老外观)与「跟随鼠标屏幕」;文案统一成"灵动岛 / ClassIsland"
- 设置页:新增「ClassIsland 联动」(联动状态 + 通知模式)与「作业录入」分区
- 校验:verify:e2e 增补 assignDate 默认当天 / ?date= 过滤 / days / 非法日期 422 / 班级账号录入 201 与跨班 403;verify:classisland 与插件静态契约增补上课时段暂存断言(57/57)
- 文档:README 与 AGENTS.md 同步(作业按天、上课时段策略、两个方向的开关表、新增踩坑)

门禁:pnpm lint 0 error、pnpm typecheck 四包通过、verify:classisland-plugin 57/57 通过。
未跑:verify:e2e / verify:classisland(需后端启动)、verify:web / verify:desktop(需先构建产物)。
This commit is contained in:
LoveChengke committed 2026-09-27 16:34:01 +08:00
1 parent d0830f6830
commit 7536abfc95
33 files changed
+1236 -162

No files matched your search

+33 -2
View File
@@ -6,6 +6,9 @@ import {
DEFAULT_CLASSISLAND_NOTIFICATION_CHANNEL,
DEFAULT_ISLAND_APPEARANCE,
DEFAULT_SERVER_URL,
HOMEWORK_PHRASE_DEFAULTS,
HOMEWORK_PHRASE_MAX_COUNT,
HOMEWORK_PHRASE_MAX_LENGTH,
type ClassIslandNotificationChannel,
type IslandAppearance,
} from '@classhelper/shared';
@@ -16,7 +19,6 @@ const DEFAULT_HOMEWORK_BOARD: HomeworkBoardSettings = {
mode: 'board',
showTime: false,
fontSize: 15,
todayOnly: true,
};
interface PersistedConfig {
@@ -31,6 +33,8 @@ interface PersistedConfig {
homeworkBoard?: Partial<HomeworkBoardSettings>;
/** 通知显示位置(向后兼容:旧配置文件没有该字段时按默认 both 补齐) */
notificationChannel?: ClassIslandNotificationChannel;
/** 作业录入快捷短语(向后兼容:旧配置没有该字段时用默认那一组) */
homeworkPhrases?: string[];
}
const DEFAULT_CONFIG: PersistedConfig = {
@@ -41,8 +45,28 @@ const DEFAULT_CONFIG: PersistedConfig = {
island: { ...DEFAULT_ISLAND_APPEARANCE },
homeworkBoard: { ...DEFAULT_HOMEWORK_BOARD },
notificationChannel: DEFAULT_CLASSISLAND_NOTIFICATION_CHANNEL,
homeworkPhrases: [...HOMEWORK_PHRASE_DEFAULTS],
};
/**
* 作业快捷短语的合法化:去空白、去重、限长限量。
* 空数组是**合法值**(老师把短语全删了 = 不想用快捷短语),因此不能拿默认值顶回去。
*/
function normalizeHomeworkPhrases(input?: string[]): string[] {
if (!Array.isArray(input)) return [...HOMEWORK_PHRASE_DEFAULTS];
const seen = new Set<string>();
const result: string[] = [];
for (const raw of input) {
if (typeof raw !== 'string') continue;
const value = raw.trim().slice(0, HOMEWORK_PHRASE_MAX_LENGTH);
if (!value || seen.has(value)) continue;
seen.add(value);
result.push(value);
if (result.length >= HOMEWORK_PHRASE_MAX_COUNT) break;
}
return result;
}
/** 通知显示位置的白名单校验:非法值一律回落到默认值,避免坏配置把提醒"静默吞掉" */
function normalizeNotificationChannel(
input?: ClassIslandNotificationChannel,
@@ -62,7 +86,6 @@ function normalizeHomeworkBoard(input?: Partial<HomeworkBoardSettings>): Homewor
return {
mode,
showTime: input?.showTime === true,
todayOnly: input?.todayOnly !== false,
fontSize: Math.min(28, Math.max(11, Math.round(rawFont))),
};
}
@@ -102,6 +125,9 @@ function normalizeIslandAppearance(input?: Partial<IslandAppearance>): IslandApp
? (input?.style as IslandAppearance['style'])
: DEFAULT_ISLAND_APPEARANCE.style,
idleSliver: input?.idleSliver === true,
marginX: clamp(input?.marginX, 0, 200, DEFAULT_ISLAND_APPEARANCE.marginX),
marginY: clamp(input?.marginY, 0, 160, DEFAULT_ISLAND_APPEARANCE.marginY),
followCursorDisplay: input?.followCursorDisplay === true,
};
}
@@ -146,6 +172,7 @@ function readPersisted(): PersistedConfig {
island: normalizeIslandAppearance(parsed.island),
homeworkBoard: normalizeHomeworkBoard(parsed.homeworkBoard),
notificationChannel: normalizeNotificationChannel(parsed.notificationChannel),
homeworkPhrases: normalizeHomeworkPhrases(parsed.homeworkPhrases),
};
} catch {
return { ...DEFAULT_CONFIG };
@@ -168,6 +195,7 @@ export function getConfig(): DesktopStoredConfig {
island: normalizeIslandAppearance(persisted.island),
homeworkBoard: normalizeHomeworkBoard(persisted.homeworkBoard),
notificationChannel: normalizeNotificationChannel(persisted.notificationChannel),
homeworkPhrases: normalizeHomeworkPhrases(persisted.homeworkPhrases),
};
}
@@ -190,6 +218,9 @@ export function saveConfig(patch: Partial<DesktopStoredConfig>): DesktopStoredCo
...patch.homeworkBoard,
});
}
if (patch.homeworkPhrases !== undefined) {
persisted.homeworkPhrases = normalizeHomeworkPhrases(patch.homeworkPhrases);
}
if (patch.notificationChannel !== undefined) {
persisted.notificationChannel = normalizeNotificationChannel(patch.notificationChannel);
}
+17 -4
View File
@@ -758,21 +758,30 @@ class IslandController {
* - 全部取整,避免 `Math.round` 在 0.5 边界上左右跳变(那会表现为 ±0.5px 的横向抖动)。
*/
private resolveAnchor(): IslandAnchor {
const area = screen.getPrimaryDisplay().workArea;
const margin = ISLAND_MARGIN;
// 多显示器教室电脑:打开"跟随鼠标屏幕"后,岛出现在鼠标所在的那块屏(讲台切换投影时不用改设置)
const display = this.appearance.followCursorDisplay
? screen.getDisplayNearestPoint(screen.getCursorScreenPoint())
: screen.getPrimaryDisplay();
const area = display.workArea;
const marginX = Math.round(this.appearance.marginX ?? ISLAND_MARGIN);
const marginY = Math.round(this.appearance.marginY ?? ISLAND_MARGIN);
const center = Math.round(area.x + area.width / 2);
// 6 个停靠位置(顶/底 × 左/中/右),与 WinIsland 的 DockPosition 一一对应
const position = this.appearance.position;
const vMode: IslandAnchor['vMode'] = position.startsWith('bottom') ? 'bottom' : 'top';
const vValue = vMode === 'bottom' ? area.y + area.height - margin : area.y + margin;
const vValue = vMode === 'bottom' ? area.y + area.height - marginY : area.y + marginY;
const hMode: IslandAnchor['hMode'] = position.endsWith('left')
? 'left'
: position.endsWith('right')
? 'right'
: 'center';
const hValue =
hMode === 'left' ? area.x + margin : hMode === 'right' ? area.x + area.width - margin : center;
hMode === 'left'
? area.x + marginX
: hMode === 'right'
? area.x + area.width - marginX
: center;
return { hMode, hValue, vMode, vValue };
}
@@ -1336,6 +1345,10 @@ export function normalizeAppearance(input: IslandAppearance): IslandAppearance {
alwaysOnTop: input.alwaysOnTop !== false,
style: ISLAND_STYLES.includes(input.style) ? input.style : 'black',
idleSliver: input.idleSliver === true,
// 边距:0 表示贴着屏幕边缘,上限给到 200/160 是为了"多显示器 + 任务栏在侧面"这类布局
marginX: clamp(input.marginX ?? DEFAULT_ISLAND_APPEARANCE.marginX, 0, 200),
marginY: clamp(input.marginY ?? DEFAULT_ISLAND_APPEARANCE.marginY, 0, 160),
followCursorDisplay: input.followCursorDisplay === true,
};
}
+34 -2
View File
@@ -2209,6 +2209,38 @@ async function runIslandChecks(
);
}
record('个性设置:停靠位置生效(6 个锚点)', positionOk, positionDetails.join(' '));
// 11.3b) 边距:左右/上下边距必须真的把岛挪走(用户反馈「调边距没反应」:
// 滑块在渲染层、定位在主进程,主进程没重跑就会出现「数字在变、岛不动」)
island.setAppearance({ ...appearanceBefore, position: 'top-left', marginX: 48, marginY: 40 });
await sleep(450);
const marginLeft = (await readIslandGeometry())?.island ?? null;
const marginLeftOk =
Boolean(marginLeft) &&
Math.abs((marginLeft as Electron.Rectangle).x - (workArea.x + 48)) <= 1 &&
Math.abs((marginLeft as Electron.Rectangle).y - (workArea.y + 40)) <= 1;
island.setAppearance({ ...appearanceBefore, position: 'top-right', marginX: 48, marginY: 8 });
await sleep(450);
const marginRight = (await readIslandGeometry())?.island ?? null;
const marginRightOk =
Boolean(marginRight) &&
Math.abs(
(marginRight as Electron.Rectangle).x +
(marginRight as Electron.Rectangle).width -
(workArea.x + workArea.width - 48),
) <= 1;
record(
'个性设置:边距生效(左右 + 上下)',
marginLeftOk && marginRightOk,
'top-left(marginX=48,marginY=40)=' +
(marginLeft ? marginLeft.x + ',' + marginLeft.y : '-') +
(marginLeftOk ? '✓' : '✗') +
' top-right(marginX=48)=' +
(marginRight ? marginRight.x + ',' + marginRight.y : '-') +
(marginRightOk ? '✓' : '✗'),
);
island.setAppearance(appearanceBefore);
// 11.4 置顶开关 → 窗口 alwaysOnTop
@@ -2557,10 +2589,10 @@ async function runSettingsPageChannelCheck(
for (let i = 0; i < 40 && !group; i += 1) {
await wait(100);
group = Array.from(document.querySelectorAll('.el-card')).find((card) =>
(card.querySelector('.el-card__header')?.textContent ?? '').includes('通知显示位置'),
(card.querySelector('.el-card__header')?.textContent ?? '').includes('ClassIsland 联动'),
);
}
if (!group) return { ok: false, detail: '设置页没有「通知显示位置」卡片' };
if (!group) return { ok: false, detail: '设置页没有「ClassIsland 联动」卡片' };
const radios = Array.from(group.querySelectorAll('.el-radio'));
// 注意:这段脚本本身是外层模板字符串,里面不能再出现反引号(会截断外层字符串)
if (radios.length < 3) return { ok: false, detail: '单选项只有 ' + radios.length + ' 个' };