From b6fca99390b79096b8c57b643046d34900d7cd9e Mon Sep 17 00:00:00 2001 From: wangziqi Date: Sat, 11 Jul 2026 14:55:49 +0800 Subject: [PATCH] fix: align permission navigation and page access --- apps/admin/src/App.tsx | 5 +- .../permission-navigation.integration.test.ts | 28 ++++++++++ apps/admin/src/auth/permission-navigation.ts | 53 ++++++++++++++++++ .../auth/permission-tabs.integration.test.ts | 19 +++++++ apps/admin/src/auth/permission-tabs.ts | 13 +++++ apps/admin/src/components/DefaultRoute.tsx | 14 +++++ apps/admin/src/components/PermissionRoute.tsx | 17 ++++-- apps/admin/src/layouts/MainLayout.tsx | 2 +- .../src/pages/ClassroomRentals/index.tsx | 6 ++- apps/admin/src/pages/Deposits/index.tsx | 39 ++++++++------ apps/admin/src/pages/Expenses/index.tsx | 9 ++-- .../src/pages/IntegrationConfig/index.tsx | 9 ++-- apps/admin/src/pages/Login/index.tsx | 6 ++- apps/admin/src/pages/Permissions/index.tsx | 11 ++++ apps/admin/src/pages/Schedules/index.tsx | 18 ++++--- .../src/deposits/deposits.controller.ts | 6 +++ .../src/deposits/deposits.lookups.spec.ts | 15 ++++++ apps/server/src/deposits/deposits.service.ts | 8 +++ .../src/expenses/expenses.controller.ts | 6 +++ .../src/expenses/expenses.lookups.spec.ts | 25 +++++++++ apps/server/src/expenses/expenses.service.ts | 15 ++++++ .../config/integration-config.controller.ts | 2 +- apps/server/src/rbac/rbac.permissions.spec.ts | 25 +++++++-- .../src/schedules/schedules.controller.ts | 10 ++++ .../src/schedules/schedules.lookups.spec.ts | 31 +++++++++++ .../server/src/schedules/schedules.service.ts | 37 ++++++++++++- docs/permission-matrix.md | 54 +++++++++++++++++++ 27 files changed, 434 insertions(+), 49 deletions(-) create mode 100644 apps/admin/src/auth/permission-navigation.integration.test.ts create mode 100644 apps/admin/src/auth/permission-navigation.ts create mode 100644 apps/admin/src/auth/permission-tabs.integration.test.ts create mode 100644 apps/admin/src/auth/permission-tabs.ts create mode 100644 apps/admin/src/components/DefaultRoute.tsx create mode 100644 apps/server/src/deposits/deposits.lookups.spec.ts create mode 100644 apps/server/src/expenses/expenses.lookups.spec.ts create mode 100644 apps/server/src/schedules/schedules.lookups.spec.ts create mode 100644 docs/permission-matrix.md diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx index 15007b0..6863893 100644 --- a/apps/admin/src/App.tsx +++ b/apps/admin/src/App.tsx @@ -4,6 +4,7 @@ import { ConfigProvider, App as AntdApp, Spin } from 'antd'; import zhCN from 'antd/es/locale/zh_CN'; import MainLayout from './layouts/MainLayout'; import PermissionRoute from './components/PermissionRoute'; +import DefaultRoute from './components/DefaultRoute'; import AppMessageBridge from './ui/AppMessageBridge'; const LoginPage = lazy(() => import('./pages/Login')); @@ -67,7 +68,7 @@ const App: React.FC = () => { } > - } /> + } /> { + } diff --git a/apps/admin/src/auth/permission-navigation.integration.test.ts b/apps/admin/src/auth/permission-navigation.integration.test.ts new file mode 100644 index 0000000..a730368 --- /dev/null +++ b/apps/admin/src/auth/permission-navigation.integration.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { + findFirstAccessiblePath, + getRequiredPermission, + canAccessPath, +} from './permission-navigation'; + +describe('permission navigation', () => { + it('does not default to dashboard when dashboard permission is absent', () => { + const permissions = ['class:view', 'schedule:view']; + expect(findFirstAccessiblePath(permissions)).toBe('/classes'); + }); + + it('uses dashboard when it is the first accessible page', () => { + expect(findFirstAccessiblePath(['dashboard:view', 'student:view'])).toBe('/dashboard'); + }); + + it('returns null when the user has no page permissions', () => { + expect(findFirstAccessiblePath(['profile:view'])).toBeNull(); + }); + + it('keeps route permission lookup aligned for nested detail routes', () => { + expect(getRequiredPermission('/classes/12')).toBe('class:view'); + expect(getRequiredPermission('/students/8/profile')).toBe('student:view'); + expect(canAccessPath('/ai-config', ['ai:config:read'])).toBe(true); + expect(canAccessPath('/ai-config', ['integration:read'])).toBe(false); + }); +}); diff --git a/apps/admin/src/auth/permission-navigation.ts b/apps/admin/src/auth/permission-navigation.ts new file mode 100644 index 0000000..0c897f4 --- /dev/null +++ b/apps/admin/src/auth/permission-navigation.ts @@ -0,0 +1,53 @@ +export interface PermissionPage { + path: string; + permission: string; + matches?: (pathname: string) => boolean; +} + +/** + * Single source of truth for page-level navigation permissions. + * Order also defines the landing-page priority after login. + */ +export const PERMISSION_PAGES: readonly PermissionPage[] = [ + { path: '/dashboard', permission: 'dashboard:view' }, + { path: '/room-visual', permission: 'room:view' }, + { path: '/rooms', permission: 'room:view' }, + { path: '/occupancies', permission: 'occupancy:view' }, + { path: '/students', permission: 'student:view', matches: (p) => p === '/students' || /^\/students\/\d+\/profile$/.test(p) }, + { path: '/classes', permission: 'class:view', matches: (p) => p === '/classes' || /^\/classes\/\d+$/.test(p) }, + { path: '/attendance', permission: 'attendance:view' }, + { path: '/schedules', permission: 'schedule:view' }, + { path: '/teacher-workspace', permission: 'class:view' }, + { path: '/classroom-schedule', permission: 'rental:view' }, + { path: '/classrooms', permission: 'classroom:view' }, + { path: '/classroom-rentals', permission: 'rental:view' }, + { path: '/organizations', permission: 'organization:view' }, + { path: '/expenses', permission: 'expense:view' }, + { path: '/deposits', permission: 'deposit:view' }, + { path: '/bills', permission: 'bill:view' }, + { path: '/notifications', permission: 'notification:view' }, + { path: '/operation-logs', permission: 'log:view' }, + { path: '/roles', permission: 'role:view' }, + { path: '/permissions', permission: 'role:view' }, + { path: '/integration-config', permission: 'integration:read' }, + { path: '/ai-config', permission: 'ai:config:read' }, + { path: '/users', permission: 'user:view' }, + { path: '/teachers', permission: 'user:view' }, +] as const; + +function matchesPage(page: PermissionPage, pathname: string): boolean { + return page.matches ? page.matches(pathname) : page.path === pathname; +} + +export function getRequiredPermission(pathname: string): string | null { + return PERMISSION_PAGES.find((page) => matchesPage(page, pathname))?.permission ?? null; +} + +export function canAccessPath(pathname: string, permissions: readonly string[]): boolean { + const required = getRequiredPermission(pathname); + return required === null || permissions.includes(required); +} + +export function findFirstAccessiblePath(permissions: readonly string[]): string | null { + return PERMISSION_PAGES.find((page) => permissions.includes(page.permission))?.path ?? null; +} diff --git a/apps/admin/src/auth/permission-tabs.integration.test.ts b/apps/admin/src/auth/permission-tabs.integration.test.ts new file mode 100644 index 0000000..14f1065 --- /dev/null +++ b/apps/admin/src/auth/permission-tabs.integration.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +import { filterTabsByPermission } from './permission-tabs'; + +describe('permission-aware tabs', () => { + const tabs = [ + { key: 'all', requiredPermission: 'deposit:view' }, + { key: 'pending', requiredPermission: 'deposit:approve' }, + ]; + + it('hides tabs whose backing API permission is missing', () => { + expect(filterTabsByPermission(tabs, ['deposit:view']).map((tab) => tab.key)).toEqual(['all']); + }); + + it('shows a privileged tab only when its permission is present', () => { + expect( + filterTabsByPermission(tabs, ['deposit:view', 'deposit:approve']).map((tab) => tab.key), + ).toEqual(['all', 'pending']); + }); +}); diff --git a/apps/admin/src/auth/permission-tabs.ts b/apps/admin/src/auth/permission-tabs.ts new file mode 100644 index 0000000..e6896ef --- /dev/null +++ b/apps/admin/src/auth/permission-tabs.ts @@ -0,0 +1,13 @@ +export interface PermissionTab { + key: string; + requiredPermission?: string; +} + +export function filterTabsByPermission( + tabs: readonly T[], + permissions: readonly string[], +): T[] { + return tabs.filter( + (tab) => !tab.requiredPermission || permissions.includes(tab.requiredPermission), + ); +} diff --git a/apps/admin/src/components/DefaultRoute.tsx b/apps/admin/src/components/DefaultRoute.tsx new file mode 100644 index 0000000..df1aacf --- /dev/null +++ b/apps/admin/src/components/DefaultRoute.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { Navigate } from 'react-router-dom'; +import { Result } from 'antd'; +import { usePermission } from '../hooks/usePermission'; +import { findFirstAccessiblePath } from '../auth/permission-navigation'; + +const DefaultRoute: React.FC = () => { + const { permissions } = usePermission(); + const firstPath = findFirstAccessiblePath(permissions); + if (firstPath) return ; + return ; +}; + +export default DefaultRoute; diff --git a/apps/admin/src/components/PermissionRoute.tsx b/apps/admin/src/components/PermissionRoute.tsx index 0e24234..8219272 100644 --- a/apps/admin/src/components/PermissionRoute.tsx +++ b/apps/admin/src/components/PermissionRoute.tsx @@ -1,5 +1,7 @@ import React from 'react'; -import { Result } from 'antd'; +import { Result, Button } from 'antd'; +import { useNavigate } from 'react-router-dom'; +import { findFirstAccessiblePath } from '../auth/permission-navigation'; import { usePermission } from '../hooks/usePermission'; interface PermissionRouteProps { @@ -8,9 +10,18 @@ interface PermissionRouteProps { } const PermissionRoute: React.FC = ({ permission, children }) => { - const { hasPermission } = usePermission(); + const { permissions, hasPermission } = usePermission(); + const navigate = useNavigate(); if (!hasPermission(permission)) { - return ; + const firstPath = findFirstAccessiblePath(permissions); + return ( + navigate(firstPath, { replace: true })}>前往可访问页面 : undefined} + /> + ); } return <>{children}; }; diff --git a/apps/admin/src/layouts/MainLayout.tsx b/apps/admin/src/layouts/MainLayout.tsx index ce94223..057d611 100644 --- a/apps/admin/src/layouts/MainLayout.tsx +++ b/apps/admin/src/layouts/MainLayout.tsx @@ -88,7 +88,7 @@ const allMenuItems: MenuItemType[] = [ label: '教室管理', permission: 'classroom:view', children: [ - { key: '/classroom-schedule', icon: , label: '排期总览', permission: 'classroom:view' }, + { key: '/classroom-schedule', icon: , label: '排期总览', permission: 'rental:view' }, { key: '/classrooms', icon: , label: '教室列表', permission: 'classroom:view' }, { key: '/classroom-rentals', icon: , label: '租赁订单', permission: 'rental:view' }, { key: '/organizations', icon: , label: '机构管理', permission: 'organization:view' }, diff --git a/apps/admin/src/pages/ClassroomRentals/index.tsx b/apps/admin/src/pages/ClassroomRentals/index.tsx index 4baa18b..08d7451 100644 --- a/apps/admin/src/pages/ClassroomRentals/index.tsx +++ b/apps/admin/src/pages/ClassroomRentals/index.tsx @@ -21,6 +21,7 @@ import api from '../../api'; import { downloadBlob } from '../../utils/download'; import PermissionButton from '../../components/PermissionButton'; import { message } from '../../ui/app-message'; +import { usePermission } from '../../hooks/usePermission'; interface UnavailableDatesResponse { dates: string[]; @@ -29,6 +30,7 @@ export const unavailableDatesCacheKey = (classroomId: number, date: Dayjs) => `${classroomId}:${date.format('YYYY-MM')}`; const ClassroomRentalsPage: React.FC = () => { + const { hasAnyPermission } = usePermission(); const [data, setData] = useState([]); const [classrooms, setClassrooms] = useState([]); const [organizations, setOrganizations] = useState([]); @@ -82,8 +84,8 @@ const ClassroomRentalsPage: React.FC = () => { }; useEffect(() => { - fetchMeta(); - }, []); + if (hasAnyPermission('rental:create', 'rental:edit')) fetchMeta(); + }, [hasAnyPermission]); useEffect(() => { fetchData(); }, [filterMonth]); diff --git a/apps/admin/src/pages/Deposits/index.tsx b/apps/admin/src/pages/Deposits/index.tsx index b66ba27..ee75d90 100644 --- a/apps/admin/src/pages/Deposits/index.tsx +++ b/apps/admin/src/pages/Deposits/index.tsx @@ -21,6 +21,7 @@ import api from '../../api'; import { maskPhone, maskIdNumber } from '../../utils/sensitive'; import PermissionButton from '../../components/PermissionButton'; import { message } from '../../ui/app-message'; +import { usePermission } from '../../hooks/usePermission'; const statusMap: Record = { paid: { text: '已缴', color: 'green' }, @@ -51,6 +52,7 @@ interface PendingRefund { } const DepositsPage: React.FC = () => { + const { hasPermission } = usePermission(); const [data, setData] = useState([]); const [students, setStudents] = useState([]); const [loading, setLoading] = useState(false); @@ -73,7 +75,10 @@ const DepositsPage: React.FC = () => { const fetchData = async () => { setLoading(true); try { - const [d, s]: any[] = await Promise.all([api.get('/deposits'), api.get('/students')]); + const [d, s]: any[] = await Promise.all([ + api.get('/deposits'), + hasPermission('deposit:create') ? api.get('/deposits/student-lookups') : Promise.resolve([]), + ]); setData(d); setStudents(s); } catch (e: any) { @@ -95,7 +100,7 @@ const DepositsPage: React.FC = () => { useEffect(() => { fetchData(); - }, []); + }, [hasPermission]); const filteredData = useMemo(() => { return data.filter((d: any) => { @@ -444,20 +449,22 @@ const DepositsPage: React.FC = () => { ), }, - { - key: 'pending', - label: '待审批退款', - children: ( - `共 ${total} 条` }} - /> - ), - }, + ...(hasPermission('deposit:approve') + ? [{ + key: 'pending', + label: '待审批退款', + children: ( +
`共 ${total} 条` }} + /> + ), + }] + : []), ]} /> diff --git a/apps/admin/src/pages/Expenses/index.tsx b/apps/admin/src/pages/Expenses/index.tsx index 61b5875..9411be6 100644 --- a/apps/admin/src/pages/Expenses/index.tsx +++ b/apps/admin/src/pages/Expenses/index.tsx @@ -114,16 +114,15 @@ const ExpensesPage: React.FC = () => { const fetchData = useCallback(async () => { setLoading(true); try { - const [re, pe, rm, st]: any[] = await Promise.all([ + const [re, pe, lookups]: any[] = await Promise.all([ api.get('/expenses/room'), api.get('/expenses/personal'), - api.get('/rooms'), - api.get('/students'), + api.get('/expenses/lookups').catch(() => ({ rooms: [], students: [] })), ]); setRoomExpenses(re); setPersonalExpenses(pe); - setRooms(rm); - setStudents(st); + setRooms(lookups.rooms || []); + setStudents(lookups.students || []); } catch (e: any) { message.error(e?.message || '加载失败,请稍后重试'); } diff --git a/apps/admin/src/pages/IntegrationConfig/index.tsx b/apps/admin/src/pages/IntegrationConfig/index.tsx index cff18dc..ae0e7d2 100644 --- a/apps/admin/src/pages/IntegrationConfig/index.tsx +++ b/apps/admin/src/pages/IntegrationConfig/index.tsx @@ -12,6 +12,8 @@ import type { DataNode } from 'antd/es/tree'; import type { TreeSelectProps } from 'antd/es/tree-select'; import api from '../../api'; import { message } from '../../ui/app-message'; +import { usePermission } from '../../hooks/usePermission'; +import PermissionButton from '../../components/PermissionButton'; interface DingTalkConfig { agentId: string; @@ -63,6 +65,7 @@ interface ImportResult { } const IntegrationConfigPage: React.FC = () => { + const { hasAllPermissions } = usePermission(); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); const [testing, setTesting] = useState(false); @@ -279,7 +282,7 @@ const IntegrationConfigPage: React.FC = () => { } }; - const syncTabItems = config + const syncTabItems = config && hasAllPermissions('sync:read', 'class:view', 'class:edit') ? [ { key: 'sync-users', @@ -461,9 +464,9 @@ const IntegrationConfigPage: React.FC = () => { - + diff --git a/apps/admin/src/pages/Login/index.tsx b/apps/admin/src/pages/Login/index.tsx index 44d7d28..aea98a5 100644 --- a/apps/admin/src/pages/Login/index.tsx +++ b/apps/admin/src/pages/Login/index.tsx @@ -5,6 +5,7 @@ import { UserOutlined, LockOutlined } from '@ant-design/icons'; import api from '../../api'; import { message } from '../../ui/app-message'; import { writePermissions } from '../../auth/permission-store'; +import { findFirstAccessiblePath } from '../../auth/permission-navigation'; const { Title } = Typography; @@ -18,9 +19,10 @@ const LoginPage: React.FC = () => { const res: any = await api.post('/auth/login', values); localStorage.setItem('token', res.access_token); localStorage.setItem('user', JSON.stringify(res.user)); - writePermissions(res.user.permissions || []); + const permissions = res.user.permissions || []; + writePermissions(permissions); message.success('登录成功'); - navigate('/dashboard'); + navigate(findFirstAccessiblePath(permissions) || '/', { replace: true }); } catch (err: any) { message.error(err?.message || '登录失败'); } finally { diff --git a/apps/admin/src/pages/Permissions/index.tsx b/apps/admin/src/pages/Permissions/index.tsx index b34efbb..15f0f89 100644 --- a/apps/admin/src/pages/Permissions/index.tsx +++ b/apps/admin/src/pages/Permissions/index.tsx @@ -30,6 +30,17 @@ const PermissionsPage: React.FC = () => { log: '操作日志', user: '用户管理', role: '角色管理', + class: '班级管理', + schedule: '排课管理', + attendance: '考勤管理', + learning: '学习记录', + exam: '考试管理', + sync: '数据同步', + integration: '集成配置', + department: '部门管理', + notification: '通知中心', + profile: '个人资料', + ai: 'AI 模型配置', }; useEffect(() => { diff --git a/apps/admin/src/pages/Schedules/index.tsx b/apps/admin/src/pages/Schedules/index.tsx index 75fc8f5..76ff1e3 100644 --- a/apps/admin/src/pages/Schedules/index.tsx +++ b/apps/admin/src/pages/Schedules/index.tsx @@ -34,6 +34,7 @@ import { import dayjs, { Dayjs } from 'dayjs'; import api from '../../api'; import PermissionButton from '../../components/PermissionButton'; +import { usePermission } from '../../hooks/usePermission'; import { message } from '../../ui/app-message'; import { buildSchedulePayload, @@ -99,6 +100,7 @@ const WEEKDAY_NUMBERS = [1, 2, 3, 4, 5, 6, 7]; // ---- Component ---- const SchedulesPage: React.FC = () => { + const { hasPermission } = usePermission(); // View mode and navigation const [viewMode, setViewMode] = useState<'week' | 'month'>('week'); const [viewDate, setViewDate] = useState(() => dayjs().weekday(1).startOf('day')); @@ -235,9 +237,8 @@ const SchedulesPage: React.FC = () => { const fetchData = useCallback(async () => { setLoading(true); try { - const [classroomsRes, classesRes, schedulesRes] = await Promise.all([ - api.get('/classrooms') as Promise, - api.get('/classes') as Promise, + const [lookups, schedulesRes] = await Promise.all([ + api.get('/class-schedules/lookups') as Promise<{ classrooms: ClassroomItem[]; classes: ClassItem[] }>, api.get('/class-schedules/weekly', { params: { startDate: startDateStr, @@ -247,8 +248,8 @@ const SchedulesPage: React.FC = () => { }) as Promise>>, ]); - setClassrooms(classroomsRes); - setClasses(classesRes); + setClassrooms(lookups.classrooms); + setClasses(lookups.classes); // Convert string keys to numbers const typedMatrix: Record> = {}; @@ -326,7 +327,7 @@ const SchedulesPage: React.FC = () => { setSelectedSchedules(schedules); setModalMode('detail'); setModalOpen(true); - } else { + } else if (hasPermission('schedule:create')) { setSelectedSchedules([]); setEditingSchedule(null); setModalMode('create'); @@ -948,7 +949,8 @@ const SchedulesPage: React.FC = () => { }} > 已有排课 - + {selectedSchedules.length === 0 ? ( diff --git a/apps/server/src/deposits/deposits.controller.ts b/apps/server/src/deposits/deposits.controller.ts index 71998a2..b31e808 100644 --- a/apps/server/src/deposits/deposits.controller.ts +++ b/apps/server/src/deposits/deposits.controller.ts @@ -32,6 +32,12 @@ export class DepositsController { @InjectRepository(Student) private studentRepo: Repository, ) {} + @Get('student-lookups') + @RequirePermission('deposit:create') + getStudentLookups() { + return this.service.getStudentLookups(); + } + @Get() @RequirePermission('deposit:view') findAll(@Query('studentId') studentId?: string, @Query('status') status?: string) { diff --git a/apps/server/src/deposits/deposits.lookups.spec.ts b/apps/server/src/deposits/deposits.lookups.spec.ts new file mode 100644 index 0000000..c6f2e7e --- /dev/null +++ b/apps/server/src/deposits/deposits.lookups.spec.ts @@ -0,0 +1,15 @@ +import { DepositsService } from './deposits.service'; + +describe('DepositsService permission-scoped lookups', () => { + it('returns only minimal student fields needed by deposit forms', async () => { + const studentRepo = { + find: jest.fn().mockResolvedValue([{ id: 2, name: '张三', studentNo: 'S2' }]), + }; + const service = new DepositsService({} as never, {} as never, studentRepo as never); + + await expect(service.getStudentLookups()).resolves.toEqual([ + { id: 2, name: '张三', studentNo: 'S2' }, + ]); + expect(studentRepo.find).toHaveBeenCalledWith(expect.objectContaining({ select: ['id', 'name', 'studentNo'] })); + }); +}); diff --git a/apps/server/src/deposits/deposits.service.ts b/apps/server/src/deposits/deposits.service.ts index 18a1134..b2a096f 100644 --- a/apps/server/src/deposits/deposits.service.ts +++ b/apps/server/src/deposits/deposits.service.ts @@ -18,6 +18,14 @@ export class DepositsService { private studentRepo: Repository, ) {} + async getStudentLookups() { + return this.studentRepo.find({ + select: ['id', 'name', 'studentNo'], + where: { status: 'active' }, + order: { name: 'ASC' }, + }); + } + async findAll(query?: { studentId?: number; status?: string }) { const qb = this.repo .createQueryBuilder('d') diff --git a/apps/server/src/expenses/expenses.controller.ts b/apps/server/src/expenses/expenses.controller.ts index 93d12d5..6b91120 100644 --- a/apps/server/src/expenses/expenses.controller.ts +++ b/apps/server/src/expenses/expenses.controller.ts @@ -72,6 +72,12 @@ export class ExpensesController { private logService: OperationLogsService, ) {} + @Get('lookups') + @RequirePermission('expense:create', 'expense:edit') + getFormLookups() { + return this.service.getFormLookups(); + } + @Post('room') @RequirePermission('expense:create') async createRoomExpense(@Body() dto: CreateRoomExpenseDto, @Request() req: any) { diff --git a/apps/server/src/expenses/expenses.lookups.spec.ts b/apps/server/src/expenses/expenses.lookups.spec.ts new file mode 100644 index 0000000..25d2d9f --- /dev/null +++ b/apps/server/src/expenses/expenses.lookups.spec.ts @@ -0,0 +1,25 @@ +import { ExpensesService } from './expenses.service'; + +describe('ExpensesService permission-scoped lookups', () => { + it('returns only minimal room and student fields needed by expense forms', async () => { + const roomRepo = { + find: jest.fn().mockResolvedValue([{ id: 1, roomNumber: '101', building: 'A' }]), + }; + const studentRepo = { + find: jest.fn().mockResolvedValue([{ id: 2, name: '张三', studentNo: 'S2' }]), + }; + const service = new ExpensesService( + {} as never, + {} as never, + roomRepo as never, + studentRepo as never, + ); + + await expect(service.getFormLookups()).resolves.toEqual({ + rooms: [{ id: 1, roomNumber: '101', building: 'A' }], + students: [{ id: 2, name: '张三', studentNo: 'S2' }], + }); + expect(roomRepo.find).toHaveBeenCalledWith(expect.objectContaining({ select: ['id', 'roomNumber', 'building'] })); + expect(studentRepo.find).toHaveBeenCalledWith(expect.objectContaining({ select: ['id', 'name', 'studentNo'] })); + }); +}); diff --git a/apps/server/src/expenses/expenses.service.ts b/apps/server/src/expenses/expenses.service.ts index 9c1cf98..c020249 100644 --- a/apps/server/src/expenses/expenses.service.ts +++ b/apps/server/src/expenses/expenses.service.ts @@ -22,6 +22,21 @@ export class ExpensesService { @InjectRepository(Student) private studentRepo: Repository, ) {} + async getFormLookups() { + const [rooms, students] = await Promise.all([ + this.roomRepo.find({ + select: ['id', 'roomNumber', 'building'], + order: { building: 'ASC', roomNumber: 'ASC' }, + }), + this.studentRepo.find({ + select: ['id', 'name', 'studentNo'], + where: { status: 'active' }, + order: { name: 'ASC' }, + }), + ]); + return { rooms, students }; + } + // 宿舍费用 async createRoomExpense(dto: CreateRoomExpenseDto, userId?: number) { const room = await this.roomRepo.findOne({ where: { id: dto.roomId } }); diff --git a/apps/server/src/integration/config/integration-config.controller.ts b/apps/server/src/integration/config/integration-config.controller.ts index fbeedab..f916927 100644 --- a/apps/server/src/integration/config/integration-config.controller.ts +++ b/apps/server/src/integration/config/integration-config.controller.ts @@ -30,7 +30,7 @@ export class IntegrationConfigController { /** 保存配置 */ @Post() - @RequirePermission('integration:read') + @RequirePermission('integration:trigger') async saveConfig(@Body() body: SaveConfigRequest) { await this.service.saveConfig(body); return { success: true, message: '配置已保存' }; diff --git a/apps/server/src/rbac/rbac.permissions.spec.ts b/apps/server/src/rbac/rbac.permissions.spec.ts index 5416f1a..a2f5543 100644 --- a/apps/server/src/rbac/rbac.permissions.spec.ts +++ b/apps/server/src/rbac/rbac.permissions.spec.ts @@ -1,12 +1,17 @@ import { PRESET_ROLES } from './rbac.service'; +function permissionsFor(roleCode: string): { groups: string[]; extras: string[] } { + const role = PRESET_ROLES.find((item) => item.code === roleCode); + if (!role) throw new Error(`missing role ${roleCode}`); + return { groups: role.permissionGroups, extras: role.extraPermissions ?? [] }; +} + describe('preset role permissions', () => { it('gives teachers explicit workspace permissions without class/schedule delete privileges', () => { - const teacher = PRESET_ROLES.find((role) => role.code === 'teacher'); + const teacher = permissionsFor('teacher'); - expect(teacher).toBeDefined(); - expect(teacher?.permissionGroups).toEqual(['notification', 'profile']); - expect(teacher?.extraPermissions).toEqual( + expect(teacher.groups).toEqual(['notification', 'profile']); + expect(teacher.extras).toEqual( expect.arrayContaining([ 'student:view', 'class:view', @@ -16,8 +21,18 @@ describe('preset role permissions', () => { 'attendance:export', ]), ); - expect(teacher?.extraPermissions).not.toEqual( + expect(teacher.extras).not.toEqual( expect.arrayContaining(['class:delete', 'schedule:delete']), ); }); + + it('gives institution heads every read permission required by the classroom rental pages', () => { + const role = permissionsFor('institution_head'); + expect(role.groups).toEqual(expect.arrayContaining(['classroom', 'rental', 'organization'])); + }); + + it('keeps roles without dashboard access off the dashboard', () => { + expect(permissionsFor('teacher').groups).not.toContain('dashboard'); + expect(permissionsFor('institution_head').groups).not.toContain('dashboard'); + }); }); diff --git a/apps/server/src/schedules/schedules.controller.ts b/apps/server/src/schedules/schedules.controller.ts index f8a9081..616f8f5 100644 --- a/apps/server/src/schedules/schedules.controller.ts +++ b/apps/server/src/schedules/schedules.controller.ts @@ -56,6 +56,16 @@ export class SchedulesController { ); } + @Get('lookups') + @RequirePermission('schedule:view') + async getLookups(@Request() req: { user: RequestUser }) { + const classIds = await this.service.getAccessibleClassIds( + req.user.id, + this.canManageAllSchedules(req), + ); + return this.service.getLookups(classIds); + } + @Get() @RequirePermission('schedule:view') async findAll(@Query() query: QueryScheduleDto, @Request() req: { user: RequestUser }) { diff --git a/apps/server/src/schedules/schedules.lookups.spec.ts b/apps/server/src/schedules/schedules.lookups.spec.ts new file mode 100644 index 0000000..99b0cc9 --- /dev/null +++ b/apps/server/src/schedules/schedules.lookups.spec.ts @@ -0,0 +1,31 @@ +import { SchedulesService } from './schedules.service'; + +describe('SchedulesService permission-scoped lookups', () => { + it('returns scoped classes and minimal classrooms for schedule viewers', async () => { + const classRepo = { + find: jest.fn().mockResolvedValue([{ id: 3, name: '三班', code: 'C3' }]), + }; + const scheduleRepo = { + createQueryBuilder: jest.fn().mockReturnValue({ + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + innerJoin: jest.fn().mockReturnThis(), + distinct: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + addOrderBy: jest.fn().mockReturnThis(), + getRawMany: jest.fn().mockResolvedValue([{ classroomId: 5, classroomName: '教室5', classroomBuilding: 'A' }]), + }), + }; + const service = new SchedulesService( + scheduleRepo as never, + classRepo as never, + {} as never, + {} as never, + ); + + await expect(service.getLookups([3])).resolves.toEqual({ + classes: [{ id: 3, name: '三班', code: 'C3' }], + classrooms: [{ id: 5, name: '教室5', building: 'A' }], + }); + }); +}); diff --git a/apps/server/src/schedules/schedules.service.ts b/apps/server/src/schedules/schedules.service.ts index 5166c8c..ab2e2ca 100644 --- a/apps/server/src/schedules/schedules.service.ts +++ b/apps/server/src/schedules/schedules.service.ts @@ -5,7 +5,7 @@ import { BadRequestException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { In, Repository } from 'typeorm'; import { ClassSchedule, Class, ClassroomRental, ClassTeacher } from '../entities'; import { @@ -33,6 +33,41 @@ export class SchedulesService { return [...new Set(assignments.map((assignment) => assignment.classId))]; } + async getLookups(accessibleClassIds?: number[]) { + const classes = accessibleClassIds + ? accessibleClassIds.length > 0 + ? await this.classRepo.find({ + where: { id: In(accessibleClassIds) }, + select: ['id', 'name', 'code'], + order: { name: 'ASC' }, + }) + : [] + : await this.classRepo.find({ + select: ['id', 'name', 'code'], + order: { name: 'ASC' }, + }); + + const classroomRows = await this.scheduleRepo + .createQueryBuilder('schedule') + .select('classroom.id', 'classroomId') + .addSelect('classroom.name', 'classroomName') + .addSelect('classroom.building', 'classroomBuilding') + .innerJoin('schedule.classroom', 'classroom') + .distinct(true) + .orderBy('classroom.building', 'ASC') + .addOrderBy('classroom.name', 'ASC') + .getRawMany(); + + return { + classes, + classrooms: classroomRows.map((row) => ({ + id: Number(row.classroomId), + name: String(row.classroomName ?? ''), + building: String(row.classroomBuilding ?? ''), + })), + }; + } + async findAll(query: QueryScheduleDto, accessibleClassIds?: number[]) { const qb = this.scheduleRepo.createQueryBuilder('cs'); diff --git a/docs/permission-matrix.md b/docs/permission-matrix.md new file mode 100644 index 0000000..f4a0f2f --- /dev/null +++ b/docs/permission-matrix.md @@ -0,0 +1,54 @@ +# 权限与默认角色矩阵 + +## 页面入口权限 + +| 页面 | 权限 | +|---|---| +| 数据面板 | `dashboard:view` | +| 宿舍总览 / 宿舍管理 | `room:view` | +| 入住管理 | `occupancy:view` | +| 学生列表 / 学生档案 | `student:view` | +| 班级列表 / 班级详情 / 教师工作台 | `class:view` | +| 考勤管理 | `attendance:view` | +| 排课管理 | `schedule:view` | +| 教室列表 | `classroom:view` | +| 排期总览 | `rental:view` | +| 租赁订单 | `rental:view` | +| 机构管理 | `organization:view` | +| 费用管理 | `expense:view` | +| 押金管理 | `deposit:view` | +| 账单管理 | `bill:view` | +| 通知中心 | `notification:view` | +| 操作日志 | `log:view` | +| 角色管理 / 权限一览 | `role:view` | +| 钉钉集成配置 | `integration:read` | +| AI 模型配置 | `ai:config:read` | +| 账号 / 教师管理 | `user:view` | + +登录后不再固定跳转 Dashboard,而是按上表顺序进入当前账号拥有权限的第一个页面。 + +## 特殊功能与 Tab + +| 功能 | 权限 | +|---|---| +| 押金“待审批退款” Tab | `deposit:approve` | +| 钉钉集成“同步用户” Tab | 同时需要 `sync:read`、`class:view`、`class:edit` | +| 保存钉钉配置 | `integration:trigger` | +| 查看/测试钉钉配置 | `integration:read` | +| 保存 AI 配置 | `ai:config:write` | +| 测试 AI 连接 | `ai:config:test` | +| 清除 AI 密钥 | `ai:config:write` | + +## 默认角色范围 + +| 角色 | 默认范围 | +|---|---| +| 超管 | 全部权限 | +| 宿管老师 | 学生、宿舍、入住、费用、账单、押金、日志、Dashboard、班级、排课、考勤、通知、个人资料 | +| 老师 | 本班学生查看、班级查看、排课查看、考勤查看/录入/导出、通知、个人资料;无 Dashboard | +| 机构负责人 | 教室、租赁、机构、通知、个人资料;无 Dashboard | +| 财务 | 费用、账单、押金、Dashboard、通知、个人资料 | +| 宿管 | 学生、宿舍、入住、押金、Dashboard、通知、个人资料 | +| 教务 | 班级、排课、考勤、教室、学习、考试、Dashboard、通知、个人资料 | + +系统角色的预置权限只会自动补齐,不会删除管理员手动追加的权限。