Files
gongxue-base/apps/admin/src/pages/RoomVisual/index.tsx
wangziqi 42d3f0e27f feat: DingTalk attendance import + integration config + expense types + UI polish
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
2026-07-09 09:11:56 +08:00

355 lines
13 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 } 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;