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

+5
View File
@@ -1,3 +1,4 @@
import { dayKeyLocal } from '@classhelper/shared';
import type {
ClassDto,
ClassIslandNotificationChannel,
@@ -204,6 +205,8 @@ export interface HomeworkLike {
title: string;
content: string;
attachmentUrl: string | null;
/** 作业所属日期(YYYY-MM-DD,本地日期);老库可能为空串 */
assignDate?: string | null;
createdBy: string;
createdAt: Date;
course?: CourseBriefLike | null;
@@ -243,6 +246,8 @@ export function toHomeworkDto(
attachmentUrl: item.attachmentUrl,
createdBy: item.createdBy,
createdAt: toIso(item.createdAt),
// 老库兜底:assignDate 为空时按 createdAt 的本地日期回退,前端不必处理空值
assignDate: item.assignDate || dayKeyLocal(item.createdAt),
course: item.course ? toCourseBrief(item.course) : null,
creator: item.creator ? toTeacherBrief(item.creator) : null,
completed: own ? own.completed : undefined,
@@ -73,6 +73,8 @@ export async function createCall(user: TokenPayload, input: CreateCallInput): Pr
title: created.title,
content: created.content,
urgent: input.urgent === true,
// 叫人属于「主动通知」:教室的 ClassIsland 上课时段也要立刻弹
kind: 'call',
createdBy: user.sub,
notificationId: created.id,
teacherName,
@@ -113,6 +113,17 @@ router.patch(
},
);
/**
* GET /api/classes/:id/classisland-status - 本班 ClassIsland 联动状态
*
* 教室机器(班级账号)也能读:客户端「设置 → ClassIsland 联动」据此显示"已接入 / 未接入"。
*/
router.get('/:id/classisland-status', validate({ params: idParamSchema }), async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
sendOk(res, await classService.getClassIslandStatus(user, id), '获取联动状态成功');
});
/**
* GET /api/classes/:id/notification-channel - 读取"通知显示到哪个端"
*
@@ -355,6 +355,44 @@ export async function removeTeacher(user: TokenPayload, classId: string, teacher
await prisma.classTeacher.deleteMany({ where: { classId, teacherId } });
}
/* ---------------------------------------------------------------- ClassIsland 联动状态 */
/**
* 本班的 ClassIsland 联动状态(教室客户端的「ClassIsland 联动」面板展示)。
*
* 为什么单独开一个接口:设备列表(/integrations/devices)是管理面,只对教师/管理员开放;
* 而教室机器(班级账号)需要知道"本班接没接 ClassIsland、最后什么时候上报的",
* 才能给出准确的提示(例如没接就别选"只在 ClassIsland 上弹")。
*/
export async function getClassIslandStatus(
user: TokenPayload,
classId: string,
): Promise<{
connected: boolean;
deviceName: string | null;
deviceCount: number;
lastSeenAt: string | null;
pluginVersion: string | null;
classIslandVersion: string | null;
}> {
await assertClassAccess(user, classId);
const devices = await prisma.integrationDevice.findMany({
where: { classId, enabled: true, mode: 'plugin' },
select: { name: true, lastSeenAt: true, pluginVersion: true, classIslandVersion: true },
orderBy: { lastSeenAt: 'desc' },
});
const latest = devices[0];
return {
connected: devices.length > 0,
deviceName: latest?.name ?? null,
deviceCount: devices.length,
lastSeenAt: latest?.lastSeenAt ? latest.lastSeenAt.toISOString() : null,
pluginVersion: latest?.pluginVersion ?? null,
classIslandVersion: latest?.classIslandVersion ?? null,
};
}
/* ---------------------------------------------------------------- 通知显示位置 */
/**
@@ -6,11 +6,13 @@ import { validate, validatedParams, validatedQuery } from '../../middleware/vali
import { defineModule } from '../module.types.js';
import {
createHomeworkSchema,
listHomeworkDaysQuerySchema,
listHomeworksQuerySchema,
updateHomeworkSchema,
updateHomeworkStatusSchema,
updateHomeworkSubmissionsSchema,
type CreateHomeworkInput,
type ListHomeworkDaysInput,
type UpdateHomeworkInput,
type UpdateHomeworkStatusInput,
type UpdateHomeworkSubmissionsInput,
@@ -33,16 +35,31 @@ router.get('/', validate({ query: listHomeworksQuerySchema }), async (req, res)
});
/** GET /api/homeworks/:id - 作业详情 */
/**
* GET /api/homeworks/days - 一段时间内"哪些天有作业"(客户端/Web 的日期选择器高亮)
*
* 必须注册在 /:id 之前,否则 "days" 会被当成作业 id 命中 /:id 路由。
*/
router.get('/days', validate({ query: listHomeworkDaysQuerySchema }), async (req, res) => {
const user = getAuthUser(req);
const query = validatedQuery<ListHomeworkDaysInput>(req);
sendOk(res, await homeworkService.listHomeworkDays(user, query), '获取作业日历成功');
});
router.get('/:id', validate({ params: idParamSchema }), async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
sendOk(res, await homeworkService.getHomework(user, id), '获取作业详情成功');
});
/** POST /api/homeworks - 发布作业(发布后广播 homework:new) */
/**
* POST /api/homeworks - 发布作业(发布后广播 homework:new)
*
* 这里**不加 requireRole**:教室机器的班级账号也要能录入作业(需求「支持作业在客户端录入」),
* 能不能发由服务层的 createHomework 判定(staff 或本班班级账号,其余 403)。
*/
router.post(
'/',
requireRole('ADMIN', 'TEACHER'),
validate({ body: createHomeworkSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -1,12 +1,30 @@
import { z } from 'zod';
import { isDayKey } from '@classhelper/shared';
import { booleanFlagSchema } from '../../lib/schemas.js';
/** YYYY-MM-DD,且必须是真实存在的日期 */
const dayKeySchema = z
.string()
.trim()
.refine((value) => isDayKey(value), { message: '日期格式应为 YYYY-MM-DD' });
export const listHomeworksQuerySchema = z.object({
classId: z.string().min(1).optional(),
courseId: z.string().min(1).optional(),
/** 学生端:只看未完成 */
pendingOnly: booleanFlagSchema,
keyword: z.string().trim().max(64).optional(),
/** 只看某一天的作业(按 Homework.assignDate,本地日期) */
date: dayKeySchema.optional(),
});
/** 按天查看用:查一段时间内"哪些天有作业"(日期选择器高亮) */
export const listHomeworkDaysQuerySchema = z.object({
classId: z.string().min(1).optional(),
from: dayKeySchema.optional(),
to: dayKeySchema.optional(),
/** 不传 from/to 时回看的天数(默认 60 天) */
days: z.coerce.number().int().min(1).max(366).optional(),
});
export const createHomeworkSchema = z.object({
@@ -15,10 +33,14 @@ export const createHomeworkSchema = z.object({
title: z.string().trim().min(1, '请输入作业标题').max(120),
content: z.string().min(1, '请输入作业内容').max(5000),
attachmentUrl: z.string().trim().max(500).nullish(),
/** 作业所属日期(YYYY-MM-DD):客户端/Web 录入时选,不传按服务器当天 */
assignDate: dayKeySchema.nullish(),
});
export const updateHomeworkSchema = z.object({
courseId: z.string().min(1).nullish(),
/** 改作业所属日期(把作业挪到另一天) */
assignDate: dayKeySchema.optional(),
title: z.string().trim().min(1).max(120).optional(),
content: z.string().min(1).max(5000).optional(),
attachmentUrl: z.string().trim().max(500).nullish(),
@@ -35,6 +57,7 @@ export const updateHomeworkSubmissionsSchema = z.object({
notSubmittedUserIds: z.array(z.string().min(1)).max(200).default([]),
});
export type ListHomeworkDaysInput = z.infer<typeof listHomeworkDaysQuerySchema>;
export type CreateHomeworkInput = z.infer<typeof createHomeworkSchema>;
export type UpdateHomeworkInput = z.infer<typeof updateHomeworkSchema>;
export type UpdateHomeworkStatusInput = z.infer<typeof updateHomeworkStatusSchema>;
@@ -1,5 +1,8 @@
import {
SOCKET_EVENTS,
dayKeyLocal,
shiftDayKey,
type HomeworkDaysDto,
type HomeworkDto,
type HomeworkStatusDto,
type HomeworkSubmissionDto,
@@ -16,7 +19,12 @@ import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js';
import { toHomeworkDto, toHomeworkStatusDto } from '../../lib/mappers.js';
import { isClassSession, personalIdWhere, resolvePersonalIds } from '../../lib/session.js';
import {
classSessionClassId,
isClassSession,
personalIdWhere,
resolvePersonalIds,
} from '../../lib/session.js';
import { emitToClass } from '../../realtime/bus.js';
import type {
CreateHomeworkInput,
@@ -33,6 +41,8 @@ export interface ListHomeworkOptions {
courseId?: string;
pendingOnly?: boolean;
keyword?: string;
/** 只看某一天(Homework.assignDate,YYYY-MM-DD) */
date?: string;
}
/**
@@ -60,13 +70,15 @@ export async function listHomeworks(
...(student && options.pendingOnly
? { statuses: { none: { ...personalWhere, completed: true } } }
: {}),
// 按天查看:作业「属于哪一天」看 assignDate(本地日期),不是 createdAt
...(options.date ? { assignDate: options.date } : {}),
},
include: {
course: courseSelect,
creator: creatorSelect,
statuses: student ? { where: personalWhere } : true,
},
orderBy: { createdAt: 'desc' },
orderBy: [{ assignDate: 'desc' }, { createdAt: 'desc' }],
take: 200,
});
@@ -75,6 +87,37 @@ export async function listHomeworks(
);
}
/**
* 按天查看:查一段时间内「哪些天有作业」(日期选择器高亮用)。
*
* 只回有作业的日期(稀疏),避免把整月日历塞回前端;不传区间时默认回看 60 天。
*/
export async function listHomeworkDays(
user: TokenPayload,
options: { classId?: string; from?: string; to?: string; days?: number },
): Promise<HomeworkDaysDto> {
const scope = await resolveClassScope(user, options.classId);
const today = dayKeyLocal(new Date());
const to = options.to ?? today;
const from = options.from ?? shiftDayKey(to, -(options.days ?? 60) + 1);
const rows = await prisma.homework.findMany({
where: { ...classScopeWhere(scope), assignDate: { gte: from, lte: to } },
select: { assignDate: true },
});
const counter = new Map<string, number>();
for (const row of rows) {
if (!row.assignDate) continue;
counter.set(row.assignDate, (counter.get(row.assignDate) ?? 0) + 1);
}
return {
days: [...counter.entries()]
.map(([date, count]) => ({ date, count }))
.sort((a, b) => a.date.localeCompare(b.date)),
};
}
export async function getHomework(user: TokenPayload, homeworkId: string): Promise<HomeworkDto> {
const student = isStudent(user);
const personalIds = student ? await resolvePersonalIds(user) : [];
@@ -95,11 +138,32 @@ export async function getHomework(user: TokenPayload, homeworkId: string): Promi
});
}
/** 发布作业并实时推送到班级房间 */
/**
* 发布作业并实时推送到班级房间。
*
* 权限:管理员 / 班主任 / 科任老师,**以及本班的班级账号**(教室机器的客户端录入 ——
* 需求就是「支持作业在客户端录入」,老师在教室电脑上顺手就能记一条)。
*/
export async function createHomework(user: TokenPayload, input: CreateHomeworkInput): Promise<HomeworkDto> {
await assertCanPublishContent(user, input.classId);
if (isClassSession(user) && classSessionClassId(user) === input.classId) {
await assertClassAccess(user, input.classId);
} else {
await assertCanPublishContent(user, input.classId);
}
if (input.courseId) await assertCourseInClass(input.courseId, input.classId);
// 班级账号的 sub 是**班级 id**(不是用户 id),直接写 createdBy 会撞外键;
// 教室机器录入的作业归属该班班主任,Web 端看到的作者才是真人。
let createdBy = user.sub;
if (isClassSession(user)) {
const owner = await prisma.class.findUnique({
where: { id: input.classId },
select: { teacherId: true },
});
if (!owner) throw ApiError.notFound('班级不存在');
createdBy = owner.teacherId;
}
const created = await prisma.homework.create({
data: {
classId: input.classId,
@@ -107,7 +171,9 @@ export async function createHomework(user: TokenPayload, input: CreateHomeworkIn
title: input.title,
content: input.content,
attachmentUrl: input.attachmentUrl ?? null,
createdBy: user.sub,
// 不传日期就记成「服务器当天」;教室在 UTC+8,用本地日期而不是 UTC 日期
assignDate: input.assignDate ?? dayKeyLocal(new Date()),
createdBy,
},
include: { course: courseSelect, creator: creatorSelect, statuses: true },
});
@@ -135,6 +201,7 @@ export async function updateHomework(
...(input.content ? { content: input.content } : {}),
...(input.courseId !== undefined ? { courseId: input.courseId ?? null } : {}),
...(input.attachmentUrl !== undefined ? { attachmentUrl: input.attachmentUrl ?? null } : {}),
...(input.assignDate !== undefined ? { assignDate: input.assignDate } : {}),
},
include: { course: courseSelect, creator: creatorSelect, statuses: true },
});
@@ -225,6 +225,7 @@ function toPushDto(
durationSeconds: number;
speechContent: string | null;
urgent: boolean;
kind?: string | null;
createdAt: Date;
},
extras: { className?: string | null; teacherName?: string | null } = {},
@@ -237,6 +238,8 @@ function toPushDto(
speechContent: record.speechContent,
createdAt: record.createdAt.toISOString(),
urgent: record.urgent,
// 老数据(迁移前)kind 为空:按普通通知处理
kind: record.kind === 'call' ? 'call' : 'notification',
classId: record.classId,
className: extras.className ?? null,
teacherName: extras.teacherName ?? null,
@@ -325,6 +328,8 @@ export interface PushToClassIslandInput {
notificationId?: string | null;
/** 覆盖落库/广播时附带的教师名(三个入口的取法不同) */
teacherName?: string | null;
/** 提醒类型:notification(默认)/ call(叫人,算「主动通知」) */
kind?: 'notification' | 'call';
}
/**
@@ -369,6 +374,7 @@ export async function pushToClassIsland(
durationSeconds: input.durationSeconds ?? REPORT_DEFAULTS.notificationDuration,
speechContent: input.speechContent ?? null,
urgent: input.urgent === true,
kind: input.kind ?? 'notification',
createdBy: input.createdBy ?? null,
notificationId: input.notificationId ?? null,
// 24 小时未送达/未确认的提醒不再补发,避免学生过几天突然看到旧提醒