From f06b114a29693d5980df521b28d8796ad63e121c Mon Sep 17 00:00:00 2001 From: wangziqi Date: Sat, 18 Jul 2026 16:35:15 +0800 Subject: [PATCH] feat(admin): improve responsive management pages --- apps/admin/src/App.tsx | 480 +++++++++--------- apps/admin/src/api/index.ts | 3 +- .../src/auth/menu-policy.integration.test.ts | 4 +- apps/admin/src/auth/menu-policy.ts | 43 +- .../permission-navigation.integration.test.ts | 6 +- apps/admin/src/auth/permission-navigation.ts | 12 +- apps/admin/src/auth/permission-store.ts | 4 +- apps/admin/src/components/DefaultRoute.tsx | 4 +- .../admin/src/components/NotificationBell.tsx | 41 +- apps/admin/src/components/PermissionRoute.tsx | 8 +- .../StudentProfileContent/index.tsx | 255 ++++++---- apps/admin/src/index.css | 66 ++- apps/admin/src/layouts/MainLayout.tsx | 82 +-- .../AiConfig/helpers.integration.test.ts | 12 +- .../attendance-workspace.integration.test.ts | 13 +- .../pages/Attendance/attendance-workspace.ts | 7 +- .../admin/src/pages/Attendance/attendance.css | 157 +++++- apps/admin/src/pages/Attendance/index.tsx | 404 +++++++++++---- apps/admin/src/pages/AttendanceDevices.tsx | 112 +++- apps/admin/src/pages/Bills/index.tsx | 268 ++++++---- apps/admin/src/pages/Classes/detail.tsx | 130 +++-- apps/admin/src/pages/Classes/index.tsx | 158 ++++-- .../src/pages/ClassroomRentals/index.tsx | 56 +- apps/admin/src/pages/Classrooms/index.tsx | 200 +++++--- ...deposit-student-option.integration.test.ts | 13 +- apps/admin/src/pages/Deposits/index.tsx | 283 +++++++---- apps/admin/src/pages/Expenses/index.tsx | 344 +++++++------ .../src/pages/IntegrationConfig/index.tsx | 422 ++++++++------- ...ntegration-config-form.integration.test.ts | 5 +- apps/admin/src/pages/Login/index.tsx | 49 +- apps/admin/src/pages/Notifications/index.tsx | 20 +- apps/admin/src/pages/Occupancies/index.tsx | 268 +++++----- apps/admin/src/pages/OperationLogs/index.tsx | 114 +++-- apps/admin/src/pages/Roles/index.tsx | 113 ++--- apps/admin/src/pages/RoomVisual/index.tsx | 113 ++++- apps/admin/src/pages/Rooms/index.tsx | 435 ++++++++++------ apps/admin/src/pages/Schedules/index.tsx | 19 +- .../schedule-form.integration.test.ts | 1 - apps/admin/src/pages/Students/index.tsx | 11 +- .../src/pages/TeacherWorkspace/index.tsx | 105 ++-- apps/admin/src/pages/Teachers/index.tsx | 161 +++--- apps/admin/src/pages/Users/index.tsx | 209 ++++---- apps/admin/src/pages/Wallets/index.tsx | 345 ++++++++++--- apps/admin/src/test/fixtures.ts | 75 ++- apps/admin/src/test/helpers.ts | 4 +- 45 files changed, 3596 insertions(+), 2038 deletions(-) diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx index b8f6297..4163de4 100644 --- a/apps/admin/src/App.tsx +++ b/apps/admin/src/App.tsx @@ -24,7 +24,7 @@ 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 OrganizationsPage = lazy(() => import('./pages/Organizations')) +const OrganizationsPage = lazy(() => import('./pages/Organizations')); const ClassroomRentalsPage = lazy(() => import('./pages/ClassroomRentals')); const ClassroomSchedulePage = lazy(() => import('./pages/ClassroomSchedule')); const SchedulesPage = lazy(() => import('./pages/Schedules')); @@ -59,254 +59,258 @@ const App: React.FC = () => { - }> - - } /> - - - - } - > - } /> + + + + } + > + + } /> - - + + + } - /> - - - - } - /> - - - - } - /> + > + } /> + + + + } + /> + + + + } + /> + + + + } + /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> - - - - } - /> + + + + } + /> - - - - } - /> + + + + } + /> - - - - } - /> + + + + } + /> - - - - } - /> + + + + } + /> - - - - } - /> + + + + } + /> - - - - - } - /> - - - - - } - /> - - + + + + } + /> + + diff --git a/apps/admin/src/api/index.ts b/apps/admin/src/api/index.ts index 978f547..20af776 100644 --- a/apps/admin/src/api/index.ts +++ b/apps/admin/src/api/index.ts @@ -16,8 +16,7 @@ instance.interceptors.request.use((config) => { instance.interceptors.response.use( (res) => res.data, (err) => { - const isLoginRequest = - err.config?.url === '/auth/login' || err.config?.url === 'auth/login'; + const isLoginRequest = err.config?.url === '/auth/login' || err.config?.url === 'auth/login'; if (err.response?.status === 401 && !isLoginRequest) { localStorage.removeItem('token'); diff --git a/apps/admin/src/auth/menu-policy.integration.test.ts b/apps/admin/src/auth/menu-policy.integration.test.ts index a31724d..ce8f9bc 100644 --- a/apps/admin/src/auth/menu-policy.integration.test.ts +++ b/apps/admin/src/auth/menu-policy.integration.test.ts @@ -45,9 +45,7 @@ describe('role-aware menu policy', () => { '/attendance', '/notifications', ]); - expect(findRoleAwareLandingPath(['任课老师'], teacherPermissions)).toBe( - '/teacher-workspace', - ); + expect(findRoleAwareLandingPath(['任课老师'], teacherPermissions)).toBe('/teacher-workspace'); }); it('places schedules and attendance only once in academic management', () => { diff --git a/apps/admin/src/auth/menu-policy.ts b/apps/admin/src/auth/menu-policy.ts index ad49d2e..aa990b7 100644 --- a/apps/admin/src/auth/menu-policy.ts +++ b/apps/admin/src/auth/menu-policy.ts @@ -43,7 +43,12 @@ const SECTIONS: MenuSection[] = [ icon: 'calendar', roles: ['teacher'], children: [ - { key: '/teacher-workspace', label: '今日教学', icon: 'workspace', permission: 'teacher-workspace:view' }, + { + key: '/teacher-workspace', + label: '今日教学', + icon: 'workspace', + permission: 'teacher-workspace:view', + }, { key: '/schedules', label: '我的排课', icon: 'calendar', permission: 'schedule:view' }, { key: '/attendance', label: '课程考勤', icon: 'attendance', permission: 'attendance:view' }, ], @@ -83,11 +88,26 @@ const SECTIONS: MenuSection[] = [ icon: 'classroom', roles: ['classroom', 'super'], children: [ - { key: '/classroom-schedule', label: '教室排期', icon: 'calendar', permission: 'rental:view' }, + { + key: '/classroom-schedule', + label: '教室排期', + icon: 'calendar', + permission: 'rental:view', + }, { key: '/classrooms', label: '教室管理', icon: 'classroom', permission: 'classroom:view' }, - { key: '/attendance-devices', label: '考勤机绑定', icon: 'attendance', permission: 'classroom:view' }, + { + key: '/attendance-devices', + label: '考勤机绑定', + icon: 'attendance', + permission: 'classroom:view', + }, { key: '/classroom-rentals', label: '租赁订单', icon: 'rental', permission: 'rental:view' }, - { key: '/organizations', label: '机构管理', icon: 'organization', permission: 'organization:view' }, + { + key: '/organizations', + label: '机构管理', + icon: 'organization', + permission: 'organization:view', + }, ], }, { @@ -100,13 +120,21 @@ const SECTIONS: MenuSection[] = [ { key: '/roles', label: '角色管理', icon: 'role', permission: 'role:view' }, { key: '/permissions', label: '权限一览', icon: 'permission', permission: 'role:view' }, { key: '/operation-logs', label: '操作日志', icon: 'log', permission: 'log:view' }, - { key: '/integration-config', label: '钉钉集成', icon: 'integration', permission: 'integration:read' }, + { + key: '/integration-config', + label: '钉钉集成', + icon: 'integration', + permission: 'integration:read', + }, { key: '/ai-config', label: 'AI 配置', icon: 'ai', permission: 'ai:config:read' }, ], }, ]; -export function getRoleDomains(roles: readonly string[], permissions: readonly string[]): Set { +export function getRoleDomains( + roles: readonly string[], + permissions: readonly string[], +): Set { const normalized = new Set(roles.map((role) => ROLE_ALIASES[role]).filter(Boolean)); // 权限可以来自多个叠加角色,因此业务域按能力累加,而不是只选择一个。 if (permissions.includes('student:view') || permissions.includes('class:view')) { @@ -167,7 +195,8 @@ export function buildMenu(roles: readonly string[], permissions: readonly string const children = section.children .filter((child) => permissionSet.has(child.permission)) .map(({ permission: _, ...child }) => child); - if (children.length > 0) sections.push({ ...section, children, roles: undefined } as AppMenuItem); + if (children.length > 0) + sections.push({ ...section, children, roles: undefined } as AppMenuItem); } if (permissionSet.has('notification:view')) { diff --git a/apps/admin/src/auth/permission-navigation.integration.test.ts b/apps/admin/src/auth/permission-navigation.integration.test.ts index facc6a1..16882b8 100644 --- a/apps/admin/src/auth/permission-navigation.integration.test.ts +++ b/apps/admin/src/auth/permission-navigation.integration.test.ts @@ -13,11 +13,7 @@ describe('permission navigation', () => { it('lands teachers on the teacher workspace without global student or class access', () => { expect( - findFirstAccessiblePath([ - 'teacher-workspace:view', - 'schedule:view', - 'attendance:view', - ]), + findFirstAccessiblePath(['teacher-workspace:view', 'schedule:view', 'attendance:view']), ).toBe('/teacher-workspace'); expect(canAccessPath('/students', ['teacher-workspace:view'])).toBe(false); expect(canAccessPath('/classes', ['teacher-workspace:view'])).toBe(false); diff --git a/apps/admin/src/auth/permission-navigation.ts b/apps/admin/src/auth/permission-navigation.ts index 6fde26f..6b09f63 100644 --- a/apps/admin/src/auth/permission-navigation.ts +++ b/apps/admin/src/auth/permission-navigation.ts @@ -14,8 +14,16 @@ export const PERMISSION_PAGES: readonly PermissionPage[] = [ { path: '/rooms', permission: 'room:view' }, { path: '/occupancies', permission: 'occupancy:view' }, { path: '/teacher-workspace', permission: 'teacher-workspace:view' }, - { path: '/students', permission: 'student:view', matches: (p) => p === '/students' || /^\/students\/\d+\/profile$/.test(p) }, - { path: '/classes', permission: 'class:view', matches: (p) => p === '/classes' || /^\/classes\/\d+$/.test(p) }, + { + path: '/students', + permission: 'student:view', + matches: (p) => p === '/students' || /^\/students\/\d+\/profile$/.test(p), + }, + { + path: '/classes', + permission: 'class:view', + matches: (p) => p === '/classes' || /^\/classes\/\d+$/.test(p), + }, { path: '/attendance', permission: 'attendance:view' }, { path: '/schedules', permission: 'schedule:view' }, { path: '/classroom-schedule', permission: 'rental:view' }, diff --git a/apps/admin/src/auth/permission-store.ts b/apps/admin/src/auth/permission-store.ts index e469a08..ecc0d8a 100644 --- a/apps/admin/src/auth/permission-store.ts +++ b/apps/admin/src/auth/permission-store.ts @@ -3,7 +3,9 @@ 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') : []; + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === 'string') + : []; } catch { return []; } diff --git a/apps/admin/src/components/DefaultRoute.tsx b/apps/admin/src/components/DefaultRoute.tsx index 201f91c..9366729 100644 --- a/apps/admin/src/components/DefaultRoute.tsx +++ b/apps/admin/src/components/DefaultRoute.tsx @@ -15,7 +15,9 @@ const DefaultRoute: React.FC = () => { })(); const firstPath = findRoleAwareLandingPath(roles, permissions); if (firstPath) return ; - return ; + return ( + + ); }; export default DefaultRoute; diff --git a/apps/admin/src/components/NotificationBell.tsx b/apps/admin/src/components/NotificationBell.tsx index 91e7fb1..bc69f82 100644 --- a/apps/admin/src/components/NotificationBell.tsx +++ b/apps/admin/src/components/NotificationBell.tsx @@ -34,16 +34,20 @@ const NotificationBell: React.FC = () => { const fetchNotifications = async () => { try { - const data = await api.get('/notifications?limit=20') as unknown as NotificationItem[]; + const data = (await api.get('/notifications?limit=20')) as unknown as NotificationItem[]; setNotifications(data); - } catch { /* ignore */ } + } catch { + /* ignore */ + } }; const fetchUnread = async () => { try { - const data = await api.get('/notifications/unread-count') as unknown as { count: number }; + const data = (await api.get('/notifications/unread-count')) as unknown as { count: number }; setUnreadCount(data.count); - } catch { /* ignore */ } + } catch { + /* ignore */ + } }; const openRef = useRef(open); openRef.current = open; @@ -60,7 +64,9 @@ const NotificationBell: React.FC = () => { JSON.parse(event.data); setUnreadCount((c) => c + 1); if (openRef.current) fetchNotifications(); - } catch { /* ignore */ } + } catch { + /* ignore */ + } }; es.onerror = () => { es.close(); @@ -84,7 +90,9 @@ const NotificationBell: React.FC = () => { try { await api.put(`/notifications/${item.id}/read`); setUnreadCount((c) => Math.max(0, c - 1)); - } catch { /* ignore */ } + } catch { + /* ignore */ + } } setOpen(false); if (item.link) navigate(item.link); @@ -94,10 +102,10 @@ const NotificationBell: React.FC = () => { try { await api.put('/notifications/read-all'); setUnreadCount(0); - setNotifications((prev) => - prev.map((n) => ({ ...n, isRead: true })), - ); - } catch { /* ignore */ } + setNotifications((prev) => prev.map((n) => ({ ...n, isRead: true }))); + } catch { + /* ignore */ + } }; const content = ( @@ -148,18 +156,13 @@ const NotificationBell: React.FC = () => { ) } title={ - - [{notificationTypeLabels[item.type] || item.type}] {formatNotificationText(item.title)} + + [{notificationTypeLabels[item.type] || item.type}]{' '} + {formatNotificationText(item.title)} } description={ - + {timeAgo(item.createdAt)} } diff --git a/apps/admin/src/components/PermissionRoute.tsx b/apps/admin/src/components/PermissionRoute.tsx index 9a3b880..fc557eb 100644 --- a/apps/admin/src/components/PermissionRoute.tsx +++ b/apps/admin/src/components/PermissionRoute.tsx @@ -25,7 +25,13 @@ const PermissionRoute: React.FC = ({ permission, children status="403" title="无权访问" subTitle="您没有访问此页面的权限" - extra={firstPath ? : undefined} + extra={ + firstPath ? ( + + ) : undefined + } /> ); } diff --git a/apps/admin/src/components/StudentProfileContent/index.tsx b/apps/admin/src/components/StudentProfileContent/index.tsx index 3203a81..3877f22 100644 --- a/apps/admin/src/components/StudentProfileContent/index.tsx +++ b/apps/admin/src/components/StudentProfileContent/index.tsx @@ -215,7 +215,9 @@ const getStudentStatus = (value?: string | null): { text: string; color: string const formatEnrollmentDisplayName = (enrollment: EnrollmentRecord): string => enrollment.className || - (enrollment.courseCategory ? getCourseCategoryLabel(enrollment.courseCategory) : String(enrollment.id)); + (enrollment.courseCategory + ? getCourseCategoryLabel(enrollment.courseCategory) + : String(enrollment.id)); const ATTACHMENT_CATEGORY_OPTIONS = [ { value: 'id_card', label: '身份证' }, @@ -253,16 +255,31 @@ const SESSION_LABELS: Record = { const AttendanceTab: React.FC<{ data: AttendanceRecordItem[] }> = ({ data }) => { const columns: ColumnsType = [ { title: '日期', dataIndex: 'attendanceDate', width: 120 }, - { title: '课程', render: (_: unknown, record) => record.schedule?.subject || record.class?.name || '课程考勤' }, - { title: '时段', dataIndex: 'session', width: 100, render: (value: string) => SESSION_LABELS[value] || value || '-' }, { - title: '结果', dataIndex: 'status', width: 90, + title: '课程', + render: (_: unknown, record) => record.schedule?.subject || record.class?.name || '课程考勤', + }, + { + title: '时段', + dataIndex: 'session', + width: 100, + render: (value: string) => SESSION_LABELS[value] || value || '-', + }, + { + title: '结果', + dataIndex: 'status', + width: 90, render: (value: string) => { const meta = ATTENDANCE_STATUS_MAP[value] || { text: value || '-', color: 'default' }; return {meta.text}; }, }, - { title: '打卡时间', dataIndex: 'punchTime', width: 170, render: (value?: string | null) => value ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : '-' }, + { + title: '打卡时间', + dataIndex: 'punchTime', + width: 170, + render: (value?: string | null) => (value ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : '-'), + }, { title: '打卡设备', render: (_: unknown, record) => { @@ -283,7 +300,9 @@ const AttendanceTab: React.FC<{ data: AttendanceRecordItem[] }> = ({ data }) => scroll={{ x: 900 }} pagination={{ defaultPageSize: 15, showSizeChanger: true, pageSizeOptions: [15, 30, 50] }} /> - ) : ; + ) : ( + + ); }; interface TabProps { @@ -291,11 +310,11 @@ interface TabProps { onRefresh: () => void; } -const ProfileTab: React.FC<{ data: ProfileData | null; studentId: number; onRefresh: () => void }> = ({ - data, - studentId, - onRefresh, -}) => { +const ProfileTab: React.FC<{ + data: ProfileData | null; + studentId: number; + onRefresh: () => void; +}> = ({ data, studentId, onRefresh }) => { const [form] = Form.useForm(); const [saving, setSaving] = useState(false); @@ -439,10 +458,18 @@ const EnrollmentsTab: React.FC = ({ confirmLoading={saving} >
- + @@ -466,12 +493,9 @@ const EnrollmentsTab: React.FC = ({ ); }; -const ExamScoresTab: React.FC = ({ - data, - studentId, - enrollments, - onRefresh, -}) => { +const ExamScoresTab: React.FC< + TabProps & { data: ExamScoreRecord[]; enrollments: EnrollmentRecord[] } +> = ({ data, studentId, enrollments, onRefresh }) => { const [modalOpen, setModalOpen] = useState(false); const [form] = Form.useForm(); const [saving, setSaving] = useState(false); @@ -505,8 +529,16 @@ const ExamScoresTab: React.FC v || '-' }, { title: '科目', dataIndex: 'subject' }, { title: '成绩', dataIndex: 'score' }, - { title: '班级均分', dataIndex: 'classAvg', render: (v: number | undefined) => (v !== undefined ? v : '-') }, - { title: '排名', dataIndex: 'rank', render: (v: number | undefined) => (v !== undefined ? v : '-') }, + { + title: '班级均分', + dataIndex: 'classAvg', + render: (v: number | undefined) => (v !== undefined ? v : '-'), + }, + { + title: '排名', + dataIndex: 'rank', + render: (v: number | undefined) => (v !== undefined ? v : '-'), + }, { title: '考试日期', dataIndex: 'examDate', render: (v: string) => v || '-' }, { title: '关联报读', @@ -550,13 +582,21 @@ const ExamScoresTab: React.FC - + - + @@ -587,7 +627,11 @@ const ExamScoresTab: React.FC = ({ data, studentId, onRefresh }) => { +const LearningTab: React.FC = ({ + data, + studentId, + onRefresh, +}) => { const [modalOpen, setModalOpen] = useState(false); const [form] = Form.useForm(); const [saving, setSaving] = useState(false); @@ -655,13 +699,25 @@ const LearningTab: React.FC = ({ data, st confirmLoading={saving} > - + - + diff --git a/apps/admin/src/pages/AttendanceDevices.tsx b/apps/admin/src/pages/AttendanceDevices.tsx index 34d726e..fbfa8da 100644 --- a/apps/admin/src/pages/AttendanceDevices.tsx +++ b/apps/admin/src/pages/AttendanceDevices.tsx @@ -59,22 +59,22 @@ const AttendanceDevicesPage: React.FC = () => { }, []); const classroomOptions = useMemo( - () => classrooms.map((item) => ({ - value: item.id, - label: item.building ? `${item.name}(${item.building})` : item.name, - })), + () => + classrooms.map((item) => ({ + value: item.id, + label: item.building ? `${item.name}(${item.building})` : item.name, + })), [classrooms], ); const filteredData = useMemo(() => { const text = keyword.trim().toLocaleLowerCase('zh-CN'); if (!text) return data; - return data.filter((item) => [ - item.deviceSn, - item.deviceName, - item.classroom?.name, - item.location, - ].some((value) => (value || '').toLocaleLowerCase('zh-CN').includes(text))); + return data.filter((item) => + [item.deviceSn, item.deviceName, item.classroom?.name, item.location].some((value) => + (value || '').toLocaleLowerCase('zh-CN').includes(text), + ), + ); }, [data, keyword]); const openCreate = () => { @@ -131,17 +131,48 @@ const AttendanceDevicesPage: React.FC = () => { const columns: ColumnsType = [ { title: '设备名称', dataIndex: 'deviceName', width: 180 }, - { title: 'SN 码', dataIndex: 'deviceSn', width: 220, render: (value) => {value} }, - { title: '绑定教室', dataIndex: ['classroom', 'name'], width: 160, render: (_value, record) => record.classroom?.name || `教室 ${record.classroomId}` }, - { title: '位置', dataIndex: 'location', render: (value) => value || }, - { title: '状态', dataIndex: 'status', width: 90, render: (value: keyof typeof statusMeta) => {statusMeta[value]?.text || value} }, - { title: '备注', dataIndex: 'notes', ellipsis: true, render: (value) => value || }, + { + title: 'SN 码', + dataIndex: 'deviceSn', + width: 220, + render: (value) => {value}, + }, + { + title: '绑定教室', + dataIndex: ['classroom', 'name'], + width: 160, + render: (_value, record) => record.classroom?.name || `教室 ${record.classroomId}`, + }, + { + title: '位置', + dataIndex: 'location', + render: (value) => value || , + }, + { + title: '状态', + dataIndex: 'status', + width: 90, + render: (value: keyof typeof statusMeta) => ( + {statusMeta[value]?.text || value} + ), + }, + { + title: '备注', + dataIndex: 'notes', + ellipsis: true, + render: (value) => value || , + }, { title: '操作', width: 150, render: (_, record) => ( - openEdit(record)}> + openEdit(record)} + > 编辑 handleDelete(record.id)}> @@ -156,7 +187,15 @@ const AttendanceDevicesPage: React.FC = () => { return (
-
+
{ value={keyword} onChange={(event) => setKeyword(event.target.value)} /> - } onClick={openCreate}> + } + onClick={openCreate} + > 添加考勤机
@@ -188,17 +232,39 @@ const AttendanceDevicesPage: React.FC = () => { okText="保存" > - + - + - - - diff --git a/apps/admin/src/pages/Bills/index.tsx b/apps/admin/src/pages/Bills/index.tsx index af2d466..3722c62 100644 --- a/apps/admin/src/pages/Bills/index.tsx +++ b/apps/admin/src/pages/Bills/index.tsx @@ -27,7 +27,6 @@ import { message } from '../../ui/app-message'; import { buildBillPrintHtml, type BillPrintData } from './bill-print'; import { newOperationId } from '../../utils/operation-id'; - const statusMap: Record = { unpaid: { text: '待支付', color: 'orange' }, partially_paid: { text: '部分支付', color: 'gold' }, @@ -64,7 +63,7 @@ const BillsPage: React.FC = () => { const params: Record = {}; if (filterStatus) params.status = filterStatus; if (filterExpenseType) params.expenseType = filterExpenseType; - const res = await api.get('/bills', { params }) as unknown[]; + const res = (await api.get('/bills', { params })) as unknown[]; setBills(res); } catch (e: any) { message.error(e?.message || '加载失败,请稍后重试'); @@ -120,17 +119,30 @@ const BillsPage: React.FC = () => { } }; - - const handleCancel = async (id: number) => { let reason = ''; Modal.confirm({ title: '取消账单并退回已扣余额', - content: { reason = event.target.value; }} />, - okText: '确认取消', cancelText: '返回', + content: ( + { + reason = event.target.value; + }} + /> + ), + okText: '确认取消', + cancelText: '返回', onOk: async () => { - if (!reason.trim()) { message.error('请输入取消原因'); throw new Error('reason required'); } - await api.post(`/bills/${id}/cancel`, { operationId: newOperationId(), reason: reason.trim() }); + if (!reason.trim()) { + message.error('请输入取消原因'); + throw new Error('reason required'); + } + await api.post(`/bills/${id}/cancel`, { + operationId: newOperationId(), + reason: reason.trim(), + }); message.success('账单已取消,已扣余额已冲正退回'); fetchData(); }, @@ -142,7 +154,9 @@ const BillsPage: React.FC = () => { await api.delete(`/bills/${id}`); message.success('账单已归档'); fetchData(); - } catch (error: any) { message.error(error?.message || '归档失败'); } + } catch (error: any) { + message.error(error?.message || '归档失败'); + } }; const batchArchive = async () => { @@ -175,7 +189,9 @@ const BillsPage: React.FC = () => { return; } - printWindow.document.write('

正在加载账单...

'); + printWindow.document.write( + '

正在加载账单...

', + ); try { const bill = await api.get(`/bills/${billId}`); printWindow.document.open(); @@ -187,89 +203,125 @@ const BillsPage: React.FC = () => { } }; - const columns = useMemo(() => [ - { title: '学生', width: 120, render: (_: any, r: any) => r.student?.name || '-' }, - { title: '账单周期', width: 200, render: (_: any, r: any) => `${r.periodStart} ~ ${r.periodEnd}` }, - { - title: '分摊费用', - dataIndex: 'sharedAmount', - width: 120, - align: 'right' as const, - render: (v: number) => `¥${Number(v).toFixed(2)}`, - }, - { - title: '个人费用', - dataIndex: 'personalAmount', - width: 120, - align: 'right' as const, - render: (v: number) => `¥${Number(v).toFixed(2)}`, - }, - { - title: '总计', - dataIndex: 'totalAmount', - width: 100, - align: 'right' as const, - render: (v: number) => ¥{Number(v).toFixed(2)}, - }, - { - title: '已扣余额', dataIndex: 'paidAmount', width: 110, - render: (value: number) => ¥{Number(value || 0).toFixed(2)}, - }, - { - title: '待补缴', dataIndex: 'outstandingAmount', width: 110, - render: (value: number) => 0 ? '#cf1322' : '#389e0d' }}>¥{Number(value || 0).toFixed(2)}, - }, - { - title: '钱包余额', dataIndex: 'walletBalance', width: 110, - render: (value: number) => `¥${Number(value || 0).toFixed(2)}`, - }, - { - title: '状态', - dataIndex: 'status', - width: 90, - render: (s: string) => {statusMap[s]?.text}, - }, - { - title: '生成时间', - dataIndex: 'generatedAt', - width: 160, - render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm'), - }, - { - title: '操作', - width: 320, - render: (_: any, record: any) => ( - - showDetail(record.id)} - > - 详情 - - } - onClick={() => handleExportPdf(record.id)} - > - PDF - - {record.status !== 'cancelled' && ( - handleCancel(record.id)}> - 取消并冲正 + const columns = useMemo( + () => [ + { title: '学生', width: 120, render: (_: any, r: any) => r.student?.name || '-' }, + { + title: '账单周期', + width: 200, + render: (_: any, r: any) => `${r.periodStart} ~ ${r.periodEnd}`, + }, + { + title: '分摊费用', + dataIndex: 'sharedAmount', + width: 120, + align: 'right' as const, + render: (v: number) => `¥${Number(v).toFixed(2)}`, + }, + { + title: '个人费用', + dataIndex: 'personalAmount', + width: 120, + align: 'right' as const, + render: (v: number) => `¥${Number(v).toFixed(2)}`, + }, + { + title: '总计', + dataIndex: 'totalAmount', + width: 100, + align: 'right' as const, + render: (v: number) => ¥{Number(v).toFixed(2)}, + }, + { + title: '已扣余额', + dataIndex: 'paidAmount', + width: 110, + render: (value: number) => ( + ¥{Number(value || 0).toFixed(2)} + ), + }, + { + title: '待补缴', + dataIndex: 'outstandingAmount', + width: 110, + render: (value: number) => ( + 0 ? '#cf1322' : '#389e0d' }}> + ¥{Number(value || 0).toFixed(2)} + + ), + }, + { + title: '钱包余额', + dataIndex: 'walletBalance', + width: 110, + render: (value: number) => `¥${Number(value || 0).toFixed(2)}`, + }, + { + title: '状态', + dataIndex: 'status', + width: 90, + render: (s: string) => {statusMap[s]?.text}, + }, + { + title: '生成时间', + dataIndex: 'generatedAt', + width: 160, + render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm'), + }, + { + title: '操作', + width: 320, + render: (_: any, record: any) => ( + + showDetail(record.id)} + > + 详情 - )} - {Number(record.paidAmount || 0) === 0 && record.status !== 'cancelled' && ( - handleArchive(record.id)} okText="归档" cancelText="取消"> - }>归档 - - )} - - ), - }, - ], [showDetail, handleArchive, handleCancel, handleExportPdf]); + } + onClick={() => handleExportPdf(record.id)} + > + PDF + + {record.status !== 'cancelled' && ( + handleCancel(record.id)} + > + 取消并冲正 + + )} + {Number(record.paidAmount || 0) === 0 && record.status !== 'cancelled' && ( + handleArchive(record.id)} + okText="归档" + cancelText="取消" + > + } + > + 归档 + + + )} + + ), + }, + ], + [showDetail, handleArchive, handleCancel, handleExportPdf], + ); return (
@@ -297,8 +349,20 @@ const BillsPage: React.FC = () => { { value: 'cancelled', label: '已取消' }, ]} /> - { picker="month" placeholder="选择月份" format="YYYY-MM" - disabledDate={(current) => !!current && !current.endOf('month').isBefore(dayjs(), 'day')} + disabledDate={(current) => + !!current && !current.endOf('month').isBefore(dayjs(), 'day') + } /> @@ -412,9 +478,15 @@ const BillsPage: React.FC = () => { - ¥{Number(detailModal.paidAmount || 0).toFixed(2)} - ¥{Number(detailModal.outstandingAmount || 0).toFixed(2)} - ¥{Number(detailModal.walletBalance || 0).toFixed(2)} + + ¥{Number(detailModal.paidAmount || 0).toFixed(2)} + + + ¥{Number(detailModal.outstandingAmount || 0).toFixed(2)} + + + ¥{Number(detailModal.walletBalance || 0).toFixed(2)} +

费用明细

= { }; const WEEK_DAY_MAP: Record = { - 1: '周一', 2: '周二', 3: '周三', 4: '周四', 5: '周五', 6: '周六', 7: '周日', + 1: '周一', + 2: '周二', + 3: '周三', + 4: '周四', + 5: '周五', + 6: '周六', + 7: '周日', }; const SCHEDULE_TYPE_MAP: Record = { @@ -145,14 +166,18 @@ const ClassDetailPage: React.FC = () => { // Schedule & attendance state const [schedules, setSchedules] = useState([]); - const [scheduleDateRange, setScheduleDateRange] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null]>([null, null]); + const [scheduleDateRange, setScheduleDateRange] = useState< + [dayjs.Dayjs | null, dayjs.Dayjs | null] + >([null, null]); const [attendanceSummary, setAttendanceSummary] = useState(null); - const [attendanceDateRange, setAttendanceDateRange] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null]>([null, null]); + const [attendanceDateRange, setAttendanceDateRange] = useState< + [dayjs.Dayjs | null, dayjs.Dayjs | null] + >([null, null]); const fetchDetail = useCallback(async () => { setLoading(true); try { - const res = await api.get(`/classes/${id}`) as ClassDetail; + const res = (await api.get(`/classes/${id}`)) as ClassDetail; setDetail(res); setStudents(res.students || []); setTeachers(res.teachers || []); @@ -164,7 +189,9 @@ const ClassDetailPage: React.FC = () => { } }, [id]); - useEffect(() => { fetchDetail(); }, [fetchDetail]); + useEffect(() => { + fetchDetail(); + }, [fetchDetail]); const fetchSchedules = useCallback(async () => { if (!id) return; @@ -180,7 +207,9 @@ const ClassDetailPage: React.FC = () => { } }, [id, scheduleDateRange]); - useEffect(() => { fetchSchedules(); }, [fetchSchedules]); + useEffect(() => { + fetchSchedules(); + }, [fetchSchedules]); const fetchAttendanceSummary = useCallback(async () => { if (!id) return; @@ -196,7 +225,9 @@ const ClassDetailPage: React.FC = () => { } }, [id, attendanceDateRange]); - useEffect(() => { fetchAttendanceSummary(); }, [fetchAttendanceSummary]); + useEffect(() => { + fetchAttendanceSummary(); + }, [fetchAttendanceSummary]); const handleSaveInfo = async () => { try { @@ -275,7 +306,9 @@ const ClassDetailPage: React.FC = () => { const openStudentModal = async () => { try { - const res = await api.get('/students', { params: { includeArchived: 'false' } }) as StudentItem[]; + const res = (await api.get('/students', { + params: { includeArchived: 'false' }, + })) as StudentItem[]; setAllStudents(res || []); setSelectedStudentIds([]); setStudentModalOpen(true); @@ -287,7 +320,7 @@ const ClassDetailPage: React.FC = () => { const openTeacherModal = async () => { try { - const res = await api.get('/rbac/users') as UserItem[]; + const res = (await api.get('/rbac/users')) as UserItem[]; setAllUsers(res || []); setTeacherUserId(undefined); setTeacherRole('subject_teacher'); @@ -310,9 +343,7 @@ const ClassDetailPage: React.FC = () => { title: '状态', dataIndex: 'status', render: (v: string) => ( - - {v === 'active' ? '在读' : '已离班'} - + {v === 'active' ? '在读' : '已离班'} ), }, { @@ -320,7 +351,9 @@ const ClassDetailPage: React.FC = () => { render: (_: unknown, r: ClassStudent) => r.status === 'active' ? ( handleRemoveStudent(r.studentId)}> - 移除 + + 移除 + ) : null, }, @@ -342,7 +375,9 @@ const ClassDetailPage: React.FC = () => { title: '操作', render: (_: unknown, r: ClassTeacher) => ( handleRemoveTeacher(r.userId)}> - 移除 + + 移除 + ), }, @@ -351,15 +386,27 @@ const ClassDetailPage: React.FC = () => { const scheduleColumns: ColumnsType = [ { title: '教室', dataIndex: 'classroomName', render: (v: string | null) => v || '-' }, { title: '星期', dataIndex: 'weekDay', render: (v: number) => WEEK_DAY_MAP[v] || v }, - { title: '时间', render: (_: unknown, r: ClassScheduleItem) => `${r.startTime} - ${r.endTime}` }, - { title: '签到窗口', render: (_: unknown, r: ClassScheduleItem) => `课前 ${r.attendanceAdvanceMinutes ?? 30} 分钟至下课` }, - { title: '日期范围', render: (_: unknown, r: ClassScheduleItem) => `${r.startDate} ~ ${r.endDate}` }, + { + title: '时间', + render: (_: unknown, r: ClassScheduleItem) => `${r.startTime} - ${r.endTime}`, + }, + { + title: '签到窗口', + render: (_: unknown, r: ClassScheduleItem) => + `课前 ${r.attendanceAdvanceMinutes ?? 30} 分钟至下课`, + }, + { + title: '日期范围', + render: (_: unknown, r: ClassScheduleItem) => `${r.startDate} ~ ${r.endDate}`, + }, { title: '科目', dataIndex: 'subject' }, { title: '类型', dataIndex: 'scheduleType', render: (v: string) => SCHEDULE_TYPE_MAP[v] || v }, { title: '状态', dataIndex: 'status', - render: (v: string) => {v === 'active' ? '启用' : v}, + render: (v: string) => ( + {v === 'active' ? '启用' : v} + ), }, ]; @@ -368,7 +415,9 @@ const ClassDetailPage: React.FC = () => { title={ @@ -458,9 +511,7 @@ const ClassDetailPage: React.FC = () => { {teachers.find((t) => t.roleType === 'head_teacher')?.username || '-'} - - {detail.notes || '-'} - + {detail.notes || '-'} { label: '课表', children: (
- + setScheduleDateRange(dates as [dayjs.Dayjs | null, dayjs.Dayjs | null])} + onChange={(dates) => + setScheduleDateRange(dates as [dayjs.Dayjs | null, dayjs.Dayjs | null]) + } placeholder={['开始日期', '结束日期']} /> @@ -638,6 +691,7 @@ const ClassDetailPage: React.FC = () => { columns={scheduleColumns} dataSource={schedules} rowKey="id" + scroll={{ x: 'max-content' }} pagination={{ defaultPageSize: 20, showSizeChanger: true, @@ -652,31 +706,37 @@ const ClassDetailPage: React.FC = () => { label: '出勤汇总', children: (
- + setAttendanceDateRange(dates as [dayjs.Dayjs | null, dayjs.Dayjs | null])} + onChange={(dates) => + setAttendanceDateRange(dates as [dayjs.Dayjs | null, dayjs.Dayjs | null]) + } placeholder={['开始日期', '结束日期']} /> {attendanceSummary && ( - -
+ + - + - + - + - + diff --git a/apps/admin/src/pages/Classes/index.tsx b/apps/admin/src/pages/Classes/index.tsx index a815c2d..519850f 100644 --- a/apps/admin/src/pages/Classes/index.tsx +++ b/apps/admin/src/pages/Classes/index.tsx @@ -1,7 +1,19 @@ import React, { useEffect, useState, useMemo, useCallback } from 'react'; import { - Table, Button, Input, Select, Space, Tag, Modal, Form, InputNumber, - DatePicker, Popconfirm, Card, Switch, Empty, + Table, + Button, + Input, + Select, + Space, + Tag, + Modal, + Form, + InputNumber, + DatePicker, + Popconfirm, + Card, + Switch, + Empty, } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { PlusOutlined, SearchOutlined, TeamOutlined, InboxOutlined } from '@ant-design/icons'; @@ -96,13 +108,14 @@ const ClassesPage: React.FC = () => { setData(res); } catch (e: any) { message.error(e?.message || '加载失败,请稍后重试'); - } - finally { + } finally { setLoading(false); } }, [filterStatus, filterType, showArchived]); - useEffect(() => { fetchData(); }, [fetchData]); + useEffect(() => { + fetchData(); + }, [fetchData]); const filtered = useMemo(() => { if (!searchText) return data; @@ -154,58 +167,86 @@ const ClassesPage: React.FC = () => { } }; - const columns: ColumnsType = useMemo(() => [ - { - title: '班级名称', dataIndex: 'name', width: 120, - sorter: (a, b) => a.name.localeCompare(b.name), - }, - { title: '编码', dataIndex: 'code', width: 140 }, - { - title: '班型', dataIndex: 'classType', width: 100, - render: (v: string) => {TYPE_MAP[v] || v}, - }, - { - title: '开班日期', dataIndex: 'startDate', width: 110, - render: (v: string | null) => v || '-', - }, - { - title: '学员', width: 100, - render: (_: unknown, r: ClassItem) => `${r.studentCount || 0}/${r.maxStudents || '-'}`, - }, - { - title: '状态', dataIndex: 'status', width: 100, - render: (v: string) => { - const cfg = STATUS_MAP[v] || { color: 'default', text: v }; - return {cfg.text}; + const columns: ColumnsType = useMemo( + () => [ + { + title: '班级名称', + dataIndex: 'name', + width: 120, + sorter: (a, b) => a.name.localeCompare(b.name), }, - }, - { - title: '操作', width: 280, - render: (_: unknown, r: ClassItem) => ( - - - handleEdit(r)}> - 编辑 - - {r.isArchived ? ( - handleArchive(r.id, false)}> - 恢复 - - ) : ( - handleArchive(r.id, true)}> - 归档 - - )} - - ), - }, - ], []); + { title: '编码', dataIndex: 'code', width: 140 }, + { + title: '班型', + dataIndex: 'classType', + width: 100, + render: (v: string) => {TYPE_MAP[v] || v}, + }, + { + title: '开班日期', + dataIndex: 'startDate', + width: 110, + render: (v: string | null) => v || '-', + }, + { + title: '学员', + width: 100, + render: (_: unknown, r: ClassItem) => `${r.studentCount || 0}/${r.maxStudents || '-'}`, + }, + { + title: '状态', + dataIndex: 'status', + width: 100, + render: (v: string) => { + const cfg = STATUS_MAP[v] || { color: 'default', text: v }; + return {cfg.text}; + }, + }, + { + title: '操作', + width: 280, + render: (_: unknown, r: ClassItem) => ( + + + handleEdit(r)}> + 编辑 + + {r.isArchived ? ( + handleArchive(r.id, false)}> + + 恢复 + + + ) : ( + handleArchive(r.id, true)} + > + + 归档 + + + )} + + ), + }, + ], + [], + ); return ( - + } @@ -229,7 +270,12 @@ const ClassesPage: React.FC = () => { onChange={setFilterStatus} options={Object.entries(STATUS_MAP).map(([k, v]) => ({ value: k, label: v.text }))} /> - } onClick={handleCreate}> + } + onClick={handleCreate} + > 创建班级 @@ -287,7 +333,9 @@ const ClassesPage: React.FC = () => { - ({ value: k, label: v.text }))} + /> diff --git a/apps/admin/src/pages/ClassroomRentals/index.tsx b/apps/admin/src/pages/ClassroomRentals/index.tsx index 1c06816..59cbd46 100644 --- a/apps/admin/src/pages/ClassroomRentals/index.tsx +++ b/apps/admin/src/pages/ClassroomRentals/index.tsx @@ -15,7 +15,13 @@ import { Tooltip, Empty, } from 'antd'; -import { PlusOutlined, UploadOutlined, FileTextOutlined, StopOutlined, CheckOutlined } from '@ant-design/icons'; +import { + PlusOutlined, + UploadOutlined, + FileTextOutlined, + StopOutlined, + CheckOutlined, +} from '@ant-design/icons'; import dayjs, { Dayjs } from 'dayjs'; import api from '../../api'; import { downloadBlob } from '../../utils/download'; @@ -392,17 +398,36 @@ const ClassroomRentalsPage: React.FC = () => { {record.effectiveStatus === 'active' && ( <> - openEdit(record)}> + openEdit(record)} + > 编辑 - handleRentalAction(record.id, 'cancel')}> - }> + handleRentalAction(record.id, 'cancel')} + > + } + > 取消 {!dayjs(record.startDate).isAfter(dayjs(), 'day') && ( - handleRentalAction(record.id, 'end')}> - }> + handleRentalAction(record.id, 'end')} + > + } + > 结束 @@ -410,8 +435,13 @@ const ClassroomRentalsPage: React.FC = () => { )} {record.effectiveStatus !== 'active' && ( - handleDelete(record.id)}> - 归档 + handleDelete(record.id)} + > + + 归档 + )} @@ -511,10 +541,12 @@ const ClassroomRentalsPage: React.FC = () => { optionFilterProp="label" placeholder="选择教室" onChange={handleClassroomChange} - options={classrooms.filter((c) => c.status === 'available').map((c) => ({ - value: c.id, - label: `${c.building ? c.building + ' · ' : ''}${c.name}(${c.roomType})`, - }))} + options={classrooms + .filter((c) => c.status === 'available') + .map((c) => ({ + value: c.id, + label: `${c.building ? c.building + ' · ' : ''}${c.name}(${c.roomType})`, + }))} /> diff --git a/apps/admin/src/pages/Classrooms/index.tsx b/apps/admin/src/pages/Classrooms/index.tsx index b528742..60d25a6 100644 --- a/apps/admin/src/pages/Classrooms/index.tsx +++ b/apps/admin/src/pages/Classrooms/index.tsx @@ -60,8 +60,16 @@ const ClassroomsPage: React.FC = () => { const filteredData = useMemo(() => { let result = data; - if (searchText) { const s = searchText.toLowerCase(); result = result.filter((d: Record) => (typeof d.name === 'string' && d.name.toLowerCase().includes(s)) || (typeof d.building === 'string' && d.building.toLowerCase().includes(s))); } - if (filterStatus) result = result.filter((d: Record) => d.effectiveStatus === filterStatus); + if (searchText) { + const s = searchText.toLowerCase(); + result = result.filter( + (d: Record) => + (typeof d.name === 'string' && d.name.toLowerCase().includes(s)) || + (typeof d.building === 'string' && d.building.toLowerCase().includes(s)), + ); + } + if (filterStatus) + result = result.filter((d: Record) => d.effectiveStatus === filterStatus); return result; }, [data, searchText, filterStatus]); @@ -140,72 +148,98 @@ const ClassroomsPage: React.FC = () => { .catch(() => message.error('下载失败')); }; - const columns = useMemo(() => [ - { - title: '教室名', width: 120, - dataIndex: 'name', - sorter: (a: any, b: any) => a.name.localeCompare(b.name), - }, - { title: '楼栋', dataIndex: 'building', width: 80 }, - { title: '楼层', dataIndex: 'floor', width: 80 }, - { - title: '类型', width: 90, - dataIndex: 'roomType', - render: (v: string) => {v || '-'}, - }, - { title: '容量', dataIndex: 'capacity', width: 80 }, - { - title: '状态', width: 100, - dataIndex: 'status', - render: (_s: string, record: { effectiveStatus?: string; status: string; currentUsage?: CurrentUsage | null }) => { - const effectiveStatus = record.effectiveStatus || record.status; - return ( - - {statusMap[effectiveStatus]?.text || effectiveStatus} - - ); + const columns = useMemo( + () => [ + { + title: '教室名', + width: 120, + dataIndex: 'name', + sorter: (a: any, b: any) => a.name.localeCompare(b.name), }, - }, - { - title: '操作', - width: 180, - render: (_: any, record: any) => ( - - {record.status === 'archived' ? ( - handleRestore(record.id)}> - } type="link"> - 恢复 - - - ) : ( - <> - { - setEditing(record); - form.setFieldsValue(record); - setModalOpen(true); - }} - > - 编辑 - - handleArchive(record.id)} - okText="归档" - cancelText="取消" - > - }> - 归档 + { title: '楼栋', dataIndex: 'building', width: 80 }, + { title: '楼层', dataIndex: 'floor', width: 80 }, + { + title: '类型', + width: 90, + dataIndex: 'roomType', + render: (v: string) => {v || '-'}, + }, + { title: '容量', dataIndex: 'capacity', width: 80 }, + { + title: '状态', + width: 100, + dataIndex: 'status', + render: ( + _s: string, + record: { effectiveStatus?: string; status: string; currentUsage?: CurrentUsage | null }, + ) => { + const effectiveStatus = record.effectiveStatus || record.status; + return ( + + + {statusMap[effectiveStatus]?.text || effectiveStatus} + + + ); + }, + }, + { + title: '操作', + width: 180, + render: (_: any, record: any) => ( + + {record.status === 'archived' ? ( + handleRestore(record.id)}> + } + type="link" + > + 恢复 - - )} - - ), - }, - ], []); + ) : ( + <> + { + setEditing(record); + form.setFieldsValue(record); + setModalOpen(true); + }} + > + 编辑 + + handleArchive(record.id)} + okText="归档" + cancelText="取消" + > + } + > + 归档 + + + + )} + + ), + }, + ], + [], + ); return (
@@ -228,7 +262,20 @@ const ClassroomsPage: React.FC = () => { if (!e.target.value) setSearchText(''); }} /> -
{ options={studentOptions} /> - + - + @@ -574,7 +628,7 @@ const DepositsPage: React.FC = () => {
当前可用押金: ¥{Number(refundModal?.amount || 0).toFixed(2)}
- + @@ -594,19 +648,34 @@ const DepositsPage: React.FC = () => { {detailModal && (
-

当前可用押金: ¥{Number(detailModal.amount).toFixed(2)}

-

最近收取日期: {detailModal.paidDate}

+

+ 当前可用押金: ¥{Number(detailModal.amount).toFixed(2)} +

+

+ 最近收取日期: {detailModal.paidDate} +

状态:{' '} {statusMap[detailModal.status]?.text || detailModal.status}

- {detailModal.notes &&

备注: {detailModal.notes}

} + {detailModal.notes && ( +

+ 备注: {detailModal.notes} +

+ )}
{/* Installments Section */} -
+

分期记录

{ title="确定归档?" onConfirm={() => handleDeleteInstallment(item.id)} > - }> + } + > 归档 , @@ -676,10 +751,10 @@ const DepositsPage: React.FC = () => { okText="确认" > - + - + diff --git a/apps/admin/src/pages/Expenses/index.tsx b/apps/admin/src/pages/Expenses/index.tsx index e5a45e8..30325af 100644 --- a/apps/admin/src/pages/Expenses/index.tsx +++ b/apps/admin/src/pages/Expenses/index.tsx @@ -32,11 +32,9 @@ import { message } from '../../ui/app-message'; const { RangePicker } = DatePicker; const isFormValidationError = (error: unknown) => - typeof error === 'object' - && error !== null - && Array.isArray((error as { errorFields?: unknown }).errorFields); - - + typeof error === 'object' && + error !== null && + Array.isArray((error as { errorFields?: unknown }).errorFields); const ExpensesPage: React.FC = () => { const [roomExpenses, setRoomExpenses] = useState([]); @@ -63,27 +61,32 @@ const ExpensesPage: React.FC = () => { // Dynamic expense type options from API const [typeOptions, setTypeOptions] = useState<{ value: string; label: string }[]>([]); - const [personalTypeOptions, setPersonalTypeOptions] = useState<{ value: string; label: string }[]>([]); + const [personalTypeOptions, setPersonalTypeOptions] = useState< + { value: string; label: string }[] + >([]); const [typeMap, setTypeMap] = useState>({}); useEffect(() => { - api.get>('/expense-types').then((types) => { - const roomTypes: { value: string; label: string }[] = []; - const personalTypes: { value: string; label: string }[] = []; - const map: Record = {}; - for (const t of types) { - map[t.code] = t.name; - if (t.category === 'room' || t.category === 'both') { - roomTypes.push({ value: t.code, label: t.name }); + api + .get>('/expense-types') + .then((types) => { + const roomTypes: { value: string; label: string }[] = []; + const personalTypes: { value: string; label: string }[] = []; + const map: Record = {}; + for (const t of types) { + map[t.code] = t.name; + if (t.category === 'room' || t.category === 'both') { + roomTypes.push({ value: t.code, label: t.name }); + } + if (t.category === 'personal' || t.category === 'both') { + personalTypes.push({ value: t.code, label: t.name }); + } } - if (t.category === 'personal' || t.category === 'both') { - personalTypes.push({ value: t.code, label: t.name }); - } - } - setTypeOptions(roomTypes); - setPersonalTypeOptions(personalTypes); - setTypeMap(map); - }).catch(() => {}); + setTypeOptions(roomTypes); + setPersonalTypeOptions(personalTypes); + setTypeMap(map); + }) + .catch(() => {}); }, []); const handleBatchDeleteRoom = async () => { @@ -207,12 +210,17 @@ const ExpensesPage: React.FC = () => { description: values.description, }); const bill = result.bill; - message.success(`账单已生成,已从余额扣除 ¥${Number(bill.paidAmount || 0).toFixed(2)},待补缴 ¥${Number(bill.outstandingAmount || 0).toFixed(2)}`); + message.success( + `账单已生成,已从余额扣除 ¥${Number(bill.paidAmount || 0).toFixed(2)},待补缴 ¥${Number(bill.outstandingAmount || 0).toFixed(2)}`, + ); setUtilityModal(false); utilityForm.resetFields(); fetchData(); - } catch (e: any) { message.error(e?.message || '水电费出账失败'); } - finally { setSaving(false); } + } catch (e: any) { + message.error(e?.message || '水电费出账失败'); + } finally { + setSaving(false); + } }; const handlePersonalExpense = async () => { @@ -247,121 +255,139 @@ const ExpensesPage: React.FC = () => { } }; - const roomColumns = useMemo(() => [ - { title: '宿舍', width: 120, render: (_: any, r: any) => r.room?.roomNumber || '-' }, - { - title: '费用类型', width: 100, - dataIndex: 'expenseType', - render: (v: string) => {typeMap[v] || v}, - }, - { title: '金额', dataIndex: 'amount', width: 100, render: (v: number) => `¥${Number(v).toFixed(2)}` }, - { title: '账单周期', width: 200, render: (_: any, r: any) => `${r.periodStart} ~ ${r.periodEnd}` }, - { title: '说明', dataIndex: 'description', width: 150 }, - { - title: '录入时间', width: 160, - dataIndex: 'createdAt', - render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm'), - }, - { - title: '操作', - width: 120, - render: (_: any, record: any) => ( - - } - onClick={() => { - setEditingRoom(record); - roomForm.setFieldsValue({ - roomId: record.roomId, - expenseType: record.expenseType, - amount: Number(record.amount), - period: [dayjs(record.periodStart), dayjs(record.periodEnd)], - description: record.description, - }); - setRoomModal(true); - }} - > - 编辑 - - { - await api.delete(`/expenses/room/${record.id}`); - message.success('归档成功'); - fetchData(); - }} - > + const roomColumns = useMemo( + () => [ + { title: '宿舍', width: 120, render: (_: any, r: any) => r.room?.roomNumber || '-' }, + { + title: '费用类型', + width: 100, + dataIndex: 'expenseType', + render: (v: string) => {typeMap[v] || v}, + }, + { + title: '金额', + dataIndex: 'amount', + width: 100, + render: (v: number) => `¥${Number(v).toFixed(2)}`, + }, + { + title: '账单周期', + width: 200, + render: (_: any, r: any) => `${r.periodStart} ~ ${r.periodEnd}`, + }, + { title: '说明', dataIndex: 'description', width: 150 }, + { + title: '录入时间', + width: 160, + dataIndex: 'createdAt', + render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm'), + }, + { + title: '操作', + width: 120, + render: (_: any, record: any) => ( + } + icon={} + onClick={() => { + setEditingRoom(record); + roomForm.setFieldsValue({ + roomId: record.roomId, + expenseType: record.expenseType, + amount: Number(record.amount), + period: [dayjs(record.periodStart), dayjs(record.periodEnd)], + description: record.description, + }); + setRoomModal(true); + }} > - 归档 + 编辑 - - - ), - }, - ], [setEditingRoom, roomForm, setRoomModal, fetchData, typeMap]); + { + await api.delete(`/expenses/room/${record.id}`); + message.success('归档成功'); + fetchData(); + }} + > + } + > + 归档 + + + + ), + }, + ], + [setEditingRoom, roomForm, setRoomModal, fetchData, typeMap], + ); - const personalColumns = useMemo(() => [ - { title: '学生', width: 120, render: (_: any, r: any) => r.student?.name || '-' }, - { - title: '费用类型', width: 100, - dataIndex: 'expenseType', - render: (v: string) => {typeMap[v] || v}, - }, - { title: '金额', dataIndex: 'amount', render: (v: number) => `¥${Number(v).toFixed(2)}` }, - { title: '日期', dataIndex: 'expenseDate', width: 110 }, - { title: '说明', dataIndex: 'description', width: 150 }, - { - title: '操作', - width: 120, - render: (_: any, record: any) => ( - - } - onClick={() => { - setEditingPersonal(record); - personalForm.setFieldsValue({ - studentId: record.studentId, - roomId: record.roomId, - expenseType: record.expenseType, - amount: Number(record.amount), - expenseDate: dayjs(record.expenseDate), - description: record.description, - }); - setPersonalModal(true); - }} - > - 编辑 - - { - await api.delete(`/expenses/personal/${record.id}`); - message.success('归档成功'); - fetchData(); - }} - > + const personalColumns = useMemo( + () => [ + { title: '学生', width: 120, render: (_: any, r: any) => r.student?.name || '-' }, + { + title: '费用类型', + width: 100, + dataIndex: 'expenseType', + render: (v: string) => {typeMap[v] || v}, + }, + { title: '金额', dataIndex: 'amount', render: (v: number) => `¥${Number(v).toFixed(2)}` }, + { title: '日期', dataIndex: 'expenseDate', width: 110 }, + { title: '说明', dataIndex: 'description', width: 150 }, + { + title: '操作', + width: 120, + render: (_: any, record: any) => ( + } + icon={} + onClick={() => { + setEditingPersonal(record); + personalForm.setFieldsValue({ + studentId: record.studentId, + roomId: record.roomId, + expenseType: record.expenseType, + amount: Number(record.amount), + expenseDate: dayjs(record.expenseDate), + description: record.description, + }); + setPersonalModal(true); + }} > - 归档 + 编辑 - - - ), - }, - ], [setEditingPersonal, personalForm, setPersonalModal, fetchData, typeMap]); + { + await api.delete(`/expenses/personal/${record.id}`); + message.success('归档成功'); + fetchData(); + }} + > + } + > + 归档 + + + + ), + }, + ], + [setEditingPersonal, personalForm, setPersonalModal, fetchData, typeMap], + ); return (
@@ -430,8 +456,8 @@ const ExpensesPage: React.FC = () => { permission="expense:view" icon={} onClick={() => { - downloadBlob('/expenses/utility/template', '水电费导入模板.xlsx').catch(() => - message.error('下载失败'), + downloadBlob('/expenses/utility/template', '水电费导入模板.xlsx').catch( + () => message.error('下载失败'), ); }} > @@ -547,9 +573,10 @@ const ExpensesPage: React.FC = () => { permission="expense:view" icon={} onClick={() => { - downloadBlob('/expenses/personal/template', '个人附加费导入模板.xlsx').catch( - () => message.error('下载失败'), - ); + downloadBlob( + '/expenses/personal/template', + '个人附加费导入模板.xlsx', + ).catch(() => message.error('下载失败')); }} > 下载模板 @@ -586,7 +613,10 @@ const ExpensesPage: React.FC = () => { } - onClick={() => { utilityForm.resetFields(); setUtilityModal(true); }} + onClick={() => { + utilityForm.resetFields(); + setUtilityModal(true); + }} > 添加学生水电费 @@ -669,16 +699,42 @@ const ExpensesPage: React.FC = () => { - - setUtilityModal(false)} okText="生成账单并扣余额" confirmLoading={saving}> + setUtilityModal(false)} + okText="生成账单并扣余额" + confirmLoading={saving} + >
- ({ + value: student.id, + label: `${student.name} (${student.studentNo || `#${student.id}`})`, + }))} + /> + + + - - -
diff --git a/apps/admin/src/pages/IntegrationConfig/index.tsx b/apps/admin/src/pages/IntegrationConfig/index.tsx index 0886bef..ab017c0 100644 --- a/apps/admin/src/pages/IntegrationConfig/index.tsx +++ b/apps/admin/src/pages/IntegrationConfig/index.tsx @@ -1,12 +1,33 @@ import React, { useEffect, useState, useMemo, useCallback } from 'react'; import { - Card, Form, Input, Button, Space, Spin, Alert, Descriptions, Tag, Divider, - Drawer, Tree, Select, TreeSelect, Modal, DatePicker, - Row, Col, List, + Card, + Form, + Input, + Button, + Space, + Spin, + Alert, + Descriptions, + Tag, + Divider, + Drawer, + Tree, + Select, + TreeSelect, + Modal, + DatePicker, + Row, + Col, + List, } from 'antd'; import { - SaveOutlined, ApiOutlined, CheckCircleOutlined, CloseCircleOutlined, - SyncOutlined, BankOutlined, UserOutlined, + SaveOutlined, + ApiOutlined, + CheckCircleOutlined, + CloseCircleOutlined, + SyncOutlined, + BankOutlined, + UserOutlined, StopOutlined, } from '@ant-design/icons'; import type { DataNode } from 'antd/es/tree'; @@ -89,7 +110,6 @@ interface DeleteAttendanceGroupsResponse { }; } - const IntegrationConfigPage: React.FC = () => { const { hasAllPermissions } = usePermission(); const [loading, setLoading] = useState(false); @@ -116,11 +136,13 @@ const IntegrationConfigPage: React.FC = () => { const [loadingGroups, setLoadingGroups] = useState(false); const [deletingGroups, setDeletingGroups] = useState(false); - const fetchConfig = async () => { setLoading(true); try { - const res = await api.get<{ success: boolean; data: Array<{ type: string; verify: boolean; config: DingTalkConfig }> }>('/integration/config'); + const res = await api.get<{ + success: boolean; + data: Array<{ type: string; verify: boolean; config: DingTalkConfig }>; + }>('/integration/config'); const dt = res.data?.find((c) => c.type === 'DINGTALK'); if (dt) { setConfig(dt.config); @@ -159,10 +181,13 @@ const IntegrationConfigPage: React.FC = () => { const payload = buildDingTalkConfigPayload(values); setTesting(true); try { - const res = await api.post<{ success: boolean; message: string }>('/integration/config/test', { - type: 'DINGTALK', - config: payload, - }); + const res = await api.post<{ success: boolean; message: string }>( + '/integration/config/test', + { + type: 'DINGTALK', + config: payload, + }, + ); setVerified(res.success); message.success(res.message); } catch (e: unknown) { @@ -174,7 +199,6 @@ const IntegrationConfigPage: React.FC = () => { } }; - const loadDeptTree = async () => { try { const res = await api.get('/sync/dingtalk/org-tree'); @@ -200,7 +224,9 @@ const IntegrationConfigPage: React.FC = () => { } else { setClasses(res.data ?? []); } - } catch { /* ignore */ } + } catch { + /* ignore */ + } }; const handleFetchOrgTree = async () => { @@ -208,7 +234,9 @@ const IntegrationConfigPage: React.FC = () => { try { const params: Record = {}; if (syncRootDeptId) params.rootDeptId = String(syncRootDeptId); - const res = await api.get('/sync/dingtalk/org-tree-with-users', { params }); + const res = await api.get('/sync/dingtalk/org-tree-with-users', { + params, + }); if (res.success && res.data) { setOrgTree(res.data); setCheckedKeys([]); @@ -226,7 +254,6 @@ const IntegrationConfigPage: React.FC = () => { } }; - const buildTreeData = useCallback((nodes: DingOrgTreeNodeExt[]): DataNode[] => { return nodes.map((node) => { const users = node.users ?? []; @@ -262,7 +289,11 @@ const IntegrationConfigPage: React.FC = () => { const treeData = useMemo(() => buildTreeData(orgTree), [orgTree, buildTreeData]); - const extractCheckedUsers = useCallback((): Array<{ dingUserId: string; name: string; mobile?: string }> => { + const extractCheckedUsers = useCallback((): Array<{ + dingUserId: string; + name: string; + mobile?: string; + }> => { const result: Array<{ dingUserId: string; name: string; mobile?: string }> = []; const walk = (nodes: DingOrgTreeNodeExt[]) => { for (const node of nodes) { @@ -285,9 +316,13 @@ const IntegrationConfigPage: React.FC = () => { setImporting(true); try { - const res = await api.post(`/classes/${selectedClassId}/students/import`, { users }); + const res = await api.post(`/classes/${selectedClassId}/students/import`, { + users, + }); if (res.conflicts > 0) { - message.warning(`导入 ${res.imported} 人,跳过 ${res.skipped} 人,${res.conflicts} 人需人工绑定`); + message.warning( + `导入 ${res.imported} 人,跳过 ${res.skipped} 人,${res.conflicts} 人需人工绑定`, + ); } else { message.success(`导入 ${res.imported} 人,跳过 ${res.skipped} 人`); } @@ -354,175 +389,206 @@ const IntegrationConfigPage: React.FC = () => { } }; - const syncPanel = config && hasAllPermissions('sync:read', 'class:view', 'class:edit') - ? ( -
- + const syncPanel = + config && hasAllPermissions('sync:read', 'class:view', 'class:edit') ? ( +
+ + + setSyncRootDeptId(v)} + placeholder="选择起始部门(不选=全部)" + allowClear + treeDefaultExpandAll + style={{ minWidth: 240 }} + onDropdownVisibleChange={(open) => { + if (open) loadDeptTree(); + }} + /> + + } + loading={loadingGroups} + onClick={openDeleteAllGroups} + > + 清空钉钉全部考勤组 + + + + {drawerOpen && ( + { + setDrawerOpen(false); + }} + width="min(900px, 100vw)" + footer={ - setSyncRootDeptId(v)} - placeholder="选择起始部门(不选=全部)" - allowClear - treeDefaultExpandAll - style={{ minWidth: 240 }} - onDropdownVisibleChange={(open) => { if (open) loadDeptTree(); }} - /> + - } - loading={loadingGroups} - onClick={openDeleteAllGroups} + - - {drawerOpen && ( - { setDrawerOpen(false); }} - width={900} - footer={ - - - - - + } + > + +
+
+ setCheckedKeys(checked as React.Key[])} + /> +
+ + + setClassModalOpen(true)}> + + 创建班级 + } > - - -
- setCheckedKeys(checked as React.Key[])} + ( + setSelectedClassId(cls.id)} + style={{ + cursor: 'pointer', + background: selectedClassId === cls.id ? '#e6f4ff' : undefined, + borderRadius: 4, + padding: '8px 12px', + }} + > + -
- - - setClassModalOpen(true)}>+ 创建班级}> - ( - setSelectedClassId(cls.id)} - style={{ - cursor: 'pointer', - background: selectedClassId === cls.id ? '#e6f4ff' : undefined, - borderRadius: 4, - padding: '8px 12px', - }} - > - - - )} - /> - - - + + )} + /> + + + - { /* Create class Modal */ } - { setClassModalOpen(false); classForm.resetFields(); }} - confirmLoading={importing} - destroyOnClose - > -
- - - - - - - - + + + + + + } placeholder="用户名" /> - + } placeholder="密码" /> diff --git a/apps/admin/src/pages/Notifications/index.tsx b/apps/admin/src/pages/Notifications/index.tsx index 4d1990b..9dc152a 100644 --- a/apps/admin/src/pages/Notifications/index.tsx +++ b/apps/admin/src/pages/Notifications/index.tsx @@ -60,7 +60,7 @@ const NotificationsPage: React.FC = () => { const fetchData = async () => { setLoading(true); try { - const data = await api.get('/notifications?limit=50') as unknown as NotificationItem[]; + const data = (await api.get('/notifications?limit=50')) as unknown as NotificationItem[]; setNotifications(data); } catch (e: any) { console.error('加载通知失败', e); @@ -91,18 +91,15 @@ const NotificationsPage: React.FC = () => { const handleMarkAll = async () => { try { await api.put('/notifications/read-all'); - setNotifications((prev) => - prev.map((n) => ({ ...n, isRead: true })), - ); + setNotifications((prev) => prev.map((n) => ({ ...n, isRead: true }))); } catch (e: any) { console.error('全部已读失败', e); message.error(e?.message || '操作失败'); } }; - const filtered = filter === 'all' - ? notifications - : notifications.filter((n) => n.type === filter); + const filtered = + filter === 'all' ? notifications : notifications.filter((n) => n.type === filter); const filterItems = [ { key: 'all', icon: , label: '全部' }, @@ -126,7 +123,9 @@ const NotificationsPage: React.FC = () => { )}
- 通知中心 + + 通知中心 +
{isMobile && ( @@ -181,10 +180,7 @@ const NotificationsPage: React.FC = () => { } title={ - + {formatNotificationText(item.title)} diff --git a/apps/admin/src/pages/Occupancies/index.tsx b/apps/admin/src/pages/Occupancies/index.tsx index 416fbbc..c106e5b 100644 --- a/apps/admin/src/pages/Occupancies/index.tsx +++ b/apps/admin/src/pages/Occupancies/index.tsx @@ -66,7 +66,13 @@ const OccupanciesPage: React.FC = () => { setLoading(true); try { const [occRes, stuRes, rmRes] = (await Promise.allSettled([ - api.get('/occupancies', { params: { active: showActive ? 'true' : undefined, dateFrom: dateRange?.[0]?.format('YYYY-MM-DD'), dateTo: dateRange?.[1]?.format('YYYY-MM-DD') } }), + api.get('/occupancies', { + params: { + active: showActive ? 'true' : undefined, + dateFrom: dateRange?.[0]?.format('YYYY-MM-DD'), + dateTo: dateRange?.[1]?.format('YYYY-MM-DD'), + }, + }), api.get('/students/basic-lookups'), api.get('/rooms/overview'), ])) as PromiseSettledResult[]; @@ -107,7 +113,9 @@ const OccupanciesPage: React.FC = () => { setAvailableBeds(beds); setAvailableLockers(lockers); if (beds.length === 1) checkInForm.setFieldValue('bedId', beds[0].id); - } catch (e) { console.error(e); } + } catch (e) { + console.error(e); + } }; const handleTransferRoomChange = async (roomId: number) => { @@ -195,10 +203,7 @@ const OccupanciesPage: React.FC = () => { const values = await transferForm.validateFields(); setSaving(true); try { - await api.put( - `/occupancies/${transferModal.id}/transfer`, - buildTransferPayload(values), - ); + await api.put(`/occupancies/${transferModal.id}/transfer`, buildTransferPayload(values)); message.success('换房成功'); setTransferModal(null); transferForm.resetFields(); @@ -246,83 +251,104 @@ const OccupanciesPage: React.FC = () => { } }; - const columns = useMemo(() => [ - { title: '学生', width: 120, render: (_: any, r: any) => r.student?.name || '-' }, - { title: '宿舍', width: 120, render: (_: any, r: any) => r.room?.roomNumber || '-' }, - { title: '床位', width: 80, render: (_: unknown, r: Record) => (r.bed as Record | undefined)?.bedNumber || '-' }, - { title: '柜子', width: 80, render: (_: unknown, r: Record) => (r.locker as Record | undefined)?.lockerNumber || '-' }, - { title: '入住日期', dataIndex: 'checkInDate', width: 110 }, - { title: '计费起始', dataIndex: 'billingStartDate', width: 110 }, - { - title: '退宿日期', - dataIndex: 'checkOutDate', - width: 110, - render: (v: any) => v || 在住, - }, - { title: '计费截止', dataIndex: 'billingEndDate', render: (v: any) => v || '-' }, - { title: '退宿原因', dataIndex: 'checkOutReason', render: (v: any) => v || '-' }, - { - title: '操作', - width: 220, - render: (_: any, record: any) => - !record.checkOutDate ? ( - - } - onClick={() => { - setCheckOutModal(record); - checkOutForm.setFieldsValue({ checkOutDate: dayjs() }); - }} - > - 退宿 - - } - onClick={() => { - setTransferAvailableBeds([]); - setTransferAvailableLockers([]); - transferForm.resetFields(); - setTransferModal(record); - transferForm.setFieldsValue({ transferDate: dayjs() }); - }} - > - 换房 - - - ) : ( - - 已退宿 - { - try { - await api.delete(`/occupancies/${record.id}`); - message.success('归档成功'); - fetchData(); - } catch (e: any) { - message.error(e?.message || '归档失败'); - } - }} - > - }> - 归档 + const columns = useMemo( + () => [ + { title: '学生', width: 120, render: (_: any, r: any) => r.student?.name || '-' }, + { title: '宿舍', width: 120, render: (_: any, r: any) => r.room?.roomNumber || '-' }, + { + title: '床位', + width: 80, + render: (_: unknown, r: Record) => + (r.bed as Record | undefined)?.bedNumber || '-', + }, + { + title: '柜子', + width: 80, + render: (_: unknown, r: Record) => + (r.locker as Record | undefined)?.lockerNumber || '-', + }, + { title: '入住日期', dataIndex: 'checkInDate', width: 110 }, + { title: '计费起始', dataIndex: 'billingStartDate', width: 110 }, + { + title: '退宿日期', + dataIndex: 'checkOutDate', + width: 110, + render: (v: any) => v || 在住, + }, + { title: '计费截止', dataIndex: 'billingEndDate', render: (v: any) => v || '-' }, + { title: '退宿原因', dataIndex: 'checkOutReason', render: (v: any) => v || '-' }, + { + title: '操作', + width: 220, + render: (_: any, record: any) => + !record.checkOutDate ? ( + + } + onClick={() => { + setCheckOutModal(record); + checkOutForm.setFieldsValue({ checkOutDate: dayjs() }); + }} + > + 退宿 - - - ), - }, - ], [fetchData, setCheckOutModal, checkOutForm, setTransferModal, transferForm]); + } + onClick={() => { + setTransferAvailableBeds([]); + setTransferAvailableLockers([]); + transferForm.resetFields(); + setTransferModal(record); + transferForm.setFieldsValue({ transferDate: dayjs() }); + }} + > + 换房 + + + ) : ( + + 已退宿 + { + try { + await api.delete(`/occupancies/${record.id}`); + message.success('归档成功'); + fetchData(); + } catch (e: any) { + message.error(e?.message || '归档失败'); + } + }} + > + } + > + 归档 + + + + ), + }, + ], + [fetchData, setCheckOutModal, checkOutForm, setTransferModal, transferForm], + ); - const rowSelection = useMemo(() => ({ - selectedRowKeys, - onChange: (keys: any[]) => setSelectedRowKeys(keys), - // 「在住记录」Tab:禁用已退宿(防止误选用于批量退宿);「全部记录」Tab:均可选用于批量归档 - getCheckboxProps: (record: any) => (showActive ? { disabled: !!record.checkOutDate } : {}), - }), [selectedRowKeys, showActive]); + const rowSelection = useMemo( + () => ({ + selectedRowKeys, + onChange: (keys: any[]) => setSelectedRowKeys(keys), + // 「在住记录」Tab:禁用已退宿(防止误选用于批量退宿);「全部记录」Tab:均可选用于批量归档 + getCheckboxProps: (record: any) => (showActive ? { disabled: !!record.checkOutDate } : {}), + }), + [selectedRowKeys, showActive], + ); return (
@@ -348,7 +374,14 @@ const OccupanciesPage: React.FC = () => { allowClear style={{ width: 200 }} /> - { setDateRange(dates ? [dates[0], dates[1]] : null); }} placeholder={['入住开始', '入住结束']} style={{ width: 240 }} /> + { + setDateRange(dates ? [dates[0], dates[1]] : null); + }} + placeholder={['入住开始', '入住结束']} + style={{ width: 240 }} + /> { icon={} onClick={() => { checkInForm.resetFields(); - checkInForm.setFieldsValue({ checkInDate: dayjs(), collectDeposit: true, depositAmount: 500 }); + checkInForm.setFieldsValue({ + checkInDate: dayjs(), + collectDeposit: true, + depositAmount: 500, + }); setCheckInModal(true); }} > @@ -432,26 +469,26 @@ const OccupanciesPage: React.FC = () => { 导入时自动收押金 {autoDeposit && ( - setDepositAmount(v || 500)} - style={{ width: 60 }} - /> - - 元 - - + setDepositAmount(v || 500)} + style={{ width: 60 }} + /> + + 元 + + )} @@ -543,7 +580,7 @@ const OccupanciesPage: React.FC = () => { .filter((s: any) => s.status === 'active') .map((s: any) => ({ value: s.id, - label: `${s.name} (${s.idNumber ? maskIdNumber(s.idNumber) : (s.phone ? maskPhone(s.phone) : '')})`, + label: `${s.name} (${s.idNumber ? maskIdNumber(s.idNumber) : s.phone ? maskPhone(s.phone) : ''})`, }))} /> @@ -588,11 +625,7 @@ const OccupanciesPage: React.FC = () => { placeholder="默认为短租" /> - + { > - prev.collectDeposit !== current.collectDeposit}> + prev.collectDeposit !== current.collectDeposit} + > {({ getFieldValue }) => getFieldValue('collectDeposit') ? ( { label="押金金额" rules={[{ required: true, message: '请输入押金金额' }]} > - + ) : null } diff --git a/apps/admin/src/pages/OperationLogs/index.tsx b/apps/admin/src/pages/OperationLogs/index.tsx index 0fc8e9c..ab77076 100644 --- a/apps/admin/src/pages/OperationLogs/index.tsx +++ b/apps/admin/src/pages/OperationLogs/index.tsx @@ -53,63 +53,67 @@ const OperationLogsPage: React.FC = () => { fetchData(); }, [fetchData]); - const columns = useMemo(() => [ - { - title: '时间', - dataIndex: 'createdAt', - width: 170, - render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm:ss'), - }, - { title: '操作人', dataIndex: 'username', width: 100 }, - { - title: '模块', - dataIndex: 'module', - width: 100, - render: (v: string) => {v}, - }, - { title: '操作', dataIndex: 'action', width: 150 }, - { - title: '状态', - dataIndex: 'status', - width: 80, - render: (v: string) => { - const s = statusMap[v] || statusMap['success']; - return {s.text}; + const columns = useMemo( + () => [ + { + title: '时间', + dataIndex: 'createdAt', + width: 170, + render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm:ss'), }, - }, - { - title: '详情', width: 200, - dataIndex: 'detail', - ellipsis: true, - render: (v: string) => - v ? ( - - {v} - - ) : ( - '-' - ), - }, - { title: 'IP地址', dataIndex: 'ipAddress', width: 130, render: (v: string) => v || '-' }, - { - title: '终端', - dataIndex: 'userAgent', - width: 120, - ellipsis: true, - render: (v: string) => { - if (!v) return '-'; - if (v.includes('Mobile')) return 手机; - if (v.includes('Windows')) return Windows; - if (v.includes('Mac')) return Mac; - if (v.includes('Linux')) return Linux; - return ( - - 其他 - - ); + { title: '操作人', dataIndex: 'username', width: 100 }, + { + title: '模块', + dataIndex: 'module', + width: 100, + render: (v: string) => {v}, }, - }, - ], []); + { title: '操作', dataIndex: 'action', width: 150 }, + { + title: '状态', + dataIndex: 'status', + width: 80, + render: (v: string) => { + const s = statusMap[v] || statusMap['success']; + return {s.text}; + }, + }, + { + title: '详情', + width: 200, + dataIndex: 'detail', + ellipsis: true, + render: (v: string) => + v ? ( + + {v} + + ) : ( + '-' + ), + }, + { title: 'IP地址', dataIndex: 'ipAddress', width: 130, render: (v: string) => v || '-' }, + { + title: '终端', + dataIndex: 'userAgent', + width: 120, + ellipsis: true, + render: (v: string) => { + if (!v) return '-'; + if (v.includes('Mobile')) return 手机; + if (v.includes('Windows')) return Windows; + if (v.includes('Mac')) return Mac; + if (v.includes('Linux')) return Linux; + return ( + + 其他 + + ); + }, + }, + ], + [], + ); return (
diff --git a/apps/admin/src/pages/Roles/index.tsx b/apps/admin/src/pages/Roles/index.tsx index 7e6bac2..5a5e4c3 100644 --- a/apps/admin/src/pages/Roles/index.tsx +++ b/apps/admin/src/pages/Roles/index.tsx @@ -1,16 +1,5 @@ import React, { useEffect, useState, useMemo, useCallback } from 'react'; -import { - Table, - Modal, - Form, - Input, - Space, - Tag, - Popconfirm, - Card, - Checkbox, - Empty, -} from 'antd'; +import { Table, Modal, Form, Input, Space, Tag, Popconfirm, Card, Checkbox, Empty } from 'antd'; import { PlusOutlined, EditOutlined, StopOutlined } from '@ant-design/icons'; import api from '../../api'; import PermissionButton from '../../components/PermissionButton'; @@ -144,53 +133,61 @@ const RolesPage: React.FC = () => { ai: 'AI 配置', }; - const columns = useMemo(() => [ - { title: 'ID', dataIndex: 'id', width: 80 }, - { title: '名称', dataIndex: 'name', width: 120 }, - { title: '描述', dataIndex: 'description', width: 200 }, - { - title: '权限标签', - dataIndex: 'permissions', - width: 150, - render: (perms: PermissionItem[]) => - perms?.length > 0 ? ( - {perms.length} 个权限 - ) : ( - 无权限 + const columns = useMemo( + () => [ + { title: 'ID', dataIndex: 'id', width: 80 }, + { title: '名称', dataIndex: 'name', width: 120 }, + { title: '描述', dataIndex: 'description', width: 200 }, + { + title: '权限标签', + dataIndex: 'permissions', + width: 150, + render: (perms: PermissionItem[]) => + perms?.length > 0 ? ( + {perms.length} 个权限 + ) : ( + 无权限 + ), + }, + { + title: '系统', + dataIndex: 'isSystem', + width: 80, + render: (v: boolean) => (v ? 系统 : null), + }, + { + title: '操作', + width: 180, + fixed: 'right' as const, + render: (_: any, record: RoleItem) => ( + + } + onClick={() => handleEdit(record)} + > + 编辑 + + {!record.isSystem && ( + handleDisable(record.id)}> + } + > + 停用 + + + )} + ), - }, - { - title: '系统', - dataIndex: 'isSystem', - width: 80, - render: (v: boolean) => (v ? 系统 : null), - }, - { - title: '操作', - width: 180, - fixed: 'right' as const, - render: (_: any, record: RoleItem) => ( - - } - onClick={() => handleEdit(record)} - > - 编辑 - - {!record.isSystem && ( - handleDisable(record.id)}> - }> - 停用 - - - )} - - ), - }, - ], []); + }, + ], + [], + ); const handleGroupCheckAll = (group: string, checked: boolean) => { const groupPermIds = diff --git a/apps/admin/src/pages/RoomVisual/index.tsx b/apps/admin/src/pages/RoomVisual/index.tsx index 6badcb9..6134b54 100644 --- a/apps/admin/src/pages/RoomVisual/index.tsx +++ b/apps/admin/src/pages/RoomVisual/index.tsx @@ -1,6 +1,27 @@ import React, { useEffect, useState, useCallback } from 'react'; -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 { + 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'; @@ -9,10 +30,14 @@ function getCardStyle(room: any): React.CSSProperties { let base: React.CSSProperties; if (room.status === 'maintenance') base = { background: '#f5f5f5', borderColor: '#d9d9d9' }; else if (room.currentCount === 0) base = { background: '#f6ffed', borderColor: '#b7eb8f' }; - else if (room.currentCount >= room.capacity) base = { background: '#fff2f0', borderColor: '#ffccc7' }; + else if (room.currentCount >= room.capacity) + base = { background: '#fff2f0', borderColor: '#ffccc7' }; else base = { background: '#e6f4ff', borderColor: '#91caff' }; if (room.organizationColor) { - return { ...base, background: `color-mix(in srgb, ${room.organizationColor} 15%, ${base.background || '#fff'} 85%)` }; + return { + ...base, + background: `color-mix(in srgb, ${room.organizationColor} 15%, ${base.background || '#fff'} 85%)`, + }; } return base; } @@ -29,7 +54,10 @@ function getOrganizationTags(occupants: any[]) { ...new Map( occupants .filter((o: any) => o.organizationName) - .map((o: any) => [o.organizationId, { name: o.organizationName, color: o.organizationColor }]), + .map((o: any) => [ + o.organizationId, + { name: o.organizationName, color: o.organizationColor }, + ]), ).values(), ] as { name: string; color: string | null }[]; if (organizationList.length === 0) return null; @@ -80,7 +108,8 @@ const RoomVisualPage: React.FC = () => { const rooms = data.rooms.filter((r: any) => { if (selectedBuilding !== 'all' && r.building !== selectedBuilding) return false; - if (selectedOrganization !== 'all' && !(r.organizationIds || []).includes(selectedOrganization)) return false; + if (selectedOrganization !== 'all' && !(r.organizationIds || []).includes(selectedOrganization)) + return false; return true; }); @@ -138,7 +167,15 @@ const RoomVisualPage: React.FC = () => { label: ( {t.color && ( - + )} {t.name} @@ -175,17 +212,29 @@ const RoomVisualPage: React.FC = () => {
- + - + - + @@ -214,13 +263,27 @@ const RoomVisualPage: React.FC = () => { marginBottom: 8, }} > - + {room.organizationColor && ( - + )} {room.roomNumber} @@ -231,7 +294,13 @@ const RoomVisualPage: React.FC = () => { {room.floor && {room.floor}F} {room.totalBeds > 0 && ( -
= room.totalBeds ? '#FF3B30' : '#34C759', marginBottom: 6 }}> +
= room.totalBeds ? '#FF3B30' : '#34C759', + marginBottom: 6, + }} + > 床位: {room.occupiedBeds}/{room.totalBeds}
)} @@ -259,10 +328,16 @@ const RoomVisualPage: React.FC = () => { )} {getOrganizationTags(room.occupants)} {room.occupants.length > 0 && ( -
+
{room.occupants.slice(0, 4).map((o: any) => ( - }> + } + > {o.studentName} diff --git a/apps/admin/src/pages/Rooms/index.tsx b/apps/admin/src/pages/Rooms/index.tsx index 4ea0783..51995f1 100644 --- a/apps/admin/src/pages/Rooms/index.tsx +++ b/apps/admin/src/pages/Rooms/index.tsx @@ -70,8 +70,13 @@ function parseRoomNumber(input: string) { const floor = parseInt(roomPart.charAt(0), 10) || undefined; let roomType = '四人间'; let capacity = 4; - if (bldgNum === '2') { roomType = '单人间'; capacity = 1; } - else if (bldgNum === '8') { roomType = '爆改房'; capacity = 2; } + if (bldgNum === '2') { + roomType = '单人间'; + capacity = 1; + } else if (bldgNum === '8') { + roomType = '爆改房'; + capacity = 2; + } return { building: `${bldgNum}号楼`, floor, roomType, capacity }; } return null; @@ -150,12 +155,19 @@ const RoomsPage: React.FC = () => { let result = data; if (searchText) { const keyword = searchText.toLowerCase(); - result = result.filter((r: Record) => typeof r.roomNumber === 'string' && r.roomNumber.toLowerCase().includes(keyword)); + result = result.filter( + (r: Record) => + typeof r.roomNumber === 'string' && r.roomNumber.toLowerCase().includes(keyword), + ); } - if (filterBuilding) result = result.filter((r: Record) => r.building === filterBuilding); - if (filterStatus) result = result.filter((r: Record) => r.status === filterStatus); + if (filterBuilding) + result = result.filter((r: Record) => r.building === filterBuilding); + if (filterStatus) + result = result.filter((r: Record) => r.status === filterStatus); if (filterRentalCategory) { - result = result.filter((r: Record) => r.rentalCategory === filterRentalCategory); + result = result.filter( + (r: Record) => r.rentalCategory === filterRentalCategory, + ); } return result; }, [data, searchText, filterBuilding, filterStatus, filterRentalCategory]); @@ -221,8 +233,11 @@ const RoomsPage: React.FC = () => { bedForm.resetFields(); setBedEditing(null); fetchBeds(drawerRoom.id); - } catch (e: any) { message.error(e?.message || '操作失败'); } - finally { setSavingBed(false); } + } catch (e: any) { + message.error(e?.message || '操作失败'); + } finally { + setSavingBed(false); + } }; const handleDeleteBed = async (id: number) => { @@ -230,7 +245,9 @@ const RoomsPage: React.FC = () => { await api.delete(`/rooms/${drawerRoom.id}/beds/${id}`); message.success('已归档'); fetchBeds(drawerRoom.id); - } catch (e: any) { message.error(e?.message || '归档失败'); } + } catch (e: any) { + message.error(e?.message || '归档失败'); + } }; const handleBatchBeds = async (count: number) => { @@ -238,7 +255,9 @@ const RoomsPage: React.FC = () => { await api.post(`/rooms/${drawerRoom.id}/beds/batch`, { count }); message.success(`已生成 ${count} 张床位`); fetchBeds(drawerRoom.id); - } catch (e: any) { message.error(e?.message || '批量生成失败'); } + } catch (e: any) { + message.error(e?.message || '批量生成失败'); + } }; const handleSaveLocker = async () => { @@ -255,8 +274,11 @@ const RoomsPage: React.FC = () => { lockerForm.resetFields(); setLockerEditing(null); fetchLockers(drawerRoom.id); - } catch (e: any) { message.error(e?.message || '操作失败'); } - finally { setSavingLocker(false); } + } catch (e: any) { + message.error(e?.message || '操作失败'); + } finally { + setSavingLocker(false); + } }; const handleDeleteLocker = async (id: number) => { @@ -264,7 +286,9 @@ const RoomsPage: React.FC = () => { await api.delete(`/rooms/${drawerRoom.id}/lockers/${id}`); message.success('已归档'); fetchLockers(drawerRoom.id); - } catch (e: any) { message.error(e?.message || '归档失败'); } + } catch (e: any) { + message.error(e?.message || '归档失败'); + } }; const handleBatchLockers = async (count: number) => { @@ -272,7 +296,9 @@ const RoomsPage: React.FC = () => { await api.post(`/rooms/${drawerRoom.id}/lockers/batch`, { count }); message.success(`已生成 ${count} 个柜子`); fetchLockers(drawerRoom.id); - } catch (e: any) { message.error(e?.message || '批量生成失败'); } + } catch (e: any) { + message.error(e?.message || '批量生成失败'); + } }; const handleArchive = async (id: number) => { @@ -304,109 +330,126 @@ const RoomsPage: React.FC = () => { downloadBlob('/rooms/export' + params, '房间列表.xlsx').catch(() => message.error('导出失败')); }; - const columns = useMemo(() => [ - { - title: '房间号', - dataIndex: 'roomNumber', - width: 100, - sorter: (a: any, b: any) => a.roomNumber.localeCompare(b.roomNumber), - }, - { title: '楼栋', dataIndex: 'building', width: 80 }, - { title: '楼层', dataIndex: 'floor', width: 80 }, - { title: '类型', dataIndex: 'roomType', width: 90, render: (v: any) => v || '-' }, - { - title: '租赁类型', - dataIndex: 'rentalCategory', - width: 100, - render: (v: string) => { - if (v === 'long') return 长租; - if (v === 'short') return 短租; - return '-'; + const columns = useMemo( + () => [ + { + title: '房间号', + dataIndex: 'roomNumber', + width: 100, + sorter: (a: any, b: any) => a.roomNumber.localeCompare(b.roomNumber), }, - }, - { - title: '月租金', - dataIndex: 'monthlyRate', - width: 100, - render: (v: number) => (v ? `¥${v}` : '-'), - }, - { title: '额定人数', dataIndex: 'capacity', width: 80 }, - { - title: '当前入住', - width: 80, - render: (_: any, r: any) => - r.status === 'archived' ? ( - - - ) : ( - = r.capacity ? '#ff4d4f' : '#52c41a' }} - /> - ), - }, - { - title: '状态', - dataIndex: 'status', - width: 80, - render: (s: string) => {statusMap[s]?.text || s}, - }, - { - title: '操作', - width: 220, - render: (_: unknown, record: unknown) => { - const r = record as { status?: string; id: number }; - return ( - - {r.status === 'archived' ? ( - handleRestore(r.id)} - > - } type="link"> - 恢复 - - - ) : ( - <> - { - const rec = record as { id: number }; - setDrawerRoom(record); - setDrawerOpen(true); - await Promise.all([fetchBeds(rec.id), fetchLockers(rec.id)]); - }} - > - 查看 - - { - const rec = record as { id: number }; - setEditing(rec); - form.setFieldsValue(rec); - setModalOpen(true); - }} - > - 编辑 - - handleArchive(r.id)}> - }> - 归档 + { title: '楼栋', dataIndex: 'building', width: 80 }, + { title: '楼层', dataIndex: 'floor', width: 80 }, + { title: '类型', dataIndex: 'roomType', width: 90, render: (v: any) => v || '-' }, + { + title: '租赁类型', + dataIndex: 'rentalCategory', + width: 100, + render: (v: string) => { + if (v === 'long') return 长租; + if (v === 'short') return 短租; + return '-'; + }, + }, + { + title: '月租金', + dataIndex: 'monthlyRate', + width: 100, + render: (v: number) => (v ? `¥${v}` : '-'), + }, + { title: '额定人数', dataIndex: 'capacity', width: 80 }, + { + title: '当前入住', + width: 80, + render: (_: any, r: any) => + r.status === 'archived' ? ( + - + ) : ( + = r.capacity ? '#ff4d4f' : '#52c41a' }} + /> + ), + }, + { + title: '状态', + dataIndex: 'status', + width: 80, + render: (s: string) => {statusMap[s]?.text || s}, + }, + { + title: '操作', + width: 220, + render: (_: unknown, record: unknown) => { + const r = record as { status?: string; id: number }; + return ( + + {r.status === 'archived' ? ( + handleRestore(r.id)}> + } + type="link" + > + 恢复 - - )} - - ); + ) : ( + <> + { + const rec = record as { id: number }; + setDrawerRoom(record); + setDrawerOpen(true); + await Promise.all([fetchBeds(rec.id), fetchLockers(rec.id)]); + }} + > + 查看 + + { + const rec = record as { id: number }; + setEditing(rec); + form.setFieldsValue(rec); + setModalOpen(true); + }} + > + 编辑 + + handleArchive(r.id)}> + } + > + 归档 + + + + )} + + ); + }, }, - }, - ], [showArchived, buildings, handleBatchDelete, handleRestore, handleArchive, fetchBeds, fetchLockers]); + ], + [ + showArchived, + buildings, + handleBatchDelete, + handleRestore, + handleArchive, + fetchBeds, + fetchLockers, + ], + ); return (
@@ -427,8 +470,18 @@ const RoomsPage: React.FC = () => { onChange={(v) => setFilterBuilding(v)} options={buildings.map((b) => ({ value: b, label: b }))} /> -
{ columns={[ { title: '编号', dataIndex: 'bedNumber', width: 80 }, { - title: '状态', dataIndex: 'status', width: 80, + title: '状态', + dataIndex: 'status', + width: 80, render: (s: string) => { const map: Record = { available: { text: '空闲', color: 'green' }, @@ -687,7 +794,8 @@ const RoomsPage: React.FC = () => { }, { title: '备注', dataIndex: 'notes', render: (v: string) => v || '-' }, { - title: '操作', width: 120, + title: '操作', + width: 120, render: (_: any, r: any) => ( { size="small" type="link" disabled={drawerRoom?.status === 'archived'} - onClick={() => { setBedEditing(r); bedForm.setFieldsValue(r); setBedModalOpen(true); }} + onClick={() => { + setBedEditing(r); + bedForm.setFieldsValue(r); + setBedModalOpen(true); + }} > 编辑 {r.status !== 'occupied' && ( - handleDeleteBed(r.id)}> + handleDeleteBed(r.id)} + > { size="small" icon={} disabled={drawerRoom?.status === 'archived'} - onClick={() => { setLockerEditing(null); lockerForm.resetFields(); setLockerModalOpen(true); }} + onClick={() => { + setLockerEditing(null); + lockerForm.resetFields(); + setLockerModalOpen(true); + }} > 添加柜子 + } onConfirm={() => { - const input = document.getElementById('batch-locker-count') as HTMLInputElement; + const input = document.getElementById( + 'batch-locker-count', + ) as HTMLInputElement; handleBatchLockers(input ? parseInt(input.value) || 4 : 4); }} okText="生成" disabled={drawerRoom?.status === 'archived'} > - +
{ columns={[ { title: '编号', dataIndex: 'lockerNumber', width: 80 }, { - title: '状态', dataIndex: 'status', width: 80, + title: '状态', + dataIndex: 'status', + width: 80, render: (s: string) => { const map: Record = { available: { text: '空闲', color: 'green' }, @@ -770,7 +901,8 @@ const RoomsPage: React.FC = () => { }, { title: '备注', dataIndex: 'notes', render: (v: string) => v || '-' }, { - title: '操作', width: 120, + title: '操作', + width: 120, render: (_: any, r: any) => ( { size="small" type="link" disabled={drawerRoom?.status === 'archived'} - onClick={() => { setLockerEditing(r); lockerForm.setFieldsValue(r); setLockerModalOpen(true); }} + onClick={() => { + setLockerEditing(r); + lockerForm.setFieldsValue(r); + setLockerModalOpen(true); + }} > 编辑 {r.status !== 'occupied' && ( - handleDeleteLocker(r.id)}> + handleDeleteLocker(r.id)} + > { title={bedEditing ? '编辑床位' : '添加床位'} open={bedModalOpen} onOk={handleSaveBed} - onCancel={() => { setBedModalOpen(false); setBedEditing(null); }} + onCancel={() => { + setBedModalOpen(false); + setBedEditing(null); + }} confirmLoading={savingBed} okText="保存" > @@ -838,7 +980,10 @@ const RoomsPage: React.FC = () => { title={lockerEditing ? '编辑柜子' : '添加柜子'} open={lockerModalOpen} onOk={handleSaveLocker} - onCancel={() => { setLockerModalOpen(false); setLockerEditing(null); }} + onCancel={() => { + setLockerModalOpen(false); + setLockerEditing(null); + }} confirmLoading={savingLocker} okText="保存" > diff --git a/apps/admin/src/pages/Schedules/index.tsx b/apps/admin/src/pages/Schedules/index.tsx index 6913188..ce448cb 100644 --- a/apps/admin/src/pages/Schedules/index.tsx +++ b/apps/admin/src/pages/Schedules/index.tsx @@ -478,7 +478,6 @@ const SchedulesPage: React.FC = () => { } }; - // ---- Classroom select options ---- const classroomOptions = useMemo( @@ -1192,17 +1191,17 @@ const SchedulesPage: React.FC = () => { {syncResult ? ( /* ── 同步结果 ── */
- -
+ + - + - + - + { ) : syncStatus ? ( /* ── 同步确认信息 ── */
- -
+ + - + { }} /> - + { }); }); - describe('schedule notes normalization', () => { it('omits whitespace-only notes from the payload', () => { expect( diff --git a/apps/admin/src/pages/Students/index.tsx b/apps/admin/src/pages/Students/index.tsx index f14847c..3f8c4ab 100644 --- a/apps/admin/src/pages/Students/index.tsx +++ b/apps/admin/src/pages/Students/index.tsx @@ -62,7 +62,6 @@ interface EnrollmentInfo { }; } - interface StudentCreateImportResult { message?: string; imported?: number; @@ -609,7 +608,11 @@ const StudentsPage: React.FC = () => { > 添加学生 - + { } return ( - + {enrollments.map((enr, idx) => ( - + { fetchData(); }, []); - const classColumns: ColumnsType = useMemo(() => [ - { - title: '班级名称', - dataIndex: 'className', - render: (v: string, r: AssignedClass) => `${v} (${r.classCode})`, - }, - { - title: '角色', - dataIndex: 'roleType', - render: (v: string) => {ROLE_LABELS[v] || v}, - }, - { - title: '科目', - dataIndex: 'subject', - render: (v: string | null) => v || '-', - }, - ], []); + const classColumns: ColumnsType = useMemo( + () => [ + { + title: '班级名称', + dataIndex: 'className', + render: (v: string, r: AssignedClass) => `${v} (${r.classCode})`, + }, + { + title: '角色', + dataIndex: 'roleType', + render: (v: string) => {ROLE_LABELS[v] || v}, + }, + { + title: '科目', + dataIndex: 'subject', + render: (v: string | null) => v || '-', + }, + ], + [], + ); - const scheduleColumns: ColumnsType = useMemo(() => [ - { - title: '时间', - key: 'time', - render: (_: unknown, r: ScheduleItem) => `${r.startTime} - ${r.endTime}`, - }, - { - title: '星期', - dataIndex: 'weekDay', - render: (v: number) => {WEEKDAY_LABELS[String(v)] || v}, - }, - { - title: '科目', - dataIndex: 'subject', - }, - { - title: '类型', - dataIndex: 'scheduleType', - render: (v: string) => ( - - {v === 'INTERNAL' ? '内部课程' : '租赁'} - - ), - }, - ], []); + const scheduleColumns: ColumnsType = useMemo( + () => [ + { + title: '时间', + key: 'time', + render: (_: unknown, r: ScheduleItem) => `${r.startTime} - ${r.endTime}`, + }, + { + title: '星期', + dataIndex: 'weekDay', + render: (v: number) => {WEEKDAY_LABELS[String(v)] || v}, + }, + { + title: '科目', + dataIndex: 'subject', + }, + { + title: '类型', + dataIndex: 'scheduleType', + render: (v: string) => ( + + {v === 'INTERNAL' ? '内部课程' : '租赁'} + + ), + }, + ], + [], + ); - const studentColumns: ColumnsType = useMemo(() => [ - { title: '姓名', dataIndex: 'studentName' }, - { title: '学号', dataIndex: 'studentNo', render: (v: string) => v || '-' }, - { title: '班级', dataIndex: 'className' }, - { title: '加入日期', dataIndex: 'joinDate', render: (v: string) => v || '-' }, - ], []); + const studentColumns: ColumnsType = useMemo( + () => [ + { title: '姓名', dataIndex: 'studentName' }, + { title: '学号', dataIndex: 'studentNo', render: (v: string) => v || '-' }, + { title: '班级', dataIndex: 'className' }, + { title: '加入日期', dataIndex: 'joinDate', render: (v: string) => v || '-' }, + ], + [], + ); if (loading) { return ( diff --git a/apps/admin/src/pages/Teachers/index.tsx b/apps/admin/src/pages/Teachers/index.tsx index bf39d45..c7cb9cd 100644 --- a/apps/admin/src/pages/Teachers/index.tsx +++ b/apps/admin/src/pages/Teachers/index.tsx @@ -101,87 +101,94 @@ const TeachersPage: React.FC = () => { } }; - const columns = useMemo(() => [ - { title: '姓名', dataIndex: 'name', key: 'name', width: 120 }, - { title: '用户名', dataIndex: 'username', key: 'username', width: 130 }, - { - title: '角色', - dataIndex: 'roles', - key: 'roles', - width: 220, - render: (roles: TeacherRow['roles']) => - roles.map((r) => {ROLE_LABELS[r.code] || r.name}), - }, - { - title: '任课班级', - dataIndex: 'classAssignments', - key: 'classes', - width: 200, - render: (ca: TeacherRow['classAssignments']) => - ca?.length - ? ca.map((a, i) => ( - - {a.className || '-'} - {a.subject ? ` (${a.subject})` : ''} - - )) - : '-', - }, - { - title: '科目', - dataIndex: 'profile', - key: 'subjects', - width: 130, - render: (p: TeacherRow['profile']) => p?.subjects?.join('、') || '-', - }, - { - title: '入职日期', - dataIndex: 'profile', - key: 'joinedAt', - width: 110, - render: (p: TeacherRow['profile']) => p?.joinedAt || '-', - }, - { - title: '状态', - dataIndex: 'isActive', - key: 'status', - width: 90, - render: (v: boolean) => {v ? '在职' : '停用'}, - }, - { - title: '最后登录', - dataIndex: 'lastLoginAt', - key: 'login', - width: 160, - render: (v: string) => (v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '-'), - }, - { - title: '操作', - key: 'actions', - width: 100, - render: (_: unknown, r: TeacherRow) => ( - - ), - }, - ], []); + const columns = useMemo( + () => [ + { title: '姓名', dataIndex: 'name', key: 'name', width: 120 }, + { title: '用户名', dataIndex: 'username', key: 'username', width: 130 }, + { + title: '角色', + dataIndex: 'roles', + key: 'roles', + width: 220, + render: (roles: TeacherRow['roles']) => + roles.map((r) => {ROLE_LABELS[r.code] || r.name}), + }, + { + title: '任课班级', + dataIndex: 'classAssignments', + key: 'classes', + width: 200, + render: (ca: TeacherRow['classAssignments']) => + ca?.length + ? ca.map((a, i) => ( + + {a.className || '-'} + {a.subject ? ` (${a.subject})` : ''} + + )) + : '-', + }, + { + title: '科目', + dataIndex: 'profile', + key: 'subjects', + width: 130, + render: (p: TeacherRow['profile']) => p?.subjects?.join('、') || '-', + }, + { + title: '入职日期', + dataIndex: 'profile', + key: 'joinedAt', + width: 110, + render: (p: TeacherRow['profile']) => p?.joinedAt || '-', + }, + { + title: '状态', + dataIndex: 'isActive', + key: 'status', + width: 90, + render: (v: boolean) => {v ? '在职' : '停用'}, + }, + { + title: '最后登录', + dataIndex: 'lastLoginAt', + key: 'login', + width: 160, + render: (v: string) => (v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '-'), + }, + { + title: '操作', + key: 'actions', + width: 100, + render: (_: unknown, r: TeacherRow) => ( + + ), + }, + ], + [], + ); return (

教师管理

- + { const [data, setData] = useState([]); @@ -36,7 +29,6 @@ const UsersPage: React.FC = () => { const [saving, setSaving] = useState(false); const [showArchived, setShowArchived] = useState(false); - const handleOpenProfile = async (record: any) => { setProfileUser(record); try { @@ -79,7 +71,6 @@ const UsersPage: React.FC = () => { setLoading(false); }, [showArchived]); - useEffect(() => { fetchData(); }, [fetchData]); @@ -162,89 +153,99 @@ const UsersPage: React.FC = () => { } }; - const columns = useMemo(() => [ - { title: 'ID', dataIndex: 'id', width: 60 }, - { title: '用户名', dataIndex: 'username', width: 120 }, - { title: '姓名', dataIndex: 'name', width: 120 }, - { - title: '角色', - dataIndex: 'roles', - width: 200, - render: (v: any[]) => - v && v.length > 0 ? ( - v.map((r: any) => ( - - {r.name} - - )) - ) : ( - 无角色 - ), - }, - { - title: '状态', - dataIndex: 'isActive', - width: 80, - render: (v: boolean) => {v ? '启用' : '禁用'}, - }, - { - title: '最后登录', - dataIndex: 'lastLoginAt', - width: 170, - render: (v: string) => (v ? dayjs(v).format('YYYY-MM-DD HH:mm:ss') : '-'), - }, - { - title: '创建时间', - dataIndex: 'createdAt', - width: 170, - render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm:ss'), - }, - { - title: '操作', - width: 240, - fixed: 'right' as const, - render: (_: unknown, record: any) => ( - - } - onClick={() => handleOpenProfile(record)} - > - 档案 - - } - onClick={() => handleEdit(record)} - > - 编辑 - - } - onClick={() => handleResetPwd(record)} - > - 重置密码 - - {record.isArchived ? ( - handleArchive(record.id, false)}> - 恢复 - + const columns = useMemo( + () => [ + { title: 'ID', dataIndex: 'id', width: 60 }, + { title: '用户名', dataIndex: 'username', width: 120 }, + { title: '姓名', dataIndex: 'name', width: 120 }, + { + title: '角色', + dataIndex: 'roles', + width: 200, + render: (v: any[]) => + v && v.length > 0 ? ( + v.map((r: any) => ( + + {r.name} + + )) ) : ( - handleArchive(record.id, true)}> - 归档 - - )} - - ), - }, - ], []); + 无角色 + ), + }, + { + title: '状态', + dataIndex: 'isActive', + width: 80, + render: (v: boolean) => {v ? '启用' : '禁用'}, + }, + { + title: '最后登录', + dataIndex: 'lastLoginAt', + width: 170, + render: (v: string) => (v ? dayjs(v).format('YYYY-MM-DD HH:mm:ss') : '-'), + }, + { + title: '创建时间', + dataIndex: 'createdAt', + width: 170, + render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm:ss'), + }, + { + title: '操作', + width: 240, + fixed: 'right' as const, + render: (_: unknown, record: any) => ( + + } + onClick={() => handleOpenProfile(record)} + > + 档案 + + } + onClick={() => handleEdit(record)} + > + 编辑 + + } + onClick={() => handleResetPwd(record)} + > + 重置密码 + + {record.isArchived ? ( + handleArchive(record.id, false)}> + + 恢复 + + + ) : ( + handleArchive(record.id, true)} + > + + 归档 + + + )} + + ), + }, + ], + [], + ); return (
@@ -256,7 +257,8 @@ const UsersPage: React.FC = () => { alignItems: 'center', flexWrap: 'wrap', gap: 8, - }}> + }} + >

账号管理

{ - +
`共 ${total} 人` }} + /> + setSelected(null)} + onOk={submitChange} + confirmLoading={saving} + okText="确认" + > + + + + + + + + + + + + + setBatchModalOpen(false)} + onOk={submitBatchChange} + confirmLoading={saving} + okText="确认批量修改" + > +
+
+ 已选择 {selectedRowKeys.length}{' '} + 名学生,将按相同金额批量修改水电费余额。 +
+ + + + + + + + + + +
+ { + setDrawerOpen(false); + setSelected(null); + }} + > +
dayjs(value).format('YYYY-MM-DD HH:mm'), + }, + { + title: '类型', + dataIndex: 'type', + render: (value: string) => transactionNames[value] || value, + }, + { + title: '金额', + dataIndex: 'amount', + render: (value: number) => ( + = 0 ? '#389e0d' : '#cf1322' }}> + {Number(value) >= 0 ? '+' : ''}¥{Number(value).toFixed(2)} + + ), + }, + { + title: '变动后余额', + dataIndex: 'balanceAfter', + render: (value: number) => `¥${Number(value).toFixed(2)}`, + }, + { + title: '关联账单', + dataIndex: 'billId', + render: (value: number) => (value ? `#${value}` : '-'), + }, + { title: '说明', dataIndex: 'description' }, + ]} + /> + -
`共 ${total} 人` }} - /> - setSelected(null)} onOk={submitChange} confirmLoading={saving} okText="确认"> -
- - - - -
- setBatchModalOpen(false)} - onOk={submitBatchChange} - confirmLoading={saving} - okText="确认批量修改" - > -
-
- 已选择 {selectedRowKeys.length} 名学生,将按相同金额批量修改水电费余额。 -
- - - - - - - - -
- { setDrawerOpen(false); setSelected(null); }}> -
dayjs(value).format('YYYY-MM-DD HH:mm') }, - { title: '类型', dataIndex: 'type', render: (value: string) => transactionNames[value] || value }, - { title: '金额', dataIndex: 'amount', render: (value: number) => = 0 ? '#389e0d' : '#cf1322' }}>{Number(value) >= 0 ? '+' : ''}¥{Number(value).toFixed(2)} }, - { title: '变动后余额', dataIndex: 'balanceAfter', render: (value: number) => `¥${Number(value).toFixed(2)}` }, - { title: '关联账单', dataIndex: 'billId', render: (value: number) => value ? `#${value}` : '-' }, - { title: '说明', dataIndex: 'description' }, - ]} /> - - ; + ); }; export default WalletsPage; diff --git a/apps/admin/src/test/fixtures.ts b/apps/admin/src/test/fixtures.ts index fb0aa9f..a8bc120 100644 --- a/apps/admin/src/test/fixtures.ts +++ b/apps/admin/src/test/fixtures.ts @@ -202,24 +202,67 @@ export const LOG_ACTIONS = { // ── Permission nodes (PRD §17) ────────────────────────────────────── export const PERMISSION_NODES = [ - 'student:view', 'student:add', 'student:update', 'student:delete', - 'student:import', 'student:export', - 'room:view', 'room:add', 'room:update', 'room:delete', - 'occupancy:view', 'occupancy:add', 'occupancy:update', - 'bill:view', 'bill:generate', 'bill:confirm', 'bill:markPaid', 'bill:export', - 'expense:view', 'expense:add', 'expense:update', 'expense:delete', - 'deposit:view', 'deposit:collect', 'deposit:refund', - 'class:view', 'class:add', 'class:update', 'class:delete', - 'schedule:view', 'schedule:add', 'schedule:update', 'schedule:delete', - 'attendance:view', 'attendance:add', 'attendance:update', 'attendance:delete', + 'student:view', + 'student:add', + 'student:update', + 'student:delete', + 'student:import', + 'student:export', + 'room:view', + 'room:add', + 'room:update', + 'room:delete', + 'occupancy:view', + 'occupancy:add', + 'occupancy:update', + 'bill:view', + 'bill:generate', + 'bill:confirm', + 'bill:markPaid', + 'bill:export', + 'expense:view', + 'expense:add', + 'expense:update', + 'expense:delete', + 'deposit:view', + 'deposit:collect', + 'deposit:refund', + 'class:view', + 'class:add', + 'class:update', + 'class:delete', + 'schedule:view', + 'schedule:add', + 'schedule:update', + 'schedule:delete', + 'attendance:view', + 'attendance:add', + 'attendance:update', + 'attendance:delete', 'attendance:batch', - 'classroom:view', 'classroom:add', 'classroom:update', 'classroom:delete', - 'organization:view', 'organization:create', 'organization:edit', 'organization:delete', - 'rental:view', 'rental:add', 'rental:update', 'rental:delete', - 'archive:view', 'archive:import', 'archive:export', + 'classroom:view', + 'classroom:add', + 'classroom:update', + 'classroom:delete', + 'organization:view', + 'organization:create', + 'organization:edit', + 'organization:delete', + 'rental:view', + 'rental:add', + 'rental:update', + 'rental:delete', + 'archive:view', + 'archive:import', + 'archive:export', 'report:generate', 'log:view', - 'role:view', 'role:add', 'role:update', 'role:delete', - 'user:view', 'user:add', 'user:update', + 'role:view', + 'role:add', + 'role:update', + 'role:delete', + 'user:view', + 'user:add', + 'user:update', 'dashboard:view', ] as const; diff --git a/apps/admin/src/test/helpers.ts b/apps/admin/src/test/helpers.ts index 81beded..aab9506 100644 --- a/apps/admin/src/test/helpers.ts +++ b/apps/admin/src/test/helpers.ts @@ -25,7 +25,9 @@ type Role = keyof typeof CREDENTIALS; * Login as a specific role and store the token in localStorage. * Returns the parsed response data. */ -export async function loginAs(role: Role): Promise<{ token: string; user: Record }> { +export async function loginAs( + role: Role, +): Promise<{ token: string; user: Record }> { const creds = CREDENTIALS[role]; const res = await fetch(`${BASE}/api/auth/login`, { method: 'POST',