fix permissions and teacher attendance workflows

This commit is contained in:
2026-07-10 20:40:52 +08:00
parent 247879f276
commit 8ed1682b90
95 changed files with 4745 additions and 1237 deletions

View File

@@ -31,6 +31,7 @@
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
"@vitest/browser": "^4.1.10", "@vitest/browser": "^4.1.10",
"@vitest/browser-playwright": "^4.1.10",
"@vitest/coverage-v8": "^4.1.10", "@vitest/coverage-v8": "^4.1.10",
"playwright": "^1.61.1", "playwright": "^1.61.1",
"typescript": "~6.0.2", "typescript": "~6.0.2",

View File

@@ -1,35 +1,37 @@
import React from 'react'; import React, { Suspense, lazy } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { ConfigProvider, App as AntdApp } from 'antd'; import { ConfigProvider, App as AntdApp, Spin } from 'antd';
import zhCN from 'antd/es/locale/zh_CN'; import zhCN from 'antd/es/locale/zh_CN';
import MainLayout from './layouts/MainLayout'; import MainLayout from './layouts/MainLayout';
import LoginPage from './pages/Login';
import DashboardPage from './pages/Dashboard';
import StudentsPage from './pages/Students';
import RoomsPage from './pages/Rooms';
import OccupanciesPage from './pages/Occupancies';
import ExpensesPage from './pages/Expenses';
import BillsPage from './pages/Bills';
import RoomVisualPage from './pages/RoomVisual';
import OperationLogsPage from './pages/OperationLogs';
import UsersPage from './pages/Users';
import ClassroomsPage from './pages/Classrooms';
import DepositsPage from './pages/Deposits';
import TeachersPage from './pages/Teachers';
import StudentProfilePage from './pages/StudentProfile';
import ClassesPage from './pages/Classes';
import ClassDetailPage from './pages/Classes/detail';
import TenantsPage from './pages/Tenants';
import ClassroomRentalsPage from './pages/ClassroomRentals';
import ClassroomSchedulePage from './pages/ClassroomSchedule';
import SchedulesPage from './pages/Schedules';
import RolesPage from './pages/Roles';
import PermissionsPage from './pages/Permissions';
import AttendancePage from './pages/Attendance';
import TeacherWorkspacePage from './pages/TeacherWorkspace';
import NotificationsPage from './pages/Notifications';
import IntegrationConfigPage from './pages/IntegrationConfig';
import PermissionRoute from './components/PermissionRoute'; import PermissionRoute from './components/PermissionRoute';
import AppMessageBridge from './ui/AppMessageBridge';
const LoginPage = lazy(() => import('./pages/Login'));
const DashboardPage = lazy(() => import('./pages/Dashboard'));
const StudentsPage = lazy(() => import('./pages/Students'));
const RoomsPage = lazy(() => import('./pages/Rooms'));
const OccupanciesPage = lazy(() => import('./pages/Occupancies'));
const ExpensesPage = lazy(() => import('./pages/Expenses'));
const BillsPage = lazy(() => import('./pages/Bills'));
const RoomVisualPage = lazy(() => import('./pages/RoomVisual'));
const OperationLogsPage = lazy(() => import('./pages/OperationLogs'));
const UsersPage = lazy(() => import('./pages/Users'));
const ClassroomsPage = lazy(() => import('./pages/Classrooms'));
const DepositsPage = lazy(() => import('./pages/Deposits'));
const TeachersPage = lazy(() => import('./pages/Teachers'));
const StudentProfilePage = lazy(() => import('./pages/StudentProfile'));
const ClassesPage = lazy(() => import('./pages/Classes'));
const ClassDetailPage = lazy(() => import('./pages/Classes/detail'));
const TenantsPage = lazy(() => import('./pages/Tenants'));
const ClassroomRentalsPage = lazy(() => import('./pages/ClassroomRentals'));
const ClassroomSchedulePage = lazy(() => import('./pages/ClassroomSchedule'));
const SchedulesPage = lazy(() => import('./pages/Schedules'));
const RolesPage = lazy(() => import('./pages/Roles'));
const PermissionsPage = lazy(() => import('./pages/Permissions'));
const AttendancePage = lazy(() => import('./pages/Attendance'));
const TeacherWorkspacePage = lazy(() => import('./pages/TeacherWorkspace'));
const NotificationsPage = lazy(() => import('./pages/Notifications'));
const IntegrationConfigPage = lazy(() => import('./pages/IntegrationConfig'));
const PrivateRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => { const PrivateRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
@@ -51,7 +53,9 @@ const App: React.FC = () => {
}} }}
> >
<AntdApp> <AntdApp>
<AppMessageBridge />
<BrowserRouter> <BrowserRouter>
<Suspense fallback={<div style={{ minHeight: '40vh', display: 'grid', placeItems: 'center' }}><Spin size="large" /></div>}>
<Routes> <Routes>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route <Route
@@ -252,7 +256,14 @@ const App: React.FC = () => {
} }
/> />
<Route path="notifications" element={<NotificationsPage />} /> <Route
path="notifications"
element={
<PermissionRoute permission="notification:view">
<NotificationsPage />
</PermissionRoute>
}
/>
<Route <Route
@@ -265,6 +276,7 @@ const App: React.FC = () => {
/> />
</Route> </Route>
</Routes> </Routes>
</Suspense>
</BrowserRouter> </BrowserRouter>
</AntdApp> </AntdApp>
</ConfigProvider> </ConfigProvider>

View File

@@ -0,0 +1,15 @@
export const PERMISSIONS_UPDATED_EVENT = 'permissions-updated';
export function readPermissions(): string[] {
try {
const value = JSON.parse(localStorage.getItem('permissions') || '[]');
return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [];
} catch {
return [];
}
}
export function writePermissions(permissions: string[]): void {
localStorage.setItem('permissions', JSON.stringify([...new Set(permissions)]));
window.dispatchEvent(new Event(PERMISSIONS_UPDATED_EVENT));
}

View File

@@ -0,0 +1,56 @@
import React, { useEffect, useRef } from 'react';
import * as echarts from 'echarts/core';
export type EChartsOption = Record<string, unknown>;
import { BarChart, CustomChart, LineChart, PieChart } from 'echarts/charts';
import {
DataZoomComponent,
GridComponent,
LegendComponent,
TooltipComponent,
VisualMapComponent,
} from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';
echarts.use([
BarChart,
CustomChart,
LineChart,
PieChart,
DataZoomComponent,
GridComponent,
LegendComponent,
TooltipComponent,
VisualMapComponent,
CanvasRenderer,
]);
interface EChartsProps {
option: EChartsOption;
style?: React.CSSProperties;
className?: string;
}
const ECharts: React.FC<EChartsProps> = ({ option, style, className }) => {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!containerRef.current) return;
const chart = echarts.init(containerRef.current);
chart.setOption(option);
const observer = new ResizeObserver(() => chart.resize());
observer.observe(containerRef.current);
return () => {
observer.disconnect();
chart.dispose();
};
}, []);
useEffect(() => {
const chart = containerRef.current ? echarts.getInstanceByDom(containerRef.current) : undefined;
chart?.setOption(option, true);
}, [option]);
return <div ref={containerRef} className={className} style={style} />;
};
export default ECharts;

View File

@@ -14,7 +14,6 @@ import {
Upload, Upload,
Tag, Tag,
Space, Space,
message,
Popconfirm, Popconfirm,
Empty, Empty,
Row, Row,
@@ -36,6 +35,7 @@ import dayjs from 'dayjs';
import api from '../../api'; import api from '../../api';
import { maskPhone, maskIdNumber } from '../../utils/sensitive'; import { maskPhone, maskIdNumber } from '../../utils/sensitive';
import { useViewSensitive } from '../../hooks/useViewSensitive'; import { useViewSensitive } from '../../hooks/useViewSensitive';
import { message } from '../../ui/app-message';
// ---- Types ---- // ---- Types ----

View File

@@ -1,21 +1,31 @@
import { useMemo } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { PERMISSIONS_UPDATED_EVENT, readPermissions } from '../auth/permission-store';
export function usePermission() { export function usePermission() {
const permissions: string[] = useMemo(() => { const [permissions, setPermissions] = useState<string[]>(readPermissions);
try {
return JSON.parse(localStorage.getItem('permissions') || '[]'); useEffect(() => {
} catch { const refresh = () => setPermissions(readPermissions());
return []; window.addEventListener(PERMISSIONS_UPDATED_EVENT, refresh);
} window.addEventListener('storage', refresh);
return () => {
window.removeEventListener(PERMISSIONS_UPDATED_EVENT, refresh);
window.removeEventListener('storage', refresh);
};
}, []); }, []);
const hasPermission = (code: string): boolean => permissions.includes(code); const hasPermission = useCallback(
(code: string): boolean => permissions.includes(code),
const hasAnyPermission = (...codes: string[]): boolean => [permissions],
codes.some((c) => permissions.includes(c)); );
const hasAnyPermission = useCallback(
const hasAllPermissions = (...codes: string[]): boolean => (...codes: string[]): boolean => codes.some((code) => permissions.includes(code)),
codes.every((c) => permissions.includes(c)); [permissions],
);
const hasAllPermissions = useCallback(
(...codes: string[]): boolean => codes.every((code) => permissions.includes(code)),
[permissions],
);
return { permissions, hasPermission, hasAnyPermission, hasAllPermissions }; return { permissions, hasPermission, hasAnyPermission, hasAllPermissions };
} }

View File

@@ -1,6 +1,7 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { Modal, message } from 'antd'; import { Modal } from 'antd';
import api from '../api'; import api from '../api';
import { message } from '../ui/app-message';
/** /**
* Shared hook for viewing sensitive student info (phone / ID number). * Shared hook for viewing sensitive student info (phone / ID number).

View File

@@ -28,6 +28,8 @@ import {
ApiOutlined, ApiOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { usePermission } from '../hooks/usePermission'; import { usePermission } from '../hooks/usePermission';
import api from '../api';
import { writePermissions } from '../auth/permission-store';
import NotificationBell from '../components/NotificationBell'; import NotificationBell from '../components/NotificationBell';
const { Header, Sider, Content } = Layout; const { Header, Sider, Content } = Layout;
@@ -128,6 +130,21 @@ const MainLayout: React.FC = () => {
const user = useMemo(() => JSON.parse(localStorage.getItem('user') || '{}'), []); const user = useMemo(() => JSON.parse(localStorage.getItem('user') || '{}'), []);
const { hasPermission } = usePermission(); const { hasPermission } = usePermission();
useEffect(() => {
let cancelled = false;
api.get<{ id: number; username: string; permissions: string[]; roles?: string[] }>('/auth/profile')
.then((profile) => {
if (cancelled) return;
writePermissions(profile.permissions || []);
const cachedUser = JSON.parse(localStorage.getItem('user') || '{}');
localStorage.setItem('user', JSON.stringify({ ...cachedUser, ...profile }));
})
.catch(() => {
// The API interceptor handles expired/invalid sessions.
});
return () => { cancelled = true; };
}, []);
const screens = Grid.useBreakpoint(); const screens = Grid.useBreakpoint();
const isMobile = !screens.sm; // < 576px (仅 xs) const isMobile = !screens.sm; // < 576px (仅 xs)
const isTablet = (screens.sm || screens.md) && !screens.lg; // 576-991px const isTablet = (screens.sm || screens.md) && !screens.lg; // 576-991px
@@ -288,7 +305,7 @@ const MainLayout: React.FC = () => {
onClick={() => (isMobile || isTablet ? setDrawerOpen(true) : setCollapsed(!collapsed))} onClick={() => (isMobile || isTablet ? setDrawerOpen(true) : setCollapsed(!collapsed))}
/> />
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<NotificationBell /> {hasPermission('notification:view') && <NotificationBell />}
<Dropdown <Dropdown
menu={{ menu={{
items: [ items: [

View File

@@ -1,7 +1,20 @@
import React, { useEffect, useState, useMemo, useCallback } from 'react'; import React, { useEffect, useState, useMemo, useCallback } from 'react';
import { import {
Table, Button, Modal, Form, DatePicker, Select, Space, message, Table,
Tag, Card, Input, Tooltip, Row, Col, Tabs, Alert, Button,
Modal,
Form,
DatePicker,
Select,
Space,
Tag,
Card,
Input,
Tooltip,
Row,
Col,
Tabs,
Alert,
} from 'antd'; } from 'antd';
import { import {
PlusOutlined, PlusOutlined,
@@ -13,6 +26,8 @@ import {
import dayjs, { Dayjs } from 'dayjs'; import dayjs, { Dayjs } from 'dayjs';
import api from '../../api'; import api from '../../api';
import PermissionButton from '../../components/PermissionButton'; import PermissionButton from '../../components/PermissionButton';
import { usePermission } from '../../hooks/usePermission';
import { message } from '../../ui/app-message';
const { RangePicker } = DatePicker; const { RangePicker } = DatePicker;
@@ -51,7 +66,6 @@ const SOURCE_OPTIONS = [
{ value: 'dingtalk', label: '钉钉导入' }, { value: 'dingtalk', label: '钉钉导入' },
]; ];
const MATCH_STATUS_MAP: Record<string, { text: string; color: string }> = { const MATCH_STATUS_MAP: Record<string, { text: string; color: string }> = {
unmatched: { text: '未处理', color: 'default' }, unmatched: { text: '未处理', color: 'default' },
pending: { text: '待匹配', color: 'orange' }, pending: { text: '待匹配', color: 'orange' },
@@ -98,16 +112,28 @@ interface BatchRecordInput {
interface DingRecord { interface DingRecord {
id: number; id: number;
dingUserId: string; dingUserId: string;
checkTime: string; attendanceDate: string;
rawStatus: string; checkInTime?: string;
checkOutTime?: string;
timeResult: string;
matchStatus: string; matchStatus: string;
studentId?: number; studentId?: number;
} }
interface AlertItem { studentId: number; studentName: string; className: string; type: string; count: number; lastDate: string } interface AlertItem {
studentId: number;
studentName: string;
className: string;
type: string;
count: number;
lastDate: string;
}
// ── Component ── // ── Component ──
const AttendancePage: React.FC = () => { const AttendancePage: React.FC = () => {
const { hasAnyPermission } = usePermission();
const canManageAllAttendance = hasAnyPermission('class:edit', 'attendance:edit');
// ── State ── // ── State ──
const [records, setRecords] = useState<AttendanceRecordItem[]>([]); const [records, setRecords] = useState<AttendanceRecordItem[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -127,7 +153,11 @@ const AttendancePage: React.FC = () => {
// View toggle // View toggle
const [calendarView, setCalendarView] = useState(false); const [calendarView, setCalendarView] = useState(false);
const [calendarData, setCalendarData] = useState< const [calendarData, setCalendarData] = useState<
{ studentId: number; studentName: string; days: { date: string; session: string; status: string }[] }[] {
studentId: number;
studentName: string;
days: { date: string; session: string; status: string }[];
}[]
>([]); >([]);
const [calendarLoading, setCalendarLoading] = useState(false); const [calendarLoading, setCalendarLoading] = useState(false);
@@ -143,8 +173,9 @@ const AttendancePage: React.FC = () => {
const [dingMatchStatus, setDingMatchStatus] = useState<string | undefined>(undefined); const [dingMatchStatus, setDingMatchStatus] = useState<string | undefined>(undefined);
// DingTalk import // DingTalk import
const [importModalOpen, setImportModalOpen] = useState(false); const [importModalOpen, setImportModalOpen] = useState(false);
const [importDateRange, setImportDateRange] = useState<[Dayjs, Dayjs] | null>(null); const [importClassId, setImportClassId] = useState<number | undefined>(undefined);
const [importAutoMatch, setImportAutoMatch] = useState(true); const [importClassOptions, setImportClassOptions] = useState<ClassOption[]>([]);
const [importDateRange, setImportDateRange] = useState<[Dayjs, Dayjs] | null>([dayjs(), dayjs()]);
const [importing, setImporting] = useState(false); const [importing, setImporting] = useState(false);
const [importProgressMsg, setImportProgressMsg] = useState(''); const [importProgressMsg, setImportProgressMsg] = useState('');
@@ -152,7 +183,9 @@ const AttendancePage: React.FC = () => {
const [matchModalOpen, setMatchModalOpen] = useState(false); const [matchModalOpen, setMatchModalOpen] = useState(false);
const [matchRecordId, setMatchRecordId] = useState<number | null>(null); const [matchRecordId, setMatchRecordId] = useState<number | null>(null);
const [matchStudentSearch, setMatchStudentSearch] = useState(''); const [matchStudentSearch, setMatchStudentSearch] = useState('');
const [matchStudentResults, setMatchStudentResults] = useState<{ id: number; name: string }[]>([]); const [matchStudentResults, setMatchStudentResults] = useState<{ id: number; name: string }[]>(
[],
);
const [matchStudentLoading, setMatchStudentLoading] = useState(false); const [matchStudentLoading, setMatchStudentLoading] = useState(false);
const [matchSubmitting, setMatchSubmitting] = useState(false); const [matchSubmitting, setMatchSubmitting] = useState(false);
@@ -165,7 +198,9 @@ const AttendancePage: React.FC = () => {
const [batchRemark, setBatchRemark] = useState(''); const [batchRemark, setBatchRemark] = useState('');
const [batchSubmitting, setBatchSubmitting] = useState(false); const [batchSubmitting, setBatchSubmitting] = useState(false);
const [studentSearch, setStudentSearch] = useState(''); const [studentSearch, setStudentSearch] = useState('');
const [studentSearchResults, setStudentSearchResults] = useState<{ id: number; name: string }[]>([]); const [studentSearchResults, setStudentSearchResults] = useState<{ id: number; name: string }[]>(
[],
);
const [studentSearchLoading, setStudentSearchLoading] = useState(false); const [studentSearchLoading, setStudentSearchLoading] = useState(false);
// Edit modal // Edit modal
@@ -173,11 +208,14 @@ const AttendancePage: React.FC = () => {
const [editRecord, setEditRecord] = useState<AttendanceRecordItem | null>(null); const [editRecord, setEditRecord] = useState<AttendanceRecordItem | null>(null);
const [editForm] = Form.useForm(); const [editForm] = Form.useForm();
// ── Edit record ── // ── Edit record ──
const handleEdit = (record: AttendanceRecordItem) => { const handleEdit = (record: AttendanceRecordItem) => {
setEditRecord(record); setEditRecord(record);
editForm.setFieldsValue({ session: record.session, status: record.status, remark: record.remark }); editForm.setFieldsValue({
session: record.session,
status: record.status,
remark: record.remark,
});
setEditModalOpen(true); setEditModalOpen(true);
}; };
@@ -216,7 +254,10 @@ const AttendancePage: React.FC = () => {
if (filterStatus) params.status = filterStatus; if (filterStatus) params.status = filterStatus;
if (filterSource) params.source = filterSource; if (filterSource) params.source = filterSource;
const data = await api.get<{ list: AttendanceRecordItem[]; total: number }>('/attendance-records', { params }); const data = await api.get<{ list: AttendanceRecordItem[]; total: number }>(
'/attendance-records',
{ params },
);
setRecords(data.list); setRecords(data.list);
setTotal(data.total); setTotal(data.total);
} catch (e: unknown) { } catch (e: unknown) {
@@ -235,7 +276,13 @@ const AttendancePage: React.FC = () => {
} }
setCalendarLoading(true); setCalendarLoading(true);
try { try {
const res = await api.get<{ studentId: number; studentName: string; days: { date: string; session: string; status: string }[] }[]>('/attendance-records/calendar', { const res = await api.get<
{
studentId: number;
studentName: string;
days: { date: string; session: string; status: string }[];
}[]
>('/attendance-records/calendar', {
params: { classId: filterClassId }, params: { classId: filterClassId },
}); });
setCalendarData(res); setCalendarData(res);
@@ -257,7 +304,9 @@ const AttendancePage: React.FC = () => {
if (filterDateRange?.[1]) params.dateTo = filterDateRange[1].format('YYYY-MM-DD'); if (filterDateRange?.[1]) params.dateTo = filterDateRange[1].format('YYYY-MM-DD');
if (dingMatchStatus) params.matchStatus = dingMatchStatus; if (dingMatchStatus) params.matchStatus = dingMatchStatus;
const data = await api.get<{ list: DingRecord[]; total: number }>('/ding-attendance-raw', { params }); const data = await api.get<{ list: DingRecord[]; total: number }>('/ding-attendance-raw', {
params,
});
setDingRecords(data.list); setDingRecords(data.list);
setDingTotal(data.total); setDingTotal(data.total);
} catch (e: unknown) { } catch (e: unknown) {
@@ -268,8 +317,42 @@ const AttendancePage: React.FC = () => {
} }
}, [dingPage, dingPageSize, filterClassId, filterDateRange, dingMatchStatus]); }, [dingPage, dingPageSize, filterClassId, filterDateRange, dingMatchStatus]);
// ── Classes the current teacher may import from DingTalk ──
const fetchImportClasses = useCallback(async () => {
try {
const options = await api.get<ClassOption[]>('/attendance-records/import/dingtalk/classes');
setImportClassOptions(options);
setImportClassId(
(current) => current ?? (options.length === 1 ? options[0].classId : undefined),
);
} catch (e: unknown) {
const err = e as { message?: string };
setImportClassOptions([]);
message.error(err?.message || '加载可拉取班级失败,请刷新页面后重试');
}
}, []);
const openDingTalkImportModal = () => {
const today = dayjs();
setImportDateRange([today, today]);
setImportProgressMsg('');
setImportModalOpen(true);
void fetchImportClasses();
};
const closeDingTalkImportModal = () => {
setImportModalOpen(false);
setImportClassId(undefined);
setImportDateRange([dayjs(), dayjs()]);
setImportProgressMsg('');
};
// ── DingTalk import handler ── // ── DingTalk import handler ──
const handleImportDingTalk = useCallback(async () => { const handleImportDingTalk = useCallback(async () => {
if (!importClassId) {
message.warning('请选择要拉取考勤的班级');
return;
}
if (!importDateRange?.[0] || !importDateRange?.[1]) { if (!importDateRange?.[0] || !importDateRange?.[1]) {
message.warning('请选择导入日期范围'); message.warning('请选择导入日期范围');
return; return;
@@ -279,11 +362,16 @@ const AttendancePage: React.FC = () => {
try { try {
const result = await api.post<{ const result = await api.post<{
success: boolean; imported: number; skipped: number; matched: number; errors: string[]; duration: number; success: boolean;
imported: number;
skipped: number;
matched: number;
errors: string[];
duration: number;
}>('/attendance-records/import/dingtalk', { }>('/attendance-records/import/dingtalk', {
classId: importClassId,
start: importDateRange[0].format('YYYY-MM-DD'), start: importDateRange[0].format('YYYY-MM-DD'),
end: importDateRange[1].format('YYYY-MM-DD'), end: importDateRange[1].format('YYYY-MM-DD'),
autoMatch: importAutoMatch,
}); });
setImportProgressMsg(''); setImportProgressMsg('');
@@ -305,7 +393,7 @@ const AttendancePage: React.FC = () => {
} finally { } finally {
setImporting(false); setImporting(false);
} }
}, [importDateRange, importAutoMatch, fetchDingRecords]); }, [canManageAllAttendance, importClassId, importDateRange, fetchDingRecords]);
// ── Effects ── // ── Effects ──
useEffect(() => { useEffect(() => {
@@ -313,10 +401,15 @@ const AttendancePage: React.FC = () => {
}, [fetchClasses]); }, [fetchClasses]);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
api.get<AlertItem[]>('/attendance-records/alerts') api
.then((data) => { if (!cancelled) setAlerts(data); }) .get<AlertItem[]>('/attendance-records/alerts')
.then((data) => {
if (!cancelled) setAlerts(data);
})
.catch(() => {}); .catch(() => {});
return () => { cancelled = true; }; return () => {
cancelled = true;
};
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -342,8 +435,10 @@ const AttendancePage: React.FC = () => {
} }
setStudentSearchLoading(true); setStudentSearchLoading(true);
try { try {
const data = await api.get<{ list?: { id: number; name: string }[] } | { id: number; name: string }[]>('/students', { params: { search: value, pageSize: 10 } }); const data = await api.get<
const list = Array.isArray(data) ? data : data.list ?? []; { list?: { id: number; name: string }[] } | { id: number; name: string }[]
>('/students', { params: { search: value, pageSize: 10 } });
const list = Array.isArray(data) ? data : (data.list ?? []);
setStudentSearchResults(list); setStudentSearchResults(list);
} catch { } catch {
setStudentSearchResults([]); setStudentSearchResults([]);
@@ -397,7 +492,15 @@ const AttendancePage: React.FC = () => {
} finally { } finally {
setBatchSubmitting(false); setBatchSubmitting(false);
} }
}, [batchStudents, batchDate, batchSession, batchStatus, batchRemark, filterClassId, fetchRecords]); }, [
batchStudents,
batchDate,
batchSession,
batchStatus,
batchRemark,
filterClassId,
fetchRecords,
]);
// ── Reset filters ── // ── Reset filters ──
const handleReset = useCallback(() => { const handleReset = useCallback(() => {
@@ -425,8 +528,10 @@ const AttendancePage: React.FC = () => {
} }
setMatchStudentLoading(true); setMatchStudentLoading(true);
try { try {
const data = await api.get<{ list?: { id: number; name: string }[] } | { id: number; name: string }[]>('/students', { params: { search: value, pageSize: 10 } }); const data = await api.get<
const list = Array.isArray(data) ? data : data.list ?? []; { list?: { id: number; name: string }[] } | { id: number; name: string }[]
>('/students', { params: { search: value, pageSize: 10 } });
const list = Array.isArray(data) ? data : (data.list ?? []);
setMatchStudentResults(list); setMatchStudentResults(list);
} catch { } catch {
setMatchStudentResults([]); setMatchStudentResults([]);
@@ -435,22 +540,25 @@ const AttendancePage: React.FC = () => {
} }
}, []); }, []);
const handleMatchSubmit = useCallback(async (studentId: number) => { const handleMatchSubmit = useCallback(
if (matchRecordId === null) return; async (studentId: number) => {
setMatchSubmitting(true); if (matchRecordId === null) return;
try { setMatchSubmitting(true);
await api.post(`/ding-attendance-raw/${matchRecordId}/match`, { studentId }); try {
message.success('匹配成功'); await api.post(`/ding-attendance-raw/${matchRecordId}/match`, { studentId });
setMatchModalOpen(false); message.success('匹配成功');
setMatchRecordId(null); setMatchModalOpen(false);
fetchDingRecords(); setMatchRecordId(null);
} catch (e: unknown) { fetchDingRecords();
const err = e as { message?: string }; } catch (e: unknown) {
message.error(err?.message || '匹配失败'); const err = e as { message?: string };
} finally { message.error(err?.message || '匹配失败');
setMatchSubmitting(false); } finally {
} setMatchSubmitting(false);
}, [matchRecordId, fetchDingRecords]); }
},
[matchRecordId, fetchDingRecords],
);
// ── Report download ── // ── Report download ──
const handleExportReport = useCallback(() => { const handleExportReport = useCallback(() => {
@@ -491,15 +599,17 @@ const AttendancePage: React.FC = () => {
}, },
{ {
title: '打卡时间', title: '打卡时间',
dataIndex: 'checkTime',
key: 'checkTime', key: 'checkTime',
width: 160, width: 180,
render: (v: string) => (v ? dayjs(v).format('YYYY-MM-DD HH:mm:ss') : '-'), render: (_: unknown, record: DingRecord) => {
const value = record.checkInTime || record.checkOutTime;
return value ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : record.attendanceDate || '-';
},
}, },
{ {
title: '打卡状态', title: '打卡状态',
dataIndex: 'rawStatus', dataIndex: 'timeResult',
key: 'rawStatus', key: 'timeResult',
width: 120, width: 120,
}, },
{ {
@@ -519,9 +629,14 @@ const AttendancePage: React.FC = () => {
render: (_: unknown, record: DingRecord) => { render: (_: unknown, record: DingRecord) => {
if (record.matchStatus === 'matched') return <span>-</span>; if (record.matchStatus === 'matched') return <span>-</span>;
return ( return (
<Button size="small" type="link" onClick={() => openMatchModal(record.id)}> <PermissionButton
permission="attendance:edit"
size="small"
type="link"
onClick={() => openMatchModal(record.id)}
>
</Button> </PermissionButton>
); );
}, },
}, },
@@ -596,7 +711,11 @@ const AttendancePage: React.FC = () => {
width: 80, width: 80,
fixed: 'right' as const, fixed: 'right' as const,
render: (_: unknown, record: AttendanceRecordItem) => ( render: (_: unknown, record: AttendanceRecordItem) => (
<PermissionButton permission="attendance:edit" size="small" onClick={() => handleEdit(record)}> <PermissionButton
permission="attendance:edit"
size="small"
onClick={() => handleEdit(record)}
>
</PermissionButton> </PermissionButton>
), ),
@@ -616,46 +735,68 @@ const AttendancePage: React.FC = () => {
return Array.from(dates).sort(); return Array.from(dates).sort();
}, [calendarData]); }, [calendarData]);
const calendarColumns = useMemo(() => [ const calendarColumns = useMemo(
{ () => [
title: '学生', {
dataIndex: 'studentName', title: '学生',
key: 'studentName', dataIndex: 'studentName',
width: 100, key: 'studentName',
fixed: 'left' as const, width: 100,
}, fixed: 'left' as const,
...calendarDates.map((date) => ({
title: (
<div style={{ textAlign: 'center', fontSize: 12 }}>
<div>{dayjs(date).format('MM/DD')}</div>
<div style={{ color: '#999' }}>{dayjs(date).format('ddd')}</div>
</div>
),
key: date,
width: 80,
render: (_: unknown, record: { studentId: number; studentName: string; days: { date: string; session: string; status: string }[] }) => {
const dayRecord = record.days.find((d) => d.date === date);
if (!dayRecord) return <span style={{ color: '#d9d9d9' }}>-</span>;
const statusInfo = STATUS_MAP[dayRecord.status];
return (
<Tooltip title={`${SESSION_MAP[dayRecord.session] || dayRecord.session}: ${statusInfo?.text || dayRecord.status}`}>
<Tag color={statusInfo?.color || 'default'} style={{ margin: 0, cursor: 'pointer' }}>
{statusInfo?.text || dayRecord.status}
</Tag>
</Tooltip>
);
}, },
})), ...calendarDates.map((date) => ({
], [calendarDates]); title: (
<div style={{ textAlign: 'center', fontSize: 12 }}>
<div>{dayjs(date).format('MM/DD')}</div>
<div style={{ color: '#999' }}>{dayjs(date).format('ddd')}</div>
</div>
),
key: date,
width: 80,
render: (
_: unknown,
record: {
studentId: number;
studentName: string;
days: { date: string; session: string; status: string }[];
},
) => {
const dayRecord = record.days.find((d) => d.date === date);
if (!dayRecord) return <span style={{ color: '#d9d9d9' }}>-</span>;
const statusInfo = STATUS_MAP[dayRecord.status];
return (
<Tooltip
title={`${SESSION_MAP[dayRecord.session] || dayRecord.session}: ${statusInfo?.text || dayRecord.status}`}
>
<Tag color={statusInfo?.color || 'default'} style={{ margin: 0, cursor: 'pointer' }}>
{statusInfo?.text || dayRecord.status}
</Tag>
</Tooltip>
);
},
})),
],
[calendarDates],
);
// ── Render ── // ── Render ──
return ( return (
<div> <div>
{alerts.length > 0 && ( {alerts.length > 0 && (
<Alert type="warning" showIcon closable <Alert
type="warning"
showIcon
closable
title={`考勤预警:${alerts.length} 名学生异常`} title={`考勤预警:${alerts.length} 名学生异常`}
description={alerts.map(a => `${a.studentName}(${a.className || '-'})${a.type} ${a.count}次,最近${a.lastDate}`).join('')} description={alerts
style={{ marginBottom: 16 }} />)} .map(
(a) =>
`${a.studentName}(${a.className || '-'})${a.type} ${a.count}次,最近${a.lastDate}`,
)
.join('')}
style={{ marginBottom: 16 }}
/>
)}
<Tabs <Tabs
activeKey={activeTab} activeKey={activeTab}
onChange={setActiveTab} onChange={setActiveTab}
@@ -752,12 +893,13 @@ const AttendancePage: React.FC = () => {
> >
</PermissionButton> </PermissionButton>
<Button <PermissionButton
permission="attendance:export"
icon={<ExportOutlined />} icon={<ExportOutlined />}
onClick={handleExportReport} onClick={handleExportReport}
> >
</Button> </PermissionButton>
</Space> </Space>
<Button <Button
icon={calendarView ? <UnorderedListOutlined /> : <CalendarOutlined />} icon={calendarView ? <UnorderedListOutlined /> : <CalendarOutlined />}
@@ -858,14 +1000,15 @@ const AttendancePage: React.FC = () => {
/> />
</Col> </Col>
<Col> <Col>
<Button <PermissionButton
permission="attendance:create"
type="primary" type="primary"
icon={<CloudDownloadOutlined />} icon={<CloudDownloadOutlined />}
loading={importing} loading={importing}
onClick={() => setImportModalOpen(true)} onClick={openDingTalkImportModal}
> >
</Button> </PermissionButton>
</Col> </Col>
</Row> </Row>
{importing && ( {importing && (
@@ -996,39 +1139,53 @@ const AttendancePage: React.FC = () => {
{/* ── DingTalk import modal ── */} {/* ── DingTalk import modal ── */}
<Modal <Modal
title="从钉钉拉取考勤数据" title={canManageAllAttendance ? '从钉钉拉取考勤数据' : '同步今日考勤'}
open={importModalOpen} open={importModalOpen}
onOk={handleImportDingTalk} onOk={handleImportDingTalk}
onCancel={() => { setImportModalOpen(false); setImportDateRange(null); setImportProgressMsg(''); }} onCancel={closeDingTalkImportModal}
confirmLoading={importing} confirmLoading={importing}
okText="开始拉取" okText={canManageAllAttendance ? '开始拉取' : '同步今日'}
cancelText="取消" cancelText="取消"
> >
<Form layout="vertical"> <Form layout="vertical">
<Form.Item label="日期范围" required> <Form.Item label="班级" required>
<RangePicker
value={importDateRange}
onChange={(dates) => setImportDateRange(dates as [Dayjs, Dayjs] | null)}
style={{ width: '100%' }}
placeholder={['开始日期', '结束日期']}
/>
</Form.Item>
<Form.Item label="自动匹配">
<Select <Select
value={importAutoMatch ? 'yes' : 'no'} value={importClassId}
onChange={(v) => setImportAutoMatch(v === 'yes')} onChange={setImportClassId}
options={[ placeholder="选择自己任教的班级"
{ value: 'yes', label: '是 — 导入后按姓名自动匹配学生' }, options={importClassOptions.map((item) => ({
{ value: 'no', label: '否 — 仅导入原始数据,稍后手动匹配' }, value: item.classId,
]} label: item.className,
}))}
/> />
</Form.Item> </Form.Item>
{canManageAllAttendance ? (
<Form.Item label="日期范围" required>
<RangePicker
value={importDateRange}
onChange={(dates) => setImportDateRange(dates as [Dayjs, Dayjs] | null)}
style={{ width: '100%' }}
placeholder={['开始日期', '结束日期']}
/>
</Form.Item>
) : (
<Alert
type="info"
showIcon
message={`将同步今天(${dayjs().format('YYYY-MM-DD')})的钉钉考勤`}
description="老师端只同步当天数据,不展示历史日期范围。历史数据请联系管理员处理。"
/>
)}
</Form> </Form>
</Modal> </Modal>
{/* ── Edit modal ── */} {/* ── Edit modal ── */}
<Modal title="编辑考勤" open={editModalOpen} onOk={handleEditSubmit} onCancel={() => setEditModalOpen(false)}> <Modal
title="编辑考勤"
open={editModalOpen}
onOk={handleEditSubmit}
onCancel={() => setEditModalOpen(false)}
>
<Form form={editForm} layout="vertical"> <Form form={editForm} layout="vertical">
<Form.Item name="session" label="时段" rules={[{ required: true }]}> <Form.Item name="session" label="时段" rules={[{ required: true }]}>
<Select options={SESSION_OPTIONS.map((s) => ({ value: s.value, label: s.label }))} /> <Select options={SESSION_OPTIONS.map((s) => ({ value: s.value, label: s.label }))} />
@@ -1083,7 +1240,6 @@ const AttendancePage: React.FC = () => {
</Form.Item> </Form.Item>
</Form> </Form>
</Modal> </Modal>
</div> </div>
); );
}; };

View File

@@ -5,7 +5,6 @@ import {
Form, Form,
DatePicker, DatePicker,
Space, Space,
message,
Tag, Tag,
Descriptions, Descriptions,
Popconfirm, Popconfirm,
@@ -25,6 +24,7 @@ import dayjs from 'dayjs';
import api from '../../api'; import api from '../../api';
import PermissionButton from '../../components/PermissionButton'; import PermissionButton from '../../components/PermissionButton';
import { downloadBlob } from '../../utils/download'; import { downloadBlob } from '../../utils/download';
import { message } from '../../ui/app-message';
const { RangePicker } = DatePicker; const { RangePicker } = DatePicker;

View File

@@ -2,13 +2,14 @@ import React, { useEffect, useState, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { import {
Card, Tabs, Descriptions, Table, Button, Space, Select, Modal, Tag, Card, Tabs, Descriptions, Table, Button, Space, Select, Modal, Tag,
Popconfirm, message, Form, Input, DatePicker, InputNumber, Row, Col, Statistic, Popconfirm, Form, Input, DatePicker, InputNumber, Row, Col, Statistic,
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { ArrowLeftOutlined, PlusOutlined, DownloadOutlined } from '@ant-design/icons'; import { ArrowLeftOutlined, PlusOutlined, DownloadOutlined } from '@ant-design/icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import api from '../../api'; import api from '../../api';
import PermissionButton from '../../components/PermissionButton'; import PermissionButton from '../../components/PermissionButton';
import { message } from '../../ui/app-message';
// ---- Types ---- // ---- Types ----
@@ -317,7 +318,7 @@ const ClassDetailPage: React.FC = () => {
title: '操作', title: '操作',
render: (_: unknown, r: ClassStudent) => ( render: (_: unknown, r: ClassStudent) => (
<Popconfirm title="确认移除?" onConfirm={() => handleRemoveStudent(r.studentId)}> <Popconfirm title="确认移除?" onConfirm={() => handleRemoveStudent(r.studentId)}>
<Button size="small" danger></Button> <PermissionButton permission="class:edit" size="small" danger></PermissionButton>
</Popconfirm> </Popconfirm>
), ),
}, },
@@ -339,7 +340,7 @@ const ClassDetailPage: React.FC = () => {
title: '操作', title: '操作',
render: (_: unknown, r: ClassTeacher) => ( render: (_: unknown, r: ClassTeacher) => (
<Popconfirm title="确认移除?" onConfirm={() => handleRemoveTeacher(r.userId)}> <Popconfirm title="确认移除?" onConfirm={() => handleRemoveTeacher(r.userId)}>
<Button size="small" danger></Button> <PermissionButton permission="class:edit" size="small" danger></PermissionButton>
</Popconfirm> </Popconfirm>
), ),
}, },
@@ -430,9 +431,9 @@ const ClassDetailPage: React.FC = () => {
<Input.TextArea rows={3} /> <Input.TextArea rows={3} />
</Form.Item> </Form.Item>
<Space> <Space>
<Button type="primary" onClick={handleSaveInfo}> <PermissionButton permission="class:edit" type="primary" onClick={handleSaveInfo}>
</Button> </PermissionButton>
<Button onClick={() => setEditingInfo(false)}></Button> <Button onClick={() => setEditingInfo(false)}></Button>
</Space> </Space>
</Form> </Form>
@@ -443,10 +444,10 @@ const ClassDetailPage: React.FC = () => {
{TYPE_MAP[detail.classType]} {TYPE_MAP[detail.classType]}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="开班日期"> <Descriptions.Item label="开班日期">
{detail.startDate || '-'} {detail.startDate ? dayjs(detail.startDate).format('YYYY-MM-DD') : '-'}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="结课日期"> <Descriptions.Item label="结课日期">
{detail.endDate || '-'} {detail.endDate ? dayjs(detail.endDate).format('YYYY-MM-DD') : '-'}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="学员"> <Descriptions.Item label="学员">
{detail.studentCount}/{detail.maxStudents || '-'} {detail.studentCount}/{detail.maxStudents || '-'}
@@ -487,14 +488,15 @@ const ClassDetailPage: React.FC = () => {
label: `花名册 (${students.filter((s) => s.status === 'active').length})`, label: `花名册 (${students.filter((s) => s.status === 'active').length})`,
children: ( children: (
<div> <div>
<Button <PermissionButton
permission="class:edit"
icon={<PlusOutlined />} icon={<PlusOutlined />}
type="primary" type="primary"
onClick={openStudentModal} onClick={openStudentModal}
style={{ marginBottom: 16, marginRight: 8 }} style={{ marginBottom: 16, marginRight: 8 }}
> >
</Button> </PermissionButton>
<PermissionButton <PermissionButton
permission="class:view" permission="class:view"
icon={<DownloadOutlined />} icon={<DownloadOutlined />}
@@ -556,14 +558,15 @@ const ClassDetailPage: React.FC = () => {
label: `教师 (${teachers.length})`, label: `教师 (${teachers.length})`,
children: ( children: (
<div> <div>
<Button <PermissionButton
permission="class:edit"
icon={<PlusOutlined />} icon={<PlusOutlined />}
type="primary" type="primary"
onClick={openTeacherModal} onClick={openTeacherModal}
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
> >
</Button> </PermissionButton>
<Table<ClassTeacher> <Table<ClassTeacher>
columns={teacherColumns} columns={teacherColumns}
dataSource={teachers} dataSource={teachers}

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useState, useMemo, useCallback } from 'react'; import React, { useEffect, useState, useMemo, useCallback } from 'react';
import { import {
Table, Button, Input, Select, Space, Tag, Modal, Form, InputNumber, Table, Button, Input, Select, Space, Tag, Modal, Form, InputNumber,
DatePicker, Popconfirm, message, Card, Switch, Empty, DatePicker, Popconfirm, Card, Switch, Empty,
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { PlusOutlined, SearchOutlined, TeamOutlined, InboxOutlined } from '@ant-design/icons'; import { PlusOutlined, SearchOutlined, TeamOutlined, InboxOutlined } from '@ant-design/icons';
@@ -9,6 +9,7 @@ import { useNavigate } from 'react-router-dom';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import api from '../../api'; import api from '../../api';
import PermissionButton from '../../components/PermissionButton'; import PermissionButton from '../../components/PermissionButton';
import { message } from '../../ui/app-message';
// ---- Types ---- // ---- Types ----

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState, useMemo } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { import {
Table, Table,
Button, Button,
@@ -9,7 +9,6 @@ import {
InputNumber, InputNumber,
Input, Input,
Space, Space,
message,
Tag, Tag,
Popconfirm, Popconfirm,
Upload, Upload,
@@ -21,6 +20,13 @@ import dayjs, { Dayjs } from 'dayjs';
import api from '../../api'; import api from '../../api';
import { downloadBlob } from '../../utils/download'; import { downloadBlob } from '../../utils/download';
import PermissionButton from '../../components/PermissionButton'; import PermissionButton from '../../components/PermissionButton';
import { message } from '../../ui/app-message';
interface UnavailableDatesResponse {
dates: string[];
}
export const unavailableDatesCacheKey = (classroomId: number, date: Dayjs) =>
`${classroomId}:${date.format('YYYY-MM')}`;
const ClassroomRentalsPage: React.FC = () => { const ClassroomRentalsPage: React.FC = () => {
const [data, setData] = useState<any[]>([]); const [data, setData] = useState<any[]>([]);
@@ -33,6 +39,11 @@ const ClassroomRentalsPage: React.FC = () => {
const [filterMonth, setFilterMonth] = useState<Dayjs | null>(null); const [filterMonth, setFilterMonth] = useState<Dayjs | null>(null);
const [searchText, setSearchText] = useState(''); const [searchText, setSearchText] = useState('');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [unavailableDates, setUnavailableDates] = useState<Set<string>>(new Set());
const loadedUnavailableMonths = useRef<Set<string>>(new Set());
const unavailableRequestVersion = useRef(0);
const [unavailableDatesLoading, setUnavailableDatesLoading] = useState(false);
const selectedClassroomId = Form.useWatch('classroomId', form);
const filteredData = useMemo(() => { const filteredData = useMemo(() => {
if (!searchText) return data; if (!searchText) return data;
@@ -74,8 +85,84 @@ const ClassroomRentalsPage: React.FC = () => {
fetchData(); fetchData();
}, [filterMonth]); }, [filterMonth]);
const resetUnavailableDates = () => {
unavailableRequestVersion.current += 1;
loadedUnavailableMonths.current.clear();
setUnavailableDates(new Set());
};
const loadUnavailableDates = useCallback(
async (classroomId: number, date: Dayjs, excludeId?: number) => {
const key = unavailableDatesCacheKey(classroomId, date);
if (loadedUnavailableMonths.current.has(key)) return;
loadedUnavailableMonths.current.add(key);
const requestVersion = unavailableRequestVersion.current;
setUnavailableDatesLoading(true);
try {
const response = await api.get<UnavailableDatesResponse>(
'/classroom-rentals/unavailable-dates',
{
params: {
classroomId,
year: date.year(),
month: date.month() + 1,
excludeId,
},
},
);
if (requestVersion !== unavailableRequestVersion.current) return;
setUnavailableDates((current) => {
const next = new Set(current);
response.dates.forEach((item) => next.add(item));
return next;
});
} catch (e: any) {
loadedUnavailableMonths.current.delete(key);
if (requestVersion === unavailableRequestVersion.current) {
message.error(e?.message || '加载教室占用日期失败');
}
} finally {
if (requestVersion === unavailableRequestVersion.current) {
setUnavailableDatesLoading(false);
}
}
},
[],
);
const handleClassroomChange = (classroomId: number) => {
form.setFieldValue('dateRange', undefined);
resetUnavailableDates();
void loadUnavailableDates(classroomId, dayjs(), editing?.id);
void loadUnavailableDates(classroomId, dayjs().add(1, 'month'), editing?.id);
};
const handleCalendarChange = (date: Dayjs) => {
const classroomId = form.getFieldValue('classroomId');
if (classroomId) void loadUnavailableDates(classroomId, date, editing?.id);
};
const isDateUnavailable = (date: Dayjs) => unavailableDates.has(date.format('YYYY-MM-DD'));
const rangeIncludesUnavailableDate = (range?: [Dayjs, Dayjs]) => {
if (!range) return false;
for (
let date = range[0].startOf('day');
!date.isAfter(range[1], 'day');
date = date.add(1, 'day')
) {
if (isDateUnavailable(date)) return true;
}
return false;
};
const handleSave = async () => { const handleSave = async () => {
const values = await form.validateFields(); const values = await form.validateFields();
if (rangeIncludesUnavailableDate(values.dateRange)) {
message.error('所选日期范围包含已排课或已租赁日期,请重新选择');
return;
}
setSaving(true); setSaving(true);
const payload = { const payload = {
classroomId: values.classroomId, classroomId: values.classroomId,
@@ -124,10 +211,7 @@ const ClassroomRentalsPage: React.FC = () => {
const handleDownloadContract = async (id: number, filename?: string) => { const handleDownloadContract = async (id: number, filename?: string) => {
try { try {
await downloadBlob( await downloadBlob(`/classroom-rentals/${id}/contract`, filename || `contract-${id}.pdf`);
`/classroom-rentals/${id}/contract`,
filename || `contract-${id}.pdf`,
);
} catch { } catch {
message.error('下载失败(可能文件已丢失)'); message.error('下载失败(可能文件已丢失)');
} }
@@ -145,6 +229,7 @@ const ClassroomRentalsPage: React.FC = () => {
const openEdit = (record: any) => { const openEdit = (record: any) => {
setEditing(record); setEditing(record);
resetUnavailableDates();
form.setFieldsValue({ form.setFieldsValue({
classroomId: record.classroomId, classroomId: record.classroomId,
tenantId: record.tenantId, tenantId: record.tenantId,
@@ -154,115 +239,145 @@ const ClassroomRentalsPage: React.FC = () => {
notes: record.notes, notes: record.notes,
}); });
setModalOpen(true); setModalOpen(true);
void loadUnavailableDates(record.classroomId, dayjs(record.startDate), record.id);
void loadUnavailableDates(
record.classroomId,
dayjs(record.startDate).add(1, 'month'),
record.id,
);
}; };
const columns = useMemo(() => [ const columns = useMemo(
{ () => [
title: '教室', width: 120, {
dataIndex: 'classroom', title: '教室',
render: (c: any) => width: 120,
c ? ( dataIndex: 'classroom',
<span> render: (c: any) =>
{c.building ? `${c.building} · ` : ''} c ? (
{c.name} <span>
</span> {c.building ? `${c.building} · ` : ''}
) : ( {c.name}
'-' </span>
), ) : (
}, '-'
{ ),
title: '租赁方', width: 100,
dataIndex: 'tenant',
render: (t: any) =>
t ? (
<Tag color={t.color} style={{ background: t.color, color: '#fff', borderColor: t.color }}>
{t.name}
</Tag>
) : (
'-'
),
},
{ title: '开始日期', dataIndex: 'startDate', width: 110 },
{ title: '结束日期', dataIndex: 'endDate', width: 110 },
{
title: '时长', width: 80,
render: (_: any, r: any) => {
const d = dayjs(r.endDate).diff(dayjs(r.startDate), 'day') + 1;
return `${d}`;
}, },
}, {
{ title: '日租金', dataIndex: 'dailyRate', width: 100, render: (v: any) => (v ? `¥${v}` : '-') }, title: '租赁方',
{ title: '总额', dataIndex: 'totalAmount', width: 100, render: (v: any) => (v ? `¥${v}` : '-') }, width: 100,
{ dataIndex: 'tenant',
title: '合同', width: 120, render: (t: any) =>
dataIndex: 'contractPath', t ? (
render: (v: string, r: any) => <Tag
v ? ( color={t.color}
<Space> style={{ background: t.color, color: '#fff', borderColor: t.color }}
<Tooltip title={r.contractOriginalName}> >
<Button {t.name}
size="small" </Tag>
icon={<FileTextOutlined />} ) : (
onClick={() => handleDownloadContract(r.id, r.contractOriginalName)} '-'
> ),
},
{ title: '开始日期', dataIndex: 'startDate', width: 110 },
{ title: '结束日期', dataIndex: 'endDate', width: 110 },
{
title: '时长',
width: 80,
render: (_: any, r: any) => {
const d = dayjs(r.endDate).diff(dayjs(r.startDate), 'day') + 1;
return `${d}`;
},
},
{
title: '日租金',
dataIndex: 'dailyRate',
width: 100,
render: (v: any) => (v ? `¥${v}` : '-'),
},
{
title: '总额',
dataIndex: 'totalAmount',
width: 100,
render: (v: any) => (v ? `¥${v}` : '-'),
},
{
title: '合同',
width: 120,
dataIndex: 'contractPath',
render: (v: string, r: any) =>
v ? (
<Space>
<Tooltip title={r.contractOriginalName}>
<Button
size="small"
icon={<FileTextOutlined />}
onClick={() => handleDownloadContract(r.id, r.contractOriginalName)}
>
</Button>
</Tooltip>
<Popconfirm title="删除合同文件?" onConfirm={() => handleDeleteContract(r.id)}>
<Button size="small" danger icon={<DeleteOutlined />} aria-label="删除合同文件" />
</Popconfirm>
</Space>
) : (
<Upload
accept="application/pdf"
showUploadList={false}
customRequest={async ({ file, onSuccess, onError }: any) => {
if (file.size > 10 * 1024 * 1024) {
message.error('文件不能超过 10MB');
onError?.(new Error('size'));
return;
}
const formData = new FormData();
formData.append('file', file);
try {
await api.post(`/classroom-rentals/${r.id}/contract`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
message.success('合同已上传');
onSuccess?.({});
fetchData();
} catch (e: any) {
message.error(e?.message || '上传失败');
onError?.(e);
}
}}
>
<Button size="small" icon={<UploadOutlined />}>
PDF
</Button> </Button>
</Tooltip> </Upload>
<Popconfirm title="删除合同文件?" onConfirm={() => handleDeleteContract(r.id)}> ),
<Button size="small" danger icon={<DeleteOutlined />} aria-label="删除合同文件" /> },
{
title: '操作',
width: 150,
render: (_: any, record: any) => (
<Space>
<PermissionButton
permission="rental:edit"
size="small"
onClick={() => openEdit(record)}
>
</PermissionButton>
<Popconfirm
title="确定删除该租赁订单?合同文件将一并删除。"
onConfirm={() => handleDelete(record.id)}
>
<PermissionButton permission="rental:delete" size="small" danger>
</PermissionButton>
</Popconfirm> </Popconfirm>
</Space> </Space>
) : (
<Upload
accept="application/pdf"
showUploadList={false}
customRequest={async ({ file, onSuccess, onError }: any) => {
if (file.size > 10 * 1024 * 1024) {
message.error('文件不能超过 10MB');
onError?.(new Error('size'));
return;
}
const formData = new FormData();
formData.append('file', file);
try {
await api.post(`/classroom-rentals/${r.id}/contract`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
message.success('合同已上传');
onSuccess?.({});
fetchData();
} catch (e: any) {
message.error(e?.message || '上传失败');
onError?.(e);
}
}}
>
<Button size="small" icon={<UploadOutlined />}>
PDF
</Button>
</Upload>
), ),
}, },
{ ],
title: '操作', [],
width: 150, );
render: (_: any, record: any) => (
<Space>
<PermissionButton permission="rental:edit" size="small" onClick={() => openEdit(record)}>
</PermissionButton>
<Popconfirm
title="确定删除该租赁订单?合同文件将一并删除。"
onConfirm={() => handleDelete(record.id)}
>
<PermissionButton permission="rental:delete" size="small" danger>
</PermissionButton>
</Popconfirm>
</Space>
),
},
], []);
return ( return (
<div> <div>
@@ -301,6 +416,7 @@ const ClassroomRentalsPage: React.FC = () => {
onClick={() => { onClick={() => {
setEditing(null); setEditing(null);
form.resetFields(); form.resetFields();
resetUnavailableDates();
setModalOpen(true); setModalOpen(true);
}} }}
> >
@@ -323,6 +439,7 @@ const ClassroomRentalsPage: React.FC = () => {
onCancel={() => { onCancel={() => {
setModalOpen(false); setModalOpen(false);
setEditing(null); setEditing(null);
resetUnavailableDates();
}} }}
confirmLoading={saving} confirmLoading={saving}
okText="保存" okText="保存"
@@ -334,6 +451,7 @@ const ClassroomRentalsPage: React.FC = () => {
showSearch showSearch
optionFilterProp="label" optionFilterProp="label"
placeholder="选择教室" placeholder="选择教室"
onChange={handleClassroomChange}
options={classrooms.map((c) => ({ options={classrooms.map((c) => ({
value: c.id, value: c.id,
label: `${c.building ? c.building + ' · ' : ''}${c.name}${c.roomType}`, label: `${c.building ? c.building + ' · ' : ''}${c.name}${c.roomType}`,
@@ -353,6 +471,9 @@ const ClassroomRentalsPage: React.FC = () => {
style={{ width: '100%' }} style={{ width: '100%' }}
placeholder={['开始日期', '结束日期']} placeholder={['开始日期', '结束日期']}
format="YYYY-MM-DD" format="YYYY-MM-DD"
disabled={!selectedClassroomId}
disabledDate={(date) => unavailableDatesLoading || isDateUnavailable(date)}
onPanelChange={(dates) => dates.forEach((date) => date && handleCalendarChange(date))}
/> />
</Form.Item> </Form.Item>
<Form.Item name="dailyRate" label="日租金(可选)"> <Form.Item name="dailyRate" label="日租金(可选)">

View File

@@ -0,0 +1,12 @@
import { describe, expect, it } from 'vitest';
import dayjs from 'dayjs';
import { unavailableDatesCacheKey } from './index';
describe('classroom rental unavailable dates cache', () => {
it('scopes loaded month keys by classroom id', () => {
const month = dayjs('2026-07-10');
expect(unavailableDatesCacheKey(1, month)).toBe('1:2026-07');
expect(unavailableDatesCacheKey(1, month)).not.toBe(unavailableDatesCacheKey(2, month));
});
});

View File

@@ -12,12 +12,12 @@ import {
Spin, Spin,
Empty, Empty,
Tooltip, Tooltip,
message, } from 'antd';
} from 'antd';
import { CalendarOutlined, FileTextOutlined } from '@ant-design/icons'; import { CalendarOutlined, FileTextOutlined } from '@ant-design/icons';
import dayjs, { Dayjs } from 'dayjs'; import dayjs, { Dayjs } from 'dayjs';
import api from '../../api'; import api from '../../api';
import { downloadBlob } from '../../utils/download'; import { downloadBlob } from '../../utils/download';
import { message } from '../../ui/app-message';
interface ScheduleData { interface ScheduleData {
year: number; year: number;

View File

@@ -8,7 +8,6 @@ import {
InputNumber, InputNumber,
Select, Select,
Space, Space,
message,
Tag, Tag,
Popconfirm, Popconfirm,
Upload, Upload,
@@ -24,6 +23,7 @@ import {
} from '@ant-design/icons'; } from '@ant-design/icons';
import api from '../../api'; import api from '../../api';
import PermissionButton from '../../components/PermissionButton'; import PermissionButton from '../../components/PermissionButton';
import { message } from '../../ui/app-message';
const statusMap: Record<string, { text: string; color: string }> = { const statusMap: Record<string, { text: string; color: string }> = {
available: { text: '可用', color: 'green' }, available: { text: '可用', color: 'green' },

View File

@@ -1,15 +1,5 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { import { Row, Col, Card, Statistic, DatePicker, Spin, Grid, Collapse } from 'antd';
Row,
Col,
Card,
Statistic,
DatePicker,
Spin,
Grid,
message,
Collapse,
} from 'antd';
import { import {
TeamOutlined, TeamOutlined,
HomeOutlined, HomeOutlined,
@@ -25,10 +15,11 @@ import {
ExclamationCircleOutlined, ExclamationCircleOutlined,
DollarOutlined, DollarOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import ReactECharts from 'echarts-for-react'; import ReactECharts, { type EChartsOption } from '../../components/ECharts';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import api from '../../api'; import api from '../../api';
import { message } from '../../ui/app-message';
const { RangePicker } = DatePicker; const { RangePicker } = DatePicker;
@@ -43,14 +34,48 @@ const COLORS = [
'#FFCC00', '#FFCC00',
]; ];
interface BillStatRow { status: string; count: string; total: string } interface BillStatRow {
interface ClassAttendanceRank { className: string; present: number; total: number; rate: number } status: string;
interface ClassroomOccupancy { name: string; building: string; capacity: number; scheduleDays: number; rentalCount: number; occupancy: number } count: string;
interface ClassroomUtilStats { totalClassrooms: number; inUseCount: number; utilizationRate: string; scheduleCount: number; rentalCount: number } total: string;
interface AttendanceTrendRow { date: string; rate: string } }
interface IncomeTrendRow { month: string; amount: number } interface ClassAttendanceRank {
interface OccupancyByBuildingRow { building: string; count: string } className: string;
interface ExpenseByTypeRow { type: string; total: string } present: number;
total: number;
rate: number;
}
interface ClassroomOccupancy {
name: string;
building: string;
capacity: number;
scheduleDays: number;
rentalCount: number;
occupancy: number;
}
interface ClassroomUtilStats {
totalClassrooms: number;
inUseCount: number;
utilizationRate: string;
scheduleCount: number;
rentalCount: number;
}
interface AttendanceTrendRow {
date: string;
rate: string;
}
interface IncomeTrendRow {
month: string;
amount: number;
}
interface OccupancyByBuildingRow {
building: string;
count: string;
}
interface ExpenseByTypeRow {
type: string;
total: string;
}
interface GanttOccupancy { interface GanttOccupancy {
studentName: string; studentName: string;
studentId?: string; studentId?: string;
@@ -160,7 +185,10 @@ const DashboardPage: React.FC = () => {
const isMobile = !screens.sm; const isMobile = !screens.sm;
const navigate = useNavigate(); const navigate = useNavigate();
const [stats, setStats] = useState<DashboardStats | null>(null); const [stats, setStats] = useState<DashboardStats | null>(null);
const [classRanking, setClassRanking] = useState<{ top: ClassAttendanceRank[]; bottom: ClassAttendanceRank[] }>({ top: [], bottom: [] }); const [classRanking, setClassRanking] = useState<{
top: ClassAttendanceRank[];
bottom: ClassAttendanceRank[];
}>({ top: [], bottom: [] });
const [classroomOccupancy, setClassroomOccupancy] = useState<ClassroomOccupancy[]>([]); const [classroomOccupancy, setClassroomOccupancy] = useState<ClassroomOccupancy[]>([]);
const [ganttData, setGanttData] = useState<GanttRoom[]>([]); const [ganttData, setGanttData] = useState<GanttRoom[]>([]);
const [roomRanking, setRoomRanking] = useState<Array<{ roomNumber: string; total: string }>>([]); const [roomRanking, setRoomRanking] = useState<Array<{ roomNumber: string; total: string }>>([]);
@@ -186,7 +214,9 @@ const DashboardPage: React.FC = () => {
api.get<Array<{ roomNumber: string; total: string }>>('/dashboard/room-ranking', { api.get<Array<{ roomNumber: string; total: string }>>('/dashboard/room-ranking', {
params: { periodStart: period[0], periodEnd: period[1] }, params: { periodStart: period[0], periodEnd: period[1] },
}), }),
api.get<{ top: ClassAttendanceRank[]; bottom: ClassAttendanceRank[] }>('/dashboard/class-attendance-ranking'), api.get<{ top: ClassAttendanceRank[]; bottom: ClassAttendanceRank[] }>(
'/dashboard/class-attendance-ranking',
),
api.get<GanttRoom[]>('/dashboard/gantt', { api.get<GanttRoom[]>('/dashboard/gantt', {
params: { periodStart: period[0], periodEnd: period[1] }, params: { periodStart: period[0], periodEnd: period[1] },
}), }),
@@ -215,56 +245,69 @@ const DashboardPage: React.FC = () => {
const [expenseTypeMap, setExpenseTypeMap] = useState<Record<string, string>>({}); const [expenseTypeMap, setExpenseTypeMap] = useState<Record<string, string>>({});
useEffect(() => { useEffect(() => {
api.get<Array<{ code: string; name: string }>>('/expense-types').then((types) => { api
const map: Record<string, string> = {}; .get<Array<{ code: string; name: string }>>('/expense-types')
for (const t of types) map[t.code] = t.name; .then((types) => {
setExpenseTypeMap(map); const map: Record<string, string> = {};
}).catch(() => {}); for (const t of types) map[t.code] = t.name;
setExpenseTypeMap(map);
})
.catch(() => {});
}, []); }, []);
// ─── 图表 option 计算(保留全部原有逻辑) ─── // ─── 图表 option 计算(保留全部原有逻辑) ───
// 今日出勤状态分布环图 // 今日出勤状态分布环图
const attendanceRingOption = useMemo(() => ({ const attendanceRingOption = useMemo<EChartsOption>(
tooltip: { trigger: 'item' }, () => ({
legend: { bottom: 0 }, tooltip: { trigger: 'item' },
series: [ legend: { bottom: 0 },
{ series: [
type: 'pie', {
radius: ['40%', '70%'], type: 'pie',
center: ['50%', '45%'], radius: ['40%', '70%'],
data: Object.entries(stats?.attendanceByStatus ?? {}).map(([status, count]) => ({ center: ['50%', '45%'],
name: attendanceLabelMap[status] ?? status, data: Object.entries(stats?.attendanceByStatus ?? {}).map(([status, count]) => ({
value: count, name: attendanceLabelMap[status] ?? status,
})), value: count,
itemStyle: { borderRadius: 4, borderColor: '#fff', borderWidth: 2 }, })),
}, itemStyle: { borderRadius: 4, borderColor: '#fff', borderWidth: 2 },
], },
color: COLORS, ],
}), [stats?.attendanceByStatus]); color: COLORS,
}),
[stats?.attendanceByStatus],
);
// 宿舍费用排行 // 宿舍费用排行
const barOption = useMemo(() => ({ const barOption = useMemo<EChartsOption>(
tooltip: {}, () => ({
grid: { left: 80, right: 20, bottom: 30, top: 10 }, tooltip: {},
xAxis: { type: 'value' }, grid: { left: 80, right: 20, bottom: 30, top: 10 },
yAxis: { xAxis: { type: 'value' },
type: 'category', yAxis: {
data: roomRanking.map((r) => r.roomNumber).reverse(), type: 'category',
inverse: false, data: roomRanking.map((r) => r.roomNumber).reverse(),
}, inverse: false,
series: [
{
type: 'bar',
data: roomRanking.map((r) => Number(r.total)).reverse(),
itemStyle: { color: '#007AFF', borderRadius: [0, 4, 4, 0] },
}, },
], series: [
}), [roomRanking]); {
type: 'bar',
data: roomRanking.map((r) => Number(r.total)).reverse(),
itemStyle: { color: '#007AFF', borderRadius: [0, 4, 4, 0] },
},
],
}),
[roomRanking],
);
// 班级考勤排行 - 前5 // 班级考勤排行 - 前5
const classRankingTopOption = { const classRankingTopOption: EChartsOption = {
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' }, valueFormatter: (v: number) => `${v}%` }, tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
valueFormatter: (v: number) => `${v}%`,
},
grid: { left: 80, right: 30, bottom: 30, top: 10 }, grid: { left: 80, right: 30, bottom: 30, top: 10 },
xAxis: { type: 'value', max: 100, axisLabel: { formatter: '{value}%' } }, xAxis: { type: 'value', max: 100, axisLabel: { formatter: '{value}%' } },
yAxis: { yAxis: {
@@ -283,8 +326,12 @@ const DashboardPage: React.FC = () => {
}; };
// 班级考勤排行 - 后5 // 班级考勤排行 - 后5
const classRankingBottomOption = { const classRankingBottomOption: EChartsOption = {
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' }, valueFormatter: (v: number) => `${v}%` }, tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
valueFormatter: (v: number) => `${v}%`,
},
grid: { left: 80, right: 30, bottom: 30, top: 10 }, grid: { left: 80, right: 30, bottom: 30, top: 10 },
xAxis: { type: 'value', max: 100, axisLabel: { formatter: '{value}%' } }, xAxis: { type: 'value', max: 100, axisLabel: { formatter: '{value}%' } },
yAxis: { yAxis: {
@@ -303,7 +350,7 @@ const DashboardPage: React.FC = () => {
}; };
// 考勤趋势折线图 // 考勤趋势折线图
const attendanceLineOption = { const attendanceLineOption: EChartsOption = {
tooltip: { trigger: 'axis' }, tooltip: { trigger: 'axis' },
grid: { left: 50, right: 20, bottom: 30, top: 10 }, grid: { left: 50, right: 20, bottom: 30, top: 10 },
xAxis: { xAxis: {
@@ -325,14 +372,17 @@ const DashboardPage: React.FC = () => {
}; };
// 收入趋势折线图 // 收入趋势折线图
const incomeLineOption = { const incomeLineOption: EChartsOption = {
tooltip: { trigger: 'axis', valueFormatter: (v: number) => `¥${v.toLocaleString()}` }, tooltip: { trigger: 'axis', valueFormatter: (v: number) => `¥${v.toLocaleString()}` },
grid: { left: 70, right: 20, bottom: 30, top: 10 }, grid: { left: 70, right: 20, bottom: 30, top: 10 },
xAxis: { xAxis: {
type: 'category', type: 'category',
data: (stats?.incomeTrend || []).map((d: { month: string }) => d.month), data: (stats?.incomeTrend || []).map((d: { month: string }) => d.month),
}, },
yAxis: { type: 'value', axisLabel: { formatter: (v: number) => `¥${(v / 10000).toFixed(0)}` } }, yAxis: {
type: 'value',
axisLabel: { formatter: (v: number) => `¥${(v / 10000).toFixed(0)}` },
},
series: [ series: [
{ {
type: 'line', type: 'line',
@@ -346,57 +396,68 @@ const DashboardPage: React.FC = () => {
}; };
// 入住时间线(甘特图) // 入住时间线(甘特图)
const ganttOption = useMemo(() => ({ const ganttOption = useMemo<EChartsOption>(
tooltip: { () => ({
formatter: (p: { data: { name: string; value: [string, string, string, boolean] } }) => tooltip: {
`${p.data.name}<br/>入住: ${p.data.value[1]}<br/>退宿: ${p.data.value[2]}`, formatter: (p: { data: { name: string; value: [string, string, string, boolean] } }) =>
}, `${p.data.name}<br/>入住: ${p.data.value[1]}<br/>退宿: ${p.data.value[2]}`,
grid: { left: 100, right: 30, bottom: 40, top: 20 },
xAxis: { type: 'time' },
yAxis: { type: 'category', data: ganttData.map((r) => r.roomNumber), inverse: true },
dataZoom: [
{ type: 'slider', xAxisIndex: 0, bottom: 10, height: 20 },
{ type: 'inside', xAxisIndex: 0 },
],
series: [{
type: 'custom',
renderItem: (_params: unknown, api: {
value: (i: number) => string | boolean;
coord: (p: [string | number, string | number]) => [number, number];
size: (p: [number, number]) => [number, number];
}) => {
const [cat, startDate, endDate, isActive] = [
api.value(0), api.value(1), api.value(2), api.value(3),
] as unknown as [string, string, string, boolean];
const start = api.coord([startDate, cat]);
const end = api.coord([endDate, cat]);
const height = api.size([0, 1])[1] * 0.6;
const rectShape = {
x: start[0],
y: start[1] - height / 2,
width: Math.max(end[0] - start[0], 2),
height,
};
return {
type: 'rect' as const,
shape: rectShape,
style: { fill: isActive ? '#34C759' : '#FF9500', stroke: '#fff', lineWidth: 1 },
};
}, },
encode: { x: [1, 2], y: 0 }, grid: { left: 100, right: 30, bottom: 40, top: 20 },
data: ganttData.flatMap((r) => xAxis: { type: 'time' },
(r.occupancies || []).map((o) => ({ yAxis: { type: 'category', data: ganttData.map((r) => r.roomNumber), inverse: true },
name: o.studentName, dataZoom: [
value: [ { type: 'slider', xAxisIndex: 0, bottom: 10, height: 20 },
r.roomNumber, { type: 'inside', xAxisIndex: 0 },
o.checkInDate, ],
o.checkOutDate || new Date().toISOString().slice(0, 10), series: [
!o.checkOutDate, {
] as [string, string, string, boolean], type: 'custom',
})) renderItem: (
), _params: unknown,
}], api: {
}), [ganttData]); value: (i: number) => string | boolean;
coord: (p: [string | number, string | number]) => [number, number];
size: (p: [number, number]) => [number, number];
},
) => {
const [cat, startDate, endDate, isActive] = [
api.value(0),
api.value(1),
api.value(2),
api.value(3),
] as unknown as [string, string, string, boolean];
const start = api.coord([startDate, cat]);
const end = api.coord([endDate, cat]);
const height = api.size([0, 1])[1] * 0.6;
const rectShape = {
x: start[0],
y: start[1] - height / 2,
width: Math.max(end[0] - start[0], 2),
height,
};
return {
type: 'rect' as const,
shape: rectShape,
style: { fill: isActive ? '#34C759' : '#FF9500', stroke: '#fff', lineWidth: 1 },
};
},
encode: { x: [1, 2], y: 0 },
data: ganttData.flatMap((r) =>
(r.occupancies || []).map((o) => ({
name: o.studentName,
value: [
r.roomNumber,
o.checkInDate,
o.checkOutDate || new Date().toISOString().slice(0, 10),
!o.checkOutDate,
] as [string, string, string, boolean],
})),
),
},
],
}),
[ganttData],
);
// ─── 懒加载 hooks ─── // ─── 懒加载 hooks ───
const classroomHeatmapVp = useInViewport('200px'); const classroomHeatmapVp = useInViewport('200px');
@@ -425,7 +486,9 @@ const DashboardPage: React.FC = () => {
gap: 12, gap: 12,
}} }}
> >
<h2 style={{ margin: 0 }}>{refreshLoading && <Spin size="small" style={{ marginLeft: 12 }} />}</h2> <h2 style={{ margin: 0 }}>
{refreshLoading && <Spin size="small" style={{ marginLeft: 12 }} />}
</h2>
<RangePicker <RangePicker
aria-label="选择日期范围" aria-label="选择日期范围"
value={[dayjs(period[0]), dayjs(period[1])]} value={[dayjs(period[0]), dayjs(period[1])]}
@@ -445,14 +508,22 @@ const DashboardPage: React.FC = () => {
styles={{ body: { padding: 16 } }} styles={{ body: { padding: 16 } }}
onClick={() => navigate('/attendance')} onClick={() => navigate('/attendance')}
> >
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <div
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}
>
<ExclamationCircleOutlined <ExclamationCircleOutlined
style={{ fontSize: 28, color: absentCount > 0 ? '#FF9500' : '#999' }} style={{ fontSize: 28, color: absentCount > 0 ? '#FF9500' : '#999' }}
/> />
<ArrowRightOutlined style={{ color: '#bbb' }} /> <ArrowRightOutlined style={{ color: '#bbb' }} />
</div> </div>
<div style={{ marginTop: 8 }}> <div style={{ marginTop: 8 }}>
<div style={{ fontSize: 24, fontWeight: 700, color: absentCount > 0 ? '#FF9500' : '#999' }}> <div
style={{
fontSize: 24,
fontWeight: 700,
color: absentCount > 0 ? '#FF9500' : '#999',
}}
>
{absentCount} {absentCount}
</div> </div>
<div style={{ fontSize: 13, color: '#666', marginTop: 2 }}></div> <div style={{ fontSize: 13, color: '#666', marginTop: 2 }}></div>
@@ -472,18 +543,28 @@ const DashboardPage: React.FC = () => {
styles={{ body: { padding: 16 } }} styles={{ body: { padding: 16 } }}
onClick={() => navigate('/bills')} onClick={() => navigate('/bills')}
> >
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <div
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}
>
<DollarOutlined <DollarOutlined
style={{ fontSize: 28, color: draftCount > 0 ? '#AF52DE' : '#999' }} style={{ fontSize: 28, color: draftCount > 0 ? '#AF52DE' : '#999' }}
/> />
<ArrowRightOutlined style={{ color: '#bbb' }} /> <ArrowRightOutlined style={{ color: '#bbb' }} />
</div> </div>
<div style={{ marginTop: 8 }}> <div style={{ marginTop: 8 }}>
<div style={{ fontSize: 24, fontWeight: 700, color: draftCount > 0 ? '#AF52DE' : '#999' }}> <div
style={{
fontSize: 24,
fontWeight: 700,
color: draftCount > 0 ? '#AF52DE' : '#999',
}}
>
{draftCount} {draftCount}
</div> </div>
<div style={{ fontSize: 13, color: '#666', marginTop: 2 }}></div> <div style={{ fontSize: 13, color: '#666', marginTop: 2 }}></div>
<div style={{ fontSize: 12, color: draftCount > 0 ? '#AF52DE' : '#999', marginTop: 4 }}> <div
style={{ fontSize: 12, color: draftCount > 0 ? '#AF52DE' : '#999', marginTop: 4 }}
>
{draftCount > 0 ? `合计 ¥${draftTotal.toLocaleString()}` : '暂无待处理'} {draftCount > 0 ? `合计 ¥${draftTotal.toLocaleString()}` : '暂无待处理'}
</div> </div>
</div> </div>
@@ -497,18 +578,32 @@ const DashboardPage: React.FC = () => {
styles={{ body: { padding: 16 } }} styles={{ body: { padding: 16 } }}
onClick={() => navigate('/deposits')} onClick={() => navigate('/deposits')}
> >
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <div
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}
>
<BankOutlined <BankOutlined
style={{ fontSize: 28, color: pendingDeposits > 0 ? '#FF3B30' : '#999' }} style={{ fontSize: 28, color: pendingDeposits > 0 ? '#FF3B30' : '#999' }}
/> />
<ArrowRightOutlined style={{ color: '#bbb' }} /> <ArrowRightOutlined style={{ color: '#bbb' }} />
</div> </div>
<div style={{ marginTop: 8 }}> <div style={{ marginTop: 8 }}>
<div style={{ fontSize: 24, fontWeight: 700, color: pendingDeposits > 0 ? '#FF3B30' : '#999' }}> <div
style={{
fontSize: 24,
fontWeight: 700,
color: pendingDeposits > 0 ? '#FF3B30' : '#999',
}}
>
¥{pendingDeposits.toLocaleString()} ¥{pendingDeposits.toLocaleString()}
</div> </div>
<div style={{ fontSize: 13, color: '#666', marginTop: 2 }}>退</div> <div style={{ fontSize: 13, color: '#666', marginTop: 2 }}>退</div>
<div style={{ fontSize: 12, color: pendingDeposits > 0 ? '#FF3B30' : '#999', marginTop: 4 }}> <div
style={{
fontSize: 12,
color: pendingDeposits > 0 ? '#FF3B30' : '#999',
marginTop: 4,
}}
>
{pendingDeposits > 0 ? '需要处理' : '暂无待退'} {pendingDeposits > 0 ? '需要处理' : '暂无待退'}
</div> </div>
</div> </div>
@@ -577,90 +672,135 @@ const DashboardPage: React.FC = () => {
{/* ═══════════ 更多指标(折叠) ═══════════ */} {/* ═══════════ 更多指标(折叠) ═══════════ */}
<Collapse <Collapse
ghost ghost
items={[{ items={[
key: 'more-metrics', {
label: '更多指标', key: 'more-metrics',
children: ( label: '更多指标',
<> children: (
<Row gutter={[16, 16]} style={SECTION_ROW_STYLE}> <>
<Col xs={12} sm={12} md={6}> <Row gutter={[16, 16]} style={SECTION_ROW_STYLE}>
<Card> <Col xs={12} sm={12} md={6}>
<Statistic title="宿舍总数" value={stats?.totalRooms || 0} prefix={<HomeOutlined />} /> <Card>
</Card> <Statistic
</Col> title="宿舍总数"
<Col xs={12} sm={12} md={6}> value={stats?.totalRooms || 0}
<Card> prefix={<HomeOutlined />}
<Statistic title="在读学生" value={stats?.totalStudents || 0} prefix={<TeamOutlined />} /> />
</Card> </Card>
</Col>
<Col xs={12} sm={12} md={6}>
<Card>
<Statistic title="教室总数" value={stats?.classroomCount || 0} prefix={<BankOutlined />} />
</Card>
</Col>
<Col xs={12} sm={12} md={6}>
<Card>
<Statistic title="班级总数" value={stats?.classCount ?? 0} prefix={<TeamOutlined />} />
</Card>
</Col>
</Row>
<Row gutter={[16, 16]} style={SECTION_ROW_STYLE}>
<Col xs={12} sm={12} md={6}>
<Card>
<Statistic title="教师总数" value={stats?.teacherCount ?? 0} prefix={<SolutionOutlined />} />
</Card>
</Col>
<Col xs={12} sm={12} md={6}>
<Card>
<Statistic
title="待退押金"
value={stats?.pendingDeposits ?? 0}
precision={2}
prefix="¥"
/>
</Card>
</Col>
<Col xs={12} sm={12} md={6}>
<Card>
<Statistic title="活跃租赁" value={stats?.activeRentals ?? 0} prefix={<FileProtectOutlined />} />
</Card>
</Col>
<Col xs={12} sm={12} md={6}>
<Card>
<Statistic
title="今日出勤"
value={stats?.todayPresent ?? 0}
suffix="人"
prefix={<CheckCircleOutlined />}
/>
</Card>
</Col>
</Row>
<Card title="教室利用率" style={MARGIN_BOTTOM_16_STYLE}>
<Row gutter={[24, 16]}>
<Col xs={12} sm={6}>
<Statistic title="教室总数" value={classroomUtil?.totalClassrooms ?? '-'} prefix={<ReadOutlined />} />
</Col> </Col>
<Col xs={12} sm={6}> <Col xs={12} sm={12} md={6}>
<Statistic title="今日使用" value={classroomUtil?.inUseCount ?? '-'} prefix={<CheckCircleOutlined />} /> <Card>
<Statistic
title="在读学生"
value={stats?.totalStudents || 0}
prefix={<TeamOutlined />}
/>
</Card>
</Col> </Col>
<Col xs={12} sm={6}> <Col xs={12} sm={12} md={6}>
<Statistic <Card>
title="利用率" <Statistic
value={classroomUtil?.utilizationRate ?? '-'} title="教室总数"
suffix="%" value={stats?.classroomCount || 0}
prefix={<PercentageOutlined />} prefix={<BankOutlined />}
styles={{ value: { color: Number(classroomUtil?.utilizationRate ?? 0) > 70 ? '#34C759' : '#FF9500' } }} />
/> </Card>
</Col> </Col>
<Col xs={12} sm={6}> <Col xs={12} sm={12} md={6}>
<Statistic title="内部排课" value={classroomUtil?.scheduleCount ?? '-'} prefix={<CalendarOutlined />} /> <Card>
<Statistic
title="班级总数"
value={stats?.classCount ?? 0}
prefix={<TeamOutlined />}
/>
</Card>
</Col> </Col>
</Row> </Row>
</Card> <Row gutter={[16, 16]} style={SECTION_ROW_STYLE}>
</> <Col xs={12} sm={12} md={6}>
), <Card>
}]} <Statistic
title="教师总数"
value={stats?.teacherCount ?? 0}
prefix={<SolutionOutlined />}
/>
</Card>
</Col>
<Col xs={12} sm={12} md={6}>
<Card>
<Statistic
title="待退押金"
value={stats?.pendingDeposits ?? 0}
precision={2}
prefix="¥"
/>
</Card>
</Col>
<Col xs={12} sm={12} md={6}>
<Card>
<Statistic
title="活跃租赁"
value={stats?.activeRentals ?? 0}
prefix={<FileProtectOutlined />}
/>
</Card>
</Col>
<Col xs={12} sm={12} md={6}>
<Card>
<Statistic
title="今日出勤"
value={stats?.todayPresent ?? 0}
suffix="人"
prefix={<CheckCircleOutlined />}
/>
</Card>
</Col>
</Row>
<Card title="教室利用率" style={MARGIN_BOTTOM_16_STYLE}>
<Row gutter={[24, 16]}>
<Col xs={12} sm={6}>
<Statistic
title="教室总数"
value={classroomUtil?.totalClassrooms ?? '-'}
prefix={<ReadOutlined />}
/>
</Col>
<Col xs={12} sm={6}>
<Statistic
title="今日使用"
value={classroomUtil?.inUseCount ?? '-'}
prefix={<CheckCircleOutlined />}
/>
</Col>
<Col xs={12} sm={6}>
<Statistic
title="利用率"
value={classroomUtil?.utilizationRate ?? '-'}
suffix="%"
prefix={<PercentageOutlined />}
styles={{
value: {
color:
Number(classroomUtil?.utilizationRate ?? 0) > 70
? '#34C759'
: '#FF9500',
},
}}
/>
</Col>
<Col xs={12} sm={6}>
<Statistic
title="内部排课"
value={classroomUtil?.scheduleCount ?? '-'}
prefix={<CalendarOutlined />}
/>
</Col>
</Row>
</Card>
</>
),
},
]}
/> />
{/* ═══════════ 图表:考勤趋势 + 出勤分布 ═══════════ */} {/* ═══════════ 图表:考勤趋势 + 出勤分布 ═══════════ */}
@@ -668,7 +808,10 @@ const DashboardPage: React.FC = () => {
<Col xs={24} sm={12}> <Col xs={24} sm={12}>
<Card title="考勤趋势近30天"> <Card title="考勤趋势近30天">
{(stats?.attendanceTrend || []).length > 0 ? ( {(stats?.attendanceTrend || []).length > 0 ? (
<ReactECharts option={attendanceLineOption} style={{ width: '100%', height: isMobile ? 250 : 300 }} /> <ReactECharts
option={attendanceLineOption}
style={{ width: '100%', height: isMobile ? 250 : 300 }}
/>
) : ( ) : (
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}></div> <div style={{ textAlign: 'center', padding: 40, color: '#999' }}></div>
)} )}
@@ -677,7 +820,10 @@ const DashboardPage: React.FC = () => {
<Col xs={24} sm={12}> <Col xs={24} sm={12}>
<Card title="今日出勤状态分布"> <Card title="今日出勤状态分布">
{Object.keys(stats?.attendanceByStatus ?? {}).length > 0 ? ( {Object.keys(stats?.attendanceByStatus ?? {}).length > 0 ? (
<ReactECharts option={attendanceRingOption} style={{ width: '100%', height: isMobile ? 250 : 300 }} /> <ReactECharts
option={attendanceRingOption}
style={{ width: '100%', height: isMobile ? 250 : 300 }}
/>
) : ( ) : (
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}></div> <div style={{ textAlign: 'center', padding: 40, color: '#999' }}></div>
)} )}
@@ -690,7 +836,10 @@ const DashboardPage: React.FC = () => {
<Col xs={24} sm={12}> <Col xs={24} sm={12}>
<Card title="班级出勤率 TOP 5"> <Card title="班级出勤率 TOP 5">
{classRanking.top.length > 0 ? ( {classRanking.top.length > 0 ? (
<ReactECharts option={classRankingTopOption} style={{ width: '100%', height: isMobile ? 250 : 300 }} /> <ReactECharts
option={classRankingTopOption}
style={{ width: '100%', height: isMobile ? 250 : 300 }}
/>
) : ( ) : (
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}></div> <div style={{ textAlign: 'center', padding: 40, color: '#999' }}></div>
)} )}
@@ -699,7 +848,10 @@ const DashboardPage: React.FC = () => {
<Col xs={24} sm={12}> <Col xs={24} sm={12}>
<Card title="班级出勤率 末位 5"> <Card title="班级出勤率 末位 5">
{classRanking.bottom.length > 0 ? ( {classRanking.bottom.length > 0 ? (
<ReactECharts option={classRankingBottomOption} style={{ width: '100%', height: isMobile ? 250 : 300 }} /> <ReactECharts
option={classRankingBottomOption}
style={{ width: '100%', height: isMobile ? 250 : 300 }}
/>
) : ( ) : (
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}></div> <div style={{ textAlign: 'center', padding: 40, color: '#999' }}></div>
)} )}
@@ -711,13 +863,28 @@ const DashboardPage: React.FC = () => {
<Row gutter={[16, 16]} style={SECTION_ROW_STYLE}> <Row gutter={[16, 16]} style={SECTION_ROW_STYLE}>
<Col xs={24} sm={12}> <Col xs={24} sm={12}>
<Card title="费用类型分布"> <Card title="费用类型分布">
{((stats?.expenseByType) ?? []).length > 0 ? ( {(stats?.expenseByType ?? []).length > 0 ? (
<ReactECharts option={{ <ReactECharts
tooltip: { trigger: 'item' }, option={
legend: { bottom: 0 }, {
color: COLORS, tooltip: { trigger: 'item' },
series: [{ type: 'pie', radius: ['40%', '70%'], center: ['50%', '45%'], data: (stats?.expenseByType ?? []).map((e) => ({ name: expenseTypeMap[e.type] ?? e.type, value: Number(e.total) })) }], legend: { bottom: 0 },
}} style={{ width: '100%', height: isMobile ? 250 : 300 }} /> color: COLORS,
series: [
{
type: 'pie',
radius: ['40%', '70%'],
center: ['50%', '45%'],
data: (stats?.expenseByType ?? []).map((e) => ({
name: expenseTypeMap[e.type] ?? e.type,
value: Number(e.total),
})),
},
],
} satisfies EChartsOption
}
style={{ width: '100%', height: isMobile ? 250 : 300 }}
/>
) : ( ) : (
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}></div> <div style={{ textAlign: 'center', padding: 40, color: '#999' }}></div>
)} )}
@@ -726,7 +893,10 @@ const DashboardPage: React.FC = () => {
<Col xs={24} sm={12}> <Col xs={24} sm={12}>
<Card title="宿舍费用排行 TOP 20"> <Card title="宿舍费用排行 TOP 20">
{roomRanking.length > 0 ? ( {roomRanking.length > 0 ? (
<ReactECharts option={barOption} style={{ width: '100%', height: isMobile ? 250 : 300 }} /> <ReactECharts
option={barOption}
style={{ width: '100%', height: isMobile ? 250 : 300 }}
/>
) : ( ) : (
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}></div> <div style={{ textAlign: 'center', padding: 40, color: '#999' }}></div>
)} )}
@@ -739,7 +909,10 @@ const DashboardPage: React.FC = () => {
<Col xs={24}> <Col xs={24}>
<Card title="月度收入趋势"> <Card title="月度收入趋势">
{(stats?.incomeTrend || []).length > 0 ? ( {(stats?.incomeTrend || []).length > 0 ? (
<ReactECharts option={incomeLineOption} style={{ width: '100%', height: isMobile ? 250 : 300 }} /> <ReactECharts
option={incomeLineOption}
style={{ width: '100%', height: isMobile ? 250 : 300 }}
/>
) : ( ) : (
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}></div> <div style={{ textAlign: 'center', padding: 40, color: '#999' }}></div>
)} )}
@@ -754,27 +927,60 @@ const DashboardPage: React.FC = () => {
<Col xs={24}> <Col xs={24}>
<Card title="教室占用热力图"> <Card title="教室占用热力图">
{classroomOccupancy.length > 0 ? ( {classroomOccupancy.length > 0 ? (
<ReactECharts option={{ <ReactECharts
tooltip: { option={
formatter: (p: { name: string; data: { scheduleDays: number; rentalCount: number; occupancy: number } }) => {
`${p.name}<br/>排课: ${p.data.scheduleDays}天 租赁: ${p.data.rentalCount}个 占用率: ${(p.data.occupancy * 100).toFixed(0)}%`, tooltip: {
}, formatter: (p: {
grid: { left: 100, right: 20, bottom: 30, top: 10 }, name: string;
xAxis: { type: 'value', max: 1 }, data: { scheduleDays: number; rentalCount: number; occupancy: number };
yAxis: { type: 'category', data: classroomOccupancy.map((r) => r.name), inverse: true }, }) =>
visualMap: { `${p.name}<br/>排课: ${p.data.scheduleDays}天 租赁: ${p.data.rentalCount}个 占用率: ${(p.data.occupancy * 100).toFixed(0)}%`,
min: 0, max: 1, orient: 'horizontal', left: 'center', bottom: 0, },
inRange: { color: ['#e6f4ff', '#91caff', '#40a9ff', '#0050b3', '#002c8c'] }, grid: { left: 100, right: 20, bottom: 30, top: 10 },
}, xAxis: { type: 'value', max: 1 },
series: [{ yAxis: {
type: 'bar', type: 'category',
data: classroomOccupancy.map((r) => ({ name: r.name, value: r.occupancy, scheduleDays: r.scheduleDays, rentalCount: r.rentalCount, occupancy: r.occupancy })), data: classroomOccupancy.map((r) => r.name),
itemStyle: { borderRadius: [0, 4, 4, 0] }, inverse: true,
label: { show: true, position: 'right', formatter: (p: { data: { occupancy: number } }) => `${(p.data.occupancy * 100).toFixed(0)}%` }, },
}], visualMap: {
}} style={{ width: '100%', height: isMobile ? 300 : 400 }} /> min: 0,
max: 1,
orient: 'horizontal',
left: 'center',
bottom: 0,
inRange: {
color: ['#e6f4ff', '#91caff', '#40a9ff', '#0050b3', '#002c8c'],
},
},
series: [
{
type: 'bar',
data: classroomOccupancy.map((r) => ({
name: r.name,
value: r.occupancy,
scheduleDays: r.scheduleDays,
rentalCount: r.rentalCount,
occupancy: r.occupancy,
})),
itemStyle: { borderRadius: [0, 4, 4, 0] },
label: {
show: true,
position: 'right',
formatter: (p: { data: { occupancy: number } }) =>
`${(p.data.occupancy * 100).toFixed(0)}%`,
},
},
],
} satisfies EChartsOption
}
style={{ width: '100%', height: isMobile ? 300 : 400 }}
/>
) : ( ) : (
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}></div> <div style={{ textAlign: 'center', padding: 40, color: '#999' }}>
</div>
)} )}
</Card> </Card>
</Col> </Col>
@@ -793,9 +999,14 @@ const DashboardPage: React.FC = () => {
<Col xs={24}> <Col xs={24}>
<Card title="入住时间线(甘特图)"> <Card title="入住时间线(甘特图)">
{ganttData.length > 0 ? ( {ganttData.length > 0 ? (
<ReactECharts option={ganttOption} style={{ width: '100%', height: isMobile ? 300 : 450 }} /> <ReactECharts
option={ganttOption}
style={{ width: '100%', height: isMobile ? 300 : 450 }}
/>
) : ( ) : (
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}></div> <div style={{ textAlign: 'center', padding: 40, color: '#999' }}>
</div>
)} )}
</Card> </Card>
</Col> </Col>

View File

@@ -8,7 +8,6 @@ import {
InputNumber, InputNumber,
Input, Input,
Space, Space,
message,
Tag, Tag,
Popconfirm, Popconfirm,
Tabs, Tabs,
@@ -21,6 +20,7 @@ import dayjs from 'dayjs';
import api from '../../api'; import api from '../../api';
import { maskPhone, maskIdNumber } from '../../utils/sensitive'; import { maskPhone, maskIdNumber } from '../../utils/sensitive';
import PermissionButton from '../../components/PermissionButton'; import PermissionButton from '../../components/PermissionButton';
import { message } from '../../ui/app-message';
const statusMap: Record<string, { text: string; color: string }> = { const statusMap: Record<string, { text: string; color: string }> = {
paid: { text: '已缴', color: 'green' }, paid: { text: '已缴', color: 'green' },

View File

@@ -9,7 +9,6 @@ import {
InputNumber, InputNumber,
Input, Input,
Space, Space,
message,
Tag, Tag,
Tabs, Tabs,
Popconfirm, Popconfirm,
@@ -28,6 +27,7 @@ import dayjs from 'dayjs';
import api from '../../api'; import api from '../../api';
import PermissionButton from '../../components/PermissionButton'; import PermissionButton from '../../components/PermissionButton';
import { downloadBlob } from '../../utils/download'; import { downloadBlob } from '../../utils/download';
import { message } from '../../ui/app-message';
const { RangePicker } = DatePicker; const { RangePicker } = DatePicker;

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useState, useMemo, useCallback } from 'react'; import React, { useEffect, useState, useMemo, useCallback } from 'react';
import { import {
Card, Form, Input, Button, Space, message, Spin, Switch, Alert, Descriptions, Tag, Card, Form, Input, Button, Space, Spin, Switch, Alert, Descriptions, Tag,
Tabs, Drawer, Tree, Select, TreeSelect, Modal, DatePicker, InputNumber, Tabs, Drawer, Tree, Select, TreeSelect, Modal, DatePicker, InputNumber,
Row, Col, List, Row, Col, List,
} from 'antd'; } from 'antd';
@@ -11,6 +11,7 @@ import {
import type { DataNode } from 'antd/es/tree'; import type { DataNode } from 'antd/es/tree';
import type { TreeSelectProps } from 'antd/es/tree-select'; import type { TreeSelectProps } from 'antd/es/tree-select';
import api from '../../api'; import api from '../../api';
import { message } from '../../ui/app-message';
interface DingTalkConfig { interface DingTalkConfig {
agentId: string; agentId: string;

View File

@@ -1,8 +1,10 @@
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Form, Input, Button, Card, message, Typography } from 'antd'; import { Form, Input, Button, Card, Typography } from 'antd';
import { UserOutlined, LockOutlined } from '@ant-design/icons'; import { UserOutlined, LockOutlined } from '@ant-design/icons';
import api from '../../api'; import api from '../../api';
import { message } from '../../ui/app-message';
import { writePermissions } from '../../auth/permission-store';
const { Title } = Typography; const { Title } = Typography;
@@ -16,7 +18,7 @@ const LoginPage: React.FC = () => {
const res: any = await api.post('/auth/login', values); const res: any = await api.post('/auth/login', values);
localStorage.setItem('token', res.access_token); localStorage.setItem('token', res.access_token);
localStorage.setItem('user', JSON.stringify(res.user)); localStorage.setItem('user', JSON.stringify(res.user));
localStorage.setItem('permissions', JSON.stringify(res.user.permissions || [])); writePermissions(res.user.permissions || []);
message.success('登录成功'); message.success('登录成功');
navigate('/dashboard'); navigate('/dashboard');
} catch (err: any) { } catch (err: any) {

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { List, Typography, Menu, Layout, Button, Empty, Spin, Space, message } from 'antd'; import { List, Typography, Menu, Layout, Button, Empty, Spin, Space } from 'antd';
import { import {
BellOutlined, BellOutlined,
DollarOutlined, DollarOutlined,
@@ -9,6 +9,7 @@ import {
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import api from '../../api'; import api from '../../api';
import { message } from '../../ui/app-message';
const { Sider, Content } = Layout; const { Sider, Content } = Layout;

View File

@@ -9,7 +9,6 @@ import {
Input, Input,
InputNumber, InputNumber,
Space, Space,
message,
Tag, Tag,
Popconfirm, Popconfirm,
Upload, Upload,
@@ -32,6 +31,8 @@ import api from '../../api';
import { downloadBlob } from '../../utils/download'; import { downloadBlob } from '../../utils/download';
import { maskPhone, maskIdNumber } from '../../utils/sensitive'; import { maskPhone, maskIdNumber } from '../../utils/sensitive';
import PermissionButton from '../../components/PermissionButton'; import PermissionButton from '../../components/PermissionButton';
import { message } from '../../ui/app-message';
const { RangePicker } = DatePicker; const { RangePicker } = DatePicker;
const OccupanciesPage: React.FC = () => { const OccupanciesPage: React.FC = () => {

View File

@@ -1,7 +1,8 @@
import React, { useEffect, useState, useMemo, useCallback } from 'react'; import React, { useEffect, useState, useMemo, useCallback } from 'react';
import { Table, Select, DatePicker, Space, Tag, Tooltip, message } from 'antd'; import { Table, Select, DatePicker, Space, Tag, Tooltip } from 'antd';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import api from '../../api'; import api from '../../api';
import { message } from '../../ui/app-message';
const { RangePicker } = DatePicker; const { RangePicker } = DatePicker;

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Card, Tag, Input, Space, Spin, Empty, message } from 'antd'; import { Card, Tag, Input, Space, Spin, Empty } from 'antd';
import api from '../../api'; import api from '../../api';
import { message } from '../../ui/app-message';
interface PermissionItem { interface PermissionItem {
id: number; id: number;

View File

@@ -7,7 +7,6 @@ import {
Space, Space,
Tag, Tag,
Popconfirm, Popconfirm,
message,
Card, Card,
Checkbox, Checkbox,
Empty, Empty,
@@ -15,6 +14,7 @@ import {
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons'; import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
import api from '../../api'; import api from '../../api';
import PermissionButton from '../../components/PermissionButton'; import PermissionButton from '../../components/PermissionButton';
import { message } from '../../ui/app-message';
interface PermissionItem { interface PermissionItem {
id: number; id: number;

View File

@@ -1,8 +1,9 @@
import React, { useEffect, useState, useCallback } from 'react'; import React, { useEffect, useState, useCallback } from 'react';
import { Row, Col, Card, Tag, Select, Statistic, Modal, Spin, Badge, Tooltip, DatePicker, Alert, Button, message } from 'antd'; import { Row, Col, Card, Tag, Select, Statistic, Modal, Spin, Badge, Tooltip, DatePicker, Alert, Button } from 'antd';
import { HomeOutlined, UserOutlined, CalendarOutlined, BankOutlined, HistoryOutlined, ShopOutlined } from '@ant-design/icons'; import { HomeOutlined, UserOutlined, CalendarOutlined, BankOutlined, HistoryOutlined, ShopOutlined } from '@ant-design/icons';
import dayjs, { Dayjs } from 'dayjs'; import dayjs, { Dayjs } from 'dayjs';
import api from '../../api'; import api from '../../api';
import { message } from '../../ui/app-message';
function getCardStyle(room: any): React.CSSProperties { function getCardStyle(room: any): React.CSSProperties {
let base: React.CSSProperties; let base: React.CSSProperties;

View File

@@ -8,7 +8,6 @@ import {
InputNumber, InputNumber,
Select, Select,
Space, Space,
message,
Tag, Tag,
Popconfirm, Popconfirm,
Badge, Badge,
@@ -31,6 +30,7 @@ import {
import api from '../../api'; import api from '../../api';
import { downloadBlob } from '../../utils/download'; import { downloadBlob } from '../../utils/download';
import PermissionButton from '../../components/PermissionButton'; import PermissionButton from '../../components/PermissionButton';
import { message } from '../../ui/app-message';
const statusMap: Record<string, { text: string; color: string }> = { const statusMap: Record<string, { text: string; color: string }> = {
available: { text: '可入住', color: 'green' }, available: { text: '可入住', color: 'green' },

View File

@@ -1,8 +1,26 @@
import React, { useEffect, useState, useMemo, useCallback } from 'react'; import React, { useEffect, useState, useMemo, useCallback } from 'react';
import { import {
Card, Button, Select, Modal, Form, Input, DatePicker, TimePicker, Card,
Popconfirm, message, Space, Spin, Empty, Tag, Tooltip, Segmented, Button,
Badge, Row, Col, Statistic, Alert, Select,
Modal,
Form,
Input,
DatePicker,
TimePicker,
Popconfirm,
Space,
Spin,
Empty,
Tag,
Tooltip,
Segmented,
Badge,
Row,
Col,
Statistic,
Alert,
Switch,
} from 'antd'; } from 'antd';
import { import {
CalendarOutlined, CalendarOutlined,
@@ -11,10 +29,17 @@ import {
DeleteOutlined, DeleteOutlined,
CloudSyncOutlined, CloudSyncOutlined,
PlusOutlined, PlusOutlined,
EditOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import dayjs, { Dayjs } from 'dayjs'; import dayjs, { Dayjs } from 'dayjs';
import api from '../../api'; import api from '../../api';
import PermissionButton from '../../components/PermissionButton'; import PermissionButton from '../../components/PermissionButton';
import { message } from '../../ui/app-message';
import {
buildSchedulePayload,
scheduleToFormValues,
type ScheduleFormValues,
} from './schedule-form';
// ---- Types ---- // ---- Types ----
@@ -50,20 +75,14 @@ interface ClassItem {
code: string; code: string;
} }
interface ClassTeacherOption {
interface UserItem {
id: number; id: number;
username: string; userId: number;
name: string; username?: string;
name?: string;
roleType: string;
subject?: string | null;
} }
interface ScheduleFormValues {
classId: number;
subject: string;
teacherId?: number;
timeRange: [Dayjs, Dayjs];
dateRange: [Dayjs, Dayjs];
}
/** 排班同步返回结果 */ /** 排班同步返回结果 */
interface ScheduleSyncResult { interface ScheduleSyncResult {
scheduleCount: number; scheduleCount: number;
@@ -90,7 +109,7 @@ const SchedulesPage: React.FC = () => {
// Data // Data
const [classrooms, setClassrooms] = useState<ClassroomItem[]>([]); const [classrooms, setClassrooms] = useState<ClassroomItem[]>([]);
const [classes, setClasses] = useState<ClassItem[]>([]); const [classes, setClasses] = useState<ClassItem[]>([]);
const [users, setUsers] = useState<UserItem[]>([]); const [classTeachers, setClassTeachers] = useState<ClassTeacherOption[]>([]);
const [matrix, setMatrix] = useState<Record<number, Record<number, ClassScheduleItem[]>>>({}); const [matrix, setMatrix] = useState<Record<number, Record<number, ClassScheduleItem[]>>>({});
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -100,7 +119,8 @@ const SchedulesPage: React.FC = () => {
// Modal // Modal
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [modalMode, setModalMode] = useState<'create' | 'detail'>('create'); const [modalMode, setModalMode] = useState<'create' | 'edit' | 'detail'>('create');
const [editingSchedule, setEditingSchedule] = useState<ClassScheduleItem | null>(null);
const [selectedCell, setSelectedCell] = useState<{ const [selectedCell, setSelectedCell] = useState<{
classroomId: number; classroomId: number;
weekDay: number; weekDay: number;
@@ -112,15 +132,21 @@ const SchedulesPage: React.FC = () => {
const [syncModalOpen, setSyncModalOpen] = useState(false); const [syncModalOpen, setSyncModalOpen] = useState(false);
const [syncing, setSyncing] = useState(false); const [syncing, setSyncing] = useState(false);
const [syncStatus, setSyncStatus] = useState<{ const [syncStatus, setSyncStatus] = useState<{
activeSchedules: number; mappedClasses: number; totalClasses: number; activeSchedules: number;
mappedClasses: number;
totalClasses: number;
} | null>(null); } | null>(null);
const [syncResult, setSyncResult] = useState<{ const [syncResult, setSyncResult] = useState<{
scheduleCount: number; shiftCount: number; groupCount: number; scheduleCount: number;
syncedItems: number; skippedNoMapping: number; shiftCount: number;
groupCount: number;
syncedItems: number;
skippedNoMapping: number;
groups: Array<{ className: string; groupId: number; itemCount: number }>; groups: Array<{ className: string; groupId: number; itemCount: number }>;
} | null>(null); } | null>(null);
const [syncDateFrom, setSyncDateFrom] = useState<Dayjs>(dayjs); const [syncDateFrom, setSyncDateFrom] = useState<Dayjs>(dayjs);
const [syncDays, setSyncDays] = useState(30); const [syncDays, setSyncDays] = useState(30);
const [attendanceMachineOnly, setAttendanceMachineOnly] = useState(false);
/** 打开同步弹窗时先查询就绪状态 */ /** 打开同步弹窗时先查询就绪状态 */
const openSyncModal = useCallback(async () => { const openSyncModal = useCallback(async () => {
@@ -128,7 +154,8 @@ const SchedulesPage: React.FC = () => {
setSyncResult(null); setSyncResult(null);
try { try {
const res = await api.get<{ const res = await api.get<{
success: boolean; data: { activeSchedules: number; mappedClasses: number; totalClasses: number }; success: boolean;
data: { activeSchedules: number; mappedClasses: number; totalClasses: number };
}>('/sync/schedule/status'); }>('/sync/schedule/status');
setSyncStatus(res.data); setSyncStatus(res.data);
} catch { } catch {
@@ -141,11 +168,13 @@ const SchedulesPage: React.FC = () => {
setSyncing(true); setSyncing(true);
try { try {
const res = await api.post<{ const res = await api.post<{
success: boolean; data: ScheduleSyncResult; success: boolean;
data: ScheduleSyncResult;
}>('/sync/schedule/sync', null, { }>('/sync/schedule/sync', null, {
params: { params: {
dateFrom: syncDateFrom.format('YYYY-MM-DD'), dateFrom: syncDateFrom.format('YYYY-MM-DD'),
days: syncDays, days: syncDays,
attendanceMachineOnly,
}, },
}); });
setSyncResult(res.data); setSyncResult(res.data);
@@ -156,7 +185,7 @@ const SchedulesPage: React.FC = () => {
} finally { } finally {
setSyncing(false); setSyncing(false);
} }
}, [syncDateFrom, syncDays]); }, [syncDateFrom, syncDays, attendanceMachineOnly]);
const [form] = Form.useForm<ScheduleFormValues>(); const [form] = Form.useForm<ScheduleFormValues>();
// Derived week/month info // Derived week/month info
@@ -201,7 +230,6 @@ const SchedulesPage: React.FC = () => {
return weekEnd.format('YYYY-MM-DD'); return weekEnd.format('YYYY-MM-DD');
}, [viewMode, weekEnd, calendarDays]); }, [viewMode, weekEnd, calendarDays]);
// ---- Data fetching ---- // ---- Data fetching ----
const fetchData = useCallback(async () => { const fetchData = useCallback(async () => {
@@ -244,10 +272,6 @@ const SchedulesPage: React.FC = () => {
fetchData(); fetchData();
}, [fetchData]); }, [fetchData]);
useEffect(() => {
api.get<UserItem[]>('/rbac/users').then(setUsers).catch(() => {});
}, []);
// ---- Filtered classrooms ---- // ---- Filtered classrooms ----
const filteredClassrooms = useMemo(() => { const filteredClassrooms = useMemo(() => {
@@ -304,7 +328,10 @@ const SchedulesPage: React.FC = () => {
setModalOpen(true); setModalOpen(true);
} else { } else {
setSelectedSchedules([]); setSelectedSchedules([]);
setEditingSchedule(null);
setModalMode('create'); setModalMode('create');
form.resetFields();
form.setFieldsValue({ classroomId, weekDay });
setModalOpen(true); setModalOpen(true);
} }
}; };
@@ -334,38 +361,75 @@ const SchedulesPage: React.FC = () => {
setModalOpen(true); setModalOpen(true);
}; };
// ---- Create schedule ---- const loadClassTeachers = useCallback(async (classId: number) => {
try {
const teachers = await api.get<ClassTeacherOption[]>(
`/class-schedules/classes/${classId}/teachers`,
);
setClassTeachers(teachers);
return teachers;
} catch {
setClassTeachers([]);
return [];
}
}, []);
const applyClassTeacherDefaults = useCallback(
async (classId: number, subject?: string) => {
const teachers = await loadClassTeachers(classId);
const subjectTeachers = teachers.filter((teacher) => teacher.roleType === 'subject_teacher');
const matchedBySubject = subject
? subjectTeachers.filter((teacher) => teacher.subject && teacher.subject === subject)
: [];
const matched = matchedBySubject.length > 0 ? matchedBySubject : subjectTeachers;
if (matched.length === 1) {
form.setFieldValue('teacherId', matched[0].userId);
if (!subject && matched[0].subject) form.setFieldValue('subject', matched[0].subject);
} else {
form.setFieldValue('teacherId', undefined);
}
},
[form, loadClassTeachers],
);
// ---- Create / edit schedule ----
const handleSubmit = async () => { const handleSubmit = async () => {
if (!selectedCell) return; if (modalMode === 'create' && !selectedCell) return;
if (modalMode === 'edit' && !editingSchedule) return;
try { try {
const values = await form.validateFields(); const values = (await form.validateFields()) as ScheduleFormValues;
setSubmitting(true); setSubmitting(true);
const payload = buildSchedulePayload(values);
const payload = { if (modalMode === 'edit' && editingSchedule) {
classId: values.classId, await api.put(`/class-schedules/${editingSchedule.id}`, payload);
subject: values.subject, message.success('排课更新成功,请重新同步到钉钉排班');
teacherId: values.teacherId, } else {
classroomId: selectedCell.classroomId, await api.post('/class-schedules', payload);
weekDay: selectedCell.weekDay, message.success('排课创建成功');
startTime: values.timeRange[0].format('HH:mm'), }
endTime: values.timeRange[1].format('HH:mm'),
startDate: values.dateRange[0].format('YYYY-MM-DD'),
endDate: values.dateRange[1].format('YYYY-MM-DD'),
};
await api.post('/class-schedules', payload);
message.success('排课创建成功');
setModalOpen(false); setModalOpen(false);
setEditingSchedule(null);
fetchData(); fetchData();
} catch (e: unknown) { } catch (e: unknown) {
const err = e as { message?: string; status?: number }; const err = e as { message?: string; status?: number };
message.error(err?.message || '创建排课失败'); message.error(err?.message || (modalMode === 'edit' ? '更新排课失败' : '创建排课失败'));
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
}; };
const openEditSchedule = (schedule: ClassScheduleItem) => {
if (schedule.scheduleType === 'RENTAL') {
message.warning('租赁排课请在租赁订单中修改');
return;
}
setEditingSchedule(schedule);
setModalMode('edit');
form.setFieldsValue(scheduleToFormValues(schedule));
void loadClassTeachers(schedule.classId);
};
// ---- Delete schedule ---- // ---- Delete schedule ----
@@ -406,15 +470,6 @@ const SchedulesPage: React.FC = () => {
[classes], [classes],
); );
const userOptions = useMemo(
() =>
users.map((u) => ({
value: u.id,
label: `${u.name || u.username}${u.name ? ` (${u.username})` : ''}`,
})),
[users],
);
// ---- Render ---- // ---- Render ----
const selectedClassroom = selectedCell const selectedClassroom = selectedCell
@@ -450,7 +505,12 @@ const SchedulesPage: React.FC = () => {
{ label: '月视图', value: 'month' }, { label: '月视图', value: 'month' },
]} ]}
/> />
<PermissionButton permission="sync:trigger" type="primary" icon={<CloudSyncOutlined />} onClick={openSyncModal}> <PermissionButton
permission="sync:trigger"
type="primary"
icon={<CloudSyncOutlined />}
onClick={openSyncModal}
>
</PermissionButton> </PermissionButton>
{viewMode === 'week' ? ( {viewMode === 'week' ? (
@@ -467,10 +527,7 @@ const SchedulesPage: React.FC = () => {
({startDateStr} ~ {endDateStr}) ({startDateStr} ~ {endDateStr})
</span> </span>
</span> </span>
<Button <Button icon={<RightOutlined />} onClick={() => setViewDate(viewDate.add(7, 'day'))}>
icon={<RightOutlined />}
onClick={() => setViewDate(viewDate.add(7, 'day'))}
>
</Button> </Button>
</> </>
@@ -524,7 +581,7 @@ const SchedulesPage: React.FC = () => {
<Spin spinning={loading}> <Spin spinning={loading}>
{classrooms.length === 0 ? ( {classrooms.length === 0 ? (
<Empty description="暂无教室数据" /> <Empty description="暂无教室数据" />
) : (viewMode === 'week' ? ( ) : viewMode === 'week' ? (
<div style={{ overflowX: 'auto' }}> <div style={{ overflowX: 'auto' }}>
<table <table
style={{ style={{
@@ -724,10 +781,14 @@ const SchedulesPage: React.FC = () => {
transition: 'background 0.15s', transition: 'background 0.15s',
}} }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.background = isCurrentMonth ? '#f0f5ff' : '#f0f0f0'; (e.currentTarget as HTMLElement).style.background = isCurrentMonth
? '#f0f5ff'
: '#f0f0f0';
}} }}
onMouseLeave={(e) => { onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.background = isCurrentMonth ? '' : '#fafafa'; (e.currentTarget as HTMLElement).style.background = isCurrentMonth
? ''
: '#fafafa';
}} }}
> >
<div <div
@@ -756,7 +817,7 @@ const SchedulesPage: React.FC = () => {
</tbody> </tbody>
</table> </table>
</div> </div>
))} )}
</Spin> </Spin>
{/* Modal */} {/* Modal */}
@@ -764,24 +825,25 @@ const SchedulesPage: React.FC = () => {
title={ title={
modalMode === 'create' modalMode === 'create'
? `新增排课 — ${selectedClassroom?.name || ''} · ${selectedCell ? WEEKDAYS[selectedCell.weekDay - 1] : ''}` ? `新增排课 — ${selectedClassroom?.name || ''} · ${selectedCell ? WEEKDAYS[selectedCell.weekDay - 1] : ''}`
: selectedDate : modalMode === 'edit'
? `排课详情${selectedDate.format('YYYY-MM-DD')} ${WEEKDAYS[selectedDate.day() === 0 ? 6 : selectedDate.day() - 1]}` ? `编辑排课 — ${editingSchedule?.subject || ''}`
: `排课详情 — ${selectedClassroom?.name || ''} · ${selectedCell ? WEEKDAYS[selectedCell.weekDay - 1] : ''}` : selectedDate
? `排课详情 — ${selectedDate.format('YYYY-MM-DD')} ${WEEKDAYS[selectedDate.day() === 0 ? 6 : selectedDate.day() - 1]}`
: `排课详情 — ${selectedClassroom?.name || ''} · ${selectedCell ? WEEKDAYS[selectedCell.weekDay - 1] : ''}`
} }
open={modalOpen} open={modalOpen}
onCancel={() => setModalOpen(false)} onCancel={() => {
onOk={modalMode === 'create' ? handleSubmit : undefined} setModalOpen(false);
setEditingSchedule(null);
}}
onOk={modalMode !== 'detail' ? handleSubmit : undefined}
confirmLoading={submitting} confirmLoading={submitting}
okText={modalMode === 'create' ? '创建' : undefined} okText={modalMode === 'edit' ? '保存' : modalMode === 'create' ? '创建' : undefined}
footer={ footer={modalMode === 'detail' ? null : undefined}
modalMode === 'create'
? undefined // use default ok/cancel
: null // no footer for detail mode
}
width={600} width={600}
destroyOnHidden destroyOnHidden
> >
{modalMode === 'create' ? ( {modalMode !== 'detail' ? (
<Form form={form} layout="vertical" style={{ marginTop: 16 }}> <Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item <Form.Item
name="classId" name="classId"
@@ -793,6 +855,33 @@ const SchedulesPage: React.FC = () => {
showSearch showSearch
optionFilterProp="label" optionFilterProp="label"
options={classOptions} options={classOptions}
onChange={(classId: number) => {
form.setFieldValue('teacherId', undefined);
void applyClassTeacherDefaults(classId, form.getFieldValue('subject'));
}}
/>
</Form.Item>
<Form.Item
name="classroomId"
label="教室"
rules={[{ required: true, message: '请选择教室' }]}
>
<Select
placeholder="选择教室"
showSearch
optionFilterProp="label"
options={classroomOptions}
/>
</Form.Item>
<Form.Item
name="weekDay"
label="星期"
rules={[{ required: true, message: '请选择星期' }]}
>
<Select
options={WEEKDAY_NUMBERS.map((value) => ({ value, label: WEEKDAYS[value - 1] }))}
/> />
</Form.Item> </Form.Item>
@@ -801,30 +890,26 @@ const SchedulesPage: React.FC = () => {
label="科目" label="科目"
rules={[{ required: true, message: '请输入科目' }]} rules={[{ required: true, message: '请输入科目' }]}
> >
<Input placeholder="如:数学、语文" /> <Input
</Form.Item> placeholder="如:数学、语文"
onBlur={(event) => {
<Form.Item const classId = form.getFieldValue('classId');
name="teacherId" if (classId) void applyClassTeacherDefaults(classId, event.target.value);
label="教师(可选)" }}
>
<Select
showSearch
allowClear
placeholder="搜索并选择教师"
optionFilterProp="label"
options={userOptions}
/> />
</Form.Item> </Form.Item>
<Form.Item label="教室"> <Form.Item name="teacherId" label="任课老师">
<Input value={selectedClassroom?.name || ''} disabled /> <Select
</Form.Item> showSearch
allowClear
<Form.Item label="星期"> placeholder="先选班级;系统会按科目自动带出任课老师"
<Input optionFilterProp="label"
value={selectedCell ? WEEKDAYS[selectedCell.weekDay - 1] : ''} options={classTeachers.map((teacher) => ({
disabled value: teacher.userId,
label: `${teacher.name || teacher.username || `#${teacher.userId}`}${teacher.subject ? ` · ${teacher.subject}` : ''}`,
}))}
notFoundContent="该班级暂无可选教师,请先在班级详情配置教师"
/> />
</Form.Item> </Form.Item>
@@ -854,14 +939,28 @@ const SchedulesPage: React.FC = () => {
</Form> </Form>
) : ( ) : (
<div style={{ lineHeight: 2 }}> <div style={{ lineHeight: 2 }}>
<div style={{ marginBottom: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div
style={{
marginBottom: 12,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<span style={{ fontWeight: 500 }}></span> <span style={{ fontWeight: 500 }}></span>
<Button <Button
type="primary" type="primary"
icon={<PlusOutlined />} icon={<PlusOutlined />}
onClick={() => { onClick={() => {
setEditingSchedule(null);
setModalMode('create'); setModalMode('create');
form.resetFields(); form.resetFields();
form.setFieldsValue({
classroomId: selectedCell?.classroomId,
weekDay:
selectedCell?.weekDay ?? (selectedDate ? selectedDate.day() || 7 : undefined),
dateRange: selectedDate ? [selectedDate, selectedDate] : undefined,
});
}} }}
> >
@@ -877,7 +976,13 @@ const SchedulesPage: React.FC = () => {
style={{ marginBottom: 8 }} style={{ marginBottom: 8 }}
styles={{ body: { padding: 12 } }} styles={{ body: { padding: 12 } }}
> >
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}> <div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
}}
>
<div> <div>
<div> <div>
<strong></strong> <strong></strong>
@@ -890,9 +995,9 @@ const SchedulesPage: React.FC = () => {
{s.teacherId != null && ( {s.teacherId != null && (
<div> <div>
<strong></strong> <strong></strong>
{users.find((u) => u.id === s.teacherId)?.name {classTeachers.find((u) => u.userId === s.teacherId)?.name ||
|| users.find((u) => u.id === s.teacherId)?.username classTeachers.find((u) => u.userId === s.teacherId)?.username ||
|| `#${s.teacherId}`} `#${s.teacherId}`}
</div> </div>
)} )}
<div> <div>
@@ -916,21 +1021,33 @@ const SchedulesPage: React.FC = () => {
<Tag color={s.status === 'active' ? 'green' : 'default'}>{s.status}</Tag> <Tag color={s.status === 'active' ? 'green' : 'default'}>{s.status}</Tag>
</div> </div>
</div> </div>
<Popconfirm <Space>
title="确认删除该排课?" {s.scheduleType !== 'RENTAL' && (
onConfirm={() => handleDelete(s.id)} <PermissionButton
okText="删除" permission="schedule:edit"
cancelText="取消" size="small"
> icon={<EditOutlined />}
<PermissionButton onClick={() => openEditSchedule(s)}
permission="schedule:delete" >
size="small"
danger </PermissionButton>
icon={<DeleteOutlined />} )}
<Popconfirm
title="确认删除该排课?"
onConfirm={() => handleDelete(s.id)}
okText="删除"
cancelText="取消"
> >
<PermissionButton
</PermissionButton> permission="schedule:delete"
</Popconfirm> size="small"
danger
icon={<DeleteOutlined />}
>
</PermissionButton>
</Popconfirm>
</Space>
</div> </div>
</Card> </Card>
)) ))
@@ -943,22 +1060,45 @@ const SchedulesPage: React.FC = () => {
<Modal <Modal
title="同步排课到钉钉考勤排班" title="同步排课到钉钉考勤排班"
open={syncModalOpen} open={syncModalOpen}
onCancel={() => { setSyncModalOpen(false); setSyncResult(null); }} onCancel={() => {
footer={syncResult ? [ setSyncModalOpen(false);
<Button key="close" onClick={() => { setSyncModalOpen(false); setSyncResult(null); }}></Button>, setSyncResult(null);
] : [ }}
<Button key="cancel" onClick={() => { setSyncModalOpen(false); setSyncResult(null); }}></Button>, footer={
<Button syncResult
key="sync" ? [
type="primary" <Button
icon={<CloudSyncOutlined />} key="close"
loading={syncing} onClick={() => {
onClick={handleSyncSchedule} setSyncModalOpen(false);
disabled={!syncStatus || syncStatus.activeSchedules === 0} setSyncResult(null);
> }}
>
</Button>,
]} </Button>,
]
: [
<Button
key="cancel"
onClick={() => {
setSyncModalOpen(false);
setSyncResult(null);
}}
>
</Button>,
<Button
key="sync"
type="primary"
icon={<CloudSyncOutlined />}
loading={syncing}
onClick={handleSyncSchedule}
disabled={!syncStatus || syncStatus.activeSchedules === 0}
>
</Button>,
]
}
width={560} width={560}
> >
{syncResult ? ( {syncResult ? (
@@ -1014,11 +1154,18 @@ const SchedulesPage: React.FC = () => {
title="已就绪班级" title="已就绪班级"
value={syncStatus.mappedClasses} value={syncStatus.mappedClasses}
suffix={`/ ${syncStatus.totalClasses}`} suffix={`/ ${syncStatus.totalClasses}`}
valueStyle={{ color: syncStatus.mappedClasses < syncStatus.totalClasses ? '#faad14' : '#3f8600' }} valueStyle={{
color:
syncStatus.mappedClasses < syncStatus.totalClasses ? '#faad14' : '#3f8600',
}}
/> />
</Col> </Col>
<Col span={8}> <Col span={8}>
<Statistic title="无绑定学生班级" value={syncStatus.totalClasses - syncStatus.mappedClasses} suffix="个" /> <Statistic
title="无绑定学生班级"
value={syncStatus.totalClasses - syncStatus.mappedClasses}
suffix="个"
/>
</Col> </Col>
</Row> </Row>
{syncStatus.mappedClasses < syncStatus.totalClasses && ( {syncStatus.mappedClasses < syncStatus.totalClasses && (
@@ -1031,9 +1178,13 @@ const SchedulesPage: React.FC = () => {
)} )}
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: 16 }}>
<div style={{ marginBottom: 8, fontWeight: 500 }}></div> <div style={{ marginBottom: 8, fontWeight: 500 }}></div>
<Space> <Space wrap>
<span></span> <span></span>
<DatePicker value={syncDateFrom} onChange={(d) => d && setSyncDateFrom(d)} allowClear={false} /> <DatePicker
value={syncDateFrom}
onChange={(d) => d && setSyncDateFrom(d)}
allowClear={false}
/>
<span></span> <span></span>
<Select <Select
value={syncDays} value={syncDays}
@@ -1048,9 +1199,32 @@ const SchedulesPage: React.FC = () => {
]} ]}
/> />
</Space> </Space>
<div style={{ marginTop: 16 }}>
<Space align="start">
<Switch checked={attendanceMachineOnly} onChange={setAttendanceMachineOnly} />
<div>
<div style={{ fontWeight: 500 }}></div>
<div style={{ color: '#8c8c8c', fontSize: 12, marginTop: 2 }}>
Wi-Fi
</div>
</div>
</Space>
</div>
{attendanceMachineOnly && (
<Alert
type="info"
showIcon
message="已存在的同名考勤组也会在本次同步中更新为仅考勤机打卡。"
style={{ marginTop: 12 }}
/>
)}
</div> </div>
{syncStatus.activeSchedules === 0 && ( {syncStatus.activeSchedules === 0 && (
<Alert type="info" message="当前没有活跃排课。请先在排课页面创建排课记录。" showIcon /> <Alert
type="info"
message="当前没有活跃排课。请先在排课页面创建排课记录。"
showIcon
/>
)} )}
</div> </div>
) : ( ) : (

View File

@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest';
import dayjs from 'dayjs';
import { buildSchedulePayload, scheduleToFormValues } from './schedule-form';
describe('schedule edit form mapping', () => {
it('fills an existing schedule into editable form values', () => {
const values = scheduleToFormValues({
id: 3,
classId: 1,
classroomId: 1,
weekDay: 5,
subject: '语文',
teacherId: null,
startTime: '14:00',
endTime: '18:00',
startDate: '2026-07-01',
endDate: '2026-07-31',
});
expect(values.classroomId).toBe(1);
expect(values.weekDay).toBe(5);
expect(values.timeRange.map((item) => item.format('HH:mm'))).toEqual(['14:00', '18:00']);
expect(values.dateRange.map((item) => item.format('YYYY-MM-DD'))).toEqual([
'2026-07-01',
'2026-07-31',
]);
});
it('builds the update payload from edited form values', () => {
expect(
buildSchedulePayload({
classId: 1,
classroomId: 2,
weekDay: 6,
subject: '作文',
teacherId: 4,
timeRange: [dayjs('2026-01-01 13:30'), dayjs('2026-01-01 17:20')],
dateRange: [dayjs('2026-08-01'), dayjs('2026-08-31')],
}),
).toEqual({
classId: 1,
classroomId: 2,
weekDay: 6,
subject: '作文',
teacherId: 4,
startTime: '13:30',
endTime: '17:20',
startDate: '2026-08-01',
endDate: '2026-08-31',
});
});
});

View File

@@ -0,0 +1,46 @@
import dayjs, { type Dayjs } from 'dayjs';
export interface ScheduleFormValues {
classId: number;
classroomId: number;
weekDay: number;
subject: string;
teacherId?: number;
timeRange: [Dayjs, Dayjs];
dateRange: [Dayjs, Dayjs];
}
export interface EditableSchedule {
id: number;
classId: number;
classroomId: number;
weekDay: number;
subject: string;
teacherId: number | null;
startTime: string;
endTime: string;
startDate: string;
endDate: string;
}
export const scheduleToFormValues = (schedule: EditableSchedule): ScheduleFormValues => ({
classId: schedule.classId,
classroomId: schedule.classroomId,
weekDay: schedule.weekDay,
subject: schedule.subject,
teacherId: schedule.teacherId ?? undefined,
timeRange: [dayjs(`2000-01-01 ${schedule.startTime}`), dayjs(`2000-01-01 ${schedule.endTime}`)],
dateRange: [dayjs(schedule.startDate), dayjs(schedule.endDate)],
});
export const buildSchedulePayload = (values: ScheduleFormValues) => ({
classId: values.classId,
classroomId: values.classroomId,
weekDay: values.weekDay,
subject: values.subject,
teacherId: values.teacherId,
startTime: values.timeRange[0].format('HH:mm'),
endTime: values.timeRange[1].format('HH:mm'),
startDate: values.dateRange[0].format('YYYY-MM-DD'),
endDate: values.dateRange[1].format('YYYY-MM-DD'),
});

View File

@@ -1,38 +1,40 @@
import React, { useEffect, useState, useMemo, useCallback } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { import {
Table, App,
Button, Button,
Modal, Card,
Col,
Descriptions,
Drawer,
Empty,
Form, Form,
Input, Input,
Modal,
Popconfirm,
Row,
Select, Select,
Space, Space,
message, Table,
Tag, Tag,
Popconfirm,
Upload, Upload,
App,
Row,
Col,
Card,
Drawer,
Descriptions,
Empty,
} from 'antd'; } from 'antd';
import type { UploadProps } from 'antd';
import { import {
PlusOutlined,
UploadOutlined,
DownloadOutlined,
UndoOutlined,
InboxOutlined,
ExportOutlined,
DeleteOutlined, DeleteOutlined,
DownloadOutlined,
ExportOutlined,
EyeOutlined, EyeOutlined,
InboxOutlined,
PlusOutlined,
SwapOutlined,
UndoOutlined,
UploadOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import api from '../../api'; import api from '../../api';
import PermissionButton from '../../components/PermissionButton'; import PermissionButton from '../../components/PermissionButton';
import StudentProfileContent from '../../components/StudentProfileContent'; import StudentProfileContent from '../../components/StudentProfileContent';
import { maskPhone, maskIdNumber } from '../../utils/sensitive'; import { maskIdNumber, maskPhone } from '../../utils/sensitive';
import { message } from '../../ui/app-message';
const statusMap: Record<string, { text: string; color: string }> = { const statusMap: Record<string, { text: string; color: string }> = {
active: { text: '在读', color: 'green' }, active: { text: '在读', color: 'green' },
@@ -129,10 +131,13 @@ const StudentsPage: React.FC = () => {
const fetchData = useCallback(async () => { const fetchData = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
const params: Record<string, unknown> = { name: searchName || undefined, includeArchived: 'true' }; const params: Record<string, unknown> = {
name: searchName || undefined,
includeArchived: 'true',
};
if (filterStatus) params.status = filterStatus; if (filterStatus) params.status = filterStatus;
if (filterTenantId) params.tenantId = filterTenantId; if (filterTenantId) params.tenantId = filterTenantId;
const res = await api.get('/students', { params }) as Array<Record<string, unknown>>; const res = (await api.get('/students', { params })) as Array<Record<string, unknown>>;
const list = res as Array<Record<string, unknown>>; const list = res as Array<Record<string, unknown>>;
const archived = list.filter((r) => r.status === 'archived'); const archived = list.filter((r) => r.status === 'archived');
setArchivedCount(archived.length); setArchivedCount(archived.length);
@@ -149,9 +154,12 @@ const StudentsPage: React.FC = () => {
}, [fetchData]); }, [fetchData]);
useEffect(() => { useEffect(() => {
api.get('/tenants', { params: { includeArchived: 'false' } }).then((res: unknown) => { api
setTenants(res as Array<{ id: number; name: string }>); .get('/tenants', { params: { includeArchived: 'false' } })
}).catch(() => {}); .then((res: unknown) => {
setTenants(res as Array<{ id: number; name: string }>);
})
.catch(() => {});
}, []); }, []);
const handleSave = async () => { const handleSave = async () => {
const values = await form.validateFields(); const values = await form.validateFields();
@@ -213,6 +221,23 @@ const StudentsPage: React.FC = () => {
.catch(() => message.error('下载失败')); .catch(() => message.error('下载失败'));
}; };
const handleMatchImport: UploadProps['customRequest'] = async ({ file, onSuccess, onError }) => {
const formData = new FormData();
formData.append('file', file as File);
try {
const res = (await api.post('/students/import-match', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})) as { message: string };
message.success(res.message);
onSuccess?.(res);
fetchData();
} catch (e: unknown) {
const err = e as { message?: string };
message.error(err?.message || '匹配导入失败');
onError?.(e instanceof Error ? e : new Error(err?.message || '匹配导入失败'));
}
};
const handleExport = () => { const handleExport = () => {
const baseURL = import.meta.env.PROD const baseURL = import.meta.env.PROD
? '/api' ? '/api'
@@ -232,128 +257,178 @@ const StudentsPage: React.FC = () => {
.catch(() => message.error('导出失败')); .catch(() => message.error('导出失败'));
}; };
const columns = useMemo(() => [ const columns = useMemo(
{ title: 'ID', dataIndex: 'id', width: 70 }, () => [
{ { title: 'ID', dataIndex: 'id', width: 70 },
title: '姓名', {
dataIndex: 'name', title: '姓名',
width: 120, dataIndex: 'name',
render: (v: string, record: any) => ( width: 120,
<Button type="link" size="small" onClick={() => openDrawer(record.id)}>{v}</Button> render: (v: string, record: any) => (
), <Button type="link" size="small" onClick={() => openDrawer(record.id)}>
}, {v}
{ </Button>
title: '电话', ),
dataIndex: 'phone',
width: 140,
render: (v: string, record: any) => {
if (!v) return '-';
return (
<span>
<span style={{ marginRight: 4 }}>{maskPhone(v)}</span>
<Button type="link" size="small" style={{ padding: '8px 4px' }} onClick={() => handleViewSensitive(record.id, '电话', v)} title="点击查看完整号码">
<EyeOutlined style={{ fontSize: 12, color: '#999' }} />
</Button>
</span>
);
}, },
}, {
{ title: '电话',
title: '学号', dataIndex: 'phone',
dataIndex: 'studentNo', width: 140,
width: 120, render: (v: string, record: any) => {
render: (v: string) => v || '-', if (!v) return '-';
}, return (
{ <span>
title: '身份证', <span style={{ marginRight: 4 }}>{maskPhone(v)}</span>
dataIndex: 'idNumber', <Button
width: 180,
render: (v: string, record: any) => {
if (!v) return '-';
return (
<span>
<span style={{ marginRight: 4 }}>{maskIdNumber(v)}</span>
<Button type="link" size="small" style={{ padding: '8px 4px' }} onClick={() => handleViewSensitive(record.id, '身份证号', v)} title="点击查看完整号码">
<EyeOutlined style={{ fontSize: 12, color: '#999' }} />
</Button>
</span>
);
},
},
{ title: '民族', dataIndex: 'ethnicity', width: 90 },
{ title: '紧急联系人', dataIndex: 'emergencyContact', width: 100 },
{ title: '紧急联系人电话', dataIndex: 'emergencyPhone', width: 130 },
{
title: '所属机构',
dataIndex: 'tenant',
width: 100,
render: (tenant: { name?: string } | null) =>
tenant?.name ? <Tag color="purple" style={{ maxWidth: '100%', overflow: 'hidden', textOverflow: 'ellipsis' }}>{tenant.name}</Tag> : '-',
},
{ title: '负责人', dataIndex: 'supervisor', width: 100 },
{
title: '状态',
dataIndex: 'status',
width: 80,
render: (s: string) => <Tag color={statusMap[s]?.color} style={{ maxWidth: '100%', overflow: 'hidden', textOverflow: 'ellipsis' }}>{statusMap[s]?.text || s}</Tag>,
},
{
title: '操作',
width: 180,
render: (_: any, record: any) => (
<Space>
{record.status === 'archived' ? (
<Popconfirm
title="确定恢复此学生?恢复后将重新出现在学生列表中。"
onConfirm={() => handleRestore(record.id)}
okText="恢复"
cancelText="取消"
>
<PermissionButton permission="student:edit" size="small" icon={<UndoOutlined />} type="link">
</PermissionButton>
</Popconfirm>
) : (
<>
<PermissionButton
permission="student:view"
size="small"
type="link" type="link"
onClick={() => openDrawer(record.id)}
>
</PermissionButton>
<PermissionButton
permission="student:edit"
size="small" size="small"
onClick={() => { style={{ padding: '8px 4px' }}
setEditing(record); onClick={() => handleViewSensitive(record.id, '电话', v)}
form.setFieldsValue(record); title="点击查看完整号码"
setModalOpen(true);
}}
> >
<EyeOutlined style={{ fontSize: 12, color: '#999' }} />
</PermissionButton> </Button>
</span>
);
},
},
{
title: '学号',
dataIndex: 'studentNo',
width: 120,
render: (v: string) => v || '-',
},
{
title: '身份证',
dataIndex: 'idNumber',
width: 180,
render: (v: string, record: any) => {
if (!v) return '-';
return (
<span>
<span style={{ marginRight: 4 }}>{maskIdNumber(v)}</span>
<Button
type="link"
size="small"
style={{ padding: '8px 4px' }}
onClick={() => handleViewSensitive(record.id, '身份证号', v)}
title="点击查看完整号码"
>
<EyeOutlined style={{ fontSize: 12, color: '#999' }} />
</Button>
</span>
);
},
},
{ title: '民族', dataIndex: 'ethnicity', width: 90 },
{ title: '紧急联系人', dataIndex: 'emergencyContact', width: 100 },
{ title: '紧急联系人电话', dataIndex: 'emergencyPhone', width: 130 },
{
title: '所属机构',
dataIndex: 'tenant',
width: 100,
render: (tenant: { name?: string } | null) =>
tenant?.name ? (
<Tag
color="purple"
style={{ maxWidth: '100%', overflow: 'hidden', textOverflow: 'ellipsis' }}
>
{tenant.name}
</Tag>
) : (
'-'
),
},
{ title: '负责人', dataIndex: 'supervisor', width: 100 },
{
title: '状态',
dataIndex: 'status',
width: 80,
render: (s: string) => (
<Tag
color={statusMap[s]?.color}
style={{ maxWidth: '100%', overflow: 'hidden', textOverflow: 'ellipsis' }}
>
{statusMap[s]?.text || s}
</Tag>
),
},
{
title: '操作',
width: 180,
render: (_: any, record: any) => (
<Space>
{record.status === 'archived' ? (
<Popconfirm <Popconfirm
title="归档后不会删除数据,可随时恢复。确定归档?" title="确定恢复此学生?恢复后将重新出现在学生列表中。"
onConfirm={() => handleArchive(record.id)} onConfirm={() => handleRestore(record.id)}
okText="归档" okText="恢复"
cancelText="取消" cancelText="取消"
> >
<PermissionButton permission="student:delete" size="small" icon={<InboxOutlined />}> <PermissionButton
permission="student:edit"
size="small"
icon={<UndoOutlined />}
type="link"
>
</PermissionButton> </PermissionButton>
</Popconfirm> </Popconfirm>
</> ) : (
)} <>
</Space> <PermissionButton
), permission="student:view"
}, size="small"
], [handleViewSensitive, openDrawer, showArchived, tenants]); type="link"
onClick={() => openDrawer(record.id)}
>
</PermissionButton>
<PermissionButton
permission="student:edit"
size="small"
onClick={() => {
setEditing(record);
form.setFieldsValue(record);
setModalOpen(true);
}}
>
</PermissionButton>
<Popconfirm
title="归档后不会删除数据,可随时恢复。确定归档?"
onConfirm={() => handleArchive(record.id)}
okText="归档"
cancelText="取消"
>
<PermissionButton
permission="student:delete"
size="small"
icon={<InboxOutlined />}
>
</PermissionButton>
</Popconfirm>
</>
)}
</Space>
),
},
],
[handleViewSensitive, openDrawer, showArchived, tenants],
);
return ( return (
<div> <div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }}> <div
style={{
marginBottom: 16,
display: 'flex',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: 8,
}}
>
<Space wrap> <Space wrap>
<Input.Search <Input.Search
placeholder="搜索学生姓名" placeholder="搜索学生姓名"
@@ -361,11 +436,37 @@ const StudentsPage: React.FC = () => {
allowClear allowClear
style={{ width: 250 }} style={{ width: 250 }}
/> />
<Select placeholder="状态筛选" allowClear style={{ width: 120 }} value={filterStatus} onChange={(v) => { setFilterStatus(v); }}> <Select
{Object.entries(statusMap).filter(([k]) => k !== 'archived').map(([k, v]) => <Select.Option key={k} value={k}>{v.text}</Select.Option>)} placeholder="状态筛选"
allowClear
style={{ width: 120 }}
value={filterStatus}
onChange={(v) => {
setFilterStatus(v);
}}
>
{Object.entries(statusMap)
.filter(([k]) => k !== 'archived')
.map(([k, v]) => (
<Select.Option key={k} value={k}>
{v.text}
</Select.Option>
))}
</Select> </Select>
<Select placeholder="所属机构" allowClear style={{ width: 140 }} value={filterTenantId} onChange={(v) => { setFilterTenantId(v); }}> <Select
{tenants.map((t: { id: number; name: string }) => <Select.Option key={t.id} value={t.id}>{t.name}</Select.Option>)} placeholder="所属机构"
allowClear
style={{ width: 140 }}
value={filterTenantId}
onChange={(v) => {
setFilterTenantId(v);
}}
>
{tenants.map((t: { id: number; name: string }) => (
<Select.Option key={t.id} value={t.id}>
{t.name}
</Select.Option>
))}
</Select> </Select>
<Button <Button
type={showArchived ? 'primary' : 'default'} type={showArchived ? 'primary' : 'default'}
@@ -421,12 +522,15 @@ const StudentsPage: React.FC = () => {
fetchData(); fetchData();
} catch (e: any) { } catch (e: any) {
message.error(e?.message || '导入失败'); message.error(e?.message || '导入失败');
onError?.(e); onError?.(e instanceof Error ? e : new Error(e?.message || '导入失败'));
} }
}} }}
> >
<Button icon={<UploadOutlined />}>Excel</Button> <Button icon={<UploadOutlined />}>Excel</Button>
</Upload> </Upload>
<Upload accept=".xlsx,.xls" showUploadList={false} customRequest={handleMatchImport}>
<Button icon={<SwapOutlined />}></Button>
</Upload>
<PermissionButton <PermissionButton
permission="student:view" permission="student:view"
icon={<DownloadOutlined />} icon={<DownloadOutlined />}
@@ -481,8 +585,12 @@ const StudentsPage: React.FC = () => {
> >
<Descriptions column={1} size="small"> <Descriptions column={1} size="small">
<Descriptions.Item label="班级">{enr.className || '-'}</Descriptions.Item> <Descriptions.Item label="班级">{enr.className || '-'}</Descriptions.Item>
<Descriptions.Item label="开班日期">{enr.startDate || enr.joinDate || '-'}</Descriptions.Item> <Descriptions.Item label="开班日期">
<Descriptions.Item label="结课日期">{enr.endDate || enr.leaveDate || '-'}</Descriptions.Item> {enr.startDate || enr.joinDate || '-'}
</Descriptions.Item>
<Descriptions.Item label="结课日期">
{enr.endDate || enr.leaveDate || '-'}
</Descriptions.Item>
<Descriptions.Item label="状态"> <Descriptions.Item label="状态">
<Tag color={enr.status === 'active' ? 'green' : 'default'}> <Tag color={enr.status === 'active' ? 'green' : 'default'}>
{enr.status || '-'} {enr.status || '-'}
@@ -509,7 +617,7 @@ const StudentsPage: React.FC = () => {
} }
}, },
}} }}
/> />
<style>{`.archived-row { opacity: 0.6; background: #fafafa !important; }`}</style> <style>{`.archived-row { opacity: 0.6; background: #fafafa !important; }`}</style>
<Modal <Modal
title={editing ? '编辑学生' : '添加学生'} title={editing ? '编辑学生' : '添加学生'}
@@ -553,11 +661,7 @@ const StudentsPage: React.FC = () => {
<Form.Item name="emergencyPhone" label="紧急联系人电话"> <Form.Item name="emergencyPhone" label="紧急联系人电话">
<Input /> <Input />
</Form.Item> </Form.Item>
<Form.Item <Form.Item name="tenantId" label="所属机构" tooltip="选择租赁方,留空表示本机构">
name="tenantId"
label="所属机构"
tooltip="选择租赁方,留空表示本机构"
>
<Select <Select
allowClear allowClear
placeholder="选择租赁方" placeholder="选择租赁方"
@@ -587,14 +691,18 @@ const StudentsPage: React.FC = () => {
<Drawer <Drawer
title={null} title={null}
open={drawerOpen} open={drawerOpen}
onClose={() => { setDrawerOpen(false); }} onClose={() => {
setDrawerOpen(false);
}}
size={720} size={720}
> >
{drawerStudentId && ( {drawerStudentId && (
<StudentProfileContent <StudentProfileContent
studentId={drawerStudentId} studentId={drawerStudentId}
inDrawer inDrawer
onClose={() => { setDrawerOpen(false); }} onClose={() => {
setDrawerOpen(false);
}}
/> />
)} )}
</Drawer> </Drawer>

View File

@@ -1,7 +1,8 @@
import React, { useEffect, useState, useMemo } from 'react'; import React, { useEffect, useState, useMemo } from 'react';
import { Card, Tabs, Table, Tag, Empty, Spin, message } from 'antd'; import { Card, Tabs, Table, Tag, Empty, Spin } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import api from '../../api'; import api from '../../api';
import { message } from '../../ui/app-message';
interface AssignedClass { interface AssignedClass {
classId: number; classId: number;

View File

@@ -1,8 +1,9 @@
import React, { useEffect, useState, useCallback, useMemo } from 'react'; import React, { useEffect, useState, useCallback, useMemo } from 'react';
import { Table, Input, Button, Modal, Form, Select, DatePicker, Tag, Space, message } from 'antd'; import { Table, Input, Button, Modal, Form, Select, DatePicker, Tag, Space } from 'antd';
import { EditOutlined } from '@ant-design/icons'; import { EditOutlined } from '@ant-design/icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import api from '../../api'; import api from '../../api';
import { message } from '../../ui/app-message';
interface TeacherRow { interface TeacherRow {
id: number; id: number;

View File

@@ -1,8 +1,9 @@
import React, { useEffect, useState, useMemo } from 'react'; import React, { useEffect, useState, useMemo } from 'react';
import { Table, Modal, Form, Input, Select, Space, message, Tag, Popconfirm, Empty } from 'antd'; import { Table, Modal, Form, Input, Select, Space, Tag, Popconfirm, Empty } from 'antd';
import { PlusOutlined, InboxOutlined } from '@ant-design/icons'; import { PlusOutlined, InboxOutlined } from '@ant-design/icons';
import api from '../../api'; import api from '../../api';
import PermissionButton from '../../components/PermissionButton'; import PermissionButton from '../../components/PermissionButton';
import { message } from '../../ui/app-message';
const PRESET_COLORS = [ const PRESET_COLORS = [
'#ff7875', '#ff7875',

View File

@@ -9,12 +9,12 @@ import {
Space, Space,
Tag, Tag,
Popconfirm, Popconfirm,
message, } from 'antd';
} from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, KeyOutlined, IdcardOutlined, InboxOutlined } from '@ant-design/icons'; import { PlusOutlined, EditOutlined, DeleteOutlined, KeyOutlined, IdcardOutlined, InboxOutlined } from '@ant-design/icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import api from '../../api'; import api from '../../api';
import PermissionButton from '../../components/PermissionButton'; import PermissionButton from '../../components/PermissionButton';
import { message } from '../../ui/app-message';
const UsersPage: React.FC = () => { const UsersPage: React.FC = () => {
const [data, setData] = useState<any[]>([]); const [data, setData] = useState<any[]>([]);

View File

@@ -0,0 +1,15 @@
import { useEffect } from 'react';
import useMessage from 'antd/es/message/useMessage';
import { bindMessageApi } from './app-message';
const AppMessageBridge: React.FC = () => {
const [messageApi, contextHolder] = useMessage();
useEffect(() => {
bindMessageApi(messageApi);
}, [messageApi]);
return contextHolder;
};
export default AppMessageBridge;

View File

@@ -0,0 +1,21 @@
import { describe, expect, it, vi } from 'vitest';
import { bindMessageApi, message } from './app-message';
describe('app message bridge', () => {
it('delegates messages to the Ant Design App context instance', () => {
const api = {
success: vi.fn(),
error: vi.fn(),
warning: vi.fn(),
};
bindMessageApi(api as never);
message.success('保存成功');
message.error('保存失败');
message.warning('请检查');
expect(api.success).toHaveBeenCalledWith('保存成功');
expect(api.error).toHaveBeenCalledWith('保存失败');
expect(api.warning).toHaveBeenCalledWith('请检查');
});
});

View File

@@ -0,0 +1,20 @@
import type { MessageInstance } from 'antd/es/message/interface';
let messageApi: MessageInstance | undefined;
export function bindMessageApi(api: MessageInstance): void {
messageApi = api;
}
function requireMessageApi(): MessageInstance {
if (!messageApi) {
throw new Error('Ant Design message API has not been initialized');
}
return messageApi;
}
export const message: Pick<MessageInstance, 'success' | 'error' | 'warning'> = {
success: (...args) => requireMessageApi().success(...args),
error: (...args) => requireMessageApi().error(...args),
warning: (...args) => requireMessageApi().warning(...args),
};

View File

@@ -1,6 +1,7 @@
import { defineConfig } from 'vitest/config'; import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import path from 'node:path'; import path from 'node:path';
import { playwright } from '@vitest/browser-playwright';
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
@@ -14,7 +15,8 @@ export default defineConfig({
browser: { browser: {
enabled: true, enabled: true,
name: 'chromium', name: 'chromium',
provider: 'playwright', provider: playwright(),
instances: [{ browser: 'chromium' }],
headless: true, headless: true,
// Slow down interactions slightly so UI animations settle // Slow down interactions slightly so UI animations settle
slowHijackESM: false, slowHijackESM: false,

View File

@@ -63,6 +63,7 @@ import { SyncModule } from './sync/sync.module';
import { NotificationsModule } from './notifications/notifications.module'; import { NotificationsModule } from './notifications/notifications.module';
import { ArchiveModule } from './archive/archive.module'; import { ArchiveModule } from './archive/archive.module';
import { ExpenseTypesModule } from './expense-types/expense-types.module'; import { ExpenseTypesModule } from './expense-types/expense-types.module';
import { DatabaseMigrationsModule } from './database/database-migrations.module';
import { IntegrationConfig, IntegrationConfigDetail } from './integration/entities/integration-config.entity'; import { IntegrationConfig, IntegrationConfigDetail } from './integration/entities/integration-config.entity';
import { IntegrationConfigModule } from './integration/config/config.module'; import { IntegrationConfigModule } from './integration/config/config.module';
@@ -142,6 +143,7 @@ import { IntegrationConfigModule } from './integration/config/config.module';
}; };
}, },
}), }),
DatabaseMigrationsModule,
AuthModule, AuthModule,
RbacModule, RbacModule,
StudentsModule, StudentsModule,

View File

@@ -0,0 +1,128 @@
import { BadRequestException } from '@nestjs/common';
import { AttendanceImportService } from './attendance-import.service';
import { DingTalkService } from '../integration/dingtalk.service';
import { AttendanceService } from './attendance.service';
describe('AttendanceImportService', () => {
const dingRawRepo = {
find: jest.fn(),
findOne: jest.fn(),
save: jest.fn(),
};
const studentRepo = { findOne: jest.fn() };
const studentDingMappingRepo = { findOne: jest.fn() };
const dingTalkService = {
fetchAttendanceResults: jest.fn(),
};
const attendanceService = {
autoMatchDingRecords: jest.fn(),
};
let service: AttendanceImportService;
beforeEach(() => {
jest.clearAllMocks();
service = new AttendanceImportService(
dingRawRepo as never,
studentRepo as never,
studentDingMappingRepo as never,
dingTalkService as unknown as DingTalkService,
attendanceService as unknown as AttendanceService,
);
});
it('splits DingTalk requests by at most 50 users and 7 calendar days without offset pagination', async () => {
const userIds = Array.from({ length: 51 }, (_, index) => `user-${index + 1}`);
dingTalkService.fetchAttendanceResults.mockResolvedValue([]);
await (service as any).fetchAllPages({
startDate: '2026-07-01',
endDate: '2026-07-10',
userIds,
});
expect(dingTalkService.fetchAttendanceResults).toHaveBeenCalledTimes(4);
expect(dingTalkService.fetchAttendanceResults.mock.calls.map(([params]) => params)).toEqual([
{
startDate: '2026-07-01',
endDate: '2026-07-07',
userIds: userIds.slice(0, 50),
},
{
startDate: '2026-07-01',
endDate: '2026-07-07',
userIds: userIds.slice(50),
},
{
startDate: '2026-07-08',
endDate: '2026-07-10',
userIds: userIds.slice(0, 50),
},
{
startDate: '2026-07-08',
endDate: '2026-07-10',
userIds: userIds.slice(50),
},
]);
});
it('rejects an attendance import without DingTalk user IDs', async () => {
await expect(
(service as any).fetchAllPages({
startDate: '2026-07-01',
endDate: '2026-07-01',
userIds: [],
}),
).rejects.toBeInstanceOf(BadRequestException);
expect(dingTalkService.fetchAttendanceResults).not.toHaveBeenCalled();
});
it('stores the DingTalk user name returned with the attendance record', async () => {
const entity = await (service as any).mapToEntity({
userId: 'ding-1',
userName: '张三',
workDate: '2026-07-01',
timeResult: 'Normal',
locationResult: '',
planCheckTime: '',
actualCheckTime: '2026-07-01T08:00:00.000Z',
checkId: 'check-1',
checkType: 'OnDuty',
});
expect(entity.userName).toBe('张三');
});
it('fills the student name from the DingTalk mapping when saving an imported record', async () => {
dingTalkService.fetchAttendanceResults.mockResolvedValue([
{
userId: 'ding-1',
userName: '',
workDate: '2026-07-01',
timeResult: 'Normal',
locationResult: '',
planCheckTime: '',
actualCheckTime: '2026-07-01T08:00:00.000Z',
checkId: 'check-1',
checkType: 'OnDuty',
},
]);
dingRawRepo.find.mockResolvedValue([]);
studentDingMappingRepo.findOne.mockResolvedValue({ studentId: 3 });
studentRepo.findOne.mockResolvedValue({ id: 3, name: '张三' });
dingRawRepo.save.mockImplementation(async (entities) => entities);
await service.importFromDingTalk({
startDate: '2026-07-01',
endDate: '2026-07-01',
userIds: ['ding-1'],
autoMatch: false,
});
expect(dingRawRepo.save).toHaveBeenCalledWith(
[expect.objectContaining({ userName: '张三' })],
{ chunk: 50 },
);
});
});

View File

@@ -1,4 +1,4 @@
import { Injectable, Logger } from '@nestjs/common'; import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In } from 'typeorm'; import { Repository, In } from 'typeorm';
import { Subject, Observable } from 'rxjs'; import { Subject, Observable } from 'rxjs';
@@ -115,7 +115,7 @@ export class AttendanceImportService {
const batchSize = 100; const batchSize = 100;
for (let i = 0; i < newRecords.length; i += batchSize) { for (let i = 0; i < newRecords.length; i += batchSize) {
const batch = newRecords.slice(i, i + batchSize); const batch = newRecords.slice(i, i + batchSize);
const entities = batch.map((r) => this.mapToEntity(r)); const entities = await Promise.all(batch.map((record) => this.mapToEntity(record)));
try { try {
await this.dingRawRepo.save(entities, { chunk: 50 }); await this.dingRawRepo.save(entities, { chunk: 50 });
imported += entities.length; imported += entities.length;
@@ -151,42 +151,92 @@ export class AttendanceImportService {
} }
/** /**
* Paginate through DingTalk attendance API. * DingTalk requires userIds, accepts at most 50 users per request, and
* The DingTalk API returns max 50 records per page. * allows a maximum inclusive date range of 7 calendar days.
*/ */
private async fetchAllPages(params: { private async fetchAllPages(params: {
startDate: string; startDate: string;
endDate: string; endDate: string;
userIds?: string[]; userIds?: string[];
}): Promise<DingTalkAttendanceResult[]> { }): Promise<DingTalkAttendanceResult[]> {
const userIds = [...new Set((params.userIds ?? []).filter(Boolean))];
if (userIds.length === 0) {
throw new BadRequestException('拉取钉钉考勤必须指定人员范围');
}
if (params.startDate > params.endDate) {
throw new BadRequestException('开始日期不能晚于结束日期');
}
const allResults: DingTalkAttendanceResult[] = []; const allResults: DingTalkAttendanceResult[] = [];
const pageSize = 50; const userBatches = this.chunk(userIds, 50);
let offset = 0; const dateRanges = this.splitDateRanges(params.startDate, params.endDate, 7);
let hasMore = true; const totalRequests = userBatches.length * dateRanges.length;
let completedRequests = 0;
while (hasMore) { for (const range of dateRanges) {
const batch = await this.dingTalkService.fetchAttendanceResults({ for (const users of userBatches) {
startDate: params.startDate, const batch = await this.dingTalkService.fetchAttendanceResults({
endDate: params.endDate, startDate: range.startDate,
userIds: params.userIds, endDate: range.endDate,
offset, userIds: users,
limit: pageSize, });
});
if (batch.length === 0) {
hasMore = false;
} else {
allResults.push(...batch); allResults.push(...batch);
offset += batch.length; completedRequests++;
this.emit('fetching', allResults.length, allResults.length + (batch.length < pageSize ? 0 : pageSize), `Fetched ${allResults.length} records...`); this.emit(
// If last page was smaller than pageSize, we're done 'fetching',
if (batch.length < pageSize) hasMore = false; completedRequests,
totalRequests,
`已完成 ${completedRequests}/${totalRequests} 批,获取 ${allResults.length} 条记录`,
);
} }
} }
return allResults; return allResults;
} }
private chunk<T>(items: T[], size: number): T[][] {
const result: T[][] = [];
for (let index = 0; index < items.length; index += size) {
result.push(items.slice(index, index + size));
}
return result;
}
private splitDateRanges(
startDate: string,
endDate: string,
maxDays: number,
): Array<{ startDate: string; endDate: string }> {
const ranges: Array<{ startDate: string; endDate: string }> = [];
let cursor = this.parseDate(startDate);
const end = this.parseDate(endDate);
while (cursor.getTime() <= end.getTime()) {
const rangeEnd = new Date(cursor);
rangeEnd.setUTCDate(rangeEnd.getUTCDate() + maxDays - 1);
if (rangeEnd.getTime() > end.getTime()) rangeEnd.setTime(end.getTime());
ranges.push({
startDate: this.formatDate(cursor),
endDate: this.formatDate(rangeEnd),
});
cursor = new Date(rangeEnd);
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
return ranges;
}
private parseDate(value: string): Date {
const date = new Date(`${value}T00:00:00.000Z`);
if (Number.isNaN(date.getTime())) {
throw new BadRequestException(`无效日期: ${value}`);
}
return date;
}
private formatDate(value: Date): string {
return value.toISOString().slice(0, 10);
}
/** /**
* Query which dingIds already exist to skip duplicates. * Query which dingIds already exist to skip duplicates.
*/ */
@@ -206,10 +256,10 @@ export class AttendanceImportService {
/** /**
* Map a DingTalk API result to a DingAttendanceRaw entity. * Map a DingTalk API result to a DingAttendanceRaw entity.
*/ */
private mapToEntity(r: DingTalkAttendanceResult): DingAttendanceRaw { private async mapToEntity(r: DingTalkAttendanceResult): Promise<DingAttendanceRaw> {
const entity = new DingAttendanceRaw(); const entity = new DingAttendanceRaw();
entity.dingUserId = r.userId; entity.dingUserId = r.userId;
entity.userName = ''; // Will be filled from the result if available entity.userName = r.userName || await this.resolveStudentName(r.userId);
entity.attendanceDate = r.workDate; entity.attendanceDate = r.workDate;
entity.dingId = r.checkId; entity.dingId = r.checkId;
entity.attendanceType = r.checkType || 'OnDuty'; entity.attendanceType = r.checkType || 'OnDuty';
@@ -233,6 +283,15 @@ export class AttendanceImportService {
return entity; return entity;
} }
private async resolveStudentName(dingUserId: string): Promise<string> {
const mapping = await this.studentDingMappingRepo.findOne({
where: { dingUserId },
});
if (!mapping) return '';
const student = await this.studentRepo.findOne({ where: { id: mapping.studentId } });
return student?.name || '';
}
/** /**
* Auto-match unmatched records to students via the dingUserId → userId mapping chain. * Auto-match unmatched records to students via the dingUserId → userId mapping chain.
* *

View File

@@ -0,0 +1,150 @@
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { AttendanceController } from './attendance.controller';
import { AttendanceService } from './attendance.service';
import { AttendanceImportService } from './attendance-import.service';
import { OperationLogsService } from '../operation-logs/operation-logs.service';
describe('AttendanceController — DingTalk import scope', () => {
const attendanceService = {
getTeacherClassDingUserIds: jest.fn(),
getImportableClasses: jest.fn(),
};
const importService = {
importFromDingTalk: jest.fn(),
};
const logService = {
log: jest.fn(),
};
let controller: AttendanceController;
beforeEach(() => {
jest.clearAllMocks();
controller = new AttendanceController(
attendanceService as unknown as AttendanceService,
importService as unknown as AttendanceImportService,
logService as unknown as OperationLogsService,
);
importService.importFromDingTalk.mockResolvedValue({
success: true,
imported: 0,
skipped: 0,
matched: 0,
errors: [],
duration: 1,
});
});
it('defaults teacher DingTalk import to today when no date range is provided', async () => {
jest.useFakeTimers().setSystemTime(new Date('2026-07-10T08:00:00.000Z'));
attendanceService.getTeacherClassDingUserIds.mockResolvedValue(['ding-today']);
await controller.importFromDingTalk({ classId: 8 }, {
user: { id: 21, username: 'teacher', permissions: [], isSuperAdmin: false },
} as never);
expect(importService.importFromDingTalk).toHaveBeenCalledWith({
startDate: '2026-07-10',
endDate: '2026-07-10',
userIds: ['ding-today'],
autoMatch: true,
});
jest.useRealTimers();
});
it('uses only the selected class students mapped to DingTalk for a teacher import', async () => {
attendanceService.getTeacherClassDingUserIds.mockResolvedValue(['ding-1', 'ding-2']);
await controller.importFromDingTalk(
{ start: '2026-07-01', end: '2026-07-02', classId: 8, autoMatch: true },
{ user: { id: 21, username: 'teacher', permissions: [], isSuperAdmin: false } } as never,
);
expect(attendanceService.getTeacherClassDingUserIds).toHaveBeenCalledWith(21, 8, false);
expect(importService.importFromDingTalk).toHaveBeenCalledWith({
startDate: '2026-07-01',
endDate: '2026-07-02',
userIds: ['ding-1', 'ding-2'],
autoMatch: true,
});
});
it('does not allow a teacher to supply arbitrary DingTalk user IDs', async () => {
await expect(
controller.importFromDingTalk(
{ start: '2026-07-01', end: '2026-07-02', users: 'someone-else', autoMatch: true },
{ user: { id: 21, username: 'teacher', permissions: [], isSuperAdmin: false } } as never,
),
).rejects.toBeInstanceOf(ForbiddenException);
expect(importService.importFromDingTalk).not.toHaveBeenCalled();
});
it('requires teachers to select one of their classes', async () => {
await expect(
controller.importFromDingTalk({ start: '2026-07-01', end: '2026-07-02', autoMatch: true }, {
user: { id: 21, username: 'teacher', permissions: [], isSuperAdmin: false },
} as never),
).rejects.toBeInstanceOf(BadRequestException);
});
it('lists only classes available to the current user for DingTalk import', async () => {
attendanceService.getImportableClasses.mockResolvedValue([{ classId: 8, className: '八班' }]);
await expect(
controller.getDingTalkImportClasses({
user: { id: 21, username: 'teacher', permissions: [], isSuperAdmin: false },
} as never),
).resolves.toEqual([{ classId: 8, className: '八班' }]);
expect(attendanceService.getImportableClasses).toHaveBeenCalledWith(21, false);
});
it('always auto-matches class-scoped imports even if an old client sends autoMatch=false', async () => {
attendanceService.getTeacherClassDingUserIds.mockResolvedValue(['ding-1']);
await controller.importFromDingTalk(
{ start: '2026-07-01', end: '2026-07-02', classId: 8, autoMatch: false },
{ user: { id: 21, username: 'teacher', permissions: [], isSuperAdmin: false } } as never,
);
expect(importService.importFromDingTalk).toHaveBeenCalledWith(
expect.objectContaining({ autoMatch: true }),
);
});
it('allows class managers to choose any importable class and supply explicit DingTalk users', async () => {
attendanceService.getImportableClasses.mockResolvedValue([{ classId: 1, className: '一班' }]);
await expect(
controller.getDingTalkImportClasses({
user: {
id: 7,
username: 'manager',
permissions: ['class:edit', 'attendance:create'],
isSuperAdmin: false,
},
} as never),
).resolves.toEqual([{ classId: 1, className: '一班' }]);
expect(attendanceService.getImportableClasses).toHaveBeenCalledWith(7, true);
await controller.importFromDingTalk(
{ start: '2026-07-01', end: '2026-07-02', users: 'ding-1,ding-2', autoMatch: true },
{
user: {
id: 7,
username: 'manager',
permissions: ['class:edit', 'attendance:create'],
isSuperAdmin: false,
},
} as never,
);
expect(importService.importFromDingTalk).toHaveBeenLastCalledWith({
startDate: '2026-07-01',
endDate: '2026-07-02',
userIds: ['ding-1', 'ding-2'],
autoMatch: true,
});
});
});

View File

@@ -11,6 +11,8 @@ import {
UseGuards, UseGuards,
Request, Request,
Res, Res,
BadRequestException,
ForbiddenException,
} from '@nestjs/common'; } from '@nestjs/common';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import type { Request as ExpressRequest, Response } from 'express'; import type { Request as ExpressRequest, Response } from 'express';
@@ -34,7 +36,6 @@ import { extractRequestInfo } from '../common/request-utils';
import { RequirePermission } from '../auth/decorators/permission.decorator'; import { RequirePermission } from '../auth/decorators/permission.decorator';
import * as ExcelJS from 'exceljs'; import * as ExcelJS from 'exceljs';
/** SSE event shape for @Sse() decorator */ /** SSE event shape for @Sse() decorator */
interface SseEvent { interface SseEvent {
data: string | Record<string, unknown>; data: string | Record<string, unknown>;
@@ -46,7 +47,8 @@ interface SseEvent {
interface RequestUser { interface RequestUser {
id: number; id: number;
username: string; username: string;
role?: string; permissions?: string[];
isSuperAdmin?: boolean;
} }
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@@ -58,13 +60,30 @@ export class AttendanceController {
private readonly logService: OperationLogsService, private readonly logService: OperationLogsService,
) {} ) {}
private getTodayDateOnly(): string {
const today = new Date();
const year = today.getFullYear();
const month = String(today.getMonth() + 1).padStart(2, '0');
const day = String(today.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
private canManageAllAttendance(user: RequestUser): boolean {
return user.isSuperAdmin === true || user.permissions?.includes('class:edit') === true;
}
private getAccessibleClassIds(user: RequestUser) {
return this.service.getAccessibleClassIds(user.id, this.canManageAllAttendance(user));
}
private assertClassAccess(user: RequestUser, classId: number) {
return this.service.assertClassAccess(user.id, classId, this.canManageAllAttendance(user));
}
// ── Batch create attendance records ── // ── Batch create attendance records ──
@Post('attendance-records/batch') @Post('attendance-records/batch')
@RequirePermission('attendance:create') @RequirePermission('attendance:create')
async batchCreate( async batchCreate(@Body() dto: BatchCreateAttendanceDto, @Request() req: any) {
@Body() dto: BatchCreateAttendanceDto,
@Request() req: any,
) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.batchCreate(dto); const result = await this.service.batchCreate(dto);
await this.logService.log({ await this.logService.log({
@@ -82,10 +101,7 @@ export class AttendanceController {
// ── Generate attendance records from schedules (with optional date range) ── // ── Generate attendance records from schedules (with optional date range) ──
@Post('attendance-records/generate-from-schedules') @Post('attendance-records/generate-from-schedules')
@RequirePermission('attendance:create') @RequirePermission('attendance:create')
async generateFromSchedules( async generateFromSchedules(@Body() dto: GenerateFromSchedulesDto, @Request() req: any) {
@Body() dto: GenerateFromSchedulesDto,
@Request() req: any,
) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.generateFromSchedules(dto); const result = await this.service.generateFromSchedules(dto);
await this.logService.log({ await this.logService.log({
@@ -100,15 +116,17 @@ export class AttendanceController {
return result; return result;
} }
// ── Export attendance records ── // ── Export attendance records ──
@Get('attendance-records/export') @Get('attendance-records/export')
@RequirePermission('attendance:export') @RequirePermission('attendance:export')
async exportRecords( async exportRecords(
@Query() query: QueryAttendanceRecordsDto, @Query() query: QueryAttendanceRecordsDto,
@Res() res: Response, @Res() res: Response,
@Request() req: { user: RequestUser },
) { ) {
const records = await this.service.findAllForExport(query); if (query.classId) await this.assertClassAccess(req.user, query.classId);
const classIds = await this.getAccessibleClassIds(req.user);
const records = await this.service.findAllForExport(query, classIds);
const workbook = new ExcelJS.Workbook(); const workbook = new ExcelJS.Workbook();
const ws = workbook.addWorksheet('考勤统计报表'); const ws = workbook.addWorksheet('考勤统计报表');
@@ -140,9 +158,7 @@ export class AttendanceController {
}); });
} }
const dateRange = [query.dateFrom, query.dateTo] const dateRange = [query.dateFrom, query.dateTo].filter(Boolean).join('-') || '全部';
.filter(Boolean)
.join('-') || '全部';
res.setHeader( res.setHeader(
'Content-Type', 'Content-Type',
@@ -159,8 +175,9 @@ export class AttendanceController {
// ── List attendance records with filters ── // ── List attendance records with filters ──
@Get('attendance-records') @Get('attendance-records')
@RequirePermission('attendance:view') @RequirePermission('attendance:view')
findAll(@Query() query: QueryAttendanceRecordsDto) { async findAll(@Query() query: QueryAttendanceRecordsDto, @Request() req: { user: RequestUser }) {
return this.service.findAll(query); if (query.classId) await this.assertClassAccess(req.user, query.classId);
return this.service.findAll(query, await this.getAccessibleClassIds(req.user));
} }
// ── Update a single attendance record ── // ── Update a single attendance record ──
@@ -190,10 +207,7 @@ export class AttendanceController {
// ── Delete a single attendance record ── // ── Delete a single attendance record ──
@Delete('attendance-records/:id') @Delete('attendance-records/:id')
@RequirePermission('attendance:edit') @RequirePermission('attendance:edit')
async remove( async remove(@Param('id') id: string, @Request() req: any) {
@Param('id') id: string,
@Request() req: any,
) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.remove(+id); const result = await this.service.remove(+id);
await this.logService.log({ await this.logService.log({
@@ -213,29 +227,38 @@ export class AttendanceController {
// ── Get distinct classes with attendance records ── // ── Get distinct classes with attendance records ──
@Get('attendance-records/classes') @Get('attendance-records/classes')
@RequirePermission('attendance:view') @RequirePermission('attendance:view')
getClasses() { async getClasses(@Request() req: { user: RequestUser }) {
return this.service.getClasses(); return this.service.getClasses(await this.getAccessibleClassIds(req.user));
} }
// ── Attendance summary ── // ── Attendance summary ──
@Get('attendance-records/summary') @Get('attendance-records/summary')
@RequirePermission('attendance:view') @RequirePermission('attendance:view')
getSummary(@Query() query: AttendanceSummaryQueryDto) { async getSummary(
return this.service.getSummary(query); @Query() query: AttendanceSummaryQueryDto,
@Request() req: { user: RequestUser },
) {
if (query.classId) await this.assertClassAccess(req.user, query.classId);
return this.service.getSummary(query, await this.getAccessibleClassIds(req.user));
} }
// ── Attendance calendar ── // ── Attendance calendar ──
@Get('attendance-records/calendar') @Get('attendance-records/calendar')
@RequirePermission('attendance:view') @RequirePermission('attendance:view')
getCalendar(@Query() query: AttendanceCalendarQueryDto) { async getCalendar(
@Query() query: AttendanceCalendarQueryDto,
@Request() req: { user: RequestUser },
) {
await this.assertClassAccess(req.user, query.classId);
return this.service.getCalendar(query); return this.service.getCalendar(query);
} }
// ── DingAttendance raw records ── // ── DingAttendance raw records ──
@Get('ding-attendance-raw') @Get('ding-attendance-raw')
@RequirePermission('attendance:view') @RequirePermission('attendance:view')
getDingRaw(@Query() query: QueryDingRawDto) { async getDingRaw(@Query() query: QueryDingRawDto, @Request() req: { user: RequestUser }) {
return this.service.getDingRaw(query); if (query.classId) await this.assertClassAccess(req.user, query.classId);
return this.service.getDingRaw(query, await this.getAccessibleClassIds(req.user));
} }
// ── Match a dingtalk record to a student ── // ── Match a dingtalk record to a student ──
@@ -270,7 +293,11 @@ export class AttendanceController {
@Res() res: Response, @Res() res: Response,
@Request() req: any, @Request() req: any,
) { ) {
const reportData = await this.service.getReport(query); if (query.classId) await this.assertClassAccess(req.user, query.classId);
const reportData = await this.service.getReport(
query,
await this.getAccessibleClassIds(req.user),
);
const workbook = new ExcelJS.Workbook(); const workbook = new ExcelJS.Workbook();
const ws = workbook.addWorksheet('考勤统计报表'); const ws = workbook.addWorksheet('考勤统计报表');
@@ -316,7 +343,10 @@ export class AttendanceController {
userAgent, userAgent,
}); });
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); res.setHeader(
'Content-Type',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
);
res.setHeader('Content-Disposition', 'attachment; filename=attendance-report.xlsx'); res.setHeader('Content-Disposition', 'attachment; filename=attendance-report.xlsx');
await workbook.xlsx.write(res); await workbook.xlsx.write(res);
res.end(); res.end();
@@ -325,13 +355,15 @@ export class AttendanceController {
// ── Abnormal attendance alerts ── // ── Abnormal attendance alerts ──
@Get('attendance-records/alerts') @Get('attendance-records/alerts')
@RequirePermission('attendance:view') @RequirePermission('attendance:view')
getAlerts( async getAlerts(
@Request() req: { user: RequestUser },
@Query('days') days?: string, @Query('days') days?: string,
@Query('threshold') threshold?: string, @Query('threshold') threshold?: string,
) { ) {
return this.service.getAlerts( return this.service.getAlerts(
days ? +days : 14, days ? +days : 14,
threshold ? +threshold : 3, threshold ? +threshold : 3,
await this.getAccessibleClassIds(req.user),
); );
} }
@@ -345,6 +377,12 @@ export class AttendanceController {
// DingTalk attendance import with SSE streaming progress // DingTalk attendance import with SSE streaming progress
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
@Get('attendance-records/import/dingtalk/classes')
@RequirePermission('attendance:create')
getDingTalkImportClasses(@Request() req: { user: RequestUser }) {
return this.service.getImportableClasses(req.user.id, this.canManageAllAttendance(req.user));
}
/** /**
* Trigger DingTalk attendance import. * Trigger DingTalk attendance import.
* Mirrors `dws attendance check result` pipeline: * Mirrors `dws attendance check result` pipeline:
@@ -352,16 +390,38 @@ export class AttendanceController {
*/ */
@Post('attendance-records/import/dingtalk') @Post('attendance-records/import/dingtalk')
@RequirePermission('attendance:create') @RequirePermission('attendance:create')
async importFromDingTalk( async importFromDingTalk(@Body() dto: DingTalkImportDto, @Request() req: { user: RequestUser }) {
@Body() dto: DingTalkImportDto,
@Request() req: { user: RequestUser },
) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const canManageAll = this.canManageAllAttendance(req.user);
let userIds: string[];
if (dto.users) {
if (!canManageAll) {
throw new ForbiddenException('仅管理员可指定钉钉用户范围');
}
userIds = dto.users
.split(',')
.map((value) => value.trim())
.filter(Boolean);
} else {
if (!dto.classId) {
throw new BadRequestException('请选择要拉取考勤的班级');
}
userIds = await this.service.getTeacherClassDingUserIds(
req.user.id,
dto.classId,
canManageAll,
);
}
const startDate = dto.start ?? this.getTodayDateOnly();
const endDate = dto.end ?? startDate;
const result = await this.importService.importFromDingTalk({ const result = await this.importService.importFromDingTalk({
startDate: dto.start, startDate,
endDate: dto.end, endDate,
userIds: dto.users?.split(',').map((s) => s.trim()).filter(Boolean), userIds,
autoMatch: dto.autoMatch ?? true, autoMatch: true,
}); });
await this.logService.log({ await this.logService.log({
@@ -369,7 +429,7 @@ export class AttendanceController {
username: req.user?.username, username: req.user?.username,
module: '考勤管理', module: '考勤管理',
action: '钉钉考勤导入', action: '钉钉考勤导入',
detail: `${dto.start}~${dto.end}, 导入=${result.imported}, 跳过=${result.skipped}, 匹配=${result.matched}`, detail: `${startDate}~${endDate}, 导入=${result.imported}, 跳过=${result.skipped}, 匹配=${result.matched}`,
ipAddress, ipAddress,
userAgent, userAgent,
}); });
@@ -385,7 +445,7 @@ export class AttendanceController {
* execute in the standard request pipeline before the SSE handler is invoked. * execute in the standard request pipeline before the SSE handler is invoked.
* If this ever breaks after a NestJS upgrade, verify guard execution order. * If this ever breaks after a NestJS upgrade, verify guard execution order.
*/ */
@Sse('attendance-records/import/dingtalk/stream') @Sse('attendance-records/import/dingtalk/stream')
@RequirePermission('attendance:view') @RequirePermission('attendance:view')
importProgressStream(): Observable<SseEvent> { importProgressStream(): Observable<SseEvent> {
return new Observable<SseEvent>((subscriber) => { return new Observable<SseEvent>((subscriber) => {
@@ -401,5 +461,4 @@ export class AttendanceController {
return () => subscription.unsubscribe(); return () => subscription.unsubscribe();
}); });
} }
}
}

View File

@@ -1,6 +1,6 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { AttendanceRecord, DingAttendanceRaw, Student, Class, ClassSchedule, ClassStudent, StudentDingMapping } from '../entities'; import { AttendanceRecord, DingAttendanceRaw, Student, Class, ClassSchedule, ClassStudent, ClassTeacher, StudentDingMapping } from '../entities';
import { AttendanceService } from './attendance.service'; import { AttendanceService } from './attendance.service';
import { AttendanceImportService } from './attendance-import.service'; import { AttendanceImportService } from './attendance-import.service';
import { AttendanceController } from './attendance.controller'; import { AttendanceController } from './attendance.controller';
@@ -9,7 +9,7 @@ import { IntegrationModule } from '../integration/integration.module';
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([AttendanceRecord, DingAttendanceRaw, Student, Class, ClassSchedule, ClassStudent, StudentDingMapping]), TypeOrmModule.forFeature([AttendanceRecord, DingAttendanceRaw, Student, Class, ClassSchedule, ClassStudent, ClassTeacher, StudentDingMapping]),
OperationLogsModule, OperationLogsModule,
IntegrationModule, IntegrationModule,
], ],

View File

@@ -10,6 +10,7 @@ import { Student } from '../entities/student.entity';
import { ClassSchedule } from '../entities/class-schedule.entity'; import { ClassSchedule } from '../entities/class-schedule.entity';
import { ClassStudent } from '../entities/class-student.entity'; import { ClassStudent } from '../entities/class-student.entity';
import { StudentDingMapping } from '../entities/student-ding-mapping.entity'; import { StudentDingMapping } from '../entities/student-ding-mapping.entity';
import { ClassTeacher } from '../entities/class-teacher.entity';
import { BatchCreateAttendanceDto } from './dto/attendance.dto'; import { BatchCreateAttendanceDto } from './dto/attendance.dto';
describe('AttendanceService — batchCreate', () => { describe('AttendanceService — batchCreate', () => {
@@ -51,6 +52,7 @@ describe('AttendanceService — batchCreate', () => {
{ provide: getRepositoryToken(ClassSchedule), useValue: mockScheduleRepo }, { provide: getRepositoryToken(ClassSchedule), useValue: mockScheduleRepo },
{ provide: getRepositoryToken(StudentDingMapping), useValue: mockStudentDingMappingRepo }, { provide: getRepositoryToken(StudentDingMapping), useValue: mockStudentDingMappingRepo },
{ provide: getRepositoryToken(ClassStudent), useValue: mockClassStudentRepo }, { provide: getRepositoryToken(ClassStudent), useValue: mockClassStudentRepo },
{ provide: getRepositoryToken(ClassTeacher), useValue: { findOne: jest.fn() } },
], ],
}).compile(); }).compile();
@@ -105,3 +107,115 @@ describe('AttendanceService — batchCreate', () => {
// Requires mock setup for StudentDingMapping, ClassSchedule, ClassStudent, and DingAttendanceRaw repos. // Requires mock setup for StudentDingMapping, ClassSchedule, ClassStudent, and DingAttendanceRaw repos.
}); });
}); });
describe('AttendanceService — teacher DingTalk class scope', () => {
const classTeacherRepo = {
findOne: jest.fn(),
find: jest.fn(),
};
const classStudentRepo = {
find: jest.fn(),
};
const mappingRepo = {
find: jest.fn(),
};
const createService = () =>
new AttendanceService(
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
classStudentRepo as never,
mappingRepo as never,
classTeacherRepo as never,
);
beforeEach(() => {
jest.clearAllMocks();
});
it('returns only mapped active students for a class assigned to the teacher', async () => {
classTeacherRepo.findOne.mockResolvedValue({ classId: 8, userId: 21 });
classStudentRepo.find.mockResolvedValue([
{ studentId: 2 },
{ studentId: 1 },
{ studentId: 2 },
]);
mappingRepo.find.mockResolvedValue([
{ studentId: 1, dingUserId: 'ding-1' },
{ studentId: 2, dingUserId: 'ding-2' },
]);
await expect(createService().getTeacherClassDingUserIds(21, 8, false)).resolves.toEqual([
'ding-1',
'ding-2',
]);
});
it('lists distinct classes assigned to a teacher', async () => {
classTeacherRepo.find.mockResolvedValue([
{ classId: 8, class: { name: '八班' } },
{ classId: 8, class: { name: '八班' } },
{ classId: 9, class: { name: '九班' } },
]);
await expect(createService().getImportableClasses(21, false)).resolves.toEqual([
{ classId: 8, className: '八班' },
{ classId: 9, className: '九班' },
]);
expect(classTeacherRepo.find).toHaveBeenCalledWith({
where: { userId: 21 },
relations: ['class'],
});
});
it('rejects a class that is not assigned to the teacher', async () => {
classTeacherRepo.findOne.mockResolvedValue(null);
await expect(createService().getTeacherClassDingUserIds(21, 99, false)).rejects.toBeInstanceOf(
BadRequestException,
);
});
});
describe('AttendanceService — DingTalk raw query', () => {
it('returns the paginated shape and filters by class student mappings', async () => {
const qb = {
leftJoinAndSelect: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
addOrderBy: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
getManyAndCount: jest.fn().mockResolvedValue([[{ id: 1 }], 1]),
};
const dingRepo = { createQueryBuilder: jest.fn().mockReturnValue(qb) };
const classStudentRepo = { find: jest.fn().mockResolvedValue([{ studentId: 3 }]) };
const mappingRepo = { find: jest.fn().mockResolvedValue([{ studentId: 3, dingUserId: 'ding-3' }]) };
const service = new AttendanceService(
{} as never,
dingRepo as never,
{} as never,
{} as never,
{} as never,
classStudentRepo as never,
mappingRepo as never,
{} as never,
);
await expect(
service.getDingRaw({ classId: 8, dateFrom: '2026-07-01', page: 2, pageSize: 10 }),
).resolves.toEqual({ list: [{ id: 1 }], total: 1, page: 2, pageSize: 10 });
expect(qb.andWhere).toHaveBeenCalledWith('ar.dingUserId IN (:...dingUserIds)', {
dingUserIds: ['ding-3'],
});
expect(qb.andWhere).toHaveBeenCalledWith('ar.attendanceDate >= :dateFrom', {
dateFrom: '2026-07-01',
});
expect(qb.skip).toHaveBeenCalledWith(10);
expect(qb.take).toHaveBeenCalledWith(10);
});
});

View File

@@ -5,7 +5,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In, Between, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; import { Repository, In, Between, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
import { AttendanceRecord, DingAttendanceRaw, Class, Student, ClassSchedule, ClassStudent, ScheduleType, StudentDingMapping } from '../entities'; import { AttendanceRecord, DingAttendanceRaw, Class, Student, ClassSchedule, ClassStudent, ClassTeacher, ScheduleType, StudentDingMapping } from '../entities';
import { import {
BatchCreateAttendanceDto, BatchCreateAttendanceDto,
AttendanceSummaryQueryDto, AttendanceSummaryQueryDto,
@@ -35,8 +35,83 @@ export class AttendanceService {
private classStudentRepo: Repository<ClassStudent>, private classStudentRepo: Repository<ClassStudent>,
@InjectRepository(StudentDingMapping) @InjectRepository(StudentDingMapping)
private studentDingMappingRepo: Repository<StudentDingMapping>, private studentDingMappingRepo: Repository<StudentDingMapping>,
@InjectRepository(ClassTeacher)
private classTeacherRepo: Repository<ClassTeacher>,
) {} ) {}
async getAccessibleClassIds(userId: number, canManageAll = false): Promise<number[] | undefined> {
if (canManageAll) return undefined;
const assignments = await this.classTeacherRepo.find({ where: { userId } });
return [...new Set(assignments.map((assignment) => assignment.classId))];
}
async assertClassAccess(userId: number, classId: number, canManageAll = false): Promise<void> {
if (canManageAll) return;
const assignment = await this.classTeacherRepo.findOne({ where: { userId, classId } });
if (!assignment) throw new BadRequestException('只能访问自己任教班级的考勤');
}
/** List classes the current user may select for DingTalk attendance import. */
async getImportableClasses(userId: number, isSuperAdmin = false) {
if (isSuperAdmin) {
const classes = await this.classRepo.find({
where: { isArchived: false },
order: { name: 'ASC' },
});
return classes.map((item) => ({ classId: item.id, className: item.name }));
}
const assignments = await this.classTeacherRepo.find({
where: { userId },
relations: ['class'],
});
const classes = new Map<number, string>();
for (const assignment of assignments) {
if (assignment.class && !assignment.class.isArchived) {
classes.set(assignment.classId, assignment.class.name);
}
}
return [...classes.entries()]
.map(([classId, className]) => ({ classId, className }))
.sort((left, right) => left.className.localeCompare(right.className, 'zh-CN'));
}
/** Resolve the DingTalk users a teacher may import for one assigned class. */
async getTeacherClassDingUserIds(
userId: number,
classId: number,
isSuperAdmin = false,
): Promise<string[]> {
if (!isSuperAdmin) {
const assignment = await this.classTeacherRepo.findOne({
where: { userId, classId },
});
if (!assignment) {
throw new BadRequestException('只能拉取自己任教班级的考勤记录');
}
} else {
const cls = await this.classRepo.findOne({ where: { id: classId } });
if (!cls) throw new NotFoundException(`Class ${classId} not found`);
}
const classStudents = await this.classStudentRepo.find({
where: { classId, status: 'active' },
});
const studentIds = [...new Set(classStudents.map((item) => item.studentId))];
if (studentIds.length === 0) {
throw new BadRequestException('该班级暂无在读学生');
}
const mappings = await this.studentDingMappingRepo.find({
where: { studentId: In(studentIds) },
});
const userIds = [...new Set(mappings.map((mapping) => mapping.dingUserId).filter(Boolean))];
if (userIds.length === 0) {
throw new BadRequestException('该班级学生尚未同步钉钉账号');
}
return userIds.sort();
}
// ── Batch create attendance records ── // ── Batch create attendance records ──
async batchCreate(dto: BatchCreateAttendanceDto) { async batchCreate(dto: BatchCreateAttendanceDto) {
if (!dto.records || dto.records.length === 0) { if (!dto.records || dto.records.length === 0) {
@@ -169,11 +244,15 @@ export class AttendanceService {
} }
// ── Attendance summary ── // ── Attendance summary ──
async getSummary(query: AttendanceSummaryQueryDto) { async getSummary(query: AttendanceSummaryQueryDto, accessibleClassIds?: number[]) {
const qb = this.attendanceRepo.createQueryBuilder('ar'); const qb = this.attendanceRepo.createQueryBuilder('ar');
if (query.classId) { if (query.classId) {
qb.andWhere('ar.classId = :classId', { classId: query.classId }); qb.andWhere('ar.classId = :classId', { classId: query.classId });
} }
else if (accessibleClassIds) {
if (accessibleClassIds.length === 0) return { total: 0, present: 0, late: 0, absent: 0, leave: 0, presentRate: 0 };
qb.andWhere('ar.classId IN (:...accessibleClassIds)', { accessibleClassIds });
}
if (query.dateFrom) { if (query.dateFrom) {
qb.andWhere('ar.attendanceDate >= :dateFrom', { dateFrom: query.dateFrom }); qb.andWhere('ar.attendanceDate >= :dateFrom', { dateFrom: query.dateFrom });
} }
@@ -267,7 +346,7 @@ export class AttendanceService {
source?: string; source?: string;
page?: number; page?: number;
pageSize?: number; pageSize?: number;
}) { }, accessibleClassIds?: number[]) {
const page = query.page || 1; const page = query.page || 1;
const pageSize = query.pageSize || 20; const pageSize = query.pageSize || 20;
@@ -278,6 +357,10 @@ export class AttendanceService {
if (query.classId) { if (query.classId) {
qb.andWhere('ar.classId = :classId', { classId: query.classId }); qb.andWhere('ar.classId = :classId', { classId: query.classId });
} }
else if (accessibleClassIds) {
if (accessibleClassIds.length === 0) return { list: [], total: 0, page, pageSize };
qb.andWhere('ar.classId IN (:...accessibleClassIds)', { accessibleClassIds });
}
if (query.dateFrom) { if (query.dateFrom) {
qb.andWhere('ar.attendanceDate >= :dateFrom', { dateFrom: query.dateFrom }); qb.andWhere('ar.attendanceDate >= :dateFrom', { dateFrom: query.dateFrom });
} }
@@ -302,18 +385,18 @@ export class AttendanceService {
} }
// ── Get distinct classes with attendance records ── // ── Get distinct classes with attendance records ──
async getClasses() { async getClasses(accessibleClassIds?: number[]) {
const qb = this.attendanceRepo const qb = this.attendanceRepo
.createQueryBuilder('ar') .createQueryBuilder('ar')
.select('DISTINCT ar.classId', 'classId') .select('DISTINCT ar.classId', 'classId')
.where('ar.classId IS NOT NULL'); .where('ar.classId IS NOT NULL');
const rows = await qb const rows = accessibleClassIds
.orderBy('ar.classId', 'ASC') ? accessibleClassIds.map((classId) => ({ classId }))
.getRawMany(); : await qb.orderBy('ar.classId', 'ASC').getRawMany();
const classIds = rows.map((r) => r.classId).filter(Boolean) as number[]; const classIds = [...new Set(rows.map((r) => Number(r.classId)).filter(Boolean))];
if (classIds.length === 0) return []; if (classIds.length === 0) return [];
const where = { id: In(classIds) }; const where = { id: In(classIds) };
@@ -323,17 +406,44 @@ export class AttendanceService {
} }
// ── DingAttendance raw records ── // ── DingAttendance raw records ──
async getDingRaw(query: QueryDingRawDto) { async getDingRaw(query: QueryDingRawDto, accessibleClassIds?: number[]) {
const where: any = {}; const page = query.page || 1;
const pageSize = query.pageSize || 20;
const qb = this.dingRawRepo.createQueryBuilder('ar');
qb.leftJoinAndSelect('ar.matchedStudent', 'matchedStudent');
if (query.matchStatus) { if (query.matchStatus) {
where.matchStatus = query.matchStatus; qb.andWhere('ar.matchStatus = :matchStatus', { matchStatus: query.matchStatus });
}
if (query.dateFrom) {
qb.andWhere('ar.attendanceDate >= :dateFrom', { dateFrom: query.dateFrom });
}
if (query.dateTo) {
qb.andWhere('ar.attendanceDate <= :dateTo', { dateTo: query.dateTo });
}
const scopedClassIds = query.classId ? [query.classId] : accessibleClassIds;
if (scopedClassIds) {
if (scopedClassIds.length === 0) return { list: [], total: 0, page, pageSize };
const classStudents = await this.classStudentRepo.find({
where: { classId: In(scopedClassIds), status: 'active' },
});
const studentIds = [...new Set(classStudents.map((item) => item.studentId))];
if (studentIds.length === 0) return { list: [], total: 0, page, pageSize };
const mappings = await this.studentDingMappingRepo.find({
where: { studentId: In(studentIds) },
});
const dingUserIds = [...new Set(mappings.map((mapping) => mapping.dingUserId).filter(Boolean))];
if (dingUserIds.length === 0) return { list: [], total: 0, page, pageSize };
qb.andWhere('ar.dingUserId IN (:...dingUserIds)', { dingUserIds });
} }
return this.dingRawRepo.find({ qb.orderBy('ar.attendanceDate', 'DESC')
where, .addOrderBy('ar.checkInTime', 'ASC')
relations: ['matchedStudent'], .skip((page - 1) * pageSize)
order: { attendanceDate: 'DESC', checkInTime: 'ASC' }, .take(pageSize);
});
const [list, total] = await qb.getManyAndCount();
return { list, total, page, pageSize };
} }
// ── Match a dingtalk record to a student ── // ── Match a dingtalk record to a student ──
@@ -385,7 +495,7 @@ export class AttendanceService {
session?: string; session?: string;
status?: string; status?: string;
source?: string; source?: string;
}) { }, accessibleClassIds?: number[]) {
const qb = this.attendanceRepo.createQueryBuilder('ar'); const qb = this.attendanceRepo.createQueryBuilder('ar');
qb.leftJoinAndSelect('ar.student', 'student') qb.leftJoinAndSelect('ar.student', 'student')
@@ -393,6 +503,10 @@ export class AttendanceService {
if (query.classId) { if (query.classId) {
qb.andWhere('ar.classId = :classId', { classId: query.classId }); qb.andWhere('ar.classId = :classId', { classId: query.classId });
} }
else if (accessibleClassIds) {
if (accessibleClassIds.length === 0) return [];
qb.andWhere('ar.classId IN (:...accessibleClassIds)', { accessibleClassIds });
}
if (query.dateFrom) { if (query.dateFrom) {
qb.andWhere('ar.attendanceDate >= :dateFrom', { dateFrom: query.dateFrom }); qb.andWhere('ar.attendanceDate >= :dateFrom', { dateFrom: query.dateFrom });
} }
@@ -445,7 +559,7 @@ export class AttendanceService {
} }
// ── Class-based attendance report ── // ── Class-based attendance report ──
async getReport(query: AttendanceReportQueryDto) { async getReport(query: AttendanceReportQueryDto, accessibleClassIds?: number[]) {
const qb = this.attendanceRepo.createQueryBuilder('ar'); const qb = this.attendanceRepo.createQueryBuilder('ar');
qb.leftJoin('ar.class', 'class') qb.leftJoin('ar.class', 'class')
@@ -456,6 +570,10 @@ export class AttendanceService {
if (query.classId) { if (query.classId) {
qb.andWhere('ar.classId = :classId', { classId: query.classId }); qb.andWhere('ar.classId = :classId', { classId: query.classId });
} }
else if (accessibleClassIds) {
if (accessibleClassIds.length === 0) return [];
qb.andWhere('ar.classId IN (:...accessibleClassIds)', { accessibleClassIds });
}
if (query.dateFrom) { if (query.dateFrom) {
qb.andWhere('ar.attendanceDate >= :dateFrom', { dateFrom: query.dateFrom }); qb.andWhere('ar.attendanceDate >= :dateFrom', { dateFrom: query.dateFrom });
} }
@@ -509,7 +627,7 @@ export class AttendanceService {
}); });
} }
// ── Attendance alerts: detect consecutive absences/late ── // ── Attendance alerts: detect consecutive absences/late ──
async getAlerts(days: number = 14, threshold: number = 3) { async getAlerts(days: number = 14, threshold: number = 3, accessibleClassIds?: number[]) {
const cutoff = new Date(); const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - days); cutoff.setDate(cutoff.getDate() - days);
const cutoffStr = cutoff.toISOString().slice(0, 10); const cutoffStr = cutoff.toISOString().slice(0, 10);
@@ -520,9 +638,13 @@ export class AttendanceService {
.leftJoinAndSelect('a.class', 'class'); .leftJoinAndSelect('a.class', 'class');
qb.where('a.attendanceDate >= :cutoff', { cutoff: cutoffStr })
.andWhere('a.status IN (:...statuses)', { statuses: ['absent', 'late'] });
if (accessibleClassIds) {
if (accessibleClassIds.length === 0) return [];
qb.andWhere('a.classId IN (:...accessibleClassIds)', { accessibleClassIds });
}
const records = await qb const records = await qb
.where('a.attendanceDate >= :cutoff', { cutoff: cutoffStr })
.andWhere('a.status IN (:...statuses)', { statuses: ['absent', 'late'] })
.orderBy('a.studentId', 'ASC') .orderBy('a.studentId', 'ASC')
.addOrderBy('a.attendanceDate', 'DESC') .addOrderBy('a.attendanceDate', 'DESC')
.getMany(); .getMany();

View File

@@ -0,0 +1,59 @@
import { DingTalkService } from '../integration/dingtalk.service';
describe('DingTalkService — attendance records', () => {
const originalAppKey = process.env.DINGTALK_APP_KEY;
const originalAppSecret = process.env.DINGTALK_APP_SECRET;
let service: DingTalkService;
beforeEach(() => {
process.env.DINGTALK_APP_KEY = 'test-app-key';
process.env.DINGTALK_APP_SECRET = 'test-app-secret';
service = new DingTalkService({} as never, {} as never);
Object.assign(service, {
accessToken: 'test-token',
tokenExpiresAt: Date.now() + 3_600_000,
});
});
afterEach(() => {
jest.restoreAllMocks();
global.fetch = undefined as unknown as typeof fetch;
});
afterAll(() => {
if (originalAppKey === undefined) delete process.env.DINGTALK_APP_KEY;
else process.env.DINGTALK_APP_KEY = originalAppKey;
if (originalAppSecret === undefined) delete process.env.DINGTALK_APP_SECRET;
else process.env.DINGTALK_APP_SECRET = originalAppSecret;
});
it('sends the required userIds and does not send unsupported offset/limit fields', async () => {
global.fetch = jest.fn().mockResolvedValue({
json: jest.fn().mockResolvedValue({ errcode: 0, errmsg: 'ok', recordresult: [] }),
}) as jest.MockedFunction<typeof fetch>;
await service.fetchAttendanceResults({
startDate: '2026-07-01',
endDate: '2026-07-07',
userIds: ['ding-1', 'ding-2'],
});
expect(JSON.parse((global.fetch as jest.Mock).mock.calls[0][1].body)).toEqual({
checkDateFrom: '2026-07-01 00:00:00',
checkDateTo: '2026-07-07 23:59:59',
userIds: ['ding-1', 'ding-2'],
});
});
it('rejects missing userIds before calling DingTalk', async () => {
await expect(
service.fetchAttendanceResults({
startDate: '2026-07-01',
endDate: '2026-07-01',
}),
).rejects.toThrow('userIds');
expect(global.fetch).toBeUndefined();
});
});

View File

@@ -80,6 +80,29 @@ export class QueryDingRawDto {
@IsString() @IsString()
@IsIn(['unmatched', 'pending', 'matched']) @IsIn(['unmatched', 'pending', 'matched'])
matchStatus?: string; matchStatus?: string;
@IsOptional()
@IsInt()
@Type(() => Number)
classId?: number;
@IsOptional()
@IsDateString()
dateFrom?: string;
@IsOptional()
@IsDateString()
dateTo?: string;
@IsOptional()
@IsInt()
@Type(() => Number)
page?: number;
@IsOptional()
@IsInt()
@Type(() => Number)
pageSize?: number;
} }
export class QueryAttendanceRecordsDto { export class QueryAttendanceRecordsDto {

View File

@@ -1,13 +1,4 @@
import { import { IsOptional, IsString, IsDateString, IsInt, Min } from 'class-validator';
IsOptional,
IsString,
IsDateString,
IsBoolean,
IsInt,
IsNotEmpty,
Min,
Max,
} from 'class-validator';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
/** /**
@@ -15,24 +6,30 @@ import { Type } from 'class-transformer';
* Mirrors `dws attendance check result` flags. * Mirrors `dws attendance check result` flags.
*/ */
export class DingTalkImportDto { export class DingTalkImportDto {
/** Start date (YYYY-MM-DD), required */ /** Start date (YYYY-MM-DD). Defaults to today when omitted. */
@IsNotEmpty() @IsOptional()
@IsDateString() @IsDateString()
start: string; start?: string;
/** End date (YYYY-MM-DD), required, max 1 month span */ /** End date (YYYY-MM-DD). Defaults to start/today when omitted. */
@IsNotEmpty() @IsOptional()
@IsDateString() @IsDateString()
end: string; end?: string;
/** Comma-separated DingTalk user IDs, optional (default: all org users) */ /** Target class. Required for non-super-admin users. */
@IsOptional()
@IsInt()
@Min(1)
@Type(() => Number)
classId?: number;
/** Comma-separated DingTalk user IDs. Super-admin override only. */
@IsOptional() @IsOptional()
@IsString() @IsString()
users?: string; users?: string;
/** Auto-match imported records to students after import */ /** @deprecated Imports are always matched through DingTalk user mappings. */
@IsOptional() @IsOptional()
@IsBoolean()
@Type(() => Boolean) @Type(() => Boolean)
autoMatch?: boolean; autoMatch?: boolean;
} }

View File

@@ -1,11 +1,11 @@
import { Controller, Post, Body, Get, Request, Req, UseGuards } from '@nestjs/common'; import { Controller, Post, Body, Get, Request, Req } from '@nestjs/common';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { LoginDto } from './dto/auth.dto'; import { LoginDto } from './dto/auth.dto';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { OperationLogsService } from '../operation-logs/operation-logs.service'; import { OperationLogsService } from '../operation-logs/operation-logs.service';
import { extractRequestInfo } from '../common/request-utils'; import { extractRequestInfo } from '../common/request-utils';
import { Throttle } from '@nestjs/throttler'; import { Throttle } from '@nestjs/throttler';
import { Public } from './decorators/public.decorator'; import { Public } from './decorators/public.decorator';
import { Authenticated } from './decorators/authenticated.decorator';
@Controller('auth') @Controller('auth')
export class AuthController { export class AuthController {
@@ -45,8 +45,7 @@ export class AuthController {
} }
} }
@Public() @Authenticated()
@UseGuards(JwtAuthGuard)
@Get('profile') @Get('profile')
getProfile(@Request() req: any) { getProfile(@Request() req: any) {
return req.user; return req.user;

View File

@@ -0,0 +1,29 @@
import * as bcrypt from 'bcryptjs';
import { AuthService } from './auth.service';
describe('AuthService — super admin identity', () => {
it('marks the preset 超管 role as super admin in the JWT payload', async () => {
const userRepo = {
findOne: jest.fn().mockResolvedValue({
id: 1,
username: 'admin',
name: '管理员',
passwordHash: await bcrypt.hash('secret', 4),
isActive: true,
roles: [{ name: '超管', status: 1 }],
}),
save: jest.fn(),
};
const jwtService = { sign: jest.fn().mockReturnValue('token') };
const rbacService = {
getUserPermissions: jest.fn().mockResolvedValue(['attendance:create']),
};
const service = new AuthService(userRepo as never, jwtService as never, rbacService as never);
await service.login({ username: 'admin', password: 'secret' }, '127.0.0.1');
expect(jwtService.sign).toHaveBeenCalledWith(
expect.objectContaining({ isSuperAdmin: true }),
);
});
});

View File

@@ -59,7 +59,12 @@ export class AuthService {
// 获取用户权限 // 获取用户权限
const permissions = await this.rbacService.getUserPermissions(user.id); const permissions = await this.rbacService.getUserPermissions(user.id);
const isSuperAdmin = user.roles?.some((r) => r.name === 'super_admin') ?? false; const isSuperAdmin =
user.roles?.some(
(role) =>
role.status === 1 &&
(role.name === '超管' || role.name === 'super_admin' || role.code === 'super_admin'),
) ?? false;
const payload = { sub: user.id, username: user.username, permissions, isSuperAdmin }; const payload = { sub: user.id, username: user.username, permissions, isSuperAdmin };
// 获取角色名称列表 // 获取角色名称列表

View File

@@ -0,0 +1,9 @@
import { SetMetadata } from '@nestjs/common';
export const AUTHENTICATED_KEY = 'authenticatedOnly';
/**
* 标记为“只需要登录”的接口:仍由全局 JwtAuthGuard 校验 JWT
* 但 PermissionGuard 不要求具体业务权限。
*/
export const Authenticated = () => SetMetadata(AUTHENTICATED_KEY, true);

View File

@@ -0,0 +1,50 @@
import { PermissionGuard } from './permission.guard';
describe('PermissionGuard', () => {
const createContext = (user: unknown) =>
({
getHandler: () => function handler() {},
getClass: () => class Controller {},
switchToHttp: () => ({ getRequest: () => ({ user }) }),
}) as never;
it('denies routes that forgot to declare permissions', () => {
const reflector = {
getAllAndOverride: jest.fn().mockReturnValue(false),
getAllAndMerge: jest.fn().mockReturnValue(undefined),
};
const guard = new PermissionGuard(reflector as never);
expect(guard.canActivate(createContext({ permissions: ['dashboard:view'] }))).toBe(false);
});
it('allows explicitly public routes without a user', () => {
const reflector = {
getAllAndOverride: jest.fn().mockReturnValue(true),
getAllAndMerge: jest.fn(),
};
const guard = new PermissionGuard(reflector as never);
expect(guard.canActivate(createContext(undefined))).toBe(true);
});
it('allows authenticated-only routes for logged-in users without requiring profile:view', () => {
const reflector = {
getAllAndOverride: jest.fn().mockReturnValueOnce(false).mockReturnValueOnce(true),
getAllAndMerge: jest.fn(),
};
const guard = new PermissionGuard(reflector as never);
expect(guard.canActivate(createContext({ permissions: [] }))).toBe(true);
});
it('denies authenticated-only routes when no authenticated user is present', () => {
const reflector = {
getAllAndOverride: jest.fn().mockReturnValueOnce(false).mockReturnValueOnce(true),
getAllAndMerge: jest.fn(),
};
const guard = new PermissionGuard(reflector as never);
expect(guard.canActivate(createContext(undefined))).toBe(false);
});
});

View File

@@ -2,18 +2,19 @@ import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core'; import { Reflector } from '@nestjs/core';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator'; import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
import { PERMISSION_KEY } from '../decorators/permission.decorator'; import { PERMISSION_KEY } from '../decorators/permission.decorator';
import { AUTHENTICATED_KEY } from '../decorators/authenticated.decorator';
/** /**
* 权限守卫 — 默认放行策略(⚠️ 安全关键) * 权限守卫 — 默认拒绝策略(安全关键)
* *
* 当 handler/controller 上不存在 @RequirePermission 时,守卫放行(仅需登录即可访问 * 当 handler/controller 上不存在 @RequirePermission、@Authenticated 且未标记 @Public 时,守卫拒绝访问。
* 这是有意的设计选择:所有敏感路由必须显式标注 @RequirePermission * 所有路由必须显式声明公开、仅登录或所需权限
* *
* ⚠️ 新增路由时务必添加 @RequirePermission,否则该路由对所有已认证用户开放! * ⚠️ 新增路由时务必添加 @RequirePermission、@Authenticated 或 @Public。
* 建议配合 lint 规则确保无遗漏。 * 建议配合 lint 规则确保无遗漏。
*/ */
@Injectable() @Injectable()
export class PermissionGuard implements CanActivate { export class PermissionGuard implements CanActivate {
constructor(private reflector: Reflector) {} constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean { canActivate(context: ExecutionContext): boolean {
@@ -24,20 +25,28 @@ import { PERMISSION_KEY } from '../decorators/permission.decorator';
]); ]);
if (isPublic) return true; if (isPublic) return true;
// 2. 获取所需权限getAllAndMerge 合并 handler+class 层的所有 metadata const request = context.switchToHttp().getRequest();
const user = request.user;
// 2. @Authenticated() 只要求已登录,具体 JWT 有效性由 JwtAuthGuard 负责。
const authenticatedOnly = this.reflector.getAllAndOverride<boolean>(AUTHENTICATED_KEY, [
context.getHandler(),
context.getClass(),
]);
if (authenticatedOnly) return !!user;
// 3. 获取所需权限getAllAndMerge 合并 handler+class 层的所有 metadata
const requiredPermissions = this.reflector.getAllAndMerge<string[]>(PERMISSION_KEY, [ const requiredPermissions = this.reflector.getAllAndMerge<string[]>(PERMISSION_KEY, [
context.getHandler(), context.getHandler(),
context.getClass(), context.getClass(),
]); ]);
// 无装饰器 = 仅需登录即可,放行 // 无权限声明且非 @Public/@Authenticated默认拒绝避免新增接口意外裸奔
if (!requiredPermissions || requiredPermissions.length === 0) return true; if (!requiredPermissions || requiredPermissions.length === 0) return false;
// 3. 从 JWT payload 获取用户权限 // 4. 从 JWT payload 获取用户权限
const request = context.switchToHttp().getRequest();
const user = request.user;
if (!user || !user.permissions || !Array.isArray(user.permissions)) return false; if (!user || !user.permissions || !Array.isArray(user.permissions)) return false;
// 4. OR 匹配:用户拥有 requiredPermissions 中任一权限即可通过 // 5. OR 匹配:用户拥有 requiredPermissions 中任一权限即可通过
return requiredPermissions.some((p) => user.permissions.includes(p)); return requiredPermissions.some((p) => user.permissions.includes(p));
} }
} }

View File

@@ -0,0 +1,48 @@
import { UnauthorizedException } from '@nestjs/common';
import { JwtStrategy } from './jwt.strategy';
describe('JwtStrategy', () => {
const config = { get: jest.fn().mockReturnValue('secret') };
it('refreshes permissions from the database instead of trusting stale JWT permissions', async () => {
const userRepo = {
findOne: jest.fn().mockResolvedValue({
id: 7,
username: 'teacher',
isActive: true,
isArchived: false,
roles: [
{
name: '老师',
status: 1,
permissions: [{ code: 'class:view' }, { code: 'attendance:view' }],
},
],
}),
};
const strategy = new JwtStrategy(config as never, userRepo as never);
await expect(
strategy.validate({ sub: 7, username: 'teacher', permissions: ['user:delete'] }),
).resolves.toEqual({
id: 7,
username: 'teacher',
permissions: ['class:view', 'attendance:view'],
isSuperAdmin: false,
roles: ['老师'],
});
});
it.each([
[{ id: 7, isActive: false, isArchived: false, roles: [] }],
[{ id: 7, isActive: true, isArchived: true, roles: [] }],
[null],
])('rejects disabled, archived, or deleted users', async (user) => {
const userRepo = { findOne: jest.fn().mockResolvedValue(user) };
const strategy = new JwtStrategy(config as never, userRepo as never);
await expect(strategy.validate({ sub: 7, username: 'teacher' })).rejects.toBeInstanceOf(
UnauthorizedException,
);
});
});

View File

@@ -1,12 +1,18 @@
import { Injectable } from '@nestjs/common'; import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport'; import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt'; import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { Request } from 'express'; import { Request } from 'express';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from '../../entities/user.entity';
@Injectable() @Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) { export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(config: ConfigService) { constructor(
config: ConfigService,
@InjectRepository(User) private readonly userRepo: Repository<User>,
) {
super({ super({
jwtFromRequest: ExtractJwt.fromExtractors([ jwtFromRequest: ExtractJwt.fromExtractors([
// 1. Standard Bearer header (existing behavior) // 1. Standard Bearer header (existing behavior)
@@ -25,12 +31,32 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
}); });
} }
async validate(payload: any) { async validate(payload: { sub?: number; username?: string }) {
if (!payload.sub) throw new UnauthorizedException('登录状态无效');
const user = await this.userRepo.findOne({
where: { id: payload.sub },
relations: ['roles', 'roles.permissions'],
});
if (!user || !user.isActive || user.isArchived) {
throw new UnauthorizedException('账号已失效,请重新登录');
}
const permissions = new Set<string>();
const roles: string[] = [];
let isSuperAdmin = false;
for (const role of user.roles ?? []) {
if (role.status !== 1) continue;
roles.push(role.name);
if (role.name === '超管' || role.name === 'super_admin') isSuperAdmin = true;
for (const permission of role.permissions ?? []) permissions.add(permission.code);
}
return { return {
id: payload.sub, id: user.id,
username: payload.username, username: user.username,
permissions: payload.permissions || [], permissions: [...permissions],
isSuperAdmin: payload.isSuperAdmin || false, isSuperAdmin,
roles,
}; };
} }
} }

View File

@@ -31,6 +31,16 @@ import { NotificationsService } from '../notifications/notifications.service';
import { NotificationType } from '../entities/notification.entity'; import { NotificationType } from '../entities/notification.entity';
import * as ExcelJS from 'exceljs'; import * as ExcelJS from 'exceljs';
interface RequestUser {
id: number;
permissions?: string[];
isSuperAdmin?: boolean;
}
interface AuthenticatedRequest {
user: RequestUser;
}
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Controller('classes') @Controller('classes')
export class ClassesController { export class ClassesController {
@@ -40,30 +50,48 @@ export class ClassesController {
private readonly notificationsService: NotificationsService, private readonly notificationsService: NotificationsService,
) {} ) {}
private assertReadAccess(req: AuthenticatedRequest, classId: number) {
const canManageAll =
req.user.isSuperAdmin === true || req.user.permissions?.includes('class:edit') === true;
return this.service.assertClassAccess(req.user.id, classId, canManageAll);
}
@Get() @Get()
@RequirePermission('class:view') @RequirePermission('class:view')
findAll(@Query() query: QueryClassDto) { async findAll(@Query() query: QueryClassDto, @Request() req: AuthenticatedRequest) {
return this.service.findAll(query); const classIds = await this.service.getAccessibleClassIds(
req.user.id,
req.user.isSuperAdmin === true || req.user.permissions?.includes('class:edit') === true,
);
return this.service.findAll(query, classIds);
} }
@Get(':id') @Get(':id')
@RequirePermission('class:view') @RequirePermission('class:view')
findOne(@Param('id') id: string) { async findOne(@Param('id') id: string, @Request() req: AuthenticatedRequest) {
await this.assertReadAccess(req, +id);
return this.service.findOne(+id); return this.service.findOne(+id);
} }
@Get(':id/schedule') @Get(':id/schedule')
@RequirePermission('class:view') @RequirePermission('class:view')
getSchedule(@Param('id') id: string, @Query() query: QueryClassScheduleDto) { async getSchedule(
@Param('id') id: string,
@Query() query: QueryClassScheduleDto,
@Request() req: AuthenticatedRequest,
) {
await this.assertReadAccess(req, +id);
return this.service.getSchedule(+id, query); return this.service.getSchedule(+id, query);
} }
@Get(':id/attendance-summary') @Get(':id/attendance-summary')
@RequirePermission('class:view') @RequirePermission('class:view')
getAttendanceSummary( async getAttendanceSummary(
@Param('id') id: string, @Param('id') id: string,
@Query() query: QueryClassAttendanceSummaryDto, @Query() query: QueryClassAttendanceSummaryDto,
@Request() req: AuthenticatedRequest,
) { ) {
await this.assertReadAccess(req, +id);
return this.service.getAttendanceSummary(+id, query); return this.service.getAttendanceSummary(+id, query);
} }
@@ -86,14 +114,10 @@ export class ClassesController {
return result; return result;
} }
/** 批量导入学生到班级通过钉钉用户ID */ /** 批量导入学生到班级通过钉钉用户ID */
@Post(':id/students/import') @Post(':id/students/import')
@RequirePermission('class:edit') @RequirePermission('class:edit')
async batchImportStudents( async batchImportStudents(@Param('id') id: string, @Body() dto: BatchImportStudentsDto) {
@Param('id') id: string,
@Body() dto: BatchImportStudentsDto,
) {
return this.service.batchImportStudents(+id, dto.users); return this.service.batchImportStudents(+id, dto.users);
} }
@@ -113,11 +137,7 @@ export class ClassesController {
@Put(':id') @Put(':id')
@RequirePermission('class:edit') @RequirePermission('class:edit')
async update( async update(@Param('id') id: string, @Body() dto: UpdateClassDto, @Request() req: any) {
@Param('id') id: string,
@Body() dto: UpdateClassDto,
@Request() req: any,
) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.update(+id, dto); const result = await this.service.update(+id, dto);
await this.logService.log({ await this.logService.log({
@@ -154,7 +174,12 @@ export class ClassesController {
@Get(':id/roster/export') @Get(':id/roster/export')
@RequirePermission('class:view') @RequirePermission('class:view')
async exportRoster(@Param('id') id: string, @Res() res: Response) { async exportRoster(
@Param('id') id: string,
@Res() res: Response,
@Request() req: AuthenticatedRequest,
) {
await this.assertReadAccess(req, +id);
const classEntity = await this.service.findOne(+id); const classEntity = await this.service.findOne(+id);
const classStudents = await this.service.getStudents(+id); const classStudents = await this.service.getStudents(+id);
@@ -192,17 +217,14 @@ export class ClassesController {
@Get(':id/students') @Get(':id/students')
@RequirePermission('class:view') @RequirePermission('class:view')
getStudents(@Param('id') id: string) { async getStudents(@Param('id') id: string, @Request() req: AuthenticatedRequest) {
await this.assertReadAccess(req, +id);
return this.service.getStudents(+id); return this.service.getStudents(+id);
} }
@Post(':id/students') @Post(':id/students')
@RequirePermission('class:edit') @RequirePermission('class:edit')
async addStudents( async addStudents(@Param('id') id: string, @Body() dto: AddStudentsDto, @Request() req: any) {
@Param('id') id: string,
@Body() dto: AddStudentsDto,
@Request() req: any,
) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.addStudents(+id, dto.studentIds); const result = await this.service.addStudents(+id, dto.studentIds);
await this.logService.log({ await this.logService.log({
@@ -255,17 +277,14 @@ export class ClassesController {
@Get(':id/teachers') @Get(':id/teachers')
@RequirePermission('class:view') @RequirePermission('class:view')
getTeachers(@Param('id') id: string) { async getTeachers(@Param('id') id: string, @Request() req: AuthenticatedRequest) {
await this.assertReadAccess(req, +id);
return this.service.getTeachers(+id); return this.service.getTeachers(+id);
} }
@Post(':id/teachers') @Post(':id/teachers')
@RequirePermission('class:edit') @RequirePermission('class:edit')
async addTeacher( async addTeacher(@Param('id') id: string, @Body() dto: AddTeacherDto, @Request() req: any) {
@Param('id') id: string,
@Body() dto: AddTeacherDto,
@Request() req: any,
) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.addTeacher(+id, dto); const result = await this.service.addTeacher(+id, dto);
await this.logService.log({ await this.logService.log({
@@ -290,6 +309,29 @@ export class ClassesController {
return result; return result;
} }
@Delete(':id/teacher-assignments/:assignmentId')
@RequirePermission('class:edit')
async removeTeacherAssignment(
@Param('id') id: string,
@Param('assignmentId') assignmentId: string,
@Request() req: any,
) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.removeTeacherAssignment(+id, +assignmentId);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '班级管理',
action: '移除教师角色',
targetId: +id,
targetType: 'class',
detail: `移除教师分配${assignmentId}`,
ipAddress,
userAgent,
});
return result;
}
@Delete(':id/teachers/:userId') @Delete(':id/teachers/:userId')
@RequirePermission('class:edit') @RequirePermission('class:edit')
async removeTeacher( async removeTeacher(

View File

@@ -0,0 +1,64 @@
import { ForbiddenException } from '@nestjs/common';
import { ClassesService } from './classes.service';
describe('ClassesService — teacher data scope', () => {
const classRepo = { find: jest.fn() };
const classStudentRepo = { createQueryBuilder: jest.fn() };
const classTeacherRepo = { find: jest.fn(), findOne: jest.fn() };
const service = new ClassesService(
classRepo as never,
classStudentRepo as never,
classTeacherRepo as never,
{} as never,
{} as never,
{} as never,
{} as never,
);
beforeEach(() => jest.clearAllMocks());
it('returns only class ids assigned to a teacher', async () => {
classTeacherRepo.find.mockResolvedValue([{ classId: 3 }, { classId: 5 }, { classId: 3 }]);
await expect(service.getAccessibleClassIds(21, false)).resolves.toEqual([3, 5]);
});
it('rejects access to a class outside the teacher assignments', async () => {
classTeacherRepo.findOne.mockResolvedValue(null);
await expect(service.assertClassAccess(21, 9, false)).rejects.toBeInstanceOf(
ForbiddenException,
);
});
it('allows class managers to access any class', async () => {
await expect(service.assertClassAccess(21, 9, true)).resolves.toBeUndefined();
expect(classTeacherRepo.findOne).not.toHaveBeenCalled();
});
});
it('clears denormalized teacher ids when the last teacher for that role is removed', async () => {
const classRepo = { update: jest.fn() };
const classTeacherRepo = {
find: jest.fn().mockResolvedValue([]),
delete: jest.fn().mockResolvedValue({ affected: 1 }),
};
const service = new ClassesService(
classRepo as never,
{} as never,
classTeacherRepo as never,
{} as never,
{} as never,
{} as never,
{} as never,
);
await service.removeTeacher(8, 21);
expect(classRepo.update).toHaveBeenCalledWith(8, {
headTeacherId: null,
lifeTeacherId: null,
academicTeacherId: null,
});
});

View File

@@ -1,8 +1,31 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import {
Injectable,
NotFoundException,
BadRequestException,
ForbiddenException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In, Like } from 'typeorm'; import { Repository, In, Like } from 'typeorm';
import { Class, ClassStudent, ClassTeacher, ClassSchedule, AttendanceRecord, Classroom, Student, StudentDingMapping } from '../entities'; import {
import { CreateClassDto, UpdateClassDto, QueryClassDto, AddTeacherDto, QueryClassScheduleDto, QueryClassAttendanceSummaryDto, BatchImportStudentsDto } from './dto/class.dto'; Class,
ClassStudent,
ClassTeacher,
ClassSchedule,
AttendanceRecord,
Classroom,
Student,
StudentDingMapping,
} from '../entities';
import { normalizeDateOnly } from '../database/date-normalization';
import {
CreateClassDto,
UpdateClassDto,
QueryClassDto,
AddTeacherDto,
QueryClassScheduleDto,
QueryClassAttendanceSummaryDto,
BatchImportStudentsDto,
} from './dto/class.dto';
interface RawStudentCount { interface RawStudentCount {
classId: string; classId: string;
@@ -28,7 +51,19 @@ export class ClassesService {
private studentDingMappingRepo: Repository<StudentDingMapping>, private studentDingMappingRepo: Repository<StudentDingMapping>,
) {} ) {}
async findAll(query: QueryClassDto) { async getAccessibleClassIds(userId: number, canManageAll = false): Promise<number[] | undefined> {
if (canManageAll) return undefined;
const assignments = await this.classTeacherRepo.find({ where: { userId } });
return [...new Set(assignments.map((assignment) => assignment.classId))];
}
async assertClassAccess(userId: number, classId: number, canManageAll = false): Promise<void> {
if (canManageAll) return;
const assignment = await this.classTeacherRepo.findOne({ where: { userId, classId } });
if (!assignment) throw new ForbiddenException('只能访问自己被分配的班级');
}
async findAll(query: QueryClassDto, accessibleClassIds?: number[]) {
let where: Record<string, unknown> = {}; let where: Record<string, unknown> = {};
if (query.status) where.status = query.status; if (query.status) where.status = query.status;
if (query.classType) where.classType = query.classType; if (query.classType) where.classType = query.classType;
@@ -36,6 +71,11 @@ export class ClassesService {
// Default: hide archived, unless explicitly requested // Default: hide archived, unless explicitly requested
where.isArchived = query.isArchived ?? false; where.isArchived = query.isArchived ?? false;
if (accessibleClassIds) {
if (accessibleClassIds.length === 0) return [];
where.id = In(accessibleClassIds);
}
const classes = await this.classRepo.find({ const classes = await this.classRepo.find({
where, where,
order: { createdAt: 'DESC' as const }, order: { createdAt: 'DESC' as const },
@@ -96,14 +136,21 @@ export class ClassesService {
async create(dto: CreateClassDto) { async create(dto: CreateClassDto) {
const { studentIds, teachers, users, ...classData } = dto; const { studentIds, teachers, users, ...classData } = dto;
const cls = this.classRepo.create({
const cls = this.classRepo.create(classData); ...classData,
startDate: normalizeDateOnly(classData.startDate) ?? undefined,
endDate: normalizeDateOnly(classData.endDate) ?? undefined,
});
const saved = await this.classRepo.save(cls); const saved = await this.classRepo.save(cls);
// add students // add students
if (studentIds?.length) { if (studentIds?.length) {
const entries = studentIds.map((sid: number) => const entries = studentIds.map((sid: number) =>
this.classStudentRepo.create({ classId: saved.id, studentId: sid, joinDate: new Date().toISOString().split('T')[0] }), this.classStudentRepo.create({
classId: saved.id,
studentId: sid,
joinDate: new Date().toISOString().split('T')[0],
}),
); );
await this.classStudentRepo.save(entries); await this.classStudentRepo.save(entries);
} }
@@ -111,7 +158,12 @@ export class ClassesService {
// add teachers // add teachers
if (teachers?.length) { if (teachers?.length) {
const entries = teachers.map((t) => const entries = teachers.map((t) =>
this.classTeacherRepo.create({ classId: saved.id, userId: t.userId, roleType: t.roleType, subject: t.subject }), this.classTeacherRepo.create({
classId: saved.id,
userId: t.userId,
roleType: t.roleType,
subject: t.subject,
}),
); );
await this.classTeacherRepo.save(entries); await this.classTeacherRepo.save(entries);
@@ -127,37 +179,41 @@ export class ClassesService {
return this.findOne(saved.id); return this.findOne(saved.id);
} }
async batchImportStudents(
async batchImportStudents(classId: number, users: Array<{ classId: number,
dingUserId: string; name: string; mobile?: string; users: Array<{
}>): Promise<{ imported: number; skipped: number }> { dingUserId: string;
name: string;
mobile?: string;
}>,
): Promise<{ imported: number; skipped: number }> {
const classEntity = await this.classRepo.findOne({ where: { id: classId } }); const classEntity = await this.classRepo.findOne({ where: { id: classId } });
if (!classEntity) throw new NotFoundException('班级不存在'); if (!classEntity) throw new NotFoundException('班级不存在');
if (users.length === 0) return { imported: 0, skipped: 0 }; if (users.length === 0) return { imported: 0, skipped: 0 };
const dingUserIds = users.map(u => u.dingUserId); const dingUserIds = users.map((u) => u.dingUserId);
// 1. Fetch all existing ding mappings in one query // 1. Fetch all existing ding mappings in one query
const existingMappings = await this.studentDingMappingRepo.find({ const existingMappings = await this.studentDingMappingRepo.find({
where: { dingUserId: In(dingUserIds) }, where: { dingUserId: In(dingUserIds) },
}); });
const dingToStudentId = new Map(existingMappings.map(m => [m.dingUserId, m.studentId])); const dingToStudentId = new Map(existingMappings.map((m) => [m.dingUserId, m.studentId]));
// 2. Batch create students for new dingUserIds // 2. Batch create students for new dingUserIds
const newUsers = users.filter(u => !dingToStudentId.has(u.dingUserId)); const newUsers = users.filter((u) => !dingToStudentId.has(u.dingUserId));
if (newUsers.length > 0) { if (newUsers.length > 0) {
const newStudents = newUsers.map(u => const newStudents = newUsers.map((u) =>
this.studentRepo.create({ this.studentRepo.create({
name: u.name, name: u.name,
phone: u.mobile || `dt_${u.dingUserId}`, phone: u.mobile || `dt_${u.dingUserId}`,
status: 'active', status: 'active',
}) }),
); );
const savedStudents = await this.studentRepo.save(newStudents); const savedStudents = await this.studentRepo.save(newStudents);
const newMappings = savedStudents.map((s, i) => const newMappings = savedStudents.map((s, i) =>
this.studentDingMappingRepo.create({ dingUserId: newUsers[i].dingUserId, studentId: s.id }) this.studentDingMappingRepo.create({ dingUserId: newUsers[i].dingUserId, studentId: s.id }),
); );
await this.studentDingMappingRepo.save(newMappings); await this.studentDingMappingRepo.save(newMappings);
@@ -180,12 +236,14 @@ export class ClassesService {
// 4. Batch insert new class-student records // 4. Batch insert new class-student records
const newClassStudents = allStudentIds const newClassStudents = allStudentIds
.filter(sid => !alreadyInClass.has(sid)) .filter((sid) => !alreadyInClass.has(sid))
.map(studentId => .map((studentId) =>
this.classStudentRepo.create({ this.classStudentRepo.create({
classId, studentId, status: 'active', classId,
studentId,
status: 'active',
joinDate: new Date().toISOString().slice(0, 10), joinDate: new Date().toISOString().slice(0, 10),
}) }),
); );
if (newClassStudents.length > 0) { if (newClassStudents.length > 0) {
@@ -197,7 +255,15 @@ export class ClassesService {
async update(id: number, dto: UpdateClassDto) { async update(id: number, dto: UpdateClassDto) {
const cls = await this.classRepo.findOne({ where: { id } }); const cls = await this.classRepo.findOne({ where: { id } });
if (!cls) throw new NotFoundException('班级不存在'); if (!cls) throw new NotFoundException('班级不存在');
await this.classRepo.update(id, dto); await this.classRepo.update(id, {
...dto,
...(dto.startDate !== undefined
? { startDate: normalizeDateOnly(dto.startDate) ?? undefined }
: {}),
...(dto.endDate !== undefined
? { endDate: normalizeDateOnly(dto.endDate) ?? undefined }
: {}),
});
return this.findOne(id); return this.findOne(id);
} }
@@ -226,7 +292,6 @@ export class ClassesService {
return { success: true }; return { success: true };
} }
async getStudents(classId: number) { async getStudents(classId: number) {
return this.classStudentRepo.find({ return this.classStudentRepo.find({
where: { classId }, where: { classId },
@@ -243,7 +308,11 @@ export class ClassesService {
const newIds = studentIds.filter((id) => !existingIds.has(id)); const newIds = studentIds.filter((id) => !existingIds.has(id));
const entries = newIds.map((sid) => const entries = newIds.map((sid) =>
this.classStudentRepo.create({ classId, studentId: sid, joinDate: new Date().toISOString().split('T')[0] }), this.classStudentRepo.create({
classId,
studentId: sid,
joinDate: new Date().toISOString().split('T')[0],
}),
); );
if (entries.length) await this.classStudentRepo.save(entries); if (entries.length) await this.classStudentRepo.save(entries);
@@ -268,7 +337,12 @@ export class ClassesService {
}); });
if (existing) throw new BadRequestException('该教师已分配此角色'); if (existing) throw new BadRequestException('该教师已分配此角色');
const entry = this.classTeacherRepo.create({ classId, userId: dto.userId, roleType: dto.roleType, subject: dto.subject }); const entry = this.classTeacherRepo.create({
classId,
userId: dto.userId,
roleType: dto.roleType,
subject: dto.subject,
});
await this.classTeacherRepo.save(entry); await this.classTeacherRepo.save(entry);
await this.syncClassTeacherIds(classId); await this.syncClassTeacherIds(classId);
@@ -281,18 +355,22 @@ export class ClassesService {
return { success: true }; return { success: true };
} }
async removeTeacherAssignment(classId: number, assignmentId: number) {
await this.classTeacherRepo.delete({ id: assignmentId, classId });
await this.syncClassTeacherIds(classId);
return { success: true };
}
private async syncClassTeacherIds(classId: number) { private async syncClassTeacherIds(classId: number) {
const teachers = await this.classTeacherRepo.find({ where: { classId } }); const teachers = await this.classTeacherRepo.find({ where: { classId } });
const updates: Record<string, number> = {};
const head = teachers.find((t) => t.roleType === 'head_teacher'); const head = teachers.find((t) => t.roleType === 'head_teacher');
const life = teachers.find((t) => t.roleType === 'life_teacher'); const life = teachers.find((t) => t.roleType === 'life_teacher');
const academic = teachers.find((t) => t.roleType === 'academic_teacher'); const academic = teachers.find((t) => t.roleType === 'academic_teacher');
if (head) updates.headTeacherId = head.userId; await this.classRepo.update(classId, {
if (life) updates.lifeTeacherId = life.userId; headTeacherId: head?.userId ?? null,
if (academic) updates.academicTeacherId = academic.userId; lifeTeacherId: life?.userId ?? null,
if (Object.keys(updates).length > 0) { academicTeacherId: academic?.userId ?? null,
await this.classRepo.update(classId, updates); } as Partial<Class>);
}
} }
async getSchedule(classId: number, query: QueryClassScheduleDto) { async getSchedule(classId: number, query: QueryClassScheduleDto) {

View File

@@ -58,6 +58,33 @@ export class ClassroomRentalsController {
return this.service.getSchedule(y, m); return this.service.getSchedule(y, m);
} }
@Get('unavailable-dates')
@RequirePermission('rental:view')
getUnavailableDates(
@Query('classroomId') classroomId?: string,
@Query('year') year?: string,
@Query('month') month?: string,
@Query('excludeId') excludeId?: string,
) {
const parsedClassroomId = Number(classroomId);
const parsedYear = Number(year);
const parsedMonth = Number(month);
if (!Number.isInteger(parsedClassroomId) || parsedClassroomId <= 0) {
throw new BadRequestException('请选择有效教室');
}
if (!Number.isInteger(parsedYear) || parsedYear < 2000 || parsedYear > 2100) {
throw new BadRequestException('年份不合法');
}
if (!Number.isInteger(parsedMonth) || parsedMonth < 1 || parsedMonth > 12) {
throw new BadRequestException('月份必须在 1-12 之间');
}
const parsedExcludeId = excludeId === undefined ? undefined : Number(excludeId);
if (parsedExcludeId !== undefined && (!Number.isInteger(parsedExcludeId) || parsedExcludeId <= 0)) {
throw new BadRequestException('排除的租赁订单不合法');
}
return this.service.getUnavailableDates(parsedClassroomId, parsedYear, parsedMonth, parsedExcludeId);
}
@Get(':id') @Get(':id')
@RequirePermission('rental:view') @RequirePermission('rental:view')
findOne(@Param('id') id: string) { findOne(@Param('id') id: string) {

View File

@@ -1,7 +1,7 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm'; import { getRepositoryToken } from '@nestjs/typeorm';
import { ConflictException } from '@nestjs/common'; import { ConflictException } from '@nestjs/common';
import { Repository } from 'typeorm'; import { Not, Repository } from 'typeorm';
import { ClassroomRentalsService } from './classroom-rentals.service'; import { ClassroomRentalsService } from './classroom-rentals.service';
import { ClassroomRental } from '../entities/classroom-rental.entity'; import { ClassroomRental } from '../entities/classroom-rental.entity';
import { Classroom } from '../entities/classroom.entity'; import { Classroom } from '../entities/classroom.entity';
@@ -56,7 +56,7 @@ describe('ClassroomRentalsService — findConflicts', () => {
it('throws ConflictException when an active schedule overlaps the same classroom and date range', async () => { it('throws ConflictException when an active schedule overlaps the same classroom and date range', async () => {
const rentalQb = mockQueryBuilder<ClassroomRental>([]); const rentalQb = mockQueryBuilder<ClassroomRental>([]);
const scheduleQb = mockQueryBuilder<ClassSchedule>([ const scheduleQb = mockQueryBuilder<ClassSchedule>([
{ id: 5, subject: '数学', startDate: '2026-03-01', endDate: '2026-06-30' } as ClassSchedule, { id: 5, subject: '数学', weekDay: 1, startDate: '2026-03-01', endDate: '2026-06-30' } as ClassSchedule,
]); ]);
(rentalRepo.createQueryBuilder as jest.Mock).mockReturnValue(rentalQb); (rentalRepo.createQueryBuilder as jest.Mock).mockReturnValue(rentalQb);
(scheduleRepo.createQueryBuilder as jest.Mock).mockReturnValue(scheduleQb); (scheduleRepo.createQueryBuilder as jest.Mock).mockReturnValue(scheduleQb);
@@ -64,6 +64,19 @@ describe('ClassroomRentalsService — findConflicts', () => {
await expect(service.findConflicts(1, '2026-03-15', '2026-04-15')).rejects.toThrow(ConflictException); await expect(service.findConflicts(1, '2026-03-15', '2026-04-15')).rejects.toThrow(ConflictException);
}); });
it('does not treat a weekly schedule as a conflict when its weekday does not occur in the rental range', async () => {
const rentalQb = mockQueryBuilder<ClassroomRental>([]);
const scheduleQb = mockQueryBuilder<ClassSchedule>([
{ id: 5, subject: '数学', weekDay: 1, startDate: '2026-07-01', endDate: '2026-07-31' } as ClassSchedule,
]);
(rentalRepo.createQueryBuilder as jest.Mock).mockReturnValue(rentalQb);
(scheduleRepo.createQueryBuilder as jest.Mock).mockReturnValue(scheduleQb);
const result = await service.findConflicts(1, '2026-07-01', '2026-07-05');
expect(result).toHaveLength(0);
});
it('does not throw when schedule is outside the requested date range', async () => { it('does not throw when schedule is outside the requested date range', async () => {
const rentalQb = mockQueryBuilder<ClassroomRental>([]); const rentalQb = mockQueryBuilder<ClassroomRental>([]);
const scheduleQb = mockQueryBuilder<ClassSchedule>([]); const scheduleQb = mockQueryBuilder<ClassSchedule>([]);
@@ -87,6 +100,54 @@ describe('ClassroomRentalsService — findConflicts', () => {
}); });
}); });
describe('ClassroomRentalsService — unavailable dates', () => {
let service: ClassroomRentalsService;
let rentalRepo: jest.Mocked<Pick<Repository<ClassroomRental>, 'find'>>;
let scheduleRepo: jest.Mocked<Pick<Repository<ClassSchedule>, 'find'>>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ClassroomRentalsService,
{ provide: getRepositoryToken(ClassroomRental), useValue: { find: jest.fn() } },
{ provide: getRepositoryToken(Classroom), useValue: {} },
{ provide: getRepositoryToken(Tenant), useValue: {} },
{ provide: getRepositoryToken(ClassSchedule), useValue: { find: jest.fn() } },
],
}).compile();
service = module.get<ClassroomRentalsService>(ClassroomRentalsService);
rentalRepo = module.get(getRepositoryToken(ClassroomRental));
scheduleRepo = module.get(getRepositoryToken(ClassSchedule));
});
it('returns rental days and actual weekly schedule occurrence dates for a month', async () => {
rentalRepo.find.mockResolvedValue([
{ id: 10, startDate: '2026-07-03', endDate: '2026-07-04' } as ClassroomRental,
]);
scheduleRepo.find.mockResolvedValue([
{ id: 5, weekDay: 1, startDate: '2026-07-01', endDate: '2026-07-31' } as ClassSchedule,
]);
const result = await service.getUnavailableDates(1, 2026, 7);
expect(result).toEqual({
dates: ['2026-07-03', '2026-07-04', '2026-07-06', '2026-07-13', '2026-07-20', '2026-07-27'],
});
});
it('excludes the rental being edited', async () => {
rentalRepo.find.mockResolvedValue([]);
scheduleRepo.find.mockResolvedValue([]);
await service.getUnavailableDates(1, 2026, 7, 99);
expect(rentalRepo.find).toHaveBeenCalledWith(
expect.objectContaining({ where: expect.objectContaining({ id: Not(99) }) }),
);
});
});
describe('ClassroomRentalsService — rental schedule sync', () => { describe('ClassroomRentalsService — rental schedule sync', () => {
let service: ClassroomRentalsService; let service: ClassroomRentalsService;
let rentalRepo: jest.Mocked< let rentalRepo: jest.Mocked<

View File

@@ -5,7 +5,7 @@ import {
ConflictException, ConflictException,
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Not } from 'typeorm'; import { Repository, Not, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
import { ClassroomRental } from '../entities/classroom-rental.entity'; import { ClassroomRental } from '../entities/classroom-rental.entity';
import { Classroom } from '../entities/classroom.entity'; import { Classroom } from '../entities/classroom.entity';
import { Tenant } from '../entities/tenant.entity'; import { Tenant } from '../entities/tenant.entity';
@@ -80,6 +80,47 @@ export class ClassroomRentalsService {
return rental; return rental;
} }
async getUnavailableDates(classroomId: number, year: number, month: number, excludeId?: number) {
const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
const monthStart = `${year}-${String(month).padStart(2, '0')}-01`;
const monthEnd = `${year}-${String(month).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
const [rentals, schedules] = await Promise.all([
this.repo.find({
where: {
...(excludeId ? { id: Not(excludeId) } : {}),
classroomId,
status: Not('cancelled'),
startDate: LessThanOrEqual(monthEnd),
endDate: MoreThanOrEqual(monthStart),
},
}),
this.scheduleRepo.find({
where: {
classroomId,
status: 'active',
scheduleType: 'INTERNAL',
startDate: LessThanOrEqual(monthEnd),
endDate: MoreThanOrEqual(monthStart),
},
}),
]);
const unavailableDates = new Set<string>();
for (const rental of rentals) {
this.addDateRange(
unavailableDates,
rental.startDate > monthStart ? rental.startDate : monthStart,
rental.endDate < monthEnd ? rental.endDate : monthEnd,
);
}
for (const schedule of schedules) {
this.addScheduleOccurrences(unavailableDates, schedule, monthStart, monthEnd);
}
return { dates: Array.from(unavailableDates).sort() };
}
/** /**
* 查找与给定区间冲突的租赁订单,同时检测同一教室同一日期段的内部排课 * 查找与给定区间冲突的租赁订单,同时检测同一教室同一日期段的内部排课
* 重叠判定start1 <= end2 AND start2 <= end1 * 重叠判定start1 <= end2 AND start2 <= end1
@@ -96,13 +137,17 @@ export class ClassroomRentalsService {
const rentals = await qb.getMany(); const rentals = await qb.getMany();
// 检测同一教室同一日期段是否存在内部排课 // 检测同一教室同一日期段是否存在内部排课
const scheduleConflicts = await this.scheduleRepo const scheduleCandidates = await this.scheduleRepo
.createQueryBuilder('cs') .createQueryBuilder('cs')
.where('cs.classroomId = :cid', { cid: classroomId }) .where('cs.classroomId = :cid', { cid: classroomId })
.andWhere('cs.status = :status', { status: 'active' }) .andWhere('cs.status = :status', { status: 'active' })
.andWhere('cs.scheduleType = :scheduleType', { scheduleType: 'INTERNAL' })
.andWhere('cs.startDate <= :end', { end: endDate }) .andWhere('cs.startDate <= :end', { end: endDate })
.andWhere('cs.endDate >= :start', { start: startDate }) .andWhere('cs.endDate >= :start', { start: startDate })
.getMany(); .getMany();
const scheduleConflicts = scheduleCandidates.filter((schedule) =>
this.hasScheduleOccurrence(schedule, startDate, endDate),
);
if (scheduleConflicts.length > 0) { if (scheduleConflicts.length > 0) {
throw new ConflictException({ throw new ConflictException({
@@ -119,6 +164,48 @@ export class ClassroomRentalsService {
return rentals; return rentals;
} }
private hasScheduleOccurrence(schedule: ClassSchedule, startDate: string, endDate: string): boolean {
const overlapStart = schedule.startDate > startDate ? schedule.startDate : startDate;
const overlapEnd = schedule.endDate < endDate ? schedule.endDate : endDate;
if (overlapStart > overlapEnd) return false;
const startUtc = this.toUtcDate(overlapStart);
const endUtc = this.toUtcDate(overlapEnd);
const startWeekDay = startUtc.getUTCDay() || 7;
const daysUntilOccurrence = (schedule.weekDay - startWeekDay + 7) % 7;
startUtc.setUTCDate(startUtc.getUTCDate() + daysUntilOccurrence);
return startUtc <= endUtc;
}
private toUtcDate(date: string): Date {
const [year, month, day] = date.split('-').map(Number);
return new Date(Date.UTC(year, month - 1, day));
}
private addDateRange(dates: Set<string>, startDate: string, endDate: string) {
const current = this.toUtcDate(startDate);
const end = this.toUtcDate(endDate);
while (current <= end) {
dates.add(current.toISOString().slice(0, 10));
current.setUTCDate(current.getUTCDate() + 1);
}
}
private addScheduleOccurrences(dates: Set<string>, schedule: ClassSchedule, startDate: string, endDate: string) {
const overlapStart = schedule.startDate > startDate ? schedule.startDate : startDate;
const overlapEnd = schedule.endDate < endDate ? schedule.endDate : endDate;
if (overlapStart > overlapEnd) return;
const current = this.toUtcDate(overlapStart);
const end = this.toUtcDate(overlapEnd);
const startWeekDay = current.getUTCDay() || 7;
current.setUTCDate(current.getUTCDate() + ((schedule.weekDay - startWeekDay + 7) % 7));
while (current <= end) {
dates.add(current.toISOString().slice(0, 10));
current.setUTCDate(current.getUTCDate() + 7);
}
}
async create(dto: CreateRentalDto, userId?: number) { async create(dto: CreateRentalDto, userId?: number) {
if (dto.startDate > dto.endDate) throw new BadRequestException('起始日期不能晚于结束日期'); if (dto.startDate > dto.endDate) throw new BadRequestException('起始日期不能晚于结束日期');
const classroom = await this.classroomRepo.findOne({ where: { id: dto.classroomId } }); const classroom = await this.classroomRepo.findOne({ where: { id: dto.classroomId } });

View File

@@ -1,17 +1,36 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common'; import { Controller, Get, Query, Request, UseGuards } from '@nestjs/common';
import { DashboardService } from './dashboard.service'; import { DashboardService } from './dashboard.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequirePermission } from '../auth/decorators/permission.decorator'; import { RequirePermission } from '../auth/decorators/permission.decorator';
interface RequestUser {
id: number;
username: string;
permissions?: string[];
isSuperAdmin?: boolean;
}
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@RequirePermission('dashboard:view') @RequirePermission('dashboard:view')
@Controller('dashboard') @Controller('dashboard')
export class DashboardController { export class DashboardController {
constructor(private service: DashboardService) {} constructor(private service: DashboardService) {}
private canManageAllDashboard(user: RequestUser): boolean {
return (
user.isSuperAdmin === true ||
user.permissions?.includes('dashboard:manage') === true ||
user.permissions?.includes('class:edit') === true
);
}
private getAccessibleClassIds(user: RequestUser) {
return this.service.getAccessibleClassIds(user.id, this.canManageAllDashboard(user));
}
@Get('stats') @Get('stats')
getStats() { async getStats(@Request() req: { user: RequestUser }) {
return this.service.getStats(); return this.service.getStats(await this.getAccessibleClassIds(req.user));
} }
@Get('gantt') @Get('gantt')
@@ -40,8 +59,8 @@ export class DashboardController {
} }
@Get('class-attendance-ranking') @Get('class-attendance-ranking')
getClassAttendanceRanking() { async getClassAttendanceRanking(@Request() req: { user: RequestUser }) {
return this.service.getClassAttendanceRanking(); return this.service.getClassAttendanceRanking(await this.getAccessibleClassIds(req.user));
} }
@Get('classroom-occupancy') @Get('classroom-occupancy')
@@ -53,4 +72,4 @@ export class DashboardController {
async getClassroomUtilization() { async getClassroomUtilization() {
return this.service.getClassroomUtilizationStats(); return this.service.getClassroomUtilizationStats();
} }
} }

View File

@@ -12,11 +12,28 @@ import { Class } from '../entities/class.entity';
import { Deposit } from '../entities/deposit.entity'; import { Deposit } from '../entities/deposit.entity';
import { ClassroomRental } from '../entities/classroom-rental.entity'; import { ClassroomRental } from '../entities/classroom-rental.entity';
import { ClassTeacher } from '../entities/class-teacher.entity'; import { ClassTeacher } from '../entities/class-teacher.entity';
import { ClassStudent } from '../entities/class-student.entity';
import { DashboardService } from './dashboard.service'; import { DashboardService } from './dashboard.service';
import { DashboardController } from './dashboard.controller'; import { DashboardController } from './dashboard.controller';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([Room, Student, Occupancy, Bill, RoomExpense, Classroom, ClassSchedule, AttendanceRecord, Class, Deposit, ClassroomRental, ClassTeacher])], imports: [
TypeOrmModule.forFeature([
Room,
Student,
Occupancy,
Bill,
RoomExpense,
Classroom,
ClassSchedule,
AttendanceRecord,
Class,
Deposit,
ClassroomRental,
ClassTeacher,
ClassStudent,
]),
],
controllers: [DashboardController], controllers: [DashboardController],
providers: [DashboardService], providers: [DashboardService],
}) })

View File

@@ -0,0 +1,44 @@
import { DashboardService } from './dashboard.service';
const createQb = () => ({
leftJoin: jest.fn().mockReturnThis(),
select: jest.fn().mockReturnThis(),
addSelect: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
groupBy: jest.fn().mockReturnThis(),
addGroupBy: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
getRawMany: jest.fn().mockResolvedValue([]),
getRawOne: jest.fn().mockResolvedValue({ cnt: '0' }),
});
describe('DashboardService — teacher class scope', () => {
it('filters class attendance ranking by assigned classes', async () => {
const qb = createQb();
const attendanceRepo = { createQueryBuilder: jest.fn().mockReturnValue(qb) };
const service = new DashboardService(
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
attendanceRepo as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
);
await service.getClassAttendanceRanking([8, 9]);
expect(qb.andWhere).toHaveBeenCalledWith('a.classId IN (:...accessibleClassIds)', {
accessibleClassIds: [8, 9],
});
});
});

View File

@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository, IsNull, Not, MoreThanOrEqual } from 'typeorm'; import { Repository, IsNull, Not, MoreThanOrEqual, In } from 'typeorm';
import { Room } from '../entities/room.entity'; import { Room } from '../entities/room.entity';
import { Student } from '../entities/student.entity'; import { Student } from '../entities/student.entity';
import { Occupancy } from '../entities/occupancy.entity'; import { Occupancy } from '../entities/occupancy.entity';
@@ -13,11 +13,10 @@ import { Class } from '../entities/class.entity';
import { Deposit } from '../entities/deposit.entity'; import { Deposit } from '../entities/deposit.entity';
import { ClassroomRental } from '../entities/classroom-rental.entity'; import { ClassroomRental } from '../entities/classroom-rental.entity';
import { ClassTeacher } from '../entities/class-teacher.entity'; import { ClassTeacher } from '../entities/class-teacher.entity';
import { ClassStudent } from '../entities/class-student.entity';
@Injectable() @Injectable()
export class DashboardService { export class DashboardService {
constructor( constructor(
@InjectRepository(Room) private roomRepo: Repository<Room>, @InjectRepository(Room) private roomRepo: Repository<Room>,
@InjectRepository(Student) private studentRepo: Repository<Student>, @InjectRepository(Student) private studentRepo: Repository<Student>,
@@ -31,15 +30,24 @@ export class DashboardService {
@InjectRepository(Deposit) private depositRepo: Repository<Deposit>, @InjectRepository(Deposit) private depositRepo: Repository<Deposit>,
@InjectRepository(ClassroomRental) private rentalRepo: Repository<ClassroomRental>, @InjectRepository(ClassroomRental) private rentalRepo: Repository<ClassroomRental>,
@InjectRepository(ClassTeacher) private classTeacherRepo: Repository<ClassTeacher>, @InjectRepository(ClassTeacher) private classTeacherRepo: Repository<ClassTeacher>,
@InjectRepository(ClassStudent) private classStudentRepo: Repository<ClassStudent>,
) {} ) {}
async getStats() { async getAccessibleClassIds(userId: number, canManageAll = false): Promise<number[] | undefined> {
if (canManageAll) return undefined;
const assignments = await this.classTeacherRepo.find({ where: { userId } });
return [...new Set(assignments.map((assignment) => assignment.classId))];
}
async getStats(accessibleClassIds?: number[]) {
const today = new Date(); const today = new Date();
const todayStr = today.toISOString().slice(0, 10); const todayStr = today.toISOString().slice(0, 10);
const currentMonth = todayStr.slice(0, 7); // YYYY-MM const currentMonth = todayStr.slice(0, 7); // YYYY-MM
const totalRooms = await this.roomRepo.count({ where: { status: Not('archived') } }); const totalRooms = await this.roomRepo.count({ where: { status: Not('archived') } });
const totalStudents = await this.studentRepo.count({ where: { status: 'active' } }); const totalStudents = accessibleClassIds
? await this.countStudentsInClasses(accessibleClassIds)
: await this.studentRepo.count({ where: { status: 'active' } });
const occupiedBeds = await this.occRepo.count({ where: { checkOutDate: IsNull() } }); const occupiedBeds = await this.occRepo.count({ where: { checkOutDate: IsNull() } });
const capQb = this.roomRepo const capQb = this.roomRepo
.createQueryBuilder('r') .createQueryBuilder('r')
@@ -68,24 +76,22 @@ export class DashboardService {
.andWhere('s.endDate >= :today', { today: todayStr }); .andWhere('s.endDate >= :today', { today: todayStr });
const occResult = await occQb.getRawOne(); const occResult = await occQb.getRawOne();
const occupiedClassrooms = parseInt(occResult?.cnt || '0', 10); const occupiedClassrooms = parseInt(occResult?.cnt || '0', 10);
const classroomOccupancyRate = classroomCount > 0 const classroomOccupancyRate =
? ((occupiedClassrooms / classroomCount) * 100).toFixed(1) classroomCount > 0 ? ((occupiedClassrooms / classroomCount) * 100).toFixed(1) : 0;
: 0;
const attTodayQb = this.attendanceRepo const attTodayQb = this.attendanceRepo
.createQueryBuilder('a') .createQueryBuilder('a')
.select('a.status', 'status') .select('a.status', 'status')
.addSelect('COUNT(*)', 'count') .addSelect('COUNT(*)', 'count')
.where('a.attendanceDate = :today', { today: todayStr }) .where('a.attendanceDate = :today', { today: todayStr });
.groupBy('a.status'); this.applyClassScope(attTodayQb, 'a', accessibleClassIds);
attTodayQb.groupBy('a.status');
const attTodayStats = await attTodayQb.getRawMany(); const attTodayStats = await attTodayQb.getRawMany();
const todayTotal = attTodayStats.reduce((sum, r) => sum + parseInt(r.count, 10), 0); const todayTotal = attTodayStats.reduce((sum, r) => sum + parseInt(r.count, 10), 0);
const todayPresent = attTodayStats const todayPresent = attTodayStats
.filter((r) => r.status === 'present') .filter((r) => r.status === 'present')
.reduce((sum, r) => sum + parseInt(r.count, 10), 0); .reduce((sum, r) => sum + parseInt(r.count, 10), 0);
const todayAttendanceRate = todayTotal > 0 const todayAttendanceRate = todayTotal > 0 ? ((todayPresent / todayTotal) * 100).toFixed(1) : 0;
? ((todayPresent / todayTotal) * 100).toFixed(1)
: 0;
const incomeQb = this.billRepo const incomeQb = this.billRepo
.createQueryBuilder('b') .createQueryBuilder('b')
@@ -96,11 +102,13 @@ export class DashboardService {
const incomeResult = await incomeQb.getRawOne(); const incomeResult = await incomeQb.getRawOne();
const monthlyIncome = parseFloat(incomeResult?.total || '0'); const monthlyIncome = parseFloat(incomeResult?.total || '0');
const attendanceTrend = await this.getAttendanceTrend(todayStr); const attendanceTrend = await this.getAttendanceTrend(todayStr, accessibleClassIds);
const incomeTrend = await this.getIncomeTrend(currentMonth); const incomeTrend = await this.getIncomeTrend(currentMonth);
// --- New stats --- // --- New stats ---
const classCount = await this.classRepo.count({ where: {} }); const classCount = accessibleClassIds
? accessibleClassIds.length
: await this.classRepo.count({ where: {} });
const teacherResult = await this.classTeacherRepo const teacherResult = await this.classTeacherRepo
.createQueryBuilder('ct') .createQueryBuilder('ct')
@@ -116,7 +124,9 @@ export class DashboardService {
const pendingResult = await pendingQb.getRawOne(); const pendingResult = await pendingQb.getRawOne();
const pendingDeposits = parseFloat(pendingResult?.total || '0'); const pendingDeposits = parseFloat(pendingResult?.total || '0');
const activeRentals = await this.rentalRepo.count({ where: { endDate: MoreThanOrEqual(todayStr) } }); const activeRentals = await this.rentalRepo.count({
where: { endDate: MoreThanOrEqual(todayStr) },
});
const occByBldQb = this.occRepo const occByBldQb = this.occRepo
.createQueryBuilder('o') .createQueryBuilder('o')
@@ -126,10 +136,13 @@ export class DashboardService {
.where('o.checkOutDate IS NULL'); .where('o.checkOutDate IS NULL');
const occupancyByBuilding = await occByBldQb.groupBy('r.building').getRawMany(); const occupancyByBuilding = await occByBldQb.groupBy('r.building').getRawMany();
const attendanceByStatus = attTodayStats.reduce((acc, r) => { const attendanceByStatus = attTodayStats.reduce(
acc[r.status] = parseInt(r.count, 10); (acc, r) => {
return acc; acc[r.status] = parseInt(r.count, 10);
}, {} as Record<string, number>); return acc;
},
{} as Record<string, number>,
);
const expByTypeQb = this.expRepo const expByTypeQb = this.expRepo
.createQueryBuilder('e') .createQueryBuilder('e')
@@ -162,18 +175,39 @@ export class DashboardService {
}; };
} }
private async getAttendanceTrend(todayStr: string) { private applyClassScope(
qb: { andWhere: (condition: string, parameters?: Record<string, unknown>) => unknown },
alias: string,
accessibleClassIds?: number[],
) {
if (accessibleClassIds) {
qb.andWhere(`${alias}.classId IN (:...accessibleClassIds)`, { accessibleClassIds });
}
}
private async countStudentsInClasses(accessibleClassIds: number[]) {
if (accessibleClassIds.length === 0) return 0;
const classStudents = await this.classStudentRepo.find({
where: { classId: In(accessibleClassIds), status: 'active' },
});
return new Set(classStudents.map((item) => item.studentId)).size;
}
private async getAttendanceTrend(todayStr: string, accessibleClassIds?: number[]) {
const thirtyDaysAgo = new Date(todayStr); const thirtyDaysAgo = new Date(todayStr);
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 29); thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 29);
const startStr = thirtyDaysAgo.toISOString().slice(0, 10); const startStr = thirtyDaysAgo.toISOString().slice(0, 10);
const rows = await this.attendanceRepo const trendQb = this.attendanceRepo
.createQueryBuilder('a') .createQueryBuilder('a')
.select('a.attendanceDate', 'date') .select('a.attendanceDate', 'date')
.addSelect('a.status', 'status') .addSelect('a.status', 'status')
.addSelect('COUNT(*)', 'count') .addSelect('COUNT(*)', 'count')
.where('a.attendanceDate >= :start', { start: startStr }) .where('a.attendanceDate >= :start', { start: startStr })
.andWhere('a.attendanceDate <= :today', { today: todayStr }) .andWhere('a.attendanceDate <= :today', { today: todayStr });
this.applyClassScope(trendQb, 'a', accessibleClassIds);
const rows = await trendQb
.groupBy('a.attendanceDate') .groupBy('a.attendanceDate')
.addGroupBy('a.status') .addGroupBy('a.status')
.orderBy('a.attendanceDate', 'ASC') .orderBy('a.attendanceDate', 'ASC')
@@ -235,7 +269,6 @@ export class DashboardService {
.orderBy('room.roomNumber', 'ASC') .orderBy('room.roomNumber', 'ASC')
.addOrderBy('o.checkInDate', 'ASC'); .addOrderBy('o.checkInDate', 'ASC');
if (query?.building) { if (query?.building) {
qb.andWhere('room.building = :building', { building: query.building }); qb.andWhere('room.building = :building', { building: query.building });
} }
@@ -297,21 +330,24 @@ export class DashboardService {
} }
// 班级考勤排行 // 班级考勤排行
async getClassAttendanceRanking() { async getClassAttendanceRanking(accessibleClassIds?: number[]) {
if (accessibleClassIds?.length === 0) return { top: [], bottom: [] };
const qb = this.attendanceRepo const qb = this.attendanceRepo
.createQueryBuilder('a') .createQueryBuilder('a')
.leftJoin('a.class', 'class') .leftJoin('a.class', 'class')
.select('class.id', 'classId') .select('class.id', 'classId')
.addSelect('class.name', 'className') .addSelect('class.name', 'className')
.addSelect('a.status', 'status') .addSelect('a.status', 'status')
.addSelect('COUNT(*)', 'count') .addSelect('COUNT(*)', 'count');
.groupBy('class.id').addGroupBy('class.name').addGroupBy('a.status'); this.applyClassScope(qb, 'a', accessibleClassIds);
qb.groupBy('class.id').addGroupBy('class.name').addGroupBy('a.status');
const raw = await qb.getRawMany(); const raw = await qb.getRawMany();
const classMap = new Map<number, { className: string; present: number; total: number }>(); const classMap = new Map<number, { className: string; present: number; total: number }>();
for (const r of raw) { for (const r of raw) {
if (!r.classId) continue; if (!r.classId) continue;
if (!classMap.has(Number(r.classId))) classMap.set(Number(r.classId), { className: r.className, present: 0, total: 0 }); if (!classMap.has(Number(r.classId)))
classMap.set(Number(r.classId), { className: r.className, present: 0, total: 0 });
const entry = classMap.get(Number(r.classId))!; const entry = classMap.get(Number(r.classId))!;
const n = parseInt(r.count, 10); const n = parseInt(r.count, 10);
entry.total += n; entry.total += n;
@@ -319,7 +355,10 @@ export class DashboardService {
} }
const ranked = Array.from(classMap.values()) const ranked = Array.from(classMap.values())
.map(e => ({ ...e, rate: e.total > 0 ? parseFloat(((e.present / e.total) * 100).toFixed(1)) : 0 })) .map((e) => ({
...e,
rate: e.total > 0 ? parseFloat(((e.present / e.total) * 100).toFixed(1)) : 0,
}))
.sort((a, b) => b.rate - a.rate); .sort((a, b) => b.rate - a.rate);
return { top: ranked.slice(0, 5), bottom: ranked.slice(-5).reverse() }; return { top: ranked.slice(0, 5), bottom: ranked.slice(-5).reverse() };
@@ -412,9 +451,8 @@ export class DashboardService {
const scheduleCount = parseInt(schedResult?.cnt || '0', 10); const scheduleCount = parseInt(schedResult?.cnt || '0', 10);
const rentalCount = parseInt(rentalResult?.cnt || '0', 10); const rentalCount = parseInt(rentalResult?.cnt || '0', 10);
const inUseCount = allInUseIds.size; const inUseCount = allInUseIds.size;
const utilizationRate = totalClassrooms > 0 const utilizationRate =
? ((inUseCount / totalClassrooms) * 100).toFixed(1) totalClassrooms > 0 ? ((inUseCount / totalClassrooms) * 100).toFixed(1) : '0';
: '0';
return { return {
totalClassrooms, totalClassrooms,
@@ -424,4 +462,4 @@ export class DashboardService {
rentalCount, rentalCount,
}; };
} }
} }

View File

@@ -0,0 +1,5 @@
import { Module } from '@nestjs/common';
import { DatabaseMigrationsService } from './database-migrations.service';
@Module({ providers: [DatabaseMigrationsService] })
export class DatabaseMigrationsModule {}

View File

@@ -0,0 +1,41 @@
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { DataSource } from 'typeorm';
@Injectable()
export class DatabaseMigrationsService implements OnApplicationBootstrap {
private readonly logger = new Logger(DatabaseMigrationsService.name);
constructor(private readonly dataSource: DataSource) {}
async onApplicationBootstrap(): Promise<void> {
await this.normalizeClassDates();
}
private async normalizeClassDates(): Promise<void> {
const driver = this.dataSource.options.type;
const dateExpression = (column: string) =>
driver === 'mysql' ? `DATE(${column})` : `substr(${column}, 1, 10)`;
const lengthFunction = driver === 'mysql' ? 'CHAR_LENGTH' : 'length';
const result = await this.dataSource.transaction((manager) =>
manager.query(`
UPDATE classes
SET
start_date = CASE
WHEN start_date IS NULL OR start_date = '' THEN start_date
ELSE ${dateExpression('start_date')}
END,
end_date = CASE
WHEN end_date IS NULL OR end_date = '' THEN end_date
ELSE ${dateExpression('end_date')}
END
WHERE
(start_date IS NOT NULL AND ${lengthFunction}(start_date) > 10)
OR (end_date IS NOT NULL AND ${lengthFunction}(end_date) > 10)
`),
);
const affected = typeof result?.changes === 'number' ? result.changes : result?.affectedRows;
if (affected) this.logger.log(`已规范化 ${affected} 条班级日期数据`);
}
}

View File

@@ -0,0 +1,15 @@
import { normalizeDateOnly } from './date-normalization';
describe('normalizeDateOnly', () => {
it('keeps date-only values unchanged', () => {
expect(normalizeDateOnly('2026-07-02')).toBe('2026-07-02');
});
it('converts legacy ISO timestamps to their UTC calendar date', () => {
expect(normalizeDateOnly('2026-07-01T16:00:00.000Z')).toBe('2026-07-01');
});
it('rejects unsupported date formats', () => {
expect(() => normalizeDateOnly('07/01/2026')).toThrow('无效日期格式');
});
});

View File

@@ -0,0 +1,15 @@
const DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
const ISO_DATE_PREFIX_PATTERN = /^(\d{4}-\d{2}-\d{2})T/;
export function normalizeDateOnly(value?: string | null): string | null | undefined {
if (value == null || value === '') return value;
if (DATE_ONLY_PATTERN.test(value)) return value;
const isoPrefix = ISO_DATE_PREFIX_PATTERN.exec(value)?.[1];
if (isoPrefix) {
const date = new Date(value);
if (!Number.isNaN(date.getTime())) return date.toISOString().slice(0, 10);
}
throw new Error(`无效日期格式: ${value}`);
}

View File

@@ -18,6 +18,9 @@ export class Role {
@Column({ type: 'varchar', length: 30, unique: true }) @Column({ type: 'varchar', length: 30, unique: true })
name: string; name: string;
@Column({ type: 'varchar', length: 30, unique: true, nullable: true })
code: string;
@Column({ type: 'varchar', length: 200, nullable: true }) @Column({ type: 'varchar', length: 200, nullable: true })
description: string; description: string;

View File

@@ -0,0 +1,174 @@
import { DingTalkService } from './dingtalk.service';
describe('DingTalkService — queryShifts', () => {
const originalAppKey = process.env.DINGTALK_APP_KEY;
const originalAppSecret = process.env.DINGTALK_APP_SECRET;
let service: DingTalkService;
beforeEach(() => {
process.env.DINGTALK_APP_KEY = 'test-app-key';
process.env.DINGTALK_APP_SECRET = 'test-app-secret';
service = new DingTalkService({} as never, {} as never);
Object.assign(service, {
accessToken: 'test-token',
tokenExpiresAt: Date.now() + 3_600_000,
});
jest.spyOn(service as never, 'rateLimit').mockResolvedValue(undefined);
});
afterEach(() => {
jest.restoreAllMocks();
global.fetch = undefined as unknown as typeof fetch;
});
afterAll(() => {
if (originalAppKey === undefined) delete process.env.DINGTALK_APP_KEY;
else process.env.DINGTALK_APP_KEY = originalAppKey;
if (originalAppSecret === undefined) delete process.env.DINGTALK_APP_SECRET;
else process.env.DINGTALK_APP_SECRET = originalAppSecret;
});
it('unwraps the paged result object returned by DingTalk', async () => {
global.fetch = jest.fn().mockResolvedValue({
json: jest.fn().mockResolvedValue({
errcode: 0,
errmsg: 'ok',
result: {
cursor: 678215070,
has_more: false,
result: [
{ id: 677995086, name: 'A' },
{ id: 678215070, name: 'B' },
],
},
}),
}) as jest.MockedFunction<typeof fetch>;
await expect(service.queryShifts('manager')).resolves.toEqual([
{ id: 677995086, name: 'A' },
{ id: 678215070, name: 'B' },
]);
});
it('requests subsequent pages using the cursor returned by DingTalk', async () => {
global.fetch = jest
.fn()
.mockResolvedValueOnce({
json: jest.fn().mockResolvedValue({
errcode: 0,
errmsg: 'ok',
result: {
cursor: 200,
has_more: true,
result: [{ id: 100, name: '早班' }],
},
}),
})
.mockResolvedValueOnce({
json: jest.fn().mockResolvedValue({
errcode: 0,
errmsg: 'ok',
result: {
cursor: 300,
has_more: false,
result: [{ id: 200, name: '晚班' }],
},
}),
}) as jest.MockedFunction<typeof fetch>;
await expect(service.queryShifts('manager')).resolves.toEqual([
{ id: 100, name: '早班' },
{ id: 200, name: '晚班' },
]);
expect(global.fetch).toHaveBeenCalledTimes(2);
expect(JSON.parse((global.fetch as jest.Mock).mock.calls[0][1].body)).toEqual({
op_user_id: 'manager',
cursor: 0,
});
expect(JSON.parse((global.fetch as jest.Mock).mock.calls[1][1].body)).toEqual({
op_user_id: 'manager',
cursor: 200,
});
});
});
describe('DingTalkService — attendance machine only group', () => {
const originalAppKey = process.env.DINGTALK_APP_KEY;
const originalAppSecret = process.env.DINGTALK_APP_SECRET;
let service: DingTalkService;
beforeEach(() => {
process.env.DINGTALK_APP_KEY = 'test-app-key';
process.env.DINGTALK_APP_SECRET = 'test-app-secret';
service = new DingTalkService({} as never, {} as never);
Object.assign(service, {
accessToken: 'test-token',
tokenExpiresAt: Date.now() + 3_600_000,
});
jest.spyOn(service as never, 'rateLimit').mockResolvedValue(undefined);
});
afterEach(() => {
jest.restoreAllMocks();
global.fetch = undefined as unknown as typeof fetch;
});
afterAll(() => {
if (originalAppKey === undefined) delete process.env.DINGTALK_APP_KEY;
else process.env.DINGTALK_APP_KEY = originalAppKey;
if (originalAppSecret === undefined) delete process.env.DINGTALK_APP_SECRET;
else process.env.DINGTALK_APP_SECRET = originalAppSecret;
});
it('accepts the success envelope returned when updating an attendance group', async () => {
global.fetch = jest.fn().mockResolvedValue({
json: jest.fn().mockResolvedValue({
success: true,
result: { id: 123, name: '排课_冲刺班' },
request_id: 'request-1',
}),
}) as jest.MockedFunction<typeof fetch>;
await expect(
service.updateAttendanceGroup({
id: 123,
name: '排课_冲刺班',
type: 'TURN',
owner: 'manager',
members: [{ role: 'Attendance', type: 'StaffMember', user_id: 'student-1' }],
shift_ids: [456],
}),
).resolves.toBeUndefined();
});
it('disables mobile-oriented punching when creating a machine-only group', async () => {
global.fetch = jest.fn().mockResolvedValue({
json: jest.fn().mockResolvedValue({
errcode: 0,
errmsg: 'ok',
result: { id: 123 },
}),
}) as jest.MockedFunction<typeof fetch>;
await service.createAttendanceGroup({
name: '排课_冲刺班',
type: 'TURN',
owner: 'manager',
members: [{ role: 'Attendance', type: 'StaffMember', user_id: 'student-1' }],
shift_ids: [456],
attendance_machine_only: true,
});
const body = JSON.parse((global.fetch as jest.Mock).mock.calls[0][1].body);
expect(body.top_group).toEqual(expect.objectContaining({
enable_emp_select_class: false,
disable_check_without_schedule: true,
enable_outside_check: false,
enable_position_ble: false,
positions: [],
wifis: [],
}));
});
});

View File

@@ -126,6 +126,13 @@ export interface DingTalkGroupParams {
enable_emp_select_class?: boolean; enable_emp_select_class?: boolean;
disable_check_without_schedule?: boolean; disable_check_without_schedule?: boolean;
disable_check_when_rest?: boolean; disable_check_when_rest?: boolean;
/** 关闭外勤、定位、Wi-Fi 和手机蓝牙打卡,仅保留考勤机打卡入口 */
attendance_machine_only?: boolean;
}
/** 修改考勤组参数 */
export interface DingTalkGroupUpdateParams extends DingTalkGroupParams {
id: number;
} }
/** 考勤组摘要(查询返回) */ /** 考勤组摘要(查询返回) */
@@ -433,10 +440,10 @@ export class DingTalkService {
startDate: string; startDate: string;
endDate: string; endDate: string;
userIds?: string[]; userIds?: string[];
offset?: number;
limit?: number;
}): Promise<DingTalkAttendanceResult[]> { }): Promise<DingTalkAttendanceResult[]> {
if (!this.configured) throw new Error('DingTalk not configured'); if (!this.configured) throw new Error('DingTalk not configured');
if (!params.userIds?.length) throw new Error('钉钉考勤 userIds 不能为空');
if (params.userIds.length > 50) throw new Error('钉钉考勤单次最多查询50人');
const token = await this.getAccessToken(); const token = await this.getAccessToken();
const dateFrom = params.startDate.includes(' ') ? params.startDate : `${params.startDate} 00:00:00`; const dateFrom = params.startDate.includes(' ') ? params.startDate : `${params.startDate} 00:00:00`;
@@ -446,9 +453,7 @@ export class DingTalkService {
checkDateFrom: dateFrom, checkDateFrom: dateFrom,
checkDateTo: dateTo, checkDateTo: dateTo,
}; };
if (params.userIds?.length) body.userIds = params.userIds; body.userIds = params.userIds;
if (params.offset !== undefined) body.offset = params.offset;
if (params.limit !== undefined) body.limit = params.limit;
const res = await fetch( const res = await fetch(
`https://oapi.dingtalk.com/attendance/listRecord?access_token=${token}`, `https://oapi.dingtalk.com/attendance/listRecord?access_token=${token}`,
@@ -470,7 +475,9 @@ export class DingTalkService {
}; };
if (data.errcode !== 0) throw new Error(`钉钉考勤获取失败: ${data.errmsg}`); if (data.errcode !== 0) throw new Error(`钉钉考勤获取失败: ${data.errmsg}`);
return (data.recordresult ?? []).map((r) => ({ const records = data.recordresult ?? [];
return records.map((r) => ({
userId: r.userId, userId: r.userId,
userName: '', userName: '',
workDate: new Date(r.workDate).toISOString().slice(0, 10), workDate: new Date(r.workDate).toISOString().slice(0, 10),
@@ -538,28 +545,50 @@ export class DingTalkService {
return data.result!.id; return data.result!.id;
} }
/** 查询所有班次摘要 */ /** 查询所有班次摘要每页最多200条 */
async queryShifts(opUserId = 'manager'): Promise<DingTalkShiftSummary[]> { async queryShifts(opUserId = 'manager'): Promise<DingTalkShiftSummary[]> {
if (!this.configured) throw new ServiceUnavailableException('钉钉未配置'); if (!this.configured) throw new ServiceUnavailableException('钉钉未配置');
const token = await this.getAccessToken(); const token = await this.getAccessToken();
await this.rateLimit(); const all: DingTalkShiftSummary[] = [];
const res = await fetch( let cursor = 0;
`https://oapi.dingtalk.com/topapi/attendance/shift/list?access_token=${token}`, let hasMore = true;
{
method: 'POST', while (hasMore) {
headers: { 'Content-Type': 'application/json' }, await this.rateLimit();
body: JSON.stringify({ op_user_id: opUserId }), const res = await fetch(
}, `https://oapi.dingtalk.com/topapi/attendance/shift/list?access_token=${token}`,
); {
const data = (await res.json()) as { method: 'POST',
errcode: number; errmsg: string; headers: { 'Content-Type': 'application/json' },
result?: Array<{ id: number; name: string }>; body: JSON.stringify({ op_user_id: opUserId, cursor }),
}; },
if (data.errcode !== 0) { );
throw new Error(`钉钉查询班次失败: ${data.errmsg} (code=${data.errcode})`); const data = (await res.json()) as {
errcode: number;
errmsg: string;
result?: {
cursor?: number;
has_more?: boolean;
result?: Array<{ id: number; name: string }>;
};
};
if (data.errcode !== 0) {
throw new Error(`钉钉查询班次失败: ${data.errmsg} (code=${data.errcode})`);
}
const page = data.result;
all.push(...(page?.result ?? []).map((s) => ({ id: s.id, name: s.name })));
hasMore = page?.has_more ?? false;
if (hasMore) {
if (page?.cursor === undefined || page.cursor === cursor) {
throw new Error('钉钉查询班次失败: 分页游标无效');
}
cursor = page.cursor;
}
} }
return (data.result ?? []).map((s) => ({ id: s.id, name: s.name }));
return all;
} }
@@ -572,22 +601,7 @@ export class DingTalkService {
if (!this.configured) throw new ServiceUnavailableException('钉钉未配置'); if (!this.configured) throw new ServiceUnavailableException('钉钉未配置');
const token = await this.getAccessToken(); const token = await this.getAccessToken();
const topGroup: Record<string, unknown> = { const topGroup = this.buildAttendanceGroupBody(params);
name: params.name,
type: params.type,
owner: params.owner,
members: params.members.map((m) => ({
role: m.role,
type: m.type,
user_id: m.user_id,
})),
enable_emp_select_class: params.enable_emp_select_class ?? true,
disable_check_without_schedule: params.disable_check_without_schedule ?? false,
disable_check_when_rest: params.disable_check_when_rest ?? true,
};
if (params.shift_ids?.length) {
topGroup.shift_vo_list = params.shift_ids.map((id) => ({ id }));
}
const body = { op_user_id: params.owner, top_group: topGroup }; const body = { op_user_id: params.owner, top_group: topGroup };
@@ -611,6 +625,66 @@ export class DingTalkService {
return data.result!.id; return data.result!.id;
} }
/** 更新排班制考勤组,确保复用考勤组时同步最新打卡限制 */
async updateAttendanceGroup(params: DingTalkGroupUpdateParams): Promise<void> {
if (!this.configured) throw new ServiceUnavailableException('钉钉未配置');
const token = await this.getAccessToken();
const topGroup = { ...this.buildAttendanceGroupBody(params), id: params.id };
await this.rateLimit();
const res = await fetch(
`https://oapi.dingtalk.com/topapi/attendance/group/modify?access_token=${token}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ op_user_id: params.owner, top_group: topGroup }),
},
);
const data = (await res.json()) as {
errcode?: number;
errmsg?: string;
success?: boolean;
message?: string;
};
const succeeded = data.success === true || data.errcode === 0;
if (!succeeded) {
throw new Error(
`钉钉更新考勤组失败: ${data.message || data.errmsg || '未知错误'} ` +
`(code=${data.errcode ?? 'unknown'})`,
);
}
this.logger.log(`钉钉考勤组更新成功: ${params.name} (id=${params.id})`);
}
private buildAttendanceGroupBody(params: DingTalkGroupParams): Record<string, unknown> {
const machineOnly = params.attendance_machine_only ?? false;
const topGroup: Record<string, unknown> = {
name: params.name,
type: params.type,
owner: params.owner,
members: params.members.map((m) => ({
role: m.role,
type: m.type,
user_id: m.user_id,
})),
enable_emp_select_class: machineOnly ? false : (params.enable_emp_select_class ?? true),
disable_check_without_schedule: machineOnly ? true : (params.disable_check_without_schedule ?? false),
disable_check_when_rest: params.disable_check_when_rest ?? true,
};
if (params.shift_ids?.length) {
topGroup.shift_vo_list = params.shift_ids.map((id) => ({ id }));
}
if (machineOnly) {
Object.assign(topGroup, {
enable_outside_check: false,
enable_position_ble: false,
positions: [],
wifis: [],
});
}
return topGroup;
}
/** 查询所有考勤组摘要分页每页10条 */ /** 查询所有考勤组摘要分页每页10条 */
async queryAttendanceGroups(opUserId = 'manager'): Promise<DingTalkGroupSummary[]> { async queryAttendanceGroups(opUserId = 'manager'): Promise<DingTalkGroupSummary[]> {
if (!this.configured) throw new ServiceUnavailableException('钉钉未配置'); if (!this.configured) throw new ServiceUnavailableException('钉钉未配置');

View File

@@ -13,6 +13,7 @@ import { Observable, map } from 'rxjs';
import { NotificationsService } from './notifications.service'; import { NotificationsService } from './notifications.service';
import { NotificationQueryDto } from './dto/notification.dto'; import { NotificationQueryDto } from './dto/notification.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequirePermission } from '../auth/decorators/permission.decorator';
interface AuthenticatedUser { interface AuthenticatedUser {
id: number; id: number;
@@ -25,6 +26,7 @@ interface AuthenticatedRequest extends Request {
} }
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@RequirePermission('notification:view')
@Controller('notifications') @Controller('notifications')
export class NotificationsController { export class NotificationsController {
constructor(private readonly service: NotificationsService) {} constructor(private readonly service: NotificationsService) {}

View File

@@ -0,0 +1,23 @@
import { PRESET_ROLES } from './rbac.service';
describe('preset role permissions', () => {
it('gives teachers explicit workspace permissions without class/schedule delete privileges', () => {
const teacher = PRESET_ROLES.find((role) => role.code === 'teacher');
expect(teacher).toBeDefined();
expect(teacher?.permissionGroups).toEqual(['notification', 'profile']);
expect(teacher?.extraPermissions).toEqual(
expect.arrayContaining([
'student:view',
'class:view',
'schedule:view',
'attendance:view',
'attendance:create',
'attendance:export',
]),
);
expect(teacher?.extraPermissions).not.toEqual(
expect.arrayContaining(['class:delete', 'schedule:delete']),
);
});
});

View File

@@ -0,0 +1,57 @@
import { RbacService } from './rbac.service';
describe('RbacService seedData', () => {
it('adds preset permissions to system roles without removing manually granted permissions', async () => {
const permissions = [
{ id: 1, code: 'profile:view', name: '查看个人资料', group: 'profile' },
{ id: 2, code: 'notification:view', name: '查看通知', group: 'notification' },
{ id: 3, code: 'student:view', name: '查看学生', group: 'student' },
{ id: 4, code: 'class:view', name: '查看班级', group: 'class' },
{ id: 5, code: 'schedule:view', name: '查看排课', group: 'schedule' },
{ id: 6, code: 'attendance:view', name: '查看考勤', group: 'attendance' },
{ id: 7, code: 'attendance:create', name: '新增考勤', group: 'attendance' },
{ id: 8, code: 'attendance:export', name: '导出考勤', group: 'attendance' },
{ id: 9, code: 'room:view', name: '查看宿舍', group: 'room' },
];
const teacherRole = {
id: 1,
name: '老师',
description: '查看和管理本班学生',
isSystem: true,
permissions: [permissions[8]],
};
const permRepo = {
findOne: jest.fn(
async ({ where }: any) => permissions.find((p) => p.code === where.code) ?? null,
),
create: jest.fn((value) => value),
save: jest.fn(async (value) => value),
find: jest.fn(async () => permissions),
};
const roleRepo = {
findOne: jest.fn(async ({ where }: any) => (where.name === '老师' ? teacherRole : null)),
create: jest.fn((value) => ({ ...value, permissions: [] })),
save: jest.fn(async (value) => value),
find: jest.fn(async () => [teacherRole]),
};
const userRepo = { count: jest.fn(async () => 1), create: jest.fn(), save: jest.fn() };
const service = new RbacService(
permRepo as never,
roleRepo as never,
userRepo as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
);
await service.seedData();
expect(teacherRole.permissions.map((permission) => permission.code)).toEqual(
expect.arrayContaining(['room:view', 'profile:view', 'student:view', 'attendance:create']),
);
});
});

View File

@@ -2,10 +2,21 @@ import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In } from 'typeorm'; import { Repository, In } from 'typeorm';
import * as bcrypt from 'bcryptjs'; import * as bcrypt from 'bcryptjs';
import { Permission, Role, User, Class, ClassStudent, ClassTeacher, ClassSchedule, Student } from '../entities'; import {
Permission,
Role,
User,
Class,
ClassStudent,
ClassTeacher,
ClassSchedule,
Student,
} from '../entities';
const PRESET_PERMISSIONS: Array<{ code: string; name: string; group: string }> = [ const PRESET_PERMISSIONS: Array<{ code: string; name: string; group: string }> = [
{ code: 'dashboard:view', name: '查看数据面板', group: 'dashboard' }, { code: 'dashboard:view', name: '查看数据面板', group: 'dashboard' },
{ code: 'profile:view', name: '查看个人资料', group: 'profile' },
{ code: 'notification:view', name: '查看通知', group: 'notification' },
{ code: 'student:view', name: '查看学生', group: 'student' }, { code: 'student:view', name: '查看学生', group: 'student' },
{ code: 'student:create', name: '新增学生', group: 'student' }, { code: 'student:create', name: '新增学生', group: 'student' },
{ code: 'student:edit', name: '编辑学生', group: 'student' }, { code: 'student:edit', name: '编辑学生', group: 'student' },
@@ -87,7 +98,7 @@ const PRESET_PERMISSIONS: Array<{ code: string; name: string; group: string }> =
{ code: 'department:delete', name: '删除部门', group: 'department' }, { code: 'department:delete', name: '删除部门', group: 'department' },
]; ];
const PRESET_ROLES: Array<{ export const PRESET_ROLES: Array<{
name: string; name: string;
code: string; code: string;
description: string; description: string;
@@ -119,6 +130,8 @@ const PRESET_ROLES: Array<{
'class', 'class',
'schedule', 'schedule',
'attendance', 'attendance',
'notification',
'profile',
], ],
}, },
{ {
@@ -126,36 +139,61 @@ const PRESET_ROLES: Array<{
code: 'teacher', code: 'teacher',
description: '查看和管理本班学生', description: '查看和管理本班学生',
isSystem: true, isSystem: true,
permissionGroups: ['student'], permissionGroups: ['notification', 'profile'],
extraPermissions: ['student:view'], extraPermissions: [
'student:view',
'class:view',
'schedule:view',
'attendance:view',
'attendance:create',
'attendance:export',
],
}, },
{ {
name: '机构负责人', name: '机构负责人',
code: 'institution_head', code: 'institution_head',
description: '管理机构教室和课程', description: '管理机构教室和课程',
isSystem: true, isSystem: true,
permissionGroups: ['classroom', 'rental', 'tenant'], permissionGroups: ['classroom', 'rental', 'tenant', 'notification', 'profile'],
}, },
{ {
name: '财务', name: '财务',
code: 'finance', code: 'finance',
description: '管理费用、账单与押金', description: '管理费用、账单与押金',
isSystem: true, isSystem: true,
permissionGroups: ['expense', 'bill', 'deposit', 'dashboard'], permissionGroups: ['expense', 'bill', 'deposit', 'dashboard', 'notification', 'profile'],
}, },
{ {
name: '宿管', name: '宿管',
code: 'dorm_manager', code: 'dorm_manager',
description: '管理宿舍入住与宿舍信息', description: '管理宿舍入住与宿舍信息',
isSystem: true, isSystem: true,
permissionGroups: ['student', 'room', 'occupancy', 'deposit', 'dashboard'], permissionGroups: [
'student',
'room',
'occupancy',
'deposit',
'dashboard',
'notification',
'profile',
],
}, },
{ {
name: '教务', name: '教务',
code: 'academic', code: 'academic',
description: '管理班级、排课、考勤、学习与考试', description: '管理班级、排课、考勤、学习与考试',
isSystem: true, isSystem: true,
permissionGroups: ['class', 'schedule', 'attendance', 'classroom', 'learning', 'exam', 'dashboard'], permissionGroups: [
'class',
'schedule',
'attendance',
'classroom',
'learning',
'exam',
'dashboard',
'notification',
'profile',
],
}, },
]; ];
@@ -189,7 +227,12 @@ export class RbacService {
const exists = await this.roleRepo.findOne({ where: { name: r.name } }); const exists = await this.roleRepo.findOne({ where: { name: r.name } });
if (!exists) { if (!exists) {
await this.roleRepo.save( await this.roleRepo.save(
this.roleRepo.create({ name: r.name, description: r.description, isSystem: r.isSystem }), this.roleRepo.create({
name: r.name,
code: r.code,
description: r.description,
isSystem: r.isSystem,
}),
); );
} }
} }
@@ -197,8 +240,12 @@ export class RbacService {
// Step 3: 构建角色-权限关联 // Step 3: 构建角色-权限关联
for (const preset of PRESET_ROLES) { for (const preset of PRESET_ROLES) {
const role = allRoles.find((r) => r.name === preset.name); const role = allRoles.find((r) => r.name === preset.name || r.code === preset.code);
if (!role) continue; if (!role) continue;
if (role.code !== preset.code) {
role.code = preset.code;
await this.roleRepo.save(role);
}
let perms: Permission[]; let perms: Permission[];
if (preset.permissionGroups.length === 0) { if (preset.permissionGroups.length === 0) {
@@ -215,11 +262,12 @@ export class RbacService {
); );
} }
// 幂等:只插入尚未关联的 // 系统角色只补齐预置权限,不移除管理员手动授予的额外权限。
const existingIds = new Set(role.permissions.map((p) => p.id)); // 这样新增权限(例如 profile:view会自动补上同时避免重启后覆盖人工配置。
const toAdd = perms.filter((p) => !existingIds.has(p.id)); const currentIds = new Set(role.permissions.map((permission) => permission.id));
if (toAdd.length > 0) { const missingPerms = perms.filter((permission) => !currentIds.has(permission.id));
role.permissions = [...role.permissions, ...toAdd]; if (missingPerms.length > 0) {
role.permissions = [...role.permissions, ...missingPerms];
await this.roleRepo.save(role); await this.roleRepo.save(role);
} }
} }
@@ -247,7 +295,6 @@ export class RbacService {
this.logger.log(`种子数据初始化完成: ${allPerms.length} 权限点, ${allRoles.length} 角色`); this.logger.log(`种子数据初始化完成: ${allPerms.length} 权限点, ${allRoles.length} 角色`);
} }
async findAllRoles(): Promise<Role[]> { async findAllRoles(): Promise<Role[]> {
return this.roleRepo.find({ return this.roleRepo.find({
relations: ['permissions'], relations: ['permissions'],
@@ -450,14 +497,18 @@ export class RbacService {
}; };
} }
async updateUserProfile(id: number, dto: { subjects?: string[]; joinedAt?: string; qualifications?: string }) { async updateUserProfile(
id: number,
dto: { subjects?: string[]; joinedAt?: string; qualifications?: string },
) {
const user = await this.userRepo.findOne({ where: { id } }); const user = await this.userRepo.findOne({ where: { id } });
if (!user) throw new Error('用户不存在'); if (!user) throw new Error('用户不存在');
const current = user.profile || {}; const current = user.profile || {};
user.profile = { user.profile = {
subjects: dto.subjects !== undefined ? dto.subjects : current.subjects, subjects: dto.subjects !== undefined ? dto.subjects : current.subjects,
joinedAt: dto.joinedAt !== undefined ? dto.joinedAt : current.joinedAt, joinedAt: dto.joinedAt !== undefined ? dto.joinedAt : current.joinedAt,
qualifications: dto.qualifications !== undefined ? dto.qualifications : current.qualifications, qualifications:
dto.qualifications !== undefined ? dto.qualifications : current.qualifications,
}; };
await this.userRepo.save(user); await this.userRepo.save(user);
return { message: '资料已更新', profile: user.profile }; return { message: '资料已更新', profile: user.profile };
@@ -501,6 +552,7 @@ export class RbacService {
.andWhere('cs.startDate <= :today', { today: todayStr }) .andWhere('cs.startDate <= :today', { today: todayStr })
.andWhere('cs.endDate >= :today', { today: todayStr }) .andWhere('cs.endDate >= :today', { today: todayStr })
.andWhere('cs.status = :status', { status: 'active' }) .andWhere('cs.status = :status', { status: 'active' })
.andWhere('cs.teacherId = :userId', { userId })
.orderBy('cs.startTime', 'ASC') .orderBy('cs.startTime', 'ASC')
.getMany(); .getMany();
@@ -524,6 +576,8 @@ export class RbacService {
todaySchedules: todaySchedules.map((s) => ({ todaySchedules: todaySchedules.map((s) => ({
id: s.id, id: s.id,
classId: s.classId, classId: s.classId,
classroomId: s.classroomId,
teacherId: s.teacherId,
weekDay: s.weekDay, weekDay: s.weekDay,
startTime: s.startTime, startTime: s.startTime,
endTime: s.endTime, endTime: s.endTime,
@@ -535,18 +589,18 @@ export class RbacService {
} }
async getTeachers(query?: { search?: string; page?: number; pageSize?: number }) { async getTeachers(query?: { search?: string; page?: number; pageSize?: number }) {
const page = query?.page || 1;
const pageSize = query?.pageSize || 20;
const teacherRoleCodes = ['teacher', 'class_teacher', 'dormitory_supervisor', 'super_admin'];
const teacherRoleNames = ['老师', '班主任', '宿管老师', '超管'];
const qb = this.userRepo const qb = this.userRepo
.createQueryBuilder('u') .createQueryBuilder('u')
.leftJoin('u.roles', 'role') .leftJoinAndSelect('u.roles', 'role')
.leftJoin(ClassTeacher, 'ct', 'ct.userId = u.id') .where('(role.code IN (:...roleCodes) OR role.name IN (:...roleNames))', {
.leftJoin('ct.class', 'c') roleCodes: teacherRoleCodes,
.select([ roleNames: teacherRoleNames,
'u.id', 'u.username', 'u.name', 'u.isActive', 'u.profile', 'u.lastLoginAt', });
'role.code', 'role.name',
'ct.roleType', 'ct.subject', 'ct.id',
'c.id', 'c.name',
])
.where('role.code IN (:...roles)', { roles: ['teacher', 'class_teacher', 'dormitory_supervisor', 'super_admin'] });
if (query?.search) { if (query?.search) {
qb.andWhere('(u.name LIKE :s OR u.username LIKE :s)', { s: `%${query.search}%` }); qb.andWhere('(u.name LIKE :s OR u.username LIKE :s)', { s: `%${query.search}%` });
@@ -555,37 +609,38 @@ export class RbacService {
const total = await qb.getCount(); const total = await qb.getCount();
const users = await qb const users = await qb
.orderBy('u.name', 'ASC') .orderBy('u.name', 'ASC')
.skip(((query?.page || 1) - 1) * (query?.pageSize || 20)) .skip((page - 1) * pageSize)
.take(query?.pageSize || 20) .take(pageSize)
.getMany(); .getMany();
const list = users.map((u) => { const userIds = users.map((user) => user.id);
// TypeORM injects __ct__ and __class__ via leftJoin on non-entity relations const assignments =
const raw = u as unknown as Record<string, unknown>; userIds.length > 0
const classAssignments: Array<{ roleType?: string; subject?: string; className: string | null }> = []; ? await this.classTeacherRepo.find({ where: { userId: In(userIds) }, relations: ['class'] })
const rawCt = raw['__ct__']; : [];
if (Array.isArray(rawCt)) { const assignmentsByUser = new Map<number, ClassTeacher[]>();
for (const ct of rawCt) { for (const assignment of assignments) {
const ctRaw = ct as Record<string, unknown>; const list = assignmentsByUser.get(assignment.userId) || [];
const cls = ctRaw['__class__'] as Record<string, unknown> | undefined; list.push(assignment);
classAssignments.push({ assignmentsByUser.set(assignment.userId, list);
roleType: typeof ctRaw['roleType'] === 'string' ? ctRaw['roleType'] : undefined, }
subject: typeof ctRaw['subject'] === 'string' ? ctRaw['subject'] : undefined,
className: cls && typeof cls['name'] === 'string' ? cls['name'] : null, const list = users.map((u) => ({
}); id: u.id,
} username: u.username,
} name: u.name,
return { isActive: u.isActive,
id: u.id, profile: u.profile,
username: u.username, lastLoginAt: u.lastLoginAt,
name: u.name, roles: u.roles || [],
isActive: u.isActive, classAssignments: (assignmentsByUser.get(u.id) || []).map((assignment) => ({
profile: u.profile, id: assignment.id,
lastLoginAt: u.lastLoginAt, classId: assignment.classId,
roles: u.roles || [], roleType: assignment.roleType,
classAssignments, subject: assignment.subject,
}; className: assignment.class?.name || null,
}); })),
}));
return { list, total }; return { list, total };
} }

View File

@@ -25,6 +25,13 @@ import { NotificationsService } from '../notifications/notifications.service';
import { NotificationType } from '../entities/notification.entity'; import { NotificationType } from '../entities/notification.entity';
import { RequirePermission } from '../auth/decorators/permission.decorator'; import { RequirePermission } from '../auth/decorators/permission.decorator';
interface RequestUser {
id: number;
username: string;
permissions?: string[];
isSuperAdmin?: boolean;
}
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Controller('class-schedules') @Controller('class-schedules')
export class SchedulesController { export class SchedulesController {
@@ -34,24 +41,48 @@ export class SchedulesController {
private readonly notificationsService: NotificationsService, private readonly notificationsService: NotificationsService,
) {} ) {}
private canManageAllSchedules(user: RequestUser): boolean {
return (
user.isSuperAdmin === true ||
user.permissions?.includes('schedule:edit') === true ||
user.permissions?.includes('class:edit') === true
);
}
@Get() @Get()
@RequirePermission('schedule:view') @RequirePermission('schedule:view')
findAll(@Query() query: QueryScheduleDto) { async findAll(@Query() query: QueryScheduleDto, @Request() req: { user: RequestUser }) {
return this.service.findAll(query); const classIds = await this.service.getAccessibleClassIds(
req.user.id,
this.canManageAllSchedules(req.user),
);
return this.service.findAll(query, classIds);
} }
@Get('weekly') @Get('weekly')
@RequirePermission('schedule:view') @RequirePermission('schedule:view')
getWeeklyView(@Query() query: WeeklyViewQueryDto) { async getWeeklyView(@Query() query: WeeklyViewQueryDto, @Request() req: { user: RequestUser }) {
return this.service.getWeeklyView(query); const classIds = await this.service.getAccessibleClassIds(
req.user.id,
this.canManageAllSchedules(req.user),
);
return this.service.getWeeklyView(query, classIds);
}
@Get('classes/:classId/teachers')
@RequirePermission('schedule:view')
async getClassTeachers(@Param('classId') classId: string, @Request() req: { user: RequestUser }) {
const classIds = await this.service.getAccessibleClassIds(
req.user.id,
this.canManageAllSchedules(req.user),
);
if (classIds && !classIds.includes(+classId)) return [];
return this.service.getClassTeachers(+classId);
} }
@Get('classroom/:id/occupancy') @Get('classroom/:id/occupancy')
@RequirePermission('schedule:view') @RequirePermission('schedule:view')
getClassroomOccupancy( getClassroomOccupancy(@Param('id') id: string, @Query('date') date?: string) {
@Param('id') id: string,
@Query('date') date?: string,
) {
return this.service.getClassroomOccupancy(+id, date); return this.service.getClassroomOccupancy(+id, date);
} }
@@ -63,7 +94,10 @@ export class SchedulesController {
@Post() @Post()
@RequirePermission('schedule:create') @RequirePermission('schedule:create')
async create(@Body() dto: CreateScheduleDto, @Request() req: { user?: { id: number; username: string }; headers?: Record<string, string> }) { async create(
@Body() dto: CreateScheduleDto,
@Request() req: { user?: { id: number; username: string }; headers?: Record<string, string> },
) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
try { try {
const result = await this.service.create(dto); const result = await this.service.create(dto);
@@ -83,9 +117,16 @@ export class SchedulesController {
if (error instanceof ConflictException) { if (error instanceof ConflictException) {
try { try {
const conflicts = await this.service.checkConflict( const conflicts = await this.service.checkConflict(
dto.classroomId, dto.weekDay, dto.startTime, dto.endTime, dto.startDate, dto.endDate, dto.classroomId,
dto.weekDay,
dto.startTime,
dto.endTime,
dto.startDate,
dto.endDate,
); );
const teacherIds = [...new Set(conflicts.map(c => c.teacherId).filter((id): id is number => id != null))]; const teacherIds = [
...new Set(conflicts.map((c) => c.teacherId).filter((id): id is number => id != null)),
];
if (teacherIds.length > 0) { if (teacherIds.length > 0) {
void this.notificationsService.create({ void this.notificationsService.create({
recipientIds: teacherIds, recipientIds: teacherIds,
@@ -127,9 +168,16 @@ export class SchedulesController {
if (error instanceof ConflictException) { if (error instanceof ConflictException) {
try { try {
const conflicts = await this.service.checkConflict( const conflicts = await this.service.checkConflict(
existing.classroomId, existing.weekDay, existing.startTime, existing.endTime, existing.startDate, existing.endDate, existing.classroomId,
existing.weekDay,
existing.startTime,
existing.endTime,
existing.startDate,
existing.endDate,
); );
const teacherIds = [...new Set(conflicts.map(c => c.teacherId).filter((id): id is number => id != null))]; const teacherIds = [
...new Set(conflicts.map((c) => c.teacherId).filter((id): id is number => id != null)),
];
if (teacherIds.length > 0) { if (teacherIds.length > 0) {
void this.notificationsService.create({ void this.notificationsService.create({
recipientIds: teacherIds, recipientIds: teacherIds,
@@ -146,7 +194,10 @@ export class SchedulesController {
@Delete(':id') @Delete(':id')
@RequirePermission('schedule:delete') @RequirePermission('schedule:delete')
async remove(@Param('id') id: string, @Request() req: { user?: { id: number; username: string }; headers?: Record<string, string> }) { async remove(
@Param('id') id: string,
@Request() req: { user?: { id: number; username: string }; headers?: Record<string, string> },
) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.remove(+id); const result = await this.service.remove(+id);
await this.logService.log({ await this.logService.log({

View File

@@ -1,13 +1,17 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { ClassSchedule, Class, ClassroomRental } from '../entities'; import { ClassSchedule, Class, ClassroomRental, ClassTeacher } from '../entities';
import { SchedulesService } from './schedules.service'; import { SchedulesService } from './schedules.service';
import { SchedulesController } from './schedules.controller'; import { SchedulesController } from './schedules.controller';
import { OperationLogsModule } from '../operation-logs/operation-logs.module'; import { OperationLogsModule } from '../operation-logs/operation-logs.module';
import { NotificationsModule } from '../notifications/notifications.module'; import { NotificationsModule } from '../notifications/notifications.module';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([ClassSchedule, Class, ClassroomRental]), OperationLogsModule, NotificationsModule], imports: [
TypeOrmModule.forFeature([ClassSchedule, Class, ClassroomRental, ClassTeacher]),
OperationLogsModule,
NotificationsModule,
],
controllers: [SchedulesController], controllers: [SchedulesController],
providers: [SchedulesService], providers: [SchedulesService],
exports: [SchedulesService], exports: [SchedulesService],

View File

@@ -0,0 +1,41 @@
import { SchedulesService } from './schedules.service';
const createQb = () => ({
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
addOrderBy: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue([]),
});
describe('SchedulesService — teacher class scope', () => {
it('filters schedule list to assigned classes when no class filter is selected', async () => {
const qb = createQb();
const scheduleRepo = { createQueryBuilder: jest.fn().mockReturnValue(qb) };
const service = new SchedulesService(
scheduleRepo as never,
{} as never,
{} as never,
{} as never,
);
await service.findAll({}, [3, 5]);
expect(qb.andWhere).toHaveBeenCalledWith('cs.classId IN (:...accessibleClassIds)', {
accessibleClassIds: [3, 5],
});
});
it('returns no schedules when teacher has no assigned classes', async () => {
const qb = createQb();
const scheduleRepo = { createQueryBuilder: jest.fn().mockReturnValue(qb) };
const service = new SchedulesService(
scheduleRepo as never,
{} as never,
{} as never,
{} as never,
);
await expect(service.findAll({}, [])).resolves.toEqual([]);
expect(qb.getMany).not.toHaveBeenCalled();
});
});

View File

@@ -6,6 +6,7 @@ import { SchedulesService } from './schedules.service';
import { ClassSchedule, ScheduleType } from '../entities/class-schedule.entity'; import { ClassSchedule, ScheduleType } from '../entities/class-schedule.entity';
import { ClassroomRental } from '../entities/classroom-rental.entity'; import { ClassroomRental } from '../entities/classroom-rental.entity';
import { Class } from '../entities/class.entity'; import { Class } from '../entities/class.entity';
import { ClassTeacher } from '../entities/class-teacher.entity';
/** Build a mock query-builder where each chain method returns `this`. */ /** Build a mock query-builder where each chain method returns `this`. */
function mockQueryBuilder<T>(results: T[] = []) { function mockQueryBuilder<T>(results: T[] = []) {
@@ -34,7 +35,14 @@ describe('SchedulesService — checkConflict', () => {
SchedulesService, SchedulesService,
{ provide: getRepositoryToken(ClassSchedule), useValue: mockRepo }, { provide: getRepositoryToken(ClassSchedule), useValue: mockRepo },
{ provide: getRepositoryToken(Class), useValue: { find: jest.fn().mockResolvedValue([]) } }, { provide: getRepositoryToken(Class), useValue: { find: jest.fn().mockResolvedValue([]) } },
{ provide: getRepositoryToken(ClassroomRental), useValue: { createQueryBuilder: jest.fn() } }, {
provide: getRepositoryToken(ClassroomRental),
useValue: { createQueryBuilder: jest.fn() },
},
{
provide: getRepositoryToken(ClassTeacher),
useValue: { find: jest.fn().mockResolvedValue([]) },
},
], ],
}).compile(); }).compile();
@@ -103,7 +111,12 @@ describe('SchedulesService — checkConflict', () => {
it('overlapping classroom rental → ConflictException', async () => { it('overlapping classroom rental → ConflictException', async () => {
const qb = mockQueryBuilder<ClassSchedule>([]); const qb = mockQueryBuilder<ClassSchedule>([]);
const rentalQb = mockQueryBuilder<ClassroomRental>([ const rentalQb = mockQueryBuilder<ClassroomRental>([
{ id: 10, startDate: '2026-03-01', endDate: '2026-03-31', status: 'active' } as ClassroomRental, {
id: 10,
startDate: '2026-03-01',
endDate: '2026-03-31',
status: 'active',
} as ClassroomRental,
]); ]);
(scheduleRepo.createQueryBuilder as jest.Mock).mockReturnValue(qb); (scheduleRepo.createQueryBuilder as jest.Mock).mockReturnValue(qb);
(rentalRepo.createQueryBuilder as jest.Mock).mockReturnValue(rentalQb); (rentalRepo.createQueryBuilder as jest.Mock).mockReturnValue(rentalQb);
@@ -135,7 +148,14 @@ describe('SchedulesService — getClassroomOccupancy', () => {
SchedulesService, SchedulesService,
{ provide: getRepositoryToken(ClassSchedule), useValue: { createQueryBuilder: jest.fn() } }, { provide: getRepositoryToken(ClassSchedule), useValue: { createQueryBuilder: jest.fn() } },
{ provide: getRepositoryToken(Class), useValue: { find: jest.fn().mockResolvedValue([]) } }, { provide: getRepositoryToken(Class), useValue: { find: jest.fn().mockResolvedValue([]) } },
{ provide: getRepositoryToken(ClassroomRental), useValue: { createQueryBuilder: jest.fn() } }, {
provide: getRepositoryToken(ClassroomRental),
useValue: { createQueryBuilder: jest.fn() },
},
{
provide: getRepositoryToken(ClassTeacher),
useValue: { find: jest.fn().mockResolvedValue([]) },
},
], ],
}).compile(); }).compile();

View File

@@ -1,7 +1,12 @@
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; import {
Injectable,
NotFoundException,
ConflictException,
BadRequestException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { ClassSchedule, Class, ClassroomRental } from '../entities'; import { ClassSchedule, Class, ClassroomRental, ClassTeacher } from '../entities';
import { import {
CreateScheduleDto, CreateScheduleDto,
@@ -18,23 +23,75 @@ export class SchedulesService {
@InjectRepository(Class) private readonly classRepo: Repository<Class>, @InjectRepository(Class) private readonly classRepo: Repository<Class>,
@InjectRepository(ClassroomRental) @InjectRepository(ClassroomRental)
private readonly rentalRepo: Repository<ClassroomRental>, private readonly rentalRepo: Repository<ClassroomRental>,
@InjectRepository(ClassTeacher)
private readonly classTeacherRepo: Repository<ClassTeacher>,
) {} ) {}
async findAll(query: QueryScheduleDto) { async getAccessibleClassIds(userId: number, canManageAll = false): Promise<number[] | undefined> {
if (canManageAll) return undefined;
const assignments = await this.classTeacherRepo.find({ where: { userId } });
return [...new Set(assignments.map((assignment) => assignment.classId))];
}
async findAll(query: QueryScheduleDto, accessibleClassIds?: number[]) {
const qb = this.scheduleRepo.createQueryBuilder('cs'); const qb = this.scheduleRepo.createQueryBuilder('cs');
if (query.classroomId) qb.andWhere('cs.classroomId = :classroomId', { classroomId: query.classroomId }); if (query.classroomId)
qb.andWhere('cs.classroomId = :classroomId', { classroomId: query.classroomId });
if (query.classId) qb.andWhere('cs.classId = :classId', { classId: query.classId }); if (query.classId) qb.andWhere('cs.classId = :classId', { classId: query.classId });
else if (accessibleClassIds) {
if (accessibleClassIds.length === 0) return [];
qb.andWhere('cs.classId IN (:...accessibleClassIds)', { accessibleClassIds });
}
if (query.weekDay) qb.andWhere('cs.weekDay = :weekDay', { weekDay: query.weekDay }); if (query.weekDay) qb.andWhere('cs.weekDay = :weekDay', { weekDay: query.weekDay });
if (query.startDate) qb.andWhere('cs.startDate >= :startDate', { startDate: query.startDate }); if (query.startDate) qb.andWhere('cs.startDate >= :startDate', { startDate: query.startDate });
if (query.endDate) qb.andWhere('cs.endDate <= :endDate', { endDate: query.endDate }); if (query.endDate) qb.andWhere('cs.endDate <= :endDate', { endDate: query.endDate });
qb.orderBy('cs.weekDay', 'ASC') qb.orderBy('cs.weekDay', 'ASC').addOrderBy('cs.startTime', 'ASC');
.addOrderBy('cs.startTime', 'ASC');
return qb.getMany(); return qb.getMany();
} }
async getClassTeachers(classId: number) {
const teachers = await this.classTeacherRepo.find({
where: { classId },
relations: ['user'],
order: { roleType: 'ASC', subject: 'ASC' },
});
return teachers.map((teacher) => ({
id: teacher.id,
userId: teacher.userId,
username: teacher.user?.username,
name: teacher.user?.name,
roleType: teacher.roleType,
subject: teacher.subject,
}));
}
private async normalizeTeacherForSchedule<
T extends { classId?: number; subject?: string; teacherId?: number | null },
>(dto: T): Promise<T> {
if (!dto.classId || !dto.subject || dto.teacherId) return dto;
const teachers = await this.classTeacherRepo.find({
where: { classId: dto.classId, roleType: 'subject_teacher', subject: dto.subject },
});
if (teachers.length === 1) {
dto.teacherId = teachers[0].userId;
}
return dto;
}
private async assertTeacherAssignedToClass(
classId: number | null | undefined,
teacherId: number | null | undefined,
) {
if (!classId || !teacherId) return;
const assignment = await this.classTeacherRepo.findOne({
where: { classId, userId: teacherId },
});
if (!assignment) throw new BadRequestException('只能选择该班级已配置的教师');
}
async findOne(id: number) { async findOne(id: number) {
const schedule = await this.scheduleRepo.findOne({ where: { id } }); const schedule = await this.scheduleRepo.findOne({ where: { id } });
if (!schedule) throw new NotFoundException('排课记录不存在'); if (!schedule) throw new NotFoundException('排课记录不存在');
@@ -42,7 +99,16 @@ export class SchedulesService {
} }
async create(dto: CreateScheduleDto) { async create(dto: CreateScheduleDto) {
await this.checkConflict(dto.classroomId, dto.weekDay, dto.startTime, dto.endTime, dto.startDate, dto.endDate); await this.normalizeTeacherForSchedule(dto);
await this.assertTeacherAssignedToClass(dto.classId, dto.teacherId);
await this.checkConflict(
dto.classroomId,
dto.weekDay,
dto.startTime,
dto.endTime,
dto.startDate,
dto.endDate,
);
const schedule = this.scheduleRepo.create(dto); const schedule = this.scheduleRepo.create(dto);
const saved = await this.scheduleRepo.save(schedule); const saved = await this.scheduleRepo.save(schedule);
@@ -61,6 +127,16 @@ export class SchedulesService {
const startDate = dto.startDate ?? existing.startDate; const startDate = dto.startDate ?? existing.startDate;
const endDate = dto.endDate ?? existing.endDate; const endDate = dto.endDate ?? existing.endDate;
const normalized = await this.normalizeTeacherForSchedule({
...dto,
classId: dto.classId ?? existing.classId ?? undefined,
subject: dto.subject ?? existing.subject,
});
if (dto.teacherId === undefined && normalized.teacherId !== undefined) {
dto.teacherId = normalized.teacherId;
}
const teacherId = dto.teacherId ?? existing.teacherId;
await this.assertTeacherAssignedToClass(dto.classId ?? existing.classId, teacherId);
await this.checkConflict(classroomId, weekDay, startTime, endTime, startDate, endDate, id); await this.checkConflict(classroomId, weekDay, startTime, endTime, startDate, endDate, id);
await this.scheduleRepo.update(id, dto); await this.scheduleRepo.update(id, dto);
@@ -120,11 +196,15 @@ export class SchedulesService {
return conflicts; return conflicts;
} }
async getWeeklyView(query: WeeklyViewQueryDto) { async getWeeklyView(query: WeeklyViewQueryDto, accessibleClassIds?: number[]) {
const qb = this.scheduleRepo.createQueryBuilder('cs'); const qb = this.scheduleRepo.createQueryBuilder('cs');
if (query.classroomId) { if (query.classroomId) {
qb.andWhere('cs.classroomId = :classroomId', { classroomId: query.classroomId }); qb.andWhere('cs.classroomId = :classroomId', { classroomId: query.classroomId });
} }
if (accessibleClassIds) {
if (accessibleClassIds.length === 0) return {};
qb.andWhere('cs.classId IN (:...accessibleClassIds)', { accessibleClassIds });
}
if (query.startDate) { if (query.startDate) {
qb.andWhere('cs.endDate >= :startDate', { startDate: query.startDate }); qb.andWhere('cs.endDate >= :startDate', { startDate: query.startDate });
} }
@@ -154,16 +234,14 @@ export class SchedulesService {
.createQueryBuilder('cs') .createQueryBuilder('cs')
.where('cs.classroomId = :classroomId', { classroomId }) .where('cs.classroomId = :classroomId', { classroomId })
.andWhere('cs.status = :status', { status: 'active' }) .andWhere('cs.status = :status', { status: 'active' })
.andWhere('cs.scheduleType IN (:...scheduleTypes)', { scheduleTypes: ['INTERNAL', 'RENTAL'] }); .andWhere('cs.scheduleType IN (:...scheduleTypes)', {
scheduleTypes: ['INTERNAL', 'RENTAL'],
});
if (date) { if (date) {
qb.andWhere('cs.startDate <= :date', { date }) qb.andWhere('cs.startDate <= :date', { date }).andWhere('cs.endDate >= :date', { date });
.andWhere('cs.endDate >= :date', { date });
} }
return qb return qb.orderBy('cs.weekDay', 'ASC').addOrderBy('cs.startTime', 'ASC').getMany();
.orderBy('cs.weekDay', 'ASC')
.addOrderBy('cs.startTime', 'ASC')
.getMany();
} }
} }

View File

@@ -17,6 +17,7 @@ import {
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { Tenant } from '../entities/tenant.entity'; import { Tenant } from '../entities/tenant.entity';
import { ClassTeacher } from '../entities/class-teacher.entity';
import { FileInterceptor } from '@nestjs/platform-express'; import { FileInterceptor } from '@nestjs/platform-express';
import type { Response } from 'express'; import type { Response } from 'express';
import { StudentsService } from './students.service'; import { StudentsService } from './students.service';
@@ -30,33 +31,59 @@ import * as ExcelJS from 'exceljs';
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Controller('students') @Controller('students')
export class StudentsController { export class StudentsController {
constructor( constructor(
private service: StudentsService, private service: StudentsService,
private logService: OperationLogsService, private logService: OperationLogsService,
@InjectRepository(Tenant) private tenantRepo: Repository<Tenant>, @InjectRepository(Tenant) private tenantRepo: Repository<Tenant>,
) {} ) {}
private canManageAllStudents(user: { isSuperAdmin?: boolean; permissions?: string[] }): boolean {
return (
user.isSuperAdmin === true ||
user.permissions?.includes('student:edit') === true ||
user.permissions?.includes('class:edit') === true
);
}
@Get() @Get()
@RequirePermission('student:view') @RequirePermission('student:view')
findAll( async findAll(
@Query('name') name?: string, @Query('name') name: string | undefined,
@Query('status') status?: string, @Query('status') status: string | undefined,
@Query('includeArchived') includeArchived?: string, @Query('includeArchived') includeArchived: string | undefined,
@Query('tenantId') tenantId?: string, @Query('tenantId') tenantId: string | undefined,
@Request() req: { user: { id: number; isSuperAdmin?: boolean; permissions?: string[] } },
) { ) {
return this.service.findAll({ const classIds = await this.service.getAccessibleClassIds(
name, req.user.id,
status, this.canManageAllStudents(req.user),
includeArchived: includeArchived === 'true', );
tenantId: tenantId ? +tenantId : undefined, return this.service.findAll(
}); {
name,
status,
includeArchived: includeArchived === 'true',
tenantId: tenantId ? +tenantId : undefined,
},
classIds,
);
} }
@Get('export') @Get('export')
@RequirePermission('student:export') @RequirePermission('student:export')
async exportExcel(@Query('includeArchived') includeArchived?: string, @Res() res?: Response, @Request() req?: any) { async exportExcel(
const students = await this.service.findAll({ includeArchived: includeArchived === 'true' }); @Query('includeArchived') includeArchived?: string,
@Res() res?: Response,
@Request() req?: any,
) {
const classIds = await this.service.getAccessibleClassIds(
req.user.id,
this.canManageAllStudents(req.user),
);
const students = await this.service.findAll(
{ includeArchived: includeArchived === 'true' },
classIds,
);
const workbook = new ExcelJS.Workbook(); const workbook = new ExcelJS.Workbook();
const ws = workbook.addWorksheet('学生名单'); const ws = workbook.addWorksheet('学生名单');
ws.columns = [ ws.columns = [
@@ -303,6 +330,60 @@ export class StudentsController {
return result; return result;
} }
@Post('import-match')
@RequirePermission('student:import')
@UseInterceptors(FileInterceptor('file'))
async matchImport(@UploadedFile() file: Express.Multer.File, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(file.buffer as unknown as ArrayBuffer);
const ws = workbook.worksheets[0];
const rows: {
name: string;
phone?: string;
idNumber?: string;
gender?: string;
ethnicity?: string;
emergencyContact?: string;
emergencyPhone?: string;
organization?: string;
supervisor?: string;
tenantId?: number;
}[] = [];
ws.eachRow((row, idx) => {
if (idx === 1) return;
rows.push({
name: String(row.getCell(1).value || ''),
phone: String(row.getCell(2).value || ''),
idNumber: String(row.getCell(3).value || ''),
gender: String(row.getCell(4).value || '').trim() || undefined,
ethnicity: String(row.getCell(5).value || '').trim() || undefined,
emergencyContact: String(row.getCell(6).value || '').trim() || undefined,
emergencyPhone: String(row.getCell(7).value || '').trim() || undefined,
organization: String(row.getCell(8).value || '').trim() || undefined,
supervisor: String(row.getCell(9).value || '').trim() || undefined,
});
});
// Resolve tenant names to IDs
for (const row of rows) {
if (row.organization) {
const tenant = await this.tenantRepo.findOne({ where: { name: row.organization } });
if (tenant) row.tenantId = tenant.id;
}
}
const result = await this.service.matchImport(rows);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '学生管理',
action: '匹配导入学生',
detail: result.message,
ipAddress,
userAgent,
});
return result;
}
@Get(':id/compare-classes') @Get(':id/compare-classes')
@RequirePermission('student:view') @RequirePermission('student:view')
compareClasses(@Param('id') id: string) { compareClasses(@Param('id') id: string) {

View File

@@ -5,11 +5,21 @@ import { Class } from '../entities/class.entity';
import { Tenant } from '../entities/tenant.entity'; import { Tenant } from '../entities/tenant.entity';
import { ClassStudent } from '../entities/class-student.entity'; import { ClassStudent } from '../entities/class-student.entity';
import { AttendanceRecord } from '../entities/attendance-record.entity'; import { AttendanceRecord } from '../entities/attendance-record.entity';
import { ClassTeacher } from '../entities/class-teacher.entity';
import { StudentsService } from './students.service'; import { StudentsService } from './students.service';
import { StudentsController } from './students.controller'; import { StudentsController } from './students.controller';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([Student, Class, ClassStudent, AttendanceRecord, Tenant])], imports: [
TypeOrmModule.forFeature([
Student,
Class,
ClassStudent,
AttendanceRecord,
Tenant,
ClassTeacher,
]),
],
controllers: [StudentsController], controllers: [StudentsController],
providers: [StudentsService], providers: [StudentsService],
exports: [StudentsService], exports: [StudentsService],

View File

@@ -0,0 +1,43 @@
import { StudentsService } from './students.service';
describe('StudentsService — teacher class scope', () => {
it('limits student list to active students in assigned classes', async () => {
const repo = { find: jest.fn().mockResolvedValue([{ id: 11, name: '张三' }]) };
const classStudentRepo = {
find: jest.fn().mockResolvedValue([{ studentId: 11 }, { studentId: 11 }, { studentId: 12 }]),
};
const service = new StudentsService(
repo as never,
classStudentRepo as never,
{} as never,
{} as never,
{} as never,
);
await service.findAll({}, [3, 5]);
expect(classStudentRepo.find).toHaveBeenCalledWith({
where: { classId: expect.any(Object), status: 'active' },
});
expect(repo.find).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ id: expect.any(Object) }),
relations: ['tenant'],
}),
);
});
it('returns an empty student list when teacher has no assigned classes', async () => {
const repo = { find: jest.fn() };
const service = new StudentsService(
repo as never,
{} as never,
{} as never,
{} as never,
{} as never,
);
await expect(service.findAll({}, [])).resolves.toEqual([]);
expect(repo.find).not.toHaveBeenCalled();
});
});

View File

@@ -4,20 +4,35 @@ import { Repository, Like, Not, In, FindOptionsWhere } from 'typeorm';
import { Student } from '../entities/student.entity'; import { Student } from '../entities/student.entity';
import { Class } from '../entities/class.entity'; import { Class } from '../entities/class.entity';
import { ClassStudent } from '../entities/class-student.entity'; import { ClassStudent } from '../entities/class-student.entity';
import { ClassTeacher } from '../entities/class-teacher.entity';
import { AttendanceRecord } from '../entities/attendance-record.entity'; import { AttendanceRecord } from '../entities/attendance-record.entity';
import { CreateStudentDto, UpdateStudentDto } from './dto/student.dto'; import { CreateStudentDto, UpdateStudentDto } from './dto/student.dto';
@Injectable() @Injectable()
export class StudentsService { export class StudentsService {
constructor( constructor(
@InjectRepository(Student) private repo: Repository<Student>, @InjectRepository(Student) private repo: Repository<Student>,
@InjectRepository(ClassStudent) private classStudentRepo: Repository<ClassStudent>, @InjectRepository(ClassStudent) private classStudentRepo: Repository<ClassStudent>,
@InjectRepository(Class) private classRepo: Repository<Class>, @InjectRepository(Class) private classRepo: Repository<Class>,
@InjectRepository(AttendanceRecord) private attendanceRepo: Repository<AttendanceRecord>, @InjectRepository(AttendanceRecord) private attendanceRepo: Repository<AttendanceRecord>,
@InjectRepository(ClassTeacher) private classTeacherRepo: Repository<ClassTeacher>,
) {} ) {}
async findAll(query?: { name?: string; status?: string; includeArchived?: boolean; tenantId?: number | string }) { async getAccessibleClassIds(userId: number, canManageAll = false): Promise<number[] | undefined> {
if (canManageAll) return undefined;
const assignments = await this.classTeacherRepo.find({ where: { userId } });
return [...new Set(assignments.map((assignment) => assignment.classId))];
}
async findAll(
query?: {
name?: string;
status?: string;
includeArchived?: boolean;
tenantId?: number | string;
},
accessibleClassIds?: number[],
) {
const where: FindOptionsWhere<Student> = {}; const where: FindOptionsWhere<Student> = {};
if (query?.name) where.name = Like(`%${query.name}%`); if (query?.name) where.name = Like(`%${query.name}%`);
if (query?.tenantId) where.tenantId = Number(query.tenantId); if (query?.tenantId) where.tenantId = Number(query.tenantId);
@@ -26,6 +41,15 @@ export class StudentsService {
} else if (!query?.includeArchived) { } else if (!query?.includeArchived) {
where.status = Not(In(['archived', 'staff'])); where.status = Not(In(['archived', 'staff']));
} }
if (accessibleClassIds) {
if (accessibleClassIds.length === 0) return [];
const classStudents = await this.classStudentRepo.find({
where: { classId: In(accessibleClassIds), status: 'active' },
});
const studentIds = [...new Set(classStudents.map((item) => item.studentId))];
if (studentIds.length === 0) return [];
where.id = In(studentIds);
}
return this.repo.find({ where, order: { createdAt: 'DESC' }, relations: ['tenant'] }); return this.repo.find({ where, order: { createdAt: 'DESC' }, relations: ['tenant'] });
} }
@@ -141,6 +165,74 @@ export class StudentsService {
}; };
} }
async matchImport(
rows: {
name: string;
phone?: string;
idNumber?: string;
gender?: string;
ethnicity?: string;
emergencyContact?: string;
emergencyPhone?: string;
organization?: string;
supervisor?: string;
tenantId?: number;
}[],
) {
let matched = 0;
let skipped = 0;
for (const row of rows) {
if (!row.name || !row.name.trim()) {
skipped++;
continue;
}
// Match by phone first, then idNumber
let student = row.phone?.trim()
? await this.repo.findOne({ where: { phone: row.phone.trim() } })
: null;
if (!student && row.idNumber?.trim()) {
student = await this.repo.findOne({ where: { idNumber: row.idNumber.trim() } });
}
if (!student) {
skipped++;
continue;
}
// Update matched student with non-empty imported fields
const updates: Partial<
Pick<
Student,
| 'name'
| 'phone'
| 'idNumber'
| 'gender'
| 'ethnicity'
| 'emergencyContact'
| 'emergencyPhone'
| 'organization'
| 'supervisor'
| 'tenantId'
>
> = {};
if (row.name?.trim()) updates.name = row.name.trim();
if (row.phone?.trim()) updates.phone = row.phone.trim();
if (row.idNumber?.trim()) updates.idNumber = row.idNumber.trim();
if (row.gender) updates.gender = row.gender;
if (row.ethnicity) updates.ethnicity = row.ethnicity;
if (row.emergencyContact) updates.emergencyContact = row.emergencyContact;
if (row.emergencyPhone) updates.emergencyPhone = row.emergencyPhone;
if (row.organization) updates.organization = row.organization;
if (row.supervisor) updates.supervisor = row.supervisor;
if (row.tenantId) updates.tenantId = row.tenantId;
await this.repo.update(student.id, updates as Partial<Student>);
matched++;
}
return {
message: `匹配更新 ${matched} 人,跳过 ${skipped} 条(无匹配)`,
matched,
skipped,
};
}
async compareClasses(studentId: number) { async compareClasses(studentId: number) {
const student = await this.repo.findOne({ where: { id: studentId } }); const student = await this.repo.findOne({ where: { id: studentId } });
if (!student) throw new NotFoundException('学生不存在'); if (!student) throw new NotFoundException('学生不存在');

View File

@@ -0,0 +1,120 @@
import { ScheduleSyncService } from './schedule-sync.service';
import { ClassSchedule } from '../entities';
describe('ScheduleSyncService — absence threshold', () => {
it('updates an existing 16:00-17:00 shift so checking in before 17:00 is not absent', async () => {
const scheduleRepo = {
find: jest.fn().mockResolvedValue([
{
id: 1,
classId: 10,
classroomId: 1,
weekDay: 5,
startTime: '16:00',
endTime: '17:00',
startDate: '2026-07-01',
endDate: '2026-07-31',
status: 'active',
} as ClassSchedule,
]),
};
const classStudentRepo = {
find: jest.fn().mockResolvedValue([{ classId: 10, studentId: 20, status: 'active' }]),
};
const mappingRepo = {
find: jest.fn().mockResolvedValue([{ studentId: 20, dingUserId: 'student-1' }]),
};
const classRepo = {
find: jest.fn().mockResolvedValue([{ id: 10, name: '冲刺班' }]),
};
const dingTalkService = {
queryShifts: jest.fn().mockResolvedValue([{ id: 456, name: '排课_16:00-17:00' }]),
upsertShift: jest.fn().mockResolvedValue(456),
queryAttendanceGroups: jest.fn().mockResolvedValue([
{ group_id: 123, group_name: '排课_冲刺班', type: 'TURN', member_count: 1 },
]),
updateAttendanceGroup: jest.fn().mockResolvedValue(undefined),
createAttendanceGroup: jest.fn(),
scheduleUsers: jest.fn().mockResolvedValue(undefined),
};
const service = new ScheduleSyncService(
scheduleRepo as never,
classStudentRepo as never,
mappingRepo as never,
classRepo as never,
dingTalkService as never,
);
await service.syncAll('2026-07-10', 1);
expect(dingTalkService.upsertShift).toHaveBeenCalledWith(expect.objectContaining({
id: 456,
name: '排课_16:00-17:00',
setting: expect.objectContaining({ absenteeism_late_minutes: 60 }),
}));
});
});
describe('ScheduleSyncService — attendance machine only', () => {
it('updates a reused attendance group with machine-only restrictions', async () => {
const scheduleRepo = {
find: jest.fn().mockResolvedValue([
{
id: 1,
classId: 10,
classroomId: 1,
weekDay: 1,
startTime: '09:00',
endTime: '16:00',
startDate: '2026-07-01',
endDate: '2026-07-31',
status: 'active',
} as ClassSchedule,
]),
};
const classStudentRepo = {
find: jest.fn().mockResolvedValue([{ classId: 10, studentId: 20, status: 'active' }]),
};
const mappingRepo = {
find: jest.fn().mockResolvedValue([{ studentId: 20, dingUserId: 'student-1' }]),
};
const classRepo = {
find: jest.fn().mockResolvedValue([{ id: 10, name: '冲刺班' }]),
};
const dingTalkService = {
queryShifts: jest.fn().mockResolvedValue([{ id: 456, name: '排课_09:00-16:00' }]),
upsertShift: jest.fn().mockResolvedValue(456),
queryAttendanceGroups: jest.fn().mockResolvedValue([
{ group_id: 123, group_name: '排课_冲刺班', type: 'TURN', member_count: 1 },
]),
updateAttendanceGroup: jest.fn().mockResolvedValue(undefined),
createAttendanceGroup: jest.fn(),
scheduleUsers: jest.fn().mockResolvedValue(undefined),
};
const service = new ScheduleSyncService(
scheduleRepo as never,
classStudentRepo as never,
mappingRepo as never,
classRepo as never,
dingTalkService as never,
);
await (service.syncAll as unknown as (
dateFrom: string,
days: number,
opUserId: string,
attendanceMachineOnly: boolean,
) => Promise<unknown>)('2026-07-13', 1, 'manager', true);
expect(dingTalkService.updateAttendanceGroup).toHaveBeenCalledWith(expect.objectContaining({
id: 123,
name: '排课_冲刺班',
owner: 'manager',
shift_ids: [456],
attendance_machine_only: true,
}));
expect(dingTalkService.createAttendanceGroup).not.toHaveBeenCalled();
});
});

View File

@@ -64,11 +64,13 @@ export class ScheduleSyncService {
* @param dateFrom 起始日期YYYY-MM-DD默认今天 * @param dateFrom 起始日期YYYY-MM-DD默认今天
* @param days 同步天数,默认 30 * @param days 同步天数,默认 30
* @param opUserId 钉钉操作人 userId * @param opUserId 钉钉操作人 userId
* @param attendanceMachineOnly 是否关闭手机类打卡入口,仅使用考勤机
*/ */
async syncAll( async syncAll(
dateFrom?: string, dateFrom?: string,
days = 30, days = 30,
opUserId = 'manager', opUserId = 'manager',
attendanceMachineOnly = false,
): Promise<ScheduleSyncResult> { ): Promise<ScheduleSyncResult> {
const startDate = dateFrom || new Date().toISOString().slice(0, 10); const startDate = dateFrom || new Date().toISOString().slice(0, 10);
const endDate = this.addDays(startDate, days); const endDate = this.addDays(startDate, days);
@@ -110,20 +112,24 @@ export class ScheduleSyncService {
const shiftName = `排课_${startTime}-${endTime}`; const shiftName = `排课_${startTime}-${endTime}`;
try { try {
let shiftId = shiftByName.get(shiftName); let shiftId = shiftByName.get(shiftName);
if (shiftId === undefined) { const shiftParams = {
shiftId = await this.dingTalkService.upsertShift({ ...(shiftId === undefined ? {} : { id: shiftId }),
name: shiftName, name: shiftName,
owner: opUserId, owner: opUserId,
sections: [{ sections: [{
times: [ times: [
{ check_type: 'OnDuty', across: 0, check_time: `1970-01-01 ${startTime}:00`, free_check: false }, { check_type: 'OnDuty' as const, across: 0, check_time: `1970-01-01 ${startTime}:00`, free_check: false },
{ check_type: 'OffDuty', across: 0, check_time: `1970-01-01 ${endTime}:00`, free_check: false }, { check_type: 'OffDuty' as const, across: 0, check_time: `1970-01-01 ${endTime}:00`, free_check: false },
], ],
}], }],
setting: { is_flexible: false, serious_late_minutes: -1, absenteeism_late_minutes: -1 }, setting: {
}); is_flexible: false,
shiftByName.set(shiftName, shiftId); serious_late_minutes: -1,
} absenteeism_late_minutes: this.minutesBetween(startTime, endTime),
},
};
shiftId = await this.dingTalkService.upsertShift(shiftParams);
shiftByName.set(shiftName, shiftId);
timeToShiftId.set(key, shiftId); timeToShiftId.set(key, shiftId);
shiftCount++; shiftCount++;
} catch (e) { } catch (e) {
@@ -176,19 +182,25 @@ export class ScheduleSyncService {
let attendanceGroupId: number; let attendanceGroupId: number;
try { try {
const cached = groupByName.get(groupName); const cached = groupByName.get(groupName);
const groupParams = {
name: groupName,
type: 'TURN' as const,
owner: opUserId,
members: dingUserIds.map((uid) => ({ role: 'Attendance', type: 'StaffMember' as const, user_id: uid })),
shift_ids: [...classShiftIds],
enable_emp_select_class: true,
disable_check_without_schedule: false,
disable_check_when_rest: true,
attendance_machine_only: attendanceMachineOnly,
};
if (cached !== undefined) { if (cached !== undefined) {
attendanceGroupId = cached; attendanceGroupId = cached;
} else { await this.dingTalkService.updateAttendanceGroup({
attendanceGroupId = await this.dingTalkService.createAttendanceGroup({ ...groupParams,
name: groupName, id: attendanceGroupId,
type: 'TURN',
owner: opUserId,
members: dingUserIds.map((uid) => ({ role: 'Attendance', type: 'StaffMember', user_id: uid })),
shift_ids: [...classShiftIds],
enable_emp_select_class: true,
disable_check_without_schedule: false,
disable_check_when_rest: true,
}); });
} else {
attendanceGroupId = await this.dingTalkService.createAttendanceGroup(groupParams);
groupByName.set(groupName, attendanceGroupId); groupByName.set(groupName, attendanceGroupId);
} }
groupCount++; groupCount++;
@@ -317,6 +329,15 @@ export class ScheduleSyncService {
return items; return items;
} }
private minutesBetween(startTime: string, endTime: string): number {
const [startHour, startMinute] = startTime.split(':').map(Number);
const [endHour, endMinute] = endTime.split(':').map(Number);
const start = startHour * 60 + startMinute;
let end = endHour * 60 + endMinute;
if (end <= start) end += 24 * 60;
return end - start;
}
private addDays(dateStr: string, days: number): string { private addDays(dateStr: string, days: number): string {
const d = new Date(dateStr); const d = new Date(dateStr);
d.setDate(d.getDate() + days); d.setDate(d.getDate() + days);

View File

@@ -0,0 +1,22 @@
import { SyncController } from './sync.controller';
describe('SyncController — schedule sync options', () => {
it('forwards the attendance-machine-only option', async () => {
const syncService = {
syncScheduleToDingTalk: jest.fn().mockResolvedValue({ syncedItems: 0 }),
};
const controller = new SyncController(syncService as never);
await (controller.syncSchedule as unknown as (
dateFrom?: string,
days?: string,
attendanceMachineOnly?: string,
) => Promise<unknown>)('2026-07-10', '30', 'true');
expect(syncService.syncScheduleToDingTalk).toHaveBeenCalledWith(
'2026-07-10',
30,
true,
);
});
});

View File

@@ -67,10 +67,12 @@ export class SyncController {
async syncSchedule( async syncSchedule(
@Query('dateFrom') dateFrom?: string, @Query('dateFrom') dateFrom?: string,
@Query('days') days?: string, @Query('days') days?: string,
@Query('attendanceMachineOnly') attendanceMachineOnly?: string,
) { ) {
const result = await this.syncService.syncScheduleToDingTalk( const result = await this.syncService.syncScheduleToDingTalk(
dateFrom, dateFrom,
days ? parseInt(days, 10) : 30, days ? parseInt(days, 10) : 30,
attendanceMachineOnly === 'true',
); );
return { success: true, data: result }; return { success: true, data: result };
} }

View File

@@ -101,8 +101,12 @@ export class SyncService {
// ── 排班同步 ── // ── 排班同步 ──
/** 将本地排课同步到钉钉考勤排班 */ /** 将本地排课同步到钉钉考勤排班 */
async syncScheduleToDingTalk(dateFrom?: string, days = 30) { async syncScheduleToDingTalk(
return this.scheduleSyncService.syncAll(dateFrom, days); dateFrom?: string,
days = 30,
attendanceMachineOnly = false,
) {
return this.scheduleSyncService.syncAll(dateFrom, days, 'manager', attendanceMachineOnly);
} }
/** 获取排班同步状态(当前仅返回活跃排课统计) */ /** 获取排班同步状态(当前仅返回活跃排课统计) */

25
package-lock.json generated
View File

@@ -43,6 +43,7 @@
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
"@vitest/browser": "^4.1.10", "@vitest/browser": "^4.1.10",
"@vitest/browser-playwright": "^4.1.10",
"@vitest/coverage-v8": "^4.1.10", "@vitest/coverage-v8": "^4.1.10",
"playwright": "^1.61.1", "playwright": "^1.61.1",
"typescript": "~6.0.2", "typescript": "~6.0.2",
@@ -6537,6 +6538,30 @@
"vitest": "4.1.10" "vitest": "4.1.10"
} }
}, },
"node_modules/@vitest/browser-playwright": {
"version": "4.1.10",
"resolved": "https://registry.npmmirror.com/@vitest/browser-playwright/-/browser-playwright-4.1.10.tgz",
"integrity": "sha512-nMoXGEiRpT7m3W7NsbvrM2aKNwiNHZf+zEpUCvMteGjZFvfT96Q9fh7QyB98dvDWXiKvrLxA7bJ1mCOOv+JQPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/browser": "4.1.10",
"@vitest/mocker": "4.1.10",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"playwright": "*",
"vitest": "4.1.10"
},
"peerDependenciesMeta": {
"playwright": {
"optional": false
}
}
},
"node_modules/@vitest/browser/node_modules/magic-string": { "node_modules/@vitest/browser/node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz",