feat: 空状态引导全面应用与考勤批量标记

admin:
- 17 个列表页空态统一为 QueryEmpty 引导:学生/账单/入住/费用/押金/
  教室/房间/班级/考试/排课/机构/考勤机/钱包/租赁/通知/角色等,
  有创建权限的页面附带主操作按钮,无权限时纯展示
- 教师端课堂点名新增「全部已打卡/全部未打卡」批量按钮:
  仅作用于状态不一致的记录,确认后调用批量接口,展示成功/失败数量

server:
- 新增 PUT /attendance-records/batch-status 批量改状态接口
  (ids ≤200,逐条权限校验与会话锁,部分失败返回 failedIds,
  审计日志记录批量结果;路由声明在 :id 之前避免被捕获)

aislop scan: 5 引擎 0 issues
This commit is contained in:
2026-08-07 18:00:29 +08:00
parent c10476d203
commit 1b4ba893fd
23 changed files with 350 additions and 77 deletions

View File

@@ -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<LessonAttendanceDetailProps> = ({
className,
onClose,
}) => {
const { modal } = App.useApp();
const { hasAnyPermission } = usePermission();
const canEditAttendance = hasAnyPermission('attendance:edit', 'attendance:self-edit');
const [loadedSchedule, setLoadedSchedule] = useState<LessonAttendanceSchedule | null>(null);
@@ -88,9 +89,58 @@ const LessonAttendanceDetail: React.FC<LessonAttendanceDetailProps> = ({
const [error, setError] = useState<string | null>(null);
const [keyword, setKeyword] = useState('');
const [filter, setFilter] = useState<LessonAttendanceFilter>('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<LessonAttendanceDetailProps> = ({
<span className="lesson-record-filter-count">
{filteredRecords.length} / {records.length}
</span>
{canEditAttendance && records.length > 0 ? (
<>
<Button
size="small"
type="primary"
loading={batchUpdating === 'present'}
onClick={() => void handleBatchMark('present')}
>
</Button>
<Button
size="small"
danger
loading={batchUpdating === 'absent'}
onClick={() => void handleBatchMark('absent')}
>
</Button>
</>
) : null}
</div>
{error ? (
<QueryErrorState

View File

@@ -3,15 +3,16 @@ import { useQuery } from '@tanstack/react-query';
import { useApiMutation } from '../hooks/useApiMutation';
import { validateResponse } from '../utils/validate';
import { attendanceDevicesSchema, classroomOptionsSchema } from '../api/schemas';
import { Empty, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag } from 'antd';
import { Form, Input, Modal, Popconfirm, Select, Space, Table, Tag } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { PlusOutlined } from '@ant-design/icons';
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 { useDirtyGuard } from '../hooks/useDirtyGuard';
import { usePermission } from '../hooks/usePermission';
interface ClassroomOption {
id: number;
@@ -36,6 +37,7 @@ const statusMeta = {
} as const;
const AttendanceDevicesPage: React.FC = () => {
const { hasPermission } = usePermission();
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<AttendanceDeviceRow | null>(null);
const [saving, setSaving] = useState(false);
@@ -321,7 +323,18 @@ const AttendanceDevicesPage: React.FC = () => {
columns={columns}
dataSource={filteredData}
loading={loading}
locale={{ emptyText: <Empty description="暂无考勤机绑定" /> }}
locale={{
emptyText: (
<QueryEmpty
description="暂无考勤机绑定"
action={
hasPermission('classroom:edit')
? { label: '添加考勤机', icon: <PlusOutlined />, onClick: openCreate }
: undefined
}
/>
),
}}
pagination={{ defaultPageSize: 20, showSizeChanger: true }}
/>
)}

View File

@@ -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={<FileTextOutlined />}
onClick={() => {
generateForm.resetFields();
setGenerateModal(true);
}}
onClick={openGenerateModal}
>
</PermissionButton>
@@ -507,7 +508,18 @@ const BillsPage: React.FC = () => {
rowKey="id"
loading={loading}
pagination={{ pageSize: 15, showTotal: (total) => `${total}` }}
locale={{ emptyText: <Empty description="暂无数据" /> }}
locale={{
emptyText: (
<QueryEmpty
description="暂无账单"
action={
hasPermission('bill:generate')
? { label: '生成账单', onClick: openGenerateModal }
: undefined
}
/>
),
}}
rowSelection={{
selectedRowKeys: selectedRows,
onChange: (keys) => setSelectedRows(keys as number[]),

View File

@@ -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: <Empty description="暂无数据" /> }}
locale={{
emptyText: (
<QueryEmpty
description="暂无班级数据"
action={
hasPermission('class:create')
? { label: '创建班级', icon: <PlusOutlined />, onClick: handleCreate }
: undefined
}
/>
),
}}
pagination={{
defaultPageSize: 20,
showSizeChanger: true,

View File

@@ -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<RentalTableProps> = ({
dataSource={data}
rowKey="id"
loading={loading}
locale={{ emptyText: <Empty description="暂无数据" /> }}
locale={{ emptyText: <QueryEmpty description="暂无租赁订单,点击右上角「新增租赁」创建第一笔订单" /> }}
pagination={{
defaultPageSize: 15,
showSizeChanger: true,

View File

@@ -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 = () => {
<Spin spinning={loading}>
{!data || data.classrooms.length === 0 ? (
<Empty description="暂无教室数据" />
<QueryEmpty description="暂无教室数据,可在「教室管理」中添加教室后查看排期" />
) : (
<div style={{ overflowX: 'auto' }}>
{groups.map((group) => (

View File

@@ -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={<PlusOutlined />}
onClick={() => {
setEditing(null);
form.resetFields();
formGuard.snapshot();
setModalOpen(true);
}}
onClick={openCreateModal}
>
</PermissionButton>
@@ -531,7 +532,18 @@ const ClassroomsPage: React.FC = () => {
dataSource={filteredData}
rowKey="id"
loading={loading}
locale={{ emptyText: <Empty description="暂无数据" /> }}
locale={{
emptyText: (
<QueryEmpty
description="暂无数据"
action={
hasPermission('classroom:create')
? { label: '添加教室', onClick: openCreateModal }
: undefined
}
/>
),
}}
pagination={{
defaultPageSize: 20,
showSizeChanger: true,

View File

@@ -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<typeof import('antd').Form.useForm>[0];
onDetail: (record: DepositRecord) => void;
onRefund: (record: DepositRecord) => void;
@@ -22,6 +25,8 @@ export const DepositTable: React.FC<DepositTableProps> = ({
data,
loading,
canPurgeDeposit,
canCreateDeposit,
onCreateDeposit,
refundForm,
onDetail,
onRefund,
@@ -142,7 +147,18 @@ export const DepositTable: React.FC<DepositTableProps> = ({
pageSizeOptions: [15, 30, 50, 100],
showTotal: (total) => `${total}`,
}}
locale={{ emptyText: <Empty description="暂无数据" /> }}
locale={{
emptyText: (
<QueryEmpty
description="暂无数据"
action={
canCreateDeposit && onCreateDeposit
? { label: '收取押金', onClick: onCreateDeposit }
: undefined
}
/>
),
}}
/>
);
};

View File

@@ -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={<PlusOutlined />}
onClick={() => {
createForm.resetFields();
createForm.setFieldsValue({ amount: 500, paidDate: dayjs() });
setCreateModal(true);
}}
onClick={openCreateDeposit}
>
</PermissionButton>
@@ -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)}

View File

@@ -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 ? (
<div className="exam-empty">
<Empty description="暂无考试" />
<QueryEmpty
description="暂无考试"
action={
!showArchived
? { label: '创建考试', icon: <PlusOutlined />, onClick: openCreate }
: undefined
}
/>
</div>
) : (
<Row gutter={[16, 16]}>

View File

@@ -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<ExpenseTablePanelProps> = ({
pageSizeOptions: [15, 30, 50, 100],
showTotal: (total) => `${total}`,
}}
locale={{ emptyText: <Empty description="暂无数据" /> }}
locale={{
emptyText: (
<QueryEmpty
description="暂无数据"
action={
canImport && onAddUtility && !showArchived
? { label: '添加学生水电费', onClick: onAddUtility }
: undefined
}
/>
),
}}
rowSelection={{
selectedRowKeys: selectedKeys,
onChange: (keys) => onSelect(keys as number[]),

View File

@@ -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 = () => {
) : (
<Spin spinning={loading}>
{filtered.length === 0 ? (
<Empty description="暂无通知" />
<QueryEmpty description="暂无通知,有新消息时会在这里提醒你" />
) : (
<List
dataSource={filtered}

View File

@@ -1,7 +1,8 @@
import React from 'react';
import { Alert, Button, Empty, Popconfirm, Table } from 'antd';
import { Alert, Button, Popconfirm, Table } from 'antd';
import { InboxOutlined, LogoutOutlined, UndoOutlined, DeleteOutlined } from '@ant-design/icons';
import PermissionButton from '../../components/PermissionButton';
import { QueryEmpty } from '../../components/QueryState';
export const OccupanciesTableArea: React.FC<{
columns: any[];
@@ -12,6 +13,8 @@ export const OccupanciesTableArea: React.FC<{
batchAction: 'checkout' | 'archive' | 'restore';
canDelete: boolean;
canPurge: boolean;
canCheckIn?: boolean;
onCheckIn?: () => 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: <Empty description="暂无数据" /> }}
locale={{
emptyText: (
<QueryEmpty
description="暂无数据"
action={
canCheckIn && onCheckIn
? { label: '入住登记', onClick: onCheckIn }
: undefined
}
/>
),
}}
scroll={{ x: 1300 }}
pagination={{
defaultPageSize: 15,

View File

@@ -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 (
<div>
<Alert
@@ -473,21 +489,7 @@ const OccupanciesPage: React.FC = () => {
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();

View File

@@ -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: <Empty description="暂无机构" /> }}
locale={{
emptyText: (
<QueryEmpty
description="暂无机构"
action={
hasPermission('organization:create')
? { label: '添加机构', icon: <PlusOutlined />, onClick: () => openEditor() }
: undefined
}
/>
),
}}
scroll={{ x: 1100 }}
pagination={{
defaultPageSize: 20,

View File

@@ -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<RoleItem | null>(null);
const [form] = Form.useForm();
@@ -327,7 +330,18 @@ const RolesPage: React.FC = () => {
dataSource={data}
rowKey="id"
loading={loading}
locale={{ emptyText: <Empty description="暂无数据" /> }}
locale={{
emptyText: (
<QueryEmpty
description="暂无角色数据"
action={
hasPermission('role:create')
? { label: '添加角色', icon: <PlusOutlined />, onClick: handleAdd }
: undefined
}
/>
),
}}
scroll={{ x: 900 }}
pagination={false}
/>

View File

@@ -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 (
<>
<Table
@@ -16,7 +19,18 @@ export const RoomsTable: React.FC<{
rowKey="id"
scroll={{ x: 1200 }}
loading={loading}
locale={{ emptyText: <Empty description="暂无数据" /> }}
locale={{
emptyText: (
<QueryEmpty
description="暂无数据"
action={
canCreateRoom && onCreateRoom
? { label: '添加宿舍', onClick: onCreateRoom }
: undefined
}
/>
),
}}
pagination={{
defaultPageSize: 20,
showSizeChanger: true,

View File

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

View File

@@ -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 (
<Spin spinning={loading}>
{classrooms.length === 0 ? (
<Empty description="暂无教室数据" />
<QueryEmpty description="暂无教室数据,可在「教室管理」中添加教室后开始排课" />
) : viewMode === 'week' ? (
<div style={{ overflowX: 'auto' }}>
<table

View File

@@ -11,7 +11,7 @@ import { message } from '../../ui/app-message';
import EditableCell from '../../components/EditableCell';
import PermissionButton from '../../components/PermissionButton';
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';
@@ -265,6 +265,7 @@ const TeachersPage: React.FC = () => {
dataSource={data}
rowKey="id"
loading={loading}
locale={{ emptyText: <QueryEmpty description="暂无教师数据" /> }}
scroll={{ x: 1300 }}
pagination={{
current: page,

View File

@@ -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 = () => {
<Table
rowKey="studentId"
loading={loading}
locale={{ emptyText: <QueryEmpty description="暂无学生余额数据" /> }}
dataSource={rows}
columns={columns}
rowSelection={{ selectedRowKeys, onChange: setSelectedRowKeys }}

View File

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

View File

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