feat: 增加宿舍查寝功能

This commit is contained in:
2026-07-22 10:55:48 +08:00
parent c888dce065
commit 54e283b687
19 changed files with 923 additions and 18 deletions

View File

@@ -13,6 +13,8 @@ import {
DatePicker,
Alert,
Button,
Switch,
Space,
} from 'antd';
import {
HomeOutlined,
@@ -21,10 +23,15 @@ import {
BankOutlined,
HistoryOutlined,
ShopOutlined,
CheckCircleOutlined,
SaveOutlined,
} from '@ant-design/icons';
import dayjs, { Dayjs } from 'dayjs';
import api from '../../api';
import { message } from '../../ui/app-message';
import PermissionButton from '../../components/PermissionButton';
import { usePermission } from '../../hooks/usePermission';
import { getInitialPresentOccupancyIds, togglePresentOccupancy } from './inspection-state';
function getCardStyle(room: any): React.CSSProperties {
let base: React.CSSProperties;
@@ -84,6 +91,9 @@ const RoomVisualPage: React.FC = () => {
const [selectedOrganization, setSelectedOrganization] = useState<number | 'all'>('all');
const [detailRoom, setDetailRoom] = useState<any>(null);
const [asOf, setAsOf] = useState<Dayjs | null>(null);
const [presentOccupancyIds, setPresentOccupancyIds] = useState<number[]>([]);
const [inspectionSaving, setInspectionSaving] = useState(false);
const { hasPermission } = usePermission();
const isHistorical = !!asOf && !asOf.isSame(dayjs(), 'day');
@@ -104,6 +114,42 @@ const RoomVisualPage: React.FC = () => {
fetchData();
}, [fetchData]);
useEffect(() => {
if (!detailRoom) {
setPresentOccupancyIds([]);
return;
}
setPresentOccupancyIds(
getInitialPresentOccupancyIds(
detailRoom.occupants || [],
detailRoom.inspection?.submitted === true,
),
);
}, [detailRoom]);
const inspectionDate = (asOf || dayjs()).format('YYYY-MM-DD');
const submitInspection = async () => {
if (!detailRoom || isHistorical) return;
setInspectionSaving(true);
try {
await api.put(`/rooms/${detailRoom.id}/inspections/${inspectionDate}`, {
presentOccupancyIds,
});
message.success(detailRoom.inspection?.submitted ? '查寝记录已更新' : '查寝已提交');
const params = isHistorical ? { asOf: inspectionDate } : undefined;
const res: any = await api.get('/rooms/visual', { params });
setData(res);
const updatedRoom = res.rooms.find((room: any) => room.id === detailRoom.id);
if (updatedRoom) setDetailRoom(updatedRoom);
} catch (e: unknown) {
const err = e as { message?: string };
message.error(err?.message || '查寝提交失败');
} finally {
setInspectionSaving(false);
}
};
if (!data) return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
const rooms = data.rooms.filter((r: any) => {
@@ -345,6 +391,17 @@ const RoomVisualPage: React.FC = () => {
{room.occupants.length > 4 && <Tag>+{room.occupants.length - 4}</Tag>}
</div>
)}
{room.occupants.length > 0 && (
<div style={{ marginTop: 4 }}>
{room.inspection?.submitted ? (
<Tag color={room.inspection.source === 'automatic' ? 'orange' : 'green'}>
{room.inspection.source === 'automatic' ? '自动补记' : '已查寝'}
</Tag>
) : (
<Tag></Tag>
)}
</div>
)}
</Card>
</Col>
))}
@@ -388,9 +445,26 @@ const RoomVisualPage: React.FC = () => {
<div style={{ marginBottom: 16 }}>{getStatusLabel(detailRoom)}</div>
{detailRoom.occupants.length > 0 ? (
<div>
<h4 style={{ marginBottom: 8 }}></h4>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: 12,
marginBottom: 8,
flexWrap: 'wrap',
}}
>
<h4 style={{ margin: 0 }}></h4>
{detailRoom.inspection?.submitted && (
<Tag color={detailRoom.inspection.source === 'automatic' ? 'orange' : 'green'}>
{detailRoom.inspection.source === 'automatic' ? '自动补记' : '已提交'} ·{' '}
{detailRoom.inspection.inspectorName}
</Tag>
)}
</div>
{detailRoom.occupants.map((o: any) => (
<Card key={o.studentId} size="small" style={{ marginBottom: 8, borderRadius: 8 }}>
<Card key={o.occupancyId} size="small" style={{ marginBottom: 8, borderRadius: 8 }}>
<div
style={{
display: 'flex',
@@ -407,10 +481,44 @@ const RoomVisualPage: React.FC = () => {
</Tag>
)}
</div>
<Tag color="blue">{o.days} </Tag>
{isHistorical ? (
<Tag
color={
o.inspectionStatus === 'present'
? 'green'
: o.inspectionStatus === 'absent'
? 'red'
: 'default'
}
>
{o.inspectionStatus === 'present'
? '在寝'
: o.inspectionStatus === 'absent'
? '缺勤'
: '无记录'}
</Tag>
) : (
<Space size="small">
<span style={{ color: '#86868b', fontSize: 12 }}>
{presentOccupancyIds.includes(o.occupancyId) ? '在寝' : '缺勤'}
</span>
<Switch
checked={presentOccupancyIds.includes(o.occupancyId)}
disabled={!hasPermission('room:inspect')}
checkedChildren="在寝"
unCheckedChildren="缺勤"
onChange={(checked) =>
setPresentOccupancyIds((current) =>
togglePresentOccupancy(current, o.occupancyId, checked),
)
}
/>
</Space>
)}
</div>
<div style={{ color: '#86868b', fontSize: 12, marginTop: 4 }}>
<CalendarOutlined style={{ marginRight: 4 }} />
{o.bedNumber || '未分配'} |{' '}
{o.checkInDate} | {o.billingStartDate}
{o.supervisor && (
<span style={{ marginLeft: 8 }}>{o.supervisor}</span>
@@ -418,6 +526,37 @@ const RoomVisualPage: React.FC = () => {
</div>
</Card>
))}
{!isHistorical && hasPermission('room:inspect') && (
<div
style={{
display: 'flex',
justifyContent: 'flex-end',
gap: 8,
marginTop: 16,
flexWrap: 'wrap',
}}
>
<Button
icon={<CheckCircleOutlined />}
onClick={() =>
setPresentOccupancyIds(
detailRoom.occupants.map((occupant: any) => occupant.occupancyId),
)
}
>
宿
</Button>
<PermissionButton
permission="room:inspect"
type="primary"
icon={<SaveOutlined />}
loading={inspectionSaving}
onClick={submitInspection}
>
</PermissionButton>
</div>
)}
</div>
) : (
<div style={{ textAlign: 'center', padding: 24, color: '#86868b' }}>

View File

@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { getInitialPresentOccupancyIds, togglePresentOccupancy } from './inspection-state';
describe('room inspection state', () => {
const occupants = [
{ occupancyId: 11, inspectionStatus: 'present' as const },
{ occupancyId: 12, inspectionStatus: 'absent' as const },
];
it('starts every occupant unchecked before the room is submitted', () => {
expect(getInitialPresentOccupancyIds(occupants, false)).toEqual([]);
});
it('restores the saved present occupants after submission', () => {
expect(getInitialPresentOccupancyIds(occupants, true)).toEqual([11]);
});
it('adds and removes a single occupant without changing the others', () => {
expect(togglePresentOccupancy([11], 12, true).sort()).toEqual([11, 12]);
expect(togglePresentOccupancy([11, 12], 11, false)).toEqual([12]);
});
});

View File

@@ -0,0 +1,25 @@
export interface InspectionOccupant {
occupancyId: number;
inspectionStatus?: 'present' | 'absent' | null;
}
export function getInitialPresentOccupancyIds(
occupants: InspectionOccupant[],
submitted: boolean,
): number[] {
if (!submitted) return [];
return occupants
.filter((occupant) => occupant.inspectionStatus === 'present')
.map((occupant) => occupant.occupancyId);
}
export function togglePresentOccupancy(
currentIds: number[],
occupancyId: number,
present: boolean,
): number[] {
const next = new Set(currentIds);
if (present) next.add(occupancyId);
else next.delete(occupancyId);
return [...next];
}