fix: align permission navigation and page access
This commit is contained in:
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
53
apps/admin/src/auth/permission-navigation.ts
Normal file
53
apps/admin/src/auth/permission-navigation.ts
Normal 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;
|
||||
}
|
||||
19
apps/admin/src/auth/permission-tabs.integration.test.ts
Normal file
19
apps/admin/src/auth/permission-tabs.integration.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
13
apps/admin/src/auth/permission-tabs.ts
Normal file
13
apps/admin/src/auth/permission-tabs.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
14
apps/admin/src/components/DefaultRoute.tsx
Normal file
14
apps/admin/src/components/DefaultRoute.tsx
Normal 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;
|
||||
@@ -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}</>;
|
||||
};
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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} 条` }}
|
||||
/>
|
||||
),
|
||||
}]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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 || '加载失败,请稍后重试');
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -30,6 +30,17 @@ const PermissionsPage: React.FC = () => {
|
||||
log: '操作日志',
|
||||
user: '用户管理',
|
||||
role: '角色管理',
|
||||
class: '班级管理',
|
||||
schedule: '排课管理',
|
||||
attendance: '考勤管理',
|
||||
learning: '学习记录',
|
||||
exam: '考试管理',
|
||||
sync: '数据同步',
|
||||
integration: '集成配置',
|
||||
department: '部门管理',
|
||||
notification: '通知中心',
|
||||
profile: '个人资料',
|
||||
ai: 'AI 模型配置',
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -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="该时段暂无排课" />
|
||||
|
||||
@@ -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) {
|
||||
|
||||
15
apps/server/src/deposits/deposits.lookups.spec.ts
Normal file
15
apps/server/src/deposits/deposits.lookups.spec.ts
Normal 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'] }));
|
||||
});
|
||||
});
|
||||
@@ -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')
|
||||
|
||||
@@ -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) {
|
||||
|
||||
25
apps/server/src/expenses/expenses.lookups.spec.ts
Normal file
25
apps/server/src/expenses/expenses.lookups.spec.ts
Normal 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'] }));
|
||||
});
|
||||
});
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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: '配置已保存' };
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
31
apps/server/src/schedules/schedules.lookups.spec.ts
Normal file
31
apps/server/src/schedules/schedules.lookups.spec.ts
Normal 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' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
54
docs/permission-matrix.md
Normal 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、通知、个人资料 |
|
||||
|
||||
系统角色的预置权限只会自动补齐,不会删除管理员手动追加的权限。
|
||||
Reference in New Issue
Block a user