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 = () => {
- } loading={saving} onClick={handleSave}>
+ } loading={saving} onClick={handleSave}>
保存配置
-
+
} loading={testing} onClick={handleTest}>
测试连接
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 = () => {
}}
>
已有排课
- }
onClick={() => {
@@ -964,7 +966,7 @@ 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、通知、个人资料 |
+
+系统角色的预置权限只会自动补齐,不会删除管理员手动追加的权限。