feat(admin): 弹窗未保存内容保护与空状态引导扩展

- 新增 useDirtyGuard hook:关闭弹窗时若表单被修改,先确认再关闭
- 接入 17 个弹窗:账号/密码/档案、角色、教室、宿舍/床位/柜子、
  考勤机、机构、班级、教师、考试、排课、入住/退宿/换房、费用
  (子组件弹窗用 useEffect 在回填后快照,时序正确)
- 学生列表空状态带「添加学生」引导动作
- 排课创建成功提示「同步钉钉」,考试创建成功提示「去详情录成绩」

aislop scan: 5 引擎 0 issues
This commit is contained in:
2026-08-07 17:56:13 +08:00
parent bcbf3c8b2e
commit c10476d203
16 changed files with 273 additions and 49 deletions

View 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;

View File

@@ -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="保存"
>

View File

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

View File

@@ -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="保存"
>

View File

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

View File

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

View File

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

View File

@@ -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="保存"
>

View File

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

View File

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

View File

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

View File

@@ -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,

View File

@@ -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,

View File

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

View File

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

View File

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