Server: - Add DingTalk attendance import service with SSE progress streaming - Add IntegrationConfig entity & module for multi-tenant DingTalk setup - Add ExpenseType entity & ExpenseTypesModule - Add SeedModule for DB initialization - Add UserDingMapping entity for DingTalk user linkage - Attendance service: import flow with dedup & student auto-mapping - Rooms service: time-range overlap queries - Sync controller/service: DingTalk integration wiring - Permission guard: refactor to pure re-export - Campus scope middleware: tenant-aware filtering Admin UI: - Attendance page: import UI with progress & result summary - All pages: tableStyle/tablePagination standardization - Login page: responsive styling - Sensitive data: useViewSensitive hook for masked viewing - Vite config: path aliases, build optimization - Test infra: vitest config, test utilities Docs: PRD DingTalk batch 1 & 2 design docs
355 lines
13 KiB
TypeScript
355 lines
13 KiB
TypeScript
import React, { useEffect, useState, useCallback } from 'react';
|
||
import { Row, Col, Card, Tag, Select, Statistic, Modal, Spin, Badge, Tooltip, DatePicker, Alert, Button } from 'antd';
|
||
import { HomeOutlined, UserOutlined, CalendarOutlined, BankOutlined, HistoryOutlined, ShopOutlined } from '@ant-design/icons';
|
||
import dayjs, { Dayjs } from 'dayjs';
|
||
import api from '../../api';
|
||
|
||
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.tenantColor) {
|
||
return { ...base, background: `color-mix(in srgb, ${room.tenantColor} 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 getTenantTags(occupants: any[]) {
|
||
const tenantList = [
|
||
...new Map(
|
||
occupants
|
||
.filter((o: any) => o.tenantName)
|
||
.map((o: any) => [o.tenantId, { name: o.tenantName, color: o.tenantColor }]),
|
||
).values(),
|
||
] as { name: string; color: string | null }[];
|
||
if (tenantList.length === 0) return null;
|
||
return (
|
||
<div className="room-card-tag-wrapper" style={{ marginBottom: 6 }}>
|
||
{tenantList.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 [selectedTenant, setSelectedTenant] = useState<number | 'all'>('all');
|
||
const [detailRoom, setDetailRoom] = useState<any>(null);
|
||
const [asOf, setAsOf] = useState<Dayjs | null>(null);
|
||
|
||
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) {
|
||
console.error(e);
|
||
}
|
||
setLoading(false);
|
||
}, [isHistorical, asOf]);
|
||
|
||
useEffect(() => {
|
||
fetchData();
|
||
}, [fetchData]);
|
||
|
||
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 (selectedTenant !== 'all' && !(r.tenantIds || []).includes(selectedTenant)) return false;
|
||
return true;
|
||
});
|
||
|
||
const totalRooms = rooms.length;
|
||
const emptyRooms = rooms.filter(
|
||
(r: any) => r.currentCount === 0 && r.status !== 'maintenance',
|
||
).length;
|
||
const availableBeds = rooms.reduce(
|
||
(sum: number, r: any) =>
|
||
r.status !== 'maintenance' ? sum + (r.capacity - r.currentCount) : sum,
|
||
0,
|
||
);
|
||
const fullRooms = rooms.filter((r: any) => r.currentCount >= r.capacity).length;
|
||
|
||
/* getCardStyle, getStatusLabel, getTenantTags 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={selectedTenant}
|
||
onChange={setSelectedTenant}
|
||
style={{ width: 180 }}
|
||
options={[
|
||
{ value: 'all', label: '全部租赁方' },
|
||
...(data.tenants || []).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={availableBeds} 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.tenantColor && (
|
||
<span style={{
|
||
width: 10, height: 10, borderRadius: '50%',
|
||
backgroundColor: room.tenantColor, 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>
|
||
<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>
|
||
)}
|
||
{getTenantTags(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>
|
||
)}
|
||
</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.tenantColor && (
|
||
<div style={{ marginBottom: 8 }}>
|
||
<Tag color={detailRoom.tenantColor}>
|
||
{detailRoom.occupants[0]?.tenantName || '租户'}
|
||
</Tag>
|
||
</div>
|
||
)}
|
||
<div style={{ marginBottom: 16 }}>{getStatusLabel(detailRoom)}</div>
|
||
{detailRoom.occupants.length > 0 ? (
|
||
<div>
|
||
<h4 style={{ marginBottom: 8 }}>当前住户</h4>
|
||
{detailRoom.occupants.map((o: any) => (
|
||
<Card key={o.studentId} 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>
|
||
<Tag color="blue">{o.days} 天</Tag>
|
||
</div>
|
||
<div style={{ color: '#86868b', fontSize: 12, marginTop: 4 }}>
|
||
<CalendarOutlined style={{ marginRight: 4 }} />
|
||
入住:{o.checkInDate} | 计费起:{o.billingStartDate}
|
||
{o.supervisor && (
|
||
<span style={{ marginLeft: 8 }}>负责人:{o.supervisor}</span>
|
||
)}
|
||
</div>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div style={{ textAlign: 'center', padding: 24, color: '#86868b' }}>
|
||
当前无住户,可安排入住
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</Modal>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default RoomVisualPage;
|