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
+7
@@ -0,0 +1,7 @@
|
||||
-- 作业所属日期(YYYY-MM-DD,本地日期):客户端/Web 的按天查看与日期高亮都用它
|
||||
ALTER TABLE "Homework" ADD COLUMN "assignDate" TEXT NOT NULL DEFAULT '';
|
||||
|
||||
-- 回填历史数据:按 UTC+8 把 createdAt 折算成当天(本产品面向国内教室,服务器在 CST)
|
||||
UPDATE "Homework" SET "assignDate" = date("createdAt", '+8 hours') WHERE "assignDate" = '';
|
||||
|
||||
CREATE INDEX "Homework_classId_assignDate_idx" ON "Homework"("classId", "assignDate");
|
||||
@@ -0,0 +1,2 @@
|
||||
-- 提醒类型:notification=通知类 / call=叫人(叫人算「主动通知」,上课时段也立即弹出)
|
||||
ALTER TABLE "ClassIslandPush" ADD COLUMN "kind" TEXT NOT NULL DEFAULT 'notification';
|
||||
@@ -163,6 +163,8 @@ model Homework {
|
||||
courseId String?
|
||||
title String
|
||||
content String
|
||||
/// 作业所属日期(YYYY-MM-DD,本地日期,不带时区):客户端/Web 的按天查看与日期高亮都用它
|
||||
assignDate String @default("")
|
||||
attachmentUrl String?
|
||||
/// 遗留字段:作业"截止时间"功能已下线(UI/接口均不再读写)。
|
||||
/// 保留列以免对已安装的库做破坏性迁移;新库该列恒为 null。
|
||||
@@ -177,6 +179,7 @@ model Homework {
|
||||
statuses HomeworkStatus[]
|
||||
|
||||
@@index([classId, createdAt])
|
||||
@@index([classId, assignDate])
|
||||
@@index([courseId])
|
||||
@@index([dueAt])
|
||||
}
|
||||
@@ -328,6 +331,8 @@ model ClassIslandPush {
|
||||
/// 语音朗读内容;null = 不朗读
|
||||
speechContent String?
|
||||
urgent Boolean @default(false)
|
||||
/// 提醒类型:notification=通知类 / call=叫人(叫人算「主动通知」,上课时段也立刻弹)
|
||||
kind String @default("notification")
|
||||
/// 发起人的用户 id(教师/管理员)
|
||||
createdBy String?
|
||||
/// 同时落库到通知中心的通知 id(saveToNotifications 时写入)
|
||||
|
||||
@@ -304,6 +304,12 @@ async function main() {
|
||||
published.status === 201 && Boolean(publishPush),
|
||||
`status=${published.status} 待提醒=${(pendingAfterPublish.payload?.data?.notifications ?? []).length}`,
|
||||
);
|
||||
// kind 决定插件"上课时段弹不弹":通知类会被暂存,叫人属于主动通知要立刻弹
|
||||
record(
|
||||
'通知类推送的 kind=notification(上课时段插件会暂存)',
|
||||
publishPush?.kind === 'notification',
|
||||
`kind=${publishPush?.kind}`,
|
||||
);
|
||||
if (publishPush) {
|
||||
await api('/integrations/classisland/ack', {
|
||||
method: 'POST',
|
||||
@@ -326,6 +332,11 @@ async function main() {
|
||||
Boolean(firstStudent) && call.status === 201 && Boolean(callPush),
|
||||
firstStudent ? `status=${call.status}` : '班级里没有学生,跳过',
|
||||
);
|
||||
record(
|
||||
'叫人推送的 kind=call(算主动通知,上课时段也立刻弹)',
|
||||
callPush?.kind === 'call',
|
||||
`kind=${callPush?.kind}`,
|
||||
);
|
||||
if (callPush) {
|
||||
await api('/integrations/classisland/ack', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -455,6 +455,36 @@ async function main() {
|
||||
createdHomework.payload?.data?.dueAt === undefined,
|
||||
`dueAt=${String(createdHomework.payload?.data?.dueAt)}`,
|
||||
);
|
||||
// 按天查看:作业带"所属日期"(本地日期),并且能按它过滤
|
||||
const assignDate = createdHomework.payload?.data?.assignDate;
|
||||
record(
|
||||
'作业带所属日期(assignDate,默认服务器当天)',
|
||||
typeof assignDate === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(assignDate),
|
||||
`assignDate=${assignDate}`,
|
||||
);
|
||||
const byDay = await api(`/homeworks?classId=${classId}&date=${assignDate}`, { token: teacherToken });
|
||||
record(
|
||||
'按天查看(?date=)只返回该天的作业',
|
||||
byDay.status === 200 &&
|
||||
(byDay.payload?.data ?? []).length > 0 &&
|
||||
(byDay.payload?.data ?? []).every((item) => item.assignDate === assignDate),
|
||||
`当天条数=${(byDay.payload?.data ?? []).length}`,
|
||||
);
|
||||
const otherDay = await api(`/homeworks?classId=${classId}&date=2000-01-01`, { token: teacherToken });
|
||||
record(
|
||||
'没有作业的日期返回空列表',
|
||||
otherDay.status === 200 && (otherDay.payload?.data ?? []).length === 0,
|
||||
`条数=${(otherDay.payload?.data ?? []).length}`,
|
||||
);
|
||||
const dayMarks = await api(`/homeworks/days?classId=${classId}`, { token: teacherToken });
|
||||
const marks = dayMarks.payload?.data?.days ?? [];
|
||||
record(
|
||||
'作业日历返回有作业的日期(供日期选择器高亮)',
|
||||
dayMarks.status === 200 && marks.some((item) => item.date === assignDate && item.count >= 1),
|
||||
`日期数=${marks.length} 命中=${marks.find((item) => item.date === assignDate)?.count ?? 0}`,
|
||||
);
|
||||
const badDay = await api(`/homeworks/days?classId=${classId}&from=2026-13-45`, { token: teacherToken });
|
||||
record('作业日历非法日期被拒(422)', badDay.status === 422, `status=${badDay.status}`);
|
||||
|
||||
try {
|
||||
const { payload, elapsed } = await homeworkWait;
|
||||
@@ -1867,6 +1897,46 @@ async function main() {
|
||||
`status=${classScheduleWrite.status}`,
|
||||
);
|
||||
|
||||
// 教室机器录入作业(需求:支持作业在客户端录入):班级账号可以发,跨班不行
|
||||
const classHomeworkCreate = await api('/homeworks', {
|
||||
method: 'POST',
|
||||
token: classToken,
|
||||
body: {
|
||||
classId,
|
||||
title: '教室录入验证作业',
|
||||
content: 'P 大本 第 3 课',
|
||||
assignDate: '2026-03-02',
|
||||
},
|
||||
});
|
||||
record(
|
||||
'班级账号可在教室机器上录入作业',
|
||||
classHomeworkCreate.status === 201 && classHomeworkCreate.payload?.data?.assignDate === '2026-03-02',
|
||||
`status=${classHomeworkCreate.status} assignDate=${classHomeworkCreate.payload?.data?.assignDate}`,
|
||||
);
|
||||
if (foreignClass) {
|
||||
const classHomeworkCreateCross = await api('/homeworks', {
|
||||
method: 'POST',
|
||||
token: classToken,
|
||||
body: { classId: foreignClass.id, title: '跨班录入作业', content: '不应成功' },
|
||||
});
|
||||
record(
|
||||
'班级账号跨班录入作业被拒绝(403)',
|
||||
classHomeworkCreateCross.status === 403,
|
||||
`status=${classHomeworkCreateCross.status}`,
|
||||
);
|
||||
}
|
||||
if (classHomeworkCreate.payload?.data?.id) {
|
||||
const cleanupHomework = await api(`/homeworks/${classHomeworkCreate.payload.data.id}`, {
|
||||
method: 'DELETE',
|
||||
token: teacherToken,
|
||||
});
|
||||
record(
|
||||
'清理:教室录入的验证作业已删除',
|
||||
cleanupHomework.status === 200,
|
||||
`status=${cleanupHomework.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 班级设备代全班操作:通知已读
|
||||
const classNameNotice = await api('/notifications', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -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 小时未送达/未确认的提醒不再补发,避免学生过几天突然看到旧提醒
|
||||
|
||||
Reference in new issue
Block a user