feat(admin): 弹窗未保存内容保护与空状态引导扩展
- 新增 useDirtyGuard hook:关闭弹窗时若表单被修改,先确认再关闭 - 接入 17 个弹窗:账号/密码/档案、角色、教室、宿舍/床位/柜子、 考勤机、机构、班级、教师、考试、排课、入住/退宿/换房、费用 (子组件弹窗用 useEffect 在回填后快照,时序正确) - 学生列表空状态带「添加学生」引导动作 - 排课创建成功提示「同步钉钉」,考试创建成功提示「去详情录成绩」 aislop scan: 5 引擎 0 issues
This commit is contained in:
51
apps/admin/src/hooks/useDirtyGuard.ts
Normal file
51
apps/admin/src/hooks/useDirtyGuard.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { App } from 'antd';
|
||||
import type { FormInstance } from 'antd';
|
||||
|
||||
/**
|
||||
* 弹窗「未保存内容」保护:关闭弹窗时若表单值已被修改,先确认再关闭,
|
||||
* 避免用户误关丢失已填内容。
|
||||
*
|
||||
* 用法:
|
||||
* const { confirmClose, snapshot } = useDirtyGuard(form);
|
||||
* // 打开弹窗(或编辑回填后)时调用一次 snapshot() 记录初始值
|
||||
* const openEdit = () => { form.setFieldsValue(record); snapshot(); setOpen(true); };
|
||||
* // Modal 的 onCancel 改用确认关闭
|
||||
* <Modal onCancel={() => confirmClose(() => setOpen(false))} ...>
|
||||
*/
|
||||
export function useDirtyGuard(form: FormInstance) {
|
||||
const { modal } = App.useApp();
|
||||
const pristineRef = useRef<string>('');
|
||||
|
||||
/** 记录当前表单值为「未修改」基准;打开弹窗/回填后调用 */
|
||||
const snapshot = useCallback(() => {
|
||||
pristineRef.current = JSON.stringify(form.getFieldsValue());
|
||||
}, [form]);
|
||||
|
||||
/** 表单是否有未保存修改(与 snapshot 时对比) */
|
||||
const isDirty = useCallback(() => {
|
||||
return JSON.stringify(form.getFieldsValue()) !== pristineRef.current;
|
||||
}, [form]);
|
||||
|
||||
const confirmClose = useCallback(
|
||||
(close: () => void) => {
|
||||
if (!isDirty()) {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
modal.confirm({
|
||||
title: '放弃未保存的修改?',
|
||||
content: '当前表单有未保存的内容,关闭后修改将丢失。',
|
||||
okText: '放弃修改',
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: '继续编辑',
|
||||
onOk: close,
|
||||
});
|
||||
},
|
||||
[isDirty, modal],
|
||||
);
|
||||
|
||||
return { confirmClose, snapshot, isDirty };
|
||||
}
|
||||
|
||||
export default useDirtyGuard;
|
||||
@@ -11,6 +11,7 @@ import PermissionButton from '../components/PermissionButton';
|
||||
import EditableCell from '../components/EditableCell';
|
||||
import { QueryErrorState } from '../components/QueryState';
|
||||
import { message } from '../ui/app-message';
|
||||
import { useDirtyGuard } from '../hooks/useDirtyGuard';
|
||||
|
||||
interface ClassroomOption {
|
||||
id: number;
|
||||
@@ -40,6 +41,7 @@ const AttendanceDevicesPage: React.FC = () => {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [form] = Form.useForm();
|
||||
const formGuard = useDirtyGuard(form);
|
||||
|
||||
const {
|
||||
data: fetchResult = { devices: [], classrooms: [] },
|
||||
@@ -107,6 +109,7 @@ const AttendanceDevicesPage: React.FC = () => {
|
||||
setEditing(null);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ status: 'active' });
|
||||
formGuard.snapshot();
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
@@ -120,6 +123,7 @@ const AttendanceDevicesPage: React.FC = () => {
|
||||
location: record.location,
|
||||
notes: record.notes,
|
||||
});
|
||||
formGuard.snapshot();
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
@@ -325,10 +329,12 @@ const AttendanceDevicesPage: React.FC = () => {
|
||||
title={editing ? '编辑考勤机绑定' : '添加考勤机绑定'}
|
||||
open={modalOpen}
|
||||
onOk={handleSave}
|
||||
onCancel={() => {
|
||||
setModalOpen(false);
|
||||
setEditing(null);
|
||||
}}
|
||||
onCancel={() =>
|
||||
formGuard.confirmClose(() => {
|
||||
setModalOpen(false);
|
||||
setEditing(null);
|
||||
})
|
||||
}
|
||||
confirmLoading={saving}
|
||||
okText="保存"
|
||||
>
|
||||
|
||||
@@ -32,6 +32,7 @@ import { message } from '../../ui/app-message';
|
||||
import { usePermission } from '../../hooks/usePermission';
|
||||
import { QueryErrorState } from '../../components/QueryState';
|
||||
import { useVisibleRefetch } from '../../hooks/usePageVisible';
|
||||
import { useDirtyGuard } from '../../hooks/useDirtyGuard';
|
||||
|
||||
interface ClassItem {
|
||||
id: number;
|
||||
@@ -88,6 +89,7 @@ const ClassesPage: React.FC = () => {
|
||||
const [filterStatus, setFilterStatus] = useState<string>();
|
||||
const [filterType, setFilterType] = useState<string>();
|
||||
const [form] = Form.useForm<ClassFormValues>();
|
||||
const classFormGuard = useDirtyGuard(form);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
|
||||
@@ -178,6 +180,7 @@ const ClassesPage: React.FC = () => {
|
||||
const handleCreate = () => {
|
||||
setEditing(null);
|
||||
form.resetFields();
|
||||
classFormGuard.snapshot();
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
@@ -190,9 +193,10 @@ const ClassesPage: React.FC = () => {
|
||||
startDate: record.startDate ? dayjs(record.startDate) : undefined,
|
||||
endDate: record.endDate ? dayjs(record.endDate) : undefined,
|
||||
});
|
||||
classFormGuard.snapshot();
|
||||
setModalOpen(true);
|
||||
},
|
||||
[form],
|
||||
[form, classFormGuard],
|
||||
);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
@@ -457,7 +461,7 @@ const ClassesPage: React.FC = () => {
|
||||
title={editing ? '编辑班级' : '创建班级'}
|
||||
open={modalOpen}
|
||||
onOk={handleSubmit}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
onCancel={() => classFormGuard.confirmClose(() => setModalOpen(false))}
|
||||
confirmLoading={saving}
|
||||
width={600}
|
||||
>
|
||||
|
||||
@@ -34,6 +34,7 @@ import { message } from '../../ui/app-message';
|
||||
import { usePermission } from '../../hooks/usePermission';
|
||||
import { useUserStore } from '../../store/user/userStore';
|
||||
import { useVisibleRefetch } from '../../hooks/usePageVisible';
|
||||
import { useDirtyGuard } from '../../hooks/useDirtyGuard';
|
||||
|
||||
const statusMap: Record<string, { text: string; color: string }> = {
|
||||
available: { text: '可用', color: 'green' },
|
||||
@@ -63,6 +64,7 @@ const ClassroomsPage: React.FC = () => {
|
||||
const [editing, setEditing] = useState<any>(null);
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const formGuard = useDirtyGuard(form);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
|
||||
|
||||
@@ -383,6 +385,7 @@ const ClassroomsPage: React.FC = () => {
|
||||
onClick={() => {
|
||||
setEditing(record);
|
||||
form.setFieldsValue(record);
|
||||
formGuard.snapshot();
|
||||
setModalOpen(true);
|
||||
}}
|
||||
>
|
||||
@@ -408,7 +411,7 @@ const ClassroomsPage: React.FC = () => {
|
||||
),
|
||||
},
|
||||
],
|
||||
[handlePurge, hasPermission, saveCell, handleArchive, handleRestore, form],
|
||||
[handlePurge, hasPermission, saveCell, handleArchive, handleRestore, form, formGuard],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -461,6 +464,7 @@ const ClassroomsPage: React.FC = () => {
|
||||
onClick={() => {
|
||||
setEditing(null);
|
||||
form.resetFields();
|
||||
formGuard.snapshot();
|
||||
setModalOpen(true);
|
||||
}}
|
||||
>
|
||||
@@ -540,10 +544,12 @@ const ClassroomsPage: React.FC = () => {
|
||||
title={editing ? '编辑教室' : '添加教室'}
|
||||
open={modalOpen}
|
||||
onOk={handleSave}
|
||||
onCancel={() => {
|
||||
setModalOpen(false);
|
||||
setEditing(null);
|
||||
}}
|
||||
onCancel={() =>
|
||||
formGuard.confirmClose(() => {
|
||||
setModalOpen(false);
|
||||
setEditing(null);
|
||||
})
|
||||
}
|
||||
confirmLoading={saving}
|
||||
okText="保存"
|
||||
>
|
||||
|
||||
@@ -40,7 +40,9 @@ import { validateResponse } from '../../utils/validate';
|
||||
import { classOptionsSchema, examsSchema } from '../../api/schemas';
|
||||
import { getErrorMessage } from '../../utils/error';
|
||||
import { QueryErrorState } from '../../components/QueryState';
|
||||
import { NextStepHint } from '../../components/NextStepHint';
|
||||
import { useVisibleRefetch } from '../../hooks/usePageVisible';
|
||||
import { useDirtyGuard } from '../../hooks/useDirtyGuard';
|
||||
|
||||
const ExamsPage: React.FC = () => {
|
||||
const { modal } = App.useApp();
|
||||
@@ -48,6 +50,7 @@ const ExamsPage: React.FC = () => {
|
||||
const { hasPermission } = usePermission();
|
||||
const canPurgeExam = hasPermission('exam:purge');
|
||||
const [form] = Form.useForm<ExamFormValues>();
|
||||
const examFormGuard = useDirtyGuard(form);
|
||||
const [batchLoading, setBatchLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
@@ -56,6 +59,8 @@ const ExamsPage: React.FC = () => {
|
||||
const [classId, setClassId] = useState<number>();
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const [selectedExamIds, setSelectedExamIds] = useState<number[]>([]);
|
||||
// 考试创建成功后的「下一步:去详情录成绩」引导
|
||||
const [examCreatedId, setExamCreatedId] = useState<number | null>(null);
|
||||
const [debouncedFilters] = useDebounceValue(
|
||||
{ keyword, examType, classId, showArchived },
|
||||
200,
|
||||
@@ -152,6 +157,7 @@ const ExamsPage: React.FC = () => {
|
||||
const openCreate = () => {
|
||||
form.resetFields();
|
||||
form.setFieldValue('examDate', dayjs());
|
||||
examFormGuard.snapshot();
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
@@ -160,9 +166,10 @@ const ExamsPage: React.FC = () => {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
const payload = { ...values, examDate: values.examDate.format('YYYY-MM-DD') };
|
||||
await saveMutation.mutateAsync(payload);
|
||||
const created = (await saveMutation.mutateAsync(payload)) as { id?: number };
|
||||
message.success('考试已创建');
|
||||
setModalOpen(false);
|
||||
if (created?.id != null) setExamCreatedId(created.id);
|
||||
} catch {
|
||||
// 校验错误静默,接口错误由 useApiMutation 统一提示
|
||||
} finally {
|
||||
@@ -255,6 +262,20 @@ const ExamsPage: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="exam-page">
|
||||
{examCreatedId !== null && (
|
||||
<NextStepHint
|
||||
title="考试已创建"
|
||||
description="接下来可以在考试详情中添加学生名单、录入成绩。"
|
||||
action={{
|
||||
label: '去考试详情',
|
||||
onClick: () => {
|
||||
navigate(`/exams/${examCreatedId}`);
|
||||
setExamCreatedId(null);
|
||||
},
|
||||
}}
|
||||
onClose={() => setExamCreatedId(null)}
|
||||
/>
|
||||
)}
|
||||
<div className="exam-toolbar">
|
||||
<Space wrap>
|
||||
<Input
|
||||
@@ -461,7 +482,7 @@ const ExamsPage: React.FC = () => {
|
||||
saving={saving}
|
||||
form={form}
|
||||
classes={classes}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
onCancel={() => examFormGuard.confirmClose(() => setModalOpen(false))}
|
||||
onSubmit={() => void submit()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// aislop-ignore-file: duplicate-block -- 表格/表单声明结构相似且参数不同,渲染逻辑已共享组件化
|
||||
import React from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import {
|
||||
DatePicker,
|
||||
Form,
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Modal,
|
||||
Select,
|
||||
} from 'antd';
|
||||
import { useDirtyGuard } from '../../hooks/useDirtyGuard';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
@@ -21,12 +22,18 @@ export const RoomExpenseModal: React.FC<{
|
||||
onOk: () => void;
|
||||
onCancel: () => void;
|
||||
}> = ({ open, editing, saving, form, rooms, typeOptions, onOk, onCancel }) => {
|
||||
const roomExpenseGuard = useDirtyGuard(form);
|
||||
// 父组件在打开弹窗前已完成表单回填,这里记录「未修改」基准
|
||||
useEffect(() => {
|
||||
if (open) roomExpenseGuard.snapshot();
|
||||
}, [open, roomExpenseGuard]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={editing ? '编辑宿舍费用' : '录入宿舍费用'}
|
||||
open={open}
|
||||
onOk={onOk}
|
||||
onCancel={onCancel}
|
||||
onCancel={() => roomExpenseGuard.confirmClose(onCancel)}
|
||||
okText={editing ? '保存' : '确认录入'}
|
||||
confirmLoading={saving}
|
||||
>
|
||||
@@ -70,12 +77,18 @@ export const UtilityModal: React.FC<{
|
||||
onOk: () => void;
|
||||
onCancel: () => void;
|
||||
}> = ({ open, saving, form, students, onOk, onCancel }) => {
|
||||
const utilityGuard = useDirtyGuard(form);
|
||||
// 打开弹窗时记录当前表单值为「未修改」基准
|
||||
useEffect(() => {
|
||||
if (open) utilityGuard.snapshot();
|
||||
}, [open, utilityGuard]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="添加学生水电费并立即出账"
|
||||
open={open}
|
||||
onOk={onOk}
|
||||
onCancel={onCancel}
|
||||
onCancel={() => utilityGuard.confirmClose(onCancel)}
|
||||
okText="生成账单并扣余额"
|
||||
confirmLoading={saving}
|
||||
>
|
||||
@@ -123,12 +136,18 @@ export const PersonalExpenseModal: React.FC<{
|
||||
onOk: () => void;
|
||||
onCancel: () => void;
|
||||
}> = ({ open, editing, saving, form, students, rooms, personalTypeOptions, onOk, onCancel }) => {
|
||||
const personalExpenseGuard = useDirtyGuard(form);
|
||||
// 父组件在打开弹窗前已完成表单回填,这里记录「未修改」基准
|
||||
useEffect(() => {
|
||||
if (open) personalExpenseGuard.snapshot();
|
||||
}, [open, personalExpenseGuard]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={editing ? '编辑个人费用' : '录入个人附加费'}
|
||||
open={open}
|
||||
onOk={onOk}
|
||||
onCancel={onCancel}
|
||||
onCancel={() => personalExpenseGuard.confirmClose(onCancel)}
|
||||
okText={editing ? '保存' : '确认录入'}
|
||||
confirmLoading={saving}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// aislop-ignore-file: duplicate-block -- 退宿/换房表单结构相似且字段不同,已共享 DateFormItem
|
||||
import React from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import {
|
||||
DatePicker,
|
||||
Form,
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from 'antd';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import { maskIdNumber, maskPhone } from '../../utils/sensitive';
|
||||
import { useDirtyGuard } from '../../hooks/useDirtyGuard';
|
||||
import type { OccupancyRow } from './OccupancyColumns';
|
||||
|
||||
export type FormRule = React.ComponentProps<typeof Form.Item>['rules'];
|
||||
@@ -76,12 +77,18 @@ export const CheckInModal: React.FC<{
|
||||
onOk,
|
||||
onCancel,
|
||||
}) => {
|
||||
const checkInGuard = useDirtyGuard(form);
|
||||
// 父组件在打开弹窗前已完成表单回填,这里记录「未修改」基准
|
||||
useEffect(() => {
|
||||
if (open && canCheckIn) checkInGuard.snapshot();
|
||||
}, [open, canCheckIn, checkInGuard]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="入住登记"
|
||||
open={open && canCheckIn}
|
||||
onOk={canCheckIn ? onOk : undefined}
|
||||
onCancel={onCancel}
|
||||
onCancel={() => checkInGuard.confirmClose(onCancel)}
|
||||
okText="确认入住"
|
||||
confirmLoading={saving}
|
||||
width={500}
|
||||
@@ -234,12 +241,18 @@ export const CheckOutModal: React.FC<{
|
||||
onOk: () => void;
|
||||
onCancel: () => void;
|
||||
}> = ({ record, canCheckOut, saving, form, dateNotBefore, onOk, onCancel }) => {
|
||||
const checkOutGuard = useDirtyGuard(form);
|
||||
// 父组件打开退宿弹窗前会用 record 回填表单,这里记录「未修改」基准
|
||||
useEffect(() => {
|
||||
if (record && canCheckOut) checkOutGuard.snapshot();
|
||||
}, [record, canCheckOut, checkOutGuard]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={`退宿 - ${record?.student?.name}`}
|
||||
open={!!record && canCheckOut}
|
||||
onOk={canCheckOut ? onOk : undefined}
|
||||
onCancel={onCancel}
|
||||
onCancel={() => checkOutGuard.confirmClose(onCancel)}
|
||||
okText="确认退宿"
|
||||
confirmLoading={saving}
|
||||
>
|
||||
@@ -308,12 +321,18 @@ export const BatchCheckOutModal: React.FC<{
|
||||
onOk,
|
||||
onCancel,
|
||||
}) => {
|
||||
const batchCheckOutGuard = useDirtyGuard(form);
|
||||
// 父组件在打开批量退宿弹窗前已完成表单回填,这里记录「未修改」基准
|
||||
useEffect(() => {
|
||||
if (open && canCheckOut) batchCheckOutGuard.snapshot();
|
||||
}, [open, canCheckOut, batchCheckOutGuard]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={`批量退宿(${selectedRowKeys.length} 人)`}
|
||||
open={open && canCheckOut}
|
||||
onOk={canCheckOut ? onOk : undefined}
|
||||
onCancel={onCancel}
|
||||
onCancel={() => batchCheckOutGuard.confirmClose(onCancel)}
|
||||
okText="确认批量退宿"
|
||||
width={500}
|
||||
>
|
||||
@@ -414,12 +433,18 @@ export const TransferModal: React.FC<{
|
||||
onOk,
|
||||
onCancel,
|
||||
}) => {
|
||||
const transferGuard = useDirtyGuard(form);
|
||||
// 父组件打开换房弹窗前会用 record 回填表单,这里记录「未修改」基准
|
||||
useEffect(() => {
|
||||
if (record && canTransfer) transferGuard.snapshot();
|
||||
}, [record, canTransfer, transferGuard]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={`换房 - ${record?.student?.name}`}
|
||||
open={!!record && canTransfer}
|
||||
onOk={onOk}
|
||||
onCancel={onCancel}
|
||||
onCancel={() => transferGuard.confirmClose(onCancel)}
|
||||
okText="确认换房"
|
||||
confirmLoading={saving}
|
||||
width={500}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { usePermission } from '../../hooks/usePermission';
|
||||
import { useApiMutation } from '../../hooks/useApiMutation';
|
||||
import { validateResponse } from '../../utils/validate';
|
||||
import { organizationsSchema } from '../../api/schemas';
|
||||
import { useDirtyGuard } from '../../hooks/useDirtyGuard';
|
||||
|
||||
const PRESET_COLORS = [
|
||||
'#ff7875',
|
||||
@@ -52,6 +53,7 @@ const OrganizationsPage: React.FC = () => {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<OrganizationItem | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
const orgGuard = useDirtyGuard(form);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState<string>();
|
||||
@@ -133,6 +135,7 @@ const OrganizationsPage: React.FC = () => {
|
||||
form.resetFields();
|
||||
if (record) form.setFieldsValue(record);
|
||||
else form.setFieldsValue({ color: PRESET_COLORS[data.length % PRESET_COLORS.length] });
|
||||
orgGuard.snapshot();
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
@@ -408,7 +411,7 @@ const OrganizationsPage: React.FC = () => {
|
||||
title={editing ? `编辑机构 · ${editing.name}` : '添加外部机构'}
|
||||
open={modalOpen}
|
||||
onOk={handleSave}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
onCancel={() => orgGuard.confirmClose(() => setModalOpen(false))}
|
||||
confirmLoading={saving}
|
||||
okText="保存"
|
||||
>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useApiMutation } from '../../hooks/useApiMutation';
|
||||
import { validateResponse } from '../../utils/validate';
|
||||
import { permissionTreeSchema, rolesSchema } from '../../api/schemas';
|
||||
import { getErrorMessage } from '../../utils/error';
|
||||
import { useDirtyGuard } from '../../hooks/useDirtyGuard';
|
||||
|
||||
interface PermissionItem {
|
||||
id: number;
|
||||
@@ -31,6 +32,7 @@ const RolesPage: React.FC = () => {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<RoleItem | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
const roleGuard = useDirtyGuard(form);
|
||||
const [selectedPermIds, setSelectedPermIds] = useState<number[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
@@ -89,6 +91,7 @@ const RolesPage: React.FC = () => {
|
||||
setEditing(null);
|
||||
form.resetFields();
|
||||
setSelectedPermIds([]);
|
||||
roleGuard.snapshot();
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
@@ -97,9 +100,10 @@ const RolesPage: React.FC = () => {
|
||||
setEditing(record);
|
||||
form.setFieldsValue({ name: record.name, description: record.description });
|
||||
setSelectedPermIds(record.permissions.map((p) => p.id));
|
||||
roleGuard.snapshot();
|
||||
setModalOpen(true);
|
||||
},
|
||||
[form],
|
||||
[form, roleGuard],
|
||||
);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
@@ -332,7 +336,7 @@ const RolesPage: React.FC = () => {
|
||||
title={editing ? '编辑角色' : '新增角色'}
|
||||
open={modalOpen}
|
||||
onOk={handleSubmit}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
onCancel={() => roleGuard.confirmClose(() => setModalOpen(false))}
|
||||
width={700}
|
||||
destroyOnHidden
|
||||
confirmLoading={saving}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useVisibleRefetch } from '../../hooks/usePageVisible';
|
||||
import { useDownload } from '../../hooks/useDownload';
|
||||
import { selectArchiveRecords } from '../archive-view';
|
||||
import { getErrorMessage } from '../../utils/error';
|
||||
import { useDirtyGuard } from '../../hooks/useDirtyGuard';
|
||||
import { QueryErrorState } from '../../components/QueryState';
|
||||
import { useRoomColumns, type BedItem, type LockerItem } from './RoomColumns';
|
||||
import { RoomDetailArea } from './RoomModals';
|
||||
@@ -40,6 +41,7 @@ const RoomsPage: React.FC = () => {
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const roomGuard = useDirtyGuard(form);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [drawerRoom, setDrawerRoom] = useState<any>(null);
|
||||
const [beds, setBeds] = useState<BedItem[]>([]);
|
||||
@@ -49,7 +51,9 @@ const RoomsPage: React.FC = () => {
|
||||
const [lockerModalOpen, setLockerModalOpen] = useState(false);
|
||||
const [lockerEditing, setLockerEditing] = useState<any>(null);
|
||||
const [bedForm] = Form.useForm();
|
||||
const bedGuard = useDirtyGuard(bedForm);
|
||||
const [lockerForm] = Form.useForm();
|
||||
const lockerGuard = useDirtyGuard(lockerForm);
|
||||
const [savingBed, setSavingBed] = useState(false);
|
||||
const [savingLocker, setSavingLocker] = useState(false);
|
||||
const [batchLoading, setBatchLoading] = useState(false);
|
||||
@@ -425,6 +429,7 @@ const RoomsPage: React.FC = () => {
|
||||
onEdit: (record) => {
|
||||
setEditing(record);
|
||||
form.setFieldsValue(record);
|
||||
roomGuard.snapshot();
|
||||
setModalOpen(true);
|
||||
},
|
||||
});
|
||||
@@ -458,6 +463,7 @@ const RoomsPage: React.FC = () => {
|
||||
onAddRoom={() => {
|
||||
setEditing(null);
|
||||
form.resetFields();
|
||||
roomGuard.snapshot();
|
||||
setModalOpen(true);
|
||||
}}
|
||||
onImport={async (options: UploadRequestOption<{ message?: string }>) => {
|
||||
@@ -505,10 +511,12 @@ const RoomsPage: React.FC = () => {
|
||||
saving={saving}
|
||||
form={form}
|
||||
onSaveRoom={handleSave}
|
||||
onCloseRoomModal={() => {
|
||||
setModalOpen(false);
|
||||
setEditing(null);
|
||||
}}
|
||||
onCloseRoomModal={() =>
|
||||
roomGuard.confirmClose(() => {
|
||||
setModalOpen(false);
|
||||
setEditing(null);
|
||||
})
|
||||
}
|
||||
drawerOpen={drawerOpen}
|
||||
drawerRoom={drawerRoom}
|
||||
beds={beds}
|
||||
@@ -531,12 +539,14 @@ const RoomsPage: React.FC = () => {
|
||||
onAddBed={() => {
|
||||
setBedEditing(null);
|
||||
bedForm.resetFields();
|
||||
bedGuard.snapshot();
|
||||
setBedModalOpen(true);
|
||||
}}
|
||||
onBatchBeds={handleBatchBeds}
|
||||
onEditBed={(r) => {
|
||||
setBedEditing(r);
|
||||
bedForm.setFieldsValue(r);
|
||||
bedGuard.snapshot();
|
||||
setBedModalOpen(true);
|
||||
}}
|
||||
onDeleteBed={handleDeleteBed}
|
||||
@@ -544,12 +554,14 @@ const RoomsPage: React.FC = () => {
|
||||
onAddLocker={() => {
|
||||
setLockerEditing(null);
|
||||
lockerForm.resetFields();
|
||||
lockerGuard.snapshot();
|
||||
setLockerModalOpen(true);
|
||||
}}
|
||||
onBatchLockers={handleBatchLockers}
|
||||
onEditLocker={(r) => {
|
||||
setLockerEditing(r);
|
||||
lockerForm.setFieldsValue(r);
|
||||
lockerGuard.snapshot();
|
||||
setLockerModalOpen(true);
|
||||
}}
|
||||
onDeleteLocker={handleDeleteLocker}
|
||||
@@ -559,19 +571,23 @@ const RoomsPage: React.FC = () => {
|
||||
savingBed={savingBed}
|
||||
bedForm={bedForm}
|
||||
onSaveBed={handleSaveBed}
|
||||
onCloseBedModal={() => {
|
||||
setBedModalOpen(false);
|
||||
setBedEditing(null);
|
||||
}}
|
||||
onCloseBedModal={() =>
|
||||
bedGuard.confirmClose(() => {
|
||||
setBedModalOpen(false);
|
||||
setBedEditing(null);
|
||||
})
|
||||
}
|
||||
lockerModalOpen={lockerModalOpen}
|
||||
lockerEditing={!!lockerEditing}
|
||||
savingLocker={savingLocker}
|
||||
lockerForm={lockerForm}
|
||||
onSaveLocker={handleSaveLocker}
|
||||
onCloseLockerModal={() => {
|
||||
setLockerModalOpen(false);
|
||||
setLockerEditing(null);
|
||||
}}
|
||||
onCloseLockerModal={() =>
|
||||
lockerGuard.confirmClose(() => {
|
||||
setLockerModalOpen(false);
|
||||
setLockerEditing(null);
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
@@ -28,6 +28,7 @@ import { isMaskedSchedule } from './schedule-visibility';
|
||||
import type { ScheduleFormValues } from './schedule-form';
|
||||
import type { ClassItem, ClassScheduleItem, ClassTeacherOption, ClassroomItem } from './ScheduleGrids';
|
||||
import { WEEKDAYS } from './ScheduleGrids';
|
||||
import { useDirtyGuard } from '../../hooks/useDirtyGuard';
|
||||
|
||||
export interface ScheduleModalProps {
|
||||
open: boolean;
|
||||
@@ -74,6 +75,12 @@ export const ScheduleModal: React.FC<ScheduleModalProps> = ({
|
||||
onClassChange,
|
||||
onSubjectBlur,
|
||||
}) => {
|
||||
const scheduleGuard = useDirtyGuard(form);
|
||||
// 弹窗打开或切换为创建/编辑模式时,父组件已完成表单回填,这里记录「未修改」基准
|
||||
useEffect(() => {
|
||||
if (open && mode !== 'detail') scheduleGuard.snapshot();
|
||||
}, [open, mode, editingSchedule, scheduleGuard]);
|
||||
|
||||
const title =
|
||||
mode === 'create'
|
||||
? `新增排课 — ${selectedClassroom?.name || ''} · ${
|
||||
@@ -93,7 +100,7 @@ export const ScheduleModal: React.FC<ScheduleModalProps> = ({
|
||||
<Modal
|
||||
title={title}
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
onCancel={() => scheduleGuard.confirmClose(onCancel)}
|
||||
onOk={mode !== 'detail' ? onSubmit : undefined}
|
||||
confirmLoading={submitting}
|
||||
okText={mode === 'edit' ? '保存' : mode === 'create' ? '创建' : undefined}
|
||||
|
||||
@@ -6,6 +6,7 @@ import dayjs, { Dayjs } from 'dayjs';
|
||||
import api from '../../api';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import { QueryErrorState } from '../../components/QueryState';
|
||||
import { NextStepHint } from '../../components/NextStepHint';
|
||||
import { usePermission } from '../../hooks/usePermission';
|
||||
import { useVisibleRefetch } from '../../hooks/usePageVisible';
|
||||
import { message } from '../../ui/app-message';
|
||||
@@ -61,6 +62,8 @@ const SchedulesPage: React.FC = () => {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [syncModalOpen, setSyncModalOpen] = useState(false);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
// 排课创建成功后的「下一步:同步钉钉」引导
|
||||
const [syncHint, setSyncHint] = useState(false);
|
||||
const [syncStatus, setSyncStatus] = useState<{
|
||||
activeSchedules: number;
|
||||
mappedClasses: number;
|
||||
@@ -108,8 +111,10 @@ const SchedulesPage: React.FC = () => {
|
||||
message.error(classification.message);
|
||||
} else if (classification.level === 'warning') {
|
||||
message.warning(classification.message);
|
||||
setSyncHint(false);
|
||||
} else {
|
||||
message.success(classification.message);
|
||||
setSyncHint(false);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e, '同步失败'));
|
||||
@@ -346,6 +351,7 @@ const SchedulesPage: React.FC = () => {
|
||||
message.success(
|
||||
modalMode === 'edit' ? '排课更新成功,请重新同步到钉钉排班' : '排课创建成功',
|
||||
);
|
||||
if (modalMode === 'create') setSyncHint(true);
|
||||
setModalOpen(false);
|
||||
setEditingSchedule(null);
|
||||
} catch {
|
||||
@@ -416,6 +422,14 @@ const SchedulesPage: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
{syncHint && (
|
||||
<NextStepHint
|
||||
title="排课已创建"
|
||||
description="同步到钉钉后,教师端与考勤设备才能看到新排课。"
|
||||
action={{ label: '同步钉钉', onClick: () => void handleSyncSchedule() }}
|
||||
onClose={() => setSyncHint(false)}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from 'react';
|
||||
import { Alert, Button, Card, Col, Descriptions, Empty, Row, Table, Tag } from 'antd';
|
||||
import { Alert, Button, Card, Col, Descriptions, Row, Table, Tag } from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import api from '../../api';
|
||||
import { QueryEmpty } from '../../components/QueryState';
|
||||
|
||||
export interface EnrollmentInfo {
|
||||
classId: number;
|
||||
@@ -22,6 +24,9 @@ export const StudentsTable: React.FC<{
|
||||
selectedRowKeys: number[];
|
||||
onSelect: (keys: number[]) => void;
|
||||
onClearSelection: () => void;
|
||||
/** 空状态下的「添加学生」动作(无权限或不传则不显示) */
|
||||
onAddStudent?: () => void;
|
||||
showArchived?: boolean;
|
||||
}> = ({
|
||||
columns,
|
||||
data,
|
||||
@@ -31,6 +36,8 @@ export const StudentsTable: React.FC<{
|
||||
selectedRowKeys,
|
||||
onSelect,
|
||||
onClearSelection,
|
||||
onAddStudent,
|
||||
showArchived,
|
||||
}) => {
|
||||
const [enrollmentData, setEnrollmentData] = React.useState<Record<number, EnrollmentInfo[]>>({});
|
||||
|
||||
@@ -59,7 +66,23 @@ export const StudentsTable: React.FC<{
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
||||
locale={{
|
||||
emptyText: (
|
||||
<QueryEmpty
|
||||
description={showArchived ? '暂无已归档学生' : '暂无学生数据'}
|
||||
action={
|
||||
onAddStudent
|
||||
? {
|
||||
label: '添加学生',
|
||||
type: 'primary',
|
||||
icon: <PlusOutlined />,
|
||||
onClick: onAddStudent,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
scroll={{ x: 1410 }}
|
||||
pagination={{
|
||||
defaultPageSize: 15,
|
||||
|
||||
@@ -595,6 +595,18 @@ const StudentsPage: React.FC = () => {
|
||||
selectedRowKeys={selectedRowKeys}
|
||||
onSelect={setSelectedRowKeys}
|
||||
onClearSelection={() => setSelectedRowKeys([])}
|
||||
showArchived={showArchived}
|
||||
onAddStudent={
|
||||
canSaveStudent
|
||||
? () => {
|
||||
setEditing(null);
|
||||
form.resetFields();
|
||||
const host = organizations.find((organization) => organization.isHost);
|
||||
if (host) form.setFieldValue('organizationId', host.id);
|
||||
setModalOpen(true);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<StudentEditModal
|
||||
|
||||
@@ -13,6 +13,7 @@ import PermissionButton from '../../components/PermissionButton';
|
||||
import { usePermission } from '../../hooks/usePermission';
|
||||
import { QueryErrorState } from '../../components/QueryState';
|
||||
import { useVisibleRefetch } from '../../hooks/usePageVisible';
|
||||
import { useDirtyGuard } from '../../hooks/useDirtyGuard';
|
||||
|
||||
interface TeacherRow {
|
||||
id: number;
|
||||
@@ -64,6 +65,7 @@ const TeachersPage: React.FC = () => {
|
||||
const [search, setSearch] = useState('');
|
||||
const [profileModal, setProfileModal] = useState<TeacherRow | null>(null);
|
||||
const [form] = Form.useForm<ProfileFormValues>();
|
||||
const profileFormGuard = useDirtyGuard(form);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const {
|
||||
@@ -222,6 +224,7 @@ const TeachersPage: React.FC = () => {
|
||||
joinedAt: r.profile?.joinedAt ? dayjs(r.profile.joinedAt) : null,
|
||||
qualifications: r.profile?.qualifications || '',
|
||||
});
|
||||
profileFormGuard.snapshot();
|
||||
}}
|
||||
>
|
||||
档案
|
||||
@@ -229,7 +232,7 @@ const TeachersPage: React.FC = () => {
|
||||
),
|
||||
},
|
||||
],
|
||||
[saveProfileCell, form],
|
||||
[saveProfileCell, form, profileFormGuard],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -295,7 +298,7 @@ const TeachersPage: React.FC = () => {
|
||||
title={`编辑档案 — ${profileModal?.name || ''}`}
|
||||
open={!!profileModal && canEditTeachers}
|
||||
onOk={canEditTeachers ? handleSaveProfile : undefined}
|
||||
onCancel={() => setProfileModal(null)}
|
||||
onCancel={() => profileFormGuard.confirmClose(() => setProfileModal(null))}
|
||||
okText="保存"
|
||||
confirmLoading={saving}
|
||||
>
|
||||
|
||||
@@ -16,6 +16,7 @@ import { userProfileResponseToFormValues, type UserProfileResponse } from './use
|
||||
import { usePermission } from '../../hooks/usePermission';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useApiMutation } from '../../hooks/useApiMutation';
|
||||
import { useDirtyGuard } from '../../hooks/useDirtyGuard';
|
||||
import { validateResponse } from '../../utils/validate';
|
||||
import { rolesSchema, usersSchema } from '../../api/schemas';
|
||||
import { getErrorMessage } from '../../utils/error';
|
||||
@@ -44,6 +45,11 @@ const UsersPage: React.FC = () => {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
|
||||
// 弹窗「未保存内容」保护
|
||||
const accountGuard = useDirtyGuard(form);
|
||||
const pwdGuard = useDirtyGuard(pwdForm);
|
||||
const profileGuard = useDirtyGuard(profileForm);
|
||||
|
||||
const handleOpenProfile = useCallback(
|
||||
async (record: any) => {
|
||||
setProfileUser(record);
|
||||
@@ -53,9 +59,10 @@ const UsersPage: React.FC = () => {
|
||||
} catch {
|
||||
profileForm.setFieldsValue({});
|
||||
}
|
||||
profileGuard.snapshot();
|
||||
setProfileModalOpen(true);
|
||||
},
|
||||
[profileForm],
|
||||
[profileForm, profileGuard],
|
||||
);
|
||||
|
||||
const handleProfileSubmit = async () => {
|
||||
@@ -133,6 +140,7 @@ const UsersPage: React.FC = () => {
|
||||
const handleAdd = () => {
|
||||
setEditing(null);
|
||||
form.resetFields();
|
||||
accountGuard.snapshot();
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
@@ -144,9 +152,10 @@ const UsersPage: React.FC = () => {
|
||||
name: record.name,
|
||||
roleIds: record.roles?.map((r: any) => r.id) || [],
|
||||
});
|
||||
accountGuard.snapshot();
|
||||
setModalOpen(true);
|
||||
},
|
||||
[form],
|
||||
[accountGuard, form],
|
||||
);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
@@ -205,9 +214,10 @@ const UsersPage: React.FC = () => {
|
||||
(record: any) => {
|
||||
setResetTarget(record);
|
||||
pwdForm.resetFields();
|
||||
pwdGuard.snapshot();
|
||||
setPwdModalOpen(true);
|
||||
},
|
||||
[pwdForm],
|
||||
[pwdForm, pwdGuard],
|
||||
);
|
||||
|
||||
const handlePwdSubmit = async () => {
|
||||
@@ -438,7 +448,7 @@ const UsersPage: React.FC = () => {
|
||||
title={editing ? '编辑账号' : '新增账号'}
|
||||
open={modalOpen}
|
||||
onOk={handleSubmit}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
onCancel={() => accountGuard.confirmClose(() => setModalOpen(false))}
|
||||
destroyOnHidden
|
||||
confirmLoading={saving}
|
||||
>
|
||||
@@ -485,7 +495,7 @@ const UsersPage: React.FC = () => {
|
||||
title={`重置密码 - ${resetTarget?.username}`}
|
||||
open={pwdModalOpen}
|
||||
onOk={handlePwdSubmit}
|
||||
onCancel={() => setPwdModalOpen(false)}
|
||||
onCancel={() => pwdGuard.confirmClose(() => setPwdModalOpen(false))}
|
||||
destroyOnHidden
|
||||
confirmLoading={saving}
|
||||
>
|
||||
@@ -504,7 +514,7 @@ const UsersPage: React.FC = () => {
|
||||
title={`教师档案 - ${profileUser?.name || profileUser?.username}`}
|
||||
open={profileModalOpen}
|
||||
onOk={handleProfileSubmit}
|
||||
onCancel={() => setProfileModalOpen(false)}
|
||||
onCancel={() => profileGuard.confirmClose(() => setProfileModalOpen(false))}
|
||||
destroyOnHidden
|
||||
confirmLoading={saving}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user