feat(rbac): 落地角色权限模型(需求 7)——班级/人员/名单/成绩收紧到管理员,班主任管本班课表,科任仅作业·叫人·通知
## 权限矩阵(前后端共用一份) - 新增 `packages/shared/src/permissions.ts`:`ClassRole`(ADMIN/HEAD/SUBJECT/STUDENT/NONE)、 `resolveClassRole()`、`canManageClasses/AssignTeachers/Roster/Schedule/Courses/PublishContent/ManageGrades/Import` 与 `PERMISSION_MATRIX`(README 与验收脚本据此展开)。 - 后端 `lib/access.ts`:新增 `resolveUserClassRole()`(`Class.teacherId` → 班主任,`ClassTeacher` → 科任) 与 `assertCanManageClasses/AssignTeachers/Roster/Schedule/PublishContent/ManageGrades`; 各 service 在**服务层**断言(路由仅做角色过滤),越权统一 403。 - 收紧项:班级增删改、班主任/科任分配、学生名单与账号 → 仅 ADMIN; 课表/课程增删改 → ADMIN 或本班班主任;成绩录入/修改/批量 → 仅 ADMIN(按需求清单); 作业/通知/叫人 → ADMIN、班主任、科任皆可。 - 读权限保持不变:教师仍可查看自己班级的课表/作业/通知/成绩列表与统计。 ## 前端按同一矩阵隐藏入口 - 侧边栏:教师端不再显示「班级管理 / 学生管理 / 成绩录入」(管理员仍是 7 项)。 - 班级页:新建/编辑/删除按钮仅管理员可见;课表页:增删改按钮仅"管理员或本班班主任"可见; 成绩页:录入/批量/删除按钮仅管理员可见。 - **叫人入口调整**:教师需要叫人,而「学生管理」是管理员专属,因此教师入口移到 「通知发布 → 叫人」(选班级 → 选学生 → 快捷短语/自定义消息;名单走 `/classes/:id/students`, 不使用管理员专属的 `/students`);管理员在学生管理页仍保留逐行「叫人」。 ## 顺手修掉的两个真实缺陷 - `NotificationsView.openCreate()`:班级列表异步加载期间点「发布通知」会导致「目标班级」为空、 校验失败且**静默不发布**;现在打开弹窗前会确保班级已加载(冒烟同步等待下拉就绪)。 - `desktop-client/scripts/smoke.mjs`:上一次冒烟被强杀后配置目录仍被句柄占用,`rmSync` 报 ENOTEMPTY 直接让冒烟启动失败;改为按进程号使用独立目录并容错清理。 ## 验证 - `pnpm verify:e2e` **82/82** ✅:新增 14 项角色权限断言(班主任/科任建班被拒、班主任删班被拒、 班主任/科任分配人员被拒、管理员分配与取消成功、科任管理课表被拒、科任可布置作业、 科任/班主任录入成绩被拒、管理员录入成功),并把历史用例中"仅管理员可执行"的操作切到管理员令牌。 - `pnpm verify:web` **17/17** ✅:新增「教师端隐藏无权入口」断言(可见菜单=仪表盘/课表/作业/通知, 越权入口=[]),叫人入口改到通知页后仍验证弹窗、8 条快捷短语、自定义输入与可选中态。 - `pnpm verify:desktop` **34/34** ✅;`lint` / `format:check` / `typecheck` 全绿。 - 数据清理:清掉历史遗留的测试班级与 4 条探针课表,演示数据恢复为 3 个班级。
This commit is contained in:
1 parent
71f43042ca
commit
30e4c36809
25 files changed
+760
-228
No files matched your search
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, reactive, ref } from 'vue';
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
|
||||
import {
|
||||
CALL_QUICK_PHRASES,
|
||||
NOTIFICATION_PRIORITIES,
|
||||
PRIORITY_LABELS,
|
||||
PRIORITY_TAG_TYPES,
|
||||
@@ -13,8 +14,9 @@ import {
|
||||
type ClassPeriod,
|
||||
type NotificationDto,
|
||||
type NotificationPriority,
|
||||
type StudentDto,
|
||||
} from '@classhelper/shared';
|
||||
import { classApi, notificationApi, scheduleApi } from '@/api';
|
||||
import { callApi, classApi, notificationApi, scheduleApi } from '@/api';
|
||||
import { useRealtimeStore } from '@/stores/realtime';
|
||||
import UrgentClassWarning from '@/components/UrgentClassWarning.vue';
|
||||
|
||||
@@ -49,6 +51,68 @@ async function loadNotifications(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 叫人(教师可用) */
|
||||
|
||||
/**
|
||||
* 叫人入口放在通知页:班主任与科任老师都能用(学生管理页是管理员专属)。
|
||||
* 学生名单走 `/classes/:id/students`(班级详情接口,教师有权访问),
|
||||
* 不使用管理员专属的 `/students`。
|
||||
*/
|
||||
const callVisible = ref(false);
|
||||
const callSending = ref(false);
|
||||
const callClassId = ref('');
|
||||
const callStudentId = ref('');
|
||||
const callStudents = ref<StudentDto[]>([]);
|
||||
const callQuickPhrase = ref<string>(CALL_QUICK_PHRASES[0] ?? '');
|
||||
const callMessage = ref('');
|
||||
|
||||
async function openCall(): Promise<void> {
|
||||
callClassId.value = filter.classId || classes.value[0]?.id || '';
|
||||
callStudentId.value = '';
|
||||
callMessage.value = '';
|
||||
callQuickPhrase.value = CALL_QUICK_PHRASES[0] ?? '';
|
||||
callStudents.value = [];
|
||||
callVisible.value = true;
|
||||
if (callClassId.value) await loadCallStudents();
|
||||
}
|
||||
|
||||
async function loadCallStudents(): Promise<void> {
|
||||
callStudentId.value = '';
|
||||
callStudents.value = callClassId.value ? await classApi.students(callClassId.value) : [];
|
||||
}
|
||||
|
||||
function pickCallPhrase(phrase: string): void {
|
||||
callQuickPhrase.value = callQuickPhrase.value === phrase ? '' : phrase;
|
||||
}
|
||||
|
||||
async function submitCall(): Promise<void> {
|
||||
if (!callClassId.value) {
|
||||
ElMessage.warning('请选择班级');
|
||||
return;
|
||||
}
|
||||
if (!callStudentId.value) {
|
||||
ElMessage.warning('请选择学生');
|
||||
return;
|
||||
}
|
||||
if (!callMessage.value.trim() && !callQuickPhrase.value.trim()) {
|
||||
ElMessage.warning('请选择快捷短语或填写自定义消息');
|
||||
return;
|
||||
}
|
||||
callSending.value = true;
|
||||
try {
|
||||
const created = await callApi.create({
|
||||
classId: callClassId.value,
|
||||
studentId: callStudentId.value,
|
||||
...(callMessage.value.trim() ? { message: callMessage.value.trim() } : {}),
|
||||
...(callQuickPhrase.value.trim() ? { quickPhrase: callQuickPhrase.value.trim() } : {}),
|
||||
});
|
||||
ElMessage.success(`已通知:${created.title}`);
|
||||
callVisible.value = false;
|
||||
} finally {
|
||||
callSending.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 发布 */
|
||||
|
||||
const formVisible = ref(false);
|
||||
@@ -73,7 +137,9 @@ const urgentWarning = reactive({
|
||||
title: '',
|
||||
});
|
||||
|
||||
function openCreate(): void {
|
||||
async function openCreate(): Promise<void> {
|
||||
// 班级列表可能还没加载完(进入页面后立刻点按钮):先补一次,避免「目标班级」为空导致校验失败
|
||||
if (classes.value.length === 0) await loadClasses().catch(() => undefined);
|
||||
form.classId = filter.classId || classes.value[0]?.id || '';
|
||||
form.title = '';
|
||||
form.content = '';
|
||||
@@ -202,6 +268,7 @@ onUnmounted(() => {
|
||||
@keyup.enter="loadNotifications"
|
||||
@clear="loadNotifications"
|
||||
/>
|
||||
<el-button type="warning" :icon="'Bell'" @click="openCall">叫人</el-button>
|
||||
<el-button type="primary" :icon="'Plus'" @click="openCreate">发布通知</el-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -273,6 +340,59 @@ onUnmounted(() => {
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 叫人:选班级 → 选学生 → 快捷短语 / 自定义消息 → 学生端灵动岛立即弹出 -->
|
||||
<el-dialog v-model="callVisible" title="叫人" width="520px">
|
||||
<el-form label-width="76px">
|
||||
<el-form-item label="班级">
|
||||
<el-select v-model="callClassId" style="width: 100%" @change="loadCallStudents">
|
||||
<el-option v-for="item in classes" :key="item.id" :label="item.name" :value="item.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="学生">
|
||||
<el-select v-model="callStudentId" filterable placeholder="选择学生" style="width: 100%">
|
||||
<el-option
|
||||
v-for="item in callStudents"
|
||||
:key="item.id"
|
||||
:label="`${item.name}(${item.username})`"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="快捷短语">
|
||||
<div class="call-phrases">
|
||||
<el-tag
|
||||
v-for="phrase in CALL_QUICK_PHRASES"
|
||||
:key="phrase"
|
||||
class="call-phrase"
|
||||
:effect="callQuickPhrase === phrase ? 'dark' : 'plain'"
|
||||
:type="callQuickPhrase === phrase ? 'warning' : 'info'"
|
||||
@click="pickCallPhrase(phrase)"
|
||||
>
|
||||
{{ phrase }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="自定义">
|
||||
<el-input
|
||||
v-model="callMessage"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
maxlength="200"
|
||||
show-word-limit
|
||||
placeholder="可选;填写后优先于快捷短语"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-alert
|
||||
type="warning"
|
||||
:closable="false"
|
||||
title="学生端桌面会立即浮出「请 XXX 同学找 XXX 老师」,上课时段也照常弹出"
|
||||
/>
|
||||
<template #footer>
|
||||
<el-button @click="callVisible = false">取消</el-button>
|
||||
<el-button type="warning" :loading="callSending" @click="submitCall">发送叫人</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
<!-- 上课时段发布紧急通知的全屏二次确认(3 秒倒计时) -->
|
||||
<UrgentClassWarning
|
||||
:visible="urgentWarning.visible"
|
||||
|
||||
Reference in new issue
Block a user