fix: align permission navigation and page access

This commit is contained in:
2026-07-11 14:55:49 +08:00
parent 1e1c476bc3
commit b6fca99390
27 changed files with 434 additions and 49 deletions

View File

@@ -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 = () => {
</PrivateRoute>
}
>
<Route index element={<Navigate to="/dashboard" />} />
<Route index element={<DefaultRoute />} />
<Route
path="dashboard"
element={
@@ -224,7 +225,7 @@ const App: React.FC = () => {
<Route
path="classroom-schedule"
element={
<PermissionRoute permission="classroom:view">
<PermissionRoute permission="rental:view">
<ClassroomSchedulePage />
</PermissionRoute>
}

View File

@@ -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);
});
});

View File

@@ -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;
}

View File

@@ -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']);
});
});

View File

@@ -0,0 +1,13 @@
export interface PermissionTab {
key: string;
requiredPermission?: string;
}
export function filterTabsByPermission<T extends PermissionTab>(
tabs: readonly T[],
permissions: readonly string[],
): T[] {
return tabs.filter(
(tab) => !tab.requiredPermission || permissions.includes(tab.requiredPermission),
);
}

View File

@@ -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 <Navigate to={firstPath} replace />;
return <Result status="403" title="暂无可访问功能" subTitle="请联系管理员为当前账号分配功能权限" />;
};
export default DefaultRoute;

View File

@@ -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<PermissionRouteProps> = ({ permission, children }) => {
const { hasPermission } = usePermission();
const { permissions, hasPermission } = usePermission();
const navigate = useNavigate();
if (!hasPermission(permission)) {
return <Result status="403" title="无权访问" subTitle="您没有访问此页面的权限" />;
const firstPath = findFirstAccessiblePath(permissions);
return (
<Result
status="403"
title="无权访问"
subTitle="您没有访问此页面的权限"
extra={firstPath ? <Button type="primary" onClick={() => navigate(firstPath, { replace: true })}>访</Button> : undefined}
/>
);
}
return <>{children}</>;
};

View File

@@ -88,7 +88,7 @@ const allMenuItems: MenuItemType[] = [
label: '教室管理',
permission: 'classroom:view',
children: [
{ key: '/classroom-schedule', icon: <CalendarOutlined />, label: '排期总览', permission: 'classroom:view' },
{ key: '/classroom-schedule', icon: <CalendarOutlined />, label: '排期总览', permission: 'rental:view' },
{ key: '/classrooms', icon: <ReadOutlined />, label: '教室列表', permission: 'classroom:view' },
{ key: '/classroom-rentals', icon: <FileProtectOutlined />, label: '租赁订单', permission: 'rental:view' },
{ key: '/organizations', icon: <TagsOutlined />, label: '机构管理', permission: 'organization:view' },

View File

@@ -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<any[]>([]);
const [classrooms, setClassrooms] = useState<any[]>([]);
const [organizations, setOrganizations] = useState<any[]>([]);
@@ -82,8 +84,8 @@ const ClassroomRentalsPage: React.FC = () => {
};
useEffect(() => {
fetchMeta();
}, []);
if (hasAnyPermission('rental:create', 'rental:edit')) fetchMeta();
}, [hasAnyPermission]);
useEffect(() => {
fetchData();
}, [filterMonth]);

View File

@@ -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<string, { text: string; color: string }> = {
paid: { text: '已缴', color: 'green' },
@@ -51,6 +52,7 @@ interface PendingRefund {
}
const DepositsPage: React.FC = () => {
const { hasPermission } = usePermission();
const [data, setData] = useState<any[]>([]);
const [students, setStudents] = useState<any[]>([]);
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: (
<Table
columns={pendingColumns}
dataSource={pendingRefunds}
rowKey="id"
loading={pendingLoading}
scroll={{ x: 1200 }}
pagination={{ pageSize: 15, showTotal: (total) => `${total}` }}
/>
),
},
...(hasPermission('deposit:approve')
? [{
key: 'pending',
label: '待审批退款',
children: (
<Table
columns={pendingColumns}
dataSource={pendingRefunds}
rowKey="id"
loading={pendingLoading}
scroll={{ x: 1200 }}
pagination={{ pageSize: 15, showTotal: (total) => `${total}` }}
/>
),
}]
: []),
]}
/>

View File

@@ -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 || '加载失败,请稍后重试');
}

View File

@@ -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 = () => {
<Switch />
</Form.Item>
<Space>
<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={handleSave}>
<PermissionButton permission="integration:trigger" type="primary" icon={<SaveOutlined />} loading={saving} onClick={handleSave}>
</Button>
</PermissionButton>
<Button icon={<ApiOutlined />} loading={testing} onClick={handleTest}>
</Button>

View File

@@ -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 {

View File

@@ -30,6 +30,17 @@ const PermissionsPage: React.FC = () => {
log: '操作日志',
user: '用户管理',
role: '角色管理',
class: '班级管理',
schedule: '排课管理',
attendance: '考勤管理',
learning: '学习记录',
exam: '考试管理',
sync: '数据同步',
integration: '集成配置',
department: '部门管理',
notification: '通知中心',
profile: '个人资料',
ai: 'AI 模型配置',
};
useEffect(() => {

View File

@@ -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>(() => 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<ClassroomItem[]>,
api.get('/classes') as Promise<ClassItem[]>,
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<Record<string, Record<string, ClassScheduleItem[]>>>,
]);
setClassrooms(classroomsRes);
setClasses(classesRes);
setClassrooms(lookups.classrooms);
setClasses(lookups.classes);
// Convert string keys to numbers
const typedMatrix: Record<number, Record<number, ClassScheduleItem[]>> = {};
@@ -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 = () => {
}}
>
<span style={{ fontWeight: 500 }}></span>
<Button
<PermissionButton
permission="schedule:create"
type="primary"
icon={<PlusOutlined />}
onClick={() => {
@@ -964,7 +966,7 @@ const SchedulesPage: React.FC = () => {
}}
>
</Button>
</PermissionButton>
</div>
{selectedSchedules.length === 0 ? (
<Empty description="该时段暂无排课" />

View File

@@ -32,6 +32,12 @@ export class DepositsController {
@InjectRepository(Student) private studentRepo: Repository<Student>,
) {}
@Get('student-lookups')
@RequirePermission('deposit:create')
getStudentLookups() {
return this.service.getStudentLookups();
}
@Get()
@RequirePermission('deposit:view')
findAll(@Query('studentId') studentId?: string, @Query('status') status?: string) {

View File

@@ -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'] }));
});
});

View File

@@ -18,6 +18,14 @@ export class DepositsService {
private studentRepo: Repository<Student>,
) {}
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')

View File

@@ -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) {

View File

@@ -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'] }));
});
});

View File

@@ -22,6 +22,21 @@ export class ExpensesService {
@InjectRepository(Student) private studentRepo: Repository<Student>,
) {}
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 } });

View File

@@ -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: '配置已保存' };

View File

@@ -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');
});
});

View File

@@ -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 }) {

View File

@@ -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' }],
});
});
});

View File

@@ -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');

54
docs/permission-matrix.md Normal file
View File

@@ -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、通知、个人资料 |
系统角色的预置权限只会自动补齐,不会删除管理员手动追加的权限。