Files
gongxue-base/apps/admin/src/pages/RoomVisual/index.tsx
T

571 lines
21 KiB
TypeScript

import React, { useState } from 'react';
import {Row, Col, Card, Tag, Select, Statistic, Modal, Spin, Badge, Tooltip, DatePicker, Alert, Button, Switch, Space, Skeleton} 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 { useQuery, useQueryClient } from '@tanstack/react-query';
import PermissionButton from '../../components/PermissionButton';
import { usePermission } from '../../hooks/usePermission';
import { getInitialPresentOccupancyIds, togglePresentOccupancy } from './inspection-state';
import { getErrorMessage } from '../../utils/error';
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 [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 queryClient = useQueryClient();
const { data, isLoading, isFetching, isError } = useQuery<any>({
queryKey: ['rooms', 'visual', isHistorical, asOf],
queryFn: async () => {
const params = asOf ? { asOf: asOf.format('YYYY-MM-DD') } : undefined;
return await api.get('/rooms/visual', { params });
},
});
const loading = isLoading || isFetching;
const openRoomDetail = (room: any) => {
setDetailRoom(room);
setPresentOccupancyIds(
getInitialPresentOccupancyIds(room.occupants || [], room.inspection?.submitted === true),
);
};
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 });
queryClient.setQueryData(['rooms', 'visual', isHistorical, asOf], res);
const updatedRoom = res.rooms.find((room: any) => room.id === detailRoom.id);
if (updatedRoom) setDetailRoom(updatedRoom);
} catch (e: unknown) {
message.error(getErrorMessage(e, '查寝提交失败'));
} finally {
setInspectionSaving(false);
}
};
if (isError && !data) {
return (
<Space direction="vertical" align="center" style={{ display: 'flex', padding: '100px 0' }}>
<Alert type="error" showIcon message="宿舍数据加载失败" description="请检查网络后重试。" />
<Button
type="primary"
loading={loading}
onClick={() =>
void queryClient.refetchQueries({ queryKey: ['rooms', 'visual', isHistorical, asOf] })
}
>
重试
</Button>
</Space>
);
}
if (!data)
return (
<div style={{ padding: 24 }}>
<Skeleton active paragraph={{ rows: 12 }} />
</div>
);
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 ? 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={() => openRoomDetail(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>
{hasPermission('room:inspect') ? (
<Switch
checked={presentOccupancyIds.includes(o.occupancyId)}
checkedChildren="在寝"
unCheckedChildren="缺勤"
onChange={(checked) =>
setPresentOccupancyIds((current) =>
togglePresentOccupancy(current, o.occupancyId, checked),
)
}
/>
) : null}
</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;