Compare commits
3 Commits
fix/dataso
...
7bd9a284be
| Author | SHA1 | Date | |
|---|---|---|---|
| 7bd9a284be | |||
| 3ac99b808e | |||
| f39136d9ce |
@@ -54,14 +54,12 @@ describe('permission state', () => {
|
|||||||
expect(container?.textContent).toContain('编辑学生');
|
expect(container?.textContent).toContain('编辑学生');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fails closed after profile refresh failure', async () => {
|
it('stays fail-closed while profile verification is retried after a failure', async () => {
|
||||||
writePermissions(['student:edit']);
|
writePermissions(['student:edit']);
|
||||||
beginPermissionVerification();
|
beginPermissionVerification();
|
||||||
clearPermissions('ready');
|
|
||||||
|
|
||||||
expect(readPermissionState()).toEqual({ permissions: [], status: 'ready' });
|
expect(readPermissionState()).toEqual({ permissions: [], status: 'loading' });
|
||||||
await renderPermissionButton();
|
await renderPermissionButton();
|
||||||
expect(container?.textContent).not.toContain('编辑学生');
|
expect(container?.textContent).not.toContain('编辑学生');
|
||||||
expect(localStorage.getItem('permissions')).toBeNull();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -312,8 +312,9 @@ interface MatchModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const JinshujuMatchModal: React.FC<MatchModalProps> = ({ open, onClose, onApplied }) => {
|
const JinshujuMatchModal: React.FC<MatchModalProps> = ({ open, onClose, onApplied }) => {
|
||||||
const { hasPermission } = usePermission();
|
const { hasPermission, hasAllPermissions, permissionsReady } = usePermission();
|
||||||
const canTriggerSync = hasPermission('sync:trigger');
|
const canTriggerSync = hasPermission('sync:trigger');
|
||||||
|
const canEnterModal = permissionsReady && hasAllPermissions('sync:read', 'sync:trigger');
|
||||||
const [step, setStep] = useState<'connection' | 'rule' | 'match' | 'applying'>('connection');
|
const [step, setStep] = useState<'connection' | 'rule' | 'match' | 'applying'>('connection');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [selectedRuleId, setSelectedRuleId] = useState<number | undefined>();
|
const [selectedRuleId, setSelectedRuleId] = useState<number | undefined>();
|
||||||
@@ -334,8 +335,22 @@ const JinshujuMatchModal: React.FC<MatchModalProps> = ({ open, onClose, onApplie
|
|||||||
|
|
||||||
// Load rules on open
|
// Load rules on open
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) loadRules();
|
if (open && canEnterModal) loadRules();
|
||||||
}, [open]);
|
}, [open, canEnterModal]);
|
||||||
|
|
||||||
|
// Close and reset when permission is lost
|
||||||
|
const enteredRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (canEnterModal) {
|
||||||
|
enteredRef.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (enteredRef.current) {
|
||||||
|
enteredRef.current = false;
|
||||||
|
reset();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}, [canEnterModal, onClose]);
|
||||||
|
|
||||||
const loadRules = async () => {
|
const loadRules = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -347,6 +362,7 @@ const JinshujuMatchModal: React.FC<MatchModalProps> = ({ open, onClose, onApplie
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleConnectionNext = async () => {
|
const handleConnectionNext = async () => {
|
||||||
|
if (!canTriggerSync) return;
|
||||||
try {
|
try {
|
||||||
const values = await credForm.validateFields();
|
const values = await credForm.validateFields();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -366,6 +382,7 @@ const JinshujuMatchModal: React.FC<MatchModalProps> = ({ open, onClose, onApplie
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handlePreview = async () => {
|
const handlePreview = async () => {
|
||||||
|
if (!canTriggerSync) return;
|
||||||
try {
|
try {
|
||||||
const values = await credForm.validateFields();
|
const values = await credForm.validateFields();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -689,7 +706,7 @@ const JinshujuMatchModal: React.FC<MatchModalProps> = ({ open, onClose, onApplie
|
|||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title="同步金数据"
|
title="同步金数据"
|
||||||
open={open}
|
open={open && canEnterModal}
|
||||||
onCancel={handleClose}
|
onCancel={handleClose}
|
||||||
width={step === 'match' || step === 'applying' ? 900 : 640}
|
width={step === 'match' || step === 'applying' ? 900 : 640}
|
||||||
maskClosable={false}
|
maskClosable={false}
|
||||||
|
|||||||
@@ -1401,9 +1401,12 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
|
|||||||
onClose,
|
onClose,
|
||||||
}) => {
|
}) => {
|
||||||
const { hasPermission, hasAnyPermission } = usePermission();
|
const { hasPermission, hasAnyPermission } = usePermission();
|
||||||
const canViewOrganizations = hasPermission('organization:view');
|
const canLoadOrganizations = hasAnyPermission(
|
||||||
const canChooseOrganization =
|
'organization:view',
|
||||||
canViewOrganizations && hasAnyPermission('student:create', 'student:edit');
|
'student:create',
|
||||||
|
'student:edit',
|
||||||
|
);
|
||||||
|
const canChooseOrganization = hasAnyPermission('student:create', 'student:edit');
|
||||||
const [aggregateData, setAggregateData] = useState<StudentProfileAggregate | null>(null);
|
const [aggregateData, setAggregateData] = useState<StudentProfileAggregate | null>(null);
|
||||||
const [organizations, setOrganizations] = useState<Array<{ id: number; name: string }>>([]);
|
const [organizations, setOrganizations] = useState<Array<{ id: number; name: string }>>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -1426,17 +1429,17 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
|
|||||||
}, [fetchData]);
|
}, [fetchData]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!canViewOrganizations) {
|
if (!canLoadOrganizations) {
|
||||||
setOrganizations([]);
|
setOrganizations([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
api
|
api
|
||||||
.get('/organizations', { params: { includeArchived: 'false' } })
|
.get('/organizations/options')
|
||||||
.then((res: unknown) => {
|
.then((res: unknown) => {
|
||||||
setOrganizations(res as Array<{ id: number; name: string }>);
|
setOrganizations(res as Array<{ id: number; name: string; isHost?: boolean }>);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, [canViewOrganizations]);
|
}, [canLoadOrganizations]);
|
||||||
|
|
||||||
const handlePreviewReport = useCallback(async () => {
|
const handlePreviewReport = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -1451,7 +1454,11 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
|
|||||||
}
|
}
|
||||||
}, [studentId]);
|
}, [studentId]);
|
||||||
|
|
||||||
const handleViewSensitive = useViewSensitive(studentId, '学生档案');
|
const handleViewSensitive = useViewSensitive(
|
||||||
|
studentId,
|
||||||
|
'学生档案',
|
||||||
|
hasPermission('log:create'),
|
||||||
|
);
|
||||||
|
|
||||||
const tabItems = useMemo(() => {
|
const tabItems = useMemo(() => {
|
||||||
if (!aggregateData) return [];
|
if (!aggregateData) return [];
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
import { Modal } from 'antd';
|
import { Modal } from 'antd';
|
||||||
import api from '../api';
|
import api from '../api';
|
||||||
import { message } from '../ui/app-message';
|
import { message } from '../ui/app-message';
|
||||||
@@ -9,16 +9,35 @@ import { message } from '../ui/app-message';
|
|||||||
*
|
*
|
||||||
* @param studentId - The student whose data is being viewed
|
* @param studentId - The student whose data is being viewed
|
||||||
* @param module - Audit module label (e.g. '学生管理', '学生档案')
|
* @param module - Audit module label (e.g. '学生管理', '学生档案')
|
||||||
|
* @param canLog - Whether the current user has log:create; when false any
|
||||||
|
* already-open confirm modal is destroyed.
|
||||||
*/
|
*/
|
||||||
export function useViewSensitive(studentId: number, module: string) {
|
export function useViewSensitive(studentId: number, module: string, canLog: boolean) {
|
||||||
|
const canLogRef = useRef(canLog);
|
||||||
|
const modalRef = useRef<ReturnType<typeof Modal.confirm> | null>(null);
|
||||||
|
canLogRef.current = canLog;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canLogRef.current && modalRef.current) {
|
||||||
|
modalRef.current.destroy();
|
||||||
|
modalRef.current = null;
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
modalRef.current?.destroy();
|
||||||
|
modalRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return useCallback(
|
return useCallback(
|
||||||
(field: string, value: string) => {
|
(field: string, value: string) => {
|
||||||
Modal.confirm({
|
if (!canLogRef.current) return;
|
||||||
|
modalRef.current = Modal.confirm({
|
||||||
title: '查看敏感信息',
|
title: '查看敏感信息',
|
||||||
content: `您即将查看 "${field}" 的完整信息。此操作将被记录。`,
|
content: `您即将查看 "${field}" 的完整信息。此操作将被记录。`,
|
||||||
okText: '确认查看',
|
okText: '确认查看',
|
||||||
cancelText: '取消',
|
cancelText: '取消',
|
||||||
onOk: async () => {
|
onOk: async () => {
|
||||||
|
if (!canLogRef.current) return;
|
||||||
try {
|
try {
|
||||||
await api.post('/operation-logs/audit', {
|
await api.post('/operation-logs/audit', {
|
||||||
module,
|
module,
|
||||||
@@ -37,6 +56,9 @@ export function useViewSensitive(studentId: number, module: string) {
|
|||||||
okText: '关闭',
|
okText: '关闭',
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
afterClose: () => {
|
||||||
|
modalRef.current = null;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[studentId, module],
|
[studentId, module],
|
||||||
|
|||||||
@@ -86,25 +86,57 @@ const MainLayout: React.FC = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
beginPermissionVerification();
|
let retryTimer: number | undefined;
|
||||||
api
|
let verificationInFlight = false;
|
||||||
.get<{ id: number; username: string; permissions: string[]; roles?: string[] }>(
|
|
||||||
'/auth/profile',
|
const verifyPermissions = () => {
|
||||||
)
|
if (cancelled || verificationInFlight || !localStorage.getItem('token')) return;
|
||||||
.then((profile) => {
|
if (retryTimer !== undefined) {
|
||||||
if (cancelled) return;
|
window.clearTimeout(retryTimer);
|
||||||
writePermissions(profile.permissions || []);
|
retryTimer = undefined;
|
||||||
const cachedUser = JSON.parse(localStorage.getItem('user') || '{}');
|
}
|
||||||
const nextUser = { ...cachedUser, ...profile };
|
verificationInFlight = true;
|
||||||
localStorage.setItem('user', JSON.stringify(nextUser));
|
beginPermissionVerification();
|
||||||
setUser(nextUser);
|
api
|
||||||
})
|
.get<{ id: number; username: string; permissions: string[]; roles?: string[] }>(
|
||||||
.catch(() => {
|
'/auth/profile',
|
||||||
if (!cancelled) clearPermissions('ready');
|
)
|
||||||
// The API interceptor handles expired/invalid sessions.
|
.then((profile) => {
|
||||||
});
|
if (cancelled) return;
|
||||||
|
verificationInFlight = false;
|
||||||
|
writePermissions(profile.permissions || []);
|
||||||
|
const cachedUser = JSON.parse(localStorage.getItem('user') || '{}');
|
||||||
|
const nextUser = { ...cachedUser, ...profile };
|
||||||
|
localStorage.setItem('user', JSON.stringify(nextUser));
|
||||||
|
setUser(nextUser);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
verificationInFlight = false;
|
||||||
|
if (cancelled || !localStorage.getItem('token')) return;
|
||||||
|
retryTimer = window.setTimeout(verifyPermissions, 5_000);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStorage = (event: StorageEvent) => {
|
||||||
|
if (event.key !== 'token' && event.key !== 'permissions') return;
|
||||||
|
beginPermissionVerification();
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
const handleOnline = () => verifyPermissions();
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
if (document.visibilityState === 'visible') verifyPermissions();
|
||||||
|
};
|
||||||
|
|
||||||
|
verifyPermissions();
|
||||||
|
window.addEventListener('storage', handleStorage);
|
||||||
|
window.addEventListener('online', handleOnline);
|
||||||
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
if (retryTimer !== undefined) window.clearTimeout(retryTimer);
|
||||||
|
window.removeEventListener('storage', handleStorage);
|
||||||
|
window.removeEventListener('online', handleOnline);
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -1146,9 +1146,9 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) =>
|
|||||||
value={studentSearch}
|
value={studentSearch}
|
||||||
onChange={(event) => setStudentSearch(event.target.value)}
|
onChange={(event) => setStudentSearch(event.target.value)}
|
||||||
/>
|
/>
|
||||||
<Button icon={<ExportOutlined />} onClick={handleExport}>
|
<PermissionButton permission="attendance:export" icon={<ExportOutlined />} onClick={handleExport}>
|
||||||
导出
|
导出
|
||||||
</Button>
|
</PermissionButton>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div className="student-legend">
|
<div className="student-legend">
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ interface ExamDetail extends ExamItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const PhoneCell: React.FC<{ row: ScoreRow }> = ({ row }) => {
|
const PhoneCell: React.FC<{ row: ScoreRow }> = ({ row }) => {
|
||||||
const reveal = useViewSensitive(row.studentId, '考试管理');
|
|
||||||
const { hasPermission } = usePermission();
|
const { hasPermission } = usePermission();
|
||||||
|
const reveal = useViewSensitive(row.studentId, '考试管理', hasPermission('log:create'));
|
||||||
if (!row.phone) return <>-</>;
|
if (!row.phone) return <>-</>;
|
||||||
return (
|
return (
|
||||||
<Space size={4}>
|
<Space size={4}>
|
||||||
|
|||||||
@@ -38,8 +38,11 @@ import { usePermission } from '../../hooks/usePermission';
|
|||||||
const { RangePicker } = DatePicker;
|
const { RangePicker } = DatePicker;
|
||||||
|
|
||||||
const OccupanciesPage: React.FC = () => {
|
const OccupanciesPage: React.FC = () => {
|
||||||
const { hasPermission } = usePermission();
|
const { hasPermission, permissionsReady } = usePermission();
|
||||||
const canCheckIn = hasPermission('occupancy:checkin');
|
const canCheckIn = permissionsReady && hasPermission('occupancy:checkin');
|
||||||
|
const canCheckOut = permissionsReady && hasPermission('occupancy:checkout');
|
||||||
|
const canTransfer = permissionsReady && hasPermission('occupancy:transfer');
|
||||||
|
const canDelete = permissionsReady && hasPermission('occupancy:delete');
|
||||||
const [data, setData] = useState<any[]>([]);
|
const [data, setData] = useState<any[]>([]);
|
||||||
const [students, setStudents] = useState<any[]>([]);
|
const [students, setStudents] = useState<any[]>([]);
|
||||||
const [rooms, setRooms] = useState<any[]>([]);
|
const [rooms, setRooms] = useState<any[]>([]);
|
||||||
@@ -69,6 +72,12 @@ const OccupanciesPage: React.FC = () => {
|
|||||||
const selectedCheckInRoomId = Form.useWatch('roomId', checkInForm);
|
const selectedCheckInRoomId = Form.useWatch('roomId', checkInForm);
|
||||||
const selectedTransferRoomId = Form.useWatch('newRoomId', transferForm);
|
const selectedTransferRoomId = Form.useWatch('newRoomId', transferForm);
|
||||||
|
|
||||||
|
// Close modals when the user loses the required permission
|
||||||
|
useEffect(() => { if (!canCheckIn) { setCheckInModal(false); checkInForm.resetFields(); } }, [canCheckIn, checkInForm]);
|
||||||
|
useEffect(() => { if (!canCheckOut && checkOutModal) { setCheckOutModal(null); checkOutForm.resetFields(); } }, [canCheckOut, checkOutModal, checkOutForm]);
|
||||||
|
useEffect(() => { if (!canCheckOut) { setBatchCheckOutModal(false); batchCheckOutForm.resetFields(); } }, [canCheckOut, batchCheckOutForm]);
|
||||||
|
useEffect(() => { if (!canTransfer && transferModal) { setTransferModal(null); transferForm.resetFields(); } }, [canTransfer, transferModal, transferForm]);
|
||||||
|
|
||||||
const activeOccupancyByStudentId = useMemo(() => {
|
const activeOccupancyByStudentId = useMemo(() => {
|
||||||
const map = new Map<number, any>();
|
const map = new Map<number, any>();
|
||||||
data.forEach((item) => {
|
data.forEach((item) => {
|
||||||
@@ -372,27 +381,28 @@ const OccupanciesPage: React.FC = () => {
|
|||||||
) : (
|
) : (
|
||||||
<Space>
|
<Space>
|
||||||
<Tag>已退宿</Tag>
|
<Tag>已退宿</Tag>
|
||||||
<Popconfirm
|
{canDelete ? (
|
||||||
title="确定归档此记录?"
|
<Popconfirm
|
||||||
onConfirm={async () => {
|
title="确定归档此记录?"
|
||||||
try {
|
onConfirm={async () => {
|
||||||
await api.delete(`/occupancies/${record.id}`);
|
try {
|
||||||
message.success('归档成功');
|
await api.delete(`/occupancies/${record.id}`);
|
||||||
fetchData();
|
message.success('归档成功');
|
||||||
} catch (e: any) {
|
fetchData();
|
||||||
message.error(e?.message || '归档失败');
|
} catch (e: any) {
|
||||||
}
|
message.error(e?.message || '归档失败');
|
||||||
}}
|
}
|
||||||
>
|
}}
|
||||||
<PermissionButton
|
|
||||||
permission="occupancy:delete"
|
|
||||||
size="small"
|
|
||||||
danger
|
|
||||||
icon={<InboxOutlined />}
|
|
||||||
>
|
>
|
||||||
归档
|
<Button
|
||||||
</PermissionButton>
|
size="small"
|
||||||
</Popconfirm>
|
danger
|
||||||
|
icon={<InboxOutlined />}
|
||||||
|
>
|
||||||
|
归档
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
) : null}
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -585,23 +595,24 @@ const OccupanciesPage: React.FC = () => {
|
|||||||
批量退宿
|
批量退宿
|
||||||
</PermissionButton>
|
</PermissionButton>
|
||||||
) : (
|
) : (
|
||||||
<Popconfirm
|
canDelete ? (
|
||||||
title={`确定归档选中的 ${selectedRowKeys.length} 条入住记录?在住记录会自动跳过`}
|
<Popconfirm
|
||||||
onConfirm={handleBatchDelete}
|
title={`确定归档选中的 ${selectedRowKeys.length} 条入住记录?在住记录会自动跳过`}
|
||||||
okText="归档"
|
onConfirm={handleBatchDelete}
|
||||||
cancelText="取消"
|
okText="归档"
|
||||||
>
|
cancelText="取消"
|
||||||
<PermissionButton
|
|
||||||
permission="occupancy:delete"
|
|
||||||
danger
|
|
||||||
size="small"
|
|
||||||
icon={<InboxOutlined />}
|
|
||||||
style={{ marginLeft: 12 }}
|
|
||||||
loading={batchLoading}
|
|
||||||
>
|
>
|
||||||
批量归档
|
<Button
|
||||||
</PermissionButton>
|
danger
|
||||||
</Popconfirm>
|
size="small"
|
||||||
|
icon={<InboxOutlined />}
|
||||||
|
style={{ marginLeft: 12 }}
|
||||||
|
loading={batchLoading}
|
||||||
|
>
|
||||||
|
批量归档
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
) : null
|
||||||
)}
|
)}
|
||||||
<Button size="small" onClick={() => setSelectedRowKeys([])} style={{ marginLeft: 8 }}>
|
<Button size="small" onClick={() => setSelectedRowKeys([])} style={{ marginLeft: 8 }}>
|
||||||
取消选择
|
取消选择
|
||||||
@@ -629,8 +640,8 @@ const OccupanciesPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
<Modal
|
<Modal
|
||||||
title="入住登记"
|
title="入住登记"
|
||||||
open={checkInModal}
|
open={checkInModal && canCheckIn}
|
||||||
onOk={handleCheckIn}
|
onOk={canCheckIn ? handleCheckIn : undefined}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
setCheckInModal(false);
|
setCheckInModal(false);
|
||||||
setAvailableBeds([]);
|
setAvailableBeds([]);
|
||||||
@@ -804,8 +815,8 @@ const OccupanciesPage: React.FC = () => {
|
|||||||
{/* 退宿弹窗 */}
|
{/* 退宿弹窗 */}
|
||||||
<Modal
|
<Modal
|
||||||
title={`退宿 - ${checkOutModal?.student?.name}`}
|
title={`退宿 - ${checkOutModal?.student?.name}`}
|
||||||
open={!!checkOutModal}
|
open={!!checkOutModal && canCheckOut}
|
||||||
onOk={handleCheckOut}
|
onOk={canCheckOut ? handleCheckOut : undefined}
|
||||||
onCancel={() => setCheckOutModal(null)}
|
onCancel={() => setCheckOutModal(null)}
|
||||||
okText="确认退宿"
|
okText="确认退宿"
|
||||||
confirmLoading={saving}
|
confirmLoading={saving}
|
||||||
@@ -861,8 +872,8 @@ const OccupanciesPage: React.FC = () => {
|
|||||||
{/* 批量退宿弹窗 */}
|
{/* 批量退宿弹窗 */}
|
||||||
<Modal
|
<Modal
|
||||||
title={`批量退宿(${selectedRowKeys.length} 人)`}
|
title={`批量退宿(${selectedRowKeys.length} 人)`}
|
||||||
open={batchCheckOutModal}
|
open={batchCheckOutModal && canCheckOut}
|
||||||
onOk={handleBatchCheckOut}
|
onOk={canCheckOut ? handleBatchCheckOut : undefined}
|
||||||
onCancel={() => setBatchCheckOutModal(false)}
|
onCancel={() => setBatchCheckOutModal(false)}
|
||||||
okText="确认批量退宿"
|
okText="确认批量退宿"
|
||||||
width={500}
|
width={500}
|
||||||
@@ -938,7 +949,7 @@ const OccupanciesPage: React.FC = () => {
|
|||||||
{/* 换房弹窗 */}
|
{/* 换房弹窗 */}
|
||||||
<Modal
|
<Modal
|
||||||
title={`换房 - ${transferModal?.student?.name}`}
|
title={`换房 - ${transferModal?.student?.name}`}
|
||||||
open={!!transferModal}
|
open={!!transferModal && canTransfer}
|
||||||
onOk={handleTransfer}
|
onOk={handleTransfer}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
setTransferModal(null);
|
setTransferModal(null);
|
||||||
|
|||||||
@@ -85,12 +85,15 @@ function parseRoomNumber(input: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const RoomsPage: React.FC = () => {
|
const RoomsPage: React.FC = () => {
|
||||||
const { hasPermission } = usePermission();
|
const { hasPermission, permissionsReady } = usePermission();
|
||||||
const canEditRooms = hasPermission('room:edit');
|
const canEditRooms = permissionsReady && hasPermission('room:edit');
|
||||||
|
const canCreateRooms = permissionsReady && hasPermission('room:create');
|
||||||
|
const canDeleteRooms = permissionsReady && hasPermission('room:delete');
|
||||||
const [data, setData] = useState<any[]>([]);
|
const [data, setData] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState<any>(null);
|
const [editing, setEditing] = useState<any>(null);
|
||||||
|
const canSaveRoom = editing ? canEditRooms : canCreateRooms;
|
||||||
const [showArchived, setShowArchived] = useState(false);
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
const [archivedCount, setArchivedCount] = useState(0);
|
const [archivedCount, setArchivedCount] = useState(0);
|
||||||
const [searchText, setSearchText] = useState('');
|
const [searchText, setSearchText] = useState('');
|
||||||
@@ -114,6 +117,9 @@ const RoomsPage: React.FC = () => {
|
|||||||
const [savingLocker, setSavingLocker] = useState(false);
|
const [savingLocker, setSavingLocker] = useState(false);
|
||||||
const [batchLoading, setBatchLoading] = useState(false);
|
const [batchLoading, setBatchLoading] = useState(false);
|
||||||
|
|
||||||
|
// Close modals when the required permission is lost
|
||||||
|
useEffect(() => { if (!canSaveRoom && modalOpen) { setModalOpen(false); setEditing(null); form.resetFields(); } }, [canSaveRoom, modalOpen, form]);
|
||||||
|
|
||||||
const handleBatchDelete = async () => {
|
const handleBatchDelete = async () => {
|
||||||
setBatchLoading(true);
|
setBatchLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -522,16 +528,17 @@ const RoomsPage: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<Space>
|
<Space>
|
||||||
{r.status === 'archived' ? (
|
{r.status === 'archived' ? (
|
||||||
<Popconfirm title="确定恢复此宿舍?" onConfirm={() => handleRestore(r.id)}>
|
canEditRooms ? (
|
||||||
<PermissionButton
|
<Popconfirm title="确定恢复此宿舍?" onConfirm={() => handleRestore(r.id)}>
|
||||||
permission="room:edit"
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
icon={<UndoOutlined />}
|
icon={<UndoOutlined />}
|
||||||
type="link"
|
type="link"
|
||||||
>
|
>
|
||||||
恢复
|
恢复
|
||||||
</PermissionButton>
|
</Button>
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
|
) : null
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<PermissionButton
|
<PermissionButton
|
||||||
@@ -559,15 +566,16 @@ const RoomsPage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
编辑
|
编辑
|
||||||
</PermissionButton>
|
</PermissionButton>
|
||||||
<Popconfirm title="确定归档?" onConfirm={() => handleArchive(r.id)}>
|
{canDeleteRooms ? (
|
||||||
<PermissionButton
|
<Popconfirm title="确定归档?" onConfirm={() => handleArchive(r.id)}>
|
||||||
permission="room:delete"
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
icon={<InboxOutlined />}
|
icon={<InboxOutlined />}
|
||||||
>
|
>
|
||||||
归档
|
归档
|
||||||
</PermissionButton>
|
</Button>
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
@@ -638,23 +646,24 @@ const RoomsPage: React.FC = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
<Space wrap className="responsive-toolbar__group">
|
<Space wrap className="responsive-toolbar__group">
|
||||||
<Popconfirm
|
{canDeleteRooms ? (
|
||||||
title={`确定批量归档选中的 ${selectedRowKeys.length} 间宿舍?(有在住人员的会跳过)`}
|
<Popconfirm
|
||||||
onConfirm={handleBatchDelete}
|
title={`确定批量归档选中的 ${selectedRowKeys.length} 间宿舍?(有在住人员的会跳过)`}
|
||||||
okText="归档"
|
onConfirm={handleBatchDelete}
|
||||||
cancelText="取消"
|
okText="归档"
|
||||||
disabled={selectedRowKeys.length === 0}
|
cancelText="取消"
|
||||||
>
|
|
||||||
<PermissionButton
|
|
||||||
permission="room:delete"
|
|
||||||
danger
|
|
||||||
icon={<InboxOutlined />}
|
|
||||||
disabled={selectedRowKeys.length === 0}
|
disabled={selectedRowKeys.length === 0}
|
||||||
loading={batchLoading}
|
|
||||||
>
|
>
|
||||||
批量归档
|
<Button
|
||||||
</PermissionButton>
|
danger
|
||||||
</Popconfirm>
|
icon={<InboxOutlined />}
|
||||||
|
disabled={selectedRowKeys.length === 0}
|
||||||
|
loading={batchLoading}
|
||||||
|
>
|
||||||
|
批量归档
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
) : null}
|
||||||
<PermissionButton
|
<PermissionButton
|
||||||
permission="room:create"
|
permission="room:create"
|
||||||
type="primary"
|
type="primary"
|
||||||
@@ -732,8 +741,8 @@ const RoomsPage: React.FC = () => {
|
|||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title={editing ? '编辑宿舍' : '添加宿舍'}
|
title={editing ? '编辑宿舍' : '添加宿舍'}
|
||||||
open={modalOpen}
|
open={modalOpen && canSaveRoom}
|
||||||
onOk={handleSave}
|
onOk={canSaveRoom ? handleSave : undefined}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
setModalOpen(false);
|
setModalOpen(false);
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
@@ -994,20 +1003,19 @@ const RoomsPage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
编辑
|
编辑
|
||||||
</PermissionButton>
|
</PermissionButton>
|
||||||
{r.status !== 'occupied' && (
|
{r.status !== 'occupied' && canEditRooms && (
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
title="确定归档?"
|
title="确定归档?"
|
||||||
onConfirm={() => handleDeleteBed(r.id)}
|
onConfirm={() => handleDeleteBed(r.id)}
|
||||||
>
|
>
|
||||||
<PermissionButton
|
<Button
|
||||||
permission="room:edit"
|
|
||||||
size="small"
|
size="small"
|
||||||
type="link"
|
type="link"
|
||||||
danger
|
danger
|
||||||
disabled={drawerRoom?.status === 'archived'}
|
disabled={drawerRoom?.status === 'archived'}
|
||||||
>
|
>
|
||||||
归档
|
归档
|
||||||
</PermissionButton>
|
</Button>
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
@@ -1147,20 +1155,19 @@ const RoomsPage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
编辑
|
编辑
|
||||||
</PermissionButton>
|
</PermissionButton>
|
||||||
{r.status !== 'occupied' && (
|
{r.status !== 'occupied' && canEditRooms && (
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
title="确定归档?"
|
title="确定归档?"
|
||||||
onConfirm={() => handleDeleteLocker(r.id)}
|
onConfirm={() => handleDeleteLocker(r.id)}
|
||||||
>
|
>
|
||||||
<PermissionButton
|
<Button
|
||||||
permission="room:edit"
|
|
||||||
size="small"
|
size="small"
|
||||||
type="link"
|
type="link"
|
||||||
danger
|
danger
|
||||||
disabled={drawerRoom?.status === 'archived'}
|
disabled={drawerRoom?.status === 'archived'}
|
||||||
>
|
>
|
||||||
归档
|
归档
|
||||||
</PermissionButton>
|
</Button>
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
|
|||||||
@@ -85,15 +85,24 @@ interface StudentFilterLookups {
|
|||||||
|
|
||||||
const StudentsPage: React.FC = () => {
|
const StudentsPage: React.FC = () => {
|
||||||
const { modal } = App.useApp();
|
const { modal } = App.useApp();
|
||||||
const { hasPermission, hasAnyPermission } = usePermission();
|
const { hasPermission, hasAnyPermission, hasAllPermissions } = usePermission();
|
||||||
const canViewOrganizations = hasPermission('organization:view');
|
const canViewOrganizations = hasPermission('organization:view');
|
||||||
const canChooseOrganization =
|
const canLoadOrganizations = hasAnyPermission(
|
||||||
canViewOrganizations && hasAnyPermission('student:create', 'student:edit');
|
'organization:view',
|
||||||
|
'student:create',
|
||||||
|
'student:edit',
|
||||||
|
);
|
||||||
|
const canChooseOrganization = hasAnyPermission('student:create', 'student:edit');
|
||||||
|
const canCreateStudent = hasPermission('student:create');
|
||||||
|
const canEditStudent = hasPermission('student:edit');
|
||||||
|
const canDeleteStudent = hasPermission('student:delete');
|
||||||
|
const canSyncJinshuju = hasAllPermissions('sync:read', 'sync:trigger');
|
||||||
const [data, setData] = useState<any[]>([]);
|
const [data, setData] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [organizations, setOrganizations] = useState<any[]>([]);
|
const [organizations, setOrganizations] = useState<any[]>([]);
|
||||||
const [editing, setEditing] = useState<any>(null);
|
const [editing, setEditing] = useState<any>(null);
|
||||||
|
const canSaveStudent = editing ? canEditStudent : canCreateStudent;
|
||||||
const [searchName, setSearchName] = useState('');
|
const [searchName, setSearchName] = useState('');
|
||||||
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
|
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
|
||||||
const [filterOrganizationId, setFilterOrganizationId] = useState<number | undefined>(undefined);
|
const [filterOrganizationId, setFilterOrganizationId] = useState<number | undefined>(undefined);
|
||||||
@@ -118,13 +127,40 @@ const StudentsPage: React.FC = () => {
|
|||||||
|
|
||||||
const [jinshujuOpen, setJinshujuOpen] = useState(false);
|
const [jinshujuOpen, setJinshujuOpen] = useState(false);
|
||||||
|
|
||||||
|
// Sensitive info modal — command-style; destroy when log:create is lost or comp unmounts.
|
||||||
|
// Close the student form modal when the user loses the required permission.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canSaveStudent && modalOpen) {
|
||||||
|
setModalOpen(false);
|
||||||
|
setEditing(null);
|
||||||
|
form.resetFields();
|
||||||
|
}
|
||||||
|
}, [canSaveStudent, modalOpen, form]);
|
||||||
|
|
||||||
|
// Close sensitive modal when log:create is lost (imperative ref already set above).
|
||||||
|
const logCreateRef = React.useRef(hasPermission('log:create'));
|
||||||
|
const sensitiveModalRef = React.useRef<ReturnType<typeof modal.confirm> | null>(null);
|
||||||
|
logCreateRef.current = hasPermission('log:create');
|
||||||
|
useEffect(() => {
|
||||||
|
if (!logCreateRef.current && sensitiveModalRef.current) {
|
||||||
|
sensitiveModalRef.current.destroy();
|
||||||
|
sensitiveModalRef.current = null;
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
sensitiveModalRef.current?.destroy();
|
||||||
|
sensitiveModalRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleViewSensitive = (studentId: number, field: string, value: string) => {
|
const handleViewSensitive = (studentId: number, field: string, value: string) => {
|
||||||
modal.confirm({
|
if (!logCreateRef.current) return;
|
||||||
|
sensitiveModalRef.current = modal.confirm({
|
||||||
title: '查看敏感信息',
|
title: '查看敏感信息',
|
||||||
content: `您即将查看 "${field}" 的完整信息。此操作将被记录。`,
|
content: `您即将查看 "${field}" 的完整信息。此操作将被记录。`,
|
||||||
okText: '确认查看',
|
okText: '确认查看',
|
||||||
cancelText: '取消',
|
cancelText: '取消',
|
||||||
onOk: async () => {
|
onOk: async () => {
|
||||||
|
if (!logCreateRef.current) return;
|
||||||
try {
|
try {
|
||||||
await api.post('/operation-logs/audit', {
|
await api.post('/operation-logs/audit', {
|
||||||
module: '学生管理',
|
module: '学生管理',
|
||||||
@@ -142,6 +178,9 @@ const StudentsPage: React.FC = () => {
|
|||||||
message.error('审计日志记录失败,请稍后重试');
|
message.error('审计日志记录失败,请稍后重试');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
afterClose: () => {
|
||||||
|
sensitiveModalRef.current = null;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -194,16 +233,25 @@ const StudentsPage: React.FC = () => {
|
|||||||
}, [fetchData]);
|
}, [fetchData]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!canLoadOrganizations) {
|
||||||
|
setOrganizations([]);
|
||||||
|
setFilterOrganizationId(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (canViewOrganizations) {
|
if (canViewOrganizations) {
|
||||||
api
|
api
|
||||||
.get('/organizations', { params: { includeArchived: 'false' } })
|
.get('/organizations', { params: { includeArchived: 'false' } })
|
||||||
.then((res: unknown) => {
|
.then((res: unknown) => {
|
||||||
setOrganizations(res as Array<{ id: number; name: string }>);
|
setOrganizations(res as Array<{ id: number; name: string; isHost?: boolean }>);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
} else {
|
} else {
|
||||||
setOrganizations([]);
|
api
|
||||||
setFilterOrganizationId(undefined);
|
.get('/organizations/options')
|
||||||
|
.then((res: unknown) => {
|
||||||
|
setOrganizations(res as Array<{ id: number; name: string; isHost?: boolean }>);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
api
|
api
|
||||||
.get<StudentFilterLookups>('/students/filter-lookups')
|
.get<StudentFilterLookups>('/students/filter-lookups')
|
||||||
@@ -212,7 +260,7 @@ const StudentsPage: React.FC = () => {
|
|||||||
setTeacherOptions(res.teachers || []);
|
setTeacherOptions(res.teachers || []);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, [canViewOrganizations]);
|
}, [canLoadOrganizations]);
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
const values = await form.validateFields();
|
const values = await form.validateFields();
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
@@ -614,21 +662,18 @@ const StudentsPage: React.FC = () => {
|
|||||||
render: (_: any, record: any) => (
|
render: (_: any, record: any) => (
|
||||||
<Space>
|
<Space>
|
||||||
{record.status === 'archived' ? (
|
{record.status === 'archived' ? (
|
||||||
<Popconfirm
|
canEditStudent ? (
|
||||||
title="确定恢复此学生?恢复后将重新出现在学生列表中。"
|
<Popconfirm
|
||||||
onConfirm={() => handleRestore(record.id)}
|
title="确定恢复此学生?恢复后将重新出现在学生列表中。"
|
||||||
okText="恢复"
|
onConfirm={() => handleRestore(record.id)}
|
||||||
cancelText="取消"
|
okText="恢复"
|
||||||
>
|
cancelText="取消"
|
||||||
<PermissionButton
|
|
||||||
permission="student:edit"
|
|
||||||
size="small"
|
|
||||||
icon={<UndoOutlined />}
|
|
||||||
type="link"
|
|
||||||
>
|
>
|
||||||
恢复
|
<Button size="small" icon={<UndoOutlined />} type="link">
|
||||||
</PermissionButton>
|
恢复
|
||||||
</Popconfirm>
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
) : null
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<PermissionButton
|
<PermissionButton
|
||||||
@@ -650,20 +695,21 @@ const StudentsPage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
编辑
|
编辑
|
||||||
</PermissionButton>
|
</PermissionButton>
|
||||||
<Popconfirm
|
{canDeleteStudent ? (
|
||||||
title="归档后不会删除数据,可随时恢复。确定归档?"
|
<Popconfirm
|
||||||
onConfirm={() => handleArchive(record.id)}
|
title="归档后不会删除数据,可随时恢复。确定归档?"
|
||||||
okText="归档"
|
onConfirm={() => handleArchive(record.id)}
|
||||||
cancelText="取消"
|
okText="归档"
|
||||||
>
|
cancelText="取消"
|
||||||
<PermissionButton
|
>
|
||||||
permission="student:delete"
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
icon={<InboxOutlined />}
|
icon={<InboxOutlined />}
|
||||||
>
|
>
|
||||||
归档
|
归档
|
||||||
</PermissionButton>
|
</Button>
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
@@ -765,23 +811,24 @@ const StudentsPage: React.FC = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
<Space wrap className="responsive-toolbar__group">
|
<Space wrap className="responsive-toolbar__group">
|
||||||
<Popconfirm
|
{canDeleteStudent ? (
|
||||||
title={`确定批量归档选中的 ${selectedRowKeys.length} 名学生?(数据保留,可恢复)`}
|
<Popconfirm
|
||||||
onConfirm={handleBatchDelete}
|
title={`确定批量归档选中的 ${selectedRowKeys.length} 名学生?(数据保留,可恢复)`}
|
||||||
okText="归档"
|
onConfirm={handleBatchDelete}
|
||||||
cancelText="取消"
|
okText="归档"
|
||||||
disabled={selectedRowKeys.length === 0}
|
cancelText="取消"
|
||||||
>
|
|
||||||
<PermissionButton
|
|
||||||
permission="student:delete"
|
|
||||||
danger
|
|
||||||
icon={<InboxOutlined />}
|
|
||||||
disabled={selectedRowKeys.length === 0}
|
disabled={selectedRowKeys.length === 0}
|
||||||
loading={batchLoading}
|
|
||||||
>
|
>
|
||||||
批量归档
|
<Button
|
||||||
</PermissionButton>
|
danger
|
||||||
</Popconfirm>
|
icon={<InboxOutlined />}
|
||||||
|
disabled={selectedRowKeys.length === 0}
|
||||||
|
loading={batchLoading}
|
||||||
|
>
|
||||||
|
批量归档
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
) : null}
|
||||||
<PermissionButton
|
<PermissionButton
|
||||||
permission="student:create"
|
permission="student:create"
|
||||||
type="primary"
|
type="primary"
|
||||||
@@ -814,13 +861,11 @@ const StudentsPage: React.FC = () => {
|
|||||||
</Upload>
|
</Upload>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
<PermissionButton
|
{canSyncJinshuju ? (
|
||||||
permission="sync:read"
|
<Button icon={<CloudUploadOutlined />} onClick={() => setJinshujuOpen(true)}>
|
||||||
icon={<CloudUploadOutlined />}
|
同步金数据
|
||||||
onClick={() => setJinshujuOpen(true)}
|
</Button>
|
||||||
>
|
) : null}
|
||||||
同步金数据
|
|
||||||
</PermissionButton>
|
|
||||||
<PermissionButton
|
<PermissionButton
|
||||||
permission="student:view"
|
permission="student:view"
|
||||||
icon={<DownloadOutlined />}
|
icon={<DownloadOutlined />}
|
||||||
@@ -929,8 +974,8 @@ const StudentsPage: React.FC = () => {
|
|||||||
title={editing ? '编辑学生' : '添加学生'}
|
title={editing ? '编辑学生' : '添加学生'}
|
||||||
className="student-form-modal"
|
className="student-form-modal"
|
||||||
width={720}
|
width={720}
|
||||||
open={modalOpen}
|
open={modalOpen && canSaveStudent}
|
||||||
onOk={handleSave}
|
onOk={canSaveStudent ? handleSave : undefined}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
setModalOpen(false);
|
setModalOpen(false);
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
@@ -1007,7 +1052,7 @@ const StudentsPage: React.FC = () => {
|
|||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{hasPermission('sync:read') ? (
|
{canSyncJinshuju ? (
|
||||||
<JinshujuMatchModal
|
<JinshujuMatchModal
|
||||||
open={jinshujuOpen}
|
open={jinshujuOpen}
|
||||||
onClose={() => setJinshujuOpen(false)}
|
onClose={() => setJinshujuOpen(false)}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { PERMISSION_KEY } from '../auth/decorators/permission.decorator';
|
||||||
|
import { OrganizationsController } from './organizations.controller';
|
||||||
|
|
||||||
|
describe('OrganizationsController permissions', () => {
|
||||||
|
it('allows student editors to use the options endpoint without full entity exposure', () => {
|
||||||
|
expect(
|
||||||
|
Reflect.getMetadata(PERMISSION_KEY, OrganizationsController.prototype.findOptions),
|
||||||
|
).toEqual(['organization:view', 'student:create', 'student:edit']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the full entity list restricted to organization viewers only', () => {
|
||||||
|
expect(Reflect.getMetadata(PERMISSION_KEY, OrganizationsController.prototype.findAll)).toEqual([
|
||||||
|
'organization:view',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps organization detail restricted to organization viewers', () => {
|
||||||
|
expect(Reflect.getMetadata(PERMISSION_KEY, OrganizationsController.prototype.findOne)).toEqual([
|
||||||
|
'organization:view',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -25,6 +25,12 @@ export class OrganizationsController {
|
|||||||
private logService: OperationLogsService,
|
private logService: OperationLogsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
@Get('options')
|
||||||
|
@RequirePermission('organization:view', 'student:create', 'student:edit')
|
||||||
|
findOptions() {
|
||||||
|
return this.service.findOptions();
|
||||||
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@RequirePermission('organization:view')
|
@RequirePermission('organization:view')
|
||||||
findAll(
|
findAll(
|
||||||
|
|||||||
@@ -27,4 +27,21 @@ describe('OrganizationsService — host organization rules', () => {
|
|||||||
await expect(service.remove(1)).rejects.toBeInstanceOf(BadRequestException);
|
await expect(service.remove(1)).rejects.toBeInstanceOf(BadRequestException);
|
||||||
expect(repo.update).not.toHaveBeenCalled();
|
expect(repo.update).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('findOptions returns only id, name, isHost for active organizations', async () => {
|
||||||
|
const orgs = [
|
||||||
|
{ id: 1, name: '本机构', isHost: true },
|
||||||
|
{ id: 2, name: '分校', isHost: false },
|
||||||
|
];
|
||||||
|
repo.find.mockResolvedValue(orgs as Organization[]);
|
||||||
|
|
||||||
|
const result = await service.findOptions();
|
||||||
|
|
||||||
|
expect(repo.find).toHaveBeenCalledWith({
|
||||||
|
select: ['id', 'name', 'isHost'],
|
||||||
|
where: { status: 'active' },
|
||||||
|
order: { isHost: 'DESC', name: 'ASC' },
|
||||||
|
});
|
||||||
|
expect(result).toEqual(orgs);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,6 +30,14 @@ export class OrganizationsService {
|
|||||||
return this.repo.find({ where, order: { isHost: 'DESC', name: 'ASC' } });
|
return this.repo.find({ where, order: { isHost: 'DESC', name: 'ASC' } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findOptions() {
|
||||||
|
return this.repo.find({
|
||||||
|
select: ['id', 'name', 'isHost'] as const,
|
||||||
|
where: { status: 'active' },
|
||||||
|
order: { isHost: 'DESC' as const, name: 'ASC' as const },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async findOne(id: number) {
|
async findOne(id: number) {
|
||||||
const organization = await this.repo.findOne({ where: { id } });
|
const organization = await this.repo.findOne({ where: { id } });
|
||||||
if (!organization) throw new NotFoundException('机构不存在');
|
if (!organization) throw new NotFoundException('机构不存在');
|
||||||
|
|||||||
@@ -33,9 +33,8 @@ export class CreateStudentDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
emergencyPhone?: string;
|
emergencyPhone?: string;
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsInt()
|
@IsInt()
|
||||||
organizationId?: number;
|
organizationId: number;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@@ -43,21 +43,6 @@ describe('StudentsService — archive lifecycle boundaries', () => {
|
|||||||
expect(repo.save).not.toHaveBeenCalled();
|
expect(repo.save).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('defaults a new student to the active host organization when none is supplied', async () => {
|
|
||||||
const repo = {
|
|
||||||
create: jest.fn((value) => value),
|
|
||||||
save: jest.fn(async (value) => ({ ...value, id: 1 })),
|
|
||||||
};
|
|
||||||
const organizationRepo = {
|
|
||||||
findOne: jest.fn().mockResolvedValue({ id: 7, isHost: true, status: 'active' }),
|
|
||||||
};
|
|
||||||
|
|
||||||
await expect(createService(repo, organizationRepo).create({ name: '张三' })).resolves.toEqual(
|
|
||||||
expect.objectContaining({ organizationId: 7 }),
|
|
||||||
);
|
|
||||||
expect(repo.create).toHaveBeenCalledWith(expect.objectContaining({ organizationId: 7 }));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns not found for a missing student', async () => {
|
it('returns not found for a missing student', async () => {
|
||||||
const repo = { findOne: jest.fn().mockResolvedValue(null) };
|
const repo = { findOne: jest.fn().mockResolvedValue(null) };
|
||||||
await expect(createService(repo).findOne(404)).rejects.toBeInstanceOf(NotFoundException);
|
await expect(createService(repo).findOne(404)).rejects.toBeInstanceOf(NotFoundException);
|
||||||
@@ -65,13 +50,11 @@ describe('StudentsService — archive lifecycle boundaries', () => {
|
|||||||
|
|
||||||
it('builds export archive maps from profile and result rows', async () => {
|
it('builds export archive maps from profile and result rows', async () => {
|
||||||
const profileRepo = {
|
const profileRepo = {
|
||||||
find: jest.fn().mockResolvedValue([
|
find: jest.fn().mockResolvedValue([{
|
||||||
{
|
studentId: 1,
|
||||||
studentId: 1,
|
targetCollege: '北京大学',
|
||||||
targetCollege: '北京大学',
|
collegeSchool: '北京职业技术学院',
|
||||||
collegeSchool: '北京职业技术学院',
|
}]),
|
||||||
},
|
|
||||||
]),
|
|
||||||
};
|
};
|
||||||
const resultRepo = {
|
const resultRepo = {
|
||||||
find: jest.fn().mockResolvedValue([{ studentId: 1, admissionStatus: 'pending' }]),
|
find: jest.fn().mockResolvedValue([{ studentId: 1, admissionStatus: 'pending' }]),
|
||||||
|
|||||||
@@ -164,9 +164,8 @@ export class StudentsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async create(dto: CreateStudentDto) {
|
async create(dto: CreateStudentDto) {
|
||||||
const organizationId = dto.organizationId || (await this.getHostOrganizationId());
|
await this.assertActiveOrganization(dto.organizationId);
|
||||||
await this.assertActiveOrganization(organizationId);
|
return this.repo.save(this.repo.create(dto));
|
||||||
return this.repo.save(this.repo.create({ ...dto, organizationId }));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(id: number, dto: UpdateStudentDto) {
|
async update(id: number, dto: UpdateStudentDto) {
|
||||||
@@ -315,9 +314,7 @@ export class StudentsService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizeImportData(
|
private normalizeImportData(importData: StudentWorkbookImport | StudentImportRow[]): StudentWorkbookImport {
|
||||||
importData: StudentWorkbookImport | StudentImportRow[],
|
|
||||||
): StudentWorkbookImport {
|
|
||||||
if (Array.isArray(importData)) {
|
if (Array.isArray(importData)) {
|
||||||
return { students: importData, enrollments: [], examScores: [], learningRecords: [] };
|
return { students: importData, enrollments: [], examScores: [], learningRecords: [] };
|
||||||
}
|
}
|
||||||
@@ -373,24 +370,18 @@ export class StudentsService {
|
|||||||
if (!phone) return imported;
|
if (!phone) return imported;
|
||||||
|
|
||||||
const enrollmentByClassName = new Map<string, StudentEnrollment>();
|
const enrollmentByClassName = new Map<string, StudentEnrollment>();
|
||||||
for (const enrollmentRow of data.enrollments.filter(
|
for (const enrollmentRow of data.enrollments.filter((item) => this.normalizePhone(item.phone) === phone)) {
|
||||||
(item) => this.normalizePhone(item.phone) === phone,
|
|
||||||
)) {
|
|
||||||
const enrollment = await this.upsertEnrollmentFromImport(studentId, enrollmentRow);
|
const enrollment = await this.upsertEnrollmentFromImport(studentId, enrollmentRow);
|
||||||
if (!enrollment) continue;
|
if (!enrollment) continue;
|
||||||
if (enrollment.className) enrollmentByClassName.set(enrollment.className, enrollment);
|
if (enrollment.className) enrollmentByClassName.set(enrollment.className, enrollment);
|
||||||
imported++;
|
imported++;
|
||||||
}
|
}
|
||||||
for (const examRow of data.examScores.filter(
|
for (const examRow of data.examScores.filter((item) => this.normalizePhone(item.phone) === phone)) {
|
||||||
(item) => this.normalizePhone(item.phone) === phone,
|
|
||||||
)) {
|
|
||||||
if (await this.upsertExamScoreFromImport(studentId, examRow, enrollmentByClassName)) {
|
if (await this.upsertExamScoreFromImport(studentId, examRow, enrollmentByClassName)) {
|
||||||
imported++;
|
imported++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const learningRow of data.learningRecords.filter(
|
for (const learningRow of data.learningRecords.filter((item) => this.normalizePhone(item.phone) === phone)) {
|
||||||
(item) => this.normalizePhone(item.phone) === phone,
|
|
||||||
)) {
|
|
||||||
if (await this.upsertLearningRecordFromImport(studentId, learningRow)) {
|
if (await this.upsertLearningRecordFromImport(studentId, learningRow)) {
|
||||||
imported++;
|
imported++;
|
||||||
}
|
}
|
||||||
@@ -399,9 +390,7 @@ export class StudentsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async upsertProfileFromImport(studentId: number, row: StudentImportRow) {
|
private async upsertProfileFromImport(studentId: number, row: StudentImportRow) {
|
||||||
const entity =
|
const entity = (await this.profileRepo.findOne({ where: { studentId } })) || this.profileRepo.create({ studentId });
|
||||||
(await this.profileRepo.findOne({ where: { studentId } })) ||
|
|
||||||
this.profileRepo.create({ studentId });
|
|
||||||
if (row.targetCollege?.trim()) entity.targetCollege = row.targetCollege.trim();
|
if (row.targetCollege?.trim()) entity.targetCollege = row.targetCollege.trim();
|
||||||
if (row.targetMajor?.trim()) entity.targetMajor = row.targetMajor.trim();
|
if (row.targetMajor?.trim()) entity.targetMajor = row.targetMajor.trim();
|
||||||
if (row.collegeSchool?.trim()) entity.collegeSchool = row.collegeSchool.trim();
|
if (row.collegeSchool?.trim()) entity.collegeSchool = row.collegeSchool.trim();
|
||||||
@@ -414,12 +403,9 @@ export class StudentsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async upsertResultFromImport(studentId: number, row: StudentImportRow) {
|
private async upsertResultFromImport(studentId: number, row: StudentImportRow) {
|
||||||
const entity =
|
const entity = (await this.resultRepo.findOne({ where: { studentId } })) || this.resultRepo.create({ studentId });
|
||||||
(await this.resultRepo.findOne({ where: { studentId } })) ||
|
|
||||||
this.resultRepo.create({ studentId });
|
|
||||||
if (row.cultureFinalScore !== undefined) entity.cultureFinalScore = row.cultureFinalScore;
|
if (row.cultureFinalScore !== undefined) entity.cultureFinalScore = row.cultureFinalScore;
|
||||||
if (row.professionalFinalScore !== undefined)
|
if (row.professionalFinalScore !== undefined) entity.professionalFinalScore = row.professionalFinalScore;
|
||||||
entity.professionalFinalScore = row.professionalFinalScore;
|
|
||||||
if (row.admissionStatus?.trim()) entity.admissionStatus = row.admissionStatus.trim();
|
if (row.admissionStatus?.trim()) entity.admissionStatus = row.admissionStatus.trim();
|
||||||
if (row.admittedCollege?.trim()) entity.admittedCollege = row.admittedCollege.trim();
|
if (row.admittedCollege?.trim()) entity.admittedCollege = row.admittedCollege.trim();
|
||||||
if (row.admittedMajor?.trim()) entity.admittedMajor = row.admittedMajor.trim();
|
if (row.admittedMajor?.trim()) entity.admittedMajor = row.admittedMajor.trim();
|
||||||
@@ -637,9 +623,10 @@ export class StudentsService {
|
|||||||
|
|
||||||
// ---- Filters ----
|
// ---- Filters ----
|
||||||
if (query?.keyword) {
|
if (query?.keyword) {
|
||||||
qb.andWhere('(student.name LIKE :keyword OR student.studentNo LIKE :keyword)', {
|
qb.andWhere(
|
||||||
keyword: `%${query.keyword}%`,
|
'(student.name LIKE :keyword OR student.studentNo LIKE :keyword)',
|
||||||
});
|
{ keyword: `%${query.keyword}%` },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (query?.organizationId) {
|
if (query?.organizationId) {
|
||||||
qb.andWhere('student.organizationId = :orgId', { orgId: query.organizationId });
|
qb.andWhere('student.organizationId = :orgId', { orgId: query.organizationId });
|
||||||
|
|||||||
Reference in New Issue
Block a user