fix(integrations,grades,clients,web,client,plugin): 课表同步改为按需(修「课表被反复删改」)+ 客户端页合并 + 成绩排名(1.3.1)
线上问题的根因 - ClassIsland 插件默认每 15 秒上报一次,每次都带全量课表,上报完还顺手把课表镜像回教室 (本机 `UploadSchedule` / `MirrorSchedule` 两个开关默认都是开)。于是教室的课表每 15 秒被重写、 服务端也每 15 秒 merge + 清理一次幽灵行 —— 老师看到的就是"课表被反复删改"。 - 现在两个方向都只由 Web 端的一次性待办触发,插件侧那两个开关整体删除; 定时上报只报**运行状态**(当前上什么课),不带课表。 课表同步(#3) - 新增 `IntegrationDevice.requestMirrorSchedule` + `POST /integrations/devices/:id/mirror-schedule` (「下发课表」),与既有的 `request-schedule`(「获取课表」)对称;拉取成功即清标记 - 插件 `ClassPlanWriter.Apply(..., force: true)`:停用同一天同一单双周相位下冲突的旧课表 —— **只停用不删除**,内容原样留在档案里,老师随时能重新启用 - 插件设置页删掉「自动上报课表」「镜像回 ClassIsland」两个开关,改为一句说明 「客户端」页(#4) - 把「ClassIsland 联动」与「客户端设备」合成一页 `ClientHubView.vue`:一个班一张卡片, 两条链路状态放一起,勾选可批量下发课表 / 更新,点卡片进详情做逐台操作 - 「下发提醒」也搬了过来;入口权限用 `classisland:notify`(默认 ADMIN + TEACHER), 页内按钮再各自按 device:manage / remote:control / client:update-push 显示或禁用 - 两个旧页面 `meta.hidden: true` **保留路由**(安装包缓存管理、联动步骤说明仍在), 从详情抽屉里给链接 —— 不删功能 成绩(#5/#6/#7) - 新增 `Exam`(班级 + 考试名 + `countInTotal`)与迁移;`GET/PUT /api/grades/exams`、 `GET /api/grades/ranking`(两条都**不挂权限键**,班级端的有效权限是空集,边界在 service 里) - 排名口径写在服务端:**同一学生同一科目只取最高分**(补录 / 重考会多行,不去重等于"总分多一科")、 并列同名次(1、2、2、4) - 「成绩录入」页加「考试口径」开关(按班级 + 考试名记住);客户端「成绩」页新增总分榜 + 单科榜 - `updateStudentGradeQuery` 改成只有教师 / 管理员能改 —— 原先班级端自己也能改 - 客户端的成绩查询开关改为只读(只显示服务端下发的状态) 其他(#2/#5/#8/#9) - 全校学期开始日期改成 `AppSetting` 的 `term.startDate`,管理员在「学期周次」页直接改、立刻重算周次 (原先这一级只能来自 `.env`) - 大模型助手发紧急通知补上「上课时段 3 秒全屏警告」:判据抽成 `composables/useClassInSession.ts`, 与手动发布页共用 - 客户端去掉「关怀模式」「自动更新」两个开关(教室机器是统一投放的终端) - 修掉「选中设置二级菜单后收起分组、高亮块仍留在原地」:底片只在路由 / 滚动 / 缩放时重测, 而收起分组动不了 `.aside` 的盒子 —— 补上"子项被 overflow:hidden 裁掉"的判据 + 观察分组 `<ul>` 高度 - `src/types/express.d.ts` 里 `req.device` 改成引用 `DeviceContext`:原先手抄了一份结构, 往接口加字段时两边就漂了,而报错会落在 `getDevice()` 的 `return device;` 上,看不出真正原因 验收:`lint` / `typecheck` / `check:icons` / `docs:check` 全绿; 后端端到端 **247/247**、联动链路 **44/44**、插件静态契约 **76/76**、 Web 真实点击 **34/34**(含新增的「客户端」页用例)、客户端冒烟 **90/90**。
This commit is contained in:
1 parent
3236d3ad83
commit
227117ccb3
49 files changed
+2025
-281
No files matched your search
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@classhelper/server",
|
||||
"version": "1.3.0",
|
||||
"version": "1.3.1",
|
||||
"private": true,
|
||||
"description": "ClassHelper后端服务:REST API + Socket.IO 实时推送 + Prisma 数据访问",
|
||||
"type": "module",
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
-- 成绩:按考试记住「是否把所有科目相加以总分排名」
|
||||
--
|
||||
-- 需求:「单次考试(如月考,期中考),在导入成绩时可选是否将所有科目相加以总分排名,
|
||||
-- 客户端也可查看到总分排名和个别单科的排名」。
|
||||
--
|
||||
-- `Grade.examName` 一直是自由文本,服务端光看它不知道这次考试算不算总分;而客户端要显示
|
||||
-- 总分榜就必须知道,所以按「班级 + 考试名」单独记一条 `Exam`。
|
||||
--
|
||||
-- 与 `Grade` 是**弱关联**:两边只按名字匹配,不建外键。成绩删光不会连带删掉设置,
|
||||
-- 补录同一场考试时设置还在。
|
||||
--
|
||||
-- 注意:本仓库的 SQLite / MySQL 双库约束是「只用 String / Int / Float / Boolean / DateTime」,
|
||||
-- 因此这里没有 enum、没有 @db.*。
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Exam" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"classId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"countInTotal" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "Exam_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Exam_classId_name_key" ON "Exam"("classId", "name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Exam_classId_idx" ON "Exam"("classId");
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
-- 课表下发改成「按按钮才发生」
|
||||
--
|
||||
-- 需求:「ClassIsland 联动改为下发或获取课表,获取课表时点击获取才会获取,而不是一直获取」。
|
||||
--
|
||||
-- 起因(2026-10-11 实测):插件每 `ReportIntervalSeconds`(默认 15)秒上报一次,且
|
||||
-- 本机 `UploadSchedule` 开着时**每次都带全量课表**、`MirrorSchedule` 开着时**每次上报完
|
||||
-- 还顺手镜像一遍**。于是教室的课表每 15 秒被重写一次,服务端这边也每 15 秒
|
||||
-- merge + 清理一次幽灵行 —— 老师看到的就是"课表被反复删改"。
|
||||
--
|
||||
-- 现在两个方向都只由一次性待办触发:
|
||||
-- · 教室 → 服务端:`requestScheduleReport`(已有,「从教室机器获取课表」按钮)
|
||||
-- · 服务端 → 教室:`requestMirrorSchedule`(本次新增,「下发课表到教室」按钮)
|
||||
-- `mirrorScheduleToClassIsland` 的含义同时收窄为「这台设备接不接受下发」,不再是自动同步开关。
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "IntegrationDevice" ADD COLUMN "requestMirrorSchedule" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -241,6 +241,7 @@ model Class {
|
||||
homeworks Homework[]
|
||||
notifications Notification[]
|
||||
grades Grade[]
|
||||
exams Exam[]
|
||||
timeLayouts TimeLayout[]
|
||||
integrationDevices IntegrationDevice[]
|
||||
classIslandPushes ClassIslandPush[]
|
||||
@@ -465,11 +466,23 @@ model IntegrationDevice {
|
||||
/// 「从教室机器取一次课表」改成 Web 端的手动动作(点击前会弹警告),
|
||||
/// 因为自动回传会在老师手排课之后被教室的旧课表悄悄覆盖。
|
||||
syncScheduleToServer Boolean @default(false)
|
||||
/// 是否允许把本班课表镜像回 ClassIsland(由插件执行)。**默认开启** —— 这是现在的主方向。
|
||||
/// 是否允许把本班课表镜像回 ClassIsland(由插件执行)。
|
||||
///
|
||||
/// **2026-10-11 起含义收窄为"允许下发"**:不再是"自动同步的主方向" —— 插件以前每 15 秒
|
||||
/// 上报一次、每次上报完都顺手镜像一遍(`MirrorSchedule` 开着时),教室的课表被反复重写;
|
||||
/// 现在镜像只由 `requestMirrorSchedule` 这个一次性待办触发,这里只表示"这台设备接不接受下发"。
|
||||
mirrorScheduleToClassIsland Boolean @default(true)
|
||||
/// 待办的「立即上报一次课表」请求:Web 端点「从教室机器获取课表」时置位,
|
||||
/// 插件在下一次心跳看到它就把当前课表推上来并清除标记。
|
||||
requestScheduleReport Boolean @default(false)
|
||||
/// 待办的「立即下发一次课表」请求:Web 端点「下发课表到教室」时置位,
|
||||
/// 插件在下一次心跳看到它就去拉本班课表并**强制替换**写进 ClassIsland,
|
||||
/// 拉取成功即由服务端清除标记(见 `pullClassPlanForDevice`)。
|
||||
///
|
||||
/// 为什么要一次性待办而不是"开关":教室机常常离线,一个实时下发的长连接握手不值得,
|
||||
/// 而后台定时镜像会与老师在教室本地做的调整互相打架(2026-10-11 实测:
|
||||
/// 插件每 15 秒重写一遍课表,服务端那边看起来就是课表被反复删改)。
|
||||
requestMirrorSchedule Boolean @default(false)
|
||||
classIslandVersion String?
|
||||
pluginVersion String?
|
||||
/// 最后一次**带运行状态**的上报时间
|
||||
@@ -696,3 +709,27 @@ model AiAudit {
|
||||
|
||||
@@index([userId, createdAt])
|
||||
}
|
||||
|
||||
/// 一次考试(月考 / 期中考…)在本班的设置。
|
||||
///
|
||||
/// 需求:「单次考试在导入成绩时可选是否将所有科目相加以总分排名,客户端也能看到总分排名」。
|
||||
/// `Grade.examName` 是自由文本,服务端光看它不知道这次考试算不算总分,而客户端要显示总分榜
|
||||
/// 就必须知道 —— 于是按「班级 + 考试名」记一条。
|
||||
///
|
||||
/// 与 `Grade` 是**弱关联**(两边只按名字匹配,不建外键):成绩删光也不会连带删掉设置,
|
||||
/// 补录同一场考试时设置还在;反过来留一条没有成绩的设置是无害的。
|
||||
model Exam {
|
||||
id String @id @default(cuid())
|
||||
classId String
|
||||
/// 考试名,与 `Grade.examName` 同口径(如「第一次月考」)
|
||||
name String
|
||||
/// 是否把所有科目(本次考试已录入的)相加做总分排名
|
||||
countInTotal Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([classId, name])
|
||||
@@index([classId])
|
||||
}
|
||||
@@ -19,6 +19,15 @@ export const SETTING_KEYS = {
|
||||
aiApiKey: 'ai.apiKey',
|
||||
/** 追加到系统提示词末尾的学校自定义要求(可空) */
|
||||
aiSystemPromptExtra: 'ai.systemPromptExtra',
|
||||
/**
|
||||
* 全校学期开始日期(`YYYY-MM-DD`,本地日期)。
|
||||
*
|
||||
* 需求:「服务端可设置学期开始日期,以保证当前周次正确,管理员也可以随时纠正」。
|
||||
* 原先它只能来自 `.env` 的 `TERM_START_DATE`(改一次要动文件 + 重启),
|
||||
* 于是"开学日期填错了"这种事只能靠运维;现在管理员在「学期周次」页直接改,立即生效。
|
||||
* 班级自己的 `Class.termStartDate` 仍然优先(见 `lib/term.ts` 的三级回落)。
|
||||
*/
|
||||
termStartDate: 'term.startDate',
|
||||
} as const;
|
||||
|
||||
export type SettingKey = (typeof SETTING_KEYS)[keyof typeof SETTING_KEYS];
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from '@classhelper/shared';
|
||||
import { env } from '../config/env.js';
|
||||
import { prisma } from './db.js';
|
||||
import { SETTING_KEYS, getSetting } from './settings.js';
|
||||
|
||||
export { MAX_TERM_WEEK };
|
||||
|
||||
@@ -40,21 +41,30 @@ export function resolveCurrentWeek(now: Date = new Date(), context?: TermWeekCon
|
||||
* 取出某个班的周次口径。
|
||||
*
|
||||
* 优先级:班级自己的逐周区间 → 全校默认的逐周区间 → 纯线性推算。
|
||||
* 开学日期同理:`Class.termStartDate` 优先,没配就用全局 `TERM_START_DATE`。
|
||||
* 开学日期同理:`Class.termStartDate` → 全校设置(`term.startDate`,管理员在「学期周次」页改)
|
||||
* → `.env` 的 `TERM_START_DATE` 兜底。中间那一级是 2026-10-11 加的:原先只有 `.env`,
|
||||
* 管理员改一次开学日期得动文件 + 重启,填错了只能靠运维。
|
||||
*
|
||||
* @param classId 不传/空串表示**只看全校默认**(管理员在「学期周次」页编辑默认口径时就是这种调用)
|
||||
*/
|
||||
export async function loadTermContext(classId?: string | null): Promise<TermWeekContext> {
|
||||
const globalWeeks = await prisma.termWeek.findMany({
|
||||
where: { classId: '' },
|
||||
orderBy: { weekNumber: 'asc' },
|
||||
select: { weekNumber: true, startDate: true, endDate: true },
|
||||
});
|
||||
const [globalWeeks, settingStart] = await Promise.all([
|
||||
prisma.termWeek.findMany({
|
||||
where: { classId: '' },
|
||||
orderBy: { weekNumber: 'asc' },
|
||||
select: { weekNumber: true, startDate: true, endDate: true },
|
||||
}),
|
||||
getSetting(SETTING_KEYS.termStartDate),
|
||||
]);
|
||||
if (!classId) {
|
||||
// 全校默认没有"班级记录"可以存开学日期,因此**第 1 周的起始日就是它**
|
||||
// 全校默认没有"班级记录"可以存开学日期,因此按「显式设置 → 第 1 周的起始日 → .env」回落
|
||||
// (管理员点「按开学日期生成」时,第 1 周就是从那天开始的)
|
||||
const firstGlobal = globalWeeks.find((item) => item.weekNumber === 1)?.startDate;
|
||||
return { termStartDate: firstGlobal || env.termStartDate, weeks: globalWeeks, maxWeek: MAX_TERM_WEEK };
|
||||
return {
|
||||
termStartDate: settingStart || firstGlobal || env.termStartDate,
|
||||
weeks: globalWeeks,
|
||||
maxWeek: MAX_TERM_WEEK,
|
||||
};
|
||||
}
|
||||
|
||||
const [cls, classWeeks] = await Promise.all([
|
||||
@@ -73,7 +83,7 @@ export async function loadTermContext(classId?: string | null): Promise<TermWeek
|
||||
// 班级的逐周区间若覆盖了第 1 周,开学日期就以它为准(管理员改周次时最直观)
|
||||
const firstWeekStart = weeks.find((item) => item.weekNumber === 1)?.startDate;
|
||||
return {
|
||||
termStartDate: cls?.termStartDate || firstWeekStart || env.termStartDate,
|
||||
termStartDate: cls?.termStartDate || firstWeekStart || settingStart || env.termStartDate,
|
||||
weeks,
|
||||
maxWeek: cls?.termWeeks && cls.termWeeks > 0 ? cls.termWeeks : MAX_TERM_WEEK,
|
||||
};
|
||||
|
||||
@@ -592,10 +592,13 @@ export async function getStudentGradeQuery(
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置「是否允许 ClassHelper 班级端按学号查询本班学生成绩明细」。
|
||||
* 设置「是否允许 ClassHelper 班级端查看本班成绩与排名」。
|
||||
*
|
||||
* 与通知显示位置同一套模式:**班级端自己改**(教室里老师最清楚要不要给学生看),
|
||||
* 教师 / 管理员在 Web 端也能改。关闭后班级端查成绩一律 403,教师端不受影响。
|
||||
* **只有教师 / 管理员能改**(需求:「成绩查询由服务端控制,客户端只读」)。
|
||||
* 原先班级端自己也能改这个开关 —— 教室机器能给自己开成绩查询,等于把
|
||||
* "学校说不给看"变成了一台机器说了算;而与它同一套模式的「通知显示位置」
|
||||
* (`Class.notificationChannel`)**仍然由班级端自己改**,那个确实是现场偏好,
|
||||
* 两者别再按"同一个模式"去理解。关闭后班级端查成绩一律 403,教师端不受影响。
|
||||
*/
|
||||
export async function updateStudentGradeQuery(
|
||||
user: TokenPayload,
|
||||
@@ -603,8 +606,8 @@ export async function updateStudentGradeQuery(
|
||||
input: UpdateStudentGradeQueryInput,
|
||||
): Promise<{ studentGradeQueryEnabled: boolean }> {
|
||||
await assertClassAccess(user, classId);
|
||||
if (!isClassDevice(user) && !isStaff(user)) {
|
||||
throw ApiError.forbidden('只有 ClassHelper 班级端或教师/管理员可以修改成绩查询开关');
|
||||
if (!isStaff(user)) {
|
||||
throw ApiError.forbidden('成绩查询开关由教师/管理员在 Web 端设置,班级端只读');
|
||||
}
|
||||
|
||||
await prisma.class.update({
|
||||
|
||||
@@ -7,7 +7,10 @@ import { defineModule } from '../module.types.js';
|
||||
import {
|
||||
bulkCreateGradeSchema,
|
||||
createGradeSchema,
|
||||
examListQuerySchema,
|
||||
examRankingQuerySchema,
|
||||
listGradesQuerySchema,
|
||||
setExamSchema,
|
||||
statsQuerySchema,
|
||||
studentGradeParamSchema,
|
||||
studentGradeQuerySchema,
|
||||
@@ -65,6 +68,54 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/grades/exams?classId= - 某班出现过的考试(含"是否计入总分")
|
||||
*
|
||||
* **刻意不挂权限键**:ClassHelper 班级端要用它列出可选考试(班级端的有效权限是空集,
|
||||
* 挂 `grade:read` 会把教室机器挡在门外)。边界在 service 里:`assertClassAccess` + 班级开关。
|
||||
*/
|
||||
router.get(
|
||||
'/exams',
|
||||
validate({ query: examListQuerySchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const { classId } = validatedQuery<{ classId: string }>(req);
|
||||
sendOk(res, await gradeService.listExams(user, classId), '获取考试列表成功');
|
||||
},
|
||||
);
|
||||
|
||||
/** PUT /api/grades/exams - 设置"这次考试是否把所有科目相加做总分排名"(教师/管理员) */
|
||||
router.put(
|
||||
'/exams',
|
||||
requirePermission('grade:write'),
|
||||
validate({ body: setExamSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const body = req.body as { classId: string; name: string; countInTotal: boolean };
|
||||
sendOk(
|
||||
res,
|
||||
await gradeService.setExamCountInTotal(user, body.classId, body.name, body.countInTotal),
|
||||
body.countInTotal ? '已设为:这次考试按总分排名' : '已设为:这次考试不排总分',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/grades/ranking?classId=&examName= - 一次考试的单科榜与总分榜
|
||||
*
|
||||
* 同样不挂权限键(班级端要看),边界在 service 里 —— 与按学号查明细共用
|
||||
* `canQueryStudentGrades`:班级端受按班开关控制,教师端不受影响。
|
||||
*/
|
||||
router.get(
|
||||
'/ranking',
|
||||
validate({ query: examRankingQuerySchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const { classId, examName } = validatedQuery<{ classId: string; examName: string }>(req);
|
||||
sendOk(res, await gradeService.getExamRanking(user, classId, examName), '获取考试排名成功');
|
||||
},
|
||||
);
|
||||
|
||||
/** GET /api/grades?classId=&courseId=&studentId=&examName= - 成绩列表(教师) */
|
||||
router.get(
|
||||
'/',
|
||||
|
||||
@@ -98,3 +98,23 @@ export type CreateGradeInput = z.infer<typeof createGradeSchema>;
|
||||
export type UpdateGradeInput = z.infer<typeof updateGradeSchema>;
|
||||
export type BulkCreateGradeInput = z.infer<typeof bulkCreateGradeSchema>;
|
||||
export type UpdateGradeLevelsInput = z.infer<typeof updateGradeLevelsSchema>;
|
||||
|
||||
/* ---------------------------------------------------------------- 考试与排名 */
|
||||
|
||||
/** GET /api/grades/exams?classId= */
|
||||
export const examListQuerySchema = z.object({
|
||||
classId: z.string().min(1),
|
||||
});
|
||||
|
||||
/** PUT /api/grades/exams —— 设置"这次考试是否把所有科目相加做总分排名" */
|
||||
export const setExamSchema = z.object({
|
||||
classId: z.string().min(1),
|
||||
name: z.string().trim().min(1, '考试名不能为空').max(60, '考试名最多 60 个字'),
|
||||
countInTotal: z.boolean(),
|
||||
});
|
||||
|
||||
/** GET /api/grades/ranking?classId=&examName= */
|
||||
export const examRankingQuerySchema = z.object({
|
||||
classId: z.string().min(1),
|
||||
examName: z.string().trim().min(1, '请指定考试名').max(60),
|
||||
});
|
||||
@@ -4,6 +4,9 @@ import {
|
||||
gradeLevel,
|
||||
gradePercent,
|
||||
normalizeStudentNo,
|
||||
type ExamDto,
|
||||
type ExamRankingDto,
|
||||
type ExamSubjectRanking,
|
||||
type GradeDto,
|
||||
type GradeLevelType,
|
||||
type GradeStats,
|
||||
@@ -14,12 +17,14 @@ import {
|
||||
assertClassAccess,
|
||||
classScopeWhere,
|
||||
isClassDevice,
|
||||
isStaff,
|
||||
resolveClassScope,
|
||||
resolveUserClassRole,
|
||||
} from '../../lib/access.js';
|
||||
import { prisma } from '../../lib/db.js';
|
||||
import { ApiError } from '../../lib/http.js';
|
||||
import type { TokenPayload } from '../../lib/jwt.js';
|
||||
import { logger } from '../../lib/logger.js';
|
||||
import { toGradeDto, toStudentBrief } from '../../lib/mappers.js';
|
||||
import { parseOptionalDate } from '../../lib/schemas.js';
|
||||
import { resolvePersonalIds } from '../../lib/session.js';
|
||||
@@ -473,3 +478,190 @@ async function assertCourseInClass(courseId: string, classId: string): Promise<v
|
||||
if (!course) throw ApiError.badRequest('课程不存在');
|
||||
if (course.classId !== classId) throw ApiError.badRequest('该课程不属于所选班级');
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 考试与排名 */
|
||||
|
||||
/**
|
||||
* 列出某班出现过的考试名(`Grade.examName` 的去重),并带上"是否计入总分"。
|
||||
*
|
||||
* 以**成绩行**为准而不是以 `Exam` 表为准:老师可能先导了成绩才开始管口径,
|
||||
* 只列 `Exam` 会让刚导进去的那次考试在界面上根本找不到。
|
||||
*/
|
||||
export async function listExams(user: TokenPayload, classId: string): Promise<ExamDto[]> {
|
||||
await assertClassAccess(user, classId);
|
||||
const [groups, exams] = await Promise.all([
|
||||
prisma.grade.groupBy({ by: ['examName'], where: { classId }, _count: { _all: true } }),
|
||||
prisma.exam.findMany({
|
||||
where: { classId },
|
||||
select: { id: true, name: true, countInTotal: true },
|
||||
}),
|
||||
]);
|
||||
const byName = new Map(exams.map((item) => [item.name, item]));
|
||||
return groups
|
||||
.map((group) => {
|
||||
const row = byName.get(group.examName);
|
||||
return {
|
||||
id: row?.id ?? '',
|
||||
classId,
|
||||
name: group.examName,
|
||||
countInTotal: row?.countInTotal ?? false,
|
||||
gradeCount: group._count._all,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name, 'zh-Hans-CN'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置「这次考试要不要把所有科目相加做总分排名」。仅教师 / 管理员。
|
||||
*
|
||||
* 为什么要单独一个开关:语数外的 150 分和音体美的 100 分不是一个量纲,
|
||||
* "这次考试要不要合总分"是学校的口径,程序不能替它默认决定 ——
|
||||
* 而客户端要显示总分榜就必须知道,所以按「班级 + 考试名」存一条(`Exam` 表)。
|
||||
*/
|
||||
export async function setExamCountInTotal(
|
||||
user: TokenPayload,
|
||||
classId: string,
|
||||
name: string,
|
||||
countInTotal: boolean,
|
||||
): Promise<ExamDto> {
|
||||
await assertClassAccess(user, classId);
|
||||
if (!isStaff(user)) throw ApiError.forbidden('只有教师/管理员可以设置考试口径');
|
||||
|
||||
const row = await prisma.exam.upsert({
|
||||
where: { classId_name: { classId, name } },
|
||||
create: { classId, name, countInTotal },
|
||||
update: { countInTotal },
|
||||
});
|
||||
const gradeCount = await prisma.grade.count({ where: { classId, examName: name } });
|
||||
logger.info(`考试口径:班级=${classId} 考试=${name} 计入总分=${countInTotal} 由 ${user.name} 设置`);
|
||||
return { id: row.id, classId, name, countInTotal, gradeCount };
|
||||
}
|
||||
|
||||
/** 并列名次:分数相同的取同一名次,下一个名次跳过(1、2、2、4) */
|
||||
function assignRanks<T extends { score: number }>(
|
||||
rows: T[],
|
||||
rankOf: (row: T) => number,
|
||||
): Array<T & { rank: number }> {
|
||||
const sorted = [...rows].sort((a, b) => rankOf(b) - rankOf(a));
|
||||
let lastScore: number | null = null;
|
||||
let lastRank = 0;
|
||||
return sorted.map((row, index) => {
|
||||
const score = rankOf(row);
|
||||
const rank = lastScore !== null && score === lastScore ? lastRank : index + 1;
|
||||
lastScore = score;
|
||||
lastRank = rank;
|
||||
return { ...row, rank };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次考试的排名(单科 + 总分)。
|
||||
*
|
||||
* 单科榜按 `courseId` 分组;成绩行里 `courseId` 可能为 null(没挂科目的成绩),
|
||||
* 那种归到「未关联课程」一栏,不并进任何科目。
|
||||
*
|
||||
* 总分榜只在 `Exam.countInTotal` 为 true 时才算 —— 见 `setExamCountInTotal` 的说明。
|
||||
* 合计时**按科目去重**:同一个学生同一个科目在一次考试里有多行(老师补录过)时只取最高分,
|
||||
* 否则"补录一条"会变成"总分多一科",排名直接失真。
|
||||
*
|
||||
* 班级端受 `Class.studentGradeQueryEnabled` 控制(与按学号查明细同一条边界)。
|
||||
*/
|
||||
export async function getExamRanking(
|
||||
user: TokenPayload,
|
||||
classId: string,
|
||||
examName: string,
|
||||
): Promise<ExamRankingDto> {
|
||||
await assertClassAccess(user, classId);
|
||||
const classRecord = await prisma.class.findUnique({
|
||||
where: { id: classId },
|
||||
select: { name: true, studentGradeQueryEnabled: true },
|
||||
});
|
||||
if (!classRecord) throw ApiError.notFound('班级不存在');
|
||||
|
||||
const classRole = await resolveUserClassRole(user, classId);
|
||||
const allowed = canQueryStudentGrades({
|
||||
classRole,
|
||||
// 老库补列时的默认值是 true(开箱可用),所以 null/undefined 都按开启处理
|
||||
gradeQueryEnabled: classRecord.studentGradeQueryEnabled ?? true,
|
||||
});
|
||||
if (!allowed) throw ApiError.forbidden('本班未开放成绩查询,请联系老师开启');
|
||||
|
||||
const [grades, exam] = await Promise.all([
|
||||
prisma.grade.findMany({
|
||||
where: { classId, examName },
|
||||
include,
|
||||
orderBy: [{ publishedAt: 'desc' }],
|
||||
}),
|
||||
prisma.exam.findUnique({
|
||||
where: { classId_name: { classId, name: examName } },
|
||||
select: { countInTotal: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
// 同一个学生同一科目只留最高分(补录 / 重考的场景)
|
||||
const best = new Map<string, GradeDto>();
|
||||
for (const grade of grades.map(toGradeDto)) {
|
||||
const key = `${grade.studentId}|${grade.courseId ?? ''}`;
|
||||
const current = best.get(key);
|
||||
if (!current || grade.score > current.score) best.set(key, grade);
|
||||
}
|
||||
const unique = [...best.values()];
|
||||
|
||||
const byCourse = new Map<string, { courseId: string | null; courseName: string; rows: GradeDto[] }>();
|
||||
for (const grade of unique) {
|
||||
const key = grade.courseId ?? '__none__';
|
||||
const entry = byCourse.get(key) ?? {
|
||||
courseId: grade.courseId,
|
||||
courseName: grade.course?.name ?? '未关联课程',
|
||||
rows: [],
|
||||
};
|
||||
entry.rows.push(grade);
|
||||
byCourse.set(key, entry);
|
||||
}
|
||||
|
||||
const subjects: ExamSubjectRanking[] = [...byCourse.values()]
|
||||
.map((entry) => ({
|
||||
courseId: entry.courseId,
|
||||
courseName: entry.courseName,
|
||||
rows: assignRanks(entry.rows, (row) => gradePercent(row.score, row.totalScore)).map((row) => ({
|
||||
studentId: row.studentId,
|
||||
studentNo: row.student?.studentNo ?? '',
|
||||
studentName: row.student?.name ?? '',
|
||||
score: row.score,
|
||||
totalScore: row.totalScore,
|
||||
rank: row.rank,
|
||||
})),
|
||||
}))
|
||||
.sort((a, b) => a.courseName.localeCompare(b.courseName, 'zh-Hans-CN'));
|
||||
|
||||
const countInTotal = exam?.countInTotal === true;
|
||||
let total: ExamRankingDto['total'] = [];
|
||||
if (countInTotal) {
|
||||
const sum = new Map<
|
||||
string,
|
||||
{ studentId: string; studentNo: string; studentName: string; score: number; subjectCount: number }
|
||||
>();
|
||||
for (const grade of unique) {
|
||||
const entry = sum.get(grade.studentId) ?? {
|
||||
studentId: grade.studentId,
|
||||
studentNo: grade.student?.studentNo ?? '',
|
||||
studentName: grade.student?.name ?? '',
|
||||
score: 0,
|
||||
subjectCount: 0,
|
||||
};
|
||||
entry.score += grade.score;
|
||||
entry.subjectCount += 1;
|
||||
sum.set(grade.studentId, entry);
|
||||
}
|
||||
total = assignRanks([...sum.values()], (row) => row.score);
|
||||
}
|
||||
|
||||
return {
|
||||
classId,
|
||||
className: classRecord.name,
|
||||
examName,
|
||||
countInTotal,
|
||||
subjects,
|
||||
total,
|
||||
};
|
||||
}
|
||||
@@ -42,6 +42,8 @@ export interface DeviceContext {
|
||||
enabled: boolean;
|
||||
syncScheduleToServer: boolean;
|
||||
mirrorScheduleToClassIsland: boolean;
|
||||
/** 是否有一次待办的「下发课表到教室」(Web 端点按钮时置位,拉取成功即清除) */
|
||||
mirrorRequested: boolean;
|
||||
}
|
||||
|
||||
/** 插件侧路由统一挂这个中间件:解析设备令牌并回查设备记录 */
|
||||
@@ -61,6 +63,7 @@ export function authenticateDevice(): RequestHandler {
|
||||
enabled: true,
|
||||
syncScheduleToServer: true,
|
||||
mirrorScheduleToClassIsland: true,
|
||||
requestMirrorSchedule: true,
|
||||
},
|
||||
});
|
||||
if (!record) throw ApiError.unauthorized('设备令牌无效,请在「ClassIsland 联动」里重新生成');
|
||||
@@ -72,6 +75,7 @@ export function authenticateDevice(): RequestHandler {
|
||||
enabled: record.enabled,
|
||||
syncScheduleToServer: record.syncScheduleToServer,
|
||||
mirrorScheduleToClassIsland: record.mirrorScheduleToClassIsland,
|
||||
mirrorRequested: record.requestMirrorSchedule === true,
|
||||
};
|
||||
req.device = context;
|
||||
next();
|
||||
|
||||
@@ -183,8 +183,28 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
/** POST /api/integrations/devices/:id/token - 重置令牌(旧令牌立即失效) */
|
||||
/**
|
||||
* POST /api/integrations/devices/:id/mirror-schedule - 「下发一次课表到教室」
|
||||
*
|
||||
* 与「获取课表」反方向、同样是一次性动作。插件在下一次心跳看到待办就去拉本班课表,
|
||||
* 并**强制替换**写进 ClassIsland(覆盖老师手排的冲突课表)。
|
||||
*
|
||||
* 为什么要做成按钮而不是后台一直同步:插件原先每 15 秒上报一次、每次上报完还顺手镜像一遍,
|
||||
* 教室课表被反复重写,服务端也每 15 秒 merge + 清理一次幽灵行 —— 老师看到的是
|
||||
* "课表被反复删改"(2026-10-11 实测)。
|
||||
*/
|
||||
router.post(
|
||||
'/devices/:id/mirror-schedule',
|
||||
requirePermission('device:manage'),
|
||||
validate({ params: deviceIdParamSchema }),
|
||||
async (req, res) => {
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
const result = await integrationService.requestMirrorSchedule(getAuthUser(req), id);
|
||||
sendOk(res, result, result.nextHeartbeatHint);
|
||||
},
|
||||
);
|
||||
|
||||
/** POST /api/integrations/devices/:id/token - 重置令牌(旧令牌立即失效) */router.post(
|
||||
'/devices/:id/token',
|
||||
requirePermission('device:manage'),
|
||||
validate({ params: deviceIdParamSchema }),
|
||||
|
||||
@@ -238,10 +238,12 @@ async function findDeviceOrFail(id: string): Promise<{
|
||||
classId: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
/** 「下发课表到教室」要按它判断这台设备接不接受下发(见 requestMirrorSchedule) */
|
||||
mirrorScheduleToClassIsland: boolean;
|
||||
}> {
|
||||
const record = await prisma.integrationDevice.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, classId: true, name: true, enabled: true },
|
||||
select: { id: true, classId: true, name: true, enabled: true, mirrorScheduleToClassIsland: true },
|
||||
});
|
||||
if (!record) throw ApiError.notFound('设备不存在或已被删除');
|
||||
return record;
|
||||
@@ -305,10 +307,43 @@ export async function requestScheduleReport(
|
||||
};
|
||||
}
|
||||
|
||||
/** 重置令牌:旧令牌立即失效(插件侧需要重新填写) */
|
||||
export async function resetDeviceToken(
|
||||
/**
|
||||
* 「**下发一次课表到教室**」的待办(Web 端手动动作)。
|
||||
*
|
||||
* 与 `requestScheduleReport` 对称:服务端没有反向通道去催插件,所以只置一个标记,
|
||||
* 插件在下一次心跳看到 `settings.mirrorRequested = true` 就去拉本班课表、
|
||||
* **强制替换**写进 ClassIsland(见插件侧 `ClassPlanWriter` 的 force 语义)。
|
||||
*
|
||||
* 为什么要改成手动(2026-10-11):插件原先每 15 秒上报一次、每次上报完还顺手镜像一遍
|
||||
* (插件侧的 `MirrorSchedule` 开关),教室课表被反复重写、服务端也每 15 秒 merge + 清理
|
||||
* 一次幽灵行 —— 老师看到的是"课表被反复删改"。两个方向现在都只由点击触发。
|
||||
*/
|
||||
export async function requestMirrorSchedule(
|
||||
user: TokenPayload,
|
||||
deviceId: string,
|
||||
): Promise<{ requested: boolean; deviceName: string; nextHeartbeatHint: string }> {
|
||||
const current = await findDeviceOrFail(deviceId);
|
||||
await assertCanManageSchedule(user, current.classId);
|
||||
if (!current.enabled) throw ApiError.badRequest('该设备已停用,请先启用后再下发课表');
|
||||
if (!current.mirrorScheduleToClassIsland) {
|
||||
throw ApiError.badRequest('这台设备没有开启「允许下发课表」,请先在设备设置里打开');
|
||||
}
|
||||
|
||||
await prisma.integrationDevice.update({
|
||||
where: { id: deviceId },
|
||||
data: { requestMirrorSchedule: true },
|
||||
});
|
||||
logger.info(`已请求向设备下发课表:${current.name}(classId=${current.classId})由 ${user.name} 发起`);
|
||||
return {
|
||||
requested: true,
|
||||
deviceName: current.name,
|
||||
nextHeartbeatHint: '教室机器会在下一次心跳(约 1 分钟内)拉取并强制替换教室课表',
|
||||
};
|
||||
}
|
||||
|
||||
/** 重置令牌:旧令牌立即失效(插件侧需要重新填写) */
|
||||
export async function resetDeviceToken(
|
||||
user: TokenPayload, deviceId: string,
|
||||
): Promise<IntegrationDeviceTokenDto> {
|
||||
const current = await findDeviceOrFail(deviceId);
|
||||
await assertCanManageSchedule(user, current.classId);
|
||||
@@ -649,6 +684,9 @@ export async function applyReport(
|
||||
// 注意它**独立于** syncScheduleToServer —— 后者是"允许自动回传",现在默认关闭,
|
||||
// 但管理员显式点"获取课表"时仍然应当能取到。
|
||||
scheduleRequested: scheduleRequested,
|
||||
// 「下发一次课表到教室」的待办(反方向)。插件看到就去拉本班课表并强制替换,
|
||||
// 拉取成功时由 `pullClassPlanForDevice` 清除标记 —— 与上面那条对称。
|
||||
mirrorRequested: device.mirrorRequested,
|
||||
},
|
||||
pendingNotification: pending,
|
||||
};
|
||||
@@ -755,6 +793,17 @@ export async function pullClassPlanForDevice(device: DeviceContext): Promise<{
|
||||
const termContext = await loadTermContext(device.classId);
|
||||
const week = resolveCurrentWeek(now, termContext);
|
||||
|
||||
// 「下发一次课表」的待办到这里就算完成:插件已经来拉了,拿到就会写进 ClassIsland。
|
||||
// **必须清掉** —— 不清的话每次心跳都会重新下发一遍,又变回"一直下发",
|
||||
// 而这正是老师反映"课表被反复删改"的源头。
|
||||
if (device.mirrorRequested) {
|
||||
await prisma.integrationDevice.update({
|
||||
where: { id: device.deviceId },
|
||||
data: { requestMirrorSchedule: false },
|
||||
});
|
||||
logger.info(`课表下发待办已完成:${device.name}(classId=${device.classId})`);
|
||||
}
|
||||
|
||||
const classRecord = await prisma.class.findUnique({
|
||||
where: { id: device.classId },
|
||||
select: { id: true, name: true, termWeeks: true, termStartDate: true },
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ApiError } from '../../lib/http.js';
|
||||
import type { TokenPayload } from '../../lib/jwt.js';
|
||||
import { logger } from '../../lib/logger.js';
|
||||
import { MAX_TERM_WEEK, loadTermContext, resolveCurrentWeek } from '../../lib/term.js';
|
||||
import { SETTING_KEYS, setSetting } from '../../lib/settings.js';
|
||||
import { emitToClass } from '../../realtime/bus.js';
|
||||
import { SOCKET_EVENTS } from '@classhelper/shared';
|
||||
import { notifyScheduleChanged } from '../integrations/integrations.service.js';
|
||||
@@ -110,6 +111,13 @@ export async function updateTermWeeks(user: TokenPayload, input: UpdateTermWeeks
|
||||
: []),
|
||||
]);
|
||||
|
||||
// 全校默认没有"班级记录"可以挂开学日期,写进全局设置(`term.startDate`)。
|
||||
// 需求:「服务端可设置学期开始日期,以保证当前周次正确,管理员也可以随时纠正」——
|
||||
// 原先这一级只能来自 `.env`,改一次要动文件 + 重启。
|
||||
if (input.termStartDate && !scope) {
|
||||
await setSetting(SETTING_KEYS.termStartDate, input.termStartDate);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`学期周次已更新:范围=${scope || '全校默认'} 周数=${input.weeks.length} 由 ${user.name} 执行`,
|
||||
);
|
||||
|
||||
+10
-9
@@ -1,11 +1,13 @@
|
||||
import type { PermissionKey } from '@classhelper/shared';
|
||||
import type { TokenPayload } from '../lib/jwt.js';
|
||||
import type { DeviceContext } from '../modules/integrations/device-auth.js';
|
||||
|
||||
/**
|
||||
* 扩展 Express 的 Request 类型。
|
||||
* - auth:认证中间件解析出的当前用户 + 生效权限集合
|
||||
* - validatedQuery / validatedParams:校验中间件产出的类型安全数据
|
||||
* (Express 5 中 req.query 是只读 getter,不能直接覆盖,因此单独存放)
|
||||
* - device:ClassIsland 插件上报时解析出的设备上下文
|
||||
*/
|
||||
declare global {
|
||||
namespace Express {
|
||||
@@ -18,15 +20,14 @@ declare global {
|
||||
};
|
||||
validatedQuery?: unknown;
|
||||
validatedParams?: unknown;
|
||||
/** ClassIsland 联动插件上报时解析出的设备上下文(见 modules/integrations/device-auth.ts) */
|
||||
device?: {
|
||||
deviceId: string;
|
||||
classId: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
syncScheduleToServer: boolean;
|
||||
mirrorScheduleToClassIsland: boolean;
|
||||
};
|
||||
/**
|
||||
* ClassIsland 联动插件上报时解析出的设备上下文(见 modules/integrations/device-auth.ts)。
|
||||
*
|
||||
* **引用那个接口本身,不要在这里再抄一遍字段** —— 原先这里是手抄的结构,
|
||||
* 往 `DeviceContext` 加字段时两边就漂了,而报错会落在 `getDevice()` 的 `return device;`
|
||||
* 上("Property 'x' is missing … but required"),看不出真正原因是这处重复声明。
|
||||
*/
|
||||
device?: DeviceContext;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user