拆分能力、分配范围和业务关系,固定管理员能力并收紧组织级写入口。 补齐内容、课表、设备会话和实时通道授权,兼容旧库分配及历史快照。 重写权限分配预览,按实际可操作范围展示维护入口,合并学生入口并独立学年升级页。 增加授权攻击矩阵及静态门禁,更新隔离回归与开发契约。 验证:授权矩阵 23 段通过,Node 24 下 Web 63/63,类型检查、lint 与文档严格构建通过。
206 lines
9.8 KiB
JavaScript
206 lines
9.8 KiB
JavaScript
/** 只创建独立临时班级;验证任课教师的只读详情不能显示班主任维护入口。 */
|
|
async function checkClassRelations(win, options, record) {
|
|
const base = `${new URL(options.url).origin}/api`;
|
|
async function request(route, token, method = 'GET', body) {
|
|
const response = await fetch(`${base}${route}`, {
|
|
method,
|
|
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
|
...(body ? { body: JSON.stringify(body) } : {}),
|
|
});
|
|
const result = await response.json();
|
|
return { status: response.status, data: result.data, message: result.message };
|
|
}
|
|
async function data(route, token, method, body) {
|
|
const result = await request(route, token, method, body);
|
|
if (result.status >= 400) throw new Error(`${route}: ${result.status} ${result.message}`);
|
|
return result.data;
|
|
}
|
|
async function wait(expression) {
|
|
const end = Date.now() + 8000;
|
|
while (Date.now() < end) {
|
|
if (await win.webContents.executeJavaScript(expression)) return;
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
}
|
|
throw new Error('班级详情未就绪');
|
|
}
|
|
const teacher = await data('/auth/login', null, 'POST', {
|
|
username: options.username,
|
|
password: options.password,
|
|
});
|
|
const admin = await data('/auth/login', null, 'POST', {
|
|
username: options.adminUsername,
|
|
password: options.adminPassword,
|
|
});
|
|
let other, student;
|
|
try {
|
|
other = await data('/classes', admin.token, 'POST', {
|
|
grade: '验证',
|
|
enrollmentYear: 2098,
|
|
classIndex: 1 + Math.floor(Math.random() * 99),
|
|
teacherId: admin.user.id,
|
|
code: `REL${Date.now().toString(36).toUpperCase()}`,
|
|
});
|
|
await data(`/classes/${other.id}/subject-teachers`, admin.token, 'PUT', {
|
|
assignments: [{ subjectName: '数学', teacherId: teacher.user.id }],
|
|
});
|
|
student = await data('/students', admin.token, 'POST', {
|
|
classId: other.id,
|
|
studentNo: `REL${Date.now()}`,
|
|
name: '独立关系验证学生',
|
|
});
|
|
const detail = await data(`/classes/${other.id}`, teacher.token);
|
|
const statuses = await Promise.all([
|
|
request(`/students/${student.id}`, teacher.token, 'PATCH', { name: student.name }),
|
|
request(`/classes/${other.id}/settings`, teacher.token, 'PATCH', { termWeeks: detail.termWeeks }),
|
|
request(`/classes/${other.id}/student-grade-query`, teacher.token, 'PATCH', {
|
|
enabled: detail.studentGradeQueryEnabled,
|
|
}),
|
|
request(`/courses/${detail.courses[0].id}`, teacher.token, 'DELETE'),
|
|
]);
|
|
record(
|
|
'任课教师跨班修改学生、学期设置、成绩开关及删除课程均返回 403',
|
|
statuses.every((result) => result.status === 403),
|
|
statuses.map((result) => result.status).join('/'),
|
|
);
|
|
|
|
await win.loadURL(`${new URL(options.url).origin}/classes`);
|
|
await wait(
|
|
`Array.from(document.querySelectorAll('.el-table__row')).some(row => row.textContent.includes(${JSON.stringify(other.name)}))`,
|
|
);
|
|
await win.webContents.executeJavaScript(
|
|
`Array.from(document.querySelectorAll('.el-table__row')).find(row => row.textContent.includes(${JSON.stringify(other.name)})).querySelector('button').click()`,
|
|
);
|
|
await wait(
|
|
`Boolean(document.querySelector('.el-drawer .el-tabs')) && !document.querySelector('.el-drawer .el-loading-mask')`,
|
|
);
|
|
const peer = await win.webContents.executeJavaScript(`(() => {
|
|
const drawer = document.querySelector('.el-drawer');
|
|
const pane = name => drawer.querySelector('#pane-' + name);
|
|
const writes = Array.from(pane('students').querySelectorAll('button')).filter(button => ['添加学生', '移出'].includes(button.textContent.trim()));
|
|
return { studentsVisible: pane('students').textContent.includes('独立关系验证学生'), studentWrites: writes.length, courseDeletes: Array.from(pane('courses').querySelectorAll('button')).filter(button => button.textContent.trim() === '删除').length, courseCreate: Boolean(pane('courses').querySelector('input')), gradeQueryDisabled: pane('settings').querySelector('.el-switch')?.classList.contains('is-disabled') };
|
|
})()`);
|
|
record(
|
|
'任课教师可查看别班详情,名单/课程写入口隐藏且成绩开关只读',
|
|
peer.studentsVisible &&
|
|
peer.studentWrites === 0 &&
|
|
peer.courseDeletes === 0 &&
|
|
!peer.courseCreate &&
|
|
peer.gradeQueryDisabled,
|
|
JSON.stringify(peer),
|
|
);
|
|
|
|
const classes = await data('/classes', teacher.token);
|
|
const own = classes.find((row) => row.teacher?.id === teacher.user.id);
|
|
if (!own) throw new Error('缺少独立的班主任班级');
|
|
await win.loadURL(`${new URL(options.url).origin}/classes`);
|
|
await wait(
|
|
`Array.from(document.querySelectorAll('.el-table__row')).some(row => row.textContent.includes(${JSON.stringify(own.name)}))`,
|
|
);
|
|
await win.webContents.executeJavaScript(
|
|
`Array.from(document.querySelectorAll('.el-table__row')).find(row => row.textContent.includes(${JSON.stringify(own.name)})).querySelector('button').click()`,
|
|
);
|
|
await wait(
|
|
`Boolean(document.querySelector('.el-drawer .el-tabs')) && !document.querySelector('.el-drawer .el-loading-mask')`,
|
|
);
|
|
const owner = await win.webContents.executeJavaScript(`(() => {
|
|
const drawer = document.querySelector('.el-drawer');
|
|
return { studentWrite: Array.from(drawer.querySelectorAll('#pane-students button')).some(button => button.textContent.trim() === '添加学生'), courseCreate: Boolean(drawer.querySelector('#pane-courses input')), gradeQueryEnabled: !drawer.querySelector('#pane-settings .el-switch')?.classList.contains('is-disabled') };
|
|
})()`);
|
|
record(
|
|
'班主任本人班级仍可维护名单、课程和成绩查询开关',
|
|
owner.studentWrite && owner.courseCreate && owner.gradeQueryEnabled,
|
|
JSON.stringify(owner),
|
|
);
|
|
const members = await data('/permissions/users', admin.token);
|
|
const previous = members.find((member) => member.id === teacher.user.id).assignments;
|
|
const groups = [];
|
|
try {
|
|
const allClasses = await data('/classes', admin.token);
|
|
const unrelatedGrade = allClasses.find(
|
|
(row) => row.grade !== own.grade && row.teacher?.id !== teacher.user.id,
|
|
)?.grade;
|
|
if (!unrelatedGrade) throw new Error('缺少不担任班主任的隔离年级');
|
|
const reads = await data('/permissions/groups', admin.token, 'POST', {
|
|
name: `菜单只读${Date.now()}`,
|
|
scope: 'ALL',
|
|
keys: [
|
|
'dashboard:read',
|
|
'class:read',
|
|
'roster:read',
|
|
'schedule:read',
|
|
'term:read',
|
|
'homework:read',
|
|
'notification:read',
|
|
'grade:read',
|
|
'courseware:read',
|
|
'client:read',
|
|
],
|
|
});
|
|
groups.push(reads.id);
|
|
const limited = await data('/permissions/groups', admin.token, 'POST', {
|
|
name: `菜单空关系${Date.now()}`,
|
|
scope: 'GRADE',
|
|
scopeValue: unrelatedGrade,
|
|
keys: ['class:settings'],
|
|
});
|
|
groups.push(limited.id);
|
|
await data(`/permissions/users/${teacher.user.id}/groups`, admin.token, 'PUT', {
|
|
assignments: [
|
|
{ groupId: reads.id, scope: 'ALL', scopeValue: '' },
|
|
{ groupId: limited.id, scope: 'GRADE', scopeValue: unrelatedGrade },
|
|
],
|
|
});
|
|
const restricted = await data('/permissions/me', teacher.token);
|
|
await win.loadURL(`${new URL(options.url).origin}/dashboard`);
|
|
await wait(
|
|
`Boolean(document.querySelector('.page-title')) && document.querySelectorAll('.el-menu-item').length > 0`,
|
|
);
|
|
const labels = await win.webContents.executeJavaScript(
|
|
`Array.from(document.querySelectorAll('.el-menu-item')).map(node => node.textContent.trim())`,
|
|
);
|
|
const hidden = [
|
|
'班级管理',
|
|
'学生管理',
|
|
'课表档案',
|
|
'学期周次',
|
|
'作业发布',
|
|
'通知发布',
|
|
'成绩录入',
|
|
'课件',
|
|
'客户端',
|
|
];
|
|
record(
|
|
'只有读权限或写键无可操作班级时隐藏维护页面',
|
|
restricted.permissions.includes('class:settings') &&
|
|
restricted.actionScopes['class:settings'].length === 0 &&
|
|
hidden.every((label) => !labels.includes(label)),
|
|
labels.join(','),
|
|
);
|
|
} finally {
|
|
await data(`/permissions/users/${teacher.user.id}/groups`, admin.token, 'PUT', {
|
|
assignments: previous.map(({ groupId, scope, scopeValue }) => ({ groupId, scope, scopeValue })),
|
|
});
|
|
for (const id of groups) await data(`/permissions/groups/${id}`, admin.token, 'DELETE');
|
|
}
|
|
await win.loadURL(`${new URL(options.url).origin}/term-weeks`);
|
|
await wait(
|
|
`document.querySelector('.page-title')?.textContent.trim() === '学期周次' && document.querySelector('.page').textContent.includes('当前第') && !document.querySelector('.el-loading-mask')`,
|
|
);
|
|
const partial = await win.webContents.executeJavaScript(`(() => ({
|
|
menu: Array.from(document.querySelectorAll('.el-menu-item')).some(node => node.textContent.trim() === '学期周次'),
|
|
own: document.querySelector('.page').textContent.includes(${JSON.stringify(own.name)}),
|
|
editable: Array.from(document.querySelectorAll('.page button')).some(node => node.textContent.trim() === '保存'),
|
|
dateEnabled: !document.querySelector('.page .el-date-editor input')?.disabled,
|
|
}))()`);
|
|
record(
|
|
'部分班级可维护时保留周次入口,进入后默认选中本人可编辑班级',
|
|
partial.menu && partial.own && partial.editable && partial.dateEnabled,
|
|
JSON.stringify(partial),
|
|
);
|
|
} finally {
|
|
if (other && student) await data(`/classes/${other.id}/students/${student.id}`, admin.token, 'DELETE');
|
|
if (other) await data(`/classes/${other.id}`, admin.token, 'DELETE');
|
|
}
|
|
}
|
|
module.exports = { checkClassRelations };
|