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:
LoveChengke committed 2026-09-12 15:02:02 +08:00
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"