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,6 +1,6 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Like, IsNull, Not, In } from 'typeorm';
import { Repository, Like, IsNull, Not, In, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
import { CampusScope } from '../common/campus-scope';
import { Room } from '../entities/room.entity';
import { Occupancy } from '../entities/occupancy.entity';
@@ -43,8 +43,7 @@ export class RoomsService {
if (stdMatch) {
const bldgNum = stdMatch[1];
const roomPart = stdMatch[2];
const floor =
roomPart.length >= 3 ? parseInt(roomPart.charAt(0), 10) : parseInt(roomPart.charAt(0), 10);
const floor = parseInt(roomPart.charAt(0), 10) || undefined;
const building = `${bldgNum}号楼`;
let roomType = '四人间';
let capacity = 4;
@@ -100,7 +99,14 @@ export class RoomsService {
}
async create(dto: CreateRoomDto) {
const entity = this.repo.create(dto);
const parsed = RoomsService.parseRoomNumber(dto.roomNumber);
const entity = this.repo.create({
...dto,
building: dto.building ?? parsed.building,
floor: dto.floor ?? parsed.floor,
roomType: dto.roomType ?? parsed.roomType,
capacity: dto.capacity ?? parsed.capacity,
});
if (dto.departmentId) entity.departmentId = dto.departmentId;
return this.repo.save(entity);
}
@@ -165,26 +171,41 @@ export class RoomsService {
return { message: '已恢复' };
}
async getRoomVisual() {
async getRoomVisual(asOf?: string) {
// asOf 为空 = 实时(今天)。带 asOf = 还原该日期结束时的历史入住快照。
const isHistorical = !!asOf;
const targetDate = asOf || new Date().toISOString().slice(0, 10);
// 实时视图排除已归档房间;历史视图不排除——当时有人住的房间即使现在已归档也应显示。
const rooms = await this.repo.find({
where: await this.scope.filter({ status: Not('archived') }),
where: await this.scope.filter(isHistorical ? {} : { status: Not('archived') }),
order: { building: 'ASC', roomNumber: 'ASC' },
});
// scope.filter() produces identical scope conditions within the same request;
// extract once and spread to avoid redundant calls.
const scopeWhere = await this.scope.filter({});
const occupancies = await this.occRepo.find({
where: await this.scope.filter({ checkOutDate: IsNull() }),
where: isHistorical
? [
{ ...scopeWhere, checkInDate: LessThanOrEqual(targetDate), checkOutDate: IsNull() },
{ ...scopeWhere, checkInDate: LessThanOrEqual(targetDate), checkOutDate: MoreThanOrEqual(targetDate) },
]
: { ...scopeWhere, checkOutDate: IsNull() },
relations: ['student', 'tenant'],
order: { checkInDate: 'ASC' },
});
// 按roomId分组入住记录
const occMap = new Map<number, any[]>();
// days已住天数相对目标日期计算而非固定今天历史视图才准确。
const refTime = new Date(targetDate).getTime();
for (const occ of occupancies) {
if (!occMap.has(occ.roomId)) occMap.set(occ.roomId, []);
const now = new Date();
const checkIn = new Date(occ.checkInDate);
const days = Math.max(
1,
Math.ceil((now.getTime() - checkIn.getTime()) / (1000 * 60 * 60 * 24)),
Math.ceil((refTime - checkIn.getTime()) / (1000 * 60 * 60 * 24)),
);
occMap.get(occ.roomId)!.push({
studentId: occ.studentId,
@@ -194,6 +215,7 @@ export class RoomsService {
days,
organization: occ.student?.organization || null,
supervisor: occ.student?.supervisor || null,
tenantId: occ.tenantId || null,
tenantName: occ.tenant?.name || null,
tenantColor: occ.tenant?.color || null,
});
@@ -202,9 +224,14 @@ export class RoomsService {
// 获取各楼栋列表
const buildings = [...new Set(rooms.map((r) => r.building).filter(Boolean))];
// 历史视图纳入了已归档房间,但只保留当时确实有人住的归档房间,避免空归档房间刷屏。
const visibleRooms = isHistorical
? rooms.filter((r) => r.status !== 'archived' || (occMap.get(r.id)?.length ?? 0) > 0)
: rooms;
return {
buildings,
rooms: rooms.map((room) => {
rooms: visibleRooms.map((room) => {
const occ = occMap.get(room.id) || [];
// 计算机构标注
const orgs = [...new Set(occ.map((o: any) => o.organization).filter(Boolean))];
@@ -220,6 +247,8 @@ export class RoomsService {
// 计算租户颜色:所有住户同一租户则使用该颜色
const tenantColors = [...new Set(occ.map((o: any) => o.tenantColor).filter(Boolean))];
const tenantColor: string | null = tenantColors.length === 1 ? tenantColors[0] : null;
// 房间涉及的租户 id供前端按租赁方筛选
const tenantIds = [...new Set(occ.map((o: any) => o.tenantId).filter(Boolean))];
return {
id: room.id,
roomNumber: room.roomNumber,
@@ -231,8 +260,17 @@ export class RoomsService {
occupants: occ,
orgLabel,
tenantColor,
tenantIds,
};
}),
// 当前视图内出现过的租赁方,供筛选下拉使用
tenants: [
...new Map(
occupancies
.filter((o) => o.tenantId && o.tenant)
.map((o) => [o.tenantId, { id: o.tenantId, name: o.tenant.name, color: o.tenant.color || null }]),
).values(),
].sort((a, b) => a.name.localeCompare(b.name)),
};
}
@@ -266,7 +304,7 @@ export class RoomsService {
this.repo.create({
roomNumber: row.roomNumber.trim(),
building: row.building?.trim() || parsed.building || undefined,
floor: row.floor || parsed.floor || undefined,
floor: row.floor ?? parsed.floor,
capacity: row.capacity || parsed.capacity || 4,
roomType: row.roomType || parsed.roomType || undefined,
rentalCategory: row.rentalCategory || undefined,