fix permissions and teacher attendance workflows
This commit is contained in:
@@ -1,35 +1,37 @@
|
||||
import React from 'react';
|
||||
import React, { Suspense, lazy } from 'react';
|
||||
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 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 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 token = localStorage.getItem('token');
|
||||
@@ -51,7 +53,9 @@ const App: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
<AntdApp>
|
||||
<AppMessageBridge />
|
||||
<BrowserRouter>
|
||||
<Suspense fallback={<div style={{ minHeight: '40vh', display: 'grid', placeItems: 'center' }}><Spin size="large" /></div>}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<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
|
||||
@@ -265,6 +276,7 @@ const App: React.FC = () => {
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</BrowserRouter>
|
||||
</AntdApp>
|
||||
</ConfigProvider>
|
||||
|
||||
15
apps/admin/src/auth/permission-store.ts
Normal file
15
apps/admin/src/auth/permission-store.ts
Normal 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));
|
||||
}
|
||||
56
apps/admin/src/components/ECharts.tsx
Normal file
56
apps/admin/src/components/ECharts.tsx
Normal 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;
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
Upload,
|
||||
Tag,
|
||||
Space,
|
||||
message,
|
||||
Popconfirm,
|
||||
Empty,
|
||||
Row,
|
||||
@@ -36,6 +35,7 @@ import dayjs from 'dayjs';
|
||||
import api from '../../api';
|
||||
import { maskPhone, maskIdNumber } from '../../utils/sensitive';
|
||||
import { useViewSensitive } from '../../hooks/useViewSensitive';
|
||||
import { message } from '../../ui/app-message';
|
||||
|
||||
// ---- Types ----
|
||||
|
||||
|
||||
@@ -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() {
|
||||
const permissions: string[] = useMemo(() => {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem('permissions') || '[]');
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const [permissions, setPermissions] = useState<string[]>(readPermissions);
|
||||
|
||||
useEffect(() => {
|
||||
const refresh = () => setPermissions(readPermissions());
|
||||
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 hasAnyPermission = (...codes: string[]): boolean =>
|
||||
codes.some((c) => permissions.includes(c));
|
||||
|
||||
const hasAllPermissions = (...codes: string[]): boolean =>
|
||||
codes.every((c) => permissions.includes(c));
|
||||
const hasPermission = useCallback(
|
||||
(code: string): boolean => permissions.includes(code),
|
||||
[permissions],
|
||||
);
|
||||
const hasAnyPermission = useCallback(
|
||||
(...codes: string[]): boolean => codes.some((code) => permissions.includes(code)),
|
||||
[permissions],
|
||||
);
|
||||
const hasAllPermissions = useCallback(
|
||||
(...codes: string[]): boolean => codes.every((code) => permissions.includes(code)),
|
||||
[permissions],
|
||||
);
|
||||
|
||||
return { permissions, hasPermission, hasAnyPermission, hasAllPermissions };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Modal, message } from 'antd';
|
||||
import { Modal } from 'antd';
|
||||
import api from '../api';
|
||||
import { message } from '../ui/app-message';
|
||||
|
||||
/**
|
||||
* Shared hook for viewing sensitive student info (phone / ID number).
|
||||
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
ApiOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { usePermission } from '../hooks/usePermission';
|
||||
import api from '../api';
|
||||
import { writePermissions } from '../auth/permission-store';
|
||||
import NotificationBell from '../components/NotificationBell';
|
||||
|
||||
const { Header, Sider, Content } = Layout;
|
||||
@@ -128,6 +130,21 @@ const MainLayout: React.FC = () => {
|
||||
const user = useMemo(() => JSON.parse(localStorage.getItem('user') || '{}'), []);
|
||||
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 isMobile = !screens.sm; // < 576px (仅 xs)
|
||||
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))}
|
||||
/>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<NotificationBell />
|
||||
{hasPermission('notification:view') && <NotificationBell />}
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
import React, { useEffect, useState, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
Table, Button, Modal, Form, DatePicker, Select, Space, message,
|
||||
Tag, Card, Input, Tooltip, Row, Col, Tabs, Alert,
|
||||
Table,
|
||||
Button,
|
||||
Modal,
|
||||
Form,
|
||||
DatePicker,
|
||||
Select,
|
||||
Space,
|
||||
Tag,
|
||||
Card,
|
||||
Input,
|
||||
Tooltip,
|
||||
Row,
|
||||
Col,
|
||||
Tabs,
|
||||
Alert,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
@@ -13,6 +26,8 @@ import {
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import api from '../../api';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import { usePermission } from '../../hooks/usePermission';
|
||||
import { message } from '../../ui/app-message';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
@@ -51,7 +66,6 @@ const SOURCE_OPTIONS = [
|
||||
{ value: 'dingtalk', label: '钉钉导入' },
|
||||
];
|
||||
|
||||
|
||||
const MATCH_STATUS_MAP: Record<string, { text: string; color: string }> = {
|
||||
unmatched: { text: '未处理', color: 'default' },
|
||||
pending: { text: '待匹配', color: 'orange' },
|
||||
@@ -98,16 +112,28 @@ interface BatchRecordInput {
|
||||
interface DingRecord {
|
||||
id: number;
|
||||
dingUserId: string;
|
||||
checkTime: string;
|
||||
rawStatus: string;
|
||||
attendanceDate: string;
|
||||
checkInTime?: string;
|
||||
checkOutTime?: string;
|
||||
timeResult: string;
|
||||
matchStatus: string;
|
||||
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 ──
|
||||
|
||||
const AttendancePage: React.FC = () => {
|
||||
const { hasAnyPermission } = usePermission();
|
||||
const canManageAllAttendance = hasAnyPermission('class:edit', 'attendance:edit');
|
||||
|
||||
// ── State ──
|
||||
const [records, setRecords] = useState<AttendanceRecordItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -127,7 +153,11 @@ const AttendancePage: React.FC = () => {
|
||||
// View toggle
|
||||
const [calendarView, setCalendarView] = useState(false);
|
||||
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);
|
||||
|
||||
@@ -143,8 +173,9 @@ const AttendancePage: React.FC = () => {
|
||||
const [dingMatchStatus, setDingMatchStatus] = useState<string | undefined>(undefined);
|
||||
// DingTalk import
|
||||
const [importModalOpen, setImportModalOpen] = useState(false);
|
||||
const [importDateRange, setImportDateRange] = useState<[Dayjs, Dayjs] | null>(null);
|
||||
const [importAutoMatch, setImportAutoMatch] = useState(true);
|
||||
const [importClassId, setImportClassId] = useState<number | undefined>(undefined);
|
||||
const [importClassOptions, setImportClassOptions] = useState<ClassOption[]>([]);
|
||||
const [importDateRange, setImportDateRange] = useState<[Dayjs, Dayjs] | null>([dayjs(), dayjs()]);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [importProgressMsg, setImportProgressMsg] = useState('');
|
||||
|
||||
@@ -152,7 +183,9 @@ const AttendancePage: React.FC = () => {
|
||||
const [matchModalOpen, setMatchModalOpen] = useState(false);
|
||||
const [matchRecordId, setMatchRecordId] = useState<number | null>(null);
|
||||
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 [matchSubmitting, setMatchSubmitting] = useState(false);
|
||||
|
||||
@@ -165,7 +198,9 @@ const AttendancePage: React.FC = () => {
|
||||
const [batchRemark, setBatchRemark] = useState('');
|
||||
const [batchSubmitting, setBatchSubmitting] = useState(false);
|
||||
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);
|
||||
|
||||
// Edit modal
|
||||
@@ -173,11 +208,14 @@ const AttendancePage: React.FC = () => {
|
||||
const [editRecord, setEditRecord] = useState<AttendanceRecordItem | null>(null);
|
||||
const [editForm] = Form.useForm();
|
||||
|
||||
|
||||
// ── Edit record ──
|
||||
const handleEdit = (record: AttendanceRecordItem) => {
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -216,7 +254,10 @@ const AttendancePage: React.FC = () => {
|
||||
if (filterStatus) params.status = filterStatus;
|
||||
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);
|
||||
setTotal(data.total);
|
||||
} catch (e: unknown) {
|
||||
@@ -235,7 +276,13 @@ const AttendancePage: React.FC = () => {
|
||||
}
|
||||
setCalendarLoading(true);
|
||||
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 },
|
||||
});
|
||||
setCalendarData(res);
|
||||
@@ -257,7 +304,9 @@ const AttendancePage: React.FC = () => {
|
||||
if (filterDateRange?.[1]) params.dateTo = filterDateRange[1].format('YYYY-MM-DD');
|
||||
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);
|
||||
setDingTotal(data.total);
|
||||
} catch (e: unknown) {
|
||||
@@ -268,8 +317,42 @@ const AttendancePage: React.FC = () => {
|
||||
}
|
||||
}, [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 ──
|
||||
const handleImportDingTalk = useCallback(async () => {
|
||||
if (!importClassId) {
|
||||
message.warning('请选择要拉取考勤的班级');
|
||||
return;
|
||||
}
|
||||
if (!importDateRange?.[0] || !importDateRange?.[1]) {
|
||||
message.warning('请选择导入日期范围');
|
||||
return;
|
||||
@@ -279,11 +362,16 @@ const AttendancePage: React.FC = () => {
|
||||
|
||||
try {
|
||||
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', {
|
||||
classId: importClassId,
|
||||
start: importDateRange[0].format('YYYY-MM-DD'),
|
||||
end: importDateRange[1].format('YYYY-MM-DD'),
|
||||
autoMatch: importAutoMatch,
|
||||
});
|
||||
|
||||
setImportProgressMsg('');
|
||||
@@ -305,7 +393,7 @@ const AttendancePage: React.FC = () => {
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
}, [importDateRange, importAutoMatch, fetchDingRecords]);
|
||||
}, [canManageAllAttendance, importClassId, importDateRange, fetchDingRecords]);
|
||||
|
||||
// ── Effects ──
|
||||
useEffect(() => {
|
||||
@@ -313,10 +401,15 @@ const AttendancePage: React.FC = () => {
|
||||
}, [fetchClasses]);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api.get<AlertItem[]>('/attendance-records/alerts')
|
||||
.then((data) => { if (!cancelled) setAlerts(data); })
|
||||
api
|
||||
.get<AlertItem[]>('/attendance-records/alerts')
|
||||
.then((data) => {
|
||||
if (!cancelled) setAlerts(data);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -342,8 +435,10 @@ const AttendancePage: React.FC = () => {
|
||||
}
|
||||
setStudentSearchLoading(true);
|
||||
try {
|
||||
const data = await api.get<{ list?: { id: number; name: string }[] } | { id: number; name: string }[]>('/students', { params: { search: value, pageSize: 10 } });
|
||||
const list = Array.isArray(data) ? data : data.list ?? [];
|
||||
const data = await api.get<
|
||||
{ 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);
|
||||
} catch {
|
||||
setStudentSearchResults([]);
|
||||
@@ -397,7 +492,15 @@ const AttendancePage: React.FC = () => {
|
||||
} finally {
|
||||
setBatchSubmitting(false);
|
||||
}
|
||||
}, [batchStudents, batchDate, batchSession, batchStatus, batchRemark, filterClassId, fetchRecords]);
|
||||
}, [
|
||||
batchStudents,
|
||||
batchDate,
|
||||
batchSession,
|
||||
batchStatus,
|
||||
batchRemark,
|
||||
filterClassId,
|
||||
fetchRecords,
|
||||
]);
|
||||
|
||||
// ── Reset filters ──
|
||||
const handleReset = useCallback(() => {
|
||||
@@ -425,8 +528,10 @@ const AttendancePage: React.FC = () => {
|
||||
}
|
||||
setMatchStudentLoading(true);
|
||||
try {
|
||||
const data = await api.get<{ list?: { id: number; name: string }[] } | { id: number; name: string }[]>('/students', { params: { search: value, pageSize: 10 } });
|
||||
const list = Array.isArray(data) ? data : data.list ?? [];
|
||||
const data = await api.get<
|
||||
{ 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);
|
||||
} catch {
|
||||
setMatchStudentResults([]);
|
||||
@@ -435,22 +540,25 @@ const AttendancePage: React.FC = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleMatchSubmit = useCallback(async (studentId: number) => {
|
||||
if (matchRecordId === null) return;
|
||||
setMatchSubmitting(true);
|
||||
try {
|
||||
await api.post(`/ding-attendance-raw/${matchRecordId}/match`, { studentId });
|
||||
message.success('匹配成功');
|
||||
setMatchModalOpen(false);
|
||||
setMatchRecordId(null);
|
||||
fetchDingRecords();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string };
|
||||
message.error(err?.message || '匹配失败');
|
||||
} finally {
|
||||
setMatchSubmitting(false);
|
||||
}
|
||||
}, [matchRecordId, fetchDingRecords]);
|
||||
const handleMatchSubmit = useCallback(
|
||||
async (studentId: number) => {
|
||||
if (matchRecordId === null) return;
|
||||
setMatchSubmitting(true);
|
||||
try {
|
||||
await api.post(`/ding-attendance-raw/${matchRecordId}/match`, { studentId });
|
||||
message.success('匹配成功');
|
||||
setMatchModalOpen(false);
|
||||
setMatchRecordId(null);
|
||||
fetchDingRecords();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string };
|
||||
message.error(err?.message || '匹配失败');
|
||||
} finally {
|
||||
setMatchSubmitting(false);
|
||||
}
|
||||
},
|
||||
[matchRecordId, fetchDingRecords],
|
||||
);
|
||||
|
||||
// ── Report download ──
|
||||
const handleExportReport = useCallback(() => {
|
||||
@@ -491,15 +599,17 @@ const AttendancePage: React.FC = () => {
|
||||
},
|
||||
{
|
||||
title: '打卡时间',
|
||||
dataIndex: 'checkTime',
|
||||
key: 'checkTime',
|
||||
width: 160,
|
||||
render: (v: string) => (v ? dayjs(v).format('YYYY-MM-DD HH:mm:ss') : '-'),
|
||||
width: 180,
|
||||
render: (_: unknown, record: DingRecord) => {
|
||||
const value = record.checkInTime || record.checkOutTime;
|
||||
return value ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : record.attendanceDate || '-';
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '打卡状态',
|
||||
dataIndex: 'rawStatus',
|
||||
key: 'rawStatus',
|
||||
dataIndex: 'timeResult',
|
||||
key: 'timeResult',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
@@ -519,9 +629,14 @@ const AttendancePage: React.FC = () => {
|
||||
render: (_: unknown, record: DingRecord) => {
|
||||
if (record.matchStatus === 'matched') return <span>-</span>;
|
||||
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,
|
||||
fixed: 'right' as const,
|
||||
render: (_: unknown, record: AttendanceRecordItem) => (
|
||||
<PermissionButton permission="attendance:edit" size="small" onClick={() => handleEdit(record)}>
|
||||
<PermissionButton
|
||||
permission="attendance:edit"
|
||||
size="small"
|
||||
onClick={() => handleEdit(record)}
|
||||
>
|
||||
编辑
|
||||
</PermissionButton>
|
||||
),
|
||||
@@ -616,46 +735,68 @@ const AttendancePage: React.FC = () => {
|
||||
return Array.from(dates).sort();
|
||||
}, [calendarData]);
|
||||
|
||||
const calendarColumns = useMemo(() => [
|
||||
{
|
||||
title: '学生',
|
||||
dataIndex: 'studentName',
|
||||
key: 'studentName',
|
||||
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>
|
||||
);
|
||||
const calendarColumns = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: '学生',
|
||||
dataIndex: 'studentName',
|
||||
key: 'studentName',
|
||||
width: 100,
|
||||
fixed: 'left' as const,
|
||||
},
|
||||
})),
|
||||
], [calendarDates]);
|
||||
...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],
|
||||
);
|
||||
|
||||
// ── Render ──
|
||||
return (
|
||||
<div>
|
||||
{alerts.length > 0 && (
|
||||
<Alert type="warning" showIcon closable
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
closable
|
||||
title={`考勤预警:${alerts.length} 名学生异常`}
|
||||
description={alerts.map(a => `${a.studentName}(${a.className || '-'}):${a.type} ${a.count}次,最近${a.lastDate}`).join(';')}
|
||||
style={{ marginBottom: 16 }} />)}
|
||||
description={alerts
|
||||
.map(
|
||||
(a) =>
|
||||
`${a.studentName}(${a.className || '-'}):${a.type} ${a.count}次,最近${a.lastDate}`,
|
||||
)
|
||||
.join(';')}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
)}
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
@@ -752,12 +893,13 @@ const AttendancePage: React.FC = () => {
|
||||
>
|
||||
批量录入
|
||||
</PermissionButton>
|
||||
<Button
|
||||
<PermissionButton
|
||||
permission="attendance:export"
|
||||
icon={<ExportOutlined />}
|
||||
onClick={handleExportReport}
|
||||
>
|
||||
导出考勤报表
|
||||
</Button>
|
||||
</PermissionButton>
|
||||
</Space>
|
||||
<Button
|
||||
icon={calendarView ? <UnorderedListOutlined /> : <CalendarOutlined />}
|
||||
@@ -858,14 +1000,15 @@ const AttendancePage: React.FC = () => {
|
||||
/>
|
||||
</Col>
|
||||
<Col>
|
||||
<Button
|
||||
<PermissionButton
|
||||
permission="attendance:create"
|
||||
type="primary"
|
||||
icon={<CloudDownloadOutlined />}
|
||||
loading={importing}
|
||||
onClick={() => setImportModalOpen(true)}
|
||||
onClick={openDingTalkImportModal}
|
||||
>
|
||||
从钉钉拉取考勤
|
||||
</Button>
|
||||
</PermissionButton>
|
||||
</Col>
|
||||
</Row>
|
||||
{importing && (
|
||||
@@ -996,39 +1139,53 @@ const AttendancePage: React.FC = () => {
|
||||
|
||||
{/* ── DingTalk import modal ── */}
|
||||
<Modal
|
||||
title="从钉钉拉取考勤数据"
|
||||
title={canManageAllAttendance ? '从钉钉拉取考勤数据' : '同步今日考勤'}
|
||||
open={importModalOpen}
|
||||
onOk={handleImportDingTalk}
|
||||
onCancel={() => { setImportModalOpen(false); setImportDateRange(null); setImportProgressMsg(''); }}
|
||||
onCancel={closeDingTalkImportModal}
|
||||
confirmLoading={importing}
|
||||
okText="开始拉取"
|
||||
okText={canManageAllAttendance ? '开始拉取' : '同步今日'}
|
||||
cancelText="取消"
|
||||
>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="日期范围" required>
|
||||
<RangePicker
|
||||
value={importDateRange}
|
||||
onChange={(dates) => setImportDateRange(dates as [Dayjs, Dayjs] | null)}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={['开始日期', '结束日期']}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="自动匹配">
|
||||
<Form.Item label="班级" required>
|
||||
<Select
|
||||
value={importAutoMatch ? 'yes' : 'no'}
|
||||
onChange={(v) => setImportAutoMatch(v === 'yes')}
|
||||
options={[
|
||||
{ value: 'yes', label: '是 — 导入后按姓名自动匹配学生' },
|
||||
{ value: 'no', label: '否 — 仅导入原始数据,稍后手动匹配' },
|
||||
]}
|
||||
value={importClassId}
|
||||
onChange={setImportClassId}
|
||||
placeholder="选择自己任教的班级"
|
||||
options={importClassOptions.map((item) => ({
|
||||
value: item.classId,
|
||||
label: item.className,
|
||||
}))}
|
||||
/>
|
||||
</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>
|
||||
</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.Item name="session" label="时段" rules={[{ required: true }]}>
|
||||
<Select options={SESSION_OPTIONS.map((s) => ({ value: s.value, label: s.label }))} />
|
||||
@@ -1083,7 +1240,6 @@ const AttendancePage: React.FC = () => {
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
Form,
|
||||
DatePicker,
|
||||
Space,
|
||||
message,
|
||||
Tag,
|
||||
Descriptions,
|
||||
Popconfirm,
|
||||
@@ -25,6 +24,7 @@ import dayjs from 'dayjs';
|
||||
import api from '../../api';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import { downloadBlob } from '../../utils/download';
|
||||
import { message } from '../../ui/app-message';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
|
||||
@@ -2,13 +2,14 @@ import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
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';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { ArrowLeftOutlined, PlusOutlined, DownloadOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import api from '../../api';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import { message } from '../../ui/app-message';
|
||||
|
||||
// ---- Types ----
|
||||
|
||||
@@ -317,7 +318,7 @@ const ClassDetailPage: React.FC = () => {
|
||||
title: '操作',
|
||||
render: (_: unknown, r: ClassStudent) => (
|
||||
<Popconfirm title="确认移除?" onConfirm={() => handleRemoveStudent(r.studentId)}>
|
||||
<Button size="small" danger>移除</Button>
|
||||
<PermissionButton permission="class:edit" size="small" danger>移除</PermissionButton>
|
||||
</Popconfirm>
|
||||
),
|
||||
},
|
||||
@@ -339,7 +340,7 @@ const ClassDetailPage: React.FC = () => {
|
||||
title: '操作',
|
||||
render: (_: unknown, r: ClassTeacher) => (
|
||||
<Popconfirm title="确认移除?" onConfirm={() => handleRemoveTeacher(r.userId)}>
|
||||
<Button size="small" danger>移除</Button>
|
||||
<PermissionButton permission="class:edit" size="small" danger>移除</PermissionButton>
|
||||
</Popconfirm>
|
||||
),
|
||||
},
|
||||
@@ -430,9 +431,9 @@ const ClassDetailPage: React.FC = () => {
|
||||
<Input.TextArea rows={3} />
|
||||
</Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" onClick={handleSaveInfo}>
|
||||
<PermissionButton permission="class:edit" type="primary" onClick={handleSaveInfo}>
|
||||
保存
|
||||
</Button>
|
||||
</PermissionButton>
|
||||
<Button onClick={() => setEditingInfo(false)}>取消</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
@@ -443,10 +444,10 @@ const ClassDetailPage: React.FC = () => {
|
||||
{TYPE_MAP[detail.classType]}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="开班日期">
|
||||
{detail.startDate || '-'}
|
||||
{detail.startDate ? dayjs(detail.startDate).format('YYYY-MM-DD') : '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="结课日期">
|
||||
{detail.endDate || '-'}
|
||||
{detail.endDate ? dayjs(detail.endDate).format('YYYY-MM-DD') : '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="学员">
|
||||
{detail.studentCount}/{detail.maxStudents || '-'}
|
||||
@@ -487,14 +488,15 @@ const ClassDetailPage: React.FC = () => {
|
||||
label: `花名册 (${students.filter((s) => s.status === 'active').length})`,
|
||||
children: (
|
||||
<div>
|
||||
<Button
|
||||
<PermissionButton
|
||||
permission="class:edit"
|
||||
icon={<PlusOutlined />}
|
||||
type="primary"
|
||||
onClick={openStudentModal}
|
||||
style={{ marginBottom: 16, marginRight: 8 }}
|
||||
>
|
||||
添加学员
|
||||
</Button>
|
||||
</PermissionButton>
|
||||
<PermissionButton
|
||||
permission="class:view"
|
||||
icon={<DownloadOutlined />}
|
||||
@@ -556,14 +558,15 @@ const ClassDetailPage: React.FC = () => {
|
||||
label: `教师 (${teachers.length})`,
|
||||
children: (
|
||||
<div>
|
||||
<Button
|
||||
<PermissionButton
|
||||
permission="class:edit"
|
||||
icon={<PlusOutlined />}
|
||||
type="primary"
|
||||
onClick={openTeacherModal}
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
添加教师
|
||||
</Button>
|
||||
</PermissionButton>
|
||||
<Table<ClassTeacher>
|
||||
columns={teacherColumns}
|
||||
dataSource={teachers}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useState, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
Table, Button, Input, Select, Space, Tag, Modal, Form, InputNumber,
|
||||
DatePicker, Popconfirm, message, Card, Switch, Empty,
|
||||
DatePicker, Popconfirm, Card, Switch, Empty,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { PlusOutlined, SearchOutlined, TeamOutlined, InboxOutlined } from '@ant-design/icons';
|
||||
@@ -9,6 +9,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import dayjs from 'dayjs';
|
||||
import api from '../../api';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import { message } from '../../ui/app-message';
|
||||
|
||||
// ---- Types ----
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
InputNumber,
|
||||
Input,
|
||||
Space,
|
||||
message,
|
||||
Tag,
|
||||
Popconfirm,
|
||||
Upload,
|
||||
@@ -21,6 +20,13 @@ import dayjs, { Dayjs } from 'dayjs';
|
||||
import api from '../../api';
|
||||
import { downloadBlob } from '../../utils/download';
|
||||
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 [data, setData] = useState<any[]>([]);
|
||||
@@ -33,6 +39,11 @@ const ClassroomRentalsPage: React.FC = () => {
|
||||
const [filterMonth, setFilterMonth] = useState<Dayjs | null>(null);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
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(() => {
|
||||
if (!searchText) return data;
|
||||
@@ -74,8 +85,84 @@ const ClassroomRentalsPage: React.FC = () => {
|
||||
fetchData();
|
||||
}, [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 values = await form.validateFields();
|
||||
if (rangeIncludesUnavailableDate(values.dateRange)) {
|
||||
message.error('所选日期范围包含已排课或已租赁日期,请重新选择');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
const payload = {
|
||||
classroomId: values.classroomId,
|
||||
@@ -124,10 +211,7 @@ const ClassroomRentalsPage: React.FC = () => {
|
||||
|
||||
const handleDownloadContract = async (id: number, filename?: string) => {
|
||||
try {
|
||||
await downloadBlob(
|
||||
`/classroom-rentals/${id}/contract`,
|
||||
filename || `contract-${id}.pdf`,
|
||||
);
|
||||
await downloadBlob(`/classroom-rentals/${id}/contract`, filename || `contract-${id}.pdf`);
|
||||
} catch {
|
||||
message.error('下载失败(可能文件已丢失)');
|
||||
}
|
||||
@@ -145,6 +229,7 @@ const ClassroomRentalsPage: React.FC = () => {
|
||||
|
||||
const openEdit = (record: any) => {
|
||||
setEditing(record);
|
||||
resetUnavailableDates();
|
||||
form.setFieldsValue({
|
||||
classroomId: record.classroomId,
|
||||
tenantId: record.tenantId,
|
||||
@@ -154,115 +239,145 @@ const ClassroomRentalsPage: React.FC = () => {
|
||||
notes: record.notes,
|
||||
});
|
||||
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(() => [
|
||||
{
|
||||
title: '教室', width: 120,
|
||||
dataIndex: 'classroom',
|
||||
render: (c: any) =>
|
||||
c ? (
|
||||
<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}天`;
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: '教室',
|
||||
width: 120,
|
||||
dataIndex: 'classroom',
|
||||
render: (c: any) =>
|
||||
c ? (
|
||||
<span>
|
||||
{c.building ? `${c.building} · ` : ''}
|
||||
{c.name}
|
||||
</span>
|
||||
) : (
|
||||
'-'
|
||||
),
|
||||
},
|
||||
},
|
||||
{ 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)}
|
||||
>
|
||||
下载
|
||||
{
|
||||
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: '总额',
|
||||
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>
|
||||
</Tooltip>
|
||||
<Popconfirm title="删除合同文件?" onConfirm={() => handleDeleteContract(r.id)}>
|
||||
<Button size="small" danger icon={<DeleteOutlined />} aria-label="删除合同文件" />
|
||||
</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>
|
||||
) : (
|
||||
<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 (
|
||||
<div>
|
||||
@@ -301,6 +416,7 @@ const ClassroomRentalsPage: React.FC = () => {
|
||||
onClick={() => {
|
||||
setEditing(null);
|
||||
form.resetFields();
|
||||
resetUnavailableDates();
|
||||
setModalOpen(true);
|
||||
}}
|
||||
>
|
||||
@@ -323,6 +439,7 @@ const ClassroomRentalsPage: React.FC = () => {
|
||||
onCancel={() => {
|
||||
setModalOpen(false);
|
||||
setEditing(null);
|
||||
resetUnavailableDates();
|
||||
}}
|
||||
confirmLoading={saving}
|
||||
okText="保存"
|
||||
@@ -334,6 +451,7 @@ const ClassroomRentalsPage: React.FC = () => {
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder="选择教室"
|
||||
onChange={handleClassroomChange}
|
||||
options={classrooms.map((c) => ({
|
||||
value: c.id,
|
||||
label: `${c.building ? c.building + ' · ' : ''}${c.name}(${c.roomType})`,
|
||||
@@ -353,6 +471,9 @@ const ClassroomRentalsPage: React.FC = () => {
|
||||
style={{ width: '100%' }}
|
||||
placeholder={['开始日期', '结束日期']}
|
||||
format="YYYY-MM-DD"
|
||||
disabled={!selectedClassroomId}
|
||||
disabledDate={(date) => unavailableDatesLoading || isDateUnavailable(date)}
|
||||
onPanelChange={(dates) => dates.forEach((date) => date && handleCalendarChange(date))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="dailyRate" label="日租金(可选)">
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -12,12 +12,12 @@ import {
|
||||
Spin,
|
||||
Empty,
|
||||
Tooltip,
|
||||
message,
|
||||
} from 'antd';
|
||||
} from 'antd';
|
||||
import { CalendarOutlined, FileTextOutlined } from '@ant-design/icons';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import api from '../../api';
|
||||
import { downloadBlob } from '../../utils/download';
|
||||
import { message } from '../../ui/app-message';
|
||||
|
||||
interface ScheduleData {
|
||||
year: number;
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
InputNumber,
|
||||
Select,
|
||||
Space,
|
||||
message,
|
||||
Tag,
|
||||
Popconfirm,
|
||||
Upload,
|
||||
@@ -24,6 +23,7 @@ import {
|
||||
} from '@ant-design/icons';
|
||||
import api from '../../api';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import { message } from '../../ui/app-message';
|
||||
|
||||
const statusMap: Record<string, { text: string; color: string }> = {
|
||||
available: { text: '可用', color: 'green' },
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Row,
|
||||
Col,
|
||||
Card,
|
||||
Statistic,
|
||||
DatePicker,
|
||||
Spin,
|
||||
Grid,
|
||||
message,
|
||||
Collapse,
|
||||
} from 'antd';
|
||||
import { Row, Col, Card, Statistic, DatePicker, Spin, Grid, Collapse } from 'antd';
|
||||
import {
|
||||
TeamOutlined,
|
||||
HomeOutlined,
|
||||
@@ -25,10 +15,11 @@ import {
|
||||
ExclamationCircleOutlined,
|
||||
DollarOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import ReactECharts, { type EChartsOption } from '../../components/ECharts';
|
||||
import dayjs from 'dayjs';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import api from '../../api';
|
||||
import { message } from '../../ui/app-message';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
@@ -43,14 +34,48 @@ const COLORS = [
|
||||
'#FFCC00',
|
||||
];
|
||||
|
||||
interface BillStatRow { status: string; count: string; total: string }
|
||||
interface ClassAttendanceRank { className: 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 BillStatRow {
|
||||
status: string;
|
||||
count: string;
|
||||
total: string;
|
||||
}
|
||||
interface ClassAttendanceRank {
|
||||
className: 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 {
|
||||
studentName: string;
|
||||
studentId?: string;
|
||||
@@ -160,7 +185,10 @@ const DashboardPage: React.FC = () => {
|
||||
const isMobile = !screens.sm;
|
||||
const navigate = useNavigate();
|
||||
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 [ganttData, setGanttData] = useState<GanttRoom[]>([]);
|
||||
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', {
|
||||
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', {
|
||||
params: { periodStart: period[0], periodEnd: period[1] },
|
||||
}),
|
||||
@@ -215,56 +245,69 @@ const DashboardPage: React.FC = () => {
|
||||
const [expenseTypeMap, setExpenseTypeMap] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
api.get<Array<{ code: string; name: string }>>('/expense-types').then((types) => {
|
||||
const map: Record<string, string> = {};
|
||||
for (const t of types) map[t.code] = t.name;
|
||||
setExpenseTypeMap(map);
|
||||
}).catch(() => {});
|
||||
api
|
||||
.get<Array<{ code: string; name: string }>>('/expense-types')
|
||||
.then((types) => {
|
||||
const map: Record<string, string> = {};
|
||||
for (const t of types) map[t.code] = t.name;
|
||||
setExpenseTypeMap(map);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// ─── 图表 option 计算(保留全部原有逻辑) ───
|
||||
|
||||
// 今日出勤状态分布环图
|
||||
const attendanceRingOption = useMemo(() => ({
|
||||
tooltip: { trigger: 'item' },
|
||||
legend: { bottom: 0 },
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: ['40%', '70%'],
|
||||
center: ['50%', '45%'],
|
||||
data: Object.entries(stats?.attendanceByStatus ?? {}).map(([status, count]) => ({
|
||||
name: attendanceLabelMap[status] ?? status,
|
||||
value: count,
|
||||
})),
|
||||
itemStyle: { borderRadius: 4, borderColor: '#fff', borderWidth: 2 },
|
||||
},
|
||||
],
|
||||
color: COLORS,
|
||||
}), [stats?.attendanceByStatus]);
|
||||
const attendanceRingOption = useMemo<EChartsOption>(
|
||||
() => ({
|
||||
tooltip: { trigger: 'item' },
|
||||
legend: { bottom: 0 },
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: ['40%', '70%'],
|
||||
center: ['50%', '45%'],
|
||||
data: Object.entries(stats?.attendanceByStatus ?? {}).map(([status, count]) => ({
|
||||
name: attendanceLabelMap[status] ?? status,
|
||||
value: count,
|
||||
})),
|
||||
itemStyle: { borderRadius: 4, borderColor: '#fff', borderWidth: 2 },
|
||||
},
|
||||
],
|
||||
color: COLORS,
|
||||
}),
|
||||
[stats?.attendanceByStatus],
|
||||
);
|
||||
|
||||
// 宿舍费用排行
|
||||
const barOption = useMemo(() => ({
|
||||
tooltip: {},
|
||||
grid: { left: 80, right: 20, bottom: 30, top: 10 },
|
||||
xAxis: { type: 'value' },
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
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] },
|
||||
const barOption = useMemo<EChartsOption>(
|
||||
() => ({
|
||||
tooltip: {},
|
||||
grid: { left: 80, right: 20, bottom: 30, top: 10 },
|
||||
xAxis: { type: 'value' },
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
data: roomRanking.map((r) => r.roomNumber).reverse(),
|
||||
inverse: false,
|
||||
},
|
||||
],
|
||||
}), [roomRanking]);
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
data: roomRanking.map((r) => Number(r.total)).reverse(),
|
||||
itemStyle: { color: '#007AFF', borderRadius: [0, 4, 4, 0] },
|
||||
},
|
||||
],
|
||||
}),
|
||||
[roomRanking],
|
||||
);
|
||||
|
||||
// 班级考勤排行 - 前5
|
||||
const classRankingTopOption = {
|
||||
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' }, valueFormatter: (v: number) => `${v}%` },
|
||||
const classRankingTopOption: EChartsOption = {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'shadow' },
|
||||
valueFormatter: (v: number) => `${v}%`,
|
||||
},
|
||||
grid: { left: 80, right: 30, bottom: 30, top: 10 },
|
||||
xAxis: { type: 'value', max: 100, axisLabel: { formatter: '{value}%' } },
|
||||
yAxis: {
|
||||
@@ -283,8 +326,12 @@ const DashboardPage: React.FC = () => {
|
||||
};
|
||||
|
||||
// 班级考勤排行 - 后5
|
||||
const classRankingBottomOption = {
|
||||
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' }, valueFormatter: (v: number) => `${v}%` },
|
||||
const classRankingBottomOption: EChartsOption = {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'shadow' },
|
||||
valueFormatter: (v: number) => `${v}%`,
|
||||
},
|
||||
grid: { left: 80, right: 30, bottom: 30, top: 10 },
|
||||
xAxis: { type: 'value', max: 100, axisLabel: { formatter: '{value}%' } },
|
||||
yAxis: {
|
||||
@@ -303,7 +350,7 @@ const DashboardPage: React.FC = () => {
|
||||
};
|
||||
|
||||
// 考勤趋势折线图
|
||||
const attendanceLineOption = {
|
||||
const attendanceLineOption: EChartsOption = {
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { left: 50, right: 20, bottom: 30, top: 10 },
|
||||
xAxis: {
|
||||
@@ -325,14 +372,17 @@ const DashboardPage: React.FC = () => {
|
||||
};
|
||||
|
||||
// 收入趋势折线图
|
||||
const incomeLineOption = {
|
||||
const incomeLineOption: EChartsOption = {
|
||||
tooltip: { trigger: 'axis', valueFormatter: (v: number) => `¥${v.toLocaleString()}` },
|
||||
grid: { left: 70, right: 20, bottom: 30, top: 10 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
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: [
|
||||
{
|
||||
type: 'line',
|
||||
@@ -346,57 +396,68 @@ const DashboardPage: React.FC = () => {
|
||||
};
|
||||
|
||||
// 入住时间线(甘特图)
|
||||
const ganttOption = useMemo(() => ({
|
||||
tooltip: {
|
||||
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 },
|
||||
};
|
||||
const ganttOption = useMemo<EChartsOption>(
|
||||
() => ({
|
||||
tooltip: {
|
||||
formatter: (p: { data: { name: string; value: [string, string, string, boolean] } }) =>
|
||||
`${p.data.name}<br/>入住: ${p.data.value[1]}<br/>退宿: ${p.data.value[2]}`,
|
||||
},
|
||||
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]);
|
||||
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 },
|
||||
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 ───
|
||||
const classroomHeatmapVp = useInViewport('200px');
|
||||
@@ -425,7 +486,9 @@ const DashboardPage: React.FC = () => {
|
||||
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
|
||||
aria-label="选择日期范围"
|
||||
value={[dayjs(period[0]), dayjs(period[1])]}
|
||||
@@ -445,14 +508,22 @@ const DashboardPage: React.FC = () => {
|
||||
styles={{ body: { padding: 16 } }}
|
||||
onClick={() => navigate('/attendance')}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div
|
||||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}
|
||||
>
|
||||
<ExclamationCircleOutlined
|
||||
style={{ fontSize: 28, color: absentCount > 0 ? '#FF9500' : '#999' }}
|
||||
/>
|
||||
<ArrowRightOutlined style={{ color: '#bbb' }} />
|
||||
</div>
|
||||
<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}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: '#666', marginTop: 2 }}>今日缺勤人数</div>
|
||||
@@ -472,18 +543,28 @@ const DashboardPage: React.FC = () => {
|
||||
styles={{ body: { padding: 16 } }}
|
||||
onClick={() => navigate('/bills')}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div
|
||||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}
|
||||
>
|
||||
<DollarOutlined
|
||||
style={{ fontSize: 28, color: draftCount > 0 ? '#AF52DE' : '#999' }}
|
||||
/>
|
||||
<ArrowRightOutlined style={{ color: '#bbb' }} />
|
||||
</div>
|
||||
<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}
|
||||
</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()}` : '暂无待处理'}
|
||||
</div>
|
||||
</div>
|
||||
@@ -497,18 +578,32 @@ const DashboardPage: React.FC = () => {
|
||||
styles={{ body: { padding: 16 } }}
|
||||
onClick={() => navigate('/deposits')}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div
|
||||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}
|
||||
>
|
||||
<BankOutlined
|
||||
style={{ fontSize: 28, color: pendingDeposits > 0 ? '#FF3B30' : '#999' }}
|
||||
/>
|
||||
<ArrowRightOutlined style={{ color: '#bbb' }} />
|
||||
</div>
|
||||
<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()}
|
||||
</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 ? '需要处理' : '暂无待退'}
|
||||
</div>
|
||||
</div>
|
||||
@@ -577,90 +672,135 @@ const DashboardPage: React.FC = () => {
|
||||
{/* ═══════════ 更多指标(折叠) ═══════════ */}
|
||||
<Collapse
|
||||
ghost
|
||||
items={[{
|
||||
key: 'more-metrics',
|
||||
label: '更多指标',
|
||||
children: (
|
||||
<>
|
||||
<Row gutter={[16, 16]} style={SECTION_ROW_STYLE}>
|
||||
<Col xs={12} sm={12} md={6}>
|
||||
<Card>
|
||||
<Statistic title="宿舍总数" value={stats?.totalRooms || 0} prefix={<HomeOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={12} md={6}>
|
||||
<Card>
|
||||
<Statistic title="在读学生" value={stats?.totalStudents || 0} prefix={<TeamOutlined />} />
|
||||
</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 />} />
|
||||
items={[
|
||||
{
|
||||
key: 'more-metrics',
|
||||
label: '更多指标',
|
||||
children: (
|
||||
<>
|
||||
<Row gutter={[16, 16]} style={SECTION_ROW_STYLE}>
|
||||
<Col xs={12} sm={12} md={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="宿舍总数"
|
||||
value={stats?.totalRooms || 0}
|
||||
prefix={<HomeOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Statistic title="今日使用" value={classroomUtil?.inUseCount ?? '-'} prefix={<CheckCircleOutlined />} />
|
||||
<Col xs={12} sm={12} md={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="在读学生"
|
||||
value={stats?.totalStudents || 0}
|
||||
prefix={<TeamOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</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 xs={12} sm={12} md={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="教室总数"
|
||||
value={stats?.classroomCount || 0}
|
||||
prefix={<BankOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Statistic title="内部排课" value={classroomUtil?.scheduleCount ?? '-'} prefix={<CalendarOutlined />} />
|
||||
<Col xs={12} sm={12} md={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="班级总数"
|
||||
value={stats?.classCount ?? 0}
|
||||
prefix={<TeamOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</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}>
|
||||
<Card title="考勤趋势(近30天)">
|
||||
{(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>
|
||||
)}
|
||||
@@ -677,7 +820,10 @@ const DashboardPage: React.FC = () => {
|
||||
<Col xs={24} sm={12}>
|
||||
<Card title="今日出勤状态分布">
|
||||
{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>
|
||||
)}
|
||||
@@ -690,7 +836,10 @@ const DashboardPage: React.FC = () => {
|
||||
<Col xs={24} sm={12}>
|
||||
<Card title="班级出勤率 TOP 5">
|
||||
{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>
|
||||
)}
|
||||
@@ -699,7 +848,10 @@ const DashboardPage: React.FC = () => {
|
||||
<Col xs={24} sm={12}>
|
||||
<Card title="班级出勤率 末位 5">
|
||||
{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>
|
||||
)}
|
||||
@@ -711,13 +863,28 @@ const DashboardPage: React.FC = () => {
|
||||
<Row gutter={[16, 16]} style={SECTION_ROW_STYLE}>
|
||||
<Col xs={24} sm={12}>
|
||||
<Card title="费用类型分布">
|
||||
{((stats?.expenseByType) ?? []).length > 0 ? (
|
||||
<ReactECharts option={{
|
||||
tooltip: { trigger: 'item' },
|
||||
legend: { bottom: 0 },
|
||||
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) })) }],
|
||||
}} style={{ width: '100%', height: isMobile ? 250 : 300 }} />
|
||||
{(stats?.expenseByType ?? []).length > 0 ? (
|
||||
<ReactECharts
|
||||
option={
|
||||
{
|
||||
tooltip: { trigger: 'item' },
|
||||
legend: { bottom: 0 },
|
||||
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>
|
||||
)}
|
||||
@@ -726,7 +893,10 @@ const DashboardPage: React.FC = () => {
|
||||
<Col xs={24} sm={12}>
|
||||
<Card title="宿舍费用排行 TOP 20">
|
||||
{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>
|
||||
)}
|
||||
@@ -739,7 +909,10 @@ const DashboardPage: React.FC = () => {
|
||||
<Col xs={24}>
|
||||
<Card title="月度收入趋势">
|
||||
{(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>
|
||||
)}
|
||||
@@ -754,27 +927,60 @@ const DashboardPage: React.FC = () => {
|
||||
<Col xs={24}>
|
||||
<Card title="教室占用热力图">
|
||||
{classroomOccupancy.length > 0 ? (
|
||||
<ReactECharts option={{
|
||||
tooltip: {
|
||||
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)}%`,
|
||||
},
|
||||
grid: { left: 100, right: 20, bottom: 30, top: 10 },
|
||||
xAxis: { type: 'value', max: 1 },
|
||||
yAxis: { type: 'category', data: classroomOccupancy.map((r) => r.name), inverse: true },
|
||||
visualMap: {
|
||||
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)}%` },
|
||||
}],
|
||||
}} style={{ width: '100%', height: isMobile ? 300 : 400 }} />
|
||||
<ReactECharts
|
||||
option={
|
||||
{
|
||||
tooltip: {
|
||||
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)}%`,
|
||||
},
|
||||
grid: { left: 100, right: 20, bottom: 30, top: 10 },
|
||||
xAxis: { type: 'value', max: 1 },
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
data: classroomOccupancy.map((r) => r.name),
|
||||
inverse: true,
|
||||
},
|
||||
visualMap: {
|
||||
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>
|
||||
</Col>
|
||||
@@ -793,9 +999,14 @@ const DashboardPage: React.FC = () => {
|
||||
<Col xs={24}>
|
||||
<Card title="入住时间线(甘特图)">
|
||||
{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>
|
||||
</Col>
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
InputNumber,
|
||||
Input,
|
||||
Space,
|
||||
message,
|
||||
Tag,
|
||||
Popconfirm,
|
||||
Tabs,
|
||||
@@ -21,6 +20,7 @@ import dayjs from 'dayjs';
|
||||
import api from '../../api';
|
||||
import { maskPhone, maskIdNumber } from '../../utils/sensitive';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import { message } from '../../ui/app-message';
|
||||
|
||||
const statusMap: Record<string, { text: string; color: string }> = {
|
||||
paid: { text: '已缴', color: 'green' },
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
InputNumber,
|
||||
Input,
|
||||
Space,
|
||||
message,
|
||||
Tag,
|
||||
Tabs,
|
||||
Popconfirm,
|
||||
@@ -28,6 +27,7 @@ import dayjs from 'dayjs';
|
||||
import api from '../../api';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import { downloadBlob } from '../../utils/download';
|
||||
import { message } from '../../ui/app-message';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useState, useMemo, useCallback } from 'react';
|
||||
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,
|
||||
Row, Col, List,
|
||||
} from 'antd';
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import type { DataNode } from 'antd/es/tree';
|
||||
import type { TreeSelectProps } from 'antd/es/tree-select';
|
||||
import api from '../../api';
|
||||
import { message } from '../../ui/app-message';
|
||||
|
||||
interface DingTalkConfig {
|
||||
agentId: string;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
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 api from '../../api';
|
||||
import { message } from '../../ui/app-message';
|
||||
import { writePermissions } from '../../auth/permission-store';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
@@ -16,7 +18,7 @@ const LoginPage: React.FC = () => {
|
||||
const res: any = await api.post('/auth/login', values);
|
||||
localStorage.setItem('token', res.access_token);
|
||||
localStorage.setItem('user', JSON.stringify(res.user));
|
||||
localStorage.setItem('permissions', JSON.stringify(res.user.permissions || []));
|
||||
writePermissions(res.user.permissions || []);
|
||||
message.success('登录成功');
|
||||
navigate('/dashboard');
|
||||
} catch (err: any) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 {
|
||||
BellOutlined,
|
||||
DollarOutlined,
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import api from '../../api';
|
||||
import { message } from '../../ui/app-message';
|
||||
|
||||
const { Sider, Content } = Layout;
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
Input,
|
||||
InputNumber,
|
||||
Space,
|
||||
message,
|
||||
Tag,
|
||||
Popconfirm,
|
||||
Upload,
|
||||
@@ -32,6 +31,8 @@ import api from '../../api';
|
||||
import { downloadBlob } from '../../utils/download';
|
||||
import { maskPhone, maskIdNumber } from '../../utils/sensitive';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import { message } from '../../ui/app-message';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
const OccupanciesPage: React.FC = () => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
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 api from '../../api';
|
||||
import { message } from '../../ui/app-message';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 { message } from '../../ui/app-message';
|
||||
|
||||
interface PermissionItem {
|
||||
id: number;
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
Space,
|
||||
Tag,
|
||||
Popconfirm,
|
||||
message,
|
||||
Card,
|
||||
Checkbox,
|
||||
Empty,
|
||||
@@ -15,6 +14,7 @@ import {
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import api from '../../api';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import { message } from '../../ui/app-message';
|
||||
|
||||
interface PermissionItem {
|
||||
id: number;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
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 dayjs, { Dayjs } from 'dayjs';
|
||||
import api from '../../api';
|
||||
import { message } from '../../ui/app-message';
|
||||
|
||||
function getCardStyle(room: any): React.CSSProperties {
|
||||
let base: React.CSSProperties;
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
InputNumber,
|
||||
Select,
|
||||
Space,
|
||||
message,
|
||||
Tag,
|
||||
Popconfirm,
|
||||
Badge,
|
||||
@@ -31,6 +30,7 @@ import {
|
||||
import api from '../../api';
|
||||
import { downloadBlob } from '../../utils/download';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import { message } from '../../ui/app-message';
|
||||
|
||||
const statusMap: Record<string, { text: string; color: string }> = {
|
||||
available: { text: '可入住', color: 'green' },
|
||||
|
||||
@@ -1,8 +1,26 @@
|
||||
import React, { useEffect, useState, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
Card, Button, Select, Modal, Form, Input, DatePicker, TimePicker,
|
||||
Popconfirm, message, Space, Spin, Empty, Tag, Tooltip, Segmented,
|
||||
Badge, Row, Col, Statistic, Alert,
|
||||
Card,
|
||||
Button,
|
||||
Select,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
DatePicker,
|
||||
TimePicker,
|
||||
Popconfirm,
|
||||
Space,
|
||||
Spin,
|
||||
Empty,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Segmented,
|
||||
Badge,
|
||||
Row,
|
||||
Col,
|
||||
Statistic,
|
||||
Alert,
|
||||
Switch,
|
||||
} from 'antd';
|
||||
import {
|
||||
CalendarOutlined,
|
||||
@@ -11,10 +29,17 @@ import {
|
||||
DeleteOutlined,
|
||||
CloudSyncOutlined,
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import api from '../../api';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import { message } from '../../ui/app-message';
|
||||
import {
|
||||
buildSchedulePayload,
|
||||
scheduleToFormValues,
|
||||
type ScheduleFormValues,
|
||||
} from './schedule-form';
|
||||
|
||||
// ---- Types ----
|
||||
|
||||
@@ -50,20 +75,14 @@ interface ClassItem {
|
||||
code: string;
|
||||
}
|
||||
|
||||
|
||||
interface UserItem {
|
||||
interface ClassTeacherOption {
|
||||
id: number;
|
||||
username: string;
|
||||
name: string;
|
||||
userId: number;
|
||||
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 {
|
||||
scheduleCount: number;
|
||||
@@ -90,7 +109,7 @@ const SchedulesPage: React.FC = () => {
|
||||
// Data
|
||||
const [classrooms, setClassrooms] = useState<ClassroomItem[]>([]);
|
||||
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 [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -100,7 +119,8 @@ const SchedulesPage: React.FC = () => {
|
||||
|
||||
// Modal
|
||||
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<{
|
||||
classroomId: number;
|
||||
weekDay: number;
|
||||
@@ -112,15 +132,21 @@ const SchedulesPage: React.FC = () => {
|
||||
const [syncModalOpen, setSyncModalOpen] = useState(false);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [syncStatus, setSyncStatus] = useState<{
|
||||
activeSchedules: number; mappedClasses: number; totalClasses: number;
|
||||
activeSchedules: number;
|
||||
mappedClasses: number;
|
||||
totalClasses: number;
|
||||
} | null>(null);
|
||||
const [syncResult, setSyncResult] = useState<{
|
||||
scheduleCount: number; shiftCount: number; groupCount: number;
|
||||
syncedItems: number; skippedNoMapping: number;
|
||||
scheduleCount: number;
|
||||
shiftCount: number;
|
||||
groupCount: number;
|
||||
syncedItems: number;
|
||||
skippedNoMapping: number;
|
||||
groups: Array<{ className: string; groupId: number; itemCount: number }>;
|
||||
} | null>(null);
|
||||
const [syncDateFrom, setSyncDateFrom] = useState<Dayjs>(dayjs);
|
||||
const [syncDays, setSyncDays] = useState(30);
|
||||
const [attendanceMachineOnly, setAttendanceMachineOnly] = useState(false);
|
||||
|
||||
/** 打开同步弹窗时先查询就绪状态 */
|
||||
const openSyncModal = useCallback(async () => {
|
||||
@@ -128,7 +154,8 @@ const SchedulesPage: React.FC = () => {
|
||||
setSyncResult(null);
|
||||
try {
|
||||
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');
|
||||
setSyncStatus(res.data);
|
||||
} catch {
|
||||
@@ -141,11 +168,13 @@ const SchedulesPage: React.FC = () => {
|
||||
setSyncing(true);
|
||||
try {
|
||||
const res = await api.post<{
|
||||
success: boolean; data: ScheduleSyncResult;
|
||||
success: boolean;
|
||||
data: ScheduleSyncResult;
|
||||
}>('/sync/schedule/sync', null, {
|
||||
params: {
|
||||
dateFrom: syncDateFrom.format('YYYY-MM-DD'),
|
||||
days: syncDays,
|
||||
attendanceMachineOnly,
|
||||
},
|
||||
});
|
||||
setSyncResult(res.data);
|
||||
@@ -156,7 +185,7 @@ const SchedulesPage: React.FC = () => {
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
}, [syncDateFrom, syncDays]);
|
||||
}, [syncDateFrom, syncDays, attendanceMachineOnly]);
|
||||
const [form] = Form.useForm<ScheduleFormValues>();
|
||||
|
||||
// Derived week/month info
|
||||
@@ -201,7 +230,6 @@ const SchedulesPage: React.FC = () => {
|
||||
return weekEnd.format('YYYY-MM-DD');
|
||||
}, [viewMode, weekEnd, calendarDays]);
|
||||
|
||||
|
||||
// ---- Data fetching ----
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
@@ -244,10 +272,6 @@ const SchedulesPage: React.FC = () => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
useEffect(() => {
|
||||
api.get<UserItem[]>('/rbac/users').then(setUsers).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// ---- Filtered classrooms ----
|
||||
|
||||
const filteredClassrooms = useMemo(() => {
|
||||
@@ -304,7 +328,10 @@ const SchedulesPage: React.FC = () => {
|
||||
setModalOpen(true);
|
||||
} else {
|
||||
setSelectedSchedules([]);
|
||||
setEditingSchedule(null);
|
||||
setModalMode('create');
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ classroomId, weekDay });
|
||||
setModalOpen(true);
|
||||
}
|
||||
};
|
||||
@@ -334,38 +361,75 @@ const SchedulesPage: React.FC = () => {
|
||||
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 () => {
|
||||
if (!selectedCell) return;
|
||||
if (modalMode === 'create' && !selectedCell) return;
|
||||
if (modalMode === 'edit' && !editingSchedule) return;
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const values = (await form.validateFields()) as ScheduleFormValues;
|
||||
setSubmitting(true);
|
||||
const payload = buildSchedulePayload(values);
|
||||
|
||||
const payload = {
|
||||
classId: values.classId,
|
||||
subject: values.subject,
|
||||
teacherId: values.teacherId,
|
||||
classroomId: selectedCell.classroomId,
|
||||
weekDay: selectedCell.weekDay,
|
||||
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('排课创建成功');
|
||||
if (modalMode === 'edit' && editingSchedule) {
|
||||
await api.put(`/class-schedules/${editingSchedule.id}`, payload);
|
||||
message.success('排课更新成功,请重新同步到钉钉排班');
|
||||
} else {
|
||||
await api.post('/class-schedules', payload);
|
||||
message.success('排课创建成功');
|
||||
}
|
||||
setModalOpen(false);
|
||||
setEditingSchedule(null);
|
||||
fetchData();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string; status?: number };
|
||||
message.error(err?.message || '创建排课失败');
|
||||
message.error(err?.message || (modalMode === 'edit' ? '更新排课失败' : '创建排课失败'));
|
||||
} finally {
|
||||
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 ----
|
||||
|
||||
@@ -406,15 +470,6 @@ const SchedulesPage: React.FC = () => {
|
||||
[classes],
|
||||
);
|
||||
|
||||
const userOptions = useMemo(
|
||||
() =>
|
||||
users.map((u) => ({
|
||||
value: u.id,
|
||||
label: `${u.name || u.username}${u.name ? ` (${u.username})` : ''}`,
|
||||
})),
|
||||
[users],
|
||||
);
|
||||
|
||||
// ---- Render ----
|
||||
|
||||
const selectedClassroom = selectedCell
|
||||
@@ -450,7 +505,12 @@ const SchedulesPage: React.FC = () => {
|
||||
{ label: '月视图', value: 'month' },
|
||||
]}
|
||||
/>
|
||||
<PermissionButton permission="sync:trigger" type="primary" icon={<CloudSyncOutlined />} onClick={openSyncModal}>
|
||||
<PermissionButton
|
||||
permission="sync:trigger"
|
||||
type="primary"
|
||||
icon={<CloudSyncOutlined />}
|
||||
onClick={openSyncModal}
|
||||
>
|
||||
同步到钉钉排班
|
||||
</PermissionButton>
|
||||
{viewMode === 'week' ? (
|
||||
@@ -467,10 +527,7 @@ const SchedulesPage: React.FC = () => {
|
||||
({startDateStr} ~ {endDateStr})
|
||||
</span>
|
||||
</span>
|
||||
<Button
|
||||
icon={<RightOutlined />}
|
||||
onClick={() => setViewDate(viewDate.add(7, 'day'))}
|
||||
>
|
||||
<Button icon={<RightOutlined />} onClick={() => setViewDate(viewDate.add(7, 'day'))}>
|
||||
下一周
|
||||
</Button>
|
||||
</>
|
||||
@@ -524,7 +581,7 @@ const SchedulesPage: React.FC = () => {
|
||||
<Spin spinning={loading}>
|
||||
{classrooms.length === 0 ? (
|
||||
<Empty description="暂无教室数据" />
|
||||
) : (viewMode === 'week' ? (
|
||||
) : viewMode === 'week' ? (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table
|
||||
style={{
|
||||
@@ -724,10 +781,14 @@ const SchedulesPage: React.FC = () => {
|
||||
transition: 'background 0.15s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.background = isCurrentMonth ? '#f0f5ff' : '#f0f0f0';
|
||||
(e.currentTarget as HTMLElement).style.background = isCurrentMonth
|
||||
? '#f0f5ff'
|
||||
: '#f0f0f0';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.background = isCurrentMonth ? '' : '#fafafa';
|
||||
(e.currentTarget as HTMLElement).style.background = isCurrentMonth
|
||||
? ''
|
||||
: '#fafafa';
|
||||
}}
|
||||
>
|
||||
<div
|
||||
@@ -756,7 +817,7 @@ const SchedulesPage: React.FC = () => {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</Spin>
|
||||
|
||||
{/* Modal */}
|
||||
@@ -764,24 +825,25 @@ const SchedulesPage: React.FC = () => {
|
||||
title={
|
||||
modalMode === 'create'
|
||||
? `新增排课 — ${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] : ''}`
|
||||
: modalMode === 'edit'
|
||||
? `编辑排课 — ${editingSchedule?.subject || ''}`
|
||||
: selectedDate
|
||||
? `排课详情 — ${selectedDate.format('YYYY-MM-DD')} ${WEEKDAYS[selectedDate.day() === 0 ? 6 : selectedDate.day() - 1]}`
|
||||
: `排课详情 — ${selectedClassroom?.name || ''} · ${selectedCell ? WEEKDAYS[selectedCell.weekDay - 1] : ''}`
|
||||
}
|
||||
open={modalOpen}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
onOk={modalMode === 'create' ? handleSubmit : undefined}
|
||||
onCancel={() => {
|
||||
setModalOpen(false);
|
||||
setEditingSchedule(null);
|
||||
}}
|
||||
onOk={modalMode !== 'detail' ? handleSubmit : undefined}
|
||||
confirmLoading={submitting}
|
||||
okText={modalMode === 'create' ? '创建' : undefined}
|
||||
footer={
|
||||
modalMode === 'create'
|
||||
? undefined // use default ok/cancel
|
||||
: null // no footer for detail mode
|
||||
}
|
||||
okText={modalMode === 'edit' ? '保存' : modalMode === 'create' ? '创建' : undefined}
|
||||
footer={modalMode === 'detail' ? null : undefined}
|
||||
width={600}
|
||||
destroyOnHidden
|
||||
>
|
||||
{modalMode === 'create' ? (
|
||||
{modalMode !== 'detail' ? (
|
||||
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item
|
||||
name="classId"
|
||||
@@ -793,6 +855,33 @@ const SchedulesPage: React.FC = () => {
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
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>
|
||||
|
||||
@@ -801,30 +890,26 @@ const SchedulesPage: React.FC = () => {
|
||||
label="科目"
|
||||
rules={[{ required: true, message: '请输入科目' }]}
|
||||
>
|
||||
<Input placeholder="如:数学、语文" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="teacherId"
|
||||
label="教师(可选)"
|
||||
>
|
||||
<Select
|
||||
showSearch
|
||||
allowClear
|
||||
placeholder="搜索并选择教师"
|
||||
optionFilterProp="label"
|
||||
options={userOptions}
|
||||
<Input
|
||||
placeholder="如:数学、语文"
|
||||
onBlur={(event) => {
|
||||
const classId = form.getFieldValue('classId');
|
||||
if (classId) void applyClassTeacherDefaults(classId, event.target.value);
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="教室">
|
||||
<Input value={selectedClassroom?.name || ''} disabled />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="星期">
|
||||
<Input
|
||||
value={selectedCell ? WEEKDAYS[selectedCell.weekDay - 1] : ''}
|
||||
disabled
|
||||
<Form.Item name="teacherId" label="任课老师">
|
||||
<Select
|
||||
showSearch
|
||||
allowClear
|
||||
placeholder="先选班级;系统会按科目自动带出任课老师"
|
||||
optionFilterProp="label"
|
||||
options={classTeachers.map((teacher) => ({
|
||||
value: teacher.userId,
|
||||
label: `${teacher.name || teacher.username || `#${teacher.userId}`}${teacher.subject ? ` · ${teacher.subject}` : ''}`,
|
||||
}))}
|
||||
notFoundContent="该班级暂无可选教师,请先在班级详情配置教师"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -854,14 +939,28 @@ const SchedulesPage: React.FC = () => {
|
||||
</Form>
|
||||
) : (
|
||||
<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>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
setEditingSchedule(null);
|
||||
setModalMode('create');
|
||||
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 }}
|
||||
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>
|
||||
<strong>科目:</strong>
|
||||
@@ -890,9 +995,9 @@ const SchedulesPage: React.FC = () => {
|
||||
{s.teacherId != null && (
|
||||
<div>
|
||||
<strong>教师:</strong>
|
||||
{users.find((u) => u.id === s.teacherId)?.name
|
||||
|| users.find((u) => u.id === s.teacherId)?.username
|
||||
|| `#${s.teacherId}`}
|
||||
{classTeachers.find((u) => u.userId === s.teacherId)?.name ||
|
||||
classTeachers.find((u) => u.userId === s.teacherId)?.username ||
|
||||
`#${s.teacherId}`}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
@@ -916,21 +1021,33 @@ const SchedulesPage: React.FC = () => {
|
||||
<Tag color={s.status === 'active' ? 'green' : 'default'}>{s.status}</Tag>
|
||||
</div>
|
||||
</div>
|
||||
<Popconfirm
|
||||
title="确认删除该排课?"
|
||||
onConfirm={() => handleDelete(s.id)}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
<PermissionButton
|
||||
permission="schedule:delete"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
<Space>
|
||||
{s.scheduleType !== 'RENTAL' && (
|
||||
<PermissionButton
|
||||
permission="schedule:edit"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => openEditSchedule(s)}
|
||||
>
|
||||
编辑
|
||||
</PermissionButton>
|
||||
)}
|
||||
<Popconfirm
|
||||
title="确认删除该排课?"
|
||||
onConfirm={() => handleDelete(s.id)}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
删除
|
||||
</PermissionButton>
|
||||
</Popconfirm>
|
||||
<PermissionButton
|
||||
permission="schedule:delete"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
>
|
||||
删除
|
||||
</PermissionButton>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
@@ -943,22 +1060,45 @@ const SchedulesPage: React.FC = () => {
|
||||
<Modal
|
||||
title="同步排课到钉钉考勤排班"
|
||||
open={syncModalOpen}
|
||||
onCancel={() => { setSyncModalOpen(false); setSyncResult(null); }}
|
||||
footer={syncResult ? [
|
||||
<Button key="close" onClick={() => { setSyncModalOpen(false); setSyncResult(null); }}>关闭</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>,
|
||||
]}
|
||||
onCancel={() => {
|
||||
setSyncModalOpen(false);
|
||||
setSyncResult(null);
|
||||
}}
|
||||
footer={
|
||||
syncResult
|
||||
? [
|
||||
<Button
|
||||
key="close"
|
||||
onClick={() => {
|
||||
setSyncModalOpen(false);
|
||||
setSyncResult(null);
|
||||
}}
|
||||
>
|
||||
关闭
|
||||
</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}
|
||||
>
|
||||
{syncResult ? (
|
||||
@@ -1014,11 +1154,18 @@ const SchedulesPage: React.FC = () => {
|
||||
title="已就绪班级"
|
||||
value={syncStatus.mappedClasses}
|
||||
suffix={`/ ${syncStatus.totalClasses}`}
|
||||
valueStyle={{ color: syncStatus.mappedClasses < syncStatus.totalClasses ? '#faad14' : '#3f8600' }}
|
||||
valueStyle={{
|
||||
color:
|
||||
syncStatus.mappedClasses < syncStatus.totalClasses ? '#faad14' : '#3f8600',
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Statistic title="无绑定学生班级" value={syncStatus.totalClasses - syncStatus.mappedClasses} suffix="个" />
|
||||
<Statistic
|
||||
title="无绑定学生班级"
|
||||
value={syncStatus.totalClasses - syncStatus.mappedClasses}
|
||||
suffix="个"
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
{syncStatus.mappedClasses < syncStatus.totalClasses && (
|
||||
@@ -1031,9 +1178,13 @@ const SchedulesPage: React.FC = () => {
|
||||
)}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ marginBottom: 8, fontWeight: 500 }}>同步参数</div>
|
||||
<Space>
|
||||
<Space wrap>
|
||||
<span>起始日期:</span>
|
||||
<DatePicker value={syncDateFrom} onChange={(d) => d && setSyncDateFrom(d)} allowClear={false} />
|
||||
<DatePicker
|
||||
value={syncDateFrom}
|
||||
onChange={(d) => d && setSyncDateFrom(d)}
|
||||
allowClear={false}
|
||||
/>
|
||||
<span>天数:</span>
|
||||
<Select
|
||||
value={syncDays}
|
||||
@@ -1048,9 +1199,32 @@ const SchedulesPage: React.FC = () => {
|
||||
]}
|
||||
/>
|
||||
</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>
|
||||
{syncStatus.activeSchedules === 0 && (
|
||||
<Alert type="info" message="当前没有活跃排课。请先在排课页面创建排课记录。" showIcon />
|
||||
<Alert
|
||||
type="info"
|
||||
message="当前没有活跃排课。请先在排课页面创建排课记录。"
|
||||
showIcon
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
46
apps/admin/src/pages/Schedules/schedule-form.ts
Normal file
46
apps/admin/src/pages/Schedules/schedule-form.ts
Normal 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'),
|
||||
});
|
||||
@@ -1,38 +1,40 @@
|
||||
import React, { useEffect, useState, useMemo, useCallback } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Table,
|
||||
App,
|
||||
Button,
|
||||
Modal,
|
||||
Card,
|
||||
Col,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Empty,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
message,
|
||||
Table,
|
||||
Tag,
|
||||
Popconfirm,
|
||||
Upload,
|
||||
App,
|
||||
Row,
|
||||
Col,
|
||||
Card,
|
||||
Drawer,
|
||||
Descriptions,
|
||||
Empty,
|
||||
} from 'antd';
|
||||
import type { UploadProps } from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
UploadOutlined,
|
||||
DownloadOutlined,
|
||||
UndoOutlined,
|
||||
InboxOutlined,
|
||||
ExportOutlined,
|
||||
DeleteOutlined,
|
||||
DownloadOutlined,
|
||||
ExportOutlined,
|
||||
EyeOutlined,
|
||||
InboxOutlined,
|
||||
PlusOutlined,
|
||||
SwapOutlined,
|
||||
UndoOutlined,
|
||||
UploadOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import api from '../../api';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
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 }> = {
|
||||
active: { text: '在读', color: 'green' },
|
||||
@@ -129,10 +131,13 @@ const StudentsPage: React.FC = () => {
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
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 (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 archived = list.filter((r) => r.status === 'archived');
|
||||
setArchivedCount(archived.length);
|
||||
@@ -149,9 +154,12 @@ const StudentsPage: React.FC = () => {
|
||||
}, [fetchData]);
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/tenants', { params: { includeArchived: 'false' } }).then((res: unknown) => {
|
||||
setTenants(res as Array<{ id: number; name: string }>);
|
||||
}).catch(() => {});
|
||||
api
|
||||
.get('/tenants', { params: { includeArchived: 'false' } })
|
||||
.then((res: unknown) => {
|
||||
setTenants(res as Array<{ id: number; name: string }>);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
const handleSave = async () => {
|
||||
const values = await form.validateFields();
|
||||
@@ -213,6 +221,23 @@ const StudentsPage: React.FC = () => {
|
||||
.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 baseURL = import.meta.env.PROD
|
||||
? '/api'
|
||||
@@ -232,128 +257,178 @@ const StudentsPage: React.FC = () => {
|
||||
.catch(() => message.error('导出失败'));
|
||||
};
|
||||
|
||||
const columns = useMemo(() => [
|
||||
{ title: 'ID', dataIndex: 'id', width: 70 },
|
||||
{
|
||||
title: '姓名',
|
||||
dataIndex: 'name',
|
||||
width: 120,
|
||||
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>
|
||||
);
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{ title: 'ID', dataIndex: 'id', width: 70 },
|
||||
{
|
||||
title: '姓名',
|
||||
dataIndex: 'name',
|
||||
width: 120,
|
||||
render: (v: string, record: any) => (
|
||||
<Button type="link" size="small" onClick={() => openDrawer(record.id)}>
|
||||
{v}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
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
|
||||
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"
|
||||
{
|
||||
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"
|
||||
onClick={() => openDrawer(record.id)}
|
||||
>
|
||||
档案
|
||||
</PermissionButton>
|
||||
<PermissionButton
|
||||
permission="student:edit"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setEditing(record);
|
||||
form.setFieldsValue(record);
|
||||
setModalOpen(true);
|
||||
}}
|
||||
style={{ padding: '8px 4px' }}
|
||||
onClick={() => handleViewSensitive(record.id, '电话', v)}
|
||||
title="点击查看完整号码"
|
||||
>
|
||||
编辑
|
||||
</PermissionButton>
|
||||
<EyeOutlined style={{ fontSize: 12, color: '#999' }} />
|
||||
</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
|
||||
title="归档后不会删除数据,可随时恢复。确定归档?"
|
||||
onConfirm={() => handleArchive(record.id)}
|
||||
okText="归档"
|
||||
title="确定恢复此学生?恢复后将重新出现在学生列表中。"
|
||||
onConfirm={() => handleRestore(record.id)}
|
||||
okText="恢复"
|
||||
cancelText="取消"
|
||||
>
|
||||
<PermissionButton permission="student:delete" size="small" icon={<InboxOutlined />}>
|
||||
归档
|
||||
<PermissionButton
|
||||
permission="student:edit"
|
||||
size="small"
|
||||
icon={<UndoOutlined />}
|
||||
type="link"
|
||||
>
|
||||
恢复
|
||||
</PermissionButton>
|
||||
</Popconfirm>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
], [handleViewSensitive, openDrawer, showArchived, tenants]);
|
||||
) : (
|
||||
<>
|
||||
<PermissionButton
|
||||
permission="student:view"
|
||||
size="small"
|
||||
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 (
|
||||
<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>
|
||||
<Input.Search
|
||||
placeholder="搜索学生姓名"
|
||||
@@ -361,11 +436,37 @@ const StudentsPage: React.FC = () => {
|
||||
allowClear
|
||||
style={{ width: 250 }}
|
||||
/>
|
||||
<Select 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
|
||||
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 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
|
||||
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>
|
||||
<Button
|
||||
type={showArchived ? 'primary' : 'default'}
|
||||
@@ -421,12 +522,15 @@ const StudentsPage: React.FC = () => {
|
||||
fetchData();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '导入失败');
|
||||
onError?.(e);
|
||||
onError?.(e instanceof Error ? e : new Error(e?.message || '导入失败'));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button icon={<UploadOutlined />}>导入Excel</Button>
|
||||
</Upload>
|
||||
<Upload accept=".xlsx,.xls" showUploadList={false} customRequest={handleMatchImport}>
|
||||
<Button icon={<SwapOutlined />}>匹配导入</Button>
|
||||
</Upload>
|
||||
<PermissionButton
|
||||
permission="student:view"
|
||||
icon={<DownloadOutlined />}
|
||||
@@ -481,8 +585,12 @@ const StudentsPage: React.FC = () => {
|
||||
>
|
||||
<Descriptions column={1} size="small">
|
||||
<Descriptions.Item label="班级">{enr.className || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="开班日期">{enr.startDate || enr.joinDate || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="结课日期">{enr.endDate || enr.leaveDate || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="开班日期">
|
||||
{enr.startDate || enr.joinDate || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="结课日期">
|
||||
{enr.endDate || enr.leaveDate || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag color={enr.status === 'active' ? 'green' : 'default'}>
|
||||
{enr.status || '-'}
|
||||
@@ -509,7 +617,7 @@ const StudentsPage: React.FC = () => {
|
||||
}
|
||||
},
|
||||
}}
|
||||
/>
|
||||
/>
|
||||
<style>{`.archived-row { opacity: 0.6; background: #fafafa !important; }`}</style>
|
||||
<Modal
|
||||
title={editing ? '编辑学生' : '添加学生'}
|
||||
@@ -553,11 +661,7 @@ const StudentsPage: React.FC = () => {
|
||||
<Form.Item name="emergencyPhone" label="紧急联系人电话">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="tenantId"
|
||||
label="所属机构"
|
||||
tooltip="选择租赁方,留空表示本机构"
|
||||
>
|
||||
<Form.Item name="tenantId" label="所属机构" tooltip="选择租赁方,留空表示本机构">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="选择租赁方"
|
||||
@@ -587,14 +691,18 @@ const StudentsPage: React.FC = () => {
|
||||
<Drawer
|
||||
title={null}
|
||||
open={drawerOpen}
|
||||
onClose={() => { setDrawerOpen(false); }}
|
||||
onClose={() => {
|
||||
setDrawerOpen(false);
|
||||
}}
|
||||
size={720}
|
||||
>
|
||||
{drawerStudentId && (
|
||||
<StudentProfileContent
|
||||
studentId={drawerStudentId}
|
||||
inDrawer
|
||||
onClose={() => { setDrawerOpen(false); }}
|
||||
onClose={() => {
|
||||
setDrawerOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
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 api from '../../api';
|
||||
import { message } from '../../ui/app-message';
|
||||
|
||||
interface AssignedClass {
|
||||
classId: number;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
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 dayjs from 'dayjs';
|
||||
import api from '../../api';
|
||||
import { message } from '../../ui/app-message';
|
||||
|
||||
interface TeacherRow {
|
||||
id: number;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
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 api from '../../api';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import { message } from '../../ui/app-message';
|
||||
|
||||
const PRESET_COLORS = [
|
||||
'#ff7875',
|
||||
|
||||
@@ -9,12 +9,12 @@ import {
|
||||
Space,
|
||||
Tag,
|
||||
Popconfirm,
|
||||
message,
|
||||
} from 'antd';
|
||||
} from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, KeyOutlined, IdcardOutlined, InboxOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import api from '../../api';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import { message } from '../../ui/app-message';
|
||||
|
||||
const UsersPage: React.FC = () => {
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
|
||||
15
apps/admin/src/ui/AppMessageBridge.tsx
Normal file
15
apps/admin/src/ui/AppMessageBridge.tsx
Normal 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;
|
||||
21
apps/admin/src/ui/app-message.integration.test.ts
Normal file
21
apps/admin/src/ui/app-message.integration.test.ts
Normal 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('请检查');
|
||||
});
|
||||
});
|
||||
20
apps/admin/src/ui/app-message.ts
Normal file
20
apps/admin/src/ui/app-message.ts
Normal 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),
|
||||
};
|
||||
Reference in New Issue
Block a user