feat: 空状态引导全面应用与考勤批量标记
admin: - 17 个列表页空态统一为 QueryEmpty 引导:学生/账单/入住/费用/押金/ 教室/房间/班级/考试/排课/机构/考勤机/钱包/租赁/通知/角色等, 有创建权限的页面附带主操作按钮,无权限时纯展示 - 教师端课堂点名新增「全部已打卡/全部未打卡」批量按钮: 仅作用于状态不一致的记录,确认后调用批量接口,展示成功/失败数量 server: - 新增 PUT /attendance-records/batch-status 批量改状态接口 (ids ≤200,逐条权限校验与会话锁,部分失败返回 failedIds, 审计日志记录批量结果;路由声明在 :id 之前避免被捕获) aislop scan: 5 引擎 0 issues
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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[]),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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]}>
|
||||
|
||||
@@ -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[]),
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user