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:
1 parent
d0830f6830
commit
7536abfc95
33 files changed
+1236
-162
No files matched your search
@@ -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 },
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user