fix permissions and teacher attendance workflows

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

View File

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

View File

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

View File

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

View File

@@ -14,7 +14,6 @@ import {
Upload,
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 ----

View File

@@ -1,21 +1,31 @@
import { useMemo } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { PERMISSIONS_UPDATED_EVENT, readPermissions } from '../auth/permission-store';
export function usePermission() {
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 };
}

View File

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

View File

@@ -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: [

View File

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

View File

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

View File

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

View File

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

View File

@@ -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="日租金(可选)">

View File

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

View File

@@ -12,12 +12,12 @@ import {
Spin,
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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>
) : (

View File

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

View File

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

View File

@@ -1,38 +1,40 @@
import React, { useEffect, useState, useMemo, useCallback } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
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>

View File

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

View File

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

View File

@@ -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',

View File

@@ -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[]>([]);

View File

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

View File

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

View File

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