线上问题的根因 - 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**。
168 lines
7.4 KiB
TypeScript
168 lines
7.4 KiB
TypeScript
import {
|
||
API_PATHS,
|
||
type ClassDetailDto,
|
||
type ClassDto,
|
||
type ClassIslandNotificationChannel,
|
||
type CourseDto,
|
||
type ExamDto,
|
||
type ExamRankingDto,
|
||
type HomeworkDaysDto,
|
||
type DashboardSummary,
|
||
type GradeDto,
|
||
type HomeworkDto,
|
||
type HomeworkStatusDto,
|
||
type HomeworkSubmissionsDto,
|
||
type LoginRequest,
|
||
type LoginResponse,
|
||
type NotificationDto,
|
||
type ScheduleDto,
|
||
type ScheduleWeekView,
|
||
type SessionUser,
|
||
type StudentGradeDetailDto,
|
||
} from '@classhelper/shared';
|
||
import { api } from './http.js';
|
||
|
||
/** ClassHelper 班级端只需要这一批接口(与后端同一套 REST 契约) */
|
||
export const authApi = {
|
||
/** 账号登录(教师/管理员用;客户端界面走班级登录,学生个人账号已清理不能登录) */
|
||
login: (payload: LoginRequest): Promise<LoginResponse> => api.post(API_PATHS.auth.login, payload),
|
||
/** 班级账号登录(ClassHelper 班级端主入口):班级码 + 班级密码 */
|
||
classLogin: (payload: { code: string; password: string }): Promise<LoginResponse> =>
|
||
api.post('/auth/class-login', payload),
|
||
logout: (): Promise<{ loggedOut: boolean }> => api.post(API_PATHS.auth.logout, {}),
|
||
me: (): Promise<SessionUser> => api.get(API_PATHS.auth.me),
|
||
/**
|
||
* 修改密码(设置页「账号信息」里的入口)。
|
||
* 服务端同一个接口按会话类型分流:班级账号改的是**班级密码**,教师/管理员改的是本人登录密码 ——
|
||
* 因此这台机器无论以哪种身份登录,改密入口都走这一条。
|
||
*/
|
||
changePassword: (payload: {
|
||
currentPassword: string;
|
||
newPassword: string;
|
||
}): Promise<{ changed: boolean }> => api.patch(API_PATHS.auth.changePassword, payload),
|
||
};
|
||
|
||
export const classApi = {
|
||
list: (): Promise<ClassDto[]> => api.get(API_PATHS.classes),
|
||
detail: (id: string): Promise<ClassDetailDto> => api.get(`${API_PATHS.classes}/${id}`),
|
||
};
|
||
|
||
/**
|
||
* 通知显示位置(both / client / classisland)。
|
||
*
|
||
* 由**这台教室机器自己**决定"提醒弹在 ClassHelper 还是 ClassIsland":
|
||
* 本地存一份用于本机的弹窗/上岛判断,同时写回班级记录,服务端据此决定要不要推 ClassIsland。
|
||
*/
|
||
export const classChannelApi = {
|
||
get: (classId: string): Promise<{ notificationChannel: ClassIslandNotificationChannel }> =>
|
||
api.get(`${API_PATHS.classes}/${classId}/notification-channel`),
|
||
set: (
|
||
classId: string,
|
||
notificationChannel: ClassIslandNotificationChannel,
|
||
): Promise<{ notificationChannel: ClassIslandNotificationChannel }> =>
|
||
api.patch(`${API_PATHS.classes}/${classId}/notification-channel`, { notificationChannel }),
|
||
};
|
||
|
||
/** 本班 ClassIsland 联动状态(教室客户端「设置 → ClassIsland 联动」展示) */
|
||
export const classIslandStatusApi = {
|
||
get: (
|
||
classId: string,
|
||
): Promise<{
|
||
connected: boolean;
|
||
deviceName: string | null;
|
||
deviceCount: number;
|
||
lastSeenAt: string | null;
|
||
pluginVersion: string | null;
|
||
classIslandVersion: string | null;
|
||
}> => api.get(`${API_PATHS.classes}/${classId}/classisland-status`),
|
||
};
|
||
|
||
export const scheduleApi = {
|
||
list: (params: { classId?: string; week?: number }): Promise<ScheduleDto[]> =>
|
||
api.get(API_PATHS.schedules, params),
|
||
grid: (params: { classId?: string; week?: number }): Promise<ScheduleWeekView> =>
|
||
api.get(`${API_PATHS.schedules}/grid`, params),
|
||
};
|
||
|
||
export const courseApi = {
|
||
list: (classId?: string): Promise<CourseDto[]> =>
|
||
api.get(API_PATHS.courses, classId ? { classId } : undefined),
|
||
};
|
||
|
||
export const homeworkApi = {
|
||
list: (params?: {
|
||
classId?: string;
|
||
pendingOnly?: boolean;
|
||
courseId?: string;
|
||
/** 只看某一天(YYYY-MM-DD,按作业所属日期) */
|
||
date?: string;
|
||
}): Promise<HomeworkDto[]> => api.get(API_PATHS.homeworks, params),
|
||
/** 哪些天有作业(日期选择器高亮) */
|
||
days: (params: { classId?: string; from?: string; to?: string; days?: number }): Promise<HomeworkDaysDto> =>
|
||
api.get(`${API_PATHS.homeworks}/days`, params),
|
||
/** 在教室机器上直接录入作业(班级账号可调用;也可选所属日期) */
|
||
create: (payload: {
|
||
classId: string;
|
||
courseId?: string | null;
|
||
title: string;
|
||
content: string;
|
||
assignDate?: string | null;
|
||
}): Promise<HomeworkDto> => api.post(API_PATHS.homeworks, payload),
|
||
detail: (id: string): Promise<HomeworkDto> => api.get(`${API_PATHS.homeworks}/${id}`),
|
||
updateStatus: (id: string, completed: boolean): Promise<HomeworkStatusDto> =>
|
||
api.patch(`${API_PATHS.homeworks}/${id}/status`, { completed }),
|
||
/** 提交名单 / 未交名单(教师与班级设备可用) */
|
||
submissions: (id: string): Promise<HomeworkSubmissionsDto> =>
|
||
api.get(`${API_PATHS.homeworks}/${id}/submissions`),
|
||
saveSubmissions: (id: string, notSubmittedUserIds: string[]): Promise<HomeworkSubmissionsDto> =>
|
||
api.patch(`${API_PATHS.homeworks}/${id}/submissions`, { notSubmittedUserIds }),
|
||
};
|
||
|
||
export const notificationApi = {
|
||
list: (params?: { classId?: string; unreadOnly?: boolean }): Promise<NotificationDto[]> =>
|
||
api.get(API_PATHS.notifications, params),
|
||
unreadCount: (): Promise<{ count: number }> => api.get(`${API_PATHS.notifications}/unread-count`),
|
||
// id 来自服务端或灵动岛(作业卡是本地合成的 `homework-…`),一律 encodeURIComponent:
|
||
// 否则含 `/` 或 `..` 的 id 会改变实际请求的路径
|
||
markRead: (id: string): Promise<{ notificationId: string; readAt: string }> =>
|
||
api.post(`${API_PATHS.notifications}/${encodeURIComponent(id)}/read`, {}),
|
||
markAllRead: (): Promise<{ marked: number }> => api.post(`${API_PATHS.notifications}/read-all`, {}),
|
||
};
|
||
|
||
export const gradeApi = {
|
||
my: (): Promise<GradeDto[]> => api.get(`${API_PATHS.grades}/my`),
|
||
/**
|
||
* 按**学号**查某个学生的成绩明细。
|
||
* 学生没有账号,因此这是教室机器代学生查本人的通道;是否开放由班级上的
|
||
* `studentGradeQueryEnabled` 开关决定(关闭时服务端返回 403 并给出原因)。
|
||
*/
|
||
studentDetail: (studentNo: string): Promise<StudentGradeDetailDto> =>
|
||
api.get(`${API_PATHS.grades}/student/${encodeURIComponent(studentNo)}`),
|
||
/** 本班出现过的考试(含"这次考试算不算总分") */
|
||
exams: (classId: string): Promise<ExamDto[]> => api.get(`${API_PATHS.grades}/exams`, { classId }),
|
||
/**
|
||
* 一次考试的单科榜与总分榜。
|
||
* 总分榜只在老师把这次考试设为"所有科目相加排名"时才有内容;
|
||
* 整条通道与按学号查明细共用同一个按班开关(关闭时服务端 403)。
|
||
*/
|
||
ranking: (classId: string, examName: string): Promise<ExamRankingDto> =>
|
||
api.get(`${API_PATHS.grades}/ranking`, { classId, examName }),
|
||
};
|
||
|
||
/**
|
||
* ClassHelper 班级端设置:「是否允许按学号查询本班学生成绩明细」。
|
||
* 与通知显示位置同一套模式 —— 由教室机器自己决定,教师/管理员在 Web 端也能改。
|
||
*/
|
||
export const classGradeQueryApi = {
|
||
get: (classId: string): Promise<{ studentGradeQueryEnabled: boolean }> =>
|
||
api.get(`${API_PATHS.classes}/${classId}/student-grade-query`),
|
||
set: (classId: string, enabled: boolean): Promise<{ studentGradeQueryEnabled: boolean }> =>
|
||
api.patch(`${API_PATHS.classes}/${classId}/student-grade-query`, { enabled }),
|
||
};
|
||
|
||
export const dashboardApi = {
|
||
summary: (): Promise<DashboardSummary> => api.get(API_PATHS.dashboard),
|
||
term: (classId?: string): Promise<{ currentWeek: number; maxWeek: number }> =>
|
||
api.get('/dashboard/term', classId ? { classId } : undefined),
|
||
};
|