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
@@ -14,16 +14,28 @@ const auth = useAuthStore();
|
||||
const realtime = useRealtimeStore();
|
||||
const { isMobile } = useResponsive();
|
||||
|
||||
/**
|
||||
* 菜单项与可见角色(与后端 `@classhelper/shared/permissions` 矩阵保持一致):
|
||||
* - 班级管理 / 学生管理:仅管理员(班主任与科任老师都没有班级增删改与人员分配权限)
|
||||
* - 课表管理:管理员 + 班主任(页面内再按"是否本班班主任"控制增删改按钮)
|
||||
* - 作业 / 通知:所有教师
|
||||
* - 成绩录入:仅管理员(成绩写入仅管理员;教师仍可在仪表盘查看汇总)
|
||||
*/
|
||||
const menuItems = [
|
||||
{ path: '/dashboard', title: '仪表盘', icon: 'Odometer' },
|
||||
{ path: '/classes', title: '班级管理', icon: 'School' },
|
||||
{ path: '/students', title: '学生管理', icon: 'User' },
|
||||
{ path: '/schedules', title: '课表管理', icon: 'Calendar' },
|
||||
{ path: '/homeworks', title: '作业发布', icon: 'Notebook' },
|
||||
{ path: '/notifications', title: '通知发布', icon: 'Bell' },
|
||||
{ path: '/grades', title: '成绩录入', icon: 'Trophy' },
|
||||
{ path: '/dashboard', title: '仪表盘', icon: 'Odometer', roles: ['ADMIN', 'TEACHER'] },
|
||||
{ path: '/classes', title: '班级管理', icon: 'School', roles: ['ADMIN'] },
|
||||
{ path: '/students', title: '学生管理', icon: 'User', roles: ['ADMIN'] },
|
||||
{ path: '/schedules', title: '课表管理', icon: 'Calendar', roles: ['ADMIN', 'TEACHER'] },
|
||||
{ path: '/homeworks', title: '作业发布', icon: 'Notebook', roles: ['ADMIN', 'TEACHER'] },
|
||||
{ path: '/notifications', title: '通知发布', icon: 'Bell', roles: ['ADMIN', 'TEACHER'] },
|
||||
{ path: '/grades', title: '成绩录入', icon: 'Trophy', roles: ['ADMIN'] },
|
||||
];
|
||||
|
||||
/** 当前账号可见的菜单(教师看不到班级/学生/成绩录入等无权入口) */
|
||||
const visibleMenuItems = computed(() =>
|
||||
menuItems.filter((item) => (auth.role ? item.roles.includes(auth.role) : false)),
|
||||
);
|
||||
|
||||
/** 高亮当前菜单:直接比较路由路径,避免依赖路由名 */
|
||||
const activeMenu = computed(() => route.path);
|
||||
|
||||
@@ -129,7 +141,7 @@ async function submitPassword(): Promise<void> {
|
||||
<span class="brand-text">班级小助手</span>
|
||||
</div>
|
||||
<el-menu :default-active="activeMenu" class="layout-menu" @select="handleMenuSelect">
|
||||
<el-menu-item v-for="item in menuItems" :key="item.path" :index="item.path">
|
||||
<el-menu-item v-for="item in visibleMenuItems" :key="item.path" :index="item.path">
|
||||
<el-icon><component :is="item.icon" /></el-icon>
|
||||
<span>{{ item.title }}</span>
|
||||
</el-menu-item>
|
||||
@@ -217,7 +229,7 @@ async function submitPassword(): Promise<void> {
|
||||
<span class="brand-text">班级小助手</span>
|
||||
</div>
|
||||
<el-menu :default-active="activeMenu" class="layout-menu" @select="handleMenuSelect">
|
||||
<el-menu-item v-for="item in menuItems" :key="item.path" :index="item.path">
|
||||
<el-menu-item v-for="item in visibleMenuItems" :key="item.path" :index="item.path">
|
||||
<el-icon><component :is="item.icon" /></el-icon>
|
||||
<span>{{ item.title }}</span>
|
||||
</el-menu-item>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue';
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
|
||||
import {
|
||||
@@ -11,12 +11,17 @@ import {
|
||||
type UserDto,
|
||||
} from '@classhelper/shared';
|
||||
import { classApi, courseApi, teacherApi } from '@/api';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useRealtimeStore } from '@/stores/realtime';
|
||||
import { useResponsive } from '@/composables/useResponsive';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const realtime = useRealtimeStore();
|
||||
const { isMobile } = useResponsive();
|
||||
|
||||
/** 仅管理员可管理班级(增删改)与人员分配 —— 与后端权限矩阵一致 */
|
||||
const isAdmin = computed(() => auth.role === 'ADMIN');
|
||||
|
||||
/** 小屏下详情描述改为单列,避免文字被压成竖排 */
|
||||
const detailColumns = computed(() => (isMobile.value ? 1 : 3));
|
||||
|
||||
@@ -239,7 +244,7 @@ onUnmounted(() => {
|
||||
@clear="loadClasses"
|
||||
/>
|
||||
<el-button :icon="'Search'" @click="loadClasses">查询</el-button>
|
||||
<el-button type="primary" :icon="'Plus'" @click="openCreate">新建班级</el-button>
|
||||
<el-button v-if="isAdmin" type="primary" :icon="'Plus'" @click="openCreate">新建班级</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -268,8 +273,8 @@ onUnmounted(() => {
|
||||
<el-table-column label="操作" width="230" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
|
||||
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button link type="danger" @click="removeClass(row)">删除</el-button>
|
||||
<el-button v-if="isAdmin" link type="primary" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button v-if="isAdmin" link type="danger" @click="removeClass(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import * as echarts from 'echarts';
|
||||
@@ -14,8 +14,13 @@ import {
|
||||
type StudentDto,
|
||||
} from '@classhelper/shared';
|
||||
import { classApi, courseApi, gradeApi } from '@/api';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useRealtimeStore } from '@/stores/realtime';
|
||||
|
||||
const auth = useAuthStore();
|
||||
|
||||
/** 成绩写入仅管理员(教师仍可查看列表与统计,符合需求 7 的权限清单) */
|
||||
const isAdmin = computed(() => auth.role === 'ADMIN');
|
||||
const realtime = useRealtimeStore();
|
||||
|
||||
const loading = ref(false);
|
||||
@@ -273,8 +278,8 @@ onUnmounted(() => {
|
||||
<el-select v-model="filter.examName" placeholder="全部考试" clearable style="width: 160px">
|
||||
<el-option v-for="item in examOptions" :key="item" :label="item" :value="item" />
|
||||
</el-select>
|
||||
<el-button type="primary" :icon="'Plus'" @click="openSingle">单条录入</el-button>
|
||||
<el-button type="success" :icon="'Upload'" @click="openBulk">批量录入</el-button>
|
||||
<el-button v-if="isAdmin" type="primary" :icon="'Plus'" @click="openSingle">单条录入</el-button>
|
||||
<el-button v-if="isAdmin" type="success" :icon="'Upload'" @click="openBulk">批量录入</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -335,7 +340,7 @@ onUnmounted(() => {
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="90" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="danger" @click="removeGrade(row)">删除</el-button>
|
||||
<el-button v-if="isAdmin" link type="danger" @click="removeGrade(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue';
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
|
||||
import {
|
||||
@@ -14,10 +14,23 @@ import {
|
||||
type ScheduleWeekView,
|
||||
} from '@classhelper/shared';
|
||||
import { classApi, courseApi, dashboardApi, scheduleApi } from '@/api';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useRealtimeStore } from '@/stores/realtime';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const realtime = useRealtimeStore();
|
||||
|
||||
/**
|
||||
* 课表管理权限:管理员,或"所选班级的班主任"(Class.teacherId === 当前用户)。
|
||||
* 与后端 assertCanManageSchedule 使用同一权限矩阵;科任老师不显示写入口。
|
||||
*/
|
||||
const canManageSchedule = computed(() => {
|
||||
if (auth.role === 'ADMIN') return true;
|
||||
if (auth.role !== 'TEACHER') return false;
|
||||
const current = classes.value.find((item) => item.id === query.classId);
|
||||
return Boolean(current && current.teacherId === auth.user?.id);
|
||||
});
|
||||
|
||||
const loading = ref(false);
|
||||
const classes = ref<ClassDto[]>([]);
|
||||
const courses = ref<CourseDto[]>([]);
|
||||
@@ -212,7 +225,9 @@ onUnmounted(() => {
|
||||
<el-option v-for="week in weekOptions" :key="week" :label="`第 ${week} 周`" :value="week" />
|
||||
</el-select>
|
||||
<el-button :icon="'Refresh'" @click="loadSchedules">刷新</el-button>
|
||||
<el-button type="primary" :icon="'Plus'" @click="openCreate">新增课表</el-button>
|
||||
<el-button v-if="canManageSchedule" type="primary" :icon="'Plus'" @click="openCreate">
|
||||
新增课表
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -241,8 +256,18 @@ onUnmounted(() => {
|
||||
<div class="timetable-meta">{{ item.location ?? '未填地点' }}</div>
|
||||
<div class="timetable-meta">{{ formatWeekRange(item.weekStart, item.weekEnd) }}</div>
|
||||
<div>
|
||||
<el-button link type="primary" size="small" @click="openEdit(item)">编辑</el-button>
|
||||
<el-button link type="danger" size="small" @click="removeSchedule(item)">删除</el-button>
|
||||
<el-button v-if="canManageSchedule" link type="primary" size="small" @click="openEdit(item)">
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canManageSchedule"
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
@click="removeSchedule(item)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in new issue
Block a user