diff --git a/apps/admin/package.json b/apps/admin/package.json index 77e3e23..c60087b 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -31,6 +31,7 @@ "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", "@vitest/browser": "^4.1.10", + "@vitest/browser-playwright": "^4.1.10", "@vitest/coverage-v8": "^4.1.10", "playwright": "^1.61.1", "typescript": "~6.0.2", diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx index a813b9a..9a60c4a 100644 --- a/apps/admin/src/App.tsx +++ b/apps/admin/src/App.tsx @@ -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 = () => { }} > + + }> } /> { } /> - } /> + + + + } + /> { /> + diff --git a/apps/admin/src/auth/permission-store.ts b/apps/admin/src/auth/permission-store.ts new file mode 100644 index 0000000..e469a08 --- /dev/null +++ b/apps/admin/src/auth/permission-store.ts @@ -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)); +} diff --git a/apps/admin/src/components/ECharts.tsx b/apps/admin/src/components/ECharts.tsx new file mode 100644 index 0000000..570a33b --- /dev/null +++ b/apps/admin/src/components/ECharts.tsx @@ -0,0 +1,56 @@ +import React, { useEffect, useRef } from 'react'; +import * as echarts from 'echarts/core'; +export type EChartsOption = Record; +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 = ({ option, style, className }) => { + const containerRef = useRef(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
; +}; + +export default ECharts; diff --git a/apps/admin/src/components/StudentProfileContent/index.tsx b/apps/admin/src/components/StudentProfileContent/index.tsx index 13de633..ac91a6c 100644 --- a/apps/admin/src/components/StudentProfileContent/index.tsx +++ b/apps/admin/src/components/StudentProfileContent/index.tsx @@ -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 ---- diff --git a/apps/admin/src/hooks/usePermission.ts b/apps/admin/src/hooks/usePermission.ts index bec42d8..5632fe4 100644 --- a/apps/admin/src/hooks/usePermission.ts +++ b/apps/admin/src/hooks/usePermission.ts @@ -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(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 }; } diff --git a/apps/admin/src/hooks/useViewSensitive.ts b/apps/admin/src/hooks/useViewSensitive.ts index d84ee40..48cc70e 100644 --- a/apps/admin/src/hooks/useViewSensitive.ts +++ b/apps/admin/src/hooks/useViewSensitive.ts @@ -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). diff --git a/apps/admin/src/layouts/MainLayout.tsx b/apps/admin/src/layouts/MainLayout.tsx index eb7236e..2832851 100644 --- a/apps/admin/src/layouts/MainLayout.tsx +++ b/apps/admin/src/layouts/MainLayout.tsx @@ -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))} />
- + {hasPermission('notification:view') && } = { 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([]); 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(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(undefined); + const [importClassOptions, setImportClassOptions] = useState([]); + 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(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(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('/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('/attendance-records/alerts') - .then((data) => { if (!cancelled) setAlerts(data); }) + api + .get('/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 -; return ( - + ); }, }, @@ -596,7 +711,11 @@ const AttendancePage: React.FC = () => { width: 80, fixed: 'right' as const, render: (_: unknown, record: AttendanceRecordItem) => ( - handleEdit(record)}> + handleEdit(record)} + > 编辑 ), @@ -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: ( -
-
{dayjs(date).format('MM/DD')}
-
{dayjs(date).format('ddd')}
-
- ), - 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 -; - const statusInfo = STATUS_MAP[dayRecord.status]; - return ( - - - {statusInfo?.text || dayRecord.status} - - - ); + const calendarColumns = useMemo( + () => [ + { + title: '学生', + dataIndex: 'studentName', + key: 'studentName', + width: 100, + fixed: 'left' as const, }, - })), - ], [calendarDates]); + ...calendarDates.map((date) => ({ + title: ( +
+
{dayjs(date).format('MM/DD')}
+
{dayjs(date).format('ddd')}
+
+ ), + 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 -; + const statusInfo = STATUS_MAP[dayRecord.status]; + return ( + + + {statusInfo?.text || dayRecord.status} + + + ); + }, + })), + ], + [calendarDates], + ); // ── Render ── return (
{alerts.length > 0 && ( - `${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 }} + /> + )} { > 批量录入 - + + {importing && ( @@ -996,39 +1139,53 @@ const AttendancePage: React.FC = () => { {/* ── DingTalk import modal ── */} { setImportModalOpen(false); setImportDateRange(null); setImportProgressMsg(''); }} + onCancel={closeDingTalkImportModal} confirmLoading={importing} - okText="开始拉取" + okText={canManageAllAttendance ? '开始拉取' : '同步今日'} cancelText="取消" >
- - setImportDateRange(dates as [Dayjs, Dayjs] | null)} - style={{ width: '100%' }} - placeholder={['开始日期', '结束日期']} - /> - - + ({ value: s.value, label: s.label }))} /> @@ -1083,7 +1240,6 @@ const AttendancePage: React.FC = () => {
-
); }; diff --git a/apps/admin/src/pages/Bills/index.tsx b/apps/admin/src/pages/Bills/index.tsx index 2cd18c6..a1828ee 100644 --- a/apps/admin/src/pages/Bills/index.tsx +++ b/apps/admin/src/pages/Bills/index.tsx @@ -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; diff --git a/apps/admin/src/pages/Classes/detail.tsx b/apps/admin/src/pages/Classes/detail.tsx index e4bf096..435b46b 100644 --- a/apps/admin/src/pages/Classes/detail.tsx +++ b/apps/admin/src/pages/Classes/detail.tsx @@ -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) => ( handleRemoveStudent(r.studentId)}> - + 移除 ), }, @@ -339,7 +340,7 @@ const ClassDetailPage: React.FC = () => { title: '操作', render: (_: unknown, r: ClassTeacher) => ( handleRemoveTeacher(r.userId)}> - + 移除 ), }, @@ -430,9 +431,9 @@ const ClassDetailPage: React.FC = () => { - +
@@ -443,10 +444,10 @@ const ClassDetailPage: React.FC = () => { {TYPE_MAP[detail.classType]} - {detail.startDate || '-'} + {detail.startDate ? dayjs(detail.startDate).format('YYYY-MM-DD') : '-'} - {detail.endDate || '-'} + {detail.endDate ? dayjs(detail.endDate).format('YYYY-MM-DD') : '-'} {detail.studentCount}/{detail.maxStudents || '-'} @@ -487,14 +488,15 @@ const ClassDetailPage: React.FC = () => { label: `花名册 (${students.filter((s) => s.status === 'active').length})`, children: (
- + } @@ -556,14 +558,15 @@ const ClassDetailPage: React.FC = () => { label: `教师 (${teachers.length})`, children: (
- + columns={teacherColumns} dataSource={teachers} diff --git a/apps/admin/src/pages/Classes/index.tsx b/apps/admin/src/pages/Classes/index.tsx index 9b3b0d2..c1f7d6a 100644 --- a/apps/admin/src/pages/Classes/index.tsx +++ b/apps/admin/src/pages/Classes/index.tsx @@ -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 ---- diff --git a/apps/admin/src/pages/ClassroomRentals/index.tsx b/apps/admin/src/pages/ClassroomRentals/index.tsx index de2d3cf..db22e2e 100644 --- a/apps/admin/src/pages/ClassroomRentals/index.tsx +++ b/apps/admin/src/pages/ClassroomRentals/index.tsx @@ -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([]); @@ -33,6 +39,11 @@ const ClassroomRentalsPage: React.FC = () => { const [filterMonth, setFilterMonth] = useState(null); const [searchText, setSearchText] = useState(''); const [saving, setSaving] = useState(false); + const [unavailableDates, setUnavailableDates] = useState>(new Set()); + const loadedUnavailableMonths = useRef>(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( + '/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 ? ( - - {c.building ? `${c.building} · ` : ''} - {c.name} - - ) : ( - '-' - ), - }, - { - title: '租赁方', width: 100, - dataIndex: 'tenant', - render: (t: any) => - t ? ( - - {t.name} - - ) : ( - '-' - ), - }, - { 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 ? ( + + {c.building ? `${c.building} · ` : ''} + {c.name} + + ) : ( + '-' + ), }, - }, - { 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 ? ( - - - + + handleDeleteContract(r.id)}> + - - handleDeleteContract(r.id)}> - - ), - }, - { - title: '操作', - width: 150, - render: (_: any, record: any) => ( - - openEdit(record)}> - 编辑 - - handleDelete(record.id)} - > - - 删除 - - - - ), - }, - ], []); + }, + ], + [], + ); return (
@@ -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))} /> diff --git a/apps/admin/src/pages/ClassroomRentals/unavailable-dates-cache.integration.test.ts b/apps/admin/src/pages/ClassroomRentals/unavailable-dates-cache.integration.test.ts new file mode 100644 index 0000000..3f216ef --- /dev/null +++ b/apps/admin/src/pages/ClassroomRentals/unavailable-dates-cache.integration.test.ts @@ -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)); + }); +}); diff --git a/apps/admin/src/pages/ClassroomSchedule/index.tsx b/apps/admin/src/pages/ClassroomSchedule/index.tsx index b0a1be5..0d54e35 100644 --- a/apps/admin/src/pages/ClassroomSchedule/index.tsx +++ b/apps/admin/src/pages/ClassroomSchedule/index.tsx @@ -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; diff --git a/apps/admin/src/pages/Classrooms/index.tsx b/apps/admin/src/pages/Classrooms/index.tsx index e0e7f32..8e67ccd 100644 --- a/apps/admin/src/pages/Classrooms/index.tsx +++ b/apps/admin/src/pages/Classrooms/index.tsx @@ -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 = { available: { text: '可用', color: 'green' }, diff --git a/apps/admin/src/pages/Dashboard/index.tsx b/apps/admin/src/pages/Dashboard/index.tsx index 219b1e4..778656e 100644 --- a/apps/admin/src/pages/Dashboard/index.tsx +++ b/apps/admin/src/pages/Dashboard/index.tsx @@ -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(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([]); const [ganttData, setGanttData] = useState([]); const [roomRanking, setRoomRanking] = useState>([]); @@ -186,7 +214,9 @@ const DashboardPage: React.FC = () => { api.get>('/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('/dashboard/gantt', { params: { periodStart: period[0], periodEnd: period[1] }, }), @@ -215,56 +245,69 @@ const DashboardPage: React.FC = () => { const [expenseTypeMap, setExpenseTypeMap] = useState>({}); useEffect(() => { - api.get>('/expense-types').then((types) => { - const map: Record = {}; - for (const t of types) map[t.code] = t.name; - setExpenseTypeMap(map); - }).catch(() => {}); + api + .get>('/expense-types') + .then((types) => { + const map: Record = {}; + 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( + () => ({ + 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( + () => ({ + 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}
入住: ${p.data.value[1]}
退宿: ${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( + () => ({ + tooltip: { + formatter: (p: { data: { name: string; value: [string, string, string, boolean] } }) => + `${p.data.name}
入住: ${p.data.value[1]}
退宿: ${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, }} > -

工作台{refreshLoading && }

+

+ 工作台{refreshLoading && } +

{ styles={{ body: { padding: 16 } }} onClick={() => navigate('/attendance')} > -
+
0 ? '#FF9500' : '#999' }} />
-
0 ? '#FF9500' : '#999' }}> +
0 ? '#FF9500' : '#999', + }} + > {absentCount}
今日缺勤人数
@@ -472,18 +543,28 @@ const DashboardPage: React.FC = () => { styles={{ body: { padding: 16 } }} onClick={() => navigate('/bills')} > -
+
0 ? '#AF52DE' : '#999' }} />
-
0 ? '#AF52DE' : '#999' }}> +
0 ? '#AF52DE' : '#999', + }} + > {draftCount}
待处理账单
-
0 ? '#AF52DE' : '#999', marginTop: 4 }}> +
0 ? '#AF52DE' : '#999', marginTop: 4 }} + > {draftCount > 0 ? `合计 ¥${draftTotal.toLocaleString()}` : '暂无待处理'}
@@ -497,18 +578,32 @@ const DashboardPage: React.FC = () => { styles={{ body: { padding: 16 } }} onClick={() => navigate('/deposits')} > -
+
0 ? '#FF3B30' : '#999' }} />
-
0 ? '#FF3B30' : '#999' }}> +
0 ? '#FF3B30' : '#999', + }} + > ¥{pendingDeposits.toLocaleString()}
待退押金
-
0 ? '#FF3B30' : '#999', marginTop: 4 }}> +
0 ? '#FF3B30' : '#999', + marginTop: 4, + }} + > {pendingDeposits > 0 ? '需要处理' : '暂无待退'}
@@ -577,90 +672,135 @@ const DashboardPage: React.FC = () => { {/* ═══════════ 更多指标(折叠) ═══════════ */} - - - - } /> - - - - - } /> - - - - - } /> - - - - - } /> - - - - - - - } /> - - - - - - - - - - } /> - - - - - } - /> - - - - - - - } /> + items={[ + { + key: 'more-metrics', + label: '更多指标', + children: ( + <> + + + + } + /> + - - } /> + + + } + /> + - - } - styles={{ value: { color: Number(classroomUtil?.utilizationRate ?? 0) > 70 ? '#34C759' : '#FF9500' } }} - /> + + + } + /> + - - } /> + + + } + /> + - - - ), - }]} + + + + } + /> + + + + + + + + + + } + /> + + + + + } + /> + + + + + + + } + /> + + + } + /> + + + } + styles={{ + value: { + color: + Number(classroomUtil?.utilizationRate ?? 0) > 70 + ? '#34C759' + : '#FF9500', + }, + }} + /> + + + } + /> + + + + + ), + }, + ]} /> {/* ═══════════ 图表:考勤趋势 + 出勤分布 ═══════════ */} @@ -668,7 +808,10 @@ const DashboardPage: React.FC = () => { {(stats?.attendanceTrend || []).length > 0 ? ( - + ) : (
暂无考勤数据
)} @@ -677,7 +820,10 @@ const DashboardPage: React.FC = () => { {Object.keys(stats?.attendanceByStatus ?? {}).length > 0 ? ( - + ) : (
暂无考勤数据
)} @@ -690,7 +836,10 @@ const DashboardPage: React.FC = () => { {classRanking.top.length > 0 ? ( - + ) : (
暂无考勤数据
)} @@ -699,7 +848,10 @@ const DashboardPage: React.FC = () => { {classRanking.bottom.length > 0 ? ( - + ) : (
暂无考勤数据
)} @@ -711,13 +863,28 @@ const DashboardPage: React.FC = () => { - {((stats?.expenseByType) ?? []).length > 0 ? ( - ({ name: expenseTypeMap[e.type] ?? e.type, value: Number(e.total) })) }], - }} style={{ width: '100%', height: isMobile ? 250 : 300 }} /> + {(stats?.expenseByType ?? []).length > 0 ? ( + ({ + name: expenseTypeMap[e.type] ?? e.type, + value: Number(e.total), + })), + }, + ], + } satisfies EChartsOption + } + style={{ width: '100%', height: isMobile ? 250 : 300 }} + /> ) : (
暂无费用数据
)} @@ -726,7 +893,10 @@ const DashboardPage: React.FC = () => { {roomRanking.length > 0 ? ( - + ) : (
暂无费用数据
)} @@ -739,7 +909,10 @@ const DashboardPage: React.FC = () => { {(stats?.incomeTrend || []).length > 0 ? ( - + ) : (
暂无收入数据
)} @@ -754,27 +927,60 @@ const DashboardPage: React.FC = () => { {classroomOccupancy.length > 0 ? ( - - `${p.name}
排课: ${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 }} /> + + `${p.name}
排课: ${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 }} + /> ) : ( -
暂无教室数据
+
+ 暂无教室数据 +
)}
@@ -793,9 +999,14 @@ const DashboardPage: React.FC = () => { {ganttData.length > 0 ? ( - + ) : ( -
暂无入住数据
+
+ 暂无入住数据 +
)}
diff --git a/apps/admin/src/pages/Deposits/index.tsx b/apps/admin/src/pages/Deposits/index.tsx index b2049a1..b66ba27 100644 --- a/apps/admin/src/pages/Deposits/index.tsx +++ b/apps/admin/src/pages/Deposits/index.tsx @@ -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 = { paid: { text: '已缴', color: 'green' }, diff --git a/apps/admin/src/pages/Expenses/index.tsx b/apps/admin/src/pages/Expenses/index.tsx index 928b950..61b5875 100644 --- a/apps/admin/src/pages/Expenses/index.tsx +++ b/apps/admin/src/pages/Expenses/index.tsx @@ -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; diff --git a/apps/admin/src/pages/IntegrationConfig/index.tsx b/apps/admin/src/pages/IntegrationConfig/index.tsx index 5c45180..cff18dc 100644 --- a/apps/admin/src/pages/IntegrationConfig/index.tsx +++ b/apps/admin/src/pages/IntegrationConfig/index.tsx @@ -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; diff --git a/apps/admin/src/pages/Login/index.tsx b/apps/admin/src/pages/Login/index.tsx index af82f42..44d7d28 100644 --- a/apps/admin/src/pages/Login/index.tsx +++ b/apps/admin/src/pages/Login/index.tsx @@ -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) { diff --git a/apps/admin/src/pages/Notifications/index.tsx b/apps/admin/src/pages/Notifications/index.tsx index 0b4465c..e247956 100644 --- a/apps/admin/src/pages/Notifications/index.tsx +++ b/apps/admin/src/pages/Notifications/index.tsx @@ -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; diff --git a/apps/admin/src/pages/Occupancies/index.tsx b/apps/admin/src/pages/Occupancies/index.tsx index 9a757b3..625cd64 100644 --- a/apps/admin/src/pages/Occupancies/index.tsx +++ b/apps/admin/src/pages/Occupancies/index.tsx @@ -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 = () => { diff --git a/apps/admin/src/pages/OperationLogs/index.tsx b/apps/admin/src/pages/OperationLogs/index.tsx index 6b10123..ca80f28 100644 --- a/apps/admin/src/pages/OperationLogs/index.tsx +++ b/apps/admin/src/pages/OperationLogs/index.tsx @@ -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; diff --git a/apps/admin/src/pages/Permissions/index.tsx b/apps/admin/src/pages/Permissions/index.tsx index de742bb..76bce05 100644 --- a/apps/admin/src/pages/Permissions/index.tsx +++ b/apps/admin/src/pages/Permissions/index.tsx @@ -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; diff --git a/apps/admin/src/pages/Roles/index.tsx b/apps/admin/src/pages/Roles/index.tsx index 97d137e..25d03f3 100644 --- a/apps/admin/src/pages/Roles/index.tsx +++ b/apps/admin/src/pages/Roles/index.tsx @@ -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; diff --git a/apps/admin/src/pages/RoomVisual/index.tsx b/apps/admin/src/pages/RoomVisual/index.tsx index b2f2e1a..435a04b 100644 --- a/apps/admin/src/pages/RoomVisual/index.tsx +++ b/apps/admin/src/pages/RoomVisual/index.tsx @@ -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; diff --git a/apps/admin/src/pages/Rooms/index.tsx b/apps/admin/src/pages/Rooms/index.tsx index 8176287..8afe184 100644 --- a/apps/admin/src/pages/Rooms/index.tsx +++ b/apps/admin/src/pages/Rooms/index.tsx @@ -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 = { available: { text: '可入住', color: 'green' }, diff --git a/apps/admin/src/pages/Schedules/index.tsx b/apps/admin/src/pages/Schedules/index.tsx index 53046ea..75fc8f5 100644 --- a/apps/admin/src/pages/Schedules/index.tsx +++ b/apps/admin/src/pages/Schedules/index.tsx @@ -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([]); const [classes, setClasses] = useState([]); - const [users, setUsers] = useState([]); + const [classTeachers, setClassTeachers] = useState([]); const [matrix, setMatrix] = useState>>({}); 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(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); 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(); // 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('/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( + `/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' }, ]} /> - } onClick={openSyncModal}> + } + onClick={openSyncModal} + > 同步到钉钉排班 {viewMode === 'week' ? ( @@ -467,10 +527,7 @@ const SchedulesPage: React.FC = () => { ({startDateStr} ~ {endDateStr}) - @@ -524,7 +581,7 @@ const SchedulesPage: React.FC = () => { {classrooms.length === 0 ? ( - ) : (viewMode === 'week' ? ( + ) : viewMode === 'week' ? (
{ 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'; }} >
{
- ))} + )}
{/* 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' ? (
{ showSearch optionFilterProp="label" options={classOptions} + onChange={(classId: number) => { + form.setFieldValue('teacherId', undefined); + void applyClassTeacherDefaults(classId, form.getFieldValue('subject')); + }} + /> + + + + ({ value, label: WEEKDAYS[value - 1] }))} /> @@ -801,30 +890,26 @@ const SchedulesPage: React.FC = () => { label="科目" rules={[{ required: true, message: '请输入科目' }]} > - - - - - - - - - + { ]} /> +
+ + +
+
仅允许考勤机打卡
+
+ 开启后将关闭外勤、定位、Wi-Fi 和手机蓝牙打卡,并禁止无排班打卡。 +
+
+
+
+ {attendanceMachineOnly && ( + + )}
{syncStatus.activeSchedules === 0 && ( - + )}
) : ( diff --git a/apps/admin/src/pages/Schedules/schedule-form.integration.test.ts b/apps/admin/src/pages/Schedules/schedule-form.integration.test.ts new file mode 100644 index 0000000..d87ec6e --- /dev/null +++ b/apps/admin/src/pages/Schedules/schedule-form.integration.test.ts @@ -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', + }); + }); +}); diff --git a/apps/admin/src/pages/Schedules/schedule-form.ts b/apps/admin/src/pages/Schedules/schedule-form.ts new file mode 100644 index 0000000..d72cb41 --- /dev/null +++ b/apps/admin/src/pages/Schedules/schedule-form.ts @@ -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'), +}); diff --git a/apps/admin/src/pages/Students/index.tsx b/apps/admin/src/pages/Students/index.tsx index 752bd89..f2baf6c 100644 --- a/apps/admin/src/pages/Students/index.tsx +++ b/apps/admin/src/pages/Students/index.tsx @@ -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 = { active: { text: '在读', color: 'green' }, @@ -129,10 +131,13 @@ const StudentsPage: React.FC = () => { const fetchData = useCallback(async () => { setLoading(true); try { - const params: Record = { name: searchName || undefined, includeArchived: 'true' }; + const params: Record = { + name: searchName || undefined, + includeArchived: 'true', + }; if (filterStatus) params.status = filterStatus; if (filterTenantId) params.tenantId = filterTenantId; - const res = await api.get('/students', { params }) as Array>; + const res = (await api.get('/students', { params })) as Array>; const list = res as Array>; 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) => ( - - ), - }, - { - title: '电话', - dataIndex: 'phone', - width: 140, - render: (v: string, record: any) => { - if (!v) return '-'; - return ( - - {maskPhone(v)} - - - ); + const columns = useMemo( + () => [ + { title: 'ID', dataIndex: 'id', width: 70 }, + { + title: '姓名', + dataIndex: 'name', + width: 120, + render: (v: string, record: any) => ( + + ), }, - }, - { - title: '学号', - dataIndex: 'studentNo', - width: 120, - render: (v: string) => v || '-', - }, - { - title: '身份证', - dataIndex: 'idNumber', - width: 180, - render: (v: string, record: any) => { - if (!v) return '-'; - return ( - - {maskIdNumber(v)} - - - ); - }, - }, - { 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 ? {tenant.name} : '-', - }, - { title: '负责人', dataIndex: 'supervisor', width: 100 }, - { - title: '状态', - dataIndex: 'status', - width: 80, - render: (s: string) => {statusMap[s]?.text || s}, - }, - { - title: '操作', - width: 180, - render: (_: any, record: any) => ( - - {record.status === 'archived' ? ( - handleRestore(record.id)} - okText="恢复" - cancelText="取消" - > - } type="link"> - 恢复 - - - ) : ( - <> - { + if (!v) return '-'; + return ( + + {maskPhone(v)} + + + ); + }, + }, + { + title: '学号', + dataIndex: 'studentNo', + width: 120, + render: (v: string) => v || '-', + }, + { + title: '身份证', + dataIndex: 'idNumber', + width: 180, + render: (v: string, record: any) => { + if (!v) return '-'; + return ( + + {maskIdNumber(v)} + + + ); + }, + }, + { 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 ? ( + + {tenant.name} + + ) : ( + '-' + ), + }, + { title: '负责人', dataIndex: 'supervisor', width: 100 }, + { + title: '状态', + dataIndex: 'status', + width: 80, + render: (s: string) => ( + + {statusMap[s]?.text || s} + + ), + }, + { + title: '操作', + width: 180, + render: (_: any, record: any) => ( + + {record.status === 'archived' ? ( handleArchive(record.id)} - okText="归档" + title="确定恢复此学生?恢复后将重新出现在学生列表中。" + onConfirm={() => handleRestore(record.id)} + okText="恢复" cancelText="取消" > - }> - 归档 + } + type="link" + > + 恢复 - - )} - - ), - }, - ], [handleViewSensitive, openDrawer, showArchived, tenants]); + ) : ( + <> + openDrawer(record.id)} + > + 档案 + + { + setEditing(record); + form.setFieldsValue(record); + setModalOpen(true); + }} + > + 编辑 + + handleArchive(record.id)} + okText="归档" + cancelText="取消" + > + } + > + 归档 + + + + )} + + ), + }, + ], + [handleViewSensitive, openDrawer, showArchived, tenants], + ); return (
-
+
{ allowClear style={{ width: 250 }} /> - { + setFilterStatus(v); + }} + > + {Object.entries(statusMap) + .filter(([k]) => k !== 'archived') + .map(([k, v]) => ( + + {v.text} + + ))} - { + setFilterTenantId(v); + }} + > + {tenants.map((t: { id: number; name: string }) => ( + + {t.name} + + ))} + + + } @@ -481,8 +585,12 @@ const StudentsPage: React.FC = () => { > {enr.className || '-'} - {enr.startDate || enr.joinDate || '-'} - {enr.endDate || enr.leaveDate || '-'} + + {enr.startDate || enr.joinDate || '-'} + + + {enr.endDate || enr.leaveDate || '-'} + {enr.status || '-'} @@ -509,7 +617,7 @@ const StudentsPage: React.FC = () => { } }, }} - /> + /> { - +