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
This commit is contained in:
2026-07-09 09:11:56 +08:00
parent f1959f0d2a
commit 42d3f0e27f
71 changed files with 5331 additions and 609 deletions

View File

@@ -1,36 +1,86 @@
import React, { useEffect, useState } from 'react';
import { Row, Col, Card, Tag, Select, Statistic, Modal, Spin, Badge, Tooltip } from 'antd';
import { HomeOutlined, UserOutlined, CalendarOutlined, BankOutlined } from '@ant-design/icons';
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 fetchData = async () => {
const isHistorical = !!asOf && !asOf.isSame(dayjs(), 'day');
const fetchData = useCallback(async () => {
setLoading(true);
try {
const res: any = await api.get('/rooms/visual');
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 (loading || !data)
return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
if (!data) return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
const rooms =
selectedBuilding === 'all'
? data.rooms
: data.rooms.filter((r: any) => r.building === selectedBuilding);
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(
@@ -43,24 +93,7 @@ const RoomVisualPage: React.FC = () => {
);
const fullRooms = rooms.filter((r: any) => r.currentCount >= r.capacity).length;
const 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;
};
const 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>;
};
/* getCardStyle, getStatusLabel, getTenantTags are now standalone functions outside the component */
return (
<div>
@@ -75,17 +108,64 @@ const RoomVisualPage: React.FC = () => {
}}
>
<h2 style={{ margin: 0 }}>宿</h2>
<Select
value={selectedBuilding}
onChange={setSelectedBuilding}
style={{ width: 160 }}
options={[
{ value: 'all', label: '全部楼栋' },
...data.buildings.map((b: string) => ({ value: b, label: b })),
]}
/>
<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}>
@@ -95,17 +175,17 @@ const RoomVisualPage: React.FC = () => {
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic title="空闲房间" value={emptyRooms} valueStyle={{ color: '#34C759' }} />
<Statistic title="空闲房间" value={emptyRooms} styles={{ value: { color: '#34C759' } }} />
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic title="可安排床位" value={availableBeds} valueStyle={{ color: '#007AFF' }} />
<Statistic title="可安排床位" value={availableBeds} styles={{ value: { color: '#007AFF' } }} />
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic title="满员房间" value={fullRooms} valueStyle={{ color: '#FF3B30' }} />
<Statistic title="满员房间" value={fullRooms} styles={{ value: { color: '#FF3B30' } }} />
</Card>
</Col>
</Row>
@@ -161,22 +241,23 @@ const RoomVisualPage: React.FC = () => {
: room.currentCount > 0
? '#007AFF'
: '#34C759',
fontSize: 11,
fontSize: 12,
}}
/>
</div>
{room.orgLabel && (
<div style={{ marginBottom: 6 }}>
<Tag color="purple" style={{ fontSize: 11 }} icon={<BankOutlined />}>
<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 style={{ borderTop: '1px solid rgba(0,0,0,0.06)', paddingTop: 6 }}>
<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: 11 }} icon={<UserOutlined />}>
<Tag style={{ margin: '0 4px 4px 0', fontSize: 12, maxWidth: '100%' }} icon={<UserOutlined />}>
{o.studentName}
</Tag>
</Tooltip>
@@ -241,7 +322,7 @@ const RoomVisualPage: React.FC = () => {
<UserOutlined style={{ marginRight: 6 }} />
<strong>{o.studentName}</strong>
{o.organization && (
<Tag color="purple" style={{ marginLeft: 6, fontSize: 11 }}>
<Tag color="purple" style={{ marginLeft: 6, fontSize: 12 }}>
{o.organization}
</Tag>
)}