feat(authz): 重写权限模板分配与班级授权边界

拆分能力、分配范围和业务关系,固定管理员能力并收紧组织级写入口。
补齐内容、课表、设备会话和实时通道授权,兼容旧库分配及历史快照。
重写权限分配预览,按实际可操作范围展示维护入口,合并学生入口并独立学年升级页。
增加授权攻击矩阵及静态门禁,更新隔离回归与开发契约。

验证:授权矩阵 23 段通过,Node 24 下 Web 63/63,类型检查、lint 与文档严格构建通过。
This commit is contained in:
LoveChengke committed 2026-10-31 09:40:23 +08:00
1 parent 682818dd4a
commit 66be3a63b1
115 files changed
+4463 -2747

No files matched your search

+4
View File
@@ -105,6 +105,10 @@ export const classApi = {
create: (payload: CreateClassRequest): Promise<ClassDto> => api.post(API_PATHS.classes, payload),
update: (id: string, payload: UpdateClassRequest): Promise<ClassDto> =>
api.patch(`${API_PATHS.classes}/${id}`, payload),
updateSettings: (
id: string,
payload: { termWeeks?: number; termStartDate?: string | null },
): Promise<ClassDto> => api.patch(`${API_PATHS.classes}/${id}/settings`, payload),
remove: (id: string): Promise<{ id: string }> => api.delete(`${API_PATHS.classes}/${id}`),
students: (id: string): Promise<StudentDto[]> => api.get(`${API_PATHS.classes}/${id}/students`),
addStudent: (
@@ -21,6 +21,7 @@ const props = defineProps<{
layouts: TimeLayoutDetailDto[];
courses: CourseDto[];
canEdit: boolean;
canCreate: boolean;
saving: boolean;
/** 引用当前时间表的日课表数量,显示在「时间表信息」里。 */
planCount: number;
@@ -321,7 +322,7 @@ onUnmounted(() => {
<aside class="layout-explorer" aria-label="时间表列表">
<el-input v-model="search" placeholder="搜索时间表" clearable />
<div class="explorer-actions">
<button class="command" :disabled="!canEdit" @click="emit('create')">
<button class="command" :disabled="!canCreate" @click="emit('create')">
<ArchiveIcon name="plus" />
新时间表
</button>
@@ -388,7 +389,7 @@ onUnmounted(() => {
<div v-if="!layout" class="panel-empty">
<ArchiveIcon name="clock" />
<p>新建一份时间表开始编辑</p>
<button class="command" :disabled="!canEdit" @click="emit('create')">+ 新时间表</button>
<button class="command" :disabled="!canCreate" @click="emit('create')">+ 新时间表</button>
</div>
<div v-else-if="view === 'list'" class="point-list" aria-label="时间点列表">
<div
+7 -2
View File
@@ -1,4 +1,9 @@
/** 只定义分组;子页标题、图标和权限直接读取路由,避免菜单与路由各维护一套权限。 */
import type { PermissionKey } from '@classhelper/shared';
/** 字符串动作使用服务端业务范围;manage 用于班级管理,permission 用于无班级目标的操作。 */
export type MenuAction = PermissionKey | { key: PermissionKey; scope: 'manage' | 'permission' };
/** 只定义分组;标题、图标、读权限和可操作条件均读取路由。 */
export const MANAGEMENT_GROUPS = [
{ id: 'workspace', title: '工作台', icon: 'Odometer', paths: ['/dashboard', '/assistant'] },
{
@@ -11,7 +16,7 @@ export const MANAGEMENT_GROUPS = [
id: 'school',
title: '班级与人员',
icon: 'School',
paths: ['/classes', '/students', '/teachers', '/roster', '/term-weeks', '/promotion', '/archives'],
paths: ['/classes', '/students', '/teachers', '/term-weeks', '/promotion', '/archives'],
},
{ id: 'devices', title: '设备运维', icon: 'Monitor', paths: ['/clients'] },
{
+4 -20
View File
@@ -77,18 +77,7 @@ function openAssistant(): void {
void router.push('/assistant');
}
/**
* 菜单项与可见角色(与后端 `@classhelper/shared/permissions` 矩阵保持一致):
* - 班级管理 / 学生管理 / 教师管理:仅管理员(班主任与科任老师都没有班级增删改与人员分配权限)
* - 课表管理:管理员 + 班主任(页面内再按"是否本班班主任"控制增删改按钮)
* - 成绩录入:管理员 + 教师;页面内只对"管理员或所选班级的班主任"显示写入/导入按钮
* (需求 6:成绩与表格导入老师端可用且不越权;科任老师只能看,写入会被后端 403)
* - 作业 / 通知:所有教师
* - 数据库管理:仅管理员(备份/恢复/导入导出/一键切换是最高危操作,后端同样 requireRole('ADMIN'))
* - 权限组管理 / 客户端设备:`roles` 只是粗筛(管理员 + 教师),真正的门控是**生效权限**
* (`permission:manage` / `client:manage`)—— 教师被某个权限组授予它之后同样该看见
* (见 `visibleMenuItems`)
*/
/** 菜单标题、图标、能力取路由;服务端负责范围与业务关系的最终校验。 */
const menuItems = router
.getRoutes()
.filter((record) => MANAGEMENT_GROUPS.some((g) => (g.paths as readonly string[]).includes(record.path)))
@@ -96,16 +85,11 @@ const menuItems = router
path: record.path,
title: String(record.meta.title || record.path),
icon: String(record.meta.icon || 'Setting'),
roles: record.meta.roles as string[] | undefined,
permission: record.meta.permission as PermissionKey | undefined,
menuActions: record.meta.menuActions,
}));
const visibleMenuItems = computed(() =>
menuItems.filter(
(item) =>
!!auth.role &&
(!item.roles?.length || item.roles.includes(auth.role)) &&
(!item.permission || auth.can(item.permission)),
),
menuItems.filter((item) => !!auth.role && auth.canShowMenu(item.permission, item.menuActions)),
);
const menuGroups = computed(() =>
MANAGEMENT_GROUPS.map((group) => ({
@@ -123,12 +107,12 @@ const searchResults = computed(() =>
);
const tabs = ref<Array<ManagementMenuItem & { fullPath: string }>>([]);
function addCurrentTab() {
tabs.value = tabs.value.filter((tab) => visibleMenuItems.value.some((item) => item.path === tab.path));
const item = visibleMenuItems.value.find((item) => item.path === route.path);
if (!item) return;
const tab = tabs.value.find((tab) => tab.path === item.path);
if (tab) tab.fullPath = route.fullPath;
else tabs.value.push({ path: item.path, title: item.title, icon: item.icon, fullPath: route.fullPath });
tabs.value = tabs.value.filter((tab) => visibleMenuItems.value.some((item) => item.path === tab.path));
if (tabs.value.length > 20) tabs.value.splice(tabs.value[0]?.path === '/dashboard' ? 1 : 0, 1);
}
watch([() => route.fullPath, visibleMenuItems], addCurrentTab, { immediate: true });
+78 -46
View File
@@ -21,8 +21,8 @@ const routes: RouteRecordRaw[] = [
meta: {
title: '课件',
icon: 'FolderOpened',
roles: ['ADMIN', 'TEACHER'],
permission: 'courseware:read',
menuActions: [{ key: 'courseware:write', scope: 'permission' }],
},
},
{
@@ -32,15 +32,13 @@ const routes: RouteRecordRaw[] = [
meta: {
title: '课表档案',
icon: 'Calendar',
roles: ['ADMIN', 'TEACHER'],
permission: 'schedule:read',
menuActions: ['schedule:write', 'timetable:manage'],
},
},
{
path: 'roster',
name: 'roster',
component: () => import('@/views/StudentsView.vue'),
meta: { title: '学生名单', icon: 'User', roles: ['TEACHER'], permission: 'roster:read' },
redirect: (to) => ({ path: '/students', query: to.query, hash: to.hash }),
},
{
path: 'system-settings',
@@ -52,37 +50,59 @@ const routes: RouteRecordRaw[] = [
path: 'dashboard',
name: 'dashboard',
component: () => import('@/views/DashboardView.vue'),
meta: { title: '仪表盘', icon: 'Odometer' },
meta: { title: '仪表盘', icon: 'Odometer', permission: 'dashboard:read' },
},
{
path: 'classes',
name: 'classes',
component: () => import('@/views/ClassesView.vue'),
meta: { title: '班级管理', icon: 'School', permission: 'class:read' },
meta: {
title: '班级管理',
icon: 'School',
permission: 'class:read',
menuActions: [
'class:manage',
'class:account',
'class:settings',
'roster:manage',
'schedule:write',
{ key: 'grade:write', scope: 'manage' },
],
},
},
{
path: 'students',
name: 'students',
component: () => import('@/views/StudentsView.vue'),
meta: { title: '学生管理', icon: 'User', permission: 'roster:read' },
meta: {
title: '学生管理',
icon: 'User',
permission: 'roster:read',
menuActions: ['roster:manage', 'call:create'],
},
},
{
path: 'teachers',
name: 'teachers',
component: () => import('@/views/TeachersView.vue'),
meta: { title: '教师管理', icon: 'UserFilled', roles: ['ADMIN'] },
meta: { title: '教师管理', icon: 'UserFilled', permission: 'teacher:manage' },
},
{
path: 'term-weeks',
name: 'term-weeks',
component: () => import('@/views/TermWeeksView.vue'),
// 改周次会让全校/某班的课表口径整体变化,与「班级管理」同一档权限
meta: { title: '学期周次', icon: 'Calendar', permission: 'term:read' },
meta: {
title: '学期周次',
icon: 'Calendar',
permission: 'term:read',
menuActions: ['term:write', 'class:settings'],
},
},
{
path: 'promotion',
name: 'promotion',
component: () => import('@/components/PromotionSettings.vue'),
component: () => import('@/views/PromotionView.vue'),
meta: { title: '学年升级', icon: 'Calendar', permission: 'promotion:manage' },
},
{
@@ -96,25 +116,40 @@ const routes: RouteRecordRaw[] = [
path: 'schedules',
name: 'schedules',
redirect: '/plan-editor',
meta: { title: '课表管理', icon: 'Calendar' },
meta: { title: '课表管理', icon: 'Calendar', permission: 'schedule:read' },
},
{
path: 'homeworks',
name: 'homeworks',
component: () => import('@/views/HomeworksView.vue'),
meta: { title: '作业发布', icon: 'Notebook' },
meta: {
title: '作业发布',
icon: 'Notebook',
permission: 'homework:read',
menuActions: ['homework:write'],
},
},
{
path: 'notifications',
name: 'notifications',
component: () => import('@/views/NotificationsView.vue'),
meta: { title: '通知发布', icon: 'Bell' },
meta: {
title: '通知发布',
icon: 'Bell',
permission: 'notification:read',
menuActions: ['notification:publish', 'notification:delete', 'call:create'],
},
},
{
path: 'grades',
name: 'grades',
component: () => import('@/views/GradesView.vue'),
meta: { title: '成绩录入', icon: 'Trophy' },
meta: {
title: '成绩录入',
icon: 'Trophy',
permission: 'grade:read',
menuActions: ['grade:write', 'grade:import'],
},
},
{
path: 'assistant',
@@ -122,7 +157,7 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/AssistantView.vue'),
// 按**生效权限**门控(教师默认就有 ai:use,见 shared/permissions.ts 的默认模板):
// 管理员把 ai:use 从某个权限组收回后,这一页同时从菜单与路由上消失
meta: { title: 'AI 助手', icon: 'MagicStick', permission: 'ai:use' },
meta: { title: 'AI 助手', icon: 'MagicStick', permission: 'ai:use', menuActions: ['ai:use'] },
},
{
path: 'ai-settings',
@@ -136,25 +171,35 @@ const routes: RouteRecordRaw[] = [
name: 'database',
component: () => import('@/views/DatabaseView.vue'),
// 与后端 requireRole('ADMIN') 对齐:改动数据库是最高危操作,仅管理员
meta: { title: '数据库管理', icon: 'Coin', roles: ['ADMIN'] },
meta: { title: '数据库管理', icon: 'Coin', permission: 'database:manage' },
},
{
path: 'permissions',
name: 'permissions',
component: () => import('@/views/PermissionsView.vue'),
// 这一页**按生效权限**门控而不是按角色:教师被某个权限组授予 permission:manage 后同样可用,
// 与后端 requirePermission('permission:manage') 对齐(详见下方守卫)
meta: { title: '权限组管理', icon: 'Lock', permission: 'permission:manage' },
// 管理员专用能力不可下放,菜单与路由仍统一按服务端能力集合过滤。
meta: { title: '权限与范围', icon: 'Lock', permission: 'permission:manage' },
},
{
path: 'clients',
name: 'clients',
component: () => import('@/views/ClientsView.vue'),
meta: { title: '客户端', icon: 'Monitor', permission: 'client:read' },
meta: {
title: '客户端',
icon: 'Monitor',
permission: 'client:read',
menuActions: ['client:manage', 'client:update-push', 'remote:control', 'client:update'],
},
},
{ path: 'client-devices', redirect: '/clients' },
],
},
{
path: '/no-access',
name: 'no-access',
component: () => import('@/views/NoAccessView.vue'),
meta: { title: '暂无功能授权' },
},
{
path: '/:pathMatch(.*)*',
name: 'not-found',
@@ -168,18 +213,6 @@ export const router = createRouter({
routes,
});
/** 读取已保存的登录用户角色(路由守卫用;未登录/解析失败返回 null) */
function storedRole(): string | null {
try {
const raw = localStorage.getItem(STORAGE_KEYS.user);
if (!raw) return null;
const parsed = JSON.parse(raw) as { role?: string };
return parsed?.role ?? null;
} catch {
return null;
}
}
router.beforeEach(async (to) => {
const token = localStorage.getItem(STORAGE_KEYS.token);
if (to.meta.public) {
@@ -189,23 +222,22 @@ router.beforeEach(async (to) => {
if (!token) {
return { name: 'login', query: { redirect: to.fullPath } };
}
// 仅管理员可见的页面(班级/学生/教师管理):直接输网址也要挡回去,
// 不能依赖"菜单隐藏"——菜单隐藏只是看不见,不是权限。
const roles = to.meta.roles;
if (Array.isArray(roles) && roles.length > 0) {
const role = storedRole();
if (!role || !(roles as string[]).includes(role)) {
return { name: 'dashboard' };
}
}
// 按**生效权限**门控的页面(权限组管理):角色判不出来 —— 教师被权限组授予
// permission:manage 后同样该放行,所以这里异步取一次生效权限。
// `ensurePermissions()` 在 store 内是幂等的(本次会话只真拉一次),不会每次跳转都打接口。
const permission = to.meta.permission;
if (typeof permission === 'string') {
const auth = useAuthStore();
await auth.ensurePermissions();
if (!auth.can(permission as PermissionKey)) return { name: 'dashboard' };
if (!auth.can(permission as PermissionKey)) {
if (auth.can('dashboard:read')) return { name: 'dashboard' };
const fallback = router
.getRoutes()
.find(
(r) =>
r.name &&
typeof r.meta.permission === 'string' &&
auth.canShowMenu(r.meta.permission, r.meta.menuActions),
);
return fallback ? { name: fallback.name! } : { name: 'no-access' };
}
}
return true;
});
+62 -2
View File
@@ -2,6 +2,7 @@ import { computed, ref } from 'vue';
import { defineStore } from 'pinia';
import {
ROLE_LABELS,
PERMISSION_META,
STORAGE_KEYS,
hasPermission,
type LoginRequest,
@@ -10,6 +11,7 @@ import {
type SessionUser,
} from '@classhelper/shared';
import { authApi, permissionApi } from '@/api';
import type { MenuAction } from '@/config/navigation';
/**
* 会话主体。
@@ -44,6 +46,54 @@ export const useAuthStore = defineStore('auth', () => {
/** 生效权限是否来自显式分组;没有显式分组时使用可编辑的角色默认组。 */
const permissionsFromGroups = ref(false);
const permissionsLoaded = ref(false);
const classScopes = ref<Partial<Record<PermissionKey, string[]>>>({});
const manageScopes = ref<Partial<Record<PermissionKey, string[]>>>({});
const actionScopes = ref<Partial<Record<PermissionKey, string[]>>>({});
const allScopeKeys = ref<PermissionKey[]>([]);
function canForClass(key: PermissionKey, classId: string | null | undefined): boolean {
return (
can(key) &&
(classId ? (classScopes.value[key] ?? []).includes(classId) : allScopeKeys.value.includes(key))
);
}
function canManageClass(key: PermissionKey, classId: string | null | undefined): boolean {
return (
can(key) &&
(classId
? (manageScopes.value[key] ?? classScopes.value[key] ?? []).includes(classId)
: allScopeKeys.value.includes(key))
);
}
function canShowMenu(permission: PermissionKey | undefined, actions?: readonly MenuAction[]): boolean {
if (permission && !can(permission)) return false;
if (!actions) return true;
return actions.some((action) => {
const key = typeof action === 'string' ? action : action.key;
if (!can(key)) return false;
if (
(typeof action !== 'string' && action.scope === 'permission') ||
PERMISSION_META[key].kind !== 'class'
)
return true;
const ids =
typeof action !== 'string' && action.scope === 'manage'
? (manageScopes.value[key] ?? [])
: (actionScopes.value[key] ?? []);
// 全校名单维护还允许新建未分班学生。
if (
key === 'roster:manage' &&
allScopeKeys.value.includes(key) &&
(!permission || allScopeKeys.value.includes(permission))
)
return true;
return ids.some(
(id) =>
!permission ||
PERMISSION_META[permission].kind !== 'class' ||
(classScopes.value[permission] ?? []).includes(id),
);
});
}
const isAuthenticated = computed(() => Boolean(token.value));
const role = computed(() => user.value?.role ?? null);
@@ -54,10 +104,9 @@ export const useAuthStore = defineStore('auth', () => {
/**
* 是否有某个生效权限。
*
* 管理员同样遵守生效集合,只保留与服务端一致的两个管理入口,避免锁死。
* 页面直接使用服务端生效集合;管理员全集也由服务端返回。
*/
function can(key: PermissionKey): boolean {
if (isAdmin.value && (key === 'permission:manage' || key === 'system:manage')) return true;
return hasPermission(permissions.value, key);
}
@@ -73,6 +122,10 @@ export const useAuthStore = defineStore('auth', () => {
try {
const mine = await permissionApi.me();
permissions.value = mine.permissions;
classScopes.value = mine.classScopes ?? {};
manageScopes.value = mine.manageScopes ?? {};
actionScopes.value = mine.actionScopes ?? {};
allScopeKeys.value = mine.allScopeKeys ?? [];
permissionsFromGroups.value = mine.fromGroups;
permissionsLoaded.value = true;
} catch {
@@ -83,6 +136,10 @@ export const useAuthStore = defineStore('auth', () => {
/** 清掉权限缓存(换账号 / 退出登录时必须调用,否则会沿用上一个账号的权限) */
function resetPermissions(): void {
permissions.value = [];
classScopes.value = {};
manageScopes.value = {};
actionScopes.value = {};
allScopeKeys.value = [];
permissionsFromGroups.value = false;
permissionsLoaded.value = false;
}
@@ -146,6 +203,9 @@ export const useAuthStore = defineStore('auth', () => {
displayName,
roleLabel,
can,
canForClass,
canManageClass,
canShowMenu,
ensurePermissions,
login,
logout,
+14 -1
View File
@@ -12,6 +12,7 @@ import {
type ServerToClientEvents,
} from '@classhelper/shared';
import { SOCKET_URL } from '@/config';
import { useAuthStore } from '@/stores/auth';
type RealtimeEventName = keyof ServerToClientEvents;
type EventHandler = (payload: unknown) => void;
@@ -55,8 +56,20 @@ export const useRealtimeStore = defineStore('realtime', () => {
connected.value = true;
connecting.value = false;
});
instance.on('disconnect', () => {
instance.on('disconnect', (reason: string) => {
connected.value = false;
if (reason === 'io server disconnect') {
void (async () => {
const auth = useAuthStore();
try {
await auth.fetchProfile();
await auth.ensurePermissions(true);
if (socket === instance && auth.token) instance.connect();
} catch {
/* 身份失效时等待重新登录。 */
}
})();
}
});
instance.on('connect_error', (error: Error) => {
connected.value = false;
+11
View File
@@ -0,0 +1,11 @@
import 'vue-router';
import type { PermissionKey } from '@classhelper/shared';
import type { MenuAction } from '@/config/navigation';
declare module 'vue-router' {
interface RouteMeta {
permission?: PermissionKey;
/** 维护页面任一动作在至少一个可读班级生效时显示;纯查看页面省略此项。 */
menuActions?: readonly MenuAction[];
}
}
+61 -17
View File
@@ -32,7 +32,10 @@ const realtime = useRealtimeStore();
const { isMobile } = useResponsive();
/** 仅管理员可管理班级(增删改)与人员分配 —— 与后端权限矩阵一致 */
const canManageClass = computed(() => auth.can('class:manage'));
const canManageClass = computed(() => auth.isAdmin && auth.can('class:manage'));
function canHeadAction(row: ClassDto, key: 'class:account' | 'class:settings') {
return auth.canForClass(key, row.id) && (auth.isAdmin || row.teacher?.id === auth.user?.id);
}
/** 小屏下详情描述改为单列,避免文字被压成竖排 */
const detailColumns = computed(() => (isMobile.value ? 1 : 3));
@@ -131,7 +134,6 @@ async function submitForm(): Promise<void> {
await classApi.update(editingId.value, {
enrollmentYear: form.enrollmentYear,
classIndex: form.classIndex,
termWeeks: form.termWeeks,
});
// 编辑弹窗里也能直接换班主任(仅管理员;班主任决定谁能管这个班的课表与成绩)
if (canManageClass.value && form.headTeacherId) {
@@ -246,6 +248,9 @@ async function removeClass(row: ClassDto): Promise<void> {
const detailVisible = ref(false);
const detailLoading = ref(false);
const detail = ref<ClassDetailDto | null>(null);
const canManageDetailRoster = computed(() => auth.canManageClass('roster:manage', detail.value?.id));
const canManageDetailCourses = computed(() => auth.canManageClass('schedule:write', detail.value?.id));
const canManageDetailGradeQuery = computed(() => auth.canManageClass('grade:write', detail.value?.id));
const activeTab = ref('students');
async function openDetail(row: ClassDto): Promise<void> {
@@ -285,7 +290,7 @@ const studentRules: FormRules = {
};
async function addStudent(): Promise<void> {
if (!detail.value) return;
if (!detail.value || !canManageDetailRoster.value) return;
if (!studentForm.studentNo.trim() || !studentForm.name.trim()) {
ElMessage.warning('请填写学号和姓名');
return;
@@ -307,7 +312,7 @@ async function addStudent(): Promise<void> {
}
async function removeStudent(row: StudentDto): Promise<void> {
if (!detail.value) return;
if (!detail.value || !canManageDetailRoster.value) return;
await ElMessageBox.confirm(`将「${row.name}」移出本班级?`, '确认', { type: 'warning' });
await classApi.removeStudent(detail.value.id, row.id);
ElMessage.success('已移出班级');
@@ -320,6 +325,7 @@ async function removeStudent(row: StudentDto): Promise<void> {
const courseForm = reactive({ name: '' });
async function addCourse(): Promise<void> {
if (!canManageDetailCourses.value) return;
if (!detail.value || !courseForm.name.trim()) {
ElMessage.warning('请输入课程名称');
return;
@@ -331,6 +337,7 @@ async function addCourse(): Promise<void> {
}
async function removeCourse(row: CourseDto): Promise<void> {
if (!auth.canManageClass('schedule:write', row.classId)) return;
await ElMessageBox.confirm(`删除课程「${row.name}」?相关课表与作业关联会被清除。`, '确认', {
type: 'warning',
});
@@ -409,7 +416,7 @@ const gradeQuerySaving = ref(false);
/** 是否允许教室的 ClassHelper 班级端按学号查询本班学生的成绩明细 */
async function toggleGradeQuery(enabled: boolean): Promise<void> {
if (!detail.value) return;
if (!detail.value || !canManageDetailGradeQuery.value) return;
gradeQuerySaving.value = true;
try {
await classApi.setGradeQuery(detail.value.id, enabled);
@@ -470,6 +477,24 @@ onUnmounted(() => {
realtime.off(SOCKET_EVENTS.notificationNew, onClassChanged);
realtime.off(SOCKET_EVENTS.homeworkNew, onClassChanged);
});
const settings = reactive({ visible: false, id: '', termWeeks: 20, termStartDate: '' });
function openSettings(row: ClassDto) {
Object.assign(settings, {
visible: true,
id: row.id,
termWeeks: row.termWeeks,
termStartDate: row.termStartDate ?? '',
});
}
async function saveSettings() {
await classApi.updateSettings(settings.id, {
termWeeks: settings.termWeeks,
termStartDate: settings.termStartDate,
});
settings.visible = false;
await loadClasses();
ElMessage.success('学期设置已保存');
}
</script>
<template>
@@ -546,9 +571,17 @@ onUnmounted(() => {
<el-table-column label="操作" width="310" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
<el-button v-if="canManageClass" link type="warning" @click="openAccount(row)">
<el-button
v-if="canHeadAction(row, 'class:account')"
link
type="warning"
@click="openAccount(row)"
>
班级账号
</el-button>
<el-button v-if="canHeadAction(row, 'class:settings')" link @click="openSettings(row)">
学期设置
</el-button>
<el-button v-if="canManageClass" link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button v-if="canManageClass" link type="danger" @click="removeClass(row)">删除</el-button>
</template>
@@ -556,6 +589,20 @@ onUnmounted(() => {
</el-table>
</el-card>
<el-dialog v-model="settings.visible" title="本班学期设置" width="440px">
<el-form label-width="100px">
<el-form-item label="教学周数">
<el-input-number v-model="settings.termWeeks" :min="1" :max="maxTermWeeks" />
</el-form-item>
<el-form-item label="开学日期">
<el-date-picker v-model="settings.termStartDate" type="date" value-format="YYYY-MM-DD" clearable />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="settings.visible = false">取消</el-button>
<el-button type="primary" @click="saveSettings">保存设置</el-button>
</template>
</el-dialog>
<!-- 班级账号:班级码 + 班级密码(教室机器上 ClassHelper 班级端的登录凭据) -->
<el-dialog v-model="accountVisible" title="班级账号(ClassHelper 班级端登录)" width="480px">
<el-alert type="info" :closable="false" show-icon class="mb-12">
@@ -625,7 +672,7 @@ onUnmounted(() => {
班主任可以管理本班课表与成绩;留空则默认由创建者本人担任
</span>
</el-form-item>
<el-form-item label="学期周数">
<el-form-item v-if="!editingId" label="学期周数">
<el-input-number v-model="form.termWeeks" :min="1" :max="maxTermWeeks" :step="1" />
<span class="text-muted ml-8">
本学期一共多少教学周(默认 20,最多 {{ maxTermWeeks }} 周);课表周次选择与默认结束周都按它来
@@ -680,7 +727,7 @@ onUnmounted(() => {
<el-tab-pane label="学生名单" name="students">
<div class="toolbar mt-12">
<el-button
v-if="auth.can('roster:manage')"
v-if="canManageDetailRoster"
type="primary"
size="small"
:icon="'Plus'"
@@ -703,11 +750,9 @@ onUnmounted(() => {
<el-table-column label="加入时间" width="180">
<template #default="{ row }">{{ formatDate(row.createdAt, true) }}</template>
</el-table-column>
<el-table-column label="操作" width="90">
<el-table-column v-if="canManageDetailRoster" label="操作" width="90">
<template #default="{ row }">
<el-button v-if="auth.can('roster:manage')" link type="danger" @click="removeStudent(row)">
移出
</el-button>
<el-button link type="danger" @click="removeStudent(row)">移出</el-button>
</template>
</el-table-column>
</el-table>
@@ -719,15 +764,14 @@ onUnmounted(() => {
<el-table-column label="任课教师" width="140">
<template #default="{ row }">{{ row.teacher?.name ?? '-' }}</template>
</el-table-column>
<el-table-column label="操作" width="90">
<el-table-column v-if="canManageDetailCourses" label="操作" width="90">
<template #default="{ row }">
<el-button v-if="auth.can('schedule:write')" link type="danger" @click="removeCourse(row)">
删除
</el-button>
<el-button link type="danger" @click="removeCourse(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-input
v-if="canManageDetailCourses"
v-model="courseForm.name"
placeholder="输入课程名称后回车快速添加"
class="mt-12"
@@ -817,7 +861,7 @@ onUnmounted(() => {
<el-switch
:model-value="detail?.studentGradeQueryEnabled ?? true"
:loading="gradeQuerySaving"
:disabled="!auth.can('grade:write')"
:disabled="!canManageDetailGradeQuery"
@change="(value: boolean) => toggleGradeQuery(value)"
/>
</div>
+3 -3
View File
@@ -672,15 +672,15 @@ const deviceEmptyText = computed(() =>
<div class="client-actions">
<el-tooltip
:disabled="can('client:update')"
content="当前账号没有「向客户端统一下发更新」权限(client:update)"
:disabled="can('client:update-push')"
content="当前账号没有「向客户端统一下发更新」权限(client:update-push)"
placement="top"
>
<span>
<el-button
type="primary"
:icon="'Upload'"
:disabled="!can('client:update')"
:disabled="!can('client:update-push')"
@click="openUpdateDialog"
>
一键推送更新
+30 -12
View File
@@ -37,8 +37,20 @@ const quickEntries = computed(() =>
icon: 'Calendar',
enabled: auth.can('schedule:read'),
},
{ path: '/homeworks', title: '发布作业', hint: '布置并追踪完成情况', icon: 'Notebook', enabled: true },
{ path: '/notifications', title: '通知与叫人', hint: '实时送达教室', icon: 'Bell', enabled: true },
{
path: '/homeworks',
title: '发布作业',
hint: '布置并追踪完成情况',
icon: 'Notebook',
enabled: auth.can('homework:read'),
},
{
path: '/notifications',
title: '通知与叫人',
hint: '实时送达教室',
icon: 'Bell',
enabled: auth.can('notification:read'),
},
{
path: '/clients',
title: '客户端',
@@ -52,18 +64,24 @@ const quickEntries = computed(() =>
const statCards = computed(() => {
const data = summary.value;
return [
{ label: '管理班级', value: data?.classCount ?? 0, hint: '按权限范围统计' },
{ label: '学生人数', value: data?.studentCount ?? 0, hint: '所辖班级学生' },
{ label: '课程数量', value: data?.courseCount ?? 0, hint: '含全部课程' },
{ label: '课表条目', value: data?.scheduleCount ?? 0, hint: '全部周次' },
{ section: 'classes', label: '管理班级', value: data?.classCount ?? 0, hint: '按权限范围统计' },
{ section: 'students', label: '学生人数', value: data?.studentCount ?? 0, hint: '所辖班级学生' },
{ section: 'courses', label: '课程数量', value: data?.courseCount ?? 0, hint: '含全部课程' },
{ section: 'schedules', label: '课表条目', value: data?.scheduleCount ?? 0, hint: '全部周次' },
{
section: 'homeworks',
label: '作业总数',
value: data?.homeworkCount ?? 0,
hint: `未完成/待完成 ${data?.pendingHomeworkCount ?? 0}`,
},
{ label: '通知总数', value: data?.notificationCount ?? 0, hint: `未读 ${unreadCount.value}` },
{ label: '成绩记录', value: data?.gradeCount ?? 0, hint: '已发布成绩' },
];
{
section: 'notifications',
label: '通知总数',
value: data?.notificationCount ?? 0,
hint: `未读 ${unreadCount.value}`,
},
{ section: 'grades', label: '成绩记录', value: data?.gradeCount ?? 0, hint: '已发布成绩' },
].filter((card) => data?.sections[card.section as keyof DashboardSummary['sections']]);
});
/**
@@ -76,7 +94,7 @@ async function load(): Promise<void> {
loading.value = true;
try {
summary.value = await dashboardApi.summary();
const result = await notificationApi.unreadCount();
const result = auth.can('notification:read') ? await notificationApi.unreadCount() : { count: 0 };
unreadCount.value = result.count;
} finally {
loading.value = false;
@@ -158,7 +176,7 @@ function openNotification(item: NotificationDto): void {
<el-row :gutter="12" class="mt-16">
<el-col :xs="24" :md="24">
<el-card shadow="never">
<el-card v-if="summary?.sections.notifications" shadow="never">
<template #header>
<div class="card-header">
<span>最新通知</span>
@@ -184,7 +202,7 @@ function openNotification(item: NotificationDto): void {
</el-col>
</el-row>
<el-card shadow="never" class="mt-16 table-card">
<el-card v-if="summary?.sections.homeworks" shadow="never" class="mt-16 table-card">
<template #header>
<div class="card-header">
<span>最近作业</span>
+18 -9
View File
@@ -35,7 +35,7 @@ const auth = useAuthStore();
* (查 `Course.teacherId`),越权一律 403。因此这里只要是教师就放行按钮,
* 让老师能看到入口并在被拒时得到明确的后端提示,而不是"按钮凭空消失、不知道为什么"。
*/
const canManageGrades = computed(() => auth.role === 'ADMIN' || auth.role === 'TEACHER');
const canManageGrades = computed(() => auth.can('grade:write'));
const realtime = useRealtimeStore();
const loading = ref(false);
@@ -221,10 +221,7 @@ const ownCourses = computed(() =>
);
/** 能不能在该班新建科目(与课表管理同权限:管理员或本班班主任) */
const canCreateCourse = computed(() => {
if (auth.isAdmin) return true;
return classes.value.find((item) => item.id === filter.classId)?.teacherId === auth.user?.id;
});
const canCreateCourse = computed(() => auth.canManageClass('schedule:write', filter.classId));
/** 录入弹窗的默认科目:优先沿用页面筛选,但筛的是别人的科目时退回"我的科目"第一条 */
function defaultFormCourseId(): string {
@@ -489,6 +486,12 @@ onMounted(async () => {
onUnmounted(() => {
realtime.off(SOCKET_EVENTS.gradeUpdated, onGradeEvent);
});
function canEditContent(row: GradeDto): boolean {
if (!auth.canForClass('grade:write', row.classId)) return false;
if (auth.isAdmin) return true;
if (!row.courseId) return classes.value.find((c) => c.id === row.classId)?.teacher?.id === auth.user?.id;
return courses.value.some((c) => c.id === row.courseId && c.teacherId === auth.user?.id);
}
</script>
<template>
@@ -519,7 +522,7 @@ onUnmounted(() => {
<el-button v-if="canManageGrades" type="success" :icon="'Upload'" @click="openBulk">
批量录入
</el-button>
<el-button v-if="canManageGrades" type="warning" :icon="'Document'" @click="openImport">
<el-button v-if="auth.can('grade:import')" type="warning" :icon="'Document'" @click="openImport">
导入表格
</el-button>
<el-button v-if="canManageGrades" :icon="'Sort'" @click="openLevelBatch">批量改等级</el-button>
@@ -537,7 +540,11 @@ onUnmounted(() => {
<!-- 考试口径:这次考试要不要把所有科目相加做总分排名(按「班级 + 考试名」记,
客户端据此显示总分榜)。导入成绩时顺手就能定下来。 -->
<el-card v-if="canManageGrades && filter.classId && filter.examName" shadow="never" class="mt-12">
<el-card
v-if="auth.canManageClass('grade:write', filter.classId) && filter.examName"
shadow="never"
class="mt-12"
>
<div class="toolbar">
<span class="exam-label">考试口径</span>
<el-tag effect="plain">{{ filter.examName }}</el-tag>
@@ -615,7 +622,7 @@ onUnmounted(() => {
</el-tag>
<!-- 单个编辑:percent 口径下相当于手动覆盖自动换算的结果 -->
<el-select
v-if="canManageGrades"
v-if="canEditContent(row)"
:model-value="row.level"
size="small"
style="width: 88px"
@@ -639,7 +646,9 @@ onUnmounted(() => {
</el-table-column>
<el-table-column label="操作" width="90" fixed="right">
<template #default="{ row }">
<el-button v-if="canManageGrades" link type="danger" @click="removeGrade(row)">删除</el-button>
<el-button v-if="canEditContent(row)" link type="danger" @click="removeGrade(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
+17 -9
View File
@@ -78,6 +78,7 @@ const notSubmittedNames = computed(() =>
/** 打开详情时顺带读一次未交名单(教师/管理员都有权限) */
async function loadSubmissions(homeworkId: string): Promise<boolean> {
if (!current.value || !canEditContent(current.value)) return false;
const requestId = ++submissionsRequestId;
submissionsLoading.value = true;
try {
@@ -237,10 +238,7 @@ const ownFormCourses = computed(() =>
);
/** 该班里能不能新建科目:与课表管理同权限(管理员或本班班主任) */
const canCreateCourse = computed(() => {
if (auth.isAdmin) return true;
return classes.value.find((item) => item.id === form.classId)?.teacherId === auth.user?.id;
});
const canCreateCourse = computed(() => auth.canManageClass('schedule:write', form.classId));
async function loadFormCourses(): Promise<void> {
const classId = form.classId;
@@ -500,6 +498,12 @@ onUnmounted(() => {
realtime.off(SOCKET_EVENTS.homeworkUpdated, onHomeworkEvent);
realtime.off(SOCKET_EVENTS.homeworkStatus, onHomeworkEvent);
});
function canEditContent(row: HomeworkDto): boolean {
if (!auth.canForClass('homework:write', row.classId)) return false;
if (auth.isAdmin) return true;
if (!row.courseId) return classes.value.find((c) => c.id === row.classId)?.teacher?.id === auth.user?.id;
return courses.value.some((c) => c.id === row.courseId && c.teacherId === auth.user?.id);
}
</script>
<template>
@@ -545,8 +549,10 @@ onUnmounted(() => {
@keyup.enter="loadHomeworks"
@clear="loadHomeworks"
/>
<el-button type="primary" :icon="'Plus'" @click="openCreate">发布作业</el-button>
<el-button @click="openBulk">批量布置</el-button>
<el-button v-if="auth.can('homework:write')" type="primary" :icon="'Plus'" @click="openCreate">
发布作业
</el-button>
<el-button v-if="auth.can('homework:write')" @click="openBulk">批量布置</el-button>
</div>
</div>
@@ -565,8 +571,10 @@ onUnmounted(() => {
<el-table-column label="操作" width="210" 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="removeHomework(row)">删除</el-button>
<el-button v-if="canEditContent(row)" link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button v-if="canEditContent(row)" link type="danger" @click="removeHomework(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
@@ -715,7 +723,7 @@ onUnmounted(() => {
已完成 {{ completedMap.done }} / {{ completedMap.total }} 人({{ completedMap.rate }}%)
<el-progress :percentage="completedMap.rate" :stroke-width="10" class="mt-12" />
</el-descriptions-item>
<el-descriptions-item label="未交名单" :span="2">
<el-descriptions-item v-if="current && canEditContent(current)" label="未交名单" :span="2">
<div v-loading="submissionsLoading" class="submission-summary">
<template v-if="submissionStudents.length === 0">该班还没有学生账号</template>
<template v-else-if="notSubmittedNames.length === 0">
@@ -0,0 +1,15 @@
<script setup lang="ts">
import { useAuthStore } from '@/stores/auth';
import { useRouter } from 'vue-router';
const auth = useAuthStore();
const router = useRouter();
async function logout() {
await auth.logout();
await router.replace('/login');
}
</script>
<template>
<el-result icon="info" title="暂无可访问的功能" sub-title="请联系管理员检查模板与分配范围。">
<template #extra><el-button @click="logout">退出登录</el-button></template>
</el-result>
</template>
File diff suppressed because it is too large. Load diff
@@ -29,12 +29,16 @@ const importVisible = ref(false),
search = ref(''),
activePoint = ref('');
const fileInput = ref<HTMLInputElement>();
const canTime = computed(() => auth.permissions.includes('timetable:manage'));
const canWrite = computed(
const canCreateTime = computed(
() =>
auth.can('schedule:write') &&
(auth.role === 'ADMIN' || classes.value.find((c) => c.id === classId.value)?.teacherId === auth.user?.id),
auth.can('timetable:manage') ||
(auth.can('schedule:write') &&
classes.value.find((c) => c.id === classId.value)?.teacher?.id === auth.user?.id),
);
const canTime = computed(
() => auth.can('timetable:manage') || (canCreateTime.value && layout.value?.classId === classId.value),
);
const canWrite = computed(() => auth.canManageClass('schedule:write', classId.value));
const classes = ref<ClassDto[]>([]),
courses = ref<CourseDto[]>([]);
const classId = ref(''),
@@ -172,11 +176,11 @@ watch(
{ flush: 'sync' },
);
async function newLayout() {
if (!canTime.value || saving.value) return;
if (!canCreateTime.value || saving.value) return;
const { value } = await ElMessageBox.prompt('输入时间表名称', '新时间表');
const result = await api.post<TimeLayoutDetailDto>('/time-layouts', {
name: value,
classId: classId.value,
classId: classId.value || null,
points: [],
});
selectedLayout.value = result.id;
@@ -974,10 +978,18 @@ function selectPlanItem(id: string) {
</el-button>
</template>
<div class="transfer-menu">
<el-button text :disabled="!canWrite || !classId" @click="openImport">
<el-button
text
:disabled="!canWrite || !auth.can('import:schedule') || !classId"
@click="openImport"
>
导入 ClassIsland 档案
</el-button>
<el-button text :disabled="!canWrite || !classId" @click="fileInput?.click()">
<el-button
text
:disabled="!canWrite || !auth.can('import:schedule') || !classId"
@click="fileInput?.click()"
>
导入 JSON
</el-button>
<el-button text :disabled="!classId" @click="exportArchive">导出完整档案</el-button>
@@ -1427,6 +1439,7 @@ function selectPlanItem(id: string) {
:layouts="layouts"
:courses="courses"
:can-edit="canTime"
:can-create="canCreateTime"
:saving="saving"
:plan-count="layoutPlanCount"
@select-layout="selectedLayout = $event"
@@ -0,0 +1,15 @@
<script setup lang="ts">
import PromotionSettings from '@/components/PromotionSettings.vue';
</script>
<template>
<div class="page">
<div class="page-header">
<div>
<h2 class="page-title">学年升级</h2>
<p class="page-subtitle">维护年度升级计划与学段轨道,执行前先预览影响并确认备份。</p>
</div>
</div>
<PromotionSettings />
</div>
</template>
@@ -31,12 +31,7 @@ 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 canManageSchedule = computed(() => auth.canManageClass('schedule:write', query.classId));
const loading = ref(false);
const classes = ref<ClassDto[]>([]);
+37 -11
View File
@@ -20,7 +20,11 @@ import { callApi, classApi, studentApi } from '@/api';
import TableImportDialog from '@/components/TableImportDialog.vue';
const auth = useAuthStore();
const readOnly = computed(() => !auth.can('roster:manage'));
const readOnly = computed(() =>
filter.classId
? !auth.canManageClass('roster:manage', filter.classId)
: !classes.value.some((row) => auth.canManageClass('roster:manage', row.id)),
);
const loading = ref(false);
const students = ref<StudentDto[]>([]);
const classes = ref<ClassDto[]>([]);
@@ -54,6 +58,12 @@ function exportRoster() {
URL.revokeObjectURL(url);
}
const classOptions = computed(() => classes.value);
const managedClassOptions = computed(() =>
classes.value.filter((row) => auth.canManageClass('roster:manage', row.id)),
);
function canSelectStudent(row: StudentDto): boolean {
return auth.canManageClass('roster:manage', row.classId) && row.status === 'active' && !row.archivedYearId;
}
async function loadClasses(): Promise<void> {
classes.value = auth.can('class:read') ? await classApi.list() : [];
@@ -404,7 +414,7 @@ async function submitCall(): Promise<void> {
<el-button v-if="!readOnly" @click="openIn()">转入学生</el-button>
<el-button v-if="!readOnly" type="primary" :icon="'Plus'" @click="openCreate">新建学生</el-button>
<el-button
v-if="!readOnly && auth.can('import:students')"
v-if="!readOnly && filter.classId && auth.canManageClass('import:students', filter.classId)"
type="warning"
:icon="'Document'"
@click="openImport"
@@ -448,7 +458,7 @@ async function submitCall(): Promise<void> {
empty-text="暂无学生数据"
@selection-change="(rows: StudentDto[]) => (selection = rows)"
>
<el-table-column v-if="!readOnly" type="selection" width="46" />
<el-table-column v-if="!readOnly" type="selection" width="46" :selectable="canSelectStudent" />
<el-table-column prop="studentNo" label="学号" width="130" />
<el-table-column prop="name" label="姓名" width="110" />
<el-table-column label="性别" width="80">
@@ -478,7 +488,7 @@ async function submitCall(): Promise<void> {
<el-table-column label="操作" width="370" fixed="right">
<template #default="{ row }">
<el-button
v-if="auth.can('call:create') && row.status === 'active'"
v-if="auth.canForClass('call:create', row.classId) && row.status === 'active'"
link
type="success"
@click="openCall(row)"
@@ -486,7 +496,11 @@ async function submitCall(): Promise<void> {
叫人
</el-button>
<el-button
v-if="!readOnly && row.status === 'active' && !row.archivedYearId"
v-if="
auth.canManageClass('roster:manage', row.classId) &&
row.status === 'active' &&
!row.archivedYearId
"
link
type="primary"
@click="openTransfer([row])"
@@ -495,7 +509,11 @@ async function submitCall(): Promise<void> {
</el-button>
<el-button link @click="openHistory(row)">生命周期历史</el-button>
<el-button
v-if="!readOnly && ['active', 'inactive'].includes(row.status) && !row.archivedYearId"
v-if="
auth.canManageClass('roster:manage', row.classId) &&
['active', 'inactive'].includes(row.status) &&
!row.archivedYearId
"
link
type="primary"
@click="openEdit(row)"
@@ -503,7 +521,11 @@ async function submitCall(): Promise<void> {
编辑
</el-button>
<el-button
v-if="!readOnly && row.status === 'active' && !row.archivedYearId"
v-if="
auth.canManageClass('roster:manage', row.classId) &&
row.status === 'active' &&
!row.archivedYearId
"
link
type="danger"
@click="removeStudent(row)"
@@ -521,7 +543,11 @@ async function submitCall(): Promise<void> {
删除空记录
</el-button>
<el-button
v-if="!readOnly && ['transferred', 'inactive'].includes(row.status) && !row.archivedYearId"
v-if="
auth.canManageClass('roster:manage', row.classId) &&
['transferred', 'inactive'].includes(row.status) &&
!row.archivedYearId
"
link
type="primary"
@click="openIn(row)"
@@ -556,7 +582,7 @@ async function submitCall(): Promise<void> {
style="width: 100%"
>
<el-option
v-for="item in classOptions"
v-for="item in managedClassOptions"
:key="item.id"
:label="`${item.name} / ${item.grade}`"
:value="item.id"
@@ -604,7 +630,7 @@ async function submitCall(): Promise<void> {
<el-form-item label="目标在读班级" required>
<el-select v-model="inForm.toClassId">
<el-option
v-for="item in classOptions"
v-for="item in managedClassOptions"
:key="item.id"
:label="`${item.name} / ${item.grade}`"
:value="item.id"
@@ -729,7 +755,7 @@ async function submitCall(): Promise<void> {
<el-form-item v-if="!editingId" label="班级">
<el-select v-model="form.classId" placeholder="暂不分班" clearable style="width: 100%">
<el-option
v-for="item in classOptions"
v-for="item in managedClassOptions"
:key="item.id"
:label="`${item.name} / ${item.grade}`"
:value="item.id"
+23 -19
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
/**
* 学期周次(仅管理员)。
* 学期周次(按班级范围读取,管理员或本班班主任维护)。
*
* 为什么要有这一页:课表、作业、成绩都按**教学周**组织,而"开学日期 + 每周七天"的
* 线性推算遇到法定节假日调休、周末补课、错峰开学就会**整体错位**(错一周全错)。
@@ -13,7 +13,6 @@
* `classId` 留空 = 全校默认;某个班有自己的配置时优先用它。
*/
import { useAuthStore } from '@/stores/auth';
import PromotionSettings from '@/components/PromotionSettings.vue';
import { computed, onMounted, ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import {
@@ -39,6 +38,13 @@ const weeks = ref<TermWeekDto[]>([]);
const termStartDate = ref('');
const maxTermWeeks = ref(getMaxTermWeeks());
const canEdit = computed(() =>
scope.value
? auth.canForClass('class:settings', scope.value) &&
(auth.isAdmin || classes.value.find((c) => c.id === scope.value)?.teacher?.id === auth.user?.id)
: auth.can('term:write'),
);
const scopeLabel = computed(() =>
scope.value ? (classes.value.find((item) => item.id === scope.value)?.name ?? scope.value) : '全校默认',
);
@@ -171,16 +177,20 @@ async function fetchHolidays(): Promise<void> {
}
onMounted(async () => {
classes.value = await classApi.list().catch(() => []);
classes.value = (await classApi.list().catch(() => [])).filter((row) =>
auth.canForClass('term:read', row.id),
);
if (!auth.can('term:write')) {
scope.value = classes.value.find((row) => auth.canForClass('class:settings', row.id))?.id ?? '';
}
await load();
});
</script>
<template>
<div class="page">
<PromotionSettings v-if="auth.can('promotion:manage')" />
<el-form-item v-if="!scope" label="全校学期总周数">
<el-input-number v-model="maxTermWeeks" :min="1" :max="60" />
<el-input-number v-model="maxTermWeeks" :min="1" :max="60" :disabled="!canEdit" />
</el-form-item>
<div class="page-header">
<div>
@@ -212,13 +222,7 @@ onMounted(async () => {
>
拉取放假安排
</el-button>
<el-button
v-if="auth.can('term:write')"
type="primary"
:loading="saving"
:disabled="!dirty"
@click="save"
>
<el-button v-if="canEdit" type="primary" :loading="saving" :disabled="!dirty" @click="save">
保存
</el-button>
</div>
@@ -240,12 +244,13 @@ onMounted(async () => {
<span class="label">学期开始日期</span>
<el-date-picker
v-model="termStartDate"
:disabled="!canEdit"
type="date"
value-format="YYYY-MM-DD"
placeholder="第 1 教学周的周一"
style="width: 200px"
/>
<el-button v-if="auth.can('term:write')" type="primary" plain :loading="saving" @click="autoGenerate">
<el-button v-if="canEdit" type="primary" plain :loading="saving" @click="autoGenerate">
按开学日期生成
</el-button>
<span class="hint">先生成一份标准学期,再把调休那几周改掉 —— 比一周周填快得多</span>
@@ -272,9 +277,7 @@ onMounted(async () => {
<template #header>
<div class="card-header">
<span>逐周日期区间({{ weeks.length }} 周)</span>
<el-button v-if="auth.can('term:write')" size="small" :icon="'Plus'" @click="addWeek">
添加一周
</el-button>
<el-button v-if="canEdit" size="small" :icon="'Plus'" @click="addWeek">添加一周</el-button>
</div>
</template>
@@ -283,6 +286,7 @@ onMounted(async () => {
<template #default="{ row }">
<el-input-number
v-model="row.weekNumber"
:disabled="!canEdit"
:min="1"
:max="60"
size="small"
@@ -296,6 +300,7 @@ onMounted(async () => {
el-date-picker 的 daterange 给的是一个数组,直接 v-model 会写到一个不存在的 range 上 -->
<el-date-picker
:model-value="row.startDate && row.endDate ? [row.startDate, row.endDate] : null"
:disabled="!canEdit"
type="daterange"
value-format="YYYY-MM-DD"
range-separator="→"
@@ -316,6 +321,7 @@ onMounted(async () => {
<template #default="{ row }">
<el-input
v-model="row.note"
:disabled="!canEdit"
size="small"
maxlength="64"
placeholder="例如:国庆调休、期中考试周"
@@ -324,9 +330,7 @@ onMounted(async () => {
</el-table-column>
<el-table-column label="操作" width="90">
<template #default="{ $index }">
<el-button v-if="auth.can('term:write')" link type="danger" @click="removeWeek($index)">
删除
</el-button>
<el-button v-if="canEdit" link type="danger" @click="removeWeek($index)">删除</el-button>
</template>
</el-table-column>
</el-table>