feat(authz): 重写权限模板分配与班级授权边界
拆分能力、分配范围和业务关系,固定管理员能力并收紧组织级写入口。 补齐内容、课表、设备会话和实时通道授权,兼容旧库分配及历史快照。 重写权限分配预览,按实际可操作范围展示维护入口,合并学生入口并独立学年升级页。 增加授权攻击矩阵及静态门禁,更新隔离回归与开发契约。 验证:授权矩阵 23 段通过,Node 24 下 Web 63/63,类型检查、lint 与文档严格构建通过。
This commit is contained in:
1 parent
682818dd4a
commit
66be3a63b1
115 files changed
+4463
-2747
No files matched your search
@@ -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
|
||||
|
||||
@@ -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'] },
|
||||
{
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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[];
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
一键推送更新
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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[]>([]);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user