Files
gongxue-base/apps/admin/src/pages/RoomVisual/index.tsx
2026-07-22 10:55:48 +08:00

574 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useEffect, useState, useCallback } from 'react';
import {
Row,
Col,
Card,
Tag,
Select,
Statistic,
Modal,
Spin,
Badge,
Tooltip,
DatePicker,
Alert,
Button,
Switch,
Space,
} from 'antd';
import {
HomeOutlined,
UserOutlined,
CalendarOutlined,
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;
if (room.status === 'maintenance') base = { background: '#f5f5f5', borderColor: '#d9d9d9' };
else if (room.currentCount === 0) base = { background: '#f6ffed', borderColor: '#b7eb8f' };
else if (room.currentCount >= room.capacity)
base = { background: '#fff2f0', borderColor: '#ffccc7' };
else base = { background: '#e6f4ff', borderColor: '#91caff' };
if (room.organizationColor) {
return {
...base,
background: `color-mix(in srgb, ${room.organizationColor} 15%, ${base.background || '#fff'} 85%)`,
};
}
return base;
}
function getStatusLabel(room: any) {
if (room.status === 'maintenance') return <Tag color="default"></Tag>;
if (room.currentCount === 0) return <Tag color="success"></Tag>;
if (room.currentCount >= room.capacity) return <Tag color="error"></Tag>;
return <Tag color="processing"></Tag>;
}
function getOrganizationTags(occupants: any[]) {
const organizationList = [
...new Map(
occupants
.filter((o: any) => o.organizationName)
.map((o: any) => [
o.organizationId,
{ name: o.organizationName, color: o.organizationColor },
]),
).values(),
] as { name: string; color: string | null }[];
if (organizationList.length === 0) return null;
return (
<div className="room-card-tag-wrapper" style={{ marginBottom: 6 }}>
{organizationList.map((t) => (
<Tag
key={t.name}
color={t.color || 'gold'}
style={{ fontSize: 12, marginBottom: 4, maxWidth: '100%' }}
icon={<ShopOutlined />}
>
{t.name}
</Tag>
))}
</div>
);
}
const RoomVisualPage: React.FC = () => {
const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [selectedBuilding, setSelectedBuilding] = useState<string>('all');
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');
const fetchData = useCallback(async () => {
setLoading(true);
try {
const params = isHistorical ? { asOf: asOf!.format('YYYY-MM-DD') } : undefined;
const res: any = await api.get('/rooms/visual', { params });
setData(res);
} catch (e: unknown) {
const err = e as { message?: string };
message.error(err?.message || '加载失败,请稍后重试');
}
setLoading(false);
}, [isHistorical, asOf]);
useEffect(() => {
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) => {
if (selectedBuilding !== 'all' && r.building !== selectedBuilding) return false;
if (selectedOrganization !== 'all' && !(r.organizationIds || []).includes(selectedOrganization))
return false;
return true;
});
const totalRooms = rooms.length;
const emptyRooms = rooms.filter(
(r: any) => r.currentCount === 0 && r.status !== 'maintenance',
).length;
const totalBeds = rooms.reduce((sum: number, r: any) => sum + (r.totalBeds || 0), 0);
const occupiedBeds = rooms.reduce((sum: number, r: any) => sum + (r.occupiedBeds || 0), 0);
const availableBedsCount = totalBeds - occupiedBeds;
const fullRooms = rooms.filter((r: any) => r.currentCount >= r.capacity).length;
/* getCardStyle, getStatusLabel, getOrganizationTags are now standalone functions outside the component */
return (
<div>
<div
style={{
marginBottom: 16,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'wrap',
gap: 12,
}}
>
<h2 style={{ margin: 0 }}>宿</h2>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<DatePicker
value={asOf}
onChange={setAsOf}
allowClear
placeholder="查看历史日期"
suffixIcon={<HistoryOutlined />}
disabledDate={(d) => d && d.isAfter(dayjs(), 'day')}
style={{ width: 180 }}
/>
<Select
value={selectedBuilding}
onChange={setSelectedBuilding}
style={{ width: 160 }}
options={[
{ value: 'all', label: '全部楼栋' },
...data.buildings.map((b: string) => ({ value: b, label: b })),
]}
/>
<Select
value={selectedOrganization}
onChange={setSelectedOrganization}
style={{ width: 180 }}
options={[
{ value: 'all', label: '全部机构' },
...(data.organizations || []).map((t: any) => ({
value: t.id,
label: (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
{t.color && (
<span
style={{
width: 8,
height: 8,
borderRadius: '50%',
backgroundColor: t.color,
display: 'inline-block',
}}
/>
)}
{t.name}
</span>
),
})),
]}
/>
</div>
</div>
{isHistorical && (
<Alert
type="warning"
showIcon
icon={<HistoryOutlined />}
style={{ marginBottom: 16 }}
title={`正在查看 ${asOf!.format('YYYY年M月D日')} 的历史入住情况(含当日已归档房间),非实时数据`}
action={
<Button size="small" type="link" onClick={() => setAsOf(null)}>
</Button>
}
/>
)}
{loading && <Spin style={{ display: 'block', margin: '8px auto 16px' }} />}
{/* 统计栏 */}
<Row gutter={[12, 12]} style={{ marginBottom: 20 }}>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic title="宿舍总数" value={totalRooms} prefix={<HomeOutlined />} />
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic
title="空闲房间"
value={emptyRooms}
styles={{ value: { color: '#34C759' } }}
/>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic
title="可安排床位"
value={availableBedsCount}
styles={{ value: { color: '#007AFF' } }}
/>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic
title="满员房间"
value={fullRooms}
styles={{ value: { color: '#FF3B30' } }}
/>
</Card>
</Col>
</Row>
{/* 房态网格 */}
<Row gutter={[12, 12]}>
{rooms.map((room: any) => (
<Col xs={12} sm={8} md={6} lg={4} key={room.id}>
<Card
size="small"
hoverable
style={{
...getCardStyle(room),
borderRadius: 12,
borderWidth: 2,
cursor: 'pointer',
height: '100%',
}}
onClick={() => setDetailRoom(room)}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
}}
>
<span
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
fontSize: 16,
fontWeight: 600,
color: '#1d1d1f',
}}
>
{room.organizationColor && (
<span
style={{
width: 10,
height: 10,
borderRadius: '50%',
backgroundColor: room.organizationColor,
display: 'inline-block',
flexShrink: 0,
}}
/>
)}
{room.roomNumber}
</span>
{getStatusLabel(room)}
</div>
<div style={{ color: '#86868b', fontSize: 12, marginBottom: 6 }}>
{room.building && <span>{room.building} </span>}
{room.floor && <span>{room.floor}F</span>}
</div>
{room.totalBeds > 0 && (
<div
style={{
fontSize: 12,
color: room.occupiedBeds >= room.totalBeds ? '#FF3B30' : '#34C759',
marginBottom: 6,
}}
>
: {room.occupiedBeds}/{room.totalBeds}
</div>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 8 }}>
<Badge
count={`${room.currentCount}/${room.capacity}`}
showZero
style={{
backgroundColor:
room.currentCount >= room.capacity
? '#FF3B30'
: room.currentCount > 0
? '#007AFF'
: '#34C759',
fontSize: 12,
}}
/>
</div>
{room.orgLabel && (
<div className="room-card-tag-wrapper" style={{ marginBottom: 6 }}>
<Tag color="purple" style={{ fontSize: 12 }} icon={<BankOutlined />}>
{room.orgLabel}
</Tag>
</div>
)}
{getOrganizationTags(room.occupants)}
{room.occupants.length > 0 && (
<div
className="room-card-tag-wrapper"
style={{ borderTop: '1px solid rgba(0,0,0,0.06)', paddingTop: 6 }}
>
{room.occupants.slice(0, 4).map((o: any) => (
<Tooltip key={o.studentId} title={`入住 ${o.days} 天 (${o.checkInDate} 起)`}>
<Tag
style={{ margin: '0 4px 4px 0', fontSize: 12, maxWidth: '100%' }}
icon={<UserOutlined />}
>
{o.studentName}
</Tag>
</Tooltip>
))}
{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>
))}
</Row>
{/* 详情弹窗 */}
<Modal
title={`宿舍 ${detailRoom?.roomNumber} 详情`}
open={!!detailRoom}
onCancel={() => setDetailRoom(null)}
footer={null}
width={500}
>
{detailRoom && (
<div>
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={8}>
<Statistic title="额定人数" value={detailRoom.capacity} />
</Col>
<Col span={8}>
<Statistic title="当前入住" value={detailRoom.currentCount} />
</Col>
<Col span={8}>
<Statistic
title="剩余床位"
value={Math.max(0, detailRoom.capacity - detailRoom.currentCount)}
/>
</Col>
</Row>
<div style={{ marginBottom: 8, fontWeight: 500 }}>
{detailRoom.building || '-'} {detailRoom.floor ? `${detailRoom.floor}F` : ''}
</div>
{detailRoom.organizationColor && (
<div style={{ marginBottom: 8 }}>
<Tag color={detailRoom.organizationColor}>
{detailRoom.occupants[0]?.organizationName || '机构'}
</Tag>
</div>
)}
<div style={{ marginBottom: 16 }}>{getStatusLabel(detailRoom)}</div>
{detailRoom.occupants.length > 0 ? (
<div>
<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.occupancyId} size="small" style={{ marginBottom: 8, borderRadius: 8 }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<div>
<UserOutlined style={{ marginRight: 6 }} />
<strong>{o.studentName}</strong>
{o.organization && (
<Tag color="purple" style={{ marginLeft: 6, fontSize: 12 }}>
{o.organization}
</Tag>
)}
</div>
{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>
)}
</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' }}>
</div>
)}
</div>
)}
</Modal>
</div>
);
};
export default RoomVisualPage;