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 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 dayjs from 'dayjs';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import { message } from '../../ui/app-message';
|
import { message } from '../../ui/app-message';
|
||||||
@@ -79,6 +79,7 @@ const LessonAttendanceDetail: React.FC<LessonAttendanceDetailProps> = ({
|
|||||||
className,
|
className,
|
||||||
onClose,
|
onClose,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { modal } = App.useApp();
|
||||||
const { hasAnyPermission } = usePermission();
|
const { hasAnyPermission } = usePermission();
|
||||||
const canEditAttendance = hasAnyPermission('attendance:edit', 'attendance:self-edit');
|
const canEditAttendance = hasAnyPermission('attendance:edit', 'attendance:self-edit');
|
||||||
const [loadedSchedule, setLoadedSchedule] = useState<LessonAttendanceSchedule | null>(null);
|
const [loadedSchedule, setLoadedSchedule] = useState<LessonAttendanceSchedule | null>(null);
|
||||||
@@ -88,9 +89,58 @@ const LessonAttendanceDetail: React.FC<LessonAttendanceDetailProps> = ({
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [keyword, setKeyword] = useState('');
|
const [keyword, setKeyword] = useState('');
|
||||||
const [filter, setFilter] = useState<LessonAttendanceFilter>('all');
|
const [filter, setFilter] = useState<LessonAttendanceFilter>('all');
|
||||||
|
const [batchUpdating, setBatchUpdating] = useState<'present' | 'absent' | null>(null);
|
||||||
// 组件以 key 重挂载(关闭/切换课节),卸载后 in-flight 请求不再更新状态或弹提示
|
// 组件以 key 重挂载(关闭/切换课节),卸载后 in-flight 请求不再更新状态或弹提示
|
||||||
const cancelledRef = useRef(false);
|
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 () => {
|
const loadLesson = useCallback(async () => {
|
||||||
if (!schedule) return;
|
if (!schedule) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -192,6 +242,26 @@ const LessonAttendanceDetail: React.FC<LessonAttendanceDetailProps> = ({
|
|||||||
<span className="lesson-record-filter-count">
|
<span className="lesson-record-filter-count">
|
||||||
显示 {filteredRecords.length} / {records.length} 人
|
显示 {filteredRecords.length} / {records.length} 人
|
||||||
</span>
|
</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>
|
</div>
|
||||||
{error ? (
|
{error ? (
|
||||||
<QueryErrorState
|
<QueryErrorState
|
||||||
|
|||||||
@@ -3,15 +3,16 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import { useApiMutation } from '../hooks/useApiMutation';
|
import { useApiMutation } from '../hooks/useApiMutation';
|
||||||
import { validateResponse } from '../utils/validate';
|
import { validateResponse } from '../utils/validate';
|
||||||
import { attendanceDevicesSchema, classroomOptionsSchema } from '../api/schemas';
|
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 type { ColumnsType } from 'antd/es/table';
|
||||||
import { PlusOutlined } from '@ant-design/icons';
|
import { PlusOutlined } from '@ant-design/icons';
|
||||||
import api from '../api';
|
import api from '../api';
|
||||||
import PermissionButton from '../components/PermissionButton';
|
import PermissionButton from '../components/PermissionButton';
|
||||||
import EditableCell from '../components/EditableCell';
|
import EditableCell from '../components/EditableCell';
|
||||||
import { QueryErrorState } from '../components/QueryState';
|
import { QueryErrorState, QueryEmpty } from '../components/QueryState';
|
||||||
import { message } from '../ui/app-message';
|
import { message } from '../ui/app-message';
|
||||||
import { useDirtyGuard } from '../hooks/useDirtyGuard';
|
import { useDirtyGuard } from '../hooks/useDirtyGuard';
|
||||||
|
import { usePermission } from '../hooks/usePermission';
|
||||||
|
|
||||||
interface ClassroomOption {
|
interface ClassroomOption {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -36,6 +37,7 @@ const statusMeta = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const AttendanceDevicesPage: React.FC = () => {
|
const AttendanceDevicesPage: React.FC = () => {
|
||||||
|
const { hasPermission } = usePermission();
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState<AttendanceDeviceRow | null>(null);
|
const [editing, setEditing] = useState<AttendanceDeviceRow | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -321,7 +323,18 @@ const AttendanceDevicesPage: React.FC = () => {
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={filteredData}
|
dataSource={filteredData}
|
||||||
loading={loading}
|
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 }}
|
pagination={{ defaultPageSize: 20, showSizeChanger: true }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
Select,
|
Select,
|
||||||
Spin,
|
Spin,
|
||||||
Empty,
|
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
FileTextOutlined,
|
FileTextOutlined,
|
||||||
@@ -24,7 +23,7 @@ import {
|
|||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import PermissionButton from '../../components/PermissionButton';
|
import PermissionButton from '../../components/PermissionButton';
|
||||||
import { QueryErrorState } from '../../components/QueryState';
|
import { QueryErrorState, QueryEmpty } from '../../components/QueryState';
|
||||||
import { NextStepHint } from '../../components/NextStepHint';
|
import { NextStepHint } from '../../components/NextStepHint';
|
||||||
import { useVisibleRefetch } from '../../hooks/usePageVisible';
|
import { useVisibleRefetch } from '../../hooks/usePageVisible';
|
||||||
import { useDownload } from '../../hooks/useDownload';
|
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) => {
|
const showDetail = useCallback(async (id: number) => {
|
||||||
setDetailLoading(true);
|
setDetailLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -461,10 +465,7 @@ const BillsPage: React.FC = () => {
|
|||||||
permission="bill:generate"
|
permission="bill:generate"
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<FileTextOutlined />}
|
icon={<FileTextOutlined />}
|
||||||
onClick={() => {
|
onClick={openGenerateModal}
|
||||||
generateForm.resetFields();
|
|
||||||
setGenerateModal(true);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
生成账单
|
生成账单
|
||||||
</PermissionButton>
|
</PermissionButton>
|
||||||
@@ -507,7 +508,18 @@ const BillsPage: React.FC = () => {
|
|||||||
rowKey="id"
|
rowKey="id"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
pagination={{ pageSize: 15, showTotal: (total) => `共 ${total} 条` }}
|
pagination={{ pageSize: 15, showTotal: (total) => `共 ${total} 条` }}
|
||||||
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
locale={{
|
||||||
|
emptyText: (
|
||||||
|
<QueryEmpty
|
||||||
|
description="暂无账单"
|
||||||
|
action={
|
||||||
|
hasPermission('bill:generate')
|
||||||
|
? { label: '生成账单', onClick: openGenerateModal }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
rowSelection={{
|
rowSelection={{
|
||||||
selectedRowKeys: selectedRows,
|
selectedRowKeys: selectedRows,
|
||||||
onChange: (keys) => setSelectedRows(keys as number[]),
|
onChange: (keys) => setSelectedRows(keys as number[]),
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
Popconfirm,
|
Popconfirm,
|
||||||
Card,
|
Card,
|
||||||
Switch,
|
Switch,
|
||||||
Empty,
|
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { PlusOutlined, SearchOutlined, TeamOutlined, InboxOutlined } from '@ant-design/icons';
|
import { PlusOutlined, SearchOutlined, TeamOutlined, InboxOutlined } from '@ant-design/icons';
|
||||||
@@ -30,7 +29,7 @@ import PermissionButton from '../../components/PermissionButton';
|
|||||||
import EditableCell from '../../components/EditableCell';
|
import EditableCell from '../../components/EditableCell';
|
||||||
import { message } from '../../ui/app-message';
|
import { message } from '../../ui/app-message';
|
||||||
import { usePermission } from '../../hooks/usePermission';
|
import { usePermission } from '../../hooks/usePermission';
|
||||||
import { QueryErrorState } from '../../components/QueryState';
|
import { QueryErrorState, QueryEmpty } from '../../components/QueryState';
|
||||||
import { useVisibleRefetch } from '../../hooks/usePageVisible';
|
import { useVisibleRefetch } from '../../hooks/usePageVisible';
|
||||||
import { useDirtyGuard } from '../../hooks/useDirtyGuard';
|
import { useDirtyGuard } from '../../hooks/useDirtyGuard';
|
||||||
|
|
||||||
@@ -447,7 +446,18 @@ const ClassesPage: React.FC = () => {
|
|||||||
dataSource={filtered}
|
dataSource={filtered}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
locale={{
|
||||||
|
emptyText: (
|
||||||
|
<QueryEmpty
|
||||||
|
description="暂无班级数据"
|
||||||
|
action={
|
||||||
|
hasPermission('class:create')
|
||||||
|
? { label: '创建班级', icon: <PlusOutlined />, onClick: handleCreate }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
pagination={{
|
pagination={{
|
||||||
defaultPageSize: 20,
|
defaultPageSize: 20,
|
||||||
showSizeChanger: true,
|
showSizeChanger: true,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Empty,
|
|
||||||
Popconfirm,
|
Popconfirm,
|
||||||
Space,
|
Space,
|
||||||
Table,
|
Table,
|
||||||
@@ -19,6 +18,7 @@ import dayjs from 'dayjs';
|
|||||||
import PermissionButton from '../../components/PermissionButton';
|
import PermissionButton from '../../components/PermissionButton';
|
||||||
import EditableCell from '../../components/EditableCell';
|
import EditableCell from '../../components/EditableCell';
|
||||||
import { message } from '../../ui/app-message';
|
import { message } from '../../ui/app-message';
|
||||||
|
import { QueryEmpty } from '../../components/QueryState';
|
||||||
|
|
||||||
const RENTAL_FIELDS = {
|
const RENTAL_FIELDS = {
|
||||||
classroomId: 'classroomId',
|
classroomId: 'classroomId',
|
||||||
@@ -345,7 +345,7 @@ export const RentalTable: React.FC<RentalTableProps> = ({
|
|||||||
dataSource={data}
|
dataSource={data}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
locale={{ emptyText: <QueryEmpty description="暂无租赁订单,点击右上角「新增租赁」创建第一笔订单" /> }}
|
||||||
pagination={{
|
pagination={{
|
||||||
defaultPageSize: 15,
|
defaultPageSize: 15,
|
||||||
showSizeChanger: true,
|
showSizeChanger: true,
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Modal,
|
Modal,
|
||||||
Spin,
|
Spin,
|
||||||
Empty,
|
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { CalendarOutlined, FileTextOutlined, ReadOutlined } from '@ant-design/icons';
|
import { CalendarOutlined, FileTextOutlined, ReadOutlined } from '@ant-design/icons';
|
||||||
@@ -22,7 +21,7 @@ import api from '../../api';
|
|||||||
import { downloadBlob } from '../../utils/download';
|
import { downloadBlob } from '../../utils/download';
|
||||||
import { message } from '../../ui/app-message';
|
import { message } from '../../ui/app-message';
|
||||||
import { getErrorMessage } from '../../utils/error';
|
import { getErrorMessage } from '../../utils/error';
|
||||||
import { QueryErrorState } from '../../components/QueryState';
|
import { QueryErrorState, QueryEmpty } from '../../components/QueryState';
|
||||||
import { useVisibleRefetch } from '../../hooks/usePageVisible';
|
import { useVisibleRefetch } from '../../hooks/usePageVisible';
|
||||||
|
|
||||||
interface ScheduleData {
|
interface ScheduleData {
|
||||||
@@ -197,7 +196,7 @@ const ClassroomSchedulePage: React.FC = () => {
|
|||||||
|
|
||||||
<Spin spinning={loading}>
|
<Spin spinning={loading}>
|
||||||
{!data || data.classrooms.length === 0 ? (
|
{!data || data.classrooms.length === 0 ? (
|
||||||
<Empty description="暂无教室数据" />
|
<QueryEmpty description="暂无教室数据,可在「教室管理」中添加教室后查看排期" />
|
||||||
) : (
|
) : (
|
||||||
<div style={{ overflowX: 'auto' }}>
|
<div style={{ overflowX: 'auto' }}>
|
||||||
{groups.map((group) => (
|
{groups.map((group) => (
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
Popconfirm,
|
Popconfirm,
|
||||||
Upload,
|
Upload,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Empty,
|
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
@@ -29,7 +28,7 @@ import {
|
|||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import PermissionButton from '../../components/PermissionButton';
|
import PermissionButton from '../../components/PermissionButton';
|
||||||
import EditableCell from '../../components/EditableCell';
|
import EditableCell from '../../components/EditableCell';
|
||||||
import { QueryErrorState } from '../../components/QueryState';
|
import { QueryErrorState, QueryEmpty } from '../../components/QueryState';
|
||||||
import { message } from '../../ui/app-message';
|
import { message } from '../../ui/app-message';
|
||||||
import { usePermission } from '../../hooks/usePermission';
|
import { usePermission } from '../../hooks/usePermission';
|
||||||
import { useUserStore } from '../../store/user/userStore';
|
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(
|
const saveCell = useCallback(
|
||||||
async (record: any, field: string, value: unknown) => {
|
async (record: any, field: string, value: unknown) => {
|
||||||
try {
|
try {
|
||||||
@@ -461,12 +467,7 @@ const ClassroomsPage: React.FC = () => {
|
|||||||
permission="classroom:create"
|
permission="classroom:create"
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<PlusOutlined />}
|
icon={<PlusOutlined />}
|
||||||
onClick={() => {
|
onClick={openCreateModal}
|
||||||
setEditing(null);
|
|
||||||
form.resetFields();
|
|
||||||
formGuard.snapshot();
|
|
||||||
setModalOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
添加教室
|
添加教室
|
||||||
</PermissionButton>
|
</PermissionButton>
|
||||||
@@ -531,7 +532,18 @@ const ClassroomsPage: React.FC = () => {
|
|||||||
dataSource={filteredData}
|
dataSource={filteredData}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
locale={{
|
||||||
|
emptyText: (
|
||||||
|
<QueryEmpty
|
||||||
|
description="暂无数据"
|
||||||
|
action={
|
||||||
|
hasPermission('classroom:create')
|
||||||
|
? { label: '添加教室', onClick: openCreateModal }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
pagination={{
|
pagination={{
|
||||||
defaultPageSize: 20,
|
defaultPageSize: 20,
|
||||||
showSizeChanger: true,
|
showSizeChanger: true,
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import React from 'react';
|
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 { DeleteOutlined, InboxOutlined } from '@ant-design/icons';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import PermissionButton from '../../components/PermissionButton';
|
import PermissionButton from '../../components/PermissionButton';
|
||||||
|
import { QueryEmpty } from '../../components/QueryState';
|
||||||
import { message } from '../../ui/app-message';
|
import { message } from '../../ui/app-message';
|
||||||
import { statusMap } from './DepositModals';
|
import { statusMap } from './DepositModals';
|
||||||
import type { DepositRecord } from './DepositModals';
|
import type { DepositRecord } from './DepositModals';
|
||||||
@@ -11,6 +12,8 @@ export interface DepositTableProps {
|
|||||||
data: any[];
|
data: any[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
canPurgeDeposit: boolean;
|
canPurgeDeposit: boolean;
|
||||||
|
canCreateDeposit?: boolean;
|
||||||
|
onCreateDeposit?: () => void;
|
||||||
refundForm: ReturnType<typeof import('antd').Form.useForm>[0];
|
refundForm: ReturnType<typeof import('antd').Form.useForm>[0];
|
||||||
onDetail: (record: DepositRecord) => void;
|
onDetail: (record: DepositRecord) => void;
|
||||||
onRefund: (record: DepositRecord) => void;
|
onRefund: (record: DepositRecord) => void;
|
||||||
@@ -22,6 +25,8 @@ export const DepositTable: React.FC<DepositTableProps> = ({
|
|||||||
data,
|
data,
|
||||||
loading,
|
loading,
|
||||||
canPurgeDeposit,
|
canPurgeDeposit,
|
||||||
|
canCreateDeposit,
|
||||||
|
onCreateDeposit,
|
||||||
refundForm,
|
refundForm,
|
||||||
onDetail,
|
onDetail,
|
||||||
onRefund,
|
onRefund,
|
||||||
@@ -142,7 +147,18 @@ export const DepositTable: React.FC<DepositTableProps> = ({
|
|||||||
pageSizeOptions: [15, 30, 50, 100],
|
pageSizeOptions: [15, 30, 50, 100],
|
||||||
showTotal: (total) => `共 ${total} 条`,
|
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);
|
setBatchModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openCreateDeposit = () => {
|
||||||
|
createForm.resetFields();
|
||||||
|
createForm.setFieldsValue({ amount: 500, paidDate: dayjs() });
|
||||||
|
setCreateModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
const handleBatchRoomTypeChange = (roomType: string) => {
|
const handleBatchRoomTypeChange = (roomType: string) => {
|
||||||
setBatchRoomType(roomType);
|
setBatchRoomType(roomType);
|
||||||
// 切换房型后候选学生列表会变化,重置勾选状态,避免把上一房型的选择提交到新房型
|
// 切换房型后候选学生列表会变化,重置勾选状态,避免把上一房型的选择提交到新房型
|
||||||
@@ -431,11 +437,7 @@ const DepositsPage: React.FC = () => {
|
|||||||
permission="deposit:create"
|
permission="deposit:create"
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<PlusOutlined />}
|
icon={<PlusOutlined />}
|
||||||
onClick={() => {
|
onClick={openCreateDeposit}
|
||||||
createForm.resetFields();
|
|
||||||
createForm.setFieldsValue({ amount: 500, paidDate: dayjs() });
|
|
||||||
setCreateModal(true);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
收取押金
|
收取押金
|
||||||
</PermissionButton>
|
</PermissionButton>
|
||||||
@@ -458,6 +460,8 @@ const DepositsPage: React.FC = () => {
|
|||||||
data={filteredData}
|
data={filteredData}
|
||||||
loading={loading || (!!filterRoomType && eligibleLoading)}
|
loading={loading || (!!filterRoomType && eligibleLoading)}
|
||||||
canPurgeDeposit={canPurgeDeposit}
|
canPurgeDeposit={canPurgeDeposit}
|
||||||
|
canCreateDeposit={hasPermission('deposit:create')}
|
||||||
|
onCreateDeposit={openCreateDeposit}
|
||||||
refundForm={refundForm}
|
refundForm={refundForm}
|
||||||
onDetail={(record) => setDetailModal(record)}
|
onDetail={(record) => setDetailModal(record)}
|
||||||
onRefund={(record) => setRefundModal(record)}
|
onRefund={(record) => setRefundModal(record)}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
Card,
|
Card,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
Col,
|
Col,
|
||||||
Empty,
|
|
||||||
Form,
|
Form,
|
||||||
Input,
|
Input,
|
||||||
Popconfirm,
|
Popconfirm,
|
||||||
@@ -39,7 +38,7 @@ import { useApiMutation } from '../../hooks/useApiMutation';
|
|||||||
import { validateResponse } from '../../utils/validate';
|
import { validateResponse } from '../../utils/validate';
|
||||||
import { classOptionsSchema, examsSchema } from '../../api/schemas';
|
import { classOptionsSchema, examsSchema } from '../../api/schemas';
|
||||||
import { getErrorMessage } from '../../utils/error';
|
import { getErrorMessage } from '../../utils/error';
|
||||||
import { QueryErrorState } from '../../components/QueryState';
|
import { QueryErrorState, QueryEmpty } from '../../components/QueryState';
|
||||||
import { NextStepHint } from '../../components/NextStepHint';
|
import { NextStepHint } from '../../components/NextStepHint';
|
||||||
import { useVisibleRefetch } from '../../hooks/usePageVisible';
|
import { useVisibleRefetch } from '../../hooks/usePageVisible';
|
||||||
import { useDirtyGuard } from '../../hooks/useDirtyGuard';
|
import { useDirtyGuard } from '../../hooks/useDirtyGuard';
|
||||||
@@ -371,7 +370,14 @@ const ExamsPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
) : data.length === 0 && !loading ? (
|
) : data.length === 0 && !loading ? (
|
||||||
<div className="exam-empty">
|
<div className="exam-empty">
|
||||||
<Empty description="暂无考试" />
|
<QueryEmpty
|
||||||
|
description="暂无考试"
|
||||||
|
action={
|
||||||
|
!showArchived
|
||||||
|
? { label: '创建考试', icon: <PlusOutlined />, onClick: openCreate }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Row gutter={[16, 16]}>
|
<Row gutter={[16, 16]}>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Empty,
|
|
||||||
Input,
|
Input,
|
||||||
Popconfirm,
|
Popconfirm,
|
||||||
Select,
|
Select,
|
||||||
@@ -24,6 +23,7 @@ import {
|
|||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import PermissionButton from '../../components/PermissionButton';
|
import PermissionButton from '../../components/PermissionButton';
|
||||||
import EditableCell from '../../components/EditableCell';
|
import EditableCell from '../../components/EditableCell';
|
||||||
|
import { QueryEmpty } from '../../components/QueryState';
|
||||||
import { message } from '../../ui/app-message';
|
import { message } from '../../ui/app-message';
|
||||||
|
|
||||||
export const EXPENSE_FIELDS = {
|
export const EXPENSE_FIELDS = {
|
||||||
@@ -519,7 +519,18 @@ export const ExpenseTablePanel: React.FC<ExpenseTablePanelProps> = ({
|
|||||||
pageSizeOptions: [15, 30, 50, 100],
|
pageSizeOptions: [15, 30, 50, 100],
|
||||||
showTotal: (total) => `共 ${total} 条`,
|
showTotal: (total) => `共 ${total} 条`,
|
||||||
}}
|
}}
|
||||||
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
locale={{
|
||||||
|
emptyText: (
|
||||||
|
<QueryEmpty
|
||||||
|
description="暂无数据"
|
||||||
|
action={
|
||||||
|
canImport && onAddUtility && !showArchived
|
||||||
|
? { label: '添加学生水电费', onClick: onAddUtility }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
rowSelection={{
|
rowSelection={{
|
||||||
selectedRowKeys: selectedKeys,
|
selectedRowKeys: selectedKeys,
|
||||||
onChange: (keys) => onSelect(keys as number[]),
|
onChange: (keys) => onSelect(keys as number[]),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import { validateResponse } from '../../utils/validate';
|
import { validateResponse } from '../../utils/validate';
|
||||||
import { notificationsSchema } from '../../api/schemas';
|
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 {
|
import {
|
||||||
BellOutlined,
|
BellOutlined,
|
||||||
DollarOutlined,
|
DollarOutlined,
|
||||||
@@ -13,7 +13,7 @@ import { useNavigate } from 'react-router';
|
|||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import { message } from '../../ui/app-message';
|
import { message } from '../../ui/app-message';
|
||||||
import { formatNotificationText } from '../../utils/notification-display';
|
import { formatNotificationText } from '../../utils/notification-display';
|
||||||
import { QueryErrorState } from '../../components/QueryState';
|
import { QueryErrorState, QueryEmpty } from '../../components/QueryState';
|
||||||
|
|
||||||
const { Sider, Content } = Layout;
|
const { Sider, Content } = Layout;
|
||||||
const { useBreakpoint } = Grid;
|
const { useBreakpoint } = Grid;
|
||||||
@@ -174,7 +174,7 @@ const NotificationsPage: React.FC = () => {
|
|||||||
) : (
|
) : (
|
||||||
<Spin spinning={loading}>
|
<Spin spinning={loading}>
|
||||||
{filtered.length === 0 ? (
|
{filtered.length === 0 ? (
|
||||||
<Empty description="暂无通知" />
|
<QueryEmpty description="暂无通知,有新消息时会在这里提醒你" />
|
||||||
) : (
|
) : (
|
||||||
<List
|
<List
|
||||||
dataSource={filtered}
|
dataSource={filtered}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import React from 'react';
|
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 { InboxOutlined, LogoutOutlined, UndoOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||||
import PermissionButton from '../../components/PermissionButton';
|
import PermissionButton from '../../components/PermissionButton';
|
||||||
|
import { QueryEmpty } from '../../components/QueryState';
|
||||||
|
|
||||||
export const OccupanciesTableArea: React.FC<{
|
export const OccupanciesTableArea: React.FC<{
|
||||||
columns: any[];
|
columns: any[];
|
||||||
@@ -12,6 +13,8 @@ export const OccupanciesTableArea: React.FC<{
|
|||||||
batchAction: 'checkout' | 'archive' | 'restore';
|
batchAction: 'checkout' | 'archive' | 'restore';
|
||||||
canDelete: boolean;
|
canDelete: boolean;
|
||||||
canPurge: boolean;
|
canPurge: boolean;
|
||||||
|
canCheckIn?: boolean;
|
||||||
|
onCheckIn?: () => void;
|
||||||
batchLoading: boolean;
|
batchLoading: boolean;
|
||||||
onBatchCheckOut: () => void;
|
onBatchCheckOut: () => void;
|
||||||
onBatchDelete: () => void;
|
onBatchDelete: () => void;
|
||||||
@@ -27,6 +30,8 @@ export const OccupanciesTableArea: React.FC<{
|
|||||||
batchAction,
|
batchAction,
|
||||||
canDelete,
|
canDelete,
|
||||||
canPurge,
|
canPurge,
|
||||||
|
canCheckIn,
|
||||||
|
onCheckIn,
|
||||||
batchLoading,
|
batchLoading,
|
||||||
onBatchCheckOut,
|
onBatchCheckOut,
|
||||||
onBatchDelete,
|
onBatchDelete,
|
||||||
@@ -127,7 +132,18 @@ export const OccupanciesTableArea: React.FC<{
|
|||||||
dataSource={data}
|
dataSource={data}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
locale={{
|
||||||
|
emptyText: (
|
||||||
|
<QueryEmpty
|
||||||
|
description="暂无数据"
|
||||||
|
action={
|
||||||
|
canCheckIn && onCheckIn
|
||||||
|
? { label: '入住登记', onClick: onCheckIn }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
scroll={{ x: 1300 }}
|
scroll={{ x: 1300 }}
|
||||||
pagination={{
|
pagination={{
|
||||||
defaultPageSize: 15,
|
defaultPageSize: 15,
|
||||||
|
|||||||
@@ -456,6 +456,22 @@ const OccupanciesPage: React.FC = () => {
|
|||||||
const { downloading: templateDownloading, run: runTemplateDownload } = useDownload();
|
const { downloading: templateDownloading, run: runTemplateDownload } = useDownload();
|
||||||
const { downloading: exportDownloading, run: runExportDownload } = 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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Alert
|
<Alert
|
||||||
@@ -473,21 +489,7 @@ const OccupanciesPage: React.FC = () => {
|
|||||||
dateRange={dateRange}
|
dateRange={dateRange}
|
||||||
onChangeDateRange={changeDateRange}
|
onChangeDateRange={changeDateRange}
|
||||||
canCheckIn={canCheckIn}
|
canCheckIn={canCheckIn}
|
||||||
onCheckIn={() => {
|
onCheckIn={openCheckInModal}
|
||||||
checkInForm.resetFields();
|
|
||||||
setAvailableBeds([]);
|
|
||||||
setAvailableLockers([]);
|
|
||||||
setAvailableResourcesLoading(false);
|
|
||||||
const today = dayjs();
|
|
||||||
checkInForm.setFieldsValue({
|
|
||||||
checkInDate: today,
|
|
||||||
billingStartDate: today,
|
|
||||||
stayType: 'short',
|
|
||||||
collectDeposit: true,
|
|
||||||
depositAmount: 500,
|
|
||||||
});
|
|
||||||
setCheckInModal(true);
|
|
||||||
}}
|
|
||||||
onImport={async ({ file, onSuccess, onError }: any) => {
|
onImport={async ({ file, onSuccess, onError }: any) => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
@@ -557,6 +559,8 @@ const OccupanciesPage: React.FC = () => {
|
|||||||
batchAction={viewPolicy.batchAction}
|
batchAction={viewPolicy.batchAction}
|
||||||
canDelete={canDelete}
|
canDelete={canDelete}
|
||||||
canPurge={canPurge}
|
canPurge={canPurge}
|
||||||
|
canCheckIn={canCheckIn}
|
||||||
|
onCheckIn={openCheckInModal}
|
||||||
batchLoading={batchLoading}
|
batchLoading={batchLoading}
|
||||||
onBatchCheckOut={() => {
|
onBatchCheckOut={() => {
|
||||||
batchCheckOutForm.resetFields();
|
batchCheckOutForm.resetFields();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// aislop-ignore-file: duplicate-block -- 表格/表单声明结构相似且参数不同,渲染逻辑已共享组件化
|
// aislop-ignore-file: duplicate-block -- 表格/表单声明结构相似且参数不同,渲染逻辑已共享组件化
|
||||||
import React, { useMemo, useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
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 { BankOutlined, InboxOutlined, PlusOutlined, UndoOutlined } from '@ant-design/icons';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import PermissionButton from '../../components/PermissionButton';
|
import PermissionButton from '../../components/PermissionButton';
|
||||||
@@ -12,6 +12,7 @@ import { useApiMutation } from '../../hooks/useApiMutation';
|
|||||||
import { validateResponse } from '../../utils/validate';
|
import { validateResponse } from '../../utils/validate';
|
||||||
import { organizationsSchema } from '../../api/schemas';
|
import { organizationsSchema } from '../../api/schemas';
|
||||||
import { useDirtyGuard } from '../../hooks/useDirtyGuard';
|
import { useDirtyGuard } from '../../hooks/useDirtyGuard';
|
||||||
|
import { QueryEmpty } from '../../components/QueryState';
|
||||||
|
|
||||||
const PRESET_COLORS = [
|
const PRESET_COLORS = [
|
||||||
'#ff7875',
|
'#ff7875',
|
||||||
@@ -398,7 +399,18 @@ const OrganizationsPage: React.FC = () => {
|
|||||||
dataSource={filteredData}
|
dataSource={filteredData}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
locale={{ emptyText: <Empty description="暂无机构" /> }}
|
locale={{
|
||||||
|
emptyText: (
|
||||||
|
<QueryEmpty
|
||||||
|
description="暂无机构"
|
||||||
|
action={
|
||||||
|
hasPermission('organization:create')
|
||||||
|
? { label: '添加机构', icon: <PlusOutlined />, onClick: () => openEditor() }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
scroll={{ x: 1100 }}
|
scroll={{ x: 1100 }}
|
||||||
pagination={{
|
pagination={{
|
||||||
defaultPageSize: 20,
|
defaultPageSize: 20,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useMemo, useCallback } from 'react';
|
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 { PlusOutlined, EditOutlined, StopOutlined } from '@ant-design/icons';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import PermissionButton from '../../components/PermissionButton';
|
import PermissionButton from '../../components/PermissionButton';
|
||||||
@@ -11,6 +11,8 @@ import { validateResponse } from '../../utils/validate';
|
|||||||
import { permissionTreeSchema, rolesSchema } from '../../api/schemas';
|
import { permissionTreeSchema, rolesSchema } from '../../api/schemas';
|
||||||
import { getErrorMessage } from '../../utils/error';
|
import { getErrorMessage } from '../../utils/error';
|
||||||
import { useDirtyGuard } from '../../hooks/useDirtyGuard';
|
import { useDirtyGuard } from '../../hooks/useDirtyGuard';
|
||||||
|
import { usePermission } from '../../hooks/usePermission';
|
||||||
|
import { QueryEmpty } from '../../components/QueryState';
|
||||||
|
|
||||||
interface PermissionItem {
|
interface PermissionItem {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -29,6 +31,7 @@ interface RoleItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const RolesPage: React.FC = () => {
|
const RolesPage: React.FC = () => {
|
||||||
|
const { hasPermission } = usePermission();
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState<RoleItem | null>(null);
|
const [editing, setEditing] = useState<RoleItem | null>(null);
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
@@ -327,7 +330,18 @@ const RolesPage: React.FC = () => {
|
|||||||
dataSource={data}
|
dataSource={data}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
locale={{
|
||||||
|
emptyText: (
|
||||||
|
<QueryEmpty
|
||||||
|
description="暂无角色数据"
|
||||||
|
action={
|
||||||
|
hasPermission('role:create')
|
||||||
|
? { label: '添加角色', icon: <PlusOutlined />, onClick: handleAdd }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
scroll={{ x: 900 }}
|
scroll={{ x: 900 }}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Empty, Table } from 'antd';
|
import { Table } from 'antd';
|
||||||
|
import { QueryEmpty } from '../../components/QueryState';
|
||||||
|
|
||||||
export const RoomsTable: React.FC<{
|
export const RoomsTable: React.FC<{
|
||||||
columns: any[];
|
columns: any[];
|
||||||
@@ -7,7 +8,9 @@ export const RoomsTable: React.FC<{
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
selectedRowKeys: number[];
|
selectedRowKeys: number[];
|
||||||
onSelect: (keys: number[]) => void;
|
onSelect: (keys: number[]) => void;
|
||||||
}> = ({ columns, data, loading, selectedRowKeys, onSelect }) => {
|
canCreateRoom?: boolean;
|
||||||
|
onCreateRoom?: () => void;
|
||||||
|
}> = ({ columns, data, loading, selectedRowKeys, onSelect, canCreateRoom, onCreateRoom }) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Table
|
<Table
|
||||||
@@ -16,7 +19,18 @@ export const RoomsTable: React.FC<{
|
|||||||
rowKey="id"
|
rowKey="id"
|
||||||
scroll={{ x: 1200 }}
|
scroll={{ x: 1200 }}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
locale={{
|
||||||
|
emptyText: (
|
||||||
|
<QueryEmpty
|
||||||
|
description="暂无数据"
|
||||||
|
action={
|
||||||
|
canCreateRoom && onCreateRoom
|
||||||
|
? { label: '添加宿舍', onClick: onCreateRoom }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
pagination={{
|
pagination={{
|
||||||
defaultPageSize: 20,
|
defaultPageSize: 20,
|
||||||
showSizeChanger: true,
|
showSizeChanger: true,
|
||||||
|
|||||||
@@ -182,6 +182,13 @@ const RoomsPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openAddRoomModal = () => {
|
||||||
|
setEditing(null);
|
||||||
|
form.resetFields();
|
||||||
|
roomGuard.snapshot();
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
const saveRoomCell = useCallback(
|
const saveRoomCell = useCallback(
|
||||||
async (record: any, field: string, value: unknown) => {
|
async (record: any, field: string, value: unknown) => {
|
||||||
try {
|
try {
|
||||||
@@ -460,12 +467,7 @@ const RoomsPage: React.FC = () => {
|
|||||||
onBatchRestore={handleBatchRestore}
|
onBatchRestore={handleBatchRestore}
|
||||||
onBatchPurge={handleBatchPurge}
|
onBatchPurge={handleBatchPurge}
|
||||||
onBatchDelete={handleBatchDelete}
|
onBatchDelete={handleBatchDelete}
|
||||||
onAddRoom={() => {
|
onAddRoom={openAddRoomModal}
|
||||||
setEditing(null);
|
|
||||||
form.resetFields();
|
|
||||||
roomGuard.snapshot();
|
|
||||||
setModalOpen(true);
|
|
||||||
}}
|
|
||||||
onImport={async (options: UploadRequestOption<{ message?: string }>) => {
|
onImport={async (options: UploadRequestOption<{ message?: string }>) => {
|
||||||
const { file, onSuccess, onError } = options;
|
const { file, onSuccess, onError } = options;
|
||||||
if (typeof file === 'string') {
|
if (typeof file === 'string') {
|
||||||
@@ -501,6 +503,8 @@ const RoomsPage: React.FC = () => {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
selectedRowKeys={selectedRowKeys}
|
selectedRowKeys={selectedRowKeys}
|
||||||
onSelect={setSelectedRowKeys}
|
onSelect={setSelectedRowKeys}
|
||||||
|
canCreateRoom={canCreateRooms}
|
||||||
|
onCreateRoom={openAddRoomModal}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// aislop-ignore-file: duplicate-block -- 周/月视图表格结构相似且展示维度不同,已共享 ScheduleGrid 组件
|
// aislop-ignore-file: duplicate-block -- 周/月视图表格结构相似且展示维度不同,已共享 ScheduleGrid 组件
|
||||||
import React from 'react';
|
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 type { Dayjs } from 'dayjs';
|
||||||
import { isMaskedSchedule } from './schedule-visibility';
|
import { isMaskedSchedule } from './schedule-visibility';
|
||||||
|
|
||||||
@@ -76,7 +77,7 @@ export const ScheduleGrid: React.FC<{
|
|||||||
return (
|
return (
|
||||||
<Spin spinning={loading}>
|
<Spin spinning={loading}>
|
||||||
{classrooms.length === 0 ? (
|
{classrooms.length === 0 ? (
|
||||||
<Empty description="暂无教室数据" />
|
<QueryEmpty description="暂无教室数据,可在「教室管理」中添加教室后开始排课" />
|
||||||
) : viewMode === 'week' ? (
|
) : viewMode === 'week' ? (
|
||||||
<div style={{ overflowX: 'auto' }}>
|
<div style={{ overflowX: 'auto' }}>
|
||||||
<table
|
<table
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { message } from '../../ui/app-message';
|
|||||||
import EditableCell from '../../components/EditableCell';
|
import EditableCell from '../../components/EditableCell';
|
||||||
import PermissionButton from '../../components/PermissionButton';
|
import PermissionButton from '../../components/PermissionButton';
|
||||||
import { usePermission } from '../../hooks/usePermission';
|
import { usePermission } from '../../hooks/usePermission';
|
||||||
import { QueryErrorState } from '../../components/QueryState';
|
import { QueryErrorState, QueryEmpty } from '../../components/QueryState';
|
||||||
import { useVisibleRefetch } from '../../hooks/usePageVisible';
|
import { useVisibleRefetch } from '../../hooks/usePageVisible';
|
||||||
import { useDirtyGuard } from '../../hooks/useDirtyGuard';
|
import { useDirtyGuard } from '../../hooks/useDirtyGuard';
|
||||||
|
|
||||||
@@ -265,6 +265,7 @@ const TeachersPage: React.FC = () => {
|
|||||||
dataSource={data}
|
dataSource={data}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
locale={{ emptyText: <QueryEmpty description="暂无教师数据" /> }}
|
||||||
scroll={{ x: 1300 }}
|
scroll={{ x: 1300 }}
|
||||||
pagination={{
|
pagination={{
|
||||||
current: page,
|
current: page,
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import api from '../../api';
|
|||||||
import PermissionButton from '../../components/PermissionButton';
|
import PermissionButton from '../../components/PermissionButton';
|
||||||
import { message } from '../../ui/app-message';
|
import { message } from '../../ui/app-message';
|
||||||
import { newOperationId } from '../../utils/operation-id';
|
import { newOperationId } from '../../utils/operation-id';
|
||||||
import { QueryErrorState } from '../../components/QueryState';
|
import { QueryErrorState, QueryEmpty } from '../../components/QueryState';
|
||||||
import { useVisibleRefetch } from '../../hooks/usePageVisible';
|
import { useVisibleRefetch } from '../../hooks/usePageVisible';
|
||||||
|
|
||||||
interface WalletRow {
|
interface WalletRow {
|
||||||
@@ -328,6 +328,7 @@ const WalletsPage: React.FC = () => {
|
|||||||
<Table
|
<Table
|
||||||
rowKey="studentId"
|
rowKey="studentId"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
locale={{ emptyText: <QueryEmpty description="暂无学生余额数据" /> }}
|
||||||
dataSource={rows}
|
dataSource={rows}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
rowSelection={{ selectedRowKeys, onChange: setSelectedRowKeys }}
|
rowSelection={{ selectedRowKeys, onChange: setSelectedRowKeys }}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
AttendanceReportQueryDto,
|
AttendanceReportQueryDto,
|
||||||
AttendanceAlertsQueryDto,
|
AttendanceAlertsQueryDto,
|
||||||
UpdateAttendanceRecordDto,
|
UpdateAttendanceRecordDto,
|
||||||
|
BatchUpdateAttendanceStatusDto,
|
||||||
GenerateFromSchedulesDto,
|
GenerateFromSchedulesDto,
|
||||||
RefreshDingTalkAttendanceDto,
|
RefreshDingTalkAttendanceDto,
|
||||||
} from './dto/attendance.dto';
|
} from './dto/attendance.dto';
|
||||||
@@ -232,6 +233,36 @@ export class AttendanceRecordsController extends AttendanceControllerBase {
|
|||||||
return this.service.findAll(query, await this.getAccessibleClassIds(req));
|
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 ──
|
// ── Update a single attendance record ──
|
||||||
@Put('attendance-records/:id')
|
@Put('attendance-records/:id')
|
||||||
@RequirePermission('attendance:edit', 'attendance:self-edit')
|
@RequirePermission('attendance:edit', 'attendance:self-edit')
|
||||||
|
|||||||
@@ -9,8 +9,11 @@ import {
|
|||||||
ValidateNested,
|
ValidateNested,
|
||||||
IsNotEmpty,
|
IsNotEmpty,
|
||||||
ArrayNotEmpty,
|
ArrayNotEmpty,
|
||||||
|
ArrayMinSize,
|
||||||
|
ArrayMaxSize,
|
||||||
Matches,
|
Matches,
|
||||||
Max,
|
Max,
|
||||||
|
MaxLength,
|
||||||
Min,
|
Min,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
@@ -248,6 +251,25 @@ export class UpdateAttendanceRecordDto {
|
|||||||
remark?: string;
|
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 {
|
export class AttendanceAlertsQueryDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
Reference in New Issue
Block a user