feat: 完善课表档案、课件归档、提醒与客户端更新并发布 1.4.1

- 重写课表档案工作区并合并时间表、课表与完整档案导入导出
- 修复课前/上下课提醒遮罩与通知收回冲突
- 增加课件文件夹分类、教师归档路径和客户端本机归档
- 打通客户端下载/安装/完成状态上报并在管理端展示更新过程
- 修复侧栏折叠裁切、课时多选与上传控件样式
- 统一版本到 1.4.1
This commit is contained in:
LoveChengke committed 2026-10-18 17:39:54 +08:00
1 parent 2612a912ef
commit 6347d1e5aa
59 files changed
+2251 -584

No files matched your search

+1
View File
@@ -60,6 +60,7 @@ export const SNAPSHOT_TABLES = [
'Grade',
'ClientDevice',
'Courseware',
'CoursewareFolder',
'CoursewareVisibility',
'CoursewarePush',
'CoursewareReceipt',
@@ -1,4 +1,5 @@
import { importProfile } from './profile-import.service.js';
import { exportProfile } from './profile-export.service.js';
import { Router } from 'express';
import { z } from 'zod';
import { prisma } from '../../lib/db.js';
@@ -82,6 +83,9 @@ layouts.delete(
const plans = Router();
plans.use(authenticate());
plans.get('/profile', validate({ query: z.object({ classId: z.string().min(1) }) }), async (req, res) =>
sendOk(res, await exportProfile(getAuthUser(req), validatedQuery<{ classId: string }>(req).classId)),
);
plans.post(
'/import/profile',
requirePermission('schedule:write'),
@@ -0,0 +1,106 @@
import { prisma } from '../../lib/db.js';
import { assertClassAccess } from '../../lib/access.js';
import type { TokenPayload } from '../../lib/jwt.js';
/** 按时间点外键生成完整档案,空课、停用课程与非上课时间点均保留。 */
export async function exportProfile(user: TokenPayload, classId: string) {
await assertClassAccess(user, classId);
const [cls, layouts, plans, subjects, groups, scheduled, setting] = await Promise.all([
prisma.class.findUniqueOrThrow({ where: { id: classId } }),
prisma.timeLayout.findMany({
where: { OR: [{ classId }, { classId: null }] },
include: { points: { orderBy: { position: 'asc' } } },
}),
prisma.classPlan.findMany({ where: { classId }, include: { entries: true } }),
prisma.course.findMany({ where: { classId } }),
prisma.classPlanGroup.findMany({ where: { classId } }),
prisma.scheduledClassPlan.findMany({ where: { classId } }),
prisma.appSetting.findUnique({ where: { key: `schedule.group.${classId}` } }),
]);
let selection: {
selected?: string;
temporary?: string;
expireTime?: string;
temporaryPlan?: string;
temporaryPlanDate?: string;
} = {};
try {
selection = JSON.parse(setting?.value || '{}');
} catch {
/* 使用默认群组 */
}
const types = ['LESSON', 'BREAK', 'DIVIDER', 'ACTION'];
const actions = (value: string) => {
try {
return JSON.parse(value);
} catch {
return {};
}
};
return {
Name: cls.name,
Subjects: Object.fromEntries(
subjects.map((s) => [s.id, { Name: s.name, Initial: s.initial, IsOutDoor: s.isOutDoor }]),
),
TimeLayouts: Object.fromEntries(
layouts.map((l) => [
l.id,
{
Name: l.name,
IsOverlay: l.isOverlay,
OverlaySourceId: l.overlaySourceId,
Layouts: l.points.map((p) => ({
Name: p.name,
StartTime: p.startTime,
EndTime: p.endTime,
TimeType: types.indexOf(p.timeType),
IsHideDefault: p.isHideDefault,
DefaultClassId: p.defaultClassId,
BreakName: p.breakName,
ActionSet: actions(p.actionSet),
})),
},
]),
),
ClassPlans: Object.fromEntries(
plans.map((p) => [
p.id,
{
Name: p.name,
TimeLayoutId: p.timeLayoutId,
AssociatedGroup: p.associatedGroup,
TimeRule: {
WeekDay: p.weekday,
WeekCountDiv: p.weekCountDiv,
WeekCountDivTotal: p.weekCountDivTotal,
},
WeekStart: p.weekStart,
WeekEnd: p.weekEnd,
IsEnabled: p.isEnabled,
IsOverlay: p.isOverlay,
OverlaySourceId: p.overlaySourceId,
OverlayDate: p.overlayDate,
Classes: (layouts.find((l) => l.id === p.timeLayoutId)?.points || [])
.filter((point) => point.timeType === 'LESSON')
.map((point) => {
const e = p.entries.find((entry) => entry.timeLayoutItemId === point.id);
return {
SubjectId: e?.courseId || '',
IsEnabled: e?.isEnabled ?? true,
IsChangedClass: e?.isChangedClass ?? false,
Location: e?.location || '',
};
}),
},
]),
),
ClassPlanGroups: Object.fromEntries(groups.map((g) => [g.id, { Name: g.name, IsGlobal: g.isGlobal }])),
SelectedClassPlanGroupId: selection.selected || groups.find((g) => g.isDefault)?.id || '',
TempClassPlanGroupId: selection.temporary || '',
IsTempClassPlanGroupEnabled: !!selection.temporary,
TempClassPlanGroupExpireTime: selection.expireTime,
TempClassPlanId: selection.temporaryPlan,
TempClassPlanSetupTime: selection.temporaryPlanDate,
OrderedSchedules: Object.fromEntries(scheduled.map((s) => [s.date, { ClassPlanId: s.classPlanId }])),
};
}
@@ -6,6 +6,7 @@ import { assertCanManageSchedule } from '../../lib/access.js';
import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js';
import { notifyScheduleChanged } from './class-plans.service.js';
import { getMaxTermWeeks } from '../../lib/term.js';
const record = z.record(z.string(), z.unknown());
function object(value: unknown): Record<string, unknown> {
const parsed = record.safeParse(value);
@@ -39,6 +40,7 @@ export async function importProfile(user: TokenPayload, classId: string, payload
if (Object.keys(plans).length > 500 || Object.keys(layouts).length > 100)
throw ApiError.badRequest('导入课表过多');
const cls = await prisma.class.findUniqueOrThrow({ where: { id: classId } });
const maxWeeks = await getMaxTermWeeks();
const warnings: string[] = [];
const stable = (kind: string, id: string) =>
`profile-${kind}-${createHash('sha256')
@@ -183,6 +185,14 @@ export async function importProfile(user: TokenPayload, classId: string, payload
)
throw ApiError.badRequest('导入启用规则不合法(支持1–9周轮换)');
const previous = await tx.classPlan.findUnique({ where: { id: stable('plan', id) } });
const range = z
.object({
start: z.number().int().min(1).max(maxWeeks),
end: z.number().int().min(1).max(maxWeeks),
})
.refine((r) => r.start <= r.end)
.safeParse({ start: p.WeekStart ?? 1, end: Math.min(Number(p.WeekEnd ?? maxWeeks), maxWeeks) });
if (!range.success) throw ApiError.badRequest('导入课表生效周次无效');
const data = {
classId,
name: String(p.Name || '导入课表').slice(0, 64),
@@ -191,8 +201,11 @@ export async function importProfile(user: TokenPayload, classId: string, payload
weekday,
weekCountDiv,
weekCountDivTotal,
weekStart: range.data.start,
weekEnd: range.data.end,
isEnabled: p.IsEnabled !== false,
isOverlay: p.IsOverlay === true,
overlayDate: typeof p.OverlayDate === 'string' && isDayKey(p.OverlayDate) ? p.OverlayDate : null,
};
const plan = await tx.classPlan.upsert({
where: { id: stable('plan', id) },
@@ -1,4 +1,5 @@
import { Router } from 'express';
import type { ClientUpdateProgressDto } from '@classhelper/shared';
import { sendCreated, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.js';
import { authenticate, getAuthUser, requireAnyPermission, requirePermission } from '../../middleware/auth.js';
@@ -7,6 +8,7 @@ import { defineModule } from '../module.types.js';
import {
clientCommandAckSchema,
clientHeartbeatSchema,
clientProgressSchema,
createClientCommandSchema,
listClientDevicesQuerySchema,
pushClientUpdateSchema,
@@ -20,6 +22,14 @@ import * as clientService from './clients.service.js';
const router = Router();
router.use(authenticate());
router.post('/update-progress', validate({ body: clientProgressSchema }), async (req, res) => {
const input = validatedBody<{
deviceKey: string;
progress: ClientUpdateProgressDto;
}>(req);
await clientService.reportUpdateProgress(getAuthUser(req), input.deviceKey, input.progress);
sendOk(res, null);
});
/**
* POST /api/clients/heartbeat - 客户端每 30 秒上报一次
@@ -11,6 +11,24 @@ const clientCommandKindSchema = z.enum(
/** 设备标识(客户端主进程生成并持久化的 UUID) */
const deviceKeySchema = z.string().trim().min(8, '设备标识不合法').max(80, '设备标识不合法');
export const updateProgressSchema = z.object({
version: z.string().trim().max(40),
stage: z.enum([
'idle',
'downloading',
'verifying',
'ready',
'waiting',
'installing',
'failed',
'completed',
]),
downloadedBytes: z.number().int().min(0),
totalBytes: z.number().int().min(0),
percent: z.number().min(0).max(100).nullable(),
error: z.string().max(1000),
});
export const clientProgressSchema = z.object({ deviceKey: deviceKeySchema, progress: updateProgressSchema });
/** `POST /api/clients/heartbeat`:客户端每 30 秒一次的上报 */
export const clientHeartbeatSchema = z.object({
@@ -22,6 +40,7 @@ export const clientHeartbeatSchema = z.object({
autoUpdate: z.boolean().optional(),
pendingVersion: z.string().trim().max(40).nullable().optional(),
stateNote: z.string().trim().max(120).optional(),
updateProgress: updateProgressSchema.optional(),
});
/**
@@ -2,6 +2,8 @@ import {
CLIENT_COMMAND_EXPIRE_MS,
CLIENT_COMMAND_LABELS,
CLIENT_ONLINE_WINDOW_MS,
compareVersions,
type ClientUpdateProgressDto,
type ClientUpdateMode,
type ClientCommandAckRequest,
type ClientCommandDto,
@@ -47,6 +49,7 @@ type DeviceRow = {
lastHeartbeatAt: Date | null;
lastIp: string;
stateNote: string;
updateProgress: string;
createdAt: Date;
updatedAt: Date;
class?: { id: string; name: string } | null;
@@ -153,6 +156,25 @@ export async function heartbeat(
})) as unknown as DeviceRow;
const targetVersion = device.updateTargetVersion.trim();
if (targetVersion && input.clientVersion && compareVersions(input.clientVersion, targetVersion) >= 0) {
await prisma.clientDevice.updateMany({
where: { id: device.id, updateTargetVersion: targetVersion },
data: {
pendingVersion: '',
updateProgress: JSON.stringify({
version: input.clientVersion,
stage: 'completed',
downloadedBytes: 0,
totalBytes: 0,
percent: 100,
error: '',
updatedAt: now.toISOString(),
}),
},
});
} else if (input.updateProgress && input.updateProgress.stage !== 'idle') {
await reportUpdateProgress(user, input.deviceKey, input.updateProgress);
}
const [command, update] = await Promise.all([
findPendingCommand(device.id, now),
targetVersion ? clientReleases.resolveUpdateInfo(targetVersion) : Promise.resolve(null),
@@ -176,6 +198,46 @@ export async function heartbeat(
};
}
export async function reportUpdateProgress(
user: TokenPayload,
deviceKey: string,
progress: ClientUpdateProgressDto,
) {
const device = await prisma.clientDevice.findUnique({ where: { deviceKey } });
// 教师/管理员电脑不绑定班级(会话与设备两侧都是 null),同样允许上报自己的进度。
if (!device || device.classId !== resolveClassId(user)) throw ApiError.forbidden('设备不属于当前班级');
// 已取消或更换目标的迟到进度不得覆盖当前任务。
if (progress.version !== device.updateTargetVersion || progress.stage === 'completed') return;
const previous = readUpdateProgress(device.updateProgress);
if (previous?.version === progress.version && previous.stage === 'completed') return;
const stages = ['idle', 'downloading', 'verifying', 'ready', 'waiting', 'installing', 'completed'];
if (
previous?.version === progress.version &&
previous.stage !== 'failed' &&
progress.stage !== 'failed' &&
!progress.error &&
stages.indexOf(progress.stage) < stages.indexOf(previous.stage)
)
return;
await prisma.clientDevice.updateMany({
where: {
id: device.id,
updateTargetVersion: progress.version,
updateProgress: device.updateProgress,
},
data: {
updateProgress: JSON.stringify({ ...progress, updatedAt: new Date().toISOString() }),
},
});
}
function readUpdateProgress(raw: string): ClientUpdateProgressDto | null {
try {
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
}
/** POST /api/clients/commands/:id/ack - 客户端执行完远控命令后的回执 */
export async function ackCommand(
commandId: string,
@@ -263,6 +325,7 @@ export async function listDevices(
lastHeartbeatAt: heartbeat ? heartbeat.toISOString() : null,
lastIp: row.lastIp,
stateNote: row.stateNote,
updateProgress: readUpdateProgress(row.updateProgress),
online: Boolean(heartbeat && now.getTime() - heartbeat.getTime() < CLIENT_ONLINE_WINDOW_MS),
pendingCommand: pending ? toCommandDto(pending) : null,
lastCommand: last ? toCommandDto(last) : null,
@@ -390,6 +453,7 @@ export async function pushUpdate(
where: { id: device.id },
data: {
updateTargetVersion: target,
updateProgress: '',
updateMode: clear ? 'idle' : input.mode,
updateNotBefore:
!clear && input.mode === 'after-school'
@@ -10,7 +10,13 @@ import {
} from '../../middleware/auth.js';
import { validate, validatedBody, validatedQuery, validatedParams } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import { coursewareDeviceQuery, coursewareScopeSchema, coursewareUploadQuery } from './courseware.schemas.js';
import {
coursewareDeviceQuery,
coursewareScopeSchema,
coursewareUploadQuery,
coursewareFolderSchema,
coursewareMoveSchema,
} from './courseware.schemas.js';
import type { CoursewareUploadRequest } from '@classhelper/shared';
import * as service from './courseware.service.js';
const router = Router();
@@ -22,6 +28,26 @@ router.get('/', requireStaffPermission('courseware:read'), async (req, res) =>
router.get('/classes', ...staff, async (req, res) =>
sendOk(res, await service.teachingClasses(getAuthUser(req))),
);
router.get('/folders', ...staff, async (req, res) =>
sendOk(res, await service.listFolders(getAuthUser(req))),
);
router.post('/folders', ...staff, validate({ body: coursewareFolderSchema }), async (req, res) =>
sendCreated(res, await service.createFolder(getAuthUser(req), validatedBody<{ path: string }>(req).path)),
);
router.patch(
'/:id/folder',
...staff,
validate({ params: idParamSchema, body: coursewareMoveSchema }),
async (req, res) =>
sendOk(
res,
await service.moveCourseware(
getAuthUser(req),
validatedParams<{ id: string }>(req).id,
validatedBody<{ folderPath: string }>(req).folderPath,
),
),
);
router.get(
'/transfers',
requireRole('CLASS_DEVICE'),
@@ -2,6 +2,27 @@ import { z } from 'zod';
import { COURSEWARE_MAX_BYTES } from '@classhelper/shared';
export const coursewareScopeSchema = z.object({ classIds: z.array(z.string().min(1).max(100)).max(100) });
const folderPath = z
.string()
.trim()
.min(1)
.max(240)
.refine(
(value) =>
value
.split('/')
.every(
(part) =>
part.length > 0 &&
part.length <= 60 &&
!/[<>:"\\|?*\u0000-\u001f]/.test(part) &&
!/[. ]$/.test(part) &&
!/^(con|prn|aux|nul|com[0-9]|lpt[0-9])(?:\.|$)/i.test(part),
),
'文件夹名称无效,请使用 / 分隔子文件夹',
);
export const coursewareFolderSchema = z.object({ path: folderPath });
export const coursewareMoveSchema = z.object({ folderPath: folderPath.or(z.literal('')) });
export const coursewareUploadSchema = coursewareScopeSchema
.extend({
name: z
@@ -15,6 +36,7 @@ export const coursewareUploadSchema = coursewareScopeSchema
),
size: z.number().int().min(1).max(COURSEWARE_MAX_BYTES),
mode: z.enum(['cloud', 'push']),
folderPath: folderPath.or(z.literal('')).optional(),
})
.refine((input) => input.mode !== 'push' || input.classIds.length > 0, '推送时请选择授课班级');
export const coursewareUploadQuery = z.object({
@@ -8,6 +8,7 @@ import type { Request } from 'express';
import {
COURSEWARE_MAX_BYTES,
coursewareCategory,
coursewareArchivePath,
dayKeyLocal,
SOCKET_EVENTS,
type CoursewareDto,
@@ -37,6 +38,8 @@ function dto(row: RecordWithRelations): CoursewareDto {
category: row.category as CoursewareDto['category'],
extension: row.extension,
archiveDate: row.archiveDate,
archivePath: row.archivePath || coursewareArchivePath(row.owner.name, row.createdAt, row.name),
folderPath: row.folderPath,
sha256: row.sha256,
createdAt: row.createdAt.toISOString(),
teacher: row.owner,
@@ -104,13 +107,18 @@ export async function listCourseware(user: TokenPayload) {
}
export async function uploadCourseware(user: TokenPayload, input: CoursewareUploadRequest, req: Request) {
const classIds = await validateClasses(user, input.classIds);
await assertFolder(user, input.folderPath || '');
if (!req.is('application/octet-stream')) throw ApiError.badRequest('请使用二进制文件上传');
if (req.headers['content-length'] && Number(req.headers['content-length']) !== input.size)
throw ApiError.badRequest('文件长度与上传信息不符');
const uploadedAt = new Date();
const owner = await prisma.user.findUniqueOrThrow({ where: { id: user.sub }, select: { name: true } });
const archivePath = coursewareArchivePath(owner.name, uploadedAt, input.name);
const category = coursewareCategory(input.name),
archiveDate = dayKeyLocal(new Date());
archiveDate = dayKeyLocal(uploadedAt);
const extension = path.extname(input.name).slice(1).toLowerCase();
const relative = path.join(archiveDate, category, randomUUID());
// UUID 隔离同名教师或同秒上传;对外归档路径始终使用上传时的教师名和时间。
const relative = path.join(randomUUID(), archivePath);
const file = path.join(coursewareRoot, relative),
partial = `${file}.part`;
await fsp.mkdir(path.dirname(file), { recursive: true });
@@ -142,6 +150,9 @@ export async function uploadCourseware(user: TokenPayload, input: CoursewareUplo
category,
extension,
archiveDate,
archivePath,
folderPath: input.folderPath || '',
createdAt: uploadedAt,
storagePath: relative,
sha256: hash.digest('hex'),
visibility: { create: classIds.map((classId) => ({ classId })) },
@@ -156,6 +167,35 @@ export async function uploadCourseware(user: TokenPayload, input: CoursewareUplo
throw error;
}
}
async function assertFolder(user: TokenPayload, folderPath: string) {
if (
folderPath &&
!(await prisma.coursewareFolder.findUnique({
where: { ownerId_path: { ownerId: user.sub, path: folderPath } },
}))
)
throw ApiError.badRequest('请选择自己创建的文件夹');
}
export async function listFolders(user: TokenPayload) {
const rows = await prisma.coursewareFolder.findMany({
where: { ownerId: user.sub },
orderBy: { path: 'asc' },
});
return rows.map((row) => ({ id: row.id, path: row.path }));
}
export async function createFolder(user: TokenPayload, folderPath: string) {
const row = await prisma.coursewareFolder.upsert({
where: { ownerId_path: { ownerId: user.sub, path: folderPath } },
create: { ownerId: user.sub, path: folderPath },
update: {},
});
return { id: row.id, path: row.path };
}
export async function moveCourseware(user: TokenPayload, id: string, folderPath: string) {
await owned(user, id);
await assertFolder(user, folderPath);
return dto(await prisma.courseware.update({ where: { id }, data: { folderPath }, include }));
}
export async function setVisibility(user: TokenPayload, id: string, ids: string[]) {
await owned(user, id);
const classIds = await validateClasses(user, ids);