From c10476d203ffd7e75b17632b280d359747dfe0f8 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Fri, 7 Aug 2026 17:56:13 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E5=BC=B9=E7=AA=97=E6=9C=AA?= =?UTF-8?q?=E4=BF=9D=E5=AD=98=E5=86=85=E5=AE=B9=E4=BF=9D=E6=8A=A4=E4=B8=8E?= =?UTF-8?q?=E7=A9=BA=E7=8A=B6=E6=80=81=E5=BC=95=E5=AF=BC=E6=89=A9=E5=B1=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 useDirtyGuard hook:关闭弹窗时若表单被修改,先确认再关闭 - 接入 17 个弹窗:账号/密码/档案、角色、教室、宿舍/床位/柜子、 考勤机、机构、班级、教师、考试、排课、入住/退宿/换房、费用 (子组件弹窗用 useEffect 在回填后快照,时序正确) - 学生列表空状态带「添加学生」引导动作 - 排课创建成功提示「同步钉钉」,考试创建成功提示「去详情录成绩」 aislop scan: 5 引擎 0 issues --- apps/admin/src/hooks/useDirtyGuard.ts | 51 +++++++++++++++++++ apps/admin/src/pages/AttendanceDevices.tsx | 14 +++-- apps/admin/src/pages/Classes/index.tsx | 8 ++- apps/admin/src/pages/Classrooms/index.tsx | 16 ++++-- apps/admin/src/pages/Exams/index.tsx | 25 ++++++++- .../src/pages/Expenses/ExpenseModals.tsx | 27 ++++++++-- .../src/pages/Occupancies/OccupancyModals.tsx | 35 +++++++++++-- apps/admin/src/pages/Organizations/index.tsx | 5 +- apps/admin/src/pages/Roles/index.tsx | 8 ++- apps/admin/src/pages/Rooms/index.tsx | 40 ++++++++++----- .../src/pages/Schedules/ScheduleModals.tsx | 11 +++- apps/admin/src/pages/Schedules/index.tsx | 14 +++++ .../src/pages/Students/StudentsTable.tsx | 27 +++++++++- apps/admin/src/pages/Students/index.tsx | 12 +++++ apps/admin/src/pages/Teachers/index.tsx | 7 ++- apps/admin/src/pages/Users/index.tsx | 22 +++++--- 16 files changed, 273 insertions(+), 49 deletions(-) create mode 100644 apps/admin/src/hooks/useDirtyGuard.ts diff --git a/apps/admin/src/hooks/useDirtyGuard.ts b/apps/admin/src/hooks/useDirtyGuard.ts new file mode 100644 index 0000000..2222aae --- /dev/null +++ b/apps/admin/src/hooks/useDirtyGuard.ts @@ -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 改用确认关闭 + * confirmClose(() => setOpen(false))} ...> + */ +export function useDirtyGuard(form: FormInstance) { + const { modal } = App.useApp(); + const pristineRef = useRef(''); + + /** 记录当前表单值为「未修改」基准;打开弹窗/回填后调用 */ + 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; diff --git a/apps/admin/src/pages/AttendanceDevices.tsx b/apps/admin/src/pages/AttendanceDevices.tsx index 685049f..169b9f1 100644 --- a/apps/admin/src/pages/AttendanceDevices.tsx +++ b/apps/admin/src/pages/AttendanceDevices.tsx @@ -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="保存" > diff --git a/apps/admin/src/pages/Classes/index.tsx b/apps/admin/src/pages/Classes/index.tsx index 8df4d3e..05e6c07 100644 --- a/apps/admin/src/pages/Classes/index.tsx +++ b/apps/admin/src/pages/Classes/index.tsx @@ -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(); const [filterType, setFilterType] = useState(); const [form] = Form.useForm(); + 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} > diff --git a/apps/admin/src/pages/Classrooms/index.tsx b/apps/admin/src/pages/Classrooms/index.tsx index e2a5bd4..711a3de 100644 --- a/apps/admin/src/pages/Classrooms/index.tsx +++ b/apps/admin/src/pages/Classrooms/index.tsx @@ -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 = { available: { text: '可用', color: 'green' }, @@ -63,6 +64,7 @@ const ClassroomsPage: React.FC = () => { const [editing, setEditing] = useState(null); const [showArchived, setShowArchived] = useState(false); const [form] = Form.useForm(); + const formGuard = useDirtyGuard(form); const [searchText, setSearchText] = useState(''); const [filterStatus, setFilterStatus] = useState(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="保存" > diff --git a/apps/admin/src/pages/Exams/index.tsx b/apps/admin/src/pages/Exams/index.tsx index b95f4e4..d38b553 100644 --- a/apps/admin/src/pages/Exams/index.tsx +++ b/apps/admin/src/pages/Exams/index.tsx @@ -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(); + 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(); const [showArchived, setShowArchived] = useState(false); const [selectedExamIds, setSelectedExamIds] = useState([]); + // 考试创建成功后的「下一步:去详情录成绩」引导 + const [examCreatedId, setExamCreatedId] = useState(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 (
+ {examCreatedId !== null && ( + { + navigate(`/exams/${examCreatedId}`); + setExamCreatedId(null); + }, + }} + onClose={() => setExamCreatedId(null)} + /> + )}
{ saving={saving} form={form} classes={classes} - onCancel={() => setModalOpen(false)} + onCancel={() => examFormGuard.confirmClose(() => setModalOpen(false))} onSubmit={() => void submit()} />
diff --git a/apps/admin/src/pages/Expenses/ExpenseModals.tsx b/apps/admin/src/pages/Expenses/ExpenseModals.tsx index eb35e82..efdc22e 100644 --- a/apps/admin/src/pages/Expenses/ExpenseModals.tsx +++ b/apps/admin/src/pages/Expenses/ExpenseModals.tsx @@ -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 ( 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 ( 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 ( personalExpenseGuard.confirmClose(onCancel)} okText={editing ? '保存' : '确认录入'} confirmLoading={saving} > diff --git a/apps/admin/src/pages/Occupancies/OccupancyModals.tsx b/apps/admin/src/pages/Occupancies/OccupancyModals.tsx index ebac5d7..a470425 100644 --- a/apps/admin/src/pages/Occupancies/OccupancyModals.tsx +++ b/apps/admin/src/pages/Occupancies/OccupancyModals.tsx @@ -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['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 ( 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 ( 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 ( 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 ( transferGuard.confirmClose(onCancel)} okText="确认换房" confirmLoading={saving} width={500} diff --git a/apps/admin/src/pages/Organizations/index.tsx b/apps/admin/src/pages/Organizations/index.tsx index 3b5f40b..ca39730 100644 --- a/apps/admin/src/pages/Organizations/index.tsx +++ b/apps/admin/src/pages/Organizations/index.tsx @@ -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(null); const [form] = Form.useForm(); + const orgGuard = useDirtyGuard(form); const [saving, setSaving] = useState(false); const [searchText, setSearchText] = useState(''); const [filterStatus, setFilterStatus] = useState(); @@ -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="保存" > diff --git a/apps/admin/src/pages/Roles/index.tsx b/apps/admin/src/pages/Roles/index.tsx index 649b4bf..2e11378 100644 --- a/apps/admin/src/pages/Roles/index.tsx +++ b/apps/admin/src/pages/Roles/index.tsx @@ -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(null); const [form] = Form.useForm(); + const roleGuard = useDirtyGuard(form); const [selectedPermIds, setSelectedPermIds] = useState([]); 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} diff --git a/apps/admin/src/pages/Rooms/index.tsx b/apps/admin/src/pages/Rooms/index.tsx index 3f8b47c..76fdb5e 100644 --- a/apps/admin/src/pages/Rooms/index.tsx +++ b/apps/admin/src/pages/Rooms/index.tsx @@ -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([]); const [saving, setSaving] = useState(false); const [form] = Form.useForm(); + const roomGuard = useDirtyGuard(form); const [drawerOpen, setDrawerOpen] = useState(false); const [drawerRoom, setDrawerRoom] = useState(null); const [beds, setBeds] = useState([]); @@ -49,7 +51,9 @@ const RoomsPage: React.FC = () => { const [lockerModalOpen, setLockerModalOpen] = useState(false); const [lockerEditing, setLockerEditing] = useState(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); + }) + } />
); diff --git a/apps/admin/src/pages/Schedules/ScheduleModals.tsx b/apps/admin/src/pages/Schedules/ScheduleModals.tsx index 7ed5832..26cd9ae 100644 --- a/apps/admin/src/pages/Schedules/ScheduleModals.tsx +++ b/apps/admin/src/pages/Schedules/ScheduleModals.tsx @@ -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 = ({ 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 = ({ scheduleGuard.confirmClose(onCancel)} onOk={mode !== 'detail' ? onSubmit : undefined} confirmLoading={submitting} okText={mode === 'edit' ? '保存' : mode === 'create' ? '创建' : undefined} diff --git a/apps/admin/src/pages/Schedules/index.tsx b/apps/admin/src/pages/Schedules/index.tsx index 9ce4a07..844746d 100644 --- a/apps/admin/src/pages/Schedules/index.tsx +++ b/apps/admin/src/pages/Schedules/index.tsx @@ -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 (
+ {syncHint && ( + void handleSyncSchedule() }} + onClose={() => setSyncHint(false)} + /> + )}
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>({}); @@ -59,7 +66,23 @@ export const StudentsTable: React.FC<{ dataSource={data} rowKey="id" loading={loading} - locale={{ emptyText: }} + locale={{ + emptyText: ( + , + onClick: onAddStudent, + } + : undefined + } + /> + ), + }} scroll={{ x: 1410 }} pagination={{ defaultPageSize: 15, diff --git a/apps/admin/src/pages/Students/index.tsx b/apps/admin/src/pages/Students/index.tsx index cdf59b6..731057e 100644 --- a/apps/admin/src/pages/Students/index.tsx +++ b/apps/admin/src/pages/Students/index.tsx @@ -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 + } /> )} { const [search, setSearch] = useState(''); const [profileModal, setProfileModal] = useState(null); const [form] = Form.useForm(); + 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} > diff --git a/apps/admin/src/pages/Users/index.tsx b/apps/admin/src/pages/Users/index.tsx index a223bc7..97ee139 100644 --- a/apps/admin/src/pages/Users/index.tsx +++ b/apps/admin/src/pages/Users/index.tsx @@ -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} >