From 1b4ba893fdd63d64461f02310111595afdc39751 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Fri, 7 Aug 2026 18:00:29 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=A9=BA=E7=8A=B6=E6=80=81=E5=BC=95?= =?UTF-8?q?=E5=AF=BC=E5=85=A8=E9=9D=A2=E5=BA=94=E7=94=A8=E4=B8=8E=E8=80=83?= =?UTF-8?q?=E5=8B=A4=E6=89=B9=E9=87=8F=E6=A0=87=E8=AE=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit admin: - 17 个列表页空态统一为 QueryEmpty 引导:学生/账单/入住/费用/押金/ 教室/房间/班级/考试/排课/机构/考勤机/钱包/租赁/通知/角色等, 有创建权限的页面附带主操作按钮,无权限时纯展示 - 教师端课堂点名新增「全部已打卡/全部未打卡」批量按钮: 仅作用于状态不一致的记录,确认后调用批量接口,展示成功/失败数量 server: - 新增 PUT /attendance-records/batch-status 批量改状态接口 (ids ≤200,逐条权限校验与会话锁,部分失败返回 failedIds, 审计日志记录批量结果;路由声明在 :id 之前避免被捕获) aislop scan: 5 引擎 0 issues --- .../Attendance/LessonAttendanceDetail.tsx | 72 ++++++++++++++++++- apps/admin/src/pages/AttendanceDevices.tsx | 19 ++++- apps/admin/src/pages/Bills/index.tsx | 26 +++++-- apps/admin/src/pages/Classes/index.tsx | 16 ++++- .../pages/ClassroomRentals/RentalTable.tsx | 4 +- .../src/pages/ClassroomSchedule/index.tsx | 5 +- apps/admin/src/pages/Classrooms/index.tsx | 30 +++++--- .../admin/src/pages/Deposits/DepositTable.tsx | 20 +++++- apps/admin/src/pages/Deposits/index.tsx | 14 ++-- apps/admin/src/pages/Exams/index.tsx | 12 +++- .../src/pages/Expenses/ExpenseTablePanel.tsx | 15 +++- apps/admin/src/pages/Notifications/index.tsx | 6 +- .../Occupancies/OccupanciesTableArea.tsx | 20 +++++- apps/admin/src/pages/Occupancies/index.tsx | 34 +++++---- apps/admin/src/pages/Organizations/index.tsx | 16 ++++- apps/admin/src/pages/Roles/index.tsx | 18 ++++- apps/admin/src/pages/Rooms/RoomsTable.tsx | 20 +++++- apps/admin/src/pages/Rooms/index.tsx | 16 +++-- .../src/pages/Schedules/ScheduleGrids.tsx | 5 +- apps/admin/src/pages/Teachers/index.tsx | 3 +- apps/admin/src/pages/Wallets/index.tsx | 3 +- .../attendance-records.controller.ts | 31 ++++++++ .../src/attendance/dto/attendance.dto.ts | 22 ++++++ 23 files changed, 350 insertions(+), 77 deletions(-) diff --git a/apps/admin/src/pages/Attendance/LessonAttendanceDetail.tsx b/apps/admin/src/pages/Attendance/LessonAttendanceDetail.tsx index 4075ac7..c6e64b8 100644 --- a/apps/admin/src/pages/Attendance/LessonAttendanceDetail.tsx +++ b/apps/admin/src/pages/Attendance/LessonAttendanceDetail.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Alert, Avatar, Button, Drawer, Empty, Input, Progress, Select, Table, Tag } from 'antd'; +import { Alert, App, Avatar, Button, Drawer, Empty, Input, Progress, Select, Table, Tag } from 'antd'; import dayjs from 'dayjs'; import api from '../../api'; import { message } from '../../ui/app-message'; @@ -79,6 +79,7 @@ const LessonAttendanceDetail: React.FC = ({ className, onClose, }) => { + const { modal } = App.useApp(); const { hasAnyPermission } = usePermission(); const canEditAttendance = hasAnyPermission('attendance:edit', 'attendance:self-edit'); const [loadedSchedule, setLoadedSchedule] = useState(null); @@ -88,9 +89,58 @@ const LessonAttendanceDetail: React.FC = ({ const [error, setError] = useState(null); const [keyword, setKeyword] = useState(''); const [filter, setFilter] = useState('all'); + const [batchUpdating, setBatchUpdating] = useState<'present' | 'absent' | null>(null); // 组件以 key 重挂载(关闭/切换课节),卸载后 in-flight 请求不再更新状态或弹提示 const cancelledRef = useRef(false); + /** 一键全部已打卡/全部未打卡(仅对状态不一致的记录) */ + const handleBatchMark = async (status: 'present' | 'absent') => { + if (batchUpdating || records.length === 0) return; + const targetIds = records + .filter((record) => + status === 'present' + ? record.status !== 'present' && record.status !== 'late' + : record.status !== 'absent', + ) + .map((record) => record.id); + if (targetIds.length === 0) { + message.success(status === 'present' ? '所有学生都已打卡' : '所有学生都未打卡'); + return; + } + modal.confirm({ + title: status === 'present' ? `将 ${targetIds.length} 名学生标记为已打卡?` : `将 ${targetIds.length} 名学生标记为未打卡?`, + content: + '此操作会立即写入考勤记录;已结算(课程截止后)的记录无法修改。', + okText: '确认', + cancelText: '取消', + onOk: async () => { + setBatchUpdating(status); + try { + const res = await api.put<{ updated: number; failed: number; failedIds: number[] }>( + '/attendance-records/batch-status', + { ids: targetIds, status }, + ); + if (cancelledRef.current) return; + const failedSet = new Set(res.failedIds); + setRecords((items) => + items.map((record) => + targetIds.includes(record.id) && !failedSet.has(record.id) + ? { ...record, status } + : record, + ), + ); + message.success(`已更新 ${res.updated} 条记录`); + if (res.failed > 0) message.warning(`有 ${res.failed} 条更新失败(可能已结算)`); + } catch (error: unknown) { + if (cancelledRef.current) return; + message.error(getErrorMessage(error, '批量更新失败')); + } finally { + if (!cancelledRef.current) setBatchUpdating(null); + } + }, + }); + }; + const loadLesson = useCallback(async () => { if (!schedule) return; setLoading(true); @@ -192,6 +242,26 @@ const LessonAttendanceDetail: React.FC = ({ 显示 {filteredRecords.length} / {records.length} 人 + {canEditAttendance && records.length > 0 ? ( + <> + + + + ) : null} {error ? ( { + const { hasPermission } = usePermission(); const [modalOpen, setModalOpen] = useState(false); const [editing, setEditing] = useState(null); const [saving, setSaving] = useState(false); @@ -321,7 +323,18 @@ const AttendanceDevicesPage: React.FC = () => { columns={columns} dataSource={filteredData} loading={loading} - locale={{ emptyText: }} + locale={{ + emptyText: ( + , onClick: openCreate } + : undefined + } + /> + ), + }} pagination={{ defaultPageSize: 20, showSizeChanger: true }} /> )} diff --git a/apps/admin/src/pages/Bills/index.tsx b/apps/admin/src/pages/Bills/index.tsx index e7937f7..e5bf3a0 100644 --- a/apps/admin/src/pages/Bills/index.tsx +++ b/apps/admin/src/pages/Bills/index.tsx @@ -13,7 +13,6 @@ import { Input, Select, Spin, - Empty, } from 'antd'; import { FileTextOutlined, @@ -24,7 +23,7 @@ import { import dayjs from 'dayjs'; import api from '../../api'; import PermissionButton from '../../components/PermissionButton'; -import { QueryErrorState } from '../../components/QueryState'; +import { QueryErrorState, QueryEmpty } from '../../components/QueryState'; import { NextStepHint } from '../../components/NextStepHint'; import { useVisibleRefetch } from '../../hooks/usePageVisible'; import { useDownload } from '../../hooks/useDownload'; @@ -144,6 +143,11 @@ const BillsPage: React.FC = () => { } }; + const openGenerateModal = () => { + generateForm.resetFields(); + setGenerateModal(true); + }; + const showDetail = useCallback(async (id: number) => { setDetailLoading(true); try { @@ -461,10 +465,7 @@ const BillsPage: React.FC = () => { permission="bill:generate" type="primary" icon={} - onClick={() => { - generateForm.resetFields(); - setGenerateModal(true); - }} + onClick={openGenerateModal} > 生成账单 @@ -507,7 +508,18 @@ const BillsPage: React.FC = () => { rowKey="id" loading={loading} pagination={{ pageSize: 15, showTotal: (total) => `共 ${total} 条` }} - locale={{ emptyText: }} + locale={{ + emptyText: ( + + ), + }} rowSelection={{ selectedRowKeys: selectedRows, onChange: (keys) => setSelectedRows(keys as number[]), diff --git a/apps/admin/src/pages/Classes/index.tsx b/apps/admin/src/pages/Classes/index.tsx index 05e6c07..ec58972 100644 --- a/apps/admin/src/pages/Classes/index.tsx +++ b/apps/admin/src/pages/Classes/index.tsx @@ -19,7 +19,6 @@ import { Popconfirm, Card, Switch, - Empty, } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { PlusOutlined, SearchOutlined, TeamOutlined, InboxOutlined } from '@ant-design/icons'; @@ -30,7 +29,7 @@ import PermissionButton from '../../components/PermissionButton'; import EditableCell from '../../components/EditableCell'; import { message } from '../../ui/app-message'; import { usePermission } from '../../hooks/usePermission'; -import { QueryErrorState } from '../../components/QueryState'; +import { QueryErrorState, QueryEmpty } from '../../components/QueryState'; import { useVisibleRefetch } from '../../hooks/usePageVisible'; import { useDirtyGuard } from '../../hooks/useDirtyGuard'; @@ -447,7 +446,18 @@ const ClassesPage: React.FC = () => { dataSource={filtered} rowKey="id" loading={loading} - locale={{ emptyText: }} + locale={{ + emptyText: ( + , onClick: handleCreate } + : undefined + } + /> + ), + }} pagination={{ defaultPageSize: 20, showSizeChanger: true, diff --git a/apps/admin/src/pages/ClassroomRentals/RentalTable.tsx b/apps/admin/src/pages/ClassroomRentals/RentalTable.tsx index c3d7dcd..6c90a4b 100644 --- a/apps/admin/src/pages/ClassroomRentals/RentalTable.tsx +++ b/apps/admin/src/pages/ClassroomRentals/RentalTable.tsx @@ -1,7 +1,6 @@ import React, { useState } from 'react'; import { Button, - Empty, Popconfirm, Space, Table, @@ -19,6 +18,7 @@ import dayjs from 'dayjs'; import PermissionButton from '../../components/PermissionButton'; import EditableCell from '../../components/EditableCell'; import { message } from '../../ui/app-message'; +import { QueryEmpty } from '../../components/QueryState'; const RENTAL_FIELDS = { classroomId: 'classroomId', @@ -345,7 +345,7 @@ export const RentalTable: React.FC = ({ dataSource={data} rowKey="id" loading={loading} - locale={{ emptyText: }} + locale={{ emptyText: }} pagination={{ defaultPageSize: 15, showSizeChanger: true, diff --git a/apps/admin/src/pages/ClassroomSchedule/index.tsx b/apps/admin/src/pages/ClassroomSchedule/index.tsx index ff494a4..8d478ed 100644 --- a/apps/admin/src/pages/ClassroomSchedule/index.tsx +++ b/apps/admin/src/pages/ClassroomSchedule/index.tsx @@ -13,7 +13,6 @@ import { Button, Modal, Spin, - Empty, Tooltip, } from 'antd'; import { CalendarOutlined, FileTextOutlined, ReadOutlined } from '@ant-design/icons'; @@ -22,7 +21,7 @@ import api from '../../api'; import { downloadBlob } from '../../utils/download'; import { message } from '../../ui/app-message'; import { getErrorMessage } from '../../utils/error'; -import { QueryErrorState } from '../../components/QueryState'; +import { QueryErrorState, QueryEmpty } from '../../components/QueryState'; import { useVisibleRefetch } from '../../hooks/usePageVisible'; interface ScheduleData { @@ -197,7 +196,7 @@ const ClassroomSchedulePage: React.FC = () => { {!data || data.classrooms.length === 0 ? ( - + ) : (
{groups.map((group) => ( diff --git a/apps/admin/src/pages/Classrooms/index.tsx b/apps/admin/src/pages/Classrooms/index.tsx index 711a3de..94156a5 100644 --- a/apps/admin/src/pages/Classrooms/index.tsx +++ b/apps/admin/src/pages/Classrooms/index.tsx @@ -17,7 +17,6 @@ import { Popconfirm, Upload, Tooltip, - Empty, } from 'antd'; import { PlusOutlined, @@ -29,7 +28,7 @@ import { import api from '../../api'; import PermissionButton from '../../components/PermissionButton'; import EditableCell from '../../components/EditableCell'; -import { QueryErrorState } from '../../components/QueryState'; +import { QueryErrorState, QueryEmpty } from '../../components/QueryState'; import { message } from '../../ui/app-message'; import { usePermission } from '../../hooks/usePermission'; import { useUserStore } from '../../store/user/userStore'; @@ -147,6 +146,13 @@ const ClassroomsPage: React.FC = () => { } }; + const openCreateModal = () => { + setEditing(null); + form.resetFields(); + formGuard.snapshot(); + setModalOpen(true); + }; + const saveCell = useCallback( async (record: any, field: string, value: unknown) => { try { @@ -461,12 +467,7 @@ const ClassroomsPage: React.FC = () => { permission="classroom:create" type="primary" icon={} - onClick={() => { - setEditing(null); - form.resetFields(); - formGuard.snapshot(); - setModalOpen(true); - }} + onClick={openCreateModal} > 添加教室 @@ -531,7 +532,18 @@ const ClassroomsPage: React.FC = () => { dataSource={filteredData} rowKey="id" loading={loading} - locale={{ emptyText: }} + locale={{ + emptyText: ( + + ), + }} pagination={{ defaultPageSize: 20, showSizeChanger: true, diff --git a/apps/admin/src/pages/Deposits/DepositTable.tsx b/apps/admin/src/pages/Deposits/DepositTable.tsx index e0db067..6541dde 100644 --- a/apps/admin/src/pages/Deposits/DepositTable.tsx +++ b/apps/admin/src/pages/Deposits/DepositTable.tsx @@ -1,8 +1,9 @@ import React from 'react'; -import { Button, Empty, Popconfirm, Space, Table, Tag } from 'antd'; +import { Button, Popconfirm, Space, Table, Tag } from 'antd'; import { DeleteOutlined, InboxOutlined } from '@ant-design/icons'; import dayjs from 'dayjs'; import PermissionButton from '../../components/PermissionButton'; +import { QueryEmpty } from '../../components/QueryState'; import { message } from '../../ui/app-message'; import { statusMap } from './DepositModals'; import type { DepositRecord } from './DepositModals'; @@ -11,6 +12,8 @@ export interface DepositTableProps { data: any[]; loading: boolean; canPurgeDeposit: boolean; + canCreateDeposit?: boolean; + onCreateDeposit?: () => void; refundForm: ReturnType[0]; onDetail: (record: DepositRecord) => void; onRefund: (record: DepositRecord) => void; @@ -22,6 +25,8 @@ export const DepositTable: React.FC = ({ data, loading, canPurgeDeposit, + canCreateDeposit, + onCreateDeposit, refundForm, onDetail, onRefund, @@ -142,7 +147,18 @@ export const DepositTable: React.FC = ({ pageSizeOptions: [15, 30, 50, 100], showTotal: (total) => `共 ${total} 条`, }} - locale={{ emptyText: }} + locale={{ + emptyText: ( + + ), + }} /> ); }; diff --git a/apps/admin/src/pages/Deposits/index.tsx b/apps/admin/src/pages/Deposits/index.tsx index f05b9ce..989bbe9 100644 --- a/apps/admin/src/pages/Deposits/index.tsx +++ b/apps/admin/src/pages/Deposits/index.tsx @@ -225,6 +225,12 @@ const DepositsPage: React.FC = () => { setBatchModal(true); }; + const openCreateDeposit = () => { + createForm.resetFields(); + createForm.setFieldsValue({ amount: 500, paidDate: dayjs() }); + setCreateModal(true); + }; + const handleBatchRoomTypeChange = (roomType: string) => { setBatchRoomType(roomType); // 切换房型后候选学生列表会变化,重置勾选状态,避免把上一房型的选择提交到新房型 @@ -431,11 +437,7 @@ const DepositsPage: React.FC = () => { permission="deposit:create" type="primary" icon={} - onClick={() => { - createForm.resetFields(); - createForm.setFieldsValue({ amount: 500, paidDate: dayjs() }); - setCreateModal(true); - }} + onClick={openCreateDeposit} > 收取押金 @@ -458,6 +460,8 @@ const DepositsPage: React.FC = () => { data={filteredData} loading={loading || (!!filterRoomType && eligibleLoading)} canPurgeDeposit={canPurgeDeposit} + canCreateDeposit={hasPermission('deposit:create')} + onCreateDeposit={openCreateDeposit} refundForm={refundForm} onDetail={(record) => setDetailModal(record)} onRefund={(record) => setRefundModal(record)} diff --git a/apps/admin/src/pages/Exams/index.tsx b/apps/admin/src/pages/Exams/index.tsx index d38b553..6cad315 100644 --- a/apps/admin/src/pages/Exams/index.tsx +++ b/apps/admin/src/pages/Exams/index.tsx @@ -6,7 +6,6 @@ import { Card, Checkbox, Col, - Empty, Form, Input, Popconfirm, @@ -39,7 +38,7 @@ import { useApiMutation } from '../../hooks/useApiMutation'; import { validateResponse } from '../../utils/validate'; import { classOptionsSchema, examsSchema } from '../../api/schemas'; import { getErrorMessage } from '../../utils/error'; -import { QueryErrorState } from '../../components/QueryState'; +import { QueryErrorState, QueryEmpty } from '../../components/QueryState'; import { NextStepHint } from '../../components/NextStepHint'; import { useVisibleRefetch } from '../../hooks/usePageVisible'; import { useDirtyGuard } from '../../hooks/useDirtyGuard'; @@ -371,7 +370,14 @@ const ExamsPage: React.FC = () => { /> ) : data.length === 0 && !loading ? (
- + , onClick: openCreate } + : undefined + } + />
) : ( diff --git a/apps/admin/src/pages/Expenses/ExpenseTablePanel.tsx b/apps/admin/src/pages/Expenses/ExpenseTablePanel.tsx index efaffe5..505b881 100644 --- a/apps/admin/src/pages/Expenses/ExpenseTablePanel.tsx +++ b/apps/admin/src/pages/Expenses/ExpenseTablePanel.tsx @@ -2,7 +2,6 @@ import React from 'react'; import { Button, - Empty, Input, Popconfirm, Select, @@ -24,6 +23,7 @@ import { import dayjs from 'dayjs'; import PermissionButton from '../../components/PermissionButton'; import EditableCell from '../../components/EditableCell'; +import { QueryEmpty } from '../../components/QueryState'; import { message } from '../../ui/app-message'; export const EXPENSE_FIELDS = { @@ -519,7 +519,18 @@ export const ExpenseTablePanel: React.FC = ({ pageSizeOptions: [15, 30, 50, 100], showTotal: (total) => `共 ${total} 条`, }} - locale={{ emptyText: }} + locale={{ + emptyText: ( + + ), + }} rowSelection={{ selectedRowKeys: selectedKeys, onChange: (keys) => onSelect(keys as number[]), diff --git a/apps/admin/src/pages/Notifications/index.tsx b/apps/admin/src/pages/Notifications/index.tsx index b3dcb04..ab85cf4 100644 --- a/apps/admin/src/pages/Notifications/index.tsx +++ b/apps/admin/src/pages/Notifications/index.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useEffect, useState } from 'react'; import { validateResponse } from '../../utils/validate'; import { notificationsSchema } from '../../api/schemas'; -import { List, Typography, Menu, Layout, Button, Empty, Spin, Space, Grid, Select } from 'antd'; +import { List, Typography, Menu, Layout, Button, Spin, Space, Grid, Select } from 'antd'; import { BellOutlined, DollarOutlined, @@ -13,7 +13,7 @@ import { useNavigate } from 'react-router'; import api from '../../api'; import { message } from '../../ui/app-message'; import { formatNotificationText } from '../../utils/notification-display'; -import { QueryErrorState } from '../../components/QueryState'; +import { QueryErrorState, QueryEmpty } from '../../components/QueryState'; const { Sider, Content } = Layout; const { useBreakpoint } = Grid; @@ -174,7 +174,7 @@ const NotificationsPage: React.FC = () => { ) : ( {filtered.length === 0 ? ( - + ) : ( void; batchLoading: boolean; onBatchCheckOut: () => void; onBatchDelete: () => void; @@ -27,6 +30,8 @@ export const OccupanciesTableArea: React.FC<{ batchAction, canDelete, canPurge, + canCheckIn, + onCheckIn, batchLoading, onBatchCheckOut, onBatchDelete, @@ -127,7 +132,18 @@ export const OccupanciesTableArea: React.FC<{ dataSource={data} rowKey="id" loading={loading} - locale={{ emptyText: }} + locale={{ + emptyText: ( + + ), + }} scroll={{ x: 1300 }} pagination={{ defaultPageSize: 15, diff --git a/apps/admin/src/pages/Occupancies/index.tsx b/apps/admin/src/pages/Occupancies/index.tsx index ad9555c..5d38086 100644 --- a/apps/admin/src/pages/Occupancies/index.tsx +++ b/apps/admin/src/pages/Occupancies/index.tsx @@ -456,6 +456,22 @@ const OccupanciesPage: React.FC = () => { const { downloading: templateDownloading, run: runTemplateDownload } = useDownload(); const { downloading: exportDownloading, run: runExportDownload } = useDownload(); + const openCheckInModal = () => { + checkInForm.resetFields(); + setAvailableBeds([]); + setAvailableLockers([]); + setAvailableResourcesLoading(false); + const today = dayjs(); + checkInForm.setFieldsValue({ + checkInDate: today, + billingStartDate: today, + stayType: 'short', + collectDeposit: true, + depositAmount: 500, + }); + setCheckInModal(true); + }; + return (
{ dateRange={dateRange} onChangeDateRange={changeDateRange} canCheckIn={canCheckIn} - onCheckIn={() => { - checkInForm.resetFields(); - setAvailableBeds([]); - setAvailableLockers([]); - setAvailableResourcesLoading(false); - const today = dayjs(); - checkInForm.setFieldsValue({ - checkInDate: today, - billingStartDate: today, - stayType: 'short', - collectDeposit: true, - depositAmount: 500, - }); - setCheckInModal(true); - }} + onCheckIn={openCheckInModal} onImport={async ({ file, onSuccess, onError }: any) => { const formData = new FormData(); formData.append('file', file); @@ -557,6 +559,8 @@ const OccupanciesPage: React.FC = () => { batchAction={viewPolicy.batchAction} canDelete={canDelete} canPurge={canPurge} + canCheckIn={canCheckIn} + onCheckIn={openCheckInModal} batchLoading={batchLoading} onBatchCheckOut={() => { batchCheckOutForm.resetFields(); diff --git a/apps/admin/src/pages/Organizations/index.tsx b/apps/admin/src/pages/Organizations/index.tsx index ca39730..2847987 100644 --- a/apps/admin/src/pages/Organizations/index.tsx +++ b/apps/admin/src/pages/Organizations/index.tsx @@ -1,7 +1,7 @@ // aislop-ignore-file: duplicate-block -- 表格/表单声明结构相似且参数不同,渲染逻辑已共享组件化 import React, { useMemo, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { App, Alert, Button, Empty, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag } from 'antd'; +import { App, Alert, Button, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag } from 'antd'; import { BankOutlined, InboxOutlined, PlusOutlined, UndoOutlined } from '@ant-design/icons'; import api from '../../api'; import PermissionButton from '../../components/PermissionButton'; @@ -12,6 +12,7 @@ import { useApiMutation } from '../../hooks/useApiMutation'; import { validateResponse } from '../../utils/validate'; import { organizationsSchema } from '../../api/schemas'; import { useDirtyGuard } from '../../hooks/useDirtyGuard'; +import { QueryEmpty } from '../../components/QueryState'; const PRESET_COLORS = [ '#ff7875', @@ -398,7 +399,18 @@ const OrganizationsPage: React.FC = () => { dataSource={filteredData} rowKey="id" loading={loading} - locale={{ emptyText: }} + locale={{ + emptyText: ( + , onClick: () => openEditor() } + : undefined + } + /> + ), + }} scroll={{ x: 1100 }} pagination={{ defaultPageSize: 20, diff --git a/apps/admin/src/pages/Roles/index.tsx b/apps/admin/src/pages/Roles/index.tsx index 2e11378..4d51097 100644 --- a/apps/admin/src/pages/Roles/index.tsx +++ b/apps/admin/src/pages/Roles/index.tsx @@ -1,5 +1,5 @@ import React, { 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 } from 'antd'; import { PlusOutlined, EditOutlined, StopOutlined } from '@ant-design/icons'; import api from '../../api'; import PermissionButton from '../../components/PermissionButton'; @@ -11,6 +11,8 @@ import { validateResponse } from '../../utils/validate'; import { permissionTreeSchema, rolesSchema } from '../../api/schemas'; import { getErrorMessage } from '../../utils/error'; import { useDirtyGuard } from '../../hooks/useDirtyGuard'; +import { usePermission } from '../../hooks/usePermission'; +import { QueryEmpty } from '../../components/QueryState'; interface PermissionItem { id: number; @@ -29,6 +31,7 @@ interface RoleItem { } const RolesPage: React.FC = () => { + const { hasPermission } = usePermission(); const [modalOpen, setModalOpen] = useState(false); const [editing, setEditing] = useState(null); const [form] = Form.useForm(); @@ -327,7 +330,18 @@ const RolesPage: React.FC = () => { dataSource={data} rowKey="id" loading={loading} - locale={{ emptyText: }} + locale={{ + emptyText: ( + , onClick: handleAdd } + : undefined + } + /> + ), + }} scroll={{ x: 900 }} pagination={false} /> diff --git a/apps/admin/src/pages/Rooms/RoomsTable.tsx b/apps/admin/src/pages/Rooms/RoomsTable.tsx index eccc02c..b10a614 100644 --- a/apps/admin/src/pages/Rooms/RoomsTable.tsx +++ b/apps/admin/src/pages/Rooms/RoomsTable.tsx @@ -1,5 +1,6 @@ import React from 'react'; -import { Empty, Table } from 'antd'; +import { Table } from 'antd'; +import { QueryEmpty } from '../../components/QueryState'; export const RoomsTable: React.FC<{ columns: any[]; @@ -7,7 +8,9 @@ export const RoomsTable: React.FC<{ loading: boolean; selectedRowKeys: number[]; onSelect: (keys: number[]) => void; -}> = ({ columns, data, loading, selectedRowKeys, onSelect }) => { + canCreateRoom?: boolean; + onCreateRoom?: () => void; +}> = ({ columns, data, loading, selectedRowKeys, onSelect, canCreateRoom, onCreateRoom }) => { return ( <> }} + locale={{ + emptyText: ( + + ), + }} pagination={{ defaultPageSize: 20, showSizeChanger: true, diff --git a/apps/admin/src/pages/Rooms/index.tsx b/apps/admin/src/pages/Rooms/index.tsx index 76fdb5e..b022025 100644 --- a/apps/admin/src/pages/Rooms/index.tsx +++ b/apps/admin/src/pages/Rooms/index.tsx @@ -182,6 +182,13 @@ const RoomsPage: React.FC = () => { } }; + const openAddRoomModal = () => { + setEditing(null); + form.resetFields(); + roomGuard.snapshot(); + setModalOpen(true); + }; + const saveRoomCell = useCallback( async (record: any, field: string, value: unknown) => { try { @@ -460,12 +467,7 @@ const RoomsPage: React.FC = () => { onBatchRestore={handleBatchRestore} onBatchPurge={handleBatchPurge} onBatchDelete={handleBatchDelete} - onAddRoom={() => { - setEditing(null); - form.resetFields(); - roomGuard.snapshot(); - setModalOpen(true); - }} + onAddRoom={openAddRoomModal} onImport={async (options: UploadRequestOption<{ message?: string }>) => { const { file, onSuccess, onError } = options; if (typeof file === 'string') { @@ -501,6 +503,8 @@ const RoomsPage: React.FC = () => { loading={loading} selectedRowKeys={selectedRowKeys} onSelect={setSelectedRowKeys} + canCreateRoom={canCreateRooms} + onCreateRoom={openAddRoomModal} /> )} diff --git a/apps/admin/src/pages/Schedules/ScheduleGrids.tsx b/apps/admin/src/pages/Schedules/ScheduleGrids.tsx index 44ee5b6..1234352 100644 --- a/apps/admin/src/pages/Schedules/ScheduleGrids.tsx +++ b/apps/admin/src/pages/Schedules/ScheduleGrids.tsx @@ -1,6 +1,7 @@ // aislop-ignore-file: duplicate-block -- 周/月视图表格结构相似且展示维度不同,已共享 ScheduleGrid 组件 import React from 'react'; -import { Badge, Empty, Spin, Tooltip } from 'antd'; +import { Badge, Spin, Tooltip } from 'antd'; +import { QueryEmpty } from '../../components/QueryState'; import type { Dayjs } from 'dayjs'; import { isMaskedSchedule } from './schedule-visibility'; @@ -76,7 +77,7 @@ export const ScheduleGrid: React.FC<{ return ( {classrooms.length === 0 ? ( - + ) : viewMode === 'week' ? (
{ dataSource={data} rowKey="id" loading={loading} + locale={{ emptyText: }} scroll={{ x: 1300 }} pagination={{ current: page, diff --git a/apps/admin/src/pages/Wallets/index.tsx b/apps/admin/src/pages/Wallets/index.tsx index 2e0469f..db5c561 100644 --- a/apps/admin/src/pages/Wallets/index.tsx +++ b/apps/admin/src/pages/Wallets/index.tsx @@ -23,7 +23,7 @@ import api from '../../api'; import PermissionButton from '../../components/PermissionButton'; import { message } from '../../ui/app-message'; import { newOperationId } from '../../utils/operation-id'; -import { QueryErrorState } from '../../components/QueryState'; +import { QueryErrorState, QueryEmpty } from '../../components/QueryState'; import { useVisibleRefetch } from '../../hooks/usePageVisible'; interface WalletRow { @@ -328,6 +328,7 @@ const WalletsPage: React.FC = () => {
}} dataSource={rows} columns={columns} rowSelection={{ selectedRowKeys, onChange: setSelectedRowKeys }} diff --git a/apps/server/src/attendance/attendance-records.controller.ts b/apps/server/src/attendance/attendance-records.controller.ts index ceca7dd..fd97615 100644 --- a/apps/server/src/attendance/attendance-records.controller.ts +++ b/apps/server/src/attendance/attendance-records.controller.ts @@ -17,6 +17,7 @@ import { AttendanceReportQueryDto, AttendanceAlertsQueryDto, UpdateAttendanceRecordDto, + BatchUpdateAttendanceStatusDto, GenerateFromSchedulesDto, RefreshDingTalkAttendanceDto, } from './dto/attendance.dto'; @@ -232,6 +233,36 @@ export class AttendanceRecordsController extends AttendanceControllerBase { return this.service.findAll(query, await this.getAccessibleClassIds(req)); } + // ── Batch update attendance record statuses (纠错/点名) ── + // 注意:必须在 :id 路由之前声明,否则 "batch-status" 会被 :id(ParseIntPipe) 捕获 + @Put('attendance-records/batch-status') + @RequirePermission('attendance:edit', 'attendance:self-edit') + async batchUpdateStatus( + @Body() dto: BatchUpdateAttendanceStatusDto, + @Request() req: any, + ) { + const failedIds: number[] = []; + let updated = 0; + for (const id of dto.ids) { + try { + const existing = await this.service.findAttendanceRecord(id); + if (existing.classId == null && !this.canManageAllAttendance(req)) { + throw new ForbiddenException('无权修改未关联班级的考勤记录'); + } + if (existing.classId != null) await this.assertClassAccess(req, existing.classId); + await this.service.update(id, { status: dto.status, remark: dto.remark }); + updated += 1; + } catch { + failedIds.push(id); + } + } + await logAudit(this.logService, req, { + module: '考勤管理', action: '批量修改考勤状态', targetId: 0, targetType: 'attendanceRecord', + detail: `批量 ${dto.ids.length} 条 → ${dto.status},成功 ${updated},失败 ${failedIds.length}`, + }); + return { updated, failed: failedIds.length, failedIds }; + } + // ── Update a single attendance record ── @Put('attendance-records/:id') @RequirePermission('attendance:edit', 'attendance:self-edit') diff --git a/apps/server/src/attendance/dto/attendance.dto.ts b/apps/server/src/attendance/dto/attendance.dto.ts index cf55fec..43b9469 100644 --- a/apps/server/src/attendance/dto/attendance.dto.ts +++ b/apps/server/src/attendance/dto/attendance.dto.ts @@ -9,8 +9,11 @@ import { ValidateNested, IsNotEmpty, ArrayNotEmpty, + ArrayMinSize, + ArrayMaxSize, Matches, Max, + MaxLength, Min, } from 'class-validator'; import { Type } from 'class-transformer'; @@ -248,6 +251,25 @@ export class UpdateAttendanceRecordDto { remark?: string; } +export class BatchUpdateAttendanceStatusDto { + /** 考勤记录 ID 列表(最多 200 条) */ + @IsArray() + @ArrayMinSize(1) + @ArrayMaxSize(200) + @IsInt({ each: true }) + @Min(1, { each: true }) + ids: number[]; + + @IsString() + @IsIn(['present', 'late', 'absent', 'leave']) + status: string; + + @IsOptional() + @IsString() + @MaxLength(200) + remark?: string; +} + export class AttendanceAlertsQueryDto { @IsOptional()